@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs",
5
5
  "private": false,
6
6
  "type": "module",
@@ -48,11 +48,11 @@
48
48
  "lodash.kebabcase": "^4.1.1",
49
49
  "openapi-typescript": "^7.4.2",
50
50
  "prompts": "~2.4.2",
51
- "@geekmidas/schema": "~0.1.0",
52
- "@geekmidas/constructs": "~0.6.0",
53
- "@geekmidas/envkit": "~0.5.0",
54
51
  "@geekmidas/errors": "~0.1.0",
55
- "@geekmidas/logger": "~0.4.0"
52
+ "@geekmidas/logger": "~0.4.0",
53
+ "@geekmidas/envkit": "~0.6.0",
54
+ "@geekmidas/constructs": "~0.7.0",
55
+ "@geekmidas/schema": "~0.1.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/lodash.kebabcase": "^4.1.9",
@@ -1,12 +1,17 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { mkdir } from 'node:fs/promises';
4
- import { join, relative } from 'node:path';
4
+ import { join, relative, resolve } from 'node:path';
5
5
  import type { Cron } from '@geekmidas/constructs/crons';
6
6
  import type { Endpoint } from '@geekmidas/constructs/endpoints';
7
7
  import type { Function } from '@geekmidas/constructs/functions';
8
8
  import type { Subscriber } from '@geekmidas/constructs/subscribers';
9
- import { loadConfig, loadWorkspaceConfig, parseModuleConfig } from '../config';
9
+ import {
10
+ loadAppConfig,
11
+ loadConfig,
12
+ loadWorkspaceConfig,
13
+ parseModuleConfig,
14
+ } from '../config';
10
15
  import {
11
16
  getProductionConfigFromGkm,
12
17
  normalizeHooksConfig,
@@ -49,13 +54,25 @@ export async function buildCommand(
49
54
  const loadedConfig = await loadWorkspaceConfig();
50
55
 
51
56
  // Route to workspace build mode for multi-app workspaces
57
+ // BUT only if we're at the workspace root (prevents recursive builds when
58
+ // Turbo runs gkm build in each app subdirectory)
52
59
  if (loadedConfig.type === 'workspace') {
53
- logger.log('📦 Detected workspace configuration');
54
- return workspaceBuildCommand(loadedConfig.workspace, options);
60
+ const cwd = resolve(process.cwd());
61
+ const workspaceRoot = resolve(loadedConfig.workspace.root);
62
+ const isAtWorkspaceRoot = cwd === workspaceRoot;
63
+
64
+ if (isAtWorkspaceRoot) {
65
+ logger.log('📦 Detected workspace configuration');
66
+ return workspaceBuildCommand(loadedConfig.workspace, options);
67
+ }
68
+ // When running from inside an app directory, use app-specific config
55
69
  }
56
70
 
57
- // Single-app build - use existing logic
58
- const config = await loadConfig();
71
+ // Single-app build - use app config if in workspace, otherwise legacy config
72
+ const config =
73
+ loadedConfig.type === 'workspace'
74
+ ? (await loadAppConfig()).gkmConfig
75
+ : await loadConfig();
59
76
 
60
77
  // Resolve providers from new config format
61
78
  const resolved = resolveProviders(config, options);
@@ -0,0 +1,231 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { NormalizedAppConfig } from '../../workspace/types';
3
+ import {
4
+ generatePublicUrlBuildArgs,
5
+ getPublicUrlArgNames,
6
+ isMainFrontendApp,
7
+ resolveHost,
8
+ } from '../domain';
9
+
10
+ describe('resolveHost', () => {
11
+ const dokployConfig = {
12
+ endpoint: 'https://dokploy.example.com',
13
+ projectId: 'test-project',
14
+ domains: {
15
+ development: 'dev.myapp.com',
16
+ staging: 'staging.myapp.com',
17
+ production: 'myapp.com',
18
+ },
19
+ };
20
+
21
+ const createApp = (
22
+ overrides: Partial<NormalizedAppConfig> = {},
23
+ ): NormalizedAppConfig => ({
24
+ type: 'backend',
25
+ path: 'apps/api',
26
+ port: 3000,
27
+ dependencies: [],
28
+ resolvedDeployTarget: 'dokploy',
29
+ ...overrides,
30
+ });
31
+
32
+ it('should return explicit app domain override (string)', () => {
33
+ const app = createApp({ domain: 'api.custom.com' });
34
+ const host = resolveHost('api', app, 'production', dokployConfig, false);
35
+ expect(host).toBe('api.custom.com');
36
+ });
37
+
38
+ it('should return stage-specific domain override', () => {
39
+ const app = createApp({
40
+ domain: {
41
+ production: 'login.myapp.com',
42
+ staging: 'login.staging.myapp.com',
43
+ },
44
+ });
45
+ const host = resolveHost('auth', app, 'production', dokployConfig, false);
46
+ expect(host).toBe('login.myapp.com');
47
+ });
48
+
49
+ it('should fallback to base domain pattern when no stage match in override', () => {
50
+ const app = createApp({
51
+ domain: { production: 'custom.myapp.com' },
52
+ });
53
+ const host = resolveHost('api', app, 'development', dokployConfig, false);
54
+ expect(host).toBe('api.dev.myapp.com');
55
+ });
56
+
57
+ it('should return base domain for main frontend app', () => {
58
+ const app = createApp({ type: 'frontend' });
59
+ const host = resolveHost('web', app, 'production', dokployConfig, true);
60
+ expect(host).toBe('myapp.com');
61
+ });
62
+
63
+ it('should return prefixed domain for non-main apps', () => {
64
+ const app = createApp();
65
+ const host = resolveHost('api', app, 'production', dokployConfig, false);
66
+ expect(host).toBe('api.myapp.com');
67
+ });
68
+
69
+ it('should use correct base domain for each stage', () => {
70
+ const app = createApp();
71
+
72
+ expect(resolveHost('api', app, 'development', dokployConfig, false)).toBe(
73
+ 'api.dev.myapp.com',
74
+ );
75
+ expect(resolveHost('api', app, 'staging', dokployConfig, false)).toBe(
76
+ 'api.staging.myapp.com',
77
+ );
78
+ expect(resolveHost('api', app, 'production', dokployConfig, false)).toBe(
79
+ 'api.myapp.com',
80
+ );
81
+ });
82
+
83
+ it('should throw error when no domain configured for stage', () => {
84
+ const app = createApp();
85
+ expect(() =>
86
+ resolveHost('api', app, 'unknown-stage', dokployConfig, false),
87
+ ).toThrow('No domain configured for stage "unknown-stage"');
88
+ });
89
+
90
+ it('should throw error when dokployConfig has no domains', () => {
91
+ const app = createApp();
92
+ const configWithoutDomains = {
93
+ endpoint: 'https://dokploy.example.com',
94
+ projectId: 'test-project',
95
+ };
96
+ expect(() =>
97
+ resolveHost('api', app, 'production', configWithoutDomains, false),
98
+ ).toThrow('No domain configured for stage "production"');
99
+ });
100
+ });
101
+
102
+ describe('isMainFrontendApp', () => {
103
+ const createApp = (
104
+ type: 'backend' | 'frontend',
105
+ ): NormalizedAppConfig => ({
106
+ type,
107
+ path: 'apps/test',
108
+ port: 3000,
109
+ dependencies: [],
110
+ resolvedDeployTarget: 'dokploy',
111
+ });
112
+
113
+ it('should return false for backend apps', () => {
114
+ const apps = {
115
+ api: createApp('backend'),
116
+ web: createApp('frontend'),
117
+ };
118
+ expect(isMainFrontendApp('api', apps.api, apps)).toBe(false);
119
+ });
120
+
121
+ it('should return true for app named "web" if it is frontend', () => {
122
+ const apps = {
123
+ api: createApp('backend'),
124
+ web: createApp('frontend'),
125
+ admin: createApp('frontend'),
126
+ };
127
+ expect(isMainFrontendApp('web', apps.web, apps)).toBe(true);
128
+ });
129
+
130
+ it('should return true for first frontend app when no "web" app', () => {
131
+ const apps = {
132
+ api: createApp('backend'),
133
+ dashboard: createApp('frontend'),
134
+ admin: createApp('frontend'),
135
+ };
136
+ expect(isMainFrontendApp('dashboard', apps.dashboard, apps)).toBe(true);
137
+ expect(isMainFrontendApp('admin', apps.admin, apps)).toBe(false);
138
+ });
139
+
140
+ it('should return false for non-first frontend when no "web" app', () => {
141
+ const apps = {
142
+ api: createApp('backend'),
143
+ dashboard: createApp('frontend'),
144
+ admin: createApp('frontend'),
145
+ };
146
+ expect(isMainFrontendApp('admin', apps.admin, apps)).toBe(false);
147
+ });
148
+ });
149
+
150
+ describe('generatePublicUrlBuildArgs', () => {
151
+ const createApp = (dependencies: string[]): NormalizedAppConfig => ({
152
+ type: 'frontend',
153
+ path: 'apps/web',
154
+ port: 3001,
155
+ dependencies,
156
+ resolvedDeployTarget: 'dokploy',
157
+ });
158
+
159
+ it('should generate build args for dependencies', () => {
160
+ const app = createApp(['api', 'auth']);
161
+ const deployedUrls = {
162
+ api: 'https://api.myapp.com',
163
+ auth: 'https://auth.myapp.com',
164
+ };
165
+
166
+ const buildArgs = generatePublicUrlBuildArgs(app, deployedUrls);
167
+
168
+ expect(buildArgs).toEqual([
169
+ 'NEXT_PUBLIC_API_URL=https://api.myapp.com',
170
+ 'NEXT_PUBLIC_AUTH_URL=https://auth.myapp.com',
171
+ ]);
172
+ });
173
+
174
+ it('should skip missing dependencies', () => {
175
+ const app = createApp(['api', 'auth', 'missing']);
176
+ const deployedUrls = {
177
+ api: 'https://api.myapp.com',
178
+ // auth and missing are not deployed yet
179
+ };
180
+
181
+ const buildArgs = generatePublicUrlBuildArgs(app, deployedUrls);
182
+
183
+ expect(buildArgs).toEqual(['NEXT_PUBLIC_API_URL=https://api.myapp.com']);
184
+ });
185
+
186
+ it('should return empty array when no dependencies', () => {
187
+ const app = createApp([]);
188
+ const deployedUrls = { api: 'https://api.myapp.com' };
189
+
190
+ const buildArgs = generatePublicUrlBuildArgs(app, deployedUrls);
191
+
192
+ expect(buildArgs).toEqual([]);
193
+ });
194
+
195
+ it('should handle uppercase conversion correctly', () => {
196
+ const app = createApp(['my-api', 'auth-service']);
197
+ const deployedUrls = {
198
+ 'my-api': 'https://my-api.myapp.com',
199
+ 'auth-service': 'https://auth-service.myapp.com',
200
+ };
201
+
202
+ const buildArgs = generatePublicUrlBuildArgs(app, deployedUrls);
203
+
204
+ expect(buildArgs).toEqual([
205
+ 'NEXT_PUBLIC_MY-API_URL=https://my-api.myapp.com',
206
+ 'NEXT_PUBLIC_AUTH-SERVICE_URL=https://auth-service.myapp.com',
207
+ ]);
208
+ });
209
+ });
210
+
211
+ describe('getPublicUrlArgNames', () => {
212
+ const createApp = (dependencies: string[]): NormalizedAppConfig => ({
213
+ type: 'frontend',
214
+ path: 'apps/web',
215
+ port: 3001,
216
+ dependencies,
217
+ resolvedDeployTarget: 'dokploy',
218
+ });
219
+
220
+ it('should return arg names for dependencies', () => {
221
+ const app = createApp(['api', 'auth']);
222
+ const argNames = getPublicUrlArgNames(app);
223
+ expect(argNames).toEqual(['NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_AUTH_URL']);
224
+ });
225
+
226
+ it('should return empty array when no dependencies', () => {
227
+ const app = createApp([]);
228
+ const argNames = getPublicUrlArgNames(app);
229
+ expect(argNames).toEqual([]);
230
+ });
231
+ });
@@ -0,0 +1,300 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { StageSecrets } from '../../secrets/types';
3
+ import {
4
+ encryptSecretsForApp,
5
+ filterSecretsForApp,
6
+ generateSecretsReport,
7
+ prepareSecretsForAllApps,
8
+ prepareSecretsForApp,
9
+ } from '../secrets';
10
+ import type { SniffedEnvironment } from '../sniffer';
11
+
12
+ describe('filterSecretsForApp', () => {
13
+ const createStageSecrets = (
14
+ custom: Record<string, string> = {},
15
+ ): StageSecrets => ({
16
+ stage: 'production',
17
+ createdAt: '2024-01-01T00:00:00Z',
18
+ updatedAt: '2024-01-01T00:00:00Z',
19
+ services: {},
20
+ urls: {
21
+ DATABASE_URL: 'postgresql://localhost:5432/db',
22
+ REDIS_URL: 'redis://localhost:6379',
23
+ },
24
+ custom,
25
+ });
26
+
27
+ it('should filter secrets to only required env vars', () => {
28
+ const secrets = createStageSecrets({ API_KEY: 'secret123' });
29
+ const sniffed: SniffedEnvironment = {
30
+ appName: 'api',
31
+ requiredEnvVars: ['DATABASE_URL', 'API_KEY'],
32
+ };
33
+
34
+ const result = filterSecretsForApp(secrets, sniffed);
35
+
36
+ expect(result.appName).toBe('api');
37
+ expect(result.secrets).toEqual({
38
+ DATABASE_URL: 'postgresql://localhost:5432/db',
39
+ API_KEY: 'secret123',
40
+ });
41
+ expect(result.found).toEqual(['API_KEY', 'DATABASE_URL']);
42
+ expect(result.missing).toEqual([]);
43
+ });
44
+
45
+ it('should track missing secrets', () => {
46
+ const secrets = createStageSecrets();
47
+ const sniffed: SniffedEnvironment = {
48
+ appName: 'api',
49
+ requiredEnvVars: ['DATABASE_URL', 'STRIPE_KEY', 'JWT_SECRET'],
50
+ };
51
+
52
+ const result = filterSecretsForApp(secrets, sniffed);
53
+
54
+ expect(result.found).toEqual(['DATABASE_URL']);
55
+ expect(result.missing).toEqual(['JWT_SECRET', 'STRIPE_KEY']);
56
+ });
57
+
58
+ it('should return empty secrets when no env vars required', () => {
59
+ const secrets = createStageSecrets({ API_KEY: 'secret' });
60
+ const sniffed: SniffedEnvironment = {
61
+ appName: 'web',
62
+ requiredEnvVars: [],
63
+ };
64
+
65
+ const result = filterSecretsForApp(secrets, sniffed);
66
+
67
+ expect(result.secrets).toEqual({});
68
+ expect(result.found).toEqual([]);
69
+ expect(result.missing).toEqual([]);
70
+ });
71
+
72
+ it('should include service credentials when referenced', () => {
73
+ const secrets: StageSecrets = {
74
+ stage: 'production',
75
+ createdAt: '2024-01-01T00:00:00Z',
76
+ updatedAt: '2024-01-01T00:00:00Z',
77
+ services: {
78
+ postgres: {
79
+ host: 'localhost',
80
+ port: 5432,
81
+ username: 'user',
82
+ password: 'pass',
83
+ database: 'mydb',
84
+ },
85
+ },
86
+ urls: { DATABASE_URL: 'postgresql://user:pass@localhost:5432/mydb' },
87
+ custom: {},
88
+ };
89
+ const sniffed: SniffedEnvironment = {
90
+ appName: 'api',
91
+ requiredEnvVars: ['DATABASE_URL', 'POSTGRES_PASSWORD'],
92
+ };
93
+
94
+ const result = filterSecretsForApp(secrets, sniffed);
95
+
96
+ expect(result.secrets.DATABASE_URL).toBe(
97
+ 'postgresql://user:pass@localhost:5432/mydb',
98
+ );
99
+ expect(result.secrets.POSTGRES_PASSWORD).toBe('pass');
100
+ expect(result.found).toContain('DATABASE_URL');
101
+ expect(result.found).toContain('POSTGRES_PASSWORD');
102
+ });
103
+ });
104
+
105
+ describe('encryptSecretsForApp', () => {
106
+ it('should encrypt filtered secrets and return master key', () => {
107
+ const filtered = {
108
+ appName: 'api',
109
+ secrets: { DATABASE_URL: 'postgresql://localhost:5432/db' },
110
+ found: ['DATABASE_URL'],
111
+ missing: [],
112
+ };
113
+
114
+ const result = encryptSecretsForApp(filtered);
115
+
116
+ expect(result.appName).toBe('api');
117
+ expect(result.masterKey).toHaveLength(64); // 32 bytes hex
118
+ expect(result.payload.encrypted).toBeTruthy();
119
+ expect(result.payload.iv).toBeTruthy();
120
+ expect(result.secretCount).toBe(1);
121
+ expect(result.missingSecrets).toEqual([]);
122
+ });
123
+
124
+ it('should track missing secrets in result', () => {
125
+ const filtered = {
126
+ appName: 'api',
127
+ secrets: { DATABASE_URL: 'postgresql://localhost:5432/db' },
128
+ found: ['DATABASE_URL'],
129
+ missing: ['STRIPE_KEY', 'JWT_SECRET'],
130
+ };
131
+
132
+ const result = encryptSecretsForApp(filtered);
133
+
134
+ expect(result.missingSecrets).toEqual(['STRIPE_KEY', 'JWT_SECRET']);
135
+ });
136
+
137
+ it('should handle empty secrets', () => {
138
+ const filtered = {
139
+ appName: 'web',
140
+ secrets: {},
141
+ found: [],
142
+ missing: [],
143
+ };
144
+
145
+ const result = encryptSecretsForApp(filtered);
146
+
147
+ expect(result.secretCount).toBe(0);
148
+ expect(result.masterKey).toHaveLength(64);
149
+ });
150
+ });
151
+
152
+ describe('prepareSecretsForApp', () => {
153
+ it('should filter and encrypt in one step', () => {
154
+ const secrets: StageSecrets = {
155
+ stage: 'production',
156
+ createdAt: '2024-01-01T00:00:00Z',
157
+ updatedAt: '2024-01-01T00:00:00Z',
158
+ services: {},
159
+ urls: { DATABASE_URL: 'postgresql://localhost:5432/db' },
160
+ custom: { API_KEY: 'key123' },
161
+ };
162
+ const sniffed: SniffedEnvironment = {
163
+ appName: 'api',
164
+ requiredEnvVars: ['DATABASE_URL', 'API_KEY'],
165
+ };
166
+
167
+ const result = prepareSecretsForApp(secrets, sniffed);
168
+
169
+ expect(result.appName).toBe('api');
170
+ expect(result.secretCount).toBe(2);
171
+ expect(result.masterKey).toHaveLength(64);
172
+ });
173
+ });
174
+
175
+ describe('prepareSecretsForAllApps', () => {
176
+ const secrets: StageSecrets = {
177
+ stage: 'production',
178
+ createdAt: '2024-01-01T00:00:00Z',
179
+ updatedAt: '2024-01-01T00:00:00Z',
180
+ services: {},
181
+ urls: {
182
+ DATABASE_URL: 'postgresql://localhost:5432/db',
183
+ REDIS_URL: 'redis://localhost:6379',
184
+ },
185
+ custom: { BETTER_AUTH_SECRET: 'auth-secret' },
186
+ };
187
+
188
+ it('should prepare secrets for multiple apps', () => {
189
+ const sniffedApps = new Map<string, SniffedEnvironment>([
190
+ ['api', { appName: 'api', requiredEnvVars: ['DATABASE_URL', 'REDIS_URL'] }],
191
+ [
192
+ 'auth',
193
+ {
194
+ appName: 'auth',
195
+ requiredEnvVars: ['DATABASE_URL', 'BETTER_AUTH_SECRET'],
196
+ },
197
+ ],
198
+ ]);
199
+
200
+ const results = prepareSecretsForAllApps(secrets, sniffedApps);
201
+
202
+ expect(results.size).toBe(2);
203
+ expect(results.get('api')?.secretCount).toBe(2);
204
+ expect(results.get('auth')?.secretCount).toBe(2);
205
+ });
206
+
207
+ it('should skip apps with no required env vars', () => {
208
+ const sniffedApps = new Map<string, SniffedEnvironment>([
209
+ ['api', { appName: 'api', requiredEnvVars: ['DATABASE_URL'] }],
210
+ ['web', { appName: 'web', requiredEnvVars: [] }], // Frontend - no secrets
211
+ ]);
212
+
213
+ const results = prepareSecretsForAllApps(secrets, sniffedApps);
214
+
215
+ expect(results.size).toBe(1);
216
+ expect(results.has('api')).toBe(true);
217
+ expect(results.has('web')).toBe(false);
218
+ });
219
+
220
+ it('should generate unique master keys per app', () => {
221
+ const sniffedApps = new Map<string, SniffedEnvironment>([
222
+ ['api', { appName: 'api', requiredEnvVars: ['DATABASE_URL'] }],
223
+ ['auth', { appName: 'auth', requiredEnvVars: ['DATABASE_URL'] }],
224
+ ]);
225
+
226
+ const results = prepareSecretsForAllApps(secrets, sniffedApps);
227
+
228
+ const apiKey = results.get('api')?.masterKey;
229
+ const authKey = results.get('auth')?.masterKey;
230
+
231
+ expect(apiKey).not.toBe(authKey);
232
+ });
233
+ });
234
+
235
+ describe('generateSecretsReport', () => {
236
+ it('should generate report for apps with and without secrets', () => {
237
+ const sniffedApps = new Map<string, SniffedEnvironment>([
238
+ ['api', { appName: 'api', requiredEnvVars: ['DATABASE_URL'] }],
239
+ ['auth', { appName: 'auth', requiredEnvVars: ['DATABASE_URL'] }],
240
+ ['web', { appName: 'web', requiredEnvVars: [] }],
241
+ ]);
242
+
243
+ const encryptedApps = new Map([
244
+ [
245
+ 'api',
246
+ {
247
+ appName: 'api',
248
+ payload: { encrypted: '', iv: '', masterKey: '' },
249
+ masterKey: 'key1',
250
+ secretCount: 1,
251
+ missingSecrets: [],
252
+ },
253
+ ],
254
+ [
255
+ 'auth',
256
+ {
257
+ appName: 'auth',
258
+ payload: { encrypted: '', iv: '', masterKey: '' },
259
+ masterKey: 'key2',
260
+ secretCount: 1,
261
+ missingSecrets: ['MISSING_VAR'],
262
+ },
263
+ ],
264
+ ]);
265
+
266
+ const report = generateSecretsReport(encryptedApps, sniffedApps);
267
+
268
+ expect(report.totalApps).toBe(3);
269
+ expect(report.appsWithSecrets).toEqual(['api', 'auth']);
270
+ expect(report.appsWithoutSecrets).toEqual(['web']);
271
+ expect(report.appsWithMissingSecrets).toEqual([
272
+ { appName: 'auth', missing: ['MISSING_VAR'] },
273
+ ]);
274
+ });
275
+
276
+ it('should handle all apps having secrets', () => {
277
+ const sniffedApps = new Map<string, SniffedEnvironment>([
278
+ ['api', { appName: 'api', requiredEnvVars: ['DATABASE_URL'] }],
279
+ ]);
280
+
281
+ const encryptedApps = new Map([
282
+ [
283
+ 'api',
284
+ {
285
+ appName: 'api',
286
+ payload: { encrypted: '', iv: '', masterKey: '' },
287
+ masterKey: 'key1',
288
+ secretCount: 1,
289
+ missingSecrets: [],
290
+ },
291
+ ],
292
+ ]);
293
+
294
+ const report = generateSecretsReport(encryptedApps, sniffedApps);
295
+
296
+ expect(report.appsWithSecrets).toEqual(['api']);
297
+ expect(report.appsWithoutSecrets).toEqual([]);
298
+ expect(report.appsWithMissingSecrets).toEqual([]);
299
+ });
300
+ });