@geekmidas/cli 0.17.0 → 0.19.0

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