@geekmidas/cli 0.38.0 → 0.40.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.
Files changed (80) hide show
  1. package/dist/{bundler-DQIuE3Kn.mjs → bundler-Db83tLti.mjs} +2 -2
  2. package/dist/{bundler-DQIuE3Kn.mjs.map → bundler-Db83tLti.mjs.map} +1 -1
  3. package/dist/{bundler-CyHg1v_T.cjs → bundler-DsXfFSCU.cjs} +2 -2
  4. package/dist/{bundler-CyHg1v_T.cjs.map → bundler-DsXfFSCU.cjs.map} +1 -1
  5. package/dist/{config-BC5n1a2D.mjs → config-C0b0jdmU.mjs} +2 -2
  6. package/dist/{config-BC5n1a2D.mjs.map → config-C0b0jdmU.mjs.map} +1 -1
  7. package/dist/{config-BAE9LFC1.cjs → config-xVZsRjN7.cjs} +2 -2
  8. package/dist/{config-BAE9LFC1.cjs.map → config-xVZsRjN7.cjs.map} +1 -1
  9. package/dist/config.cjs +2 -2
  10. package/dist/config.d.cts +1 -1
  11. package/dist/config.d.mts +2 -2
  12. package/dist/config.mjs +2 -2
  13. package/dist/dokploy-api-Bdmk5ImW.cjs +3 -0
  14. package/dist/{dokploy-api-C5czOZoc.cjs → dokploy-api-BdxOMH_V.cjs} +43 -1
  15. package/dist/{dokploy-api-C5czOZoc.cjs.map → dokploy-api-BdxOMH_V.cjs.map} +1 -1
  16. package/dist/{dokploy-api-B9qR2Yn1.mjs → dokploy-api-DWsqNjwP.mjs} +43 -1
  17. package/dist/{dokploy-api-B9qR2Yn1.mjs.map → dokploy-api-DWsqNjwP.mjs.map} +1 -1
  18. package/dist/dokploy-api-tZSZaHd9.mjs +3 -0
  19. package/dist/{encryption-JtMsiGNp.mjs → encryption-BC4MAODn.mjs} +1 -1
  20. package/dist/{encryption-JtMsiGNp.mjs.map → encryption-BC4MAODn.mjs.map} +1 -1
  21. package/dist/encryption-Biq0EZ4m.cjs +4 -0
  22. package/dist/encryption-CQXBZGkt.mjs +3 -0
  23. package/dist/{encryption-BAz0xQ1Q.cjs → encryption-DaCB_NmS.cjs} +13 -3
  24. package/dist/{encryption-BAz0xQ1Q.cjs.map → encryption-DaCB_NmS.cjs.map} +1 -1
  25. package/dist/{index-C7TkoYmt.d.mts → index-CXa3odEw.d.mts} +68 -7
  26. package/dist/index-CXa3odEw.d.mts.map +1 -0
  27. package/dist/{index-CpchsC9w.d.cts → index-E8Nu2Rxl.d.cts} +67 -6
  28. package/dist/index-E8Nu2Rxl.d.cts.map +1 -0
  29. package/dist/index.cjs +787 -145
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.mjs +767 -125
  32. package/dist/index.mjs.map +1 -1
  33. package/dist/{openapi-CjYeF-Tg.mjs → openapi-D3pA6FfZ.mjs} +2 -2
  34. package/dist/{openapi-CjYeF-Tg.mjs.map → openapi-D3pA6FfZ.mjs.map} +1 -1
  35. package/dist/{openapi-a-e3Y8WA.cjs → openapi-DhcCtKzM.cjs} +2 -2
  36. package/dist/{openapi-a-e3Y8WA.cjs.map → openapi-DhcCtKzM.cjs.map} +1 -1
  37. package/dist/{openapi-react-query-DvNpdDpM.cjs → openapi-react-query-C_MxpBgF.cjs} +1 -1
  38. package/dist/{openapi-react-query-DvNpdDpM.cjs.map → openapi-react-query-C_MxpBgF.cjs.map} +1 -1
  39. package/dist/{openapi-react-query-5rSortLH.mjs → openapi-react-query-ZoP9DPbY.mjs} +1 -1
  40. package/dist/{openapi-react-query-5rSortLH.mjs.map → openapi-react-query-ZoP9DPbY.mjs.map} +1 -1
  41. package/dist/openapi-react-query.cjs +1 -1
  42. package/dist/openapi-react-query.mjs +1 -1
  43. package/dist/openapi.cjs +3 -3
  44. package/dist/openapi.d.mts +1 -1
  45. package/dist/openapi.mjs +3 -3
  46. package/dist/{types-K2uQJ-FO.d.mts → types-BtGL-8QS.d.mts} +1 -1
  47. package/dist/{types-K2uQJ-FO.d.mts.map → types-BtGL-8QS.d.mts.map} +1 -1
  48. package/dist/workspace/index.cjs +1 -1
  49. package/dist/workspace/index.d.cts +2 -2
  50. package/dist/workspace/index.d.mts +3 -3
  51. package/dist/workspace/index.mjs +1 -1
  52. package/dist/{workspace-My0A4IRO.cjs → workspace-BDAhr6Kb.cjs} +33 -4
  53. package/dist/{workspace-My0A4IRO.cjs.map → workspace-BDAhr6Kb.cjs.map} +1 -1
  54. package/dist/{workspace-DFJ3sWfY.mjs → workspace-D_6ZCaR_.mjs} +33 -4
  55. package/dist/{workspace-DFJ3sWfY.mjs.map → workspace-D_6ZCaR_.mjs.map} +1 -1
  56. package/package.json +5 -5
  57. package/src/build/index.ts +23 -6
  58. package/src/deploy/__tests__/domain.spec.ts +231 -0
  59. package/src/deploy/__tests__/secrets.spec.ts +300 -0
  60. package/src/deploy/__tests__/sniffer.spec.ts +221 -0
  61. package/src/deploy/docker.ts +58 -29
  62. package/src/deploy/dokploy-api.ts +99 -0
  63. package/src/deploy/domain.ts +125 -0
  64. package/src/deploy/index.ts +364 -145
  65. package/src/deploy/secrets.ts +182 -0
  66. package/src/deploy/sniffer.ts +180 -0
  67. package/src/dev/index.ts +155 -9
  68. package/src/docker/index.ts +17 -2
  69. package/src/docker/templates.ts +171 -1
  70. package/src/index.ts +18 -1
  71. package/src/init/generators/auth.ts +2 -0
  72. package/src/init/versions.ts +2 -2
  73. package/src/workspace/index.ts +2 -0
  74. package/src/workspace/schema.ts +32 -6
  75. package/src/workspace/types.ts +64 -2
  76. package/tsconfig.tsbuildinfo +1 -1
  77. package/dist/dokploy-api-B0w17y4_.mjs +0 -3
  78. package/dist/dokploy-api-BnGeUqN4.cjs +0 -3
  79. package/dist/index-C7TkoYmt.d.mts.map +0 -1
  80. package/dist/index-CpchsC9w.d.cts.map +0 -1
@@ -0,0 +1,182 @@
1
+ import { encryptSecrets } from '../secrets/encryption.js';
2
+ import { toEmbeddableSecrets } from '../secrets/storage.js';
3
+ import type { EmbeddableSecrets, EncryptedPayload, StageSecrets } from '../secrets/types.js';
4
+ import type { SniffedEnvironment } from './sniffer.js';
5
+
6
+ /**
7
+ * Result of filtering secrets for an app.
8
+ */
9
+ export interface FilteredAppSecrets {
10
+ appName: string;
11
+ /** Secrets filtered to only include what the app needs */
12
+ secrets: EmbeddableSecrets;
13
+ /** List of required env vars that were found in secrets */
14
+ found: string[];
15
+ /** List of required env vars that were NOT found in secrets */
16
+ missing: string[];
17
+ }
18
+
19
+ /**
20
+ * Filter secrets to only include the env vars that an app requires.
21
+ *
22
+ * @param stageSecrets - All secrets for the stage
23
+ * @param sniffedEnv - The sniffed environment requirements for the app
24
+ * @returns Filtered secrets with found/missing tracking
25
+ */
26
+ export function filterSecretsForApp(
27
+ stageSecrets: StageSecrets,
28
+ sniffedEnv: SniffedEnvironment,
29
+ ): FilteredAppSecrets {
30
+ // Convert stage secrets to flat embeddable format
31
+ const allSecrets = toEmbeddableSecrets(stageSecrets);
32
+ const filtered: EmbeddableSecrets = {};
33
+ const found: string[] = [];
34
+ const missing: string[] = [];
35
+
36
+ // Filter to only required env vars
37
+ for (const key of sniffedEnv.requiredEnvVars) {
38
+ if (key in allSecrets) {
39
+ filtered[key] = allSecrets[key]!;
40
+ found.push(key);
41
+ } else {
42
+ missing.push(key);
43
+ }
44
+ }
45
+
46
+ return {
47
+ appName: sniffedEnv.appName,
48
+ secrets: filtered,
49
+ found: found.sort(),
50
+ missing: missing.sort(),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Result of encrypting secrets for an app.
56
+ */
57
+ export interface EncryptedAppSecrets {
58
+ appName: string;
59
+ /** Encrypted payload with credentials and IV */
60
+ payload: EncryptedPayload;
61
+ /** Master key for runtime decryption (hex encoded) */
62
+ masterKey: string;
63
+ /** Number of secrets encrypted */
64
+ secretCount: number;
65
+ /** List of required env vars that were NOT found in secrets */
66
+ missingSecrets: string[];
67
+ }
68
+
69
+ /**
70
+ * Encrypt filtered secrets for an app.
71
+ * Generates an ephemeral master key that should be injected into Dokploy.
72
+ *
73
+ * @param filteredSecrets - The filtered secrets for the app
74
+ * @returns Encrypted payload with master key
75
+ */
76
+ export function encryptSecretsForApp(
77
+ filteredSecrets: FilteredAppSecrets,
78
+ ): EncryptedAppSecrets {
79
+ const payload = encryptSecrets(filteredSecrets.secrets);
80
+
81
+ return {
82
+ appName: filteredSecrets.appName,
83
+ payload,
84
+ masterKey: payload.masterKey,
85
+ secretCount: Object.keys(filteredSecrets.secrets).length,
86
+ missingSecrets: filteredSecrets.missing,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Filter and encrypt secrets for an app in one step.
92
+ *
93
+ * @param stageSecrets - All secrets for the stage
94
+ * @param sniffedEnv - The sniffed environment requirements for the app
95
+ * @returns Encrypted secrets with master key
96
+ */
97
+ export function prepareSecretsForApp(
98
+ stageSecrets: StageSecrets,
99
+ sniffedEnv: SniffedEnvironment,
100
+ ): EncryptedAppSecrets {
101
+ const filtered = filterSecretsForApp(stageSecrets, sniffedEnv);
102
+ return encryptSecretsForApp(filtered);
103
+ }
104
+
105
+ /**
106
+ * Prepare secrets for multiple apps.
107
+ *
108
+ * @param stageSecrets - All secrets for the stage
109
+ * @param sniffedApps - Map of app name to sniffed environment
110
+ * @returns Map of app name to encrypted secrets
111
+ */
112
+ export function prepareSecretsForAllApps(
113
+ stageSecrets: StageSecrets,
114
+ sniffedApps: Map<string, SniffedEnvironment>,
115
+ ): Map<string, EncryptedAppSecrets> {
116
+ const results = new Map<string, EncryptedAppSecrets>();
117
+
118
+ for (const [appName, sniffedEnv] of sniffedApps) {
119
+ // Only prepare secrets for apps that have required env vars
120
+ if (sniffedEnv.requiredEnvVars.length > 0) {
121
+ const encrypted = prepareSecretsForApp(stageSecrets, sniffedEnv);
122
+ results.set(appName, encrypted);
123
+ }
124
+ }
125
+
126
+ return results;
127
+ }
128
+
129
+ /**
130
+ * Report on secrets preparation status for all apps.
131
+ */
132
+ export interface SecretsReport {
133
+ /** Total number of apps processed */
134
+ totalApps: number;
135
+ /** Apps with encrypted secrets */
136
+ appsWithSecrets: string[];
137
+ /** Apps without secrets (frontends or no env requirements) */
138
+ appsWithoutSecrets: string[];
139
+ /** Apps with missing secrets (warnings) */
140
+ appsWithMissingSecrets: Array<{
141
+ appName: string;
142
+ missing: string[];
143
+ }>;
144
+ }
145
+
146
+ /**
147
+ * Generate a report on secrets preparation.
148
+ */
149
+ export function generateSecretsReport(
150
+ encryptedApps: Map<string, EncryptedAppSecrets>,
151
+ sniffedApps: Map<string, SniffedEnvironment>,
152
+ ): SecretsReport {
153
+ const appsWithSecrets: string[] = [];
154
+ const appsWithoutSecrets: string[] = [];
155
+ const appsWithMissingSecrets: Array<{ appName: string; missing: string[] }> = [];
156
+
157
+ for (const [appName, sniffedEnv] of sniffedApps) {
158
+ if (sniffedEnv.requiredEnvVars.length === 0) {
159
+ appsWithoutSecrets.push(appName);
160
+ continue;
161
+ }
162
+
163
+ const encrypted = encryptedApps.get(appName);
164
+ if (encrypted) {
165
+ appsWithSecrets.push(appName);
166
+
167
+ if (encrypted.missingSecrets.length > 0) {
168
+ appsWithMissingSecrets.push({
169
+ appName,
170
+ missing: encrypted.missingSecrets,
171
+ });
172
+ }
173
+ }
174
+ }
175
+
176
+ return {
177
+ totalApps: sniffedApps.size,
178
+ appsWithSecrets: appsWithSecrets.sort(),
179
+ appsWithoutSecrets: appsWithoutSecrets.sort(),
180
+ appsWithMissingSecrets,
181
+ };
182
+ }
@@ -0,0 +1,180 @@
1
+ import { resolve } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import type { SniffResult } from '@geekmidas/envkit/sniffer';
4
+ import type { NormalizedAppConfig } from '../workspace/types.js';
5
+
6
+ // Re-export SniffResult for consumers
7
+ export type { SniffResult } from '@geekmidas/envkit/sniffer';
8
+
9
+ /**
10
+ * Result of sniffing an app's environment requirements.
11
+ */
12
+ export interface SniffedEnvironment {
13
+ appName: string;
14
+ requiredEnvVars: string[];
15
+ }
16
+
17
+ /**
18
+ * Options for sniffing an app's environment.
19
+ */
20
+ export interface SniffAppOptions {
21
+ /** Whether to log warnings for errors encountered during sniffing. Defaults to true. */
22
+ logWarnings?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Get required environment variables for an app.
27
+ *
28
+ * Detection strategy:
29
+ * - Frontend apps: Returns empty (no server secrets)
30
+ * - Apps with `requiredEnv`: Uses explicit list from config
31
+ * - Apps with `envParser`: Runs SnifferEnvironmentParser to detect usage
32
+ * - Apps with neither: Returns empty
33
+ *
34
+ * This function handles "fire and forget" async operations gracefully,
35
+ * capturing errors and unhandled rejections without failing the build.
36
+ *
37
+ * @param app - The normalized app configuration
38
+ * @param appName - The name of the app
39
+ * @param workspacePath - Absolute path to the workspace root
40
+ * @param options - Optional configuration for sniffing behavior
41
+ * @returns The sniffed environment with required variables
42
+ */
43
+ export async function sniffAppEnvironment(
44
+ app: NormalizedAppConfig,
45
+ appName: string,
46
+ workspacePath: string,
47
+ options: SniffAppOptions = {},
48
+ ): Promise<SniffedEnvironment> {
49
+ const { logWarnings = true } = options;
50
+
51
+ // Frontend apps don't have server-side secrets
52
+ if (app.type === 'frontend') {
53
+ return { appName, requiredEnvVars: [] };
54
+ }
55
+
56
+ // Entry-based apps with explicit env list
57
+ if (app.requiredEnv && app.requiredEnv.length > 0) {
58
+ return { appName, requiredEnvVars: [...app.requiredEnv] };
59
+ }
60
+
61
+ // Apps with envParser - run sniffer to detect env var usage
62
+ if (app.envParser) {
63
+ const result = await sniffEnvParser(app.envParser, app.path, workspacePath);
64
+
65
+ // Log any issues for debugging
66
+ if (logWarnings) {
67
+ if (result.error) {
68
+ console.warn(
69
+ `[sniffer] ${appName}: envParser threw error during sniffing (env vars still captured): ${result.error.message}`,
70
+ );
71
+ }
72
+ if (result.unhandledRejections.length > 0) {
73
+ console.warn(
74
+ `[sniffer] ${appName}: Fire-and-forget rejections during sniffing (suppressed): ${result.unhandledRejections.map((e) => e.message).join(', ')}`,
75
+ );
76
+ }
77
+ }
78
+
79
+ return { appName, requiredEnvVars: result.envVars };
80
+ }
81
+
82
+ // No env detection method available
83
+ return { appName, requiredEnvVars: [] };
84
+ }
85
+
86
+ /**
87
+ * Run the SnifferEnvironmentParser on an envParser module to detect
88
+ * which environment variables it accesses.
89
+ *
90
+ * This function handles "fire and forget" async operations by using
91
+ * the shared sniffWithFireAndForget utility from @geekmidas/envkit.
92
+ *
93
+ * @param envParserPath - The envParser config (e.g., './src/config/env#envParser')
94
+ * @param appPath - The app's path relative to workspace
95
+ * @param workspacePath - Absolute path to workspace root
96
+ * @returns SniffResult with env vars and any errors encountered
97
+ */
98
+ async function sniffEnvParser(
99
+ envParserPath: string,
100
+ appPath: string,
101
+ workspacePath: string,
102
+ ): Promise<SniffResult> {
103
+ // Parse the envParser path: './src/config/env#envParser' or './src/config/env'
104
+ const [modulePath, exportName = 'default'] = envParserPath.split('#');
105
+ if (!modulePath) {
106
+ return { envVars: [], unhandledRejections: [] };
107
+ }
108
+
109
+ // Resolve the full path to the module
110
+ const fullPath = resolve(workspacePath, appPath, modulePath);
111
+
112
+ // Dynamically import the sniffer utilities
113
+ let SnifferEnvironmentParser: any;
114
+ let sniffWithFireAndForget: any;
115
+ try {
116
+ const envkitModule = await import('@geekmidas/envkit/sniffer');
117
+ SnifferEnvironmentParser = envkitModule.SnifferEnvironmentParser;
118
+ sniffWithFireAndForget = envkitModule.sniffWithFireAndForget;
119
+ } catch (error) {
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ console.warn(`[sniffer] Failed to import SnifferEnvironmentParser: ${message}`);
122
+ return { envVars: [], unhandledRejections: [] };
123
+ }
124
+
125
+ const sniffer = new SnifferEnvironmentParser();
126
+
127
+ return sniffWithFireAndForget(sniffer, async () => {
128
+ // Import the envParser module
129
+ const moduleUrl = pathToFileURL(fullPath).href;
130
+ const module = await import(moduleUrl);
131
+
132
+ // Get the envParser function
133
+ const envParser = module[exportName];
134
+ if (typeof envParser !== 'function') {
135
+ console.warn(
136
+ `[sniffer] Export "${exportName}" from "${modulePath}" is not a function`,
137
+ );
138
+ return;
139
+ }
140
+
141
+ // The envParser function typically creates and configures an EnvironmentParser.
142
+ // We pass our sniffer which implements the same interface.
143
+ const result = envParser(sniffer);
144
+
145
+ // If the result is a ConfigParser, call parse() to trigger env var access
146
+ if (result && typeof result.parse === 'function') {
147
+ try {
148
+ result.parse();
149
+ } catch {
150
+ // Parsing may fail due to mock values, that's expected
151
+ }
152
+ }
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Sniff environment requirements for multiple apps.
158
+ *
159
+ * @param apps - Map of app name to app config
160
+ * @param workspacePath - Absolute path to workspace root
161
+ * @param options - Optional configuration for sniffing behavior
162
+ * @returns Map of app name to sniffed environment
163
+ */
164
+ export async function sniffAllApps(
165
+ apps: Record<string, NormalizedAppConfig>,
166
+ workspacePath: string,
167
+ options: SniffAppOptions = {},
168
+ ): Promise<Map<string, SniffedEnvironment>> {
169
+ const results = new Map<string, SniffedEnvironment>();
170
+
171
+ for (const [appName, app] of Object.entries(apps)) {
172
+ const sniffed = await sniffAppEnvironment(app, appName, workspacePath, options);
173
+ results.set(appName, sniffed);
174
+ }
175
+
176
+ return results;
177
+ }
178
+
179
+ // Export for testing
180
+ export { sniffEnvParser as _sniffEnvParser };
package/src/dev/index.ts CHANGED
@@ -341,6 +341,17 @@ export async function devCommand(options: DevOptions): Promise<void> {
341
341
  workspaceAppName = appConfig.appName;
342
342
  workspaceAppPort = appConfig.app.port;
343
343
  logger.log(`📦 Running app: ${appConfig.appName} on port ${workspaceAppPort}`);
344
+
345
+ // Check if app has an entry point (non-gkm app like better-auth)
346
+ if (appConfig.app.entry) {
347
+ logger.log(`📄 Using entry point: ${appConfig.app.entry}`);
348
+ return entryDevCommand({
349
+ ...options,
350
+ entry: appConfig.app.entry,
351
+ port: workspaceAppPort,
352
+ portExplicit: true,
353
+ });
354
+ }
344
355
  } catch {
345
356
  // Not in a workspace or app not found in workspace - fall back to regular loading
346
357
  const loadedConfig = await loadWorkspaceConfig();
@@ -1272,6 +1283,44 @@ export function findSecretsRoot(startDir: string): string {
1272
1283
  return startDir;
1273
1284
  }
1274
1285
 
1286
+ /**
1287
+ * Generate the credentials injection code snippet.
1288
+ * This is the common logic used by both entry wrapper and exec preload.
1289
+ * @internal
1290
+ */
1291
+ function generateCredentialsInjection(secretsJsonPath: string): string {
1292
+ return `import { Credentials } from '@geekmidas/envkit/credentials';
1293
+ import { existsSync, readFileSync } from 'node:fs';
1294
+
1295
+ // Inject dev secrets into Credentials
1296
+ const secretsPath = '${secretsJsonPath}';
1297
+ if (existsSync(secretsPath)) {
1298
+ const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
1299
+ Object.assign(Credentials, secrets);
1300
+ // Debug: uncomment to verify preload is running
1301
+ // console.log('[gkm preload] Injected', Object.keys(secrets).length, 'credentials');
1302
+ }
1303
+ `;
1304
+ }
1305
+
1306
+ /**
1307
+ * Create a preload script that injects secrets into Credentials.
1308
+ * Used by `gkm exec` to inject secrets before running any command.
1309
+ * @internal Exported for testing
1310
+ */
1311
+ export async function createCredentialsPreload(
1312
+ preloadPath: string,
1313
+ secretsJsonPath: string,
1314
+ ): Promise<void> {
1315
+ const content = `/**
1316
+ * Credentials preload generated by 'gkm exec'
1317
+ * This file is loaded via NODE_OPTIONS="--import <path>"
1318
+ */
1319
+ ${generateCredentialsInjection(secretsJsonPath)}`;
1320
+
1321
+ await writeFile(preloadPath, content);
1322
+ }
1323
+
1275
1324
  /**
1276
1325
  * Create a wrapper script that injects secrets before importing the entry file.
1277
1326
  * @internal Exported for testing
@@ -1282,15 +1331,7 @@ export async function createEntryWrapper(
1282
1331
  secretsJsonPath?: string,
1283
1332
  ): Promise<void> {
1284
1333
  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
-
1334
+ ? `${generateCredentialsInjection(secretsJsonPath)}
1294
1335
  `
1295
1336
  : '';
1296
1337
 
@@ -1789,3 +1830,108 @@ start({
1789
1830
  await fsWriteFile(serverPath, content);
1790
1831
  }
1791
1832
  }
1833
+
1834
+ /**
1835
+ * Options for the exec command.
1836
+ */
1837
+ export interface ExecOptions {
1838
+ /** Working directory */
1839
+ cwd?: string;
1840
+ }
1841
+
1842
+ /**
1843
+ * Run a command with secrets injected into Credentials.
1844
+ * Uses Node's --import flag to preload a script that populates Credentials
1845
+ * before the command loads any modules that depend on them.
1846
+ *
1847
+ * @example
1848
+ * ```bash
1849
+ * gkm exec -- npx @better-auth/cli migrate
1850
+ * gkm exec -- npx prisma migrate dev
1851
+ * ```
1852
+ */
1853
+ export async function execCommand(
1854
+ commandArgs: string[],
1855
+ options: ExecOptions = {},
1856
+ ): Promise<void> {
1857
+ const cwd = options.cwd ?? process.cwd();
1858
+
1859
+ if (commandArgs.length === 0) {
1860
+ throw new Error('No command specified. Usage: gkm exec -- <command>');
1861
+ }
1862
+
1863
+ // Load .env files
1864
+ const defaultEnv = loadEnvFiles('.env');
1865
+ if (defaultEnv.loaded.length > 0) {
1866
+ logger.log(`📦 Loaded env: ${defaultEnv.loaded.join(', ')}`);
1867
+ }
1868
+
1869
+ // Prepare credentials (loads workspace config and secrets)
1870
+ // Don't inject PORT for exec since we're not running a server
1871
+ const { credentials, secretsJsonPath, appName } =
1872
+ await prepareEntryCredentials({ cwd });
1873
+
1874
+ if (appName) {
1875
+ logger.log(`📦 App: ${appName}`);
1876
+ }
1877
+
1878
+ const secretCount = Object.keys(credentials).filter(
1879
+ (k) => k !== 'PORT',
1880
+ ).length;
1881
+ if (secretCount > 0) {
1882
+ logger.log(`🔐 Loaded ${secretCount} secret(s)`);
1883
+ }
1884
+
1885
+ // Create preload script that injects Credentials
1886
+ // Create in cwd so package resolution works (finds node_modules in app directory)
1887
+ const preloadDir = join(cwd, '.gkm');
1888
+ await mkdir(preloadDir, { recursive: true });
1889
+ const preloadPath = join(preloadDir, 'credentials-preload.ts');
1890
+ await createCredentialsPreload(preloadPath, secretsJsonPath);
1891
+
1892
+ // Build command
1893
+ const [cmd, ...args] = commandArgs;
1894
+
1895
+ if (!cmd) {
1896
+ throw new Error('No command specified');
1897
+ }
1898
+
1899
+ logger.log(`🚀 Running: ${commandArgs.join(' ')}`);
1900
+
1901
+ // Merge NODE_OPTIONS with existing value (if any)
1902
+ // Add tsx loader first so our .ts preload can be loaded
1903
+ const existingNodeOptions = process.env.NODE_OPTIONS ?? '';
1904
+ const tsxImport = '--import tsx';
1905
+ const preloadImport = `--import ${preloadPath}`;
1906
+
1907
+ // Build NODE_OPTIONS: existing + tsx loader + our preload
1908
+ const nodeOptions = [existingNodeOptions, tsxImport, preloadImport]
1909
+ .filter(Boolean)
1910
+ .join(' ');
1911
+
1912
+ // Spawn the command with secrets in both:
1913
+ // 1. Environment variables (for tools that read process.env directly)
1914
+ // 2. Preload script (for tools that use Credentials object)
1915
+ const child = spawn(cmd, args, {
1916
+ cwd,
1917
+ stdio: 'inherit',
1918
+ env: {
1919
+ ...process.env,
1920
+ ...credentials, // Inject secrets as env vars
1921
+ NODE_OPTIONS: nodeOptions,
1922
+ },
1923
+ });
1924
+
1925
+ // Wait for the command to complete
1926
+ const exitCode = await new Promise<number>((resolve) => {
1927
+ child.on('close', (code: number | null) => resolve(code ?? 0));
1928
+ child.on('error', (error: Error) => {
1929
+ logger.error(`Failed to run command: ${error.message}`);
1930
+ resolve(1);
1931
+ });
1932
+ });
1933
+
1934
+ if (exitCode !== 0) {
1935
+ process.exit(exitCode);
1936
+ }
1937
+ }
@@ -15,6 +15,7 @@ import {
15
15
  generateBackendDockerfile,
16
16
  generateDockerEntrypoint,
17
17
  generateDockerignore,
18
+ generateEntryDockerfile,
18
19
  generateMultiStageDockerfile,
19
20
  generateNextjsDockerfile,
20
21
  generateSlimDockerfile,
@@ -400,7 +401,9 @@ export async function workspaceDockerCommand(
400
401
  // Determine image name
401
402
  const imageName = appName;
402
403
 
403
- logger.log(`\n 📄 Generating Dockerfile for ${appName} (${app.type})`);
404
+ const hasEntry = !!app.entry;
405
+ const buildType = hasEntry ? 'entry' : app.type;
406
+ logger.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
404
407
 
405
408
  let dockerfile: string;
406
409
 
@@ -414,8 +417,20 @@ export async function workspaceDockerCommand(
414
417
  turboPackage,
415
418
  packageManager,
416
419
  });
420
+ } else if (app.entry) {
421
+ // Backend with custom entry point - use tsdown bundling
422
+ dockerfile = generateEntryDockerfile({
423
+ imageName,
424
+ baseImage: 'node:22-alpine',
425
+ port: app.port,
426
+ appPath,
427
+ entry: app.entry,
428
+ turboPackage,
429
+ packageManager,
430
+ healthCheckPath: '/health',
431
+ });
417
432
  } else {
418
- // Generate backend Dockerfile
433
+ // Backend with gkm routes - use gkm build
419
434
  dockerfile = generateBackendDockerfile({
420
435
  imageName,
421
436
  baseImage: 'node:22-alpine',