@geekmidas/cli 0.39.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 (77) 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 +674 -122
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.mjs +653 -101
  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/deploy/__tests__/domain.spec.ts +231 -0
  58. package/src/deploy/__tests__/secrets.spec.ts +300 -0
  59. package/src/deploy/__tests__/sniffer.spec.ts +221 -0
  60. package/src/deploy/docker.ts +40 -11
  61. package/src/deploy/dokploy-api.ts +99 -0
  62. package/src/deploy/domain.ts +125 -0
  63. package/src/deploy/index.ts +366 -148
  64. package/src/deploy/secrets.ts +182 -0
  65. package/src/deploy/sniffer.ts +180 -0
  66. package/src/dev/index.ts +11 -0
  67. package/src/docker/index.ts +17 -2
  68. package/src/docker/templates.ts +171 -1
  69. package/src/init/versions.ts +2 -2
  70. package/src/workspace/index.ts +2 -0
  71. package/src/workspace/schema.ts +32 -6
  72. package/src/workspace/types.ts +64 -2
  73. package/tsconfig.tsbuildinfo +1 -1
  74. package/dist/dokploy-api-B0w17y4_.mjs +0 -3
  75. package/dist/dokploy-api-BnGeUqN4.cjs +0 -3
  76. package/dist/index-C7TkoYmt.d.mts.map +0 -1
  77. 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();
@@ -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',
@@ -25,6 +25,12 @@ export interface FrontendDockerfileOptions {
25
25
  turboPackage: string;
26
26
  /** Detected package manager */
27
27
  packageManager: PackageManager;
28
+ /**
29
+ * Public URL build args to include in the Dockerfile.
30
+ * These will be declared as ARG and converted to ENV for Next.js build.
31
+ * Example: ['NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_AUTH_URL']
32
+ */
33
+ publicUrlArgs?: string[];
28
34
  }
29
35
 
30
36
  export interface MultiStageDockerfileOptions extends DockerTemplateOptions {
@@ -568,7 +574,14 @@ export function resolveDockerConfig(
568
574
  export function generateNextjsDockerfile(
569
575
  options: FrontendDockerfileOptions,
570
576
  ): string {
571
- const { baseImage, port, appPath, turboPackage, packageManager } = options;
577
+ const {
578
+ baseImage,
579
+ port,
580
+ appPath,
581
+ turboPackage,
582
+ packageManager,
583
+ publicUrlArgs = ['NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_AUTH_URL'],
584
+ } = options;
572
585
 
573
586
  const pm = getPmConfig(packageManager);
574
587
  const installPm = pm.install ? `RUN ${pm.install}` : '';
@@ -580,6 +593,14 @@ export function generateNextjsDockerfile(
580
593
  // Use pnpm dlx for pnpm (avoids global bin dir issues in Docker)
581
594
  const turboCmd = packageManager === 'pnpm' ? 'pnpm dlx turbo' : 'npx turbo';
582
595
 
596
+ // Generate ARG and ENV declarations for public URLs
597
+ const publicUrlArgDeclarations = publicUrlArgs
598
+ .map((arg) => `ARG ${arg}=""`)
599
+ .join('\n');
600
+ const publicUrlEnvDeclarations = publicUrlArgs
601
+ .map((arg) => `ENV ${arg}=$${arg}`)
602
+ .join('\n');
603
+
583
604
  return `# syntax=docker/dockerfile:1
584
605
  # Next.js standalone Dockerfile with turbo prune optimization
585
606
 
@@ -615,6 +636,13 @@ FROM deps AS builder
615
636
 
616
637
  WORKDIR /app
617
638
 
639
+ # Build-time args for public API URLs (populated by gkm deploy)
640
+ # These get baked into the Next.js build as public environment variables
641
+ ${publicUrlArgDeclarations}
642
+
643
+ # Convert ARGs to ENVs for Next.js build
644
+ ${publicUrlEnvDeclarations}
645
+
618
646
  # Copy pruned source
619
647
  COPY --from=pruner /app/out/full/ ./
620
648
 
@@ -717,9 +745,20 @@ FROM deps AS builder
717
745
 
718
746
  WORKDIR /app
719
747
 
748
+ # Build-time args for encrypted secrets
749
+ ARG GKM_ENCRYPTED_CREDENTIALS=""
750
+ ARG GKM_CREDENTIALS_IV=""
751
+
720
752
  # Copy pruned source
721
753
  COPY --from=pruner /app/out/full/ ./
722
754
 
755
+ # Write encrypted credentials for gkm build to embed
756
+ RUN if [ -n "$GKM_ENCRYPTED_CREDENTIALS" ]; then \
757
+ mkdir -p ${appPath}/.gkm && \
758
+ echo "$GKM_ENCRYPTED_CREDENTIALS" > ${appPath}/.gkm/credentials.enc && \
759
+ echo "$GKM_CREDENTIALS_IV" > ${appPath}/.gkm/credentials.iv; \
760
+ fi
761
+
723
762
  # Build production server using gkm
724
763
  RUN cd ${appPath} && ./node_modules/.bin/gkm build --provider server --production
725
764
 
@@ -750,3 +789,134 @@ ENTRYPOINT ["/sbin/tini", "--"]
750
789
  CMD ["node", "server.mjs"]
751
790
  `;
752
791
  }
792
+
793
+ /**
794
+ * Options for entry-based Dockerfile generation.
795
+ */
796
+ export interface EntryDockerfileOptions {
797
+ imageName: string;
798
+ baseImage: string;
799
+ port: number;
800
+ /** App path relative to workspace root */
801
+ appPath: string;
802
+ /** Entry file path relative to app path (e.g., './src/index.ts') */
803
+ entry: string;
804
+ /** Package name for turbo prune */
805
+ turboPackage: string;
806
+ /** Detected package manager */
807
+ packageManager: PackageManager;
808
+ /** Health check path (default: '/health') */
809
+ healthCheckPath?: string;
810
+ }
811
+
812
+ /**
813
+ * Generate a Dockerfile for apps with a custom entry point.
814
+ * Uses tsdown to bundle the entry point into dist/index.mjs.
815
+ * This is used for apps that don't use gkm routes (e.g., Better Auth servers).
816
+ * @internal Exported for testing
817
+ */
818
+ export function generateEntryDockerfile(options: EntryDockerfileOptions): string {
819
+ const {
820
+ baseImage,
821
+ port,
822
+ appPath,
823
+ entry,
824
+ turboPackage,
825
+ packageManager,
826
+ healthCheckPath = '/health',
827
+ } = options;
828
+
829
+ const pm = getPmConfig(packageManager);
830
+ const installPm = pm.install ? `RUN ${pm.install}` : '';
831
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
832
+ const turboCmd = packageManager === 'pnpm' ? 'pnpm dlx turbo' : 'npx turbo';
833
+
834
+ return `# syntax=docker/dockerfile:1
835
+ # Entry-based Dockerfile with turbo prune + tsdown bundling
836
+
837
+ # Stage 1: Prune monorepo
838
+ FROM ${baseImage} AS pruner
839
+
840
+ WORKDIR /app
841
+
842
+ ${installPm}
843
+
844
+ COPY . .
845
+
846
+ # Prune to only include necessary packages
847
+ RUN ${turboCmd} prune ${turboPackage} --docker
848
+
849
+ # Stage 2: Install dependencies
850
+ FROM ${baseImage} AS deps
851
+
852
+ WORKDIR /app
853
+
854
+ ${installPm}
855
+
856
+ # Copy pruned lockfile and package.jsons
857
+ COPY --from=pruner /app/out/${pm.lockfile} ./
858
+ COPY --from=pruner /app/out/json/ ./
859
+
860
+ # Install dependencies
861
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
862
+ ${turboInstallCmd}
863
+
864
+ # Stage 3: Build with tsdown
865
+ FROM deps AS builder
866
+
867
+ WORKDIR /app
868
+
869
+ # Build-time args for encrypted secrets
870
+ ARG GKM_ENCRYPTED_CREDENTIALS=""
871
+ ARG GKM_CREDENTIALS_IV=""
872
+
873
+ # Copy pruned source
874
+ COPY --from=pruner /app/out/full/ ./
875
+
876
+ # Write encrypted credentials for tsdown to embed via define
877
+ RUN if [ -n "$GKM_ENCRYPTED_CREDENTIALS" ]; then \
878
+ mkdir -p ${appPath}/.gkm && \
879
+ echo "$GKM_ENCRYPTED_CREDENTIALS" > ${appPath}/.gkm/credentials.enc && \
880
+ echo "$GKM_CREDENTIALS_IV" > ${appPath}/.gkm/credentials.iv; \
881
+ fi
882
+
883
+ # Bundle entry point with tsdown (outputs to dist/index.mjs)
884
+ # Use define to embed credentials if present
885
+ RUN cd ${appPath} && \
886
+ if [ -f .gkm/credentials.enc ]; then \
887
+ CREDS=$(cat .gkm/credentials.enc) && \
888
+ IV=$(cat .gkm/credentials.iv) && \
889
+ npx tsdown ${entry} --outDir dist --format esm \
890
+ --define __GKM_ENCRYPTED_CREDENTIALS__="'\\"$CREDS\\"'" \
891
+ --define __GKM_CREDENTIALS_IV__="'\\"$IV\\"'"; \
892
+ else \
893
+ npx tsdown ${entry} --outDir dist --format esm; \
894
+ fi
895
+
896
+ # Stage 4: Production
897
+ FROM ${baseImage} AS runner
898
+
899
+ WORKDIR /app
900
+
901
+ RUN apk add --no-cache tini
902
+
903
+ RUN addgroup --system --gid 1001 nodejs && \\
904
+ adduser --system --uid 1001 app
905
+
906
+ # Copy bundled output only (no node_modules needed - fully bundled)
907
+ COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
908
+
909
+ ENV NODE_ENV=production
910
+ ENV PORT=${port}
911
+
912
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
913
+ CMD wget -q --spider http://localhost:${port}${healthCheckPath} || exit 1
914
+
915
+ USER app
916
+
917
+ EXPOSE ${port}
918
+
919
+ ENTRYPOINT ["/sbin/tini", "--"]
920
+ CMD ["node", "index.mjs"]
921
+ `;
922
+ }
@@ -32,10 +32,10 @@ export const GEEKMIDAS_VERSIONS = {
32
32
  '@geekmidas/cli': CLI_VERSION,
33
33
  '@geekmidas/client': '~0.5.0',
34
34
  '@geekmidas/cloud': '~0.2.0',
35
- '@geekmidas/constructs': '~0.6.0',
35
+ '@geekmidas/constructs': '~0.7.0',
36
36
  '@geekmidas/db': '~0.3.0',
37
37
  '@geekmidas/emailkit': '~0.2.0',
38
- '@geekmidas/envkit': '~0.5.0',
38
+ '@geekmidas/envkit': '~0.6.0',
39
39
  '@geekmidas/errors': '~0.1.0',
40
40
  '@geekmidas/events': '~0.2.0',
41
41
  '@geekmidas/logger': '~0.4.0',
@@ -34,11 +34,13 @@ export type {
34
34
  AppConfigInput,
35
35
  AppInput,
36
36
  AppsRecord,
37
+ BackendFramework,
37
38
  ClientConfig,
38
39
  ConstrainedApps,
39
40
  DeployConfig,
40
41
  DeployTarget,
41
42
  DokployWorkspaceConfig,
43
+ FrontendFramework,
42
44
  InferAppNames,
43
45
  InferredWorkspaceConfig,
44
46
  LoadedConfig,