@geekmidas/cli 1.10.35 → 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 (63) hide show
  1. package/CHANGELOG.md +10 -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 +1094 -30
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.mjs +1094 -30
  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/index.ts +29 -1
  44. package/src/index.ts +6 -2
  45. package/src/init/__tests__/generators.spec.ts +147 -0
  46. package/src/init/generators/mobile-expo.ts +576 -0
  47. package/src/init/generators/monorepo.ts +42 -11
  48. package/src/init/generators/web-tanstack.ts +348 -0
  49. package/src/init/index.ts +40 -3
  50. package/src/init/templates/index.ts +31 -0
  51. package/src/init/versions.ts +2 -2
  52. package/src/openapi.ts +110 -23
  53. package/src/workspace/__tests__/index.spec.ts +92 -0
  54. package/src/workspace/__tests__/publicEnv.spec.ts +64 -0
  55. package/src/workspace/index.ts +16 -2
  56. package/src/workspace/publicEnv.ts +59 -0
  57. package/src/workspace/types.ts +6 -1
  58. package/dist/index-C3t5VL4R.d.cts.map +0 -1
  59. package/dist/index-Dt_dZ7K4.d.mts.map +0 -1
  60. package/dist/openapi-B_rJjImN.cjs.map +0 -1
  61. package/dist/openapi-CflxypuN.mjs.map +0 -1
  62. package/dist/workspace-Bi4X7Yzy.mjs.map +0 -1
  63. 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.35",
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",
@@ -57,10 +57,10 @@
57
57
  "tsx": "~4.20.3",
58
58
  "yaml": "~2.8.2",
59
59
  "@geekmidas/constructs": "~3.0.9",
60
- "@geekmidas/envkit": "~1.0.5",
61
60
  "@geekmidas/errors": "~1.0.0",
62
- "@geekmidas/logger": "~1.0.1",
63
- "@geekmidas/schema": "~1.0.0"
61
+ "@geekmidas/schema": "~1.0.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 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
package/src/dev/index.ts CHANGED
@@ -688,6 +688,23 @@ const FRONTEND_FRAMEWORKS: Record<FrontendFramework, FrontendFrameworkSpec> = {
688
688
  dependency: 'vite',
689
689
  installCommand: 'pnpm add -D vite',
690
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
+ },
691
708
  };
692
709
 
693
710
  /**
@@ -699,7 +716,18 @@ function detectFrontendFramework(
699
716
  fullPath: string,
700
717
  deps: Record<string, string>,
701
718
  ): FrontendFramework | undefined {
702
- const order: FrontendFramework[] = ['nextjs', 'remix', 'vite'];
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
+ ];
703
731
  for (const name of order) {
704
732
  if (deps[FRONTEND_FRAMEWORKS[name].dependency]) {
705
733
  return name;
package/src/index.ts CHANGED
@@ -286,13 +286,17 @@ program
286
286
  program
287
287
  .command('openapi')
288
288
  .description('Generate OpenAPI specification from endpoints')
289
- .action(async () => {
289
+ .option(
290
+ '--app <name>',
291
+ 'Workspace mode: generate for a single named backend app',
292
+ )
293
+ .action(async (options: { app?: string }) => {
290
294
  try {
291
295
  const globalOptions = program.opts();
292
296
  if (globalOptions.cwd) {
293
297
  process.chdir(globalOptions.cwd);
294
298
  }
295
- await openapiCommand({});
299
+ await openapiCommand({ app: options.app });
296
300
  } catch (error) {
297
301
  console.error(formatError(error));
298
302
  process.exit(1);
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest';
2
2
  import { generateConfigFiles } from '../generators/config.js';
3
3
  import { generateDockerFiles } from '../generators/docker.js';
4
4
  import { generateEnvFiles } from '../generators/env.js';
5
+ import { generateExpoAppFiles } from '../generators/mobile-expo.js';
5
6
  import { generateModelsPackage } from '../generators/models.js';
6
7
  import { generateMonorepoFiles } from '../generators/monorepo.js';
7
8
  import { generatePackageJson } from '../generators/package.js';
8
9
  import { generateTestFiles } from '../generators/test.js';
9
10
  import { generateUiPackageFiles } from '../generators/ui.js';
11
+ import { generateTanStackWebFiles } from '../generators/web-tanstack.js';
10
12
  import { apiTemplate } from '../templates/api.js';
11
13
  import type { TemplateOptions } from '../templates/index.js';
12
14
  import { minimalTemplate } from '../templates/minimal.js';
@@ -431,6 +433,151 @@ describe('generateMonorepoFiles', () => {
431
433
  });
432
434
  });
433
435
 
436
+ describe('generateTanStackWebFiles', () => {
437
+ const fullstackOptions: TemplateOptions = {
438
+ ...baseOptions,
439
+ template: 'fullstack',
440
+ monorepo: true,
441
+ apiPath: 'apps/api',
442
+ frontendFramework: 'tanstack-start',
443
+ };
444
+
445
+ it('returns empty array for non-fullstack template', () => {
446
+ expect(generateTanStackWebFiles(baseOptions)).toHaveLength(0);
447
+ });
448
+
449
+ it('generates the expected file paths', () => {
450
+ const files = generateTanStackWebFiles(fullstackOptions);
451
+ const paths = files.map((f) => f.path);
452
+ expect(paths).toContain('apps/web/package.json');
453
+ expect(paths).toContain('apps/web/vite.config.ts');
454
+ expect(paths).toContain('apps/web/src/router.tsx');
455
+ expect(paths).toContain('apps/web/src/routes/__root.tsx');
456
+ expect(paths).toContain('apps/web/src/routes/index.tsx');
457
+ expect(paths).toContain('apps/web/src/config/client.ts');
458
+ });
459
+
460
+ it('uses VITE_ prefix in client config, not NEXT_PUBLIC_', () => {
461
+ const files = generateTanStackWebFiles(fullstackOptions);
462
+ const clientConfig = files.find(
463
+ (f) => f.path === 'apps/web/src/config/client.ts',
464
+ );
465
+ expect(clientConfig).toBeDefined();
466
+ expect(clientConfig!.content).toContain('VITE_API_URL');
467
+ expect(clientConfig!.content).toContain('VITE_AUTH_URL');
468
+ expect(clientConfig!.content).not.toContain('NEXT_PUBLIC_');
469
+ });
470
+
471
+ it('declares tanstack and vite as dependencies', () => {
472
+ const files = generateTanStackWebFiles(fullstackOptions);
473
+ const pkg = JSON.parse(
474
+ files.find((f) => f.path === 'apps/web/package.json')!.content,
475
+ );
476
+ expect(pkg.dependencies['@tanstack/react-start']).toBeDefined();
477
+ expect(pkg.dependencies['@tanstack/react-router']).toBeDefined();
478
+ expect(pkg.devDependencies.vite).toBeDefined();
479
+ expect(pkg.dependencies.next).toBeUndefined();
480
+ });
481
+ });
482
+
483
+ describe('generateExpoAppFiles', () => {
484
+ const fullstackOptions: TemplateOptions = {
485
+ ...baseOptions,
486
+ template: 'fullstack',
487
+ monorepo: true,
488
+ apiPath: 'apps/api',
489
+ frontendFramework: 'expo',
490
+ };
491
+
492
+ it('returns empty array for non-fullstack template', () => {
493
+ expect(generateExpoAppFiles(baseOptions)).toHaveLength(0);
494
+ });
495
+
496
+ it('generates the expected Expo file paths', () => {
497
+ const files = generateExpoAppFiles(fullstackOptions);
498
+ const paths = files.map((f) => f.path);
499
+ expect(paths).toContain('apps/app/package.json');
500
+ expect(paths).toContain('apps/app/app.config.ts');
501
+ expect(paths).toContain('apps/app/eas.json');
502
+ expect(paths).toContain('apps/app/babel.config.js');
503
+ expect(paths).toContain('apps/app/metro.config.js');
504
+ expect(paths).toContain('apps/app/app/_layout.tsx');
505
+ expect(paths).toContain('apps/app/app/index.tsx');
506
+ expect(paths).toContain('apps/app/app/login.tsx');
507
+ expect(paths).toContain('apps/app/lib/auth-client.ts');
508
+ });
509
+
510
+ it('uses EXPO_PUBLIC_ prefix in config.ts', () => {
511
+ const files = generateExpoAppFiles(fullstackOptions);
512
+ const configTs = files.find((f) => f.path === 'apps/app/config.ts');
513
+ expect(configTs).toBeDefined();
514
+ expect(configTs!.content).toContain('EXPO_PUBLIC_API_URL');
515
+ expect(configTs!.content).toContain('EXPO_PUBLIC_AUTH_URL');
516
+ expect(configTs!.content).not.toContain('VITE_');
517
+ expect(configTs!.content).not.toContain('NEXT_PUBLIC_');
518
+ });
519
+
520
+ it('seeds eas.json with EXPO_PUBLIC_ env vars per profile', () => {
521
+ const files = generateExpoAppFiles(fullstackOptions);
522
+ const eas = JSON.parse(
523
+ files.find((f) => f.path === 'apps/app/eas.json')!.content,
524
+ );
525
+ expect(eas.build.dev.env.EXPO_PUBLIC_API_URL).toBeDefined();
526
+ expect(eas.build.dev.env.EXPO_PUBLIC_AUTH_URL).toBeDefined();
527
+ });
528
+
529
+ it('declares expo + better-auth/expo as dependencies', () => {
530
+ const files = generateExpoAppFiles(fullstackOptions);
531
+ const pkg = JSON.parse(
532
+ files.find((f) => f.path === 'apps/app/package.json')!.content,
533
+ );
534
+ expect(pkg.dependencies.expo).toBeDefined();
535
+ expect(pkg.dependencies['@better-auth/expo']).toBeDefined();
536
+ expect(pkg.dependencies['expo-router']).toBeDefined();
537
+ expect(pkg.dependencies.next).toBeUndefined();
538
+ });
539
+ });
540
+
541
+ describe('generateMonorepoFiles - frontendFramework wiring', () => {
542
+ const fullstackBase: TemplateOptions = {
543
+ ...baseOptions,
544
+ template: 'fullstack',
545
+ monorepo: true,
546
+ apiPath: 'apps/api',
547
+ };
548
+
549
+ it('emits framework: nextjs by default', () => {
550
+ const files = generateMonorepoFiles(
551
+ { ...fullstackBase, frontendFramework: 'nextjs' },
552
+ minimalTemplate,
553
+ );
554
+ const cfg = files.find((f) => f.path === 'gkm.config.ts');
555
+ expect(cfg!.content).toContain("framework: 'nextjs'");
556
+ expect(cfg!.content).toContain("path: 'apps/web'");
557
+ });
558
+
559
+ it('emits framework: tanstack-start when selected', () => {
560
+ const files = generateMonorepoFiles(
561
+ { ...fullstackBase, frontendFramework: 'tanstack-start' },
562
+ minimalTemplate,
563
+ );
564
+ const cfg = files.find((f) => f.path === 'gkm.config.ts');
565
+ expect(cfg!.content).toContain("framework: 'tanstack-start'");
566
+ expect(cfg!.content).toContain("path: 'apps/web'");
567
+ });
568
+
569
+ it('emits framework: expo and apps/app path when selected', () => {
570
+ const files = generateMonorepoFiles(
571
+ { ...fullstackBase, frontendFramework: 'expo' },
572
+ minimalTemplate,
573
+ );
574
+ const cfg = files.find((f) => f.path === 'gkm.config.ts');
575
+ expect(cfg!.content).toContain("framework: 'expo'");
576
+ expect(cfg!.content).toContain("path: 'apps/app'");
577
+ expect(cfg!.content).toContain('port: 8081');
578
+ });
579
+ });
580
+
434
581
  describe('generateModelsPackage', () => {
435
582
  it('should return empty array for non-monorepo', () => {
436
583
  const files = generateModelsPackage(baseOptions);