@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/CHANGELOG.md +12 -0
- package/dist/index.cjs +97 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +97 -24
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-BQVC9vE5.cjs → openapi-B_rJjImN.cjs} +17 -1
- package/dist/openapi-B_rJjImN.cjs.map +1 -0
- package/dist/{openapi-DY3ctapV.mjs → openapi-CflxypuN.mjs} +17 -1
- package/dist/openapi-CflxypuN.mjs.map +1 -0
- package/dist/openapi.cjs +1 -1
- package/dist/openapi.mjs +1 -1
- package/package.json +2 -2
- package/src/dev/__tests__/index.spec.ts +119 -0
- package/src/dev/index.ts +134 -37
- package/src/generators/Generator.ts +27 -0
- package/src/generators/__tests__/Generator.bustCache.spec.ts +72 -0
- package/src/init/versions.ts +2 -2
- package/dist/openapi-BQVC9vE5.cjs.map +0 -1
- package/dist/openapi-DY3ctapV.mjs.map +0 -1
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
|
-
*
|
|
645
|
+
* Per-framework validation spec for supported frontend frameworks.
|
|
645
646
|
*/
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
664
|
-
*
|
|
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
|
-
|
|
677
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
}
|
|
770
|
+
const deps: Record<string, string> = {
|
|
771
|
+
...pkg.dependencies,
|
|
772
|
+
...pkg.devDependencies,
|
|
773
|
+
};
|
|
700
774
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
+
});
|
package/src/init/versions.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
48
|
+
'@geekmidas/testkit': '~1.0.6',
|
|
49
49
|
'@geekmidas/cli': CLI_VERSION,
|
|
50
50
|
} as const;
|
|
51
51
|
|