@geekmidas/cli 0.38.0 → 0.39.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.38.0",
3
+ "version": "0.39.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,9 +48,9 @@
48
48
  "lodash.kebabcase": "^4.1.1",
49
49
  "openapi-typescript": "^7.4.2",
50
50
  "prompts": "~2.4.2",
51
- "@geekmidas/schema": "~0.1.0",
52
51
  "@geekmidas/constructs": "~0.6.0",
53
52
  "@geekmidas/envkit": "~0.5.0",
53
+ "@geekmidas/schema": "~0.1.0",
54
54
  "@geekmidas/errors": "~0.1.0",
55
55
  "@geekmidas/logger": "~0.4.0"
56
56
  },
@@ -1,12 +1,17 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { mkdir } from 'node:fs/promises';
4
- import { join, relative } from 'node:path';
4
+ import { join, relative, resolve } from 'node:path';
5
5
  import type { Cron } from '@geekmidas/constructs/crons';
6
6
  import type { Endpoint } from '@geekmidas/constructs/endpoints';
7
7
  import type { Function } from '@geekmidas/constructs/functions';
8
8
  import type { Subscriber } from '@geekmidas/constructs/subscribers';
9
- import { loadConfig, loadWorkspaceConfig, parseModuleConfig } from '../config';
9
+ import {
10
+ loadAppConfig,
11
+ loadConfig,
12
+ loadWorkspaceConfig,
13
+ parseModuleConfig,
14
+ } from '../config';
10
15
  import {
11
16
  getProductionConfigFromGkm,
12
17
  normalizeHooksConfig,
@@ -49,13 +54,25 @@ export async function buildCommand(
49
54
  const loadedConfig = await loadWorkspaceConfig();
50
55
 
51
56
  // Route to workspace build mode for multi-app workspaces
57
+ // BUT only if we're at the workspace root (prevents recursive builds when
58
+ // Turbo runs gkm build in each app subdirectory)
52
59
  if (loadedConfig.type === 'workspace') {
53
- logger.log('šŸ“¦ Detected workspace configuration');
54
- return workspaceBuildCommand(loadedConfig.workspace, options);
60
+ const cwd = resolve(process.cwd());
61
+ const workspaceRoot = resolve(loadedConfig.workspace.root);
62
+ const isAtWorkspaceRoot = cwd === workspaceRoot;
63
+
64
+ if (isAtWorkspaceRoot) {
65
+ logger.log('šŸ“¦ Detected workspace configuration');
66
+ return workspaceBuildCommand(loadedConfig.workspace, options);
67
+ }
68
+ // When running from inside an app directory, use app-specific config
55
69
  }
56
70
 
57
- // Single-app build - use existing logic
58
- const config = await loadConfig();
71
+ // Single-app build - use app config if in workspace, otherwise legacy config
72
+ const config =
73
+ loadedConfig.type === 'workspace'
74
+ ? (await loadAppConfig()).gkmConfig
75
+ : await loadConfig();
59
76
 
60
77
  // Resolve providers from new config format
61
78
  const resolved = resolveProviders(config, options);
@@ -1,8 +1,8 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
- import { dirname, join, relative } from 'node:path';
3
+ import { dirname, join } from 'node:path';
4
4
  import type { GkmConfig } from '../config';
5
- import { dockerCommand, findLockfilePath, isMonorepo } from '../docker';
5
+ import { dockerCommand, findLockfilePath } from '../docker';
6
6
  import type { DeployResult, DockerDeployConfig } from './types';
7
7
 
8
8
  /**
@@ -94,15 +94,19 @@ export function getImageRef(
94
94
 
95
95
  /**
96
96
  * Build Docker image
97
+ * @param imageRef - Full image reference (registry/name:tag)
98
+ * @param appName - Name of the app (used for Dockerfile.{appName} in workspaces)
97
99
  */
98
- async function buildImage(imageRef: string): Promise<void> {
100
+ async function buildImage(imageRef: string, appName?: string): Promise<void> {
99
101
  logger.log(`\nšŸ”Ø Building Docker image: ${imageRef}`);
100
102
 
101
103
  const cwd = process.cwd();
102
- const inMonorepo = isMonorepo(cwd);
104
+ const lockfilePath = findLockfilePath(cwd);
105
+ const lockfileDir = lockfilePath ? dirname(lockfilePath) : cwd;
106
+ const inMonorepo = lockfileDir !== cwd;
103
107
 
104
108
  // Generate appropriate Dockerfile
105
- if (inMonorepo) {
109
+ if (appName || inMonorepo) {
106
110
  logger.log(' Generating Dockerfile for monorepo (turbo prune)...');
107
111
  } else {
108
112
  logger.log(' Generating Dockerfile...');
@@ -110,19 +114,15 @@ async function buildImage(imageRef: string): Promise<void> {
110
114
  await dockerCommand({});
111
115
 
112
116
  // Determine build context and Dockerfile path
113
- let buildCwd = cwd;
114
- let dockerfilePath = '.gkm/docker/Dockerfile';
115
-
116
- if (inMonorepo) {
117
- // For monorepos, build from root so turbo prune can access all packages
118
- const lockfilePath = findLockfilePath(cwd);
119
- if (lockfilePath) {
120
- const monorepoRoot = dirname(lockfilePath);
121
- const appRelPath = relative(monorepoRoot, cwd);
122
- dockerfilePath = join(appRelPath, '.gkm/docker/Dockerfile');
123
- buildCwd = monorepoRoot;
124
- logger.log(` Building from monorepo root: ${monorepoRoot}`);
125
- }
117
+ // For workspaces with multiple apps, use per-app Dockerfile (Dockerfile.api, etc.)
118
+ const dockerfileSuffix = appName ? `.${appName}` : '';
119
+ const dockerfilePath = `.gkm/docker/Dockerfile${dockerfileSuffix}`;
120
+
121
+ // Build from workspace/monorepo root when we have a lockfile elsewhere or appName is provided
122
+ const buildCwd =
123
+ lockfilePath && (inMonorepo || appName) ? lockfileDir : cwd;
124
+ if (buildCwd !== cwd) {
125
+ logger.log(` Building from workspace root: ${buildCwd}`);
126
126
  }
127
127
 
128
128
  try {
@@ -174,8 +174,8 @@ export async function deployDocker(
174
174
  const imageName = config.imageName!;
175
175
  const imageRef = getImageRef(config.registry, imageName, tag);
176
176
 
177
- // Build image
178
- await buildImage(imageRef);
177
+ // Build image (pass appName for workspace Dockerfile selection)
178
+ await buildImage(imageRef, config.appName);
179
179
 
180
180
  // Push to registry if not skipped
181
181
  if (!skipPush) {
@@ -781,13 +781,29 @@ export async function workspaceDeployCommand(
781
781
  // Track deployed app URLs for environment variable injection
782
782
  const deployedAppUrls: Record<string, string> = {};
783
783
 
784
+ // Build the entire workspace once (not per-app to avoid Turbo/Next.js lock conflicts)
785
+ if (!skipBuild) {
786
+ logger.log('\nšŸ—ļø Building workspace...');
787
+ try {
788
+ await buildCommand({
789
+ provider: 'server',
790
+ production: true,
791
+ stage,
792
+ });
793
+ logger.log(' āœ“ Workspace build complete');
794
+ } catch (error) {
795
+ const message = error instanceof Error ? error.message : 'Unknown error';
796
+ logger.log(` āœ— Workspace build failed: ${message}`);
797
+ throw error;
798
+ }
799
+ }
800
+
784
801
  // Deploy apps in dependency order
785
802
  logger.log('\nšŸ“¦ Deploying applications...');
786
803
  const results: AppDeployResult[] = [];
787
804
 
788
805
  for (const appName of appsToDeployNames) {
789
806
  const app = workspace.apps[appName]!;
790
- const appPath = app.path;
791
807
 
792
808
  logger.log(
793
809
  `\n ${app.type === 'backend' ? 'āš™ļø' : '🌐'} Deploying ${appName}...`,
@@ -822,24 +838,8 @@ export async function workspaceDeployCommand(
822
838
  }
823
839
  }
824
840
 
825
- // Build the app if not skipped
826
- if (!skipBuild) {
827
- logger.log(` Building ${appName}...`);
828
- // For workspace, we need to build from the app directory
829
- const originalCwd = process.cwd();
830
- const fullAppPath = `${workspace.root}/${appPath}`;
831
-
832
- try {
833
- process.chdir(fullAppPath);
834
- await buildCommand({
835
- provider: 'server',
836
- production: true,
837
- stage,
838
- });
839
- } finally {
840
- process.chdir(originalCwd);
841
- }
842
- }
841
+ // Note: Workspace was already built once at the start of deployment
842
+ // to avoid Turbo/Next.js lock conflicts from concurrent builds
843
843
 
844
844
  // Build Docker image
845
845
  const imageName = `${workspace.name}-${appName}`;
@@ -856,6 +856,7 @@ export async function workspaceDeployCommand(
856
856
  config: {
857
857
  registry,
858
858
  imageName,
859
+ appName, // Pass appName for Dockerfile.{appName} selection
859
860
  },
860
861
  });
861
862
 
package/src/dev/index.ts CHANGED
@@ -1272,6 +1272,44 @@ export function findSecretsRoot(startDir: string): string {
1272
1272
  return startDir;
1273
1273
  }
1274
1274
 
1275
+ /**
1276
+ * Generate the credentials injection code snippet.
1277
+ * This is the common logic used by both entry wrapper and exec preload.
1278
+ * @internal
1279
+ */
1280
+ function generateCredentialsInjection(secretsJsonPath: string): string {
1281
+ return `import { Credentials } from '@geekmidas/envkit/credentials';
1282
+ import { existsSync, readFileSync } from 'node:fs';
1283
+
1284
+ // Inject dev secrets into Credentials
1285
+ const secretsPath = '${secretsJsonPath}';
1286
+ if (existsSync(secretsPath)) {
1287
+ const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
1288
+ Object.assign(Credentials, secrets);
1289
+ // Debug: uncomment to verify preload is running
1290
+ // console.log('[gkm preload] Injected', Object.keys(secrets).length, 'credentials');
1291
+ }
1292
+ `;
1293
+ }
1294
+
1295
+ /**
1296
+ * Create a preload script that injects secrets into Credentials.
1297
+ * Used by `gkm exec` to inject secrets before running any command.
1298
+ * @internal Exported for testing
1299
+ */
1300
+ export async function createCredentialsPreload(
1301
+ preloadPath: string,
1302
+ secretsJsonPath: string,
1303
+ ): Promise<void> {
1304
+ const content = `/**
1305
+ * Credentials preload generated by 'gkm exec'
1306
+ * This file is loaded via NODE_OPTIONS="--import <path>"
1307
+ */
1308
+ ${generateCredentialsInjection(secretsJsonPath)}`;
1309
+
1310
+ await writeFile(preloadPath, content);
1311
+ }
1312
+
1275
1313
  /**
1276
1314
  * Create a wrapper script that injects secrets before importing the entry file.
1277
1315
  * @internal Exported for testing
@@ -1282,15 +1320,7 @@ export async function createEntryWrapper(
1282
1320
  secretsJsonPath?: string,
1283
1321
  ): Promise<void> {
1284
1322
  const credentialsInjection = secretsJsonPath
1285
- ? `import { Credentials } from '@geekmidas/envkit/credentials';
1286
- import { existsSync, readFileSync } from 'node:fs';
1287
-
1288
- // Inject dev secrets into Credentials (before app import)
1289
- const secretsPath = '${secretsJsonPath}';
1290
- if (existsSync(secretsPath)) {
1291
- Object.assign(Credentials, JSON.parse(readFileSync(secretsPath, 'utf-8')));
1292
- }
1293
-
1323
+ ? `${generateCredentialsInjection(secretsJsonPath)}
1294
1324
  `
1295
1325
  : '';
1296
1326
 
@@ -1789,3 +1819,108 @@ start({
1789
1819
  await fsWriteFile(serverPath, content);
1790
1820
  }
1791
1821
  }
1822
+
1823
+ /**
1824
+ * Options for the exec command.
1825
+ */
1826
+ export interface ExecOptions {
1827
+ /** Working directory */
1828
+ cwd?: string;
1829
+ }
1830
+
1831
+ /**
1832
+ * Run a command with secrets injected into Credentials.
1833
+ * Uses Node's --import flag to preload a script that populates Credentials
1834
+ * before the command loads any modules that depend on them.
1835
+ *
1836
+ * @example
1837
+ * ```bash
1838
+ * gkm exec -- npx @better-auth/cli migrate
1839
+ * gkm exec -- npx prisma migrate dev
1840
+ * ```
1841
+ */
1842
+ export async function execCommand(
1843
+ commandArgs: string[],
1844
+ options: ExecOptions = {},
1845
+ ): Promise<void> {
1846
+ const cwd = options.cwd ?? process.cwd();
1847
+
1848
+ if (commandArgs.length === 0) {
1849
+ throw new Error('No command specified. Usage: gkm exec -- <command>');
1850
+ }
1851
+
1852
+ // Load .env files
1853
+ const defaultEnv = loadEnvFiles('.env');
1854
+ if (defaultEnv.loaded.length > 0) {
1855
+ logger.log(`šŸ“¦ Loaded env: ${defaultEnv.loaded.join(', ')}`);
1856
+ }
1857
+
1858
+ // Prepare credentials (loads workspace config and secrets)
1859
+ // Don't inject PORT for exec since we're not running a server
1860
+ const { credentials, secretsJsonPath, appName } =
1861
+ await prepareEntryCredentials({ cwd });
1862
+
1863
+ if (appName) {
1864
+ logger.log(`šŸ“¦ App: ${appName}`);
1865
+ }
1866
+
1867
+ const secretCount = Object.keys(credentials).filter(
1868
+ (k) => k !== 'PORT',
1869
+ ).length;
1870
+ if (secretCount > 0) {
1871
+ logger.log(`šŸ” Loaded ${secretCount} secret(s)`);
1872
+ }
1873
+
1874
+ // Create preload script that injects Credentials
1875
+ // Create in cwd so package resolution works (finds node_modules in app directory)
1876
+ const preloadDir = join(cwd, '.gkm');
1877
+ await mkdir(preloadDir, { recursive: true });
1878
+ const preloadPath = join(preloadDir, 'credentials-preload.ts');
1879
+ await createCredentialsPreload(preloadPath, secretsJsonPath);
1880
+
1881
+ // Build command
1882
+ const [cmd, ...args] = commandArgs;
1883
+
1884
+ if (!cmd) {
1885
+ throw new Error('No command specified');
1886
+ }
1887
+
1888
+ logger.log(`šŸš€ Running: ${commandArgs.join(' ')}`);
1889
+
1890
+ // Merge NODE_OPTIONS with existing value (if any)
1891
+ // Add tsx loader first so our .ts preload can be loaded
1892
+ const existingNodeOptions = process.env.NODE_OPTIONS ?? '';
1893
+ const tsxImport = '--import tsx';
1894
+ const preloadImport = `--import ${preloadPath}`;
1895
+
1896
+ // Build NODE_OPTIONS: existing + tsx loader + our preload
1897
+ const nodeOptions = [existingNodeOptions, tsxImport, preloadImport]
1898
+ .filter(Boolean)
1899
+ .join(' ');
1900
+
1901
+ // Spawn the command with secrets in both:
1902
+ // 1. Environment variables (for tools that read process.env directly)
1903
+ // 2. Preload script (for tools that use Credentials object)
1904
+ const child = spawn(cmd, args, {
1905
+ cwd,
1906
+ stdio: 'inherit',
1907
+ env: {
1908
+ ...process.env,
1909
+ ...credentials, // Inject secrets as env vars
1910
+ NODE_OPTIONS: nodeOptions,
1911
+ },
1912
+ });
1913
+
1914
+ // Wait for the command to complete
1915
+ const exitCode = await new Promise<number>((resolve) => {
1916
+ child.on('close', (code: number | null) => resolve(code ?? 0));
1917
+ child.on('error', (error: Error) => {
1918
+ logger.error(`Failed to run command: ${error.message}`);
1919
+ resolve(1);
1920
+ });
1921
+ });
1922
+
1923
+ if (exitCode !== 0) {
1924
+ process.exit(exitCode);
1925
+ }
1926
+ }
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ import { loginCommand, logoutCommand, whoamiCommand } from './auth';
6
6
  import { buildCommand } from './build/index';
7
7
  import { type DeployProvider, deployCommand } from './deploy/index';
8
8
  import { deployInitCommand, deployListCommand } from './deploy/init';
9
- import { devCommand } from './dev/index';
9
+ import { devCommand, execCommand } from './dev/index';
10
10
  import { type DockerOptions, dockerCommand } from './docker/index';
11
11
  import { type InitOptions, initCommand } from './init/index';
12
12
  import { openapiCommand } from './openapi';
@@ -173,6 +173,23 @@ program
173
173
  },
174
174
  );
175
175
 
176
+ program
177
+ .command('exec')
178
+ .description('Run a command with secrets injected into Credentials')
179
+ .argument('<command...>', 'Command to run (use -- before command)')
180
+ .action(async (commandArgs: string[]) => {
181
+ try {
182
+ const globalOptions = program.opts();
183
+ if (globalOptions.cwd) {
184
+ process.chdir(globalOptions.cwd);
185
+ }
186
+ await execCommand(commandArgs);
187
+ } catch (error) {
188
+ console.error(error instanceof Error ? error.message : 'Command failed');
189
+ process.exit(1);
190
+ }
191
+ });
192
+
176
193
  program
177
194
  .command('test')
178
195
  .description('Run tests with secrets loaded from environment')
@@ -26,6 +26,8 @@ export function generateAuthAppFiles(
26
26
  build: 'tsc',
27
27
  start: 'node dist/index.js',
28
28
  typecheck: 'tsc --noEmit',
29
+ 'db:migrate': 'gkm exec -- npx @better-auth/cli migrate',
30
+ 'db:generate': 'gkm exec -- npx @better-auth/cli generate',
29
31
  },
30
32
  dependencies: {
31
33
  [modelsPackage]: 'workspace:*',