keryx 0.20.8 → 0.21.1
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/index.ts +7 -0
- package/initializers/swagger.ts +32 -278
- package/package.json +2 -4
- package/util/cli.ts +27 -0
- package/util/swaggerSchemaGenerator.ts +330 -0
- package/server.json +0 -18
package/index.ts
CHANGED
|
@@ -37,6 +37,13 @@ export { deepMerge, deepMergeDefaults, loadFromEnvIfSet } from "./util/config";
|
|
|
37
37
|
export { getValidTypes } from "./util/generate";
|
|
38
38
|
export { globLoader } from "./util/glob";
|
|
39
39
|
export { type PaginatedResult, paginate } from "./util/pagination";
|
|
40
|
+
export type { JSONSchema } from "./util/swaggerSchemaGenerator";
|
|
41
|
+
export {
|
|
42
|
+
computeActionsHash,
|
|
43
|
+
generateSwaggerSchemas,
|
|
44
|
+
loadCachedSchemas,
|
|
45
|
+
writeSchemasCache,
|
|
46
|
+
} from "./util/swaggerSchemaGenerator";
|
|
40
47
|
export { toMarkdown } from "./util/toMarkdown";
|
|
41
48
|
export type { DbOrTransaction, Transaction } from "./util/transaction";
|
|
42
49
|
export { withTransaction } from "./util/transaction";
|
package/initializers/swagger.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { mkdir } from "fs/promises";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import { Project, Type, ts } from "ts-morph";
|
|
4
1
|
import { api, logger } from "../api";
|
|
5
2
|
import { Initializer } from "../classes/Initializer";
|
|
3
|
+
import type { JSONSchema } from "../util/swaggerSchemaGenerator";
|
|
4
|
+
import {
|
|
5
|
+
computeActionsHash,
|
|
6
|
+
generateSwaggerSchemas,
|
|
7
|
+
loadCachedSchemas,
|
|
8
|
+
writeSchemasCache,
|
|
9
|
+
} from "../util/swaggerSchemaGenerator";
|
|
6
10
|
|
|
7
11
|
const namespace = "swagger";
|
|
8
12
|
|
|
@@ -12,163 +16,6 @@ declare module "../classes/API" {
|
|
|
12
16
|
}
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
type JSONSchema = {
|
|
16
|
-
type?: string;
|
|
17
|
-
properties?: Record<string, JSONSchema>;
|
|
18
|
-
items?: JSONSchema;
|
|
19
|
-
oneOf?: JSONSchema[];
|
|
20
|
-
required?: string[];
|
|
21
|
-
additionalProperties?: boolean | JSONSchema;
|
|
22
|
-
enum?: (string | number | boolean | null)[];
|
|
23
|
-
const?: unknown;
|
|
24
|
-
$ref?: string;
|
|
25
|
-
description?: string;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Convert a ts-morph Type to JSON Schema format
|
|
30
|
-
*/
|
|
31
|
-
function typeToJsonSchema(
|
|
32
|
-
type: Type,
|
|
33
|
-
visited: Set<string> = new Set(),
|
|
34
|
-
): JSONSchema {
|
|
35
|
-
const typeText = type.getText();
|
|
36
|
-
|
|
37
|
-
// Prevent infinite recursion for circular types
|
|
38
|
-
if (visited.has(typeText)) {
|
|
39
|
-
return { type: "object", additionalProperties: true };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Handle Promise<T> - unwrap to T
|
|
43
|
-
if (
|
|
44
|
-
type.getSymbol()?.getName() === "Promise" ||
|
|
45
|
-
typeText.startsWith("Promise<")
|
|
46
|
-
) {
|
|
47
|
-
const typeArgs = type.getTypeArguments();
|
|
48
|
-
if (typeArgs.length > 0) {
|
|
49
|
-
return typeToJsonSchema(typeArgs[0], visited);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Handle primitives
|
|
54
|
-
if (type.isString() || type.isStringLiteral()) {
|
|
55
|
-
if (type.isStringLiteral()) {
|
|
56
|
-
return { type: "string", const: type.getLiteralValue() };
|
|
57
|
-
}
|
|
58
|
-
return { type: "string" };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (type.isNumber() || type.isNumberLiteral()) {
|
|
62
|
-
if (type.isNumberLiteral()) {
|
|
63
|
-
return { type: "number", const: type.getLiteralValue() };
|
|
64
|
-
}
|
|
65
|
-
return { type: "number" };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (type.isBoolean() || type.isBooleanLiteral()) {
|
|
69
|
-
if (type.isBooleanLiteral()) {
|
|
70
|
-
return { type: "boolean", const: type.getLiteralValue() };
|
|
71
|
-
}
|
|
72
|
-
return { type: "boolean" };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (type.isNull()) {
|
|
76
|
-
return { type: "null" as any };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (type.isUndefined()) {
|
|
80
|
-
return { type: "undefined" as any };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Handle arrays
|
|
84
|
-
if (type.isArray()) {
|
|
85
|
-
const elementType = type.getArrayElementType();
|
|
86
|
-
if (elementType) {
|
|
87
|
-
return {
|
|
88
|
-
type: "array",
|
|
89
|
-
items: typeToJsonSchema(elementType, visited),
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
return { type: "array" };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Handle unions (but not boolean which is true | false)
|
|
96
|
-
if (type.isUnion() && !type.isBoolean()) {
|
|
97
|
-
const unionTypes = type.getUnionTypes();
|
|
98
|
-
// Filter out undefined for optional properties
|
|
99
|
-
const nonUndefinedTypes = unionTypes.filter((t) => !t.isUndefined());
|
|
100
|
-
if (nonUndefinedTypes.length === 1) {
|
|
101
|
-
return typeToJsonSchema(nonUndefinedTypes[0], visited);
|
|
102
|
-
}
|
|
103
|
-
return {
|
|
104
|
-
oneOf: nonUndefinedTypes.map((t) => typeToJsonSchema(t, visited)),
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Handle objects/interfaces
|
|
109
|
-
if (type.isObject()) {
|
|
110
|
-
// Check if it's a Date
|
|
111
|
-
if (type.getSymbol()?.getName() === "Date") {
|
|
112
|
-
return { type: "string", description: "ISO 8601 date string" };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Add to visited set to prevent recursion
|
|
116
|
-
const newVisited = new Set(visited);
|
|
117
|
-
newVisited.add(typeText);
|
|
118
|
-
|
|
119
|
-
const properties: Record<string, JSONSchema> = {};
|
|
120
|
-
const required: string[] = [];
|
|
121
|
-
|
|
122
|
-
const typeProperties = type.getProperties();
|
|
123
|
-
for (const prop of typeProperties) {
|
|
124
|
-
const propName = prop.getName();
|
|
125
|
-
// Skip internal properties
|
|
126
|
-
if (propName.startsWith("_")) continue;
|
|
127
|
-
|
|
128
|
-
// Try to get the type - use getTypeAtLocation if declaration exists, otherwise use getDeclaredType
|
|
129
|
-
let propType: Type | undefined;
|
|
130
|
-
const valueDecl = prop.getValueDeclaration();
|
|
131
|
-
if (valueDecl) {
|
|
132
|
-
propType = prop.getTypeAtLocation(valueDecl);
|
|
133
|
-
} else {
|
|
134
|
-
// For computed types without a direct declaration, get the type from declarations
|
|
135
|
-
const declarations = prop.getDeclarations();
|
|
136
|
-
if (declarations.length > 0) {
|
|
137
|
-
propType = prop.getTypeAtLocation(declarations[0]);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (!propType) continue;
|
|
142
|
-
|
|
143
|
-
properties[propName] = typeToJsonSchema(propType, newVisited);
|
|
144
|
-
|
|
145
|
-
// Check if property is optional
|
|
146
|
-
if (!prop.isOptional()) {
|
|
147
|
-
required.push(propName);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const schema: JSONSchema = {
|
|
152
|
-
type: "object",
|
|
153
|
-
properties,
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
if (required.length > 0) {
|
|
157
|
-
schema.required = required;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return schema;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Handle any/unknown
|
|
164
|
-
if (type.isAny() || type.isUnknown()) {
|
|
165
|
-
return { type: "object", additionalProperties: true };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Fallback for complex types
|
|
169
|
-
return { type: "object", additionalProperties: true };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
19
|
export class SwaggerInitializer extends Initializer {
|
|
173
20
|
constructor() {
|
|
174
21
|
super(namespace);
|
|
@@ -176,137 +23,44 @@ export class SwaggerInitializer extends Initializer {
|
|
|
176
23
|
}
|
|
177
24
|
|
|
178
25
|
async initialize() {
|
|
179
|
-
const
|
|
180
|
-
const cacheFile = path.join(cacheDir, "swagger-schemas.json");
|
|
181
|
-
|
|
182
|
-
// Hash action source files to detect changes
|
|
183
|
-
const actionsDirs = [path.join(api.rootDir, "actions")];
|
|
184
|
-
const glob = new Bun.Glob("**/*.ts");
|
|
185
|
-
const hasher = new Bun.CryptoHasher("sha256");
|
|
186
|
-
for (const actionsDir of actionsDirs) {
|
|
187
|
-
try {
|
|
188
|
-
const actionFiles = Array.from(glob.scanSync(actionsDir)).sort();
|
|
189
|
-
for (const file of actionFiles) {
|
|
190
|
-
const content = await Bun.file(path.join(actionsDir, file)).text();
|
|
191
|
-
hasher.update(content);
|
|
192
|
-
}
|
|
193
|
-
} catch {
|
|
194
|
-
// Directory may not exist
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
const hash = hasher.digest("hex") as string;
|
|
26
|
+
const hash = await computeActionsHash(api.rootDir);
|
|
198
27
|
|
|
199
28
|
// Check cache
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
`Loaded ${Object.keys(cached.responseSchemas).length} OpenAPI response schemas from cache`,
|
|
207
|
-
);
|
|
208
|
-
return { responseSchemas: cached.responseSchemas };
|
|
209
|
-
}
|
|
210
|
-
} catch {
|
|
211
|
-
// Cache file corrupted, regenerate
|
|
212
|
-
}
|
|
29
|
+
const cached = await loadCachedSchemas(api.rootDir);
|
|
30
|
+
if (cached && cached.hash === hash) {
|
|
31
|
+
logger.debug(
|
|
32
|
+
`Loaded ${Object.keys(cached.responseSchemas).length} OpenAPI response schemas from cache`,
|
|
33
|
+
);
|
|
34
|
+
return { responseSchemas: cached.responseSchemas };
|
|
213
35
|
}
|
|
214
36
|
|
|
215
|
-
|
|
216
|
-
|
|
37
|
+
// Generate schemas via ts-morph
|
|
38
|
+
let responseSchemas: Record<string, JSONSchema> = {};
|
|
217
39
|
try {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const project = new Project({
|
|
222
|
-
...(hasTsConfig ? { tsConfigFilePath: tsConfigPath } : {}),
|
|
223
|
-
skipAddingFilesFromTsConfig: true,
|
|
224
|
-
compilerOptions: {
|
|
225
|
-
strict: true,
|
|
226
|
-
target: ts.ScriptTarget.ESNext,
|
|
227
|
-
module: ts.ModuleKind.ESNext,
|
|
228
|
-
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
229
|
-
},
|
|
40
|
+
const result = await generateSwaggerSchemas({
|
|
41
|
+
rootDir: api.rootDir,
|
|
42
|
+
packageDir: api.packageDir,
|
|
230
43
|
});
|
|
231
|
-
|
|
232
|
-
// Add all source files so types can be resolved across the codebase
|
|
233
|
-
project.addSourceFilesAtPaths(path.join(api.packageDir, "**/*.ts"));
|
|
234
|
-
if (api.rootDir !== api.packageDir) {
|
|
235
|
-
project.addSourceFilesAtPaths(path.join(api.rootDir, "**/*.ts"));
|
|
236
|
-
}
|
|
237
|
-
// Exclude test files
|
|
238
|
-
for (const sourceFile of project.getSourceFiles()) {
|
|
239
|
-
if (sourceFile.getFilePath().includes("__tests__")) {
|
|
240
|
-
project.removeSourceFile(sourceFile);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Process each source file
|
|
245
|
-
for (const sourceFile of project.getSourceFiles()) {
|
|
246
|
-
const classes = sourceFile.getClasses();
|
|
247
|
-
|
|
248
|
-
for (const classDecl of classes) {
|
|
249
|
-
// Check if class implements Action (has name property and run method)
|
|
250
|
-
const nameProperty = classDecl.getProperty("name");
|
|
251
|
-
const runMethod =
|
|
252
|
-
classDecl.getMethod("run") || classDecl.getProperty("run"); // run can be a property with arrow function
|
|
253
|
-
|
|
254
|
-
if (!nameProperty || !runMethod) continue;
|
|
255
|
-
|
|
256
|
-
// Get action name from the name property initializer
|
|
257
|
-
const nameInitializer = nameProperty.getInitializer();
|
|
258
|
-
if (!nameInitializer) continue;
|
|
259
|
-
|
|
260
|
-
let actionName = nameInitializer.getText();
|
|
261
|
-
// Remove quotes from string literal
|
|
262
|
-
actionName = actionName.replace(/^["']|["']$/g, "");
|
|
263
|
-
|
|
264
|
-
// Get return type of run method
|
|
265
|
-
let returnType: Type | undefined;
|
|
266
|
-
|
|
267
|
-
if (runMethod.getKind() === ts.SyntaxKind.MethodDeclaration) {
|
|
268
|
-
// It's a method
|
|
269
|
-
const method = classDecl.getMethod("run");
|
|
270
|
-
if (method) {
|
|
271
|
-
returnType = method.getReturnType();
|
|
272
|
-
}
|
|
273
|
-
} else {
|
|
274
|
-
// It's a property (arrow function)
|
|
275
|
-
const prop = classDecl.getProperty("run");
|
|
276
|
-
if (prop) {
|
|
277
|
-
const propType = prop.getType();
|
|
278
|
-
// Get the return type from the function type
|
|
279
|
-
const callSignatures = propType.getCallSignatures();
|
|
280
|
-
if (callSignatures.length > 0) {
|
|
281
|
-
returnType = callSignatures[0].getReturnType();
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (returnType) {
|
|
287
|
-
const schema = typeToJsonSchema(returnType);
|
|
288
|
-
responseSchemas[actionName] = schema;
|
|
289
|
-
logger.debug(`Generated response schema for action: ${actionName}`);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
44
|
+
responseSchemas = result.responseSchemas;
|
|
293
45
|
|
|
294
46
|
logger.info(
|
|
295
47
|
`Generated ${Object.keys(responseSchemas).length} response schemas for swagger`,
|
|
296
48
|
);
|
|
49
|
+
|
|
50
|
+
// Write cache
|
|
51
|
+
try {
|
|
52
|
+
await writeSchemasCache(api.rootDir, {
|
|
53
|
+
hash,
|
|
54
|
+
responseSchemas,
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
logger.warn(`Failed to write swagger schema cache: ${error}`);
|
|
58
|
+
}
|
|
297
59
|
} catch (error) {
|
|
298
60
|
logger.error(`Failed to generate swagger response schemas: ${error}`);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// Write cache
|
|
302
|
-
try {
|
|
303
|
-
await mkdir(cacheDir, { recursive: true });
|
|
304
|
-
await Bun.write(
|
|
305
|
-
cacheFile,
|
|
306
|
-
JSON.stringify({ hash, responseSchemas }, null, 2),
|
|
61
|
+
logger.warn(
|
|
62
|
+
"Swagger response schemas are unavailable. Run `keryx build` at build time to pre-generate them.",
|
|
307
63
|
);
|
|
308
|
-
} catch (error) {
|
|
309
|
-
logger.warn(`Failed to write swagger schema cache: ${error}`);
|
|
310
64
|
}
|
|
311
65
|
|
|
312
66
|
return { responseSchemas };
|
package/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keryx",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"mcpName": "io.github.actionhero/keryx",
|
|
3
|
+
"version": "0.21.1",
|
|
5
4
|
"module": "index.ts",
|
|
6
5
|
"type": "module",
|
|
7
6
|
"license": "MIT",
|
|
@@ -56,8 +55,7 @@
|
|
|
56
55
|
"keryx.ts",
|
|
57
56
|
"migrations.ts",
|
|
58
57
|
"tsconfig.json",
|
|
59
|
-
"LICENSE"
|
|
60
|
-
"server.json"
|
|
58
|
+
"LICENSE"
|
|
61
59
|
],
|
|
62
60
|
"bin": {
|
|
63
61
|
"keryx": "keryx.ts"
|
package/util/cli.ts
CHANGED
|
@@ -95,6 +95,33 @@ export async function buildProgram(opts: {
|
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
program
|
|
99
|
+
.command("build")
|
|
100
|
+
.summary("Pre-generate swagger schemas for production")
|
|
101
|
+
.description(
|
|
102
|
+
"Run build-time code generation. Analyzes Action return types via ts-morph\n" +
|
|
103
|
+
"and writes .cache/swagger-schemas.json so the server can skip this step at\n" +
|
|
104
|
+
"startup. Recommended for memory-constrained environments (e.g., Docker).\n\n" +
|
|
105
|
+
"Example Dockerfile usage:\n" +
|
|
106
|
+
" COPY . .\n" +
|
|
107
|
+
" RUN bun keryx.ts build\n" +
|
|
108
|
+
' CMD ["bun", "keryx.ts", "start"]',
|
|
109
|
+
)
|
|
110
|
+
.action(async () => {
|
|
111
|
+
const { generateSwaggerSchemas, writeSchemasCache } = await import(
|
|
112
|
+
"./swaggerSchemaGenerator"
|
|
113
|
+
);
|
|
114
|
+
const result = await generateSwaggerSchemas({
|
|
115
|
+
rootDir: api.rootDir,
|
|
116
|
+
packageDir: api.packageDir,
|
|
117
|
+
});
|
|
118
|
+
await writeSchemasCache(api.rootDir, result);
|
|
119
|
+
console.log(
|
|
120
|
+
`Generated ${Object.keys(result.responseSchemas).length} swagger response schemas`,
|
|
121
|
+
);
|
|
122
|
+
process.exit(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
98
125
|
program
|
|
99
126
|
.command("generate <type> <name>")
|
|
100
127
|
.alias("g")
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { mkdir } from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import type { Type } from "ts-morph";
|
|
4
|
+
|
|
5
|
+
export type JSONSchema = {
|
|
6
|
+
type?: string;
|
|
7
|
+
properties?: Record<string, JSONSchema>;
|
|
8
|
+
items?: JSONSchema;
|
|
9
|
+
oneOf?: JSONSchema[];
|
|
10
|
+
required?: string[];
|
|
11
|
+
additionalProperties?: boolean | JSONSchema;
|
|
12
|
+
enum?: (string | number | boolean | null)[];
|
|
13
|
+
const?: unknown;
|
|
14
|
+
$ref?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convert a ts-morph Type to JSON Schema format.
|
|
20
|
+
*
|
|
21
|
+
* @param type - The ts-morph Type to convert
|
|
22
|
+
* @param visited - Set of type text strings already visited (prevents infinite recursion for circular types)
|
|
23
|
+
* @returns A JSON Schema representation of the type
|
|
24
|
+
*/
|
|
25
|
+
export function typeToJsonSchema(
|
|
26
|
+
type: Type,
|
|
27
|
+
visited: Set<string> = new Set(),
|
|
28
|
+
): JSONSchema {
|
|
29
|
+
const typeText = type.getText();
|
|
30
|
+
|
|
31
|
+
// Prevent infinite recursion for circular types
|
|
32
|
+
if (visited.has(typeText)) {
|
|
33
|
+
return { type: "object", additionalProperties: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Handle Promise<T> - unwrap to T
|
|
37
|
+
if (
|
|
38
|
+
type.getSymbol()?.getName() === "Promise" ||
|
|
39
|
+
typeText.startsWith("Promise<")
|
|
40
|
+
) {
|
|
41
|
+
const typeArgs = type.getTypeArguments();
|
|
42
|
+
if (typeArgs.length > 0) {
|
|
43
|
+
return typeToJsonSchema(typeArgs[0], visited);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Handle primitives
|
|
48
|
+
if (type.isString() || type.isStringLiteral()) {
|
|
49
|
+
if (type.isStringLiteral()) {
|
|
50
|
+
return { type: "string", const: type.getLiteralValue() };
|
|
51
|
+
}
|
|
52
|
+
return { type: "string" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (type.isNumber() || type.isNumberLiteral()) {
|
|
56
|
+
if (type.isNumberLiteral()) {
|
|
57
|
+
return { type: "number", const: type.getLiteralValue() };
|
|
58
|
+
}
|
|
59
|
+
return { type: "number" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (type.isBoolean() || type.isBooleanLiteral()) {
|
|
63
|
+
if (type.isBooleanLiteral()) {
|
|
64
|
+
return { type: "boolean", const: type.getLiteralValue() };
|
|
65
|
+
}
|
|
66
|
+
return { type: "boolean" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (type.isNull()) {
|
|
70
|
+
return { type: "null" as any };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (type.isUndefined()) {
|
|
74
|
+
return { type: "undefined" as any };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Handle arrays
|
|
78
|
+
if (type.isArray()) {
|
|
79
|
+
const elementType = type.getArrayElementType();
|
|
80
|
+
if (elementType) {
|
|
81
|
+
return {
|
|
82
|
+
type: "array",
|
|
83
|
+
items: typeToJsonSchema(elementType, visited),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { type: "array" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Handle unions (but not boolean which is true | false)
|
|
90
|
+
if (type.isUnion() && !type.isBoolean()) {
|
|
91
|
+
const unionTypes = type.getUnionTypes();
|
|
92
|
+
// Filter out undefined for optional properties
|
|
93
|
+
const nonUndefinedTypes = unionTypes.filter((t) => !t.isUndefined());
|
|
94
|
+
if (nonUndefinedTypes.length === 1) {
|
|
95
|
+
return typeToJsonSchema(nonUndefinedTypes[0], visited);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
oneOf: nonUndefinedTypes.map((t) => typeToJsonSchema(t, visited)),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Handle objects/interfaces
|
|
103
|
+
if (type.isObject()) {
|
|
104
|
+
// Check if it's a Date
|
|
105
|
+
if (type.getSymbol()?.getName() === "Date") {
|
|
106
|
+
return { type: "string", description: "ISO 8601 date string" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Add to visited set to prevent recursion
|
|
110
|
+
const newVisited = new Set(visited);
|
|
111
|
+
newVisited.add(typeText);
|
|
112
|
+
|
|
113
|
+
const properties: Record<string, JSONSchema> = {};
|
|
114
|
+
const required: string[] = [];
|
|
115
|
+
|
|
116
|
+
const typeProperties = type.getProperties();
|
|
117
|
+
for (const prop of typeProperties) {
|
|
118
|
+
const propName = prop.getName();
|
|
119
|
+
// Skip internal properties
|
|
120
|
+
if (propName.startsWith("_")) continue;
|
|
121
|
+
|
|
122
|
+
// Try to get the type - use getTypeAtLocation if declaration exists, otherwise use getDeclaredType
|
|
123
|
+
let propType: Type | undefined;
|
|
124
|
+
const valueDecl = prop.getValueDeclaration();
|
|
125
|
+
if (valueDecl) {
|
|
126
|
+
propType = prop.getTypeAtLocation(valueDecl);
|
|
127
|
+
} else {
|
|
128
|
+
// For computed types without a direct declaration, get the type from declarations
|
|
129
|
+
const declarations = prop.getDeclarations();
|
|
130
|
+
if (declarations.length > 0) {
|
|
131
|
+
propType = prop.getTypeAtLocation(declarations[0]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!propType) continue;
|
|
136
|
+
|
|
137
|
+
properties[propName] = typeToJsonSchema(propType, newVisited);
|
|
138
|
+
|
|
139
|
+
// Check if property is optional
|
|
140
|
+
if (!prop.isOptional()) {
|
|
141
|
+
required.push(propName);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const schema: JSONSchema = {
|
|
146
|
+
type: "object",
|
|
147
|
+
properties,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (required.length > 0) {
|
|
151
|
+
schema.required = required;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return schema;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Handle any/unknown
|
|
158
|
+
if (type.isAny() || type.isUnknown()) {
|
|
159
|
+
return { type: "object", additionalProperties: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Fallback for complex types
|
|
163
|
+
return { type: "object", additionalProperties: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Compute a SHA256 hash of all TypeScript action source files.
|
|
168
|
+
* Used to detect when actions have changed and the cache needs regeneration.
|
|
169
|
+
*
|
|
170
|
+
* @param rootDir - The application root directory containing the `actions/` folder
|
|
171
|
+
* @returns Hex-encoded SHA256 hash string
|
|
172
|
+
*/
|
|
173
|
+
export async function computeActionsHash(rootDir: string): Promise<string> {
|
|
174
|
+
const actionsDirs = [path.join(rootDir, "actions")];
|
|
175
|
+
const glob = new Bun.Glob("**/*.ts");
|
|
176
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
177
|
+
for (const actionsDir of actionsDirs) {
|
|
178
|
+
try {
|
|
179
|
+
const actionFiles = Array.from(glob.scanSync(actionsDir)).sort();
|
|
180
|
+
for (const file of actionFiles) {
|
|
181
|
+
const content = await Bun.file(path.join(actionsDir, file)).text();
|
|
182
|
+
hasher.update(content);
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// Directory may not exist
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return hasher.digest("hex") as string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Load cached swagger schemas from disk.
|
|
193
|
+
*
|
|
194
|
+
* @param rootDir - The application root directory containing the `.cache/` folder
|
|
195
|
+
* @returns The cached data (hash + responseSchemas) or null if cache is missing/corrupted
|
|
196
|
+
*/
|
|
197
|
+
export async function loadCachedSchemas(rootDir: string): Promise<{
|
|
198
|
+
hash: string;
|
|
199
|
+
responseSchemas: Record<string, JSONSchema>;
|
|
200
|
+
} | null> {
|
|
201
|
+
const cacheFile = path.join(rootDir, ".cache", "swagger-schemas.json");
|
|
202
|
+
const cacheFileHandle = Bun.file(cacheFile);
|
|
203
|
+
if (await cacheFileHandle.exists()) {
|
|
204
|
+
try {
|
|
205
|
+
const cached = await cacheFileHandle.json();
|
|
206
|
+
if (cached.hash && cached.responseSchemas) {
|
|
207
|
+
return cached;
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// Cache file corrupted
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Run the full ts-morph analysis to generate JSON Schema representations
|
|
218
|
+
* of all Action return types.
|
|
219
|
+
*
|
|
220
|
+
* @param opts - rootDir and packageDir paths for source file discovery
|
|
221
|
+
* @param opts.rootDir - The application root directory
|
|
222
|
+
* @param opts.packageDir - The keryx framework package directory
|
|
223
|
+
* @returns Hash of action files and generated response schemas
|
|
224
|
+
*/
|
|
225
|
+
export async function generateSwaggerSchemas(opts: {
|
|
226
|
+
rootDir: string;
|
|
227
|
+
packageDir: string;
|
|
228
|
+
}): Promise<{ hash: string; responseSchemas: Record<string, JSONSchema> }> {
|
|
229
|
+
const { rootDir, packageDir } = opts;
|
|
230
|
+
const hash = await computeActionsHash(rootDir);
|
|
231
|
+
const responseSchemas: Record<string, JSONSchema> = {};
|
|
232
|
+
|
|
233
|
+
// Dynamic import so ts-morph is only loaded when actually generating
|
|
234
|
+
const { Project, ts } = await import("ts-morph");
|
|
235
|
+
|
|
236
|
+
const tsConfigPath = path.join(rootDir, "tsconfig.json");
|
|
237
|
+
const hasTsConfig = await Bun.file(tsConfigPath).exists();
|
|
238
|
+
|
|
239
|
+
const project = new Project({
|
|
240
|
+
...(hasTsConfig ? { tsConfigFilePath: tsConfigPath } : {}),
|
|
241
|
+
skipAddingFilesFromTsConfig: true,
|
|
242
|
+
compilerOptions: {
|
|
243
|
+
strict: true,
|
|
244
|
+
target: ts.ScriptTarget.ESNext,
|
|
245
|
+
module: ts.ModuleKind.ESNext,
|
|
246
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Add all source files so types can be resolved across the codebase
|
|
251
|
+
project.addSourceFilesAtPaths(path.join(packageDir, "**/*.ts"));
|
|
252
|
+
if (rootDir !== packageDir) {
|
|
253
|
+
project.addSourceFilesAtPaths(path.join(rootDir, "**/*.ts"));
|
|
254
|
+
}
|
|
255
|
+
// Exclude test files
|
|
256
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
257
|
+
if (sourceFile.getFilePath().includes("__tests__")) {
|
|
258
|
+
project.removeSourceFile(sourceFile);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Process each source file
|
|
263
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
264
|
+
const classes = sourceFile.getClasses();
|
|
265
|
+
|
|
266
|
+
for (const classDecl of classes) {
|
|
267
|
+
// Check if class implements Action (has name property and run method)
|
|
268
|
+
const nameProperty = classDecl.getProperty("name");
|
|
269
|
+
const runMethod =
|
|
270
|
+
classDecl.getMethod("run") || classDecl.getProperty("run"); // run can be a property with arrow function
|
|
271
|
+
|
|
272
|
+
if (!nameProperty || !runMethod) continue;
|
|
273
|
+
|
|
274
|
+
// Get action name from the name property initializer
|
|
275
|
+
const nameInitializer = nameProperty.getInitializer();
|
|
276
|
+
if (!nameInitializer) continue;
|
|
277
|
+
|
|
278
|
+
let actionName = nameInitializer.getText();
|
|
279
|
+
// Remove quotes from string literal
|
|
280
|
+
actionName = actionName.replace(/^["']|["']$/g, "");
|
|
281
|
+
|
|
282
|
+
// Get return type of run method
|
|
283
|
+
let returnType: Type | undefined;
|
|
284
|
+
|
|
285
|
+
if (runMethod.getKind() === ts.SyntaxKind.MethodDeclaration) {
|
|
286
|
+
// It's a method
|
|
287
|
+
const method = classDecl.getMethod("run");
|
|
288
|
+
if (method) {
|
|
289
|
+
returnType = method.getReturnType();
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
// It's a property (arrow function)
|
|
293
|
+
const prop = classDecl.getProperty("run");
|
|
294
|
+
if (prop) {
|
|
295
|
+
const propType = prop.getType();
|
|
296
|
+
// Get the return type from the function type
|
|
297
|
+
const callSignatures = propType.getCallSignatures();
|
|
298
|
+
if (callSignatures.length > 0) {
|
|
299
|
+
returnType = callSignatures[0].getReturnType();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (returnType) {
|
|
305
|
+
const schema = typeToJsonSchema(returnType);
|
|
306
|
+
responseSchemas[actionName] = schema;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { hash, responseSchemas };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Write swagger schema cache to disk.
|
|
316
|
+
*
|
|
317
|
+
* @param rootDir - The application root directory where `.cache/` will be created
|
|
318
|
+
* @param data - The hash and response schemas to cache
|
|
319
|
+
*/
|
|
320
|
+
export async function writeSchemasCache(
|
|
321
|
+
rootDir: string,
|
|
322
|
+
data: { hash: string; responseSchemas: Record<string, JSONSchema> },
|
|
323
|
+
): Promise<void> {
|
|
324
|
+
const cacheDir = path.join(rootDir, ".cache");
|
|
325
|
+
await mkdir(cacheDir, { recursive: true });
|
|
326
|
+
await Bun.write(
|
|
327
|
+
path.join(cacheDir, "swagger-schemas.json"),
|
|
328
|
+
JSON.stringify(data, null, 2),
|
|
329
|
+
);
|
|
330
|
+
}
|
package/server.json
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
-
"name": "io.github.actionhero/keryx",
|
|
4
|
-
"description": "Fullstack TypeScript framework for MCP and APIs — one action serves HTTP, WS, CLI, and MCP.",
|
|
5
|
-
"repository": {
|
|
6
|
-
"url": "https://github.com/actionhero/keryx",
|
|
7
|
-
"source": "github"
|
|
8
|
-
},
|
|
9
|
-
"version": "0.20.8",
|
|
10
|
-
"packages": [
|
|
11
|
-
{
|
|
12
|
-
"registryType": "npm",
|
|
13
|
-
"identifier": "keryx",
|
|
14
|
-
"version": "0.20.8",
|
|
15
|
-
"transport": { "type": "http" }
|
|
16
|
-
}
|
|
17
|
-
]
|
|
18
|
-
}
|