keryx 0.19.2 → 0.20.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/classes/API.ts +41 -1
- package/classes/Plugin.ts +54 -0
- package/config/index.ts +4 -0
- package/config/plugins.ts +3 -0
- package/index.ts +3 -1
- package/initializers/actionts.ts +37 -8
- package/initializers/channels.ts +24 -5
- package/initializers/mcp.ts +2 -1
- package/initializers/resque.ts +4 -3
- package/initializers/servers.ts +22 -2
- package/package.json +1 -1
- package/templates/generate/plugin.ts.mustache +15 -0
- package/util/cli.ts +4 -3
- package/util/config.ts +55 -0
- package/util/generate.ts +99 -21
- package/util/oauthHandlers.ts +15 -7
- package/util/scaffold.ts +9 -0
package/classes/API.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { Glob } from "bun";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { config } from "../config";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
deepMerge,
|
|
7
|
+
deepMergeDefaults,
|
|
8
|
+
formatLoadedMessage,
|
|
9
|
+
} from "../util/config";
|
|
6
10
|
import { globLoader } from "../util/glob";
|
|
7
11
|
import type { Initializer, InitializerSortKeys } from "./Initializer";
|
|
8
12
|
import { Logger } from "./Logger";
|
|
@@ -69,6 +73,7 @@ export class API {
|
|
|
69
73
|
this.initialized = false;
|
|
70
74
|
|
|
71
75
|
await this.loadLocalConfig();
|
|
76
|
+
this.loadPluginConfig();
|
|
72
77
|
await this.findInitializers();
|
|
73
78
|
this.sortInitializers("loadPriority");
|
|
74
79
|
|
|
@@ -205,6 +210,19 @@ export class API {
|
|
|
205
210
|
}
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Apply plugin config defaults using deepMergeDefaults so that
|
|
215
|
+
* user-set config values are never overwritten by plugin defaults.
|
|
216
|
+
*/
|
|
217
|
+
private loadPluginConfig() {
|
|
218
|
+
for (const plugin of config.plugins) {
|
|
219
|
+
if (plugin.configDefaults) {
|
|
220
|
+
deepMergeDefaults(config, plugin.configDefaults);
|
|
221
|
+
this.logger.debug(`Merged config defaults from plugin ${plugin.name}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
208
226
|
private async findInitializers() {
|
|
209
227
|
// Load framework initializers from the package directory
|
|
210
228
|
const frameworkInitializers = await globLoader<Initializer>(
|
|
@@ -214,6 +232,17 @@ export class API {
|
|
|
214
232
|
this.initializers.push(i);
|
|
215
233
|
}
|
|
216
234
|
|
|
235
|
+
// Load plugin initializers
|
|
236
|
+
let pluginInitializerCount = 0;
|
|
237
|
+
for (const plugin of config.plugins) {
|
|
238
|
+
if (plugin.initializers) {
|
|
239
|
+
for (const InitializerClass of plugin.initializers) {
|
|
240
|
+
this.initializers.push(new InitializerClass());
|
|
241
|
+
pluginInitializerCount++;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
217
246
|
// Load user project initializers (if rootDir differs from packageDir)
|
|
218
247
|
if (this.rootDir !== this.packageDir) {
|
|
219
248
|
try {
|
|
@@ -227,6 +256,17 @@ export class API {
|
|
|
227
256
|
// user project may not have initializers, that's fine
|
|
228
257
|
}
|
|
229
258
|
}
|
|
259
|
+
|
|
260
|
+
this.logger.info(
|
|
261
|
+
formatLoadedMessage("initializers", {
|
|
262
|
+
core: frameworkInitializers.length,
|
|
263
|
+
plugin: pluginInitializerCount,
|
|
264
|
+
user:
|
|
265
|
+
this.initializers.length -
|
|
266
|
+
frameworkInitializers.length -
|
|
267
|
+
pluginInitializerCount,
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
230
270
|
}
|
|
231
271
|
|
|
232
272
|
private sortInitializers(key: InitializerSortKeys) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Action } from "./Action";
|
|
2
|
+
import type { Channel } from "./Channel";
|
|
3
|
+
import type { Initializer } from "./Initializer";
|
|
4
|
+
import type { Server } from "./Server";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Keryx plugin manifest. Plugins provide class constructors that the framework
|
|
8
|
+
* instantiates during discovery, alongside any config defaults and generator extensions.
|
|
9
|
+
*
|
|
10
|
+
* First-party plugins use the `@keryxjs/*` npm scope (e.g., `@keryxjs/resque-admin`).
|
|
11
|
+
* Third-party plugins follow the `keryx-plugin-*` naming convention.
|
|
12
|
+
*
|
|
13
|
+
* Register plugins via config:
|
|
14
|
+
* ```typescript
|
|
15
|
+
* // config/plugins.ts
|
|
16
|
+
* import { myPlugin } from "@keryxjs/my-plugin";
|
|
17
|
+
* export default { plugins: [myPlugin] };
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export interface KeryxPlugin {
|
|
21
|
+
/** Unique plugin name (e.g., `"resque-admin"`). */
|
|
22
|
+
name: string;
|
|
23
|
+
/** SemVer version string for the plugin. */
|
|
24
|
+
version: string;
|
|
25
|
+
|
|
26
|
+
/** Initializer class constructors. Instantiated and merged into the lifecycle alongside framework and user initializers. */
|
|
27
|
+
initializers?: Array<new () => Initializer>;
|
|
28
|
+
/** Action class constructors. Instantiated and registered alongside user actions. */
|
|
29
|
+
actions?: Array<new () => Action>;
|
|
30
|
+
/** Channel class constructors. Instantiated and registered alongside user channels. */
|
|
31
|
+
channels?: Array<new () => Channel>;
|
|
32
|
+
/** Server class constructors. Instantiated and registered alongside framework and user servers. */
|
|
33
|
+
servers?: Array<new () => Server<unknown>>;
|
|
34
|
+
|
|
35
|
+
/** Config defaults merged before user config, so user overrides take precedence. */
|
|
36
|
+
configDefaults?: Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
/** Custom generator types for the `keryx generate` CLI command. */
|
|
39
|
+
generators?: PluginGenerator[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A custom generator type that a plugin registers with the `keryx generate` CLI command.
|
|
44
|
+
*/
|
|
45
|
+
export interface PluginGenerator {
|
|
46
|
+
/** The generator type name (e.g., `"graphql-resolver"`). Used as `keryx generate <type> <name>`. */
|
|
47
|
+
type: string;
|
|
48
|
+
/** The output subdirectory name relative to the project root (e.g., `"resolvers"`). */
|
|
49
|
+
directory: string;
|
|
50
|
+
/** Absolute path to the Mustache template file for the generated component. */
|
|
51
|
+
templatePath: string;
|
|
52
|
+
/** Absolute path to the Mustache template file for the generated test. If omitted, the default test template is used. */
|
|
53
|
+
testTemplatePath?: string;
|
|
54
|
+
}
|
package/config/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { KeryxPlugin } from "../classes/Plugin";
|
|
1
2
|
import { configActions } from "./actions";
|
|
2
3
|
import { configChannels } from "./channels";
|
|
3
4
|
import { configDatabase } from "./database";
|
|
4
5
|
import { configLogger } from "./logger";
|
|
5
6
|
import { configObservability } from "./observability";
|
|
7
|
+
import { configPlugins } from "./plugins";
|
|
6
8
|
import { configProcess } from "./process";
|
|
7
9
|
import { configRateLimit } from "./rateLimit";
|
|
8
10
|
import { configRedis } from "./redis";
|
|
@@ -13,6 +15,7 @@ import { configSession } from "./session";
|
|
|
13
15
|
import { configTasks } from "./tasks";
|
|
14
16
|
|
|
15
17
|
export const config = {
|
|
18
|
+
plugins: configPlugins,
|
|
16
19
|
actions: configActions,
|
|
17
20
|
channels: configChannels,
|
|
18
21
|
process: configProcess,
|
|
@@ -39,6 +42,7 @@ export const config = {
|
|
|
39
42
|
* ```
|
|
40
43
|
*/
|
|
41
44
|
export interface KeryxConfig {
|
|
45
|
+
plugins: KeryxPlugin[];
|
|
42
46
|
actions: typeof configActions;
|
|
43
47
|
channels: typeof configChannels;
|
|
44
48
|
process: typeof configProcess;
|
package/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ export type { ChannelMiddleware } from "./classes/Channel";
|
|
|
24
24
|
export { CHANNEL_NAME_PATTERN } from "./classes/Channel";
|
|
25
25
|
export { Connection } from "./classes/Connection";
|
|
26
26
|
export { LogLevel } from "./classes/Logger";
|
|
27
|
+
export type { KeryxPlugin, PluginGenerator } from "./classes/Plugin";
|
|
27
28
|
export { SSEResponse, StreamingResponse } from "./classes/StreamingResponse";
|
|
28
29
|
export { ErrorStatusCodes, ErrorType, TypedError } from "./classes/TypedError";
|
|
29
30
|
export type { KeryxConfig } from "./config";
|
|
@@ -32,7 +33,8 @@ export { checkRateLimit, RateLimitMiddleware } from "./middleware/rateLimit";
|
|
|
32
33
|
export { TransactionMiddleware } from "./middleware/transaction";
|
|
33
34
|
export type { WebServer } from "./servers/web";
|
|
34
35
|
export { buildProgram } from "./util/cli";
|
|
35
|
-
export { deepMerge, loadFromEnvIfSet } from "./util/config";
|
|
36
|
+
export { deepMerge, deepMergeDefaults, loadFromEnvIfSet } from "./util/config";
|
|
37
|
+
export { getValidTypes } from "./util/generate";
|
|
36
38
|
export { globLoader } from "./util/glob";
|
|
37
39
|
export { type PaginatedResult, paginate } from "./util/pagination";
|
|
38
40
|
export { toMarkdown } from "./util/toMarkdown";
|
package/initializers/actionts.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type Action, DEFAULT_QUEUE } from "../classes/Action";
|
|
|
6
6
|
import { Initializer } from "../classes/Initializer";
|
|
7
7
|
import { ErrorType, TypedError } from "../classes/TypedError";
|
|
8
8
|
import { config } from "../config";
|
|
9
|
+
import { formatLoadedMessage } from "../util/config";
|
|
9
10
|
import { globLoader } from "../util/glob";
|
|
10
11
|
|
|
11
12
|
const namespace = "actions";
|
|
@@ -85,7 +86,9 @@ export class Actions extends Initializer {
|
|
|
85
86
|
inputs: TaskInputs = {},
|
|
86
87
|
queue?: string,
|
|
87
88
|
) => {
|
|
88
|
-
const action = api.actions.actions.find(
|
|
89
|
+
const action = api.actions.actions.find(
|
|
90
|
+
(a: Action) => a.name === actionName,
|
|
91
|
+
);
|
|
89
92
|
if (!action) {
|
|
90
93
|
throw new TypedError({
|
|
91
94
|
message: `action ${actionName} not found`,
|
|
@@ -140,7 +143,9 @@ export class Actions extends Initializer {
|
|
|
140
143
|
// Validate all action names up front
|
|
141
144
|
const actionNames = new Set<string>();
|
|
142
145
|
for (const job of jobs) {
|
|
143
|
-
const action = api.actions.actions.find(
|
|
146
|
+
const action = api.actions.actions.find(
|
|
147
|
+
(a: Action) => a.name === job.action,
|
|
148
|
+
);
|
|
144
149
|
if (!action) {
|
|
145
150
|
throw new TypedError({
|
|
146
151
|
message: `action ${job.action} not found`,
|
|
@@ -152,7 +157,9 @@ export class Actions extends Initializer {
|
|
|
152
157
|
|
|
153
158
|
// Resolve queue per job: explicit job.queue > action's task.queue > DEFAULT_QUEUE
|
|
154
159
|
const resolvedJobs = jobs.map((job) => {
|
|
155
|
-
const action = api.actions.actions.find(
|
|
160
|
+
const action = api.actions.actions.find(
|
|
161
|
+
(a: Action) => a.name === job.action,
|
|
162
|
+
)!;
|
|
156
163
|
const resolvedQueue = job.queue ?? action?.task?.queue ?? DEFAULT_QUEUE;
|
|
157
164
|
return { ...job, queue: resolvedQueue, inputs: job.inputs ?? {} };
|
|
158
165
|
});
|
|
@@ -250,8 +257,8 @@ export class Actions extends Initializer {
|
|
|
250
257
|
total: parseInt(meta.total, 10) || 0,
|
|
251
258
|
completed: parseInt(meta.completed, 10) || 0,
|
|
252
259
|
failed: parseInt(meta.failed, 10) || 0,
|
|
253
|
-
results: rawResults.map((r) => JSON.parse(r)),
|
|
254
|
-
errors: rawErrors.map((e) => JSON.parse(e)),
|
|
260
|
+
results: rawResults.map((r: string) => JSON.parse(r)),
|
|
261
|
+
errors: rawErrors.map((e: string) => JSON.parse(e)),
|
|
255
262
|
};
|
|
256
263
|
};
|
|
257
264
|
|
|
@@ -565,7 +572,9 @@ export class Actions extends Initializer {
|
|
|
565
572
|
* Will throw an error if redis cannot be reached.
|
|
566
573
|
*/
|
|
567
574
|
stopRecurrentAction = async (actionName: string): Promise<number> => {
|
|
568
|
-
const action = api.actions.actions.find(
|
|
575
|
+
const action = api.actions.actions.find(
|
|
576
|
+
(a: Action) => a.name === actionName,
|
|
577
|
+
);
|
|
569
578
|
if (!action) {
|
|
570
579
|
throw new TypedError({
|
|
571
580
|
message: `action ${actionName} not found`,
|
|
@@ -634,14 +643,34 @@ export class Actions extends Initializer {
|
|
|
634
643
|
};
|
|
635
644
|
|
|
636
645
|
async initialize() {
|
|
637
|
-
|
|
646
|
+
// Load plugin actions
|
|
647
|
+
const pluginActions: Action[] = [];
|
|
648
|
+
for (const plugin of config.plugins) {
|
|
649
|
+
if (plugin.actions) {
|
|
650
|
+
for (const ActionClass of plugin.actions) {
|
|
651
|
+
pluginActions.push(new ActionClass());
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Load user actions
|
|
657
|
+
const userActions = await globLoader<Action>(
|
|
658
|
+
path.join(api.rootDir, "actions"),
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
const actions = [...pluginActions, ...userActions];
|
|
638
662
|
|
|
639
663
|
for (const a of actions) {
|
|
640
664
|
if (!a.description) a.description = `An Action: ${a.name}`;
|
|
641
665
|
a.mcp = { tool: true, ...a.mcp };
|
|
642
666
|
}
|
|
643
667
|
|
|
644
|
-
logger.info(
|
|
668
|
+
logger.info(
|
|
669
|
+
formatLoadedMessage("actions", {
|
|
670
|
+
plugin: pluginActions.length,
|
|
671
|
+
user: userActions.length,
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
645
674
|
|
|
646
675
|
return {
|
|
647
676
|
actions,
|
package/initializers/channels.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Connection } from "../classes/Connection";
|
|
|
5
5
|
import { Initializer } from "../classes/Initializer";
|
|
6
6
|
import { ErrorType, TypedError } from "../classes/TypedError";
|
|
7
7
|
import { config } from "../config";
|
|
8
|
+
import { formatLoadedMessage } from "../util/config";
|
|
8
9
|
import { globLoader } from "../util/glob";
|
|
9
10
|
|
|
10
11
|
const namespace = "channels";
|
|
@@ -35,7 +36,7 @@ export class Channels extends Initializer {
|
|
|
35
36
|
* Returns undefined if no matching channel is found.
|
|
36
37
|
*/
|
|
37
38
|
findChannel = (channelName: string): Channel | undefined => {
|
|
38
|
-
return api.channels.channels.find((c) => c.matches(channelName));
|
|
39
|
+
return api.channels.channels.find((c: Channel) => c.matches(channelName));
|
|
39
40
|
};
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -229,11 +230,22 @@ export class Channels extends Initializer {
|
|
|
229
230
|
join(LUA_DIR, "refresh-presence.lua"),
|
|
230
231
|
).text();
|
|
231
232
|
|
|
232
|
-
|
|
233
|
+
// Load plugin channels
|
|
234
|
+
const pluginChannels: Channel[] = [];
|
|
235
|
+
for (const plugin of config.plugins) {
|
|
236
|
+
if (plugin.channels) {
|
|
237
|
+
for (const ChannelClass of plugin.channels) {
|
|
238
|
+
pluginChannels.push(new ChannelClass());
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
233
242
|
|
|
234
|
-
//
|
|
243
|
+
// Load user channels
|
|
244
|
+
let userChannels: Channel[] = [];
|
|
235
245
|
try {
|
|
236
|
-
|
|
246
|
+
userChannels = await globLoader<Channel>(
|
|
247
|
+
path.join(api.rootDir, "channels"),
|
|
248
|
+
);
|
|
237
249
|
} catch (e) {
|
|
238
250
|
// channels directory may not exist, which is fine
|
|
239
251
|
logger.debug(
|
|
@@ -241,11 +253,18 @@ export class Channels extends Initializer {
|
|
|
241
253
|
);
|
|
242
254
|
}
|
|
243
255
|
|
|
256
|
+
const channels = [...pluginChannels, ...userChannels];
|
|
257
|
+
|
|
244
258
|
for (const c of channels) {
|
|
245
259
|
if (!c.description) c.description = `A Channel: ${c.name}`;
|
|
246
260
|
}
|
|
247
261
|
|
|
248
|
-
logger.info(
|
|
262
|
+
logger.info(
|
|
263
|
+
formatLoadedMessage("channels", {
|
|
264
|
+
plugin: pluginChannels.length,
|
|
265
|
+
user: userChannels.length,
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
249
268
|
|
|
250
269
|
return {
|
|
251
270
|
channels,
|
package/initializers/mcp.ts
CHANGED
|
@@ -7,6 +7,7 @@ import colors from "colors";
|
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
8
|
import * as z4mini from "zod/v4-mini";
|
|
9
9
|
import { api, logger } from "../api";
|
|
10
|
+
import type { Action } from "../classes/Action";
|
|
10
11
|
import { MCP_RESPONSE_FORMAT } from "../classes/Action";
|
|
11
12
|
import { Connection } from "../classes/Connection";
|
|
12
13
|
import { Initializer } from "../classes/Initializer";
|
|
@@ -46,7 +47,7 @@ function formatToolName(actionName: string): string {
|
|
|
46
47
|
function parseToolName(toolName: string): string {
|
|
47
48
|
// Reverse lookup against registered actions
|
|
48
49
|
const action = api.actions.actions.find(
|
|
49
|
-
(a) => formatToolName(a.name) === toolName,
|
|
50
|
+
(a: Action) => formatToolName(a.name) === toolName,
|
|
50
51
|
);
|
|
51
52
|
return action ? action.name : toolName;
|
|
52
53
|
}
|
package/initializers/resque.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ErrorPayload,
|
|
2
3
|
type Job,
|
|
3
4
|
type ParsedJob,
|
|
4
5
|
Queue,
|
|
@@ -61,7 +62,7 @@ export class Resque extends Initializer {
|
|
|
61
62
|
api.resque.jobs,
|
|
62
63
|
);
|
|
63
64
|
|
|
64
|
-
api.resque.queue.on("error", (error) => {
|
|
65
|
+
api.resque.queue.on("error", (error: Error) => {
|
|
65
66
|
logger.error(`[resque:queue] ${error}`);
|
|
66
67
|
});
|
|
67
68
|
|
|
@@ -85,7 +86,7 @@ export class Resque extends Initializer {
|
|
|
85
86
|
retryStuckJobs: config.tasks.retryStuckJobs,
|
|
86
87
|
});
|
|
87
88
|
|
|
88
|
-
api.resque.scheduler.on("error", (error) => {
|
|
89
|
+
api.resque.scheduler.on("error", (error: Error) => {
|
|
89
90
|
logger.error(`[resque:scheduler] ${error}`);
|
|
90
91
|
});
|
|
91
92
|
|
|
@@ -105,7 +106,7 @@ export class Resque extends Initializer {
|
|
|
105
106
|
});
|
|
106
107
|
api.resque.scheduler.on(
|
|
107
108
|
"cleanStuckWorker",
|
|
108
|
-
(workerName, errorPayload, delta) => {
|
|
109
|
+
(workerName: string, errorPayload: ErrorPayload, delta: number) => {
|
|
109
110
|
logger.warn(
|
|
110
111
|
`[resque:scheduler] cleaning stuck worker: ${workerName}, ${errorPayload}, ${delta}`,
|
|
111
112
|
);
|
package/initializers/servers.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
-
import { api, RUN_MODE } from "../api";
|
|
2
|
+
import { api, logger, RUN_MODE } from "../api";
|
|
3
3
|
import { Initializer } from "../classes/Initializer";
|
|
4
4
|
import type { Server } from "../classes/Server";
|
|
5
|
+
import { config } from "../config";
|
|
6
|
+
import { formatLoadedMessage } from "../util/config";
|
|
5
7
|
import { globLoader } from "../util/glob";
|
|
6
8
|
|
|
7
9
|
const namespace = "servers";
|
|
@@ -27,6 +29,16 @@ export class Servers extends Initializer {
|
|
|
27
29
|
path.join(api.packageDir, "servers"),
|
|
28
30
|
);
|
|
29
31
|
|
|
32
|
+
// Load plugin servers
|
|
33
|
+
const pluginServers: Server<any>[] = [];
|
|
34
|
+
for (const plugin of config.plugins) {
|
|
35
|
+
if (plugin.servers) {
|
|
36
|
+
for (const ServerClass of plugin.servers) {
|
|
37
|
+
pluginServers.push(new ServerClass());
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
// Load user project servers (if rootDir differs from packageDir)
|
|
31
43
|
let userServers: Server<any>[] = [];
|
|
32
44
|
if (api.rootDir !== api.packageDir) {
|
|
@@ -39,12 +51,20 @@ export class Servers extends Initializer {
|
|
|
39
51
|
}
|
|
40
52
|
}
|
|
41
53
|
|
|
42
|
-
const servers = [...frameworkServers, ...userServers];
|
|
54
|
+
const servers = [...frameworkServers, ...pluginServers, ...userServers];
|
|
43
55
|
|
|
44
56
|
for (const server of servers) {
|
|
45
57
|
await server.initialize();
|
|
46
58
|
}
|
|
47
59
|
|
|
60
|
+
logger.info(
|
|
61
|
+
formatLoadedMessage("servers", {
|
|
62
|
+
core: frameworkServers.length,
|
|
63
|
+
plugin: pluginServers.length,
|
|
64
|
+
user: userServers.length,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
|
|
48
68
|
return { servers };
|
|
49
69
|
}
|
|
50
70
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { KeryxPlugin } from "keryx";
|
|
2
|
+
|
|
3
|
+
export const {{className}}: KeryxPlugin = {
|
|
4
|
+
name: "{{name}}",
|
|
5
|
+
version: "0.1.0",
|
|
6
|
+
|
|
7
|
+
// initializers: [],
|
|
8
|
+
// actions: [],
|
|
9
|
+
// channels: [],
|
|
10
|
+
// servers: [],
|
|
11
|
+
|
|
12
|
+
// configDefaults: {},
|
|
13
|
+
|
|
14
|
+
// generators: [],
|
|
15
|
+
};
|
package/util/cli.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Action, api, Connection, RUN_MODE } from "../api";
|
|
|
5
5
|
import { ExitCode } from "./../classes/ExitCode";
|
|
6
6
|
import { TypedError } from "./../classes/TypedError";
|
|
7
7
|
import { config } from "../config";
|
|
8
|
-
import { generateComponent } from "./generate";
|
|
8
|
+
import { generateComponent, getValidTypes } from "./generate";
|
|
9
9
|
import { globLoader } from "./glob";
|
|
10
10
|
import {
|
|
11
11
|
interactiveScaffold,
|
|
@@ -100,13 +100,14 @@ export async function buildProgram(opts: {
|
|
|
100
100
|
.alias("g")
|
|
101
101
|
.summary("Generate a new component")
|
|
102
102
|
.description(
|
|
103
|
-
|
|
103
|
+
`Scaffold a new component file.\n\nValid types: ${getValidTypes().join(", ")}\n\n` +
|
|
104
104
|
"Examples:\n" +
|
|
105
105
|
" keryx generate action user:delete\n" +
|
|
106
106
|
" keryx generate initializer cache\n" +
|
|
107
107
|
" keryx generate middleware auth\n" +
|
|
108
108
|
" keryx generate channel notifications\n" +
|
|
109
109
|
" keryx generate ops UserOps\n" +
|
|
110
|
+
" keryx generate plugin analytics\n" +
|
|
110
111
|
" keryx g action hello",
|
|
111
112
|
)
|
|
112
113
|
.option("--dry-run", "Show what would be generated without writing files")
|
|
@@ -232,7 +233,7 @@ async function runActionViaCLI(options: Record<string, string>, command: any) {
|
|
|
232
233
|
|
|
233
234
|
await api.initialize();
|
|
234
235
|
|
|
235
|
-
const action = api.actions.actions.find((a) => a.name === actionName);
|
|
236
|
+
const action = api.actions.actions.find((a: Action) => a.name === actionName);
|
|
236
237
|
if (!action) {
|
|
237
238
|
exitWithError(`Action "${actionName}" not found`);
|
|
238
239
|
}
|
package/util/config.ts
CHANGED
|
@@ -27,6 +27,61 @@ export function deepMerge<T extends Record<string, any>>(
|
|
|
27
27
|
return target;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Like `deepMerge`, but only sets values that don't already exist in target.
|
|
32
|
+
* Useful for applying plugin config defaults without overwriting user-set values.
|
|
33
|
+
*/
|
|
34
|
+
export function deepMergeDefaults<T extends Record<string, any>>(
|
|
35
|
+
target: T,
|
|
36
|
+
source: Record<string, any>,
|
|
37
|
+
): T {
|
|
38
|
+
for (const key of Object.keys(source)) {
|
|
39
|
+
if (!(key in target)) {
|
|
40
|
+
(target as any)[key] = source[key];
|
|
41
|
+
} else {
|
|
42
|
+
const targetVal = target[key];
|
|
43
|
+
const sourceVal = source[key];
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
targetVal &&
|
|
47
|
+
sourceVal &&
|
|
48
|
+
typeof targetVal === "object" &&
|
|
49
|
+
typeof sourceVal === "object" &&
|
|
50
|
+
!Array.isArray(targetVal) &&
|
|
51
|
+
!Array.isArray(sourceVal)
|
|
52
|
+
) {
|
|
53
|
+
deepMergeDefaults(targetVal, sourceVal);
|
|
54
|
+
}
|
|
55
|
+
// If key exists in target and isn't a nested object, keep the target value
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return target;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a human-readable summary of how many components were loaded and where
|
|
64
|
+
* they came from. Example: `"loaded 12 initializers (10 core, 1 from plugins, 1 user-defined)"`
|
|
65
|
+
*
|
|
66
|
+
* @param label - Plural noun for the component type (e.g. "actions", "servers").
|
|
67
|
+
* @param counts - Breakdown by source. All fields are optional; zero-value
|
|
68
|
+
* categories are omitted from the output.
|
|
69
|
+
*/
|
|
70
|
+
export function formatLoadedMessage(
|
|
71
|
+
label: string,
|
|
72
|
+
counts: { core?: number; plugin?: number; user?: number },
|
|
73
|
+
): string {
|
|
74
|
+
const total = (counts.core ?? 0) + (counts.plugin ?? 0) + (counts.user ?? 0);
|
|
75
|
+
const parts: string[] = [];
|
|
76
|
+
if (counts.core) parts.push(`${counts.core} core`);
|
|
77
|
+
if (counts.plugin) parts.push(`${counts.plugin} from plugins`);
|
|
78
|
+
if (counts.user) parts.push(`${counts.user} user-defined`);
|
|
79
|
+
if (parts.length > 0) {
|
|
80
|
+
return `loaded ${total} ${label} (${parts.join(", ")})`;
|
|
81
|
+
}
|
|
82
|
+
return `loaded ${total} ${label}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
30
85
|
/**
|
|
31
86
|
Loads a value from the environment, if it's set, otherwise returns the default value.
|
|
32
87
|
*/
|
package/util/generate.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import Mustache from "mustache";
|
|
3
3
|
import path from "path";
|
|
4
|
+
import type { PluginGenerator } from "../classes/Plugin";
|
|
5
|
+
import { config } from "../config";
|
|
4
6
|
|
|
5
7
|
const VALID_TYPES = [
|
|
6
8
|
"action",
|
|
@@ -8,9 +10,39 @@ const VALID_TYPES = [
|
|
|
8
10
|
"middleware",
|
|
9
11
|
"channel",
|
|
10
12
|
"ops",
|
|
13
|
+
"plugin",
|
|
11
14
|
] as const;
|
|
12
15
|
type GeneratorType = (typeof VALID_TYPES)[number];
|
|
13
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Returns all valid generator types, including built-in types and
|
|
19
|
+
* any custom types registered by plugins.
|
|
20
|
+
*/
|
|
21
|
+
export function getValidTypes(): string[] {
|
|
22
|
+
const types = [...VALID_TYPES] as string[];
|
|
23
|
+
for (const plugin of config.plugins) {
|
|
24
|
+
if (plugin.generators) {
|
|
25
|
+
for (const gen of plugin.generators) {
|
|
26
|
+
if (!types.includes(gen.type)) types.push(gen.type);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return types;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Find a plugin generator definition for a given type.
|
|
35
|
+
*/
|
|
36
|
+
function findPluginGenerator(type: string): PluginGenerator | undefined {
|
|
37
|
+
for (const plugin of config.plugins) {
|
|
38
|
+
if (plugin.generators) {
|
|
39
|
+
const gen = plugin.generators.find((g) => g.type === type);
|
|
40
|
+
if (gen) return gen;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
14
46
|
export interface GenerateOptions {
|
|
15
47
|
dryRun?: boolean;
|
|
16
48
|
force?: boolean;
|
|
@@ -46,6 +78,7 @@ function resolveFilePath(type: GeneratorType, name: string): string {
|
|
|
46
78
|
middleware: "middleware",
|
|
47
79
|
channel: "channels",
|
|
48
80
|
ops: "ops",
|
|
81
|
+
plugin: "plugins",
|
|
49
82
|
};
|
|
50
83
|
|
|
51
84
|
const baseDir = dirMap[type];
|
|
@@ -60,6 +93,27 @@ function resolveFilePath(type: GeneratorType, name: string): string {
|
|
|
60
93
|
return path.join(baseDir, `${name}.ts`);
|
|
61
94
|
}
|
|
62
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Determine the directory and filename for a plugin generator type + name.
|
|
98
|
+
*/
|
|
99
|
+
function resolvePluginFilePath(gen: PluginGenerator, name: string): string {
|
|
100
|
+
const segments = name.split(":");
|
|
101
|
+
if (segments.length > 1) {
|
|
102
|
+
const fileName = segments.pop()!;
|
|
103
|
+
return path.join(gen.directory, ...segments, `${fileName}.ts`);
|
|
104
|
+
}
|
|
105
|
+
return path.join(gen.directory, `${name}.ts`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Determine the test file path for a plugin generator component.
|
|
110
|
+
*/
|
|
111
|
+
function resolvePluginTestPath(gen: PluginGenerator, name: string): string {
|
|
112
|
+
const componentPath = resolvePluginFilePath(gen, name);
|
|
113
|
+
const parsed = path.parse(componentPath);
|
|
114
|
+
return path.join("__tests__", parsed.dir, `${parsed.name}.test.ts`);
|
|
115
|
+
}
|
|
116
|
+
|
|
63
117
|
/**
|
|
64
118
|
* Determine the test file path for a component.
|
|
65
119
|
* "user:delete" action → "__tests__/actions/user/delete.test.ts"
|
|
@@ -92,14 +146,21 @@ export async function generateComponent(
|
|
|
92
146
|
rootDir: string,
|
|
93
147
|
options: GenerateOptions = {},
|
|
94
148
|
): Promise<string[]> {
|
|
95
|
-
|
|
149
|
+
const allValidTypes = getValidTypes();
|
|
150
|
+
if (!allValidTypes.includes(type)) {
|
|
96
151
|
throw new Error(
|
|
97
|
-
`Unknown generator type "${type}". Valid types: ${
|
|
152
|
+
`Unknown generator type "${type}". Valid types: ${allValidTypes.join(", ")}`,
|
|
98
153
|
);
|
|
99
154
|
}
|
|
100
155
|
|
|
101
|
-
|
|
102
|
-
const
|
|
156
|
+
// Check if this is a plugin-provided generator type
|
|
157
|
+
const pluginGen = VALID_TYPES.includes(type as GeneratorType)
|
|
158
|
+
? undefined
|
|
159
|
+
: findPluginGenerator(type);
|
|
160
|
+
|
|
161
|
+
const filePath = pluginGen
|
|
162
|
+
? resolvePluginFilePath(pluginGen, name)
|
|
163
|
+
: resolveFilePath(type as GeneratorType, name);
|
|
103
164
|
const fullPath = path.join(rootDir, filePath);
|
|
104
165
|
const createdFiles: string[] = [];
|
|
105
166
|
|
|
@@ -112,25 +173,32 @@ export async function generateComponent(
|
|
|
112
173
|
|
|
113
174
|
// Build template view
|
|
114
175
|
let className = toClassName(name);
|
|
115
|
-
if (
|
|
116
|
-
if (
|
|
176
|
+
if (type === "middleware") className += "Middleware";
|
|
177
|
+
if (type === "channel") className += "Channel";
|
|
178
|
+
if (type === "plugin") className += "Plugin";
|
|
117
179
|
|
|
118
180
|
const view: Record<string, string> = { name, className };
|
|
119
|
-
if (
|
|
181
|
+
if (type === "action") {
|
|
120
182
|
view.route = toRoute(name);
|
|
121
183
|
}
|
|
122
184
|
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
185
|
+
// Load and render template
|
|
186
|
+
let content: string;
|
|
187
|
+
if (pluginGen) {
|
|
188
|
+
const templateStr = await Bun.file(pluginGen.templatePath).text();
|
|
189
|
+
content = Mustache.render(templateStr, view);
|
|
190
|
+
} else {
|
|
191
|
+
const templateMap: Record<GeneratorType, string> = {
|
|
192
|
+
action: "action.ts.mustache",
|
|
193
|
+
initializer: "initializer.ts.mustache",
|
|
194
|
+
middleware: "action-middleware.ts.mustache",
|
|
195
|
+
channel: "channel.ts.mustache",
|
|
196
|
+
ops: "ops.ts.mustache",
|
|
197
|
+
plugin: "plugin.ts.mustache",
|
|
198
|
+
};
|
|
199
|
+
const template = await loadTemplate(templateMap[type as GeneratorType]);
|
|
200
|
+
content = Mustache.render(template, view);
|
|
201
|
+
}
|
|
134
202
|
|
|
135
203
|
if (options.dryRun) {
|
|
136
204
|
console.log(`Would create: ${filePath}`);
|
|
@@ -145,14 +213,24 @@ export async function generateComponent(
|
|
|
145
213
|
|
|
146
214
|
// Generate test file
|
|
147
215
|
if (!options.noTest) {
|
|
148
|
-
const testPath =
|
|
216
|
+
const testPath = pluginGen
|
|
217
|
+
? resolvePluginTestPath(pluginGen, name)
|
|
218
|
+
: resolveTestPath(type as GeneratorType, name);
|
|
149
219
|
const testFullPath = path.join(rootDir, testPath);
|
|
150
220
|
|
|
151
221
|
if (!options.force && fs.existsSync(testFullPath)) {
|
|
152
222
|
// Silently skip test file if it already exists
|
|
153
223
|
} else {
|
|
154
|
-
|
|
155
|
-
|
|
224
|
+
let testContent: string;
|
|
225
|
+
if (pluginGen?.testTemplatePath) {
|
|
226
|
+
const testTemplateStr = await Bun.file(
|
|
227
|
+
pluginGen.testTemplatePath,
|
|
228
|
+
).text();
|
|
229
|
+
testContent = Mustache.render(testTemplateStr, view);
|
|
230
|
+
} else {
|
|
231
|
+
const testTemplate = await loadTemplate("test.ts.mustache");
|
|
232
|
+
testContent = Mustache.render(testTemplate, view);
|
|
233
|
+
}
|
|
156
234
|
|
|
157
235
|
if (options.dryRun) {
|
|
158
236
|
console.log(`Would create: ${testPath}`);
|
package/util/oauthHandlers.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { api } from "../api";
|
|
3
|
-
import type { OAuthActionResponse } from "../classes/Action";
|
|
3
|
+
import type { Action, OAuthActionResponse } from "../classes/Action";
|
|
4
4
|
import { Connection } from "../classes/Connection";
|
|
5
5
|
import { config } from "../config";
|
|
6
6
|
import {
|
|
@@ -180,8 +180,10 @@ export function handleAuthorizeGet(
|
|
|
180
180
|
};
|
|
181
181
|
|
|
182
182
|
return renderAuthPage(params, templates, {
|
|
183
|
-
loginAction: api.actions.actions.find((a) => a.mcp?.isLoginAction),
|
|
184
|
-
signupAction: api.actions.actions.find(
|
|
183
|
+
loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
|
|
184
|
+
signupAction: api.actions.actions.find(
|
|
185
|
+
(a: Action) => a.mcp?.isSignupAction,
|
|
186
|
+
),
|
|
185
187
|
});
|
|
186
188
|
}
|
|
187
189
|
|
|
@@ -227,8 +229,10 @@ export async function handleAuthorizePost(
|
|
|
227
229
|
};
|
|
228
230
|
|
|
229
231
|
const authActions = {
|
|
230
|
-
loginAction: api.actions.actions.find((a) => a.mcp?.isLoginAction),
|
|
231
|
-
signupAction: api.actions.actions.find(
|
|
232
|
+
loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
|
|
233
|
+
signupAction: api.actions.actions.find(
|
|
234
|
+
(a: Action) => a.mcp?.isSignupAction,
|
|
235
|
+
),
|
|
232
236
|
};
|
|
233
237
|
|
|
234
238
|
// Validate client
|
|
@@ -263,7 +267,9 @@ export async function handleAuthorizePost(
|
|
|
263
267
|
let userId: number;
|
|
264
268
|
|
|
265
269
|
if (mode === "signup") {
|
|
266
|
-
const signupAction = api.actions.actions.find(
|
|
270
|
+
const signupAction = api.actions.actions.find(
|
|
271
|
+
(a: Action) => a.mcp?.isSignupAction,
|
|
272
|
+
);
|
|
267
273
|
if (!signupAction) {
|
|
268
274
|
oauthParams.error = "No signup action configured";
|
|
269
275
|
return renderAuthPage(oauthParams, templates, authActions);
|
|
@@ -283,7 +289,9 @@ export async function handleAuthorizePost(
|
|
|
283
289
|
connection.destroy();
|
|
284
290
|
}
|
|
285
291
|
} else {
|
|
286
|
-
const loginAction = api.actions.actions.find(
|
|
292
|
+
const loginAction = api.actions.actions.find(
|
|
293
|
+
(a: Action) => a.mcp?.isLoginAction,
|
|
294
|
+
);
|
|
287
295
|
if (!loginAction) {
|
|
288
296
|
oauthParams.error = "No login action configured";
|
|
289
297
|
return renderAuthPage(oauthParams, templates, authActions);
|
package/util/scaffold.ts
CHANGED
|
@@ -103,6 +103,10 @@ export async function generateConfigFileContents(): Promise<
|
|
|
103
103
|
/from ["']\.\.\/classes\/Logger["']/g,
|
|
104
104
|
'from "keryx"',
|
|
105
105
|
);
|
|
106
|
+
content = content.replace(
|
|
107
|
+
/from ["']\.\.\/classes\/Plugin["']/g,
|
|
108
|
+
'from "keryx"',
|
|
109
|
+
);
|
|
106
110
|
|
|
107
111
|
// In index.ts, change `export const config` to `export default`
|
|
108
112
|
// and remove the KeryxConfig interface export (it comes from the package)
|
|
@@ -112,6 +116,11 @@ export async function generateConfigFileContents(): Promise<
|
|
|
112
116
|
/\nexport interface KeryxConfig \{[\s\S]*?\}\n/,
|
|
113
117
|
"\n",
|
|
114
118
|
);
|
|
119
|
+
// Remove the KeryxPlugin import (only used by the stripped KeryxConfig interface)
|
|
120
|
+
content = content.replace(
|
|
121
|
+
/import type \{ KeryxPlugin \} from ["']keryx["'];\n/,
|
|
122
|
+
"",
|
|
123
|
+
);
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
result.set(`config/${file}`, content);
|