@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
package/dist/index.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
  import { __require } from "./chunk-Duj1WY3L.mjs";
3
- import { getAppBuildOrder, getDependencyEnvVars, getDeployTargetError, isDeployTargetSupported } from "./workspace-Bi4X7Yzy.mjs";
4
- import { getAppNameFromCwd, loadAppConfig, loadConfig, loadWorkspaceAppInfo, loadWorkspaceConfig, parseModuleConfig } from "./config-B62g483e.mjs";
3
+ import { getAppBuildOrder, getDependencyEnvVars, getDeployTargetError, getPublicEnvPrefix, isDeployTargetSupported, stripPublicPrefix } from "./workspace-D-37Ylqo.mjs";
4
+ import { getAppNameFromCwd, loadAppConfig, loadConfig, loadWorkspaceAppInfo, loadWorkspaceConfig, parseModuleConfig } from "./config-Bbu1JKvf.mjs";
5
5
  import { getCredentialsPath, getDokployCredentials, getDokployRegistryId, getDokployToken, removeDokployCredentials, storeDokployCredentials, storeDokployRegistryId } from "./credentials-s1kLcIzK.mjs";
6
- import { createStageSecrets, generateConnectionUrls, generateDbPassword, generateDbUrl, generateFullstackCustomSecrets, generateLocalStackCredentials, generatePgBossUrl, generateSecurePassword, generateServiceCredentials, rotateServicePassword, writeDockerEnvFromSecrets } from "./fullstack-secrets-P84v8zWn.mjs";
6
+ import { createStageSecrets, generateConnectionUrls, generateDbPassword, generateDbUrl, generateFullstackCustomSecrets, generateLocalStackCredentials, generatePgBossUrl, generateSecurePassword, generateServiceCredentials, rotateServicePassword, writeDockerEnvFromSecrets } from "./fullstack-secrets-l8s3II_H.mjs";
7
7
  import { getKeyPath, maskPassword, readStageSecrets, secretsExist, setCustomSecret, toEmbeddableSecrets, writeStageSecrets } from "./storage-dbb9RyBl.mjs";
8
- import { ConstructGenerator, EndpointGenerator, OPENAPI_OUTPUT_PATH, generateOpenApi, isPartitionedRoutes, normalizeRoutes, openapiCommand, resolveOpenApiConfig } from "./openapi-CflxypuN.mjs";
8
+ import { ConstructGenerator, EndpointGenerator, OPENAPI_OUTPUT_PATH, generateOpenApi, isPartitionedRoutes, normalizeRoutes, openapiCommand, resolveOpenApiConfig } from "./openapi-C5AycKTR.mjs";
9
9
  import { DokployApi } from "./dokploy-api-BIT0683o.mjs";
10
10
  import { encryptSecrets } from "./encryption-DSkIlEZ6.mjs";
11
11
  import { CachedStateProvider } from "./CachedStateProvider-C8A-cCm5.mjs";
@@ -28,14 +28,14 @@ import { randomBytes } from "node:crypto";
28
28
  import { Cron } from "@geekmidas/constructs/crons";
29
29
  import { Function } from "@geekmidas/constructs/functions";
30
30
  import { Subscriber } from "@geekmidas/constructs/subscribers";
31
+ import { fileURLToPath, pathToFileURL } from "node:url";
31
32
  import { Client } from "pg";
32
33
  import { lookup } from "node:dns/promises";
33
- import { fileURLToPath, pathToFileURL } from "node:url";
34
34
  import prompts from "prompts";
35
35
 
36
36
  //#region package.json
37
37
  var name = "@geekmidas/cli";
38
- var version = "1.10.34";
38
+ var version = "1.10.36";
39
39
  var description = "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs";
40
40
  var private$1 = false;
41
41
  var type = "module";
@@ -1489,6 +1489,27 @@ const FRONTEND_FRAMEWORKS = {
1489
1489
  ],
1490
1490
  dependency: "vite",
1491
1491
  installCommand: "pnpm add -D vite"
1492
+ },
1493
+ "tanstack-start": {
1494
+ displayName: "TanStack Start",
1495
+ configFiles: [
1496
+ "vite.config.js",
1497
+ "vite.config.ts",
1498
+ "vite.config.mjs",
1499
+ "vite.config.cjs"
1500
+ ],
1501
+ dependency: "@tanstack/react-start",
1502
+ installCommand: "pnpm add @tanstack/react-start @tanstack/react-router"
1503
+ },
1504
+ expo: {
1505
+ displayName: "Expo",
1506
+ configFiles: [
1507
+ "app.config.ts",
1508
+ "app.config.js",
1509
+ "app.json"
1510
+ ],
1511
+ dependency: "expo",
1512
+ installCommand: "pnpm add expo"
1492
1513
  }
1493
1514
  };
1494
1515
  /**
@@ -1498,6 +1519,8 @@ const FRONTEND_FRAMEWORKS = {
1498
1519
  */
1499
1520
  function detectFrontendFramework(fullPath, deps) {
1500
1521
  const order = [
1522
+ "expo",
1523
+ "tanstack-start",
1501
1524
  "nextjs",
1502
1525
  "remix",
1503
1526
  "vite"
@@ -1572,7 +1595,7 @@ async function validateFrontendApp(appName, appPath, workspaceRoot, framework) {
1572
1595
  */
1573
1596
  async function validateFrontendApps(workspace) {
1574
1597
  const results = [];
1575
- for (const [appName, app] of Object.entries(workspace.apps)) if (app.type === "frontend") {
1598
+ for (const [appName, app] of Object.entries(workspace.apps)) if (app.type === "web") {
1576
1599
  const result = await validateFrontendApp(appName, app.path, workspace.root, app.framework);
1577
1600
  results.push(result);
1578
1601
  }
@@ -1608,9 +1631,12 @@ async function loadDevSecrets(workspace) {
1608
1631
  async function workspaceDevCommand(workspace, options) {
1609
1632
  const appCount = Object.keys(workspace.apps).length;
1610
1633
  const backendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "backend");
1611
- const frontendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "frontend");
1634
+ const frontendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "web");
1635
+ const mobileApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "mobile");
1612
1636
  logger$11.log(`\n🚀 Starting workspace: ${workspace.name}`);
1613
- logger$11.log(` ${backendApps.length} backend app(s), ${frontendApps.length} frontend app(s)`);
1637
+ const counts = [`${backendApps.length} backend`, `${frontendApps.length} web`];
1638
+ if (mobileApps.length > 0) counts.push(`${mobileApps.length} mobile`);
1639
+ logger$11.log(` ${counts.join(", ")} app(s)`);
1614
1640
  const conflicts = checkPortConflicts(workspace);
1615
1641
  if (conflicts.length > 0) {
1616
1642
  for (const conflict of conflicts) logger$11.error(`❌ Port conflict: Apps "${conflict.app1}" and "${conflict.app2}" both use port ${conflict.port}`);
@@ -1659,7 +1685,8 @@ async function workspaceDevCommand(workspace, options) {
1659
1685
  const app = workspace.apps[appName];
1660
1686
  if (!app) continue;
1661
1687
  const deps = app.dependencies.length > 0 ? ` (depends on: ${app.dependencies.join(", ")})` : "";
1662
- logger$11.log(` ${app.type === "backend" ? "🔧" : "🌐"} ${appName} http://localhost:${app.port}${deps}`);
1688
+ const icon = app.type === "backend" ? "🔧" : app.type === "mobile" ? "📱" : "🌐";
1689
+ logger$11.log(` ${icon} ${appName} → http://localhost:${app.port}${deps}`);
1663
1690
  }
1664
1691
  const configFiles = [
1665
1692
  "gkm.config.ts",
@@ -2376,7 +2403,7 @@ async function workspaceBuildCommand(workspace, options) {
2376
2403
  const results = [];
2377
2404
  const apps = Object.entries(workspace.apps);
2378
2405
  const backendApps = apps.filter(([, app]) => app.type === "backend");
2379
- const frontendApps = apps.filter(([, app]) => app.type === "frontend");
2406
+ const frontendApps = apps.filter(([, app]) => app.type === "web");
2380
2407
  logger$9.log(`\n🏗️ Building workspace: ${workspace.name}`);
2381
2408
  logger$9.log(` Backend apps: ${backendApps.map(([name$1]) => name$1).join(", ") || "none"}`);
2382
2409
  logger$9.log(` Frontend apps: ${frontendApps.map(([name$1]) => name$1).join(", ") || "none"}`);
@@ -2419,7 +2446,7 @@ async function workspaceBuildCommand(workspace, options) {
2419
2446
  await openapiCommand({ cwd: workspace.root });
2420
2447
  logger$9.log(`\n📋 Build Summary:`);
2421
2448
  for (const result of results) {
2422
- const icon = result.type === "backend" ? "⚙️" : "🌐";
2449
+ const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
2423
2450
  logger$9.log(` ${icon} ${result.appName}: ${result.outputPath || "built"}`);
2424
2451
  }
2425
2452
  } catch (error) {
@@ -2440,8 +2467,14 @@ async function workspaceBuildCommand(workspace, options) {
2440
2467
  */
2441
2468
  function getAppOutputPath(workspace, _appName, app) {
2442
2469
  const appPath = join(workspace.root, app.path);
2443
- if (app.type === "frontend") return join(appPath, ".next");
2444
- else return join(appPath, ".gkm");
2470
+ if (app.type === "mobile") return "";
2471
+ if (app.type === "web") switch (app.framework) {
2472
+ case "vite":
2473
+ case "tanstack-start": return join(appPath, "dist");
2474
+ case "remix": return join(appPath, "build");
2475
+ default: return join(appPath, ".next");
2476
+ }
2477
+ return join(appPath, ".gkm");
2445
2478
  }
2446
2479
  /**
2447
2480
  * Format routes for logging, handling PartitionedRoutes.
@@ -3003,6 +3036,66 @@ async function verifyDnsRecords(appHostnames, serverIp, state) {
3003
3036
  return results;
3004
3037
  }
3005
3038
 
3039
+ //#endregion
3040
+ //#region src/deploy/domain.ts
3041
+ /**
3042
+ * Resolve the hostname for an app based on stage configuration.
3043
+ *
3044
+ * Domain resolution priority:
3045
+ * 1. Explicit app.domain override (string or stage-specific)
3046
+ * 2. Default pattern based on app type:
3047
+ * - Main frontend app gets base domain (e.g., 'myapp.com')
3048
+ * - Other apps get prefixed domain (e.g., 'api.myapp.com')
3049
+ *
3050
+ * @param appName - The name of the app
3051
+ * @param app - The normalized app configuration
3052
+ * @param stage - The deployment stage (e.g., 'production', 'development')
3053
+ * @param dokployConfig - Dokploy workspace configuration with domain mappings
3054
+ * @param isMainFrontend - Whether this is the main frontend app
3055
+ * @returns The resolved hostname for the app
3056
+ * @throws Error if no domain configuration is found for the stage
3057
+ */
3058
+ function resolveHost(appName, app, stage, dokployConfig, isMainFrontend) {
3059
+ if (app.domain) {
3060
+ if (typeof app.domain === "string") return app.domain;
3061
+ if (app.domain[stage]) return app.domain[stage];
3062
+ }
3063
+ const baseDomain = dokployConfig?.domains?.[stage];
3064
+ if (!baseDomain) throw new Error(`No domain configured for stage "${stage}". Add deploy.dokploy.domains.${stage} to gkm.config.ts`);
3065
+ if (isMainFrontend) return baseDomain;
3066
+ return `${appName}.${baseDomain}`;
3067
+ }
3068
+ /**
3069
+ * Determine if an app is the "main" frontend (gets base domain).
3070
+ *
3071
+ * An app is considered the main frontend if:
3072
+ * 1. It's named 'web' and is a frontend type
3073
+ * 2. It's the first frontend app in the apps list
3074
+ *
3075
+ * @param appName - The name of the app to check
3076
+ * @param app - The app configuration
3077
+ * @param allApps - All apps in the workspace
3078
+ * @returns True if this is the main frontend app
3079
+ */
3080
+ function isMainFrontendApp(appName, app, allApps) {
3081
+ if (app.type !== "web") return false;
3082
+ if (appName === "web") return true;
3083
+ for (const [name$1, a] of Object.entries(allApps)) if (a.type === "web") return name$1 === appName;
3084
+ return false;
3085
+ }
3086
+ /**
3087
+ * Get public URL arg names from app dependencies.
3088
+ *
3089
+ * @param app - The web/mobile app configuration
3090
+ * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
3091
+ * without a public prefix (e.g. Remix).
3092
+ */
3093
+ function getPublicUrlArgNames(app) {
3094
+ const prefix = getPublicEnvPrefix(app);
3095
+ if (!prefix) return [];
3096
+ return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
3097
+ }
3098
+
3006
3099
  //#endregion
3007
3100
  //#region src/docker/compose.ts
3008
3101
  /** Default Docker images for services */
@@ -3314,15 +3407,18 @@ function generateWorkspaceCompose(workspace, options = {}) {
3314
3407
 
3315
3408
  services:
3316
3409
  `;
3317
- for (const [appName, app] of apps) yaml += generateAppService(appName, app, apps, {
3318
- registry,
3319
- projectName: workspace.name,
3320
- hasPostgres,
3321
- hasRedis,
3322
- hasMinio,
3323
- hasMail,
3324
- eventsBackend
3325
- });
3410
+ for (const [appName, app] of apps) {
3411
+ if (app.type === "mobile") continue;
3412
+ yaml += generateAppService(appName, app, apps, {
3413
+ registry,
3414
+ projectName: workspace.name,
3415
+ hasPostgres,
3416
+ hasRedis,
3417
+ hasMinio,
3418
+ hasMail,
3419
+ eventsBackend
3420
+ });
3421
+ }
3326
3422
  if (hasPostgres) yaml += `
3327
3423
  postgres:
3328
3424
  image: ${postgresImage}
@@ -3491,8 +3587,8 @@ function getInfraServiceImage(serviceName, config$1) {
3491
3587
  function generateAppService(appName, app, allApps, options) {
3492
3588
  const { registry, projectName, hasPostgres, hasRedis, hasMinio, hasMail, eventsBackend } = options;
3493
3589
  const imageRef = registry ? `\${REGISTRY:-${registry}}/` : "";
3494
- const healthCheckPath = app.type === "frontend" ? "/" : "/health";
3495
- const healthCheckCmd = app.type === "frontend" ? `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}/"]` : `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}${healthCheckPath}"]`;
3590
+ const healthCheckPath = app.type === "web" ? "/" : "/health";
3591
+ const healthCheckCmd = app.type === "web" ? `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}/"]` : `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}${healthCheckPath}"]`;
3496
3592
  let yaml = `
3497
3593
  ${appName}:
3498
3594
  build:
@@ -4295,6 +4391,115 @@ ENTRYPOINT ["/sbin/tini", "--"]
4295
4391
  CMD ["node", "index.mjs"]
4296
4392
  `;
4297
4393
  }
4394
+ /**
4395
+ * Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
4396
+ * Remix). Builds with turbo, then runs the framework's production start
4397
+ * script via `npm start`.
4398
+ *
4399
+ * Public URL build args are declared so framework bundlers (Vite, Vinxi)
4400
+ * can inline them at build time. Per-framework prefix is the caller's job.
4401
+ * @internal Exported for testing
4402
+ */
4403
+ function generateNodeWebDockerfile(options) {
4404
+ const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
4405
+ const pm = getPmConfig(packageManager);
4406
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
4407
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
4408
+ const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
4409
+ const startCmd = packageManager === "pnpm" ? "pnpm start" : `${pm.lockfile.startsWith("package-lock") ? "npm" : packageManager} start`;
4410
+ const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
4411
+ const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
4412
+ return `# syntax=docker/dockerfile:1
4413
+ # Node SSR web Dockerfile (TanStack Start / Remix) with turbo prune
4414
+
4415
+ FROM ${baseImage} AS pruner
4416
+ WORKDIR /app
4417
+ ${installPm}
4418
+ COPY . .
4419
+ RUN ${turboCmd} prune ${turboPackage} --docker
4420
+
4421
+ FROM ${baseImage} AS deps
4422
+ WORKDIR /app
4423
+ ${installPm}
4424
+ COPY --from=pruner /app/out/${pm.lockfile} ./
4425
+ COPY --from=pruner /app/out/json/ ./
4426
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4427
+ ${turboInstallCmd}
4428
+
4429
+ FROM deps AS builder
4430
+ WORKDIR /app
4431
+ ${argDecls}
4432
+ ${envDecls}
4433
+ COPY --from=pruner /app/out/full/ ./
4434
+ COPY --from=pruner /app/tsconfig.* ./
4435
+ RUN ${turboCmd} run build --filter=${turboPackage}
4436
+
4437
+ FROM ${baseImage} AS runner
4438
+ WORKDIR /app
4439
+ RUN apk add --no-cache tini
4440
+ RUN addgroup --system --gid 1001 nodejs && \\
4441
+ adduser --system --uid 1001 app
4442
+
4443
+ # Copy the whole built workspace — frameworks differ on output paths
4444
+ COPY --from=builder --chown=app:nodejs /app/ ./
4445
+
4446
+ ENV NODE_ENV=production
4447
+ ENV PORT=${port}
4448
+ ENV HOSTNAME="0.0.0.0"
4449
+
4450
+ USER app
4451
+ EXPOSE ${port}
4452
+
4453
+ ENTRYPOINT ["/sbin/tini", "--"]
4454
+ CMD ["sh", "-c", "cd ${appPath} && ${startCmd}"]
4455
+ `;
4456
+ }
4457
+ /**
4458
+ * Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
4459
+ * turbo, then serves it via nginx.
4460
+ * @internal Exported for testing
4461
+ */
4462
+ function generateViteStaticDockerfile(options) {
4463
+ const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
4464
+ const pm = getPmConfig(packageManager);
4465
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
4466
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
4467
+ const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
4468
+ const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
4469
+ const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
4470
+ return `# syntax=docker/dockerfile:1
4471
+ # Vite SPA Dockerfile — builds static assets, serves via nginx
4472
+
4473
+ FROM ${baseImage} AS pruner
4474
+ WORKDIR /app
4475
+ ${installPm}
4476
+ COPY . .
4477
+ RUN ${turboCmd} prune ${turboPackage} --docker
4478
+
4479
+ FROM ${baseImage} AS deps
4480
+ WORKDIR /app
4481
+ ${installPm}
4482
+ COPY --from=pruner /app/out/${pm.lockfile} ./
4483
+ COPY --from=pruner /app/out/json/ ./
4484
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4485
+ ${turboInstallCmd}
4486
+
4487
+ FROM deps AS builder
4488
+ WORKDIR /app
4489
+ ${argDecls}
4490
+ ${envDecls}
4491
+ COPY --from=pruner /app/out/full/ ./
4492
+ COPY --from=pruner /app/tsconfig.* ./
4493
+ RUN ${turboCmd} run build --filter=${turboPackage}
4494
+
4495
+ FROM nginx:alpine AS runner
4496
+ COPY --from=builder /app/${appPath}/dist /usr/share/nginx/html
4497
+ 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' \\
4498
+ > /etc/nginx/conf.d/default.conf
4499
+ EXPOSE ${port}
4500
+ CMD ["nginx", "-g", "daemon off;"]
4501
+ `;
4502
+ }
4298
4503
 
4299
4504
  //#endregion
4300
4505
  //#region src/docker/index.ts
@@ -4480,6 +4685,10 @@ async function workspaceDockerCommand(workspace, options) {
4480
4685
  const packageManager = detectPackageManager$1(workspace.root);
4481
4686
  logger$7.log(` Package manager: ${packageManager}`);
4482
4687
  for (const [appName, app] of apps) {
4688
+ if (app.type === "mobile") {
4689
+ logger$7.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
4690
+ continue;
4691
+ }
4483
4692
  const appPath = app.path;
4484
4693
  const fullAppPath = join(workspace.root, appPath);
4485
4694
  const turboPackage = getAppPackageName(fullAppPath) ?? appName;
@@ -4488,15 +4697,28 @@ async function workspaceDockerCommand(workspace, options) {
4488
4697
  const buildType = hasEntry ? "entry" : app.type;
4489
4698
  logger$7.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
4490
4699
  let dockerfile;
4491
- if (app.type === "frontend") dockerfile = generateNextjsDockerfile({
4492
- imageName,
4493
- baseImage: "node:22-alpine",
4494
- port: app.port,
4495
- appPath,
4496
- turboPackage,
4497
- packageManager
4498
- });
4499
- else if (app.entry) dockerfile = generateEntryDockerfile({
4700
+ if (app.type === "web") {
4701
+ const publicUrlArgs = getPublicUrlArgNames(app);
4702
+ const webOpts = {
4703
+ imageName,
4704
+ baseImage: "node:22-alpine",
4705
+ port: app.port,
4706
+ appPath,
4707
+ turboPackage,
4708
+ packageManager,
4709
+ publicUrlArgs
4710
+ };
4711
+ switch (app.framework) {
4712
+ case "vite":
4713
+ dockerfile = generateViteStaticDockerfile(webOpts);
4714
+ break;
4715
+ case "tanstack-start":
4716
+ case "remix":
4717
+ dockerfile = generateNodeWebDockerfile(webOpts);
4718
+ break;
4719
+ default: dockerfile = generateNextjsDockerfile(webOpts);
4720
+ }
4721
+ } else if (app.entry) dockerfile = generateEntryDockerfile({
4500
4722
  imageName,
4501
4723
  baseImage: "node:22-alpine",
4502
4724
  port: app.port,
@@ -4536,7 +4758,7 @@ async function workspaceDockerCommand(workspace, options) {
4536
4758
  logger$7.log(`\n✅ Generated ${results.length} Dockerfile(s) + docker-compose.yml`);
4537
4759
  logger$7.log("\n📋 Build commands:");
4538
4760
  for (const result of results) {
4539
- const icon = result.type === "backend" ? "⚙️" : "🌐";
4761
+ const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
4540
4762
  logger$7.log(` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`);
4541
4763
  }
4542
4764
  logger$7.log("\n📋 Run all services:");
@@ -4773,54 +4995,6 @@ async function deployDokploy(options) {
4773
4995
  };
4774
4996
  }
4775
4997
 
4776
- //#endregion
4777
- //#region src/deploy/domain.ts
4778
- /**
4779
- * Resolve the hostname for an app based on stage configuration.
4780
- *
4781
- * Domain resolution priority:
4782
- * 1. Explicit app.domain override (string or stage-specific)
4783
- * 2. Default pattern based on app type:
4784
- * - Main frontend app gets base domain (e.g., 'myapp.com')
4785
- * - Other apps get prefixed domain (e.g., 'api.myapp.com')
4786
- *
4787
- * @param appName - The name of the app
4788
- * @param app - The normalized app configuration
4789
- * @param stage - The deployment stage (e.g., 'production', 'development')
4790
- * @param dokployConfig - Dokploy workspace configuration with domain mappings
4791
- * @param isMainFrontend - Whether this is the main frontend app
4792
- * @returns The resolved hostname for the app
4793
- * @throws Error if no domain configuration is found for the stage
4794
- */
4795
- function resolveHost(appName, app, stage, dokployConfig, isMainFrontend) {
4796
- if (app.domain) {
4797
- if (typeof app.domain === "string") return app.domain;
4798
- if (app.domain[stage]) return app.domain[stage];
4799
- }
4800
- const baseDomain = dokployConfig?.domains?.[stage];
4801
- if (!baseDomain) throw new Error(`No domain configured for stage "${stage}". Add deploy.dokploy.domains.${stage} to gkm.config.ts`);
4802
- if (isMainFrontend) return baseDomain;
4803
- return `${appName}.${baseDomain}`;
4804
- }
4805
- /**
4806
- * Determine if an app is the "main" frontend (gets base domain).
4807
- *
4808
- * An app is considered the main frontend if:
4809
- * 1. It's named 'web' and is a frontend type
4810
- * 2. It's the first frontend app in the apps list
4811
- *
4812
- * @param appName - The name of the app to check
4813
- * @param app - The app configuration
4814
- * @param allApps - All apps in the workspace
4815
- * @returns True if this is the main frontend app
4816
- */
4817
- function isMainFrontendApp(appName, app, allApps) {
4818
- if (app.type !== "frontend") return false;
4819
- if (appName === "web") return true;
4820
- for (const [name$1, a] of Object.entries(allApps)) if (a.type === "frontend") return name$1 === appName;
4821
- return false;
4822
- }
4823
-
4824
4998
  //#endregion
4825
4999
  //#region src/deploy/env-resolver.ts
4826
5000
  /**
@@ -4881,9 +5055,8 @@ function resolveEnvVar(varName, context) {
4881
5055
  break;
4882
5056
  }
4883
5057
  if (context.dependencyUrls && varName.endsWith("_URL")) {
4884
- let depName;
4885
- if (varName.startsWith("NEXT_PUBLIC_")) depName = varName.slice(12, -4).toLowerCase();
4886
- else depName = varName.slice(0, -4).toLowerCase();
5058
+ const stripped = stripPublicPrefix(varName) ?? varName;
5059
+ const depName = stripped.slice(0, -4).toLowerCase();
4887
5060
  if (context.dependencyUrls[depName]) return context.dependencyUrls[depName];
4888
5061
  }
4889
5062
  if (context.userSecrets) {
@@ -5295,8 +5468,9 @@ function resolveSnifferFile(baseName) {
5295
5468
  */
5296
5469
  async function sniffAppEnvironment(app, appName, workspacePath, options = {}) {
5297
5470
  const { logWarnings = true, markOptional = false } = options;
5298
- if (app.type === "frontend") {
5299
- const depVars = (app.dependencies ?? []).map((dep) => `NEXT_PUBLIC_${dep.toUpperCase()}_URL`);
5471
+ if (app.type === "web" || app.type === "mobile") {
5472
+ const publicPrefix = getPublicEnvPrefix(app.framework);
5473
+ const depVars = publicPrefix ? (app.dependencies ?? []).map((dep) => `${publicPrefix}${dep.toUpperCase()}_URL`) : [];
5300
5474
  if (app.config) {
5301
5475
  const sniffedVars = [];
5302
5476
  const sniffedOptional = [];
@@ -6221,8 +6395,10 @@ async function workspaceDeployCommand(workspace, options) {
6221
6395
  }
6222
6396
  }
6223
6397
  }
6398
+ const skippedMobileApps = appsToDeployNames.filter((name$1) => workspace.apps[name$1].type === "mobile");
6399
+ if (skippedMobileApps.length > 0) logger$3.log(`\n📱 Skipping ${skippedMobileApps.length} mobile app(s) — deploy via framework toolchain: ${skippedMobileApps.join(", ")}`);
6224
6400
  const backendApps = appsToDeployNames.filter((name$1) => workspace.apps[name$1].type === "backend");
6225
- const frontendApps = appsToDeployNames.filter((name$1) => workspace.apps[name$1].type === "frontend");
6401
+ const frontendApps = appsToDeployNames.filter((name$1) => workspace.apps[name$1].type === "web");
6226
6402
  const perAppDbCredentials = /* @__PURE__ */ new Map();
6227
6403
  if (provisionedPostgres && backendApps.length > 0) {
6228
6404
  const appsNeedingDb = backendApps.filter((appName) => {
@@ -6473,11 +6649,14 @@ async function workspaceDeployCommand(workspace, options) {
6473
6649
  const { valid, missing, resolved } = validateEnvVars(sniffedVars, envContext);
6474
6650
  if (!valid) throw new Error(formatMissingVarsError(appName, missing, stage));
6475
6651
  if (Object.keys(resolved).length > 0) logger$3.log(` Resolved ${Object.keys(resolved).length} env vars: ${Object.keys(resolved).join(", ")}`);
6652
+ const publicPrefix = getPublicEnvPrefix(app.framework);
6476
6653
  const buildArgs = [];
6477
6654
  const publicUrlArgNames = [];
6478
- for (const [key, value] of Object.entries(resolved)) if (key.startsWith("NEXT_PUBLIC_")) {
6479
- buildArgs.push(`${key}=${value}`);
6480
- publicUrlArgNames.push(key);
6655
+ if (publicPrefix) {
6656
+ for (const [key, value] of Object.entries(resolved)) if (key.startsWith(publicPrefix)) {
6657
+ buildArgs.push(`${key}=${value}`);
6658
+ publicUrlArgNames.push(key);
6659
+ }
6481
6660
  }
6482
6661
  if (buildArgs.length > 0) logger$3.log(` Build args: ${publicUrlArgNames.join(", ")}`);
6483
6662
  const imageName = `${workspace.name}-${appName}`;
@@ -6914,9 +7093,9 @@ const GEEKMIDAS_VERSIONS = {
6914
7093
  "@geekmidas/constructs": "~3.0.9",
6915
7094
  "@geekmidas/db": "~1.0.1",
6916
7095
  "@geekmidas/emailkit": "~1.0.0",
6917
- "@geekmidas/envkit": "~1.0.5",
7096
+ "@geekmidas/envkit": "~1.0.6",
6918
7097
  "@geekmidas/errors": "~1.0.0",
6919
- "@geekmidas/events": "~1.1.0",
7098
+ "@geekmidas/events": "~1.1.1",
6920
7099
  "@geekmidas/logger": "~1.0.1",
6921
7100
  "@geekmidas/rate-limit": "~2.0.0",
6922
7101
  "@geekmidas/schema": "~1.0.0",
@@ -7723,48 +7902,646 @@ coverage/
7723
7902
  }
7724
7903
 
7725
7904
  //#endregion
7726
- //#region src/init/generators/models.ts
7905
+ //#region src/init/generators/mobile-expo.ts
7727
7906
  /**
7728
- * Generate packages/models for shared Zod schemas (monorepo only)
7907
+ * Generate an Expo mobile app for the fullstack template.
7908
+ *
7909
+ * Mirrors the structure of rezgo/apps/app: NativeWind for styling,
7910
+ * expo-router for navigation, better-auth + magic link, React Query
7911
+ * for data, all wired into the toolbox's typed API client.
7729
7912
  */
7730
- function generateModelsPackage(options) {
7731
- if (!options.monorepo) return [];
7732
- const packageName = `@${options.name}/models`;
7913
+ function generateExpoAppFiles(options) {
7914
+ if (!options.monorepo || options.template !== "fullstack") return [];
7915
+ const packageName = `@${options.name}/app`;
7916
+ const apiPackage = `@${options.name}/api`;
7917
+ const modelsPackage = `@${options.name}/models`;
7918
+ const slug = options.name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
7919
+ const scheme = slug.replace(/-/g, "");
7920
+ const bundleId = `com.${scheme}.app`;
7733
7921
  const packageJson = {
7734
7922
  name: packageName,
7735
7923
  version: "0.0.1",
7924
+ main: "expo-router/entry",
7736
7925
  private: true,
7737
- type: "module",
7738
- exports: { "./*": "./src/*.ts" },
7739
- scripts: { typecheck: "tsc --noEmit" },
7740
- dependencies: {},
7741
- devDependencies: { typescript: "~5.8.2" }
7742
- };
7743
- const tsConfig = {
7744
- extends: "../../tsconfig.json",
7745
- compilerOptions: {
7746
- noEmit: true,
7747
- allowImportingTsExtensions: true
7926
+ scripts: {
7927
+ dev: "gkm exec -- expo start -c",
7928
+ ios: "expo start --ios",
7929
+ android: "expo start --android",
7930
+ web: "expo start --web",
7931
+ typecheck: "tsc --noEmit"
7748
7932
  },
7749
- include: ["src/**/*.ts"],
7750
- exclude: ["node_modules", "dist"]
7933
+ dependencies: {
7934
+ [apiPackage]: "workspace:*",
7935
+ [modelsPackage]: "workspace:*",
7936
+ "@better-auth/expo": "~1.2.0",
7937
+ "@geekmidas/client": GEEKMIDAS_VERSIONS["@geekmidas/client"],
7938
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
7939
+ "@react-navigation/native": "^7.1.0",
7940
+ "@tanstack/react-query": "~5.80.0",
7941
+ "better-auth": "~1.2.0",
7942
+ expo: "~55.0.0",
7943
+ "expo-constants": "~55.0.0",
7944
+ "expo-dev-client": "~55.0.0",
7945
+ "expo-linking": "~55.0.0",
7946
+ "expo-router": "~55.0.0",
7947
+ "expo-secure-store": "~14.2.0",
7948
+ "expo-splash-screen": "~55.0.0",
7949
+ "expo-status-bar": "~55.0.0",
7950
+ nativewind: "~4.2.0",
7951
+ react: "19.2.0",
7952
+ "react-dom": "19.2.0",
7953
+ "react-native": "0.83.6",
7954
+ "react-native-gesture-handler": "~2.30.0",
7955
+ "react-native-reanimated": "~4.2.0",
7956
+ "react-native-safe-area-context": "~5.6.0",
7957
+ "react-native-screens": "~4.23.0",
7958
+ "react-native-web": "~0.21.0",
7959
+ tailwindcss: "~3.4.0"
7960
+ },
7961
+ devDependencies: {
7962
+ "@babel/core": "^7.25.0",
7963
+ "@types/react": "~19.0.0",
7964
+ typescript: "~5.8.2"
7965
+ }
7751
7966
  };
7752
- const commonTs = `import { z } from 'zod';
7753
-
7754
- // ============================================
7755
- // Common Schemas
7756
- // ============================================
7757
-
7758
- export const IdSchema = z.uuid();
7967
+ const appConfig = `import type { ConfigContext, ExpoConfig } from '@expo/config';
7759
7968
 
7760
- export const IdParamsSchema = z.object({
7761
- id: IdSchema,
7762
- });
7763
-
7764
- export const TimestampsSchema = z.object({
7765
- createdAt: z.coerce.date(),
7766
- updatedAt: z.coerce.date(),
7767
- });
7969
+ /**
7970
+ * Expo runtime config. Public env vars (\`EXPO_PUBLIC_*\`) are inlined
7971
+ * at build time; everything else is read at runtime from \`extra\`.
7972
+ */
7973
+ export default function config(_context: ConfigContext): ExpoConfig {
7974
+ return {
7975
+ name: '${options.name}',
7976
+ slug: '${slug}',
7977
+ version: '0.0.1',
7978
+ orientation: 'portrait',
7979
+ icon: './assets/icon.png',
7980
+ scheme: '${scheme}',
7981
+ userInterfaceStyle: 'automatic',
7982
+ newArchEnabled: true,
7983
+ ios: {
7984
+ supportsTablet: true,
7985
+ bundleIdentifier: '${bundleId}',
7986
+ },
7987
+ android: {
7988
+ package: '${bundleId}',
7989
+ adaptiveIcon: {
7990
+ foregroundImage: './assets/adaptive-icon.png',
7991
+ backgroundColor: '#ffffff',
7992
+ },
7993
+ edgeToEdgeEnabled: true,
7994
+ },
7995
+ web: {
7996
+ output: 'static',
7997
+ },
7998
+ plugins: [
7999
+ 'expo-router',
8000
+ 'expo-secure-store',
8001
+ [
8002
+ 'expo-splash-screen',
8003
+ {
8004
+ image: './assets/splash.png',
8005
+ imageWidth: 200,
8006
+ resizeMode: 'contain',
8007
+ backgroundColor: '#ffffff',
8008
+ },
8009
+ ],
8010
+ ],
8011
+ experiments: {
8012
+ typedRoutes: true,
8013
+ },
8014
+ };
8015
+ }
8016
+ `;
8017
+ const easJson = {
8018
+ cli: {
8019
+ version: ">= 10.2.1",
8020
+ appVersionSource: "remote"
8021
+ },
8022
+ build: {
8023
+ dev: {
8024
+ developmentClient: true,
8025
+ distribution: "internal",
8026
+ environment: "development",
8027
+ env: {
8028
+ EXPO_PUBLIC_API_URL: "http://localhost:3000",
8029
+ EXPO_PUBLIC_AUTH_URL: "http://localhost:3002"
8030
+ }
8031
+ },
8032
+ preview: {
8033
+ distribution: "internal",
8034
+ environment: "preview",
8035
+ env: {
8036
+ EXPO_PUBLIC_API_URL: "https://api.example.com",
8037
+ EXPO_PUBLIC_AUTH_URL: "https://auth.example.com"
8038
+ }
8039
+ },
8040
+ production: {
8041
+ autoIncrement: true,
8042
+ environment: "production",
8043
+ env: {
8044
+ EXPO_PUBLIC_API_URL: "https://api.example.com",
8045
+ EXPO_PUBLIC_AUTH_URL: "https://auth.example.com"
8046
+ }
8047
+ }
8048
+ }
8049
+ };
8050
+ const babelConfig = `module.exports = function (api) {
8051
+ api.cache(true);
8052
+ return {
8053
+ presets: [
8054
+ ['babel-preset-expo', { jsxImportSource: 'nativewind' }],
8055
+ 'nativewind/babel',
8056
+ ],
8057
+ };
8058
+ };
8059
+ `;
8060
+ const metroConfig = `const { getDefaultConfig } = require('expo/metro-config');
8061
+ const { withNativeWind } = require('nativewind/metro');
8062
+
8063
+ const config = getDefaultConfig(__dirname);
8064
+
8065
+ module.exports = withNativeWind(config, { input: './global.css' });
8066
+ `;
8067
+ const tsConfig = {
8068
+ extends: "expo/tsconfig.base",
8069
+ compilerOptions: {
8070
+ strict: true,
8071
+ allowImportingTsExtensions: true,
8072
+ types: ["nativewind/types"],
8073
+ paths: {
8074
+ "@/*": ["./*"],
8075
+ [modelsPackage]: ["../../packages/models/src"],
8076
+ [`${modelsPackage}/*`]: ["../../packages/models/src/*"],
8077
+ [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi.ts"]
8078
+ }
8079
+ },
8080
+ include: [
8081
+ "**/*.ts",
8082
+ "**/*.tsx",
8083
+ ".expo/types/**/*.ts",
8084
+ "expo-env.d.ts",
8085
+ "nativewind-env.d.ts"
8086
+ ]
8087
+ };
8088
+ const tailwindConfig = `import type { Config } from 'tailwindcss';
8089
+
8090
+ export default {
8091
+ content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
8092
+ presets: [require('nativewind/preset')],
8093
+ theme: {
8094
+ extend: {},
8095
+ },
8096
+ plugins: [],
8097
+ } satisfies Config;
8098
+ `;
8099
+ const globalCss = `@tailwind base;
8100
+ @tailwind components;
8101
+ @tailwind utilities;
8102
+ `;
8103
+ const expoEnvDts = `/// <reference types="expo/types" />\n`;
8104
+ const nativewindEnvDts = `/// <reference types="nativewind/types" />\n`;
8105
+ const configTs = `import { EnvironmentParser } from '@geekmidas/envkit';
8106
+
8107
+ /**
8108
+ * Public app config. EXPO_PUBLIC_* vars are inlined into the bundle at
8109
+ * build time, so they're safe to read in client code.
8110
+ */
8111
+ const envParser = new EnvironmentParser({
8112
+ EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL,
8113
+ EXPO_PUBLIC_AUTH_URL: process.env.EXPO_PUBLIC_AUTH_URL,
8114
+ });
8115
+
8116
+ export const config = envParser
8117
+ .create((get) => ({
8118
+ apiUrl: get('EXPO_PUBLIC_API_URL').string(),
8119
+ authUrl: get('EXPO_PUBLIC_AUTH_URL').string(),
8120
+ }))
8121
+ .parse();
8122
+ `;
8123
+ const queryClientTs = `import { QueryClient } from '@tanstack/react-query';
8124
+
8125
+ export const queryClient = new QueryClient({
8126
+ defaultOptions: {
8127
+ queries: {
8128
+ staleTime: 60 * 1000,
8129
+ },
8130
+ },
8131
+ });
8132
+ `;
8133
+ const authClientTs = `import { expoClient } from '@better-auth/expo/client';
8134
+ import { magicLinkClient } from 'better-auth/client/plugins';
8135
+ import { createAuthClient } from 'better-auth/react';
8136
+ import * as SecureStore from 'expo-secure-store';
8137
+
8138
+ import { config } from '@/config.ts';
8139
+
8140
+ export const STORAGE_PREFIX = '${scheme}';
8141
+ export const COOKIE_STORE_KEY = \`\${STORAGE_PREFIX}_cookie\`;
8142
+
8143
+ export const authClient = createAuthClient({
8144
+ baseURL: config.authUrl,
8145
+ plugins: [
8146
+ expoClient({
8147
+ scheme: '${scheme}',
8148
+ storagePrefix: STORAGE_PREFIX,
8149
+ storage: SecureStore,
8150
+ }),
8151
+ magicLinkClient(),
8152
+ ],
8153
+ });
8154
+
8155
+ export const { signIn, useSession } = authClient;
8156
+
8157
+ export async function signOut() {
8158
+ try {
8159
+ await authClient.signOut();
8160
+ } finally {
8161
+ await SecureStore.deleteItemAsync(COOKIE_STORE_KEY);
8162
+ }
8163
+ }
8164
+ `;
8165
+ const apiTs = `import { createApi } from '${apiPackage}/client';
8166
+
8167
+ import { config } from '@/config.ts';
8168
+ import { authClient } from './auth-client.ts';
8169
+ import { queryClient } from './query-client.ts';
8170
+
8171
+ export function createAppApi(options?: { headers?: Record<string, string> }) {
8172
+ return createApi({
8173
+ baseURL: config.apiUrl,
8174
+ queryClient,
8175
+ headers: options?.headers,
8176
+ onRequest: (cfg) => {
8177
+ const cookie = authClient.getCookie();
8178
+ const next = { ...cfg, credentials: 'omit' as const };
8179
+ if (cookie) {
8180
+ return {
8181
+ ...next,
8182
+ headers: { ...(next.headers ?? {}), Cookie: cookie },
8183
+ };
8184
+ }
8185
+ return next;
8186
+ },
8187
+ });
8188
+ }
8189
+ `;
8190
+ const apiContextTsx = `import { createContext, type ReactNode, use } from 'react';
8191
+ import type { createAppApi } from './api.ts';
8192
+
8193
+ export type Api = ReturnType<typeof createAppApi>;
8194
+
8195
+ const ApiContext = createContext<Api | null>(null);
8196
+
8197
+ export function ApiProvider({
8198
+ api,
8199
+ children,
8200
+ }: {
8201
+ api: Api;
8202
+ children: ReactNode;
8203
+ }) {
8204
+ return <ApiContext.Provider value={api}>{children}</ApiContext.Provider>;
8205
+ }
8206
+
8207
+ export function useApi(): Api {
8208
+ const api = use(ApiContext);
8209
+ if (!api) {
8210
+ throw new Error('useApi must be used within an ApiProvider');
8211
+ }
8212
+ return api;
8213
+ }
8214
+ `;
8215
+ const layoutTsx = `import '../global.css';
8216
+
8217
+ import { QueryClientProvider } from '@tanstack/react-query';
8218
+ import { Stack, useRouter, useSegments } from 'expo-router';
8219
+ import { StatusBar } from 'expo-status-bar';
8220
+ import { useEffect, useMemo } from 'react';
8221
+ import 'react-native-reanimated';
8222
+
8223
+ import { authClient } from '@/lib/auth-client.ts';
8224
+ import { ApiProvider } from '@/lib/api-context.tsx';
8225
+ import { createAppApi } from '@/lib/api.ts';
8226
+ import { queryClient } from '@/lib/query-client.ts';
8227
+
8228
+ function AuthGate({ children }: { children: React.ReactNode }) {
8229
+ const router = useRouter();
8230
+ const segments = useSegments();
8231
+ const { data: session, isPending } = authClient.useSession();
8232
+
8233
+ useEffect(() => {
8234
+ if (isPending) return;
8235
+ const onAuthRoute = segments[0] === 'login';
8236
+ if (!session && !onAuthRoute) {
8237
+ router.replace('/login');
8238
+ } else if (session && onAuthRoute) {
8239
+ router.replace('/');
8240
+ }
8241
+ }, [isPending, session, segments, router]);
8242
+
8243
+ return <>{children}</>;
8244
+ }
8245
+
8246
+ export default function RootLayout() {
8247
+ const api = useMemo(() => createAppApi(), []);
8248
+
8249
+ return (
8250
+ <QueryClientProvider client={queryClient}>
8251
+ <ApiProvider api={api}>
8252
+ <AuthGate>
8253
+ <Stack screenOptions={{ headerShown: false }}>
8254
+ <Stack.Screen name="index" />
8255
+ <Stack.Screen name="login" />
8256
+ </Stack>
8257
+ </AuthGate>
8258
+ <StatusBar style="auto" />
8259
+ </ApiProvider>
8260
+ </QueryClientProvider>
8261
+ );
8262
+ }
8263
+ `;
8264
+ const indexTsx = `import { Pressable, Text, View } from 'react-native';
8265
+ import { SafeAreaView } from 'react-native-safe-area-context';
8266
+
8267
+ import { signOut, useSession } from '@/lib/auth-client.ts';
8268
+ import { useApi } from '@/lib/api-context.tsx';
8269
+
8270
+ export default function Home() {
8271
+ const { data: session } = useSession();
8272
+ const api = useApi();
8273
+ const { data: health } = api.useQuery('GET /health', {});
8274
+
8275
+ return (
8276
+ <SafeAreaView className="flex-1 bg-white">
8277
+ <View className="flex-1 items-center justify-center gap-4 px-6">
8278
+ <Text className="text-3xl font-bold text-slate-900">
8279
+ Welcome to ${options.name}
8280
+ </Text>
8281
+ {session ? (
8282
+ <Text className="text-sm text-slate-500">
8283
+ Signed in as {session.user.email}
8284
+ </Text>
8285
+ ) : null}
8286
+ {health ? (
8287
+ <Text className="text-xs text-slate-400">
8288
+ API: {JSON.stringify(health)}
8289
+ </Text>
8290
+ ) : null}
8291
+ <Pressable
8292
+ onPress={signOut}
8293
+ className="mt-4 rounded-lg bg-slate-900 px-6 py-3"
8294
+ >
8295
+ <Text className="font-semibold text-white">Sign out</Text>
8296
+ </Pressable>
8297
+ </View>
8298
+ </SafeAreaView>
8299
+ );
8300
+ }
8301
+ `;
8302
+ const loginTsx = `import * as Linking from 'expo-linking';
8303
+ import { useState } from 'react';
8304
+ import {
8305
+ KeyboardAvoidingView,
8306
+ Platform,
8307
+ Pressable,
8308
+ Text,
8309
+ TextInput,
8310
+ View,
8311
+ } from 'react-native';
8312
+ import { SafeAreaView } from 'react-native-safe-area-context';
8313
+
8314
+ import { authClient } from '@/lib/auth-client.ts';
8315
+
8316
+ const EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
8317
+
8318
+ export default function LoginScreen() {
8319
+ const [email, setEmail] = useState('');
8320
+ const [submitting, setSubmitting] = useState(false);
8321
+ const [sent, setSent] = useState(false);
8322
+ const [error, setError] = useState<string | null>(null);
8323
+
8324
+ async function handleSubmit() {
8325
+ if (!EMAIL_RE.test(email.trim())) {
8326
+ setError('Enter a valid email address');
8327
+ return;
8328
+ }
8329
+ setError(null);
8330
+ setSubmitting(true);
8331
+
8332
+ const callbackURL = Linking.createURL('/').replace(/^(\\w+):\\/\\/\\//, '$1://');
8333
+ try {
8334
+ const result = await authClient.signIn.magicLink({
8335
+ email: email.trim(),
8336
+ callbackURL,
8337
+ });
8338
+ if (result.error) {
8339
+ setError(result.error.message ?? 'Could not send magic link');
8340
+ return;
8341
+ }
8342
+ setSent(true);
8343
+ } catch (err) {
8344
+ setError(err instanceof Error ? err.message : 'Something went wrong');
8345
+ } finally {
8346
+ setSubmitting(false);
8347
+ }
8348
+ }
8349
+
8350
+ if (sent) {
8351
+ return (
8352
+ <SafeAreaView className="flex-1 bg-white">
8353
+ <View className="flex-1 items-center justify-center px-6">
8354
+ <Text className="text-2xl font-bold text-slate-900">
8355
+ Check your inbox
8356
+ </Text>
8357
+ <Text className="mt-3 text-center text-slate-500">
8358
+ We sent a sign-in link to {email.trim()}
8359
+ </Text>
8360
+ </View>
8361
+ </SafeAreaView>
8362
+ );
8363
+ }
8364
+
8365
+ return (
8366
+ <SafeAreaView className="flex-1 bg-white">
8367
+ <KeyboardAvoidingView
8368
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
8369
+ className="flex-1"
8370
+ >
8371
+ <View className="flex-1 justify-center px-6">
8372
+ <Text className="text-3xl font-bold text-slate-900">Sign in</Text>
8373
+ <Text className="mt-1 text-sm text-slate-500">
8374
+ We'll email you a one-time sign-in link.
8375
+ </Text>
8376
+
8377
+ <Text className="mt-6 text-xs font-semibold uppercase text-slate-500">
8378
+ Email
8379
+ </Text>
8380
+ <TextInput
8381
+ value={email}
8382
+ onChangeText={(v) => {
8383
+ setEmail(v);
8384
+ if (error) setError(null);
8385
+ }}
8386
+ autoCapitalize="none"
8387
+ autoCorrect={false}
8388
+ keyboardType="email-address"
8389
+ placeholder="you@example.com"
8390
+ placeholderTextColor="#94a3b8"
8391
+ editable={!submitting}
8392
+ className="mt-2 rounded-lg border border-slate-300 px-4 py-3"
8393
+ />
8394
+ {error ? (
8395
+ <Text className="mt-2 text-sm text-red-600">{error}</Text>
8396
+ ) : null}
8397
+
8398
+ <Pressable
8399
+ onPress={handleSubmit}
8400
+ disabled={submitting}
8401
+ className="mt-6 items-center rounded-lg bg-slate-900 py-4 disabled:opacity-60"
8402
+ >
8403
+ <Text className="font-semibold text-white">
8404
+ {submitting ? 'Sending…' : 'Email me a sign-in link'}
8405
+ </Text>
8406
+ </Pressable>
8407
+ </View>
8408
+ </KeyboardAvoidingView>
8409
+ </SafeAreaView>
8410
+ );
8411
+ }
8412
+ `;
8413
+ const gitignore = `node_modules/
8414
+ .expo/
8415
+ dist/
8416
+ *.log
8417
+ .env*.local
8418
+ ios/
8419
+ android/
8420
+ .eas/
8421
+ `;
8422
+ return [
8423
+ {
8424
+ path: "apps/app/package.json",
8425
+ content: `${JSON.stringify(packageJson, null, 2)}\n`
8426
+ },
8427
+ {
8428
+ path: "apps/app/app.config.ts",
8429
+ content: appConfig
8430
+ },
8431
+ {
8432
+ path: "apps/app/eas.json",
8433
+ content: `${JSON.stringify(easJson, null, 2)}\n`
8434
+ },
8435
+ {
8436
+ path: "apps/app/babel.config.js",
8437
+ content: babelConfig
8438
+ },
8439
+ {
8440
+ path: "apps/app/metro.config.js",
8441
+ content: metroConfig
8442
+ },
8443
+ {
8444
+ path: "apps/app/tsconfig.json",
8445
+ content: `${JSON.stringify(tsConfig, null, 2)}\n`
8446
+ },
8447
+ {
8448
+ path: "apps/app/tailwind.config.ts",
8449
+ content: tailwindConfig
8450
+ },
8451
+ {
8452
+ path: "apps/app/global.css",
8453
+ content: globalCss
8454
+ },
8455
+ {
8456
+ path: "apps/app/expo-env.d.ts",
8457
+ content: expoEnvDts
8458
+ },
8459
+ {
8460
+ path: "apps/app/nativewind-env.d.ts",
8461
+ content: nativewindEnvDts
8462
+ },
8463
+ {
8464
+ path: "apps/app/config.ts",
8465
+ content: configTs
8466
+ },
8467
+ {
8468
+ path: "apps/app/lib/query-client.ts",
8469
+ content: queryClientTs
8470
+ },
8471
+ {
8472
+ path: "apps/app/lib/auth-client.ts",
8473
+ content: authClientTs
8474
+ },
8475
+ {
8476
+ path: "apps/app/lib/api.ts",
8477
+ content: apiTs
8478
+ },
8479
+ {
8480
+ path: "apps/app/lib/api-context.tsx",
8481
+ content: apiContextTsx
8482
+ },
8483
+ {
8484
+ path: "apps/app/app/_layout.tsx",
8485
+ content: layoutTsx
8486
+ },
8487
+ {
8488
+ path: "apps/app/app/index.tsx",
8489
+ content: indexTsx
8490
+ },
8491
+ {
8492
+ path: "apps/app/app/login.tsx",
8493
+ content: loginTsx
8494
+ },
8495
+ {
8496
+ path: "apps/app/.gitignore",
8497
+ content: gitignore
8498
+ }
8499
+ ];
8500
+ }
8501
+
8502
+ //#endregion
8503
+ //#region src/init/generators/models.ts
8504
+ /**
8505
+ * Generate packages/models for shared Zod schemas (monorepo only)
8506
+ */
8507
+ function generateModelsPackage(options) {
8508
+ if (!options.monorepo) return [];
8509
+ const packageName = `@${options.name}/models`;
8510
+ const packageJson = {
8511
+ name: packageName,
8512
+ version: "0.0.1",
8513
+ private: true,
8514
+ type: "module",
8515
+ exports: { "./*": "./src/*.ts" },
8516
+ scripts: { typecheck: "tsc --noEmit" },
8517
+ dependencies: {},
8518
+ devDependencies: { typescript: "~5.8.2" }
8519
+ };
8520
+ const tsConfig = {
8521
+ extends: "../../tsconfig.json",
8522
+ compilerOptions: {
8523
+ noEmit: true,
8524
+ allowImportingTsExtensions: true
8525
+ },
8526
+ include: ["src/**/*.ts"],
8527
+ exclude: ["node_modules", "dist"]
8528
+ };
8529
+ const commonTs = `import { z } from 'zod';
8530
+
8531
+ // ============================================
8532
+ // Common Schemas
8533
+ // ============================================
8534
+
8535
+ export const IdSchema = z.uuid();
8536
+
8537
+ export const IdParamsSchema = z.object({
8538
+ id: IdSchema,
8539
+ });
8540
+
8541
+ export const TimestampsSchema = z.object({
8542
+ createdAt: z.coerce.date(),
8543
+ updatedAt: z.coerce.date(),
8544
+ });
7768
8545
 
7769
8546
  export const PaginationSchema = z.object({
7770
8547
  page: z.coerce.number().int().positive().default(1),
@@ -8111,6 +8888,43 @@ export default defineConfig({
8111
8888
  return files;
8112
8889
  }
8113
8890
  /**
8891
+ * Generate the frontend app entry for the workspace config based on the
8892
+ * selected `frontendFramework`. Defaults to Next.js for backwards compat.
8893
+ */
8894
+ function generateFrontendAppEntry(options) {
8895
+ switch (options.frontendFramework) {
8896
+ case "tanstack-start": return `web: {
8897
+ type: 'web',
8898
+ framework: 'tanstack-start',
8899
+ path: 'apps/web',
8900
+ port: 3001,
8901
+ dependencies: ['api', 'auth'],
8902
+ config: {
8903
+ client: './src/config/client.ts',
8904
+ server: './src/config/server.ts',
8905
+ },
8906
+ },`;
8907
+ case "expo": return `app: {
8908
+ type: 'mobile',
8909
+ framework: 'expo',
8910
+ path: 'apps/app',
8911
+ port: 8081,
8912
+ dependencies: ['api', 'auth'],
8913
+ },`;
8914
+ default: return `web: {
8915
+ type: 'web',
8916
+ framework: 'nextjs',
8917
+ path: 'apps/web',
8918
+ port: 3001,
8919
+ dependencies: ['api', 'auth'],
8920
+ config: {
8921
+ client: './src/config/client.ts',
8922
+ server: './src/config/server.ts',
8923
+ },
8924
+ },`;
8925
+ }
8926
+ }
8927
+ /**
8114
8928
  * Generate gkm.config.ts with defineWorkspace for fullstack template
8115
8929
  */
8116
8930
  function generateWorkspaceConfig(options) {
@@ -8151,20 +8965,10 @@ export default defineWorkspace({
8151
8965
  port: 3002,
8152
8966
  entry: './src/index.ts',
8153
8967
  framework: 'better-auth',
8154
- envParser: './src/config/env#envParser',
8155
- logger: './src/config/logger#logger',
8156
- },
8157
- web: {
8158
- type: 'frontend',
8159
- framework: 'nextjs',
8160
- path: 'apps/web',
8161
- port: 3001,
8162
- dependencies: ['api', 'auth'],
8163
- config: {
8164
- client: './src/config/client.ts',
8165
- server: './src/config/server.ts',
8166
- },
8968
+ envParser: './src/config/env#envParser',
8969
+ logger: './src/config/logger#logger',
8167
8970
  },
8971
+ ${generateFrontendAppEntry(options)}
8168
8972
  },
8169
8973
  shared: {
8170
8974
  packages: ['packages/*'],
@@ -9067,6 +9871,26 @@ const templateChoices = [{
9067
9871
  description: "Monorepo with API + Next.js + shared models"
9068
9872
  }];
9069
9873
  /**
9874
+ * Frontend framework choices for the fullstack template prompt.
9875
+ */
9876
+ const frontendFrameworkChoices = [
9877
+ {
9878
+ title: "Next.js",
9879
+ value: "nextjs",
9880
+ description: "React server components, app router (default)"
9881
+ },
9882
+ {
9883
+ title: "TanStack Start",
9884
+ value: "tanstack-start",
9885
+ description: "File-based router on Vite + Nitro"
9886
+ },
9887
+ {
9888
+ title: "Expo",
9889
+ value: "expo",
9890
+ description: "React Native mobile app (iOS + Android)"
9891
+ }
9892
+ ];
9893
+ /**
9070
9894
  * Logger type choices for prompts
9071
9895
  */
9072
9896
  const loggerTypeChoices = [{
@@ -11174,6 +11998,382 @@ node_modules/
11174
11998
  ];
11175
11999
  }
11176
12000
 
12001
+ //#endregion
12002
+ //#region src/init/generators/web-tanstack.ts
12003
+ /**
12004
+ * Generate TanStack Start web app files for the fullstack template.
12005
+ *
12006
+ * Mirrors the Next.js scaffold (apps/web) but on Vite + TanStack Start +
12007
+ * TanStack Router with the `VITE_` env-var convention.
12008
+ */
12009
+ function generateTanStackWebFiles(options) {
12010
+ if (!options.monorepo || options.template !== "fullstack") return [];
12011
+ const packageName = `@${options.name}/web`;
12012
+ const apiPackage = `@${options.name}/api`;
12013
+ const modelsPackage = `@${options.name}/models`;
12014
+ const uiPackage = `@${options.name}/ui`;
12015
+ const packageJson = {
12016
+ name: packageName,
12017
+ version: "0.0.1",
12018
+ private: true,
12019
+ type: "module",
12020
+ scripts: {
12021
+ dev: "gkm exec -- vite dev",
12022
+ build: "gkm exec -- vite build",
12023
+ start: "node .output/server/index.mjs",
12024
+ typecheck: "tsc --noEmit"
12025
+ },
12026
+ dependencies: {
12027
+ [apiPackage]: "workspace:*",
12028
+ [modelsPackage]: "workspace:*",
12029
+ [uiPackage]: "workspace:*",
12030
+ "@geekmidas/client": GEEKMIDAS_VERSIONS["@geekmidas/client"],
12031
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
12032
+ "@tanstack/react-query": "~5.80.0",
12033
+ "@tanstack/react-router": "^1.87.0",
12034
+ "@tanstack/react-start": "^1.87.0",
12035
+ "better-auth": "~1.2.0",
12036
+ react: "~19.2.0",
12037
+ "react-dom": "~19.2.0"
12038
+ },
12039
+ devDependencies: {
12040
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
12041
+ "@tailwindcss/vite": "^4.0.0",
12042
+ "@tanstack/router-plugin": "^1.87.0",
12043
+ "@types/node": "~22.0.0",
12044
+ "@types/react": "~19.0.0",
12045
+ "@types/react-dom": "~19.0.0",
12046
+ "@vitejs/plugin-react": "^4.3.4",
12047
+ tailwindcss: "^4.0.0",
12048
+ tsx: "~4.20.0",
12049
+ typescript: "~5.8.2",
12050
+ vite: "^7.0.0",
12051
+ "vite-tsconfig-paths": "~5.1.0"
12052
+ }
12053
+ };
12054
+ const viteConfig = `import { Credentials } from '@geekmidas/envkit/credentials';
12055
+ import tailwindcss from '@tailwindcss/vite';
12056
+ import { tanstackStart } from '@tanstack/react-start/plugin/vite';
12057
+ import viteReact from '@vitejs/plugin-react';
12058
+ import { defineConfig } from 'vite';
12059
+ import tsconfigPaths from 'vite-tsconfig-paths';
12060
+
12061
+ export default defineConfig({
12062
+ server: {
12063
+ port: Number(Credentials.PORT ?? 3001),
12064
+ },
12065
+ // VITE_* vars are inlined automatically; no manual loadEnv needed.
12066
+ envPrefix: 'VITE_',
12067
+ plugins: [
12068
+ tsconfigPaths({ root: import.meta.dirname }),
12069
+ tailwindcss(),
12070
+ tanstackStart(),
12071
+ viteReact(),
12072
+ ],
12073
+ });
12074
+ `;
12075
+ const tsConfig = {
12076
+ extends: "../../tsconfig.json",
12077
+ compilerOptions: {
12078
+ lib: [
12079
+ "dom",
12080
+ "dom.iterable",
12081
+ "ES2022"
12082
+ ],
12083
+ allowJs: true,
12084
+ skipLibCheck: true,
12085
+ strict: true,
12086
+ noEmit: true,
12087
+ allowImportingTsExtensions: true,
12088
+ esModuleInterop: true,
12089
+ module: "ESNext",
12090
+ moduleResolution: "bundler",
12091
+ resolveJsonModule: true,
12092
+ isolatedModules: true,
12093
+ jsx: "preserve",
12094
+ incremental: true,
12095
+ baseUrl: ".",
12096
+ types: ["vite/client"],
12097
+ paths: {
12098
+ "~/*": ["./src/*", "../../packages/ui/src/*"],
12099
+ [modelsPackage]: ["../../packages/models/src"],
12100
+ [`${modelsPackage}/*`]: ["../../packages/models/src/*"],
12101
+ [uiPackage]: ["../../packages/ui/src"],
12102
+ [`${uiPackage}/*`]: ["../../packages/ui/src/*"],
12103
+ [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi.ts"]
12104
+ }
12105
+ },
12106
+ include: [
12107
+ "**/*.ts",
12108
+ "**/*.tsx",
12109
+ "src/routeTree.gen.ts"
12110
+ ],
12111
+ exclude: [
12112
+ "node_modules",
12113
+ ".output",
12114
+ ".vinxi",
12115
+ "dist"
12116
+ ]
12117
+ };
12118
+ const queryClientTs = `import { QueryClient } from '@tanstack/react-query';
12119
+
12120
+ function makeQueryClient() {
12121
+ return new QueryClient({
12122
+ defaultOptions: {
12123
+ queries: {
12124
+ staleTime: 60 * 1000,
12125
+ },
12126
+ },
12127
+ });
12128
+ }
12129
+
12130
+ let browserQueryClient: QueryClient | undefined = undefined;
12131
+
12132
+ export function getQueryClient() {
12133
+ if (typeof window === 'undefined') {
12134
+ return makeQueryClient();
12135
+ }
12136
+ if (!browserQueryClient) browserQueryClient = makeQueryClient();
12137
+ return browserQueryClient;
12138
+ }
12139
+ `;
12140
+ const clientConfigTs = `import { EnvironmentParser } from '@geekmidas/envkit';
12141
+
12142
+ // Client config - VITE_* vars are inlined at build time.
12143
+ const envParser = new EnvironmentParser({
12144
+ VITE_API_URL: import.meta.env.VITE_API_URL,
12145
+ VITE_AUTH_URL: import.meta.env.VITE_AUTH_URL,
12146
+ });
12147
+
12148
+ export const clientConfig = envParser
12149
+ .create((get) => ({
12150
+ apiUrl: get('VITE_API_URL').string(),
12151
+ authUrl: get('VITE_AUTH_URL').string(),
12152
+ }))
12153
+ .parse();
12154
+ `;
12155
+ const serverConfigTs = `import { EnvironmentParser } from '@geekmidas/envkit';
12156
+
12157
+ // Server config - all env vars (server-side only, not exposed to browser).
12158
+ // Access these only in TanStack Start server functions / server routes.
12159
+ const envParser = new EnvironmentParser({ ...process.env });
12160
+
12161
+ export const serverConfig = envParser
12162
+ .create((_get) => ({
12163
+ // Add server-only secrets here
12164
+ // Example: stripeSecretKey: _get('STRIPE_SECRET_KEY').string(),
12165
+ }))
12166
+ .parse();
12167
+ `;
12168
+ const authClientTs = `import { magicLinkClient } from 'better-auth/client/plugins';
12169
+ import { createAuthClient } from 'better-auth/react';
12170
+ import { clientConfig } from '~/config/client.ts';
12171
+
12172
+ export const authClient = createAuthClient({
12173
+ baseURL: clientConfig.authUrl,
12174
+ plugins: [magicLinkClient()],
12175
+ });
12176
+
12177
+ export const { signIn, signUp, signOut, useSession, magicLink } = authClient;
12178
+ `;
12179
+ const apiIndexTs = `import { createApi } from '${apiPackage}/client';
12180
+ import { clientConfig } from '~/config/client.ts';
12181
+ import { getQueryClient } from '~/lib/query-client.ts';
12182
+
12183
+ export function createAppApi(options?: { headers?: Record<string, string> }) {
12184
+ return createApi({
12185
+ baseURL: clientConfig.apiUrl,
12186
+ queryClient: getQueryClient(),
12187
+ headers: options?.headers,
12188
+ onRequest: (config) => ({ ...config, credentials: 'include' }),
12189
+ });
12190
+ }
12191
+
12192
+ export const api = createAppApi();
12193
+ `;
12194
+ const routerTs = `import { createRouter } from '@tanstack/react-router';
12195
+ import { routeTree } from './routeTree.gen.ts';
12196
+
12197
+ export function getRouter() {
12198
+ return createRouter({
12199
+ routeTree,
12200
+ scrollRestoration: true,
12201
+ defaultPreload: 'intent',
12202
+ });
12203
+ }
12204
+
12205
+ declare module '@tanstack/react-router' {
12206
+ interface Register {
12207
+ router: ReturnType<typeof getRouter>;
12208
+ }
12209
+ }
12210
+ `;
12211
+ const routeTreeGen = `// This file is auto-generated by @tanstack/router-plugin.
12212
+ // It will be overwritten on the next \`vite dev\` run.
12213
+ import { rootRouteWithContext } from '@tanstack/react-router';
12214
+
12215
+ export const routeTree = rootRouteWithContext()({
12216
+ // Routes are populated by the plugin from src/routes/**.tsx
12217
+ } as never);
12218
+ `;
12219
+ const rootRouteTsx = `/// <reference types="vite/client" />
12220
+ import '~/styles/globals.css';
12221
+
12222
+ import { QueryClientProvider } from '@tanstack/react-query';
12223
+ import {
12224
+ HeadContent,
12225
+ Outlet,
12226
+ Scripts,
12227
+ createRootRoute,
12228
+ } from '@tanstack/react-router';
12229
+ import type { ReactNode } from 'react';
12230
+ import { getQueryClient } from '~/lib/query-client.ts';
12231
+
12232
+ export const Route = createRootRoute({
12233
+ head: () => ({
12234
+ meta: [
12235
+ { charSet: 'utf-8' },
12236
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
12237
+ { title: '${options.name}' },
12238
+ ],
12239
+ }),
12240
+ component: RootComponent,
12241
+ });
12242
+
12243
+ function RootComponent() {
12244
+ const queryClient = getQueryClient();
12245
+ return (
12246
+ <RootDocument>
12247
+ <QueryClientProvider client={queryClient}>
12248
+ <Outlet />
12249
+ </QueryClientProvider>
12250
+ </RootDocument>
12251
+ );
12252
+ }
12253
+
12254
+ function RootDocument({ children }: { children: ReactNode }) {
12255
+ return (
12256
+ <html lang="en">
12257
+ <head>
12258
+ <HeadContent />
12259
+ </head>
12260
+ <body>
12261
+ {children}
12262
+ <Scripts />
12263
+ </body>
12264
+ </html>
12265
+ );
12266
+ }
12267
+ `;
12268
+ const indexRouteTsx = `import { createFileRoute } from '@tanstack/react-router';
12269
+ import { api } from '~/api/index.ts';
12270
+
12271
+ export const Route = createFileRoute('/')({
12272
+ component: HomeComponent,
12273
+ });
12274
+
12275
+ function HomeComponent() {
12276
+ const { data: health } = api.useQuery('GET /health', {});
12277
+
12278
+ return (
12279
+ <main className="min-h-screen bg-background p-8">
12280
+ <div className="mx-auto max-w-4xl space-y-8">
12281
+ <div className="space-y-2">
12282
+ <h1 className="text-4xl font-bold tracking-tight">
12283
+ Welcome to ${options.name}
12284
+ </h1>
12285
+ <p className="text-muted-foreground">
12286
+ Your TanStack Start app is ready.
12287
+ </p>
12288
+ </div>
12289
+
12290
+ <section className="rounded-lg border p-6">
12291
+ <h2 className="text-xl font-semibold">API Status</h2>
12292
+ {health ? (
12293
+ <pre className="mt-4 rounded bg-muted p-4 text-sm">
12294
+ {JSON.stringify(health, null, 2)}
12295
+ </pre>
12296
+ ) : (
12297
+ <p className="mt-2 text-muted-foreground">Connecting…</p>
12298
+ )}
12299
+ </section>
12300
+ </div>
12301
+ </main>
12302
+ );
12303
+ }
12304
+ `;
12305
+ const globalsCss = `@import '${uiPackage}/styles';
12306
+
12307
+ @source "../..";
12308
+ @source "../../../../packages/ui/src";
12309
+ `;
12310
+ const gitignore = `.output/
12311
+ .vinxi/
12312
+ node_modules/
12313
+ .env.local
12314
+ *.log
12315
+ src/routeTree.gen.ts
12316
+ `;
12317
+ return [
12318
+ {
12319
+ path: "apps/web/package.json",
12320
+ content: `${JSON.stringify(packageJson, null, 2)}\n`
12321
+ },
12322
+ {
12323
+ path: "apps/web/vite.config.ts",
12324
+ content: viteConfig
12325
+ },
12326
+ {
12327
+ path: "apps/web/tsconfig.json",
12328
+ content: `${JSON.stringify(tsConfig, null, 2)}\n`
12329
+ },
12330
+ {
12331
+ path: "apps/web/src/lib/query-client.ts",
12332
+ content: queryClientTs
12333
+ },
12334
+ {
12335
+ path: "apps/web/src/lib/auth-client.ts",
12336
+ content: authClientTs
12337
+ },
12338
+ {
12339
+ path: "apps/web/src/config/client.ts",
12340
+ content: clientConfigTs
12341
+ },
12342
+ {
12343
+ path: "apps/web/src/config/server.ts",
12344
+ content: serverConfigTs
12345
+ },
12346
+ {
12347
+ path: "apps/web/src/api/index.ts",
12348
+ content: apiIndexTs
12349
+ },
12350
+ {
12351
+ path: "apps/web/src/router.tsx",
12352
+ content: routerTs
12353
+ },
12354
+ {
12355
+ path: "apps/web/src/routeTree.gen.ts",
12356
+ content: routeTreeGen
12357
+ },
12358
+ {
12359
+ path: "apps/web/src/routes/__root.tsx",
12360
+ content: rootRouteTsx
12361
+ },
12362
+ {
12363
+ path: "apps/web/src/routes/index.tsx",
12364
+ content: indexRouteTsx
12365
+ },
12366
+ {
12367
+ path: "apps/web/src/styles/globals.css",
12368
+ content: globalsCss
12369
+ },
12370
+ {
12371
+ path: "apps/web/.gitignore",
12372
+ content: gitignore
12373
+ }
12374
+ ];
12375
+ }
12376
+
11177
12377
  //#endregion
11178
12378
  //#region src/init/utils.ts
11179
12379
  /**
@@ -11386,6 +12586,13 @@ async function initCommand(projectName, options = {}) {
11386
12586
  message: "Routes structure:",
11387
12587
  choices: routesStructureChoices,
11388
12588
  initial: 0
12589
+ },
12590
+ {
12591
+ type: (_prev, values) => !options.yes && (options.template === "fullstack" || values.template === "fullstack") ? "select" : null,
12592
+ name: "frontendFramework",
12593
+ message: "Frontend framework:",
12594
+ choices: frontendFrameworkChoices,
12595
+ initial: 0
11389
12596
  }
11390
12597
  ], { onCancel });
11391
12598
  const name$1 = projectName || options.name || answers.name;
@@ -11427,6 +12634,7 @@ async function initCommand(projectName, options = {}) {
11427
12634
  const pkgManager = options.pm ? options.pm : options.yes ? "pnpm" : answers.packageManager ?? detectedPkgManager;
11428
12635
  const deployTarget = options.yes ? "dokploy" : answers.deployTarget ?? "dokploy";
11429
12636
  const database = services.db;
12637
+ const frontendFramework = isFullstack ? options.yes ? "nextjs" : answers.frontendFramework ?? "nextjs" : void 0;
11430
12638
  const templateOptions = {
11431
12639
  name: name$1,
11432
12640
  template,
@@ -11439,7 +12647,8 @@ async function initCommand(projectName, options = {}) {
11439
12647
  apiPath: monorepo ? options.apiPath ?? "apps/api" : "",
11440
12648
  packageManager: pkgManager,
11441
12649
  deployTarget,
11442
- services
12650
+ services,
12651
+ frontendFramework
11443
12652
  };
11444
12653
  const targetDir = join(cwd, name$1);
11445
12654
  const baseTemplate = getTemplate(templateOptions.template);
@@ -11467,7 +12676,7 @@ async function initCommand(projectName, options = {}) {
11467
12676
  ] : [];
11468
12677
  const dockerFiles = isMonorepo$1 && baseTemplate ? generateDockerFiles(templateOptions, baseTemplate, dbApps) : [];
11469
12678
  const rootFiles = baseTemplate ? [...generateMonorepoFiles(templateOptions, baseTemplate), ...generateModelsPackage(templateOptions)] : [];
11470
- const webAppFiles = isFullstack ? generateWebAppFiles(templateOptions) : [];
12679
+ const webAppFiles = isFullstack ? frontendFramework === "tanstack-start" ? generateTanStackWebFiles(templateOptions) : frontendFramework === "expo" ? generateExpoAppFiles(templateOptions) : generateWebAppFiles(templateOptions) : [];
11471
12680
  const authAppFiles = isFullstack ? generateAuthAppFiles(templateOptions) : [];
11472
12681
  const uiPackageFiles = isFullstack ? generateUiPackageFiles(templateOptions) : [];
11473
12682
  for (const { path, content } of rootFiles) {
@@ -11600,7 +12809,15 @@ function printNextSteps(projectName, options, pkgManager) {
11600
12809
  console.log(` │ ├── api/ # Backend API`);
11601
12810
  if (isFullstackTemplate(options.template)) {
11602
12811
  console.log(` │ ├── auth/ # Auth service (better-auth)`);
11603
- console.log(` │ └── web/ # Next.js frontend`);
12812
+ switch (options.frontendFramework) {
12813
+ case "tanstack-start":
12814
+ console.log(` │ └── web/ # TanStack Start frontend`);
12815
+ break;
12816
+ case "expo":
12817
+ console.log(` │ └── app/ # Expo (React Native) app`);
12818
+ break;
12819
+ default: console.log(` │ └── web/ # Next.js frontend`);
12820
+ }
11604
12821
  }
11605
12822
  console.log(` ├── packages/`);
11606
12823
  console.log(` │ ├── models/ # Shared Zod schemas`);
@@ -12075,7 +13292,7 @@ function printSummary(workspace, stage) {
12075
13292
  logger$1.log("\n✅ Development environment ready!\n");
12076
13293
  logger$1.log("📋 Apps:");
12077
13294
  for (const [name$1, app] of Object.entries(workspace.apps)) {
12078
- const icon = app.type === "frontend" ? "🌐" : "🔧";
13295
+ const icon = app.type === "web" ? "🌐" : app.type === "mobile" ? "📱" : "🔧";
12079
13296
  logger$1.log(` ${icon} ${name$1} → http://localhost:${app.port}`);
12080
13297
  }
12081
13298
  logger$1.log("\n🚀 Next steps:");
@@ -12434,11 +13651,11 @@ program.command("api").description("Manage REST API endpoints").action(() => {
12434
13651
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
12435
13652
  process.stdout.write("REST API management - coming soon\n");
12436
13653
  });
12437
- program.command("openapi").description("Generate OpenAPI specification from endpoints").action(async () => {
13654
+ program.command("openapi").description("Generate OpenAPI specification from endpoints").option("--app <name>", "Workspace mode: generate for a single named backend app").action(async (options) => {
12438
13655
  try {
12439
13656
  const globalOptions = program.opts();
12440
13657
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
12441
- await openapiCommand({});
13658
+ await openapiCommand({ app: options.app });
12442
13659
  } catch (error) {
12443
13660
  console.error(formatError(error));
12444
13661
  process.exit(1);
@@ -12549,7 +13766,7 @@ program.command("secrets:push").description("Push secrets to remote provider (SS
12549
13766
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
12550
13767
  const { loadWorkspaceConfig: loadWorkspaceConfig$1 } = await import("./config.mjs");
12551
13768
  const { pushSecrets: pushSecrets$1 } = await import("./sync-COnAugP-.mjs");
12552
- const { reconcileMissingSecrets } = await import("./reconcile-DAAKa0YR.mjs");
13769
+ const { reconcileMissingSecrets } = await import("./reconcile-CYGmqXvq.mjs");
12553
13770
  const { readStageSecrets: readStageSecrets$1, writeStageSecrets: writeStageSecrets$1 } = await import("./storage-Cs4WBsc4.mjs");
12554
13771
  const { workspace } = await loadWorkspaceConfig$1();
12555
13772
  const secrets = await readStageSecrets$1(options.stage, workspace.root);
@@ -12575,7 +13792,7 @@ program.command("secrets:pull").description("Pull secrets from remote provider (
12575
13792
  const { loadWorkspaceConfig: loadWorkspaceConfig$1 } = await import("./config.mjs");
12576
13793
  const { pullSecrets: pullSecrets$1 } = await import("./sync-COnAugP-.mjs");
12577
13794
  const { writeStageSecrets: writeStageSecrets$1 } = await import("./storage-Cs4WBsc4.mjs");
12578
- const { reconcileMissingSecrets } = await import("./reconcile-DAAKa0YR.mjs");
13795
+ const { reconcileMissingSecrets } = await import("./reconcile-CYGmqXvq.mjs");
12579
13796
  const { workspace } = await loadWorkspaceConfig$1();
12580
13797
  let secrets = await pullSecrets$1(options.stage, workspace);
12581
13798
  if (!secrets) {
@@ -12600,7 +13817,7 @@ program.command("secrets:reconcile").description("Backfill missing custom secret
12600
13817
  const globalOptions = program.opts();
12601
13818
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
12602
13819
  const { loadWorkspaceConfig: loadWorkspaceConfig$1 } = await import("./config.mjs");
12603
- const { reconcileMissingSecrets } = await import("./reconcile-DAAKa0YR.mjs");
13820
+ const { reconcileMissingSecrets } = await import("./reconcile-CYGmqXvq.mjs");
12604
13821
  const { readStageSecrets: readStageSecrets$1, writeStageSecrets: writeStageSecrets$1 } = await import("./storage-Cs4WBsc4.mjs");
12605
13822
  const { workspace } = await loadWorkspaceConfig$1();
12606
13823
  const secrets = await readStageSecrets$1(options.stage, workspace.root);