@geekmidas/cli 0.47.0 → 0.49.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 (50) hide show
  1. package/dist/{dokploy-api-CMWlWq7-.mjs → dokploy-api-94KzmTVf.mjs} +7 -7
  2. package/dist/dokploy-api-94KzmTVf.mjs.map +1 -0
  3. package/dist/dokploy-api-CItuaWTq.mjs +3 -0
  4. package/dist/dokploy-api-DBNE8MDt.cjs +3 -0
  5. package/dist/{dokploy-api-BnX2OxyF.cjs → dokploy-api-YD8WCQfW.cjs} +7 -7
  6. package/dist/dokploy-api-YD8WCQfW.cjs.map +1 -0
  7. package/dist/index.cjs +2390 -1890
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.mjs +2387 -1887
  10. package/dist/index.mjs.map +1 -1
  11. package/package.json +8 -6
  12. package/src/build/__tests__/handler-templates.spec.ts +947 -0
  13. package/src/deploy/__tests__/__fixtures__/entry-apps/async-entry.ts +24 -0
  14. package/src/deploy/__tests__/__fixtures__/entry-apps/nested-config-entry.ts +24 -0
  15. package/src/deploy/__tests__/__fixtures__/entry-apps/no-env-entry.ts +12 -0
  16. package/src/deploy/__tests__/__fixtures__/entry-apps/simple-entry.ts +14 -0
  17. package/src/deploy/__tests__/__fixtures__/entry-apps/throwing-entry.ts +16 -0
  18. package/src/deploy/__tests__/__fixtures__/env-parsers/non-function-export.ts +10 -0
  19. package/src/deploy/__tests__/__fixtures__/env-parsers/parseable-env-parser.ts +18 -0
  20. package/src/deploy/__tests__/__fixtures__/env-parsers/throwing-env-parser.ts +18 -0
  21. package/src/deploy/__tests__/__fixtures__/env-parsers/valid-env-parser.ts +16 -0
  22. package/src/deploy/__tests__/dns-verification.spec.ts +229 -0
  23. package/src/deploy/__tests__/dokploy-api.spec.ts +2 -3
  24. package/src/deploy/__tests__/domain.spec.ts +7 -3
  25. package/src/deploy/__tests__/env-resolver.spec.ts +469 -0
  26. package/src/deploy/__tests__/index.spec.ts +12 -12
  27. package/src/deploy/__tests__/secrets.spec.ts +4 -1
  28. package/src/deploy/__tests__/sniffer.spec.ts +326 -1
  29. package/src/deploy/__tests__/state.spec.ts +844 -0
  30. package/src/deploy/dns/hostinger-api.ts +9 -6
  31. package/src/deploy/dns/index.ts +115 -4
  32. package/src/deploy/docker.ts +1 -2
  33. package/src/deploy/dokploy-api.ts +20 -11
  34. package/src/deploy/domain.ts +5 -4
  35. package/src/deploy/env-resolver.ts +278 -0
  36. package/src/deploy/index.ts +534 -124
  37. package/src/deploy/secrets.ts +7 -2
  38. package/src/deploy/sniffer-envkit-patch.ts +43 -0
  39. package/src/deploy/sniffer-hooks.ts +52 -0
  40. package/src/deploy/sniffer-loader.ts +23 -0
  41. package/src/deploy/sniffer-worker.ts +74 -0
  42. package/src/deploy/sniffer.ts +136 -14
  43. package/src/deploy/state.ts +162 -1
  44. package/src/docker/templates.ts +10 -14
  45. package/src/init/versions.ts +3 -3
  46. package/tsconfig.tsbuildinfo +1 -1
  47. package/dist/dokploy-api-4a6h35VY.cjs +0 -3
  48. package/dist/dokploy-api-BnX2OxyF.cjs.map +0 -1
  49. package/dist/dokploy-api-CMWlWq7-.mjs.map +0 -1
  50. package/dist/dokploy-api-DQvi9iZa.mjs +0 -3
@@ -1,5 +1,53 @@
1
+ /**
2
+ * Deploy Module
3
+ *
4
+ * Handles deployment of GKM workspaces to various providers (Docker, Dokploy).
5
+ *
6
+ * ## Per-App Database Credentials
7
+ *
8
+ * When deploying to Dokploy with Postgres, this module creates per-app database
9
+ * users with isolated schemas. This follows the same pattern as local dev mode
10
+ * (docker/postgres/init.sh).
11
+ *
12
+ * ### How It Works
13
+ *
14
+ * 1. **Provisioning**: Creates Postgres service with master credentials
15
+ * 2. **User Creation**: For each backend app that needs DATABASE_URL:
16
+ * - Generates a unique password (stored in deploy state)
17
+ * - Creates a database user with that password
18
+ * - Assigns schema permissions based on app name
19
+ * 3. **Schema Assignment**:
20
+ * - `api` app: Uses `public` schema (shared tables)
21
+ * - Other apps (e.g., `auth`): Get their own schema with `search_path` set
22
+ * 4. **Environment Injection**: Each app receives its own DATABASE_URL
23
+ *
24
+ * ### Security
25
+ *
26
+ * - External Postgres port is enabled only during user creation, then disabled
27
+ * - Each app can only access its own schema
28
+ * - Credentials are stored in `.gkm/deploy-{stage}.json` (gitignored)
29
+ * - Subsequent deploys reuse existing credentials from state
30
+ *
31
+ * ### Example Flow
32
+ *
33
+ * ```
34
+ * gkm deploy --stage production
35
+ * ├─ Create Postgres (user: postgres, db: myproject)
36
+ * ├─ Enable external port temporarily
37
+ * ├─ Create user "api" → public schema
38
+ * ├─ Create user "auth" → auth schema (search_path=auth)
39
+ * ├─ Disable external port
40
+ * ├─ Deploy "api" with DATABASE_URL=postgresql://api:xxx@postgres:5432/myproject
41
+ * └─ Deploy "auth" with DATABASE_URL=postgresql://auth:yyy@postgres:5432/myproject
42
+ * ```
43
+ *
44
+ * @module deploy
45
+ */
46
+
47
+ import { randomBytes } from 'node:crypto';
1
48
  import { stdin as input, stdout as output } from 'node:process';
2
49
  import * as readline from 'node:readline/promises';
50
+ import { Client as PgClient } from 'pg';
3
51
  import {
4
52
  getDokployCredentials,
5
53
  getDokployRegistryId,
@@ -16,6 +64,11 @@ import {
16
64
  isDeployTargetSupported,
17
65
  } from '../workspace/index.js';
18
66
  import type { NormalizedWorkspace } from '../workspace/types.js';
67
+ import {
68
+ orchestrateDns,
69
+ resolveHostnameToIp,
70
+ verifyDnsRecords,
71
+ } from './dns/index.js';
19
72
  import { deployDocker, resolveDockerConfig } from './docker';
20
73
  import { deployDokploy } from './dokploy';
21
74
  import {
@@ -25,29 +78,33 @@ import {
25
78
  type DokployRedis,
26
79
  } from './dokploy-api';
27
80
  import {
81
+ generatePublicUrlBuildArgs,
82
+ getPublicUrlArgNames,
83
+ isMainFrontendApp,
84
+ resolveHost,
85
+ } from './domain.js';
86
+ import {
87
+ type EnvResolverContext,
88
+ formatMissingVarsError,
89
+ validateEnvVars,
90
+ } from './env-resolver.js';
91
+ import { updateConfig } from './init';
92
+ import { generateSecretsReport, prepareSecretsForAllApps } from './secrets.js';
93
+ import { sniffAllApps } from './sniffer.js';
94
+ import {
95
+ type AppDbCredentials,
28
96
  createEmptyState,
97
+ getAllAppCredentials,
29
98
  getApplicationId,
30
99
  getPostgresId,
31
100
  getRedisId,
32
101
  readStageState,
102
+ setAppCredentials,
33
103
  setApplicationId,
34
104
  setPostgresId,
35
105
  setRedisId,
36
106
  writeStageState,
37
107
  } from './state.js';
38
- import { orchestrateDns } from './dns/index.js';
39
- import {
40
- generatePublicUrlBuildArgs,
41
- getPublicUrlArgNames,
42
- isMainFrontendApp,
43
- resolveHost,
44
- } from './domain.js';
45
- import { updateConfig } from './init';
46
- import {
47
- generateSecretsReport,
48
- prepareSecretsForAllApps,
49
- } from './secrets.js';
50
- import { sniffAllApps } from './sniffer.js';
51
108
  import type {
52
109
  AppDeployResult,
53
110
  DeployOptions,
@@ -149,6 +206,211 @@ export interface ProvisionServicesResult {
149
206
  };
150
207
  }
151
208
 
209
+ /**
210
+ * Configuration for a database user to create during Dokploy deployment.
211
+ *
212
+ * @property name - The database username (typically matches the app name)
213
+ * @property password - The generated password for this user
214
+ * @property usePublicSchema - If true, user gets access to public schema (for api app).
215
+ * If false, user gets their own schema with search_path set.
216
+ */
217
+ interface DbUserConfig {
218
+ name: string;
219
+ password: string;
220
+ usePublicSchema: boolean;
221
+ }
222
+
223
+ /**
224
+ * Wait for Postgres to be ready to accept connections.
225
+ *
226
+ * Polls the Postgres server until it accepts a connection or max retries reached.
227
+ * Used after enabling the external port to ensure the database is accessible
228
+ * before creating users.
229
+ *
230
+ * @param host - The Postgres server hostname
231
+ * @param port - The external port (typically 5432)
232
+ * @param user - Master database user (postgres)
233
+ * @param password - Master database password
234
+ * @param database - Database name to connect to
235
+ * @param maxRetries - Maximum number of connection attempts (default: 30)
236
+ * @param retryIntervalMs - Milliseconds between retries (default: 2000)
237
+ * @throws Error if Postgres is not ready after maxRetries
238
+ */
239
+ async function waitForPostgres(
240
+ host: string,
241
+ port: number,
242
+ user: string,
243
+ password: string,
244
+ database: string,
245
+ maxRetries = 30,
246
+ retryIntervalMs = 2000,
247
+ ): Promise<void> {
248
+ for (let i = 0; i < maxRetries; i++) {
249
+ try {
250
+ const client = new PgClient({ host, port, user, password, database });
251
+ await client.connect();
252
+ await client.end();
253
+ return;
254
+ } catch {
255
+ if (i < maxRetries - 1) {
256
+ logger.log(` Waiting for Postgres... (${i + 1}/${maxRetries})`);
257
+ await new Promise((r) => setTimeout(r, retryIntervalMs));
258
+ }
259
+ }
260
+ }
261
+ throw new Error(`Postgres not ready after ${maxRetries} retries`);
262
+ }
263
+
264
+ /**
265
+ * Initialize Postgres with per-app users and schemas.
266
+ *
267
+ * This function implements the same user/schema isolation pattern used in local
268
+ * dev mode (see docker/postgres/init.sh). It:
269
+ *
270
+ * 1. Temporarily enables the external Postgres port
271
+ * 2. Connects using master credentials
272
+ * 3. Creates each user with appropriate schema permissions
273
+ * 4. Disables the external port for security
274
+ *
275
+ * Schema assignment follows this pattern:
276
+ * - `api` app: Uses `public` schema (shared tables, migrations run here)
277
+ * - Other apps: Get their own schema with `search_path` configured
278
+ *
279
+ * @param api - The Dokploy API client
280
+ * @param postgres - The provisioned Postgres service details
281
+ * @param serverHostname - The Dokploy server hostname (for external connection)
282
+ * @param users - Array of users to create with their schema configuration
283
+ *
284
+ * @example
285
+ * ```ts
286
+ * await initializePostgresUsers(api, postgres, 'dokploy.example.com', [
287
+ * { name: 'api', password: 'xxx', usePublicSchema: true },
288
+ * { name: 'auth', password: 'yyy', usePublicSchema: false },
289
+ * ]);
290
+ * ```
291
+ */
292
+ async function initializePostgresUsers(
293
+ api: DokployApi,
294
+ postgres: DokployPostgres,
295
+ serverHostname: string,
296
+ users: DbUserConfig[],
297
+ ): Promise<void> {
298
+ logger.log('\n🔧 Initializing database users...');
299
+
300
+ // Enable external port temporarily
301
+ const externalPort = 5432;
302
+ logger.log(` Enabling external port ${externalPort}...`);
303
+ await api.savePostgresExternalPort(postgres.postgresId, externalPort);
304
+
305
+ // Redeploy to apply external port change
306
+ await api.deployPostgres(postgres.postgresId);
307
+
308
+ // Wait for Postgres to be ready with external port
309
+ logger.log(
310
+ ` Waiting for Postgres to be accessible at ${serverHostname}:${externalPort}...`,
311
+ );
312
+ await waitForPostgres(
313
+ serverHostname,
314
+ externalPort,
315
+ postgres.databaseUser,
316
+ postgres.databasePassword,
317
+ postgres.databaseName,
318
+ );
319
+
320
+ // Connect and create users
321
+ const client = new PgClient({
322
+ host: serverHostname,
323
+ port: externalPort,
324
+ user: postgres.databaseUser,
325
+ password: postgres.databasePassword,
326
+ database: postgres.databaseName,
327
+ });
328
+
329
+ try {
330
+ await client.connect();
331
+
332
+ for (const user of users) {
333
+ const schemaName = user.usePublicSchema ? 'public' : user.name;
334
+ logger.log(
335
+ ` Creating user "${user.name}" with schema "${schemaName}"...`,
336
+ );
337
+
338
+ // Create or update user (handles existing users)
339
+ await client.query(`
340
+ DO $$ BEGIN
341
+ CREATE USER "${user.name}" WITH PASSWORD '${user.password}';
342
+ EXCEPTION WHEN duplicate_object THEN
343
+ ALTER USER "${user.name}" WITH PASSWORD '${user.password}';
344
+ END $$;
345
+ `);
346
+
347
+ if (user.usePublicSchema) {
348
+ // API uses public schema
349
+ await client.query(`
350
+ GRANT ALL ON SCHEMA public TO "${user.name}";
351
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "${user.name}";
352
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "${user.name}";
353
+ `);
354
+ } else {
355
+ // Other apps get their own schema
356
+ await client.query(`
357
+ CREATE SCHEMA IF NOT EXISTS "${schemaName}" AUTHORIZATION "${user.name}";
358
+ ALTER USER "${user.name}" SET search_path TO "${schemaName}";
359
+ GRANT USAGE ON SCHEMA "${schemaName}" TO "${user.name}";
360
+ GRANT ALL ON ALL TABLES IN SCHEMA "${schemaName}" TO "${user.name}";
361
+ ALTER DEFAULT PRIVILEGES IN SCHEMA "${schemaName}" GRANT ALL ON TABLES TO "${user.name}";
362
+ `);
363
+ }
364
+
365
+ logger.log(` ✓ User "${user.name}" configured`);
366
+ }
367
+ } finally {
368
+ await client.end();
369
+ }
370
+
371
+ // Disable external port for security
372
+ logger.log(' Disabling external port...');
373
+ await api.savePostgresExternalPort(postgres.postgresId, null);
374
+ await api.deployPostgres(postgres.postgresId);
375
+
376
+ logger.log(' ✓ Database users initialized');
377
+ }
378
+
379
+ /**
380
+ * Get the server hostname from the Dokploy endpoint URL
381
+ */
382
+ function getServerHostname(endpoint: string): string {
383
+ const url = new URL(endpoint);
384
+ return url.hostname;
385
+ }
386
+
387
+ /**
388
+ * Build per-app DATABASE_URL for internal Docker network communication.
389
+ *
390
+ * The URL uses the Postgres container name (postgresAppName) as the host,
391
+ * which resolves via Docker's internal DNS when apps are in the same network.
392
+ *
393
+ * @param appName - The database username (matches the app name)
394
+ * @param appPassword - The app's database password
395
+ * @param postgresAppName - The Postgres container/service name in Dokploy
396
+ * @param databaseName - The database name (typically the project name)
397
+ * @returns A properly encoded PostgreSQL connection URL
398
+ *
399
+ * @example
400
+ * ```ts
401
+ * const url = buildPerAppDatabaseUrl('api', 'secret123', 'postgres-abc', 'myproject');
402
+ * // Returns: postgresql://api:secret123@postgres-abc:5432/myproject
403
+ * ```
404
+ */
405
+ function buildPerAppDatabaseUrl(
406
+ appName: string,
407
+ appPassword: string,
408
+ postgresAppName: string,
409
+ databaseName: string,
410
+ ): string {
411
+ return `postgresql://${appName}:${encodeURIComponent(appPassword)}@${postgresAppName}:5432/${databaseName}`;
412
+ }
413
+
152
414
  /**
153
415
  * Provision docker compose services in Dokploy
154
416
  * @internal Exported for testing
@@ -157,7 +419,7 @@ export async function provisionServices(
157
419
  api: DokployApi,
158
420
  projectId: string,
159
421
  environmentId: string | undefined,
160
- appName: string,
422
+ projectName: string,
161
423
  services?: DockerComposeServices,
162
424
  existingServiceIds?: { postgresId?: string; redisId?: string },
163
425
  ): Promise<ProvisionServicesResult | undefined> {
@@ -193,14 +455,15 @@ export async function provisionServices(
193
455
 
194
456
  // If not found by ID, use findOrCreate
195
457
  if (!postgres) {
196
- const { randomBytes } = await import('node:crypto');
197
458
  const databasePassword = randomBytes(16).toString('hex');
459
+ // Use project name as database name (replace hyphens with underscores for PostgreSQL)
460
+ const databaseName = projectName.replace(/-/g, '_');
198
461
 
199
462
  const result = await api.findOrCreatePostgres(
200
463
  postgresName,
201
464
  projectId,
202
465
  environmentId,
203
- { databasePassword },
466
+ { databaseName, databasePassword },
204
467
  );
205
468
  postgres = result.postgres;
206
469
  created = result.created;
@@ -230,8 +493,7 @@ export async function provisionServices(
230
493
  serviceUrls.DATABASE_URL = `postgresql://${postgres.databaseUser}:${postgres.databasePassword}@${postgres.appName}:5432/${postgres.databaseName}`;
231
494
  logger.log(` ✓ Database credentials configured`);
232
495
  } catch (error) {
233
- const message =
234
- error instanceof Error ? error.message : 'Unknown error';
496
+ const message = error instanceof Error ? error.message : 'Unknown error';
235
497
  logger.log(` ⚠ Failed to provision PostgreSQL: ${message}`);
236
498
  }
237
499
  }
@@ -297,8 +559,7 @@ export async function provisionServices(
297
559
  serviceUrls.REDIS_URL = `redis://${password}${redis.appName}:6379`;
298
560
  logger.log(` ✓ Redis credentials configured`);
299
561
  } catch (error) {
300
- const message =
301
- error instanceof Error ? error.message : 'Unknown error';
562
+ const message = error instanceof Error ? error.message : 'Unknown error';
302
563
  logger.log(` ⚠ Failed to provision Redis: ${message}`);
303
564
  }
304
565
  }
@@ -319,14 +580,6 @@ async function ensureDokploySetup(
319
580
  ): Promise<DokploySetupResult> {
320
581
  logger.log('\n🔧 Checking Dokploy setup...');
321
582
 
322
- // Read existing secrets to check if services are already configured
323
- const { readStageSecrets } = await import('../secrets/storage');
324
- const existingSecrets = await readStageSecrets(stage);
325
- const existingUrls: { DATABASE_URL?: string; REDIS_URL?: string } = {
326
- DATABASE_URL: existingSecrets?.urls?.DATABASE_URL,
327
- REDIS_URL: existingSecrets?.urls?.REDIS_URL,
328
- };
329
-
330
583
  // Step 1: Ensure we have Dokploy credentials
331
584
  let creds = await getDokployCredentials();
332
585
 
@@ -714,7 +967,9 @@ export async function workspaceDeployCommand(
714
967
  const stageSecrets = await readStageSecrets(stage, workspace.root);
715
968
  if (!stageSecrets) {
716
969
  logger.log(` ⚠️ No secrets found for stage "${stage}"`);
717
- logger.log(` Run "gkm secrets:init --stage ${stage}" to create secrets`);
970
+ logger.log(
971
+ ` Run "gkm secrets:init --stage ${stage}" to create secrets`,
972
+ );
718
973
  }
719
974
 
720
975
  // Sniff environment variables for all apps
@@ -729,7 +984,9 @@ export async function workspaceDeployCommand(
729
984
  if (stageSecrets) {
730
985
  const report = generateSecretsReport(encryptedSecrets, sniffedApps);
731
986
  if (report.appsWithSecrets.length > 0) {
732
- logger.log(` ✓ Encrypted secrets for: ${report.appsWithSecrets.join(', ')}`);
987
+ logger.log(
988
+ ` ✓ Encrypted secrets for: ${report.appsWithSecrets.join(', ')}`,
989
+ );
733
990
  }
734
991
  if (report.appsWithMissingSecrets.length > 0) {
735
992
  for (const { appName, missing } of report.appsWithMissingSecrets) {
@@ -883,6 +1140,10 @@ export async function workspaceDeployCommand(
883
1140
  redis: services.cache !== undefined && services.cache !== false,
884
1141
  };
885
1142
 
1143
+ // Track provisioned postgres info for per-app DATABASE_URL
1144
+ let provisionedPostgres: DokployPostgres | null = null;
1145
+ let provisionedRedis: DokployRedis | null = null;
1146
+
886
1147
  if (dockerServices.postgres || dockerServices.redis) {
887
1148
  logger.log('\n🔧 Provisioning infrastructure services...');
888
1149
  // Pass existing service IDs from state (prefer state over URL sniffing)
@@ -904,9 +1165,17 @@ export async function workspaceDeployCommand(
904
1165
  if (provisionResult?.serviceIds) {
905
1166
  if (provisionResult.serviceIds.postgresId) {
906
1167
  setPostgresId(state, provisionResult.serviceIds.postgresId);
1168
+ // Fetch full postgres info for later use
1169
+ provisionedPostgres = await api.getPostgres(
1170
+ provisionResult.serviceIds.postgresId,
1171
+ );
907
1172
  }
908
1173
  if (provisionResult.serviceIds.redisId) {
909
1174
  setRedisId(state, provisionResult.serviceIds.redisId);
1175
+ // Fetch full redis info for later use
1176
+ provisionedRedis = await api.getRedis(
1177
+ provisionResult.serviceIds.redisId,
1178
+ );
910
1179
  }
911
1180
  }
912
1181
  }
@@ -921,6 +1190,60 @@ export async function workspaceDeployCommand(
921
1190
  (name) => workspace.apps[name]!.type === 'frontend',
922
1191
  );
923
1192
 
1193
+ // ==================================================================
1194
+ // Initialize per-app database users if Postgres is provisioned
1195
+ // ==================================================================
1196
+ const perAppDbCredentials = new Map<string, AppDbCredentials>();
1197
+
1198
+ if (provisionedPostgres && backendApps.length > 0) {
1199
+ // Determine which backend apps need DATABASE_URL
1200
+ const appsNeedingDb = backendApps.filter((appName) => {
1201
+ const requirements = sniffedApps.get(appName);
1202
+ return requirements?.requiredEnvVars.includes('DATABASE_URL');
1203
+ });
1204
+
1205
+ if (appsNeedingDb.length > 0) {
1206
+ logger.log(`\n🔐 Setting up per-app database credentials...`);
1207
+ logger.log(` Apps needing DATABASE_URL: ${appsNeedingDb.join(', ')}`);
1208
+
1209
+ // Get or generate credentials for each app
1210
+ const existingCredentials = getAllAppCredentials(state);
1211
+ const usersToCreate: DbUserConfig[] = [];
1212
+
1213
+ for (const appName of appsNeedingDb) {
1214
+ let credentials = existingCredentials[appName];
1215
+
1216
+ if (credentials) {
1217
+ logger.log(` ${appName}: Using existing credentials from state`);
1218
+ } else {
1219
+ // Generate new credentials
1220
+ const password = randomBytes(16).toString('hex');
1221
+ credentials = { dbUser: appName, dbPassword: password };
1222
+ setAppCredentials(state, appName, credentials);
1223
+ logger.log(` ${appName}: Generated new credentials`);
1224
+ }
1225
+
1226
+ perAppDbCredentials.set(appName, credentials);
1227
+
1228
+ // Always add to users to create (idempotent - will update if exists)
1229
+ usersToCreate.push({
1230
+ name: appName,
1231
+ password: credentials.dbPassword,
1232
+ usePublicSchema: appName === 'api', // API uses public schema, others get their own
1233
+ });
1234
+ }
1235
+
1236
+ // Initialize database users
1237
+ const serverHostname = getServerHostname(creds.endpoint);
1238
+ await initializePostgresUsers(
1239
+ api,
1240
+ provisionedPostgres,
1241
+ serverHostname,
1242
+ usersToCreate,
1243
+ );
1244
+ }
1245
+ }
1246
+
924
1247
  // Track deployed app public URLs for frontend builds
925
1248
  const publicUrls: Record<string, string> = {};
926
1249
  const results: AppDeployResult[] = [];
@@ -930,6 +1253,23 @@ export async function workspaceDeployCommand(
930
1253
  const appHostnames = new Map<string, string>(); // appName -> hostname
931
1254
  const appDomainIds = new Map<string, string>(); // appName -> domainId
932
1255
 
1256
+ // ==================================================================
1257
+ // PRE-COMPUTE: Frontend URLs for BETTER_AUTH_TRUSTED_ORIGINS
1258
+ // ==================================================================
1259
+ const frontendUrls: string[] = [];
1260
+ for (const appName of frontendApps) {
1261
+ const app = workspace.apps[appName]!;
1262
+ const isMainFrontend = isMainFrontendApp(appName, app, workspace.apps);
1263
+ const hostname = resolveHost(
1264
+ appName,
1265
+ app,
1266
+ stage,
1267
+ dokployConfig,
1268
+ isMainFrontend,
1269
+ );
1270
+ frontendUrls.push(`https://${hostname}`);
1271
+ }
1272
+
933
1273
  // ==================================================================
934
1274
  // PHASE 1: Deploy backend apps (with encrypted secrets)
935
1275
  // ==================================================================
@@ -953,7 +1293,9 @@ export async function workspaceDeployCommand(
953
1293
  logger.log(` Using cached ID: ${cachedAppId}`);
954
1294
  application = await api.getApplication(cachedAppId);
955
1295
  if (application) {
956
- logger.log(` ✓ Application found: ${application.applicationId}`);
1296
+ logger.log(
1297
+ ` ✓ Application found: ${application.applicationId}`,
1298
+ );
957
1299
  } else {
958
1300
  logger.log(` ⚠ Cached ID invalid, will create new`);
959
1301
  }
@@ -969,9 +1311,13 @@ export async function workspaceDeployCommand(
969
1311
  application = result.application;
970
1312
 
971
1313
  if (result.created) {
972
- logger.log(` Created application: ${application.applicationId}`);
1314
+ logger.log(
1315
+ ` Created application: ${application.applicationId}`,
1316
+ );
973
1317
  } else {
974
- logger.log(` Found existing application: ${application.applicationId}`);
1318
+ logger.log(
1319
+ ` Found existing application: ${application.applicationId}`,
1320
+ );
975
1321
  }
976
1322
  }
977
1323
 
@@ -1010,12 +1356,63 @@ export async function workspaceDeployCommand(
1010
1356
  buildArgs,
1011
1357
  });
1012
1358
 
1013
- // Prepare environment variables - ONLY inject GKM_MASTER_KEY
1014
- const envVars: string[] = [`NODE_ENV=production`, `PORT=${app.port}`];
1359
+ // Compute hostname first (needed for BETTER_AUTH_URL)
1360
+ const backendHost = resolveHost(
1361
+ appName,
1362
+ app,
1363
+ stage,
1364
+ dokployConfig,
1365
+ false, // Backend apps are not main frontend
1366
+ );
1015
1367
 
1016
- // Add master key for runtime decryption (NOT plain secrets)
1017
- if (appSecrets && appSecrets.masterKey) {
1018
- envVars.push(`GKM_MASTER_KEY=${appSecrets.masterKey}`);
1368
+ // Build env resolver context
1369
+ const envContext: EnvResolverContext = {
1370
+ app,
1371
+ appName,
1372
+ stage,
1373
+ state,
1374
+ appCredentials: perAppDbCredentials.get(appName),
1375
+ postgres: provisionedPostgres
1376
+ ? {
1377
+ host: provisionedPostgres.appName,
1378
+ port: 5432,
1379
+ database: provisionedPostgres.databaseName,
1380
+ }
1381
+ : undefined,
1382
+ redis: provisionedRedis
1383
+ ? {
1384
+ host: provisionedRedis.appName,
1385
+ port: 6379,
1386
+ password: provisionedRedis.databasePassword,
1387
+ }
1388
+ : undefined,
1389
+ appHostname: backendHost,
1390
+ frontendUrls,
1391
+ userSecrets: stageSecrets ?? undefined,
1392
+ masterKey: appSecrets?.masterKey,
1393
+ };
1394
+
1395
+ // Resolve all required environment variables
1396
+ const appRequirements = sniffedApps.get(appName);
1397
+ const requiredVars = appRequirements?.requiredEnvVars ?? [];
1398
+ const { valid, missing, resolved } = validateEnvVars(
1399
+ requiredVars,
1400
+ envContext,
1401
+ );
1402
+
1403
+ if (!valid) {
1404
+ throw new Error(formatMissingVarsError(appName, missing, stage));
1405
+ }
1406
+
1407
+ // Build env vars string for Dokploy
1408
+ const envVars: string[] = Object.entries(resolved).map(
1409
+ ([key, value]) => `${key}=${value}`,
1410
+ );
1411
+
1412
+ if (Object.keys(resolved).length > 0) {
1413
+ logger.log(
1414
+ ` Resolved ${Object.keys(resolved).length} env vars: ${Object.keys(resolved).join(', ')}`,
1415
+ );
1019
1416
  }
1020
1417
 
1021
1418
  // Configure and deploy application in Dokploy
@@ -1031,52 +1428,44 @@ export async function workspaceDeployCommand(
1031
1428
  logger.log(` Deploying to Dokploy...`);
1032
1429
  await api.deployApplication(application.applicationId);
1033
1430
 
1034
- // Create domain for this app
1035
- const backendHost = resolveHost(
1036
- appName,
1037
- app,
1038
- stage,
1039
- dokployConfig,
1040
- false, // Backend apps are not main frontend
1431
+ // Check if domain already exists (backendHost computed above)
1432
+ const existingDomains = await api.getDomainsByApplicationId(
1433
+ application.applicationId,
1434
+ );
1435
+ const existingDomain = existingDomains.find(
1436
+ (d) => d.host === backendHost,
1041
1437
  );
1042
1438
 
1043
- try {
1044
- const domain = await api.createDomain({
1045
- host: backendHost,
1046
- port: app.port,
1047
- https: true,
1048
- certificateType: 'letsencrypt',
1049
- applicationId: application.applicationId,
1050
- });
1051
-
1052
- // Track for DNS orchestration
1053
- appHostnames.set(appName, backendHost);
1054
- appDomainIds.set(appName, domain.domainId);
1055
-
1056
- const publicUrl = `https://${backendHost}`;
1057
- publicUrls[appName] = publicUrl;
1058
- logger.log(` ✓ Domain: ${publicUrl}`);
1059
- } catch (domainError) {
1060
- // Domain might already exist, try to get the existing domain
1439
+ if (existingDomain) {
1440
+ // Domain already exists
1061
1441
  appHostnames.set(appName, backendHost);
1062
-
1063
- // Try to get existing domain ID for validation
1442
+ appDomainIds.set(appName, existingDomain.domainId);
1443
+ publicUrls[appName] = `https://${backendHost}`;
1444
+ logger.log(` ✓ Domain: https://${backendHost} (existing)`);
1445
+ } else {
1446
+ // Create new domain
1064
1447
  try {
1065
- const existingDomains = await api.getDomainsByApplicationId(
1066
- application.applicationId,
1067
- );
1068
- const matchingDomain = existingDomains.find(
1069
- (d) => d.host === backendHost,
1070
- );
1071
- if (matchingDomain) {
1072
- appDomainIds.set(appName, matchingDomain.domainId);
1073
- }
1074
- } catch {
1075
- // Ignore - we'll just skip validation for this domain
1448
+ const domain = await api.createDomain({
1449
+ host: backendHost,
1450
+ port: app.port,
1451
+ https: true,
1452
+ certificateType: 'letsencrypt',
1453
+ applicationId: application.applicationId,
1454
+ });
1455
+
1456
+ appHostnames.set(appName, backendHost);
1457
+ appDomainIds.set(appName, domain.domainId);
1458
+ publicUrls[appName] = `https://${backendHost}`;
1459
+ logger.log(` ✓ Domain: https://${backendHost} (created)`);
1460
+ } catch (domainError) {
1461
+ const message =
1462
+ domainError instanceof Error
1463
+ ? domainError.message
1464
+ : 'Unknown error';
1465
+ logger.log(` ⚠ Domain creation failed: ${message}`);
1466
+ appHostnames.set(appName, backendHost);
1467
+ publicUrls[appName] = `https://${backendHost}`;
1076
1468
  }
1077
-
1078
- publicUrls[appName] = `https://${backendHost}`;
1079
- logger.log(` ℹ Domain already configured: https://${backendHost}`);
1080
1469
  }
1081
1470
 
1082
1471
  results.push({
@@ -1089,7 +1478,8 @@ export async function workspaceDeployCommand(
1089
1478
 
1090
1479
  logger.log(` ✓ ${appName} deployed successfully`);
1091
1480
  } catch (error) {
1092
- const message = error instanceof Error ? error.message : 'Unknown error';
1481
+ const message =
1482
+ error instanceof Error ? error.message : 'Unknown error';
1093
1483
  logger.log(` ✗ Failed to deploy ${appName}: ${message}`);
1094
1484
 
1095
1485
  results.push({
@@ -1130,7 +1520,9 @@ export async function workspaceDeployCommand(
1130
1520
  logger.log(` Using cached ID: ${cachedAppId}`);
1131
1521
  application = await api.getApplication(cachedAppId);
1132
1522
  if (application) {
1133
- logger.log(` ✓ Application found: ${application.applicationId}`);
1523
+ logger.log(
1524
+ ` ✓ Application found: ${application.applicationId}`,
1525
+ );
1134
1526
  } else {
1135
1527
  logger.log(` ⚠ Cached ID invalid, will create new`);
1136
1528
  }
@@ -1146,9 +1538,13 @@ export async function workspaceDeployCommand(
1146
1538
  application = result.application;
1147
1539
 
1148
1540
  if (result.created) {
1149
- logger.log(` Created application: ${application.applicationId}`);
1541
+ logger.log(
1542
+ ` Created application: ${application.applicationId}`,
1543
+ );
1150
1544
  } else {
1151
- logger.log(` Found existing application: ${application.applicationId}`);
1545
+ logger.log(
1546
+ ` Found existing application: ${application.applicationId}`,
1547
+ );
1152
1548
  }
1153
1549
  }
1154
1550
 
@@ -1199,7 +1595,7 @@ export async function workspaceDeployCommand(
1199
1595
  logger.log(` Deploying to Dokploy...`);
1200
1596
  await api.deployApplication(application.applicationId);
1201
1597
 
1202
- // Create domain for this app
1598
+ // Create or find domain for this app
1203
1599
  const isMainFrontend = isMainFrontendApp(appName, app, workspace.apps);
1204
1600
  const frontendHost = resolveHost(
1205
1601
  appName,
@@ -1209,43 +1605,44 @@ export async function workspaceDeployCommand(
1209
1605
  isMainFrontend,
1210
1606
  );
1211
1607
 
1212
- try {
1213
- const domain = await api.createDomain({
1214
- host: frontendHost,
1215
- port: app.port,
1216
- https: true,
1217
- certificateType: 'letsencrypt',
1218
- applicationId: application.applicationId,
1219
- });
1220
-
1221
- // Track for DNS orchestration
1222
- appHostnames.set(appName, frontendHost);
1223
- appDomainIds.set(appName, domain.domainId);
1608
+ // Check if domain already exists
1609
+ const existingFrontendDomains = await api.getDomainsByApplicationId(
1610
+ application.applicationId,
1611
+ );
1612
+ const existingFrontendDomain = existingFrontendDomains.find(
1613
+ (d) => d.host === frontendHost,
1614
+ );
1224
1615
 
1225
- const publicUrl = `https://${frontendHost}`;
1226
- publicUrls[appName] = publicUrl;
1227
- logger.log(` ✓ Domain: ${publicUrl}`);
1228
- } catch (domainError) {
1229
- // Domain might already exist, try to get the existing domain
1616
+ if (existingFrontendDomain) {
1617
+ // Domain already exists
1230
1618
  appHostnames.set(appName, frontendHost);
1231
-
1232
- // Try to get existing domain ID for validation
1619
+ appDomainIds.set(appName, existingFrontendDomain.domainId);
1620
+ publicUrls[appName] = `https://${frontendHost}`;
1621
+ logger.log(` ✓ Domain: https://${frontendHost} (existing)`);
1622
+ } else {
1623
+ // Create new domain
1233
1624
  try {
1234
- const existingDomains = await api.getDomainsByApplicationId(
1235
- application.applicationId,
1236
- );
1237
- const matchingDomain = existingDomains.find(
1238
- (d) => d.host === frontendHost,
1239
- );
1240
- if (matchingDomain) {
1241
- appDomainIds.set(appName, matchingDomain.domainId);
1242
- }
1243
- } catch {
1244
- // Ignore - we'll just skip validation for this domain
1625
+ const domain = await api.createDomain({
1626
+ host: frontendHost,
1627
+ port: app.port,
1628
+ https: true,
1629
+ certificateType: 'letsencrypt',
1630
+ applicationId: application.applicationId,
1631
+ });
1632
+
1633
+ appHostnames.set(appName, frontendHost);
1634
+ appDomainIds.set(appName, domain.domainId);
1635
+ publicUrls[appName] = `https://${frontendHost}`;
1636
+ logger.log(` ✓ Domain: https://${frontendHost} (created)`);
1637
+ } catch (domainError) {
1638
+ const message =
1639
+ domainError instanceof Error
1640
+ ? domainError.message
1641
+ : 'Unknown error';
1642
+ logger.log(` ⚠ Domain creation failed: ${message}`);
1643
+ appHostnames.set(appName, frontendHost);
1644
+ publicUrls[appName] = `https://${frontendHost}`;
1245
1645
  }
1246
-
1247
- publicUrls[appName] = `https://${frontendHost}`;
1248
- logger.log(` ℹ Domain already configured: https://${frontendHost}`);
1249
1646
  }
1250
1647
 
1251
1648
  results.push({
@@ -1258,7 +1655,8 @@ export async function workspaceDeployCommand(
1258
1655
 
1259
1656
  logger.log(` ✓ ${appName} deployed successfully`);
1260
1657
  } catch (error) {
1261
- const message = error instanceof Error ? error.message : 'Unknown error';
1658
+ const message =
1659
+ error instanceof Error ? error.message : 'Unknown error';
1262
1660
  logger.log(` ✗ Failed to deploy ${appName}: ${message}`);
1263
1661
 
1264
1662
  results.push({
@@ -1280,7 +1678,7 @@ export async function workspaceDeployCommand(
1280
1678
  logger.log(` ✓ State saved to .gkm/deploy-${stage}.json`);
1281
1679
 
1282
1680
  // ==================================================================
1283
- // DNS: Create DNS records and validate domains for SSL
1681
+ // DNS: Create DNS records, verify propagation, and validate for SSL
1284
1682
  // ==================================================================
1285
1683
  const dnsConfig = workspace.deploy.dns;
1286
1684
  if (dnsConfig && appHostnames.size > 0) {
@@ -1290,19 +1688,31 @@ export async function workspaceDeployCommand(
1290
1688
  creds.endpoint,
1291
1689
  );
1292
1690
 
1691
+ // Verify DNS records resolve correctly (with state caching)
1692
+ if (dnsResult?.serverIp && appHostnames.size > 0) {
1693
+ await verifyDnsRecords(appHostnames, dnsResult.serverIp, state);
1694
+
1695
+ // Save state again to persist DNS verification results
1696
+ await writeStageState(workspace.root, stage, state);
1697
+ }
1698
+
1293
1699
  // Validate domains to trigger SSL certificate generation
1294
- if (dnsResult?.success && appDomainIds.size > 0) {
1700
+ if (dnsResult?.success && appHostnames.size > 0) {
1295
1701
  logger.log('\n🔒 Validating domains for SSL certificates...');
1296
- for (const [appName, domainId] of appDomainIds) {
1702
+ for (const [appName, hostname] of appHostnames) {
1297
1703
  try {
1298
- await api.validateDomain(domainId);
1299
- logger.log(` ✓ ${appName}: SSL validation triggered`);
1704
+ const result = await api.validateDomain(hostname);
1705
+ if (result.isValid) {
1706
+ logger.log(` ✓ ${appName}: ${hostname} → ${result.resolvedIp}`);
1707
+ } else {
1708
+ logger.log(` ⚠ ${appName}: ${hostname} not valid`);
1709
+ }
1300
1710
  } catch (validationError) {
1301
1711
  const message =
1302
1712
  validationError instanceof Error
1303
1713
  ? validationError.message
1304
1714
  : 'Unknown error';
1305
- logger.log(` ⚠ ${appName}: SSL validation failed - ${message}`);
1715
+ logger.log(` ⚠ ${appName}: validation failed - ${message}`);
1306
1716
  }
1307
1717
  }
1308
1718
  }