@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,221 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { NormalizedAppConfig } from '../../workspace/types';
3
+ import { sniffAllApps, sniffAppEnvironment, type SniffResult } from '../sniffer';
4
+
5
+ describe('sniffAppEnvironment', () => {
6
+ const workspacePath = '/test/workspace';
7
+
8
+ const createApp = (
9
+ overrides: Partial<NormalizedAppConfig> = {},
10
+ ): NormalizedAppConfig => ({
11
+ type: 'backend',
12
+ path: 'apps/api',
13
+ port: 3000,
14
+ dependencies: [],
15
+ resolvedDeployTarget: 'dokploy',
16
+ ...overrides,
17
+ });
18
+
19
+ describe('frontend apps', () => {
20
+ it('should return empty env vars for frontend apps', async () => {
21
+ const app = createApp({ type: 'frontend' });
22
+
23
+ const result = await sniffAppEnvironment(app, 'web', workspacePath);
24
+
25
+ expect(result.appName).toBe('web');
26
+ expect(result.requiredEnvVars).toEqual([]);
27
+ });
28
+
29
+ it('should ignore requiredEnv for frontend apps', async () => {
30
+ const app = createApp({
31
+ type: 'frontend',
32
+ requiredEnv: ['API_KEY', 'SECRET'], // Should be ignored
33
+ });
34
+
35
+ const result = await sniffAppEnvironment(app, 'web', workspacePath);
36
+
37
+ expect(result.requiredEnvVars).toEqual([]);
38
+ });
39
+ });
40
+
41
+ describe('entry-based apps with requiredEnv', () => {
42
+ it('should return requiredEnv list for entry-based apps', async () => {
43
+ const app = createApp({
44
+ entry: './src/index.ts',
45
+ requiredEnv: ['DATABASE_URL', 'BETTER_AUTH_SECRET'],
46
+ });
47
+
48
+ const result = await sniffAppEnvironment(app, 'auth', workspacePath);
49
+
50
+ expect(result.appName).toBe('auth');
51
+ expect(result.requiredEnvVars).toEqual([
52
+ 'DATABASE_URL',
53
+ 'BETTER_AUTH_SECRET',
54
+ ]);
55
+ });
56
+
57
+ it('should return copy of requiredEnv (not reference)', async () => {
58
+ const requiredEnv = ['DATABASE_URL'];
59
+ const app = createApp({ requiredEnv });
60
+
61
+ const result = await sniffAppEnvironment(app, 'api', workspacePath);
62
+
63
+ // Modify the result and verify original is unchanged
64
+ result.requiredEnvVars.push('MODIFIED');
65
+ expect(requiredEnv).toEqual(['DATABASE_URL']);
66
+ });
67
+
68
+ it('should return empty when requiredEnv is empty array', async () => {
69
+ const app = createApp({ requiredEnv: [] });
70
+
71
+ const result = await sniffAppEnvironment(app, 'api', workspacePath);
72
+
73
+ expect(result.requiredEnvVars).toEqual([]);
74
+ });
75
+ });
76
+
77
+ describe('apps with envParser', () => {
78
+ it('should return empty when envParser module cannot be loaded', async () => {
79
+ const app = createApp({
80
+ envParser: './src/nonexistent/env#parser',
81
+ });
82
+
83
+ // This will fail to load the module and return empty
84
+ // Suppress warnings for this test
85
+ const result = await sniffAppEnvironment(app, 'api', workspacePath, {
86
+ logWarnings: false,
87
+ });
88
+
89
+ expect(result.requiredEnvVars).toEqual([]);
90
+ });
91
+
92
+ it('should gracefully handle errors without failing the build', async () => {
93
+ const app = createApp({
94
+ envParser: './src/invalid/path#nonexistent',
95
+ });
96
+
97
+ // Should not throw, just return empty
98
+ const result = await sniffAppEnvironment(app, 'api', workspacePath, {
99
+ logWarnings: false,
100
+ });
101
+
102
+ expect(result.appName).toBe('api');
103
+ expect(result.requiredEnvVars).toEqual([]);
104
+ });
105
+ });
106
+
107
+ describe('apps without env detection', () => {
108
+ it('should return empty when no envParser or requiredEnv', async () => {
109
+ const app = createApp({
110
+ // No envParser or requiredEnv
111
+ });
112
+
113
+ const result = await sniffAppEnvironment(app, 'api', workspacePath);
114
+
115
+ expect(result.requiredEnvVars).toEqual([]);
116
+ });
117
+ });
118
+ });
119
+
120
+ describe('sniffAllApps', () => {
121
+ const workspacePath = '/test/workspace';
122
+
123
+ it('should sniff all apps in workspace', async () => {
124
+ const apps: Record<string, NormalizedAppConfig> = {
125
+ api: {
126
+ type: 'backend',
127
+ path: 'apps/api',
128
+ port: 3000,
129
+ dependencies: [],
130
+ resolvedDeployTarget: 'dokploy',
131
+ requiredEnv: ['DATABASE_URL', 'REDIS_URL'],
132
+ },
133
+ auth: {
134
+ type: 'backend',
135
+ path: 'apps/auth',
136
+ port: 3002,
137
+ dependencies: [],
138
+ resolvedDeployTarget: 'dokploy',
139
+ requiredEnv: ['DATABASE_URL', 'BETTER_AUTH_SECRET'],
140
+ },
141
+ web: {
142
+ type: 'frontend',
143
+ path: 'apps/web',
144
+ port: 3001,
145
+ dependencies: ['api', 'auth'],
146
+ resolvedDeployTarget: 'dokploy',
147
+ },
148
+ };
149
+
150
+ const results = await sniffAllApps(apps, workspacePath);
151
+
152
+ expect(results.size).toBe(3);
153
+
154
+ expect(results.get('api')).toEqual({
155
+ appName: 'api',
156
+ requiredEnvVars: ['DATABASE_URL', 'REDIS_URL'],
157
+ });
158
+
159
+ expect(results.get('auth')).toEqual({
160
+ appName: 'auth',
161
+ requiredEnvVars: ['DATABASE_URL', 'BETTER_AUTH_SECRET'],
162
+ });
163
+
164
+ expect(results.get('web')).toEqual({
165
+ appName: 'web',
166
+ requiredEnvVars: [], // Frontend - no secrets
167
+ });
168
+ });
169
+
170
+ it('should handle empty apps record', async () => {
171
+ const apps: Record<string, NormalizedAppConfig> = {};
172
+
173
+ const results = await sniffAllApps(apps, workspacePath);
174
+
175
+ expect(results.size).toBe(0);
176
+ });
177
+
178
+ it('should pass options to individual app sniffing', async () => {
179
+ const apps: Record<string, NormalizedAppConfig> = {
180
+ api: {
181
+ type: 'backend',
182
+ path: 'apps/api',
183
+ port: 3000,
184
+ dependencies: [],
185
+ resolvedDeployTarget: 'dokploy',
186
+ envParser: './src/nonexistent/env#parser', // Will fail but shouldn't log
187
+ },
188
+ };
189
+
190
+ // Should not throw, and suppress warnings
191
+ const results = await sniffAllApps(apps, workspacePath, {
192
+ logWarnings: false,
193
+ });
194
+
195
+ expect(results.size).toBe(1);
196
+ expect(results.get('api')?.requiredEnvVars).toEqual([]);
197
+ });
198
+ });
199
+
200
+ describe('fire-and-forget handling', () => {
201
+ it('captures environment variables even when envParser throws', async () => {
202
+ // The sniffer should capture env vars that were accessed before an error
203
+ // This is the "fire and forget" pattern - errors don't stop env detection
204
+ const app: NormalizedAppConfig = {
205
+ type: 'backend',
206
+ path: 'apps/api',
207
+ port: 3000,
208
+ dependencies: [],
209
+ resolvedDeployTarget: 'dokploy',
210
+ envParser: './src/invalid#missing',
211
+ };
212
+
213
+ const result = await sniffAppEnvironment(app, 'api', '/test/workspace', {
214
+ logWarnings: false,
215
+ });
216
+
217
+ // Should return gracefully without throwing
218
+ expect(result.appName).toBe('api');
219
+ expect(Array.isArray(result.requiredEnvVars)).toBe(true);
220
+ });
221
+ });
@@ -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
  /**
@@ -76,6 +76,16 @@ export interface DockerDeployOptions {
76
76
  masterKey?: string;
77
77
  /** Docker config from gkm.config */
78
78
  config: DockerDeployConfig;
79
+ /**
80
+ * Build arguments to pass to docker build.
81
+ * Format: ['KEY=value', 'KEY2=value2']
82
+ */
83
+ buildArgs?: string[];
84
+ /**
85
+ * Public URL argument names for frontend Dockerfile generation.
86
+ * Used to ensure the Dockerfile declares these as ARG/ENV.
87
+ */
88
+ publicUrlArgs?: string[];
79
89
  }
80
90
 
81
91
  /**
@@ -94,15 +104,24 @@ export function getImageRef(
94
104
 
95
105
  /**
96
106
  * Build Docker image
107
+ * @param imageRef - Full image reference (registry/name:tag)
108
+ * @param appName - Name of the app (used for Dockerfile.{appName} in workspaces)
109
+ * @param buildArgs - Build arguments to pass to docker build
97
110
  */
98
- async function buildImage(imageRef: string): Promise<void> {
111
+ async function buildImage(
112
+ imageRef: string,
113
+ appName?: string,
114
+ buildArgs?: string[],
115
+ ): Promise<void> {
99
116
  logger.log(`\n🔨 Building Docker image: ${imageRef}`);
100
117
 
101
118
  const cwd = process.cwd();
102
- const inMonorepo = isMonorepo(cwd);
119
+ const lockfilePath = findLockfilePath(cwd);
120
+ const lockfileDir = lockfilePath ? dirname(lockfilePath) : cwd;
121
+ const inMonorepo = lockfileDir !== cwd;
103
122
 
104
123
  // Generate appropriate Dockerfile
105
- if (inMonorepo) {
124
+ if (appName || inMonorepo) {
106
125
  logger.log(' Generating Dockerfile for monorepo (turbo prune)...');
107
126
  } else {
108
127
  logger.log(' Generating Dockerfile...');
@@ -110,31 +129,41 @@ async function buildImage(imageRef: string): Promise<void> {
110
129
  await dockerCommand({});
111
130
 
112
131
  // 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
- }
132
+ // For workspaces with multiple apps, use per-app Dockerfile (Dockerfile.api, etc.)
133
+ const dockerfileSuffix = appName ? `.${appName}` : '';
134
+ const dockerfilePath = `.gkm/docker/Dockerfile${dockerfileSuffix}`;
135
+
136
+ // Build from workspace/monorepo root when we have a lockfile elsewhere or appName is provided
137
+ const buildCwd =
138
+ lockfilePath && (inMonorepo || appName) ? lockfileDir : cwd;
139
+ if (buildCwd !== cwd) {
140
+ logger.log(` Building from workspace root: ${buildCwd}`);
126
141
  }
127
142
 
143
+ // Build the build args string
144
+ const buildArgsString =
145
+ buildArgs && buildArgs.length > 0
146
+ ? buildArgs.map((arg) => `--build-arg "${arg}"`).join(' ')
147
+ : '';
148
+
128
149
  try {
129
150
  // Build for linux/amd64 to ensure compatibility with most cloud servers
130
- execSync(
131
- `DOCKER_BUILDKIT=1 docker build --platform linux/amd64 -f ${dockerfilePath} -t ${imageRef} .`,
132
- {
133
- cwd: buildCwd,
134
- stdio: 'inherit',
135
- env: { ...process.env, DOCKER_BUILDKIT: '1' },
136
- },
137
- );
151
+ const cmd = [
152
+ 'DOCKER_BUILDKIT=1 docker build',
153
+ '--platform linux/amd64',
154
+ `-f ${dockerfilePath}`,
155
+ `-t ${imageRef}`,
156
+ buildArgsString,
157
+ '.',
158
+ ]
159
+ .filter(Boolean)
160
+ .join(' ');
161
+
162
+ execSync(cmd, {
163
+ cwd: buildCwd,
164
+ stdio: 'inherit',
165
+ env: { ...process.env, DOCKER_BUILDKIT: '1' },
166
+ });
138
167
  logger.log(`✅ Image built: ${imageRef}`);
139
168
  } catch (error) {
140
169
  throw new Error(
@@ -168,14 +197,14 @@ async function pushImage(imageRef: string): Promise<void> {
168
197
  export async function deployDocker(
169
198
  options: DockerDeployOptions,
170
199
  ): Promise<DeployResult> {
171
- const { stage, tag, skipPush, masterKey, config } = options;
200
+ const { stage, tag, skipPush, masterKey, config, buildArgs } = options;
172
201
 
173
202
  // imageName should always be set by resolveDockerConfig
174
203
  const imageName = config.imageName!;
175
204
  const imageRef = getImageRef(config.registry, imageName, tag);
176
205
 
177
- // Build image
178
- await buildImage(imageRef);
206
+ // Build image (pass appName for workspace Dockerfile selection)
207
+ await buildImage(imageRef, config.appName, buildArgs);
179
208
 
180
209
  // Push to registry if not skipped
181
210
  if (!skipPush) {
@@ -458,6 +458,68 @@ export class DokployApi {
458
458
  ): Promise<void> {
459
459
  await this.post('redis.update', { redisId, ...updates });
460
460
  }
461
+
462
+ // ============================================
463
+ // Domain endpoints
464
+ // ============================================
465
+
466
+ /**
467
+ * Create a new domain for an application
468
+ */
469
+ async createDomain(options: DokployDomainCreate): Promise<DokployDomain> {
470
+ return this.post<DokployDomain>(
471
+ 'domain.create',
472
+ options as unknown as Record<string, unknown>,
473
+ );
474
+ }
475
+
476
+ /**
477
+ * Update an existing domain
478
+ */
479
+ async updateDomain(
480
+ domainId: string,
481
+ updates: Partial<DokployDomainCreate>,
482
+ ): Promise<void> {
483
+ await this.post('domain.update', { domainId, ...updates });
484
+ }
485
+
486
+ /**
487
+ * Delete a domain
488
+ */
489
+ async deleteDomain(domainId: string): Promise<void> {
490
+ await this.post('domain.delete', { domainId });
491
+ }
492
+
493
+ /**
494
+ * Get a domain by ID
495
+ */
496
+ async getDomain(domainId: string): Promise<DokployDomain> {
497
+ return this.get<DokployDomain>(`domain.one?domainId=${domainId}`);
498
+ }
499
+
500
+ /**
501
+ * Get all domains for an application
502
+ */
503
+ async getDomainsByApplicationId(
504
+ applicationId: string,
505
+ ): Promise<DokployDomain[]> {
506
+ return this.get<DokployDomain[]>(
507
+ `domain.byApplicationId?applicationId=${applicationId}`,
508
+ );
509
+ }
510
+
511
+ /**
512
+ * Auto-generate a domain name for an application
513
+ */
514
+ async generateDomain(
515
+ appName: string,
516
+ serverId?: string,
517
+ ): Promise<{ domain: string }> {
518
+ return this.post<{ domain: string }>('domain.generateDomain', {
519
+ appName,
520
+ serverId,
521
+ });
522
+ }
461
523
  }
462
524
 
463
525
  // ============================================
@@ -552,6 +614,43 @@ export interface DokployRedisUpdate {
552
614
  description: string;
553
615
  }
554
616
 
617
+ export type DokployCertificateType = 'letsencrypt' | 'none' | 'custom';
618
+ export type DokployDomainType = 'application' | 'compose' | 'preview';
619
+
620
+ export interface DokployDomainCreate {
621
+ /** Domain hostname (e.g., 'api.example.com') */
622
+ host: string;
623
+ /** URL path (optional, e.g., '/api') */
624
+ path?: string | null;
625
+ /** Container port to route to (1-65535) */
626
+ port?: number | null;
627
+ /** Enable HTTPS */
628
+ https?: boolean;
629
+ /** Associated application ID */
630
+ applicationId?: string | null;
631
+ /** Certificate type for HTTPS */
632
+ certificateType?: DokployCertificateType;
633
+ /** Custom certificate resolver name */
634
+ customCertResolver?: string | null;
635
+ /** Docker Compose service ID */
636
+ composeId?: string | null;
637
+ /** Service name for compose */
638
+ serviceName?: string | null;
639
+ /** Domain type */
640
+ domainType?: DokployDomainType | null;
641
+ /** Preview deployment ID */
642
+ previewDeploymentId?: string | null;
643
+ /** Internal routing path */
644
+ internalPath?: string | null;
645
+ /** Strip path from forwarded requests */
646
+ stripPath?: boolean;
647
+ }
648
+
649
+ export interface DokployDomain extends DokployDomainCreate {
650
+ domainId: string;
651
+ createdAt?: string;
652
+ }
653
+
555
654
  /**
556
655
  * Create a Dokploy API client from stored credentials or environment
557
656
  */
@@ -0,0 +1,125 @@
1
+ import type { DokployWorkspaceConfig, NormalizedAppConfig } from '../workspace/types.js';
2
+
3
+ /**
4
+ * Resolve the hostname for an app based on stage configuration.
5
+ *
6
+ * Domain resolution priority:
7
+ * 1. Explicit app.domain override (string or stage-specific)
8
+ * 2. Default pattern based on app type:
9
+ * - Main frontend app gets base domain (e.g., 'myapp.com')
10
+ * - Other apps get prefixed domain (e.g., 'api.myapp.com')
11
+ *
12
+ * @param appName - The name of the app
13
+ * @param app - The normalized app configuration
14
+ * @param stage - The deployment stage (e.g., 'production', 'development')
15
+ * @param dokployConfig - Dokploy workspace configuration with domain mappings
16
+ * @param isMainFrontend - Whether this is the main frontend app
17
+ * @returns The resolved hostname for the app
18
+ * @throws Error if no domain configuration is found for the stage
19
+ */
20
+ export function resolveHost(
21
+ appName: string,
22
+ app: NormalizedAppConfig,
23
+ stage: string,
24
+ dokployConfig: DokployWorkspaceConfig | undefined,
25
+ isMainFrontend: boolean,
26
+ ): string {
27
+ // 1. Check for explicit app domain override
28
+ if (app.domain) {
29
+ if (typeof app.domain === 'string') {
30
+ return app.domain;
31
+ }
32
+ if (app.domain[stage]) {
33
+ return app.domain[stage]!;
34
+ }
35
+ }
36
+
37
+ // 2. Get base domain for this stage
38
+ const baseDomain = dokployConfig?.domains?.[stage];
39
+ if (!baseDomain) {
40
+ throw new Error(
41
+ `No domain configured for stage "${stage}". ` +
42
+ `Add deploy.dokploy.domains.${stage} to gkm.config.ts`,
43
+ );
44
+ }
45
+
46
+ // 3. Main frontend app gets base domain, others get prefix
47
+ if (isMainFrontend) {
48
+ return baseDomain;
49
+ }
50
+
51
+ return `${appName}.${baseDomain}`;
52
+ }
53
+
54
+ /**
55
+ * Determine if an app is the "main" frontend (gets base domain).
56
+ *
57
+ * An app is considered the main frontend if:
58
+ * 1. It's named 'web' and is a frontend type
59
+ * 2. It's the first frontend app in the apps list
60
+ *
61
+ * @param appName - The name of the app to check
62
+ * @param app - The app configuration
63
+ * @param allApps - All apps in the workspace
64
+ * @returns True if this is the main frontend app
65
+ */
66
+ export function isMainFrontendApp(
67
+ appName: string,
68
+ app: NormalizedAppConfig,
69
+ allApps: Record<string, NormalizedAppConfig>,
70
+ ): boolean {
71
+ if (app.type !== 'frontend') {
72
+ return false;
73
+ }
74
+
75
+ // App named 'web' is always main
76
+ if (appName === 'web') {
77
+ return true;
78
+ }
79
+
80
+ // Otherwise, check if this is the first frontend
81
+ for (const [name, a] of Object.entries(allApps)) {
82
+ if (a.type === 'frontend') {
83
+ return name === appName;
84
+ }
85
+ }
86
+
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * Generate public URL build args for a frontend app based on its dependencies.
92
+ *
93
+ * @param app - The frontend app configuration
94
+ * @param deployedUrls - Map of app name to deployed public URL
95
+ * @returns Array of build args like 'NEXT_PUBLIC_API_URL=https://api.example.com'
96
+ */
97
+ export function generatePublicUrlBuildArgs(
98
+ app: NormalizedAppConfig,
99
+ deployedUrls: Record<string, string>,
100
+ ): string[] {
101
+ const buildArgs: string[] = [];
102
+
103
+ for (const dep of app.dependencies) {
104
+ const publicUrl = deployedUrls[dep];
105
+ if (publicUrl) {
106
+ // Convert app name to UPPER_SNAKE_CASE for env var
107
+ const envVarName = `NEXT_PUBLIC_${dep.toUpperCase()}_URL`;
108
+ buildArgs.push(`${envVarName}=${publicUrl}`);
109
+ }
110
+ }
111
+
112
+ return buildArgs;
113
+ }
114
+
115
+ /**
116
+ * Get public URL arg names from app dependencies.
117
+ *
118
+ * @param app - The frontend app configuration
119
+ * @returns Array of arg names like 'NEXT_PUBLIC_API_URL'
120
+ */
121
+ export function getPublicUrlArgNames(app: NormalizedAppConfig): string[] {
122
+ return app.dependencies.map(
123
+ (dep) => `NEXT_PUBLIC_${dep.toUpperCase()}_URL`,
124
+ );
125
+ }