@geekmidas/cli 1.10.34 → 1.10.36

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 (64) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/{config-Cuo8vFsp.cjs → config-QnuOcYXp.cjs} +2 -2
  3. package/dist/{config-Cuo8vFsp.cjs.map → config-QnuOcYXp.cjs.map} +1 -1
  4. package/dist/{config-B62g483e.mjs → config-U-gojtxn.mjs} +2 -2
  5. package/dist/{config-B62g483e.mjs.map → config-U-gojtxn.mjs.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/deploy/sniffer-loader.cjs +1 -1
  11. package/dist/{index-Dt_dZ7K4.d.mts → index-D7iT4dnv.d.mts} +41 -4
  12. package/dist/index-D7iT4dnv.d.mts.map +1 -0
  13. package/dist/{index-C3t5VL4R.d.cts → index-DRQq26DF.d.cts} +41 -4
  14. package/dist/index-DRQq26DF.d.cts.map +1 -0
  15. package/dist/index.cjs +1200 -63
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.mjs +1200 -63
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/{openapi-CflxypuN.mjs → openapi-BA0e3I_s.mjs} +65 -15
  20. package/dist/openapi-BA0e3I_s.mjs.map +1 -0
  21. package/dist/{openapi-B_rJjImN.cjs → openapi-Bb4UEyZN.cjs} +65 -15
  22. package/dist/openapi-Bb4UEyZN.cjs.map +1 -0
  23. package/dist/openapi.cjs +3 -3
  24. package/dist/openapi.d.cts +7 -0
  25. package/dist/openapi.d.cts.map +1 -1
  26. package/dist/openapi.d.mts +7 -0
  27. package/dist/openapi.d.mts.map +1 -1
  28. package/dist/openapi.mjs +3 -3
  29. package/dist/workspace/index.cjs +4 -1
  30. package/dist/workspace/index.d.cts +2 -2
  31. package/dist/workspace/index.d.mts +2 -2
  32. package/dist/workspace/index.mjs +2 -2
  33. package/dist/{workspace-CGYykWfn.cjs → workspace-BobDOIG9.cjs} +74 -3
  34. package/dist/workspace-BobDOIG9.cjs.map +1 -0
  35. package/dist/{workspace-Bi4X7Yzy.mjs → workspace-Cgmvgwh8.mjs} +57 -4
  36. package/dist/workspace-Cgmvgwh8.mjs.map +1 -0
  37. package/package.json +4 -4
  38. package/src/__tests__/openapi.spec.ts +99 -9
  39. package/src/deploy/__tests__/env-resolver.spec.ts +28 -0
  40. package/src/deploy/env-resolver.ts +5 -10
  41. package/src/deploy/index.ts +13 -7
  42. package/src/deploy/sniffer.ts +10 -4
  43. package/src/dev/__tests__/index.spec.ts +119 -0
  44. package/src/dev/index.ts +162 -37
  45. package/src/index.ts +6 -2
  46. package/src/init/__tests__/generators.spec.ts +147 -0
  47. package/src/init/generators/mobile-expo.ts +576 -0
  48. package/src/init/generators/monorepo.ts +42 -11
  49. package/src/init/generators/web-tanstack.ts +348 -0
  50. package/src/init/index.ts +40 -3
  51. package/src/init/templates/index.ts +31 -0
  52. package/src/init/versions.ts +3 -3
  53. package/src/openapi.ts +110 -23
  54. package/src/workspace/__tests__/index.spec.ts +92 -0
  55. package/src/workspace/__tests__/publicEnv.spec.ts +64 -0
  56. package/src/workspace/index.ts +16 -2
  57. package/src/workspace/publicEnv.ts +59 -0
  58. package/src/workspace/types.ts +6 -1
  59. package/dist/index-C3t5VL4R.d.cts.map +0 -1
  60. package/dist/index-Dt_dZ7K4.d.mts.map +0 -1
  61. package/dist/openapi-B_rJjImN.cjs.map +0 -1
  62. package/dist/openapi-CflxypuN.mjs.map +0 -1
  63. package/dist/workspace-Bi4X7Yzy.mjs.map +0 -1
  64. package/dist/workspace-CGYykWfn.cjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "1.10.34",
3
+ "version": "1.10.36",
4
4
  "description": "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs",
5
5
  "private": false,
6
6
  "type": "module",
@@ -56,11 +56,11 @@
56
56
  "prompts": "~2.4.2",
57
57
  "tsx": "~4.20.3",
58
58
  "yaml": "~2.8.2",
59
- "@geekmidas/errors": "~1.0.0",
60
- "@geekmidas/logger": "~1.0.1",
61
59
  "@geekmidas/constructs": "~3.0.9",
60
+ "@geekmidas/errors": "~1.0.0",
62
61
  "@geekmidas/schema": "~1.0.0",
63
- "@geekmidas/envkit": "~1.0.5"
62
+ "@geekmidas/envkit": "~1.0.6",
63
+ "@geekmidas/logger": "~1.0.1"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/lodash.kebabcase": "^4.1.9",
@@ -2,12 +2,6 @@ import { existsSync, realpathSync } from 'node:fs';
2
2
  import { mkdir, readFile, rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
- import {
6
- generateOpenApi,
7
- OPENAPI_OUTPUT_PATH,
8
- openapiCommand,
9
- resolveOpenApiConfig,
10
- } from '../openapi';
11
5
  import type { GkmConfig } from '../types';
12
6
  import {
13
7
  cleanupDir,
@@ -16,6 +10,33 @@ import {
16
10
  createTestFile,
17
11
  } from './test-helpers';
18
12
 
13
+ const spawnCalls: Array<{ cmd: string; args: string[]; cwd: string }> = [];
14
+ let mockSpawnExitCode = 0;
15
+
16
+ vi.mock('node:child_process', async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import('node:child_process')>();
18
+ return {
19
+ ...actual,
20
+ spawn: vi.fn((cmd: string, args: string[], opts: any) => {
21
+ spawnCalls.push({ cmd, args: args ?? [], cwd: opts?.cwd ?? '' });
22
+ const child: any = {
23
+ on(event: string, cb: (code?: number) => void) {
24
+ if (event === 'close') queueMicrotask(() => cb(mockSpawnExitCode));
25
+ return child;
26
+ },
27
+ };
28
+ return child;
29
+ }),
30
+ };
31
+ });
32
+
33
+ const {
34
+ generateOpenApi,
35
+ OPENAPI_OUTPUT_PATH,
36
+ openapiCommand,
37
+ resolveOpenApiConfig,
38
+ } = await import('../openapi');
39
+
19
40
  describe('resolveOpenApiConfig', () => {
20
41
  const baseConfig: GkmConfig = {
21
42
  routes: './src/endpoints/**/*.ts',
@@ -483,7 +504,7 @@ describe('openapiCommand - workspace mode', () => {
483
504
  vi.restoreAllMocks();
484
505
  });
485
506
 
486
- it('should generate OpenAPI for backend app in workspace', async () => {
507
+ it('should generate OpenAPI for a single app via --app flag', async () => {
487
508
  // Create workspace structure
488
509
  const apiDir = join(tempDir, 'apps/api');
489
510
  await mkdir(apiDir, { recursive: true });
@@ -515,10 +536,11 @@ describe('openapiCommand - workspace mode', () => {
515
536
  }),
516
537
  );
517
538
 
518
- process.chdir(tempDir);
539
+ // Subprocess invocation simulated: CWD = appPath, --app passed
540
+ process.chdir(apiDir);
519
541
  const consoleSpy = vi.spyOn(console, 'log');
520
542
 
521
- await openapiCommand({ cwd: tempDir });
543
+ await openapiCommand({ cwd: tempDir, app: 'api' });
522
544
 
523
545
  // Should generate OpenAPI in the backend app's .gkm folder
524
546
  const outputPath = join(apiDir, OPENAPI_OUTPUT_PATH);
@@ -532,4 +554,72 @@ describe('openapiCommand - workspace mode', () => {
532
554
  expect.stringContaining('[api] Generated OpenAPI'),
533
555
  );
534
556
  });
557
+
558
+ it('should throw when --app references unknown app', async () => {
559
+ await createTestFile(
560
+ tempDir,
561
+ 'gkm.config.json',
562
+ JSON.stringify({
563
+ name: 'test-workspace',
564
+ apps: {
565
+ api: {
566
+ type: 'backend',
567
+ path: 'apps/api',
568
+ port: 3000,
569
+ routes: './src/endpoints/**/*.ts',
570
+ openapi: { enabled: true },
571
+ },
572
+ },
573
+ }),
574
+ );
575
+
576
+ await expect(
577
+ openapiCommand({ cwd: tempDir, app: 'missing' }),
578
+ ).rejects.toThrow(/App "missing" not found/);
579
+ });
580
+
581
+ it('should spawn one subprocess per backend app in multi-app mode', async () => {
582
+ // Create workspace with two backend apps
583
+ const apiDir = join(tempDir, 'apps/api');
584
+ const adminDir = join(tempDir, 'apps/admin');
585
+ await mkdir(apiDir, { recursive: true });
586
+ await mkdir(adminDir, { recursive: true });
587
+
588
+ await createTestFile(
589
+ tempDir,
590
+ 'gkm.config.json',
591
+ JSON.stringify({
592
+ name: 'test-workspace',
593
+ apps: {
594
+ api: {
595
+ type: 'backend',
596
+ path: 'apps/api',
597
+ port: 3000,
598
+ routes: './src/endpoints/**/*.ts',
599
+ openapi: { enabled: true },
600
+ },
601
+ admin: {
602
+ type: 'backend',
603
+ path: 'apps/admin',
604
+ port: 3001,
605
+ routes: './src/endpoints/**/*.ts',
606
+ openapi: { enabled: true },
607
+ },
608
+ },
609
+ }),
610
+ );
611
+
612
+ spawnCalls.length = 0;
613
+ mockSpawnExitCode = 0;
614
+
615
+ await openapiCommand({ cwd: tempDir });
616
+
617
+ expect(spawnCalls).toHaveLength(2);
618
+ expect(spawnCalls[0]?.cwd).toBe(apiDir);
619
+ expect(spawnCalls[0]?.args).toContain('--app');
620
+ expect(spawnCalls[0]?.args).toContain('api');
621
+ expect(spawnCalls[1]?.cwd).toBe(adminDir);
622
+ expect(spawnCalls[1]?.args).toContain('--app');
623
+ expect(spawnCalls[1]?.args).toContain('admin');
624
+ });
535
625
  });
@@ -517,6 +517,34 @@ describe('resolveEnvVar', () => {
517
517
  expect(resolveEnvVar('NEXT_PUBLIC_AUTH_URL', context)).toBeUndefined();
518
518
  });
519
519
  });
520
+
521
+ describe('VITE_ prefix', () => {
522
+ it('should resolve VITE_API_URL from dependencyUrls.api', () => {
523
+ const context = createContext({
524
+ dependencyUrls: { api: 'https://api.example.com' },
525
+ });
526
+
527
+ expect(resolveEnvVar('VITE_API_URL', context)).toBe(
528
+ 'https://api.example.com',
529
+ );
530
+ });
531
+
532
+ it('should resolve all of AUTH_URL, NEXT_PUBLIC_AUTH_URL, and VITE_AUTH_URL to the same value', () => {
533
+ const context = createContext({
534
+ dependencyUrls: { auth: 'https://auth.example.com' },
535
+ });
536
+
537
+ expect(resolveEnvVar('AUTH_URL', context)).toBe(
538
+ 'https://auth.example.com',
539
+ );
540
+ expect(resolveEnvVar('NEXT_PUBLIC_AUTH_URL', context)).toBe(
541
+ 'https://auth.example.com',
542
+ );
543
+ expect(resolveEnvVar('VITE_AUTH_URL', context)).toBe(
544
+ 'https://auth.example.com',
545
+ );
546
+ });
547
+ });
520
548
  });
521
549
  });
522
550
 
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { randomBytes } from 'node:crypto';
10
10
  import type { StageSecrets } from '../secrets/types';
11
+ import { stripPublicPrefix } from '../workspace/publicEnv';
11
12
  import type { NormalizedAppConfig } from '../workspace/types';
12
13
  import {
13
14
  type AppDbCredentials,
@@ -208,17 +209,11 @@ export function resolveEnvVar(
208
209
  }
209
210
 
210
211
  // Check dependency URLs (e.g., AUTH_URL -> dependencyUrls.auth)
211
- // Also supports NEXT_PUBLIC_ prefix for frontend apps (NEXT_PUBLIC_AUTH_URL -> dependencyUrls.auth)
212
+ // Also supports any known public prefix (NEXT_PUBLIC_, VITE_, ...) so
213
+ // VITE_AUTH_URL and NEXT_PUBLIC_AUTH_URL both resolve to dependencyUrls.auth.
212
214
  if (context.dependencyUrls && varName.endsWith('_URL')) {
213
- let depName: string;
214
-
215
- if (varName.startsWith('NEXT_PUBLIC_')) {
216
- // NEXT_PUBLIC_AUTH_URL -> auth
217
- depName = varName.slice(12, -4).toLowerCase();
218
- } else {
219
- // AUTH_URL -> auth
220
- depName = varName.slice(0, -4).toLowerCase();
221
- }
215
+ const stripped = stripPublicPrefix(varName) ?? varName;
216
+ const depName = stripped.slice(0, -4).toLowerCase();
222
217
 
223
218
  if (context.dependencyUrls[depName]) {
224
219
  return context.dependencyUrls[depName];
@@ -61,6 +61,7 @@ import { readStageSecrets } from '../secrets/storage.js';
61
61
  import {
62
62
  getAppBuildOrder,
63
63
  getDeployTargetError,
64
+ getPublicEnvPrefix,
64
65
  isDeployTargetSupported,
65
66
  } from '../workspace/index.js';
66
67
  import type { NormalizedWorkspace } from '../workspace/types.js';
@@ -1661,7 +1662,8 @@ export async function workspaceDeployCommand(
1661
1662
  dependencyUrls,
1662
1663
  };
1663
1664
 
1664
- // Resolve all env vars BEFORE Docker build (NEXT_PUBLIC_* must be present at build time)
1665
+ // Resolve all env vars BEFORE Docker build (public-prefixed vars
1666
+ // must be present at bundler build time so they get inlined).
1665
1667
  const sniffedVars = sniffedApps.get(appName)?.requiredEnvVars ?? [];
1666
1668
  const { valid, missing, resolved } = validateEnvVars(
1667
1669
  sniffedVars,
@@ -1678,14 +1680,18 @@ export async function workspaceDeployCommand(
1678
1680
  );
1679
1681
  }
1680
1682
 
1681
- // Build args: all NEXT_PUBLIC_* vars must be present at Next.js build time
1683
+ // Build args: only the framework's public-prefixed vars get baked
1684
+ // into the bundle. Server-only vars stay as runtime env.
1685
+ const publicPrefix = getPublicEnvPrefix(app.framework);
1682
1686
  const buildArgs: string[] = [];
1683
1687
  const publicUrlArgNames: string[] = [];
1684
1688
 
1685
- for (const [key, value] of Object.entries(resolved)) {
1686
- if (key.startsWith('NEXT_PUBLIC_')) {
1687
- buildArgs.push(`${key}=${value}`);
1688
- publicUrlArgNames.push(key);
1689
+ if (publicPrefix) {
1690
+ for (const [key, value] of Object.entries(resolved)) {
1691
+ if (key.startsWith(publicPrefix)) {
1692
+ buildArgs.push(`${key}=${value}`);
1693
+ publicUrlArgNames.push(key);
1694
+ }
1689
1695
  }
1690
1696
  }
1691
1697
 
@@ -1693,7 +1699,7 @@ export async function workspaceDeployCommand(
1693
1699
  logger.log(` Build args: ${publicUrlArgNames.join(', ')}`);
1694
1700
  }
1695
1701
 
1696
- // Build Docker image with NEXT_PUBLIC_* vars as build args
1702
+ // Build Docker image with public-prefixed vars as build args
1697
1703
  const imageName = `${workspace.name}-${appName}`;
1698
1704
  const imageRef = registry
1699
1705
  ? `${registry}/${imageName}:${imageTag}`
@@ -5,6 +5,7 @@ import { dirname, resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import type { SniffResult } from '@geekmidas/envkit/sniffer';
7
7
  import { normalizeRoutes } from '../workspace/client-generator.js';
8
+ import { getPublicEnvPrefix } from '../workspace/publicEnv.js';
8
9
  import type { NormalizedAppConfig } from '../workspace/types.js';
9
10
 
10
11
  const __filename = fileURLToPath(import.meta.url);
@@ -117,10 +118,15 @@ export async function sniffAppEnvironment(
117
118
 
118
119
  // 1. Frontend apps - handle dependencies and config sniffing
119
120
  if (app.type === 'frontend') {
120
- // Auto-generate NEXT_PUBLIC_{DEP}_URL from dependencies
121
- const depVars = (app.dependencies ?? []).map(
122
- (dep) => `NEXT_PUBLIC_${dep.toUpperCase()}_URL`,
123
- );
121
+ // Auto-generate {prefix}{DEP}_URL from dependencies, where prefix matches
122
+ // the framework's public-var convention (NEXT_PUBLIC_, VITE_, ...).
123
+ // For frameworks without a prefix (e.g. Remix), no dep var is required.
124
+ const publicPrefix = getPublicEnvPrefix(app.framework);
125
+ const depVars = publicPrefix
126
+ ? (app.dependencies ?? []).map(
127
+ (dep) => `${publicPrefix}${dep.toUpperCase()}_URL`,
128
+ )
129
+ : [];
124
130
 
125
131
  // If config specified, sniff by importing the file(s)
126
132
  // The file calls .parse() at module load, which triggers sniffer to capture vars
@@ -892,6 +892,125 @@ describe('Workspace Dev Server', () => {
892
892
 
893
893
  expect(result.valid).toBe(true);
894
894
  });
895
+
896
+ it('should validate a properly configured Vite app', async () => {
897
+ const appDir = join(testDir, 'apps/web');
898
+ mkdirSync(appDir, { recursive: true });
899
+
900
+ writeFileSync(join(appDir, 'vite.config.ts'), 'export default {}');
901
+ writeFileSync(
902
+ join(appDir, 'package.json'),
903
+ JSON.stringify({
904
+ name: 'web',
905
+ devDependencies: { vite: '^5.0.0' },
906
+ scripts: { dev: 'vite' },
907
+ }),
908
+ );
909
+
910
+ const result = await validateFrontendApp('web', 'apps/web', testDir);
911
+
912
+ expect(result.valid).toBe(true);
913
+ expect(result.errors).toHaveLength(0);
914
+ });
915
+
916
+ it('should validate a properly configured Remix app', async () => {
917
+ const appDir = join(testDir, 'apps/web');
918
+ mkdirSync(appDir, { recursive: true });
919
+
920
+ writeFileSync(join(appDir, 'vite.config.ts'), 'export default {}');
921
+ writeFileSync(
922
+ join(appDir, 'package.json'),
923
+ JSON.stringify({
924
+ name: 'web',
925
+ devDependencies: {
926
+ '@remix-run/dev': '^2.0.0',
927
+ vite: '^5.0.0',
928
+ },
929
+ scripts: { dev: 'remix vite:dev' },
930
+ }),
931
+ );
932
+
933
+ const result = await validateFrontendApp('web', 'apps/web', testDir);
934
+
935
+ expect(result.valid).toBe(true);
936
+ expect(result.errors).toHaveLength(0);
937
+ });
938
+
939
+ it('should warn but stay valid for a custom frontend with a dev script', async () => {
940
+ const appDir = join(testDir, 'apps/web');
941
+ mkdirSync(appDir, { recursive: true });
942
+
943
+ // No recognized config, no recognized dep — but has a dev script
944
+ writeFileSync(
945
+ join(appDir, 'package.json'),
946
+ JSON.stringify({
947
+ name: 'web',
948
+ dependencies: { react: '^18.0.0' },
949
+ scripts: { dev: 'node serve.mjs' },
950
+ }),
951
+ );
952
+
953
+ const result = await validateFrontendApp('web', 'apps/web', testDir);
954
+
955
+ expect(result.valid).toBe(true);
956
+ expect(result.errors).toHaveLength(0);
957
+ expect(result.warnings).toContainEqual(
958
+ expect.stringContaining('No recognized frontend framework detected'),
959
+ );
960
+ });
961
+
962
+ it('should error for a custom frontend without a dev script', async () => {
963
+ const appDir = join(testDir, 'apps/web');
964
+ mkdirSync(appDir, { recursive: true });
965
+
966
+ writeFileSync(
967
+ join(appDir, 'package.json'),
968
+ JSON.stringify({
969
+ name: 'web',
970
+ dependencies: { react: '^18.0.0' },
971
+ }),
972
+ );
973
+
974
+ const result = await validateFrontendApp('web', 'apps/web', testDir);
975
+
976
+ expect(result.valid).toBe(false);
977
+ expect(result.errors).toContainEqual(
978
+ expect.stringContaining('No "dev" script found'),
979
+ );
980
+ });
981
+
982
+ it('should validate strictly against an explicit framework', async () => {
983
+ const appDir = join(testDir, 'apps/web');
984
+ mkdirSync(appDir, { recursive: true });
985
+
986
+ // Has next.config.js + next dep — auto-detect would say nextjs
987
+ writeFileSync(join(appDir, 'next.config.js'), 'module.exports = {}');
988
+ writeFileSync(
989
+ join(appDir, 'package.json'),
990
+ JSON.stringify({
991
+ name: 'web',
992
+ dependencies: { next: '^14.0.0' },
993
+ scripts: { dev: 'next dev' },
994
+ }),
995
+ );
996
+
997
+ // Caller explicitly declares Vite — should fail because vite
998
+ // config and vite dep are missing.
999
+ const result = await validateFrontendApp(
1000
+ 'web',
1001
+ 'apps/web',
1002
+ testDir,
1003
+ 'vite',
1004
+ );
1005
+
1006
+ expect(result.valid).toBe(false);
1007
+ expect(result.errors).toContainEqual(
1008
+ expect.stringContaining('Vite config file not found'),
1009
+ );
1010
+ expect(result.errors).toContainEqual(
1011
+ expect.stringContaining('Vite not found in dependencies'),
1012
+ );
1013
+ });
895
1014
  });
896
1015
 
897
1016
  describe('validateFrontendApps', () => {
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,107 @@ 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
+ 'tanstack-start': {
692
+ displayName: 'TanStack Start',
693
+ configFiles: [
694
+ 'vite.config.js',
695
+ 'vite.config.ts',
696
+ 'vite.config.mjs',
697
+ 'vite.config.cjs',
698
+ ],
699
+ dependency: '@tanstack/react-start',
700
+ installCommand: 'pnpm add @tanstack/react-start @tanstack/react-router',
701
+ },
702
+ expo: {
703
+ displayName: 'Expo',
704
+ configFiles: ['app.config.ts', 'app.config.js', 'app.json'],
705
+ dependency: 'expo',
706
+ installCommand: 'pnpm add expo',
707
+ },
708
+ };
709
+
710
+ /**
711
+ * Auto-detect the frontend framework by scanning package.json deps and config
712
+ * files. Dependency match wins over config file match because deps are more
713
+ * distinctive (a Remix app has both vite.config.ts AND @remix-run/dev).
714
+ */
715
+ function detectFrontendFramework(
716
+ fullPath: string,
717
+ deps: Record<string, string>,
718
+ ): FrontendFramework | undefined {
719
+ // tanstack-start before vite/remix so its more specific dep wins; vite last
720
+ // because plain vite is the fallback when neither nextjs/remix/tanstack match.
721
+ // Order matters: more specific deps win over general ones.
722
+ // expo first (own dep), then tanstack-start (uses vite), remix (uses vite),
723
+ // nextjs (own dep), and plain vite last as the fallback.
724
+ const order: FrontendFramework[] = [
725
+ 'expo',
726
+ 'tanstack-start',
727
+ 'nextjs',
728
+ 'remix',
729
+ 'vite',
730
+ ];
731
+ for (const name of order) {
732
+ if (deps[FRONTEND_FRAMEWORKS[name].dependency]) {
733
+ return name;
734
+ }
735
+ }
736
+ for (const name of order) {
737
+ const hasConfig = FRONTEND_FRAMEWORKS[name].configFiles.some((file) =>
738
+ existsSync(join(fullPath, file)),
739
+ );
740
+ if (hasConfig) {
741
+ return name;
742
+ }
743
+ }
744
+ return undefined;
745
+ }
651
746
 
652
747
  /**
653
748
  * Validation result for a frontend app.
@@ -660,59 +755,88 @@ export interface FrontendValidationResult {
660
755
  }
661
756
 
662
757
  /**
663
- * Validate a frontend (Next.js) app configuration.
664
- * Checks for Next.js config file and dependency.
758
+ * Validate a frontend app configuration.
759
+ *
760
+ * If `framework` is provided, validates strictly against that framework's
761
+ * expected config file and dependency. Otherwise auto-detects the framework
762
+ * from package.json and config files. If no recognized framework is found, the
763
+ * app is allowed through with a warning provided it has a `dev` script.
764
+ *
665
765
  * @internal Exported for testing
666
766
  */
667
767
  export async function validateFrontendApp(
668
768
  appName: string,
669
769
  appPath: string,
670
770
  workspaceRoot: string,
771
+ framework?: FrontendFramework,
671
772
  ): Promise<FrontendValidationResult> {
672
773
  const errors: string[] = [];
673
774
  const warnings: string[] = [];
674
775
  const fullPath = join(workspaceRoot, appPath);
675
776
 
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) {
777
+ const packageJsonPath = join(fullPath, 'package.json');
778
+ if (!existsSync(packageJsonPath)) {
682
779
  errors.push(
683
- `Next.js config file not found. Expected one of: ${NEXTJS_CONFIG_FILES.join(', ')}`,
780
+ `package.json not found at ${appPath}. Run: pnpm init in the app directory.`,
684
781
  );
782
+ return { appName, valid: false, errors, warnings };
685
783
  }
686
784
 
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 };
785
+ let pkg: {
786
+ dependencies?: Record<string, string>;
787
+ devDependencies?: Record<string, string>;
788
+ scripts?: Record<string, string>;
789
+ };
790
+ try {
791
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
792
+ pkg = require(packageJsonPath);
793
+ } catch {
794
+ errors.push(`Failed to read package.json at ${packageJsonPath}`);
795
+ return { appName, valid: false, errors, warnings };
796
+ }
694
797
 
695
- if (!deps.next) {
696
- errors.push(
697
- 'Next.js not found in dependencies. Run: pnpm add next react react-dom',
698
- );
699
- }
798
+ const deps: Record<string, string> = {
799
+ ...pkg.dependencies,
800
+ ...pkg.devDependencies,
801
+ };
700
802
 
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}`);
803
+ const resolvedFramework =
804
+ framework ?? detectFrontendFramework(fullPath, deps);
805
+
806
+ if (resolvedFramework) {
807
+ const spec = FRONTEND_FRAMEWORKS[resolvedFramework];
808
+ const hasConfig = spec.configFiles.some((file) =>
809
+ existsSync(join(fullPath, file)),
810
+ );
811
+ if (!hasConfig) {
812
+ errors.push(
813
+ `${spec.displayName} config file not found. Expected one of: ${spec.configFiles.join(', ')}`,
814
+ );
815
+ }
816
+ if (!deps[spec.dependency]) {
817
+ errors.push(
818
+ `${spec.displayName} not found in dependencies. Run: ${spec.installCommand}`,
819
+ );
709
820
  }
710
821
  } else {
711
- errors.push(
712
- `package.json not found at ${appPath}. Run: pnpm init in the app directory.`,
822
+ warnings.push(
823
+ 'No recognized frontend framework detected (Next.js, Vite, Remix). ' +
824
+ 'The app will run via its "dev" script. Set `framework` in your app config to enable strict validation.',
713
825
  );
714
826
  }
715
827
 
828
+ if (!pkg.scripts?.dev) {
829
+ if (resolvedFramework) {
830
+ warnings.push(
831
+ 'No "dev" script found in package.json. Turbo expects a "dev" script to run.',
832
+ );
833
+ } else {
834
+ errors.push(
835
+ 'No "dev" script found in package.json. Without a recognized framework or a dev script, there is nothing to run.',
836
+ );
837
+ }
838
+ }
839
+
716
840
  return {
717
841
  appName,
718
842
  valid: errors.length === 0,
@@ -737,6 +861,7 @@ export async function validateFrontendApps(
737
861
  appName,
738
862
  app.path,
739
863
  workspace.root,
864
+ app.framework as FrontendFramework | undefined,
740
865
  );
741
866
  results.push(result);
742
867
  }