@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
@@ -5,7 +5,7 @@
5
5
  * Authentication: Bearer token from hpanel.hostinger.com/profile/api
6
6
  */
7
7
 
8
- const HOSTINGER_API_BASE = 'https://api.hostinger.com';
8
+ const HOSTINGER_API_BASE = 'https://developers.hostinger.com';
9
9
 
10
10
  /**
11
11
  * DNS record types supported by Hostinger
@@ -30,8 +30,8 @@ export interface DnsRecord {
30
30
  type: DnsRecordType;
31
31
  /** TTL in seconds */
32
32
  ttl: number;
33
- /** Record values (e.g., ['1.2.3.4'] for A record) */
34
- records: string[];
33
+ /** Record values */
34
+ records: Array<{ content: string }>;
35
35
  }
36
36
 
37
37
  /**
@@ -148,7 +148,7 @@ export class HostingerApi {
148
148
  name: string;
149
149
  type: DnsRecordType;
150
150
  ttl: number;
151
- records: string[];
151
+ records: Array<{ content: string }>;
152
152
  }>;
153
153
  }
154
154
 
@@ -185,7 +185,10 @@ export class HostingerApi {
185
185
  * @param records - Records to validate
186
186
  * @returns true if valid, throws if invalid
187
187
  */
188
- async validateRecords(domain: string, records: DnsRecord[]): Promise<boolean> {
188
+ async validateRecords(
189
+ domain: string,
190
+ records: DnsRecord[],
191
+ ): Promise<boolean> {
189
192
  await this.request('POST', `/api/dns/v1/zones/${domain}/validate`, {
190
193
  overwrite: false,
191
194
  zone: records,
@@ -249,7 +252,7 @@ export class HostingerApi {
249
252
  name: subdomain,
250
253
  type: 'A',
251
254
  ttl,
252
- records: [ip],
255
+ records: [{ content: ip }],
253
256
  },
254
257
  ]);
255
258
 
@@ -6,7 +6,12 @@
6
6
 
7
7
  import { lookup } from 'node:dns/promises';
8
8
  import { getHostingerToken, storeHostingerToken } from '../../auth/credentials';
9
- import type { DnsConfig, DnsProvider } from '../../workspace/types';
9
+ import type { DnsConfig } from '../../workspace/types';
10
+ import {
11
+ type DokployStageState,
12
+ isDnsVerified,
13
+ setDnsVerification,
14
+ } from '../state';
10
15
  import { HostingerApi } from './hostinger-api';
11
16
 
12
17
  const logger = console;
@@ -105,7 +110,7 @@ export function printDnsRecordsTable(
105
110
  records: RequiredDnsRecord[],
106
111
  rootDomain: string,
107
112
  ): void {
108
- logger.log('\n 📋 DNS Records for ' + rootDomain + ':');
113
+ logger.log(`\n 📋 DNS Records for ${rootDomain}:`);
109
114
  logger.log(
110
115
  ' ┌─────────────────────────────────────┬──────┬─────────────────┬────────┐',
111
116
  );
@@ -164,7 +169,6 @@ export function printDnsRecordsSimple(
164
169
  */
165
170
  async function promptForToken(message: string): Promise<string> {
166
171
  const { stdin, stdout } = await import('node:process');
167
- const readline = await import('node:readline/promises');
168
172
 
169
173
  if (!stdin.isTTY) {
170
174
  throw new Error('Interactive input required for Hostinger token.');
@@ -294,7 +298,7 @@ async function createHostingerRecords(
294
298
  name: record.subdomain,
295
299
  type: 'A',
296
300
  ttl,
297
- records: [record.value],
301
+ records: [{ content: record.value }],
298
302
  },
299
303
  ]);
300
304
 
@@ -397,3 +401,110 @@ export async function orchestrateDns(
397
401
  serverIp,
398
402
  };
399
403
  }
404
+
405
+ /**
406
+ * Result of DNS verification for a single hostname
407
+ */
408
+ export interface DnsVerificationResult {
409
+ hostname: string;
410
+ appName: string;
411
+ verified: boolean;
412
+ resolvedIp?: string;
413
+ expectedIp: string;
414
+ error?: string;
415
+ skipped?: boolean; // True if already verified in state
416
+ }
417
+
418
+ /**
419
+ * Verify DNS records resolve correctly after deployment.
420
+ *
421
+ * This function:
422
+ * 1. Checks state for previously verified hostnames (skips if already verified with same IP)
423
+ * 2. Attempts to resolve each hostname to an IP
424
+ * 3. Compares resolved IP with expected server IP
425
+ * 4. Updates state with verification results
426
+ *
427
+ * @param appHostnames - Map of app names to hostnames
428
+ * @param serverIp - Expected IP address the hostnames should resolve to
429
+ * @param state - Deploy state for caching verification results
430
+ * @returns Array of verification results
431
+ */
432
+ export async function verifyDnsRecords(
433
+ appHostnames: Map<string, string>,
434
+ serverIp: string,
435
+ state: DokployStageState,
436
+ ): Promise<DnsVerificationResult[]> {
437
+ const results: DnsVerificationResult[] = [];
438
+
439
+ logger.log('\n🔍 Verifying DNS records...');
440
+
441
+ for (const [appName, hostname] of appHostnames) {
442
+ // Check if already verified with same IP
443
+ if (isDnsVerified(state, hostname, serverIp)) {
444
+ logger.log(` ✓ ${hostname} (previously verified)`);
445
+ results.push({
446
+ hostname,
447
+ appName,
448
+ verified: true,
449
+ expectedIp: serverIp,
450
+ skipped: true,
451
+ });
452
+ continue;
453
+ }
454
+
455
+ // Attempt to resolve hostname
456
+ try {
457
+ const resolvedIp = await resolveHostnameToIp(hostname);
458
+
459
+ if (resolvedIp === serverIp) {
460
+ // DNS verified successfully
461
+ setDnsVerification(state, hostname, serverIp);
462
+ logger.log(` ✓ ${hostname} → ${resolvedIp}`);
463
+ results.push({
464
+ hostname,
465
+ appName,
466
+ verified: true,
467
+ resolvedIp,
468
+ expectedIp: serverIp,
469
+ });
470
+ } else {
471
+ // DNS resolves but to wrong IP
472
+ logger.log(
473
+ ` ⚠ ${hostname} resolves to ${resolvedIp}, expected ${serverIp}`,
474
+ );
475
+ results.push({
476
+ hostname,
477
+ appName,
478
+ verified: false,
479
+ resolvedIp,
480
+ expectedIp: serverIp,
481
+ });
482
+ }
483
+ } catch (error) {
484
+ // DNS resolution failed
485
+ const message = error instanceof Error ? error.message : 'Unknown error';
486
+ logger.log(` ⚠ ${hostname} DNS not propagated (${message})`);
487
+ results.push({
488
+ hostname,
489
+ appName,
490
+ verified: false,
491
+ expectedIp: serverIp,
492
+ error: message,
493
+ });
494
+ }
495
+ }
496
+
497
+ // Summary
498
+ const verified = results.filter((r) => r.verified).length;
499
+ const skipped = results.filter((r) => r.skipped).length;
500
+ const pending = results.filter((r) => !r.verified).length;
501
+
502
+ if (pending > 0) {
503
+ logger.log(`\n ${verified} verified, ${pending} pending propagation`);
504
+ logger.log(' DNS changes may take 5-30 minutes to propagate');
505
+ } else if (skipped > 0) {
506
+ logger.log(` ${verified} verified (${skipped} from cache)`);
507
+ }
508
+
509
+ return results;
510
+ }
@@ -134,8 +134,7 @@ async function buildImage(
134
134
  const dockerfilePath = `.gkm/docker/Dockerfile${dockerfileSuffix}`;
135
135
 
136
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;
137
+ const buildCwd = lockfilePath && (inMonorepo || appName) ? lockfileDir : cwd;
139
138
  if (buildCwd !== cwd) {
140
139
  logger.log(` Building from workspace root: ${buildCwd}`);
141
140
  }
@@ -431,10 +431,10 @@ export class DokployApi {
431
431
  environmentId,
432
432
  appName:
433
433
  options?.appName ?? name.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
434
- databaseName: options?.databaseName ?? 'app',
434
+ databaseName: options?.databaseName,
435
435
  databaseUser: options?.databaseUser ?? 'postgres',
436
436
  databasePassword: options?.databasePassword,
437
- dockerImage: options?.dockerImage ?? 'postgres:16-alpine',
437
+ dockerImage: options?.dockerImage ?? 'postgres:18',
438
438
  description: options?.description ?? `Postgres database for ${name}`,
439
439
  });
440
440
  }
@@ -447,6 +447,7 @@ export class DokployApi {
447
447
  projectId: string,
448
448
  environmentId: string,
449
449
  options?: {
450
+ databaseName?: string;
450
451
  databasePassword?: string;
451
452
  },
452
453
  ): Promise<{ postgres: DokployPostgres; created: boolean }> {
@@ -513,9 +514,7 @@ export class DokployApi {
513
514
  */
514
515
  async listRedis(projectId: string): Promise<DokployRedis[]> {
515
516
  try {
516
- return await this.get<DokployRedis[]>(
517
- `redis.all?projectId=${projectId}`,
518
- );
517
+ return await this.get<DokployRedis[]>(`redis.all?projectId=${projectId}`);
519
518
  } catch {
520
519
  // Fallback: endpoint might not exist in older Dokploy versions
521
520
  return [];
@@ -557,7 +556,7 @@ export class DokployApi {
557
556
  appName:
558
557
  options?.appName ?? name.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
559
558
  databasePassword: options?.databasePassword,
560
- dockerImage: options?.dockerImage ?? 'redis:7-alpine',
559
+ dockerImage: options?.dockerImage ?? 'redis:8',
561
560
  description: options?.description ?? `Redis instance for ${name}`,
562
561
  });
563
562
  }
@@ -577,7 +576,12 @@ export class DokployApi {
577
576
  if (existing) {
578
577
  return { redis: existing, created: false };
579
578
  }
580
- const redis = await this.createRedis(name, projectId, environmentId, options);
579
+ const redis = await this.createRedis(
580
+ name,
581
+ projectId,
582
+ environmentId,
583
+ options,
584
+ );
581
585
  return { redis, created: true };
582
586
  }
583
587
 
@@ -677,10 +681,15 @@ export class DokployApi {
677
681
  * This should be called after DNS records are created and propagated.
678
682
  * It triggers Let's Encrypt certificate generation for HTTPS domains.
679
683
  *
680
- * @param domainId - The domain ID to validate
681
- */
682
- async validateDomain(domainId: string): Promise<void> {
683
- await this.post('domain.validateDomain', { domainId });
684
+ * @param domain - The domain hostname to validate (e.g., 'api.example.com')
685
+ */
686
+ async validateDomain(
687
+ domain: string,
688
+ ): Promise<{ isValid: boolean; resolvedIp: string }> {
689
+ return this.post<{ isValid: boolean; resolvedIp: string }>(
690
+ 'domain.validateDomain',
691
+ { domain },
692
+ );
684
693
  }
685
694
 
686
695
  /**
@@ -1,4 +1,7 @@
1
- import type { DokployWorkspaceConfig, NormalizedAppConfig } from '../workspace/types.js';
1
+ import type {
2
+ DokployWorkspaceConfig,
3
+ NormalizedAppConfig,
4
+ } from '../workspace/types.js';
2
5
 
3
6
  /**
4
7
  * Resolve the hostname for an app based on stage configuration.
@@ -119,7 +122,5 @@ export function generatePublicUrlBuildArgs(
119
122
  * @returns Array of arg names like 'NEXT_PUBLIC_API_URL'
120
123
  */
121
124
  export function getPublicUrlArgNames(app: NormalizedAppConfig): string[] {
122
- return app.dependencies.map(
123
- (dep) => `NEXT_PUBLIC_${dep.toUpperCase()}_URL`,
124
- );
125
+ return app.dependencies.map((dep) => `NEXT_PUBLIC_${dep.toUpperCase()}_URL`);
125
126
  }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Environment Variable Resolution for Dokploy Deployments
3
+ *
4
+ * Resolves sniffed environment variables to actual values during deployment.
5
+ * Auto-supports common variables like DATABASE_URL, REDIS_URL, BETTER_AUTH_*,
6
+ * and falls back to user-provided secrets.
7
+ */
8
+
9
+ import { randomBytes } from 'node:crypto';
10
+ import type { StageSecrets } from '../secrets/types';
11
+ import type { NormalizedAppConfig } from '../workspace/types';
12
+ import {
13
+ getGeneratedSecret,
14
+ setGeneratedSecret,
15
+ type AppDbCredentials,
16
+ type DokployStageState,
17
+ } from './state';
18
+
19
+ /**
20
+ * Context needed for environment variable resolution
21
+ */
22
+ export interface EnvResolverContext {
23
+ /** The app being deployed */
24
+ app: NormalizedAppConfig;
25
+ /** The app name */
26
+ appName: string;
27
+ /** Deployment stage (production, staging, development) */
28
+ stage: string;
29
+ /** Deploy state (for persisting generated secrets) */
30
+ state: DokployStageState;
31
+ /** Per-app database credentials (if postgres is enabled) */
32
+ appCredentials?: AppDbCredentials;
33
+ /** Postgres connection info (internal hostname) */
34
+ postgres?: {
35
+ host: string;
36
+ port: number;
37
+ database: string;
38
+ };
39
+ /** Redis connection info (internal hostname) */
40
+ redis?: {
41
+ host: string;
42
+ port: number;
43
+ password?: string;
44
+ };
45
+ /** Public hostname for this app */
46
+ appHostname: string;
47
+ /** All frontend app URLs (for BETTER_AUTH_TRUSTED_ORIGINS) */
48
+ frontendUrls: string[];
49
+ /** User-provided secrets from secrets store */
50
+ userSecrets?: StageSecrets;
51
+ /** Master key for runtime decryption (optional) */
52
+ masterKey?: string;
53
+ }
54
+
55
+ /**
56
+ * Result of environment variable resolution
57
+ */
58
+ export interface EnvResolutionResult {
59
+ /** Successfully resolved environment variables */
60
+ resolved: Record<string, string>;
61
+ /** Environment variable names that could not be resolved */
62
+ missing: string[];
63
+ }
64
+
65
+ /**
66
+ * Auto-supported environment variable names
67
+ */
68
+ export const AUTO_SUPPORTED_VARS = [
69
+ 'PORT',
70
+ 'NODE_ENV',
71
+ 'DATABASE_URL',
72
+ 'REDIS_URL',
73
+ 'BETTER_AUTH_URL',
74
+ 'BETTER_AUTH_SECRET',
75
+ 'BETTER_AUTH_TRUSTED_ORIGINS',
76
+ 'GKM_MASTER_KEY',
77
+ ] as const;
78
+
79
+ export type AutoSupportedVar = (typeof AUTO_SUPPORTED_VARS)[number];
80
+
81
+ /**
82
+ * Check if a variable name is auto-supported
83
+ */
84
+ export function isAutoSupportedVar(varName: string): varName is AutoSupportedVar {
85
+ return AUTO_SUPPORTED_VARS.includes(varName as AutoSupportedVar);
86
+ }
87
+
88
+ /**
89
+ * Generate a secure random secret (64 hex characters = 32 bytes)
90
+ */
91
+ export function generateSecret(): string {
92
+ return randomBytes(32).toString('hex');
93
+ }
94
+
95
+ /**
96
+ * Get or generate a secret for an app.
97
+ * If the secret already exists in state, returns it.
98
+ * Otherwise generates a new one and stores it.
99
+ */
100
+ export function getOrGenerateSecret(
101
+ state: DokployStageState,
102
+ appName: string,
103
+ secretName: string,
104
+ ): string {
105
+ // Check if already generated
106
+ const existing = getGeneratedSecret(state, appName, secretName);
107
+ if (existing) {
108
+ return existing;
109
+ }
110
+
111
+ // Generate new secret
112
+ const generated = generateSecret();
113
+
114
+ // Store in state for persistence
115
+ setGeneratedSecret(state, appName, secretName, generated);
116
+
117
+ return generated;
118
+ }
119
+
120
+ /**
121
+ * Build a DATABASE_URL for an app with per-app credentials
122
+ */
123
+ export function buildDatabaseUrl(
124
+ credentials: AppDbCredentials,
125
+ postgres: { host: string; port: number; database: string },
126
+ ): string {
127
+ const { dbUser, dbPassword } = credentials;
128
+ const { host, port, database } = postgres;
129
+ return `postgresql://${encodeURIComponent(dbUser)}:${encodeURIComponent(dbPassword)}@${host}:${port}/${database}`;
130
+ }
131
+
132
+ /**
133
+ * Build a REDIS_URL
134
+ */
135
+ export function buildRedisUrl(redis: {
136
+ host: string;
137
+ port: number;
138
+ password?: string;
139
+ }): string {
140
+ const { host, port, password } = redis;
141
+ if (password) {
142
+ return `redis://:${encodeURIComponent(password)}@${host}:${port}`;
143
+ }
144
+ return `redis://${host}:${port}`;
145
+ }
146
+
147
+ /**
148
+ * Resolve a single environment variable
149
+ */
150
+ export function resolveEnvVar(
151
+ varName: string,
152
+ context: EnvResolverContext,
153
+ ): string | undefined {
154
+ // Auto-supported variables
155
+ switch (varName) {
156
+ case 'PORT':
157
+ return String(context.app.port);
158
+
159
+ case 'NODE_ENV':
160
+ return context.stage === 'production' ? 'production' : 'development';
161
+
162
+ case 'DATABASE_URL':
163
+ if (context.appCredentials && context.postgres) {
164
+ return buildDatabaseUrl(context.appCredentials, context.postgres);
165
+ }
166
+ // Fall through to check user secrets
167
+ break;
168
+
169
+ case 'REDIS_URL':
170
+ if (context.redis) {
171
+ return buildRedisUrl(context.redis);
172
+ }
173
+ // Fall through to check user secrets
174
+ break;
175
+
176
+ case 'BETTER_AUTH_URL':
177
+ return `https://${context.appHostname}`;
178
+
179
+ case 'BETTER_AUTH_SECRET':
180
+ return getOrGenerateSecret(
181
+ context.state,
182
+ context.appName,
183
+ 'BETTER_AUTH_SECRET',
184
+ );
185
+
186
+ case 'BETTER_AUTH_TRUSTED_ORIGINS':
187
+ if (context.frontendUrls.length > 0) {
188
+ return context.frontendUrls.join(',');
189
+ }
190
+ // Fall through to check user secrets
191
+ break;
192
+
193
+ case 'GKM_MASTER_KEY':
194
+ if (context.masterKey) {
195
+ return context.masterKey;
196
+ }
197
+ // Fall through to check user secrets
198
+ break;
199
+ }
200
+
201
+ // Check user-provided secrets
202
+ if (context.userSecrets) {
203
+ // Check custom secrets first
204
+ if (context.userSecrets.custom[varName]) {
205
+ return context.userSecrets.custom[varName];
206
+ }
207
+
208
+ // Check URLs (DATABASE_URL, REDIS_URL, RABBITMQ_URL)
209
+ if (varName in context.userSecrets.urls) {
210
+ return context.userSecrets.urls[varName as keyof typeof context.userSecrets.urls];
211
+ }
212
+
213
+ // Check service-specific vars
214
+ if (varName === 'POSTGRES_PASSWORD' && context.userSecrets.services.postgres) {
215
+ return context.userSecrets.services.postgres.password;
216
+ }
217
+ if (varName === 'REDIS_PASSWORD' && context.userSecrets.services.redis) {
218
+ return context.userSecrets.services.redis.password;
219
+ }
220
+ }
221
+
222
+ return undefined;
223
+ }
224
+
225
+ /**
226
+ * Resolve all environment variables for an app
227
+ */
228
+ export function resolveEnvVars(
229
+ requiredVars: string[],
230
+ context: EnvResolverContext,
231
+ ): EnvResolutionResult {
232
+ const resolved: Record<string, string> = {};
233
+ const missing: string[] = [];
234
+
235
+ for (const varName of requiredVars) {
236
+ const value = resolveEnvVar(varName, context);
237
+ if (value !== undefined) {
238
+ resolved[varName] = value;
239
+ } else {
240
+ missing.push(varName);
241
+ }
242
+ }
243
+
244
+ return { resolved, missing };
245
+ }
246
+
247
+ /**
248
+ * Format missing variables error message
249
+ */
250
+ export function formatMissingVarsError(
251
+ appName: string,
252
+ missing: string[],
253
+ stage: string,
254
+ ): string {
255
+ const varList = missing.map((v) => ` - ${v}`).join('\n');
256
+ return (
257
+ `Deployment failed: ${appName} is missing required environment variables:\n` +
258
+ `${varList}\n\n` +
259
+ `Add them with:\n` +
260
+ ` gkm secrets:set <VAR_NAME> <value> --stage ${stage}\n\n` +
261
+ `Or add them to the app's requiredEnv in gkm.config.ts to have them auto-resolved.`
262
+ );
263
+ }
264
+
265
+ /**
266
+ * Validate that all required environment variables can be resolved
267
+ */
268
+ export function validateEnvVars(
269
+ requiredVars: string[],
270
+ context: EnvResolverContext,
271
+ ): { valid: boolean; missing: string[]; resolved: Record<string, string> } {
272
+ const { resolved, missing } = resolveEnvVars(requiredVars, context);
273
+ return {
274
+ valid: missing.length === 0,
275
+ missing,
276
+ resolved,
277
+ };
278
+ }