@geekmidas/cli 1.5.0 → 1.6.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 (119) hide show
  1. package/CHANGELOG.md +17 -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-C8mS0zY6.mjs → Route53Provider-DbBo7Uz5.mjs} +53 -1
  7. package/dist/Route53Provider-DbBo7Uz5.mjs.map +1 -0
  8. package/dist/{Route53Provider-Bs7Arms9.cjs → Route53Provider-kfJ77LmL.cjs} +53 -1
  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-ZQM1vBoz.cjs → config-6JHOwLCx.cjs} +30 -2
  15. package/dist/{config-ZQM1vBoz.cjs.map → config-6JHOwLCx.cjs.map} +1 -1
  16. package/dist/{config-DfCJ29PQ.mjs → config-DxASSNjr.mjs} +25 -3
  17. package/dist/{config-DfCJ29PQ.mjs.map → config-DxASSNjr.mjs.map} +1 -1
  18. package/dist/config.cjs +3 -2
  19. package/dist/config.d.cts +14 -2
  20. package/dist/config.d.cts.map +1 -1
  21. package/dist/config.d.mts +15 -3
  22. package/dist/config.d.mts.map +1 -1
  23. package/dist/config.mjs +3 -3
  24. package/dist/{dokploy-api-z0833e7r.mjs → dokploy-api-2ldYoN3i.mjs} +131 -1
  25. package/dist/dokploy-api-2ldYoN3i.mjs.map +1 -0
  26. package/dist/dokploy-api-C93pveuy.mjs +3 -0
  27. package/dist/dokploy-api-CbDh4o93.cjs +3 -0
  28. package/dist/{dokploy-api-CQvhV6Hd.cjs → dokploy-api-DLgvEQlr.cjs} +131 -1
  29. package/dist/dokploy-api-DLgvEQlr.cjs.map +1 -0
  30. package/dist/{index-C0SpUT9Y.d.mts → index-C-KxSGGK.d.mts} +133 -31
  31. package/dist/index-C-KxSGGK.d.mts.map +1 -0
  32. package/dist/{index-B58qjyBd.d.cts → index-Cyk2rTyj.d.cts} +132 -30
  33. package/dist/index-Cyk2rTyj.d.cts.map +1 -0
  34. package/dist/index.cjs +662 -152
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.mjs +626 -116
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/{openapi-BcSjLfWq.mjs → openapi-BYlyAbH3.mjs} +6 -5
  39. package/dist/openapi-BYlyAbH3.mjs.map +1 -0
  40. package/dist/{openapi-D6Hcfov0.cjs → openapi-CnvwSRDU.cjs} +6 -5
  41. package/dist/openapi-CnvwSRDU.cjs.map +1 -0
  42. package/dist/openapi.cjs +3 -3
  43. package/dist/openapi.d.cts +1 -0
  44. package/dist/openapi.d.cts.map +1 -1
  45. package/dist/openapi.d.mts +2 -1
  46. package/dist/openapi.d.mts.map +1 -1
  47. package/dist/openapi.mjs +3 -3
  48. package/dist/{types-B9UZ7fOG.d.mts → types-CZg5iUgD.d.mts} +1 -1
  49. package/dist/{types-B9UZ7fOG.d.mts.map → types-CZg5iUgD.d.mts.map} +1 -1
  50. package/dist/workspace/index.cjs +1 -1
  51. package/dist/workspace/index.d.cts +1 -1
  52. package/dist/workspace/index.d.mts +2 -2
  53. package/dist/workspace/index.mjs +1 -1
  54. package/dist/{workspace-BW2iU37P.mjs → workspace-9IQIjwkQ.mjs} +20 -4
  55. package/dist/workspace-9IQIjwkQ.mjs.map +1 -0
  56. package/dist/{workspace-2Do2YcGZ.cjs → workspace-D2ocAlpl.cjs} +20 -4
  57. package/dist/workspace-D2ocAlpl.cjs.map +1 -0
  58. package/examples/cron-example.ts +6 -6
  59. package/examples/function-example.ts +1 -1
  60. package/package.json +6 -3
  61. package/src/config.ts +44 -0
  62. package/src/deploy/__tests__/backup-provisioner.spec.ts +428 -0
  63. package/src/deploy/__tests__/createDnsProvider.spec.ts +23 -0
  64. package/src/deploy/__tests__/env-resolver.spec.ts +1 -1
  65. package/src/deploy/__tests__/undeploy.spec.ts +758 -0
  66. package/src/deploy/backup-provisioner.ts +316 -0
  67. package/src/deploy/dns/DnsProvider.ts +39 -1
  68. package/src/deploy/dns/HostingerProvider.ts +74 -0
  69. package/src/deploy/dns/Route53Provider.ts +81 -0
  70. package/src/deploy/dns/index.ts +25 -0
  71. package/src/deploy/dokploy-api.ts +237 -0
  72. package/src/deploy/index.ts +71 -13
  73. package/src/deploy/state.ts +171 -0
  74. package/src/deploy/undeploy.ts +407 -0
  75. package/src/dev/__tests__/index.spec.ts +490 -0
  76. package/src/dev/index.ts +313 -18
  77. package/src/generators/FunctionGenerator.ts +1 -1
  78. package/src/generators/Generator.ts +4 -1
  79. package/src/init/__tests__/generators.spec.ts +167 -18
  80. package/src/init/__tests__/init.spec.ts +66 -3
  81. package/src/init/generators/auth.ts +6 -5
  82. package/src/init/generators/config.ts +49 -7
  83. package/src/init/generators/docker.ts +8 -8
  84. package/src/init/generators/index.ts +1 -0
  85. package/src/init/generators/models.ts +3 -5
  86. package/src/init/generators/package.ts +4 -0
  87. package/src/init/generators/test.ts +133 -0
  88. package/src/init/generators/ui.ts +13 -12
  89. package/src/init/generators/web.ts +9 -8
  90. package/src/init/index.ts +2 -0
  91. package/src/init/templates/api.ts +6 -6
  92. package/src/init/templates/minimal.ts +2 -2
  93. package/src/init/templates/worker.ts +2 -2
  94. package/src/init/versions.ts +3 -3
  95. package/src/openapi.ts +6 -2
  96. package/src/test/__tests__/__fixtures__/workspace.ts +104 -0
  97. package/src/test/__tests__/api.spec.ts +199 -0
  98. package/src/test/__tests__/auth.spec.ts +162 -0
  99. package/src/test/__tests__/index.spec.ts +323 -0
  100. package/src/test/__tests__/web.spec.ts +210 -0
  101. package/src/test/index.ts +165 -14
  102. package/src/workspace/__tests__/index.spec.ts +3 -0
  103. package/src/workspace/index.ts +4 -2
  104. package/src/workspace/schema.ts +26 -0
  105. package/src/workspace/types.ts +14 -37
  106. package/dist/HostingerProvider-B9N-TKbp.mjs.map +0 -1
  107. package/dist/HostingerProvider-DUV9-Tzg.cjs.map +0 -1
  108. package/dist/Route53Provider-Bs7Arms9.cjs.map +0 -1
  109. package/dist/Route53Provider-C8mS0zY6.mjs.map +0 -1
  110. package/dist/dokploy-api-CQvhV6Hd.cjs.map +0 -1
  111. package/dist/dokploy-api-CWc02yyg.cjs +0 -3
  112. package/dist/dokploy-api-DSJYNx88.mjs +0 -3
  113. package/dist/dokploy-api-z0833e7r.mjs.map +0 -1
  114. package/dist/index-B58qjyBd.d.cts.map +0 -1
  115. package/dist/index-C0SpUT9Y.d.mts.map +0 -1
  116. package/dist/openapi-BcSjLfWq.mjs.map +0 -1
  117. package/dist/openapi-D6Hcfov0.cjs.map +0 -1
  118. package/dist/workspace-2Do2YcGZ.cjs.map +0 -1
  119. package/dist/workspace-BW2iU37P.mjs.map +0 -1
package/src/dev/index.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { type ChildProcess, execSync, spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { createServer } from 'node:net';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import chokidar from 'chokidar';
7
7
  import { config as dotenvConfig } from 'dotenv';
8
8
  import fg from 'fast-glob';
9
+ import { parse as parseYaml } from 'yaml';
9
10
  import { resolveProviders } from '../build/providerResolver';
10
11
  import type {
11
12
  BuildContext,
@@ -17,6 +18,7 @@ import type {
17
18
  import {
18
19
  getAppNameFromCwd,
19
20
  loadAppConfig,
21
+ loadWorkspaceAppInfo,
20
22
  loadWorkspaceConfig,
21
23
  parseModuleConfig,
22
24
  } from '../config';
@@ -137,6 +139,255 @@ export async function findAvailablePort(
137
139
  );
138
140
  }
139
141
 
142
+ /**
143
+ * A port mapping extracted from docker-compose.yml.
144
+ * Only entries using env var interpolation (e.g., `${VAR:-default}:container`) are captured.
145
+ */
146
+ export interface ComposePortMapping {
147
+ service: string;
148
+ envVar: string;
149
+ defaultPort: number;
150
+ containerPort: number;
151
+ }
152
+
153
+ /** Port state persisted to .gkm/ports.json, keyed by env var name. */
154
+ export type PortState = Record<string, number>;
155
+
156
+ export interface ResolvedServicePorts {
157
+ dockerEnv: Record<string, string>;
158
+ ports: PortState;
159
+ mappings: ComposePortMapping[];
160
+ }
161
+
162
+ const PORT_STATE_PATH = '.gkm/ports.json';
163
+
164
+ /**
165
+ * Parse docker-compose.yml and extract all port mappings that use env var interpolation.
166
+ * Entries like `'${POSTGRES_HOST_PORT:-5432}:5432'` are captured.
167
+ * Fixed port mappings like `'5050:80'` are skipped.
168
+ * @internal Exported for testing
169
+ */
170
+ export function parseComposePortMappings(
171
+ composePath: string,
172
+ ): ComposePortMapping[] {
173
+ if (!existsSync(composePath)) {
174
+ return [];
175
+ }
176
+
177
+ const content = readFileSync(composePath, 'utf-8');
178
+ const compose = parseYaml(content) as {
179
+ services?: Record<string, { ports?: string[] }>;
180
+ };
181
+
182
+ if (!compose?.services) {
183
+ return [];
184
+ }
185
+
186
+ const results: ComposePortMapping[] = [];
187
+
188
+ for (const [serviceName, serviceConfig] of Object.entries(compose.services)) {
189
+ for (const portMapping of serviceConfig?.ports ?? []) {
190
+ const match = String(portMapping).match(/\$\{(\w+):-(\d+)\}:(\d+)/);
191
+ if (match?.[1] && match[2] && match[3]) {
192
+ results.push({
193
+ service: serviceName,
194
+ envVar: match[1],
195
+ defaultPort: Number(match[2]),
196
+ containerPort: Number(match[3]),
197
+ });
198
+ }
199
+ }
200
+ }
201
+
202
+ return results;
203
+ }
204
+
205
+ /**
206
+ * Load saved port state from .gkm/ports.json.
207
+ * @internal Exported for testing
208
+ */
209
+ export async function loadPortState(workspaceRoot: string): Promise<PortState> {
210
+ try {
211
+ const raw = await readFile(join(workspaceRoot, PORT_STATE_PATH), 'utf-8');
212
+ return JSON.parse(raw) as PortState;
213
+ } catch {
214
+ return {};
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Save port state to .gkm/ports.json.
220
+ * @internal Exported for testing
221
+ */
222
+ export async function savePortState(
223
+ workspaceRoot: string,
224
+ ports: PortState,
225
+ ): Promise<void> {
226
+ const dir = join(workspaceRoot, '.gkm');
227
+ await mkdir(dir, { recursive: true });
228
+ await writeFile(
229
+ join(workspaceRoot, PORT_STATE_PATH),
230
+ `${JSON.stringify(ports, null, 2)}\n`,
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Check if a project's own Docker container is running and return its host port.
236
+ * Uses `docker compose port` scoped to the project's compose file.
237
+ * @internal Exported for testing
238
+ */
239
+ export function getContainerHostPort(
240
+ workspaceRoot: string,
241
+ service: string,
242
+ containerPort: number,
243
+ ): number | null {
244
+ try {
245
+ const result = execSync(`docker compose port ${service} ${containerPort}`, {
246
+ cwd: workspaceRoot,
247
+ stdio: 'pipe',
248
+ })
249
+ .toString()
250
+ .trim();
251
+ const match = result.match(/:(\d+)$/);
252
+ return match ? Number(match[1]) : null;
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Resolve host ports for Docker services by parsing docker-compose.yml.
260
+ * Priority: running container → saved state → find available port.
261
+ * Persists resolved ports to .gkm/ports.json.
262
+ * @internal Exported for testing
263
+ */
264
+ export async function resolveServicePorts(
265
+ workspaceRoot: string,
266
+ ): Promise<ResolvedServicePorts> {
267
+ const composePath = join(workspaceRoot, 'docker-compose.yml');
268
+ const mappings = parseComposePortMappings(composePath);
269
+
270
+ if (mappings.length === 0) {
271
+ return { dockerEnv: {}, ports: {}, mappings: [] };
272
+ }
273
+
274
+ const savedState = await loadPortState(workspaceRoot);
275
+ const dockerEnv: Record<string, string> = {};
276
+ const ports: PortState = {};
277
+
278
+ logger.log('\n🔌 Resolving service ports...');
279
+
280
+ for (const mapping of mappings) {
281
+ // 1. Check if own container is already running
282
+ const containerPort = getContainerHostPort(
283
+ workspaceRoot,
284
+ mapping.service,
285
+ mapping.containerPort,
286
+ );
287
+ if (containerPort !== null) {
288
+ ports[mapping.envVar] = containerPort;
289
+ dockerEnv[mapping.envVar] = String(containerPort);
290
+ logger.log(
291
+ ` 🔄 ${mapping.service}:${mapping.containerPort}: reusing existing container on port ${containerPort}`,
292
+ );
293
+ continue;
294
+ }
295
+
296
+ // 2. Check saved port state
297
+ const savedPort = savedState[mapping.envVar];
298
+ if (savedPort && (await isPortAvailable(savedPort))) {
299
+ ports[mapping.envVar] = savedPort;
300
+ dockerEnv[mapping.envVar] = String(savedPort);
301
+ logger.log(
302
+ ` 💾 ${mapping.service}:${mapping.containerPort}: using saved port ${savedPort}`,
303
+ );
304
+ continue;
305
+ }
306
+
307
+ // 3. Find available port
308
+ const resolvedPort = await findAvailablePort(mapping.defaultPort);
309
+ ports[mapping.envVar] = resolvedPort;
310
+ dockerEnv[mapping.envVar] = String(resolvedPort);
311
+
312
+ if (resolvedPort !== mapping.defaultPort) {
313
+ logger.log(
314
+ ` ⚡ ${mapping.service}:${mapping.containerPort}: port ${mapping.defaultPort} occupied, using port ${resolvedPort}`,
315
+ );
316
+ } else {
317
+ logger.log(
318
+ ` ✅ ${mapping.service}:${mapping.containerPort}: using default port ${resolvedPort}`,
319
+ );
320
+ }
321
+ }
322
+
323
+ await savePortState(workspaceRoot, ports);
324
+
325
+ return { dockerEnv, ports, mappings };
326
+ }
327
+
328
+ /**
329
+ * Replace a port in a URL string.
330
+ * Handles both `hostname:port` and `localhost:port` patterns.
331
+ * @internal Exported for testing
332
+ */
333
+ export function replacePortInUrl(
334
+ url: string,
335
+ oldPort: number,
336
+ newPort: number,
337
+ ): string {
338
+ if (oldPort === newPort) return url;
339
+ return url.replace(new RegExp(`:${oldPort}(?=/|$)`, 'g'), `:${newPort}`);
340
+ }
341
+
342
+ /**
343
+ * Rewrite connection URLs and port vars in secrets with resolved ports.
344
+ * Uses the parsed compose mappings to determine which default ports to replace.
345
+ * Pure transform — does not modify secrets on disk.
346
+ * @internal Exported for testing
347
+ */
348
+ export function rewriteUrlsWithPorts(
349
+ secrets: Record<string, string>,
350
+ resolvedPorts: ResolvedServicePorts,
351
+ ): Record<string, string> {
352
+ const { ports, mappings } = resolvedPorts;
353
+ const result = { ...secrets };
354
+
355
+ // Build a map of defaultPort → resolvedPort for all changed ports
356
+ const portReplacements: { defaultPort: number; resolvedPort: number }[] = [];
357
+ for (const mapping of mappings) {
358
+ const resolved = ports[mapping.envVar];
359
+ if (resolved !== undefined) {
360
+ portReplacements.push({
361
+ defaultPort: mapping.defaultPort,
362
+ resolvedPort: resolved,
363
+ });
364
+ }
365
+ }
366
+
367
+ // Rewrite _PORT env vars whose values match a default port
368
+ for (const [key, value] of Object.entries(result)) {
369
+ if (!key.endsWith('_PORT')) continue;
370
+ for (const { defaultPort, resolvedPort } of portReplacements) {
371
+ if (value === String(defaultPort)) {
372
+ result[key] = String(resolvedPort);
373
+ }
374
+ }
375
+ }
376
+
377
+ // Rewrite URLs containing default ports
378
+ for (const [key, value] of Object.entries(result)) {
379
+ if (!key.endsWith('_URL') && key !== 'DATABASE_URL') continue;
380
+
381
+ let rewritten = value;
382
+ for (const { defaultPort, resolvedPort } of portReplacements) {
383
+ rewritten = replacePortInUrl(rewritten, defaultPort, resolvedPort);
384
+ }
385
+ result[key] = rewritten;
386
+ }
387
+
388
+ return result;
389
+ }
390
+
140
391
  /**
141
392
  * Normalize telescope configuration
142
393
  * @internal Exported for testing
@@ -578,11 +829,12 @@ export async function devCommand(options: DevOptions): Promise<void> {
578
829
  resolved.providers[0] as LegacyProvider,
579
830
  enableOpenApi,
580
831
  appRoot,
832
+ true, // bust module cache on rebuild
581
833
  );
582
834
 
583
835
  // Regenerate OpenAPI if enabled
584
836
  if (enableOpenApi) {
585
- await generateOpenApi(config, { silent: true });
837
+ await generateOpenApi(config, { silent: true, bustCache: true });
586
838
  }
587
839
 
588
840
  logger.log('✅ Rebuild complete, restarting server...');
@@ -848,6 +1100,7 @@ export async function loadSecretsForApp(
848
1100
  */
849
1101
  export async function startWorkspaceServices(
850
1102
  workspace: NormalizedWorkspace,
1103
+ portEnv?: Record<string, string>,
851
1104
  ): Promise<void> {
852
1105
  const services = workspace.services;
853
1106
  if (!services.db && !services.cache && !services.mail) {
@@ -886,6 +1139,7 @@ export async function startWorkspaceServices(
886
1139
  execSync(`docker compose up -d ${servicesToStart.join(' ')}`, {
887
1140
  cwd: workspace.root,
888
1141
  stdio: 'inherit',
1142
+ env: { ...process.env, ...portEnv },
889
1143
  });
890
1144
 
891
1145
  logger.log('✅ Services started');
@@ -972,11 +1226,17 @@ async function workspaceDevCommand(
972
1226
  }
973
1227
  }
974
1228
 
975
- // Start docker-compose services
976
- await startWorkspaceServices(workspace);
1229
+ // Resolve dynamic service ports from docker-compose.yml
1230
+ const resolvedPorts = await resolveServicePorts(workspace.root);
1231
+
1232
+ // Start docker-compose services with resolved ports
1233
+ await startWorkspaceServices(workspace, resolvedPorts.dockerEnv);
977
1234
 
978
- // Load secrets if enabled
979
- const secretsEnv = await loadDevSecrets(workspace);
1235
+ // Load secrets if enabled, then rewrite URLs with resolved ports
1236
+ const secretsEnv = rewriteUrlsWithPorts(
1237
+ await loadDevSecrets(workspace),
1238
+ resolvedPorts,
1239
+ );
980
1240
  if (Object.keys(secretsEnv).length > 0) {
981
1241
  logger.log(` Loaded ${Object.keys(secretsEnv).length} secret(s)`);
982
1242
  }
@@ -1196,6 +1456,7 @@ async function buildServer(
1196
1456
  provider: LegacyProvider,
1197
1457
  enableOpenApi: boolean,
1198
1458
  appRoot: string = process.cwd(),
1459
+ bustCache = false,
1199
1460
  ): Promise<void> {
1200
1461
  // Initialize generators
1201
1462
  const endpointGenerator = new EndpointGenerator();
@@ -1206,11 +1467,13 @@ async function buildServer(
1206
1467
  // Load all constructs (resolve paths relative to appRoot)
1207
1468
  const [allEndpoints, allFunctions, allCrons, allSubscribers] =
1208
1469
  await Promise.all([
1209
- endpointGenerator.load(config.routes, appRoot),
1210
- config.functions ? functionGenerator.load(config.functions, appRoot) : [],
1211
- config.crons ? cronGenerator.load(config.crons, appRoot) : [],
1470
+ endpointGenerator.load(config.routes, appRoot, bustCache),
1471
+ config.functions
1472
+ ? functionGenerator.load(config.functions, appRoot, bustCache)
1473
+ : [],
1474
+ config.crons ? cronGenerator.load(config.crons, appRoot, bustCache) : [],
1212
1475
  config.subscribers
1213
- ? subscriberGenerator.load(config.subscribers, appRoot)
1476
+ ? subscriberGenerator.load(config.subscribers, appRoot, bustCache)
1214
1477
  : [],
1215
1478
  ]);
1216
1479
 
@@ -1347,10 +1610,10 @@ export async function prepareEntryCredentials(options: {
1347
1610
  let appName: string | undefined;
1348
1611
 
1349
1612
  try {
1350
- const appConfig = await loadAppConfig(cwd);
1351
- workspaceAppPort = appConfig.app.port;
1352
- secretsRoot = appConfig.workspaceRoot;
1353
- appName = appConfig.appName;
1613
+ const appInfo = await loadWorkspaceAppInfo(cwd);
1614
+ workspaceAppPort = appInfo.app.port;
1615
+ secretsRoot = appInfo.workspaceRoot;
1616
+ appName = appInfo.appName;
1354
1617
  } catch (error) {
1355
1618
  // Not in a workspace - use defaults
1356
1619
  logger.log(
@@ -1842,7 +2105,7 @@ export async function execCommand(
1842
2105
 
1843
2106
  // Prepare credentials (loads workspace config and secrets)
1844
2107
  // Don't inject PORT for exec since we're not running a server
1845
- const { credentials, secretsJsonPath, appName } =
2108
+ const { credentials, secretsJsonPath, appName, secretsRoot } =
1846
2109
  await prepareEntryCredentials({ cwd });
1847
2110
 
1848
2111
  if (appName) {
@@ -1856,6 +2119,33 @@ export async function execCommand(
1856
2119
  logger.log(`🔐 Loaded ${secretCount} secret(s)`);
1857
2120
  }
1858
2121
 
2122
+ // Rewrite URLs with resolved Docker ports (from gkm dev)
2123
+ const composePath = join(secretsRoot, 'docker-compose.yml');
2124
+ const mappings = parseComposePortMappings(composePath);
2125
+ if (mappings.length > 0) {
2126
+ const ports = await loadPortState(secretsRoot);
2127
+ if (Object.keys(ports).length > 0) {
2128
+ const rewritten = rewriteUrlsWithPorts(credentials, {
2129
+ dockerEnv: {},
2130
+ ports,
2131
+ mappings,
2132
+ });
2133
+ Object.assign(credentials, rewritten);
2134
+ logger.log(`🔌 Applied ${Object.keys(ports).length} port mapping(s)`);
2135
+ }
2136
+ }
2137
+
2138
+ // Inject dependency URLs (works for both frontend and backend apps)
2139
+ try {
2140
+ const appInfo = await loadWorkspaceAppInfo(cwd);
2141
+ if (appInfo.appName) {
2142
+ const depEnv = getDependencyEnvVars(appInfo.workspace, appInfo.appName);
2143
+ Object.assign(credentials, depEnv);
2144
+ }
2145
+ } catch {
2146
+ // Not in a workspace — skip dependency URL injection
2147
+ }
2148
+
1859
2149
  // Create preload script that injects Credentials
1860
2150
  // Create in cwd so package resolution works (finds node_modules in app directory)
1861
2151
  const preloadDir = join(cwd, '.gkm');
@@ -1864,13 +2154,18 @@ export async function execCommand(
1864
2154
  await createCredentialsPreload(preloadPath, secretsJsonPath);
1865
2155
 
1866
2156
  // Build command
1867
- const [cmd, ...args] = commandArgs;
2157
+ const [cmd, ...rawArgs] = commandArgs;
1868
2158
 
1869
2159
  if (!cmd) {
1870
2160
  throw new Error('No command specified');
1871
2161
  }
1872
2162
 
1873
- logger.log(`🚀 Running: ${commandArgs.join(' ')}`);
2163
+ // Replace template variables in command args (e.g. $PORT -> resolved port)
2164
+ const args = rawArgs.map((arg) =>
2165
+ arg.replace(/\$PORT\b/g, credentials.PORT ?? '3000'),
2166
+ );
2167
+
2168
+ logger.log(`🚀 Running: ${[cmd, ...args].join(' ')}`);
1874
2169
 
1875
2170
  // Merge NODE_OPTIONS with existing value (if any)
1876
2171
  // Add tsx loader first so our .ts preload can be loaded
@@ -99,7 +99,7 @@ export class FunctionGenerator extends ConstructGenerator<
99
99
  context.loggerPath,
100
100
  );
101
101
 
102
- const content = `import { AWSLambdaFunction } from '@geekmidas/constructs/functions';
102
+ const content = `import { AWSLambdaFunction } from '@geekmidas/constructs/aws';
103
103
  import { ${exportName} } from '${importPath}';
104
104
  import ${context.envParserImportPattern} from '${relativeEnvParserPath}';
105
105
  import ${context.loggerImportPattern} from '${relativeLoggerPath}';
@@ -34,6 +34,7 @@ export abstract class ConstructGenerator<T extends Construct, R = void> {
34
34
  async load(
35
35
  patterns?: Routes,
36
36
  cwd = process.cwd(),
37
+ bustCache = false,
37
38
  ): Promise<GeneratedConstruct<T>[]> {
38
39
  const logger = console;
39
40
 
@@ -56,7 +57,9 @@ export abstract class ConstructGenerator<T extends Construct, R = void> {
56
57
  for await (const f of files) {
57
58
  try {
58
59
  const file = f.toString();
59
- const module = await import(file);
60
+ // Append cache-busting query param to force re-import of changed modules
61
+ const importPath = bustCache ? `${file}?t=${Date.now()}` : file;
62
+ const module = await import(importPath);
60
63
 
61
64
  // Check all exports for constructs
62
65
  for (const [key, construct] of Object.entries(module)) {
@@ -5,7 +5,9 @@ import { generateEnvFiles } from '../generators/env.js';
5
5
  import { generateModelsPackage } from '../generators/models.js';
6
6
  import { generateMonorepoFiles } from '../generators/monorepo.js';
7
7
  import { generatePackageJson } from '../generators/package.js';
8
+ import { generateTestFiles } from '../generators/test.js';
8
9
  import { generateUiPackageFiles } from '../generators/ui.js';
10
+ import { apiTemplate } from '../templates/api.js';
9
11
  import type { TemplateOptions } from '../templates/index.js';
10
12
  import { minimalTemplate } from '../templates/minimal.js';
11
13
  import { serverlessTemplate } from '../templates/serverless.js';
@@ -211,31 +213,46 @@ describe('generateDockerFiles', () => {
211
213
  expect(files[0].path).toBe('docker-compose.yml');
212
214
  });
213
215
 
214
- it('should include postgres when database is enabled', () => {
216
+ it('should include postgres with dynamic port when database is enabled', () => {
215
217
  const files = generateDockerFiles(baseOptions, minimalTemplate);
216
218
  expect(files[0].content).toContain('postgres');
217
- expect(files[0].content).toContain('5432');
219
+ expect(files[0].content).toContain("'${POSTGRES_HOST_PORT:-5432}:5432'");
218
220
  });
219
221
 
220
- it('should include redis', () => {
222
+ it('should include redis with dynamic port', () => {
221
223
  const files = generateDockerFiles(baseOptions, minimalTemplate);
222
224
  expect(files[0].content).toContain('redis');
223
- expect(files[0].content).toContain('6379');
225
+ expect(files[0].content).toContain("'${REDIS_HOST_PORT:-6379}:6379'");
224
226
  });
225
227
 
226
- it('should include serverless-redis-http for serverless template', () => {
228
+ it('should include serverless-redis-http with dynamic port for serverless template', () => {
227
229
  const options = { ...baseOptions, template: 'serverless' as const };
228
230
  const files = generateDockerFiles(options, serverlessTemplate);
229
231
  expect(files[0].content).toContain('hiett/serverless-redis-http');
230
- expect(files[0].content).toContain('8079');
232
+ expect(files[0].content).toContain("'${SRH_HOST_PORT:-8079}:80'");
231
233
  });
232
234
 
233
- it('should include rabbitmq for worker template', () => {
235
+ it('should include rabbitmq with dynamic ports for worker template', () => {
234
236
  const options = { ...baseOptions, template: 'worker' as const };
235
237
  const files = generateDockerFiles(options, workerTemplate);
236
238
  expect(files[0].content).toContain('rabbitmq');
237
- expect(files[0].content).toContain('5672');
238
- expect(files[0].content).toContain('15672');
239
+ expect(files[0].content).toContain("'${RABBITMQ_HOST_PORT:-5672}:5672'");
240
+ expect(files[0].content).toContain(
241
+ "'${RABBITMQ_MGMT_HOST_PORT:-15672}:15672'",
242
+ );
243
+ });
244
+
245
+ it('should include mailpit with dynamic ports when mail is enabled', () => {
246
+ const options = {
247
+ ...baseOptions,
248
+ services: { db: true, cache: true, mail: true },
249
+ };
250
+ const files = generateDockerFiles(options, minimalTemplate);
251
+ expect(files[0].content).toContain('mailpit');
252
+ expect(files[0].content).toContain(
253
+ "'${MAILPIT_SMTP_HOST_PORT:-1025}:1025'",
254
+ );
255
+ expect(files[0].content).toContain("'${MAILPIT_UI_HOST_PORT:-8025}:8025'");
239
256
  });
240
257
  });
241
258
 
@@ -557,15 +574,15 @@ describe('generateUiPackageFiles', () => {
557
574
  (f) => f.path === 'packages/ui/src/components/ui/index.ts',
558
575
  );
559
576
  expect(indexFile).toBeDefined();
560
- expect(indexFile!.content).toContain("from './button'");
561
- expect(indexFile!.content).toContain("from './input'");
562
- expect(indexFile!.content).toContain("from './card'");
563
- expect(indexFile!.content).toContain("from './label'");
564
- expect(indexFile!.content).toContain("from './badge'");
565
- expect(indexFile!.content).toContain("from './separator'");
566
- expect(indexFile!.content).toContain("from './tabs'");
567
- expect(indexFile!.content).toContain("from './tooltip'");
568
- expect(indexFile!.content).toContain("from './dialog'");
577
+ expect(indexFile!.content).toContain("from './button.tsx'");
578
+ expect(indexFile!.content).toContain("from './input.tsx'");
579
+ expect(indexFile!.content).toContain("from './card.tsx'");
580
+ expect(indexFile!.content).toContain("from './label.tsx'");
581
+ expect(indexFile!.content).toContain("from './badge.tsx'");
582
+ expect(indexFile!.content).toContain("from './separator.tsx'");
583
+ expect(indexFile!.content).toContain("from './tabs.tsx'");
584
+ expect(indexFile!.content).toContain("from './tooltip.tsx'");
585
+ expect(indexFile!.content).toContain("from './dialog.tsx'");
569
586
  });
570
587
 
571
588
  it('should include cn utility function', () => {
@@ -645,3 +662,135 @@ describe('generateUiPackageFiles', () => {
645
662
  expect(config.compilerOptions.paths['~/*']).toEqual(['./src/*']);
646
663
  });
647
664
  });
665
+
666
+ describe('generateTestFiles', () => {
667
+ it('should return empty array when database is disabled', () => {
668
+ const options = { ...baseOptions, database: false };
669
+ const files = generateTestFiles(options, minimalTemplate);
670
+ expect(files).toHaveLength(0);
671
+ });
672
+
673
+ it('should generate all test infrastructure files when database is enabled', () => {
674
+ const files = generateTestFiles(baseOptions, minimalTemplate);
675
+ const paths = files.map((f) => f.path);
676
+ expect(paths).toContain('test/config.ts');
677
+ expect(paths).toContain('test/globalSetup.ts');
678
+ expect(paths).toContain('test/factory/index.ts');
679
+ expect(paths).toContain('test/factory/users.ts');
680
+ expect(paths).toContain('test/example.spec.ts');
681
+ });
682
+
683
+ it('should use wrapVitestKyselyTransaction in config', () => {
684
+ const files = generateTestFiles(baseOptions, minimalTemplate);
685
+ const configFile = files.find((f) => f.path === 'test/config.ts');
686
+ expect(configFile).toBeDefined();
687
+ expect(configFile!.content).toContain('wrapVitestKyselyTransaction');
688
+ expect(configFile!.content).toContain('@geekmidas/testkit/kysely');
689
+ expect(configFile!.content).toContain('~/services/database.ts');
690
+ });
691
+
692
+ it('should use PostgresKyselyMigrator in globalSetup', () => {
693
+ const files = generateTestFiles(baseOptions, minimalTemplate);
694
+ const setupFile = files.find((f) => f.path === 'test/globalSetup.ts');
695
+ expect(setupFile).toBeDefined();
696
+ expect(setupFile!.content).toContain('PostgresKyselyMigrator');
697
+ expect(setupFile!.content).toContain('_test');
698
+ expect(setupFile!.content).toContain('migrateToLatest');
699
+ });
700
+
701
+ it('should use KyselyFactory in factory files', () => {
702
+ const files = generateTestFiles(baseOptions, minimalTemplate);
703
+ const factoryIndex = files.find((f) => f.path === 'test/factory/index.ts');
704
+ expect(factoryIndex).toBeDefined();
705
+ expect(factoryIndex!.content).toContain('KyselyFactory');
706
+ expect(factoryIndex!.content).toContain('createFactory');
707
+
708
+ const usersBuilder = files.find((f) => f.path === 'test/factory/users.ts');
709
+ expect(usersBuilder).toBeDefined();
710
+ expect(usersBuilder!.content).toContain('KyselyFactory.createBuilder');
711
+ expect(usersBuilder!.content).toContain("'users'");
712
+ });
713
+
714
+ it('should generate example spec with transaction-wrapped it', () => {
715
+ const files = generateTestFiles(baseOptions, minimalTemplate);
716
+ const exampleSpec = files.find((f) => f.path === 'test/example.spec.ts');
717
+ expect(exampleSpec).toBeDefined();
718
+ expect(exampleSpec!.content).toContain("from './config.ts'");
719
+ expect(exampleSpec!.content).toContain('{ db }');
720
+ });
721
+
722
+ it('should work with fullstack template options', () => {
723
+ const options: TemplateOptions = {
724
+ ...baseOptions,
725
+ template: 'fullstack',
726
+ monorepo: true,
727
+ apiPath: 'apps/api',
728
+ };
729
+ const files = generateTestFiles(options, apiTemplate);
730
+ expect(files.length).toBeGreaterThan(0);
731
+ const paths = files.map((f) => f.path);
732
+ expect(paths).toContain('test/config.ts');
733
+ expect(paths).toContain('test/globalSetup.ts');
734
+ });
735
+ });
736
+
737
+ describe('generateConfigFiles - vitest.config.ts', () => {
738
+ it('should generate vitest.config.ts when database is enabled (standalone)', () => {
739
+ const files = generateConfigFiles(baseOptions, minimalTemplate);
740
+ const paths = files.map((f) => f.path);
741
+ expect(paths).toContain('vitest.config.ts');
742
+
743
+ const vitestConfig = files.find((f) => f.path === 'vitest.config.ts');
744
+ expect(vitestConfig!.content).toContain('globalSetup');
745
+ expect(vitestConfig!.content).toContain('./test/globalSetup.ts');
746
+ expect(vitestConfig!.content).toContain('vite-tsconfig-paths');
747
+ expect(vitestConfig!.content).not.toContain('globals: true');
748
+ });
749
+
750
+ it('should not generate vitest.config.ts when database is disabled', () => {
751
+ const options = { ...baseOptions, database: false };
752
+ const files = generateConfigFiles(options, minimalTemplate);
753
+ const paths = files.map((f) => f.path);
754
+ expect(paths).not.toContain('vitest.config.ts');
755
+ });
756
+
757
+ it('should generate vitest.config.ts for monorepo app with database', () => {
758
+ const options: TemplateOptions = {
759
+ ...baseOptions,
760
+ monorepo: true,
761
+ apiPath: 'apps/api',
762
+ };
763
+ const files = generateConfigFiles(options, minimalTemplate);
764
+ const paths = files.map((f) => f.path);
765
+ expect(paths).toContain('vitest.config.ts');
766
+ });
767
+
768
+ it('should generate vitest.config.ts for fullstack template with database', () => {
769
+ const options: TemplateOptions = {
770
+ ...baseOptions,
771
+ template: 'fullstack',
772
+ monorepo: true,
773
+ apiPath: 'apps/api',
774
+ };
775
+ const files = generateConfigFiles(options, apiTemplate);
776
+ const paths = files.map((f) => f.path);
777
+ expect(paths).toContain('vitest.config.ts');
778
+ });
779
+ });
780
+
781
+ describe('generatePackageJson - testkit dependencies', () => {
782
+ it('should include testkit and faker when database is enabled', () => {
783
+ const files = generatePackageJson(baseOptions, minimalTemplate);
784
+ const pkg = JSON.parse(files[0].content);
785
+ expect(pkg.devDependencies['@geekmidas/testkit']).toMatch(/^~/);
786
+ expect(pkg.devDependencies['@faker-js/faker']).toMatch(/^~/);
787
+ });
788
+
789
+ it('should not include testkit when database is disabled', () => {
790
+ const options = { ...baseOptions, database: false };
791
+ const files = generatePackageJson(options, minimalTemplate);
792
+ const pkg = JSON.parse(files[0].content);
793
+ expect(pkg.devDependencies['@geekmidas/testkit']).toBeUndefined();
794
+ expect(pkg.devDependencies['@faker-js/faker']).toBeUndefined();
795
+ });
796
+ });