@geekmidas/cli 0.7.0 → 0.8.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.
@@ -0,0 +1,204 @@
1
+ import { mkdir, readFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { e } from '@geekmidas/constructs/endpoints';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import type { BuildContext } from '../build/types';
6
+ import { EndpointGenerator } from '../generators/EndpointGenerator';
7
+ import type { GeneratedConstruct } from '../generators/Generator';
8
+
9
+ // Create a minimal mock endpoint for testing
10
+ const mockEndpoint = e.get('/test').handle(async () => ({ ok: true }));
11
+
12
+ describe('EndpointGenerator hooks generation', () => {
13
+ const testOutputDir = join(process.cwd(), '.test-output');
14
+ let generator: EndpointGenerator;
15
+
16
+ // Mock endpoint construct for testing
17
+ const mockConstruct: GeneratedConstruct<typeof mockEndpoint> = {
18
+ key: 'testEndpoint',
19
+ construct: mockEndpoint,
20
+ path: {
21
+ relative: '/project/src/endpoints/test.ts',
22
+ absolute: '/project/src/endpoints/test.ts',
23
+ },
24
+ };
25
+
26
+ beforeEach(async () => {
27
+ generator = new EndpointGenerator();
28
+ await mkdir(testOutputDir, { recursive: true });
29
+ });
30
+
31
+ afterEach(async () => {
32
+ await rm(testOutputDir, { recursive: true, force: true });
33
+ });
34
+
35
+ const baseContext: BuildContext = {
36
+ envParserPath: '/project/src/config/env.ts',
37
+ envParserImportPattern: '{ envParser }',
38
+ loggerPath: '/project/src/config/logger.ts',
39
+ loggerImportPattern: 'logger',
40
+ };
41
+
42
+ describe('generateAppFile', () => {
43
+ it('should not include hooks when hooks config is undefined', async () => {
44
+ await generator.build(baseContext, [mockConstruct], testOutputDir, {
45
+ provider: 'server',
46
+ });
47
+
48
+ const appContent = await readFile(join(testOutputDir, 'app.ts'), 'utf-8');
49
+
50
+ expect(appContent).not.toContain('serverHooks');
51
+ expect(appContent).not.toContain('beforeSetup');
52
+ expect(appContent).not.toContain('afterSetup');
53
+ });
54
+
55
+ it('should include hooks imports when hooks config is provided', async () => {
56
+ const contextWithHooks: BuildContext = {
57
+ ...baseContext,
58
+ hooks: {
59
+ serverHooksPath: '/project/src/config/hooks.ts',
60
+ },
61
+ };
62
+
63
+ await generator.build(contextWithHooks, [mockConstruct], testOutputDir, {
64
+ provider: 'server',
65
+ });
66
+
67
+ const appContent = await readFile(join(testOutputDir, 'app.ts'), 'utf-8');
68
+
69
+ expect(appContent).toContain('import * as serverHooks from');
70
+ expect(appContent).toContain('hooks.ts');
71
+ });
72
+
73
+ it('should include beforeSetup hook call', async () => {
74
+ const contextWithHooks: BuildContext = {
75
+ ...baseContext,
76
+ hooks: {
77
+ serverHooksPath: '/project/src/config/hooks.ts',
78
+ },
79
+ };
80
+
81
+ await generator.build(contextWithHooks, [mockConstruct], testOutputDir, {
82
+ provider: 'server',
83
+ });
84
+
85
+ const appContent = await readFile(join(testOutputDir, 'app.ts'), 'utf-8');
86
+
87
+ expect(appContent).toContain(
88
+ "if (typeof serverHooks.beforeSetup === 'function')",
89
+ );
90
+ expect(appContent).toContain(
91
+ 'await serverHooks.beforeSetup(honoApp, { envParser, logger })',
92
+ );
93
+ });
94
+
95
+ it('should include afterSetup hook call', async () => {
96
+ const contextWithHooks: BuildContext = {
97
+ ...baseContext,
98
+ hooks: {
99
+ serverHooksPath: '/project/src/config/hooks.ts',
100
+ },
101
+ };
102
+
103
+ await generator.build(contextWithHooks, [mockConstruct], testOutputDir, {
104
+ provider: 'server',
105
+ });
106
+
107
+ const appContent = await readFile(join(testOutputDir, 'app.ts'), 'utf-8');
108
+
109
+ expect(appContent).toContain(
110
+ "if (typeof serverHooks.afterSetup === 'function')",
111
+ );
112
+ expect(appContent).toContain(
113
+ 'await serverHooks.afterSetup(honoApp, { envParser, logger })',
114
+ );
115
+ });
116
+
117
+ it('should place telescope before beforeSetup, and beforeSetup before endpoints', async () => {
118
+ const contextWithHooks: BuildContext = {
119
+ ...baseContext,
120
+ hooks: {
121
+ serverHooksPath: '/project/src/config/hooks.ts',
122
+ },
123
+ telescope: {
124
+ enabled: true,
125
+ path: '/__telescope',
126
+ maxEntries: 100,
127
+ recordBody: true,
128
+ ignore: [],
129
+ websocket: false,
130
+ },
131
+ };
132
+
133
+ await generator.build(contextWithHooks, [mockConstruct], testOutputDir, {
134
+ provider: 'server',
135
+ });
136
+
137
+ const appContent = await readFile(join(testOutputDir, 'app.ts'), 'utf-8');
138
+
139
+ // Use specific patterns to find the actual calls (not imports/comments)
140
+ const telescopeIndex = appContent.indexOf('createMiddleware(telescope)');
141
+ const beforeSetupCallIndex = appContent.indexOf(
142
+ 'serverHooks.beforeSetup(honoApp',
143
+ );
144
+ const setupEndpointsIndex = appContent.indexOf(
145
+ 'await setupEndpoints(honoApp',
146
+ );
147
+
148
+ expect(telescopeIndex).toBeGreaterThan(0);
149
+ expect(beforeSetupCallIndex).toBeGreaterThan(0);
150
+ expect(setupEndpointsIndex).toBeGreaterThan(0);
151
+ // Telescope middleware first (to capture all requests)
152
+ expect(telescopeIndex).toBeLessThan(beforeSetupCallIndex);
153
+ // Then beforeSetup hook
154
+ expect(beforeSetupCallIndex).toBeLessThan(setupEndpointsIndex);
155
+ });
156
+
157
+ it('should place afterSetup after setupEndpoints', async () => {
158
+ const contextWithHooks: BuildContext = {
159
+ ...baseContext,
160
+ hooks: {
161
+ serverHooksPath: '/project/src/config/hooks.ts',
162
+ },
163
+ };
164
+
165
+ await generator.build(contextWithHooks, [mockConstruct], testOutputDir, {
166
+ provider: 'server',
167
+ });
168
+
169
+ const appContent = await readFile(join(testOutputDir, 'app.ts'), 'utf-8');
170
+
171
+ // Use specific patterns to find the actual calls (not imports/comments)
172
+ const setupEndpointsIndex = appContent.indexOf(
173
+ 'await setupEndpoints(honoApp',
174
+ );
175
+ const afterSetupCallIndex = appContent.indexOf(
176
+ 'serverHooks.afterSetup(honoApp',
177
+ );
178
+
179
+ expect(setupEndpointsIndex).toBeGreaterThan(0);
180
+ expect(afterSetupCallIndex).toBeGreaterThan(0);
181
+ expect(afterSetupCallIndex).toBeGreaterThan(setupEndpointsIndex);
182
+ });
183
+
184
+ it('should generate correct relative import path for hooks', async () => {
185
+ const contextWithHooks: BuildContext = {
186
+ ...baseContext,
187
+ hooks: {
188
+ serverHooksPath: join(testOutputDir, '../../src/config/hooks.ts'),
189
+ },
190
+ };
191
+
192
+ await generator.build(contextWithHooks, [mockConstruct], testOutputDir, {
193
+ provider: 'server',
194
+ });
195
+
196
+ const appContent = await readFile(join(testOutputDir, 'app.ts'), 'utf-8');
197
+
198
+ // Should have a relative import path
199
+ expect(appContent).toMatch(
200
+ /import \* as serverHooks from '\.\.\/.*hooks\.ts'/,
201
+ );
202
+ });
203
+ });
204
+ });
@@ -0,0 +1,63 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { normalizeHooksConfig } from '../dev';
3
+
4
+ describe('normalizeHooksConfig', () => {
5
+ const originalCwd = process.cwd();
6
+
7
+ beforeEach(() => {
8
+ vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ it('should return undefined when hooks config is undefined', () => {
16
+ const result = normalizeHooksConfig(undefined);
17
+ expect(result).toBeUndefined();
18
+ });
19
+
20
+ it('should return undefined when hooks config is empty object', () => {
21
+ const result = normalizeHooksConfig({});
22
+ expect(result).toBeUndefined();
23
+ });
24
+
25
+ it('should return undefined when server hooks path is not provided', () => {
26
+ const result = normalizeHooksConfig({ server: undefined });
27
+ expect(result).toBeUndefined();
28
+ });
29
+
30
+ it('should normalize path with .ts extension', () => {
31
+ const result = normalizeHooksConfig({ server: './src/hooks.ts' });
32
+
33
+ expect(result).toBeDefined();
34
+ expect(result!.serverHooksPath).toBe('/test/project/src/hooks.ts');
35
+ });
36
+
37
+ it('should add .ts extension when missing', () => {
38
+ const result = normalizeHooksConfig({ server: './src/hooks' });
39
+
40
+ expect(result).toBeDefined();
41
+ expect(result!.serverHooksPath).toBe('/test/project/src/hooks.ts');
42
+ });
43
+
44
+ it('should resolve relative paths from cwd', () => {
45
+ const result = normalizeHooksConfig({ server: 'config/server-hooks' });
46
+
47
+ expect(result).toBeDefined();
48
+ expect(result!.serverHooksPath).toBe(
49
+ '/test/project/config/server-hooks.ts',
50
+ );
51
+ });
52
+
53
+ it('should handle nested directory paths', () => {
54
+ const result = normalizeHooksConfig({
55
+ server: './src/config/hooks/server',
56
+ });
57
+
58
+ expect(result).toBeDefined();
59
+ expect(result!.serverHooksPath).toBe(
60
+ '/test/project/src/config/hooks/server.ts',
61
+ );
62
+ });
63
+ });
@@ -5,7 +5,7 @@ import type { Endpoint } from '@geekmidas/constructs/endpoints';
5
5
  import type { Function } from '@geekmidas/constructs/functions';
6
6
  import type { Subscriber } from '@geekmidas/constructs/subscribers';
7
7
  import { loadConfig, parseModuleConfig } from '../config';
8
- import { normalizeTelescopeConfig } from '../dev';
8
+ import { normalizeHooksConfig, normalizeTelescopeConfig } from '../dev';
9
9
  import {
10
10
  CronGenerator,
11
11
  EndpointGenerator,
@@ -55,12 +55,19 @@ export async function buildCommand(options: BuildOptions): Promise<void> {
55
55
  logger.log(`🔭 Telescope enabled at ${telescope.path}`);
56
56
  }
57
57
 
58
+ // Normalize hooks configuration
59
+ const hooks = normalizeHooksConfig(config.hooks);
60
+ if (hooks) {
61
+ logger.log(`🪝 Server hooks enabled`);
62
+ }
63
+
58
64
  const buildContext: BuildContext = {
59
65
  envParserPath,
60
66
  envParserImportPattern,
61
67
  loggerPath,
62
68
  loggerImportPattern,
63
69
  telescope,
70
+ hooks,
64
71
  };
65
72
 
66
73
  // Initialize generators
@@ -50,6 +50,11 @@ export interface NormalizedStudioConfig {
50
50
  schema: string;
51
51
  }
52
52
 
53
+ export interface NormalizedHooksConfig {
54
+ /** Path to server hooks module */
55
+ serverHooksPath: string;
56
+ }
57
+
53
58
  export interface BuildContext {
54
59
  envParserPath: string;
55
60
  envParserImportPattern: string;
@@ -57,6 +62,7 @@ export interface BuildContext {
57
62
  loggerImportPattern: string;
58
63
  telescope?: NormalizedTelescopeConfig;
59
64
  studio?: NormalizedStudioConfig;
65
+ hooks?: NormalizedHooksConfig;
60
66
  }
61
67
 
62
68
  export interface ProviderBuildResult {
package/src/dev/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ChildProcess, spawn } from 'node:child_process';
1
+ import { type ChildProcess, execSync, spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { mkdir } from 'node:fs/promises';
4
4
  import { createServer } from 'node:net';
@@ -9,6 +9,7 @@ import fg from 'fast-glob';
9
9
  import { resolveProviders } from '../build/providerResolver';
10
10
  import type {
11
11
  BuildContext,
12
+ NormalizedHooksConfig,
12
13
  NormalizedStudioConfig,
13
14
  NormalizedTelescopeConfig,
14
15
  } from '../build/types';
@@ -19,7 +20,11 @@ import {
19
20
  FunctionGenerator,
20
21
  SubscriberGenerator,
21
22
  } from '../generators';
22
- import { generateOpenApi, resolveOpenApiConfig } from '../openapi';
23
+ import {
24
+ OPENAPI_OUTPUT_PATH,
25
+ generateOpenApi,
26
+ resolveOpenApiConfig,
27
+ } from '../openapi';
23
28
  import type {
24
29
  GkmConfig,
25
30
  LegacyProvider,
@@ -200,6 +205,29 @@ export function normalizeStudioConfig(
200
205
  };
201
206
  }
202
207
 
208
+ /**
209
+ * Normalize hooks configuration
210
+ * @internal Exported for testing
211
+ */
212
+ export function normalizeHooksConfig(
213
+ config: GkmConfig['hooks'],
214
+ ): NormalizedHooksConfig | undefined {
215
+ if (!config?.server) {
216
+ return undefined;
217
+ }
218
+
219
+ // Resolve the path (handle .ts extension)
220
+ const serverPath = config.server.endsWith('.ts')
221
+ ? config.server
222
+ : `${config.server}.ts`;
223
+
224
+ const resolvedPath = resolve(process.cwd(), serverPath);
225
+
226
+ return {
227
+ serverHooksPath: resolvedPath,
228
+ };
229
+ }
230
+
203
231
  export interface DevOptions {
204
232
  port?: number;
205
233
  enableOpenApi?: boolean;
@@ -260,12 +288,18 @@ export async function devCommand(options: DevOptions): Promise<void> {
260
288
  logger.log(`🗄️ Studio enabled at ${studio.path}`);
261
289
  }
262
290
 
291
+ // Normalize hooks configuration
292
+ const hooks = normalizeHooksConfig(config.hooks);
293
+ if (hooks) {
294
+ logger.log(`🪝 Server hooks enabled from ${config.hooks?.server}`);
295
+ }
296
+
263
297
  // Resolve OpenAPI configuration
264
298
  const openApiConfig = resolveOpenApiConfig(config);
265
299
  // Enable OpenAPI docs endpoint if either root config or provider config enables it
266
300
  const enableOpenApi = openApiConfig.enabled || resolved.enableOpenApi;
267
301
  if (enableOpenApi) {
268
- logger.log(`📄 OpenAPI output: ${openApiConfig.output}`);
302
+ logger.log(`📄 OpenAPI output: ${OPENAPI_OUTPUT_PATH}`);
269
303
  }
270
304
 
271
305
  const buildContext: BuildContext = {
@@ -275,6 +309,7 @@ export async function devCommand(options: DevOptions): Promise<void> {
275
309
  loggerImportPattern,
276
310
  telescope,
277
311
  studio,
312
+ hooks,
278
313
  };
279
314
 
280
315
  // Build initial version
@@ -309,6 +344,9 @@ export async function devCommand(options: DevOptions): Promise<void> {
309
344
  const envParserFile = config.envParser.split('#')[0];
310
345
  const loggerFile = config.logger.split('#')[0];
311
346
 
347
+ // Get hooks file path for watching
348
+ const hooksFile = config.hooks?.server?.split('#')[0];
349
+
312
350
  const watchPatterns = [
313
351
  config.routes,
314
352
  ...(config.functions ? [config.functions] : []),
@@ -317,6 +355,10 @@ export async function devCommand(options: DevOptions): Promise<void> {
317
355
  // Add .ts extension if not present for config files
318
356
  envParserFile.endsWith('.ts') ? envParserFile : `${envParserFile}.ts`,
319
357
  loggerFile.endsWith('.ts') ? loggerFile : `${loggerFile}.ts`,
358
+ // Add hooks file to watch list
359
+ ...(hooksFile
360
+ ? [hooksFile.endsWith('.ts') ? hooksFile : `${hooksFile}.ts`]
361
+ : []),
320
362
  ].flat();
321
363
 
322
364
  // Normalize patterns - remove leading ./ when using cwd option
@@ -391,11 +433,21 @@ export async function devCommand(options: DevOptions): Promise<void> {
391
433
  });
392
434
 
393
435
  // Handle graceful shutdown
394
- const shutdown = async () => {
436
+ let isShuttingDown = false;
437
+ const shutdown = () => {
438
+ if (isShuttingDown) return;
439
+ isShuttingDown = true;
440
+
395
441
  logger.log('\n🛑 Shutting down...');
396
- await watcher.close();
397
- await devServer.stop();
398
- process.exit(0);
442
+
443
+ // Use sync-style shutdown to ensure it completes before exit
444
+ Promise.all([watcher.close(), devServer.stop()])
445
+ .catch((err) => {
446
+ logger.error('Error during shutdown:', err);
447
+ })
448
+ .finally(() => {
449
+ process.exit(0);
450
+ });
399
451
  };
400
452
 
401
453
  process.on('SIGINT', shutdown);
@@ -530,40 +582,41 @@ class DevServer {
530
582
  }
531
583
 
532
584
  async stop(): Promise<void> {
585
+ const port = this.actualPort;
586
+
533
587
  if (this.serverProcess && this.isRunning) {
534
588
  const pid = this.serverProcess.pid;
535
589
 
536
- // Kill the entire process group (negative PID kills the group)
590
+ // Use SIGKILL directly since the server ignores SIGTERM
537
591
  if (pid) {
538
592
  try {
539
- process.kill(-pid, 'SIGTERM');
593
+ process.kill(-pid, 'SIGKILL');
540
594
  } catch {
541
- // Process might already be dead
595
+ try {
596
+ process.kill(pid, 'SIGKILL');
597
+ } catch {
598
+ // Process might already be dead
599
+ }
542
600
  }
543
601
  }
544
602
 
545
- // Wait for process to exit
546
- await new Promise<void>((resolve) => {
547
- const timeout = setTimeout(() => {
548
- if (pid) {
549
- try {
550
- process.kill(-pid, 'SIGKILL');
551
- } catch {
552
- // Process might already be dead
553
- }
554
- }
555
- resolve();
556
- }, 3000);
557
-
558
- this.serverProcess?.on('exit', () => {
559
- clearTimeout(timeout);
560
- resolve();
561
- });
562
- });
563
-
564
603
  this.serverProcess = null;
565
604
  this.isRunning = false;
566
605
  }
606
+
607
+ // Also kill any processes still holding the port
608
+ this.killProcessesOnPort(port);
609
+ }
610
+
611
+ private killProcessesOnPort(port: number): void {
612
+ try {
613
+ // Use lsof to find PIDs on the port and kill them with -9
614
+ execSync(`lsof -ti tcp:${port} | xargs kill -9 2>/dev/null || true`, {
615
+ stdio: 'ignore',
616
+ });
617
+ } catch {
618
+ // Ignore errors - port may already be free
619
+ }
567
620
  }
568
621
 
569
622
  async restart(): Promise<void> {
@@ -384,6 +384,30 @@ import { createStudioApp } from '@geekmidas/studio/server/hono';`;
384
384
  }
385
385
  }
386
386
 
387
+ // Generate imports for server hooks
388
+ let hooksImports = '';
389
+ let beforeSetupCall = '';
390
+ let afterSetupCall = '';
391
+ if (context.hooks?.serverHooksPath) {
392
+ const relativeHooksPath = relative(
393
+ dirname(appPath),
394
+ context.hooks.serverHooksPath,
395
+ );
396
+ hooksImports = `import * as serverHooks from '${relativeHooksPath}';`;
397
+ beforeSetupCall = `
398
+ // Call beforeSetup hook if defined
399
+ if (typeof serverHooks.beforeSetup === 'function') {
400
+ await serverHooks.beforeSetup(honoApp, { envParser, logger });
401
+ }
402
+ `;
403
+ afterSetupCall = `
404
+ // Call afterSetup hook if defined
405
+ if (typeof serverHooks.afterSetup === 'function') {
406
+ await serverHooks.afterSetup(honoApp, { envParser, logger });
407
+ }
408
+ `;
409
+ }
410
+
387
411
  const telescopeWebSocketSetupCode = telescopeWebSocketEnabled
388
412
  ? `
389
413
  // Setup WebSocket for real-time telescope updates
@@ -480,6 +504,7 @@ import ${context.envParserImportPattern} from '${relativeEnvParserPath}';
480
504
  import ${context.loggerImportPattern} from '${relativeLoggerPath}';
481
505
  ${telescopeImports}
482
506
  ${studioImports}
507
+ ${hooksImports}
483
508
 
484
509
  export interface ServerApp {
485
510
  app: HonoType;
@@ -525,9 +550,10 @@ export interface ServerApp {
525
550
  */
526
551
  export async function createApp(app?: HonoType, enableOpenApi: boolean = true): Promise<ServerApp> {
527
552
  const honoApp = app || new Hono();
528
- ${telescopeSetup}${studioSetup}
553
+ ${telescopeSetup}${beforeSetupCall}${studioSetup}
529
554
  // Setup HTTP endpoints
530
555
  await setupEndpoints(honoApp, envParser, logger, enableOpenApi);
556
+ ${afterSetupCall}
531
557
 
532
558
  return {
533
559
  app: honoApp,
package/src/types.ts CHANGED
@@ -64,6 +64,34 @@ export interface OpenApiConfig {
64
64
  description?: string;
65
65
  }
66
66
 
67
+ export interface HooksConfig {
68
+ /**
69
+ * Path to a module exporting server lifecycle hooks.
70
+ * The module should export `beforeSetup` and/or `afterSetup` functions.
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * // src/config/hooks.ts
75
+ * import type { Hono } from 'hono';
76
+ * import type { Logger } from '@geekmidas/logger';
77
+ * import type { EnvironmentParser } from '@geekmidas/envkit';
78
+ *
79
+ * // Called BEFORE gkm endpoints are registered
80
+ * export function beforeSetup(app: Hono, ctx: { envParser: EnvironmentParser; logger: Logger }) {
81
+ * app.use('*', cors());
82
+ * app.get('/custom/health', (c) => c.json({ status: 'ok' }));
83
+ * }
84
+ *
85
+ * // Called AFTER gkm endpoints are registered
86
+ * export function afterSetup(app: Hono, ctx: { envParser: EnvironmentParser; logger: Logger }) {
87
+ * app.notFound((c) => c.json({ error: 'Not found' }, 404));
88
+ * app.onError((err, c) => c.json({ error: err.message }, 500));
89
+ * }
90
+ * ```
91
+ */
92
+ server?: string;
93
+ }
94
+
67
95
  export interface ProvidersConfig {
68
96
  aws?: {
69
97
  apiGateway?: {
@@ -86,6 +114,16 @@ export interface GkmConfig {
86
114
  envParser: string;
87
115
  logger: string;
88
116
  providers?: ProvidersConfig;
117
+ /**
118
+ * Server lifecycle hooks for customizing the Hono app.
119
+ * Allows adding custom routes, middleware, error handlers, etc.
120
+ *
121
+ * @example
122
+ * hooks: {
123
+ * server: './src/config/hooks'
124
+ * }
125
+ */
126
+ hooks?: HooksConfig;
89
127
  /**
90
128
  * Telescope configuration for debugging/monitoring.
91
129
  * Can be: