@geekmidas/cli 0.39.0 → 0.41.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 (81) hide show
  1. package/dist/{bundler-CyHg1v_T.cjs → bundler-BB-kETMd.cjs} +20 -49
  2. package/dist/bundler-BB-kETMd.cjs.map +1 -0
  3. package/dist/{bundler-DQIuE3Kn.mjs → bundler-DGry2vaR.mjs} +22 -51
  4. package/dist/bundler-DGry2vaR.mjs.map +1 -0
  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 +698 -127
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.mjs +677 -106
  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/bundler.ts +27 -79
  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 +40 -11
  62. package/src/deploy/dokploy-api.ts +99 -0
  63. package/src/deploy/domain.ts +125 -0
  64. package/src/deploy/index.ts +366 -148
  65. package/src/deploy/secrets.ts +182 -0
  66. package/src/deploy/sniffer.ts +180 -0
  67. package/src/dev/index.ts +11 -0
  68. package/src/docker/index.ts +24 -5
  69. package/src/docker/templates.ts +187 -1
  70. package/src/init/templates/api.ts +4 -4
  71. package/src/init/versions.ts +2 -2
  72. package/src/workspace/index.ts +2 -0
  73. package/src/workspace/schema.ts +32 -6
  74. package/src/workspace/types.ts +64 -2
  75. package/tsconfig.tsbuildinfo +1 -1
  76. package/dist/bundler-CyHg1v_T.cjs.map +0 -1
  77. package/dist/bundler-DQIuE3Kn.mjs.map +0 -1
  78. package/dist/dokploy-api-B0w17y4_.mjs +0 -3
  79. package/dist/dokploy-api-BnGeUqN4.cjs +0 -3
  80. package/dist/index-C7TkoYmt.d.mts.map +0 -1
  81. 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();
@@ -1,5 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { copyFileSync, existsSync, unlinkSync } from 'node:fs';
2
+ import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
3
3
  import { mkdir, writeFile } from 'node:fs/promises';
4
4
  import { basename, join } from 'node:path';
5
5
  import { loadConfig, loadWorkspaceConfig } from '../config';
@@ -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,
@@ -360,8 +361,12 @@ export interface WorkspaceDockerResult {
360
361
  */
361
362
  function getAppPackageName(appPath: string): string | undefined {
362
363
  try {
363
- // eslint-disable-next-line @typescript-eslint/no-require-imports
364
- const pkg = require(`${appPath}/package.json`);
364
+ const pkgPath = join(appPath, 'package.json');
365
+ if (!existsSync(pkgPath)) {
366
+ return undefined;
367
+ }
368
+ const content = readFileSync(pkgPath, 'utf-8');
369
+ const pkg = JSON.parse(content);
365
370
  return pkg.name;
366
371
  } catch {
367
372
  return undefined;
@@ -400,7 +405,9 @@ export async function workspaceDockerCommand(
400
405
  // Determine image name
401
406
  const imageName = appName;
402
407
 
403
- logger.log(`\n 📄 Generating Dockerfile for ${appName} (${app.type})`);
408
+ const hasEntry = !!app.entry;
409
+ const buildType = hasEntry ? 'entry' : app.type;
410
+ logger.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
404
411
 
405
412
  let dockerfile: string;
406
413
 
@@ -414,8 +421,20 @@ export async function workspaceDockerCommand(
414
421
  turboPackage,
415
422
  packageManager,
416
423
  });
424
+ } else if (app.entry) {
425
+ // Backend with custom entry point - use tsdown bundling
426
+ dockerfile = generateEntryDockerfile({
427
+ imageName,
428
+ baseImage: 'node:22-alpine',
429
+ port: app.port,
430
+ appPath,
431
+ entry: app.entry,
432
+ turboPackage,
433
+ packageManager,
434
+ healthCheckPath: '/health',
435
+ });
417
436
  } else {
418
- // Generate backend Dockerfile
437
+ // Backend with gkm routes - use gkm build
419
438
  dockerfile = generateBackendDockerfile({
420
439
  imageName,
421
440
  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,9 +636,23 @@ 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
 
649
+ # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
650
+ # Using wildcard to make it optional for single-app projects
651
+ COPY --from=pruner /app/tsconfig.* ./
652
+
653
+ # Ensure public directory exists (may be empty for scaffolded projects)
654
+ RUN mkdir -p ${appPath}/public
655
+
621
656
  # Set Next.js to produce standalone output
622
657
  ENV NEXT_TELEMETRY_DISABLED=1
623
658
 
@@ -717,9 +752,25 @@ FROM deps AS builder
717
752
 
718
753
  WORKDIR /app
719
754
 
755
+ # Build-time args for encrypted secrets
756
+ ARG GKM_ENCRYPTED_CREDENTIALS=""
757
+ ARG GKM_CREDENTIALS_IV=""
758
+
720
759
  # Copy pruned source
721
760
  COPY --from=pruner /app/out/full/ ./
722
761
 
762
+ # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
763
+ # Using wildcard to make it optional for single-app projects
764
+ COPY --from=pruner /app/gkm.config.* ./
765
+ COPY --from=pruner /app/tsconfig.* ./
766
+
767
+ # Write encrypted credentials for gkm build to embed
768
+ RUN if [ -n "$GKM_ENCRYPTED_CREDENTIALS" ]; then \
769
+ mkdir -p ${appPath}/.gkm && \
770
+ echo "$GKM_ENCRYPTED_CREDENTIALS" > ${appPath}/.gkm/credentials.enc && \
771
+ echo "$GKM_CREDENTIALS_IV" > ${appPath}/.gkm/credentials.iv; \
772
+ fi
773
+
723
774
  # Build production server using gkm
724
775
  RUN cd ${appPath} && ./node_modules/.bin/gkm build --provider server --production
725
776
 
@@ -750,3 +801,138 @@ ENTRYPOINT ["/sbin/tini", "--"]
750
801
  CMD ["node", "server.mjs"]
751
802
  `;
752
803
  }
804
+
805
+ /**
806
+ * Options for entry-based Dockerfile generation.
807
+ */
808
+ export interface EntryDockerfileOptions {
809
+ imageName: string;
810
+ baseImage: string;
811
+ port: number;
812
+ /** App path relative to workspace root */
813
+ appPath: string;
814
+ /** Entry file path relative to app path (e.g., './src/index.ts') */
815
+ entry: string;
816
+ /** Package name for turbo prune */
817
+ turboPackage: string;
818
+ /** Detected package manager */
819
+ packageManager: PackageManager;
820
+ /** Health check path (default: '/health') */
821
+ healthCheckPath?: string;
822
+ }
823
+
824
+ /**
825
+ * Generate a Dockerfile for apps with a custom entry point.
826
+ * Uses tsdown to bundle the entry point into dist/index.mjs.
827
+ * This is used for apps that don't use gkm routes (e.g., Better Auth servers).
828
+ * @internal Exported for testing
829
+ */
830
+ export function generateEntryDockerfile(options: EntryDockerfileOptions): string {
831
+ const {
832
+ baseImage,
833
+ port,
834
+ appPath,
835
+ entry,
836
+ turboPackage,
837
+ packageManager,
838
+ healthCheckPath = '/health',
839
+ } = options;
840
+
841
+ const pm = getPmConfig(packageManager);
842
+ const installPm = pm.install ? `RUN ${pm.install}` : '';
843
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
844
+ const turboCmd = packageManager === 'pnpm' ? 'pnpm dlx turbo' : 'npx turbo';
845
+
846
+ return `# syntax=docker/dockerfile:1
847
+ # Entry-based Dockerfile with turbo prune + tsdown bundling
848
+
849
+ # Stage 1: Prune monorepo
850
+ FROM ${baseImage} AS pruner
851
+
852
+ WORKDIR /app
853
+
854
+ ${installPm}
855
+
856
+ COPY . .
857
+
858
+ # Prune to only include necessary packages
859
+ RUN ${turboCmd} prune ${turboPackage} --docker
860
+
861
+ # Stage 2: Install dependencies
862
+ FROM ${baseImage} AS deps
863
+
864
+ WORKDIR /app
865
+
866
+ ${installPm}
867
+
868
+ # Copy pruned lockfile and package.jsons
869
+ COPY --from=pruner /app/out/${pm.lockfile} ./
870
+ COPY --from=pruner /app/out/json/ ./
871
+
872
+ # Install dependencies
873
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
874
+ ${turboInstallCmd}
875
+
876
+ # Stage 3: Build with tsdown
877
+ FROM deps AS builder
878
+
879
+ WORKDIR /app
880
+
881
+ # Build-time args for encrypted secrets
882
+ ARG GKM_ENCRYPTED_CREDENTIALS=""
883
+ ARG GKM_CREDENTIALS_IV=""
884
+
885
+ # Copy pruned source
886
+ COPY --from=pruner /app/out/full/ ./
887
+
888
+ # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
889
+ # Using wildcard to make it optional for single-app projects
890
+ COPY --from=pruner /app/tsconfig.* ./
891
+
892
+ # Write encrypted credentials for tsdown to embed via define
893
+ RUN if [ -n "$GKM_ENCRYPTED_CREDENTIALS" ]; then \
894
+ mkdir -p ${appPath}/.gkm && \
895
+ echo "$GKM_ENCRYPTED_CREDENTIALS" > ${appPath}/.gkm/credentials.enc && \
896
+ echo "$GKM_CREDENTIALS_IV" > ${appPath}/.gkm/credentials.iv; \
897
+ fi
898
+
899
+ # Bundle entry point with tsdown (outputs to dist/index.mjs)
900
+ # Use define to embed credentials if present
901
+ RUN cd ${appPath} && \
902
+ if [ -f .gkm/credentials.enc ]; then \
903
+ CREDS=$(cat .gkm/credentials.enc) && \
904
+ IV=$(cat .gkm/credentials.iv) && \
905
+ npx tsdown ${entry} --outDir dist --format esm \
906
+ --define __GKM_ENCRYPTED_CREDENTIALS__="'\\"$CREDS\\"'" \
907
+ --define __GKM_CREDENTIALS_IV__="'\\"$IV\\"'"; \
908
+ else \
909
+ npx tsdown ${entry} --outDir dist --format esm; \
910
+ fi
911
+
912
+ # Stage 4: Production
913
+ FROM ${baseImage} AS runner
914
+
915
+ WORKDIR /app
916
+
917
+ RUN apk add --no-cache tini
918
+
919
+ RUN addgroup --system --gid 1001 nodejs && \\
920
+ adduser --system --uid 1001 app
921
+
922
+ # Copy bundled output only (no node_modules needed - fully bundled)
923
+ COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
924
+
925
+ ENV NODE_ENV=production
926
+ ENV PORT=${port}
927
+
928
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
929
+ CMD wget -q --spider http://localhost:${port}${healthCheckPath} || exit 1
930
+
931
+ USER app
932
+
933
+ EXPOSE ${port}
934
+
935
+ ENTRYPOINT ["/sbin/tini", "--"]
936
+ CMD ["node", "index.mjs"]
937
+ `;
938
+ }
@@ -131,8 +131,8 @@ export const listUsersEndpoint = e
131
131
  .output(ListUsersResponseSchema)
132
132
  .handle(async () => ({
133
133
  users: [
134
- { id: '1', name: 'Alice' },
135
- { id: '2', name: 'Bob' },
134
+ { id: '550e8400-e29b-41d4-a716-446655440001', name: 'Alice' },
135
+ { id: '550e8400-e29b-41d4-a716-446655440002', name: 'Bob' },
136
136
  ],
137
137
  }));
138
138
  `
@@ -161,12 +161,12 @@ export const listUsersEndpoint = e
161
161
  path: getRoutePath('users/get.ts'),
162
162
  content: modelsImport
163
163
  ? `import { e } from '@geekmidas/constructs/endpoints';
164
- import { z } from 'zod';
164
+ import { IdSchema } from '${modelsImport}/common';
165
165
  import { UserResponseSchema } from '${modelsImport}/user';
166
166
 
167
167
  export const getUserEndpoint = e
168
168
  .get('/users/:id')
169
- .params(z.object({ id: z.string() }))
169
+ .params({ id: IdSchema })
170
170
  .output(UserResponseSchema)
171
171
  .handle(async ({ params }) => ({
172
172
  id: params.id,