@geekmidas/cli 1.10.35 → 1.10.37

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 (100) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/{config-B62g483e.mjs → config-Bbu1JKvf.mjs} +2 -2
  3. package/dist/{config-B62g483e.mjs.map → config-Bbu1JKvf.mjs.map} +1 -1
  4. package/dist/{config-Cuo8vFsp.cjs → config-DujV8DbR.cjs} +2 -2
  5. package/dist/{config-Cuo8vFsp.cjs.map → config-DujV8DbR.cjs.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/{fullstack-secrets-Bchl2MDd.cjs → fullstack-secrets-BYzi6Hwl.cjs} +3 -2
  12. package/dist/{fullstack-secrets-Bchl2MDd.cjs.map → fullstack-secrets-BYzi6Hwl.cjs.map} +1 -1
  13. package/dist/{fullstack-secrets-P84v8zWn.mjs → fullstack-secrets-l8s3II_H.mjs} +3 -2
  14. package/dist/{fullstack-secrets-P84v8zWn.mjs.map → fullstack-secrets-l8s3II_H.mjs.map} +1 -1
  15. package/dist/{index-Dt_dZ7K4.d.mts → index-BBvGMG_A.d.mts} +162 -68
  16. package/dist/index-BBvGMG_A.d.mts.map +1 -0
  17. package/dist/{index-C3t5VL4R.d.cts → index-Dz2a7xQU.d.cts} +162 -68
  18. package/dist/index-Dz2a7xQU.d.cts.map +1 -0
  19. package/dist/index.cjs +1366 -149
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.mjs +1366 -149
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/{openapi-B_rJjImN.cjs → openapi-0JUzU5Tv.cjs} +72 -15
  24. package/dist/openapi-0JUzU5Tv.cjs.map +1 -0
  25. package/dist/{openapi-CflxypuN.mjs → openapi-C5AycKTR.mjs} +73 -16
  26. package/dist/openapi-C5AycKTR.mjs.map +1 -0
  27. package/dist/openapi.cjs +3 -3
  28. package/dist/openapi.d.cts +7 -0
  29. package/dist/openapi.d.cts.map +1 -1
  30. package/dist/openapi.d.mts +7 -0
  31. package/dist/openapi.d.mts.map +1 -1
  32. package/dist/openapi.mjs +3 -3
  33. package/dist/{reconcile-DAAKa0YR.mjs → reconcile-CYGmqXvq.mjs} +2 -2
  34. package/dist/{reconcile-DAAKa0YR.mjs.map → reconcile-CYGmqXvq.mjs.map} +1 -1
  35. package/dist/{reconcile-CZtzdrnc.cjs → reconcile-D93YG7G5.cjs} +2 -2
  36. package/dist/{reconcile-CZtzdrnc.cjs.map → reconcile-D93YG7G5.cjs.map} +1 -1
  37. package/dist/workspace/index.cjs +4 -1
  38. package/dist/workspace/index.d.cts +2 -2
  39. package/dist/workspace/index.d.mts +2 -2
  40. package/dist/workspace/index.mjs +2 -2
  41. package/dist/{workspace-CGYykWfn.cjs → workspace-CQTT7HJQ.cjs} +107 -27
  42. package/dist/workspace-CQTT7HJQ.cjs.map +1 -0
  43. package/dist/{workspace-Bi4X7Yzy.mjs → workspace-D-37Ylqo.mjs} +90 -28
  44. package/dist/workspace-D-37Ylqo.mjs.map +1 -0
  45. package/docs/dev-server-resilience-design.md +189 -0
  46. package/package.json +4 -4
  47. package/src/__tests__/openapi.spec.ts +99 -9
  48. package/src/build/__tests__/workspace-build.spec.ts +7 -7
  49. package/src/build/index.ts +25 -9
  50. package/src/credentials/__tests__/helpers.ts +1 -1
  51. package/src/credentials/__tests__/workspaceCredentials.spec.ts +1 -1
  52. package/src/deploy/__tests__/domain.spec.ts +12 -12
  53. package/src/deploy/__tests__/env-resolver.spec.ts +29 -1
  54. package/src/deploy/__tests__/index.spec.ts +14 -14
  55. package/src/deploy/__tests__/sniffer.spec.ts +9 -9
  56. package/src/deploy/domain.ts +19 -11
  57. package/src/deploy/env-resolver.ts +5 -10
  58. package/src/deploy/index.ts +25 -8
  59. package/src/deploy/sniffer.ts +11 -5
  60. package/src/deploy/types.ts +1 -1
  61. package/src/dev/__tests__/index.spec.ts +11 -11
  62. package/src/dev/index.ts +43 -9
  63. package/src/docker/__tests__/compose.spec.ts +4 -4
  64. package/src/docker/compose.ts +5 -3
  65. package/src/docker/index.ts +34 -6
  66. package/src/docker/templates.ts +138 -0
  67. package/src/generators/OpenApiTsGenerator.ts +22 -0
  68. package/src/generators/__tests__/OpenApiTsGenerator.registry.spec.ts +77 -0
  69. package/src/index.ts +6 -2
  70. package/src/init/__tests__/generators.spec.ts +147 -0
  71. package/src/init/__tests__/init.spec.ts +1 -1
  72. package/src/init/generators/mobile-expo.ts +576 -0
  73. package/src/init/generators/monorepo.ts +42 -11
  74. package/src/init/generators/web-tanstack.ts +348 -0
  75. package/src/init/index.ts +40 -3
  76. package/src/init/templates/index.ts +31 -0
  77. package/src/init/versions.ts +2 -2
  78. package/src/openapi.ts +110 -23
  79. package/src/secrets/__tests__/reconcile.spec.ts +1 -1
  80. package/src/setup/__tests__/reconcile-secrets.spec.ts +2 -2
  81. package/src/setup/fullstack-secrets.ts +7 -1
  82. package/src/setup/index.ts +2 -1
  83. package/src/test/__tests__/__fixtures__/workspace.ts +1 -1
  84. package/src/test/__tests__/web.spec.ts +2 -2
  85. package/src/workspace/__tests__/client-generator.spec.ts +6 -6
  86. package/src/workspace/__tests__/index.spec.ts +96 -4
  87. package/src/workspace/__tests__/publicEnv.spec.ts +64 -0
  88. package/src/workspace/__tests__/schema.spec.ts +27 -30
  89. package/src/workspace/__tests__/type-inference.spec.ts +5 -5
  90. package/src/workspace/client-generator.ts +10 -7
  91. package/src/workspace/index.ts +17 -3
  92. package/src/workspace/publicEnv.ts +57 -0
  93. package/src/workspace/schema.ts +126 -27
  94. package/src/workspace/types.ts +40 -64
  95. package/dist/index-C3t5VL4R.d.cts.map +0 -1
  96. package/dist/index-Dt_dZ7K4.d.mts.map +0 -1
  97. package/dist/openapi-B_rJjImN.cjs.map +0 -1
  98. package/dist/openapi-CflxypuN.mjs.map +0 -1
  99. package/dist/workspace-Bi4X7Yzy.mjs.map +0 -1
  100. package/dist/workspace-CGYykWfn.cjs.map +0 -1
@@ -3,6 +3,7 @@ import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
3
3
  import { mkdir, writeFile } from 'node:fs/promises';
4
4
  import { basename, join } from 'node:path';
5
5
  import { loadConfig, loadWorkspaceConfig } from '../config';
6
+ import { getPublicUrlArgNames } from '../deploy/domain.js';
6
7
  import type { NormalizedWorkspace } from '../workspace/types.js';
7
8
  import {
8
9
  generateDockerCompose,
@@ -18,7 +19,9 @@ import {
18
19
  generateEntryDockerfile,
19
20
  generateMultiStageDockerfile,
20
21
  generateNextjsDockerfile,
22
+ generateNodeWebDockerfile,
21
23
  generateSlimDockerfile,
24
+ generateViteStaticDockerfile,
22
25
  hasTurboConfig,
23
26
  isMonorepo,
24
27
  resolveDockerConfig,
@@ -342,7 +345,7 @@ async function pushDockerImage(
342
345
  */
343
346
  export interface AppDockerResult {
344
347
  appName: string;
345
- type: 'backend' | 'frontend';
348
+ type: 'backend' | 'web' | 'mobile';
346
349
  dockerfile: string;
347
350
  imageName: string;
348
351
  }
@@ -396,6 +399,15 @@ export async function workspaceDockerCommand(
396
399
 
397
400
  // Generate Dockerfile for each app
398
401
  for (const [appName, app] of apps) {
402
+ // Mobile apps deploy via their own toolchain (e.g. EAS Build for Expo)
403
+ // — no Docker image is produced.
404
+ if (app.type === 'mobile') {
405
+ logger.log(
406
+ `\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`,
407
+ );
408
+ continue;
409
+ }
410
+
399
411
  const appPath = app.path;
400
412
  const fullAppPath = join(workspace.root, appPath);
401
413
 
@@ -411,16 +423,31 @@ export async function workspaceDockerCommand(
411
423
 
412
424
  let dockerfile: string;
413
425
 
414
- if (app.type === 'frontend') {
415
- // Generate Next.js Dockerfile
416
- dockerfile = generateNextjsDockerfile({
426
+ if (app.type === 'web') {
427
+ const publicUrlArgs = getPublicUrlArgNames(app);
428
+ const webOpts = {
417
429
  imageName,
418
430
  baseImage: 'node:22-alpine',
419
431
  port: app.port,
420
432
  appPath,
421
433
  turboPackage,
422
434
  packageManager,
423
- });
435
+ publicUrlArgs,
436
+ };
437
+
438
+ switch (app.framework) {
439
+ case 'vite':
440
+ dockerfile = generateViteStaticDockerfile(webOpts);
441
+ break;
442
+ case 'tanstack-start':
443
+ case 'remix':
444
+ dockerfile = generateNodeWebDockerfile(webOpts);
445
+ break;
446
+ default:
447
+ // nextjs (and any unspecified web framework — schema requires
448
+ // a valid framework, so this is just a default).
449
+ dockerfile = generateNextjsDockerfile(webOpts);
450
+ }
424
451
  } else if (app.entry) {
425
452
  // Backend with custom entry point - use tsdown bundling
426
453
  dockerfile = generateEntryDockerfile({
@@ -479,7 +506,8 @@ export async function workspaceDockerCommand(
479
506
  );
480
507
  logger.log('\n📋 Build commands:');
481
508
  for (const result of results) {
482
- const icon = result.type === 'backend' ? '⚙️' : '🌐';
509
+ const icon =
510
+ result.type === 'backend' ? '⚙️' : result.type === 'mobile' ? '📱' : '🌐';
483
511
  logger.log(
484
512
  ` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`,
485
513
  );
@@ -927,3 +927,141 @@ ENTRYPOINT ["/sbin/tini", "--"]
927
927
  CMD ["node", "index.mjs"]
928
928
  `;
929
929
  }
930
+
931
+ /**
932
+ * Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
933
+ * Remix). Builds with turbo, then runs the framework's production start
934
+ * script via `npm start`.
935
+ *
936
+ * Public URL build args are declared so framework bundlers (Vite, Vinxi)
937
+ * can inline them at build time. Per-framework prefix is the caller's job.
938
+ * @internal Exported for testing
939
+ */
940
+ export function generateNodeWebDockerfile(
941
+ options: FrontendDockerfileOptions,
942
+ ): string {
943
+ const {
944
+ baseImage,
945
+ port,
946
+ appPath,
947
+ turboPackage,
948
+ packageManager,
949
+ publicUrlArgs = [],
950
+ } = options;
951
+
952
+ const pm = getPmConfig(packageManager);
953
+ const installPm = pm.install ? `RUN ${pm.install}` : '';
954
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
955
+ const turboCmd = packageManager === 'pnpm' ? 'pnpm dlx turbo' : 'npx turbo';
956
+ const startCmd =
957
+ packageManager === 'pnpm'
958
+ ? 'pnpm start'
959
+ : `${pm.lockfile.startsWith('package-lock') ? 'npm' : packageManager} start`;
960
+
961
+ const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join('\n');
962
+ const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join('\n');
963
+
964
+ return `# syntax=docker/dockerfile:1
965
+ # Node SSR web Dockerfile (TanStack Start / Remix) with turbo prune
966
+
967
+ FROM ${baseImage} AS pruner
968
+ WORKDIR /app
969
+ ${installPm}
970
+ COPY . .
971
+ RUN ${turboCmd} prune ${turboPackage} --docker
972
+
973
+ FROM ${baseImage} AS deps
974
+ WORKDIR /app
975
+ ${installPm}
976
+ COPY --from=pruner /app/out/${pm.lockfile} ./
977
+ COPY --from=pruner /app/out/json/ ./
978
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
979
+ ${turboInstallCmd}
980
+
981
+ FROM deps AS builder
982
+ WORKDIR /app
983
+ ${argDecls}
984
+ ${envDecls}
985
+ COPY --from=pruner /app/out/full/ ./
986
+ COPY --from=pruner /app/tsconfig.* ./
987
+ RUN ${turboCmd} run build --filter=${turboPackage}
988
+
989
+ FROM ${baseImage} AS runner
990
+ WORKDIR /app
991
+ RUN apk add --no-cache tini
992
+ RUN addgroup --system --gid 1001 nodejs && \\
993
+ adduser --system --uid 1001 app
994
+
995
+ # Copy the whole built workspace — frameworks differ on output paths
996
+ COPY --from=builder --chown=app:nodejs /app/ ./
997
+
998
+ ENV NODE_ENV=production
999
+ ENV PORT=${port}
1000
+ ENV HOSTNAME="0.0.0.0"
1001
+
1002
+ USER app
1003
+ EXPOSE ${port}
1004
+
1005
+ ENTRYPOINT ["/sbin/tini", "--"]
1006
+ CMD ["sh", "-c", "cd ${appPath} && ${startCmd}"]
1007
+ `;
1008
+ }
1009
+
1010
+ /**
1011
+ * Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
1012
+ * turbo, then serves it via nginx.
1013
+ * @internal Exported for testing
1014
+ */
1015
+ export function generateViteStaticDockerfile(
1016
+ options: FrontendDockerfileOptions,
1017
+ ): string {
1018
+ const {
1019
+ baseImage,
1020
+ port,
1021
+ appPath,
1022
+ turboPackage,
1023
+ packageManager,
1024
+ publicUrlArgs = [],
1025
+ } = options;
1026
+
1027
+ const pm = getPmConfig(packageManager);
1028
+ const installPm = pm.install ? `RUN ${pm.install}` : '';
1029
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
1030
+ const turboCmd = packageManager === 'pnpm' ? 'pnpm dlx turbo' : 'npx turbo';
1031
+
1032
+ const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join('\n');
1033
+ const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join('\n');
1034
+
1035
+ return `# syntax=docker/dockerfile:1
1036
+ # Vite SPA Dockerfile — builds static assets, serves via nginx
1037
+
1038
+ FROM ${baseImage} AS pruner
1039
+ WORKDIR /app
1040
+ ${installPm}
1041
+ COPY . .
1042
+ RUN ${turboCmd} prune ${turboPackage} --docker
1043
+
1044
+ FROM ${baseImage} AS deps
1045
+ WORKDIR /app
1046
+ ${installPm}
1047
+ COPY --from=pruner /app/out/${pm.lockfile} ./
1048
+ COPY --from=pruner /app/out/json/ ./
1049
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
1050
+ ${turboInstallCmd}
1051
+
1052
+ FROM deps AS builder
1053
+ WORKDIR /app
1054
+ ${argDecls}
1055
+ ${envDecls}
1056
+ COPY --from=pruner /app/out/full/ ./
1057
+ COPY --from=pruner /app/tsconfig.* ./
1058
+ RUN ${turboCmd} run build --filter=${turboPackage}
1059
+
1060
+ FROM nginx:alpine AS runner
1061
+ COPY --from=builder /app/${appPath}/dist /usr/share/nginx/html
1062
+ RUN printf 'server {\\n listen ${port};\\n root /usr/share/nginx/html;\\n index index.html;\\n location / { try_files $uri $uri/ /index.html; }\\n}\\n' \\
1063
+ > /etc/nginx/conf.d/default.conf
1064
+ EXPOSE ${port}
1065
+ CMD ["nginx", "-g", "daemon off;"]
1066
+ `;
1067
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Endpoint } from '@geekmidas/constructs/endpoints';
2
2
  import {
3
+ getRegisteredZodJsonSchemas,
3
4
  getSchemaMetadata,
4
5
  StandardSchemaJsonSchema,
5
6
  } from '@geekmidas/schema/conversion';
@@ -313,6 +314,27 @@ export class OpenApiTsGenerator {
313
314
  }
314
315
  }
315
316
 
317
+ // Pull in every schema registered via `.meta({ id })` in zod's global
318
+ // registry, even when no endpoint references it directly. This makes
319
+ // derived schemas like `UserSchema.pick(...)` discoverable through the
320
+ // named parent type without requiring the parent to be wired into an
321
+ // endpoint.
322
+ const registrySchemas = await getRegisteredZodJsonSchemas();
323
+ for (const [rawName, rawSchema] of Object.entries(registrySchemas)) {
324
+ const defName = this.pascalCase(rawName);
325
+ if (collectedDefs.has(defName) || generatedNames.has(defName)) {
326
+ continue;
327
+ }
328
+ const {
329
+ id: _id,
330
+ $defs: _defs,
331
+ ...rest
332
+ } = rawSchema as JsonSchema & {
333
+ id?: string;
334
+ };
335
+ collectedDefs.set(defName, rest as JsonSchema);
336
+ }
337
+
316
338
  // Generate interfaces for collected $defs (nested schemas with .meta({ id: 'X' }))
317
339
  for (const [defName, defSchema] of collectedDefs) {
318
340
  if (!generatedNames.has(defName)) {
@@ -0,0 +1,77 @@
1
+ import { e } from '@geekmidas/constructs/endpoints';
2
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
+ import { z } from 'zod/v4';
4
+ import { OpenApiTsGenerator } from '../OpenApiTsGenerator';
5
+
6
+ describe('OpenApiTsGenerator — zod global registry', () => {
7
+ beforeEach(() => {
8
+ z.globalRegistry.clear();
9
+ });
10
+
11
+ afterEach(() => {
12
+ z.globalRegistry.clear();
13
+ });
14
+
15
+ it('emits a named interface for a registered schema even when no endpoint references it directly', async () => {
16
+ const UserSchema = z
17
+ .object({
18
+ id: z.string(),
19
+ email: z.string(),
20
+ name: z.string(),
21
+ })
22
+ .meta({ id: 'User' });
23
+
24
+ const UserResponseSchema = UserSchema.pick({
25
+ id: true,
26
+ name: true,
27
+ email: true,
28
+ });
29
+
30
+ const endpoint = e
31
+ .get('/users/:id')
32
+ .output(UserResponseSchema)
33
+ .handle(async () => ({ id: '1', name: 'a', email: 'a@b.c' }));
34
+
35
+ const generator = new OpenApiTsGenerator();
36
+ const content = await generator.generate([endpoint as any]);
37
+
38
+ expect(content).toMatch(/export interface User\b/);
39
+ });
40
+
41
+ it('does not duplicate when the same id is also produced by an endpoint', async () => {
42
+ const UserSchema = z
43
+ .object({
44
+ id: z.string(),
45
+ email: z.string(),
46
+ name: z.string(),
47
+ })
48
+ .meta({ id: 'User' });
49
+
50
+ const endpoint = e
51
+ .get('/users/:id')
52
+ .output(UserSchema)
53
+ .handle(async () => ({ id: '1', name: 'a', email: 'a@b.c' }));
54
+
55
+ const generator = new OpenApiTsGenerator();
56
+ const content = await generator.generate([endpoint as any]);
57
+
58
+ const occurrences = content.match(/export interface User\b/g) ?? [];
59
+ expect(occurrences.length).toBe(1);
60
+ });
61
+
62
+ it('emits multiple registered schemas', async () => {
63
+ z.object({ id: z.string() }).meta({ id: 'Foo' });
64
+ z.object({ id: z.string() }).meta({ id: 'Bar' });
65
+
66
+ const endpoint = e
67
+ .get('/ping')
68
+ .output(z.object({ ok: z.boolean() }))
69
+ .handle(async () => ({ ok: true }));
70
+
71
+ const generator = new OpenApiTsGenerator();
72
+ const content = await generator.generate([endpoint as any]);
73
+
74
+ expect(content).toMatch(/export interface Foo\b/);
75
+ expect(content).toMatch(/export interface Bar\b/);
76
+ });
77
+ });
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);
@@ -379,7 +379,7 @@ describe('initCommand', () => {
379
379
  expect(content).toContain("name: 'my-fullstack'");
380
380
  expect(content).toContain("type: 'backend'");
381
381
  expect(content).toContain("path: 'apps/api'");
382
- expect(content).toContain("type: 'frontend'");
382
+ expect(content).toContain("type: 'web'");
383
383
  expect(content).toContain("framework: 'nextjs'");
384
384
  expect(content).toContain("path: 'apps/web'");
385
385
  expect(content).toContain("packages: ['packages/*']");