@geekmidas/cli 1.10.33 → 1.10.35

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.
package/src/dev/index.ts CHANGED
@@ -55,6 +55,7 @@ import type {
55
55
  TelescopeConfig,
56
56
  } from '../types';
57
57
  import {
58
+ type FrontendFramework,
58
59
  getAppBuildOrder,
59
60
  getDependencyEnvVars,
60
61
  type NormalizedWorkspace,
@@ -641,13 +642,79 @@ export function checkPortConflicts(
641
642
  }
642
643
 
643
644
  /**
644
- * Next.js config file patterns to check.
645
+ * Per-framework validation spec for supported frontend frameworks.
645
646
  */
646
- const NEXTJS_CONFIG_FILES = [
647
- 'next.config.js',
648
- 'next.config.ts',
649
- 'next.config.mjs',
650
- ];
647
+ interface FrontendFrameworkSpec {
648
+ displayName: string;
649
+ configFiles: string[];
650
+ dependency: string;
651
+ installCommand: string;
652
+ }
653
+
654
+ const FRONTEND_FRAMEWORKS: Record<FrontendFramework, FrontendFrameworkSpec> = {
655
+ nextjs: {
656
+ displayName: 'Next.js',
657
+ configFiles: [
658
+ 'next.config.js',
659
+ 'next.config.ts',
660
+ 'next.config.mjs',
661
+ 'next.config.cjs',
662
+ ],
663
+ dependency: 'next',
664
+ installCommand: 'pnpm add next react react-dom',
665
+ },
666
+ remix: {
667
+ displayName: 'Remix',
668
+ configFiles: [
669
+ 'remix.config.js',
670
+ 'remix.config.mjs',
671
+ 'remix.config.ts',
672
+ 'vite.config.js',
673
+ 'vite.config.ts',
674
+ 'vite.config.mjs',
675
+ 'vite.config.cjs',
676
+ ],
677
+ dependency: '@remix-run/dev',
678
+ installCommand: 'pnpm add -D @remix-run/dev',
679
+ },
680
+ vite: {
681
+ displayName: 'Vite',
682
+ configFiles: [
683
+ 'vite.config.js',
684
+ 'vite.config.ts',
685
+ 'vite.config.mjs',
686
+ 'vite.config.cjs',
687
+ ],
688
+ dependency: 'vite',
689
+ installCommand: 'pnpm add -D vite',
690
+ },
691
+ };
692
+
693
+ /**
694
+ * Auto-detect the frontend framework by scanning package.json deps and config
695
+ * files. Dependency match wins over config file match because deps are more
696
+ * distinctive (a Remix app has both vite.config.ts AND @remix-run/dev).
697
+ */
698
+ function detectFrontendFramework(
699
+ fullPath: string,
700
+ deps: Record<string, string>,
701
+ ): FrontendFramework | undefined {
702
+ const order: FrontendFramework[] = ['nextjs', 'remix', 'vite'];
703
+ for (const name of order) {
704
+ if (deps[FRONTEND_FRAMEWORKS[name].dependency]) {
705
+ return name;
706
+ }
707
+ }
708
+ for (const name of order) {
709
+ const hasConfig = FRONTEND_FRAMEWORKS[name].configFiles.some((file) =>
710
+ existsSync(join(fullPath, file)),
711
+ );
712
+ if (hasConfig) {
713
+ return name;
714
+ }
715
+ }
716
+ return undefined;
717
+ }
651
718
 
652
719
  /**
653
720
  * Validation result for a frontend app.
@@ -660,59 +727,88 @@ export interface FrontendValidationResult {
660
727
  }
661
728
 
662
729
  /**
663
- * Validate a frontend (Next.js) app configuration.
664
- * Checks for Next.js config file and dependency.
730
+ * Validate a frontend app configuration.
731
+ *
732
+ * If `framework` is provided, validates strictly against that framework's
733
+ * expected config file and dependency. Otherwise auto-detects the framework
734
+ * from package.json and config files. If no recognized framework is found, the
735
+ * app is allowed through with a warning provided it has a `dev` script.
736
+ *
665
737
  * @internal Exported for testing
666
738
  */
667
739
  export async function validateFrontendApp(
668
740
  appName: string,
669
741
  appPath: string,
670
742
  workspaceRoot: string,
743
+ framework?: FrontendFramework,
671
744
  ): Promise<FrontendValidationResult> {
672
745
  const errors: string[] = [];
673
746
  const warnings: string[] = [];
674
747
  const fullPath = join(workspaceRoot, appPath);
675
748
 
676
- // Check for Next.js config file
677
- const hasConfigFile = NEXTJS_CONFIG_FILES.some((file) =>
678
- existsSync(join(fullPath, file)),
679
- );
680
-
681
- if (!hasConfigFile) {
749
+ const packageJsonPath = join(fullPath, 'package.json');
750
+ if (!existsSync(packageJsonPath)) {
682
751
  errors.push(
683
- `Next.js config file not found. Expected one of: ${NEXTJS_CONFIG_FILES.join(', ')}`,
752
+ `package.json not found at ${appPath}. Run: pnpm init in the app directory.`,
684
753
  );
754
+ return { appName, valid: false, errors, warnings };
685
755
  }
686
756
 
687
- // Check for package.json
688
- const packageJsonPath = join(fullPath, 'package.json');
689
- if (existsSync(packageJsonPath)) {
690
- try {
691
- // eslint-disable-next-line @typescript-eslint/no-require-imports
692
- const pkg = require(packageJsonPath);
693
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
757
+ let pkg: {
758
+ dependencies?: Record<string, string>;
759
+ devDependencies?: Record<string, string>;
760
+ scripts?: Record<string, string>;
761
+ };
762
+ try {
763
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
764
+ pkg = require(packageJsonPath);
765
+ } catch {
766
+ errors.push(`Failed to read package.json at ${packageJsonPath}`);
767
+ return { appName, valid: false, errors, warnings };
768
+ }
694
769
 
695
- if (!deps.next) {
696
- errors.push(
697
- 'Next.js not found in dependencies. Run: pnpm add next react react-dom',
698
- );
699
- }
770
+ const deps: Record<string, string> = {
771
+ ...pkg.dependencies,
772
+ ...pkg.devDependencies,
773
+ };
700
774
 
701
- // Check for dev script
702
- if (!pkg.scripts?.dev) {
703
- warnings.push(
704
- 'No "dev" script found in package.json. Turbo expects a "dev" script to run.',
705
- );
706
- }
707
- } catch {
708
- errors.push(`Failed to read package.json at ${packageJsonPath}`);
775
+ const resolvedFramework =
776
+ framework ?? detectFrontendFramework(fullPath, deps);
777
+
778
+ if (resolvedFramework) {
779
+ const spec = FRONTEND_FRAMEWORKS[resolvedFramework];
780
+ const hasConfig = spec.configFiles.some((file) =>
781
+ existsSync(join(fullPath, file)),
782
+ );
783
+ if (!hasConfig) {
784
+ errors.push(
785
+ `${spec.displayName} config file not found. Expected one of: ${spec.configFiles.join(', ')}`,
786
+ );
787
+ }
788
+ if (!deps[spec.dependency]) {
789
+ errors.push(
790
+ `${spec.displayName} not found in dependencies. Run: ${spec.installCommand}`,
791
+ );
709
792
  }
710
793
  } else {
711
- errors.push(
712
- `package.json not found at ${appPath}. Run: pnpm init in the app directory.`,
794
+ warnings.push(
795
+ 'No recognized frontend framework detected (Next.js, Vite, Remix). ' +
796
+ 'The app will run via its "dev" script. Set `framework` in your app config to enable strict validation.',
713
797
  );
714
798
  }
715
799
 
800
+ if (!pkg.scripts?.dev) {
801
+ if (resolvedFramework) {
802
+ warnings.push(
803
+ 'No "dev" script found in package.json. Turbo expects a "dev" script to run.',
804
+ );
805
+ } else {
806
+ errors.push(
807
+ 'No "dev" script found in package.json. Without a recognized framework or a dev script, there is nothing to run.',
808
+ );
809
+ }
810
+ }
811
+
716
812
  return {
717
813
  appName,
718
814
  valid: errors.length === 0,
@@ -737,6 +833,7 @@ export async function validateFrontendApps(
737
833
  appName,
738
834
  app.path,
739
835
  workspace.root,
836
+ app.framework as FrontendFramework | undefined,
740
837
  );
741
838
  results.push(result);
742
839
  }
@@ -9,6 +9,26 @@ import {
9
9
  type Routes,
10
10
  } from '../types';
11
11
 
12
+ /**
13
+ * Zod v4 maintains a process-wide registry of schemas registered via
14
+ * `.meta({ id })`. When we cache-bust user module imports (by appending a
15
+ * `?t=timestamp` query), those modules re-execute and try to register the
16
+ * same ids again, which throws `ID X already exists in the registry`.
17
+ *
18
+ * Clearing the registry in the CLI process is safe: the user's running
19
+ * server lives in a subprocess and has its own isolated registry.
20
+ *
21
+ * Exported for tests.
22
+ */
23
+ export function clearZodGlobalRegistry(): void {
24
+ const registry = (
25
+ globalThis as { __zod_globalRegistry?: { clear?: () => void } }
26
+ ).__zod_globalRegistry;
27
+ if (registry && typeof registry.clear === 'function') {
28
+ registry.clear();
29
+ }
30
+ }
31
+
12
32
  export interface GeneratorOptions {
13
33
  provider?: LegacyProvider;
14
34
  [key: string]: any;
@@ -65,6 +85,13 @@ export abstract class ConstructGenerator<T extends Construct, R = void> {
65
85
  absolute: true,
66
86
  });
67
87
 
88
+ // Cache-busted re-imports re-execute user modules, which would try to
89
+ // re-register Zod schemas with the same `.meta({ id })`. Clear the
90
+ // global registry once per load to prevent duplicate-id errors.
91
+ if (bustCache) {
92
+ clearZodGlobalRegistry();
93
+ }
94
+
68
95
  // Load constructs
69
96
  const constructs: GeneratedConstruct<T>[] = [];
70
97
 
@@ -0,0 +1,72 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { itWithDir } from '@geekmidas/testkit/os';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { z } from 'zod/v4';
6
+ import { EndpointGenerator } from '../EndpointGenerator';
7
+ import { clearZodGlobalRegistry } from '../Generator';
8
+
9
+ describe('clearZodGlobalRegistry', () => {
10
+ it('removes an id from the global registry so the same id can be re-registered', () => {
11
+ // Register once — succeeds
12
+ z.object({ x: z.string() }).meta({ id: 'BustCacheSchema_A' });
13
+
14
+ // Re-registering without clearing throws
15
+ expect(() =>
16
+ z.object({ y: z.string() }).meta({ id: 'BustCacheSchema_A' }),
17
+ ).toThrow(/already exists in the registry/);
18
+
19
+ // After clearing, the id can be registered again
20
+ clearZodGlobalRegistry();
21
+ expect(() =>
22
+ z.object({ z: z.string() }).meta({ id: 'BustCacheSchema_A' }),
23
+ ).not.toThrow();
24
+ });
25
+
26
+ it('is a no-op when the registry has not been initialised', () => {
27
+ // Temporarily remove the global registry
28
+ const g = globalThis as { __zod_globalRegistry?: unknown };
29
+ const saved = g.__zod_globalRegistry;
30
+ delete g.__zod_globalRegistry;
31
+
32
+ try {
33
+ // Should not throw even though the registry does not exist
34
+ expect(() => clearZodGlobalRegistry()).not.toThrow();
35
+ } finally {
36
+ g.__zod_globalRegistry = saved;
37
+ }
38
+ });
39
+ });
40
+
41
+ describe('Generator.load — bustCache integration smoke test', () => {
42
+ itWithDir(
43
+ 'loads an endpoint with .meta({ id }) across multiple cache-busted reloads without throwing',
44
+ async ({ dir }) => {
45
+ // Mirrors the real scenario: endpoint output schema registers an
46
+ // id. Reloading on file change must not throw a duplicate-id error.
47
+ const endpointFile = join(dir, 'getRentalAgreement.ts');
48
+ await writeFile(
49
+ endpointFile,
50
+ `
51
+ import { e } from '@geekmidas/constructs/endpoints';
52
+ import { z } from 'zod/v4';
53
+
54
+ export const getRentalAgreement = e
55
+ .get('/rental-agreement')
56
+ .output(z.object({ content: z.string() }).meta({ id: 'RentalAgreementOutput' }))
57
+ .handle(async () => ({ content: 'pdf-content' }));
58
+ `,
59
+ );
60
+
61
+ const generator = new EndpointGenerator();
62
+ const patterns = join(dir, '**/*.ts');
63
+
64
+ // Simulates multiple `gkm dev` reloads
65
+ for (let i = 0; i < 3; i++) {
66
+ const loaded = await generator.load(patterns, process.cwd(), true);
67
+ expect(loaded).toHaveLength(1);
68
+ expect(loaded[0].key).toBe('getRentalAgreement');
69
+ }
70
+ },
71
+ );
72
+ });
@@ -32,7 +32,7 @@ export const GEEKMIDAS_VERSIONS = {
32
32
  '@geekmidas/cache': '~1.1.0',
33
33
  '@geekmidas/client': '~4.0.4',
34
34
  '@geekmidas/cloud': '~1.0.0',
35
- '@geekmidas/constructs': '~3.0.8',
35
+ '@geekmidas/constructs': '~3.0.9',
36
36
  '@geekmidas/db': '~1.0.1',
37
37
  '@geekmidas/emailkit': '~1.0.0',
38
38
  '@geekmidas/envkit': '~1.0.5',
@@ -45,7 +45,7 @@ export const GEEKMIDAS_VERSIONS = {
45
45
  '@geekmidas/storage': '~2.0.2',
46
46
  '@geekmidas/studio': '~1.0.0',
47
47
  '@geekmidas/telescope': '~1.0.0',
48
- '@geekmidas/testkit': '~1.0.5',
48
+ '@geekmidas/testkit': '~1.0.6',
49
49
  '@geekmidas/cli': CLI_VERSION,
50
50
  } as const;
51
51