@geekmidas/cli 0.17.0 → 0.19.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 (118) hide show
  1. package/dist/{bundler-C74EKlNa.cjs → bundler-CyHg1v_T.cjs} +3 -3
  2. package/dist/{bundler-C74EKlNa.cjs.map → bundler-CyHg1v_T.cjs.map} +1 -1
  3. package/dist/{bundler-B6z6HEeh.mjs → bundler-DQIuE3Kn.mjs} +3 -3
  4. package/dist/{bundler-B6z6HEeh.mjs.map → bundler-DQIuE3Kn.mjs.map} +1 -1
  5. package/dist/{config-DYULeEv8.mjs → config-BaYqrF3n.mjs} +48 -10
  6. package/dist/config-BaYqrF3n.mjs.map +1 -0
  7. package/dist/{config-AmInkU7k.cjs → config-CxrLu8ia.cjs} +53 -9
  8. package/dist/config-CxrLu8ia.cjs.map +1 -0
  9. package/dist/config.cjs +4 -1
  10. package/dist/config.d.cts +27 -2
  11. package/dist/config.d.cts.map +1 -1
  12. package/dist/config.d.mts +27 -2
  13. package/dist/config.d.mts.map +1 -1
  14. package/dist/config.mjs +3 -2
  15. package/dist/dokploy-api-B0w17y4_.mjs +3 -0
  16. package/dist/{dokploy-api-CaETb2L6.mjs → dokploy-api-B9qR2Yn1.mjs} +1 -1
  17. package/dist/{dokploy-api-CaETb2L6.mjs.map → dokploy-api-B9qR2Yn1.mjs.map} +1 -1
  18. package/dist/dokploy-api-BnGeUqN4.cjs +3 -0
  19. package/dist/{dokploy-api-C7F9VykY.cjs → dokploy-api-C5czOZoc.cjs} +1 -1
  20. package/dist/{dokploy-api-C7F9VykY.cjs.map → dokploy-api-C5czOZoc.cjs.map} +1 -1
  21. package/dist/{encryption-D7Efcdi9.cjs → encryption-BAz0xQ1Q.cjs} +1 -1
  22. package/dist/{encryption-D7Efcdi9.cjs.map → encryption-BAz0xQ1Q.cjs.map} +1 -1
  23. package/dist/{encryption-h4Nb6W-M.mjs → encryption-JtMsiGNp.mjs} +2 -2
  24. package/dist/{encryption-h4Nb6W-M.mjs.map → encryption-JtMsiGNp.mjs.map} +1 -1
  25. package/dist/index-CWN-bgrO.d.mts +495 -0
  26. package/dist/index-CWN-bgrO.d.mts.map +1 -0
  27. package/dist/index-DEWYvYvg.d.cts +495 -0
  28. package/dist/index-DEWYvYvg.d.cts.map +1 -0
  29. package/dist/index.cjs +2644 -564
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.mjs +2639 -564
  32. package/dist/index.mjs.map +1 -1
  33. package/dist/{openapi-CZVcfxk-.mjs → openapi-CgqR6Jkw.mjs} +3 -3
  34. package/dist/{openapi-CZVcfxk-.mjs.map → openapi-CgqR6Jkw.mjs.map} +1 -1
  35. package/dist/{openapi-C89hhkZC.cjs → openapi-DfpxS0xv.cjs} +8 -2
  36. package/dist/{openapi-C89hhkZC.cjs.map → openapi-DfpxS0xv.cjs.map} +1 -1
  37. package/dist/{openapi-react-query-CM2_qlW9.mjs → openapi-react-query-5rSortLH.mjs} +1 -1
  38. package/dist/{openapi-react-query-CM2_qlW9.mjs.map → openapi-react-query-5rSortLH.mjs.map} +1 -1
  39. package/dist/{openapi-react-query-iKjfLzff.cjs → openapi-react-query-DvNpdDpM.cjs} +1 -1
  40. package/dist/{openapi-react-query-iKjfLzff.cjs.map → openapi-react-query-DvNpdDpM.cjs.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 -2
  44. package/dist/openapi.d.cts +1 -1
  45. package/dist/openapi.d.mts +1 -1
  46. package/dist/openapi.mjs +3 -2
  47. package/dist/{storage-Bn3K9Ccu.cjs → storage-BPRgh3DU.cjs} +136 -5
  48. package/dist/storage-BPRgh3DU.cjs.map +1 -0
  49. package/dist/{storage-nkGIjeXt.mjs → storage-DNj_I11J.mjs} +1 -1
  50. package/dist/storage-Dhst7BhI.mjs +272 -0
  51. package/dist/storage-Dhst7BhI.mjs.map +1 -0
  52. package/dist/{storage-UfyTn7Zm.cjs → storage-fOR8dMu5.cjs} +1 -1
  53. package/dist/{types-iFk5ms7y.d.mts → types-K2uQJ-FO.d.mts} +2 -2
  54. package/dist/{types-BgaMXsUa.d.cts.map → types-K2uQJ-FO.d.mts.map} +1 -1
  55. package/dist/{types-BgaMXsUa.d.cts → types-l53qUmGt.d.cts} +2 -2
  56. package/dist/{types-iFk5ms7y.d.mts.map → types-l53qUmGt.d.cts.map} +1 -1
  57. package/dist/workspace/index.cjs +19 -0
  58. package/dist/workspace/index.d.cts +3 -0
  59. package/dist/workspace/index.d.mts +3 -0
  60. package/dist/workspace/index.mjs +3 -0
  61. package/dist/workspace-CPLEZDZf.mjs +3788 -0
  62. package/dist/workspace-CPLEZDZf.mjs.map +1 -0
  63. package/dist/workspace-iWgBlX6h.cjs +3885 -0
  64. package/dist/workspace-iWgBlX6h.cjs.map +1 -0
  65. package/package.json +9 -4
  66. package/src/build/__tests__/workspace-build.spec.ts +215 -0
  67. package/src/build/index.ts +189 -1
  68. package/src/config.ts +71 -14
  69. package/src/deploy/__tests__/docker.spec.ts +1 -1
  70. package/src/deploy/__tests__/index.spec.ts +305 -1
  71. package/src/deploy/index.ts +426 -4
  72. package/src/deploy/types.ts +32 -0
  73. package/src/dev/__tests__/index.spec.ts +572 -1
  74. package/src/dev/index.ts +582 -2
  75. package/src/docker/__tests__/compose.spec.ts +425 -0
  76. package/src/docker/__tests__/templates.spec.ts +145 -0
  77. package/src/docker/compose.ts +248 -0
  78. package/src/docker/index.ts +159 -3
  79. package/src/docker/templates.ts +223 -4
  80. package/src/index.ts +24 -0
  81. package/src/init/__tests__/generators.spec.ts +17 -24
  82. package/src/init/__tests__/init.spec.ts +157 -5
  83. package/src/init/generators/auth.ts +220 -0
  84. package/src/init/generators/config.ts +61 -4
  85. package/src/init/generators/docker.ts +115 -8
  86. package/src/init/generators/env.ts +7 -127
  87. package/src/init/generators/index.ts +1 -0
  88. package/src/init/generators/models.ts +3 -1
  89. package/src/init/generators/monorepo.ts +154 -10
  90. package/src/init/generators/package.ts +5 -3
  91. package/src/init/generators/web.ts +213 -0
  92. package/src/init/index.ts +290 -58
  93. package/src/init/templates/api.ts +38 -29
  94. package/src/init/templates/index.ts +132 -4
  95. package/src/init/templates/minimal.ts +33 -35
  96. package/src/init/templates/serverless.ts +16 -19
  97. package/src/init/templates/worker.ts +50 -25
  98. package/src/init/versions.ts +47 -0
  99. package/src/secrets/keystore.ts +144 -0
  100. package/src/secrets/storage.ts +109 -6
  101. package/src/test/index.ts +97 -0
  102. package/src/workspace/__tests__/client-generator.spec.ts +357 -0
  103. package/src/workspace/__tests__/index.spec.ts +543 -0
  104. package/src/workspace/__tests__/schema.spec.ts +519 -0
  105. package/src/workspace/__tests__/type-inference.spec.ts +251 -0
  106. package/src/workspace/client-generator.ts +307 -0
  107. package/src/workspace/index.ts +372 -0
  108. package/src/workspace/schema.ts +368 -0
  109. package/src/workspace/types.ts +336 -0
  110. package/tsconfig.tsbuildinfo +1 -0
  111. package/tsdown.config.ts +1 -0
  112. package/dist/config-AmInkU7k.cjs.map +0 -1
  113. package/dist/config-DYULeEv8.mjs.map +0 -1
  114. package/dist/dokploy-api-B7KxOQr3.cjs +0 -3
  115. package/dist/dokploy-api-DHvfmWbi.mjs +0 -3
  116. package/dist/storage-BaOP55oq.mjs +0 -147
  117. package/dist/storage-BaOP55oq.mjs.map +0 -1
  118. package/dist/storage-Bn3K9Ccu.cjs.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
  const require_chunk = require('./chunk-CUT6urMc.cjs');
3
- const require_config = require('./config-AmInkU7k.cjs');
4
- const require_openapi = require('./openapi-C89hhkZC.cjs');
5
- const require_dokploy_api = require('./dokploy-api-C7F9VykY.cjs');
6
- const require_openapi_react_query = require('./openapi-react-query-iKjfLzff.cjs');
7
- const require_storage = require('./storage-Bn3K9Ccu.cjs');
3
+ const require_workspace = require('./workspace-iWgBlX6h.cjs');
4
+ const require_config = require('./config-CxrLu8ia.cjs');
5
+ const require_openapi = require('./openapi-DfpxS0xv.cjs');
6
+ const require_storage = require('./storage-BPRgh3DU.cjs');
7
+ const require_dokploy_api = require('./dokploy-api-C5czOZoc.cjs');
8
+ const require_openapi_react_query = require('./openapi-react-query-DvNpdDpM.cjs');
8
9
  const node_fs = require_chunk.__toESM(require("node:fs"));
9
10
  const node_path = require_chunk.__toESM(require("node:path"));
10
11
  const commander = require_chunk.__toESM(require("commander"));
@@ -20,12 +21,13 @@ const fast_glob = require_chunk.__toESM(require("fast-glob"));
20
21
  const __geekmidas_constructs_crons = require_chunk.__toESM(require("@geekmidas/constructs/crons"));
21
22
  const __geekmidas_constructs_functions = require_chunk.__toESM(require("@geekmidas/constructs/functions"));
22
23
  const __geekmidas_constructs_subscribers = require_chunk.__toESM(require("@geekmidas/constructs/subscribers"));
23
- const prompts = require_chunk.__toESM(require("prompts"));
24
24
  const node_crypto = require_chunk.__toESM(require("node:crypto"));
25
+ const prompts = require_chunk.__toESM(require("prompts"));
26
+ const node_module = require_chunk.__toESM(require("node:module"));
25
27
 
26
28
  //#region package.json
27
29
  var name = "@geekmidas/cli";
28
- var version = "0.17.0";
30
+ var version = "0.18.0";
29
31
  var description = "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs";
30
32
  var private$1 = false;
31
33
  var type = "module";
@@ -40,6 +42,11 @@ var exports$1 = {
40
42
  "import": "./dist/config.mjs",
41
43
  "require": "./dist/config.cjs"
42
44
  },
45
+ "./workspace": {
46
+ "types": "./dist/workspace/index.d.ts",
47
+ "import": "./dist/workspace/index.mjs",
48
+ "require": "./dist/workspace/index.cjs"
49
+ },
43
50
  "./openapi": {
44
51
  "types": "./dist/openapi.d.ts",
45
52
  "import": "./dist/openapi.mjs",
@@ -214,12 +221,12 @@ async function getDokployRegistryId(options) {
214
221
 
215
222
  //#endregion
216
223
  //#region src/auth/index.ts
217
- const logger$9 = console;
224
+ const logger$10 = console;
218
225
  /**
219
226
  * Validate Dokploy token by making a test API call
220
227
  */
221
228
  async function validateDokployToken(endpoint, token) {
222
- const { DokployApi: DokployApi$1 } = await Promise.resolve().then(() => require("./dokploy-api-B7KxOQr3.cjs"));
229
+ const { DokployApi: DokployApi$1 } = await Promise.resolve().then(() => require("./dokploy-api-BnGeUqN4.cjs"));
223
230
  const api = new DokployApi$1({
224
231
  baseUrl: endpoint,
225
232
  token
@@ -282,36 +289,36 @@ async function prompt$1(message, hidden = false) {
282
289
  async function loginCommand(options) {
283
290
  const { service, token: providedToken, endpoint: providedEndpoint } = options;
284
291
  if (service === "dokploy") {
285
- logger$9.log("\n🔐 Logging in to Dokploy...\n");
292
+ logger$10.log("\n🔐 Logging in to Dokploy...\n");
286
293
  let endpoint = providedEndpoint;
287
294
  if (!endpoint) endpoint = await prompt$1("Dokploy URL (e.g., https://dokploy.example.com): ");
288
295
  endpoint = endpoint.replace(/\/$/, "");
289
296
  try {
290
297
  new URL(endpoint);
291
298
  } catch {
292
- logger$9.error("Invalid URL format");
299
+ logger$10.error("Invalid URL format");
293
300
  process.exit(1);
294
301
  }
295
302
  let token = providedToken;
296
303
  if (!token) {
297
- logger$9.log(`\nGenerate a token at: ${endpoint}/settings/profile\n`);
304
+ logger$10.log(`\nGenerate a token at: ${endpoint}/settings/profile\n`);
298
305
  token = await prompt$1("API Token: ", true);
299
306
  }
300
307
  if (!token) {
301
- logger$9.error("Token is required");
308
+ logger$10.error("Token is required");
302
309
  process.exit(1);
303
310
  }
304
- logger$9.log("\nValidating credentials...");
311
+ logger$10.log("\nValidating credentials...");
305
312
  const isValid = await validateDokployToken(endpoint, token);
306
313
  if (!isValid) {
307
- logger$9.error("\n✗ Invalid credentials. Please check your token and try again.");
314
+ logger$10.error("\n✗ Invalid credentials. Please check your token and try again.");
308
315
  process.exit(1);
309
316
  }
310
317
  await storeDokployCredentials(token, endpoint);
311
- logger$9.log("\n✓ Successfully logged in to Dokploy!");
312
- logger$9.log(` Endpoint: ${endpoint}`);
313
- logger$9.log(` Credentials stored in: ${getCredentialsPath()}`);
314
- logger$9.log("\nYou can now use deploy commands without setting DOKPLOY_API_TOKEN.");
318
+ logger$10.log("\n✓ Successfully logged in to Dokploy!");
319
+ logger$10.log(` Endpoint: ${endpoint}`);
320
+ logger$10.log(` Credentials stored in: ${getCredentialsPath()}`);
321
+ logger$10.log("\nYou can now use deploy commands without setting DOKPLOY_API_TOKEN.");
315
322
  }
316
323
  }
317
324
  /**
@@ -321,28 +328,28 @@ async function logoutCommand(options) {
321
328
  const { service = "dokploy" } = options;
322
329
  if (service === "all") {
323
330
  const dokployRemoved = await removeDokployCredentials();
324
- if (dokployRemoved) logger$9.log("\n✓ Logged out from all services");
325
- else logger$9.log("\nNo stored credentials found");
331
+ if (dokployRemoved) logger$10.log("\n✓ Logged out from all services");
332
+ else logger$10.log("\nNo stored credentials found");
326
333
  return;
327
334
  }
328
335
  if (service === "dokploy") {
329
336
  const removed = await removeDokployCredentials();
330
- if (removed) logger$9.log("\n✓ Logged out from Dokploy");
331
- else logger$9.log("\nNo Dokploy credentials found");
337
+ if (removed) logger$10.log("\n✓ Logged out from Dokploy");
338
+ else logger$10.log("\nNo Dokploy credentials found");
332
339
  }
333
340
  }
334
341
  /**
335
342
  * Show current login status
336
343
  */
337
344
  async function whoamiCommand() {
338
- logger$9.log("\n📋 Current credentials:\n");
345
+ logger$10.log("\n📋 Current credentials:\n");
339
346
  const dokploy = await getDokployCredentials();
340
347
  if (dokploy) {
341
- logger$9.log(" Dokploy:");
342
- logger$9.log(` Endpoint: ${dokploy.endpoint}`);
343
- logger$9.log(` Token: ${maskToken(dokploy.token)}`);
344
- } else logger$9.log(" Dokploy: Not logged in");
345
- logger$9.log(`\n Credentials file: ${getCredentialsPath()}`);
348
+ logger$10.log(" Dokploy:");
349
+ logger$10.log(` Endpoint: ${dokploy.endpoint}`);
350
+ logger$10.log(` Token: ${maskToken(dokploy.token)}`);
351
+ } else logger$10.log(" Dokploy: Not logged in");
352
+ logger$10.log(`\n Credentials file: ${getCredentialsPath()}`);
346
353
  }
347
354
  /**
348
355
  * Mask a token for display
@@ -428,7 +435,7 @@ function isEnabled(config) {
428
435
  var CronGenerator = class extends require_openapi.ConstructGenerator {
429
436
  async build(context, constructs, outputDir, options) {
430
437
  const provider = options?.provider || "aws-lambda";
431
- const logger$10 = console;
438
+ const logger$11 = console;
432
439
  const cronInfos = [];
433
440
  if (constructs.length === 0 || provider !== "aws-lambda") return cronInfos;
434
441
  const cronsDir = (0, node_path.join)(outputDir, "crons");
@@ -443,7 +450,7 @@ var CronGenerator = class extends require_openapi.ConstructGenerator {
443
450
  memorySize: construct.memorySize,
444
451
  environment: await construct.getEnvironment()
445
452
  });
446
- logger$10.log(`Generated cron handler: ${key}`);
453
+ logger$11.log(`Generated cron handler: ${key}`);
447
454
  }
448
455
  return cronInfos;
449
456
  }
@@ -479,7 +486,7 @@ var FunctionGenerator = class extends require_openapi.ConstructGenerator {
479
486
  }
480
487
  async build(context, constructs, outputDir, options) {
481
488
  const provider = options?.provider || "aws-lambda";
482
- const logger$10 = console;
489
+ const logger$11 = console;
483
490
  const functionInfos = [];
484
491
  if (constructs.length === 0 || provider !== "aws-lambda") return functionInfos;
485
492
  const functionsDir = (0, node_path.join)(outputDir, "functions");
@@ -493,7 +500,7 @@ var FunctionGenerator = class extends require_openapi.ConstructGenerator {
493
500
  memorySize: construct.memorySize,
494
501
  environment: await construct.getEnvironment()
495
502
  });
496
- logger$10.log(`Generated function handler: ${key}`);
503
+ logger$11.log(`Generated function handler: ${key}`);
497
504
  }
498
505
  return functionInfos;
499
506
  }
@@ -526,11 +533,11 @@ var SubscriberGenerator = class extends require_openapi.ConstructGenerator {
526
533
  }
527
534
  async build(context, constructs, outputDir, options) {
528
535
  const provider = options?.provider || "aws-lambda";
529
- const logger$10 = console;
536
+ const logger$11 = console;
530
537
  const subscriberInfos = [];
531
538
  if (provider === "server") {
532
539
  await this.generateServerSubscribersFile(outputDir, constructs);
533
- logger$10.log(`Generated server subscribers file with ${constructs.length} subscribers (polling mode)`);
540
+ logger$11.log(`Generated server subscribers file with ${constructs.length} subscribers (polling mode)`);
534
541
  return subscriberInfos;
535
542
  }
536
543
  if (constructs.length === 0) return subscriberInfos;
@@ -547,7 +554,7 @@ var SubscriberGenerator = class extends require_openapi.ConstructGenerator {
547
554
  memorySize: construct.memorySize,
548
555
  environment: await construct.getEnvironment()
549
556
  });
550
- logger$10.log(`Generated subscriber handler: ${key}`);
557
+ logger$11.log(`Generated subscriber handler: ${key}`);
551
558
  }
552
559
  return subscriberInfos;
553
560
  }
@@ -711,6 +718,148 @@ export async function setupSubscribers(
711
718
  }
712
719
  };
713
720
 
721
+ //#endregion
722
+ //#region src/workspace/client-generator.ts
723
+ const logger$9 = console;
724
+ /**
725
+ * Cache of OpenAPI spec hashes to detect changes.
726
+ */
727
+ const specHashCache = /* @__PURE__ */ new Map();
728
+ /**
729
+ * Calculate hash of content for change detection.
730
+ */
731
+ function hashContent(content) {
732
+ return (0, node_crypto.createHash)("sha256").update(content).digest("hex").slice(0, 16);
733
+ }
734
+ /**
735
+ * Normalize routes to an array of patterns.
736
+ * @internal Exported for use in dev command
737
+ */
738
+ function normalizeRoutes(routes) {
739
+ if (!routes) return [];
740
+ return Array.isArray(routes) ? routes : [routes];
741
+ }
742
+ /**
743
+ * Generate OpenAPI spec for a backend app.
744
+ * Returns the spec content and endpoint count.
745
+ */
746
+ async function generateBackendOpenApi(workspace, appName) {
747
+ const app = workspace.apps[appName];
748
+ if (!app || app.type !== "backend" || !app.routes) return null;
749
+ const appPath = (0, node_path.join)(workspace.root, app.path);
750
+ const routesPatterns = normalizeRoutes(app.routes);
751
+ if (routesPatterns.length === 0) return null;
752
+ const endpointGenerator = new require_openapi.EndpointGenerator();
753
+ const allLoadedEndpoints = [];
754
+ for (const pattern of routesPatterns) {
755
+ const fullPattern = (0, node_path.join)(appPath, pattern);
756
+ const loaded = await endpointGenerator.load(fullPattern);
757
+ allLoadedEndpoints.push(...loaded);
758
+ }
759
+ const loadedEndpoints = allLoadedEndpoints;
760
+ if (loadedEndpoints.length === 0) return null;
761
+ const endpoints = loadedEndpoints.map(({ construct }) => construct);
762
+ const tsGenerator = new require_openapi.OpenApiTsGenerator();
763
+ const content = await tsGenerator.generate(endpoints, {
764
+ title: `${appName} API`,
765
+ version: "1.0.0",
766
+ description: `Auto-generated API client for ${appName}`
767
+ });
768
+ return {
769
+ content,
770
+ endpointCount: loadedEndpoints.length
771
+ };
772
+ }
773
+ /**
774
+ * Generate client for a frontend app from its backend dependencies.
775
+ * Only regenerates if the OpenAPI spec has changed.
776
+ */
777
+ async function generateClientForFrontend(workspace, frontendAppName, options = {}) {
778
+ const results = [];
779
+ const frontendApp = workspace.apps[frontendAppName];
780
+ if (!frontendApp || frontendApp.type !== "frontend") return results;
781
+ const dependencies$1 = frontendApp.dependencies || [];
782
+ const backendDeps = dependencies$1.filter((dep) => {
783
+ const depApp = workspace.apps[dep];
784
+ return depApp?.type === "backend" && depApp.routes;
785
+ });
786
+ if (backendDeps.length === 0) return results;
787
+ const clientOutput = frontendApp.client?.output || "src/api";
788
+ const frontendPath = (0, node_path.join)(workspace.root, frontendApp.path);
789
+ const outputDir = (0, node_path.join)(frontendPath, clientOutput);
790
+ for (const backendAppName of backendDeps) {
791
+ const result = {
792
+ frontendApp: frontendAppName,
793
+ backendApp: backendAppName,
794
+ outputPath: "",
795
+ endpointCount: 0,
796
+ generated: false
797
+ };
798
+ try {
799
+ const spec = await generateBackendOpenApi(workspace, backendAppName);
800
+ if (!spec) {
801
+ result.reason = "No endpoints found in backend";
802
+ results.push(result);
803
+ continue;
804
+ }
805
+ result.endpointCount = spec.endpointCount;
806
+ const cacheKey = `${backendAppName}:${frontendAppName}`;
807
+ const newHash = hashContent(spec.content);
808
+ const oldHash = specHashCache.get(cacheKey);
809
+ if (!options.force && oldHash === newHash) {
810
+ result.reason = "No schema changes detected";
811
+ results.push(result);
812
+ continue;
813
+ }
814
+ await (0, node_fs_promises.mkdir)(outputDir, { recursive: true });
815
+ const fileName = backendDeps.length === 1 ? "openapi.ts" : `${backendAppName}-api.ts`;
816
+ const outputPath = (0, node_path.join)(outputDir, fileName);
817
+ const backendRelPath = (0, node_path.relative)((0, node_path.dirname)(outputPath), (0, node_path.join)(workspace.root, workspace.apps[backendAppName].path));
818
+ const clientContent = `/**
819
+ * Auto-generated API client for ${backendAppName}
820
+ * Generated from: ${backendRelPath}
821
+ *
822
+ * DO NOT EDIT - This file is automatically regenerated when backend schemas change.
823
+ */
824
+
825
+ ${spec.content}
826
+ `;
827
+ await (0, node_fs_promises.writeFile)(outputPath, clientContent);
828
+ specHashCache.set(cacheKey, newHash);
829
+ result.outputPath = outputPath;
830
+ result.generated = true;
831
+ results.push(result);
832
+ } catch (error) {
833
+ result.reason = `Error: ${error.message}`;
834
+ results.push(result);
835
+ }
836
+ }
837
+ return results;
838
+ }
839
+ /**
840
+ * Generate clients for all frontend apps in the workspace.
841
+ */
842
+ async function generateAllClients(workspace, options = {}) {
843
+ const log = options.silent ? () => {} : logger$9.log.bind(logger$9);
844
+ const allResults = [];
845
+ for (const [appName, app] of Object.entries(workspace.apps)) if (app.type === "frontend" && app.dependencies.length > 0) {
846
+ const results = await generateClientForFrontend(workspace, appName, { force: options.force });
847
+ for (const result of results) {
848
+ if (result.generated) log(`📦 Generated client for ${result.frontendApp} from ${result.backendApp} (${result.endpointCount} endpoints)`);
849
+ allResults.push(result);
850
+ }
851
+ }
852
+ return allResults;
853
+ }
854
+ /**
855
+ * Get frontend apps that depend on a backend app.
856
+ */
857
+ function getDependentFrontends(workspace, backendAppName) {
858
+ const dependentApps = [];
859
+ for (const [appName, app] of Object.entries(workspace.apps)) if (app.type === "frontend" && app.dependencies.includes(backendAppName)) dependentApps.push(appName);
860
+ return dependentApps;
861
+ }
862
+
714
863
  //#endregion
715
864
  //#region src/dev/index.ts
716
865
  const logger$8 = console;
@@ -865,7 +1014,12 @@ function getProductionConfigFromGkm(config) {
865
1014
  async function devCommand(options) {
866
1015
  const defaultEnv = loadEnvFiles(".env");
867
1016
  if (defaultEnv.loaded.length > 0) logger$8.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
868
- const config = await require_config.loadConfig();
1017
+ const loadedConfig = await require_config.loadWorkspaceConfig();
1018
+ if (loadedConfig.type === "workspace") {
1019
+ logger$8.log("📦 Detected workspace configuration");
1020
+ return workspaceDevCommand(loadedConfig.workspace, options);
1021
+ }
1022
+ const config = loadedConfig.raw;
869
1023
  if (config.env) {
870
1024
  const { loaded, missing } = loadEnvFiles(config.env);
871
1025
  if (loaded.length > 0) logger$8.log(`📦 Loaded env: ${loaded.join(", ")}`);
@@ -970,6 +1124,312 @@ async function devCommand(options) {
970
1124
  process.on("SIGINT", shutdown);
971
1125
  process.on("SIGTERM", shutdown);
972
1126
  }
1127
+ /**
1128
+ * Generate all dependency environment variables for all apps.
1129
+ * Returns a flat object with all {APP_NAME}_URL variables.
1130
+ * @internal Exported for testing
1131
+ */
1132
+ function generateAllDependencyEnvVars(workspace, urlPrefix = "http://localhost") {
1133
+ const env = {};
1134
+ for (const appName of Object.keys(workspace.apps)) {
1135
+ const appEnv = require_workspace.getDependencyEnvVars(workspace, appName, urlPrefix);
1136
+ Object.assign(env, appEnv);
1137
+ }
1138
+ return env;
1139
+ }
1140
+ /**
1141
+ * Check for port conflicts across all apps.
1142
+ * Returns list of conflicts if any ports are duplicated.
1143
+ * @internal Exported for testing
1144
+ */
1145
+ function checkPortConflicts(workspace) {
1146
+ const conflicts = [];
1147
+ const portToApp = /* @__PURE__ */ new Map();
1148
+ for (const [appName, app] of Object.entries(workspace.apps)) {
1149
+ const existingApp = portToApp.get(app.port);
1150
+ if (existingApp) conflicts.push({
1151
+ app1: existingApp,
1152
+ app2: appName,
1153
+ port: app.port
1154
+ });
1155
+ else portToApp.set(app.port, appName);
1156
+ }
1157
+ return conflicts;
1158
+ }
1159
+ /**
1160
+ * Next.js config file patterns to check.
1161
+ */
1162
+ const NEXTJS_CONFIG_FILES = [
1163
+ "next.config.js",
1164
+ "next.config.ts",
1165
+ "next.config.mjs"
1166
+ ];
1167
+ /**
1168
+ * Validate a frontend (Next.js) app configuration.
1169
+ * Checks for Next.js config file and dependency.
1170
+ * @internal Exported for testing
1171
+ */
1172
+ async function validateFrontendApp(appName, appPath, workspaceRoot) {
1173
+ const errors = [];
1174
+ const warnings = [];
1175
+ const fullPath = (0, node_path.join)(workspaceRoot, appPath);
1176
+ const hasConfigFile = NEXTJS_CONFIG_FILES.some((file) => (0, node_fs.existsSync)((0, node_path.join)(fullPath, file)));
1177
+ if (!hasConfigFile) errors.push(`Next.js config file not found. Expected one of: ${NEXTJS_CONFIG_FILES.join(", ")}`);
1178
+ const packageJsonPath = (0, node_path.join)(fullPath, "package.json");
1179
+ if ((0, node_fs.existsSync)(packageJsonPath)) try {
1180
+ const pkg$1 = require(packageJsonPath);
1181
+ const deps = {
1182
+ ...pkg$1.dependencies,
1183
+ ...pkg$1.devDependencies
1184
+ };
1185
+ if (!deps.next) errors.push("Next.js not found in dependencies. Run: pnpm add next react react-dom");
1186
+ if (!pkg$1.scripts?.dev) warnings.push("No \"dev\" script found in package.json. Turbo expects a \"dev\" script to run.");
1187
+ } catch {
1188
+ errors.push(`Failed to read package.json at ${packageJsonPath}`);
1189
+ }
1190
+ else errors.push(`package.json not found at ${appPath}. Run: pnpm init in the app directory.`);
1191
+ return {
1192
+ appName,
1193
+ valid: errors.length === 0,
1194
+ errors,
1195
+ warnings
1196
+ };
1197
+ }
1198
+ /**
1199
+ * Validate all frontend apps in the workspace.
1200
+ * Returns validation results for each frontend app.
1201
+ * @internal Exported for testing
1202
+ */
1203
+ async function validateFrontendApps(workspace) {
1204
+ const results = [];
1205
+ for (const [appName, app] of Object.entries(workspace.apps)) if (app.type === "frontend") {
1206
+ const result = await validateFrontendApp(appName, app.path, workspace.root);
1207
+ results.push(result);
1208
+ }
1209
+ return results;
1210
+ }
1211
+ /**
1212
+ * Load secrets for development stage.
1213
+ * Returns env vars to inject, or empty object if secrets not configured/found.
1214
+ * @internal Exported for testing
1215
+ */
1216
+ async function loadDevSecrets(workspace) {
1217
+ if (!workspace.secrets.enabled) return {};
1218
+ const stages = ["dev", "development"];
1219
+ for (const stage of stages) if (require_storage.secretsExist(stage, workspace.root)) {
1220
+ const secrets = await require_storage.readStageSecrets(stage, workspace.root);
1221
+ if (secrets) {
1222
+ logger$8.log(`🔐 Loading secrets from stage: ${stage}`);
1223
+ return require_storage.toEmbeddableSecrets(secrets);
1224
+ }
1225
+ }
1226
+ logger$8.warn("⚠️ Secrets enabled but no dev/development secrets found. Run \"gkm secrets:init --stage dev\"");
1227
+ return {};
1228
+ }
1229
+ /**
1230
+ * Start docker-compose services for the workspace.
1231
+ * @internal Exported for testing
1232
+ */
1233
+ async function startWorkspaceServices(workspace) {
1234
+ const services = workspace.services;
1235
+ if (!services.db && !services.cache && !services.mail) return;
1236
+ const servicesToStart = [];
1237
+ if (services.db) servicesToStart.push("postgres");
1238
+ if (services.cache) servicesToStart.push("redis");
1239
+ if (services.mail) servicesToStart.push("mailpit");
1240
+ if (servicesToStart.length === 0) return;
1241
+ logger$8.log(`🐳 Starting services: ${servicesToStart.join(", ")}`);
1242
+ try {
1243
+ const composeFile = (0, node_path.join)(workspace.root, "docker-compose.yml");
1244
+ if (!(0, node_fs.existsSync)(composeFile)) {
1245
+ logger$8.warn("⚠️ No docker-compose.yml found. Services will not be started.");
1246
+ return;
1247
+ }
1248
+ (0, node_child_process.execSync)(`docker-compose up -d ${servicesToStart.join(" ")}`, {
1249
+ cwd: workspace.root,
1250
+ stdio: "inherit"
1251
+ });
1252
+ logger$8.log("✅ Services started");
1253
+ } catch (error) {
1254
+ logger$8.error("❌ Failed to start services:", error.message);
1255
+ throw error;
1256
+ }
1257
+ }
1258
+ /**
1259
+ * Workspace dev command - orchestrates multi-app development using Turbo.
1260
+ *
1261
+ * Flow:
1262
+ * 1. Check for port conflicts
1263
+ * 2. Start docker-compose services (db, cache, mail)
1264
+ * 3. Generate dependency URLs ({APP_NAME}_URL)
1265
+ * 4. Spawn turbo run dev with injected env vars
1266
+ */
1267
+ async function workspaceDevCommand(workspace, options) {
1268
+ const appCount = Object.keys(workspace.apps).length;
1269
+ const backendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "backend");
1270
+ const frontendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "frontend");
1271
+ logger$8.log(`\n🚀 Starting workspace: ${workspace.name}`);
1272
+ logger$8.log(` ${backendApps.length} backend app(s), ${frontendApps.length} frontend app(s)`);
1273
+ const conflicts = checkPortConflicts(workspace);
1274
+ if (conflicts.length > 0) {
1275
+ for (const conflict of conflicts) logger$8.error(`❌ Port conflict: Apps "${conflict.app1}" and "${conflict.app2}" both use port ${conflict.port}`);
1276
+ throw new Error("Port conflicts detected. Please assign unique ports to each app.");
1277
+ }
1278
+ if (frontendApps.length > 0) {
1279
+ logger$8.log("\n🔍 Validating frontend apps...");
1280
+ const validationResults = await validateFrontendApps(workspace);
1281
+ let hasErrors = false;
1282
+ for (const result of validationResults) {
1283
+ if (!result.valid) {
1284
+ hasErrors = true;
1285
+ logger$8.error(`\n❌ Frontend app "${result.appName}" validation failed:`);
1286
+ for (const error of result.errors) logger$8.error(` • ${error}`);
1287
+ }
1288
+ for (const warning of result.warnings) logger$8.warn(` ⚠️ ${result.appName}: ${warning}`);
1289
+ }
1290
+ if (hasErrors) throw new Error("Frontend app validation failed. Fix the issues above and try again.");
1291
+ logger$8.log("✅ Frontend apps validated");
1292
+ }
1293
+ if (frontendApps.length > 0) {
1294
+ const clientResults = await generateAllClients(workspace, { force: true });
1295
+ const generatedCount = clientResults.filter((r) => r.generated).length;
1296
+ if (generatedCount > 0) logger$8.log(`\n📦 Generated ${generatedCount} API client(s)`);
1297
+ }
1298
+ await startWorkspaceServices(workspace);
1299
+ const secretsEnv = await loadDevSecrets(workspace);
1300
+ if (Object.keys(secretsEnv).length > 0) logger$8.log(` Loaded ${Object.keys(secretsEnv).length} secret(s)`);
1301
+ const dependencyEnv = generateAllDependencyEnvVars(workspace);
1302
+ if (Object.keys(dependencyEnv).length > 0) {
1303
+ logger$8.log("📡 Dependency URLs:");
1304
+ for (const [key, value] of Object.entries(dependencyEnv)) logger$8.log(` ${key}=${value}`);
1305
+ }
1306
+ let turboFilter = [];
1307
+ if (options.app) {
1308
+ if (!workspace.apps[options.app]) {
1309
+ const appNames = Object.keys(workspace.apps).join(", ");
1310
+ throw new Error(`App "${options.app}" not found. Available apps: ${appNames}`);
1311
+ }
1312
+ turboFilter = ["--filter", options.app];
1313
+ logger$8.log(`\n🎯 Running single app: ${options.app}`);
1314
+ } else if (options.filter) {
1315
+ turboFilter = ["--filter", options.filter];
1316
+ logger$8.log(`\n🔍 Using filter: ${options.filter}`);
1317
+ } else logger$8.log(`\n🎯 Running all ${appCount} apps`);
1318
+ const buildOrder = require_workspace.getAppBuildOrder(workspace);
1319
+ logger$8.log("\n📋 Apps (in dependency order):");
1320
+ for (const appName of buildOrder) {
1321
+ const app = workspace.apps[appName];
1322
+ if (!app) continue;
1323
+ const deps = app.dependencies.length > 0 ? ` (depends on: ${app.dependencies.join(", ")})` : "";
1324
+ logger$8.log(` ${app.type === "backend" ? "🔧" : "🌐"} ${appName} → http://localhost:${app.port}${deps}`);
1325
+ }
1326
+ const turboEnv = {
1327
+ ...process.env,
1328
+ ...secretsEnv,
1329
+ ...dependencyEnv,
1330
+ NODE_ENV: "development"
1331
+ };
1332
+ logger$8.log("\n🏃 Starting turbo run dev...\n");
1333
+ const turboProcess = (0, node_child_process.spawn)("pnpm", [
1334
+ "turbo",
1335
+ "run",
1336
+ "dev",
1337
+ ...turboFilter
1338
+ ], {
1339
+ cwd: workspace.root,
1340
+ stdio: "inherit",
1341
+ env: turboEnv
1342
+ });
1343
+ let endpointWatcher = null;
1344
+ if (frontendApps.length > 0 && backendApps.length > 0) {
1345
+ const watchPatterns = [];
1346
+ const backendRouteMap = /* @__PURE__ */ new Map();
1347
+ for (const [appName, app] of backendApps) {
1348
+ const routePatterns = normalizeRoutes(app.routes);
1349
+ for (const routePattern of routePatterns) {
1350
+ const fullPattern = (0, node_path.join)(workspace.root, app.path, routePattern);
1351
+ watchPatterns.push(fullPattern);
1352
+ const patternKey = (0, node_path.join)(app.path, routePattern);
1353
+ const existing = backendRouteMap.get(patternKey) || [];
1354
+ backendRouteMap.set(patternKey, [...existing, appName]);
1355
+ }
1356
+ }
1357
+ if (watchPatterns.length > 0) {
1358
+ const resolvedFiles = await (0, fast_glob.default)(watchPatterns, {
1359
+ cwd: workspace.root,
1360
+ absolute: true,
1361
+ onlyFiles: true
1362
+ });
1363
+ if (resolvedFiles.length > 0) {
1364
+ logger$8.log(`\n👀 Watching ${resolvedFiles.length} endpoint file(s) for schema changes`);
1365
+ endpointWatcher = chokidar.default.watch(resolvedFiles, {
1366
+ ignored: /(^|[/\\])\../,
1367
+ persistent: true,
1368
+ ignoreInitial: true
1369
+ });
1370
+ let regenerateTimeout = null;
1371
+ endpointWatcher.on("change", async (changedPath) => {
1372
+ if (regenerateTimeout) clearTimeout(regenerateTimeout);
1373
+ regenerateTimeout = setTimeout(async () => {
1374
+ const changedBackends = [];
1375
+ for (const [appName, app] of backendApps) {
1376
+ const routePatterns = normalizeRoutes(app.routes);
1377
+ for (const routePattern of routePatterns) {
1378
+ const routesDir = (0, node_path.join)(workspace.root, app.path, routePattern.split("*")[0] || "");
1379
+ if (changedPath.startsWith(routesDir.replace(/\/$/, ""))) {
1380
+ changedBackends.push(appName);
1381
+ break;
1382
+ }
1383
+ }
1384
+ }
1385
+ if (changedBackends.length === 0) return;
1386
+ const affectedFrontends = /* @__PURE__ */ new Set();
1387
+ for (const backend of changedBackends) {
1388
+ const dependents = getDependentFrontends(workspace, backend);
1389
+ for (const frontend of dependents) affectedFrontends.add(frontend);
1390
+ }
1391
+ if (affectedFrontends.size === 0) return;
1392
+ logger$8.log(`\n🔄 Detected schema change in ${changedBackends.join(", ")}`);
1393
+ for (const frontend of affectedFrontends) try {
1394
+ const results = await generateClientForFrontend(workspace, frontend);
1395
+ for (const result of results) if (result.generated) logger$8.log(` 📦 Regenerated client for ${result.frontendApp} (${result.endpointCount} endpoints)`);
1396
+ } catch (error) {
1397
+ logger$8.error(` ❌ Failed to regenerate client for ${frontend}: ${error.message}`);
1398
+ }
1399
+ }, 500);
1400
+ });
1401
+ }
1402
+ }
1403
+ }
1404
+ let isShuttingDown = false;
1405
+ const shutdown = () => {
1406
+ if (isShuttingDown) return;
1407
+ isShuttingDown = true;
1408
+ logger$8.log("\n🛑 Shutting down workspace...");
1409
+ if (endpointWatcher) endpointWatcher.close().catch(() => {});
1410
+ if (turboProcess.pid) try {
1411
+ process.kill(-turboProcess.pid, "SIGTERM");
1412
+ } catch {
1413
+ turboProcess.kill("SIGTERM");
1414
+ }
1415
+ setTimeout(() => {
1416
+ process.exit(0);
1417
+ }, 2e3);
1418
+ };
1419
+ process.on("SIGINT", shutdown);
1420
+ process.on("SIGTERM", shutdown);
1421
+ return new Promise((resolve$1, reject) => {
1422
+ turboProcess.on("error", (error) => {
1423
+ logger$8.error("❌ Turbo error:", error);
1424
+ reject(error);
1425
+ });
1426
+ turboProcess.on("exit", (code) => {
1427
+ if (endpointWatcher) endpointWatcher.close().catch(() => {});
1428
+ if (code !== null && code !== 0) reject(new Error(`Turbo exited with code ${code}`));
1429
+ else resolve$1();
1430
+ });
1431
+ });
1432
+ }
973
1433
  async function buildServer(config, context, provider, enableOpenApi) {
974
1434
  const endpointGenerator = new require_openapi.EndpointGenerator();
975
1435
  const functionGenerator = new FunctionGenerator();
@@ -1083,10 +1543,10 @@ var DevServer = class {
1083
1543
  await this.start();
1084
1544
  }
1085
1545
  async createServerEntry() {
1086
- const { writeFile: writeFile$8 } = await import("node:fs/promises");
1087
- const { relative: relative$6, dirname: dirname$6 } = await import("node:path");
1546
+ const { writeFile: writeFile$9 } = await import("node:fs/promises");
1547
+ const { relative: relative$7, dirname: dirname$7 } = await import("node:path");
1088
1548
  const serverPath = (0, node_path.join)(process.cwd(), ".gkm", this.provider, "server.ts");
1089
- const relativeAppPath = relative$6(dirname$6(serverPath), (0, node_path.join)(dirname$6(serverPath), "app.js"));
1549
+ const relativeAppPath = relative$7(dirname$7(serverPath), (0, node_path.join)(dirname$7(serverPath), "app.js"));
1090
1550
  const serveCode = this.runtime === "bun" ? `Bun.serve({
1091
1551
  port,
1092
1552
  fetch: app.fetch,
@@ -1126,7 +1586,7 @@ start({
1126
1586
  process.exit(1);
1127
1587
  });
1128
1588
  `;
1129
- await writeFile$8(serverPath, content);
1589
+ await writeFile$9(serverPath, content);
1130
1590
  }
1131
1591
  };
1132
1592
 
@@ -1197,6 +1657,11 @@ export type RoutePath = Route['path'];
1197
1657
  //#region src/build/index.ts
1198
1658
  const logger$6 = console;
1199
1659
  async function buildCommand(options) {
1660
+ const loadedConfig = await require_config.loadWorkspaceConfig();
1661
+ if (loadedConfig.type === "workspace") {
1662
+ logger$6.log("📦 Detected workspace configuration");
1663
+ return workspaceBuildCommand(loadedConfig.workspace, options);
1664
+ }
1200
1665
  const config = await require_config.loadConfig();
1201
1666
  const resolved = resolveProviders(config, options);
1202
1667
  const productionConfigFromGkm = getProductionConfigFromGkm(config);
@@ -1293,7 +1758,7 @@ async function buildForProvider(provider, context, rootOutputDir, endpointGenera
1293
1758
  let masterKey;
1294
1759
  if (context.production?.bundle && !skipBundle) {
1295
1760
  logger$6.log(`\n📦 Bundling production server...`);
1296
- const { bundleServer } = await Promise.resolve().then(() => require("./bundler-C74EKlNa.cjs"));
1761
+ const { bundleServer } = await Promise.resolve().then(() => require("./bundler-CyHg1v_T.cjs"));
1297
1762
  const allConstructs = [
1298
1763
  ...endpoints.map((e) => e.construct),
1299
1764
  ...functions.map((f) => f.construct),
@@ -1322,6 +1787,101 @@ async function buildForProvider(provider, context, rootOutputDir, endpointGenera
1322
1787
  } else await generateAwsManifest(rootOutputDir, routes, functionInfos, cronInfos, subscriberInfos);
1323
1788
  return {};
1324
1789
  }
1790
+ /**
1791
+ * Detect available package manager.
1792
+ * @internal Exported for testing
1793
+ */
1794
+ function detectPackageManager$2() {
1795
+ if ((0, node_fs.existsSync)("pnpm-lock.yaml")) return "pnpm";
1796
+ if ((0, node_fs.existsSync)("yarn.lock")) return "yarn";
1797
+ return "npm";
1798
+ }
1799
+ /**
1800
+ * Get the turbo command for running builds.
1801
+ * @internal Exported for testing
1802
+ */
1803
+ function getTurboCommand(pm, filter) {
1804
+ const filterArg = filter ? ` --filter=${filter}` : "";
1805
+ switch (pm) {
1806
+ case "pnpm": return `pnpm exec turbo run build${filterArg}`;
1807
+ case "yarn": return `yarn turbo run build${filterArg}`;
1808
+ case "npm": return `npx turbo run build${filterArg}`;
1809
+ }
1810
+ }
1811
+ /**
1812
+ * Build all apps in a workspace using Turbo for dependency-ordered parallel builds.
1813
+ * @internal Exported for testing
1814
+ */
1815
+ async function workspaceBuildCommand(workspace, options) {
1816
+ const results = [];
1817
+ const apps = Object.entries(workspace.apps);
1818
+ const backendApps = apps.filter(([, app]) => app.type === "backend");
1819
+ const frontendApps = apps.filter(([, app]) => app.type === "frontend");
1820
+ logger$6.log(`\n🏗️ Building workspace: ${workspace.name}`);
1821
+ logger$6.log(` Backend apps: ${backendApps.map(([name$1]) => name$1).join(", ") || "none"}`);
1822
+ logger$6.log(` Frontend apps: ${frontendApps.map(([name$1]) => name$1).join(", ") || "none"}`);
1823
+ if (options.production) logger$6.log(` 🏭 Production mode enabled`);
1824
+ const buildOrder = require_workspace.getAppBuildOrder(workspace);
1825
+ logger$6.log(` Build order: ${buildOrder.join(" → ")}`);
1826
+ const pm = detectPackageManager$2();
1827
+ logger$6.log(`\n📦 Using ${pm} with Turbo for parallel builds...\n`);
1828
+ try {
1829
+ const turboCommand = getTurboCommand(pm);
1830
+ logger$6.log(`Running: ${turboCommand}`);
1831
+ await new Promise((resolve$1, reject) => {
1832
+ const child = (0, node_child_process.spawn)(turboCommand, {
1833
+ shell: true,
1834
+ cwd: workspace.root,
1835
+ stdio: "inherit",
1836
+ env: {
1837
+ ...process.env,
1838
+ NODE_ENV: options.production ? "production" : "development"
1839
+ }
1840
+ });
1841
+ child.on("close", (code) => {
1842
+ if (code === 0) resolve$1();
1843
+ else reject(new Error(`Turbo build failed with exit code ${code}`));
1844
+ });
1845
+ child.on("error", (err) => {
1846
+ reject(err);
1847
+ });
1848
+ });
1849
+ for (const [appName, app] of apps) {
1850
+ const outputPath = getAppOutputPath(workspace, appName, app);
1851
+ results.push({
1852
+ appName,
1853
+ type: app.type,
1854
+ success: true,
1855
+ outputPath
1856
+ });
1857
+ }
1858
+ logger$6.log(`\n✅ Workspace build complete!`);
1859
+ logger$6.log(`\n📋 Build Summary:`);
1860
+ for (const result of results) {
1861
+ const icon = result.type === "backend" ? "⚙️" : "🌐";
1862
+ logger$6.log(` ${icon} ${result.appName}: ${result.outputPath || "built"}`);
1863
+ }
1864
+ } catch (error) {
1865
+ const errorMessage = error instanceof Error ? error.message : "Build failed";
1866
+ logger$6.log(`\n❌ Build failed: ${errorMessage}`);
1867
+ for (const [appName, app] of apps) results.push({
1868
+ appName,
1869
+ type: app.type,
1870
+ success: false,
1871
+ error: errorMessage
1872
+ });
1873
+ throw error;
1874
+ }
1875
+ return { apps: results };
1876
+ }
1877
+ /**
1878
+ * Get the output path for a built app.
1879
+ */
1880
+ function getAppOutputPath(workspace, _appName, app) {
1881
+ const appPath = (0, node_path.join)(workspace.root, app.path);
1882
+ if (app.type === "frontend") return (0, node_path.join)(appPath, ".next");
1883
+ else return (0, node_path.join)(appPath, ".gkm");
1884
+ }
1325
1885
 
1326
1886
  //#endregion
1327
1887
  //#region src/docker/compose.ts
@@ -1509,6 +2069,163 @@ networks:
1509
2069
  driver: bridge
1510
2070
  `;
1511
2071
  }
2072
+ /**
2073
+ * Generate docker-compose.yml for a workspace with all apps as services.
2074
+ * Apps can communicate with each other via service names.
2075
+ * @internal Exported for testing
2076
+ */
2077
+ function generateWorkspaceCompose(workspace, options = {}) {
2078
+ const { registry } = options;
2079
+ const apps = Object.entries(workspace.apps);
2080
+ const services = workspace.services;
2081
+ const hasPostgres = services.db !== void 0 && services.db !== false;
2082
+ const hasRedis = services.cache !== void 0 && services.cache !== false;
2083
+ const hasMail = services.mail !== void 0 && services.mail !== false;
2084
+ const postgresImage = getInfraServiceImage("postgres", services.db);
2085
+ const redisImage = getInfraServiceImage("redis", services.cache);
2086
+ let yaml = `# Docker Compose for ${workspace.name} workspace
2087
+ # Generated by gkm - do not edit manually
2088
+
2089
+ services:
2090
+ `;
2091
+ for (const [appName, app] of apps) yaml += generateAppService(appName, app, apps, {
2092
+ registry,
2093
+ hasPostgres,
2094
+ hasRedis
2095
+ });
2096
+ if (hasPostgres) yaml += `
2097
+ postgres:
2098
+ image: ${postgresImage}
2099
+ container_name: ${workspace.name}-postgres
2100
+ restart: unless-stopped
2101
+ environment:
2102
+ POSTGRES_USER: \${POSTGRES_USER:-postgres}
2103
+ POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:-postgres}
2104
+ POSTGRES_DB: \${POSTGRES_DB:-app}
2105
+ volumes:
2106
+ - postgres_data:/var/lib/postgresql/data
2107
+ healthcheck:
2108
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
2109
+ interval: 5s
2110
+ timeout: 5s
2111
+ retries: 5
2112
+ networks:
2113
+ - workspace-network
2114
+ `;
2115
+ if (hasRedis) yaml += `
2116
+ redis:
2117
+ image: ${redisImage}
2118
+ container_name: ${workspace.name}-redis
2119
+ restart: unless-stopped
2120
+ volumes:
2121
+ - redis_data:/data
2122
+ healthcheck:
2123
+ test: ["CMD", "redis-cli", "ping"]
2124
+ interval: 5s
2125
+ timeout: 5s
2126
+ retries: 5
2127
+ networks:
2128
+ - workspace-network
2129
+ `;
2130
+ if (hasMail) yaml += `
2131
+ mailpit:
2132
+ image: axllent/mailpit:latest
2133
+ container_name: ${workspace.name}-mailpit
2134
+ restart: unless-stopped
2135
+ ports:
2136
+ - "8025:8025" # Web UI
2137
+ - "1025:1025" # SMTP
2138
+ networks:
2139
+ - workspace-network
2140
+ `;
2141
+ yaml += `
2142
+ volumes:
2143
+ `;
2144
+ if (hasPostgres) yaml += ` postgres_data:
2145
+ `;
2146
+ if (hasRedis) yaml += ` redis_data:
2147
+ `;
2148
+ yaml += `
2149
+ networks:
2150
+ workspace-network:
2151
+ driver: bridge
2152
+ `;
2153
+ return yaml;
2154
+ }
2155
+ /**
2156
+ * Get infrastructure service image with version.
2157
+ */
2158
+ function getInfraServiceImage(serviceName, config) {
2159
+ const defaults = {
2160
+ postgres: "postgres:16-alpine",
2161
+ redis: "redis:7-alpine"
2162
+ };
2163
+ if (!config || config === true) return defaults[serviceName];
2164
+ if (typeof config === "object") {
2165
+ if (config.image) return config.image;
2166
+ if (config.version) {
2167
+ const baseImage = serviceName === "postgres" ? "postgres" : "redis";
2168
+ return `${baseImage}:${config.version}`;
2169
+ }
2170
+ }
2171
+ return defaults[serviceName];
2172
+ }
2173
+ /**
2174
+ * Generate a service definition for an app.
2175
+ */
2176
+ function generateAppService(appName, app, allApps, options) {
2177
+ const { registry, hasPostgres, hasRedis } = options;
2178
+ const imageRef = registry ? `\${REGISTRY:-${registry}}/` : "";
2179
+ const healthCheckPath = app.type === "frontend" ? "/" : "/health";
2180
+ const healthCheckCmd = app.type === "frontend" ? `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}/"]` : `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}${healthCheckPath}"]`;
2181
+ let yaml = `
2182
+ ${appName}:
2183
+ build:
2184
+ context: .
2185
+ dockerfile: .gkm/docker/Dockerfile.${appName}
2186
+ image: ${imageRef}\${${appName.toUpperCase()}_IMAGE:-${appName}}:\${TAG:-latest}
2187
+ container_name: ${appName}
2188
+ restart: unless-stopped
2189
+ ports:
2190
+ - "\${${appName.toUpperCase()}_PORT:-${app.port}}:${app.port}"
2191
+ environment:
2192
+ - NODE_ENV=production
2193
+ - PORT=${app.port}
2194
+ `;
2195
+ for (const dep of app.dependencies) {
2196
+ const depApp = allApps.find(([name$1]) => name$1 === dep)?.[1];
2197
+ if (depApp) yaml += ` - ${dep.toUpperCase()}_URL=http://${dep}:${depApp.port}
2198
+ `;
2199
+ }
2200
+ if (app.type === "backend") {
2201
+ if (hasPostgres) yaml += ` - DATABASE_URL=\${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/app}
2202
+ `;
2203
+ if (hasRedis) yaml += ` - REDIS_URL=\${REDIS_URL:-redis://redis:6379}
2204
+ `;
2205
+ }
2206
+ yaml += ` healthcheck:
2207
+ test: ${healthCheckCmd}
2208
+ interval: 30s
2209
+ timeout: 3s
2210
+ retries: 3
2211
+ `;
2212
+ const dependencies$1 = [...app.dependencies];
2213
+ if (app.type === "backend") {
2214
+ if (hasPostgres) dependencies$1.push("postgres");
2215
+ if (hasRedis) dependencies$1.push("redis");
2216
+ }
2217
+ if (dependencies$1.length > 0) {
2218
+ yaml += ` depends_on:
2219
+ `;
2220
+ for (const dep of dependencies$1) yaml += ` ${dep}:
2221
+ condition: service_healthy
2222
+ `;
2223
+ }
2224
+ yaml += ` networks:
2225
+ - workspace-network
2226
+ `;
2227
+ return yaml;
2228
+ }
1512
2229
 
1513
2230
  //#endregion
1514
2231
  //#region src/docker/templates.ts
@@ -1599,6 +2316,7 @@ function getPmConfig(pm) {
1599
2316
  cacheTarget: "/root/.local/share/pnpm/store",
1600
2317
  cacheId: "pnpm",
1601
2318
  run: "pnpm",
2319
+ exec: "pnpm exec",
1602
2320
  dlx: "pnpm dlx",
1603
2321
  addGlobal: "pnpm add -g"
1604
2322
  },
@@ -1610,6 +2328,7 @@ function getPmConfig(pm) {
1610
2328
  cacheTarget: "/root/.npm",
1611
2329
  cacheId: "npm",
1612
2330
  run: "npm run",
2331
+ exec: "npx",
1613
2332
  dlx: "npx",
1614
2333
  addGlobal: "npm install -g"
1615
2334
  },
@@ -1621,6 +2340,7 @@ function getPmConfig(pm) {
1621
2340
  cacheTarget: "/root/.yarn/cache",
1622
2341
  cacheId: "yarn",
1623
2342
  run: "yarn",
2343
+ exec: "yarn exec",
1624
2344
  dlx: "yarn dlx",
1625
2345
  addGlobal: "yarn global add"
1626
2346
  },
@@ -1632,6 +2352,7 @@ function getPmConfig(pm) {
1632
2352
  cacheTarget: "/root/.bun/install/cache",
1633
2353
  cacheId: "bun",
1634
2354
  run: "bun run",
2355
+ exec: "bunx",
1635
2356
  dlx: "bunx",
1636
2357
  addGlobal: "bun add -g"
1637
2358
  }
@@ -1688,8 +2409,14 @@ WORKDIR /app
1688
2409
  # Copy source (deps already installed)
1689
2410
  COPY . .
1690
2411
 
1691
- # Build production server using CLI from npm
1692
- RUN ${pm.dlx} @geekmidas/cli build --provider server --production
2412
+ # Debug: Show node_modules/.bin contents and build production server
2413
+ RUN echo "=== node_modules/.bin contents ===" && \
2414
+ ls -la node_modules/.bin/ 2>/dev/null || echo "node_modules/.bin not found" && \
2415
+ echo "=== Checking for gkm ===" && \
2416
+ which gkm 2>/dev/null || echo "gkm not in PATH" && \
2417
+ ls -la node_modules/.bin/gkm 2>/dev/null || echo "gkm binary not found in node_modules/.bin" && \
2418
+ echo "=== Running build ===" && \
2419
+ ./node_modules/.bin/gkm build --provider server --production
1693
2420
 
1694
2421
  # Stage 3: Production
1695
2422
  FROM ${baseImage} AS runner
@@ -1770,8 +2497,14 @@ WORKDIR /app
1770
2497
  # Copy pruned source
1771
2498
  COPY --from=pruner /app/out/full/ ./
1772
2499
 
1773
- # Build production server using CLI from npm
1774
- RUN ${pm.dlx} @geekmidas/cli build --provider server --production
2500
+ # Debug: Show node_modules/.bin contents and build production server
2501
+ RUN echo "=== node_modules/.bin contents ===" && \
2502
+ ls -la node_modules/.bin/ 2>/dev/null || echo "node_modules/.bin not found" && \
2503
+ echo "=== Checking for gkm ===" && \
2504
+ which gkm 2>/dev/null || echo "gkm not in PATH" && \
2505
+ ls -la node_modules/.bin/gkm 2>/dev/null || echo "gkm binary not found in node_modules/.bin" && \
2506
+ echo "=== Running build ===" && \
2507
+ ./node_modules/.bin/gkm build --provider server --production
1775
2508
 
1776
2509
  # Stage 4: Production
1777
2510
  FROM ${baseImage} AS runner
@@ -1913,8 +2646,8 @@ function resolveDockerConfig$1(config) {
1913
2646
  const docker = config.docker ?? {};
1914
2647
  let defaultImageName = "api";
1915
2648
  try {
1916
- const pkg = require(`${process.cwd()}/package.json`);
1917
- if (pkg.name) defaultImageName = pkg.name.replace(/^@[^/]+\//, "");
2649
+ const pkg$1 = require(`${process.cwd()}/package.json`);
2650
+ if (pkg$1.name) defaultImageName = pkg$1.name.replace(/^@[^/]+\//, "");
1918
2651
  } catch {}
1919
2652
  return {
1920
2653
  registry: docker.registry ?? "",
@@ -1924,20 +2657,194 @@ function resolveDockerConfig$1(config) {
1924
2657
  compose: docker.compose
1925
2658
  };
1926
2659
  }
1927
-
1928
- //#endregion
1929
- //#region src/docker/index.ts
1930
- const logger$5 = console;
1931
2660
  /**
1932
- * Docker command implementation
1933
- * Generates Dockerfile, docker-compose.yml, and related files
1934
- *
1935
- * Default: Multi-stage Dockerfile that builds from source inside Docker
1936
- * --slim: Slim Dockerfile that copies pre-built bundle (requires prior build)
2661
+ * Generate a Dockerfile for Next.js frontend apps using standalone output.
2662
+ * Uses turbo prune for monorepo optimization.
2663
+ * @internal Exported for testing
1937
2664
  */
1938
- async function dockerCommand(options) {
1939
- const config = await require_config.loadConfig();
1940
- const dockerConfig = resolveDockerConfig$1(config);
2665
+ function generateNextjsDockerfile(options) {
2666
+ const { baseImage, port, appPath, turboPackage, packageManager } = options;
2667
+ const pm = getPmConfig(packageManager);
2668
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
2669
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
2670
+ const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
2671
+ return `# syntax=docker/dockerfile:1
2672
+ # Next.js standalone Dockerfile with turbo prune optimization
2673
+
2674
+ # Stage 1: Prune monorepo
2675
+ FROM ${baseImage} AS pruner
2676
+
2677
+ WORKDIR /app
2678
+
2679
+ ${installPm}
2680
+
2681
+ COPY . .
2682
+
2683
+ # Prune to only include necessary packages
2684
+ RUN ${turboCmd} prune ${turboPackage} --docker
2685
+
2686
+ # Stage 2: Install dependencies
2687
+ FROM ${baseImage} AS deps
2688
+
2689
+ WORKDIR /app
2690
+
2691
+ ${installPm}
2692
+
2693
+ # Copy pruned lockfile and package.jsons
2694
+ COPY --from=pruner /app/out/${pm.lockfile} ./
2695
+ COPY --from=pruner /app/out/json/ ./
2696
+
2697
+ # Install dependencies
2698
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
2699
+ ${turboInstallCmd}
2700
+
2701
+ # Stage 3: Build
2702
+ FROM deps AS builder
2703
+
2704
+ WORKDIR /app
2705
+
2706
+ # Copy pruned source
2707
+ COPY --from=pruner /app/out/full/ ./
2708
+
2709
+ # Set Next.js to produce standalone output
2710
+ ENV NEXT_TELEMETRY_DISABLED=1
2711
+
2712
+ # Build the application
2713
+ RUN ${turboCmd} run build --filter=${turboPackage}
2714
+
2715
+ # Stage 4: Production
2716
+ FROM ${baseImage} AS runner
2717
+
2718
+ WORKDIR /app
2719
+
2720
+ # Install tini for proper signal handling
2721
+ RUN apk add --no-cache tini
2722
+
2723
+ # Create non-root user
2724
+ RUN addgroup --system --gid 1001 nodejs && \\
2725
+ adduser --system --uid 1001 nextjs
2726
+
2727
+ # Set environment
2728
+ ENV NODE_ENV=production
2729
+ ENV NEXT_TELEMETRY_DISABLED=1
2730
+ ENV PORT=${port}
2731
+ ENV HOSTNAME="0.0.0.0"
2732
+
2733
+ # Copy static files and standalone output
2734
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
2735
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
2736
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
2737
+
2738
+ # Health check
2739
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\
2740
+ CMD wget -q --spider http://localhost:${port}/ || exit 1
2741
+
2742
+ USER nextjs
2743
+
2744
+ EXPOSE ${port}
2745
+
2746
+ ENTRYPOINT ["/sbin/tini", "--"]
2747
+ CMD ["node", "${appPath}/server.js"]
2748
+ `;
2749
+ }
2750
+ /**
2751
+ * Generate a Dockerfile for backend apps in a workspace.
2752
+ * Uses turbo prune for monorepo optimization.
2753
+ * @internal Exported for testing
2754
+ */
2755
+ function generateBackendDockerfile(options) {
2756
+ const { baseImage, port, appPath, turboPackage, packageManager, healthCheckPath = "/health" } = options;
2757
+ const pm = getPmConfig(packageManager);
2758
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
2759
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
2760
+ const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
2761
+ return `# syntax=docker/dockerfile:1
2762
+ # Backend Dockerfile with turbo prune optimization
2763
+
2764
+ # Stage 1: Prune monorepo
2765
+ FROM ${baseImage} AS pruner
2766
+
2767
+ WORKDIR /app
2768
+
2769
+ ${installPm}
2770
+
2771
+ COPY . .
2772
+
2773
+ # Prune to only include necessary packages
2774
+ RUN ${turboCmd} prune ${turboPackage} --docker
2775
+
2776
+ # Stage 2: Install dependencies
2777
+ FROM ${baseImage} AS deps
2778
+
2779
+ WORKDIR /app
2780
+
2781
+ ${installPm}
2782
+
2783
+ # Copy pruned lockfile and package.jsons
2784
+ COPY --from=pruner /app/out/${pm.lockfile} ./
2785
+ COPY --from=pruner /app/out/json/ ./
2786
+
2787
+ # Install dependencies
2788
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
2789
+ ${turboInstallCmd}
2790
+
2791
+ # Stage 3: Build
2792
+ FROM deps AS builder
2793
+
2794
+ WORKDIR /app
2795
+
2796
+ # Copy pruned source
2797
+ COPY --from=pruner /app/out/full/ ./
2798
+
2799
+ # Build production server using gkm
2800
+ RUN cd ${appPath} && ./node_modules/.bin/gkm build --provider server --production
2801
+
2802
+ # Stage 4: Production
2803
+ FROM ${baseImage} AS runner
2804
+
2805
+ WORKDIR /app
2806
+
2807
+ RUN apk add --no-cache tini
2808
+
2809
+ RUN addgroup --system --gid 1001 nodejs && \\
2810
+ adduser --system --uid 1001 hono
2811
+
2812
+ # Copy bundled server
2813
+ COPY --from=builder --chown=hono:nodejs /app/${appPath}/.gkm/server/dist/server.mjs ./
2814
+
2815
+ ENV NODE_ENV=production
2816
+ ENV PORT=${port}
2817
+
2818
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
2819
+ CMD wget -q --spider http://localhost:${port}${healthCheckPath} || exit 1
2820
+
2821
+ USER hono
2822
+
2823
+ EXPOSE ${port}
2824
+
2825
+ ENTRYPOINT ["/sbin/tini", "--"]
2826
+ CMD ["node", "server.mjs"]
2827
+ `;
2828
+ }
2829
+
2830
+ //#endregion
2831
+ //#region src/docker/index.ts
2832
+ const logger$5 = console;
2833
+ /**
2834
+ * Docker command implementation
2835
+ * Generates Dockerfile, docker-compose.yml, and related files
2836
+ *
2837
+ * Default: Multi-stage Dockerfile that builds from source inside Docker
2838
+ * --slim: Slim Dockerfile that copies pre-built bundle (requires prior build)
2839
+ */
2840
+ async function dockerCommand(options) {
2841
+ const loadedConfig = await require_config.loadWorkspaceConfig();
2842
+ if (loadedConfig.type === "workspace") {
2843
+ logger$5.log("📦 Detected workspace configuration");
2844
+ return workspaceDockerCommand(loadedConfig.workspace, options);
2845
+ }
2846
+ const config = await require_config.loadConfig();
2847
+ const dockerConfig = resolveDockerConfig$1(config);
1941
2848
  const serverConfig = typeof config.providers?.server === "object" ? config.providers.server : void 0;
1942
2849
  const healthCheckPath = serverConfig?.production?.healthCheck ?? "/health";
1943
2850
  const useSlim = options.slim === true;
@@ -1958,9 +2865,9 @@ async function dockerCommand(options) {
1958
2865
  } else throw new Error("Monorepo detected but turbo.json not found.\n\nDocker builds in monorepos require Turborepo for proper dependency isolation.\n\nTo fix this:\n 1. Install turbo: pnpm add -Dw turbo\n 2. Create turbo.json in your monorepo root\n 3. Run this command again\n\nSee: https://turbo.build/repo/docs/guides/tools/docker");
1959
2866
  let turboPackage = options.turboPackage ?? dockerConfig.imageName;
1960
2867
  if (useTurbo && !options.turboPackage) try {
1961
- const pkg = require(`${process.cwd()}/package.json`);
1962
- if (pkg.name) {
1963
- turboPackage = pkg.name;
2868
+ const pkg$1 = require(`${process.cwd()}/package.json`);
2869
+ if (pkg$1.name) {
2870
+ turboPackage = pkg$1.name;
1964
2871
  logger$5.log(` Turbo package: ${turboPackage}`);
1965
2872
  }
1966
2873
  } catch {}
@@ -2077,6 +2984,85 @@ async function pushDockerImage(imageName, options) {
2077
2984
  throw new Error(`Failed to push Docker image: ${error instanceof Error ? error.message : "Unknown error"}`);
2078
2985
  }
2079
2986
  }
2987
+ /**
2988
+ * Get the package name from package.json in an app directory.
2989
+ */
2990
+ function getAppPackageName(appPath) {
2991
+ try {
2992
+ const pkg$1 = require(`${appPath}/package.json`);
2993
+ return pkg$1.name;
2994
+ } catch {
2995
+ return void 0;
2996
+ }
2997
+ }
2998
+ /**
2999
+ * Generate Dockerfiles for all apps in a workspace.
3000
+ * @internal Exported for testing
3001
+ */
3002
+ async function workspaceDockerCommand(workspace, options) {
3003
+ const results = [];
3004
+ const apps = Object.entries(workspace.apps);
3005
+ logger$5.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
3006
+ const dockerDir = (0, node_path.join)(workspace.root, ".gkm", "docker");
3007
+ await (0, node_fs_promises.mkdir)(dockerDir, { recursive: true });
3008
+ const packageManager = detectPackageManager$1(workspace.root);
3009
+ logger$5.log(` Package manager: ${packageManager}`);
3010
+ for (const [appName, app] of apps) {
3011
+ const appPath = app.path;
3012
+ const fullAppPath = (0, node_path.join)(workspace.root, appPath);
3013
+ const turboPackage = getAppPackageName(fullAppPath) ?? appName;
3014
+ const imageName = appName;
3015
+ logger$5.log(`\n 📄 Generating Dockerfile for ${appName} (${app.type})`);
3016
+ let dockerfile;
3017
+ if (app.type === "frontend") dockerfile = generateNextjsDockerfile({
3018
+ imageName,
3019
+ baseImage: "node:22-alpine",
3020
+ port: app.port,
3021
+ appPath,
3022
+ turboPackage,
3023
+ packageManager
3024
+ });
3025
+ else dockerfile = generateBackendDockerfile({
3026
+ imageName,
3027
+ baseImage: "node:22-alpine",
3028
+ port: app.port,
3029
+ appPath,
3030
+ turboPackage,
3031
+ packageManager,
3032
+ healthCheckPath: "/health"
3033
+ });
3034
+ const dockerfilePath = (0, node_path.join)(dockerDir, `Dockerfile.${appName}`);
3035
+ await (0, node_fs_promises.writeFile)(dockerfilePath, dockerfile);
3036
+ logger$5.log(` Generated: .gkm/docker/Dockerfile.${appName}`);
3037
+ results.push({
3038
+ appName,
3039
+ type: app.type,
3040
+ dockerfile: dockerfilePath,
3041
+ imageName
3042
+ });
3043
+ }
3044
+ const dockerignore = generateDockerignore();
3045
+ const dockerignorePath = (0, node_path.join)(workspace.root, ".dockerignore");
3046
+ await (0, node_fs_promises.writeFile)(dockerignorePath, dockerignore);
3047
+ logger$5.log(`\n Generated: .dockerignore (workspace root)`);
3048
+ const dockerCompose = generateWorkspaceCompose(workspace, { registry: options.registry });
3049
+ const composePath = (0, node_path.join)(dockerDir, "docker-compose.yml");
3050
+ await (0, node_fs_promises.writeFile)(composePath, dockerCompose);
3051
+ logger$5.log(` Generated: .gkm/docker/docker-compose.yml`);
3052
+ logger$5.log(`\n✅ Generated ${results.length} Dockerfile(s) + docker-compose.yml`);
3053
+ logger$5.log("\n📋 Build commands:");
3054
+ for (const result of results) {
3055
+ const icon = result.type === "backend" ? "⚙️" : "🌐";
3056
+ logger$5.log(` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`);
3057
+ }
3058
+ logger$5.log("\n📋 Run all services:");
3059
+ logger$5.log(" docker compose -f .gkm/docker/docker-compose.yml up --build");
3060
+ return {
3061
+ apps: results,
3062
+ dockerCompose: composePath,
3063
+ dockerignore: dockerignorePath
3064
+ };
3065
+ }
2080
3066
 
2081
3067
  //#endregion
2082
3068
  //#region src/deploy/docker.ts
@@ -2088,8 +3074,8 @@ function getAppNameFromCwd() {
2088
3074
  const packageJsonPath = (0, node_path.join)(process.cwd(), "package.json");
2089
3075
  if (!(0, node_fs.existsSync)(packageJsonPath)) return void 0;
2090
3076
  try {
2091
- const pkg = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
2092
- if (pkg.name) return pkg.name.replace(/^@[^/]+\//, "");
3077
+ const pkg$1 = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
3078
+ if (pkg$1.name) return pkg$1.name.replace(/^@[^/]+\//, "");
2093
3079
  } catch {}
2094
3080
  return void 0;
2095
3081
  }
@@ -2105,8 +3091,8 @@ function getAppNameFromPackageJson() {
2105
3091
  const packageJsonPath = (0, node_path.join)(projectRoot, "package.json");
2106
3092
  if (!(0, node_fs.existsSync)(packageJsonPath)) return void 0;
2107
3093
  try {
2108
- const pkg = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
2109
- if (pkg.name) return pkg.name.replace(/^@[^/]+\//, "");
3094
+ const pkg$1 = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
3095
+ if (pkg$1.name) return pkg$1.name.replace(/^@[^/]+\//, "");
2110
3096
  } catch {}
2111
3097
  return void 0;
2112
3098
  }
@@ -2579,7 +3565,7 @@ async function provisionServices(api, projectId, environmentId, appName, service
2579
3565
  */
2580
3566
  async function ensureDokploySetup(config, dockerConfig, stage, services) {
2581
3567
  logger$1.log("\n🔧 Checking Dokploy setup...");
2582
- const { readStageSecrets: readStageSecrets$1 } = await Promise.resolve().then(() => require("./storage-UfyTn7Zm.cjs"));
3568
+ const { readStageSecrets: readStageSecrets$1 } = await Promise.resolve().then(() => require("./storage-fOR8dMu5.cjs"));
2583
3569
  const existingSecrets = await readStageSecrets$1(stage);
2584
3570
  const existingUrls = {
2585
3571
  DATABASE_URL: existingSecrets?.urls?.DATABASE_URL,
@@ -2757,94 +3743,335 @@ function generateTag(stage) {
2757
3743
  return `${stage}-${timestamp}`;
2758
3744
  }
2759
3745
  /**
2760
- * Main deploy command
3746
+ * Deploy all apps in a workspace to Dokploy.
3747
+ * - Workspace maps to one Dokploy project
3748
+ * - Each app maps to one Dokploy application
3749
+ * - Deploys in dependency order (backends before dependent frontends)
3750
+ * - Syncs environment variables including {APP_NAME}_URL
3751
+ * @internal Exported for testing
2761
3752
  */
2762
- async function deployCommand(options) {
2763
- const { provider, stage, tag, skipPush, skipBuild } = options;
2764
- logger$1.log(`\n🚀 Deploying to ${provider}...`);
3753
+ async function workspaceDeployCommand(workspace, options) {
3754
+ const { provider, stage, tag, skipBuild, apps: selectedApps } = options;
3755
+ if (provider !== "dokploy") throw new Error(`Workspace deployment only supports Dokploy. Got: ${provider}`);
3756
+ logger$1.log(`\n🚀 Deploying workspace "${workspace.name}" to Dokploy...`);
2765
3757
  logger$1.log(` Stage: ${stage}`);
2766
- const config = await require_config.loadConfig();
2767
3758
  const imageTag = tag ?? generateTag(stage);
2768
3759
  logger$1.log(` Tag: ${imageTag}`);
2769
- const dockerConfig = resolveDockerConfig(config);
2770
- const imageName = dockerConfig.imageName;
2771
- const registry = dockerConfig.registry;
2772
- const imageRef = registry ? `${registry}/${imageName}:${imageTag}` : `${imageName}:${imageTag}`;
2773
- let dokployConfig;
2774
- let finalRegistry = registry;
2775
- if (provider === "dokploy") {
2776
- const composeServices = config.docker?.compose?.services;
2777
- logger$1.log(`\n🔍 Docker compose config: ${JSON.stringify(config.docker?.compose)}`);
2778
- const dockerServices = composeServices ? Array.isArray(composeServices) ? {
2779
- postgres: composeServices.includes("postgres"),
2780
- redis: composeServices.includes("redis"),
2781
- rabbitmq: composeServices.includes("rabbitmq")
2782
- } : {
2783
- postgres: Boolean(composeServices.postgres),
2784
- redis: Boolean(composeServices.redis),
2785
- rabbitmq: Boolean(composeServices.rabbitmq)
2786
- } : void 0;
2787
- const setupResult = await ensureDokploySetup(config, dockerConfig, stage, dockerServices);
2788
- dokployConfig = setupResult.config;
2789
- finalRegistry = dokployConfig.registry ?? dockerConfig.registry;
2790
- if (setupResult.serviceUrls) {
2791
- const { readStageSecrets: readStageSecrets$1, writeStageSecrets: writeStageSecrets$1, initStageSecrets } = await Promise.resolve().then(() => require("./storage-UfyTn7Zm.cjs"));
2792
- let secrets = await readStageSecrets$1(stage);
2793
- if (!secrets) {
2794
- logger$1.log(` Creating secrets file for stage "${stage}"...`);
2795
- secrets = initStageSecrets(stage);
2796
- }
2797
- let updated = false;
2798
- const urlFields = [
2799
- "DATABASE_URL",
2800
- "REDIS_URL",
2801
- "RABBITMQ_URL"
2802
- ];
2803
- for (const [key, value] of Object.entries(setupResult.serviceUrls)) {
2804
- if (!value) continue;
2805
- if (urlFields.includes(key)) {
2806
- const urlKey = key;
2807
- if (!secrets.urls[urlKey]) {
2808
- secrets.urls[urlKey] = value;
2809
- logger$1.log(` Saved ${key} to secrets.urls`);
2810
- updated = true;
2811
- }
2812
- } else if (!secrets.custom[key]) {
2813
- secrets.custom[key] = value;
2814
- logger$1.log(` Saved ${key} to secrets.custom`);
2815
- updated = true;
2816
- }
2817
- }
2818
- if (updated) await writeStageSecrets$1(secrets);
3760
+ const buildOrder = require_workspace.getAppBuildOrder(workspace);
3761
+ let appsToDeployNames = buildOrder;
3762
+ if (selectedApps && selectedApps.length > 0) {
3763
+ const invalidApps = selectedApps.filter((name$1) => !workspace.apps[name$1]);
3764
+ if (invalidApps.length > 0) throw new Error(`Unknown apps: ${invalidApps.join(", ")}\nAvailable apps: ${Object.keys(workspace.apps).join(", ")}`);
3765
+ appsToDeployNames = buildOrder.filter((name$1) => selectedApps.includes(name$1));
3766
+ logger$1.log(` Deploying apps: ${appsToDeployNames.join(", ")}`);
3767
+ } else logger$1.log(` Deploying all apps: ${appsToDeployNames.join(", ")}`);
3768
+ const dokployApps = appsToDeployNames.filter((name$1) => {
3769
+ const app = workspace.apps[name$1];
3770
+ const target = app.resolvedDeployTarget;
3771
+ if (!require_workspace.isDeployTargetSupported(target)) {
3772
+ logger$1.log(` ⚠️ Skipping ${name$1}: ${require_workspace.getDeployTargetError(target, name$1)}`);
3773
+ return false;
2819
3774
  }
3775
+ return true;
3776
+ });
3777
+ if (dokployApps.length === 0) throw new Error("No apps to deploy. All selected apps have unsupported deploy targets.");
3778
+ if (dokployApps.length !== appsToDeployNames.length) {
3779
+ const skipped = appsToDeployNames.filter((name$1) => !dokployApps.includes(name$1));
3780
+ logger$1.log(` 📌 ${skipped.length} app(s) skipped due to unsupported targets`);
2820
3781
  }
2821
- let masterKey;
2822
- if (!skipBuild) {
2823
- logger$1.log(`\n📦 Building for production...`);
2824
- const buildResult = await buildCommand({
2825
- provider: "server",
2826
- production: true,
2827
- stage
2828
- });
2829
- masterKey = buildResult.masterKey;
2830
- } else logger$1.log(`\n⏭️ Skipping build (--skip-build)`);
2831
- let result;
2832
- switch (provider) {
2833
- case "docker": {
2834
- result = await deployDocker({
2835
- stage,
2836
- tag: imageTag,
2837
- skipPush,
2838
- masterKey,
2839
- config: dockerConfig
2840
- });
2841
- break;
2842
- }
2843
- case "dokploy": {
2844
- if (!dokployConfig) throw new Error("Dokploy config not initialized");
2845
- const finalImageRef = finalRegistry ? `${finalRegistry}/${imageName}:${imageTag}` : `${imageName}:${imageTag}`;
2846
- await deployDocker({
2847
- stage,
3782
+ appsToDeployNames = dokployApps;
3783
+ let creds = await getDokployCredentials();
3784
+ if (!creds) {
3785
+ logger$1.log("\n📋 Dokploy credentials not found. Let's set them up.");
3786
+ const endpoint = await prompt("Dokploy URL (e.g., https://dokploy.example.com): ");
3787
+ const normalizedEndpoint = endpoint.replace(/\/$/, "");
3788
+ try {
3789
+ new URL(normalizedEndpoint);
3790
+ } catch {
3791
+ throw new Error("Invalid URL format");
3792
+ }
3793
+ logger$1.log(`\nGenerate a token at: ${normalizedEndpoint}/settings/profile\n`);
3794
+ const token = await prompt("API Token: ", true);
3795
+ logger$1.log("\nValidating credentials...");
3796
+ const isValid = await validateDokployToken(normalizedEndpoint, token);
3797
+ if (!isValid) throw new Error("Invalid credentials. Please check your token.");
3798
+ await storeDokployCredentials(token, normalizedEndpoint);
3799
+ creds = {
3800
+ token,
3801
+ endpoint: normalizedEndpoint
3802
+ };
3803
+ logger$1.log("✓ Credentials saved");
3804
+ }
3805
+ const api = new require_dokploy_api.DokployApi({
3806
+ baseUrl: creds.endpoint,
3807
+ token: creds.token
3808
+ });
3809
+ logger$1.log("\n📁 Setting up Dokploy project...");
3810
+ const projectName = workspace.name;
3811
+ const projects = await api.listProjects();
3812
+ let project = projects.find((p) => p.name.toLowerCase() === projectName.toLowerCase());
3813
+ let environmentId;
3814
+ if (project) {
3815
+ logger$1.log(` Found existing project: ${project.name}`);
3816
+ const projectDetails = await api.getProject(project.projectId);
3817
+ const environments = projectDetails.environments ?? [];
3818
+ const matchingEnv = environments.find((e) => e.name.toLowerCase() === stage.toLowerCase());
3819
+ if (matchingEnv) {
3820
+ environmentId = matchingEnv.environmentId;
3821
+ logger$1.log(` Using environment: ${matchingEnv.name}`);
3822
+ } else {
3823
+ logger$1.log(` Creating "${stage}" environment...`);
3824
+ const env = await api.createEnvironment(project.projectId, stage);
3825
+ environmentId = env.environmentId;
3826
+ logger$1.log(` ✓ Created environment: ${stage}`);
3827
+ }
3828
+ } else {
3829
+ logger$1.log(` Creating project: ${projectName}`);
3830
+ const result = await api.createProject(projectName);
3831
+ project = result.project;
3832
+ if (result.environment.name.toLowerCase() !== stage.toLowerCase()) {
3833
+ logger$1.log(` Creating "${stage}" environment...`);
3834
+ const env = await api.createEnvironment(project.projectId, stage);
3835
+ environmentId = env.environmentId;
3836
+ } else environmentId = result.environment.environmentId;
3837
+ logger$1.log(` ✓ Created project: ${project.projectId}`);
3838
+ }
3839
+ logger$1.log("\n🐳 Checking registry...");
3840
+ let registryId = await getDokployRegistryId();
3841
+ const registry = workspace.deploy.dokploy?.registry;
3842
+ if (registryId) try {
3843
+ const reg = await api.getRegistry(registryId);
3844
+ logger$1.log(` Using registry: ${reg.registryName}`);
3845
+ } catch {
3846
+ logger$1.log(" ⚠ Stored registry not found, clearing...");
3847
+ registryId = void 0;
3848
+ await storeDokployRegistryId("");
3849
+ }
3850
+ if (!registryId) {
3851
+ const registries = await api.listRegistries();
3852
+ if (registries.length > 0) {
3853
+ registryId = registries[0].registryId;
3854
+ await storeDokployRegistryId(registryId);
3855
+ logger$1.log(` Using registry: ${registries[0].registryName}`);
3856
+ } else if (registry) {
3857
+ logger$1.log(" No registries found in Dokploy. Let's create one.");
3858
+ logger$1.log(` Registry URL: ${registry}`);
3859
+ const username = await prompt("Registry username: ");
3860
+ const password = await prompt("Registry password/token: ", true);
3861
+ const reg = await api.createRegistry("Default Registry", registry, username, password);
3862
+ registryId = reg.registryId;
3863
+ await storeDokployRegistryId(registryId);
3864
+ logger$1.log(` ✓ Registry created: ${registryId}`);
3865
+ } else logger$1.log(" ⚠ No registry configured. Set deploy.dokploy.registry in workspace config");
3866
+ }
3867
+ const services = workspace.services;
3868
+ const dockerServices = {
3869
+ postgres: services.db !== void 0 && services.db !== false,
3870
+ redis: services.cache !== void 0 && services.cache !== false
3871
+ };
3872
+ if (dockerServices.postgres || dockerServices.redis) {
3873
+ logger$1.log("\n🔧 Provisioning infrastructure services...");
3874
+ await provisionServices(api, project.projectId, environmentId, workspace.name, dockerServices);
3875
+ }
3876
+ const deployedAppUrls = {};
3877
+ logger$1.log("\n📦 Deploying applications...");
3878
+ const results = [];
3879
+ for (const appName of appsToDeployNames) {
3880
+ const app = workspace.apps[appName];
3881
+ const appPath = app.path;
3882
+ logger$1.log(`\n ${app.type === "backend" ? "⚙️" : "🌐"} Deploying ${appName}...`);
3883
+ try {
3884
+ const dokployAppName = `${workspace.name}-${appName}`;
3885
+ let application;
3886
+ try {
3887
+ application = await api.createApplication(dokployAppName, project.projectId, environmentId);
3888
+ logger$1.log(` Created application: ${application.applicationId}`);
3889
+ } catch (error) {
3890
+ const message = error instanceof Error ? error.message : "Unknown error";
3891
+ if (message.includes("already exists") || message.includes("duplicate")) logger$1.log(` Application already exists`);
3892
+ else throw error;
3893
+ }
3894
+ if (!skipBuild) {
3895
+ logger$1.log(` Building ${appName}...`);
3896
+ const originalCwd = process.cwd();
3897
+ const fullAppPath = `${workspace.root}/${appPath}`;
3898
+ try {
3899
+ process.chdir(fullAppPath);
3900
+ await buildCommand({
3901
+ provider: "server",
3902
+ production: true,
3903
+ stage
3904
+ });
3905
+ } finally {
3906
+ process.chdir(originalCwd);
3907
+ }
3908
+ }
3909
+ const imageName = `${workspace.name}-${appName}`;
3910
+ const imageRef = registry ? `${registry}/${imageName}:${imageTag}` : `${imageName}:${imageTag}`;
3911
+ logger$1.log(` Building Docker image: ${imageRef}`);
3912
+ await deployDocker({
3913
+ stage,
3914
+ tag: imageTag,
3915
+ skipPush: false,
3916
+ config: {
3917
+ registry,
3918
+ imageName
3919
+ }
3920
+ });
3921
+ const envVars = [`NODE_ENV=production`, `PORT=${app.port}`];
3922
+ for (const dep of app.dependencies) {
3923
+ const depUrl = deployedAppUrls[dep];
3924
+ if (depUrl) envVars.push(`${dep.toUpperCase()}_URL=${depUrl}`);
3925
+ }
3926
+ if (app.type === "backend") {
3927
+ if (dockerServices.postgres) envVars.push(`DATABASE_URL=\${DATABASE_URL:-postgresql://postgres:postgres@${workspace.name}-db:5432/app}`);
3928
+ if (dockerServices.redis) envVars.push(`REDIS_URL=\${REDIS_URL:-redis://${workspace.name}-cache:6379}`);
3929
+ }
3930
+ if (application) {
3931
+ await api.saveDockerProvider(application.applicationId, imageRef, { registryId });
3932
+ await api.saveApplicationEnv(application.applicationId, envVars.join("\n"));
3933
+ logger$1.log(` Deploying to Dokploy...`);
3934
+ await api.deployApplication(application.applicationId);
3935
+ const appUrl = `http://${dokployAppName}:${app.port}`;
3936
+ deployedAppUrls[appName] = appUrl;
3937
+ results.push({
3938
+ appName,
3939
+ type: app.type,
3940
+ success: true,
3941
+ applicationId: application.applicationId,
3942
+ imageRef
3943
+ });
3944
+ logger$1.log(` ✓ ${appName} deployed successfully`);
3945
+ } else {
3946
+ const appUrl = `http://${dokployAppName}:${app.port}`;
3947
+ deployedAppUrls[appName] = appUrl;
3948
+ results.push({
3949
+ appName,
3950
+ type: app.type,
3951
+ success: true,
3952
+ imageRef
3953
+ });
3954
+ logger$1.log(` ✓ ${appName} image pushed (app already exists)`);
3955
+ }
3956
+ } catch (error) {
3957
+ const message = error instanceof Error ? error.message : "Unknown error";
3958
+ logger$1.log(` ✗ Failed to deploy ${appName}: ${message}`);
3959
+ results.push({
3960
+ appName,
3961
+ type: app.type,
3962
+ success: false,
3963
+ error: message
3964
+ });
3965
+ }
3966
+ }
3967
+ const successCount = results.filter((r) => r.success).length;
3968
+ const failedCount = results.filter((r) => !r.success).length;
3969
+ logger$1.log(`\n${"─".repeat(50)}`);
3970
+ logger$1.log(`\n✅ Workspace deployment complete!`);
3971
+ logger$1.log(` Project: ${project.projectId}`);
3972
+ logger$1.log(` Successful: ${successCount}`);
3973
+ if (failedCount > 0) logger$1.log(` Failed: ${failedCount}`);
3974
+ return {
3975
+ apps: results,
3976
+ projectId: project.projectId,
3977
+ successCount,
3978
+ failedCount
3979
+ };
3980
+ }
3981
+ /**
3982
+ * Main deploy command
3983
+ */
3984
+ async function deployCommand(options) {
3985
+ const { provider, stage, tag, skipPush, skipBuild } = options;
3986
+ const loadedConfig = await require_config.loadWorkspaceConfig();
3987
+ if (loadedConfig.type === "workspace") {
3988
+ logger$1.log("📦 Detected workspace configuration");
3989
+ return workspaceDeployCommand(loadedConfig.workspace, options);
3990
+ }
3991
+ logger$1.log(`\n🚀 Deploying to ${provider}...`);
3992
+ logger$1.log(` Stage: ${stage}`);
3993
+ const config = await require_config.loadConfig();
3994
+ const imageTag = tag ?? generateTag(stage);
3995
+ logger$1.log(` Tag: ${imageTag}`);
3996
+ const dockerConfig = resolveDockerConfig(config);
3997
+ const imageName = dockerConfig.imageName;
3998
+ const registry = dockerConfig.registry;
3999
+ const imageRef = registry ? `${registry}/${imageName}:${imageTag}` : `${imageName}:${imageTag}`;
4000
+ let dokployConfig;
4001
+ let finalRegistry = registry;
4002
+ if (provider === "dokploy") {
4003
+ const composeServices = config.docker?.compose?.services;
4004
+ logger$1.log(`\n🔍 Docker compose config: ${JSON.stringify(config.docker?.compose)}`);
4005
+ const dockerServices = composeServices ? Array.isArray(composeServices) ? {
4006
+ postgres: composeServices.includes("postgres"),
4007
+ redis: composeServices.includes("redis"),
4008
+ rabbitmq: composeServices.includes("rabbitmq")
4009
+ } : {
4010
+ postgres: Boolean(composeServices.postgres),
4011
+ redis: Boolean(composeServices.redis),
4012
+ rabbitmq: Boolean(composeServices.rabbitmq)
4013
+ } : void 0;
4014
+ const setupResult = await ensureDokploySetup(config, dockerConfig, stage, dockerServices);
4015
+ dokployConfig = setupResult.config;
4016
+ finalRegistry = dokployConfig.registry ?? dockerConfig.registry;
4017
+ if (setupResult.serviceUrls) {
4018
+ const { readStageSecrets: readStageSecrets$1, writeStageSecrets: writeStageSecrets$1, initStageSecrets } = await Promise.resolve().then(() => require("./storage-fOR8dMu5.cjs"));
4019
+ let secrets = await readStageSecrets$1(stage);
4020
+ if (!secrets) {
4021
+ logger$1.log(` Creating secrets file for stage "${stage}"...`);
4022
+ secrets = initStageSecrets(stage);
4023
+ }
4024
+ let updated = false;
4025
+ const urlFields = [
4026
+ "DATABASE_URL",
4027
+ "REDIS_URL",
4028
+ "RABBITMQ_URL"
4029
+ ];
4030
+ for (const [key, value] of Object.entries(setupResult.serviceUrls)) {
4031
+ if (!value) continue;
4032
+ if (urlFields.includes(key)) {
4033
+ const urlKey = key;
4034
+ if (!secrets.urls[urlKey]) {
4035
+ secrets.urls[urlKey] = value;
4036
+ logger$1.log(` Saved ${key} to secrets.urls`);
4037
+ updated = true;
4038
+ }
4039
+ } else if (!secrets.custom[key]) {
4040
+ secrets.custom[key] = value;
4041
+ logger$1.log(` Saved ${key} to secrets.custom`);
4042
+ updated = true;
4043
+ }
4044
+ }
4045
+ if (updated) await writeStageSecrets$1(secrets);
4046
+ }
4047
+ }
4048
+ let masterKey;
4049
+ if (!skipBuild) {
4050
+ logger$1.log(`\n📦 Building for production...`);
4051
+ const buildResult = await buildCommand({
4052
+ provider: "server",
4053
+ production: true,
4054
+ stage
4055
+ });
4056
+ masterKey = buildResult.masterKey;
4057
+ } else logger$1.log(`\n⏭️ Skipping build (--skip-build)`);
4058
+ let result;
4059
+ switch (provider) {
4060
+ case "docker": {
4061
+ result = await deployDocker({
4062
+ stage,
4063
+ tag: imageTag,
4064
+ skipPush,
4065
+ masterKey,
4066
+ config: dockerConfig
4067
+ });
4068
+ break;
4069
+ }
4070
+ case "dokploy": {
4071
+ if (!dokployConfig) throw new Error("Dokploy config not initialized");
4072
+ const finalImageRef = finalRegistry ? `${finalRegistry}/${imageName}:${imageTag}` : `${imageName}:${imageTag}`;
4073
+ await deployDocker({
4074
+ stage,
2848
4075
  tag: imageTag,
2849
4076
  skipPush: false,
2850
4077
  masterKey,
@@ -2871,10 +4098,361 @@ async function deployCommand(options) {
2871
4098
  };
2872
4099
  break;
2873
4100
  }
2874
- default: throw new Error(`Unknown deploy provider: ${provider}\nSupported providers: docker, dokploy, aws-lambda`);
2875
- }
2876
- logger$1.log("\n✅ Deployment complete!");
2877
- return result;
4101
+ default: throw new Error(`Unknown deploy provider: ${provider}\nSupported providers: docker, dokploy, aws-lambda`);
4102
+ }
4103
+ logger$1.log("\n✅ Deployment complete!");
4104
+ return result;
4105
+ }
4106
+
4107
+ //#endregion
4108
+ //#region src/secrets/generator.ts
4109
+ /**
4110
+ * Generate a secure random password using URL-safe base64 characters.
4111
+ * @param length Password length (default: 32)
4112
+ */
4113
+ function generateSecurePassword(length = 32) {
4114
+ return (0, node_crypto.randomBytes)(Math.ceil(length * 3 / 4)).toString("base64url").slice(0, length);
4115
+ }
4116
+ /** Default service configurations */
4117
+ const SERVICE_DEFAULTS = {
4118
+ postgres: {
4119
+ host: "postgres",
4120
+ port: 5432,
4121
+ username: "app",
4122
+ database: "app"
4123
+ },
4124
+ redis: {
4125
+ host: "redis",
4126
+ port: 6379,
4127
+ username: "default"
4128
+ },
4129
+ rabbitmq: {
4130
+ host: "rabbitmq",
4131
+ port: 5672,
4132
+ username: "app",
4133
+ vhost: "/"
4134
+ }
4135
+ };
4136
+ /**
4137
+ * Generate credentials for a specific service.
4138
+ */
4139
+ function generateServiceCredentials(service) {
4140
+ const defaults = SERVICE_DEFAULTS[service];
4141
+ return {
4142
+ ...defaults,
4143
+ password: generateSecurePassword()
4144
+ };
4145
+ }
4146
+ /**
4147
+ * Generate credentials for multiple services.
4148
+ */
4149
+ function generateServicesCredentials(services) {
4150
+ const result = {};
4151
+ for (const service of services) result[service] = generateServiceCredentials(service);
4152
+ return result;
4153
+ }
4154
+ /**
4155
+ * Generate connection URL for PostgreSQL.
4156
+ */
4157
+ function generatePostgresUrl(creds) {
4158
+ const { username, password, host, port, database } = creds;
4159
+ return `postgresql://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}`;
4160
+ }
4161
+ /**
4162
+ * Generate connection URL for Redis.
4163
+ */
4164
+ function generateRedisUrl(creds) {
4165
+ const { password, host, port } = creds;
4166
+ return `redis://:${encodeURIComponent(password)}@${host}:${port}`;
4167
+ }
4168
+ /**
4169
+ * Generate connection URL for RabbitMQ.
4170
+ */
4171
+ function generateRabbitmqUrl(creds) {
4172
+ const { username, password, host, port, vhost } = creds;
4173
+ const encodedVhost = encodeURIComponent(vhost ?? "/");
4174
+ return `amqp://${username}:${encodeURIComponent(password)}@${host}:${port}/${encodedVhost}`;
4175
+ }
4176
+ /**
4177
+ * Generate connection URLs from service credentials.
4178
+ */
4179
+ function generateConnectionUrls(services) {
4180
+ const urls = {};
4181
+ if (services.postgres) urls.DATABASE_URL = generatePostgresUrl(services.postgres);
4182
+ if (services.redis) urls.REDIS_URL = generateRedisUrl(services.redis);
4183
+ if (services.rabbitmq) urls.RABBITMQ_URL = generateRabbitmqUrl(services.rabbitmq);
4184
+ return urls;
4185
+ }
4186
+ /**
4187
+ * Create a new StageSecrets object with generated credentials.
4188
+ */
4189
+ function createStageSecrets(stage, services) {
4190
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4191
+ const serviceCredentials = generateServicesCredentials(services);
4192
+ const urls = generateConnectionUrls(serviceCredentials);
4193
+ return {
4194
+ stage,
4195
+ createdAt: now,
4196
+ updatedAt: now,
4197
+ services: serviceCredentials,
4198
+ urls,
4199
+ custom: {}
4200
+ };
4201
+ }
4202
+ /**
4203
+ * Rotate password for a specific service.
4204
+ */
4205
+ function rotateServicePassword(secrets, service) {
4206
+ const currentCreds = secrets.services[service];
4207
+ if (!currentCreds) throw new Error(`Service "${service}" not configured in secrets`);
4208
+ const newCreds = {
4209
+ ...currentCreds,
4210
+ password: generateSecurePassword()
4211
+ };
4212
+ const newServices = {
4213
+ ...secrets.services,
4214
+ [service]: newCreds
4215
+ };
4216
+ return {
4217
+ ...secrets,
4218
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4219
+ services: newServices,
4220
+ urls: generateConnectionUrls(newServices)
4221
+ };
4222
+ }
4223
+
4224
+ //#endregion
4225
+ //#region src/init/versions.ts
4226
+ const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
4227
+ const pkg = require$1("../package.json");
4228
+ /**
4229
+ * CLI version from package.json (used for scaffolded projects)
4230
+ */
4231
+ const CLI_VERSION = `~${pkg.version}`;
4232
+ /**
4233
+ * Current released versions of @geekmidas packages
4234
+ * Update these when publishing new versions
4235
+ * Note: CLI version is read from package.json via CLI_VERSION
4236
+ */
4237
+ const GEEKMIDAS_VERSIONS = {
4238
+ "@geekmidas/audit": "~0.2.0",
4239
+ "@geekmidas/auth": "~0.2.0",
4240
+ "@geekmidas/cache": "~0.2.0",
4241
+ "@geekmidas/cli": CLI_VERSION,
4242
+ "@geekmidas/client": "~0.5.0",
4243
+ "@geekmidas/cloud": "~0.2.0",
4244
+ "@geekmidas/constructs": "~0.6.0",
4245
+ "@geekmidas/db": "~0.3.0",
4246
+ "@geekmidas/emailkit": "~0.2.0",
4247
+ "@geekmidas/envkit": "~0.4.0",
4248
+ "@geekmidas/errors": "~0.1.0",
4249
+ "@geekmidas/events": "~0.2.0",
4250
+ "@geekmidas/logger": "~0.4.0",
4251
+ "@geekmidas/rate-limit": "~0.3.0",
4252
+ "@geekmidas/schema": "~0.1.0",
4253
+ "@geekmidas/services": "~0.2.0",
4254
+ "@geekmidas/storage": "~0.1.0",
4255
+ "@geekmidas/studio": "~0.4.0",
4256
+ "@geekmidas/telescope": "~0.4.0",
4257
+ "@geekmidas/testkit": "~0.6.0"
4258
+ };
4259
+
4260
+ //#endregion
4261
+ //#region src/init/generators/auth.ts
4262
+ /**
4263
+ * Generate auth app files for fullstack template
4264
+ * Uses better-auth with magic link authentication
4265
+ */
4266
+ function generateAuthAppFiles(options) {
4267
+ if (!options.monorepo || options.template !== "fullstack") return [];
4268
+ const packageName = `@${options.name}/auth`;
4269
+ const modelsPackage = `@${options.name}/models`;
4270
+ const packageJson = {
4271
+ name: packageName,
4272
+ version: "0.0.1",
4273
+ private: true,
4274
+ type: "module",
4275
+ scripts: {
4276
+ dev: "tsx watch src/index.ts",
4277
+ build: "tsc",
4278
+ start: "node dist/index.js",
4279
+ typecheck: "tsc --noEmit"
4280
+ },
4281
+ dependencies: {
4282
+ [modelsPackage]: "workspace:*",
4283
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
4284
+ "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
4285
+ "@hono/node-server": "~1.13.0",
4286
+ "better-auth": "~1.2.0",
4287
+ hono: "~4.8.0",
4288
+ kysely: "~0.27.0",
4289
+ pg: "~8.13.0"
4290
+ },
4291
+ devDependencies: {
4292
+ "@types/node": "~22.0.0",
4293
+ "@types/pg": "~8.11.0",
4294
+ tsx: "~4.20.0",
4295
+ typescript: "~5.8.2"
4296
+ }
4297
+ };
4298
+ const tsConfig = {
4299
+ extends: "../../tsconfig.json",
4300
+ compilerOptions: {
4301
+ noEmit: true,
4302
+ baseUrl: ".",
4303
+ paths: { [`@${options.name}/*`]: ["../../packages/*/src"] }
4304
+ },
4305
+ include: ["src/**/*.ts"],
4306
+ exclude: ["node_modules", "dist"]
4307
+ };
4308
+ const envTs = `import { Credentials } from '@geekmidas/envkit/credentials';
4309
+ import { EnvironmentParser } from '@geekmidas/envkit';
4310
+
4311
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
4312
+
4313
+ // Global config - only minimal shared values
4314
+ // Service-specific config should be parsed where needed
4315
+ export const config = envParser
4316
+ .create((get) => ({
4317
+ nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
4318
+ stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
4319
+ }))
4320
+ .parse();
4321
+ `;
4322
+ const loggerTs = `import { createLogger } from '@geekmidas/logger/${options.loggerType}';
4323
+
4324
+ export const logger = createLogger();
4325
+ `;
4326
+ const authTs = `import { betterAuth } from 'better-auth';
4327
+ import { magicLink } from 'better-auth/plugins';
4328
+ import { Pool } from 'pg';
4329
+ import { envParser } from './config/env.js';
4330
+ import { logger } from './config/logger.js';
4331
+
4332
+ // Parse auth-specific config (no defaults - values from secrets)
4333
+ const authConfig = envParser
4334
+ .create((get) => ({
4335
+ databaseUrl: get('DATABASE_URL').string(),
4336
+ baseUrl: get('BETTER_AUTH_URL').string(),
4337
+ trustedOrigins: get('BETTER_AUTH_TRUSTED_ORIGINS').string(),
4338
+ secret: get('BETTER_AUTH_SECRET').string(),
4339
+ }))
4340
+ .parse();
4341
+
4342
+ export const auth = betterAuth({
4343
+ database: new Pool({
4344
+ connectionString: authConfig.databaseUrl,
4345
+ }),
4346
+ baseURL: authConfig.baseUrl,
4347
+ trustedOrigins: authConfig.trustedOrigins.split(','),
4348
+ secret: authConfig.secret,
4349
+ plugins: [
4350
+ magicLink({
4351
+ sendMagicLink: async ({ email, url }) => {
4352
+ // TODO: Implement email sending using @geekmidas/emailkit
4353
+ // For development, log the magic link
4354
+ logger.info({ email, url }, 'Magic link generated');
4355
+ console.log('\\n================================');
4356
+ console.log('MAGIC LINK FOR:', email);
4357
+ console.log(url);
4358
+ console.log('================================\\n');
4359
+ },
4360
+ expiresIn: 300, // 5 minutes
4361
+ }),
4362
+ ],
4363
+ emailAndPassword: {
4364
+ enabled: false, // Only magic link for now
4365
+ },
4366
+ });
4367
+
4368
+ export type Auth = typeof auth;
4369
+ `;
4370
+ const indexTs = `import { Hono } from 'hono';
4371
+ import { cors } from 'hono/cors';
4372
+ import { serve } from '@hono/node-server';
4373
+ import { auth } from './auth.js';
4374
+ import { envParser } from './config/env.js';
4375
+ import { logger } from './config/logger.js';
4376
+
4377
+ // Parse server config (no defaults - values from secrets)
4378
+ const serverConfig = envParser
4379
+ .create((get) => ({
4380
+ port: get('PORT').string().transform(Number),
4381
+ trustedOrigins: get('BETTER_AUTH_TRUSTED_ORIGINS').string(),
4382
+ }))
4383
+ .parse();
4384
+
4385
+ const app = new Hono();
4386
+
4387
+ // CORS must be registered before routes
4388
+ app.use(
4389
+ '/api/auth/*',
4390
+ cors({
4391
+ origin: serverConfig.trustedOrigins.split(','),
4392
+ allowHeaders: ['Content-Type', 'Authorization'],
4393
+ allowMethods: ['POST', 'GET', 'OPTIONS'],
4394
+ credentials: true,
4395
+ }),
4396
+ );
4397
+
4398
+ // Health check endpoint
4399
+ app.get('/health', (c) => {
4400
+ return c.json({
4401
+ status: 'ok',
4402
+ service: 'auth',
4403
+ timestamp: new Date().toISOString(),
4404
+ });
4405
+ });
4406
+
4407
+ // Mount better-auth handler
4408
+ app.on(['POST', 'GET'], '/api/auth/*', (c) => {
4409
+ return auth.handler(c.req.raw);
4410
+ });
4411
+
4412
+ logger.info({ port: serverConfig.port }, 'Starting auth server');
4413
+
4414
+ serve({
4415
+ fetch: app.fetch,
4416
+ port: serverConfig.port,
4417
+ }, (info) => {
4418
+ logger.info({ port: info.port }, 'Auth server running');
4419
+ });
4420
+ `;
4421
+ const gitignore = `node_modules/
4422
+ dist/
4423
+ .env.local
4424
+ *.log
4425
+ `;
4426
+ return [
4427
+ {
4428
+ path: "apps/auth/package.json",
4429
+ content: `${JSON.stringify(packageJson, null, 2)}\n`
4430
+ },
4431
+ {
4432
+ path: "apps/auth/tsconfig.json",
4433
+ content: `${JSON.stringify(tsConfig, null, 2)}\n`
4434
+ },
4435
+ {
4436
+ path: "apps/auth/src/config/env.ts",
4437
+ content: envTs
4438
+ },
4439
+ {
4440
+ path: "apps/auth/src/config/logger.ts",
4441
+ content: loggerTs
4442
+ },
4443
+ {
4444
+ path: "apps/auth/src/auth.ts",
4445
+ content: authTs
4446
+ },
4447
+ {
4448
+ path: "apps/auth/src/index.ts",
4449
+ content: indexTs
4450
+ },
4451
+ {
4452
+ path: "apps/auth/.gitignore",
4453
+ content: gitignore
4454
+ }
4455
+ ];
2878
4456
  }
2879
4457
 
2880
4458
  //#endregion
@@ -2886,6 +4464,7 @@ function generateConfigFiles(options, template) {
2886
4464
  const { telescope, studio, routesStructure } = options;
2887
4465
  const isServerless = template.name === "serverless";
2888
4466
  const hasWorker = template.name === "worker";
4467
+ const isFullstack = options.template === "fullstack";
2889
4468
  const getRoutesGlob = () => {
2890
4469
  switch (routesStructure) {
2891
4470
  case "centralized-endpoints": return "./src/endpoints/**/*.ts";
@@ -2893,6 +4472,14 @@ function generateConfigFiles(options, template) {
2893
4472
  case "domain-based": return "./src/**/routes/*.ts";
2894
4473
  }
2895
4474
  };
4475
+ if (isFullstack) return generateSingleAppConfigFiles(options, template, {
4476
+ telescope,
4477
+ studio,
4478
+ routesStructure,
4479
+ isServerless,
4480
+ hasWorker,
4481
+ getRoutesGlob
4482
+ });
2896
4483
  let gkmConfig = `import { defineConfig } from '@geekmidas/cli/config';
2897
4484
 
2898
4485
  export default defineConfig({
@@ -2921,8 +4508,7 @@ export default defineConfig({
2921
4508
  const tsConfig = options.monorepo ? {
2922
4509
  extends: "../../tsconfig.json",
2923
4510
  compilerOptions: {
2924
- outDir: "./dist",
2925
- rootDir: "./src",
4511
+ noEmit: true,
2926
4512
  baseUrl: ".",
2927
4513
  paths: { [`@${options.name}/*`]: ["../../packages/*/src"] }
2928
4514
  },
@@ -2955,7 +4541,7 @@ export default defineConfig({
2955
4541
  content: `${JSON.stringify(tsConfig, null, 2)}\n`
2956
4542
  }];
2957
4543
  const biomeConfig = {
2958
- $schema: "https://biomejs.dev/schemas/1.9.4/schema.json",
4544
+ $schema: "https://biomejs.dev/schemas/2.3.0/schema.json",
2959
4545
  vcs: {
2960
4546
  enabled: true,
2961
4547
  clientKind: "git",
@@ -3038,23 +4624,46 @@ export default defineConfig({
3038
4624
  }
3039
4625
  ];
3040
4626
  }
4627
+ function generateSingleAppConfigFiles(options, _template, _helpers) {
4628
+ const tsConfig = {
4629
+ extends: "../../tsconfig.json",
4630
+ compilerOptions: {
4631
+ noEmit: true,
4632
+ baseUrl: ".",
4633
+ paths: { [`@${options.name}/*`]: ["../../packages/*/src"] }
4634
+ },
4635
+ include: ["src/**/*.ts"],
4636
+ exclude: ["node_modules", "dist"]
4637
+ };
4638
+ return [{
4639
+ path: "tsconfig.json",
4640
+ content: `${JSON.stringify(tsConfig, null, 2)}\n`
4641
+ }];
4642
+ }
3041
4643
 
3042
4644
  //#endregion
3043
4645
  //#region src/init/generators/docker.ts
3044
4646
  /**
3045
4647
  * Generate docker-compose.yml based on template and options
3046
4648
  */
3047
- function generateDockerFiles(options, template) {
4649
+ function generateDockerFiles(options, template, dbApps) {
3048
4650
  const { database } = options;
3049
4651
  const isServerless = template.name === "serverless";
3050
4652
  const hasWorker = template.name === "worker";
4653
+ const isFullstack = options.template === "fullstack";
3051
4654
  const services = [];
3052
4655
  const volumes = [];
4656
+ const files = [];
3053
4657
  if (database) {
4658
+ const initVolume = isFullstack && dbApps?.length ? `
4659
+ - ./docker/postgres/init.sh:/docker-entrypoint-initdb.d/init.sh:ro` : "";
4660
+ const envFile = isFullstack && dbApps?.length ? `
4661
+ env_file:
4662
+ - ./docker/.env` : "";
3054
4663
  services.push(` postgres:
3055
4664
  image: postgres:16-alpine
3056
4665
  container_name: ${options.name}-postgres
3057
- restart: unless-stopped
4666
+ restart: unless-stopped${envFile}
3058
4667
  environment:
3059
4668
  POSTGRES_USER: postgres
3060
4669
  POSTGRES_PASSWORD: postgres
@@ -3062,13 +4671,23 @@ function generateDockerFiles(options, template) {
3062
4671
  ports:
3063
4672
  - '5432:5432'
3064
4673
  volumes:
3065
- - postgres_data:/var/lib/postgresql/data
4674
+ - postgres_data:/var/lib/postgresql/data${initVolume}
3066
4675
  healthcheck:
3067
4676
  test: ['CMD-SHELL', 'pg_isready -U postgres']
3068
4677
  interval: 5s
3069
4678
  timeout: 5s
3070
4679
  retries: 5`);
3071
4680
  volumes.push(" postgres_data:");
4681
+ if (isFullstack && dbApps?.length) {
4682
+ files.push({
4683
+ path: "docker/postgres/init.sh",
4684
+ content: generatePostgresInitScript(dbApps)
4685
+ });
4686
+ files.push({
4687
+ path: "docker/.env",
4688
+ content: generateDockerEnv(dbApps)
4689
+ });
4690
+ }
3072
4691
  }
3073
4692
  if (isServerless) {
3074
4693
  services.push(` redis:
@@ -3144,105 +4763,85 @@ ${services.join("\n\n")}
3144
4763
  volumes:
3145
4764
  ${volumes.join("\n")}
3146
4765
  `;
3147
- return [{
4766
+ files.push({
3148
4767
  path: "docker-compose.yml",
3149
4768
  content: dockerCompose
3150
- }];
4769
+ });
4770
+ return files;
3151
4771
  }
3152
-
3153
- //#endregion
3154
- //#region src/init/generators/env.ts
3155
4772
  /**
3156
- * Generate environment files (.env, .env.example, .env.development, .env.test, .gitignore)
4773
+ * Generate .env file for docker-compose with database passwords
3157
4774
  */
3158
- function generateEnvFiles(options, template) {
3159
- const { database } = options;
3160
- const isServerless = template.name === "serverless";
3161
- const hasWorker = template.name === "worker";
3162
- let baseEnv = `# Application
3163
- NODE_ENV=development
3164
- PORT=3000
3165
- LOG_LEVEL=info
3166
- `;
3167
- if (isServerless) baseEnv = `# AWS
3168
- STAGE=dev
3169
- AWS_REGION=us-east-1
3170
- LOG_LEVEL=info
3171
- `;
3172
- if (database) baseEnv += `
3173
- # Database
3174
- DATABASE_URL=postgresql://user:password@localhost:5432/mydb
3175
- `;
3176
- if (hasWorker) baseEnv += `
3177
- # Message Queue
3178
- RABBITMQ_URL=amqp://localhost:5672
3179
- `;
3180
- baseEnv += `
3181
- # Authentication
3182
- JWT_SECRET=your-secret-key-change-in-production
3183
- `;
3184
- let devEnv = `# Development Environment
3185
- NODE_ENV=development
3186
- PORT=3000
3187
- LOG_LEVEL=debug
3188
- `;
3189
- if (isServerless) devEnv = `# Development Environment
3190
- STAGE=dev
3191
- AWS_REGION=us-east-1
3192
- LOG_LEVEL=debug
3193
- `;
3194
- if (database) devEnv += `
3195
- # Database
3196
- DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mydb_dev
3197
- `;
3198
- if (hasWorker) devEnv += `
3199
- # Message Queue
3200
- RABBITMQ_URL=amqp://localhost:5672
3201
- `;
3202
- devEnv += `
3203
- # Authentication
3204
- JWT_SECRET=dev-secret-not-for-production
3205
- `;
3206
- let testEnv = `# Test Environment
3207
- NODE_ENV=test
3208
- PORT=3001
3209
- LOG_LEVEL=error
3210
- `;
3211
- if (isServerless) testEnv = `# Test Environment
3212
- STAGE=test
3213
- AWS_REGION=us-east-1
3214
- LOG_LEVEL=error
4775
+ function generateDockerEnv(apps) {
4776
+ const envVars = apps.map((app) => {
4777
+ const envVar = `${app.name.toUpperCase()}_DB_PASSWORD`;
4778
+ return `${envVar}=${app.password}`;
4779
+ });
4780
+ return `# Auto-generated docker environment file
4781
+ # Contains database passwords for docker-compose postgres init
4782
+ # This file is gitignored - do not commit to version control
4783
+ ${envVars.join("\n")}
3215
4784
  `;
3216
- if (database) testEnv += `
3217
- # Database
3218
- DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mydb_test
4785
+ }
4786
+ /**
4787
+ * Generate PostgreSQL init shell script that creates per-app users with separate schemas
4788
+ * Uses environment variables for passwords (more secure than hardcoded values)
4789
+ * - api user: uses public schema
4790
+ * - auth user: uses auth schema with search_path=auth
4791
+ */
4792
+ function generatePostgresInitScript(apps) {
4793
+ const userCreations = apps.map((app) => {
4794
+ const userName = app.name.replace(/-/g, "_");
4795
+ const envVar = `${app.name.toUpperCase()}_DB_PASSWORD`;
4796
+ const isApi = app.name === "api";
4797
+ const schemaName = isApi ? "public" : userName;
4798
+ if (isApi) return `
4799
+ # Create ${app.name} user (uses public schema)
4800
+ echo "Creating user ${userName}..."
4801
+ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
4802
+ CREATE USER ${userName} WITH PASSWORD '$${envVar}';
4803
+ GRANT ALL ON SCHEMA public TO ${userName};
4804
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${userName};
4805
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${userName};
4806
+ EOSQL
3219
4807
  `;
3220
- if (hasWorker) testEnv += `
3221
- # Message Queue
3222
- RABBITMQ_URL=amqp://localhost:5672
4808
+ return `
4809
+ # Create ${app.name} user with dedicated schema
4810
+ echo "Creating user ${userName} with schema ${schemaName}..."
4811
+ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
4812
+ CREATE USER ${userName} WITH PASSWORD '$${envVar}';
4813
+ CREATE SCHEMA ${schemaName} AUTHORIZATION ${userName};
4814
+ ALTER USER ${userName} SET search_path TO ${schemaName};
4815
+ GRANT USAGE ON SCHEMA ${schemaName} TO ${userName};
4816
+ GRANT ALL ON ALL TABLES IN SCHEMA ${schemaName} TO ${userName};
4817
+ GRANT ALL ON ALL SEQUENCES IN SCHEMA ${schemaName} TO ${userName};
4818
+ ALTER DEFAULT PRIVILEGES IN SCHEMA ${schemaName} GRANT ALL ON TABLES TO ${userName};
4819
+ ALTER DEFAULT PRIVILEGES IN SCHEMA ${schemaName} GRANT ALL ON SEQUENCES TO ${userName};
4820
+ EOSQL
3223
4821
  `;
3224
- testEnv += `
3225
- # Authentication
3226
- JWT_SECRET=test-secret-not-for-production
4822
+ });
4823
+ return `#!/bin/bash
4824
+ set -e
4825
+
4826
+ # Auto-generated PostgreSQL init script
4827
+ # Creates per-app users with separate schemas in a single database
4828
+ # - api: uses public schema
4829
+ # - auth: uses auth schema (search_path=auth)
4830
+ ${userCreations.join("\n")}
4831
+ echo "Database initialization complete!"
3227
4832
  `;
3228
- const files = [
3229
- {
3230
- path: ".env.example",
3231
- content: baseEnv
3232
- },
3233
- {
3234
- path: ".env",
3235
- content: baseEnv
3236
- },
3237
- {
3238
- path: ".env.development",
3239
- content: devEnv
3240
- },
3241
- {
3242
- path: ".env.test",
3243
- content: testEnv
3244
- }
3245
- ];
4833
+ }
4834
+
4835
+ //#endregion
4836
+ //#region src/init/generators/env.ts
4837
+ /**
4838
+ * Generate environment-related files (.gitignore only).
4839
+ * Note: .env files are no longer generated. Use `gkm secrets:init` to initialize
4840
+ * encrypted secrets stored in `.gkm/secrets/{stage}.json` with keys stored at
4841
+ * `~/.gkm/{project-name}/{stage}.key`.
4842
+ */
4843
+ function generateEnvFiles(options, _template) {
4844
+ const files = [];
3246
4845
  if (!options.monorepo) {
3247
4846
  const gitignore = `# Dependencies
3248
4847
  node_modules/
@@ -3251,7 +4850,7 @@ node_modules/
3251
4850
  dist/
3252
4851
  .gkm/
3253
4852
 
3254
- # Environment
4853
+ # Environment (legacy - use gkm secrets instead)
3255
4854
  .env
3256
4855
  .env.local
3257
4856
  .env.*.local
@@ -3320,6 +4919,8 @@ function generateModelsPackage(options) {
3320
4919
  const tsConfig = {
3321
4920
  extends: "../../tsconfig.json",
3322
4921
  compilerOptions: {
4922
+ declaration: true,
4923
+ declarationMap: true,
3323
4924
  outDir: "./dist",
3324
4925
  rootDir: "./src"
3325
4926
  },
@@ -3406,23 +5007,27 @@ export type UpdateUser = z.infer<typeof updateUserSchema>;
3406
5007
  */
3407
5008
  function generateMonorepoFiles(options, _template) {
3408
5009
  if (!options.monorepo) return [];
5010
+ const isFullstack = options.template === "fullstack";
3409
5011
  const rootPackageJson = {
3410
5012
  name: options.name,
3411
5013
  version: "0.0.1",
3412
5014
  private: true,
3413
5015
  type: "module",
5016
+ packageManager: "pnpm@10.13.1",
3414
5017
  scripts: {
3415
- dev: "turbo dev",
3416
- build: "turbo build",
3417
- test: "turbo test",
3418
- "test:once": "turbo test:once",
5018
+ dev: isFullstack ? "gkm dev" : "turbo dev",
5019
+ build: isFullstack ? "gkm build" : "turbo build",
5020
+ test: isFullstack ? "gkm test" : "turbo test",
5021
+ "test:once": isFullstack ? "gkm test --run" : "turbo test:once",
3419
5022
  typecheck: "turbo typecheck",
3420
5023
  lint: "biome lint .",
3421
5024
  fmt: "biome format . --write",
3422
- "fmt:check": "biome format ."
5025
+ "fmt:check": "biome format .",
5026
+ ...options.deployTarget === "dokploy" ? { deploy: "gkm deploy --provider dokploy --stage production" } : {}
3423
5027
  },
3424
5028
  devDependencies: {
3425
- "@biomejs/biome": "~1.9.4",
5029
+ "@biomejs/biome": "~2.3.0",
5030
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3426
5031
  turbo: "~2.3.0",
3427
5032
  typescript: "~5.8.2",
3428
5033
  vitest: "~4.0.0"
@@ -3435,7 +5040,7 @@ function generateMonorepoFiles(options, _template) {
3435
5040
  - 'packages/*'
3436
5041
  `;
3437
5042
  const biomeConfig = {
3438
- $schema: "https://biomejs.dev/schemas/1.9.4/schema.json",
5043
+ $schema: "https://biomejs.dev/schemas/2.3.0/schema.json",
3439
5044
  vcs: {
3440
5045
  enabled: true,
3441
5046
  clientKind: "git",
@@ -3510,6 +5115,7 @@ dist/
3510
5115
  .env
3511
5116
  .env.local
3512
5117
  .env.*.local
5118
+ docker/.env
3513
5119
 
3514
5120
  # IDE
3515
5121
  .idea/
@@ -3546,14 +5152,27 @@ coverage/
3546
5152
  esModuleInterop: true,
3547
5153
  skipLibCheck: true,
3548
5154
  forceConsistentCasingInFileNames: true,
3549
- resolveJsonModule: true,
3550
- declaration: true,
3551
- declarationMap: true,
3552
- composite: true
5155
+ resolveJsonModule: true
3553
5156
  },
3554
5157
  exclude: ["node_modules", "dist"]
3555
5158
  };
3556
- return [
5159
+ const vitestConfig = `import { defineConfig } from 'vitest/config';
5160
+
5161
+ export default defineConfig({
5162
+ test: {
5163
+ globals: true,
5164
+ environment: 'node',
5165
+ include: ['apps/**/*.{test,spec}.ts', 'packages/**/*.{test,spec}.ts'],
5166
+ exclude: ['**/node_modules/**', '**/dist/**'],
5167
+ coverage: {
5168
+ provider: 'v8',
5169
+ reporter: ['text', 'json', 'html'],
5170
+ exclude: ['**/node_modules/**', '**/dist/**', '**/*.d.ts'],
5171
+ },
5172
+ },
5173
+ });
5174
+ `;
5175
+ const files = [
3557
5176
  {
3558
5177
  path: "package.json",
3559
5178
  content: `${JSON.stringify(rootPackageJson, null, 2)}\n`
@@ -3574,11 +5193,100 @@ coverage/
3574
5193
  path: "turbo.json",
3575
5194
  content: `${JSON.stringify(turboConfig, null, 2)}\n`
3576
5195
  },
5196
+ {
5197
+ path: "vitest.config.ts",
5198
+ content: vitestConfig
5199
+ },
3577
5200
  {
3578
5201
  path: ".gitignore",
3579
5202
  content: gitignore
3580
5203
  }
3581
5204
  ];
5205
+ if (isFullstack) files.push({
5206
+ path: "gkm.config.ts",
5207
+ content: generateWorkspaceConfig(options)
5208
+ });
5209
+ return files;
5210
+ }
5211
+ /**
5212
+ * Generate gkm.config.ts with defineWorkspace for fullstack template
5213
+ */
5214
+ function generateWorkspaceConfig(options) {
5215
+ const { telescope, services, deployTarget, routesStructure } = options;
5216
+ const getRoutesGlob = () => {
5217
+ switch (routesStructure) {
5218
+ case "centralized-endpoints": return "./src/endpoints/**/*.ts";
5219
+ case "centralized-routes": return "./src/routes/**/*.ts";
5220
+ case "domain-based": return "./src/**/routes/*.ts";
5221
+ }
5222
+ };
5223
+ let config = `import { defineWorkspace } from '@geekmidas/cli/config';
5224
+
5225
+ export default defineWorkspace({
5226
+ name: '${options.name}',
5227
+ apps: {
5228
+ api: {
5229
+ type: 'backend',
5230
+ path: 'apps/api',
5231
+ port: 3000,
5232
+ routes: '${getRoutesGlob()}',
5233
+ envParser: './src/config/env#envParser',
5234
+ logger: './src/config/logger#logger',`;
5235
+ if (telescope) config += `
5236
+ telescope: {
5237
+ enabled: true,
5238
+ path: '/__telescope',
5239
+ },`;
5240
+ config += `
5241
+ openapi: {
5242
+ enabled: true,
5243
+ },
5244
+ },
5245
+ auth: {
5246
+ type: 'backend',
5247
+ path: 'apps/auth',
5248
+ port: 3002,
5249
+ envParser: './src/config/env#envParser',
5250
+ logger: './src/config/logger#logger',
5251
+ },
5252
+ web: {
5253
+ type: 'frontend',
5254
+ framework: 'nextjs',
5255
+ path: 'apps/web',
5256
+ port: 3001,
5257
+ dependencies: ['api', 'auth'],
5258
+ client: {
5259
+ output: './src/api',
5260
+ },
5261
+ },
5262
+ },
5263
+ shared: {
5264
+ packages: ['packages/*'],
5265
+ models: {
5266
+ path: 'packages/models',
5267
+ schema: 'zod',
5268
+ },
5269
+ },`;
5270
+ if (services.db || services.cache || services.mail) {
5271
+ config += `
5272
+ services: {`;
5273
+ if (services.db) config += `
5274
+ db: true,`;
5275
+ if (services.cache) config += `
5276
+ cache: true,`;
5277
+ if (services.mail) config += `
5278
+ mail: true,`;
5279
+ config += `
5280
+ },`;
5281
+ }
5282
+ if (deployTarget === "dokploy") config += `
5283
+ deploy: {
5284
+ default: 'dokploy',
5285
+ },`;
5286
+ config += `
5287
+ });
5288
+ `;
5289
+ return config;
3582
5290
  }
3583
5291
 
3584
5292
  //#endregion
@@ -3587,18 +5295,18 @@ const apiTemplate = {
3587
5295
  name: "api",
3588
5296
  description: "Full API with auth, database, services",
3589
5297
  dependencies: {
3590
- "@geekmidas/constructs": "workspace:*",
3591
- "@geekmidas/envkit": "workspace:*",
3592
- "@geekmidas/logger": "workspace:*",
3593
- "@geekmidas/services": "workspace:*",
3594
- "@geekmidas/errors": "workspace:*",
3595
- "@geekmidas/auth": "workspace:*",
5298
+ "@geekmidas/constructs": GEEKMIDAS_VERSIONS["@geekmidas/constructs"],
5299
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
5300
+ "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
5301
+ "@geekmidas/services": GEEKMIDAS_VERSIONS["@geekmidas/services"],
5302
+ "@geekmidas/errors": GEEKMIDAS_VERSIONS["@geekmidas/errors"],
5303
+ "@geekmidas/auth": GEEKMIDAS_VERSIONS["@geekmidas/auth"],
3596
5304
  hono: "~4.8.2",
3597
5305
  pino: "~9.6.0"
3598
5306
  },
3599
5307
  devDependencies: {
3600
- "@biomejs/biome": "~1.9.4",
3601
- "@geekmidas/cli": "workspace:*",
5308
+ "@biomejs/biome": "~2.3.0",
5309
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3602
5310
  "@types/node": "~22.0.0",
3603
5311
  tsx: "~4.20.0",
3604
5312
  turbo: "~2.3.0",
@@ -3635,18 +5343,17 @@ export const logger = createLogger();
3635
5343
  const files = [
3636
5344
  {
3637
5345
  path: "src/config/env.ts",
3638
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5346
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5347
+ import { EnvironmentParser } from '@geekmidas/envkit';
3639
5348
 
3640
- export const envParser = new EnvironmentParser(process.env);
5349
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
3641
5350
 
5351
+ // Global config - only minimal shared values
5352
+ // Service-specific config should be parsed in each service
3642
5353
  export const config = envParser
3643
5354
  .create((get) => ({
3644
- port: get('PORT').string().transform(Number).default(3000),
3645
- nodeEnv: get('NODE_ENV').string().default('development'),
3646
- jwtSecret: get('JWT_SECRET').string().default('change-me-in-production'),${options.database ? `
3647
- database: {
3648
- url: get('DATABASE_URL').string().default('postgresql://localhost:5432/mydb'),
3649
- },` : ""}
5355
+ nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
5356
+ stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
3650
5357
  }))
3651
5358
  .parse();
3652
5359
  `
@@ -3659,7 +5366,7 @@ export const config = envParser
3659
5366
  path: getRoutePath("health.ts"),
3660
5367
  content: `import { e } from '@geekmidas/constructs/endpoints';
3661
5368
 
3662
- export default e
5369
+ export const healthEndpoint = e
3663
5370
  .get('/health')
3664
5371
  .handle(async () => ({
3665
5372
  status: 'ok',
@@ -3671,7 +5378,7 @@ export default e
3671
5378
  path: getRoutePath("users/list.ts"),
3672
5379
  content: `import { e } from '@geekmidas/constructs/endpoints';
3673
5380
 
3674
- export default e
5381
+ export const listUsersEndpoint = e
3675
5382
  .get('/users')
3676
5383
  .handle(async () => ({
3677
5384
  users: [
@@ -3686,7 +5393,7 @@ export default e
3686
5393
  content: `import { e } from '@geekmidas/constructs/endpoints';
3687
5394
  import { z } from 'zod';
3688
5395
 
3689
- export default e
5396
+ export const getUserEndpoint = e
3690
5397
  .get('/users/:id')
3691
5398
  .params(z.object({ id: z.string() }))
3692
5399
  .handle(async ({ params }) => ({
@@ -3699,7 +5406,7 @@ export default e
3699
5406
  ];
3700
5407
  if (options.database) files.push({
3701
5408
  path: "src/services/database.ts",
3702
- content: `import type { Service } from '@geekmidas/services';
5409
+ content: `import type { Service, ServiceRegisterOptions } from '@geekmidas/services';
3703
5410
  import { Kysely, PostgresDialect } from 'kysely';
3704
5411
  import pg from 'pg';
3705
5412
 
@@ -3715,18 +5422,24 @@ export interface Database {
3715
5422
 
3716
5423
  export const databaseService = {
3717
5424
  serviceName: 'database' as const,
3718
- async register(envParser) {
5425
+ async register({ envParser, context }: ServiceRegisterOptions) {
5426
+ const logger = context.getLogger();
5427
+ logger.info('Connecting to database');
5428
+
3719
5429
  const config = envParser
3720
5430
  .create((get) => ({
3721
5431
  url: get('DATABASE_URL').string(),
3722
5432
  }))
3723
5433
  .parse();
3724
5434
 
3725
- return new Kysely<Database>({
5435
+ const db = new Kysely<Database>({
3726
5436
  dialect: new PostgresDialect({
3727
5437
  pool: new pg.Pool({ connectionString: config.url }),
3728
5438
  }),
3729
5439
  });
5440
+
5441
+ logger.info('Database connection established');
5442
+ return db;
3730
5443
  },
3731
5444
  } satisfies Service<'database', Kysely<Database>>;
3732
5445
  `
@@ -3747,13 +5460,20 @@ export const telescope = new Telescope({
3747
5460
  content: `import { Direction, InMemoryMonitoringStorage, Studio } from '@geekmidas/studio';
3748
5461
  import { Kysely, PostgresDialect } from 'kysely';
3749
5462
  import pg from 'pg';
3750
- import type { Database } from '../services/database';
3751
- import { config } from './env';
5463
+ import type { Database } from '../services/database.js';
5464
+ import { envParser } from './env.js';
5465
+
5466
+ // Parse database config for Studio
5467
+ const studioConfig = envParser
5468
+ .create((get) => ({
5469
+ databaseUrl: get('DATABASE_URL').string(),
5470
+ }))
5471
+ .parse();
3752
5472
 
3753
5473
  // Create a Kysely instance for Studio
3754
5474
  const db = new Kysely<Database>({
3755
5475
  dialect: new PostgresDialect({
3756
- pool: new pg.Pool({ connectionString: config.database.url }),
5476
+ pool: new pg.Pool({ connectionString: studioConfig.databaseUrl }),
3757
5477
  }),
3758
5478
  });
3759
5479
 
@@ -3779,15 +5499,15 @@ const minimalTemplate = {
3779
5499
  name: "minimal",
3780
5500
  description: "Basic health endpoint",
3781
5501
  dependencies: {
3782
- "@geekmidas/constructs": "workspace:*",
3783
- "@geekmidas/envkit": "workspace:*",
3784
- "@geekmidas/logger": "workspace:*",
5502
+ "@geekmidas/constructs": GEEKMIDAS_VERSIONS["@geekmidas/constructs"],
5503
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
5504
+ "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
3785
5505
  hono: "~4.8.2",
3786
5506
  pino: "~9.6.0"
3787
5507
  },
3788
5508
  devDependencies: {
3789
- "@biomejs/biome": "~1.9.4",
3790
- "@geekmidas/cli": "workspace:*",
5509
+ "@biomejs/biome": "~2.3.0",
5510
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3791
5511
  "@types/node": "~22.0.0",
3792
5512
  tsx: "~4.20.0",
3793
5513
  turbo: "~2.3.0",
@@ -3820,14 +5540,17 @@ export const logger = createLogger();
3820
5540
  const files = [
3821
5541
  {
3822
5542
  path: "src/config/env.ts",
3823
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5543
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5544
+ import { EnvironmentParser } from '@geekmidas/envkit';
3824
5545
 
3825
- export const envParser = new EnvironmentParser(process.env);
5546
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
3826
5547
 
5548
+ // Global config - only minimal shared values
5549
+ // Service-specific config should be parsed in each service
3827
5550
  export const config = envParser
3828
5551
  .create((get) => ({
3829
- port: get('PORT').string().transform(Number).default(3000),
3830
- nodeEnv: get('NODE_ENV').string().default('development'),
5552
+ nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
5553
+ stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
3831
5554
  }))
3832
5555
  .parse();
3833
5556
  `
@@ -3840,7 +5563,7 @@ export const config = envParser
3840
5563
  path: getRoutePath("health.ts"),
3841
5564
  content: `import { e } from '@geekmidas/constructs/endpoints';
3842
5565
 
3843
- export default e
5566
+ export const healthEndpoint = e
3844
5567
  .get('/health')
3845
5568
  .handle(async () => ({
3846
5569
  status: 'ok',
@@ -3849,27 +5572,9 @@ export default e
3849
5572
  `
3850
5573
  }
3851
5574
  ];
3852
- if (options.database) {
3853
- files[0] = {
3854
- path: "src/config/env.ts",
3855
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
3856
-
3857
- export const envParser = new EnvironmentParser(process.env);
3858
-
3859
- export const config = envParser
3860
- .create((get) => ({
3861
- port: get('PORT').string().transform(Number).default(3000),
3862
- nodeEnv: get('NODE_ENV').string().default('development'),
3863
- database: {
3864
- url: get('DATABASE_URL').string().default('postgresql://localhost:5432/mydb'),
3865
- },
3866
- }))
3867
- .parse();
3868
- `
3869
- };
3870
- files.push({
3871
- path: "src/services/database.ts",
3872
- content: `import type { Service } from '@geekmidas/services';
5575
+ if (options.database) files.push({
5576
+ path: "src/services/database.ts",
5577
+ content: `import type { Service, ServiceRegisterOptions } from '@geekmidas/services';
3873
5578
  import { Kysely, PostgresDialect } from 'kysely';
3874
5579
  import pg from 'pg';
3875
5580
 
@@ -3880,23 +5585,28 @@ export interface Database {
3880
5585
 
3881
5586
  export const databaseService = {
3882
5587
  serviceName: 'database' as const,
3883
- async register(envParser) {
5588
+ async register({ envParser, context }: ServiceRegisterOptions) {
5589
+ const logger = context.getLogger();
5590
+ logger.info('Connecting to database');
5591
+
3884
5592
  const config = envParser
3885
5593
  .create((get) => ({
3886
5594
  url: get('DATABASE_URL').string(),
3887
5595
  }))
3888
5596
  .parse();
3889
5597
 
3890
- return new Kysely<Database>({
5598
+ const db = new Kysely<Database>({
3891
5599
  dialect: new PostgresDialect({
3892
5600
  pool: new pg.Pool({ connectionString: config.url }),
3893
5601
  }),
3894
5602
  });
5603
+
5604
+ logger.info('Database connection established');
5605
+ return db;
3895
5606
  },
3896
5607
  } satisfies Service<'database', Kysely<Database>>;
3897
5608
  `
3898
- });
3899
- }
5609
+ });
3900
5610
  if (options.telescope) files.push({
3901
5611
  path: "src/config/telescope.ts",
3902
5612
  content: `import { Telescope } from '@geekmidas/telescope';
@@ -3913,13 +5623,20 @@ export const telescope = new Telescope({
3913
5623
  content: `import { Direction, InMemoryMonitoringStorage, Studio } from '@geekmidas/studio';
3914
5624
  import { Kysely, PostgresDialect } from 'kysely';
3915
5625
  import pg from 'pg';
3916
- import type { Database } from '../services/database';
3917
- import { config } from './env';
5626
+ import type { Database } from '../services/database.js';
5627
+ import { envParser } from './env.js';
5628
+
5629
+ // Parse database config for Studio
5630
+ const studioConfig = envParser
5631
+ .create((get) => ({
5632
+ databaseUrl: get('DATABASE_URL').string(),
5633
+ }))
5634
+ .parse();
3918
5635
 
3919
5636
  // Create a Kysely instance for Studio
3920
5637
  const db = new Kysely<Database>({
3921
5638
  dialect: new PostgresDialect({
3922
- pool: new pg.Pool({ connectionString: config.database.url }),
5639
+ pool: new pg.Pool({ connectionString: studioConfig.databaseUrl }),
3923
5640
  }),
3924
5641
  });
3925
5642
 
@@ -3945,16 +5662,16 @@ const serverlessTemplate = {
3945
5662
  name: "serverless",
3946
5663
  description: "AWS Lambda handlers",
3947
5664
  dependencies: {
3948
- "@geekmidas/constructs": "workspace:*",
3949
- "@geekmidas/envkit": "workspace:*",
3950
- "@geekmidas/logger": "workspace:*",
3951
- "@geekmidas/cloud": "workspace:*",
5665
+ "@geekmidas/constructs": GEEKMIDAS_VERSIONS["@geekmidas/constructs"],
5666
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
5667
+ "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
5668
+ "@geekmidas/cloud": GEEKMIDAS_VERSIONS["@geekmidas/cloud"],
3952
5669
  hono: "~4.8.2",
3953
5670
  pino: "~9.6.0"
3954
5671
  },
3955
5672
  devDependencies: {
3956
- "@biomejs/biome": "~1.9.4",
3957
- "@geekmidas/cli": "workspace:*",
5673
+ "@biomejs/biome": "~2.3.0",
5674
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3958
5675
  "@types/aws-lambda": "~8.10.92",
3959
5676
  "@types/node": "~22.0.0",
3960
5677
  tsx: "~4.20.0",
@@ -3988,17 +5705,17 @@ export const logger = createLogger();
3988
5705
  const files = [
3989
5706
  {
3990
5707
  path: "src/config/env.ts",
3991
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5708
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5709
+ import { EnvironmentParser } from '@geekmidas/envkit';
3992
5710
 
3993
- export const envParser = new EnvironmentParser(process.env);
5711
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
3994
5712
 
5713
+ // Global config - only minimal shared values
5714
+ // Service-specific config should be parsed in each service
3995
5715
  export const config = envParser
3996
5716
  .create((get) => ({
3997
- stage: get('STAGE').string().default('dev'),
3998
- region: get('AWS_REGION').string().default('us-east-1'),${options.database ? `
3999
- database: {
4000
- url: get('DATABASE_URL').string(),
4001
- },` : ""}
5717
+ nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
5718
+ stage: get('STAGE').enum(['dev', 'staging', 'prod']).default('dev'),
4002
5719
  }))
4003
5720
  .parse();
4004
5721
  `
@@ -4011,7 +5728,7 @@ export const config = envParser
4011
5728
  path: getRoutePath("health.ts"),
4012
5729
  content: `import { e } from '@geekmidas/constructs/endpoints';
4013
5730
 
4014
- export default e
5731
+ export const healthEndpoint = e
4015
5732
  .get('/health')
4016
5733
  .handle(async () => ({
4017
5734
  status: 'ok',
@@ -4025,7 +5742,7 @@ export default e
4025
5742
  content: `import { f } from '@geekmidas/constructs/functions';
4026
5743
  import { z } from 'zod';
4027
5744
 
4028
- export default f
5745
+ export const helloFunction = f
4029
5746
  .input(z.object({ name: z.string() }))
4030
5747
  .output(z.object({ message: z.string() }))
4031
5748
  .handle(async ({ input }) => ({
@@ -4056,16 +5773,16 @@ const workerTemplate = {
4056
5773
  name: "worker",
4057
5774
  description: "Background job processing",
4058
5775
  dependencies: {
4059
- "@geekmidas/constructs": "workspace:*",
4060
- "@geekmidas/envkit": "workspace:*",
4061
- "@geekmidas/logger": "workspace:*",
4062
- "@geekmidas/events": "workspace:*",
5776
+ "@geekmidas/constructs": GEEKMIDAS_VERSIONS["@geekmidas/constructs"],
5777
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
5778
+ "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
5779
+ "@geekmidas/events": GEEKMIDAS_VERSIONS["@geekmidas/events"],
4063
5780
  hono: "~4.8.2",
4064
5781
  pino: "~9.6.0"
4065
5782
  },
4066
5783
  devDependencies: {
4067
- "@biomejs/biome": "~1.9.4",
4068
- "@geekmidas/cli": "workspace:*",
5784
+ "@biomejs/biome": "~2.3.0",
5785
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
4069
5786
  "@types/node": "~22.0.0",
4070
5787
  tsx: "~4.20.0",
4071
5788
  turbo: "~2.3.0",
@@ -4098,20 +5815,17 @@ export const logger = createLogger();
4098
5815
  const files = [
4099
5816
  {
4100
5817
  path: "src/config/env.ts",
4101
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5818
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5819
+ import { EnvironmentParser } from '@geekmidas/envkit';
4102
5820
 
4103
- export const envParser = new EnvironmentParser(process.env);
5821
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
4104
5822
 
5823
+ // Global config - only minimal shared values
5824
+ // Service-specific config should be parsed in each service
4105
5825
  export const config = envParser
4106
5826
  .create((get) => ({
4107
- port: get('PORT').string().transform(Number).default(3000),
4108
- nodeEnv: get('NODE_ENV').string().default('development'),
4109
- rabbitmq: {
4110
- url: get('RABBITMQ_URL').string().default('amqp://localhost:5672'),
4111
- },${options.database ? `
4112
- database: {
4113
- url: get('DATABASE_URL').string().default('postgresql://localhost:5432/mydb'),
4114
- },` : ""}
5827
+ nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
5828
+ stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
4115
5829
  }))
4116
5830
  .parse();
4117
5831
  `
@@ -4124,7 +5838,7 @@ export const config = envParser
4124
5838
  path: getRoutePath("health.ts"),
4125
5839
  content: `import { e } from '@geekmidas/constructs/endpoints';
4126
5840
 
4127
- export default e
5841
+ export const healthEndpoint = e
4128
5842
  .get('/health')
4129
5843
  .handle(async () => ({
4130
5844
  status: 'ok',
@@ -4141,15 +5855,44 @@ export type AppEvents =
4141
5855
  | PublishableMessage<'user.created', { userId: string; email: string }>
4142
5856
  | PublishableMessage<'user.updated', { userId: string; changes: Record<string, unknown> }>
4143
5857
  | PublishableMessage<'order.placed', { orderId: string; userId: string; total: number }>;
5858
+ `
5859
+ },
5860
+ {
5861
+ path: "src/events/publisher.ts",
5862
+ content: `import type { Service, ServiceRegisterOptions } from '@geekmidas/services';
5863
+ import { Publisher, type EventPublisher } from '@geekmidas/events';
5864
+ import type { AppEvents } from './types.js';
5865
+
5866
+ export const eventsPublisherService = {
5867
+ serviceName: 'events' as const,
5868
+ async register({ envParser, context }: ServiceRegisterOptions) {
5869
+ const logger = context.getLogger();
5870
+ logger.info('Connecting to message broker');
5871
+
5872
+ const config = envParser
5873
+ .create((get) => ({
5874
+ url: get('RABBITMQ_URL').string().default('amqp://localhost:5672'),
5875
+ }))
5876
+ .parse();
5877
+
5878
+ const publisher = await Publisher.fromConnectionString<AppEvents>(
5879
+ \`rabbitmq://\${config.url.replace('amqp://', '')}?exchange=events\`
5880
+ );
5881
+
5882
+ logger.info('Message broker connection established');
5883
+ return publisher;
5884
+ },
5885
+ } satisfies Service<'events', EventPublisher<AppEvents>>;
4144
5886
  `
4145
5887
  },
4146
5888
  {
4147
5889
  path: "src/subscribers/user-events.ts",
4148
5890
  content: `import { s } from '@geekmidas/constructs/subscribers';
4149
- import type { AppEvents } from '../events/types.js';
5891
+ import { eventsPublisherService } from '../events/publisher.js';
4150
5892
 
4151
- export default s<AppEvents>()
4152
- .events(['user.created', 'user.updated'])
5893
+ export const userEventsSubscriber = s
5894
+ .publisher(eventsPublisherService)
5895
+ .subscribe(['user.created', 'user.updated'])
4153
5896
  .handle(async ({ event, logger }) => {
4154
5897
  logger.info({ type: event.type, payload: event.payload }, 'Processing user event');
4155
5898
 
@@ -4171,7 +5914,7 @@ export default s<AppEvents>()
4171
5914
  content: `import { cron } from '@geekmidas/constructs/crons';
4172
5915
 
4173
5916
  // Run every day at midnight
4174
- export default cron('0 0 * * *')
5917
+ export const cleanupCron = cron('0 0 * * *')
4175
5918
  .handle(async ({ logger }) => {
4176
5919
  logger.info('Running cleanup job');
4177
5920
 
@@ -4214,30 +5957,17 @@ const templates = {
4214
5957
  worker: workerTemplate
4215
5958
  };
4216
5959
  /**
4217
- * Template choices for prompts
5960
+ * Template choices for prompts (Story 1.11 simplified to api + fullstack)
4218
5961
  */
4219
- const templateChoices = [
4220
- {
4221
- title: "Minimal",
4222
- value: "minimal",
4223
- description: "Basic health endpoint"
4224
- },
4225
- {
4226
- title: "API",
4227
- value: "api",
4228
- description: "Full API with auth, database, services"
4229
- },
4230
- {
4231
- title: "Serverless",
4232
- value: "serverless",
4233
- description: "AWS Lambda handlers"
4234
- },
4235
- {
4236
- title: "Worker",
4237
- value: "worker",
4238
- description: "Background job processing"
4239
- }
4240
- ];
5962
+ const templateChoices = [{
5963
+ title: "API",
5964
+ value: "api",
5965
+ description: "Single backend API with endpoints"
5966
+ }, {
5967
+ title: "Fullstack",
5968
+ value: "fullstack",
5969
+ description: "Monorepo with API + Next.js + shared models"
5970
+ }];
4241
5971
  /**
4242
5972
  * Logger type choices for prompts
4243
5973
  */
@@ -4271,13 +6001,77 @@ const routesStructureChoices = [
4271
6001
  }
4272
6002
  ];
4273
6003
  /**
6004
+ * Package manager choices for prompts
6005
+ */
6006
+ const packageManagerChoices = [
6007
+ {
6008
+ title: "pnpm",
6009
+ value: "pnpm",
6010
+ description: "Fast, disk space efficient (recommended)"
6011
+ },
6012
+ {
6013
+ title: "npm",
6014
+ value: "npm",
6015
+ description: "Node.js default package manager"
6016
+ },
6017
+ {
6018
+ title: "yarn",
6019
+ value: "yarn",
6020
+ description: "Yarn package manager"
6021
+ },
6022
+ {
6023
+ title: "bun",
6024
+ value: "bun",
6025
+ description: "Fast JavaScript runtime and package manager"
6026
+ }
6027
+ ];
6028
+ /**
6029
+ * Deploy target choices for prompts
6030
+ */
6031
+ const deployTargetChoices = [{
6032
+ title: "Dokploy",
6033
+ value: "dokploy",
6034
+ description: "Deploy to Dokploy (Docker-based hosting)"
6035
+ }, {
6036
+ title: "Configure later",
6037
+ value: "none",
6038
+ description: "Skip deployment setup for now"
6039
+ }];
6040
+ /**
6041
+ * Services choices for multi-select prompt
6042
+ */
6043
+ const servicesChoices = [
6044
+ {
6045
+ title: "PostgreSQL",
6046
+ value: "db",
6047
+ description: "PostgreSQL database"
6048
+ },
6049
+ {
6050
+ title: "Redis",
6051
+ value: "cache",
6052
+ description: "Redis cache"
6053
+ },
6054
+ {
6055
+ title: "Mailpit",
6056
+ value: "mail",
6057
+ description: "Email testing service (dev only)"
6058
+ }
6059
+ ];
6060
+ /**
4274
6061
  * Get a template by name
4275
6062
  */
4276
6063
  function getTemplate(name$1) {
6064
+ if (name$1 === "fullstack") return templates.api;
4277
6065
  const template = templates[name$1];
4278
6066
  if (!template) throw new Error(`Unknown template: ${name$1}`);
4279
6067
  return template;
4280
6068
  }
6069
+ /**
6070
+ * Check if a template is the fullstack monorepo template
6071
+ */
6072
+ function isFullstackTemplate(name$1) {
6073
+ return name$1 === "fullstack";
6074
+ }
4281
6075
 
4282
6076
  //#endregion
4283
6077
  //#region src/init/generators/package.ts
@@ -4289,10 +6083,10 @@ function generatePackageJson(options, template) {
4289
6083
  const dependencies$1 = { ...template.dependencies };
4290
6084
  const devDependencies$1 = { ...template.devDependencies };
4291
6085
  const scripts$1 = { ...template.scripts };
4292
- if (telescope) dependencies$1["@geekmidas/telescope"] = "workspace:*";
4293
- if (studio) dependencies$1["@geekmidas/studio"] = "workspace:*";
6086
+ if (telescope) dependencies$1["@geekmidas/telescope"] = GEEKMIDAS_VERSIONS["@geekmidas/telescope"];
6087
+ if (studio) dependencies$1["@geekmidas/studio"] = GEEKMIDAS_VERSIONS["@geekmidas/studio"];
4294
6088
  if (database) {
4295
- dependencies$1["@geekmidas/db"] = "workspace:*";
6089
+ dependencies$1["@geekmidas/db"] = GEEKMIDAS_VERSIONS["@geekmidas/db"];
4296
6090
  dependencies$1.kysely = "~0.28.2";
4297
6091
  dependencies$1.pg = "~8.16.0";
4298
6092
  devDependencies$1["@types/pg"] = "~8.15.0";
@@ -4327,19 +6121,219 @@ function generatePackageJson(options, template) {
4327
6121
  dependencies: sortObject(dependencies$1),
4328
6122
  devDependencies: sortObject(devDependencies$1)
4329
6123
  };
4330
- return [{
4331
- path: "package.json",
4332
- content: `${JSON.stringify(packageJson, null, 2)}\n`
4333
- }];
6124
+ return [{
6125
+ path: "package.json",
6126
+ content: `${JSON.stringify(packageJson, null, 2)}\n`
6127
+ }];
6128
+ }
6129
+
6130
+ //#endregion
6131
+ //#region src/init/generators/source.ts
6132
+ /**
6133
+ * Generate source files from template
6134
+ */
6135
+ function generateSourceFiles(options, template) {
6136
+ return template.files(options);
6137
+ }
6138
+
6139
+ //#endregion
6140
+ //#region src/init/generators/web.ts
6141
+ /**
6142
+ * Generate Next.js web app files for fullstack template
6143
+ */
6144
+ function generateWebAppFiles(options) {
6145
+ if (!options.monorepo || options.template !== "fullstack") return [];
6146
+ const packageName = `@${options.name}/web`;
6147
+ const modelsPackage = `@${options.name}/models`;
6148
+ const packageJson = {
6149
+ name: packageName,
6150
+ version: "0.0.1",
6151
+ private: true,
6152
+ type: "module",
6153
+ scripts: {
6154
+ dev: "next dev -p 3001",
6155
+ build: "next build",
6156
+ start: "next start",
6157
+ typecheck: "tsc --noEmit"
6158
+ },
6159
+ dependencies: {
6160
+ [modelsPackage]: "workspace:*",
6161
+ next: "~16.1.0",
6162
+ react: "~19.2.0",
6163
+ "react-dom": "~19.2.0"
6164
+ },
6165
+ devDependencies: {
6166
+ "@types/node": "~22.0.0",
6167
+ "@types/react": "~19.0.0",
6168
+ "@types/react-dom": "~19.0.0",
6169
+ typescript: "~5.8.2"
6170
+ }
6171
+ };
6172
+ const nextConfig = `import type { NextConfig } from 'next';
6173
+
6174
+ const nextConfig: NextConfig = {
6175
+ output: 'standalone',
6176
+ reactStrictMode: true,
6177
+ transpilePackages: ['${modelsPackage}'],
6178
+ };
6179
+
6180
+ export default nextConfig;
6181
+ `;
6182
+ const tsConfig = {
6183
+ extends: "../../tsconfig.json",
6184
+ compilerOptions: {
6185
+ lib: [
6186
+ "dom",
6187
+ "dom.iterable",
6188
+ "ES2022"
6189
+ ],
6190
+ allowJs: true,
6191
+ skipLibCheck: true,
6192
+ strict: true,
6193
+ noEmit: true,
6194
+ esModuleInterop: true,
6195
+ module: "ESNext",
6196
+ moduleResolution: "bundler",
6197
+ resolveJsonModule: true,
6198
+ isolatedModules: true,
6199
+ jsx: "preserve",
6200
+ incremental: true,
6201
+ plugins: [{ name: "next" }],
6202
+ paths: {
6203
+ "@/*": ["./src/*"],
6204
+ [`${modelsPackage}`]: ["../../packages/models/src"],
6205
+ [`${modelsPackage}/*`]: ["../../packages/models/src/*"]
6206
+ },
6207
+ baseUrl: "."
6208
+ },
6209
+ include: [
6210
+ "next-env.d.ts",
6211
+ "**/*.ts",
6212
+ "**/*.tsx",
6213
+ ".next/types/**/*.ts"
6214
+ ],
6215
+ exclude: ["node_modules"]
6216
+ };
6217
+ const layoutTsx = `import type { Metadata } from 'next';
6218
+
6219
+ export const metadata: Metadata = {
6220
+ title: '${options.name}',
6221
+ description: 'Created with gkm init',
6222
+ };
6223
+
6224
+ export default function RootLayout({
6225
+ children,
6226
+ }: {
6227
+ children: React.ReactNode;
6228
+ }) {
6229
+ return (
6230
+ <html lang="en">
6231
+ <body>{children}</body>
6232
+ </html>
6233
+ );
4334
6234
  }
6235
+ `;
6236
+ const pageTsx = `import type { User } from '${modelsPackage}';
4335
6237
 
4336
- //#endregion
4337
- //#region src/init/generators/source.ts
4338
- /**
4339
- * Generate source files from template
4340
- */
4341
- function generateSourceFiles(options, template) {
4342
- return template.files(options);
6238
+ export default async function Home() {
6239
+ // Example: Fetch from API
6240
+ const apiUrl = process.env.API_URL || 'http://localhost:3000';
6241
+ let health = null;
6242
+
6243
+ try {
6244
+ const response = await fetch(\`\${apiUrl}/health\`, {
6245
+ cache: 'no-store',
6246
+ });
6247
+ health = await response.json();
6248
+ } catch (error) {
6249
+ console.error('Failed to fetch health:', error);
6250
+ }
6251
+
6252
+ // Example: Type-safe model usage
6253
+ const exampleUser: User = {
6254
+ id: '123e4567-e89b-12d3-a456-426614174000',
6255
+ email: 'user@example.com',
6256
+ name: 'Example User',
6257
+ createdAt: new Date(),
6258
+ updatedAt: new Date(),
6259
+ };
6260
+
6261
+ return (
6262
+ <main style={{ padding: '2rem', fontFamily: 'system-ui' }}>
6263
+ <h1>Welcome to ${options.name}</h1>
6264
+
6265
+ <section style={{ marginTop: '2rem' }}>
6266
+ <h2>API Status</h2>
6267
+ {health ? (
6268
+ <pre style={{ background: '#f0f0f0', padding: '1rem', borderRadius: '8px' }}>
6269
+ {JSON.stringify(health, null, 2)}
6270
+ </pre>
6271
+ ) : (
6272
+ <p>Unable to connect to API at {apiUrl}</p>
6273
+ )}
6274
+ </section>
6275
+
6276
+ <section style={{ marginTop: '2rem' }}>
6277
+ <h2>Shared Models</h2>
6278
+ <p>This user object is typed from @${options.name}/models:</p>
6279
+ <pre style={{ background: '#f0f0f0', padding: '1rem', borderRadius: '8px' }}>
6280
+ {JSON.stringify(exampleUser, null, 2)}
6281
+ </pre>
6282
+ </section>
6283
+
6284
+ <section style={{ marginTop: '2rem' }}>
6285
+ <h2>Next Steps</h2>
6286
+ <ul>
6287
+ <li>Edit <code>apps/web/src/app/page.tsx</code> to customize this page</li>
6288
+ <li>Add API routes in <code>apps/api/src/endpoints/</code></li>
6289
+ <li>Define shared schemas in <code>packages/models/src/</code></li>
6290
+ </ul>
6291
+ </section>
6292
+ </main>
6293
+ );
6294
+ }
6295
+ `;
6296
+ const envLocal = `# API URL (injected automatically in workspace mode)
6297
+ API_URL=http://localhost:3000
6298
+
6299
+ # Other environment variables
6300
+ # NEXT_PUBLIC_API_URL=http://localhost:3000
6301
+ `;
6302
+ const gitignore = `.next/
6303
+ node_modules/
6304
+ .env.local
6305
+ *.log
6306
+ `;
6307
+ return [
6308
+ {
6309
+ path: "apps/web/package.json",
6310
+ content: `${JSON.stringify(packageJson, null, 2)}\n`
6311
+ },
6312
+ {
6313
+ path: "apps/web/next.config.ts",
6314
+ content: nextConfig
6315
+ },
6316
+ {
6317
+ path: "apps/web/tsconfig.json",
6318
+ content: `${JSON.stringify(tsConfig, null, 2)}\n`
6319
+ },
6320
+ {
6321
+ path: "apps/web/src/app/layout.tsx",
6322
+ content: layoutTsx
6323
+ },
6324
+ {
6325
+ path: "apps/web/src/app/page.tsx",
6326
+ content: pageTsx
6327
+ },
6328
+ {
6329
+ path: "apps/web/.env.local",
6330
+ content: envLocal
6331
+ },
6332
+ {
6333
+ path: "apps/web/.gitignore",
6334
+ content: gitignore
6335
+ }
6336
+ ];
4343
6337
  }
4344
6338
 
4345
6339
  //#endregion
@@ -4407,21 +6401,36 @@ function getRunCommand(pkgManager, script) {
4407
6401
  //#endregion
4408
6402
  //#region src/init/index.ts
4409
6403
  /**
6404
+ * Generate a secure random password for database users
6405
+ */
6406
+ function generateDbPassword() {
6407
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
6408
+ }
6409
+ /**
6410
+ * Generate database URL for an app
6411
+ * All apps connect to the same database, but use different users/schemas
6412
+ */
6413
+ function generateDbUrl(appName, password, projectName, host = "localhost", port = 5432) {
6414
+ const userName = appName.replace(/-/g, "_");
6415
+ const dbName = `${projectName.replace(/-/g, "_")}_dev`;
6416
+ return `postgresql://${userName}:${password}@${host}:${port}/${dbName}`;
6417
+ }
6418
+ /**
4410
6419
  * Main init command - scaffolds a new project
4411
6420
  */
4412
6421
  async function initCommand(projectName, options = {}) {
4413
6422
  const cwd = process.cwd();
4414
- const pkgManager = detectPackageManager(cwd);
6423
+ const detectedPkgManager = detectPackageManager(cwd);
4415
6424
  prompts.default.override({});
4416
6425
  const onCancel = () => {
4417
6426
  process.exit(0);
4418
6427
  };
4419
6428
  const answers = await (0, prompts.default)([
4420
6429
  {
4421
- type: projectName ? null : "text",
6430
+ type: projectName || options.name ? null : "text",
4422
6431
  name: "name",
4423
6432
  message: "Project name:",
4424
- initial: "my-api",
6433
+ initial: "my-app",
4425
6434
  validate: (value) => {
4426
6435
  const nameValid = validateProjectName(value);
4427
6436
  if (nameValid !== true) return nameValid;
@@ -4438,21 +6447,33 @@ async function initCommand(projectName, options = {}) {
4438
6447
  initial: 0
4439
6448
  },
4440
6449
  {
4441
- type: options.yes ? null : "confirm",
4442
- name: "telescope",
4443
- message: "Include Telescope (debugging dashboard)?",
4444
- initial: true
6450
+ type: options.yes ? null : "multiselect",
6451
+ name: "services",
6452
+ message: "Services (space to select, enter to confirm):",
6453
+ choices: servicesChoices.map((c) => ({
6454
+ ...c,
6455
+ selected: true
6456
+ })),
6457
+ hint: "- Space to select. Return to submit"
4445
6458
  },
4446
6459
  {
4447
- type: options.yes ? null : "confirm",
4448
- name: "database",
4449
- message: "Include database support (Kysely)?",
4450
- initial: true
6460
+ type: options.yes ? null : "select",
6461
+ name: "packageManager",
6462
+ message: "Package manager:",
6463
+ choices: packageManagerChoices,
6464
+ initial: packageManagerChoices.findIndex((c) => c.value === detectedPkgManager)
6465
+ },
6466
+ {
6467
+ type: options.yes ? null : "select",
6468
+ name: "deployTarget",
6469
+ message: "Deployment target:",
6470
+ choices: deployTargetChoices,
6471
+ initial: 0
4451
6472
  },
4452
6473
  {
4453
- type: (prev) => options.yes ? null : prev ? "confirm" : null,
4454
- name: "studio",
4455
- message: "Include Studio (database browser)?",
6474
+ type: options.yes ? null : "confirm",
6475
+ name: "telescope",
6476
+ message: "Include Telescope (debugging dashboard)?",
4456
6477
  initial: true
4457
6478
  },
4458
6479
  {
@@ -4468,74 +6489,146 @@ async function initCommand(projectName, options = {}) {
4468
6489
  message: "Routes structure:",
4469
6490
  choices: routesStructureChoices,
4470
6491
  initial: 0
4471
- },
4472
- {
4473
- type: options.yes || options.monorepo !== void 0 ? null : "confirm",
4474
- name: "monorepo",
4475
- message: "Setup as monorepo?",
4476
- initial: false
4477
- },
4478
- {
4479
- type: (prev) => (prev === true || options.monorepo) && !options.apiPath ? "text" : null,
4480
- name: "apiPath",
4481
- message: "API app path:",
4482
- initial: "apps/api"
4483
6492
  }
4484
6493
  ], { onCancel });
4485
- const name$1 = projectName || answers.name;
4486
- if (!name$1) process.exit(1);
4487
- if (projectName) {
4488
- const nameValid = validateProjectName(projectName);
4489
- if (nameValid !== true) process.exit(1);
4490
- const dirValid = checkDirectoryExists(projectName, cwd);
4491
- if (dirValid !== true) process.exit(1);
4492
- }
4493
- const monorepo = options.monorepo ?? (options.yes ? false : answers.monorepo ?? false);
4494
- const database = options.yes ? true : answers.database ?? true;
6494
+ const name$1 = projectName || options.name || answers.name;
6495
+ if (!name$1) {
6496
+ console.error("Project name is required");
6497
+ process.exit(1);
6498
+ }
6499
+ if (projectName || options.name) {
6500
+ const nameToValidate = projectName || options.name;
6501
+ const nameValid = validateProjectName(nameToValidate);
6502
+ if (nameValid !== true) {
6503
+ console.error(nameValid);
6504
+ process.exit(1);
6505
+ }
6506
+ const dirValid = checkDirectoryExists(nameToValidate, cwd);
6507
+ if (dirValid !== true) {
6508
+ console.error(dirValid);
6509
+ process.exit(1);
6510
+ }
6511
+ }
6512
+ const template = options.template || answers.template || "api";
6513
+ const isFullstack = isFullstackTemplate(template);
6514
+ const monorepo = isFullstack || options.monorepo || false;
6515
+ const servicesArray = options.yes ? [
6516
+ "db",
6517
+ "cache",
6518
+ "mail"
6519
+ ] : answers.services || [];
6520
+ const services = {
6521
+ db: servicesArray.includes("db"),
6522
+ cache: servicesArray.includes("cache"),
6523
+ mail: servicesArray.includes("mail")
6524
+ };
6525
+ const pkgManager = options.pm ? options.pm : options.yes ? "pnpm" : answers.packageManager ?? detectedPkgManager;
6526
+ const deployTarget = options.yes ? "dokploy" : answers.deployTarget ?? "dokploy";
6527
+ const database = services.db;
4495
6528
  const templateOptions = {
4496
6529
  name: name$1,
4497
- template: options.template || answers.template || "minimal",
6530
+ template,
4498
6531
  telescope: options.yes ? true : answers.telescope ?? true,
4499
6532
  database,
4500
- studio: database && (options.yes ? true : answers.studio ?? true),
6533
+ studio: database,
4501
6534
  loggerType: options.yes ? "pino" : answers.loggerType ?? "pino",
4502
6535
  routesStructure: options.yes ? "centralized-endpoints" : answers.routesStructure ?? "centralized-endpoints",
4503
6536
  monorepo,
4504
- apiPath: monorepo ? options.apiPath ?? answers.apiPath ?? "apps/api" : ""
6537
+ apiPath: monorepo ? options.apiPath ?? "apps/api" : "",
6538
+ packageManager: pkgManager,
6539
+ deployTarget,
6540
+ services
4505
6541
  };
4506
6542
  const targetDir = (0, node_path.join)(cwd, name$1);
4507
- const template = getTemplate(templateOptions.template);
6543
+ const baseTemplate = getTemplate(templateOptions.template);
4508
6544
  const isMonorepo$1 = templateOptions.monorepo;
4509
6545
  const apiPath = templateOptions.apiPath;
6546
+ console.log("\n🚀 Creating your project...\n");
4510
6547
  await (0, node_fs_promises.mkdir)(targetDir, { recursive: true });
4511
6548
  const appDir = isMonorepo$1 ? (0, node_path.join)(targetDir, apiPath) : targetDir;
4512
6549
  if (isMonorepo$1) await (0, node_fs_promises.mkdir)(appDir, { recursive: true });
4513
- const appFiles = [
4514
- ...generatePackageJson(templateOptions, template),
4515
- ...generateConfigFiles(templateOptions, template),
4516
- ...generateEnvFiles(templateOptions, template),
4517
- ...generateSourceFiles(templateOptions, template),
4518
- ...generateDockerFiles(templateOptions, template)
4519
- ];
4520
- const rootFiles = [...generateMonorepoFiles(templateOptions, template), ...generateModelsPackage(templateOptions)];
6550
+ const dbApps = [];
6551
+ if (isFullstack && services.db) dbApps.push({
6552
+ name: "api",
6553
+ password: generateDbPassword()
6554
+ }, {
6555
+ name: "auth",
6556
+ password: generateDbPassword()
6557
+ });
6558
+ const appFiles = baseTemplate ? [
6559
+ ...generatePackageJson(templateOptions, baseTemplate),
6560
+ ...generateConfigFiles(templateOptions, baseTemplate),
6561
+ ...generateEnvFiles(templateOptions, baseTemplate),
6562
+ ...generateSourceFiles(templateOptions, baseTemplate),
6563
+ ...isMonorepo$1 ? [] : generateDockerFiles(templateOptions, baseTemplate, dbApps)
6564
+ ] : [];
6565
+ const dockerFiles = isMonorepo$1 && baseTemplate ? generateDockerFiles(templateOptions, baseTemplate, dbApps) : [];
6566
+ const rootFiles = baseTemplate ? [...generateMonorepoFiles(templateOptions, baseTemplate), ...generateModelsPackage(templateOptions)] : [];
6567
+ const webAppFiles = isFullstack ? generateWebAppFiles(templateOptions) : [];
6568
+ const authAppFiles = isFullstack ? generateAuthAppFiles(templateOptions) : [];
4521
6569
  for (const { path, content } of rootFiles) {
4522
6570
  const fullPath = (0, node_path.join)(targetDir, path);
4523
6571
  await (0, node_fs_promises.mkdir)((0, node_path.dirname)(fullPath), { recursive: true });
4524
6572
  await (0, node_fs_promises.writeFile)(fullPath, content);
4525
6573
  }
6574
+ for (const { path, content } of dockerFiles) {
6575
+ const fullPath = (0, node_path.join)(targetDir, path);
6576
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(fullPath), { recursive: true });
6577
+ await (0, node_fs_promises.writeFile)(fullPath, content);
6578
+ }
4526
6579
  for (const { path, content } of appFiles) {
4527
6580
  const fullPath = (0, node_path.join)(appDir, path);
4528
- const _displayPath = isMonorepo$1 ? `${apiPath}/${path}` : path;
4529
6581
  await (0, node_fs_promises.mkdir)((0, node_path.dirname)(fullPath), { recursive: true });
4530
6582
  await (0, node_fs_promises.writeFile)(fullPath, content);
4531
6583
  }
6584
+ for (const { path, content } of webAppFiles) {
6585
+ const fullPath = (0, node_path.join)(targetDir, path);
6586
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(fullPath), { recursive: true });
6587
+ await (0, node_fs_promises.writeFile)(fullPath, content);
6588
+ }
6589
+ for (const { path, content } of authAppFiles) {
6590
+ const fullPath = (0, node_path.join)(targetDir, path);
6591
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(fullPath), { recursive: true });
6592
+ await (0, node_fs_promises.writeFile)(fullPath, content);
6593
+ }
6594
+ console.log("🔐 Initializing encrypted secrets...\n");
6595
+ const secretServices = [];
6596
+ if (services.db) secretServices.push("postgres");
6597
+ if (services.cache) secretServices.push("redis");
6598
+ const devSecrets = createStageSecrets("development", secretServices);
6599
+ const customSecrets = {
6600
+ NODE_ENV: "development",
6601
+ PORT: "3000",
6602
+ LOG_LEVEL: "debug",
6603
+ JWT_SECRET: `dev-${Date.now()}-${Math.random().toString(36).slice(2)}`
6604
+ };
6605
+ if (isFullstack && dbApps.length > 0) {
6606
+ for (const app of dbApps) {
6607
+ const urlKey = `${app.name.toUpperCase()}_DATABASE_URL`;
6608
+ customSecrets[urlKey] = generateDbUrl(app.name, app.password, name$1);
6609
+ const passwordKey = `${app.name.toUpperCase()}_DB_PASSWORD`;
6610
+ customSecrets[passwordKey] = app.password;
6611
+ }
6612
+ customSecrets.AUTH_PORT = "3002";
6613
+ customSecrets.BETTER_AUTH_SECRET = `better-auth-${Date.now()}-${Math.random().toString(36).slice(2)}`;
6614
+ customSecrets.BETTER_AUTH_URL = "http://localhost:3002";
6615
+ customSecrets.BETTER_AUTH_TRUSTED_ORIGINS = "http://localhost:3000,http://localhost:3001";
6616
+ }
6617
+ devSecrets.custom = customSecrets;
6618
+ await require_storage.writeStageSecrets(devSecrets, targetDir);
6619
+ const keyPath = require_storage.getKeyPath("development", name$1);
6620
+ console.log(` Secrets: .gkm/secrets/development.json (encrypted)`);
6621
+ console.log(` Key: ${keyPath}\n`);
4532
6622
  if (!options.skipInstall) {
6623
+ console.log("\n📦 Installing dependencies...\n");
4533
6624
  try {
4534
6625
  (0, node_child_process.execSync)(getInstallCommand(pkgManager), {
4535
6626
  cwd: targetDir,
4536
6627
  stdio: "inherit"
4537
6628
  });
4538
- } catch {}
6629
+ } catch {
6630
+ console.error("Failed to install dependencies");
6631
+ }
4539
6632
  try {
4540
6633
  (0, node_child_process.execSync)("npx @biomejs/biome format --write --unsafe .", {
4541
6634
  cwd: targetDir,
@@ -4543,124 +6636,52 @@ async function initCommand(projectName, options = {}) {
4543
6636
  });
4544
6637
  } catch {}
4545
6638
  }
4546
- const _devCommand = getRunCommand(pkgManager, "dev");
6639
+ printNextSteps(name$1, templateOptions, pkgManager);
4547
6640
  }
4548
-
4549
- //#endregion
4550
- //#region src/secrets/generator.ts
4551
6641
  /**
4552
- * Generate a secure random password using URL-safe base64 characters.
4553
- * @param length Password length (default: 32)
6642
+ * Print success message with next steps
4554
6643
  */
4555
- function generateSecurePassword(length = 32) {
4556
- return (0, node_crypto.randomBytes)(Math.ceil(length * 3 / 4)).toString("base64url").slice(0, length);
4557
- }
4558
- /** Default service configurations */
4559
- const SERVICE_DEFAULTS = {
4560
- postgres: {
4561
- host: "postgres",
4562
- port: 5432,
4563
- username: "app",
4564
- database: "app"
4565
- },
4566
- redis: {
4567
- host: "redis",
4568
- port: 6379,
4569
- username: "default"
4570
- },
4571
- rabbitmq: {
4572
- host: "rabbitmq",
4573
- port: 5672,
4574
- username: "app",
4575
- vhost: "/"
6644
+ function printNextSteps(projectName, options, pkgManager) {
6645
+ const devCommand$1 = getRunCommand(pkgManager, "dev");
6646
+ const cdCommand = `cd ${projectName}`;
6647
+ console.log(`\n${"─".repeat(50)}`);
6648
+ console.log("\n✅ Project created successfully!\n");
6649
+ console.log("Next steps:\n");
6650
+ console.log(` ${cdCommand}`);
6651
+ if (options.services.db) {
6652
+ console.log(` # Start PostgreSQL (if not running)`);
6653
+ console.log(` docker compose up -d postgres`);
4576
6654
  }
4577
- };
4578
- /**
4579
- * Generate credentials for a specific service.
4580
- */
4581
- function generateServiceCredentials(service) {
4582
- const defaults = SERVICE_DEFAULTS[service];
4583
- return {
4584
- ...defaults,
4585
- password: generateSecurePassword()
4586
- };
4587
- }
4588
- /**
4589
- * Generate credentials for multiple services.
4590
- */
4591
- function generateServicesCredentials(services) {
4592
- const result = {};
4593
- for (const service of services) result[service] = generateServiceCredentials(service);
4594
- return result;
4595
- }
4596
- /**
4597
- * Generate connection URL for PostgreSQL.
4598
- */
4599
- function generatePostgresUrl(creds) {
4600
- const { username, password, host, port, database } = creds;
4601
- return `postgresql://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}`;
4602
- }
4603
- /**
4604
- * Generate connection URL for Redis.
4605
- */
4606
- function generateRedisUrl(creds) {
4607
- const { password, host, port } = creds;
4608
- return `redis://:${encodeURIComponent(password)}@${host}:${port}`;
4609
- }
4610
- /**
4611
- * Generate connection URL for RabbitMQ.
4612
- */
4613
- function generateRabbitmqUrl(creds) {
4614
- const { username, password, host, port, vhost } = creds;
4615
- const encodedVhost = encodeURIComponent(vhost ?? "/");
4616
- return `amqp://${username}:${encodeURIComponent(password)}@${host}:${port}/${encodedVhost}`;
4617
- }
4618
- /**
4619
- * Generate connection URLs from service credentials.
4620
- */
4621
- function generateConnectionUrls(services) {
4622
- const urls = {};
4623
- if (services.postgres) urls.DATABASE_URL = generatePostgresUrl(services.postgres);
4624
- if (services.redis) urls.REDIS_URL = generateRedisUrl(services.redis);
4625
- if (services.rabbitmq) urls.RABBITMQ_URL = generateRabbitmqUrl(services.rabbitmq);
4626
- return urls;
4627
- }
4628
- /**
4629
- * Create a new StageSecrets object with generated credentials.
4630
- */
4631
- function createStageSecrets(stage, services) {
4632
- const now = (/* @__PURE__ */ new Date()).toISOString();
4633
- const serviceCredentials = generateServicesCredentials(services);
4634
- const urls = generateConnectionUrls(serviceCredentials);
4635
- return {
4636
- stage,
4637
- createdAt: now,
4638
- updatedAt: now,
4639
- services: serviceCredentials,
4640
- urls,
4641
- custom: {}
4642
- };
4643
- }
4644
- /**
4645
- * Rotate password for a specific service.
4646
- */
4647
- function rotateServicePassword(secrets, service) {
4648
- const currentCreds = secrets.services[service];
4649
- if (!currentCreds) throw new Error(`Service "${service}" not configured in secrets`);
4650
- const newCreds = {
4651
- ...currentCreds,
4652
- password: generateSecurePassword()
4653
- };
4654
- const newServices = {
4655
- ...secrets.services,
4656
- [service]: newCreds
4657
- };
4658
- return {
4659
- ...secrets,
4660
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4661
- services: newServices,
4662
- urls: generateConnectionUrls(newServices)
4663
- };
6655
+ console.log(` ${devCommand$1}`);
6656
+ console.log("");
6657
+ if (options.monorepo) {
6658
+ console.log("📁 Project structure:");
6659
+ console.log(` ${projectName}/`);
6660
+ console.log(` ├── apps/`);
6661
+ console.log(` │ ├── api/ # Backend API`);
6662
+ if (isFullstackTemplate(options.template)) {
6663
+ console.log(` │ ├── auth/ # Auth service (better-auth)`);
6664
+ console.log(` │ └── web/ # Next.js frontend`);
6665
+ }
6666
+ console.log(` ├── packages/`);
6667
+ console.log(` │ └── models/ # Shared Zod schemas`);
6668
+ console.log(` ├── .gkm/secrets/ # Encrypted secrets`);
6669
+ console.log(` ├── gkm.config.ts # Workspace config`);
6670
+ console.log(` └── turbo.json # Turbo config`);
6671
+ console.log("");
6672
+ }
6673
+ console.log("🔐 Secrets management:");
6674
+ console.log(` gkm secrets:show --stage development # View secrets`);
6675
+ console.log(` gkm secrets:set KEY VALUE --stage development # Add secret`);
6676
+ console.log(` gkm secrets:init --stage production # Create production secrets`);
6677
+ console.log("");
6678
+ if (options.deployTarget === "dokploy") {
6679
+ console.log("🚀 Deployment:");
6680
+ console.log(` ${getRunCommand(pkgManager, "deploy")}`);
6681
+ console.log("");
6682
+ }
6683
+ console.log("📚 Documentation: https://docs.geekmidas.dev");
6684
+ console.log("");
4664
6685
  }
4665
6686
 
4666
6687
  //#endregion
@@ -4849,11 +6870,57 @@ function maskUrl(url) {
4849
6870
  }
4850
6871
  }
4851
6872
 
6873
+ //#endregion
6874
+ //#region src/test/index.ts
6875
+ /**
6876
+ * Run tests with secrets loaded from the specified stage.
6877
+ * Secrets are decrypted and injected into the environment.
6878
+ */
6879
+ async function testCommand(options = {}) {
6880
+ const stage = options.stage ?? "development";
6881
+ console.log(`\n🧪 Running tests with ${stage} secrets...\n`);
6882
+ let envVars = {};
6883
+ try {
6884
+ const secrets = await require_storage.readStageSecrets(stage);
6885
+ if (secrets) {
6886
+ envVars = require_storage.toEmbeddableSecrets(secrets);
6887
+ console.log(` Loaded ${Object.keys(envVars).length} secrets from ${stage}\n`);
6888
+ } else console.log(` No secrets found for ${stage}, running without secrets\n`);
6889
+ } catch (error) {
6890
+ if (error instanceof Error && error.message.includes("key not found")) console.log(` Decryption key not found for ${stage}, running without secrets\n`);
6891
+ else throw error;
6892
+ }
6893
+ const args = [];
6894
+ if (options.run) args.push("run");
6895
+ else if (options.watch) args.push("--watch");
6896
+ if (options.coverage) args.push("--coverage");
6897
+ if (options.ui) args.push("--ui");
6898
+ if (options.pattern) args.push(options.pattern);
6899
+ const vitestProcess = (0, node_child_process.spawn)("npx", ["vitest", ...args], {
6900
+ cwd: process.cwd(),
6901
+ stdio: "inherit",
6902
+ env: {
6903
+ ...process.env,
6904
+ ...envVars,
6905
+ NODE_ENV: "test"
6906
+ }
6907
+ });
6908
+ return new Promise((resolve$1, reject) => {
6909
+ vitestProcess.on("close", (code) => {
6910
+ if (code === 0) resolve$1();
6911
+ else reject(new Error(`Tests failed with exit code ${code}`));
6912
+ });
6913
+ vitestProcess.on("error", (error) => {
6914
+ reject(error);
6915
+ });
6916
+ });
6917
+ }
6918
+
4852
6919
  //#endregion
4853
6920
  //#region src/index.ts
4854
6921
  const program = new commander.Command();
4855
6922
  program.name("gkm").description("GeekMidas backend framework CLI").version(package_default.version).option("--cwd <path>", "Change working directory");
4856
- program.command("init").description("Scaffold a new project").argument("[name]", "Project name").option("--template <template>", "Project template (minimal, api, serverless, worker)").option("--skip-install", "Skip dependency installation", false).option("-y, --yes", "Skip prompts, use defaults", false).option("--monorepo", "Setup as monorepo with packages/models", false).option("--api-path <path>", "API app path in monorepo (default: apps/api)").action(async (name$1, options) => {
6923
+ program.command("init").description("Scaffold a new project").argument("[name]", "Project name").option("--template <template>", "Project template (minimal, api, serverless, worker)").option("--skip-install", "Skip dependency installation", false).option("-y, --yes", "Skip prompts, use defaults", false).option("--monorepo", "Setup as monorepo with packages/models", false).option("--api-path <path>", "API app path in monorepo (default: apps/api)").option("--pm <manager>", "Package manager (pnpm, npm, yarn, bun)").action(async (name$1, options) => {
4857
6924
  try {
4858
6925
  const globalOptions = program.opts();
4859
6926
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -4910,6 +6977,19 @@ program.command("dev").description("Start development server with automatic relo
4910
6977
  process.exit(1);
4911
6978
  }
4912
6979
  });
6980
+ program.command("test").description("Run tests with secrets loaded from environment").option("--stage <stage>", "Stage to load secrets from", "development").option("--run", "Run tests once without watch mode").option("--watch", "Enable watch mode").option("--coverage", "Generate coverage report").option("--ui", "Open Vitest UI").argument("[pattern]", "Pattern to filter tests").action(async (pattern, options) => {
6981
+ try {
6982
+ const globalOptions = program.opts();
6983
+ if (globalOptions.cwd) process.chdir(globalOptions.cwd);
6984
+ await testCommand({
6985
+ ...options,
6986
+ pattern
6987
+ });
6988
+ } catch (error) {
6989
+ console.error(error instanceof Error ? error.message : "Command failed");
6990
+ process.exit(1);
6991
+ }
6992
+ });
4913
6993
  program.command("cron").description("Manage cron jobs").action(() => {
4914
6994
  const globalOptions = program.opts();
4915
6995
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);