@geekmidas/cli 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/{bundler-C74EKlNa.cjs → bundler-CyHg1v_T.cjs} +3 -3
  2. package/dist/{bundler-C74EKlNa.cjs.map → bundler-CyHg1v_T.cjs.map} +1 -1
  3. package/dist/{bundler-B6z6HEeh.mjs → bundler-DQIuE3Kn.mjs} +3 -3
  4. package/dist/{bundler-B6z6HEeh.mjs.map → bundler-DQIuE3Kn.mjs.map} +1 -1
  5. package/dist/{config-DYULeEv8.mjs → config-BaYqrF3n.mjs} +48 -10
  6. package/dist/config-BaYqrF3n.mjs.map +1 -0
  7. package/dist/{config-AmInkU7k.cjs → config-CxrLu8ia.cjs} +53 -9
  8. package/dist/config-CxrLu8ia.cjs.map +1 -0
  9. package/dist/config.cjs +4 -1
  10. package/dist/config.d.cts +27 -2
  11. package/dist/config.d.cts.map +1 -1
  12. package/dist/config.d.mts +27 -2
  13. package/dist/config.d.mts.map +1 -1
  14. package/dist/config.mjs +3 -2
  15. package/dist/dokploy-api-B0w17y4_.mjs +3 -0
  16. package/dist/{dokploy-api-CaETb2L6.mjs → dokploy-api-B9qR2Yn1.mjs} +1 -1
  17. package/dist/{dokploy-api-CaETb2L6.mjs.map → dokploy-api-B9qR2Yn1.mjs.map} +1 -1
  18. package/dist/dokploy-api-BnGeUqN4.cjs +3 -0
  19. package/dist/{dokploy-api-C7F9VykY.cjs → dokploy-api-C5czOZoc.cjs} +1 -1
  20. package/dist/{dokploy-api-C7F9VykY.cjs.map → dokploy-api-C5czOZoc.cjs.map} +1 -1
  21. package/dist/{encryption-D7Efcdi9.cjs → encryption-BAz0xQ1Q.cjs} +1 -1
  22. package/dist/{encryption-D7Efcdi9.cjs.map → encryption-BAz0xQ1Q.cjs.map} +1 -1
  23. package/dist/{encryption-h4Nb6W-M.mjs → encryption-JtMsiGNp.mjs} +2 -2
  24. package/dist/{encryption-h4Nb6W-M.mjs.map → encryption-JtMsiGNp.mjs.map} +1 -1
  25. package/dist/index-CWN-bgrO.d.mts +495 -0
  26. package/dist/index-CWN-bgrO.d.mts.map +1 -0
  27. package/dist/index-DEWYvYvg.d.cts +495 -0
  28. package/dist/index-DEWYvYvg.d.cts.map +1 -0
  29. package/dist/index.cjs +2640 -564
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.mjs +2635 -564
  32. package/dist/index.mjs.map +1 -1
  33. package/dist/{openapi-CZVcfxk-.mjs → openapi-CgqR6Jkw.mjs} +3 -3
  34. package/dist/{openapi-CZVcfxk-.mjs.map → openapi-CgqR6Jkw.mjs.map} +1 -1
  35. package/dist/{openapi-C89hhkZC.cjs → openapi-DfpxS0xv.cjs} +8 -2
  36. package/dist/{openapi-C89hhkZC.cjs.map → openapi-DfpxS0xv.cjs.map} +1 -1
  37. package/dist/{openapi-react-query-CM2_qlW9.mjs → openapi-react-query-5rSortLH.mjs} +1 -1
  38. package/dist/{openapi-react-query-CM2_qlW9.mjs.map → openapi-react-query-5rSortLH.mjs.map} +1 -1
  39. package/dist/{openapi-react-query-iKjfLzff.cjs → openapi-react-query-DvNpdDpM.cjs} +1 -1
  40. package/dist/{openapi-react-query-iKjfLzff.cjs.map → openapi-react-query-DvNpdDpM.cjs.map} +1 -1
  41. package/dist/openapi-react-query.cjs +1 -1
  42. package/dist/openapi-react-query.mjs +1 -1
  43. package/dist/openapi.cjs +3 -2
  44. package/dist/openapi.d.cts +1 -1
  45. package/dist/openapi.d.mts +1 -1
  46. package/dist/openapi.mjs +3 -2
  47. package/dist/{storage-Bn3K9Ccu.cjs → storage-BPRgh3DU.cjs} +136 -5
  48. package/dist/storage-BPRgh3DU.cjs.map +1 -0
  49. package/dist/{storage-nkGIjeXt.mjs → storage-DNj_I11J.mjs} +1 -1
  50. package/dist/storage-Dhst7BhI.mjs +272 -0
  51. package/dist/storage-Dhst7BhI.mjs.map +1 -0
  52. package/dist/{storage-UfyTn7Zm.cjs → storage-fOR8dMu5.cjs} +1 -1
  53. package/dist/{types-iFk5ms7y.d.mts → types-K2uQJ-FO.d.mts} +2 -2
  54. package/dist/{types-BgaMXsUa.d.cts.map → types-K2uQJ-FO.d.mts.map} +1 -1
  55. package/dist/{types-BgaMXsUa.d.cts → types-l53qUmGt.d.cts} +2 -2
  56. package/dist/{types-iFk5ms7y.d.mts.map → types-l53qUmGt.d.cts.map} +1 -1
  57. package/dist/workspace/index.cjs +19 -0
  58. package/dist/workspace/index.d.cts +3 -0
  59. package/dist/workspace/index.d.mts +3 -0
  60. package/dist/workspace/index.mjs +3 -0
  61. package/dist/workspace-CPLEZDZf.mjs +3788 -0
  62. package/dist/workspace-CPLEZDZf.mjs.map +1 -0
  63. package/dist/workspace-iWgBlX6h.cjs +3885 -0
  64. package/dist/workspace-iWgBlX6h.cjs.map +1 -0
  65. package/package.json +9 -4
  66. package/src/build/__tests__/workspace-build.spec.ts +215 -0
  67. package/src/build/index.ts +189 -1
  68. package/src/config.ts +71 -14
  69. package/src/deploy/__tests__/docker.spec.ts +1 -1
  70. package/src/deploy/__tests__/index.spec.ts +305 -1
  71. package/src/deploy/index.ts +426 -4
  72. package/src/deploy/types.ts +32 -0
  73. package/src/dev/__tests__/index.spec.ts +572 -1
  74. package/src/dev/index.ts +582 -2
  75. package/src/docker/__tests__/compose.spec.ts +425 -0
  76. package/src/docker/__tests__/templates.spec.ts +145 -0
  77. package/src/docker/compose.ts +248 -0
  78. package/src/docker/index.ts +159 -3
  79. package/src/docker/templates.ts +219 -4
  80. package/src/index.ts +24 -0
  81. package/src/init/__tests__/generators.spec.ts +17 -24
  82. package/src/init/__tests__/init.spec.ts +157 -5
  83. package/src/init/generators/auth.ts +220 -0
  84. package/src/init/generators/config.ts +61 -4
  85. package/src/init/generators/docker.ts +115 -8
  86. package/src/init/generators/env.ts +7 -127
  87. package/src/init/generators/index.ts +1 -0
  88. package/src/init/generators/models.ts +3 -1
  89. package/src/init/generators/monorepo.ts +154 -10
  90. package/src/init/generators/package.ts +5 -3
  91. package/src/init/generators/web.ts +213 -0
  92. package/src/init/index.ts +290 -58
  93. package/src/init/templates/api.ts +38 -29
  94. package/src/init/templates/index.ts +132 -4
  95. package/src/init/templates/minimal.ts +33 -35
  96. package/src/init/templates/serverless.ts +16 -19
  97. package/src/init/templates/worker.ts +50 -25
  98. package/src/init/versions.ts +47 -0
  99. package/src/secrets/keystore.ts +144 -0
  100. package/src/secrets/storage.ts +109 -6
  101. package/src/test/index.ts +97 -0
  102. package/src/workspace/__tests__/client-generator.spec.ts +357 -0
  103. package/src/workspace/__tests__/index.spec.ts +543 -0
  104. package/src/workspace/__tests__/schema.spec.ts +519 -0
  105. package/src/workspace/__tests__/type-inference.spec.ts +251 -0
  106. package/src/workspace/client-generator.ts +307 -0
  107. package/src/workspace/index.ts +372 -0
  108. package/src/workspace/schema.ts +368 -0
  109. package/src/workspace/types.ts +336 -0
  110. package/tsconfig.tsbuildinfo +1 -1
  111. package/tsdown.config.ts +1 -0
  112. package/dist/config-AmInkU7k.cjs.map +0 -1
  113. package/dist/config-DYULeEv8.mjs.map +0 -1
  114. package/dist/dokploy-api-B7KxOQr3.cjs +0 -3
  115. package/dist/dokploy-api-DHvfmWbi.mjs +0 -3
  116. package/dist/storage-BaOP55oq.mjs +0 -147
  117. package/dist/storage-BaOP55oq.mjs.map +0 -1
  118. package/dist/storage-Bn3K9Ccu.cjs.map +0 -1
package/dist/index.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.18.0";
29
+ var version = "0.20.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
@@ -1696,8 +2408,14 @@ WORKDIR /app
1696
2408
  # Copy source (deps already installed)
1697
2409
  COPY . .
1698
2410
 
1699
- # Build production server using CLI from project dependencies
1700
- RUN ${pm.exec} gkm 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
1701
2419
 
1702
2420
  # Stage 3: Production
1703
2421
  FROM ${baseImage} AS runner
@@ -1778,8 +2496,14 @@ WORKDIR /app
1778
2496
  # Copy pruned source
1779
2497
  COPY --from=pruner /app/out/full/ ./
1780
2498
 
1781
- # Build production server using CLI from project dependencies
1782
- RUN ${pm.exec} gkm 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
1783
2507
 
1784
2508
  # Stage 4: Production
1785
2509
  FROM ${baseImage} AS runner
@@ -1921,8 +2645,8 @@ function resolveDockerConfig$1(config$1) {
1921
2645
  const docker = config$1.docker ?? {};
1922
2646
  let defaultImageName = "api";
1923
2647
  try {
1924
- const pkg = __require(`${process.cwd()}/package.json`);
1925
- 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(/^@[^/]+\//, "");
1926
2650
  } catch {}
1927
2651
  return {
1928
2652
  registry: docker.registry ?? "",
@@ -1932,20 +2656,194 @@ function resolveDockerConfig$1(config$1) {
1932
2656
  compose: docker.compose
1933
2657
  };
1934
2658
  }
1935
-
1936
- //#endregion
1937
- //#region src/docker/index.ts
1938
- const logger$5 = console;
1939
2659
  /**
1940
- * Docker command implementation
1941
- * Generates Dockerfile, docker-compose.yml, and related files
1942
- *
1943
- * Default: Multi-stage Dockerfile that builds from source inside Docker
1944
- * --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
1945
2663
  */
1946
- async function dockerCommand(options) {
1947
- const config$1 = await loadConfig();
1948
- 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);
1949
2847
  const serverConfig = typeof config$1.providers?.server === "object" ? config$1.providers.server : void 0;
1950
2848
  const healthCheckPath = serverConfig?.production?.healthCheck ?? "/health";
1951
2849
  const useSlim = options.slim === true;
@@ -1966,9 +2864,9 @@ async function dockerCommand(options) {
1966
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");
1967
2865
  let turboPackage = options.turboPackage ?? dockerConfig.imageName;
1968
2866
  if (useTurbo && !options.turboPackage) try {
1969
- const pkg = __require(`${process.cwd()}/package.json`);
1970
- if (pkg.name) {
1971
- turboPackage = pkg.name;
2867
+ const pkg$1 = __require(`${process.cwd()}/package.json`);
2868
+ if (pkg$1.name) {
2869
+ turboPackage = pkg$1.name;
1972
2870
  logger$5.log(` Turbo package: ${turboPackage}`);
1973
2871
  }
1974
2872
  } catch {}
@@ -2085,6 +2983,85 @@ async function pushDockerImage(imageName, options) {
2085
2983
  throw new Error(`Failed to push Docker image: ${error instanceof Error ? error.message : "Unknown error"}`);
2086
2984
  }
2087
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
+ }
2088
3065
 
2089
3066
  //#endregion
2090
3067
  //#region src/deploy/docker.ts
@@ -2096,8 +3073,8 @@ function getAppNameFromCwd() {
2096
3073
  const packageJsonPath = join(process.cwd(), "package.json");
2097
3074
  if (!existsSync(packageJsonPath)) return void 0;
2098
3075
  try {
2099
- const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
2100
- 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(/^@[^/]+\//, "");
2101
3078
  } catch {}
2102
3079
  return void 0;
2103
3080
  }
@@ -2113,8 +3090,8 @@ function getAppNameFromPackageJson() {
2113
3090
  const packageJsonPath = join(projectRoot, "package.json");
2114
3091
  if (!existsSync(packageJsonPath)) return void 0;
2115
3092
  try {
2116
- const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
2117
- 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(/^@[^/]+\//, "");
2118
3095
  } catch {}
2119
3096
  return void 0;
2120
3097
  }
@@ -2587,7 +3564,7 @@ async function provisionServices(api, projectId, environmentId, appName, service
2587
3564
  */
2588
3565
  async function ensureDokploySetup(config$1, dockerConfig, stage, services) {
2589
3566
  logger$1.log("\n🔧 Checking Dokploy setup...");
2590
- const { readStageSecrets: readStageSecrets$1 } = await import("./storage-nkGIjeXt.mjs");
3567
+ const { readStageSecrets: readStageSecrets$1 } = await import("./storage-DNj_I11J.mjs");
2591
3568
  const existingSecrets = await readStageSecrets$1(stage);
2592
3569
  const existingUrls = {
2593
3570
  DATABASE_URL: existingSecrets?.urls?.DATABASE_URL,
@@ -2765,94 +3742,335 @@ function generateTag(stage) {
2765
3742
  return `${stage}-${timestamp}`;
2766
3743
  }
2767
3744
  /**
2768
- * 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
2769
3751
  */
2770
- async function deployCommand(options) {
2771
- const { provider, stage, tag, skipPush, skipBuild } = options;
2772
- 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...`);
2773
3756
  logger$1.log(` Stage: ${stage}`);
2774
- const config$1 = await loadConfig();
2775
3757
  const imageTag = tag ?? generateTag(stage);
2776
3758
  logger$1.log(` Tag: ${imageTag}`);
2777
- const dockerConfig = resolveDockerConfig(config$1);
2778
- const imageName = dockerConfig.imageName;
2779
- const registry = dockerConfig.registry;
2780
- const imageRef = registry ? `${registry}/${imageName}:${imageTag}` : `${imageName}:${imageTag}`;
2781
- let dokployConfig;
2782
- let finalRegistry = registry;
2783
- if (provider === "dokploy") {
2784
- const composeServices = config$1.docker?.compose?.services;
2785
- logger$1.log(`\n🔍 Docker compose config: ${JSON.stringify(config$1.docker?.compose)}`);
2786
- const dockerServices = composeServices ? Array.isArray(composeServices) ? {
2787
- postgres: composeServices.includes("postgres"),
2788
- redis: composeServices.includes("redis"),
2789
- rabbitmq: composeServices.includes("rabbitmq")
2790
- } : {
2791
- postgres: Boolean(composeServices.postgres),
2792
- redis: Boolean(composeServices.redis),
2793
- rabbitmq: Boolean(composeServices.rabbitmq)
2794
- } : void 0;
2795
- const setupResult = await ensureDokploySetup(config$1, dockerConfig, stage, dockerServices);
2796
- dokployConfig = setupResult.config;
2797
- finalRegistry = dokployConfig.registry ?? dockerConfig.registry;
2798
- if (setupResult.serviceUrls) {
2799
- const { readStageSecrets: readStageSecrets$1, writeStageSecrets: writeStageSecrets$1, initStageSecrets } = await import("./storage-nkGIjeXt.mjs");
2800
- let secrets = await readStageSecrets$1(stage);
2801
- if (!secrets) {
2802
- logger$1.log(` Creating secrets file for stage "${stage}"...`);
2803
- secrets = initStageSecrets(stage);
2804
- }
2805
- let updated = false;
2806
- const urlFields = [
2807
- "DATABASE_URL",
2808
- "REDIS_URL",
2809
- "RABBITMQ_URL"
2810
- ];
2811
- for (const [key, value] of Object.entries(setupResult.serviceUrls)) {
2812
- if (!value) continue;
2813
- if (urlFields.includes(key)) {
2814
- const urlKey = key;
2815
- if (!secrets.urls[urlKey]) {
2816
- secrets.urls[urlKey] = value;
2817
- logger$1.log(` Saved ${key} to secrets.urls`);
2818
- updated = true;
2819
- }
2820
- } else if (!secrets.custom[key]) {
2821
- secrets.custom[key] = value;
2822
- logger$1.log(` Saved ${key} to secrets.custom`);
2823
- updated = true;
2824
- }
2825
- }
2826
- 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;
2827
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`);
2828
3780
  }
2829
- let masterKey;
2830
- if (!skipBuild) {
2831
- logger$1.log(`\n📦 Building for production...`);
2832
- const buildResult = await buildCommand({
2833
- provider: "server",
2834
- production: true,
2835
- stage
2836
- });
2837
- masterKey = buildResult.masterKey;
2838
- } else logger$1.log(`\n⏭️ Skipping build (--skip-build)`);
2839
- let result;
2840
- switch (provider) {
2841
- case "docker": {
2842
- result = await deployDocker({
2843
- stage,
2844
- tag: imageTag,
2845
- skipPush,
2846
- masterKey,
2847
- config: dockerConfig
2848
- });
2849
- break;
2850
- }
2851
- case "dokploy": {
2852
- if (!dokployConfig) throw new Error("Dokploy config not initialized");
2853
- const finalImageRef = finalRegistry ? `${finalRegistry}/${imageName}:${imageTag}` : `${imageName}:${imageTag}`;
2854
- await deployDocker({
2855
- 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,
2856
4074
  tag: imageTag,
2857
4075
  skipPush: false,
2858
4076
  masterKey,
@@ -2879,10 +4097,361 @@ async function deployCommand(options) {
2879
4097
  };
2880
4098
  break;
2881
4099
  }
2882
- default: throw new Error(`Unknown deploy provider: ${provider}\nSupported providers: docker, dokploy, aws-lambda`);
2883
- }
2884
- logger$1.log("\n✅ Deployment complete!");
2885
- 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
+ ];
2886
4455
  }
2887
4456
 
2888
4457
  //#endregion
@@ -2894,6 +4463,7 @@ function generateConfigFiles(options, template) {
2894
4463
  const { telescope, studio, routesStructure } = options;
2895
4464
  const isServerless = template.name === "serverless";
2896
4465
  const hasWorker = template.name === "worker";
4466
+ const isFullstack = options.template === "fullstack";
2897
4467
  const getRoutesGlob = () => {
2898
4468
  switch (routesStructure) {
2899
4469
  case "centralized-endpoints": return "./src/endpoints/**/*.ts";
@@ -2901,6 +4471,14 @@ function generateConfigFiles(options, template) {
2901
4471
  case "domain-based": return "./src/**/routes/*.ts";
2902
4472
  }
2903
4473
  };
4474
+ if (isFullstack) return generateSingleAppConfigFiles(options, template, {
4475
+ telescope,
4476
+ studio,
4477
+ routesStructure,
4478
+ isServerless,
4479
+ hasWorker,
4480
+ getRoutesGlob
4481
+ });
2904
4482
  let gkmConfig = `import { defineConfig } from '@geekmidas/cli/config';
2905
4483
 
2906
4484
  export default defineConfig({
@@ -2929,8 +4507,7 @@ export default defineConfig({
2929
4507
  const tsConfig = options.monorepo ? {
2930
4508
  extends: "../../tsconfig.json",
2931
4509
  compilerOptions: {
2932
- outDir: "./dist",
2933
- rootDir: "./src",
4510
+ noEmit: true,
2934
4511
  baseUrl: ".",
2935
4512
  paths: { [`@${options.name}/*`]: ["../../packages/*/src"] }
2936
4513
  },
@@ -2963,7 +4540,7 @@ export default defineConfig({
2963
4540
  content: `${JSON.stringify(tsConfig, null, 2)}\n`
2964
4541
  }];
2965
4542
  const biomeConfig = {
2966
- $schema: "https://biomejs.dev/schemas/1.9.4/schema.json",
4543
+ $schema: "https://biomejs.dev/schemas/2.3.0/schema.json",
2967
4544
  vcs: {
2968
4545
  enabled: true,
2969
4546
  clientKind: "git",
@@ -3046,23 +4623,46 @@ export default defineConfig({
3046
4623
  }
3047
4624
  ];
3048
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
+ }
3049
4642
 
3050
4643
  //#endregion
3051
4644
  //#region src/init/generators/docker.ts
3052
4645
  /**
3053
4646
  * Generate docker-compose.yml based on template and options
3054
4647
  */
3055
- function generateDockerFiles(options, template) {
4648
+ function generateDockerFiles(options, template, dbApps) {
3056
4649
  const { database } = options;
3057
4650
  const isServerless = template.name === "serverless";
3058
4651
  const hasWorker = template.name === "worker";
4652
+ const isFullstack = options.template === "fullstack";
3059
4653
  const services = [];
3060
4654
  const volumes = [];
4655
+ const files = [];
3061
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` : "";
3062
4662
  services.push(` postgres:
3063
4663
  image: postgres:16-alpine
3064
4664
  container_name: ${options.name}-postgres
3065
- restart: unless-stopped
4665
+ restart: unless-stopped${envFile}
3066
4666
  environment:
3067
4667
  POSTGRES_USER: postgres
3068
4668
  POSTGRES_PASSWORD: postgres
@@ -3070,13 +4670,23 @@ function generateDockerFiles(options, template) {
3070
4670
  ports:
3071
4671
  - '5432:5432'
3072
4672
  volumes:
3073
- - postgres_data:/var/lib/postgresql/data
4673
+ - postgres_data:/var/lib/postgresql/data${initVolume}
3074
4674
  healthcheck:
3075
4675
  test: ['CMD-SHELL', 'pg_isready -U postgres']
3076
4676
  interval: 5s
3077
4677
  timeout: 5s
3078
4678
  retries: 5`);
3079
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
+ }
3080
4690
  }
3081
4691
  if (isServerless) {
3082
4692
  services.push(` redis:
@@ -3152,105 +4762,85 @@ ${services.join("\n\n")}
3152
4762
  volumes:
3153
4763
  ${volumes.join("\n")}
3154
4764
  `;
3155
- return [{
4765
+ files.push({
3156
4766
  path: "docker-compose.yml",
3157
4767
  content: dockerCompose
3158
- }];
4768
+ });
4769
+ return files;
3159
4770
  }
3160
-
3161
- //#endregion
3162
- //#region src/init/generators/env.ts
3163
4771
  /**
3164
- * Generate environment files (.env, .env.example, .env.development, .env.test, .gitignore)
4772
+ * Generate .env file for docker-compose with database passwords
3165
4773
  */
3166
- function generateEnvFiles(options, template) {
3167
- const { database } = options;
3168
- const isServerless = template.name === "serverless";
3169
- const hasWorker = template.name === "worker";
3170
- let baseEnv = `# Application
3171
- NODE_ENV=development
3172
- PORT=3000
3173
- LOG_LEVEL=info
3174
- `;
3175
- if (isServerless) baseEnv = `# AWS
3176
- STAGE=dev
3177
- AWS_REGION=us-east-1
3178
- LOG_LEVEL=info
3179
- `;
3180
- if (database) baseEnv += `
3181
- # Database
3182
- DATABASE_URL=postgresql://user:password@localhost:5432/mydb
3183
- `;
3184
- if (hasWorker) baseEnv += `
3185
- # Message Queue
3186
- RABBITMQ_URL=amqp://localhost:5672
3187
- `;
3188
- baseEnv += `
3189
- # Authentication
3190
- JWT_SECRET=your-secret-key-change-in-production
3191
- `;
3192
- let devEnv = `# Development Environment
3193
- NODE_ENV=development
3194
- PORT=3000
3195
- LOG_LEVEL=debug
3196
- `;
3197
- if (isServerless) devEnv = `# Development Environment
3198
- STAGE=dev
3199
- AWS_REGION=us-east-1
3200
- LOG_LEVEL=debug
3201
- `;
3202
- if (database) devEnv += `
3203
- # Database
3204
- DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mydb_dev
3205
- `;
3206
- if (hasWorker) devEnv += `
3207
- # Message Queue
3208
- RABBITMQ_URL=amqp://localhost:5672
3209
- `;
3210
- devEnv += `
3211
- # Authentication
3212
- JWT_SECRET=dev-secret-not-for-production
3213
- `;
3214
- let testEnv = `# Test Environment
3215
- NODE_ENV=test
3216
- PORT=3001
3217
- LOG_LEVEL=error
3218
- `;
3219
- if (isServerless) testEnv = `# Test Environment
3220
- STAGE=test
3221
- AWS_REGION=us-east-1
3222
- 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")}
3223
4783
  `;
3224
- if (database) testEnv += `
3225
- # Database
3226
- 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
3227
4806
  `;
3228
- if (hasWorker) testEnv += `
3229
- # Message Queue
3230
- 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
3231
4820
  `;
3232
- testEnv += `
3233
- # Authentication
3234
- 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!"
3235
4831
  `;
3236
- const files = [
3237
- {
3238
- path: ".env.example",
3239
- content: baseEnv
3240
- },
3241
- {
3242
- path: ".env",
3243
- content: baseEnv
3244
- },
3245
- {
3246
- path: ".env.development",
3247
- content: devEnv
3248
- },
3249
- {
3250
- path: ".env.test",
3251
- content: testEnv
3252
- }
3253
- ];
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 = [];
3254
4844
  if (!options.monorepo) {
3255
4845
  const gitignore = `# Dependencies
3256
4846
  node_modules/
@@ -3259,7 +4849,7 @@ node_modules/
3259
4849
  dist/
3260
4850
  .gkm/
3261
4851
 
3262
- # Environment
4852
+ # Environment (legacy - use gkm secrets instead)
3263
4853
  .env
3264
4854
  .env.local
3265
4855
  .env.*.local
@@ -3328,6 +4918,8 @@ function generateModelsPackage(options) {
3328
4918
  const tsConfig = {
3329
4919
  extends: "../../tsconfig.json",
3330
4920
  compilerOptions: {
4921
+ declaration: true,
4922
+ declarationMap: true,
3331
4923
  outDir: "./dist",
3332
4924
  rootDir: "./src"
3333
4925
  },
@@ -3414,23 +5006,27 @@ export type UpdateUser = z.infer<typeof updateUserSchema>;
3414
5006
  */
3415
5007
  function generateMonorepoFiles(options, _template) {
3416
5008
  if (!options.monorepo) return [];
5009
+ const isFullstack = options.template === "fullstack";
3417
5010
  const rootPackageJson = {
3418
5011
  name: options.name,
3419
5012
  version: "0.0.1",
3420
5013
  private: true,
3421
5014
  type: "module",
5015
+ packageManager: "pnpm@10.13.1",
3422
5016
  scripts: {
3423
- dev: "turbo dev",
3424
- build: "turbo build",
3425
- test: "turbo test",
3426
- "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",
3427
5021
  typecheck: "turbo typecheck",
3428
5022
  lint: "biome lint .",
3429
5023
  fmt: "biome format . --write",
3430
- "fmt:check": "biome format ."
5024
+ "fmt:check": "biome format .",
5025
+ ...options.deployTarget === "dokploy" ? { deploy: "gkm deploy --provider dokploy --stage production" } : {}
3431
5026
  },
3432
5027
  devDependencies: {
3433
- "@biomejs/biome": "~1.9.4",
5028
+ "@biomejs/biome": "~2.3.0",
5029
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3434
5030
  turbo: "~2.3.0",
3435
5031
  typescript: "~5.8.2",
3436
5032
  vitest: "~4.0.0"
@@ -3443,7 +5039,7 @@ function generateMonorepoFiles(options, _template) {
3443
5039
  - 'packages/*'
3444
5040
  `;
3445
5041
  const biomeConfig = {
3446
- $schema: "https://biomejs.dev/schemas/1.9.4/schema.json",
5042
+ $schema: "https://biomejs.dev/schemas/2.3.0/schema.json",
3447
5043
  vcs: {
3448
5044
  enabled: true,
3449
5045
  clientKind: "git",
@@ -3518,6 +5114,7 @@ dist/
3518
5114
  .env
3519
5115
  .env.local
3520
5116
  .env.*.local
5117
+ docker/.env
3521
5118
 
3522
5119
  # IDE
3523
5120
  .idea/
@@ -3554,14 +5151,27 @@ coverage/
3554
5151
  esModuleInterop: true,
3555
5152
  skipLibCheck: true,
3556
5153
  forceConsistentCasingInFileNames: true,
3557
- resolveJsonModule: true,
3558
- declaration: true,
3559
- declarationMap: true,
3560
- composite: true
5154
+ resolveJsonModule: true
3561
5155
  },
3562
5156
  exclude: ["node_modules", "dist"]
3563
5157
  };
3564
- 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 = [
3565
5175
  {
3566
5176
  path: "package.json",
3567
5177
  content: `${JSON.stringify(rootPackageJson, null, 2)}\n`
@@ -3582,11 +5192,100 @@ coverage/
3582
5192
  path: "turbo.json",
3583
5193
  content: `${JSON.stringify(turboConfig, null, 2)}\n`
3584
5194
  },
5195
+ {
5196
+ path: "vitest.config.ts",
5197
+ content: vitestConfig
5198
+ },
3585
5199
  {
3586
5200
  path: ".gitignore",
3587
5201
  content: gitignore
3588
5202
  }
3589
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;
3590
5289
  }
3591
5290
 
3592
5291
  //#endregion
@@ -3595,18 +5294,18 @@ const apiTemplate = {
3595
5294
  name: "api",
3596
5295
  description: "Full API with auth, database, services",
3597
5296
  dependencies: {
3598
- "@geekmidas/constructs": "workspace:*",
3599
- "@geekmidas/envkit": "workspace:*",
3600
- "@geekmidas/logger": "workspace:*",
3601
- "@geekmidas/services": "workspace:*",
3602
- "@geekmidas/errors": "workspace:*",
3603
- "@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"],
3604
5303
  hono: "~4.8.2",
3605
5304
  pino: "~9.6.0"
3606
5305
  },
3607
5306
  devDependencies: {
3608
- "@biomejs/biome": "~1.9.4",
3609
- "@geekmidas/cli": "workspace:*",
5307
+ "@biomejs/biome": "~2.3.0",
5308
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3610
5309
  "@types/node": "~22.0.0",
3611
5310
  tsx: "~4.20.0",
3612
5311
  turbo: "~2.3.0",
@@ -3643,18 +5342,17 @@ export const logger = createLogger();
3643
5342
  const files = [
3644
5343
  {
3645
5344
  path: "src/config/env.ts",
3646
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5345
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5346
+ import { EnvironmentParser } from '@geekmidas/envkit';
3647
5347
 
3648
- export const envParser = new EnvironmentParser(process.env);
5348
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
3649
5349
 
5350
+ // Global config - only minimal shared values
5351
+ // Service-specific config should be parsed in each service
3650
5352
  export const config = envParser
3651
5353
  .create((get) => ({
3652
- port: get('PORT').string().transform(Number).default(3000),
3653
- nodeEnv: get('NODE_ENV').string().default('development'),
3654
- jwtSecret: get('JWT_SECRET').string().default('change-me-in-production'),${options.database ? `
3655
- database: {
3656
- url: get('DATABASE_URL').string().default('postgresql://localhost:5432/mydb'),
3657
- },` : ""}
5354
+ nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
5355
+ stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
3658
5356
  }))
3659
5357
  .parse();
3660
5358
  `
@@ -3667,7 +5365,7 @@ export const config = envParser
3667
5365
  path: getRoutePath("health.ts"),
3668
5366
  content: `import { e } from '@geekmidas/constructs/endpoints';
3669
5367
 
3670
- export default e
5368
+ export const healthEndpoint = e
3671
5369
  .get('/health')
3672
5370
  .handle(async () => ({
3673
5371
  status: 'ok',
@@ -3679,7 +5377,7 @@ export default e
3679
5377
  path: getRoutePath("users/list.ts"),
3680
5378
  content: `import { e } from '@geekmidas/constructs/endpoints';
3681
5379
 
3682
- export default e
5380
+ export const listUsersEndpoint = e
3683
5381
  .get('/users')
3684
5382
  .handle(async () => ({
3685
5383
  users: [
@@ -3694,7 +5392,7 @@ export default e
3694
5392
  content: `import { e } from '@geekmidas/constructs/endpoints';
3695
5393
  import { z } from 'zod';
3696
5394
 
3697
- export default e
5395
+ export const getUserEndpoint = e
3698
5396
  .get('/users/:id')
3699
5397
  .params(z.object({ id: z.string() }))
3700
5398
  .handle(async ({ params }) => ({
@@ -3707,7 +5405,7 @@ export default e
3707
5405
  ];
3708
5406
  if (options.database) files.push({
3709
5407
  path: "src/services/database.ts",
3710
- content: `import type { Service } from '@geekmidas/services';
5408
+ content: `import type { Service, ServiceRegisterOptions } from '@geekmidas/services';
3711
5409
  import { Kysely, PostgresDialect } from 'kysely';
3712
5410
  import pg from 'pg';
3713
5411
 
@@ -3723,18 +5421,24 @@ export interface Database {
3723
5421
 
3724
5422
  export const databaseService = {
3725
5423
  serviceName: 'database' as const,
3726
- async register(envParser) {
5424
+ async register({ envParser, context }: ServiceRegisterOptions) {
5425
+ const logger = context.getLogger();
5426
+ logger.info('Connecting to database');
5427
+
3727
5428
  const config = envParser
3728
5429
  .create((get) => ({
3729
5430
  url: get('DATABASE_URL').string(),
3730
5431
  }))
3731
5432
  .parse();
3732
5433
 
3733
- return new Kysely<Database>({
5434
+ const db = new Kysely<Database>({
3734
5435
  dialect: new PostgresDialect({
3735
5436
  pool: new pg.Pool({ connectionString: config.url }),
3736
5437
  }),
3737
5438
  });
5439
+
5440
+ logger.info('Database connection established');
5441
+ return db;
3738
5442
  },
3739
5443
  } satisfies Service<'database', Kysely<Database>>;
3740
5444
  `
@@ -3755,13 +5459,20 @@ export const telescope = new Telescope({
3755
5459
  content: `import { Direction, InMemoryMonitoringStorage, Studio } from '@geekmidas/studio';
3756
5460
  import { Kysely, PostgresDialect } from 'kysely';
3757
5461
  import pg from 'pg';
3758
- import type { Database } from '../services/database';
3759
- 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();
3760
5471
 
3761
5472
  // Create a Kysely instance for Studio
3762
5473
  const db = new Kysely<Database>({
3763
5474
  dialect: new PostgresDialect({
3764
- pool: new pg.Pool({ connectionString: config.database.url }),
5475
+ pool: new pg.Pool({ connectionString: studioConfig.databaseUrl }),
3765
5476
  }),
3766
5477
  });
3767
5478
 
@@ -3787,15 +5498,15 @@ const minimalTemplate = {
3787
5498
  name: "minimal",
3788
5499
  description: "Basic health endpoint",
3789
5500
  dependencies: {
3790
- "@geekmidas/constructs": "workspace:*",
3791
- "@geekmidas/envkit": "workspace:*",
3792
- "@geekmidas/logger": "workspace:*",
5501
+ "@geekmidas/constructs": GEEKMIDAS_VERSIONS["@geekmidas/constructs"],
5502
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
5503
+ "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
3793
5504
  hono: "~4.8.2",
3794
5505
  pino: "~9.6.0"
3795
5506
  },
3796
5507
  devDependencies: {
3797
- "@biomejs/biome": "~1.9.4",
3798
- "@geekmidas/cli": "workspace:*",
5508
+ "@biomejs/biome": "~2.3.0",
5509
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3799
5510
  "@types/node": "~22.0.0",
3800
5511
  tsx: "~4.20.0",
3801
5512
  turbo: "~2.3.0",
@@ -3828,14 +5539,17 @@ export const logger = createLogger();
3828
5539
  const files = [
3829
5540
  {
3830
5541
  path: "src/config/env.ts",
3831
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5542
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5543
+ import { EnvironmentParser } from '@geekmidas/envkit';
3832
5544
 
3833
- export const envParser = new EnvironmentParser(process.env);
5545
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
3834
5546
 
5547
+ // Global config - only minimal shared values
5548
+ // Service-specific config should be parsed in each service
3835
5549
  export const config = envParser
3836
5550
  .create((get) => ({
3837
- port: get('PORT').string().transform(Number).default(3000),
3838
- 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'),
3839
5553
  }))
3840
5554
  .parse();
3841
5555
  `
@@ -3848,7 +5562,7 @@ export const config = envParser
3848
5562
  path: getRoutePath("health.ts"),
3849
5563
  content: `import { e } from '@geekmidas/constructs/endpoints';
3850
5564
 
3851
- export default e
5565
+ export const healthEndpoint = e
3852
5566
  .get('/health')
3853
5567
  .handle(async () => ({
3854
5568
  status: 'ok',
@@ -3857,27 +5571,9 @@ export default e
3857
5571
  `
3858
5572
  }
3859
5573
  ];
3860
- if (options.database) {
3861
- files[0] = {
3862
- path: "src/config/env.ts",
3863
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
3864
-
3865
- export const envParser = new EnvironmentParser(process.env);
3866
-
3867
- export const config = envParser
3868
- .create((get) => ({
3869
- port: get('PORT').string().transform(Number).default(3000),
3870
- nodeEnv: get('NODE_ENV').string().default('development'),
3871
- database: {
3872
- url: get('DATABASE_URL').string().default('postgresql://localhost:5432/mydb'),
3873
- },
3874
- }))
3875
- .parse();
3876
- `
3877
- };
3878
- files.push({
3879
- path: "src/services/database.ts",
3880
- 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';
3881
5577
  import { Kysely, PostgresDialect } from 'kysely';
3882
5578
  import pg from 'pg';
3883
5579
 
@@ -3888,23 +5584,28 @@ export interface Database {
3888
5584
 
3889
5585
  export const databaseService = {
3890
5586
  serviceName: 'database' as const,
3891
- async register(envParser) {
5587
+ async register({ envParser, context }: ServiceRegisterOptions) {
5588
+ const logger = context.getLogger();
5589
+ logger.info('Connecting to database');
5590
+
3892
5591
  const config = envParser
3893
5592
  .create((get) => ({
3894
5593
  url: get('DATABASE_URL').string(),
3895
5594
  }))
3896
5595
  .parse();
3897
5596
 
3898
- return new Kysely<Database>({
5597
+ const db = new Kysely<Database>({
3899
5598
  dialect: new PostgresDialect({
3900
5599
  pool: new pg.Pool({ connectionString: config.url }),
3901
5600
  }),
3902
5601
  });
5602
+
5603
+ logger.info('Database connection established');
5604
+ return db;
3903
5605
  },
3904
5606
  } satisfies Service<'database', Kysely<Database>>;
3905
5607
  `
3906
- });
3907
- }
5608
+ });
3908
5609
  if (options.telescope) files.push({
3909
5610
  path: "src/config/telescope.ts",
3910
5611
  content: `import { Telescope } from '@geekmidas/telescope';
@@ -3921,13 +5622,20 @@ export const telescope = new Telescope({
3921
5622
  content: `import { Direction, InMemoryMonitoringStorage, Studio } from '@geekmidas/studio';
3922
5623
  import { Kysely, PostgresDialect } from 'kysely';
3923
5624
  import pg from 'pg';
3924
- import type { Database } from '../services/database';
3925
- 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();
3926
5634
 
3927
5635
  // Create a Kysely instance for Studio
3928
5636
  const db = new Kysely<Database>({
3929
5637
  dialect: new PostgresDialect({
3930
- pool: new pg.Pool({ connectionString: config.database.url }),
5638
+ pool: new pg.Pool({ connectionString: studioConfig.databaseUrl }),
3931
5639
  }),
3932
5640
  });
3933
5641
 
@@ -3953,16 +5661,16 @@ const serverlessTemplate = {
3953
5661
  name: "serverless",
3954
5662
  description: "AWS Lambda handlers",
3955
5663
  dependencies: {
3956
- "@geekmidas/constructs": "workspace:*",
3957
- "@geekmidas/envkit": "workspace:*",
3958
- "@geekmidas/logger": "workspace:*",
3959
- "@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"],
3960
5668
  hono: "~4.8.2",
3961
5669
  pino: "~9.6.0"
3962
5670
  },
3963
5671
  devDependencies: {
3964
- "@biomejs/biome": "~1.9.4",
3965
- "@geekmidas/cli": "workspace:*",
5672
+ "@biomejs/biome": "~2.3.0",
5673
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
3966
5674
  "@types/aws-lambda": "~8.10.92",
3967
5675
  "@types/node": "~22.0.0",
3968
5676
  tsx: "~4.20.0",
@@ -3996,17 +5704,17 @@ export const logger = createLogger();
3996
5704
  const files = [
3997
5705
  {
3998
5706
  path: "src/config/env.ts",
3999
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5707
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5708
+ import { EnvironmentParser } from '@geekmidas/envkit';
4000
5709
 
4001
- export const envParser = new EnvironmentParser(process.env);
5710
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
4002
5711
 
5712
+ // Global config - only minimal shared values
5713
+ // Service-specific config should be parsed in each service
4003
5714
  export const config = envParser
4004
5715
  .create((get) => ({
4005
- stage: get('STAGE').string().default('dev'),
4006
- region: get('AWS_REGION').string().default('us-east-1'),${options.database ? `
4007
- database: {
4008
- url: get('DATABASE_URL').string(),
4009
- },` : ""}
5716
+ nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
5717
+ stage: get('STAGE').enum(['dev', 'staging', 'prod']).default('dev'),
4010
5718
  }))
4011
5719
  .parse();
4012
5720
  `
@@ -4019,7 +5727,7 @@ export const config = envParser
4019
5727
  path: getRoutePath("health.ts"),
4020
5728
  content: `import { e } from '@geekmidas/constructs/endpoints';
4021
5729
 
4022
- export default e
5730
+ export const healthEndpoint = e
4023
5731
  .get('/health')
4024
5732
  .handle(async () => ({
4025
5733
  status: 'ok',
@@ -4033,7 +5741,7 @@ export default e
4033
5741
  content: `import { f } from '@geekmidas/constructs/functions';
4034
5742
  import { z } from 'zod';
4035
5743
 
4036
- export default f
5744
+ export const helloFunction = f
4037
5745
  .input(z.object({ name: z.string() }))
4038
5746
  .output(z.object({ message: z.string() }))
4039
5747
  .handle(async ({ input }) => ({
@@ -4064,16 +5772,16 @@ const workerTemplate = {
4064
5772
  name: "worker",
4065
5773
  description: "Background job processing",
4066
5774
  dependencies: {
4067
- "@geekmidas/constructs": "workspace:*",
4068
- "@geekmidas/envkit": "workspace:*",
4069
- "@geekmidas/logger": "workspace:*",
4070
- "@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"],
4071
5779
  hono: "~4.8.2",
4072
5780
  pino: "~9.6.0"
4073
5781
  },
4074
5782
  devDependencies: {
4075
- "@biomejs/biome": "~1.9.4",
4076
- "@geekmidas/cli": "workspace:*",
5783
+ "@biomejs/biome": "~2.3.0",
5784
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
4077
5785
  "@types/node": "~22.0.0",
4078
5786
  tsx: "~4.20.0",
4079
5787
  turbo: "~2.3.0",
@@ -4106,20 +5814,17 @@ export const logger = createLogger();
4106
5814
  const files = [
4107
5815
  {
4108
5816
  path: "src/config/env.ts",
4109
- content: `import { EnvironmentParser } from '@geekmidas/envkit';
5817
+ content: `import { Credentials } from '@geekmidas/envkit/credentials';
5818
+ import { EnvironmentParser } from '@geekmidas/envkit';
4110
5819
 
4111
- export const envParser = new EnvironmentParser(process.env);
5820
+ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
4112
5821
 
5822
+ // Global config - only minimal shared values
5823
+ // Service-specific config should be parsed in each service
4113
5824
  export const config = envParser
4114
5825
  .create((get) => ({
4115
- port: get('PORT').string().transform(Number).default(3000),
4116
- nodeEnv: get('NODE_ENV').string().default('development'),
4117
- rabbitmq: {
4118
- url: get('RABBITMQ_URL').string().default('amqp://localhost:5672'),
4119
- },${options.database ? `
4120
- database: {
4121
- url: get('DATABASE_URL').string().default('postgresql://localhost:5432/mydb'),
4122
- },` : ""}
5826
+ nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
5827
+ stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
4123
5828
  }))
4124
5829
  .parse();
4125
5830
  `
@@ -4132,7 +5837,7 @@ export const config = envParser
4132
5837
  path: getRoutePath("health.ts"),
4133
5838
  content: `import { e } from '@geekmidas/constructs/endpoints';
4134
5839
 
4135
- export default e
5840
+ export const healthEndpoint = e
4136
5841
  .get('/health')
4137
5842
  .handle(async () => ({
4138
5843
  status: 'ok',
@@ -4149,15 +5854,44 @@ export type AppEvents =
4149
5854
  | PublishableMessage<'user.created', { userId: string; email: string }>
4150
5855
  | PublishableMessage<'user.updated', { userId: string; changes: Record<string, unknown> }>
4151
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>>;
4152
5885
  `
4153
5886
  },
4154
5887
  {
4155
5888
  path: "src/subscribers/user-events.ts",
4156
5889
  content: `import { s } from '@geekmidas/constructs/subscribers';
4157
- import type { AppEvents } from '../events/types.js';
5890
+ import { eventsPublisherService } from '../events/publisher.js';
4158
5891
 
4159
- export default s<AppEvents>()
4160
- .events(['user.created', 'user.updated'])
5892
+ export const userEventsSubscriber = s
5893
+ .publisher(eventsPublisherService)
5894
+ .subscribe(['user.created', 'user.updated'])
4161
5895
  .handle(async ({ event, logger }) => {
4162
5896
  logger.info({ type: event.type, payload: event.payload }, 'Processing user event');
4163
5897
 
@@ -4179,7 +5913,7 @@ export default s<AppEvents>()
4179
5913
  content: `import { cron } from '@geekmidas/constructs/crons';
4180
5914
 
4181
5915
  // Run every day at midnight
4182
- export default cron('0 0 * * *')
5916
+ export const cleanupCron = cron('0 0 * * *')
4183
5917
  .handle(async ({ logger }) => {
4184
5918
  logger.info('Running cleanup job');
4185
5919
 
@@ -4222,30 +5956,17 @@ const templates = {
4222
5956
  worker: workerTemplate
4223
5957
  };
4224
5958
  /**
4225
- * Template choices for prompts
5959
+ * Template choices for prompts (Story 1.11 simplified to api + fullstack)
4226
5960
  */
4227
- const templateChoices = [
4228
- {
4229
- title: "Minimal",
4230
- value: "minimal",
4231
- description: "Basic health endpoint"
4232
- },
4233
- {
4234
- title: "API",
4235
- value: "api",
4236
- description: "Full API with auth, database, services"
4237
- },
4238
- {
4239
- title: "Serverless",
4240
- value: "serverless",
4241
- description: "AWS Lambda handlers"
4242
- },
4243
- {
4244
- title: "Worker",
4245
- value: "worker",
4246
- description: "Background job processing"
4247
- }
4248
- ];
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
+ }];
4249
5970
  /**
4250
5971
  * Logger type choices for prompts
4251
5972
  */
@@ -4279,13 +6000,77 @@ const routesStructureChoices = [
4279
6000
  }
4280
6001
  ];
4281
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
+ /**
4282
6060
  * Get a template by name
4283
6061
  */
4284
6062
  function getTemplate(name$1) {
6063
+ if (name$1 === "fullstack") return templates.api;
4285
6064
  const template = templates[name$1];
4286
6065
  if (!template) throw new Error(`Unknown template: ${name$1}`);
4287
6066
  return template;
4288
6067
  }
6068
+ /**
6069
+ * Check if a template is the fullstack monorepo template
6070
+ */
6071
+ function isFullstackTemplate(name$1) {
6072
+ return name$1 === "fullstack";
6073
+ }
4289
6074
 
4290
6075
  //#endregion
4291
6076
  //#region src/init/generators/package.ts
@@ -4297,10 +6082,10 @@ function generatePackageJson(options, template) {
4297
6082
  const dependencies$1 = { ...template.dependencies };
4298
6083
  const devDependencies$1 = { ...template.devDependencies };
4299
6084
  const scripts$1 = { ...template.scripts };
4300
- if (telescope) dependencies$1["@geekmidas/telescope"] = "workspace:*";
4301
- 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"];
4302
6087
  if (database) {
4303
- dependencies$1["@geekmidas/db"] = "workspace:*";
6088
+ dependencies$1["@geekmidas/db"] = GEEKMIDAS_VERSIONS["@geekmidas/db"];
4304
6089
  dependencies$1.kysely = "~0.28.2";
4305
6090
  dependencies$1.pg = "~8.16.0";
4306
6091
  devDependencies$1["@types/pg"] = "~8.15.0";
@@ -4335,19 +6120,219 @@ function generatePackageJson(options, template) {
4335
6120
  dependencies: sortObject(dependencies$1),
4336
6121
  devDependencies: sortObject(devDependencies$1)
4337
6122
  };
4338
- return [{
4339
- path: "package.json",
4340
- content: `${JSON.stringify(packageJson, null, 2)}\n`
4341
- }];
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
+ );
4342
6233
  }
6234
+ `;
6235
+ const pageTsx = `import type { User } from '${modelsPackage}';
4343
6236
 
4344
- //#endregion
4345
- //#region src/init/generators/source.ts
4346
- /**
4347
- * Generate source files from template
4348
- */
4349
- function generateSourceFiles(options, template) {
4350
- 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
+ ];
4351
6336
  }
4352
6337
 
4353
6338
  //#endregion
@@ -4415,21 +6400,36 @@ function getRunCommand(pkgManager, script) {
4415
6400
  //#endregion
4416
6401
  //#region src/init/index.ts
4417
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
+ /**
4418
6418
  * Main init command - scaffolds a new project
4419
6419
  */
4420
6420
  async function initCommand(projectName, options = {}) {
4421
6421
  const cwd = process.cwd();
4422
- const pkgManager = detectPackageManager(cwd);
6422
+ const detectedPkgManager = detectPackageManager(cwd);
4423
6423
  prompts.override({});
4424
6424
  const onCancel = () => {
4425
6425
  process.exit(0);
4426
6426
  };
4427
6427
  const answers = await prompts([
4428
6428
  {
4429
- type: projectName ? null : "text",
6429
+ type: projectName || options.name ? null : "text",
4430
6430
  name: "name",
4431
6431
  message: "Project name:",
4432
- initial: "my-api",
6432
+ initial: "my-app",
4433
6433
  validate: (value) => {
4434
6434
  const nameValid = validateProjectName(value);
4435
6435
  if (nameValid !== true) return nameValid;
@@ -4446,21 +6446,33 @@ async function initCommand(projectName, options = {}) {
4446
6446
  initial: 0
4447
6447
  },
4448
6448
  {
4449
- type: options.yes ? null : "confirm",
4450
- name: "telescope",
4451
- message: "Include Telescope (debugging dashboard)?",
4452
- 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"
4453
6457
  },
4454
6458
  {
4455
- type: options.yes ? null : "confirm",
4456
- name: "database",
4457
- message: "Include database support (Kysely)?",
4458
- 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
4459
6471
  },
4460
6472
  {
4461
- type: (prev) => options.yes ? null : prev ? "confirm" : null,
4462
- name: "studio",
4463
- message: "Include Studio (database browser)?",
6473
+ type: options.yes ? null : "confirm",
6474
+ name: "telescope",
6475
+ message: "Include Telescope (debugging dashboard)?",
4464
6476
  initial: true
4465
6477
  },
4466
6478
  {
@@ -4476,74 +6488,146 @@ async function initCommand(projectName, options = {}) {
4476
6488
  message: "Routes structure:",
4477
6489
  choices: routesStructureChoices,
4478
6490
  initial: 0
4479
- },
4480
- {
4481
- type: options.yes || options.monorepo !== void 0 ? null : "confirm",
4482
- name: "monorepo",
4483
- message: "Setup as monorepo?",
4484
- initial: false
4485
- },
4486
- {
4487
- type: (prev) => (prev === true || options.monorepo) && !options.apiPath ? "text" : null,
4488
- name: "apiPath",
4489
- message: "API app path:",
4490
- initial: "apps/api"
4491
6491
  }
4492
6492
  ], { onCancel });
4493
- const name$1 = projectName || answers.name;
4494
- if (!name$1) process.exit(1);
4495
- if (projectName) {
4496
- const nameValid = validateProjectName(projectName);
4497
- if (nameValid !== true) process.exit(1);
4498
- const dirValid = checkDirectoryExists(projectName, cwd);
4499
- if (dirValid !== true) process.exit(1);
4500
- }
4501
- const monorepo = options.monorepo ?? (options.yes ? false : answers.monorepo ?? false);
4502
- 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;
4503
6527
  const templateOptions = {
4504
6528
  name: name$1,
4505
- template: options.template || answers.template || "minimal",
6529
+ template,
4506
6530
  telescope: options.yes ? true : answers.telescope ?? true,
4507
6531
  database,
4508
- studio: database && (options.yes ? true : answers.studio ?? true),
6532
+ studio: database,
4509
6533
  loggerType: options.yes ? "pino" : answers.loggerType ?? "pino",
4510
6534
  routesStructure: options.yes ? "centralized-endpoints" : answers.routesStructure ?? "centralized-endpoints",
4511
6535
  monorepo,
4512
- apiPath: monorepo ? options.apiPath ?? answers.apiPath ?? "apps/api" : ""
6536
+ apiPath: monorepo ? options.apiPath ?? "apps/api" : "",
6537
+ packageManager: pkgManager,
6538
+ deployTarget,
6539
+ services
4513
6540
  };
4514
6541
  const targetDir = join(cwd, name$1);
4515
- const template = getTemplate(templateOptions.template);
6542
+ const baseTemplate = getTemplate(templateOptions.template);
4516
6543
  const isMonorepo$1 = templateOptions.monorepo;
4517
6544
  const apiPath = templateOptions.apiPath;
6545
+ console.log("\n🚀 Creating your project...\n");
4518
6546
  await mkdir(targetDir, { recursive: true });
4519
6547
  const appDir = isMonorepo$1 ? join(targetDir, apiPath) : targetDir;
4520
6548
  if (isMonorepo$1) await mkdir(appDir, { recursive: true });
4521
- const appFiles = [
4522
- ...generatePackageJson(templateOptions, template),
4523
- ...generateConfigFiles(templateOptions, template),
4524
- ...generateEnvFiles(templateOptions, template),
4525
- ...generateSourceFiles(templateOptions, template),
4526
- ...generateDockerFiles(templateOptions, template)
4527
- ];
4528
- 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) : [];
4529
6568
  for (const { path, content } of rootFiles) {
4530
6569
  const fullPath = join(targetDir, path);
4531
6570
  await mkdir(dirname(fullPath), { recursive: true });
4532
6571
  await writeFile(fullPath, content);
4533
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
+ }
4534
6578
  for (const { path, content } of appFiles) {
4535
6579
  const fullPath = join(appDir, path);
4536
- const _displayPath = isMonorepo$1 ? `${apiPath}/${path}` : path;
4537
6580
  await mkdir(dirname(fullPath), { recursive: true });
4538
6581
  await writeFile(fullPath, content);
4539
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`);
4540
6621
  if (!options.skipInstall) {
6622
+ console.log("\n📦 Installing dependencies...\n");
4541
6623
  try {
4542
6624
  execSync(getInstallCommand(pkgManager), {
4543
6625
  cwd: targetDir,
4544
6626
  stdio: "inherit"
4545
6627
  });
4546
- } catch {}
6628
+ } catch {
6629
+ console.error("Failed to install dependencies");
6630
+ }
4547
6631
  try {
4548
6632
  execSync("npx @biomejs/biome format --write --unsafe .", {
4549
6633
  cwd: targetDir,
@@ -4551,124 +6635,52 @@ async function initCommand(projectName, options = {}) {
4551
6635
  });
4552
6636
  } catch {}
4553
6637
  }
4554
- const _devCommand = getRunCommand(pkgManager, "dev");
6638
+ printNextSteps(name$1, templateOptions, pkgManager);
4555
6639
  }
4556
-
4557
- //#endregion
4558
- //#region src/secrets/generator.ts
4559
6640
  /**
4560
- * Generate a secure random password using URL-safe base64 characters.
4561
- * @param length Password length (default: 32)
6641
+ * Print success message with next steps
4562
6642
  */
4563
- function generateSecurePassword(length = 32) {
4564
- return randomBytes(Math.ceil(length * 3 / 4)).toString("base64url").slice(0, length);
4565
- }
4566
- /** Default service configurations */
4567
- const SERVICE_DEFAULTS = {
4568
- postgres: {
4569
- host: "postgres",
4570
- port: 5432,
4571
- username: "app",
4572
- database: "app"
4573
- },
4574
- redis: {
4575
- host: "redis",
4576
- port: 6379,
4577
- username: "default"
4578
- },
4579
- rabbitmq: {
4580
- host: "rabbitmq",
4581
- port: 5672,
4582
- username: "app",
4583
- 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`);
4584
6653
  }
4585
- };
4586
- /**
4587
- * Generate credentials for a specific service.
4588
- */
4589
- function generateServiceCredentials(service) {
4590
- const defaults = SERVICE_DEFAULTS[service];
4591
- return {
4592
- ...defaults,
4593
- password: generateSecurePassword()
4594
- };
4595
- }
4596
- /**
4597
- * Generate credentials for multiple services.
4598
- */
4599
- function generateServicesCredentials(services) {
4600
- const result = {};
4601
- for (const service of services) result[service] = generateServiceCredentials(service);
4602
- return result;
4603
- }
4604
- /**
4605
- * Generate connection URL for PostgreSQL.
4606
- */
4607
- function generatePostgresUrl(creds) {
4608
- const { username, password, host, port, database } = creds;
4609
- return `postgresql://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}`;
4610
- }
4611
- /**
4612
- * Generate connection URL for Redis.
4613
- */
4614
- function generateRedisUrl(creds) {
4615
- const { password, host, port } = creds;
4616
- return `redis://:${encodeURIComponent(password)}@${host}:${port}`;
4617
- }
4618
- /**
4619
- * Generate connection URL for RabbitMQ.
4620
- */
4621
- function generateRabbitmqUrl(creds) {
4622
- const { username, password, host, port, vhost } = creds;
4623
- const encodedVhost = encodeURIComponent(vhost ?? "/");
4624
- return `amqp://${username}:${encodeURIComponent(password)}@${host}:${port}/${encodedVhost}`;
4625
- }
4626
- /**
4627
- * Generate connection URLs from service credentials.
4628
- */
4629
- function generateConnectionUrls(services) {
4630
- const urls = {};
4631
- if (services.postgres) urls.DATABASE_URL = generatePostgresUrl(services.postgres);
4632
- if (services.redis) urls.REDIS_URL = generateRedisUrl(services.redis);
4633
- if (services.rabbitmq) urls.RABBITMQ_URL = generateRabbitmqUrl(services.rabbitmq);
4634
- return urls;
4635
- }
4636
- /**
4637
- * Create a new StageSecrets object with generated credentials.
4638
- */
4639
- function createStageSecrets(stage, services) {
4640
- const now = (/* @__PURE__ */ new Date()).toISOString();
4641
- const serviceCredentials = generateServicesCredentials(services);
4642
- const urls = generateConnectionUrls(serviceCredentials);
4643
- return {
4644
- stage,
4645
- createdAt: now,
4646
- updatedAt: now,
4647
- services: serviceCredentials,
4648
- urls,
4649
- custom: {}
4650
- };
4651
- }
4652
- /**
4653
- * Rotate password for a specific service.
4654
- */
4655
- function rotateServicePassword(secrets, service) {
4656
- const currentCreds = secrets.services[service];
4657
- if (!currentCreds) throw new Error(`Service "${service}" not configured in secrets`);
4658
- const newCreds = {
4659
- ...currentCreds,
4660
- password: generateSecurePassword()
4661
- };
4662
- const newServices = {
4663
- ...secrets.services,
4664
- [service]: newCreds
4665
- };
4666
- return {
4667
- ...secrets,
4668
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4669
- services: newServices,
4670
- urls: generateConnectionUrls(newServices)
4671
- };
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("");
4672
6684
  }
4673
6685
 
4674
6686
  //#endregion
@@ -4857,11 +6869,57 @@ function maskUrl(url) {
4857
6869
  }
4858
6870
  }
4859
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
+
4860
6918
  //#endregion
4861
6919
  //#region src/index.ts
4862
6920
  const program = new Command();
4863
6921
  program.name("gkm").description("GeekMidas backend framework CLI").version(package_default.version).option("--cwd <path>", "Change working directory");
4864
- 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) => {
4865
6923
  try {
4866
6924
  const globalOptions = program.opts();
4867
6925
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -4918,6 +6976,19 @@ program.command("dev").description("Start development server with automatic relo
4918
6976
  process.exit(1);
4919
6977
  }
4920
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
+ });
4921
6992
  program.command("cron").description("Manage cron jobs").action(() => {
4922
6993
  const globalOptions = program.opts();
4923
6994
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);