@geekmidas/cli 1.4.0 → 1.5.1

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 (88) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/{HostingerProvider-B9N-TKbp.mjs → HostingerProvider-402UdK89.mjs} +34 -1
  3. package/dist/HostingerProvider-402UdK89.mjs.map +1 -0
  4. package/dist/{HostingerProvider-DUV9-Tzg.cjs → HostingerProvider-BiXdHjiq.cjs} +34 -1
  5. package/dist/HostingerProvider-BiXdHjiq.cjs.map +1 -0
  6. package/dist/{Route53Provider-DOWmFnwN.mjs → Route53Provider-DbBo7Uz5.mjs} +55 -2
  7. package/dist/Route53Provider-DbBo7Uz5.mjs.map +1 -0
  8. package/dist/{Route53Provider-xrWuBXih.cjs → Route53Provider-kfJ77LmL.cjs} +55 -2
  9. package/dist/Route53Provider-kfJ77LmL.cjs.map +1 -0
  10. package/dist/backup-provisioner-B5e-F6zX.cjs +164 -0
  11. package/dist/backup-provisioner-B5e-F6zX.cjs.map +1 -0
  12. package/dist/backup-provisioner-BIArpmTr.mjs +163 -0
  13. package/dist/backup-provisioner-BIArpmTr.mjs.map +1 -0
  14. package/dist/{config-C1dM7aZb.cjs → config-BYn5yUt5.cjs} +2 -2
  15. package/dist/{config-C1dM7aZb.cjs.map → config-BYn5yUt5.cjs.map} +1 -1
  16. package/dist/{config-C1bidhvG.mjs → config-dLNQIvDR.mjs} +2 -2
  17. package/dist/{config-C1bidhvG.mjs.map → config-dLNQIvDR.mjs.map} +1 -1
  18. package/dist/config.cjs +2 -2
  19. package/dist/config.d.cts +1 -1
  20. package/dist/config.d.mts +2 -2
  21. package/dist/config.mjs +2 -2
  22. package/dist/{dokploy-api-z0833e7r.mjs → dokploy-api-2ldYoN3i.mjs} +131 -1
  23. package/dist/dokploy-api-2ldYoN3i.mjs.map +1 -0
  24. package/dist/dokploy-api-C93pveuy.mjs +3 -0
  25. package/dist/dokploy-api-CbDh4o93.cjs +3 -0
  26. package/dist/{dokploy-api-CQvhV6Hd.cjs → dokploy-api-DLgvEQlr.cjs} +131 -1
  27. package/dist/dokploy-api-DLgvEQlr.cjs.map +1 -0
  28. package/dist/{index-DzmZ6SUW.d.cts → index-Ba21_lNt.d.cts} +157 -29
  29. package/dist/index-Ba21_lNt.d.cts.map +1 -0
  30. package/dist/{index-DvpWzLD7.d.mts → index-Bj5VNxEL.d.mts} +158 -30
  31. package/dist/index-Bj5VNxEL.d.mts.map +1 -0
  32. package/dist/index.cjs +219 -68
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.mjs +219 -68
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/{openapi-9k6a6VA4.mjs → openapi-CMTyaIJJ.mjs} +2 -2
  37. package/dist/{openapi-9k6a6VA4.mjs.map → openapi-CMTyaIJJ.mjs.map} +1 -1
  38. package/dist/{openapi-Dcja4e1C.cjs → openapi-CqblwJZ4.cjs} +2 -2
  39. package/dist/{openapi-Dcja4e1C.cjs.map → openapi-CqblwJZ4.cjs.map} +1 -1
  40. package/dist/openapi.cjs +3 -3
  41. package/dist/openapi.d.mts +1 -1
  42. package/dist/openapi.mjs +3 -3
  43. package/dist/{types-B9UZ7fOG.d.mts → types-CZg5iUgD.d.mts} +1 -1
  44. package/dist/{types-B9UZ7fOG.d.mts.map → types-CZg5iUgD.d.mts.map} +1 -1
  45. package/dist/workspace/index.cjs +1 -1
  46. package/dist/workspace/index.d.cts +1 -1
  47. package/dist/workspace/index.d.mts +2 -2
  48. package/dist/workspace/index.mjs +1 -1
  49. package/dist/{workspace-CeFgIDC-.cjs → workspace-DIMnYaYt.cjs} +20 -2
  50. package/dist/{workspace-CeFgIDC-.cjs.map → workspace-DIMnYaYt.cjs.map} +1 -1
  51. package/dist/{workspace-Cb_I7oCJ.mjs → workspace-Dy8k7Wru.mjs} +20 -2
  52. package/dist/{workspace-Cb_I7oCJ.mjs.map → workspace-Dy8k7Wru.mjs.map} +1 -1
  53. package/examples/cron-example.ts +6 -6
  54. package/examples/function-example.ts +1 -1
  55. package/package.json +7 -5
  56. package/src/deploy/__tests__/Route53Provider.spec.ts +23 -0
  57. package/src/deploy/__tests__/backup-provisioner.spec.ts +428 -0
  58. package/src/deploy/__tests__/createDnsProvider.spec.ts +23 -0
  59. package/src/deploy/__tests__/env-resolver.spec.ts +239 -0
  60. package/src/deploy/__tests__/sniffer.spec.ts +104 -93
  61. package/src/deploy/__tests__/undeploy.spec.ts +758 -0
  62. package/src/deploy/backup-provisioner.ts +316 -0
  63. package/src/deploy/dns/DnsProvider.ts +39 -1
  64. package/src/deploy/dns/HostingerProvider.ts +74 -0
  65. package/src/deploy/dns/Route53Provider.ts +85 -1
  66. package/src/deploy/dns/index.ts +25 -0
  67. package/src/deploy/dokploy-api.ts +237 -0
  68. package/src/deploy/env-resolver.ts +11 -1
  69. package/src/deploy/index.ts +143 -37
  70. package/src/deploy/sniffer.ts +39 -7
  71. package/src/deploy/state.ts +171 -0
  72. package/src/deploy/undeploy.ts +407 -0
  73. package/src/generators/FunctionGenerator.ts +1 -1
  74. package/src/init/generators/monorepo.ts +4 -0
  75. package/src/init/generators/web.ts +45 -2
  76. package/src/init/versions.ts +2 -2
  77. package/src/workspace/schema.ts +34 -0
  78. package/src/workspace/types.ts +37 -37
  79. package/dist/HostingerProvider-B9N-TKbp.mjs.map +0 -1
  80. package/dist/HostingerProvider-DUV9-Tzg.cjs.map +0 -1
  81. package/dist/Route53Provider-DOWmFnwN.mjs.map +0 -1
  82. package/dist/Route53Provider-xrWuBXih.cjs.map +0 -1
  83. package/dist/dokploy-api-CQvhV6Hd.cjs.map +0 -1
  84. package/dist/dokploy-api-CWc02yyg.cjs +0 -3
  85. package/dist/dokploy-api-DSJYNx88.mjs +0 -3
  86. package/dist/dokploy-api-z0833e7r.mjs.map +0 -1
  87. package/dist/index-DvpWzLD7.d.mts.map +0 -1
  88. package/dist/index-DzmZ6SUW.d.cts.map +0 -1
@@ -154,6 +154,13 @@ export class DokployApi {
154
154
  });
155
155
  }
156
156
 
157
+ /**
158
+ * Delete a project and all its resources
159
+ */
160
+ async deleteProject(projectId: string): Promise<void> {
161
+ await this.post('project.remove', { projectId });
162
+ }
163
+
157
164
  // ============================================
158
165
  // Environment endpoints
159
166
  // ============================================
@@ -315,6 +322,13 @@ export class DokployApi {
315
322
  await this.post('application.deploy', { applicationId });
316
323
  }
317
324
 
325
+ /**
326
+ * Delete an application
327
+ */
328
+ async deleteApplication(applicationId: string): Promise<void> {
329
+ await this.post('application.remove', { applicationId });
330
+ }
331
+
318
332
  // ============================================
319
333
  // Registry endpoints
320
334
  // ============================================
@@ -505,6 +519,13 @@ export class DokployApi {
505
519
  await this.post('postgres.update', { postgresId, ...updates });
506
520
  }
507
521
 
522
+ /**
523
+ * Delete a Postgres database
524
+ */
525
+ async deletePostgres(postgresId: string): Promise<void> {
526
+ await this.post('postgres.remove', { postgresId });
527
+ }
528
+
508
529
  // ============================================
509
530
  // Redis endpoints
510
531
  // ============================================
@@ -626,6 +647,13 @@ export class DokployApi {
626
647
  await this.post('redis.update', { redisId, ...updates });
627
648
  }
628
649
 
650
+ /**
651
+ * Delete a Redis instance
652
+ */
653
+ async deleteRedis(redisId: string): Promise<void> {
654
+ await this.post('redis.remove', { redisId });
655
+ }
656
+
629
657
  // ============================================
630
658
  // Domain endpoints
631
659
  // ============================================
@@ -704,6 +732,144 @@ export class DokployApi {
704
732
  serverId,
705
733
  });
706
734
  }
735
+
736
+ // ============================================
737
+ // Destination endpoints (backup storage)
738
+ // ============================================
739
+
740
+ /**
741
+ * List all backup destinations
742
+ */
743
+ async listDestinations(): Promise<DokployDestination[]> {
744
+ return this.get<DokployDestination[]>('destination.all');
745
+ }
746
+
747
+ /**
748
+ * Get a destination by ID
749
+ */
750
+ async getDestination(destinationId: string): Promise<DokployDestination> {
751
+ return this.get<DokployDestination>(
752
+ `destination.one?destinationId=${destinationId}`,
753
+ );
754
+ }
755
+
756
+ /**
757
+ * Create a new S3 backup destination
758
+ */
759
+ async createDestination(
760
+ options: DokployDestinationCreate,
761
+ ): Promise<DokployDestination> {
762
+ return this.post<DokployDestination>('destination.create', { ...options });
763
+ }
764
+
765
+ /**
766
+ * Find a destination by name
767
+ */
768
+ async findDestinationByName(
769
+ name: string,
770
+ ): Promise<DokployDestination | undefined> {
771
+ const destinations = await this.listDestinations();
772
+ return destinations.find((d) => d.name === name);
773
+ }
774
+
775
+ /**
776
+ * Find or create a destination by name
777
+ */
778
+ async findOrCreateDestination(
779
+ name: string,
780
+ options: Omit<DokployDestinationCreate, 'name'>,
781
+ ): Promise<{ destination: DokployDestination; created: boolean }> {
782
+ const existing = await this.findDestinationByName(name);
783
+ if (existing) {
784
+ return { destination: existing, created: false };
785
+ }
786
+ const destination = await this.createDestination({ name, ...options });
787
+ return { destination, created: true };
788
+ }
789
+
790
+ /**
791
+ * Update a destination
792
+ */
793
+ async updateDestination(
794
+ destinationId: string,
795
+ updates: Partial<DokployDestinationCreate>,
796
+ ): Promise<void> {
797
+ await this.post('destination.update', { destinationId, ...updates });
798
+ }
799
+
800
+ /**
801
+ * Delete a destination
802
+ */
803
+ async deleteDestination(destinationId: string): Promise<void> {
804
+ await this.post('destination.remove', { destinationId });
805
+ }
806
+
807
+ /**
808
+ * Test connection to a destination
809
+ */
810
+ async testDestinationConnection(
811
+ destinationId: string,
812
+ ): Promise<{ success: boolean }> {
813
+ return this.post<{ success: boolean }>('destination.testConnection', {
814
+ destinationId,
815
+ });
816
+ }
817
+
818
+ // ============================================
819
+ // Backup endpoints (scheduled backups)
820
+ // ============================================
821
+
822
+ /**
823
+ * Create a backup schedule for postgres
824
+ */
825
+ async createPostgresBackup(
826
+ options: DokployBackupCreate,
827
+ ): Promise<DokployBackup> {
828
+ return this.post<DokployBackup>('backup.create', {
829
+ ...options,
830
+ databaseType: 'postgres',
831
+ });
832
+ }
833
+
834
+ /**
835
+ * List backups for a postgres database
836
+ */
837
+ async listPostgresBackups(postgresId: string): Promise<DokployBackup[]> {
838
+ return this.get<DokployBackup[]>(
839
+ `backup.all?postgresId=${postgresId}&databaseType=postgres`,
840
+ );
841
+ }
842
+
843
+ /**
844
+ * Get a backup by ID
845
+ */
846
+ async getBackup(backupId: string): Promise<DokployBackup> {
847
+ return this.get<DokployBackup>(`backup.one?backupId=${backupId}`);
848
+ }
849
+
850
+ /**
851
+ * Update a backup schedule
852
+ */
853
+ async updateBackup(
854
+ backupId: string,
855
+ updates: Partial<DokployBackupUpdate>,
856
+ ): Promise<void> {
857
+ await this.post('backup.update', { backupId, ...updates });
858
+ }
859
+
860
+ /**
861
+ * Delete a backup schedule
862
+ */
863
+ async deleteBackup(backupId: string): Promise<void> {
864
+ await this.post('backup.remove', { backupId });
865
+ }
866
+
867
+ /**
868
+ * Trigger a manual backup run
869
+ */
870
+ async runBackupManually(backupId: string): Promise<void> {
871
+ await this.post('backup.manualBackup', { backupId });
872
+ }
707
873
  }
708
874
 
709
875
  // ============================================
@@ -835,6 +1001,77 @@ export interface DokployDomain extends DokployDomainCreate {
835
1001
  createdAt?: string;
836
1002
  }
837
1003
 
1004
+ // ============================================
1005
+ // Destination types (backup storage)
1006
+ // ============================================
1007
+
1008
+ export interface DokployDestination {
1009
+ destinationId: string;
1010
+ name: string;
1011
+ accessKey: string;
1012
+ bucket: string;
1013
+ region: string;
1014
+ endpoint: string | null;
1015
+ createdAt?: string;
1016
+ }
1017
+
1018
+ export interface DokployDestinationCreate {
1019
+ /** User-friendly name for the destination */
1020
+ name: string;
1021
+ /** S3 access key ID */
1022
+ accessKey: string;
1023
+ /** S3 secret access key */
1024
+ secretAccessKey: string;
1025
+ /** S3 bucket name */
1026
+ bucket: string;
1027
+ /** AWS region (e.g., 'us-east-1') */
1028
+ region: string;
1029
+ /** Optional endpoint for S3-compatible services */
1030
+ endpoint?: string;
1031
+ }
1032
+
1033
+ // ============================================
1034
+ // Backup types (scheduled backups)
1035
+ // ============================================
1036
+
1037
+ export type DokployDatabaseType = 'postgres' | 'mysql' | 'mariadb' | 'mongo';
1038
+
1039
+ export interface DokployBackup {
1040
+ backupId: string;
1041
+ schedule: string;
1042
+ prefix: string;
1043
+ enabled: boolean;
1044
+ destinationId: string;
1045
+ postgresId?: string;
1046
+ databaseType: DokployDatabaseType;
1047
+ keepLatestCount: number | null;
1048
+ createdAt?: string;
1049
+ }
1050
+
1051
+ export interface DokployBackupCreate {
1052
+ /** Cron schedule (e.g., '0 2 * * *' for 2 AM daily) */
1053
+ schedule: string;
1054
+ /** Backup file prefix (e.g., 'production/postgres') */
1055
+ prefix: string;
1056
+ /** Destination ID for backup storage */
1057
+ destinationId: string;
1058
+ /** Database name to backup */
1059
+ database: string;
1060
+ /** Postgres instance ID */
1061
+ postgresId: string;
1062
+ /** Enable/disable backup (default: true) */
1063
+ enabled?: boolean;
1064
+ /** Number of backups to keep (default: keep all) */
1065
+ keepLatestCount?: number;
1066
+ }
1067
+
1068
+ export interface DokployBackupUpdate {
1069
+ schedule?: string;
1070
+ prefix?: string;
1071
+ enabled?: boolean;
1072
+ keepLatestCount?: number;
1073
+ }
1074
+
838
1075
  /**
839
1076
  * Create a Dokploy API client from stored credentials or environment
840
1077
  */
@@ -208,8 +208,18 @@ export function resolveEnvVar(
208
208
  }
209
209
 
210
210
  // Check dependency URLs (e.g., AUTH_URL -> dependencyUrls.auth)
211
+ // Also supports NEXT_PUBLIC_ prefix for frontend apps (NEXT_PUBLIC_AUTH_URL -> dependencyUrls.auth)
211
212
  if (context.dependencyUrls && varName.endsWith('_URL')) {
212
- const depName = varName.slice(0, -4).toLowerCase(); // AUTH_URL -> auth
213
+ let depName: string;
214
+
215
+ if (varName.startsWith('NEXT_PUBLIC_')) {
216
+ // NEXT_PUBLIC_AUTH_URL -> auth
217
+ depName = varName.slice(12, -4).toLowerCase();
218
+ } else {
219
+ // AUTH_URL -> auth
220
+ depName = varName.slice(0, -4).toLowerCase();
221
+ }
222
+
213
223
  if (context.dependencyUrls[depName]) {
214
224
  return context.dependencyUrls[depName];
215
225
  }
@@ -73,12 +73,7 @@ import {
73
73
  type DokployPostgres,
74
74
  type DokployRedis,
75
75
  } from './dokploy-api';
76
- import {
77
- generatePublicUrlBuildArgs,
78
- getPublicUrlArgNames,
79
- isMainFrontendApp,
80
- resolveHost,
81
- } from './domain.js';
76
+ import { isMainFrontendApp, resolveHost } from './domain.js';
82
77
  import {
83
78
  type EnvResolverContext,
84
79
  formatMissingVarsError,
@@ -93,10 +88,13 @@ import {
93
88
  createEmptyState,
94
89
  getAllAppCredentials,
95
90
  getApplicationId,
91
+ getBackupState,
96
92
  getPostgresId,
97
93
  getRedisId,
98
94
  setAppCredentials,
99
95
  setApplicationId,
96
+ setBackupState,
97
+ setPostgresBackupId,
100
98
  setPostgresId,
101
99
  setRedisId,
102
100
  } from './state.js';
@@ -330,29 +328,39 @@ async function initializePostgresUsers(
330
328
  ` Creating user "${user.name}" with schema "${schemaName}"...`,
331
329
  );
332
330
 
333
- // Create or update user (handles existing users)
334
- await client.query(`
335
- DO $$ BEGIN
336
- IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${user.name}') THEN
337
- CREATE USER "${user.name}" WITH PASSWORD '${user.password}';
338
- ELSE
339
- ALTER USER "${user.name}" WITH PASSWORD '${user.password}';
340
- END IF;
341
- END $$;
342
- `);
343
-
331
+ // Create or update user with all settings in one DO block
332
+ // This avoids "tuple already updated by self" errors from multiple ALTER USER calls
344
333
  if (user.usePublicSchema) {
345
334
  // API uses public schema
335
+ await client.query(`
336
+ DO $$ BEGIN
337
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${user.name}') THEN
338
+ CREATE USER "${user.name}" WITH PASSWORD '${user.password}';
339
+ ELSE
340
+ ALTER USER "${user.name}" WITH PASSWORD '${user.password}';
341
+ END IF;
342
+ END $$;
343
+ `);
346
344
  await client.query(`
347
345
  GRANT ALL ON SCHEMA public TO "${user.name}";
348
346
  ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "${user.name}";
349
347
  ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "${user.name}";
350
348
  `);
351
349
  } else {
352
- // Other apps get their own schema
350
+ // Other apps get their own schema - combine user creation and search_path in one block
351
+ await client.query(`
352
+ DO $$ BEGIN
353
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${user.name}') THEN
354
+ CREATE USER "${user.name}" WITH PASSWORD '${user.password}';
355
+ ELSE
356
+ ALTER USER "${user.name}" WITH PASSWORD '${user.password}';
357
+ END IF;
358
+ -- Set search_path in same transaction to avoid tuple conflict
359
+ ALTER USER "${user.name}" SET search_path TO "${schemaName}";
360
+ END $$;
361
+ `);
353
362
  await client.query(`
354
363
  CREATE SCHEMA IF NOT EXISTS "${schemaName}" AUTHORIZATION "${user.name}";
355
- ALTER USER "${user.name}" SET search_path TO "${schemaName}";
356
364
  GRANT USAGE ON SCHEMA "${schemaName}" TO "${user.name}";
357
365
  GRANT ALL ON ALL TABLES IN SCHEMA "${schemaName}" TO "${user.name}";
358
366
  ALTER DEFAULT PRIVILEGES IN SCHEMA "${schemaName}" GRANT ALL ON TABLES TO "${user.name}";
@@ -1254,6 +1262,51 @@ export async function workspaceDeployCommand(
1254
1262
  }
1255
1263
  }
1256
1264
 
1265
+ // ==================================================================
1266
+ // Provision backup destination if configured
1267
+ // ==================================================================
1268
+ if (workspace.deploy?.backups && provisionedPostgres) {
1269
+ logger.log('\n💾 Provisioning backup destination...');
1270
+
1271
+ const { provisionBackupDestination } = await import(
1272
+ './backup-provisioner.js'
1273
+ );
1274
+
1275
+ const backupState = await provisionBackupDestination({
1276
+ api,
1277
+ projectId: project.projectId,
1278
+ projectName: workspace.name,
1279
+ stage,
1280
+ config: workspace.deploy.backups,
1281
+ existingState: getBackupState(state),
1282
+ logger,
1283
+ });
1284
+
1285
+ // Save backup state
1286
+ setBackupState(state, backupState);
1287
+
1288
+ // Create backup schedule for postgres if not already configured
1289
+ if (!backupState.postgresBackupId) {
1290
+ const backupSchedule = workspace.deploy.backups.schedule ?? '0 2 * * *';
1291
+ const backupRetention = workspace.deploy.backups.retention ?? 30;
1292
+
1293
+ logger.log(' Creating postgres backup schedule...');
1294
+ const backup = await api.createPostgresBackup({
1295
+ schedule: backupSchedule,
1296
+ prefix: `${stage}/postgres`,
1297
+ destinationId: backupState.destinationId,
1298
+ database: provisionedPostgres.databaseName,
1299
+ postgresId: provisionedPostgres.postgresId,
1300
+ enabled: true,
1301
+ keepLatestCount: backupRetention,
1302
+ });
1303
+ setPostgresBackupId(state, backup.backupId);
1304
+ logger.log(` ✓ Postgres backup schedule created (${backupSchedule})`);
1305
+ } else {
1306
+ logger.log(' ✓ Using existing postgres backup schedule');
1307
+ }
1308
+ }
1309
+
1257
1310
  // Track deployed app public URLs for frontend builds
1258
1311
  const publicUrls: Record<string, string> = {};
1259
1312
  const results: AppDeployResult[] = [];
@@ -1576,13 +1629,71 @@ export async function workspaceDeployCommand(
1576
1629
  // Store application ID in state
1577
1630
  setApplicationId(state, appName, application.applicationId);
1578
1631
 
1579
- // Generate public URL build args from dependencies
1580
- const buildArgs = generatePublicUrlBuildArgs(app, publicUrls);
1632
+ // Build dependency URLs for frontend (same pattern as backend)
1633
+ const dependencyUrls: Record<string, string> = {};
1634
+ if (app.dependencies) {
1635
+ for (const dep of app.dependencies) {
1636
+ if (publicUrls[dep]) {
1637
+ dependencyUrls[dep] = publicUrls[dep];
1638
+ }
1639
+ }
1640
+ }
1641
+
1642
+ // Compute hostname for this frontend app
1643
+ const isMainFrontend = isMainFrontendApp(appName, app, workspace.apps);
1644
+ const frontendHost = resolveHost(
1645
+ appName,
1646
+ app,
1647
+ stage,
1648
+ dokployConfig,
1649
+ isMainFrontend,
1650
+ );
1651
+
1652
+ // Build env context for frontend
1653
+ const envContext: EnvResolverContext = {
1654
+ app,
1655
+ appName,
1656
+ stage,
1657
+ state,
1658
+ appHostname: frontendHost,
1659
+ frontendUrls: [],
1660
+ userSecrets: stageSecrets ?? undefined,
1661
+ dependencyUrls,
1662
+ };
1663
+
1664
+ // Resolve all env vars BEFORE Docker build (NEXT_PUBLIC_* must be present at build time)
1665
+ const sniffedVars = sniffedApps.get(appName)?.requiredEnvVars ?? [];
1666
+ const { valid, missing, resolved } = validateEnvVars(
1667
+ sniffedVars,
1668
+ envContext,
1669
+ );
1670
+
1671
+ if (!valid) {
1672
+ throw new Error(formatMissingVarsError(appName, missing, stage));
1673
+ }
1674
+
1675
+ if (Object.keys(resolved).length > 0) {
1676
+ logger.log(
1677
+ ` Resolved ${Object.keys(resolved).length} env vars: ${Object.keys(resolved).join(', ')}`,
1678
+ );
1679
+ }
1680
+
1681
+ // Build args: all NEXT_PUBLIC_* vars must be present at Next.js build time
1682
+ const buildArgs: string[] = [];
1683
+ const publicUrlArgNames: string[] = [];
1684
+
1685
+ for (const [key, value] of Object.entries(resolved)) {
1686
+ if (key.startsWith('NEXT_PUBLIC_')) {
1687
+ buildArgs.push(`${key}=${value}`);
1688
+ publicUrlArgNames.push(key);
1689
+ }
1690
+ }
1691
+
1581
1692
  if (buildArgs.length > 0) {
1582
- logger.log(` Public URLs: ${buildArgs.join(', ')}`);
1693
+ logger.log(` Build args: ${publicUrlArgNames.join(', ')}`);
1583
1694
  }
1584
1695
 
1585
- // Build Docker image with public URLs
1696
+ // Build Docker image with NEXT_PUBLIC_* vars as build args
1586
1697
  const imageName = `${workspace.name}-${appName}`;
1587
1698
  const imageRef = registry
1588
1699
  ? `${registry}/${imageName}:${imageTag}`
@@ -1600,17 +1711,22 @@ export async function workspaceDeployCommand(
1600
1711
  appName,
1601
1712
  },
1602
1713
  buildArgs,
1603
- // Pass public URL arg names for Dockerfile generation
1604
- publicUrlArgs: getPublicUrlArgNames(app),
1714
+ // Pass arg names for Dockerfile ARG generation
1715
+ publicUrlArgs: publicUrlArgNames,
1605
1716
  });
1606
1717
 
1607
- // Prepare environment variables - no secrets needed
1718
+ // Prepare runtime environment variables
1608
1719
  const envVars: string[] = [
1609
1720
  `NODE_ENV=production`,
1610
1721
  `PORT=${app.port}`,
1611
1722
  `STAGE=${stage}`,
1612
1723
  ];
1613
1724
 
1725
+ // Add all resolved vars as runtime env (for SSR and server components)
1726
+ for (const [key, value] of Object.entries(resolved)) {
1727
+ envVars.push(`${key}=${value}`);
1728
+ }
1729
+
1614
1730
  // Configure and deploy application in Dokploy
1615
1731
  await api.saveDockerProvider(application.applicationId, imageRef, {
1616
1732
  registryId,
@@ -1624,17 +1740,7 @@ export async function workspaceDeployCommand(
1624
1740
  logger.log(` Deploying to Dokploy...`);
1625
1741
  await api.deployApplication(application.applicationId);
1626
1742
 
1627
- // Create or find domain for this app
1628
- const isMainFrontend = isMainFrontendApp(appName, app, workspace.apps);
1629
- const frontendHost = resolveHost(
1630
- appName,
1631
- app,
1632
- stage,
1633
- dokployConfig,
1634
- isMainFrontend,
1635
- );
1636
-
1637
- // Check if domain already exists
1743
+ // Check if domain already exists (frontendHost computed earlier for env context)
1638
1744
  const existingFrontendDomains = await api.getDomainsByApplicationId(
1639
1745
  application.applicationId,
1640
1746
  );
@@ -98,17 +98,49 @@ export async function sniffAppEnvironment(
98
98
  ): Promise<SniffedEnvironment> {
99
99
  const { logWarnings = true } = options;
100
100
 
101
- // 1. Frontend apps don't have server-side secrets
101
+ // 1. Frontend apps - handle dependencies and config sniffing
102
102
  if (app.type === 'frontend') {
103
- return { appName, requiredEnvVars: [] };
104
- }
103
+ // Auto-generate NEXT_PUBLIC_{DEP}_URL from dependencies
104
+ const depVars = (app.dependencies ?? []).map(
105
+ (dep) => `NEXT_PUBLIC_${dep.toUpperCase()}_URL`,
106
+ );
107
+
108
+ // If config specified, sniff by importing the file(s)
109
+ // The file calls .parse() at module load, which triggers sniffer to capture vars
110
+ if (app.config) {
111
+ const sniffedVars: string[] = [];
112
+
113
+ // Collect config paths to sniff
114
+ const configPaths: string[] = [];
115
+ if (app.config.client) configPaths.push(app.config.client);
116
+ if (app.config.server) configPaths.push(app.config.server);
117
+
118
+ // Sniff each config file
119
+ for (const configPath of configPaths) {
120
+ const result = await sniffEntryFile(
121
+ configPath,
122
+ app.path,
123
+ workspacePath,
124
+ );
125
+
126
+ if (logWarnings && result.error) {
127
+ console.warn(
128
+ `[sniffer] ${appName}: Config file "${configPath}" threw error during sniffing (env vars still captured): ${result.error.message}`,
129
+ );
130
+ }
131
+
132
+ sniffedVars.push(...result.envVars);
133
+ }
134
+
135
+ // Combine: dependency vars + sniffed vars (deduplicated)
136
+ const allVars = [...new Set([...depVars, ...sniffedVars])];
137
+ return { appName, requiredEnvVars: allVars };
138
+ }
105
139
 
106
- // 2. Entry-based apps with explicit env list
107
- if (app.requiredEnv && app.requiredEnv.length > 0) {
108
- return { appName, requiredEnvVars: [...app.requiredEnv] };
140
+ return { appName, requiredEnvVars: depVars };
109
141
  }
110
142
 
111
- // 3. Entry apps - import entry file in subprocess to trigger config.parse()
143
+ // 2. Entry apps - import entry file in subprocess to trigger config.parse()
112
144
  if (app.entry) {
113
145
  const result = await sniffEntryFile(app.entry, app.path, workspacePath);
114
146