keryx 0.25.1 → 0.25.3

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.
@@ -60,7 +60,7 @@ export type FanOutStatus = {
60
60
  errors: Array<{ params: Record<string, any>; error: string }>;
61
61
  };
62
62
 
63
- declare module "../classes/API" {
63
+ declare module "keryx" {
64
64
  export interface API {
65
65
  [namespace]: Awaited<ReturnType<Actions["initialize"]>>;
66
66
  }
@@ -22,7 +22,7 @@ const REFRESH_PRESENCE_LUA = await Bun.file(
22
22
  join(LUA_DIR, "refresh-presence.lua"),
23
23
  ).text();
24
24
 
25
- declare module "../classes/API" {
25
+ declare module "keryx" {
26
26
  export interface API {
27
27
  [namespace]: Awaited<ReturnType<Channels["initialize"]>>;
28
28
  }
@@ -3,7 +3,7 @@ import { Initializer } from "../classes/Initializer";
3
3
 
4
4
  const namespace = "connections";
5
5
 
6
- declare module "../classes/API" {
6
+ declare module "keryx" {
7
7
  export interface API {
8
8
  [namespace]: Awaited<ReturnType<Connections["initialize"]>>;
9
9
  }
@@ -18,7 +18,7 @@ import {
18
18
 
19
19
  const namespace = "db";
20
20
 
21
- declare module "../classes/API" {
21
+ declare module "keryx" {
22
22
  export interface API {
23
23
  [namespace]: Awaited<ReturnType<DB["initialize"]>>;
24
24
  }
@@ -22,7 +22,7 @@ type McpHandleRequest = (req: Request, ip: string) => Promise<Response>;
22
22
 
23
23
  const namespace = "mcp";
24
24
 
25
- declare module "../classes/API" {
25
+ declare module "keryx" {
26
26
  export interface API {
27
27
  [namespace]: Awaited<ReturnType<McpInitializer["initialize"]>>;
28
28
  }
@@ -27,7 +27,7 @@ import {
27
27
 
28
28
  const namespace = "oauth";
29
29
 
30
- declare module "../classes/API" {
30
+ declare module "keryx" {
31
31
  export interface API {
32
32
  [namespace]: Awaited<ReturnType<OAuthInitializer["initialize"]>>;
33
33
  }
@@ -11,7 +11,7 @@ import { config } from "../config";
11
11
 
12
12
  const namespace = "observability";
13
13
 
14
- declare module "../classes/API" {
14
+ declare module "keryx" {
15
15
  export interface API {
16
16
  [namespace]: Awaited<ReturnType<Observability["initialize"]>>;
17
17
  }
@@ -4,7 +4,7 @@ import { config } from "../config";
4
4
 
5
5
  const namespace = "process";
6
6
 
7
- declare module "../classes/API" {
7
+ declare module "keryx" {
8
8
  export interface API {
9
9
  [namespace]: Awaited<ReturnType<Process["initialize"]>>;
10
10
  }
@@ -24,7 +24,7 @@ export type ClientUnsubscribeMessage = {
24
24
  channel: string;
25
25
  };
26
26
 
27
- declare module "../classes/API" {
27
+ declare module "keryx" {
28
28
  export interface API {
29
29
  [namespace]: Awaited<ReturnType<PubSub["initialize"]>>;
30
30
  }
@@ -10,7 +10,7 @@ import {
10
10
  const namespace = "redis";
11
11
  const testKey = `__keryx_test_key:${config.process.name}`;
12
12
 
13
- declare module "../classes/API" {
13
+ declare module "keryx" {
14
14
  export interface API {
15
15
  [namespace]: Awaited<ReturnType<Redis["initialize"]>>;
16
16
  }
@@ -33,7 +33,7 @@ function logResqueEvent(
33
33
  }
34
34
  }
35
35
 
36
- declare module "../classes/API" {
36
+ declare module "keryx" {
37
37
  export interface API {
38
38
  [namespace]: Awaited<ReturnType<Resque["initialize"]>>;
39
39
  }
@@ -8,7 +8,7 @@ import { globLoader } from "../util/glob";
8
8
 
9
9
  const namespace = "servers";
10
10
 
11
- declare module "../classes/API" {
11
+ declare module "keryx" {
12
12
  export interface API {
13
13
  [namespace]: Awaited<ReturnType<Servers["initialize"]>>;
14
14
  }
@@ -87,7 +87,7 @@ async function destroy(connection: Connection) {
87
87
  return response > 0;
88
88
  }
89
89
 
90
- declare module "../classes/API" {
90
+ declare module "keryx" {
91
91
  export interface API {
92
92
  [namespace]: Awaited<ReturnType<Session["initialize"]>>;
93
93
  }
@@ -5,7 +5,7 @@ import { config } from "../config";
5
5
 
6
6
  const namespace = "signals";
7
7
 
8
- declare module "../classes/API" {
8
+ declare module "keryx" {
9
9
  export interface API {
10
10
  [namespace]: Awaited<ReturnType<Signals["initialize"]>>;
11
11
  }
@@ -10,7 +10,7 @@ import {
10
10
 
11
11
  const namespace = "swagger";
12
12
 
13
- declare module "../classes/API" {
13
+ declare module "keryx" {
14
14
  export interface API {
15
15
  [namespace]: Awaited<ReturnType<SwaggerInitializer["initialize"]>>;
16
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.25.1",
3
+ "version": "0.25.3",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/util/cli.ts CHANGED
@@ -5,7 +5,8 @@ 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, getValidTypes } from "./generate";
8
+ import { getValidTypes } from "./componentRegistry";
9
+ import { generateComponent } from "./generate";
9
10
  import { globLoader } from "./glob";
10
11
  import {
11
12
  interactiveScaffold,
@@ -0,0 +1,184 @@
1
+ import path from "path";
2
+ import { config } from "../config";
3
+
4
+ /**
5
+ * A component that can be produced by `keryx generate`.
6
+ *
7
+ * Built-in components (action, initializer, middleware, channel, ops, plugin)
8
+ * and plugin-contributed generators are both represented as `ComponentDef`s,
9
+ * so `generateComponent()` can handle them through a single code path.
10
+ */
11
+ export interface ComponentDef {
12
+ /** Generator type name — what the user passes as `keryx generate <type> <name>`. */
13
+ type: string;
14
+ /** Output subdirectory relative to the project root (e.g. `"actions"`). */
15
+ directory: string;
16
+ /** Absolute path to the Mustache template for the component file. */
17
+ templatePath: string;
18
+ /** Absolute path to the Mustache template for the test file. Falls back to the default generate/test.ts.mustache. */
19
+ testTemplatePath?: string;
20
+ /** Suffix appended to the PascalCase class name (e.g. `"Middleware"` → `FooMiddleware`). */
21
+ classSuffix?: string;
22
+ /** When true, a `route` field is added to the Mustache view (used by actions). */
23
+ includeRoute?: boolean;
24
+ }
25
+
26
+ const generateTemplatesDir = path.join(
27
+ import.meta.dir,
28
+ "..",
29
+ "templates",
30
+ "generate",
31
+ );
32
+ const scaffoldTemplatesDir = path.join(
33
+ import.meta.dir,
34
+ "..",
35
+ "templates",
36
+ "scaffold",
37
+ );
38
+
39
+ const BUILT_IN_DEFS: ComponentDef[] = [
40
+ {
41
+ type: "action",
42
+ directory: "actions",
43
+ templatePath: path.join(generateTemplatesDir, "action.ts.mustache"),
44
+ includeRoute: true,
45
+ },
46
+ {
47
+ type: "initializer",
48
+ directory: "initializers",
49
+ templatePath: path.join(generateTemplatesDir, "initializer.ts.mustache"),
50
+ },
51
+ {
52
+ type: "middleware",
53
+ directory: "middleware",
54
+ templatePath: path.join(
55
+ generateTemplatesDir,
56
+ "action-middleware.ts.mustache",
57
+ ),
58
+ classSuffix: "Middleware",
59
+ },
60
+ {
61
+ type: "channel",
62
+ directory: "channels",
63
+ templatePath: path.join(generateTemplatesDir, "channel.ts.mustache"),
64
+ classSuffix: "Channel",
65
+ },
66
+ {
67
+ type: "ops",
68
+ directory: "ops",
69
+ templatePath: path.join(generateTemplatesDir, "ops.ts.mustache"),
70
+ },
71
+ {
72
+ type: "plugin",
73
+ directory: "plugins",
74
+ templatePath: path.join(generateTemplatesDir, "plugin.ts.mustache"),
75
+ classSuffix: "Plugin",
76
+ },
77
+ ];
78
+
79
+ /**
80
+ * Returns all component definitions, merging built-ins with plugin-contributed generators.
81
+ * Plugin generators with a type matching a built-in are ignored (built-ins win).
82
+ */
83
+ export function getComponentDefs(): ComponentDef[] {
84
+ const defs: ComponentDef[] = [...BUILT_IN_DEFS];
85
+ const seen = new Set(defs.map((d) => d.type));
86
+ for (const plugin of config.plugins) {
87
+ if (!plugin.generators) continue;
88
+ for (const gen of plugin.generators) {
89
+ if (seen.has(gen.type)) continue;
90
+ defs.push({
91
+ type: gen.type,
92
+ directory: gen.directory,
93
+ templatePath: gen.templatePath,
94
+ testTemplatePath: gen.testTemplatePath,
95
+ });
96
+ seen.add(gen.type);
97
+ }
98
+ }
99
+ return defs;
100
+ }
101
+
102
+ /**
103
+ * Look up a component definition by type. Returns undefined when unknown.
104
+ */
105
+ export function getComponentDef(type: string): ComponentDef | undefined {
106
+ return getComponentDefs().find((d) => d.type === type);
107
+ }
108
+
109
+ /**
110
+ * Returns all valid generator type names (built-ins + plugin generators).
111
+ */
112
+ export function getValidTypes(): string[] {
113
+ return getComponentDefs().map((d) => d.type);
114
+ }
115
+
116
+ /**
117
+ * Convert a colon-separated name to PascalCase.
118
+ * e.g. "user:delete" → "UserDelete", "hello" → "Hello".
119
+ */
120
+ export function toClassName(name: string): string {
121
+ return name
122
+ .split(":")
123
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
124
+ .join("");
125
+ }
126
+
127
+ /**
128
+ * Derive a web route from a colon-separated action name.
129
+ * "hello" → "/hello", "user:delete" → "/user/delete".
130
+ */
131
+ export function toRoute(name: string): string {
132
+ return "/" + name.replace(/:/g, "/");
133
+ }
134
+
135
+ /**
136
+ * Resolve the component output path relative to the project root.
137
+ * Colon-separated names nest under subdirectories:
138
+ * "user:delete" with directory "actions" → "actions/user/delete.ts".
139
+ */
140
+ export function resolveComponentPath(def: ComponentDef, name: string): string {
141
+ const segments = name.split(":");
142
+ if (segments.length > 1) {
143
+ const fileName = segments.pop()!;
144
+ return path.join(def.directory, ...segments, `${fileName}.ts`);
145
+ }
146
+ return path.join(def.directory, `${name}.ts`);
147
+ }
148
+
149
+ /**
150
+ * Derive the test file path for a component path.
151
+ * "actions/user/delete.ts" → "__tests__/actions/user/delete.test.ts".
152
+ */
153
+ export function resolveTestPath(componentPath: string): string {
154
+ const parsed = path.parse(componentPath);
155
+ return path.join("__tests__", parsed.dir, `${parsed.name}.test.ts`);
156
+ }
157
+
158
+ /**
159
+ * Build the Mustache view for a component, applying the definition's
160
+ * class-suffix and route conventions.
161
+ */
162
+ export function buildComponentView(
163
+ def: ComponentDef,
164
+ name: string,
165
+ ): Record<string, string> {
166
+ const className = toClassName(name) + (def.classSuffix ?? "");
167
+ const view: Record<string, string> = { name, className };
168
+ if (def.includeRoute) view.route = toRoute(name);
169
+ return view;
170
+ }
171
+
172
+ /**
173
+ * Load a template from `packages/keryx/templates/generate/`.
174
+ */
175
+ export async function loadGenerateTemplate(name: string): Promise<string> {
176
+ return Bun.file(path.join(generateTemplatesDir, name)).text();
177
+ }
178
+
179
+ /**
180
+ * Load a template from `packages/keryx/templates/scaffold/`.
181
+ */
182
+ export async function loadScaffoldTemplate(name: string): Promise<string> {
183
+ return Bun.file(path.join(scaffoldTemplatesDir, name)).text();
184
+ }
package/util/generate.ts CHANGED
@@ -1,47 +1,16 @@
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
+ import {
5
+ buildComponentView,
6
+ getComponentDef,
7
+ getValidTypes,
8
+ loadGenerateTemplate,
9
+ resolveComponentPath,
10
+ resolveTestPath,
11
+ } from "./componentRegistry";
6
12
 
7
- const VALID_TYPES = [
8
- "action",
9
- "initializer",
10
- "middleware",
11
- "channel",
12
- "ops",
13
- "plugin",
14
- ] as const;
15
- type GeneratorType = (typeof VALID_TYPES)[number];
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
- }
13
+ export { getValidTypes } from "./componentRegistry";
45
14
 
46
15
  export interface GenerateOptions {
47
16
  dryRun?: boolean;
@@ -49,89 +18,6 @@ export interface GenerateOptions {
49
18
  noTest?: boolean;
50
19
  }
51
20
 
52
- const templatesDir = path.join(import.meta.dir, "..", "templates", "generate");
53
-
54
- async function loadTemplate(name: string): Promise<string> {
55
- return Bun.file(path.join(templatesDir, name)).text();
56
- }
57
-
58
- /**
59
- * Convert a colon-separated name to PascalCase class name.
60
- * e.g. "user:delete" → "UserDelete", "hello" → "Hello"
61
- */
62
- function toClassName(name: string): string {
63
- return name
64
- .split(":")
65
- .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
66
- .join("");
67
- }
68
-
69
- /**
70
- * Determine the directory and filename for a component type + name.
71
- * Actions with colons get nested: "user:delete" → "actions/user/delete.ts"
72
- * Others are flat: "cache" → "initializers/cache.ts"
73
- */
74
- function resolveFilePath(type: GeneratorType, name: string): string {
75
- const dirMap: Record<GeneratorType, string> = {
76
- action: "actions",
77
- initializer: "initializers",
78
- middleware: "middleware",
79
- channel: "channels",
80
- ops: "ops",
81
- plugin: "plugins",
82
- };
83
-
84
- const baseDir = dirMap[type];
85
- const segments = name.split(":");
86
-
87
- if (segments.length > 1) {
88
- // "user:delete" → "actions/user/delete.ts"
89
- const fileName = segments.pop()!;
90
- return path.join(baseDir, ...segments, `${fileName}.ts`);
91
- }
92
-
93
- return path.join(baseDir, `${name}.ts`);
94
- }
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
-
117
- /**
118
- * Determine the test file path for a component.
119
- * "user:delete" action → "__tests__/actions/user/delete.test.ts"
120
- */
121
- function resolveTestPath(type: GeneratorType, name: string): string {
122
- const componentPath = resolveFilePath(type, name);
123
- const parsed = path.parse(componentPath);
124
- return path.join("__tests__", parsed.dir, `${parsed.name}.test.ts`);
125
- }
126
-
127
- /**
128
- * Derive the web route from an action name.
129
- * "hello" → "/api/hello", "user:delete" → "/api/user/delete"
130
- */
131
- function toRoute(name: string): string {
132
- return "/" + name.replace(/:/g, "/");
133
- }
134
-
135
21
  /**
136
22
  * Generate a component file (and optionally a test file).
137
23
  * @param type The component type to generate
@@ -146,59 +32,26 @@ export async function generateComponent(
146
32
  rootDir: string,
147
33
  options: GenerateOptions = {},
148
34
  ): Promise<string[]> {
149
- const allValidTypes = getValidTypes();
150
- if (!allValidTypes.includes(type)) {
35
+ const def = getComponentDef(type);
36
+ if (!def) {
151
37
  throw new Error(
152
- `Unknown generator type "${type}". Valid types: ${allValidTypes.join(", ")}`,
38
+ `Unknown generator type "${type}". Valid types: ${getValidTypes().join(", ")}`,
153
39
  );
154
40
  }
155
41
 
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);
42
+ const filePath = resolveComponentPath(def, name);
164
43
  const fullPath = path.join(rootDir, filePath);
165
44
  const createdFiles: string[] = [];
166
45
 
167
- // Check for conflicts
168
46
  if (!options.force && fs.existsSync(fullPath)) {
169
47
  throw new Error(
170
48
  `File already exists: ${filePath}. Use --force to overwrite.`,
171
49
  );
172
50
  }
173
51
 
174
- // Build template view
175
- let className = toClassName(name);
176
- if (type === "middleware") className += "Middleware";
177
- if (type === "channel") className += "Channel";
178
- if (type === "plugin") className += "Plugin";
179
-
180
- const view: Record<string, string> = { name, className };
181
- if (type === "action") {
182
- view.route = toRoute(name);
183
- }
184
-
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
- }
52
+ const view = buildComponentView(def, name);
53
+ const template = await Bun.file(def.templatePath).text();
54
+ const content = Mustache.render(template, view);
202
55
 
203
56
  if (options.dryRun) {
204
57
  console.log(`Would create: ${filePath}`);
@@ -211,26 +64,17 @@ export async function generateComponent(
211
64
  createdFiles.push(filePath);
212
65
  }
213
66
 
214
- // Generate test file
215
67
  if (!options.noTest) {
216
- const testPath = pluginGen
217
- ? resolvePluginTestPath(pluginGen, name)
218
- : resolveTestPath(type as GeneratorType, name);
68
+ const testPath = resolveTestPath(filePath);
219
69
  const testFullPath = path.join(rootDir, testPath);
220
70
 
221
71
  if (!options.force && fs.existsSync(testFullPath)) {
222
72
  // Silently skip test file if it already exists
223
73
  } else {
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
- }
74
+ const testTemplate = def.testTemplatePath
75
+ ? await Bun.file(def.testTemplatePath).text()
76
+ : await loadGenerateTemplate("test.ts.mustache");
77
+ const testContent = Mustache.render(testTemplate, view);
234
78
 
235
79
  if (options.dryRun) {
236
80
  console.log(`Would create: ${testPath}`);
package/util/scaffold.ts CHANGED
@@ -4,18 +4,13 @@ import Mustache from "mustache";
4
4
  import path from "path";
5
5
  import * as readline from "readline";
6
6
  import pkg from "../package.json";
7
+ import { loadScaffoldTemplate as loadTemplate } from "./componentRegistry";
7
8
 
8
9
  export interface ScaffoldOptions {
9
10
  includeDb: boolean;
10
11
  includeExample: boolean;
11
12
  }
12
13
 
13
- const templatesDir = path.join(import.meta.dir, "..", "templates", "scaffold");
14
-
15
- async function loadTemplate(name: string): Promise<string> {
16
- return Bun.file(path.join(templatesDir, name)).text();
17
- }
18
-
19
14
  async function prompt(question: string, defaultValue: string): Promise<string> {
20
15
  const rl = readline.createInterface({
21
16
  input: process.stdin,