@geekmidas/cli 1.10.34 → 1.10.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/{config-Cuo8vFsp.cjs → config-QnuOcYXp.cjs} +2 -2
  3. package/dist/{config-Cuo8vFsp.cjs.map → config-QnuOcYXp.cjs.map} +1 -1
  4. package/dist/{config-B62g483e.mjs → config-U-gojtxn.mjs} +2 -2
  5. package/dist/{config-B62g483e.mjs.map → config-U-gojtxn.mjs.map} +1 -1
  6. package/dist/config.cjs +2 -2
  7. package/dist/config.d.cts +1 -1
  8. package/dist/config.d.mts +1 -1
  9. package/dist/config.mjs +2 -2
  10. package/dist/deploy/sniffer-loader.cjs +1 -1
  11. package/dist/{index-Dt_dZ7K4.d.mts → index-D7iT4dnv.d.mts} +41 -4
  12. package/dist/index-D7iT4dnv.d.mts.map +1 -0
  13. package/dist/{index-C3t5VL4R.d.cts → index-DRQq26DF.d.cts} +41 -4
  14. package/dist/index-DRQq26DF.d.cts.map +1 -0
  15. package/dist/index.cjs +1200 -63
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.mjs +1200 -63
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/{openapi-CflxypuN.mjs → openapi-BA0e3I_s.mjs} +65 -15
  20. package/dist/openapi-BA0e3I_s.mjs.map +1 -0
  21. package/dist/{openapi-B_rJjImN.cjs → openapi-Bb4UEyZN.cjs} +65 -15
  22. package/dist/openapi-Bb4UEyZN.cjs.map +1 -0
  23. package/dist/openapi.cjs +3 -3
  24. package/dist/openapi.d.cts +7 -0
  25. package/dist/openapi.d.cts.map +1 -1
  26. package/dist/openapi.d.mts +7 -0
  27. package/dist/openapi.d.mts.map +1 -1
  28. package/dist/openapi.mjs +3 -3
  29. package/dist/workspace/index.cjs +4 -1
  30. package/dist/workspace/index.d.cts +2 -2
  31. package/dist/workspace/index.d.mts +2 -2
  32. package/dist/workspace/index.mjs +2 -2
  33. package/dist/{workspace-CGYykWfn.cjs → workspace-BobDOIG9.cjs} +74 -3
  34. package/dist/workspace-BobDOIG9.cjs.map +1 -0
  35. package/dist/{workspace-Bi4X7Yzy.mjs → workspace-Cgmvgwh8.mjs} +57 -4
  36. package/dist/workspace-Cgmvgwh8.mjs.map +1 -0
  37. package/package.json +4 -4
  38. package/src/__tests__/openapi.spec.ts +99 -9
  39. package/src/deploy/__tests__/env-resolver.spec.ts +28 -0
  40. package/src/deploy/env-resolver.ts +5 -10
  41. package/src/deploy/index.ts +13 -7
  42. package/src/deploy/sniffer.ts +10 -4
  43. package/src/dev/__tests__/index.spec.ts +119 -0
  44. package/src/dev/index.ts +162 -37
  45. package/src/index.ts +6 -2
  46. package/src/init/__tests__/generators.spec.ts +147 -0
  47. package/src/init/generators/mobile-expo.ts +576 -0
  48. package/src/init/generators/monorepo.ts +42 -11
  49. package/src/init/generators/web-tanstack.ts +348 -0
  50. package/src/init/index.ts +40 -3
  51. package/src/init/templates/index.ts +31 -0
  52. package/src/init/versions.ts +3 -3
  53. package/src/openapi.ts +110 -23
  54. package/src/workspace/__tests__/index.spec.ts +92 -0
  55. package/src/workspace/__tests__/publicEnv.spec.ts +64 -0
  56. package/src/workspace/index.ts +16 -2
  57. package/src/workspace/publicEnv.ts +59 -0
  58. package/src/workspace/types.ts +6 -1
  59. package/dist/index-C3t5VL4R.d.cts.map +0 -1
  60. package/dist/index-Dt_dZ7K4.d.mts.map +0 -1
  61. package/dist/openapi-B_rJjImN.cjs.map +0 -1
  62. package/dist/openapi-CflxypuN.mjs.map +0 -1
  63. package/dist/workspace-Bi4X7Yzy.mjs.map +0 -1
  64. package/dist/workspace-CGYykWfn.cjs.map +0 -1
package/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-Cgmvgwh8.mjs";
4
+ import { getAppNameFromCwd, loadAppConfig, loadConfig, loadWorkspaceAppInfo, loadWorkspaceConfig, parseModuleConfig } from "./config-U-gojtxn.mjs";
5
5
  import { getCredentialsPath, getDokployCredentials, getDokployRegistryId, getDokployToken, removeDokployCredentials, storeDokployCredentials, storeDokployRegistryId } from "./credentials-s1kLcIzK.mjs";
6
6
  import { createStageSecrets, generateConnectionUrls, generateDbPassword, generateDbUrl, generateFullstackCustomSecrets, generateLocalStackCredentials, generatePgBossUrl, generateSecurePassword, generateServiceCredentials, rotateServicePassword, writeDockerEnvFromSecrets } from "./fullstack-secrets-P84v8zWn.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-BA0e3I_s.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.33";
38
+ var version = "1.10.35";
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";
@@ -1453,38 +1453,134 @@ function checkPortConflicts(workspace) {
1453
1453
  }
1454
1454
  return conflicts;
1455
1455
  }
1456
+ const FRONTEND_FRAMEWORKS = {
1457
+ nextjs: {
1458
+ displayName: "Next.js",
1459
+ configFiles: [
1460
+ "next.config.js",
1461
+ "next.config.ts",
1462
+ "next.config.mjs",
1463
+ "next.config.cjs"
1464
+ ],
1465
+ dependency: "next",
1466
+ installCommand: "pnpm add next react react-dom"
1467
+ },
1468
+ remix: {
1469
+ displayName: "Remix",
1470
+ configFiles: [
1471
+ "remix.config.js",
1472
+ "remix.config.mjs",
1473
+ "remix.config.ts",
1474
+ "vite.config.js",
1475
+ "vite.config.ts",
1476
+ "vite.config.mjs",
1477
+ "vite.config.cjs"
1478
+ ],
1479
+ dependency: "@remix-run/dev",
1480
+ installCommand: "pnpm add -D @remix-run/dev"
1481
+ },
1482
+ vite: {
1483
+ displayName: "Vite",
1484
+ configFiles: [
1485
+ "vite.config.js",
1486
+ "vite.config.ts",
1487
+ "vite.config.mjs",
1488
+ "vite.config.cjs"
1489
+ ],
1490
+ dependency: "vite",
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"
1513
+ }
1514
+ };
1456
1515
  /**
1457
- * Next.js config file patterns to check.
1458
- */
1459
- const NEXTJS_CONFIG_FILES = [
1460
- "next.config.js",
1461
- "next.config.ts",
1462
- "next.config.mjs"
1463
- ];
1516
+ * Auto-detect the frontend framework by scanning package.json deps and config
1517
+ * files. Dependency match wins over config file match because deps are more
1518
+ * distinctive (a Remix app has both vite.config.ts AND @remix-run/dev).
1519
+ */
1520
+ function detectFrontendFramework(fullPath, deps) {
1521
+ const order = [
1522
+ "expo",
1523
+ "tanstack-start",
1524
+ "nextjs",
1525
+ "remix",
1526
+ "vite"
1527
+ ];
1528
+ for (const name$1 of order) if (deps[FRONTEND_FRAMEWORKS[name$1].dependency]) return name$1;
1529
+ for (const name$1 of order) {
1530
+ const hasConfig = FRONTEND_FRAMEWORKS[name$1].configFiles.some((file) => existsSync(join(fullPath, file)));
1531
+ if (hasConfig) return name$1;
1532
+ }
1533
+ return void 0;
1534
+ }
1464
1535
  /**
1465
- * Validate a frontend (Next.js) app configuration.
1466
- * Checks for Next.js config file and dependency.
1536
+ * Validate a frontend app configuration.
1537
+ *
1538
+ * If `framework` is provided, validates strictly against that framework's
1539
+ * expected config file and dependency. Otherwise auto-detects the framework
1540
+ * from package.json and config files. If no recognized framework is found, the
1541
+ * app is allowed through with a warning provided it has a `dev` script.
1542
+ *
1467
1543
  * @internal Exported for testing
1468
1544
  */
1469
- async function validateFrontendApp(appName, appPath, workspaceRoot) {
1545
+ async function validateFrontendApp(appName, appPath, workspaceRoot, framework) {
1470
1546
  const errors = [];
1471
1547
  const warnings = [];
1472
1548
  const fullPath = join(workspaceRoot, appPath);
1473
- const hasConfigFile = NEXTJS_CONFIG_FILES.some((file) => existsSync(join(fullPath, file)));
1474
- if (!hasConfigFile) errors.push(`Next.js config file not found. Expected one of: ${NEXTJS_CONFIG_FILES.join(", ")}`);
1475
1549
  const packageJsonPath = join(fullPath, "package.json");
1476
- if (existsSync(packageJsonPath)) try {
1477
- const pkg$1 = __require(packageJsonPath);
1478
- const deps = {
1479
- ...pkg$1.dependencies,
1480
- ...pkg$1.devDependencies
1550
+ if (!existsSync(packageJsonPath)) {
1551
+ errors.push(`package.json not found at ${appPath}. Run: pnpm init in the app directory.`);
1552
+ return {
1553
+ appName,
1554
+ valid: false,
1555
+ errors,
1556
+ warnings
1481
1557
  };
1482
- if (!deps.next) errors.push("Next.js not found in dependencies. Run: pnpm add next react react-dom");
1483
- if (!pkg$1.scripts?.dev) warnings.push("No \"dev\" script found in package.json. Turbo expects a \"dev\" script to run.");
1558
+ }
1559
+ let pkg$1;
1560
+ try {
1561
+ pkg$1 = __require(packageJsonPath);
1484
1562
  } catch {
1485
1563
  errors.push(`Failed to read package.json at ${packageJsonPath}`);
1564
+ return {
1565
+ appName,
1566
+ valid: false,
1567
+ errors,
1568
+ warnings
1569
+ };
1486
1570
  }
1487
- else errors.push(`package.json not found at ${appPath}. Run: pnpm init in the app directory.`);
1571
+ const deps = {
1572
+ ...pkg$1.dependencies,
1573
+ ...pkg$1.devDependencies
1574
+ };
1575
+ const resolvedFramework = framework ?? detectFrontendFramework(fullPath, deps);
1576
+ if (resolvedFramework) {
1577
+ const spec = FRONTEND_FRAMEWORKS[resolvedFramework];
1578
+ const hasConfig = spec.configFiles.some((file) => existsSync(join(fullPath, file)));
1579
+ if (!hasConfig) errors.push(`${spec.displayName} config file not found. Expected one of: ${spec.configFiles.join(", ")}`);
1580
+ if (!deps[spec.dependency]) errors.push(`${spec.displayName} not found in dependencies. Run: ${spec.installCommand}`);
1581
+ } else warnings.push("No recognized frontend framework detected (Next.js, Vite, Remix). The app will run via its \"dev\" script. Set `framework` in your app config to enable strict validation.");
1582
+ if (!pkg$1.scripts?.dev) if (resolvedFramework) warnings.push("No \"dev\" script found in package.json. Turbo expects a \"dev\" script to run.");
1583
+ else errors.push("No \"dev\" script found in package.json. Without a recognized framework or a dev script, there is nothing to run.");
1488
1584
  return {
1489
1585
  appName,
1490
1586
  valid: errors.length === 0,
@@ -1500,7 +1596,7 @@ async function validateFrontendApp(appName, appPath, workspaceRoot) {
1500
1596
  async function validateFrontendApps(workspace) {
1501
1597
  const results = [];
1502
1598
  for (const [appName, app] of Object.entries(workspace.apps)) if (app.type === "frontend") {
1503
- const result = await validateFrontendApp(appName, app.path, workspace.root);
1599
+ const result = await validateFrontendApp(appName, app.path, workspace.root, app.framework);
1504
1600
  results.push(result);
1505
1601
  }
1506
1602
  return results;
@@ -4808,9 +4904,8 @@ function resolveEnvVar(varName, context) {
4808
4904
  break;
4809
4905
  }
4810
4906
  if (context.dependencyUrls && varName.endsWith("_URL")) {
4811
- let depName;
4812
- if (varName.startsWith("NEXT_PUBLIC_")) depName = varName.slice(12, -4).toLowerCase();
4813
- else depName = varName.slice(0, -4).toLowerCase();
4907
+ const stripped = stripPublicPrefix(varName) ?? varName;
4908
+ const depName = stripped.slice(0, -4).toLowerCase();
4814
4909
  if (context.dependencyUrls[depName]) return context.dependencyUrls[depName];
4815
4910
  }
4816
4911
  if (context.userSecrets) {
@@ -5223,7 +5318,8 @@ function resolveSnifferFile(baseName) {
5223
5318
  async function sniffAppEnvironment(app, appName, workspacePath, options = {}) {
5224
5319
  const { logWarnings = true, markOptional = false } = options;
5225
5320
  if (app.type === "frontend") {
5226
- const depVars = (app.dependencies ?? []).map((dep) => `NEXT_PUBLIC_${dep.toUpperCase()}_URL`);
5321
+ const publicPrefix = getPublicEnvPrefix(app.framework);
5322
+ const depVars = publicPrefix ? (app.dependencies ?? []).map((dep) => `${publicPrefix}${dep.toUpperCase()}_URL`) : [];
5227
5323
  if (app.config) {
5228
5324
  const sniffedVars = [];
5229
5325
  const sniffedOptional = [];
@@ -6400,11 +6496,14 @@ async function workspaceDeployCommand(workspace, options) {
6400
6496
  const { valid, missing, resolved } = validateEnvVars(sniffedVars, envContext);
6401
6497
  if (!valid) throw new Error(formatMissingVarsError(appName, missing, stage));
6402
6498
  if (Object.keys(resolved).length > 0) logger$3.log(` Resolved ${Object.keys(resolved).length} env vars: ${Object.keys(resolved).join(", ")}`);
6499
+ const publicPrefix = getPublicEnvPrefix(app.framework);
6403
6500
  const buildArgs = [];
6404
6501
  const publicUrlArgNames = [];
6405
- for (const [key, value] of Object.entries(resolved)) if (key.startsWith("NEXT_PUBLIC_")) {
6406
- buildArgs.push(`${key}=${value}`);
6407
- publicUrlArgNames.push(key);
6502
+ if (publicPrefix) {
6503
+ for (const [key, value] of Object.entries(resolved)) if (key.startsWith(publicPrefix)) {
6504
+ buildArgs.push(`${key}=${value}`);
6505
+ publicUrlArgNames.push(key);
6506
+ }
6408
6507
  }
6409
6508
  if (buildArgs.length > 0) logger$3.log(` Build args: ${publicUrlArgNames.join(", ")}`);
6410
6509
  const imageName = `${workspace.name}-${appName}`;
@@ -6841,9 +6940,9 @@ const GEEKMIDAS_VERSIONS = {
6841
6940
  "@geekmidas/constructs": "~3.0.9",
6842
6941
  "@geekmidas/db": "~1.0.1",
6843
6942
  "@geekmidas/emailkit": "~1.0.0",
6844
- "@geekmidas/envkit": "~1.0.5",
6943
+ "@geekmidas/envkit": "~1.0.6",
6845
6944
  "@geekmidas/errors": "~1.0.0",
6846
- "@geekmidas/events": "~1.1.0",
6945
+ "@geekmidas/events": "~1.1.1",
6847
6946
  "@geekmidas/logger": "~1.0.1",
6848
6947
  "@geekmidas/rate-limit": "~2.0.0",
6849
6948
  "@geekmidas/schema": "~1.0.0",
@@ -6851,7 +6950,7 @@ const GEEKMIDAS_VERSIONS = {
6851
6950
  "@geekmidas/storage": "~2.0.2",
6852
6951
  "@geekmidas/studio": "~1.0.0",
6853
6952
  "@geekmidas/telescope": "~1.0.0",
6854
- "@geekmidas/testkit": "~1.0.5",
6953
+ "@geekmidas/testkit": "~1.0.6",
6855
6954
  "@geekmidas/cli": CLI_VERSION
6856
6955
  };
6857
6956
 
@@ -7649,6 +7748,604 @@ coverage/
7649
7748
  return files;
7650
7749
  }
7651
7750
 
7751
+ //#endregion
7752
+ //#region src/init/generators/mobile-expo.ts
7753
+ /**
7754
+ * Generate an Expo mobile app for the fullstack template.
7755
+ *
7756
+ * Mirrors the structure of rezgo/apps/app: NativeWind for styling,
7757
+ * expo-router for navigation, better-auth + magic link, React Query
7758
+ * for data, all wired into the toolbox's typed API client.
7759
+ */
7760
+ function generateExpoAppFiles(options) {
7761
+ if (!options.monorepo || options.template !== "fullstack") return [];
7762
+ const packageName = `@${options.name}/app`;
7763
+ const apiPackage = `@${options.name}/api`;
7764
+ const modelsPackage = `@${options.name}/models`;
7765
+ const slug = options.name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
7766
+ const scheme = slug.replace(/-/g, "");
7767
+ const bundleId = `com.${scheme}.app`;
7768
+ const packageJson = {
7769
+ name: packageName,
7770
+ version: "0.0.1",
7771
+ main: "expo-router/entry",
7772
+ private: true,
7773
+ scripts: {
7774
+ dev: "gkm exec -- expo start -c",
7775
+ ios: "expo start --ios",
7776
+ android: "expo start --android",
7777
+ web: "expo start --web",
7778
+ typecheck: "tsc --noEmit"
7779
+ },
7780
+ dependencies: {
7781
+ [apiPackage]: "workspace:*",
7782
+ [modelsPackage]: "workspace:*",
7783
+ "@better-auth/expo": "~1.2.0",
7784
+ "@geekmidas/client": GEEKMIDAS_VERSIONS["@geekmidas/client"],
7785
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
7786
+ "@react-navigation/native": "^7.1.0",
7787
+ "@tanstack/react-query": "~5.80.0",
7788
+ "better-auth": "~1.2.0",
7789
+ expo: "~55.0.0",
7790
+ "expo-constants": "~55.0.0",
7791
+ "expo-dev-client": "~55.0.0",
7792
+ "expo-linking": "~55.0.0",
7793
+ "expo-router": "~55.0.0",
7794
+ "expo-secure-store": "~14.2.0",
7795
+ "expo-splash-screen": "~55.0.0",
7796
+ "expo-status-bar": "~55.0.0",
7797
+ nativewind: "~4.2.0",
7798
+ react: "19.2.0",
7799
+ "react-dom": "19.2.0",
7800
+ "react-native": "0.83.6",
7801
+ "react-native-gesture-handler": "~2.30.0",
7802
+ "react-native-reanimated": "~4.2.0",
7803
+ "react-native-safe-area-context": "~5.6.0",
7804
+ "react-native-screens": "~4.23.0",
7805
+ "react-native-web": "~0.21.0",
7806
+ tailwindcss: "~3.4.0"
7807
+ },
7808
+ devDependencies: {
7809
+ "@babel/core": "^7.25.0",
7810
+ "@types/react": "~19.0.0",
7811
+ typescript: "~5.8.2"
7812
+ }
7813
+ };
7814
+ const appConfig = `import type { ConfigContext, ExpoConfig } from '@expo/config';
7815
+
7816
+ /**
7817
+ * Expo runtime config. Public env vars (\`EXPO_PUBLIC_*\`) are inlined
7818
+ * at build time; everything else is read at runtime from \`extra\`.
7819
+ */
7820
+ export default function config(_context: ConfigContext): ExpoConfig {
7821
+ return {
7822
+ name: '${options.name}',
7823
+ slug: '${slug}',
7824
+ version: '0.0.1',
7825
+ orientation: 'portrait',
7826
+ icon: './assets/icon.png',
7827
+ scheme: '${scheme}',
7828
+ userInterfaceStyle: 'automatic',
7829
+ newArchEnabled: true,
7830
+ ios: {
7831
+ supportsTablet: true,
7832
+ bundleIdentifier: '${bundleId}',
7833
+ },
7834
+ android: {
7835
+ package: '${bundleId}',
7836
+ adaptiveIcon: {
7837
+ foregroundImage: './assets/adaptive-icon.png',
7838
+ backgroundColor: '#ffffff',
7839
+ },
7840
+ edgeToEdgeEnabled: true,
7841
+ },
7842
+ web: {
7843
+ output: 'static',
7844
+ },
7845
+ plugins: [
7846
+ 'expo-router',
7847
+ 'expo-secure-store',
7848
+ [
7849
+ 'expo-splash-screen',
7850
+ {
7851
+ image: './assets/splash.png',
7852
+ imageWidth: 200,
7853
+ resizeMode: 'contain',
7854
+ backgroundColor: '#ffffff',
7855
+ },
7856
+ ],
7857
+ ],
7858
+ experiments: {
7859
+ typedRoutes: true,
7860
+ },
7861
+ };
7862
+ }
7863
+ `;
7864
+ const easJson = {
7865
+ cli: {
7866
+ version: ">= 10.2.1",
7867
+ appVersionSource: "remote"
7868
+ },
7869
+ build: {
7870
+ dev: {
7871
+ developmentClient: true,
7872
+ distribution: "internal",
7873
+ environment: "development",
7874
+ env: {
7875
+ EXPO_PUBLIC_API_URL: "http://localhost:3000",
7876
+ EXPO_PUBLIC_AUTH_URL: "http://localhost:3002"
7877
+ }
7878
+ },
7879
+ preview: {
7880
+ distribution: "internal",
7881
+ environment: "preview",
7882
+ env: {
7883
+ EXPO_PUBLIC_API_URL: "https://api.example.com",
7884
+ EXPO_PUBLIC_AUTH_URL: "https://auth.example.com"
7885
+ }
7886
+ },
7887
+ production: {
7888
+ autoIncrement: true,
7889
+ environment: "production",
7890
+ env: {
7891
+ EXPO_PUBLIC_API_URL: "https://api.example.com",
7892
+ EXPO_PUBLIC_AUTH_URL: "https://auth.example.com"
7893
+ }
7894
+ }
7895
+ }
7896
+ };
7897
+ const babelConfig = `module.exports = function (api) {
7898
+ api.cache(true);
7899
+ return {
7900
+ presets: [
7901
+ ['babel-preset-expo', { jsxImportSource: 'nativewind' }],
7902
+ 'nativewind/babel',
7903
+ ],
7904
+ };
7905
+ };
7906
+ `;
7907
+ const metroConfig = `const { getDefaultConfig } = require('expo/metro-config');
7908
+ const { withNativeWind } = require('nativewind/metro');
7909
+
7910
+ const config = getDefaultConfig(__dirname);
7911
+
7912
+ module.exports = withNativeWind(config, { input: './global.css' });
7913
+ `;
7914
+ const tsConfig = {
7915
+ extends: "expo/tsconfig.base",
7916
+ compilerOptions: {
7917
+ strict: true,
7918
+ allowImportingTsExtensions: true,
7919
+ types: ["nativewind/types"],
7920
+ paths: {
7921
+ "@/*": ["./*"],
7922
+ [modelsPackage]: ["../../packages/models/src"],
7923
+ [`${modelsPackage}/*`]: ["../../packages/models/src/*"],
7924
+ [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi.ts"]
7925
+ }
7926
+ },
7927
+ include: [
7928
+ "**/*.ts",
7929
+ "**/*.tsx",
7930
+ ".expo/types/**/*.ts",
7931
+ "expo-env.d.ts",
7932
+ "nativewind-env.d.ts"
7933
+ ]
7934
+ };
7935
+ const tailwindConfig = `import type { Config } from 'tailwindcss';
7936
+
7937
+ export default {
7938
+ content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
7939
+ presets: [require('nativewind/preset')],
7940
+ theme: {
7941
+ extend: {},
7942
+ },
7943
+ plugins: [],
7944
+ } satisfies Config;
7945
+ `;
7946
+ const globalCss = `@tailwind base;
7947
+ @tailwind components;
7948
+ @tailwind utilities;
7949
+ `;
7950
+ const expoEnvDts = `/// <reference types="expo/types" />\n`;
7951
+ const nativewindEnvDts = `/// <reference types="nativewind/types" />\n`;
7952
+ const configTs = `import { EnvironmentParser } from '@geekmidas/envkit';
7953
+
7954
+ /**
7955
+ * Public app config. EXPO_PUBLIC_* vars are inlined into the bundle at
7956
+ * build time, so they're safe to read in client code.
7957
+ */
7958
+ const envParser = new EnvironmentParser({
7959
+ EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL,
7960
+ EXPO_PUBLIC_AUTH_URL: process.env.EXPO_PUBLIC_AUTH_URL,
7961
+ });
7962
+
7963
+ export const config = envParser
7964
+ .create((get) => ({
7965
+ apiUrl: get('EXPO_PUBLIC_API_URL').string(),
7966
+ authUrl: get('EXPO_PUBLIC_AUTH_URL').string(),
7967
+ }))
7968
+ .parse();
7969
+ `;
7970
+ const queryClientTs = `import { QueryClient } from '@tanstack/react-query';
7971
+
7972
+ export const queryClient = new QueryClient({
7973
+ defaultOptions: {
7974
+ queries: {
7975
+ staleTime: 60 * 1000,
7976
+ },
7977
+ },
7978
+ });
7979
+ `;
7980
+ const authClientTs = `import { expoClient } from '@better-auth/expo/client';
7981
+ import { magicLinkClient } from 'better-auth/client/plugins';
7982
+ import { createAuthClient } from 'better-auth/react';
7983
+ import * as SecureStore from 'expo-secure-store';
7984
+
7985
+ import { config } from '@/config.ts';
7986
+
7987
+ export const STORAGE_PREFIX = '${scheme}';
7988
+ export const COOKIE_STORE_KEY = \`\${STORAGE_PREFIX}_cookie\`;
7989
+
7990
+ export const authClient = createAuthClient({
7991
+ baseURL: config.authUrl,
7992
+ plugins: [
7993
+ expoClient({
7994
+ scheme: '${scheme}',
7995
+ storagePrefix: STORAGE_PREFIX,
7996
+ storage: SecureStore,
7997
+ }),
7998
+ magicLinkClient(),
7999
+ ],
8000
+ });
8001
+
8002
+ export const { signIn, useSession } = authClient;
8003
+
8004
+ export async function signOut() {
8005
+ try {
8006
+ await authClient.signOut();
8007
+ } finally {
8008
+ await SecureStore.deleteItemAsync(COOKIE_STORE_KEY);
8009
+ }
8010
+ }
8011
+ `;
8012
+ const apiTs = `import { createApi } from '${apiPackage}/client';
8013
+
8014
+ import { config } from '@/config.ts';
8015
+ import { authClient } from './auth-client.ts';
8016
+ import { queryClient } from './query-client.ts';
8017
+
8018
+ export function createAppApi(options?: { headers?: Record<string, string> }) {
8019
+ return createApi({
8020
+ baseURL: config.apiUrl,
8021
+ queryClient,
8022
+ headers: options?.headers,
8023
+ onRequest: (cfg) => {
8024
+ const cookie = authClient.getCookie();
8025
+ const next = { ...cfg, credentials: 'omit' as const };
8026
+ if (cookie) {
8027
+ return {
8028
+ ...next,
8029
+ headers: { ...(next.headers ?? {}), Cookie: cookie },
8030
+ };
8031
+ }
8032
+ return next;
8033
+ },
8034
+ });
8035
+ }
8036
+ `;
8037
+ const apiContextTsx = `import { createContext, type ReactNode, use } from 'react';
8038
+ import type { createAppApi } from './api.ts';
8039
+
8040
+ export type Api = ReturnType<typeof createAppApi>;
8041
+
8042
+ const ApiContext = createContext<Api | null>(null);
8043
+
8044
+ export function ApiProvider({
8045
+ api,
8046
+ children,
8047
+ }: {
8048
+ api: Api;
8049
+ children: ReactNode;
8050
+ }) {
8051
+ return <ApiContext.Provider value={api}>{children}</ApiContext.Provider>;
8052
+ }
8053
+
8054
+ export function useApi(): Api {
8055
+ const api = use(ApiContext);
8056
+ if (!api) {
8057
+ throw new Error('useApi must be used within an ApiProvider');
8058
+ }
8059
+ return api;
8060
+ }
8061
+ `;
8062
+ const layoutTsx = `import '../global.css';
8063
+
8064
+ import { QueryClientProvider } from '@tanstack/react-query';
8065
+ import { Stack, useRouter, useSegments } from 'expo-router';
8066
+ import { StatusBar } from 'expo-status-bar';
8067
+ import { useEffect, useMemo } from 'react';
8068
+ import 'react-native-reanimated';
8069
+
8070
+ import { authClient } from '@/lib/auth-client.ts';
8071
+ import { ApiProvider } from '@/lib/api-context.tsx';
8072
+ import { createAppApi } from '@/lib/api.ts';
8073
+ import { queryClient } from '@/lib/query-client.ts';
8074
+
8075
+ function AuthGate({ children }: { children: React.ReactNode }) {
8076
+ const router = useRouter();
8077
+ const segments = useSegments();
8078
+ const { data: session, isPending } = authClient.useSession();
8079
+
8080
+ useEffect(() => {
8081
+ if (isPending) return;
8082
+ const onAuthRoute = segments[0] === 'login';
8083
+ if (!session && !onAuthRoute) {
8084
+ router.replace('/login');
8085
+ } else if (session && onAuthRoute) {
8086
+ router.replace('/');
8087
+ }
8088
+ }, [isPending, session, segments, router]);
8089
+
8090
+ return <>{children}</>;
8091
+ }
8092
+
8093
+ export default function RootLayout() {
8094
+ const api = useMemo(() => createAppApi(), []);
8095
+
8096
+ return (
8097
+ <QueryClientProvider client={queryClient}>
8098
+ <ApiProvider api={api}>
8099
+ <AuthGate>
8100
+ <Stack screenOptions={{ headerShown: false }}>
8101
+ <Stack.Screen name="index" />
8102
+ <Stack.Screen name="login" />
8103
+ </Stack>
8104
+ </AuthGate>
8105
+ <StatusBar style="auto" />
8106
+ </ApiProvider>
8107
+ </QueryClientProvider>
8108
+ );
8109
+ }
8110
+ `;
8111
+ const indexTsx = `import { Pressable, Text, View } from 'react-native';
8112
+ import { SafeAreaView } from 'react-native-safe-area-context';
8113
+
8114
+ import { signOut, useSession } from '@/lib/auth-client.ts';
8115
+ import { useApi } from '@/lib/api-context.tsx';
8116
+
8117
+ export default function Home() {
8118
+ const { data: session } = useSession();
8119
+ const api = useApi();
8120
+ const { data: health } = api.useQuery('GET /health', {});
8121
+
8122
+ return (
8123
+ <SafeAreaView className="flex-1 bg-white">
8124
+ <View className="flex-1 items-center justify-center gap-4 px-6">
8125
+ <Text className="text-3xl font-bold text-slate-900">
8126
+ Welcome to ${options.name}
8127
+ </Text>
8128
+ {session ? (
8129
+ <Text className="text-sm text-slate-500">
8130
+ Signed in as {session.user.email}
8131
+ </Text>
8132
+ ) : null}
8133
+ {health ? (
8134
+ <Text className="text-xs text-slate-400">
8135
+ API: {JSON.stringify(health)}
8136
+ </Text>
8137
+ ) : null}
8138
+ <Pressable
8139
+ onPress={signOut}
8140
+ className="mt-4 rounded-lg bg-slate-900 px-6 py-3"
8141
+ >
8142
+ <Text className="font-semibold text-white">Sign out</Text>
8143
+ </Pressable>
8144
+ </View>
8145
+ </SafeAreaView>
8146
+ );
8147
+ }
8148
+ `;
8149
+ const loginTsx = `import * as Linking from 'expo-linking';
8150
+ import { useState } from 'react';
8151
+ import {
8152
+ KeyboardAvoidingView,
8153
+ Platform,
8154
+ Pressable,
8155
+ Text,
8156
+ TextInput,
8157
+ View,
8158
+ } from 'react-native';
8159
+ import { SafeAreaView } from 'react-native-safe-area-context';
8160
+
8161
+ import { authClient } from '@/lib/auth-client.ts';
8162
+
8163
+ const EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
8164
+
8165
+ export default function LoginScreen() {
8166
+ const [email, setEmail] = useState('');
8167
+ const [submitting, setSubmitting] = useState(false);
8168
+ const [sent, setSent] = useState(false);
8169
+ const [error, setError] = useState<string | null>(null);
8170
+
8171
+ async function handleSubmit() {
8172
+ if (!EMAIL_RE.test(email.trim())) {
8173
+ setError('Enter a valid email address');
8174
+ return;
8175
+ }
8176
+ setError(null);
8177
+ setSubmitting(true);
8178
+
8179
+ const callbackURL = Linking.createURL('/').replace(/^(\\w+):\\/\\/\\//, '$1://');
8180
+ try {
8181
+ const result = await authClient.signIn.magicLink({
8182
+ email: email.trim(),
8183
+ callbackURL,
8184
+ });
8185
+ if (result.error) {
8186
+ setError(result.error.message ?? 'Could not send magic link');
8187
+ return;
8188
+ }
8189
+ setSent(true);
8190
+ } catch (err) {
8191
+ setError(err instanceof Error ? err.message : 'Something went wrong');
8192
+ } finally {
8193
+ setSubmitting(false);
8194
+ }
8195
+ }
8196
+
8197
+ if (sent) {
8198
+ return (
8199
+ <SafeAreaView className="flex-1 bg-white">
8200
+ <View className="flex-1 items-center justify-center px-6">
8201
+ <Text className="text-2xl font-bold text-slate-900">
8202
+ Check your inbox
8203
+ </Text>
8204
+ <Text className="mt-3 text-center text-slate-500">
8205
+ We sent a sign-in link to {email.trim()}
8206
+ </Text>
8207
+ </View>
8208
+ </SafeAreaView>
8209
+ );
8210
+ }
8211
+
8212
+ return (
8213
+ <SafeAreaView className="flex-1 bg-white">
8214
+ <KeyboardAvoidingView
8215
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
8216
+ className="flex-1"
8217
+ >
8218
+ <View className="flex-1 justify-center px-6">
8219
+ <Text className="text-3xl font-bold text-slate-900">Sign in</Text>
8220
+ <Text className="mt-1 text-sm text-slate-500">
8221
+ We'll email you a one-time sign-in link.
8222
+ </Text>
8223
+
8224
+ <Text className="mt-6 text-xs font-semibold uppercase text-slate-500">
8225
+ Email
8226
+ </Text>
8227
+ <TextInput
8228
+ value={email}
8229
+ onChangeText={(v) => {
8230
+ setEmail(v);
8231
+ if (error) setError(null);
8232
+ }}
8233
+ autoCapitalize="none"
8234
+ autoCorrect={false}
8235
+ keyboardType="email-address"
8236
+ placeholder="you@example.com"
8237
+ placeholderTextColor="#94a3b8"
8238
+ editable={!submitting}
8239
+ className="mt-2 rounded-lg border border-slate-300 px-4 py-3"
8240
+ />
8241
+ {error ? (
8242
+ <Text className="mt-2 text-sm text-red-600">{error}</Text>
8243
+ ) : null}
8244
+
8245
+ <Pressable
8246
+ onPress={handleSubmit}
8247
+ disabled={submitting}
8248
+ className="mt-6 items-center rounded-lg bg-slate-900 py-4 disabled:opacity-60"
8249
+ >
8250
+ <Text className="font-semibold text-white">
8251
+ {submitting ? 'Sending…' : 'Email me a sign-in link'}
8252
+ </Text>
8253
+ </Pressable>
8254
+ </View>
8255
+ </KeyboardAvoidingView>
8256
+ </SafeAreaView>
8257
+ );
8258
+ }
8259
+ `;
8260
+ const gitignore = `node_modules/
8261
+ .expo/
8262
+ dist/
8263
+ *.log
8264
+ .env*.local
8265
+ ios/
8266
+ android/
8267
+ .eas/
8268
+ `;
8269
+ return [
8270
+ {
8271
+ path: "apps/app/package.json",
8272
+ content: `${JSON.stringify(packageJson, null, 2)}\n`
8273
+ },
8274
+ {
8275
+ path: "apps/app/app.config.ts",
8276
+ content: appConfig
8277
+ },
8278
+ {
8279
+ path: "apps/app/eas.json",
8280
+ content: `${JSON.stringify(easJson, null, 2)}\n`
8281
+ },
8282
+ {
8283
+ path: "apps/app/babel.config.js",
8284
+ content: babelConfig
8285
+ },
8286
+ {
8287
+ path: "apps/app/metro.config.js",
8288
+ content: metroConfig
8289
+ },
8290
+ {
8291
+ path: "apps/app/tsconfig.json",
8292
+ content: `${JSON.stringify(tsConfig, null, 2)}\n`
8293
+ },
8294
+ {
8295
+ path: "apps/app/tailwind.config.ts",
8296
+ content: tailwindConfig
8297
+ },
8298
+ {
8299
+ path: "apps/app/global.css",
8300
+ content: globalCss
8301
+ },
8302
+ {
8303
+ path: "apps/app/expo-env.d.ts",
8304
+ content: expoEnvDts
8305
+ },
8306
+ {
8307
+ path: "apps/app/nativewind-env.d.ts",
8308
+ content: nativewindEnvDts
8309
+ },
8310
+ {
8311
+ path: "apps/app/config.ts",
8312
+ content: configTs
8313
+ },
8314
+ {
8315
+ path: "apps/app/lib/query-client.ts",
8316
+ content: queryClientTs
8317
+ },
8318
+ {
8319
+ path: "apps/app/lib/auth-client.ts",
8320
+ content: authClientTs
8321
+ },
8322
+ {
8323
+ path: "apps/app/lib/api.ts",
8324
+ content: apiTs
8325
+ },
8326
+ {
8327
+ path: "apps/app/lib/api-context.tsx",
8328
+ content: apiContextTsx
8329
+ },
8330
+ {
8331
+ path: "apps/app/app/_layout.tsx",
8332
+ content: layoutTsx
8333
+ },
8334
+ {
8335
+ path: "apps/app/app/index.tsx",
8336
+ content: indexTsx
8337
+ },
8338
+ {
8339
+ path: "apps/app/app/login.tsx",
8340
+ content: loginTsx
8341
+ },
8342
+ {
8343
+ path: "apps/app/.gitignore",
8344
+ content: gitignore
8345
+ }
8346
+ ];
8347
+ }
8348
+
7652
8349
  //#endregion
7653
8350
  //#region src/init/generators/models.ts
7654
8351
  /**
@@ -8038,18 +8735,55 @@ export default defineConfig({
8038
8735
  return files;
8039
8736
  }
8040
8737
  /**
8041
- * Generate gkm.config.ts with defineWorkspace for fullstack template
8738
+ * Generate the frontend app entry for the workspace config based on the
8739
+ * selected `frontendFramework`. Defaults to Next.js for backwards compat.
8042
8740
  */
8043
- function generateWorkspaceConfig(options) {
8044
- const { telescope, services, deployTarget, routesStructure } = options;
8045
- const getRoutesGlob = () => {
8046
- switch (routesStructure) {
8047
- case "centralized-endpoints": return "./src/endpoints/**/*.ts";
8048
- case "centralized-routes": return "./src/routes/**/*.ts";
8049
- case "domain-based": return "./src/**/routes/*.ts";
8050
- }
8051
- };
8052
- let config$1 = `import { defineWorkspace } from '@geekmidas/cli/config';
8741
+ function generateFrontendAppEntry(options) {
8742
+ switch (options.frontendFramework) {
8743
+ case "tanstack-start": return `web: {
8744
+ type: 'frontend',
8745
+ framework: 'tanstack-start',
8746
+ path: 'apps/web',
8747
+ port: 3001,
8748
+ dependencies: ['api', 'auth'],
8749
+ config: {
8750
+ client: './src/config/client.ts',
8751
+ server: './src/config/server.ts',
8752
+ },
8753
+ },`;
8754
+ case "expo": return `app: {
8755
+ type: 'frontend',
8756
+ framework: 'expo',
8757
+ path: 'apps/app',
8758
+ port: 8081,
8759
+ dependencies: ['api', 'auth'],
8760
+ },`;
8761
+ default: return `web: {
8762
+ type: 'frontend',
8763
+ framework: 'nextjs',
8764
+ path: 'apps/web',
8765
+ port: 3001,
8766
+ dependencies: ['api', 'auth'],
8767
+ config: {
8768
+ client: './src/config/client.ts',
8769
+ server: './src/config/server.ts',
8770
+ },
8771
+ },`;
8772
+ }
8773
+ }
8774
+ /**
8775
+ * Generate gkm.config.ts with defineWorkspace for fullstack template
8776
+ */
8777
+ function generateWorkspaceConfig(options) {
8778
+ const { telescope, services, deployTarget, routesStructure } = options;
8779
+ const getRoutesGlob = () => {
8780
+ switch (routesStructure) {
8781
+ case "centralized-endpoints": return "./src/endpoints/**/*.ts";
8782
+ case "centralized-routes": return "./src/routes/**/*.ts";
8783
+ case "domain-based": return "./src/**/routes/*.ts";
8784
+ }
8785
+ };
8786
+ let config$1 = `import { defineWorkspace } from '@geekmidas/cli/config';
8053
8787
 
8054
8788
  export default defineWorkspace({
8055
8789
  name: '${options.name}',
@@ -8081,17 +8815,7 @@ export default defineWorkspace({
8081
8815
  envParser: './src/config/env#envParser',
8082
8816
  logger: './src/config/logger#logger',
8083
8817
  },
8084
- web: {
8085
- type: 'frontend',
8086
- framework: 'nextjs',
8087
- path: 'apps/web',
8088
- port: 3001,
8089
- dependencies: ['api', 'auth'],
8090
- config: {
8091
- client: './src/config/client.ts',
8092
- server: './src/config/server.ts',
8093
- },
8094
- },
8818
+ ${generateFrontendAppEntry(options)}
8095
8819
  },
8096
8820
  shared: {
8097
8821
  packages: ['packages/*'],
@@ -8994,6 +9718,26 @@ const templateChoices = [{
8994
9718
  description: "Monorepo with API + Next.js + shared models"
8995
9719
  }];
8996
9720
  /**
9721
+ * Frontend framework choices for the fullstack template prompt.
9722
+ */
9723
+ const frontendFrameworkChoices = [
9724
+ {
9725
+ title: "Next.js",
9726
+ value: "nextjs",
9727
+ description: "React server components, app router (default)"
9728
+ },
9729
+ {
9730
+ title: "TanStack Start",
9731
+ value: "tanstack-start",
9732
+ description: "File-based router on Vite + Nitro"
9733
+ },
9734
+ {
9735
+ title: "Expo",
9736
+ value: "expo",
9737
+ description: "React Native mobile app (iOS + Android)"
9738
+ }
9739
+ ];
9740
+ /**
8997
9741
  * Logger type choices for prompts
8998
9742
  */
8999
9743
  const loggerTypeChoices = [{
@@ -11101,6 +11845,382 @@ node_modules/
11101
11845
  ];
11102
11846
  }
11103
11847
 
11848
+ //#endregion
11849
+ //#region src/init/generators/web-tanstack.ts
11850
+ /**
11851
+ * Generate TanStack Start web app files for the fullstack template.
11852
+ *
11853
+ * Mirrors the Next.js scaffold (apps/web) but on Vite + TanStack Start +
11854
+ * TanStack Router with the `VITE_` env-var convention.
11855
+ */
11856
+ function generateTanStackWebFiles(options) {
11857
+ if (!options.monorepo || options.template !== "fullstack") return [];
11858
+ const packageName = `@${options.name}/web`;
11859
+ const apiPackage = `@${options.name}/api`;
11860
+ const modelsPackage = `@${options.name}/models`;
11861
+ const uiPackage = `@${options.name}/ui`;
11862
+ const packageJson = {
11863
+ name: packageName,
11864
+ version: "0.0.1",
11865
+ private: true,
11866
+ type: "module",
11867
+ scripts: {
11868
+ dev: "gkm exec -- vite dev",
11869
+ build: "gkm exec -- vite build",
11870
+ start: "node .output/server/index.mjs",
11871
+ typecheck: "tsc --noEmit"
11872
+ },
11873
+ dependencies: {
11874
+ [apiPackage]: "workspace:*",
11875
+ [modelsPackage]: "workspace:*",
11876
+ [uiPackage]: "workspace:*",
11877
+ "@geekmidas/client": GEEKMIDAS_VERSIONS["@geekmidas/client"],
11878
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
11879
+ "@tanstack/react-query": "~5.80.0",
11880
+ "@tanstack/react-router": "^1.87.0",
11881
+ "@tanstack/react-start": "^1.87.0",
11882
+ "better-auth": "~1.2.0",
11883
+ react: "~19.2.0",
11884
+ "react-dom": "~19.2.0"
11885
+ },
11886
+ devDependencies: {
11887
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
11888
+ "@tailwindcss/vite": "^4.0.0",
11889
+ "@tanstack/router-plugin": "^1.87.0",
11890
+ "@types/node": "~22.0.0",
11891
+ "@types/react": "~19.0.0",
11892
+ "@types/react-dom": "~19.0.0",
11893
+ "@vitejs/plugin-react": "^4.3.4",
11894
+ tailwindcss: "^4.0.0",
11895
+ tsx: "~4.20.0",
11896
+ typescript: "~5.8.2",
11897
+ vite: "^7.0.0",
11898
+ "vite-tsconfig-paths": "~5.1.0"
11899
+ }
11900
+ };
11901
+ const viteConfig = `import { Credentials } from '@geekmidas/envkit/credentials';
11902
+ import tailwindcss from '@tailwindcss/vite';
11903
+ import { tanstackStart } from '@tanstack/react-start/plugin/vite';
11904
+ import viteReact from '@vitejs/plugin-react';
11905
+ import { defineConfig } from 'vite';
11906
+ import tsconfigPaths from 'vite-tsconfig-paths';
11907
+
11908
+ export default defineConfig({
11909
+ server: {
11910
+ port: Number(Credentials.PORT ?? 3001),
11911
+ },
11912
+ // VITE_* vars are inlined automatically; no manual loadEnv needed.
11913
+ envPrefix: 'VITE_',
11914
+ plugins: [
11915
+ tsconfigPaths({ root: import.meta.dirname }),
11916
+ tailwindcss(),
11917
+ tanstackStart(),
11918
+ viteReact(),
11919
+ ],
11920
+ });
11921
+ `;
11922
+ const tsConfig = {
11923
+ extends: "../../tsconfig.json",
11924
+ compilerOptions: {
11925
+ lib: [
11926
+ "dom",
11927
+ "dom.iterable",
11928
+ "ES2022"
11929
+ ],
11930
+ allowJs: true,
11931
+ skipLibCheck: true,
11932
+ strict: true,
11933
+ noEmit: true,
11934
+ allowImportingTsExtensions: true,
11935
+ esModuleInterop: true,
11936
+ module: "ESNext",
11937
+ moduleResolution: "bundler",
11938
+ resolveJsonModule: true,
11939
+ isolatedModules: true,
11940
+ jsx: "preserve",
11941
+ incremental: true,
11942
+ baseUrl: ".",
11943
+ types: ["vite/client"],
11944
+ paths: {
11945
+ "~/*": ["./src/*", "../../packages/ui/src/*"],
11946
+ [modelsPackage]: ["../../packages/models/src"],
11947
+ [`${modelsPackage}/*`]: ["../../packages/models/src/*"],
11948
+ [uiPackage]: ["../../packages/ui/src"],
11949
+ [`${uiPackage}/*`]: ["../../packages/ui/src/*"],
11950
+ [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi.ts"]
11951
+ }
11952
+ },
11953
+ include: [
11954
+ "**/*.ts",
11955
+ "**/*.tsx",
11956
+ "src/routeTree.gen.ts"
11957
+ ],
11958
+ exclude: [
11959
+ "node_modules",
11960
+ ".output",
11961
+ ".vinxi",
11962
+ "dist"
11963
+ ]
11964
+ };
11965
+ const queryClientTs = `import { QueryClient } from '@tanstack/react-query';
11966
+
11967
+ function makeQueryClient() {
11968
+ return new QueryClient({
11969
+ defaultOptions: {
11970
+ queries: {
11971
+ staleTime: 60 * 1000,
11972
+ },
11973
+ },
11974
+ });
11975
+ }
11976
+
11977
+ let browserQueryClient: QueryClient | undefined = undefined;
11978
+
11979
+ export function getQueryClient() {
11980
+ if (typeof window === 'undefined') {
11981
+ return makeQueryClient();
11982
+ }
11983
+ if (!browserQueryClient) browserQueryClient = makeQueryClient();
11984
+ return browserQueryClient;
11985
+ }
11986
+ `;
11987
+ const clientConfigTs = `import { EnvironmentParser } from '@geekmidas/envkit';
11988
+
11989
+ // Client config - VITE_* vars are inlined at build time.
11990
+ const envParser = new EnvironmentParser({
11991
+ VITE_API_URL: import.meta.env.VITE_API_URL,
11992
+ VITE_AUTH_URL: import.meta.env.VITE_AUTH_URL,
11993
+ });
11994
+
11995
+ export const clientConfig = envParser
11996
+ .create((get) => ({
11997
+ apiUrl: get('VITE_API_URL').string(),
11998
+ authUrl: get('VITE_AUTH_URL').string(),
11999
+ }))
12000
+ .parse();
12001
+ `;
12002
+ const serverConfigTs = `import { EnvironmentParser } from '@geekmidas/envkit';
12003
+
12004
+ // Server config - all env vars (server-side only, not exposed to browser).
12005
+ // Access these only in TanStack Start server functions / server routes.
12006
+ const envParser = new EnvironmentParser({ ...process.env });
12007
+
12008
+ export const serverConfig = envParser
12009
+ .create((_get) => ({
12010
+ // Add server-only secrets here
12011
+ // Example: stripeSecretKey: _get('STRIPE_SECRET_KEY').string(),
12012
+ }))
12013
+ .parse();
12014
+ `;
12015
+ const authClientTs = `import { magicLinkClient } from 'better-auth/client/plugins';
12016
+ import { createAuthClient } from 'better-auth/react';
12017
+ import { clientConfig } from '~/config/client.ts';
12018
+
12019
+ export const authClient = createAuthClient({
12020
+ baseURL: clientConfig.authUrl,
12021
+ plugins: [magicLinkClient()],
12022
+ });
12023
+
12024
+ export const { signIn, signUp, signOut, useSession, magicLink } = authClient;
12025
+ `;
12026
+ const apiIndexTs = `import { createApi } from '${apiPackage}/client';
12027
+ import { clientConfig } from '~/config/client.ts';
12028
+ import { getQueryClient } from '~/lib/query-client.ts';
12029
+
12030
+ export function createAppApi(options?: { headers?: Record<string, string> }) {
12031
+ return createApi({
12032
+ baseURL: clientConfig.apiUrl,
12033
+ queryClient: getQueryClient(),
12034
+ headers: options?.headers,
12035
+ onRequest: (config) => ({ ...config, credentials: 'include' }),
12036
+ });
12037
+ }
12038
+
12039
+ export const api = createAppApi();
12040
+ `;
12041
+ const routerTs = `import { createRouter } from '@tanstack/react-router';
12042
+ import { routeTree } from './routeTree.gen.ts';
12043
+
12044
+ export function getRouter() {
12045
+ return createRouter({
12046
+ routeTree,
12047
+ scrollRestoration: true,
12048
+ defaultPreload: 'intent',
12049
+ });
12050
+ }
12051
+
12052
+ declare module '@tanstack/react-router' {
12053
+ interface Register {
12054
+ router: ReturnType<typeof getRouter>;
12055
+ }
12056
+ }
12057
+ `;
12058
+ const routeTreeGen = `// This file is auto-generated by @tanstack/router-plugin.
12059
+ // It will be overwritten on the next \`vite dev\` run.
12060
+ import { rootRouteWithContext } from '@tanstack/react-router';
12061
+
12062
+ export const routeTree = rootRouteWithContext()({
12063
+ // Routes are populated by the plugin from src/routes/**.tsx
12064
+ } as never);
12065
+ `;
12066
+ const rootRouteTsx = `/// <reference types="vite/client" />
12067
+ import '~/styles/globals.css';
12068
+
12069
+ import { QueryClientProvider } from '@tanstack/react-query';
12070
+ import {
12071
+ HeadContent,
12072
+ Outlet,
12073
+ Scripts,
12074
+ createRootRoute,
12075
+ } from '@tanstack/react-router';
12076
+ import type { ReactNode } from 'react';
12077
+ import { getQueryClient } from '~/lib/query-client.ts';
12078
+
12079
+ export const Route = createRootRoute({
12080
+ head: () => ({
12081
+ meta: [
12082
+ { charSet: 'utf-8' },
12083
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
12084
+ { title: '${options.name}' },
12085
+ ],
12086
+ }),
12087
+ component: RootComponent,
12088
+ });
12089
+
12090
+ function RootComponent() {
12091
+ const queryClient = getQueryClient();
12092
+ return (
12093
+ <RootDocument>
12094
+ <QueryClientProvider client={queryClient}>
12095
+ <Outlet />
12096
+ </QueryClientProvider>
12097
+ </RootDocument>
12098
+ );
12099
+ }
12100
+
12101
+ function RootDocument({ children }: { children: ReactNode }) {
12102
+ return (
12103
+ <html lang="en">
12104
+ <head>
12105
+ <HeadContent />
12106
+ </head>
12107
+ <body>
12108
+ {children}
12109
+ <Scripts />
12110
+ </body>
12111
+ </html>
12112
+ );
12113
+ }
12114
+ `;
12115
+ const indexRouteTsx = `import { createFileRoute } from '@tanstack/react-router';
12116
+ import { api } from '~/api/index.ts';
12117
+
12118
+ export const Route = createFileRoute('/')({
12119
+ component: HomeComponent,
12120
+ });
12121
+
12122
+ function HomeComponent() {
12123
+ const { data: health } = api.useQuery('GET /health', {});
12124
+
12125
+ return (
12126
+ <main className="min-h-screen bg-background p-8">
12127
+ <div className="mx-auto max-w-4xl space-y-8">
12128
+ <div className="space-y-2">
12129
+ <h1 className="text-4xl font-bold tracking-tight">
12130
+ Welcome to ${options.name}
12131
+ </h1>
12132
+ <p className="text-muted-foreground">
12133
+ Your TanStack Start app is ready.
12134
+ </p>
12135
+ </div>
12136
+
12137
+ <section className="rounded-lg border p-6">
12138
+ <h2 className="text-xl font-semibold">API Status</h2>
12139
+ {health ? (
12140
+ <pre className="mt-4 rounded bg-muted p-4 text-sm">
12141
+ {JSON.stringify(health, null, 2)}
12142
+ </pre>
12143
+ ) : (
12144
+ <p className="mt-2 text-muted-foreground">Connecting…</p>
12145
+ )}
12146
+ </section>
12147
+ </div>
12148
+ </main>
12149
+ );
12150
+ }
12151
+ `;
12152
+ const globalsCss = `@import '${uiPackage}/styles';
12153
+
12154
+ @source "../..";
12155
+ @source "../../../../packages/ui/src";
12156
+ `;
12157
+ const gitignore = `.output/
12158
+ .vinxi/
12159
+ node_modules/
12160
+ .env.local
12161
+ *.log
12162
+ src/routeTree.gen.ts
12163
+ `;
12164
+ return [
12165
+ {
12166
+ path: "apps/web/package.json",
12167
+ content: `${JSON.stringify(packageJson, null, 2)}\n`
12168
+ },
12169
+ {
12170
+ path: "apps/web/vite.config.ts",
12171
+ content: viteConfig
12172
+ },
12173
+ {
12174
+ path: "apps/web/tsconfig.json",
12175
+ content: `${JSON.stringify(tsConfig, null, 2)}\n`
12176
+ },
12177
+ {
12178
+ path: "apps/web/src/lib/query-client.ts",
12179
+ content: queryClientTs
12180
+ },
12181
+ {
12182
+ path: "apps/web/src/lib/auth-client.ts",
12183
+ content: authClientTs
12184
+ },
12185
+ {
12186
+ path: "apps/web/src/config/client.ts",
12187
+ content: clientConfigTs
12188
+ },
12189
+ {
12190
+ path: "apps/web/src/config/server.ts",
12191
+ content: serverConfigTs
12192
+ },
12193
+ {
12194
+ path: "apps/web/src/api/index.ts",
12195
+ content: apiIndexTs
12196
+ },
12197
+ {
12198
+ path: "apps/web/src/router.tsx",
12199
+ content: routerTs
12200
+ },
12201
+ {
12202
+ path: "apps/web/src/routeTree.gen.ts",
12203
+ content: routeTreeGen
12204
+ },
12205
+ {
12206
+ path: "apps/web/src/routes/__root.tsx",
12207
+ content: rootRouteTsx
12208
+ },
12209
+ {
12210
+ path: "apps/web/src/routes/index.tsx",
12211
+ content: indexRouteTsx
12212
+ },
12213
+ {
12214
+ path: "apps/web/src/styles/globals.css",
12215
+ content: globalsCss
12216
+ },
12217
+ {
12218
+ path: "apps/web/.gitignore",
12219
+ content: gitignore
12220
+ }
12221
+ ];
12222
+ }
12223
+
11104
12224
  //#endregion
11105
12225
  //#region src/init/utils.ts
11106
12226
  /**
@@ -11313,6 +12433,13 @@ async function initCommand(projectName, options = {}) {
11313
12433
  message: "Routes structure:",
11314
12434
  choices: routesStructureChoices,
11315
12435
  initial: 0
12436
+ },
12437
+ {
12438
+ type: (_prev, values) => !options.yes && (options.template === "fullstack" || values.template === "fullstack") ? "select" : null,
12439
+ name: "frontendFramework",
12440
+ message: "Frontend framework:",
12441
+ choices: frontendFrameworkChoices,
12442
+ initial: 0
11316
12443
  }
11317
12444
  ], { onCancel });
11318
12445
  const name$1 = projectName || options.name || answers.name;
@@ -11354,6 +12481,7 @@ async function initCommand(projectName, options = {}) {
11354
12481
  const pkgManager = options.pm ? options.pm : options.yes ? "pnpm" : answers.packageManager ?? detectedPkgManager;
11355
12482
  const deployTarget = options.yes ? "dokploy" : answers.deployTarget ?? "dokploy";
11356
12483
  const database = services.db;
12484
+ const frontendFramework = isFullstack ? options.yes ? "nextjs" : answers.frontendFramework ?? "nextjs" : void 0;
11357
12485
  const templateOptions = {
11358
12486
  name: name$1,
11359
12487
  template,
@@ -11366,7 +12494,8 @@ async function initCommand(projectName, options = {}) {
11366
12494
  apiPath: monorepo ? options.apiPath ?? "apps/api" : "",
11367
12495
  packageManager: pkgManager,
11368
12496
  deployTarget,
11369
- services
12497
+ services,
12498
+ frontendFramework
11370
12499
  };
11371
12500
  const targetDir = join(cwd, name$1);
11372
12501
  const baseTemplate = getTemplate(templateOptions.template);
@@ -11394,7 +12523,7 @@ async function initCommand(projectName, options = {}) {
11394
12523
  ] : [];
11395
12524
  const dockerFiles = isMonorepo$1 && baseTemplate ? generateDockerFiles(templateOptions, baseTemplate, dbApps) : [];
11396
12525
  const rootFiles = baseTemplate ? [...generateMonorepoFiles(templateOptions, baseTemplate), ...generateModelsPackage(templateOptions)] : [];
11397
- const webAppFiles = isFullstack ? generateWebAppFiles(templateOptions) : [];
12526
+ const webAppFiles = isFullstack ? frontendFramework === "tanstack-start" ? generateTanStackWebFiles(templateOptions) : frontendFramework === "expo" ? generateExpoAppFiles(templateOptions) : generateWebAppFiles(templateOptions) : [];
11398
12527
  const authAppFiles = isFullstack ? generateAuthAppFiles(templateOptions) : [];
11399
12528
  const uiPackageFiles = isFullstack ? generateUiPackageFiles(templateOptions) : [];
11400
12529
  for (const { path, content } of rootFiles) {
@@ -11527,7 +12656,15 @@ function printNextSteps(projectName, options, pkgManager) {
11527
12656
  console.log(` │ ├── api/ # Backend API`);
11528
12657
  if (isFullstackTemplate(options.template)) {
11529
12658
  console.log(` │ ├── auth/ # Auth service (better-auth)`);
11530
- console.log(` │ └── web/ # Next.js frontend`);
12659
+ switch (options.frontendFramework) {
12660
+ case "tanstack-start":
12661
+ console.log(` │ └── web/ # TanStack Start frontend`);
12662
+ break;
12663
+ case "expo":
12664
+ console.log(` │ └── app/ # Expo (React Native) app`);
12665
+ break;
12666
+ default: console.log(` │ └── web/ # Next.js frontend`);
12667
+ }
11531
12668
  }
11532
12669
  console.log(` ├── packages/`);
11533
12670
  console.log(` │ ├── models/ # Shared Zod schemas`);
@@ -12361,11 +13498,11 @@ program.command("api").description("Manage REST API endpoints").action(() => {
12361
13498
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
12362
13499
  process.stdout.write("REST API management - coming soon\n");
12363
13500
  });
12364
- program.command("openapi").description("Generate OpenAPI specification from endpoints").action(async () => {
13501
+ program.command("openapi").description("Generate OpenAPI specification from endpoints").option("--app <name>", "Workspace mode: generate for a single named backend app").action(async (options) => {
12365
13502
  try {
12366
13503
  const globalOptions = program.opts();
12367
13504
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
12368
- await openapiCommand({});
13505
+ await openapiCommand({ app: options.app });
12369
13506
  } catch (error) {
12370
13507
  console.error(formatError(error));
12371
13508
  process.exit(1);