@geekmidas/cli 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs",
5
5
  "private": false,
6
6
  "type": "module",
@@ -48,11 +48,11 @@
48
48
  "lodash.kebabcase": "^4.1.1",
49
49
  "openapi-typescript": "^7.4.2",
50
50
  "prompts": "~2.4.2",
51
+ "@geekmidas/constructs": "~0.6.0",
51
52
  "@geekmidas/errors": "~0.1.0",
52
53
  "@geekmidas/logger": "~0.4.0",
53
- "@geekmidas/constructs": "~0.6.0",
54
- "@geekmidas/envkit": "~0.5.0",
55
- "@geekmidas/schema": "~0.1.0"
54
+ "@geekmidas/schema": "~0.1.0",
55
+ "@geekmidas/envkit": "~0.5.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/lodash.kebabcase": "^4.1.9",
@@ -0,0 +1,140 @@
1
+ import { mkdir, readFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { createEntryWrapper, findSecretsRoot } from '../index';
6
+
7
+ describe('findSecretsRoot', () => {
8
+ let testDir: string;
9
+
10
+ beforeEach(async () => {
11
+ testDir = join(tmpdir(), `gkm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
12
+ await mkdir(testDir, { recursive: true });
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await rm(testDir, { recursive: true, force: true });
17
+ });
18
+
19
+ it('should return startDir when no .gkm/secrets exists', () => {
20
+ const result = findSecretsRoot(testDir);
21
+ expect(result).toBe(testDir);
22
+ });
23
+
24
+ it('should find secrets in current directory', async () => {
25
+ await mkdir(join(testDir, '.gkm', 'secrets'), { recursive: true });
26
+
27
+ const result = findSecretsRoot(testDir);
28
+ expect(result).toBe(testDir);
29
+ });
30
+
31
+ it('should find secrets in parent directory', async () => {
32
+ const childDir = join(testDir, 'apps', 'auth');
33
+ await mkdir(childDir, { recursive: true });
34
+ await mkdir(join(testDir, '.gkm', 'secrets'), { recursive: true });
35
+
36
+ const result = findSecretsRoot(childDir);
37
+ expect(result).toBe(testDir);
38
+ });
39
+
40
+ it('should find secrets in grandparent directory', async () => {
41
+ const grandchildDir = join(testDir, 'apps', 'auth', 'src');
42
+ await mkdir(grandchildDir, { recursive: true });
43
+ await mkdir(join(testDir, '.gkm', 'secrets'), { recursive: true });
44
+
45
+ const result = findSecretsRoot(grandchildDir);
46
+ expect(result).toBe(testDir);
47
+ });
48
+
49
+ it('should prefer closer secrets directory', async () => {
50
+ // Create secrets in both parent and child
51
+ const childDir = join(testDir, 'apps', 'auth');
52
+ await mkdir(join(testDir, '.gkm', 'secrets'), { recursive: true });
53
+ await mkdir(join(childDir, '.gkm', 'secrets'), { recursive: true });
54
+
55
+ const result = findSecretsRoot(childDir);
56
+ expect(result).toBe(childDir);
57
+ });
58
+ });
59
+
60
+ describe('createEntryWrapper', () => {
61
+ let testDir: string;
62
+
63
+ beforeEach(async () => {
64
+ testDir = join(tmpdir(), `gkm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
65
+ await mkdir(testDir, { recursive: true });
66
+ });
67
+
68
+ afterEach(async () => {
69
+ await rm(testDir, { recursive: true, force: true });
70
+ });
71
+
72
+ it('should create wrapper without secrets injection', async () => {
73
+ const wrapperPath = join(testDir, 'wrapper.ts');
74
+ const entryPath = '/path/to/entry.ts';
75
+
76
+ await createEntryWrapper(wrapperPath, entryPath, undefined);
77
+
78
+ const content = await readFile(wrapperPath, 'utf-8');
79
+
80
+ expect(content).toContain("import '/path/to/entry.ts'");
81
+ expect(content).not.toContain('Credentials');
82
+ expect(content).toContain("Entry wrapper generated by 'gkm dev --entry'");
83
+ });
84
+
85
+ it('should create wrapper with secrets injection', async () => {
86
+ const wrapperPath = join(testDir, 'wrapper.ts');
87
+ const entryPath = '/path/to/entry.ts';
88
+ const secretsPath = '/path/to/secrets.json';
89
+
90
+ await createEntryWrapper(wrapperPath, entryPath, secretsPath);
91
+
92
+ const content = await readFile(wrapperPath, 'utf-8');
93
+
94
+ expect(content).toContain(
95
+ "import { Credentials } from '@geekmidas/envkit/credentials'",
96
+ );
97
+ expect(content).toContain(secretsPath);
98
+ expect(content).toContain('Object.assign(Credentials');
99
+ expect(content).toContain("import '/path/to/entry.ts'");
100
+ });
101
+
102
+ it('should inject secrets before entry import', async () => {
103
+ const wrapperPath = join(testDir, 'wrapper.ts');
104
+ const entryPath = '/path/to/entry.ts';
105
+ const secretsPath = '/path/to/secrets.json';
106
+
107
+ await createEntryWrapper(wrapperPath, entryPath, secretsPath);
108
+
109
+ const content = await readFile(wrapperPath, 'utf-8');
110
+
111
+ const credentialsIndex = content.indexOf('Credentials');
112
+ const importIndex = content.indexOf("import '/path/to/entry.ts'");
113
+
114
+ expect(credentialsIndex).toBeGreaterThan(-1);
115
+ expect(importIndex).toBeGreaterThan(-1);
116
+ expect(credentialsIndex).toBeLessThan(importIndex);
117
+ });
118
+
119
+ it('should include shebang line', async () => {
120
+ const wrapperPath = join(testDir, 'wrapper.ts');
121
+ const entryPath = '/path/to/entry.ts';
122
+
123
+ await createEntryWrapper(wrapperPath, entryPath, undefined);
124
+
125
+ const content = await readFile(wrapperPath, 'utf-8');
126
+
127
+ expect(content.startsWith('#!/usr/bin/env node')).toBe(true);
128
+ });
129
+
130
+ it('should handle Windows-style paths', async () => {
131
+ const wrapperPath = join(testDir, 'wrapper.ts');
132
+ const entryPath = 'C:\\Users\\test\\project\\src\\index.ts';
133
+
134
+ await createEntryWrapper(wrapperPath, entryPath, undefined);
135
+
136
+ const content = await readFile(wrapperPath, 'utf-8');
137
+
138
+ expect(content).toContain(entryPath);
139
+ });
140
+ });
package/src/dev/index.ts CHANGED
@@ -2,7 +2,7 @@ import { type ChildProcess, execSync, spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { mkdir, writeFile } from 'node:fs/promises';
4
4
  import { createServer } from 'node:net';
5
- import { join, resolve } from 'node:path';
5
+ import { dirname, join, resolve } from 'node:path';
6
6
  import chokidar from 'chokidar';
7
7
  import { config as dotenvConfig } from 'dotenv';
8
8
  import fg from 'fast-glob';
@@ -304,9 +304,18 @@ export interface DevOptions {
304
304
  app?: string;
305
305
  /** Filter apps by pattern (passed to turbo --filter) */
306
306
  filter?: string;
307
+ /** Entry file to run (bypasses gkm config) */
308
+ entry?: string;
309
+ /** Watch for file changes (default: true with --entry) */
310
+ watch?: boolean;
307
311
  }
308
312
 
309
313
  export async function devCommand(options: DevOptions): Promise<void> {
314
+ // Handle --entry mode: run any file with secret injection
315
+ if (options.entry) {
316
+ return entryDevCommand(options);
317
+ }
318
+
310
319
  // Load default .env file BEFORE loading config
311
320
  // This ensures env vars are available when config and its dependencies are loaded
312
321
  const defaultEnv = loadEnvFiles('.env');
@@ -1242,6 +1251,242 @@ async function buildServer(
1242
1251
  ]);
1243
1252
  }
1244
1253
 
1254
+ /**
1255
+ * Find the directory containing .gkm/secrets/.
1256
+ * Walks up from cwd until it finds one, or returns cwd.
1257
+ * @internal Exported for testing
1258
+ */
1259
+ export function findSecretsRoot(startDir: string): string {
1260
+ let dir = startDir;
1261
+ while (dir !== '/') {
1262
+ if (existsSync(join(dir, '.gkm', 'secrets'))) {
1263
+ return dir;
1264
+ }
1265
+ const parent = dirname(dir);
1266
+ if (parent === dir) break;
1267
+ dir = parent;
1268
+ }
1269
+ return startDir;
1270
+ }
1271
+
1272
+ /**
1273
+ * Create a wrapper script that injects secrets before importing the entry file.
1274
+ * @internal Exported for testing
1275
+ */
1276
+ export async function createEntryWrapper(
1277
+ wrapperPath: string,
1278
+ entryPath: string,
1279
+ secretsJsonPath?: string,
1280
+ ): Promise<void> {
1281
+ const credentialsInjection = secretsJsonPath
1282
+ ? `import { Credentials } from '@geekmidas/envkit/credentials';
1283
+ import { existsSync, readFileSync } from 'node:fs';
1284
+
1285
+ // Inject dev secrets into Credentials (before app import)
1286
+ const secretsPath = '${secretsJsonPath}';
1287
+ if (existsSync(secretsPath)) {
1288
+ Object.assign(Credentials, JSON.parse(readFileSync(secretsPath, 'utf-8')));
1289
+ }
1290
+
1291
+ `
1292
+ : '';
1293
+
1294
+ const content = `#!/usr/bin/env node
1295
+ /**
1296
+ * Entry wrapper generated by 'gkm dev --entry'
1297
+ */
1298
+ ${credentialsInjection}// Import and run the user's entry file
1299
+ import '${entryPath}';
1300
+ `;
1301
+
1302
+ await writeFile(wrapperPath, content);
1303
+ }
1304
+
1305
+ /**
1306
+ * Run any TypeScript file with secret injection.
1307
+ * Does not require gkm.config.ts.
1308
+ */
1309
+ async function entryDevCommand(options: DevOptions): Promise<void> {
1310
+ const { entry, port = 3000, watch = true } = options;
1311
+
1312
+ if (!entry) {
1313
+ throw new Error('--entry requires a file path');
1314
+ }
1315
+
1316
+ const entryPath = resolve(process.cwd(), entry);
1317
+
1318
+ if (!existsSync(entryPath)) {
1319
+ throw new Error(`Entry file not found: ${entryPath}`);
1320
+ }
1321
+
1322
+ logger.log(`šŸš€ Starting entry file: ${entry}`);
1323
+
1324
+ // Load .env files
1325
+ const defaultEnv = loadEnvFiles('.env');
1326
+ if (defaultEnv.loaded.length > 0) {
1327
+ logger.log(`šŸ“¦ Loaded env: ${defaultEnv.loaded.join(', ')}`);
1328
+ }
1329
+
1330
+ // Determine secrets root (current dir or workspace root)
1331
+ const secretsRoot = findSecretsRoot(process.cwd());
1332
+
1333
+ // Determine app name for per-app secret mapping
1334
+ const appName = getAppNameFromCwd() ?? undefined;
1335
+
1336
+ // Load secrets
1337
+ const appSecrets = await loadSecretsForApp(secretsRoot, appName);
1338
+ if (Object.keys(appSecrets).length > 0) {
1339
+ logger.log(`šŸ” Loaded ${Object.keys(appSecrets).length} secret(s)`);
1340
+ }
1341
+
1342
+ // Write secrets to temp JSON file
1343
+ let secretsJsonPath: string | undefined;
1344
+ if (Object.keys(appSecrets).length > 0) {
1345
+ const secretsDir = join(secretsRoot, '.gkm');
1346
+ await mkdir(secretsDir, { recursive: true });
1347
+ secretsJsonPath = join(secretsDir, 'dev-secrets.json');
1348
+ await writeFile(secretsJsonPath, JSON.stringify(appSecrets, null, 2));
1349
+ }
1350
+
1351
+ // Create wrapper entry that injects secrets before importing user's file
1352
+ const wrapperDir = join(process.cwd(), '.gkm');
1353
+ await mkdir(wrapperDir, { recursive: true });
1354
+ const wrapperPath = join(wrapperDir, 'entry-wrapper.ts');
1355
+ await createEntryWrapper(wrapperPath, entryPath, secretsJsonPath);
1356
+
1357
+ // Start with tsx
1358
+ const runner = new EntryRunner(wrapperPath, entryPath, watch, port);
1359
+ await runner.start();
1360
+
1361
+ // Handle graceful shutdown
1362
+ let isShuttingDown = false;
1363
+ const shutdown = () => {
1364
+ if (isShuttingDown) return;
1365
+ isShuttingDown = true;
1366
+
1367
+ logger.log('\nšŸ›‘ Shutting down...');
1368
+ runner.stop();
1369
+ process.exit(0);
1370
+ };
1371
+
1372
+ process.on('SIGINT', shutdown);
1373
+ process.on('SIGTERM', shutdown);
1374
+
1375
+ // Keep the process alive
1376
+ await new Promise(() => {});
1377
+ }
1378
+
1379
+ /**
1380
+ * Runs and watches a TypeScript entry file using tsx.
1381
+ */
1382
+ class EntryRunner {
1383
+ private childProcess: ChildProcess | null = null;
1384
+ private watcher: ReturnType<typeof chokidar.watch> | null = null;
1385
+ private isRunning = false;
1386
+
1387
+ constructor(
1388
+ private wrapperPath: string,
1389
+ private entryPath: string,
1390
+ private watch: boolean,
1391
+ private port: number,
1392
+ ) {}
1393
+
1394
+ async start(): Promise<void> {
1395
+ await this.runProcess();
1396
+
1397
+ if (this.watch) {
1398
+ // Watch the entry file's directory for changes
1399
+ const watchDir = dirname(this.entryPath);
1400
+
1401
+ this.watcher = chokidar.watch(watchDir, {
1402
+ ignored: /(^|[/\\])\../,
1403
+ persistent: true,
1404
+ ignoreInitial: true,
1405
+ });
1406
+
1407
+ let restartTimeout: NodeJS.Timeout | null = null;
1408
+
1409
+ this.watcher.on('change', (path) => {
1410
+ logger.log(`šŸ“ File changed: ${path}`);
1411
+
1412
+ // Debounce restarts
1413
+ if (restartTimeout) {
1414
+ clearTimeout(restartTimeout);
1415
+ }
1416
+
1417
+ restartTimeout = setTimeout(async () => {
1418
+ logger.log('šŸ”„ Restarting...');
1419
+ await this.restart();
1420
+ }, 300);
1421
+ });
1422
+
1423
+ logger.log(`šŸ‘€ Watching for changes in: ${watchDir}`);
1424
+ }
1425
+ }
1426
+
1427
+ private async runProcess(): Promise<void> {
1428
+ // Pass PORT as environment variable
1429
+ const env = { ...process.env, PORT: String(this.port) };
1430
+
1431
+ this.childProcess = spawn('npx', ['tsx', this.wrapperPath], {
1432
+ stdio: 'inherit',
1433
+ env,
1434
+ detached: true,
1435
+ });
1436
+
1437
+ this.isRunning = true;
1438
+
1439
+ this.childProcess.on('error', (error) => {
1440
+ logger.error('āŒ Process error:', error);
1441
+ });
1442
+
1443
+ this.childProcess.on('exit', (code) => {
1444
+ if (code !== null && code !== 0 && code !== 143) {
1445
+ // 143 = SIGTERM
1446
+ logger.error(`āŒ Process exited with code ${code}`);
1447
+ }
1448
+ this.isRunning = false;
1449
+ });
1450
+
1451
+ // Give the process a moment to start
1452
+ await new Promise((resolve) => setTimeout(resolve, 500));
1453
+
1454
+ if (this.isRunning) {
1455
+ logger.log(`\nšŸŽ‰ Running at http://localhost:${this.port}`);
1456
+ }
1457
+ }
1458
+
1459
+ async restart(): Promise<void> {
1460
+ this.stopProcess();
1461
+ await new Promise((resolve) => setTimeout(resolve, 500));
1462
+ await this.runProcess();
1463
+ }
1464
+
1465
+ stop(): void {
1466
+ this.watcher?.close();
1467
+ this.stopProcess();
1468
+ }
1469
+
1470
+ private stopProcess(): void {
1471
+ if (this.childProcess && this.isRunning) {
1472
+ const pid = this.childProcess.pid;
1473
+ if (pid) {
1474
+ try {
1475
+ process.kill(-pid, 'SIGTERM');
1476
+ } catch {
1477
+ try {
1478
+ process.kill(pid, 'SIGTERM');
1479
+ } catch {
1480
+ // Process already dead
1481
+ }
1482
+ }
1483
+ }
1484
+ this.childProcess = null;
1485
+ this.isRunning = false;
1486
+ }
1487
+ }
1488
+ }
1489
+
1245
1490
  class DevServer {
1246
1491
  private serverProcess: ChildProcess | null = null;
1247
1492
  private isRunning = false;
package/src/index.ts CHANGED
@@ -136,28 +136,42 @@ program
136
136
  .command('dev')
137
137
  .description('Start development server with automatic reload')
138
138
  .option('-p, --port <port>', 'Port to run the development server on')
139
+ .option('--entry <file>', 'Entry file to run (bypasses gkm config)')
140
+ .option('--watch', 'Watch for file changes (default: true with --entry)')
141
+ .option('--no-watch', 'Disable file watching')
139
142
  .option(
140
143
  '--enable-openapi',
141
144
  'Enable OpenAPI documentation for development server',
142
145
  true,
143
146
  )
144
- .action(async (options: { port?: string; enableOpenapi?: boolean }) => {
145
- try {
146
- const globalOptions = program.opts();
147
- if (globalOptions.cwd) {
148
- process.chdir(globalOptions.cwd);
149
- }
147
+ .action(
148
+ async (options: {
149
+ port?: string;
150
+ entry?: string;
151
+ watch?: boolean;
152
+ enableOpenapi?: boolean;
153
+ }) => {
154
+ try {
155
+ const globalOptions = program.opts();
156
+ if (globalOptions.cwd) {
157
+ process.chdir(globalOptions.cwd);
158
+ }
150
159
 
151
- await devCommand({
152
- port: options.port ? Number.parseInt(options.port, 10) : 3000,
153
- portExplicit: !!options.port,
154
- enableOpenApi: options.enableOpenapi ?? true,
155
- });
156
- } catch (error) {
157
- console.error(error instanceof Error ? error.message : 'Command failed');
158
- process.exit(1);
159
- }
160
- });
160
+ await devCommand({
161
+ port: options.port ? Number.parseInt(options.port, 10) : 3000,
162
+ portExplicit: !!options.port,
163
+ enableOpenApi: options.enableOpenapi ?? true,
164
+ entry: options.entry,
165
+ watch: options.watch,
166
+ });
167
+ } catch (error) {
168
+ console.error(
169
+ error instanceof Error ? error.message : 'Command failed',
170
+ );
171
+ process.exit(1);
172
+ }
173
+ },
174
+ );
161
175
 
162
176
  program
163
177
  .command('test')
@@ -35,7 +35,7 @@ export const GEEKMIDAS_VERSIONS = {
35
35
  '@geekmidas/constructs': '~0.6.0',
36
36
  '@geekmidas/db': '~0.3.0',
37
37
  '@geekmidas/emailkit': '~0.2.0',
38
- '@geekmidas/envkit': '~0.4.0',
38
+ '@geekmidas/envkit': '~0.5.0',
39
39
  '@geekmidas/errors': '~0.1.0',
40
40
  '@geekmidas/events': '~0.2.0',
41
41
  '@geekmidas/logger': '~0.4.0',