@geekmidas/cli 0.23.0 → 0.25.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/dist/openapi.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
  require('./workspace-iWgBlX6h.cjs');
3
- require('./config-CxrLu8ia.cjs');
4
- const require_openapi = require('./openapi-DfpxS0xv.cjs');
3
+ require('./config-CTftATBX.cjs');
4
+ const require_openapi = require('./openapi-BrhkPKM7.cjs');
5
5
 
6
6
  exports.OPENAPI_OUTPUT_PATH = require_openapi.OPENAPI_OUTPUT_PATH;
7
7
  exports.generateOpenApi = require_openapi.generateOpenApi;
package/dist/openapi.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
  import "./workspace-CPLEZDZf.mjs";
3
- import "./config-BaYqrF3n.mjs";
4
- import { OPENAPI_OUTPUT_PATH, generateOpenApi, openapiCommand, resolveOpenApiConfig } from "./openapi-CgqR6Jkw.mjs";
3
+ import "./config-BogU0_oQ.mjs";
4
+ import { OPENAPI_OUTPUT_PATH, generateOpenApi, openapiCommand, resolveOpenApiConfig } from "./openapi-DNbXfhXE.mjs";
5
5
 
6
6
  export { OPENAPI_OUTPUT_PATH, generateOpenApi, openapiCommand, resolveOpenApiConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "0.23.0",
3
+ "version": "0.25.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/errors": "~0.1.0",
52
51
  "@geekmidas/constructs": "~0.6.0",
53
52
  "@geekmidas/envkit": "~0.4.0",
54
53
  "@geekmidas/logger": "~0.4.0",
55
- "@geekmidas/schema": "~0.1.0"
54
+ "@geekmidas/schema": "~0.1.0",
55
+ "@geekmidas/errors": "~0.1.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/lodash.kebabcase": "^4.1.9",
@@ -64,7 +64,7 @@
64
64
  "@geekmidas/testkit": "0.6.0"
65
65
  },
66
66
  "peerDependencies": {
67
- "@geekmidas/telescope": "~0.4.0"
67
+ "@geekmidas/telescope": "~0.5.0"
68
68
  },
69
69
  "peerDependenciesMeta": {
70
70
  "@geekmidas/telescope": {
@@ -1,7 +1,8 @@
1
- import { writeFile } from 'node:fs/promises';
1
+ import { realpathSync } from 'node:fs';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
3
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
- import { loadConfig } from '../config';
5
+ import { getAppNameFromCwd, loadAppConfig, loadConfig } from '../config';
5
6
  import { cleanupDir, createTempDir } from './test-helpers';
6
7
 
7
8
  describe('loadConfig', () => {
@@ -108,3 +109,314 @@ export default {
108
109
  expect(config.routes).toBe('./ts-routes/**/*.ts');
109
110
  });
110
111
  });
112
+
113
+ describe('getAppNameFromCwd', () => {
114
+ let tempDir: string;
115
+
116
+ beforeEach(async () => {
117
+ tempDir = await createTempDir();
118
+ });
119
+
120
+ afterEach(async () => {
121
+ await cleanupDir(tempDir);
122
+ });
123
+
124
+ it('should return app name from package.json', async () => {
125
+ const packageJson = { name: 'my-app', version: '1.0.0' };
126
+ await writeFile(join(tempDir, 'package.json'), JSON.stringify(packageJson));
127
+
128
+ const appName = getAppNameFromCwd(tempDir);
129
+
130
+ expect(appName).toBe('my-app');
131
+ });
132
+
133
+ it('should extract name from scoped package', async () => {
134
+ const packageJson = { name: '@myorg/api', version: '1.0.0' };
135
+ await writeFile(join(tempDir, 'package.json'), JSON.stringify(packageJson));
136
+
137
+ const appName = getAppNameFromCwd(tempDir);
138
+
139
+ expect(appName).toBe('api');
140
+ });
141
+
142
+ it('should handle scoped package with nested scope', async () => {
143
+ const packageJson = { name: '@my-company/auth-service', version: '1.0.0' };
144
+ await writeFile(join(tempDir, 'package.json'), JSON.stringify(packageJson));
145
+
146
+ const appName = getAppNameFromCwd(tempDir);
147
+
148
+ expect(appName).toBe('auth-service');
149
+ });
150
+
151
+ it('should return null if package.json does not exist', async () => {
152
+ const appName = getAppNameFromCwd(tempDir);
153
+
154
+ expect(appName).toBeNull();
155
+ });
156
+
157
+ it('should return null if package.json has no name field', async () => {
158
+ const packageJson = { version: '1.0.0' };
159
+ await writeFile(join(tempDir, 'package.json'), JSON.stringify(packageJson));
160
+
161
+ const appName = getAppNameFromCwd(tempDir);
162
+
163
+ expect(appName).toBeNull();
164
+ });
165
+
166
+ it('should return null if package.json is invalid JSON', async () => {
167
+ await writeFile(join(tempDir, 'package.json'), 'not valid json');
168
+
169
+ const appName = getAppNameFromCwd(tempDir);
170
+
171
+ expect(appName).toBeNull();
172
+ });
173
+ });
174
+
175
+ describe('loadAppConfig', () => {
176
+ let tempDir: string;
177
+ let originalCwd: string;
178
+
179
+ beforeEach(async () => {
180
+ tempDir = await createTempDir();
181
+ originalCwd = process.cwd();
182
+ });
183
+
184
+ afterEach(async () => {
185
+ process.chdir(originalCwd);
186
+ // Clean up GKM_CONFIG_PATH env var
187
+ delete process.env.GKM_CONFIG_PATH;
188
+ await cleanupDir(tempDir);
189
+ });
190
+
191
+ it('should load app config from workspace when in app directory', async () => {
192
+ // Create workspace structure
193
+ const workspaceRoot = tempDir;
194
+ const appDir = join(workspaceRoot, 'apps', 'api');
195
+ await mkdir(appDir, { recursive: true });
196
+
197
+ // Create workspace config (plain JS object with __isWorkspace marker)
198
+ const workspaceConfig = `
199
+ export default {
200
+ __isWorkspace: true,
201
+ name: 'test-workspace',
202
+ apps: {
203
+ api: {
204
+ type: 'backend',
205
+ path: 'apps/api',
206
+ port: 3000,
207
+ routes: './src/endpoints/**/*.ts',
208
+ envParser: './src/config/env',
209
+ logger: './src/config/logger',
210
+ },
211
+ },
212
+ };
213
+ `;
214
+ await writeFile(join(workspaceRoot, 'gkm.config.ts'), workspaceConfig);
215
+
216
+ // Create app package.json
217
+ const packageJson = { name: '@test-workspace/api', version: '1.0.0' };
218
+ await writeFile(join(appDir, 'package.json'), JSON.stringify(packageJson));
219
+
220
+ // Change to app directory
221
+ process.chdir(appDir);
222
+
223
+ const result = await loadAppConfig();
224
+
225
+ expect(result.appName).toBe('api');
226
+ expect(result.gkmConfig.routes).toBe('./src/endpoints/**/*.ts');
227
+ // Use realpathSync to handle macOS /var -> /private/var symlink
228
+ expect(realpathSync(result.appRoot)).toBe(realpathSync(appDir));
229
+ expect(realpathSync(result.workspaceRoot)).toBe(
230
+ realpathSync(workspaceRoot),
231
+ );
232
+ });
233
+
234
+ it('should throw error if app not found in workspace', async () => {
235
+ // Create workspace structure
236
+ const workspaceRoot = tempDir;
237
+ const appDir = join(workspaceRoot, 'apps', 'unknown');
238
+ await mkdir(appDir, { recursive: true });
239
+
240
+ // Create workspace config without 'unknown' app
241
+ const workspaceConfig = `
242
+ export default {
243
+ __isWorkspace: true,
244
+ name: 'test-workspace',
245
+ apps: {
246
+ api: {
247
+ type: 'backend',
248
+ path: 'apps/api',
249
+ port: 3000,
250
+ routes: './src/endpoints/**/*.ts',
251
+ envParser: './src/config/env',
252
+ logger: './src/config/logger',
253
+ },
254
+ },
255
+ };
256
+ `;
257
+ await writeFile(join(workspaceRoot, 'gkm.config.ts'), workspaceConfig);
258
+
259
+ // Create app package.json with different name
260
+ const packageJson = { name: '@test-workspace/unknown', version: '1.0.0' };
261
+ await writeFile(join(appDir, 'package.json'), JSON.stringify(packageJson));
262
+
263
+ // Change to app directory
264
+ process.chdir(appDir);
265
+
266
+ await expect(loadAppConfig()).rejects.toThrow(
267
+ 'App "unknown" not found in workspace config',
268
+ );
269
+ });
270
+
271
+ it('should throw error if no package.json exists', async () => {
272
+ // Create workspace structure without package.json in app
273
+ const workspaceRoot = tempDir;
274
+ const appDir = join(workspaceRoot, 'apps', 'api');
275
+ await mkdir(appDir, { recursive: true });
276
+
277
+ const workspaceConfig = `
278
+ export default {
279
+ routes: './src/endpoints/**/*.ts',
280
+ envParser: './src/config/env',
281
+ logger: './src/config/logger',
282
+ };
283
+ `;
284
+ await writeFile(join(workspaceRoot, 'gkm.config.ts'), workspaceConfig);
285
+
286
+ // Change to app directory (no package.json)
287
+ process.chdir(appDir);
288
+
289
+ await expect(loadAppConfig()).rejects.toThrow(
290
+ 'Could not determine app name',
291
+ );
292
+ });
293
+
294
+ it('should use GKM_CONFIG_PATH env var when set', async () => {
295
+ // Create workspace at a different location
296
+ const workspaceRoot = await createTempDir('workspace-');
297
+ const configPath = join(workspaceRoot, 'gkm.config.ts');
298
+
299
+ // Create workspace config
300
+ const workspaceConfig = `
301
+ export default {
302
+ __isWorkspace: true,
303
+ name: 'env-test',
304
+ apps: {
305
+ api: {
306
+ type: 'backend',
307
+ path: 'apps/api',
308
+ port: 3000,
309
+ routes: './src/endpoints/**/*.ts',
310
+ envParser: './src/config/env',
311
+ logger: './src/config/logger',
312
+ },
313
+ },
314
+ };
315
+ `;
316
+ await writeFile(configPath, workspaceConfig);
317
+
318
+ // Create app directory in temp dir (separate from workspace)
319
+ const appDir = join(tempDir, 'apps', 'api');
320
+ await mkdir(appDir, { recursive: true });
321
+
322
+ // Create app package.json
323
+ const packageJson = { name: '@env-test/api', version: '1.0.0' };
324
+ await writeFile(join(appDir, 'package.json'), JSON.stringify(packageJson));
325
+
326
+ // Set GKM_CONFIG_PATH
327
+ process.env.GKM_CONFIG_PATH = configPath;
328
+
329
+ // Change to app directory
330
+ process.chdir(appDir);
331
+
332
+ const result = await loadAppConfig();
333
+
334
+ expect(result.appName).toBe('api');
335
+ // Use realpathSync to handle macOS /var -> /private/var symlink
336
+ expect(realpathSync(result.workspaceRoot)).toBe(
337
+ realpathSync(workspaceRoot),
338
+ );
339
+
340
+ // Cleanup the extra temp dir
341
+ await cleanupDir(workspaceRoot);
342
+ });
343
+ });
344
+
345
+ describe('config discovery', () => {
346
+ let tempDir: string;
347
+ let originalCwd: string;
348
+
349
+ beforeEach(async () => {
350
+ tempDir = await createTempDir();
351
+ originalCwd = process.cwd();
352
+ });
353
+
354
+ afterEach(async () => {
355
+ process.chdir(originalCwd);
356
+ delete process.env.GKM_CONFIG_PATH;
357
+ await cleanupDir(tempDir);
358
+ });
359
+
360
+ it('should find config by walking up directories', async () => {
361
+ // Create nested directory structure
362
+ const nestedDir = join(tempDir, 'apps', 'api', 'src', 'endpoints');
363
+ await mkdir(nestedDir, { recursive: true });
364
+
365
+ // Create config at root
366
+ const configContent = `
367
+ export default {
368
+ routes: './src/endpoints/**/*.ts',
369
+ envParser: './src/config/env',
370
+ logger: './src/config/logger',
371
+ };
372
+ `;
373
+ await writeFile(join(tempDir, 'gkm.config.ts'), configContent);
374
+
375
+ // Change to deeply nested directory
376
+ process.chdir(nestedDir);
377
+
378
+ const config = await loadConfig();
379
+
380
+ expect(config.routes).toBe('./src/endpoints/**/*.ts');
381
+ });
382
+
383
+ it('should prefer GKM_CONFIG_PATH over walking up directories', async () => {
384
+ // Create two configs at different levels
385
+ const nestedDir = join(tempDir, 'apps', 'api');
386
+ await mkdir(nestedDir, { recursive: true });
387
+
388
+ // Config at root
389
+ const rootConfig = `
390
+ export default {
391
+ routes: './root-routes/**/*.ts',
392
+ envParser: './src/config/env',
393
+ logger: './src/config/logger',
394
+ };
395
+ `;
396
+ await writeFile(join(tempDir, 'gkm.config.ts'), rootConfig);
397
+
398
+ // Config in a different location pointed to by env var
399
+ const envConfigDir = await createTempDir('env-config-');
400
+ const envConfig = `
401
+ export default {
402
+ routes: './env-routes/**/*.ts',
403
+ envParser: './src/config/env',
404
+ logger: './src/config/logger',
405
+ };
406
+ `;
407
+ await writeFile(join(envConfigDir, 'gkm.config.ts'), envConfig);
408
+
409
+ // Set GKM_CONFIG_PATH to the env config
410
+ process.env.GKM_CONFIG_PATH = join(envConfigDir, 'gkm.config.ts');
411
+
412
+ // Change to nested directory
413
+ process.chdir(nestedDir);
414
+
415
+ const config = await loadConfig();
416
+
417
+ expect(config.routes).toBe('./env-routes/**/*.ts');
418
+
419
+ // Cleanup
420
+ await cleanupDir(envConfigDir);
421
+ });
422
+ });
package/src/config.ts CHANGED
@@ -1,9 +1,12 @@
1
- import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, parse } from 'node:path';
3
3
  import type { GkmConfig } from './types.js';
4
4
  import {
5
+ getAppGkmConfig,
5
6
  isWorkspaceConfig,
6
7
  type LoadedConfig,
8
+ type NormalizedAppConfig,
9
+ type NormalizedWorkspace,
7
10
  processConfig,
8
11
  type WorkspaceConfig,
9
12
  } from './workspace/index.js';
@@ -70,17 +73,45 @@ export function parseModuleConfig(
70
73
  return { path, importPattern };
71
74
  }
72
75
 
76
+ export interface ConfigDiscoveryResult {
77
+ configPath: string;
78
+ workspaceRoot: string;
79
+ }
80
+
73
81
  /**
74
82
  * Find and return the path to the config file.
83
+ *
84
+ * Resolution order:
85
+ * 1. GKM_CONFIG_PATH env var (set by workspace dev command)
86
+ * 2. Walk up directory tree from cwd
75
87
  */
76
- function findConfigPath(cwd: string): string {
88
+ function findConfigPath(cwd: string): ConfigDiscoveryResult {
77
89
  const files = ['gkm.config.json', 'gkm.config.ts', 'gkm.config.js'];
78
90
 
79
- for (const file of files) {
80
- const path = join(cwd, file);
81
- if (existsSync(path)) {
82
- return path;
91
+ // Check GKM_CONFIG_PATH env var first (set by workspace dev command)
92
+ const envConfigPath = process.env.GKM_CONFIG_PATH;
93
+ if (envConfigPath && existsSync(envConfigPath)) {
94
+ return {
95
+ configPath: envConfigPath,
96
+ workspaceRoot: dirname(envConfigPath),
97
+ };
98
+ }
99
+
100
+ // Walk up directory tree to find config
101
+ let currentDir = cwd;
102
+ const { root } = parse(currentDir);
103
+
104
+ while (currentDir !== root) {
105
+ for (const file of files) {
106
+ const configPath = join(currentDir, file);
107
+ if (existsSync(configPath)) {
108
+ return {
109
+ configPath,
110
+ workspaceRoot: currentDir,
111
+ };
112
+ }
83
113
  }
114
+ currentDir = dirname(currentDir);
84
115
  }
85
116
 
86
117
  throw new Error(
@@ -88,17 +119,58 @@ function findConfigPath(cwd: string): string {
88
119
  );
89
120
  }
90
121
 
122
+ /**
123
+ * Get app name from package.json in the given directory.
124
+ * Handles scoped packages by extracting the name after the scope.
125
+ *
126
+ * @example
127
+ * getAppNameFromCwd('/path/to/apps/api')
128
+ * // package.json: { "name": "@myorg/api" }
129
+ * // Returns: 'api'
130
+ */
131
+ export function getAppNameFromCwd(cwd: string = process.cwd()): string | null {
132
+ const packageJsonPath = join(cwd, 'package.json');
133
+
134
+ if (!existsSync(packageJsonPath)) {
135
+ return null;
136
+ }
137
+
138
+ try {
139
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
140
+ const name = packageJson.name as string | undefined;
141
+
142
+ if (!name) {
143
+ return null;
144
+ }
145
+
146
+ // Handle scoped packages: @scope/name -> name
147
+ if (name.startsWith('@') && name.includes('/')) {
148
+ return name.split('/')[1] ?? null;
149
+ }
150
+
151
+ return name;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ interface RawConfigResult {
158
+ config: GkmConfig | WorkspaceConfig;
159
+ workspaceRoot: string;
160
+ }
161
+
91
162
  /**
92
163
  * Load raw configuration from file.
93
164
  */
94
- async function loadRawConfig(
95
- cwd: string,
96
- ): Promise<GkmConfig | WorkspaceConfig> {
97
- const configPath = findConfigPath(cwd);
165
+ async function loadRawConfig(cwd: string): Promise<RawConfigResult> {
166
+ const { configPath, workspaceRoot } = findConfigPath(cwd);
98
167
 
99
168
  try {
100
169
  const config = await import(configPath);
101
- return config.default;
170
+ return {
171
+ config: config.default,
172
+ workspaceRoot,
173
+ };
102
174
  } catch (error) {
103
175
  throw new Error(`Failed to load config: ${(error as Error).message}`);
104
176
  }
@@ -113,7 +185,7 @@ async function loadRawConfig(
113
185
  export async function loadConfig(
114
186
  cwd: string = process.cwd(),
115
187
  ): Promise<GkmConfig> {
116
- const config = await loadRawConfig(cwd);
188
+ const { config } = await loadRawConfig(cwd);
117
189
 
118
190
  // If it's a workspace config, throw an error
119
191
  if (isWorkspaceConfig(config)) {
@@ -145,6 +217,70 @@ export async function loadConfig(
145
217
  export async function loadWorkspaceConfig(
146
218
  cwd: string = process.cwd(),
147
219
  ): Promise<LoadedConfig> {
148
- const config = await loadRawConfig(cwd);
149
- return processConfig(config, cwd);
220
+ const { config, workspaceRoot } = await loadRawConfig(cwd);
221
+ return processConfig(config, workspaceRoot);
222
+ }
223
+
224
+ export interface AppConfigResult {
225
+ appName: string;
226
+ app: NormalizedAppConfig;
227
+ gkmConfig: GkmConfig;
228
+ workspace: NormalizedWorkspace;
229
+ workspaceRoot: string;
230
+ appRoot: string;
231
+ }
232
+
233
+ /**
234
+ * Load app-specific configuration from workspace.
235
+ * Uses the app name from package.json to find the correct app config.
236
+ *
237
+ * @example
238
+ * ```ts
239
+ * // From apps/api directory with package.json: { "name": "@myorg/api" }
240
+ * const { app, workspace, workspaceRoot } = await loadAppConfig();
241
+ * console.log(app.routes); // './src/endpoints/**\/*.ts'
242
+ * ```
243
+ */
244
+ export async function loadAppConfig(
245
+ cwd: string = process.cwd(),
246
+ ): Promise<AppConfigResult> {
247
+ const appName = getAppNameFromCwd(cwd);
248
+
249
+ if (!appName) {
250
+ throw new Error(
251
+ 'Could not determine app name. Ensure package.json exists with a "name" field.',
252
+ );
253
+ }
254
+
255
+ const { config, workspaceRoot } = await loadRawConfig(cwd);
256
+ const loadedConfig = processConfig(config, workspaceRoot);
257
+
258
+ // Find the app in workspace (apps is a Record<string, NormalizedAppConfig>)
259
+ const app = loadedConfig.workspace.apps[appName];
260
+
261
+ if (!app) {
262
+ const availableApps = Object.keys(loadedConfig.workspace.apps).join(', ');
263
+ throw new Error(
264
+ `App "${appName}" not found in workspace config. Available apps: ${availableApps}. ` +
265
+ `Ensure the package.json name matches the app key in gkm.config.ts.`,
266
+ );
267
+ }
268
+
269
+ // Get the app's GKM config using the helper
270
+ const gkmConfig = getAppGkmConfig(loadedConfig.workspace, appName);
271
+
272
+ if (!gkmConfig) {
273
+ throw new Error(
274
+ `App "${appName}" is not a backend app and cannot be run with gkm dev.`,
275
+ );
276
+ }
277
+
278
+ return {
279
+ appName,
280
+ app,
281
+ gkmConfig,
282
+ workspace: loadedConfig.workspace,
283
+ workspaceRoot,
284
+ appRoot: join(workspaceRoot, app.path),
285
+ };
150
286
  }