@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/src/dev/index.ts CHANGED
@@ -14,7 +14,7 @@ import type {
14
14
  NormalizedStudioConfig,
15
15
  NormalizedTelescopeConfig,
16
16
  } from '../build/types';
17
- import { loadConfig, parseModuleConfig } from '../config';
17
+ import { loadWorkspaceConfig, parseModuleConfig } from '../config';
18
18
  import {
19
19
  CronGenerator,
20
20
  EndpointGenerator,
@@ -26,6 +26,11 @@ import {
26
26
  OPENAPI_OUTPUT_PATH,
27
27
  resolveOpenApiConfig,
28
28
  } from '../openapi';
29
+ import {
30
+ readStageSecrets,
31
+ secretsExist,
32
+ toEmbeddableSecrets,
33
+ } from '../secrets/storage.js';
29
34
  import type {
30
35
  GkmConfig,
31
36
  LegacyProvider,
@@ -35,6 +40,17 @@ import type {
35
40
  StudioConfig,
36
41
  TelescopeConfig,
37
42
  } from '../types';
43
+ import {
44
+ generateAllClients,
45
+ generateClientForFrontend,
46
+ getDependentFrontends,
47
+ normalizeRoutes,
48
+ } from '../workspace/client-generator.js';
49
+ import {
50
+ getAppBuildOrder,
51
+ getDependencyEnvVars,
52
+ type NormalizedWorkspace,
53
+ } from '../workspace/index.js';
38
54
 
39
55
  const logger = console;
40
56
 
@@ -278,6 +294,10 @@ export interface DevOptions {
278
294
  port?: number;
279
295
  portExplicit?: boolean;
280
296
  enableOpenApi?: boolean;
297
+ /** Specific app to run in workspace mode (default: all apps) */
298
+ app?: string;
299
+ /** Filter apps by pattern (passed to turbo --filter) */
300
+ filter?: string;
281
301
  }
282
302
 
283
303
  export async function devCommand(options: DevOptions): Promise<void> {
@@ -288,7 +308,17 @@ export async function devCommand(options: DevOptions): Promise<void> {
288
308
  logger.log(`📦 Loaded env: ${defaultEnv.loaded.join(', ')}`);
289
309
  }
290
310
 
291
- const config = await loadConfig();
311
+ // Try to load workspace config first
312
+ const loadedConfig = await loadWorkspaceConfig();
313
+
314
+ // Route to workspace dev mode for multi-app workspaces
315
+ if (loadedConfig.type === 'workspace') {
316
+ logger.log('📦 Detected workspace configuration');
317
+ return workspaceDevCommand(loadedConfig.workspace, options);
318
+ }
319
+
320
+ // Single-app mode - use existing logic
321
+ const config = loadedConfig.raw as GkmConfig;
292
322
 
293
323
  // Load any additional env files specified in config
294
324
  if (config.env) {
@@ -510,6 +540,556 @@ export async function devCommand(options: DevOptions): Promise<void> {
510
540
  process.on('SIGTERM', shutdown);
511
541
  }
512
542
 
543
+ /**
544
+ * Generate all dependency environment variables for all apps.
545
+ * Returns a flat object with all {APP_NAME}_URL variables.
546
+ * @internal Exported for testing
547
+ */
548
+ export function generateAllDependencyEnvVars(
549
+ workspace: NormalizedWorkspace,
550
+ urlPrefix = 'http://localhost',
551
+ ): Record<string, string> {
552
+ const env: Record<string, string> = {};
553
+
554
+ for (const appName of Object.keys(workspace.apps)) {
555
+ const appEnv = getDependencyEnvVars(workspace, appName, urlPrefix);
556
+ Object.assign(env, appEnv);
557
+ }
558
+
559
+ return env;
560
+ }
561
+
562
+ /**
563
+ * Check for port conflicts across all apps.
564
+ * Returns list of conflicts if any ports are duplicated.
565
+ * @internal Exported for testing
566
+ */
567
+ export function checkPortConflicts(
568
+ workspace: NormalizedWorkspace,
569
+ ): { app1: string; app2: string; port: number }[] {
570
+ const conflicts: { app1: string; app2: string; port: number }[] = [];
571
+ const portToApp = new Map<number, string>();
572
+
573
+ for (const [appName, app] of Object.entries(workspace.apps)) {
574
+ const existingApp = portToApp.get(app.port);
575
+ if (existingApp) {
576
+ conflicts.push({ app1: existingApp, app2: appName, port: app.port });
577
+ } else {
578
+ portToApp.set(app.port, appName);
579
+ }
580
+ }
581
+
582
+ return conflicts;
583
+ }
584
+
585
+ /**
586
+ * Next.js config file patterns to check.
587
+ */
588
+ const NEXTJS_CONFIG_FILES = [
589
+ 'next.config.js',
590
+ 'next.config.ts',
591
+ 'next.config.mjs',
592
+ ];
593
+
594
+ /**
595
+ * Validation result for a frontend app.
596
+ */
597
+ export interface FrontendValidationResult {
598
+ appName: string;
599
+ valid: boolean;
600
+ errors: string[];
601
+ warnings: string[];
602
+ }
603
+
604
+ /**
605
+ * Validate a frontend (Next.js) app configuration.
606
+ * Checks for Next.js config file and dependency.
607
+ * @internal Exported for testing
608
+ */
609
+ export async function validateFrontendApp(
610
+ appName: string,
611
+ appPath: string,
612
+ workspaceRoot: string,
613
+ ): Promise<FrontendValidationResult> {
614
+ const errors: string[] = [];
615
+ const warnings: string[] = [];
616
+ const fullPath = join(workspaceRoot, appPath);
617
+
618
+ // Check for Next.js config file
619
+ const hasConfigFile = NEXTJS_CONFIG_FILES.some((file) =>
620
+ existsSync(join(fullPath, file)),
621
+ );
622
+
623
+ if (!hasConfigFile) {
624
+ errors.push(
625
+ `Next.js config file not found. Expected one of: ${NEXTJS_CONFIG_FILES.join(', ')}`,
626
+ );
627
+ }
628
+
629
+ // Check for package.json
630
+ const packageJsonPath = join(fullPath, 'package.json');
631
+ if (existsSync(packageJsonPath)) {
632
+ try {
633
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
634
+ const pkg = require(packageJsonPath);
635
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
636
+
637
+ if (!deps.next) {
638
+ errors.push(
639
+ 'Next.js not found in dependencies. Run: pnpm add next react react-dom',
640
+ );
641
+ }
642
+
643
+ // Check for dev script
644
+ if (!pkg.scripts?.dev) {
645
+ warnings.push(
646
+ 'No "dev" script found in package.json. Turbo expects a "dev" script to run.',
647
+ );
648
+ }
649
+ } catch {
650
+ errors.push(`Failed to read package.json at ${packageJsonPath}`);
651
+ }
652
+ } else {
653
+ errors.push(
654
+ `package.json not found at ${appPath}. Run: pnpm init in the app directory.`,
655
+ );
656
+ }
657
+
658
+ return {
659
+ appName,
660
+ valid: errors.length === 0,
661
+ errors,
662
+ warnings,
663
+ };
664
+ }
665
+
666
+ /**
667
+ * Validate all frontend apps in the workspace.
668
+ * Returns validation results for each frontend app.
669
+ * @internal Exported for testing
670
+ */
671
+ export async function validateFrontendApps(
672
+ workspace: NormalizedWorkspace,
673
+ ): Promise<FrontendValidationResult[]> {
674
+ const results: FrontendValidationResult[] = [];
675
+
676
+ for (const [appName, app] of Object.entries(workspace.apps)) {
677
+ if (app.type === 'frontend') {
678
+ const result = await validateFrontendApp(
679
+ appName,
680
+ app.path,
681
+ workspace.root,
682
+ );
683
+ results.push(result);
684
+ }
685
+ }
686
+
687
+ return results;
688
+ }
689
+
690
+ /**
691
+ * Load secrets for development stage.
692
+ * Returns env vars to inject, or empty object if secrets not configured/found.
693
+ * @internal Exported for testing
694
+ */
695
+ export async function loadDevSecrets(
696
+ workspace: NormalizedWorkspace,
697
+ ): Promise<Record<string, string>> {
698
+ // Check if secrets are enabled in workspace config
699
+ if (!workspace.secrets.enabled) {
700
+ return {};
701
+ }
702
+
703
+ // Try 'dev' stage first, then 'development'
704
+ const stages = ['dev', 'development'];
705
+
706
+ for (const stage of stages) {
707
+ if (secretsExist(stage, workspace.root)) {
708
+ const secrets = await readStageSecrets(stage, workspace.root);
709
+ if (secrets) {
710
+ logger.log(`🔐 Loading secrets from stage: ${stage}`);
711
+ return toEmbeddableSecrets(secrets);
712
+ }
713
+ }
714
+ }
715
+
716
+ logger.warn(
717
+ '⚠️ Secrets enabled but no dev/development secrets found. Run "gkm secrets:init --stage dev"',
718
+ );
719
+ return {};
720
+ }
721
+
722
+ /**
723
+ * Start docker-compose services for the workspace.
724
+ * @internal Exported for testing
725
+ */
726
+ export async function startWorkspaceServices(
727
+ workspace: NormalizedWorkspace,
728
+ ): Promise<void> {
729
+ const services = workspace.services;
730
+ if (!services.db && !services.cache && !services.mail) {
731
+ return;
732
+ }
733
+
734
+ const servicesToStart: string[] = [];
735
+
736
+ if (services.db) {
737
+ servicesToStart.push('postgres');
738
+ }
739
+ if (services.cache) {
740
+ servicesToStart.push('redis');
741
+ }
742
+ if (services.mail) {
743
+ servicesToStart.push('mailpit');
744
+ }
745
+
746
+ if (servicesToStart.length === 0) {
747
+ return;
748
+ }
749
+
750
+ logger.log(`🐳 Starting services: ${servicesToStart.join(', ')}`);
751
+
752
+ try {
753
+ // Check if docker-compose.yml exists
754
+ const composeFile = join(workspace.root, 'docker-compose.yml');
755
+ if (!existsSync(composeFile)) {
756
+ logger.warn(
757
+ '⚠️ No docker-compose.yml found. Services will not be started.',
758
+ );
759
+ return;
760
+ }
761
+
762
+ // Start services with docker-compose
763
+ execSync(`docker compose up -d ${servicesToStart.join(' ')}`, {
764
+ cwd: workspace.root,
765
+ stdio: 'inherit',
766
+ });
767
+
768
+ logger.log('✅ Services started');
769
+ } catch (error) {
770
+ logger.error('❌ Failed to start services:', (error as Error).message);
771
+ throw error;
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Workspace dev command - orchestrates multi-app development using Turbo.
777
+ *
778
+ * Flow:
779
+ * 1. Check for port conflicts
780
+ * 2. Start docker-compose services (db, cache, mail)
781
+ * 3. Generate dependency URLs ({APP_NAME}_URL)
782
+ * 4. Spawn turbo run dev with injected env vars
783
+ */
784
+ async function workspaceDevCommand(
785
+ workspace: NormalizedWorkspace,
786
+ options: DevOptions,
787
+ ): Promise<void> {
788
+ const appCount = Object.keys(workspace.apps).length;
789
+ const backendApps = Object.entries(workspace.apps).filter(
790
+ ([_, app]) => app.type === 'backend',
791
+ );
792
+ const frontendApps = Object.entries(workspace.apps).filter(
793
+ ([_, app]) => app.type === 'frontend',
794
+ );
795
+
796
+ logger.log(`\n🚀 Starting workspace: ${workspace.name}`);
797
+ logger.log(
798
+ ` ${backendApps.length} backend app(s), ${frontendApps.length} frontend app(s)`,
799
+ );
800
+
801
+ // Check for port conflicts
802
+ const conflicts = checkPortConflicts(workspace);
803
+ if (conflicts.length > 0) {
804
+ for (const conflict of conflicts) {
805
+ logger.error(
806
+ `❌ Port conflict: Apps "${conflict.app1}" and "${conflict.app2}" both use port ${conflict.port}`,
807
+ );
808
+ }
809
+ throw new Error(
810
+ 'Port conflicts detected. Please assign unique ports to each app.',
811
+ );
812
+ }
813
+
814
+ // Validate frontend apps (Next.js setup)
815
+ if (frontendApps.length > 0) {
816
+ logger.log('\n🔍 Validating frontend apps...');
817
+ const validationResults = await validateFrontendApps(workspace);
818
+
819
+ let hasErrors = false;
820
+ for (const result of validationResults) {
821
+ if (!result.valid) {
822
+ hasErrors = true;
823
+ logger.error(
824
+ `\n❌ Frontend app "${result.appName}" validation failed:`,
825
+ );
826
+ for (const error of result.errors) {
827
+ logger.error(` • ${error}`);
828
+ }
829
+ }
830
+ for (const warning of result.warnings) {
831
+ logger.warn(` ⚠️ ${result.appName}: ${warning}`);
832
+ }
833
+ }
834
+
835
+ if (hasErrors) {
836
+ throw new Error(
837
+ 'Frontend app validation failed. Fix the issues above and try again.',
838
+ );
839
+ }
840
+ logger.log('✅ Frontend apps validated');
841
+ }
842
+
843
+ // Generate initial clients for frontends with backend dependencies
844
+ if (frontendApps.length > 0) {
845
+ const clientResults = await generateAllClients(workspace, { force: true });
846
+ const generatedCount = clientResults.filter((r) => r.generated).length;
847
+ if (generatedCount > 0) {
848
+ logger.log(`\n📦 Generated ${generatedCount} API client(s)`);
849
+ }
850
+ }
851
+
852
+ // Start docker-compose services
853
+ await startWorkspaceServices(workspace);
854
+
855
+ // Load secrets if enabled
856
+ const secretsEnv = await loadDevSecrets(workspace);
857
+ if (Object.keys(secretsEnv).length > 0) {
858
+ logger.log(` Loaded ${Object.keys(secretsEnv).length} secret(s)`);
859
+ }
860
+
861
+ // Generate dependency URLs
862
+ const dependencyEnv = generateAllDependencyEnvVars(workspace);
863
+ if (Object.keys(dependencyEnv).length > 0) {
864
+ logger.log('📡 Dependency URLs:');
865
+ for (const [key, value] of Object.entries(dependencyEnv)) {
866
+ logger.log(` ${key}=${value}`);
867
+ }
868
+ }
869
+
870
+ // Build turbo filter
871
+ let turboFilter: string[] = [];
872
+ if (options.app) {
873
+ // Run specific app
874
+ if (!workspace.apps[options.app]) {
875
+ const appNames = Object.keys(workspace.apps).join(', ');
876
+ throw new Error(
877
+ `App "${options.app}" not found. Available apps: ${appNames}`,
878
+ );
879
+ }
880
+ turboFilter = ['--filter', options.app];
881
+ logger.log(`\n🎯 Running single app: ${options.app}`);
882
+ } else if (options.filter) {
883
+ // Use custom filter
884
+ turboFilter = ['--filter', options.filter];
885
+ logger.log(`\n🔍 Using filter: ${options.filter}`);
886
+ } else {
887
+ // Run all apps
888
+ logger.log(`\n🎯 Running all ${appCount} apps`);
889
+ }
890
+
891
+ // List apps and their ports
892
+ const buildOrder = getAppBuildOrder(workspace);
893
+ logger.log('\n📋 Apps (in dependency order):');
894
+ for (const appName of buildOrder) {
895
+ const app = workspace.apps[appName];
896
+ if (!app) continue;
897
+ const deps =
898
+ app.dependencies.length > 0
899
+ ? ` (depends on: ${app.dependencies.join(', ')})`
900
+ : '';
901
+ logger.log(
902
+ ` ${app.type === 'backend' ? '🔧' : '🌐'} ${appName} → http://localhost:${app.port}${deps}`,
903
+ );
904
+ }
905
+
906
+ // Prepare environment variables
907
+ // Order matters: secrets first, then dependencies (dependencies can override)
908
+ const turboEnv: Record<string, string> = {
909
+ ...process.env,
910
+ ...secretsEnv,
911
+ ...dependencyEnv,
912
+ NODE_ENV: 'development',
913
+ };
914
+
915
+ // Spawn turbo run dev
916
+ logger.log('\n🏃 Starting turbo run dev...\n');
917
+
918
+ const turboProcess = spawn('pnpm', ['turbo', 'run', 'dev', ...turboFilter], {
919
+ cwd: workspace.root,
920
+ stdio: 'inherit',
921
+ env: turboEnv,
922
+ });
923
+
924
+ // Set up file watcher for backend endpoint changes (smart client regeneration)
925
+ let endpointWatcher: ReturnType<typeof chokidar.watch> | null = null;
926
+
927
+ if (frontendApps.length > 0 && backendApps.length > 0) {
928
+ // Collect all backend route patterns to watch
929
+ const watchPatterns: string[] = [];
930
+ const backendRouteMap = new Map<string, string[]>(); // routes pattern -> backend app names
931
+
932
+ for (const [appName, app] of backendApps) {
933
+ const routePatterns = normalizeRoutes(app.routes);
934
+ for (const routePattern of routePatterns) {
935
+ const fullPattern = join(workspace.root, app.path, routePattern);
936
+ watchPatterns.push(fullPattern);
937
+
938
+ // Map pattern to app name for change detection
939
+ const patternKey = join(app.path, routePattern);
940
+ const existing = backendRouteMap.get(patternKey) || [];
941
+ backendRouteMap.set(patternKey, [...existing, appName]);
942
+ }
943
+ }
944
+
945
+ if (watchPatterns.length > 0) {
946
+ // Resolve glob patterns to files
947
+ const resolvedFiles = await fg(watchPatterns, {
948
+ cwd: workspace.root,
949
+ absolute: true,
950
+ onlyFiles: true,
951
+ });
952
+
953
+ if (resolvedFiles.length > 0) {
954
+ logger.log(
955
+ `\n👀 Watching ${resolvedFiles.length} endpoint file(s) for schema changes`,
956
+ );
957
+
958
+ endpointWatcher = chokidar.watch(resolvedFiles, {
959
+ ignored: /(^|[/\\])\../,
960
+ persistent: true,
961
+ ignoreInitial: true,
962
+ });
963
+
964
+ let regenerateTimeout: NodeJS.Timeout | null = null;
965
+
966
+ endpointWatcher.on('change', async (changedPath) => {
967
+ // Debounce regeneration
968
+ if (regenerateTimeout) {
969
+ clearTimeout(regenerateTimeout);
970
+ }
971
+
972
+ regenerateTimeout = setTimeout(async () => {
973
+ // Find which backend app this file belongs to
974
+ const changedBackends: string[] = [];
975
+
976
+ for (const [appName, app] of backendApps) {
977
+ const routePatterns = normalizeRoutes(app.routes);
978
+ for (const routePattern of routePatterns) {
979
+ const routesDir = join(
980
+ workspace.root,
981
+ app.path,
982
+ routePattern.split('*')[0] || '',
983
+ );
984
+ if (changedPath.startsWith(routesDir.replace(/\/$/, ''))) {
985
+ changedBackends.push(appName);
986
+ break; // Found a match, no need to check other patterns
987
+ }
988
+ }
989
+ }
990
+
991
+ if (changedBackends.length === 0) {
992
+ return;
993
+ }
994
+
995
+ // Find frontends that depend on changed backends
996
+ const affectedFrontends = new Set<string>();
997
+ for (const backend of changedBackends) {
998
+ const dependents = getDependentFrontends(workspace, backend);
999
+ for (const frontend of dependents) {
1000
+ affectedFrontends.add(frontend);
1001
+ }
1002
+ }
1003
+
1004
+ if (affectedFrontends.size === 0) {
1005
+ return;
1006
+ }
1007
+
1008
+ // Regenerate clients for affected frontends
1009
+ logger.log(
1010
+ `\n🔄 Detected schema change in ${changedBackends.join(', ')}`,
1011
+ );
1012
+
1013
+ for (const frontend of affectedFrontends) {
1014
+ try {
1015
+ const results = await generateClientForFrontend(
1016
+ workspace,
1017
+ frontend,
1018
+ );
1019
+ for (const result of results) {
1020
+ if (result.generated) {
1021
+ logger.log(
1022
+ ` 📦 Regenerated client for ${result.frontendApp} (${result.endpointCount} endpoints)`,
1023
+ );
1024
+ }
1025
+ }
1026
+ } catch (error) {
1027
+ logger.error(
1028
+ ` ❌ Failed to regenerate client for ${frontend}: ${(error as Error).message}`,
1029
+ );
1030
+ }
1031
+ }
1032
+ }, 500); // 500ms debounce
1033
+ });
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ // Handle graceful shutdown
1039
+ let isShuttingDown = false;
1040
+ const shutdown = () => {
1041
+ if (isShuttingDown) return;
1042
+ isShuttingDown = true;
1043
+
1044
+ logger.log('\n🛑 Shutting down workspace...');
1045
+
1046
+ // Close endpoint watcher
1047
+ if (endpointWatcher) {
1048
+ endpointWatcher.close().catch(() => {});
1049
+ }
1050
+
1051
+ // Kill turbo process
1052
+ if (turboProcess.pid) {
1053
+ try {
1054
+ // Try to kill the process group
1055
+ process.kill(-turboProcess.pid, 'SIGTERM');
1056
+ } catch {
1057
+ // Fall back to killing just the process
1058
+ turboProcess.kill('SIGTERM');
1059
+ }
1060
+ }
1061
+
1062
+ // Give processes time to clean up
1063
+ setTimeout(() => {
1064
+ process.exit(0);
1065
+ }, 2000);
1066
+ };
1067
+
1068
+ process.on('SIGINT', shutdown);
1069
+ process.on('SIGTERM', shutdown);
1070
+
1071
+ // Wait for turbo to exit
1072
+ return new Promise((resolve, reject) => {
1073
+ turboProcess.on('error', (error) => {
1074
+ logger.error('❌ Turbo error:', error);
1075
+ reject(error);
1076
+ });
1077
+
1078
+ turboProcess.on('exit', (code) => {
1079
+ // Close watcher on exit
1080
+ if (endpointWatcher) {
1081
+ endpointWatcher.close().catch(() => {});
1082
+ }
1083
+
1084
+ if (code !== null && code !== 0) {
1085
+ reject(new Error(`Turbo exited with code ${code}`));
1086
+ } else {
1087
+ resolve();
1088
+ }
1089
+ });
1090
+ });
1091
+ }
1092
+
513
1093
  async function buildServer(
514
1094
  config: any,
515
1095
  context: BuildContext,