@donkeylabs/cli 2.0.21 → 2.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/package.json +2 -1
- package/src/commands/generate-client.ts +104 -0
- package/src/commands/generate-utils.ts +121 -0
- package/src/commands/generate.ts +239 -218
- package/src/index.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@donkeylabs/cli",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI for @donkeylabs/server - project scaffolding and code generation",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"picocolors": "^1.1.1",
|
|
31
31
|
"prompts": "^2.4.2",
|
|
32
32
|
"sharp": "^0.33.0",
|
|
33
|
+
"typescript": "^5.0.0",
|
|
33
34
|
"zod": "^3.23.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
@@ -0,0 +1,104 @@
|
|
|
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 routes = await extractRoutesFromServer(entryPath);
|
|
22
|
+
|
|
23
|
+
if (routes.length === 0) {
|
|
24
|
+
console.warn(pc.yellow("No routes found - generating empty client"));
|
|
25
|
+
} else {
|
|
26
|
+
console.log(pc.green(`Found ${routes.length} routes`));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Dispatch to adapter
|
|
30
|
+
if (adapter === "typescript") {
|
|
31
|
+
await generateTypescriptClient(routes, output);
|
|
32
|
+
} else {
|
|
33
|
+
await generateAdapterClient(adapter, config, routes, output);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Built-in TypeScript client generation using @donkeylabs/server/generator
|
|
39
|
+
*/
|
|
40
|
+
async function generateTypescriptClient(routes: RouteInfo[], output: string): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
const { generateClientCode } = await import("@donkeylabs/server/generator");
|
|
43
|
+
|
|
44
|
+
const code = generateClientCode({ routes });
|
|
45
|
+
|
|
46
|
+
// Write single .ts file
|
|
47
|
+
const outputPath = output.endsWith(".ts") ? output : join(output, "index.ts");
|
|
48
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
49
|
+
await writeFile(outputPath, code);
|
|
50
|
+
|
|
51
|
+
console.log(pc.green(`Generated TypeScript client:`), pc.dim(outputPath));
|
|
52
|
+
} catch (e: any) {
|
|
53
|
+
if (e.code === "ERR_MODULE_NOT_FOUND" || e.message?.includes("Cannot find")) {
|
|
54
|
+
console.error(pc.red("@donkeylabs/server not found"));
|
|
55
|
+
console.error(pc.dim("Make sure @donkeylabs/server is installed"));
|
|
56
|
+
} else {
|
|
57
|
+
console.error(pc.red("Failed to generate TypeScript client"));
|
|
58
|
+
console.error(pc.dim(e.message));
|
|
59
|
+
}
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Adapter-based client generation (sveltekit, swift, or custom package)
|
|
66
|
+
*/
|
|
67
|
+
async function generateAdapterClient(
|
|
68
|
+
adapter: string,
|
|
69
|
+
config: DonkeylabsConfig,
|
|
70
|
+
routes: RouteInfo[],
|
|
71
|
+
output: string
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
// Resolve adapter to package path
|
|
74
|
+
let adapterPackage: string;
|
|
75
|
+
if (adapter === "sveltekit") {
|
|
76
|
+
adapterPackage = "@donkeylabs/adapter-sveltekit";
|
|
77
|
+
} else if (adapter === "swift") {
|
|
78
|
+
adapterPackage = "@donkeylabs/adapter-swift";
|
|
79
|
+
} else {
|
|
80
|
+
// Treat as full package name (e.g., "@myorg/custom-adapter")
|
|
81
|
+
adapterPackage = adapter;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const generatorPath = `${adapterPackage}/generator`;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const adapterModule = await import(generatorPath);
|
|
88
|
+
if (!adapterModule.generateClient) {
|
|
89
|
+
console.error(pc.red(`Adapter ${adapterPackage} does not export generateClient`));
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
await adapterModule.generateClient(config, routes, output);
|
|
93
|
+
console.log(pc.green(`Generated client (${adapter}):`), pc.dim(output));
|
|
94
|
+
} catch (e: any) {
|
|
95
|
+
if (e.code === "ERR_MODULE_NOT_FOUND" || e.message?.includes("Cannot find")) {
|
|
96
|
+
console.error(pc.red(`Adapter not found: ${adapterPackage}`));
|
|
97
|
+
console.error(pc.dim(`Install it with: bun add ${adapterPackage}`));
|
|
98
|
+
} else {
|
|
99
|
+
console.error(pc.red(`Failed to generate client with adapter: ${adapterPackage}`));
|
|
100
|
+
console.error(pc.dim(e.message));
|
|
101
|
+
}
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
/**
|
|
40
|
+
* Run the server entry file with DONKEYLABS_GENERATE=1 to get typed route metadata
|
|
41
|
+
*/
|
|
42
|
+
export async function extractRoutesFromServer(entryPath: string): Promise<RouteInfo[]> {
|
|
43
|
+
const fullPath = join(process.cwd(), entryPath);
|
|
44
|
+
|
|
45
|
+
if (!existsSync(fullPath)) {
|
|
46
|
+
console.warn(pc.yellow(`Entry file not found: ${entryPath}`));
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TIMEOUT_MS = 10000; // 10 second timeout
|
|
51
|
+
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const child = spawn("bun", [fullPath], {
|
|
54
|
+
env: { ...process.env, DONKEYLABS_GENERATE: "1" },
|
|
55
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
56
|
+
cwd: process.cwd(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
let stdout = "";
|
|
60
|
+
let stderr = "";
|
|
61
|
+
let timedOut = false;
|
|
62
|
+
|
|
63
|
+
// Timeout handler
|
|
64
|
+
const timeout = setTimeout(() => {
|
|
65
|
+
timedOut = true;
|
|
66
|
+
child.kill("SIGTERM");
|
|
67
|
+
console.warn(pc.yellow(`Route extraction timed out after ${TIMEOUT_MS / 1000}s`));
|
|
68
|
+
console.warn(pc.dim("Make sure routes are registered with server.use() before any blocking operations"));
|
|
69
|
+
resolve([]);
|
|
70
|
+
}, TIMEOUT_MS);
|
|
71
|
+
|
|
72
|
+
child.stdout?.on("data", (data) => {
|
|
73
|
+
stdout += data.toString();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
child.stderr?.on("data", (data) => {
|
|
77
|
+
stderr += data.toString();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
child.on("close", (code) => {
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
if (timedOut) return; // Already resolved
|
|
83
|
+
|
|
84
|
+
if (code !== 0) {
|
|
85
|
+
console.warn(pc.yellow(`Failed to extract routes from server (exit code ${code})`));
|
|
86
|
+
if (stderr) console.warn(pc.dim(stderr));
|
|
87
|
+
resolve([]);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const result = JSON.parse(stdout.trim());
|
|
93
|
+
// Convert server output to RouteInfo format
|
|
94
|
+
const routes: RouteInfo[] = (result.routes || []).map((r: any) => {
|
|
95
|
+
const parts = r.name.split(".");
|
|
96
|
+
return {
|
|
97
|
+
name: r.name,
|
|
98
|
+
prefix: parts.slice(0, -1).join("."),
|
|
99
|
+
routeName: parts[parts.length - 1] || r.name,
|
|
100
|
+
handler: r.handler || "typed",
|
|
101
|
+
// Server outputs TypeScript strings directly now
|
|
102
|
+
inputSource: r.inputType,
|
|
103
|
+
outputSource: r.outputType,
|
|
104
|
+
// SSE event schemas
|
|
105
|
+
eventsSource: r.eventsType,
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
resolve(routes);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.warn(pc.yellow("Failed to parse route data from server"));
|
|
111
|
+
resolve([]);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
child.on("error", (err) => {
|
|
116
|
+
clearTimeout(timeout);
|
|
117
|
+
console.warn(pc.yellow(`Failed to run entry file: ${err.message}`));
|
|
118
|
+
resolve([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
package/src/commands/generate.ts
CHANGED
|
@@ -1,34 +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
|
-
|
|
11
|
-
|
|
12
|
-
plugins: string[];
|
|
13
|
-
outDir?: string;
|
|
14
|
-
client?: {
|
|
15
|
-
output: string;
|
|
16
|
-
};
|
|
17
|
-
routes?: string; // Route files pattern, default: "./src/routes/**/handler.ts"
|
|
18
|
-
entry?: string; // Server entry file for extracting routes, default: "./src/index.ts"
|
|
19
|
-
adapter?: string; // Adapter package for framework-specific generation, e.g., "@donkeylabs/adapter-sveltekit"
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function loadConfig(): Promise<DonkeylabsConfig> {
|
|
23
|
-
const configPath = join(process.cwd(), "donkeylabs.config.ts");
|
|
24
|
-
|
|
25
|
-
if (!existsSync(configPath)) {
|
|
26
|
-
throw new Error("donkeylabs.config.ts not found. Run 'donkeylabs init' first.");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const config = await import(configPath);
|
|
30
|
-
return config.default;
|
|
31
|
-
}
|
|
9
|
+
import * as ts from "typescript";
|
|
10
|
+
import { loadConfig, extractRoutesFromServer, type DonkeylabsConfig, type RouteInfo } from "./generate-utils";
|
|
32
11
|
|
|
33
12
|
async function getPluginExportName(pluginPath: string): Promise<string | null> {
|
|
34
13
|
try {
|
|
@@ -54,70 +33,236 @@ async function getPluginDefinedName(pluginPath: string): Promise<string | null>
|
|
|
54
33
|
async function extractHandlerNames(pluginPath: string): Promise<string[]> {
|
|
55
34
|
try {
|
|
56
35
|
const content = await readFile(pluginPath, "utf-8");
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
36
|
+
const astNames = extractHandlerNamesFromAst(content, pluginPath);
|
|
37
|
+
if (astNames.length > 0) {
|
|
38
|
+
return astNames;
|
|
39
|
+
}
|
|
59
40
|
|
|
60
|
-
|
|
61
|
-
return [...handlersBlock.matchAll(/(\w+)\s*:/g)]
|
|
62
|
-
.map((m) => m[1])
|
|
63
|
-
.filter((name): name is string => !!name);
|
|
41
|
+
return extractHandlerNamesRegexFallback(content);
|
|
64
42
|
} catch {
|
|
65
43
|
return [];
|
|
66
44
|
}
|
|
67
45
|
}
|
|
68
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
|
+
|
|
69
125
|
async function extractMiddlewareNames(pluginPath: string): Promise<string[]> {
|
|
70
126
|
try {
|
|
71
127
|
const content = await readFile(pluginPath, "utf-8");
|
|
128
|
+
const astNames = extractMiddlewareNamesFromAst(content, pluginPath);
|
|
129
|
+
if (astNames.length > 0) {
|
|
130
|
+
return astNames;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return extractMiddlewareNamesRegexFallback(content);
|
|
134
|
+
} catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function extractMiddlewareNamesFromAst(content: string, fileName: string): string[] {
|
|
140
|
+
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
72
141
|
|
|
73
|
-
|
|
142
|
+
const names = new Set<string>();
|
|
74
143
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
144
|
+
const addFromObjectLiteral = (obj: ts.ObjectLiteralExpression) => {
|
|
145
|
+
for (const prop of obj.properties) {
|
|
146
|
+
if (!ts.isPropertyAssignment(prop) && !ts.isMethodDeclaration(prop)) continue;
|
|
147
|
+
const propName = getStaticPropertyName(prop.name);
|
|
148
|
+
if (propName && isSafeIdentifier(propName)) {
|
|
149
|
+
names.add(propName);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const collectFromMiddlewareInitializer = (initializer: ts.Expression) => {
|
|
155
|
+
if (ts.isObjectLiteralExpression(initializer)) {
|
|
156
|
+
addFromObjectLiteral(initializer);
|
|
157
|
+
return;
|
|
79
158
|
}
|
|
80
159
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
160
|
+
if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) {
|
|
161
|
+
const body = initializer.body;
|
|
162
|
+
|
|
163
|
+
if (ts.isObjectLiteralExpression(body)) {
|
|
164
|
+
addFromObjectLiteral(body);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (ts.isParenthesizedExpression(body) && ts.isObjectLiteralExpression(body.expression)) {
|
|
169
|
+
addFromObjectLiteral(body.expression);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (ts.isBlock(body)) {
|
|
174
|
+
for (const stmt of body.statements) {
|
|
175
|
+
if (!ts.isReturnStatement(stmt) || !stmt.expression) continue;
|
|
176
|
+
|
|
177
|
+
if (ts.isObjectLiteralExpression(stmt.expression)) {
|
|
178
|
+
addFromObjectLiteral(stmt.expression);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
ts.isParenthesizedExpression(stmt.expression) &&
|
|
184
|
+
ts.isObjectLiteralExpression(stmt.expression.expression)
|
|
185
|
+
) {
|
|
186
|
+
addFromObjectLiteral(stmt.expression.expression);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
87
190
|
}
|
|
88
191
|
}
|
|
192
|
+
};
|
|
89
193
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
for (const key of extractTopLevelObjectKeys(block)) {
|
|
96
|
-
names.add(key);
|
|
194
|
+
const visit = (node: ts.Node) => {
|
|
195
|
+
if (ts.isPropertyAssignment(node)) {
|
|
196
|
+
const propName = getStaticPropertyName(node.name);
|
|
197
|
+
if (propName === "middleware") {
|
|
198
|
+
collectFromMiddlewareInitializer(node.initializer);
|
|
97
199
|
}
|
|
98
200
|
}
|
|
201
|
+
ts.forEachChild(node, visit);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
visit(sourceFile);
|
|
205
|
+
return [...names];
|
|
206
|
+
}
|
|
99
207
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
208
|
+
function getStaticPropertyName(name: ts.PropertyName): string | null {
|
|
209
|
+
if (ts.isIdentifier(name)) return name.text;
|
|
210
|
+
if (ts.isStringLiteral(name)) return name.text;
|
|
211
|
+
if (ts.isNoSubstitutionTemplateLiteral(name)) return name.text;
|
|
212
|
+
if (ts.isNumericLiteral(name)) return name.text;
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
106
215
|
|
|
107
|
-
|
|
108
|
-
|
|
216
|
+
function isSafeIdentifier(name: string): boolean {
|
|
217
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
|
218
|
+
}
|
|
109
219
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
220
|
+
function extractMiddlewareNamesRegexFallback(content: string): string[] {
|
|
221
|
+
const names = new Set<string>();
|
|
222
|
+
|
|
223
|
+
// Look for middleware definitions: `name: createMiddleware(...)`
|
|
224
|
+
// Supports generic config: `name: createMiddleware<Config>(...)`
|
|
225
|
+
for (const match of content.matchAll(/(\w+)\s*:\s*createMiddleware\s*(?:<[^>]+>)?\s*\(/g)) {
|
|
226
|
+
if (match[1]) names.add(match[1]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Look for direct middleware objects: `middleware: { ... }`
|
|
230
|
+
for (const match of content.matchAll(/middleware\s*:\s*\{/g)) {
|
|
231
|
+
const openBracePos = (match.index ?? 0) + match[0].length - 1;
|
|
232
|
+
const block = extractBalancedBlock(content, openBracePos, "{", "}");
|
|
233
|
+
for (const key of extractTopLevelObjectKeys(block)) {
|
|
234
|
+
names.add(key);
|
|
115
235
|
}
|
|
236
|
+
}
|
|
116
237
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
238
|
+
// Look for middleware factory returning object literal directly:
|
|
239
|
+
// `middleware: (ctx, service) => ({ ... })`
|
|
240
|
+
for (const match of content.matchAll(/middleware\s*:\s*\([^)]*\)\s*=>\s*\(\s*\{/g)) {
|
|
241
|
+
const openBracePos = (match.index ?? 0) + match[0].length - 1;
|
|
242
|
+
const block = extractBalancedBlock(content, openBracePos, "{", "}");
|
|
243
|
+
for (const key of extractTopLevelObjectKeys(block)) {
|
|
244
|
+
names.add(key);
|
|
245
|
+
}
|
|
120
246
|
}
|
|
247
|
+
|
|
248
|
+
// Look for middleware factory with block body:
|
|
249
|
+
// `middleware: (...) => { return { ... } }`
|
|
250
|
+
for (const match of content.matchAll(/middleware\s*:\s*\([^)]*\)\s*=>\s*\{/g)) {
|
|
251
|
+
const openBracePos = (match.index ?? 0) + match[0].length - 1;
|
|
252
|
+
const fnBody = extractBalancedBlock(content, openBracePos, "{", "}");
|
|
253
|
+
if (!fnBody) continue;
|
|
254
|
+
|
|
255
|
+
const returnMatch = fnBody.match(/return\s*\{/);
|
|
256
|
+
if (!returnMatch || returnMatch.index === undefined) continue;
|
|
257
|
+
|
|
258
|
+
const returnObjStart = returnMatch.index + returnMatch[0].length - 1;
|
|
259
|
+
const returnObj = extractBalancedBlock(fnBody, returnObjStart, "{", "}");
|
|
260
|
+
for (const key of extractTopLevelObjectKeys(returnObj)) {
|
|
261
|
+
names.add(key);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return [...names];
|
|
121
266
|
}
|
|
122
267
|
|
|
123
268
|
function extractTopLevelObjectKeys(objectBlock: string): string[] {
|
|
@@ -268,82 +413,49 @@ interface EventDefinitionInfo {
|
|
|
268
413
|
async function extractEventsFromFile(filePath: string): Promise<EventDefinitionInfo[]> {
|
|
269
414
|
try {
|
|
270
415
|
const content = await readFile(filePath, "utf-8");
|
|
416
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
271
417
|
const events: EventDefinitionInfo[] = [];
|
|
272
418
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
// Extract the object block
|
|
280
|
-
const blockStart = defineEventsMatch.index + defineEventsMatch[0].length - 1;
|
|
281
|
-
const block = extractBalancedBlock(content, blockStart, "{", "}");
|
|
282
|
-
if (!block) return events;
|
|
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;
|
|
283
424
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const eventPattern = /["']([^"']+)["']\s*:\s*(z\.[^,}]+(?:\([^)]*\))?)/g;
|
|
425
|
+
const schemaSource = prop.initializer.getText(sourceFile).trim();
|
|
426
|
+
if (!schemaSource) continue;
|
|
287
427
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
// More robust extraction - find each event key and its schema
|
|
292
|
-
const keyPattern = /["']([a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*)["']\s*:/gi;
|
|
293
|
-
let keyMatch;
|
|
294
|
-
const keyPositions: { name: string; pos: number }[] = [];
|
|
295
|
-
|
|
296
|
-
while ((keyMatch = keyPattern.exec(innerBlock)) !== null) {
|
|
297
|
-
keyPositions.push({ name: keyMatch[1]!, pos: keyMatch.index + keyMatch[0].length });
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// For each key, extract the Zod schema that follows
|
|
301
|
-
for (let i = 0; i < keyPositions.length; i++) {
|
|
302
|
-
const { name, pos } = keyPositions[i]!;
|
|
303
|
-
const nextPos = keyPositions[i + 1]?.pos ?? innerBlock.length;
|
|
304
|
-
|
|
305
|
-
// Get the slice between this key and the next
|
|
306
|
-
let schemaSlice = innerBlock.slice(pos, nextPos).trim();
|
|
307
|
-
|
|
308
|
-
// Find where the Zod expression ends
|
|
309
|
-
if (schemaSlice.startsWith("z.")) {
|
|
310
|
-
// Extract balanced parentheses for the schema
|
|
311
|
-
let depth = 0;
|
|
312
|
-
let endIdx = 0;
|
|
313
|
-
let foundParen = false;
|
|
314
|
-
|
|
315
|
-
for (let j = 0; j < schemaSlice.length; j++) {
|
|
316
|
-
if (schemaSlice[j] === "(") {
|
|
317
|
-
depth++;
|
|
318
|
-
foundParen = true;
|
|
319
|
-
} else if (schemaSlice[j] === ")") {
|
|
320
|
-
depth--;
|
|
321
|
-
if (depth === 0 && foundParen) {
|
|
322
|
-
endIdx = j + 1;
|
|
323
|
-
// Check for chained methods
|
|
324
|
-
const rest = schemaSlice.slice(endIdx);
|
|
325
|
-
const chainMatch = rest.match(/^(\s*\.\w+\([^)]*\))+/);
|
|
326
|
-
if (chainMatch) {
|
|
327
|
-
endIdx += chainMatch[0].length;
|
|
328
|
-
}
|
|
329
|
-
break;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
428
|
+
events.push({ name: eventName, schemaSource });
|
|
429
|
+
}
|
|
430
|
+
};
|
|
333
431
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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);
|
|
337
437
|
}
|
|
338
438
|
}
|
|
339
|
-
|
|
439
|
+
ts.forEachChild(node, visit);
|
|
440
|
+
};
|
|
340
441
|
|
|
442
|
+
visit(sourceFile);
|
|
341
443
|
return events;
|
|
342
444
|
} catch {
|
|
343
445
|
return [];
|
|
344
446
|
}
|
|
345
447
|
}
|
|
346
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
|
+
|
|
347
459
|
/**
|
|
348
460
|
* Find and extract server-level events from common locations.
|
|
349
461
|
*/
|
|
@@ -470,16 +582,7 @@ interface ExtractedRoute {
|
|
|
470
582
|
handler: string;
|
|
471
583
|
}
|
|
472
584
|
|
|
473
|
-
|
|
474
|
-
name: string;
|
|
475
|
-
prefix: string;
|
|
476
|
-
routeName: string;
|
|
477
|
-
handler: "typed" | "raw" | string;
|
|
478
|
-
inputSource?: string;
|
|
479
|
-
outputSource?: string;
|
|
480
|
-
/** SSE event schemas (for sse handler) */
|
|
481
|
-
eventsSource?: Record<string, string>;
|
|
482
|
-
}
|
|
585
|
+
// RouteInfo is imported from ./generate-utils
|
|
483
586
|
|
|
484
587
|
/**
|
|
485
588
|
* Extract a balanced block from source code starting at a given position
|
|
@@ -702,89 +805,7 @@ async function findRouteFiles(pattern: string): Promise<string[]> {
|
|
|
702
805
|
return files;
|
|
703
806
|
}
|
|
704
807
|
|
|
705
|
-
|
|
706
|
-
* Run the server entry file with DONKEYLABS_GENERATE=1 to get typed route metadata
|
|
707
|
-
*/
|
|
708
|
-
async function extractRoutesFromServer(entryPath: string): Promise<RouteInfo[]> {
|
|
709
|
-
const fullPath = join(process.cwd(), entryPath);
|
|
710
|
-
|
|
711
|
-
if (!existsSync(fullPath)) {
|
|
712
|
-
console.warn(pc.yellow(`Entry file not found: ${entryPath}`));
|
|
713
|
-
return [];
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
const TIMEOUT_MS = 10000; // 10 second timeout
|
|
717
|
-
|
|
718
|
-
return new Promise((resolve) => {
|
|
719
|
-
const child = spawn("bun", [fullPath], {
|
|
720
|
-
env: { ...process.env, DONKEYLABS_GENERATE: "1" },
|
|
721
|
-
stdio: ["inherit", "pipe", "pipe"],
|
|
722
|
-
cwd: process.cwd(),
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
let stdout = "";
|
|
726
|
-
let stderr = "";
|
|
727
|
-
let timedOut = false;
|
|
728
|
-
|
|
729
|
-
// Timeout handler
|
|
730
|
-
const timeout = setTimeout(() => {
|
|
731
|
-
timedOut = true;
|
|
732
|
-
child.kill("SIGTERM");
|
|
733
|
-
console.warn(pc.yellow(`Route extraction timed out after ${TIMEOUT_MS / 1000}s`));
|
|
734
|
-
console.warn(pc.dim("Make sure routes are registered with server.use() before any blocking operations"));
|
|
735
|
-
resolve([]);
|
|
736
|
-
}, TIMEOUT_MS);
|
|
737
|
-
|
|
738
|
-
child.stdout?.on("data", (data) => {
|
|
739
|
-
stdout += data.toString();
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
child.stderr?.on("data", (data) => {
|
|
743
|
-
stderr += data.toString();
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
child.on("close", (code) => {
|
|
747
|
-
clearTimeout(timeout);
|
|
748
|
-
if (timedOut) return; // Already resolved
|
|
749
|
-
|
|
750
|
-
if (code !== 0) {
|
|
751
|
-
console.warn(pc.yellow(`Failed to extract routes from server (exit code ${code})`));
|
|
752
|
-
if (stderr) console.warn(pc.dim(stderr));
|
|
753
|
-
resolve([]);
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
try {
|
|
758
|
-
const result = JSON.parse(stdout.trim());
|
|
759
|
-
// Convert server output to RouteInfo format
|
|
760
|
-
const routes: RouteInfo[] = (result.routes || []).map((r: any) => {
|
|
761
|
-
const parts = r.name.split(".");
|
|
762
|
-
return {
|
|
763
|
-
name: r.name,
|
|
764
|
-
prefix: parts.slice(0, -1).join("."),
|
|
765
|
-
routeName: parts[parts.length - 1] || r.name,
|
|
766
|
-
handler: r.handler || "typed",
|
|
767
|
-
// Server outputs TypeScript strings directly now
|
|
768
|
-
inputSource: r.inputType,
|
|
769
|
-
outputSource: r.outputType,
|
|
770
|
-
// SSE event schemas
|
|
771
|
-
eventsSource: r.eventsType,
|
|
772
|
-
};
|
|
773
|
-
});
|
|
774
|
-
resolve(routes);
|
|
775
|
-
} catch (e) {
|
|
776
|
-
console.warn(pc.yellow("Failed to parse route data from server"));
|
|
777
|
-
resolve([]);
|
|
778
|
-
}
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
child.on("error", (err) => {
|
|
782
|
-
clearTimeout(timeout);
|
|
783
|
-
console.warn(pc.yellow(`Failed to run entry file: ${err.message}`));
|
|
784
|
-
resolve([]);
|
|
785
|
-
});
|
|
786
|
-
});
|
|
787
|
-
}
|
|
808
|
+
// extractRoutesFromServer is imported from ./generate-utils
|
|
788
809
|
|
|
789
810
|
/**
|
|
790
811
|
* Generate schema.ts from plugin migrations using kysely-codegen
|
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));
|