@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.
- package/CHANGELOG.md +16 -0
- package/dist/{config-Cuo8vFsp.cjs → config-QnuOcYXp.cjs} +2 -2
- package/dist/{config-Cuo8vFsp.cjs.map → config-QnuOcYXp.cjs.map} +1 -1
- package/dist/{config-B62g483e.mjs → config-U-gojtxn.mjs} +2 -2
- package/dist/{config-B62g483e.mjs.map → config-U-gojtxn.mjs.map} +1 -1
- package/dist/config.cjs +2 -2
- package/dist/config.d.cts +1 -1
- package/dist/config.d.mts +1 -1
- package/dist/config.mjs +2 -2
- package/dist/deploy/sniffer-loader.cjs +1 -1
- package/dist/{index-Dt_dZ7K4.d.mts → index-D7iT4dnv.d.mts} +41 -4
- package/dist/index-D7iT4dnv.d.mts.map +1 -0
- package/dist/{index-C3t5VL4R.d.cts → index-DRQq26DF.d.cts} +41 -4
- package/dist/index-DRQq26DF.d.cts.map +1 -0
- package/dist/index.cjs +1200 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +1200 -63
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-CflxypuN.mjs → openapi-BA0e3I_s.mjs} +65 -15
- package/dist/openapi-BA0e3I_s.mjs.map +1 -0
- package/dist/{openapi-B_rJjImN.cjs → openapi-Bb4UEyZN.cjs} +65 -15
- package/dist/openapi-Bb4UEyZN.cjs.map +1 -0
- package/dist/openapi.cjs +3 -3
- package/dist/openapi.d.cts +7 -0
- package/dist/openapi.d.cts.map +1 -1
- package/dist/openapi.d.mts +7 -0
- package/dist/openapi.d.mts.map +1 -1
- package/dist/openapi.mjs +3 -3
- package/dist/workspace/index.cjs +4 -1
- package/dist/workspace/index.d.cts +2 -2
- package/dist/workspace/index.d.mts +2 -2
- package/dist/workspace/index.mjs +2 -2
- package/dist/{workspace-CGYykWfn.cjs → workspace-BobDOIG9.cjs} +74 -3
- package/dist/workspace-BobDOIG9.cjs.map +1 -0
- package/dist/{workspace-Bi4X7Yzy.mjs → workspace-Cgmvgwh8.mjs} +57 -4
- package/dist/workspace-Cgmvgwh8.mjs.map +1 -0
- package/package.json +4 -4
- package/src/__tests__/openapi.spec.ts +99 -9
- package/src/deploy/__tests__/env-resolver.spec.ts +28 -0
- package/src/deploy/env-resolver.ts +5 -10
- package/src/deploy/index.ts +13 -7
- package/src/deploy/sniffer.ts +10 -4
- package/src/dev/__tests__/index.spec.ts +119 -0
- package/src/dev/index.ts +162 -37
- package/src/index.ts +6 -2
- package/src/init/__tests__/generators.spec.ts +147 -0
- package/src/init/generators/mobile-expo.ts +576 -0
- package/src/init/generators/monorepo.ts +42 -11
- package/src/init/generators/web-tanstack.ts +348 -0
- package/src/init/index.ts +40 -3
- package/src/init/templates/index.ts +31 -0
- package/src/init/versions.ts +3 -3
- package/src/openapi.ts +110 -23
- package/src/workspace/__tests__/index.spec.ts +92 -0
- package/src/workspace/__tests__/publicEnv.spec.ts +64 -0
- package/src/workspace/index.ts +16 -2
- package/src/workspace/publicEnv.ts +59 -0
- package/src/workspace/types.ts +6 -1
- package/dist/index-C3t5VL4R.d.cts.map +0 -1
- package/dist/index-Dt_dZ7K4.d.mts.map +0 -1
- package/dist/openapi-B_rJjImN.cjs.map +0 -1
- package/dist/openapi-CflxypuN.mjs.map +0 -1
- package/dist/workspace-Bi4X7Yzy.mjs.map +0 -1
- 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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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];
|
package/src/deploy/index.ts
CHANGED
|
@@ -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 (
|
|
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:
|
|
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
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
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
|
|
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}`
|
package/src/deploy/sniffer.ts
CHANGED
|
@@ -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
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
*
|
|
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
|
+
'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
|
|
664
|
-
*
|
|
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
|
-
|
|
677
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
}
|
|
798
|
+
const deps: Record<string, string> = {
|
|
799
|
+
...pkg.dependencies,
|
|
800
|
+
...pkg.devDependencies,
|
|
801
|
+
};
|
|
700
802
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
}
|