@geekmidas/cli 1.10.36 → 1.10.37

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 (82) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/{config-U-gojtxn.mjs → config-Bbu1JKvf.mjs} +2 -2
  3. package/dist/{config-U-gojtxn.mjs.map → config-Bbu1JKvf.mjs.map} +1 -1
  4. package/dist/{config-QnuOcYXp.cjs → config-DujV8DbR.cjs} +2 -2
  5. package/dist/{config-QnuOcYXp.cjs.map → config-DujV8DbR.cjs.map} +1 -1
  6. package/dist/config.cjs +2 -2
  7. package/dist/config.d.cts +1 -1
  8. package/dist/config.d.mts +1 -1
  9. package/dist/config.mjs +2 -2
  10. package/dist/{fullstack-secrets-Bchl2MDd.cjs → fullstack-secrets-BYzi6Hwl.cjs} +3 -2
  11. package/dist/{fullstack-secrets-Bchl2MDd.cjs.map → fullstack-secrets-BYzi6Hwl.cjs.map} +1 -1
  12. package/dist/{fullstack-secrets-P84v8zWn.mjs → fullstack-secrets-l8s3II_H.mjs} +3 -2
  13. package/dist/{fullstack-secrets-P84v8zWn.mjs.map → fullstack-secrets-l8s3II_H.mjs.map} +1 -1
  14. package/dist/{index-D7iT4dnv.d.mts → index-BBvGMG_A.d.mts} +124 -67
  15. package/dist/index-BBvGMG_A.d.mts.map +1 -0
  16. package/dist/{index-DRQq26DF.d.cts → index-Dz2a7xQU.d.cts} +124 -67
  17. package/dist/index-Dz2a7xQU.d.cts.map +1 -0
  18. package/dist/index.cjs +244 -91
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.mjs +244 -91
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/{openapi-Bb4UEyZN.cjs → openapi-0JUzU5Tv.cjs} +9 -2
  23. package/dist/{openapi-Bb4UEyZN.cjs.map → openapi-0JUzU5Tv.cjs.map} +1 -1
  24. package/dist/{openapi-BA0e3I_s.mjs → openapi-C5AycKTR.mjs} +10 -3
  25. package/dist/{openapi-BA0e3I_s.mjs.map → openapi-C5AycKTR.mjs.map} +1 -1
  26. package/dist/openapi.cjs +3 -3
  27. package/dist/openapi.mjs +3 -3
  28. package/dist/{reconcile-DAAKa0YR.mjs → reconcile-CYGmqXvq.mjs} +2 -2
  29. package/dist/{reconcile-DAAKa0YR.mjs.map → reconcile-CYGmqXvq.mjs.map} +1 -1
  30. package/dist/{reconcile-CZtzdrnc.cjs → reconcile-D93YG7G5.cjs} +2 -2
  31. package/dist/{reconcile-CZtzdrnc.cjs.map → reconcile-D93YG7G5.cjs.map} +1 -1
  32. package/dist/workspace/index.cjs +1 -1
  33. package/dist/workspace/index.d.cts +1 -1
  34. package/dist/workspace/index.d.mts +1 -1
  35. package/dist/workspace/index.mjs +1 -1
  36. package/dist/{workspace-BobDOIG9.cjs → workspace-CQTT7HJQ.cjs} +34 -25
  37. package/dist/workspace-CQTT7HJQ.cjs.map +1 -0
  38. package/dist/{workspace-Cgmvgwh8.mjs → workspace-D-37Ylqo.mjs} +34 -25
  39. package/dist/workspace-D-37Ylqo.mjs.map +1 -0
  40. package/docs/dev-server-resilience-design.md +189 -0
  41. package/package.json +5 -5
  42. package/src/build/__tests__/workspace-build.spec.ts +7 -7
  43. package/src/build/index.ts +25 -9
  44. package/src/credentials/__tests__/helpers.ts +1 -1
  45. package/src/credentials/__tests__/workspaceCredentials.spec.ts +1 -1
  46. package/src/deploy/__tests__/domain.spec.ts +12 -12
  47. package/src/deploy/__tests__/env-resolver.spec.ts +1 -1
  48. package/src/deploy/__tests__/index.spec.ts +14 -14
  49. package/src/deploy/__tests__/sniffer.spec.ts +9 -9
  50. package/src/deploy/domain.ts +19 -11
  51. package/src/deploy/index.ts +12 -1
  52. package/src/deploy/sniffer.ts +1 -1
  53. package/src/deploy/types.ts +1 -1
  54. package/src/dev/__tests__/index.spec.ts +11 -11
  55. package/src/dev/index.ts +14 -8
  56. package/src/docker/__tests__/compose.spec.ts +4 -4
  57. package/src/docker/compose.ts +5 -3
  58. package/src/docker/index.ts +34 -6
  59. package/src/docker/templates.ts +138 -0
  60. package/src/generators/OpenApiTsGenerator.ts +22 -0
  61. package/src/generators/__tests__/OpenApiTsGenerator.registry.spec.ts +77 -0
  62. package/src/init/__tests__/init.spec.ts +1 -1
  63. package/src/init/generators/monorepo.ts +3 -3
  64. package/src/secrets/__tests__/reconcile.spec.ts +1 -1
  65. package/src/setup/__tests__/reconcile-secrets.spec.ts +2 -2
  66. package/src/setup/fullstack-secrets.ts +7 -1
  67. package/src/setup/index.ts +2 -1
  68. package/src/test/__tests__/__fixtures__/workspace.ts +1 -1
  69. package/src/test/__tests__/web.spec.ts +2 -2
  70. package/src/workspace/__tests__/client-generator.spec.ts +6 -6
  71. package/src/workspace/__tests__/index.spec.ts +4 -4
  72. package/src/workspace/__tests__/schema.spec.ts +27 -30
  73. package/src/workspace/__tests__/type-inference.spec.ts +5 -5
  74. package/src/workspace/client-generator.ts +10 -7
  75. package/src/workspace/index.ts +1 -1
  76. package/src/workspace/publicEnv.ts +2 -4
  77. package/src/workspace/schema.ts +126 -27
  78. package/src/workspace/types.ts +40 -69
  79. package/dist/index-D7iT4dnv.d.mts.map +0 -1
  80. package/dist/index-DRQq26DF.d.cts.map +0 -1
  81. package/dist/workspace-BobDOIG9.cjs.map +0 -1
  82. package/dist/workspace-Cgmvgwh8.mjs.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { getPublicEnvPrefix } from '../workspace/index.js';
1
2
  import type {
2
3
  DokployWorkspaceConfig,
3
4
  NormalizedAppConfig,
@@ -71,7 +72,7 @@ export function isMainFrontendApp(
71
72
  app: NormalizedAppConfig,
72
73
  allApps: Record<string, NormalizedAppConfig>,
73
74
  ): boolean {
74
- if (app.type !== 'frontend') {
75
+ if (app.type !== 'web') {
75
76
  return false;
76
77
  }
77
78
 
@@ -82,7 +83,7 @@ export function isMainFrontendApp(
82
83
 
83
84
  // Otherwise, check if this is the first frontend
84
85
  for (const [name, a] of Object.entries(allApps)) {
85
- if (a.type === 'frontend') {
86
+ if (a.type === 'web') {
86
87
  return name === appName;
87
88
  }
88
89
  }
@@ -91,9 +92,13 @@ export function isMainFrontendApp(
91
92
  }
92
93
 
93
94
  /**
94
- * Generate public URL build args for a frontend app based on its dependencies.
95
+ * Generate public URL build args for a web/mobile app based on its dependencies.
95
96
  *
96
- * @param app - The frontend app configuration
97
+ * The prefix is chosen by the app's framework (NEXT_PUBLIC_, VITE_,
98
+ * EXPO_PUBLIC_, or none for Remix). Apps without a public prefix get no
99
+ * build args — they should fetch URLs at runtime instead.
100
+ *
101
+ * @param app - The web/mobile app configuration
97
102
  * @param deployedUrls - Map of app name to deployed public URL
98
103
  * @returns Array of build args like 'NEXT_PUBLIC_API_URL=https://api.example.com'
99
104
  */
@@ -101,14 +106,14 @@ export function generatePublicUrlBuildArgs(
101
106
  app: NormalizedAppConfig,
102
107
  deployedUrls: Record<string, string>,
103
108
  ): string[] {
104
- const buildArgs: string[] = [];
109
+ const prefix = getPublicEnvPrefix(app);
110
+ if (!prefix) return [];
105
111
 
112
+ const buildArgs: string[] = [];
106
113
  for (const dep of app.dependencies) {
107
114
  const publicUrl = deployedUrls[dep];
108
115
  if (publicUrl) {
109
- // Convert app name to UPPER_SNAKE_CASE for env var
110
- const envVarName = `NEXT_PUBLIC_${dep.toUpperCase()}_URL`;
111
- buildArgs.push(`${envVarName}=${publicUrl}`);
116
+ buildArgs.push(`${prefix}${dep.toUpperCase()}_URL=${publicUrl}`);
112
117
  }
113
118
  }
114
119
 
@@ -118,9 +123,12 @@ export function generatePublicUrlBuildArgs(
118
123
  /**
119
124
  * Get public URL arg names from app dependencies.
120
125
  *
121
- * @param app - The frontend app configuration
122
- * @returns Array of arg names like 'NEXT_PUBLIC_API_URL'
126
+ * @param app - The web/mobile app configuration
127
+ * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
128
+ * without a public prefix (e.g. Remix).
123
129
  */
124
130
  export function getPublicUrlArgNames(app: NormalizedAppConfig): string[] {
125
- return app.dependencies.map((dep) => `NEXT_PUBLIC_${dep.toUpperCase()}_URL`);
131
+ const prefix = getPublicEnvPrefix(app);
132
+ if (!prefix) return [];
133
+ return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
126
134
  }
@@ -1202,11 +1202,22 @@ export async function workspaceDeployCommand(
1202
1202
  // ==================================================================
1203
1203
  // Separate apps by type for two-phase deployment
1204
1204
  // ==================================================================
1205
+ // Mobile apps deploy via their own toolchain (e.g. EAS Build for Expo)
1206
+ // — skip them in the Dokploy deploy phases.
1207
+ const skippedMobileApps = appsToDeployNames.filter(
1208
+ (name) => workspace.apps[name]!.type === 'mobile',
1209
+ );
1210
+ if (skippedMobileApps.length > 0) {
1211
+ logger.log(
1212
+ `\n📱 Skipping ${skippedMobileApps.length} mobile app(s) — deploy via framework toolchain: ${skippedMobileApps.join(', ')}`,
1213
+ );
1214
+ }
1215
+
1205
1216
  const backendApps = appsToDeployNames.filter(
1206
1217
  (name) => workspace.apps[name]!.type === 'backend',
1207
1218
  );
1208
1219
  const frontendApps = appsToDeployNames.filter(
1209
- (name) => workspace.apps[name]!.type === 'frontend',
1220
+ (name) => workspace.apps[name]!.type === 'web',
1210
1221
  );
1211
1222
 
1212
1223
  // ==================================================================
@@ -117,7 +117,7 @@ export async function sniffAppEnvironment(
117
117
  const { logWarnings = true, markOptional = false } = options;
118
118
 
119
119
  // 1. Frontend apps - handle dependencies and config sniffing
120
- if (app.type === 'frontend') {
120
+ if (app.type === 'web' || app.type === 'mobile') {
121
121
  // Auto-generate {prefix}{DEP}_URL from dependencies, where prefix matches
122
122
  // the framework's public-var convention (NEXT_PUBLIC_, VITE_, ...).
123
123
  // For frameworks without a prefix (e.g. Remix), no dep var is required.
@@ -34,7 +34,7 @@ export interface AppDeployResult {
34
34
  /** App name */
35
35
  appName: string;
36
36
  /** App type */
37
- type: 'backend' | 'frontend';
37
+ type: 'backend' | 'web' | 'mobile';
38
38
  /** Whether deployment succeeded */
39
39
  success: boolean;
40
40
  /** Dokploy application ID */
@@ -455,13 +455,13 @@ describe('Workspace Dev Server', () => {
455
455
  dependencies: [],
456
456
  },
457
457
  web: {
458
- type: 'frontend',
458
+ type: 'web',
459
459
  path: 'apps/web',
460
460
  port: 3001,
461
461
  dependencies: [],
462
462
  },
463
463
  admin: {
464
- type: 'frontend',
464
+ type: 'web',
465
465
  path: 'apps/admin',
466
466
  port: 3002,
467
467
  dependencies: [],
@@ -481,7 +481,7 @@ describe('Workspace Dev Server', () => {
481
481
  dependencies: [],
482
482
  },
483
483
  web: {
484
- type: 'frontend',
484
+ type: 'web',
485
485
  path: 'apps/web',
486
486
  port: 3000, // Same port as api!
487
487
  dependencies: [],
@@ -512,13 +512,13 @@ describe('Workspace Dev Server', () => {
512
512
  dependencies: [],
513
513
  },
514
514
  web: {
515
- type: 'frontend',
515
+ type: 'web',
516
516
  path: 'apps/web',
517
517
  port: 3001,
518
518
  dependencies: [],
519
519
  },
520
520
  admin: {
521
- type: 'frontend',
521
+ type: 'web',
522
522
  path: 'apps/admin',
523
523
  port: 3001, // Conflicts with web
524
524
  dependencies: [],
@@ -554,7 +554,7 @@ describe('Workspace Dev Server', () => {
554
554
  dependencies: [],
555
555
  },
556
556
  web: {
557
- type: 'frontend',
557
+ type: 'web',
558
558
  path: 'apps/web',
559
559
  port: 3001,
560
560
  dependencies: [],
@@ -580,7 +580,7 @@ describe('Workspace Dev Server', () => {
580
580
  dependencies: [],
581
581
  },
582
582
  web: {
583
- type: 'frontend',
583
+ type: 'web',
584
584
  path: 'apps/web',
585
585
  port: 3002,
586
586
  dependencies: ['api', 'auth'],
@@ -634,7 +634,7 @@ describe('Workspace Dev Server', () => {
634
634
  dependencies: [],
635
635
  },
636
636
  web: {
637
- type: 'frontend',
637
+ type: 'web',
638
638
  path: 'apps/web',
639
639
  port: 3001,
640
640
  dependencies: ['api'],
@@ -669,13 +669,13 @@ describe('Workspace Dev Server', () => {
669
669
  dependencies: ['auth'], // Payments depends on auth
670
670
  },
671
671
  web: {
672
- type: 'frontend',
672
+ type: 'web',
673
673
  path: 'apps/web',
674
674
  port: 3003,
675
675
  dependencies: ['api', 'auth'], // Web depends on api and auth
676
676
  },
677
677
  admin: {
678
- type: 'frontend',
678
+ type: 'web',
679
679
  path: 'apps/admin',
680
680
  port: 3004,
681
681
  dependencies: ['api', 'payments'], // Admin depends on api and payments
@@ -1056,7 +1056,7 @@ describe('Workspace Dev Server', () => {
1056
1056
  resolvedDeployTarget: 'dokploy',
1057
1057
  },
1058
1058
  web: {
1059
- type: 'frontend',
1059
+ type: 'web',
1060
1060
  path: 'apps/web',
1061
1061
  port: 3001,
1062
1062
  dependencies: ['api'],
package/src/dev/index.ts CHANGED
@@ -856,7 +856,7 @@ export async function validateFrontendApps(
856
856
  const results: FrontendValidationResult[] = [];
857
857
 
858
858
  for (const [appName, app] of Object.entries(workspace.apps)) {
859
- if (app.type === 'frontend') {
859
+ if (app.type === 'web') {
860
860
  const result = await validateFrontendApp(
861
861
  appName,
862
862
  app.path,
@@ -920,13 +920,19 @@ async function workspaceDevCommand(
920
920
  ([_, app]) => app.type === 'backend',
921
921
  );
922
922
  const frontendApps = Object.entries(workspace.apps).filter(
923
- ([_, app]) => app.type === 'frontend',
923
+ ([_, app]) => app.type === 'web',
924
+ );
925
+ const mobileApps = Object.entries(workspace.apps).filter(
926
+ ([_, app]) => app.type === 'mobile',
924
927
  );
925
928
 
926
929
  logger.log(`\n🚀 Starting workspace: ${workspace.name}`);
927
- logger.log(
928
- ` ${backendApps.length} backend app(s), ${frontendApps.length} frontend app(s)`,
929
- );
930
+ const counts = [
931
+ `${backendApps.length} backend`,
932
+ `${frontendApps.length} web`,
933
+ ];
934
+ if (mobileApps.length > 0) counts.push(`${mobileApps.length} mobile`);
935
+ logger.log(` ${counts.join(', ')} app(s)`);
930
936
 
931
937
  // Check for port conflicts
932
938
  const conflicts = checkPortConflicts(workspace);
@@ -1030,9 +1036,9 @@ async function workspaceDevCommand(
1030
1036
  app.dependencies.length > 0
1031
1037
  ? ` (depends on: ${app.dependencies.join(', ')})`
1032
1038
  : '';
1033
- logger.log(
1034
- ` ${app.type === 'backend' ? '🔧' : '🌐'} ${appName} http://localhost:${app.port}${deps}`,
1035
- );
1039
+ const icon =
1040
+ app.type === 'backend' ? '🔧' : app.type === 'mobile' ? '📱' : '🌐';
1041
+ logger.log(` ${icon} ${appName} → http://localhost:${app.port}${deps}`);
1036
1042
  }
1037
1043
 
1038
1044
  // Find the config file path for GKM_CONFIG_PATH
@@ -647,7 +647,7 @@ describe('generateWorkspaceCompose', () => {
647
647
  resolvedDeployTarget: 'dokploy',
648
648
  },
649
649
  web: {
650
- type: 'frontend',
650
+ type: 'web',
651
651
  path: 'apps/web',
652
652
  port: 3001,
653
653
  dependencies: ['api'],
@@ -793,7 +793,7 @@ describe('generateWorkspaceCompose', () => {
793
793
  resolvedDeployTarget: 'dokploy',
794
794
  },
795
795
  web: {
796
- type: 'frontend',
796
+ type: 'web',
797
797
  path: 'apps/web',
798
798
  port: 3001,
799
799
  dependencies: ['api', 'auth'],
@@ -849,7 +849,7 @@ describe('generateWorkspaceCompose', () => {
849
849
  const workspace = createWorkspace({
850
850
  apps: {
851
851
  web: {
852
- type: 'frontend',
852
+ type: 'web',
853
853
  path: 'apps/web',
854
854
  port: 3001,
855
855
  dependencies: [],
@@ -1016,7 +1016,7 @@ describe('generateWorkspaceCompose', () => {
1016
1016
  const workspace = createWorkspace({
1017
1017
  apps: {
1018
1018
  web: {
1019
- type: 'frontend',
1019
+ type: 'web',
1020
1020
  path: 'apps/web',
1021
1021
  port: 3001,
1022
1022
  dependencies: [],
@@ -444,8 +444,10 @@ export function generateWorkspaceCompose(
444
444
  services:
445
445
  `;
446
446
 
447
- // Generate service for each app
447
+ // Generate service for each app (mobile apps deploy via their own
448
+ // toolchain — no compose service)
448
449
  for (const [appName, app] of apps) {
450
+ if (app.type === 'mobile') continue;
449
451
  yaml += generateAppService(appName, app, apps, {
450
452
  registry,
451
453
  projectName: workspace.name,
@@ -698,9 +700,9 @@ function generateAppService(
698
700
  const imageRef = registry ? `\${REGISTRY:-${registry}}/` : '';
699
701
 
700
702
  // Health check path - frontends use /, backends use /health
701
- const healthCheckPath = app.type === 'frontend' ? '/' : '/health';
703
+ const healthCheckPath = app.type === 'web' ? '/' : '/health';
702
704
  const healthCheckCmd =
703
- app.type === 'frontend'
705
+ app.type === 'web'
704
706
  ? `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}/"]`
705
707
  : `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}${healthCheckPath}"]`;
706
708
 
@@ -3,6 +3,7 @@ import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
3
3
  import { mkdir, writeFile } from 'node:fs/promises';
4
4
  import { basename, join } from 'node:path';
5
5
  import { loadConfig, loadWorkspaceConfig } from '../config';
6
+ import { getPublicUrlArgNames } from '../deploy/domain.js';
6
7
  import type { NormalizedWorkspace } from '../workspace/types.js';
7
8
  import {
8
9
  generateDockerCompose,
@@ -18,7 +19,9 @@ import {
18
19
  generateEntryDockerfile,
19
20
  generateMultiStageDockerfile,
20
21
  generateNextjsDockerfile,
22
+ generateNodeWebDockerfile,
21
23
  generateSlimDockerfile,
24
+ generateViteStaticDockerfile,
22
25
  hasTurboConfig,
23
26
  isMonorepo,
24
27
  resolveDockerConfig,
@@ -342,7 +345,7 @@ async function pushDockerImage(
342
345
  */
343
346
  export interface AppDockerResult {
344
347
  appName: string;
345
- type: 'backend' | 'frontend';
348
+ type: 'backend' | 'web' | 'mobile';
346
349
  dockerfile: string;
347
350
  imageName: string;
348
351
  }
@@ -396,6 +399,15 @@ export async function workspaceDockerCommand(
396
399
 
397
400
  // Generate Dockerfile for each app
398
401
  for (const [appName, app] of apps) {
402
+ // Mobile apps deploy via their own toolchain (e.g. EAS Build for Expo)
403
+ // — no Docker image is produced.
404
+ if (app.type === 'mobile') {
405
+ logger.log(
406
+ `\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`,
407
+ );
408
+ continue;
409
+ }
410
+
399
411
  const appPath = app.path;
400
412
  const fullAppPath = join(workspace.root, appPath);
401
413
 
@@ -411,16 +423,31 @@ export async function workspaceDockerCommand(
411
423
 
412
424
  let dockerfile: string;
413
425
 
414
- if (app.type === 'frontend') {
415
- // Generate Next.js Dockerfile
416
- dockerfile = generateNextjsDockerfile({
426
+ if (app.type === 'web') {
427
+ const publicUrlArgs = getPublicUrlArgNames(app);
428
+ const webOpts = {
417
429
  imageName,
418
430
  baseImage: 'node:22-alpine',
419
431
  port: app.port,
420
432
  appPath,
421
433
  turboPackage,
422
434
  packageManager,
423
- });
435
+ publicUrlArgs,
436
+ };
437
+
438
+ switch (app.framework) {
439
+ case 'vite':
440
+ dockerfile = generateViteStaticDockerfile(webOpts);
441
+ break;
442
+ case 'tanstack-start':
443
+ case 'remix':
444
+ dockerfile = generateNodeWebDockerfile(webOpts);
445
+ break;
446
+ default:
447
+ // nextjs (and any unspecified web framework — schema requires
448
+ // a valid framework, so this is just a default).
449
+ dockerfile = generateNextjsDockerfile(webOpts);
450
+ }
424
451
  } else if (app.entry) {
425
452
  // Backend with custom entry point - use tsdown bundling
426
453
  dockerfile = generateEntryDockerfile({
@@ -479,7 +506,8 @@ export async function workspaceDockerCommand(
479
506
  );
480
507
  logger.log('\n📋 Build commands:');
481
508
  for (const result of results) {
482
- const icon = result.type === 'backend' ? '⚙️' : '🌐';
509
+ const icon =
510
+ result.type === 'backend' ? '⚙️' : result.type === 'mobile' ? '📱' : '🌐';
483
511
  logger.log(
484
512
  ` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`,
485
513
  );
@@ -927,3 +927,141 @@ ENTRYPOINT ["/sbin/tini", "--"]
927
927
  CMD ["node", "index.mjs"]
928
928
  `;
929
929
  }
930
+
931
+ /**
932
+ * Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
933
+ * Remix). Builds with turbo, then runs the framework's production start
934
+ * script via `npm start`.
935
+ *
936
+ * Public URL build args are declared so framework bundlers (Vite, Vinxi)
937
+ * can inline them at build time. Per-framework prefix is the caller's job.
938
+ * @internal Exported for testing
939
+ */
940
+ export function generateNodeWebDockerfile(
941
+ options: FrontendDockerfileOptions,
942
+ ): string {
943
+ const {
944
+ baseImage,
945
+ port,
946
+ appPath,
947
+ turboPackage,
948
+ packageManager,
949
+ publicUrlArgs = [],
950
+ } = options;
951
+
952
+ const pm = getPmConfig(packageManager);
953
+ const installPm = pm.install ? `RUN ${pm.install}` : '';
954
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
955
+ const turboCmd = packageManager === 'pnpm' ? 'pnpm dlx turbo' : 'npx turbo';
956
+ const startCmd =
957
+ packageManager === 'pnpm'
958
+ ? 'pnpm start'
959
+ : `${pm.lockfile.startsWith('package-lock') ? 'npm' : packageManager} start`;
960
+
961
+ const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join('\n');
962
+ const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join('\n');
963
+
964
+ return `# syntax=docker/dockerfile:1
965
+ # Node SSR web Dockerfile (TanStack Start / Remix) with turbo prune
966
+
967
+ FROM ${baseImage} AS pruner
968
+ WORKDIR /app
969
+ ${installPm}
970
+ COPY . .
971
+ RUN ${turboCmd} prune ${turboPackage} --docker
972
+
973
+ FROM ${baseImage} AS deps
974
+ WORKDIR /app
975
+ ${installPm}
976
+ COPY --from=pruner /app/out/${pm.lockfile} ./
977
+ COPY --from=pruner /app/out/json/ ./
978
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
979
+ ${turboInstallCmd}
980
+
981
+ FROM deps AS builder
982
+ WORKDIR /app
983
+ ${argDecls}
984
+ ${envDecls}
985
+ COPY --from=pruner /app/out/full/ ./
986
+ COPY --from=pruner /app/tsconfig.* ./
987
+ RUN ${turboCmd} run build --filter=${turboPackage}
988
+
989
+ FROM ${baseImage} AS runner
990
+ WORKDIR /app
991
+ RUN apk add --no-cache tini
992
+ RUN addgroup --system --gid 1001 nodejs && \\
993
+ adduser --system --uid 1001 app
994
+
995
+ # Copy the whole built workspace — frameworks differ on output paths
996
+ COPY --from=builder --chown=app:nodejs /app/ ./
997
+
998
+ ENV NODE_ENV=production
999
+ ENV PORT=${port}
1000
+ ENV HOSTNAME="0.0.0.0"
1001
+
1002
+ USER app
1003
+ EXPOSE ${port}
1004
+
1005
+ ENTRYPOINT ["/sbin/tini", "--"]
1006
+ CMD ["sh", "-c", "cd ${appPath} && ${startCmd}"]
1007
+ `;
1008
+ }
1009
+
1010
+ /**
1011
+ * Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
1012
+ * turbo, then serves it via nginx.
1013
+ * @internal Exported for testing
1014
+ */
1015
+ export function generateViteStaticDockerfile(
1016
+ options: FrontendDockerfileOptions,
1017
+ ): string {
1018
+ const {
1019
+ baseImage,
1020
+ port,
1021
+ appPath,
1022
+ turboPackage,
1023
+ packageManager,
1024
+ publicUrlArgs = [],
1025
+ } = options;
1026
+
1027
+ const pm = getPmConfig(packageManager);
1028
+ const installPm = pm.install ? `RUN ${pm.install}` : '';
1029
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
1030
+ const turboCmd = packageManager === 'pnpm' ? 'pnpm dlx turbo' : 'npx turbo';
1031
+
1032
+ const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join('\n');
1033
+ const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join('\n');
1034
+
1035
+ return `# syntax=docker/dockerfile:1
1036
+ # Vite SPA Dockerfile — builds static assets, serves via nginx
1037
+
1038
+ FROM ${baseImage} AS pruner
1039
+ WORKDIR /app
1040
+ ${installPm}
1041
+ COPY . .
1042
+ RUN ${turboCmd} prune ${turboPackage} --docker
1043
+
1044
+ FROM ${baseImage} AS deps
1045
+ WORKDIR /app
1046
+ ${installPm}
1047
+ COPY --from=pruner /app/out/${pm.lockfile} ./
1048
+ COPY --from=pruner /app/out/json/ ./
1049
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
1050
+ ${turboInstallCmd}
1051
+
1052
+ FROM deps AS builder
1053
+ WORKDIR /app
1054
+ ${argDecls}
1055
+ ${envDecls}
1056
+ COPY --from=pruner /app/out/full/ ./
1057
+ COPY --from=pruner /app/tsconfig.* ./
1058
+ RUN ${turboCmd} run build --filter=${turboPackage}
1059
+
1060
+ FROM nginx:alpine AS runner
1061
+ COPY --from=builder /app/${appPath}/dist /usr/share/nginx/html
1062
+ RUN printf 'server {\\n listen ${port};\\n root /usr/share/nginx/html;\\n index index.html;\\n location / { try_files $uri $uri/ /index.html; }\\n}\\n' \\
1063
+ > /etc/nginx/conf.d/default.conf
1064
+ EXPOSE ${port}
1065
+ CMD ["nginx", "-g", "daemon off;"]
1066
+ `;
1067
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Endpoint } from '@geekmidas/constructs/endpoints';
2
2
  import {
3
+ getRegisteredZodJsonSchemas,
3
4
  getSchemaMetadata,
4
5
  StandardSchemaJsonSchema,
5
6
  } from '@geekmidas/schema/conversion';
@@ -313,6 +314,27 @@ export class OpenApiTsGenerator {
313
314
  }
314
315
  }
315
316
 
317
+ // Pull in every schema registered via `.meta({ id })` in zod's global
318
+ // registry, even when no endpoint references it directly. This makes
319
+ // derived schemas like `UserSchema.pick(...)` discoverable through the
320
+ // named parent type without requiring the parent to be wired into an
321
+ // endpoint.
322
+ const registrySchemas = await getRegisteredZodJsonSchemas();
323
+ for (const [rawName, rawSchema] of Object.entries(registrySchemas)) {
324
+ const defName = this.pascalCase(rawName);
325
+ if (collectedDefs.has(defName) || generatedNames.has(defName)) {
326
+ continue;
327
+ }
328
+ const {
329
+ id: _id,
330
+ $defs: _defs,
331
+ ...rest
332
+ } = rawSchema as JsonSchema & {
333
+ id?: string;
334
+ };
335
+ collectedDefs.set(defName, rest as JsonSchema);
336
+ }
337
+
316
338
  // Generate interfaces for collected $defs (nested schemas with .meta({ id: 'X' }))
317
339
  for (const [defName, defSchema] of collectedDefs) {
318
340
  if (!generatedNames.has(defName)) {
@@ -0,0 +1,77 @@
1
+ import { e } from '@geekmidas/constructs/endpoints';
2
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
+ import { z } from 'zod/v4';
4
+ import { OpenApiTsGenerator } from '../OpenApiTsGenerator';
5
+
6
+ describe('OpenApiTsGenerator — zod global registry', () => {
7
+ beforeEach(() => {
8
+ z.globalRegistry.clear();
9
+ });
10
+
11
+ afterEach(() => {
12
+ z.globalRegistry.clear();
13
+ });
14
+
15
+ it('emits a named interface for a registered schema even when no endpoint references it directly', async () => {
16
+ const UserSchema = z
17
+ .object({
18
+ id: z.string(),
19
+ email: z.string(),
20
+ name: z.string(),
21
+ })
22
+ .meta({ id: 'User' });
23
+
24
+ const UserResponseSchema = UserSchema.pick({
25
+ id: true,
26
+ name: true,
27
+ email: true,
28
+ });
29
+
30
+ const endpoint = e
31
+ .get('/users/:id')
32
+ .output(UserResponseSchema)
33
+ .handle(async () => ({ id: '1', name: 'a', email: 'a@b.c' }));
34
+
35
+ const generator = new OpenApiTsGenerator();
36
+ const content = await generator.generate([endpoint as any]);
37
+
38
+ expect(content).toMatch(/export interface User\b/);
39
+ });
40
+
41
+ it('does not duplicate when the same id is also produced by an endpoint', async () => {
42
+ const UserSchema = z
43
+ .object({
44
+ id: z.string(),
45
+ email: z.string(),
46
+ name: z.string(),
47
+ })
48
+ .meta({ id: 'User' });
49
+
50
+ const endpoint = e
51
+ .get('/users/:id')
52
+ .output(UserSchema)
53
+ .handle(async () => ({ id: '1', name: 'a', email: 'a@b.c' }));
54
+
55
+ const generator = new OpenApiTsGenerator();
56
+ const content = await generator.generate([endpoint as any]);
57
+
58
+ const occurrences = content.match(/export interface User\b/g) ?? [];
59
+ expect(occurrences.length).toBe(1);
60
+ });
61
+
62
+ it('emits multiple registered schemas', async () => {
63
+ z.object({ id: z.string() }).meta({ id: 'Foo' });
64
+ z.object({ id: z.string() }).meta({ id: 'Bar' });
65
+
66
+ const endpoint = e
67
+ .get('/ping')
68
+ .output(z.object({ ok: z.boolean() }))
69
+ .handle(async () => ({ ok: true }));
70
+
71
+ const generator = new OpenApiTsGenerator();
72
+ const content = await generator.generate([endpoint as any]);
73
+
74
+ expect(content).toMatch(/export interface Foo\b/);
75
+ expect(content).toMatch(/export interface Bar\b/);
76
+ });
77
+ });
@@ -379,7 +379,7 @@ describe('initCommand', () => {
379
379
  expect(content).toContain("name: 'my-fullstack'");
380
380
  expect(content).toContain("type: 'backend'");
381
381
  expect(content).toContain("path: 'apps/api'");
382
- expect(content).toContain("type: 'frontend'");
382
+ expect(content).toContain("type: 'web'");
383
383
  expect(content).toContain("framework: 'nextjs'");
384
384
  expect(content).toContain("path: 'apps/web'");
385
385
  expect(content).toContain("packages: ['packages/*']");