@geekmidas/cli 0.18.0 → 0.20.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 +2640 -564
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.mjs +2635 -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 +219 -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 -1
  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.18.0";
30
+ var version = "0.20.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
@@ -1692,8 +2409,14 @@ WORKDIR /app
1692
2409
  # Copy source (deps already installed)
1693
2410
  COPY . .
1694
2411
 
1695
- # Build production server using CLI from project dependencies
1696
- RUN ${pm.exec} gkm 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
1697
2420
 
1698
2421
  # Stage 3: Production
1699
2422
  FROM ${baseImage} AS runner
@@ -1774,8 +2497,14 @@ WORKDIR /app
1774
2497
  # Copy pruned source
1775
2498
  COPY --from=pruner /app/out/full/ ./
1776
2499
 
1777
- # Build production server using CLI from project dependencies
1778
- RUN ${pm.exec} gkm 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
1779
2508
 
1780
2509
  # Stage 4: Production
1781
2510
  FROM ${baseImage} AS runner
@@ -1917,8 +2646,8 @@ function resolveDockerConfig$1(config) {
1917
2646
  const docker = config.docker ?? {};
1918
2647
  let defaultImageName = "api";
1919
2648
  try {
1920
- const pkg = require(`${process.cwd()}/package.json`);
1921
- 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(/^@[^/]+\//, "");
1922
2651
  } catch {}
1923
2652
  return {
1924
2653
  registry: docker.registry ?? "",
@@ -1928,20 +2657,194 @@ function resolveDockerConfig$1(config) {
1928
2657
  compose: docker.compose
1929
2658
  };
1930
2659
  }
1931
-
1932
- //#endregion
1933
- //#region src/docker/index.ts
1934
- const logger$5 = console;
1935
2660
  /**
1936
- * Docker command implementation
1937
- * Generates Dockerfile, docker-compose.yml, and related files
1938
- *
1939
- * Default: Multi-stage Dockerfile that builds from source inside Docker
1940
- * --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
1941
2664
  */
1942
- async function dockerCommand(options) {
1943
- const config = await require_config.loadConfig();
1944
- 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);
1945
2848
  const serverConfig = typeof config.providers?.server === "object" ? config.providers.server : void 0;
1946
2849
  const healthCheckPath = serverConfig?.production?.healthCheck ?? "/health";
1947
2850
  const useSlim = options.slim === true;
@@ -1962,9 +2865,9 @@ async function dockerCommand(options) {
1962
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");
1963
2866
  let turboPackage = options.turboPackage ?? dockerConfig.imageName;
1964
2867
  if (useTurbo && !options.turboPackage) try {
1965
- const pkg = require(`${process.cwd()}/package.json`);
1966
- if (pkg.name) {
1967
- turboPackage = pkg.name;
2868
+ const pkg$1 = require(`${process.cwd()}/package.json`);
2869
+ if (pkg$1.name) {
2870
+ turboPackage = pkg$1.name;
1968
2871
  logger$5.log(` Turbo package: ${turboPackage}`);
1969
2872
  }
1970
2873
  } catch {}
@@ -2081,6 +2984,85 @@ async function pushDockerImage(imageName, options) {
2081
2984
  throw new Error(`Failed to push Docker image: ${error instanceof Error ? error.message : "Unknown error"}`);
2082
2985
  }
2083
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
+ }
2084
3066
 
2085
3067
  //#endregion
2086
3068
  //#region src/deploy/docker.ts
@@ -2092,8 +3074,8 @@ function getAppNameFromCwd() {
2092
3074
  const packageJsonPath = (0, node_path.join)(process.cwd(), "package.json");
2093
3075
  if (!(0, node_fs.existsSync)(packageJsonPath)) return void 0;
2094
3076
  try {
2095
- const pkg = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
2096
- 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(/^@[^/]+\//, "");
2097
3079
  } catch {}
2098
3080
  return void 0;
2099
3081
  }
@@ -2109,8 +3091,8 @@ function getAppNameFromPackageJson() {
2109
3091
  const packageJsonPath = (0, node_path.join)(projectRoot, "package.json");
2110
3092
  if (!(0, node_fs.existsSync)(packageJsonPath)) return void 0;
2111
3093
  try {
2112
- const pkg = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
2113
- 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(/^@[^/]+\//, "");
2114
3096
  } catch {}
2115
3097
  return void 0;
2116
3098
  }
@@ -2583,7 +3565,7 @@ async function provisionServices(api, projectId, environmentId, appName, service
2583
3565
  */
2584
3566
  async function ensureDokploySetup(config, dockerConfig, stage, services) {
2585
3567
  logger$1.log("\n🔧 Checking Dokploy setup...");
2586
- 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"));
2587
3569
  const existingSecrets = await readStageSecrets$1(stage);
2588
3570
  const existingUrls = {
2589
3571
  DATABASE_URL: existingSecrets?.urls?.DATABASE_URL,
@@ -2761,94 +3743,335 @@ function generateTag(stage) {
2761
3743
  return `${stage}-${timestamp}`;
2762
3744
  }
2763
3745
  /**
2764
- * 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
2765
3752
  */
2766
- async function deployCommand(options) {
2767
- const { provider, stage, tag, skipPush, skipBuild } = options;
2768
- 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...`);
2769
3757
  logger$1.log(` Stage: ${stage}`);
2770
- const config = await require_config.loadConfig();
2771
3758
  const imageTag = tag ?? generateTag(stage);
2772
3759
  logger$1.log(` Tag: ${imageTag}`);
2773
- const dockerConfig = resolveDockerConfig(config);
2774
- const imageName = dockerConfig.imageName;
2775
- const registry = dockerConfig.registry;
2776
- const imageRef = registry ? `${registry}/${imageName}:${imageTag}` : `${imageName}:${imageTag}`;
2777
- let dokployConfig;
2778
- let finalRegistry = registry;
2779
- if (provider === "dokploy") {
2780
- const composeServices = config.docker?.compose?.services;
2781
- logger$1.log(`\n🔍 Docker compose config: ${JSON.stringify(config.docker?.compose)}`);
2782
- const dockerServices = composeServices ? Array.isArray(composeServices) ? {
2783
- postgres: composeServices.includes("postgres"),
2784
- redis: composeServices.includes("redis"),
2785
- rabbitmq: composeServices.includes("rabbitmq")
2786
- } : {
2787
- postgres: Boolean(composeServices.postgres),
2788
- redis: Boolean(composeServices.redis),
2789
- rabbitmq: Boolean(composeServices.rabbitmq)
2790
- } : void 0;
2791
- const setupResult = await ensureDokploySetup(config, dockerConfig, stage, dockerServices);
2792
- dokployConfig = setupResult.config;
2793
- finalRegistry = dokployConfig.registry ?? dockerConfig.registry;
2794
- if (setupResult.serviceUrls) {
2795
- const { readStageSecrets: readStageSecrets$1, writeStageSecrets: writeStageSecrets$1, initStageSecrets } = await Promise.resolve().then(() => require("./storage-UfyTn7Zm.cjs"));
2796
- let secrets = await readStageSecrets$1(stage);
2797
- if (!secrets) {
2798
- logger$1.log(` Creating secrets file for stage "${stage}"...`);
2799
- secrets = initStageSecrets(stage);
2800
- }
2801
- let updated = false;
2802
- const urlFields = [
2803
- "DATABASE_URL",
2804
- "REDIS_URL",
2805
- "RABBITMQ_URL"
2806
- ];
2807
- for (const [key, value] of Object.entries(setupResult.serviceUrls)) {
2808
- if (!value) continue;
2809
- if (urlFields.includes(key)) {
2810
- const urlKey = key;
2811
- if (!secrets.urls[urlKey]) {
2812
- secrets.urls[urlKey] = value;
2813
- logger$1.log(` Saved ${key} to secrets.urls`);
2814
- updated = true;
2815
- }
2816
- } else if (!secrets.custom[key]) {
2817
- secrets.custom[key] = value;
2818
- logger$1.log(` Saved ${key} to secrets.custom`);
2819
- updated = true;
2820
- }
2821
- }
2822
- 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;
2823
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`);
2824
3781
  }
2825
- let masterKey;
2826
- if (!skipBuild) {
2827
- logger$1.log(`\n📦 Building for production...`);
2828
- const buildResult = await buildCommand({
2829
- provider: "server",
2830
- production: true,
2831
- stage
2832
- });
2833
- masterKey = buildResult.masterKey;
2834
- } else logger$1.log(`\n⏭️ Skipping build (--skip-build)`);
2835
- let result;
2836
- switch (provider) {
2837
- case "docker": {
2838
- result = await deployDocker({
2839
- stage,
2840
- tag: imageTag,
2841
- skipPush,
2842
- masterKey,
2843
- config: dockerConfig
2844
- });
2845
- break;
2846
- }
2847
- case "dokploy": {
2848
- if (!dokployConfig) throw new Error("Dokploy config not initialized");
2849
- const finalImageRef = finalRegistry ? `${finalRegistry}/${imageName}:${imageTag}` : `${imageName}:${imageTag}`;
2850
- await deployDocker({
2851
- 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,
2852
4075
  tag: imageTag,
2853
4076
  skipPush: false,
2854
4077
  masterKey,
@@ -2875,10 +4098,361 @@ async function deployCommand(options) {
2875
4098
  };
2876
4099
  break;
2877
4100
  }
2878
- default: throw new Error(`Unknown deploy provider: ${provider}\nSupported providers: docker, dokploy, aws-lambda`);
2879
- }
2880
- logger$1.log("\n✅ Deployment complete!");
2881
- 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
+ ];
2882
4456
  }
2883
4457
 
2884
4458
  //#endregion
@@ -2890,6 +4464,7 @@ function generateConfigFiles(options, template) {
2890
4464
  const { telescope, studio, routesStructure } = options;
2891
4465
  const isServerless = template.name === "serverless";
2892
4466
  const hasWorker = template.name === "worker";
4467
+ const isFullstack = options.template === "fullstack";
2893
4468
  const getRoutesGlob = () => {
2894
4469
  switch (routesStructure) {
2895
4470
  case "centralized-endpoints": return "./src/endpoints/**/*.ts";
@@ -2897,6 +4472,14 @@ function generateConfigFiles(options, template) {
2897
4472
  case "domain-based": return "./src/**/routes/*.ts";
2898
4473
  }
2899
4474
  };
4475
+ if (isFullstack) return generateSingleAppConfigFiles(options, template, {
4476
+ telescope,
4477
+ studio,
4478
+ routesStructure,
4479
+ isServerless,
4480
+ hasWorker,
4481
+ getRoutesGlob
4482
+ });
2900
4483
  let gkmConfig = `import { defineConfig } from '@geekmidas/cli/config';
2901
4484
 
2902
4485
  export default defineConfig({
@@ -2925,8 +4508,7 @@ export default defineConfig({
2925
4508
  const tsConfig = options.monorepo ? {
2926
4509
  extends: "../../tsconfig.json",
2927
4510
  compilerOptions: {
2928
- outDir: "./dist",
2929
- rootDir: "./src",
4511
+ noEmit: true,
2930
4512
  baseUrl: ".",
2931
4513
  paths: { [`@${options.name}/*`]: ["../../packages/*/src"] }
2932
4514
  },
@@ -2959,7 +4541,7 @@ export default defineConfig({
2959
4541
  content: `${JSON.stringify(tsConfig, null, 2)}\n`
2960
4542
  }];
2961
4543
  const biomeConfig = {
2962
- $schema: "https://biomejs.dev/schemas/1.9.4/schema.json",
4544
+ $schema: "https://biomejs.dev/schemas/2.3.0/schema.json",
2963
4545
  vcs: {
2964
4546
  enabled: true,
2965
4547
  clientKind: "git",
@@ -3042,23 +4624,46 @@ export default defineConfig({
3042
4624
  }
3043
4625
  ];
3044
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
+ }
3045
4643
 
3046
4644
  //#endregion
3047
4645
  //#region src/init/generators/docker.ts
3048
4646
  /**
3049
4647
  * Generate docker-compose.yml based on template and options
3050
4648
  */
3051
- function generateDockerFiles(options, template) {
4649
+ function generateDockerFiles(options, template, dbApps) {
3052
4650
  const { database } = options;
3053
4651
  const isServerless = template.name === "serverless";
3054
4652
  const hasWorker = template.name === "worker";
4653
+ const isFullstack = options.template === "fullstack";
3055
4654
  const services = [];
3056
4655
  const volumes = [];
4656
+ const files = [];
3057
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` : "";
3058
4663
  services.push(` postgres:
3059
4664
  image: postgres:16-alpine
3060
4665
  container_name: ${options.name}-postgres
3061
- restart: unless-stopped
4666
+ restart: unless-stopped${envFile}
3062
4667
  environment:
3063
4668
  POSTGRES_USER: postgres
3064
4669
  POSTGRES_PASSWORD: postgres
@@ -3066,13 +4671,23 @@ function generateDockerFiles(options, template) {
3066
4671
  ports:
3067
4672
  - '5432:5432'
3068
4673
  volumes:
3069
- - postgres_data:/var/lib/postgresql/data
4674
+ - postgres_data:/var/lib/postgresql/data${initVolume}
3070
4675
  healthcheck:
3071
4676
  test: ['CMD-SHELL', 'pg_isready -U postgres']
3072
4677
  interval: 5s
3073
4678
  timeout: 5s
3074
4679
  retries: 5`);
3075
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
+ }
3076
4691
  }
3077
4692
  if (isServerless) {
3078
4693
  services.push(` redis:
@@ -3148,105 +4763,85 @@ ${services.join("\n\n")}
3148
4763
  volumes:
3149
4764
  ${volumes.join("\n")}
3150
4765
  `;
3151
- return [{
4766
+ files.push({
3152
4767
  path: "docker-compose.yml",
3153
4768
  content: dockerCompose
3154
- }];
4769
+ });
4770
+ return files;
3155
4771
  }
3156
-
3157
- //#endregion
3158
- //#region src/init/generators/env.ts
3159
4772
  /**
3160
- * Generate environment files (.env, .env.example, .env.development, .env.test, .gitignore)
4773
+ * Generate .env file for docker-compose with database passwords
3161
4774
  */
3162
- function generateEnvFiles(options, template) {
3163
- const { database } = options;
3164
- const isServerless = template.name === "serverless";
3165
- const hasWorker = template.name === "worker";
3166
- let baseEnv = `# Application
3167
- NODE_ENV=development
3168
- PORT=3000
3169
- LOG_LEVEL=info
3170
- `;
3171
- if (isServerless) baseEnv = `# AWS
3172
- STAGE=dev
3173
- AWS_REGION=us-east-1
3174
- LOG_LEVEL=info
3175
- `;
3176
- if (database) baseEnv += `
3177
- # Database
3178
- DATABASE_URL=postgresql://user:password@localhost:5432/mydb
3179
- `;
3180
- if (hasWorker) baseEnv += `
3181
- # Message Queue
3182
- RABBITMQ_URL=amqp://localhost:5672
3183
- `;
3184
- baseEnv += `
3185
- # Authentication
3186
- JWT_SECRET=your-secret-key-change-in-production
3187
- `;
3188
- let devEnv = `# Development Environment
3189
- NODE_ENV=development
3190
- PORT=3000
3191
- LOG_LEVEL=debug
3192
- `;
3193
- if (isServerless) devEnv = `# Development Environment
3194
- STAGE=dev
3195
- AWS_REGION=us-east-1
3196
- LOG_LEVEL=debug
3197
- `;
3198
- if (database) devEnv += `
3199
- # Database
3200
- DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mydb_dev
3201
- `;
3202
- if (hasWorker) devEnv += `
3203
- # Message Queue
3204
- RABBITMQ_URL=amqp://localhost:5672
3205
- `;
3206
- devEnv += `
3207
- # Authentication
3208
- JWT_SECRET=dev-secret-not-for-production
3209
- `;
3210
- let testEnv = `# Test Environment
3211
- NODE_ENV=test
3212
- PORT=3001
3213
- LOG_LEVEL=error
3214
- `;
3215
- if (isServerless) testEnv = `# Test Environment
3216
- STAGE=test
3217
- AWS_REGION=us-east-1
3218
- 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")}
3219
4784
  `;
3220
- if (database) testEnv += `
3221
- # Database
3222
- 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
3223
4807
  `;
3224
- if (hasWorker) testEnv += `
3225
- # Message Queue
3226
- 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
3227
4821
  `;
3228
- testEnv += `
3229
- # Authentication
3230
- 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!"
3231
4832
  `;
3232
- const files = [
3233
- {
3234
- path: ".env.example",
3235
- content: baseEnv
3236
- },
3237
- {
3238
- path: ".env",
3239
- content: baseEnv
3240
- },
3241
- {
3242
- path: ".env.development",
3243
- content: devEnv
3244
- },
3245
- {
3246
- path: ".env.test",
3247
- content: testEnv
3248
- }
3249
- ];
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 = [];
3250
4845
  if (!options.monorepo) {
3251
4846
  const gitignore = `# Dependencies
3252
4847
  node_modules/
@@ -3255,7 +4850,7 @@ node_modules/
3255
4850
  dist/
3256
4851
  .gkm/
3257
4852
 
3258
- # Environment
4853
+ # Environment (legacy - use gkm secrets instead)
3259
4854
  .env
3260
4855
  .env.local
3261
4856
  .env.*.local
@@ -3324,6 +4919,8 @@ function generateModelsPackage(options) {
3324
4919
  const tsConfig = {
3325
4920
  extends: "../../tsconfig.json",
3326
4921
  compilerOptions: {
4922
+ declaration: true,
4923
+ declarationMap: true,
3327
4924
  outDir: "./dist",
3328
4925
  rootDir: "./src"
3329
4926
  },
@@ -3410,23 +5007,27 @@ export type UpdateUser = z.infer<typeof updateUserSchema>;
3410
5007
  */
3411
5008
  function generateMonorepoFiles(options, _template) {
3412
5009
  if (!options.monorepo) return [];
5010
+ const isFullstack = options.template === "fullstack";
3413
5011
  const rootPackageJson = {
3414
5012
  name: options.name,
3415
5013
  version: "0.0.1",
3416
5014
  private: true,
3417
5015
  type: "module",
5016
+ packageManager: "pnpm@10.13.1",
3418
5017
  scripts: {
3419
- dev: "turbo dev",
3420
- build: "turbo build",
3421
- test: "turbo test",
3422
- "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",
3423
5022
  typecheck: "turbo typecheck",
3424
5023
  lint: "biome lint .",
3425
5024
  fmt: "biome format . --write",
3426
- "fmt:check": "biome format ."
5025
+ "fmt:check": "biome format .",
5026
+ ...options.deployTarget === "dokploy" ? { deploy: "gkm deploy --provider dokploy --stage production" } : {}
3427
5027
  },
3428
5028
  devDependencies: {
3429
- "@biomejs/biome": "~1.9.4",
5029
+ "@biomejs/biome": "~2.3.0",
5030
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3430
5031
  turbo: "~2.3.0",
3431
5032
  typescript: "~5.8.2",
3432
5033
  vitest: "~4.0.0"
@@ -3439,7 +5040,7 @@ function generateMonorepoFiles(options, _template) {
3439
5040
  - 'packages/*'
3440
5041
  `;
3441
5042
  const biomeConfig = {
3442
- $schema: "https://biomejs.dev/schemas/1.9.4/schema.json",
5043
+ $schema: "https://biomejs.dev/schemas/2.3.0/schema.json",
3443
5044
  vcs: {
3444
5045
  enabled: true,
3445
5046
  clientKind: "git",
@@ -3514,6 +5115,7 @@ dist/
3514
5115
  .env
3515
5116
  .env.local
3516
5117
  .env.*.local
5118
+ docker/.env
3517
5119
 
3518
5120
  # IDE
3519
5121
  .idea/
@@ -3550,14 +5152,27 @@ coverage/
3550
5152
  esModuleInterop: true,
3551
5153
  skipLibCheck: true,
3552
5154
  forceConsistentCasingInFileNames: true,
3553
- resolveJsonModule: true,
3554
- declaration: true,
3555
- declarationMap: true,
3556
- composite: true
5155
+ resolveJsonModule: true
3557
5156
  },
3558
5157
  exclude: ["node_modules", "dist"]
3559
5158
  };
3560
- 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 = [
3561
5176
  {
3562
5177
  path: "package.json",
3563
5178
  content: `${JSON.stringify(rootPackageJson, null, 2)}\n`
@@ -3578,11 +5193,100 @@ coverage/
3578
5193
  path: "turbo.json",
3579
5194
  content: `${JSON.stringify(turboConfig, null, 2)}\n`
3580
5195
  },
5196
+ {
5197
+ path: "vitest.config.ts",
5198
+ content: vitestConfig
5199
+ },
3581
5200
  {
3582
5201
  path: ".gitignore",
3583
5202
  content: gitignore
3584
5203
  }
3585
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;
3586
5290
  }
3587
5291
 
3588
5292
  //#endregion
@@ -3591,18 +5295,18 @@ const apiTemplate = {
3591
5295
  name: "api",
3592
5296
  description: "Full API with auth, database, services",
3593
5297
  dependencies: {
3594
- "@geekmidas/constructs": "workspace:*",
3595
- "@geekmidas/envkit": "workspace:*",
3596
- "@geekmidas/logger": "workspace:*",
3597
- "@geekmidas/services": "workspace:*",
3598
- "@geekmidas/errors": "workspace:*",
3599
- "@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"],
3600
5304
  hono: "~4.8.2",
3601
5305
  pino: "~9.6.0"
3602
5306
  },
3603
5307
  devDependencies: {
3604
- "@biomejs/biome": "~1.9.4",
3605
- "@geekmidas/cli": "workspace:*",
5308
+ "@biomejs/biome": "~2.3.0",
5309
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3606
5310
  "@types/node": "~22.0.0",
3607
5311
  tsx: "~4.20.0",
3608
5312
  turbo: "~2.3.0",
@@ -3639,18 +5343,17 @@ export const logger = createLogger();
3639
5343
  const files = [
3640
5344
  {
3641
5345
  path: "src/config/env.ts",
3642
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5346
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5347
+ import { EnvironmentParser } from '@geekmidas/envkit';
3643
5348
 
3644
- export const envParser = new EnvironmentParser(process.env);
5349
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
3645
5350
 
5351
+ // Global config - only minimal shared values
5352
+ // Service-specific config should be parsed in each service
3646
5353
  export const config = envParser
3647
5354
  .create((get) => ({
3648
- port: get('PORT').string().transform(Number).default(3000),
3649
- nodeEnv: get('NODE_ENV').string().default('development'),
3650
- jwtSecret: get('JWT_SECRET').string().default('change-me-in-production'),${options.database ? `
3651
- database: {
3652
- url: get('DATABASE_URL').string().default('postgresql://localhost:5432/mydb'),
3653
- },` : ""}
5355
+ nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
5356
+ stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
3654
5357
  }))
3655
5358
  .parse();
3656
5359
  `
@@ -3663,7 +5366,7 @@ export const config = envParser
3663
5366
  path: getRoutePath("health.ts"),
3664
5367
  content: `import { e } from '@geekmidas/constructs/endpoints';
3665
5368
 
3666
- export default e
5369
+ export const healthEndpoint = e
3667
5370
  .get('/health')
3668
5371
  .handle(async () => ({
3669
5372
  status: 'ok',
@@ -3675,7 +5378,7 @@ export default e
3675
5378
  path: getRoutePath("users/list.ts"),
3676
5379
  content: `import { e } from '@geekmidas/constructs/endpoints';
3677
5380
 
3678
- export default e
5381
+ export const listUsersEndpoint = e
3679
5382
  .get('/users')
3680
5383
  .handle(async () => ({
3681
5384
  users: [
@@ -3690,7 +5393,7 @@ export default e
3690
5393
  content: `import { e } from '@geekmidas/constructs/endpoints';
3691
5394
  import { z } from 'zod';
3692
5395
 
3693
- export default e
5396
+ export const getUserEndpoint = e
3694
5397
  .get('/users/:id')
3695
5398
  .params(z.object({ id: z.string() }))
3696
5399
  .handle(async ({ params }) => ({
@@ -3703,7 +5406,7 @@ export default e
3703
5406
  ];
3704
5407
  if (options.database) files.push({
3705
5408
  path: "src/services/database.ts",
3706
- content: `import type { Service } from '@geekmidas/services';
5409
+ content: `import type { Service, ServiceRegisterOptions } from '@geekmidas/services';
3707
5410
  import { Kysely, PostgresDialect } from 'kysely';
3708
5411
  import pg from 'pg';
3709
5412
 
@@ -3719,18 +5422,24 @@ export interface Database {
3719
5422
 
3720
5423
  export const databaseService = {
3721
5424
  serviceName: 'database' as const,
3722
- async register(envParser) {
5425
+ async register({ envParser, context }: ServiceRegisterOptions) {
5426
+ const logger = context.getLogger();
5427
+ logger.info('Connecting to database');
5428
+
3723
5429
  const config = envParser
3724
5430
  .create((get) => ({
3725
5431
  url: get('DATABASE_URL').string(),
3726
5432
  }))
3727
5433
  .parse();
3728
5434
 
3729
- return new Kysely<Database>({
5435
+ const db = new Kysely<Database>({
3730
5436
  dialect: new PostgresDialect({
3731
5437
  pool: new pg.Pool({ connectionString: config.url }),
3732
5438
  }),
3733
5439
  });
5440
+
5441
+ logger.info('Database connection established');
5442
+ return db;
3734
5443
  },
3735
5444
  } satisfies Service<'database', Kysely<Database>>;
3736
5445
  `
@@ -3751,13 +5460,20 @@ export const telescope = new Telescope({
3751
5460
  content: `import { Direction, InMemoryMonitoringStorage, Studio } from '@geekmidas/studio';
3752
5461
  import { Kysely, PostgresDialect } from 'kysely';
3753
5462
  import pg from 'pg';
3754
- import type { Database } from '../services/database';
3755
- 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();
3756
5472
 
3757
5473
  // Create a Kysely instance for Studio
3758
5474
  const db = new Kysely<Database>({
3759
5475
  dialect: new PostgresDialect({
3760
- pool: new pg.Pool({ connectionString: config.database.url }),
5476
+ pool: new pg.Pool({ connectionString: studioConfig.databaseUrl }),
3761
5477
  }),
3762
5478
  });
3763
5479
 
@@ -3783,15 +5499,15 @@ const minimalTemplate = {
3783
5499
  name: "minimal",
3784
5500
  description: "Basic health endpoint",
3785
5501
  dependencies: {
3786
- "@geekmidas/constructs": "workspace:*",
3787
- "@geekmidas/envkit": "workspace:*",
3788
- "@geekmidas/logger": "workspace:*",
5502
+ "@geekmidas/constructs": GEEKMIDAS_VERSIONS["@geekmidas/constructs"],
5503
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
5504
+ "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
3789
5505
  hono: "~4.8.2",
3790
5506
  pino: "~9.6.0"
3791
5507
  },
3792
5508
  devDependencies: {
3793
- "@biomejs/biome": "~1.9.4",
3794
- "@geekmidas/cli": "workspace:*",
5509
+ "@biomejs/biome": "~2.3.0",
5510
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3795
5511
  "@types/node": "~22.0.0",
3796
5512
  tsx: "~4.20.0",
3797
5513
  turbo: "~2.3.0",
@@ -3824,14 +5540,17 @@ export const logger = createLogger();
3824
5540
  const files = [
3825
5541
  {
3826
5542
  path: "src/config/env.ts",
3827
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5543
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5544
+ import { EnvironmentParser } from '@geekmidas/envkit';
3828
5545
 
3829
- export const envParser = new EnvironmentParser(process.env);
5546
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
3830
5547
 
5548
+ // Global config - only minimal shared values
5549
+ // Service-specific config should be parsed in each service
3831
5550
  export const config = envParser
3832
5551
  .create((get) => ({
3833
- port: get('PORT').string().transform(Number).default(3000),
3834
- 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'),
3835
5554
  }))
3836
5555
  .parse();
3837
5556
  `
@@ -3844,7 +5563,7 @@ export const config = envParser
3844
5563
  path: getRoutePath("health.ts"),
3845
5564
  content: `import { e } from '@geekmidas/constructs/endpoints';
3846
5565
 
3847
- export default e
5566
+ export const healthEndpoint = e
3848
5567
  .get('/health')
3849
5568
  .handle(async () => ({
3850
5569
  status: 'ok',
@@ -3853,27 +5572,9 @@ export default e
3853
5572
  `
3854
5573
  }
3855
5574
  ];
3856
- if (options.database) {
3857
- files[0] = {
3858
- path: "src/config/env.ts",
3859
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
3860
-
3861
- export const envParser = new EnvironmentParser(process.env);
3862
-
3863
- export const config = envParser
3864
- .create((get) => ({
3865
- port: get('PORT').string().transform(Number).default(3000),
3866
- nodeEnv: get('NODE_ENV').string().default('development'),
3867
- database: {
3868
- url: get('DATABASE_URL').string().default('postgresql://localhost:5432/mydb'),
3869
- },
3870
- }))
3871
- .parse();
3872
- `
3873
- };
3874
- files.push({
3875
- path: "src/services/database.ts",
3876
- 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';
3877
5578
  import { Kysely, PostgresDialect } from 'kysely';
3878
5579
  import pg from 'pg';
3879
5580
 
@@ -3884,23 +5585,28 @@ export interface Database {
3884
5585
 
3885
5586
  export const databaseService = {
3886
5587
  serviceName: 'database' as const,
3887
- async register(envParser) {
5588
+ async register({ envParser, context }: ServiceRegisterOptions) {
5589
+ const logger = context.getLogger();
5590
+ logger.info('Connecting to database');
5591
+
3888
5592
  const config = envParser
3889
5593
  .create((get) => ({
3890
5594
  url: get('DATABASE_URL').string(),
3891
5595
  }))
3892
5596
  .parse();
3893
5597
 
3894
- return new Kysely<Database>({
5598
+ const db = new Kysely<Database>({
3895
5599
  dialect: new PostgresDialect({
3896
5600
  pool: new pg.Pool({ connectionString: config.url }),
3897
5601
  }),
3898
5602
  });
5603
+
5604
+ logger.info('Database connection established');
5605
+ return db;
3899
5606
  },
3900
5607
  } satisfies Service<'database', Kysely<Database>>;
3901
5608
  `
3902
- });
3903
- }
5609
+ });
3904
5610
  if (options.telescope) files.push({
3905
5611
  path: "src/config/telescope.ts",
3906
5612
  content: `import { Telescope } from '@geekmidas/telescope';
@@ -3917,13 +5623,20 @@ export const telescope = new Telescope({
3917
5623
  content: `import { Direction, InMemoryMonitoringStorage, Studio } from '@geekmidas/studio';
3918
5624
  import { Kysely, PostgresDialect } from 'kysely';
3919
5625
  import pg from 'pg';
3920
- import type { Database } from '../services/database';
3921
- 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();
3922
5635
 
3923
5636
  // Create a Kysely instance for Studio
3924
5637
  const db = new Kysely<Database>({
3925
5638
  dialect: new PostgresDialect({
3926
- pool: new pg.Pool({ connectionString: config.database.url }),
5639
+ pool: new pg.Pool({ connectionString: studioConfig.databaseUrl }),
3927
5640
  }),
3928
5641
  });
3929
5642
 
@@ -3949,16 +5662,16 @@ const serverlessTemplate = {
3949
5662
  name: "serverless",
3950
5663
  description: "AWS Lambda handlers",
3951
5664
  dependencies: {
3952
- "@geekmidas/constructs": "workspace:*",
3953
- "@geekmidas/envkit": "workspace:*",
3954
- "@geekmidas/logger": "workspace:*",
3955
- "@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"],
3956
5669
  hono: "~4.8.2",
3957
5670
  pino: "~9.6.0"
3958
5671
  },
3959
5672
  devDependencies: {
3960
- "@biomejs/biome": "~1.9.4",
3961
- "@geekmidas/cli": "workspace:*",
5673
+ "@biomejs/biome": "~2.3.0",
5674
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3962
5675
  "@types/aws-lambda": "~8.10.92",
3963
5676
  "@types/node": "~22.0.0",
3964
5677
  tsx: "~4.20.0",
@@ -3992,17 +5705,17 @@ export const logger = createLogger();
3992
5705
  const files = [
3993
5706
  {
3994
5707
  path: "src/config/env.ts",
3995
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5708
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5709
+ import { EnvironmentParser } from '@geekmidas/envkit';
3996
5710
 
3997
- export const envParser = new EnvironmentParser(process.env);
5711
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
3998
5712
 
5713
+ // Global config - only minimal shared values
5714
+ // Service-specific config should be parsed in each service
3999
5715
  export const config = envParser
4000
5716
  .create((get) => ({
4001
- stage: get('STAGE').string().default('dev'),
4002
- region: get('AWS_REGION').string().default('us-east-1'),${options.database ? `
4003
- database: {
4004
- url: get('DATABASE_URL').string(),
4005
- },` : ""}
5717
+ nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
5718
+ stage: get('STAGE').enum(['dev', 'staging', 'prod']).default('dev'),
4006
5719
  }))
4007
5720
  .parse();
4008
5721
  `
@@ -4015,7 +5728,7 @@ export const config = envParser
4015
5728
  path: getRoutePath("health.ts"),
4016
5729
  content: `import { e } from '@geekmidas/constructs/endpoints';
4017
5730
 
4018
- export default e
5731
+ export const healthEndpoint = e
4019
5732
  .get('/health')
4020
5733
  .handle(async () => ({
4021
5734
  status: 'ok',
@@ -4029,7 +5742,7 @@ export default e
4029
5742
  content: `import { f } from '@geekmidas/constructs/functions';
4030
5743
  import { z } from 'zod';
4031
5744
 
4032
- export default f
5745
+ export const helloFunction = f
4033
5746
  .input(z.object({ name: z.string() }))
4034
5747
  .output(z.object({ message: z.string() }))
4035
5748
  .handle(async ({ input }) => ({
@@ -4060,16 +5773,16 @@ const workerTemplate = {
4060
5773
  name: "worker",
4061
5774
  description: "Background job processing",
4062
5775
  dependencies: {
4063
- "@geekmidas/constructs": "workspace:*",
4064
- "@geekmidas/envkit": "workspace:*",
4065
- "@geekmidas/logger": "workspace:*",
4066
- "@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"],
4067
5780
  hono: "~4.8.2",
4068
5781
  pino: "~9.6.0"
4069
5782
  },
4070
5783
  devDependencies: {
4071
- "@biomejs/biome": "~1.9.4",
4072
- "@geekmidas/cli": "workspace:*",
5784
+ "@biomejs/biome": "~2.3.0",
5785
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
4073
5786
  "@types/node": "~22.0.0",
4074
5787
  tsx: "~4.20.0",
4075
5788
  turbo: "~2.3.0",
@@ -4102,20 +5815,17 @@ export const logger = createLogger();
4102
5815
  const files = [
4103
5816
  {
4104
5817
  path: "src/config/env.ts",
4105
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5818
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5819
+ import { EnvironmentParser } from '@geekmidas/envkit';
4106
5820
 
4107
- export const envParser = new EnvironmentParser(process.env);
5821
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
4108
5822
 
5823
+ // Global config - only minimal shared values
5824
+ // Service-specific config should be parsed in each service
4109
5825
  export const config = envParser
4110
5826
  .create((get) => ({
4111
- port: get('PORT').string().transform(Number).default(3000),
4112
- nodeEnv: get('NODE_ENV').string().default('development'),
4113
- rabbitmq: {
4114
- url: get('RABBITMQ_URL').string().default('amqp://localhost:5672'),
4115
- },${options.database ? `
4116
- database: {
4117
- url: get('DATABASE_URL').string().default('postgresql://localhost:5432/mydb'),
4118
- },` : ""}
5827
+ nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
5828
+ stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
4119
5829
  }))
4120
5830
  .parse();
4121
5831
  `
@@ -4128,7 +5838,7 @@ export const config = envParser
4128
5838
  path: getRoutePath("health.ts"),
4129
5839
  content: `import { e } from '@geekmidas/constructs/endpoints';
4130
5840
 
4131
- export default e
5841
+ export const healthEndpoint = e
4132
5842
  .get('/health')
4133
5843
  .handle(async () => ({
4134
5844
  status: 'ok',
@@ -4145,15 +5855,44 @@ export type AppEvents =
4145
5855
  | PublishableMessage<'user.created', { userId: string; email: string }>
4146
5856
  | PublishableMessage<'user.updated', { userId: string; changes: Record<string, unknown> }>
4147
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>>;
4148
5886
  `
4149
5887
  },
4150
5888
  {
4151
5889
  path: "src/subscribers/user-events.ts",
4152
5890
  content: `import { s } from '@geekmidas/constructs/subscribers';
4153
- import type { AppEvents } from '../events/types.js';
5891
+ import { eventsPublisherService } from '../events/publisher.js';
4154
5892
 
4155
- export default s<AppEvents>()
4156
- .events(['user.created', 'user.updated'])
5893
+ export const userEventsSubscriber = s
5894
+ .publisher(eventsPublisherService)
5895
+ .subscribe(['user.created', 'user.updated'])
4157
5896
  .handle(async ({ event, logger }) => {
4158
5897
  logger.info({ type: event.type, payload: event.payload }, 'Processing user event');
4159
5898
 
@@ -4175,7 +5914,7 @@ export default s<AppEvents>()
4175
5914
  content: `import { cron } from '@geekmidas/constructs/crons';
4176
5915
 
4177
5916
  // Run every day at midnight
4178
- export default cron('0 0 * * *')
5917
+ export const cleanupCron = cron('0 0 * * *')
4179
5918
  .handle(async ({ logger }) => {
4180
5919
  logger.info('Running cleanup job');
4181
5920
 
@@ -4218,30 +5957,17 @@ const templates = {
4218
5957
  worker: workerTemplate
4219
5958
  };
4220
5959
  /**
4221
- * Template choices for prompts
5960
+ * Template choices for prompts (Story 1.11 simplified to api + fullstack)
4222
5961
  */
4223
- const templateChoices = [
4224
- {
4225
- title: "Minimal",
4226
- value: "minimal",
4227
- description: "Basic health endpoint"
4228
- },
4229
- {
4230
- title: "API",
4231
- value: "api",
4232
- description: "Full API with auth, database, services"
4233
- },
4234
- {
4235
- title: "Serverless",
4236
- value: "serverless",
4237
- description: "AWS Lambda handlers"
4238
- },
4239
- {
4240
- title: "Worker",
4241
- value: "worker",
4242
- description: "Background job processing"
4243
- }
4244
- ];
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
+ }];
4245
5971
  /**
4246
5972
  * Logger type choices for prompts
4247
5973
  */
@@ -4275,13 +6001,77 @@ const routesStructureChoices = [
4275
6001
  }
4276
6002
  ];
4277
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
+ /**
4278
6061
  * Get a template by name
4279
6062
  */
4280
6063
  function getTemplate(name$1) {
6064
+ if (name$1 === "fullstack") return templates.api;
4281
6065
  const template = templates[name$1];
4282
6066
  if (!template) throw new Error(`Unknown template: ${name$1}`);
4283
6067
  return template;
4284
6068
  }
6069
+ /**
6070
+ * Check if a template is the fullstack monorepo template
6071
+ */
6072
+ function isFullstackTemplate(name$1) {
6073
+ return name$1 === "fullstack";
6074
+ }
4285
6075
 
4286
6076
  //#endregion
4287
6077
  //#region src/init/generators/package.ts
@@ -4293,10 +6083,10 @@ function generatePackageJson(options, template) {
4293
6083
  const dependencies$1 = { ...template.dependencies };
4294
6084
  const devDependencies$1 = { ...template.devDependencies };
4295
6085
  const scripts$1 = { ...template.scripts };
4296
- if (telescope) dependencies$1["@geekmidas/telescope"] = "workspace:*";
4297
- 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"];
4298
6088
  if (database) {
4299
- dependencies$1["@geekmidas/db"] = "workspace:*";
6089
+ dependencies$1["@geekmidas/db"] = GEEKMIDAS_VERSIONS["@geekmidas/db"];
4300
6090
  dependencies$1.kysely = "~0.28.2";
4301
6091
  dependencies$1.pg = "~8.16.0";
4302
6092
  devDependencies$1["@types/pg"] = "~8.15.0";
@@ -4331,19 +6121,219 @@ function generatePackageJson(options, template) {
4331
6121
  dependencies: sortObject(dependencies$1),
4332
6122
  devDependencies: sortObject(devDependencies$1)
4333
6123
  };
4334
- return [{
4335
- path: "package.json",
4336
- content: `${JSON.stringify(packageJson, null, 2)}\n`
4337
- }];
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
+ );
4338
6234
  }
6235
+ `;
6236
+ const pageTsx = `import type { User } from '${modelsPackage}';
4339
6237
 
4340
- //#endregion
4341
- //#region src/init/generators/source.ts
4342
- /**
4343
- * Generate source files from template
4344
- */
4345
- function generateSourceFiles(options, template) {
4346
- 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
+ ];
4347
6337
  }
4348
6338
 
4349
6339
  //#endregion
@@ -4411,21 +6401,36 @@ function getRunCommand(pkgManager, script) {
4411
6401
  //#endregion
4412
6402
  //#region src/init/index.ts
4413
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
+ /**
4414
6419
  * Main init command - scaffolds a new project
4415
6420
  */
4416
6421
  async function initCommand(projectName, options = {}) {
4417
6422
  const cwd = process.cwd();
4418
- const pkgManager = detectPackageManager(cwd);
6423
+ const detectedPkgManager = detectPackageManager(cwd);
4419
6424
  prompts.default.override({});
4420
6425
  const onCancel = () => {
4421
6426
  process.exit(0);
4422
6427
  };
4423
6428
  const answers = await (0, prompts.default)([
4424
6429
  {
4425
- type: projectName ? null : "text",
6430
+ type: projectName || options.name ? null : "text",
4426
6431
  name: "name",
4427
6432
  message: "Project name:",
4428
- initial: "my-api",
6433
+ initial: "my-app",
4429
6434
  validate: (value) => {
4430
6435
  const nameValid = validateProjectName(value);
4431
6436
  if (nameValid !== true) return nameValid;
@@ -4442,21 +6447,33 @@ async function initCommand(projectName, options = {}) {
4442
6447
  initial: 0
4443
6448
  },
4444
6449
  {
4445
- type: options.yes ? null : "confirm",
4446
- name: "telescope",
4447
- message: "Include Telescope (debugging dashboard)?",
4448
- 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"
4449
6458
  },
4450
6459
  {
4451
- type: options.yes ? null : "confirm",
4452
- name: "database",
4453
- message: "Include database support (Kysely)?",
4454
- 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
4455
6472
  },
4456
6473
  {
4457
- type: (prev) => options.yes ? null : prev ? "confirm" : null,
4458
- name: "studio",
4459
- message: "Include Studio (database browser)?",
6474
+ type: options.yes ? null : "confirm",
6475
+ name: "telescope",
6476
+ message: "Include Telescope (debugging dashboard)?",
4460
6477
  initial: true
4461
6478
  },
4462
6479
  {
@@ -4472,74 +6489,146 @@ async function initCommand(projectName, options = {}) {
4472
6489
  message: "Routes structure:",
4473
6490
  choices: routesStructureChoices,
4474
6491
  initial: 0
4475
- },
4476
- {
4477
- type: options.yes || options.monorepo !== void 0 ? null : "confirm",
4478
- name: "monorepo",
4479
- message: "Setup as monorepo?",
4480
- initial: false
4481
- },
4482
- {
4483
- type: (prev) => (prev === true || options.monorepo) && !options.apiPath ? "text" : null,
4484
- name: "apiPath",
4485
- message: "API app path:",
4486
- initial: "apps/api"
4487
6492
  }
4488
6493
  ], { onCancel });
4489
- const name$1 = projectName || answers.name;
4490
- if (!name$1) process.exit(1);
4491
- if (projectName) {
4492
- const nameValid = validateProjectName(projectName);
4493
- if (nameValid !== true) process.exit(1);
4494
- const dirValid = checkDirectoryExists(projectName, cwd);
4495
- if (dirValid !== true) process.exit(1);
4496
- }
4497
- const monorepo = options.monorepo ?? (options.yes ? false : answers.monorepo ?? false);
4498
- 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;
4499
6528
  const templateOptions = {
4500
6529
  name: name$1,
4501
- template: options.template || answers.template || "minimal",
6530
+ template,
4502
6531
  telescope: options.yes ? true : answers.telescope ?? true,
4503
6532
  database,
4504
- studio: database && (options.yes ? true : answers.studio ?? true),
6533
+ studio: database,
4505
6534
  loggerType: options.yes ? "pino" : answers.loggerType ?? "pino",
4506
6535
  routesStructure: options.yes ? "centralized-endpoints" : answers.routesStructure ?? "centralized-endpoints",
4507
6536
  monorepo,
4508
- apiPath: monorepo ? options.apiPath ?? answers.apiPath ?? "apps/api" : ""
6537
+ apiPath: monorepo ? options.apiPath ?? "apps/api" : "",
6538
+ packageManager: pkgManager,
6539
+ deployTarget,
6540
+ services
4509
6541
  };
4510
6542
  const targetDir = (0, node_path.join)(cwd, name$1);
4511
- const template = getTemplate(templateOptions.template);
6543
+ const baseTemplate = getTemplate(templateOptions.template);
4512
6544
  const isMonorepo$1 = templateOptions.monorepo;
4513
6545
  const apiPath = templateOptions.apiPath;
6546
+ console.log("\n🚀 Creating your project...\n");
4514
6547
  await (0, node_fs_promises.mkdir)(targetDir, { recursive: true });
4515
6548
  const appDir = isMonorepo$1 ? (0, node_path.join)(targetDir, apiPath) : targetDir;
4516
6549
  if (isMonorepo$1) await (0, node_fs_promises.mkdir)(appDir, { recursive: true });
4517
- const appFiles = [
4518
- ...generatePackageJson(templateOptions, template),
4519
- ...generateConfigFiles(templateOptions, template),
4520
- ...generateEnvFiles(templateOptions, template),
4521
- ...generateSourceFiles(templateOptions, template),
4522
- ...generateDockerFiles(templateOptions, template)
4523
- ];
4524
- 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) : [];
4525
6569
  for (const { path, content } of rootFiles) {
4526
6570
  const fullPath = (0, node_path.join)(targetDir, path);
4527
6571
  await (0, node_fs_promises.mkdir)((0, node_path.dirname)(fullPath), { recursive: true });
4528
6572
  await (0, node_fs_promises.writeFile)(fullPath, content);
4529
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
+ }
4530
6579
  for (const { path, content } of appFiles) {
4531
6580
  const fullPath = (0, node_path.join)(appDir, path);
4532
- const _displayPath = isMonorepo$1 ? `${apiPath}/${path}` : path;
4533
6581
  await (0, node_fs_promises.mkdir)((0, node_path.dirname)(fullPath), { recursive: true });
4534
6582
  await (0, node_fs_promises.writeFile)(fullPath, content);
4535
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`);
4536
6622
  if (!options.skipInstall) {
6623
+ console.log("\n📦 Installing dependencies...\n");
4537
6624
  try {
4538
6625
  (0, node_child_process.execSync)(getInstallCommand(pkgManager), {
4539
6626
  cwd: targetDir,
4540
6627
  stdio: "inherit"
4541
6628
  });
4542
- } catch {}
6629
+ } catch {
6630
+ console.error("Failed to install dependencies");
6631
+ }
4543
6632
  try {
4544
6633
  (0, node_child_process.execSync)("npx @biomejs/biome format --write --unsafe .", {
4545
6634
  cwd: targetDir,
@@ -4547,124 +6636,52 @@ async function initCommand(projectName, options = {}) {
4547
6636
  });
4548
6637
  } catch {}
4549
6638
  }
4550
- const _devCommand = getRunCommand(pkgManager, "dev");
6639
+ printNextSteps(name$1, templateOptions, pkgManager);
4551
6640
  }
4552
-
4553
- //#endregion
4554
- //#region src/secrets/generator.ts
4555
6641
  /**
4556
- * Generate a secure random password using URL-safe base64 characters.
4557
- * @param length Password length (default: 32)
6642
+ * Print success message with next steps
4558
6643
  */
4559
- function generateSecurePassword(length = 32) {
4560
- return (0, node_crypto.randomBytes)(Math.ceil(length * 3 / 4)).toString("base64url").slice(0, length);
4561
- }
4562
- /** Default service configurations */
4563
- const SERVICE_DEFAULTS = {
4564
- postgres: {
4565
- host: "postgres",
4566
- port: 5432,
4567
- username: "app",
4568
- database: "app"
4569
- },
4570
- redis: {
4571
- host: "redis",
4572
- port: 6379,
4573
- username: "default"
4574
- },
4575
- rabbitmq: {
4576
- host: "rabbitmq",
4577
- port: 5672,
4578
- username: "app",
4579
- 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`);
4580
6654
  }
4581
- };
4582
- /**
4583
- * Generate credentials for a specific service.
4584
- */
4585
- function generateServiceCredentials(service) {
4586
- const defaults = SERVICE_DEFAULTS[service];
4587
- return {
4588
- ...defaults,
4589
- password: generateSecurePassword()
4590
- };
4591
- }
4592
- /**
4593
- * Generate credentials for multiple services.
4594
- */
4595
- function generateServicesCredentials(services) {
4596
- const result = {};
4597
- for (const service of services) result[service] = generateServiceCredentials(service);
4598
- return result;
4599
- }
4600
- /**
4601
- * Generate connection URL for PostgreSQL.
4602
- */
4603
- function generatePostgresUrl(creds) {
4604
- const { username, password, host, port, database } = creds;
4605
- return `postgresql://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}`;
4606
- }
4607
- /**
4608
- * Generate connection URL for Redis.
4609
- */
4610
- function generateRedisUrl(creds) {
4611
- const { password, host, port } = creds;
4612
- return `redis://:${encodeURIComponent(password)}@${host}:${port}`;
4613
- }
4614
- /**
4615
- * Generate connection URL for RabbitMQ.
4616
- */
4617
- function generateRabbitmqUrl(creds) {
4618
- const { username, password, host, port, vhost } = creds;
4619
- const encodedVhost = encodeURIComponent(vhost ?? "/");
4620
- return `amqp://${username}:${encodeURIComponent(password)}@${host}:${port}/${encodedVhost}`;
4621
- }
4622
- /**
4623
- * Generate connection URLs from service credentials.
4624
- */
4625
- function generateConnectionUrls(services) {
4626
- const urls = {};
4627
- if (services.postgres) urls.DATABASE_URL = generatePostgresUrl(services.postgres);
4628
- if (services.redis) urls.REDIS_URL = generateRedisUrl(services.redis);
4629
- if (services.rabbitmq) urls.RABBITMQ_URL = generateRabbitmqUrl(services.rabbitmq);
4630
- return urls;
4631
- }
4632
- /**
4633
- * Create a new StageSecrets object with generated credentials.
4634
- */
4635
- function createStageSecrets(stage, services) {
4636
- const now = (/* @__PURE__ */ new Date()).toISOString();
4637
- const serviceCredentials = generateServicesCredentials(services);
4638
- const urls = generateConnectionUrls(serviceCredentials);
4639
- return {
4640
- stage,
4641
- createdAt: now,
4642
- updatedAt: now,
4643
- services: serviceCredentials,
4644
- urls,
4645
- custom: {}
4646
- };
4647
- }
4648
- /**
4649
- * Rotate password for a specific service.
4650
- */
4651
- function rotateServicePassword(secrets, service) {
4652
- const currentCreds = secrets.services[service];
4653
- if (!currentCreds) throw new Error(`Service "${service}" not configured in secrets`);
4654
- const newCreds = {
4655
- ...currentCreds,
4656
- password: generateSecurePassword()
4657
- };
4658
- const newServices = {
4659
- ...secrets.services,
4660
- [service]: newCreds
4661
- };
4662
- return {
4663
- ...secrets,
4664
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4665
- services: newServices,
4666
- urls: generateConnectionUrls(newServices)
4667
- };
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("");
4668
6685
  }
4669
6686
 
4670
6687
  //#endregion
@@ -4853,11 +6870,57 @@ function maskUrl(url) {
4853
6870
  }
4854
6871
  }
4855
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
+
4856
6919
  //#endregion
4857
6920
  //#region src/index.ts
4858
6921
  const program = new commander.Command();
4859
6922
  program.name("gkm").description("GeekMidas backend framework CLI").version(package_default.version).option("--cwd <path>", "Change working directory");
4860
- 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) => {
4861
6924
  try {
4862
6925
  const globalOptions = program.opts();
4863
6926
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -4914,6 +6977,19 @@ program.command("dev").description("Start development server with automatic relo
4914
6977
  process.exit(1);
4915
6978
  }
4916
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
+ });
4917
6993
  program.command("cron").description("Manage cron jobs").action(() => {
4918
6994
  const globalOptions = program.opts();
4919
6995
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);