Added support for building mobile applications with Expo

This commit is contained in:
Aman Varshney
2025-03-29 12:31:51 +05:30
parent 228f24d6db
commit 1c66d64be5
90 changed files with 981 additions and 204 deletions

View File

@@ -9,6 +9,7 @@ export const PKG_ROOT = path.join(distPath, "../");
export const DEFAULT_CONFIG: ProjectConfig = {
projectName: "my-better-t-app",
frontend: ["web"],
database: "sqlite",
orm: "drizzle",
auth: true,

View File

@@ -87,7 +87,7 @@ async function setupPwa(projectDir: string) {
await fs.copy(pwaTemplateDir, projectDir, { overwrite: true });
}
const clientPackageDir = path.join(projectDir, "apps/client");
const clientPackageDir = path.join(projectDir, "apps/web");
addPackageDependency({
dependencies: ["vite-plugin-pwa"],

View File

@@ -23,7 +23,7 @@ export async function setupAuth(
}
const serverDir = path.join(projectDir, "apps/server");
const clientDir = path.join(projectDir, "apps/client");
const clientDir = path.join(projectDir, "apps/web");
try {
addPackageDependency({

View File

@@ -18,6 +18,7 @@ import {
fixGitignoreFiles,
setupAuthTemplate,
setupBackendFramework,
setupFrontendTemplates,
setupOrmTemplate,
} from "./template-manager";
@@ -29,6 +30,8 @@ export async function createProject(options: ProjectConfig): Promise<string> {
await fs.ensureDir(projectDir);
await copyBaseTemplate(projectDir);
await setupFrontendTemplates(projectDir, options.frontend);
await fixGitignoreFiles(projectDir);
await setupBackendFramework(projectDir, options.backendFramework);
@@ -89,6 +92,7 @@ export async function createProject(options: ProjectConfig): Promise<string> {
options.orm,
options.addons,
options.runtime,
options.frontend,
);
return projectDir;

View File

@@ -57,7 +57,7 @@ Then, run the development server:
${packageManagerRunCmd} dev
\`\`\`
Open [http://localhost:3001](http://localhost:3001) in your browser to see the client application.
Open [http://localhost:3001](http://localhost:3001) in your browser to see the web application.
The API is running at [http://localhost:3000](http://localhost:3000).
## Project Structure
@@ -65,7 +65,7 @@ The API is running at [http://localhost:3000](http://localhost:3000).
\`\`\`
${projectName}/
├── apps/
│ ├── client/ # Frontend application (React, TanStack Router)
│ ├── web/ # Frontend application (React, TanStack Router)
│ └── server/ # Backend API (Hono, tRPC)
\`\`\`
@@ -173,9 +173,9 @@ function generateScriptsList(
orm: ProjectOrm,
auth: boolean,
): string {
let scripts = `- \`${packageManagerRunCmd} dev\`: Start both client and server in development mode
- \`${packageManagerRunCmd} build\`: Build both client and server
- \`${packageManagerRunCmd} dev:client\`: Start only the client
let scripts = `- \`${packageManagerRunCmd} dev\`: Start both web and server in development mode
- \`${packageManagerRunCmd} build\`: Build both web and server
- \`${packageManagerRunCmd} dev:web\`: Start only the web application
- \`${packageManagerRunCmd} dev:server\`: Start only the server
- \`${packageManagerRunCmd} check-types\`: Check TypeScript types across all apps`;

View File

@@ -8,7 +8,6 @@ export async function setupEnvironmentVariables(
options: ProjectConfig,
): Promise<void> {
const serverDir = path.join(projectDir, "apps/server");
const clientDir = path.join(projectDir, "apps/client");
const envPath = path.join(serverDir, ".env");
let envContent = "";
@@ -49,16 +48,35 @@ export async function setupEnvironmentVariables(
await fs.writeFile(envPath, envContent.trim());
const clientEnvPath = path.join(clientDir, ".env");
let clientEnvContent = "";
if (options.frontend.includes("web")) {
const clientDir = path.join(projectDir, "apps/web");
const clientEnvPath = path.join(clientDir, ".env");
let clientEnvContent = "";
if (await fs.pathExists(clientEnvPath)) {
clientEnvContent = await fs.readFile(clientEnvPath, "utf8");
if (await fs.pathExists(clientEnvPath)) {
clientEnvContent = await fs.readFile(clientEnvPath, "utf8");
}
if (!clientEnvContent.includes("VITE_SERVER_URL")) {
clientEnvContent += "VITE_SERVER_URL=http://localhost:3000\n";
}
await fs.writeFile(clientEnvPath, clientEnvContent.trim());
}
if (!clientEnvContent.includes("VITE_SERVER_URL")) {
clientEnvContent += "VITE_SERVER_URL=http://localhost:3000\n";
}
if (options.frontend.includes("native")) {
const nativeDir = path.join(projectDir, "apps/native");
const nativeEnvPath = path.join(nativeDir, ".env");
let nativeEnvContent = "";
await fs.writeFile(clientEnvPath, clientEnvContent.trim());
if (await fs.pathExists(nativeEnvPath)) {
nativeEnvContent = await fs.readFile(nativeEnvPath, "utf8");
}
if (!nativeEnvContent.includes("EXPO_PUBLIC_SERVER_URL")) {
nativeEnvContent += "EXPO_PUBLIC_SERVER_URL=http://localhost:3000\n";
}
await fs.writeFile(nativeEnvPath, nativeEnvContent.trim());
}
}

View File

@@ -23,8 +23,8 @@ async function setupTodoExample(
): Promise<void> {
const todoExampleDir = path.join(PKG_ROOT, "template/examples/todo");
if (await fs.pathExists(todoExampleDir)) {
const todoRouteDir = path.join(todoExampleDir, "apps/client/src/routes");
const targetRouteDir = path.join(projectDir, "apps/client/src/routes");
const todoRouteDir = path.join(todoExampleDir, "apps/web/src/routes");
const targetRouteDir = path.join(projectDir, "apps/web/src/routes");
await fs.copy(todoRouteDir, targetRouteDir, { overwrite: true });
if (orm !== "none") {
@@ -55,7 +55,7 @@ async function updateHeaderWithTodoLink(
): Promise<void> {
const headerPath = path.join(
projectDir,
"apps/client/src/components/header.tsx",
"apps/web/src/components/header.tsx",
);
if (await fs.pathExists(headerPath)) {
@@ -125,7 +125,7 @@ async function updateRouterIndex(projectDir: string): Promise<void> {
}
async function addTodoButtonToHomepage(projectDir: string): Promise<void> {
const indexPath = path.join(projectDir, "apps/client/src/routes/index.tsx");
const indexPath = path.join(projectDir, "apps/web/src/routes/index.tsx");
if (await fs.pathExists(indexPath)) {
let indexContent = await fs.readFile(indexPath, "utf8");

View File

@@ -4,6 +4,7 @@ import type {
PackageManager,
ProjectAddons,
ProjectDatabase,
ProjectFrontend,
ProjectOrm,
Runtime,
} from "../types";
@@ -13,9 +14,10 @@ export function displayPostInstallInstructions(
projectName: string,
packageManager: PackageManager,
depsInstalled: boolean,
orm?: ProjectOrm,
addons?: ProjectAddons[],
runtime?: Runtime,
orm: ProjectOrm,
addons: ProjectAddons[],
runtime: Runtime,
frontends: ProjectFrontend[],
) {
const runCmd = packageManager === "npm" ? "npm run" : packageManager;
const cdCmd = `cd ${projectName}`;
@@ -32,15 +34,29 @@ export function displayPostInstallInstructions(
const lintingInstructions = hasHuskyOrBiome
? getLintingInstructions(runCmd)
: "";
const nativeInstructions = frontends?.includes("native")
? getNativeInstructions()
: "";
const hasWebFrontend = frontends?.includes("web");
const hasNativeFrontend = frontends?.includes("native");
const hasFrontend = hasWebFrontend || hasNativeFrontend;
log.info(`${pc.bold("Next steps:")}
${pc.cyan("1.")} ${cdCmd}
${!depsInstalled ? `${pc.cyan("2.")} ${packageManager} install\n` : ""}${pc.cyan(depsInstalled ? "2." : "3.")} ${runCmd} dev
${pc.bold("Your project will be available at:")}
${pc.cyan("•")} Frontend: http://localhost:3001
${pc.cyan("•")} API: http://localhost:3000
${databaseInstructions ? `\n${databaseInstructions.trim()}` : ""}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${lintingInstructions ? `\n${lintingInstructions.trim()}` : ""}`);
${
hasFrontend
? `${hasWebFrontend ? `${pc.cyan("•")} Frontend: http://localhost:3001\n` : ""}`
: `${pc.yellow("NOTE:")} You are creating a backend-only app (no frontend selected)\n`
}${pc.cyan("•")} API: http://localhost:3000
${nativeInstructions ? `\n${nativeInstructions.trim()}` : ""}${databaseInstructions ? `\n${databaseInstructions.trim()}` : ""}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${lintingInstructions ? `\n${lintingInstructions.trim()}` : ""}`);
}
function getNativeInstructions(): string {
return `${pc.yellow("NOTE:")} If the Expo app cannot connect to the server, update the EXPO_PUBLIC_SERVER_URL in apps/native/.env to use your local IP address instead of localhost:\n${pc.dim("EXPO_PUBLIC_SERVER_URL=http://192.168.0.103:3000")}\n`;
}
function getLintingInstructions(runCmd?: string): string {
@@ -95,5 +111,5 @@ function getDatabaseInstructions(
}
function getTauriInstructions(runCmd?: string): string {
return `${pc.bold("Desktop app with Tauri:")}\n${pc.cyan("•")} Start desktop app: ${pc.dim(`cd apps/client && ${runCmd} desktop:dev`)}\n${pc.cyan("•")} Build desktop app: ${pc.dim(`cd apps/client && ${runCmd} desktop:build`)}\n${pc.yellow("NOTE:")} Tauri requires Rust and platform-specific dependencies. See: ${pc.dim("https://v2.tauri.app/start/prerequisites/")}\n\n`;
return `${pc.bold("Desktop app with Tauri:")}\n${pc.cyan("•")} Start desktop app: ${pc.dim(`cd apps/web && ${runCmd} desktop:dev`)}\n${pc.cyan("•")} Build desktop app: ${pc.dim(`cd apps/web && ${runCmd} desktop:build`)}\n${pc.yellow("NOTE:")} Tauri requires Rust and platform-specific dependencies. See: ${pc.dim("https://v2.tauri.app/start/prerequisites/")}\n\n`;
}

View File

@@ -11,7 +11,7 @@ export async function setupTauri(
packageManager: PackageManager,
): Promise<void> {
const s = spinner();
const clientPackageDir = path.join(projectDir, "apps/client");
const clientPackageDir = path.join(projectDir, "apps/web");
try {
s.start("Setting up Tauri desktop app support...");

View File

@@ -1,7 +1,12 @@
import path from "node:path";
import fs from "fs-extra";
import { PKG_ROOT } from "../constants";
import type { BackendFramework, ProjectDatabase, ProjectOrm } from "../types";
import type {
BackendFramework,
ProjectDatabase,
ProjectFrontend,
ProjectOrm,
} from "../types";
export async function copyBaseTemplate(projectDir: string): Promise<void> {
const templateDir = path.join(PKG_ROOT, "template/base");
@@ -11,6 +16,30 @@ export async function copyBaseTemplate(projectDir: string): Promise<void> {
await fs.copy(templateDir, projectDir);
}
export async function setupFrontendTemplates(
projectDir: string,
frontends: ProjectFrontend[],
): Promise<void> {
if (!frontends.includes("web")) {
const webDir = path.join(projectDir, "apps/web");
if (await fs.pathExists(webDir)) {
await fs.remove(webDir);
}
}
if (!frontends.includes("native")) {
const nativeDir = path.join(projectDir, "apps/native");
if (await fs.pathExists(nativeDir)) {
await fs.remove(nativeDir);
}
} else {
await fs.writeFile(
path.join(projectDir, ".npmrc"),
"node-linker=hoisted\n",
);
}
}
export async function setupBackendFramework(
projectDir: string,
framework: BackendFramework,
@@ -67,8 +96,8 @@ export async function setupAuthTemplate(
const authTemplateDir = path.join(PKG_ROOT, "template/with-auth");
if (await fs.pathExists(authTemplateDir)) {
const clientAuthDir = path.join(authTemplateDir, "apps/client");
const projectClientDir = path.join(projectDir, "apps/client");
const clientAuthDir = path.join(authTemplateDir, "apps/web");
const projectClientDir = path.join(projectDir, "apps/web");
await fs.copy(clientAuthDir, projectClientDir, { overwrite: true });
const serverAuthDir = path.join(authTemplateDir, "apps/server/src");
@@ -118,7 +147,8 @@ export async function setupAuthTemplate(
export async function fixGitignoreFiles(projectDir: string): Promise<void> {
const gitignorePaths = [
path.join(projectDir, "_gitignore"),
path.join(projectDir, "apps/client/_gitignore"),
path.join(projectDir, "apps/web/_gitignore"),
path.join(projectDir, "apps/native/_gitignore"),
path.join(projectDir, "apps/server/_gitignore"),
];

View File

@@ -10,6 +10,7 @@ import type {
ProjectAddons,
ProjectConfig,
ProjectExamples,
ProjectFrontend,
Runtime,
} from "./types";
import { displayConfig } from "./utils/display-config";
@@ -59,6 +60,10 @@ async function main() {
.option("--hono", "Use Hono backend framework")
.option("--elysia", "Use Elysia backend framework")
.option("--runtime <runtime>", "Specify runtime (bun or node)")
.option("--web", "Include web frontend")
.option("--native", "Include Expo frontend")
.option("--no-web", "Exclude web frontend")
.option("--no-native", "Exclude Expo frontend")
.parse();
const s = spinner();
@@ -115,6 +120,16 @@ async function main() {
.filter((e) => e === "todo") as ProjectExamples[])
: [],
}),
...((options.web !== undefined || options.native !== undefined) && {
frontend: [
...(options.web === false ? [] : options.web === true ? ["web"] : []),
...(options.native === false
? []
: options.native === true
? ["native"]
: []),
] as ProjectFrontend[],
}),
};
if (!options.yes && Object.keys(flagConfig).length > 0) {

View File

@@ -1,12 +1,26 @@
import { cancel, confirm, isCancel } from "@clack/prompts";
import { cancel, confirm, isCancel, log } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type { ProjectFrontend } from "../types";
export async function getAuthChoice(
auth: boolean | undefined,
hasDatabase: boolean,
frontends?: ProjectFrontend[],
): Promise<boolean> {
if (!hasDatabase) return false;
const hasNative = frontends?.includes("native");
const hasWeb = frontends?.includes("web");
if (hasNative) {
log.warn(
pc.yellow("Note: Authentication is not yet available with native"),
);
}
if (!hasWeb) return false;
if (auth !== undefined) return auth;
const response = await confirm({

View File

@@ -7,6 +7,7 @@ import type {
ProjectConfig,
ProjectDatabase,
ProjectExamples,
ProjectFrontend,
ProjectOrm,
Runtime,
} from "../types";
@@ -15,6 +16,7 @@ import { getAuthChoice } from "./auth";
import { getBackendFrameworkChoice } from "./backend-framework";
import { getDatabaseChoice } from "./database";
import { getExamplesChoice } from "./examples";
import { getFrontendChoice } from "./frontend-option";
import { getGitChoice } from "./git";
import { getNoInstallChoice } from "./install";
import { getORMChoice } from "./orm";
@@ -36,6 +38,7 @@ type PromptGroupResults = {
turso: boolean;
backendFramework: BackendFramework;
runtime: Runtime;
frontend: ProjectFrontend[];
};
export async function gatherConfig(
@@ -46,13 +49,18 @@ export async function gatherConfig(
projectName: async () => {
return getProjectName(flags.projectName);
},
frontend: () => getFrontendChoice(flags.frontend),
backendFramework: () => getBackendFrameworkChoice(flags.backendFramework),
runtime: () => getRuntimeChoice(flags.runtime),
database: () => getDatabaseChoice(flags.database),
orm: ({ results }) =>
getORMChoice(flags.orm, results.database !== "none"),
auth: ({ results }) =>
getAuthChoice(flags.auth, results.database !== "none"),
getAuthChoice(
flags.auth,
results.database !== "none",
results.frontend,
),
turso: ({ results }) =>
results.database === "sqlite" && results.orm !== "prisma"
? getTursoSetupChoice(flags.turso)
@@ -74,6 +82,7 @@ export async function gatherConfig(
return {
projectName: result.projectName,
frontend: result.frontend,
database: result.database,
orm: result.orm,
auth: result.auth,

View File

@@ -0,0 +1,35 @@
import { cancel, isCancel, multiselect } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type { ProjectFrontend } from "../types";
export async function getFrontendChoice(
frontendOptions?: ProjectFrontend[],
): Promise<ProjectFrontend[]> {
if (frontendOptions !== undefined) return frontendOptions;
const response = await multiselect<ProjectFrontend>({
message: "Which frontend applications would you like to create?",
options: [
{
value: "web",
label: "Web App",
hint: "React + TanStack Router web application",
},
{
value: "native",
label: "Native App",
hint: "React Native + Expo application",
},
],
initialValues: DEFAULT_CONFIG.frontend,
required: false,
});
if (isCancel(response)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
return response;
}

View File

@@ -21,7 +21,7 @@ export async function getORMChoice(
{
value: "prisma",
label: "Prisma",
hint: "Powerful, feature-rich ORM with schema migrations",
hint: "Powerful, feature-rich ORM",
},
],
initialValue: DEFAULT_CONFIG.orm,

View File

@@ -5,6 +5,7 @@ export type ProjectAddons = "pwa" | "biome" | "tauri" | "husky";
export type BackendFramework = "hono" | "elysia";
export type Runtime = "node" | "bun";
export type ProjectExamples = "todo";
export type ProjectFrontend = "web" | "native";
export interface ProjectConfig {
projectName: string;
@@ -19,4 +20,5 @@ export interface ProjectConfig {
packageManager: PackageManager;
noInstall?: boolean;
turso?: boolean;
frontend: ProjectFrontend[];
}

View File

@@ -35,6 +35,20 @@ export function generateReproducibleCommand(config: ProjectConfig): string {
flags.push(`--runtime ${config.runtime}`);
}
if (config.frontend) {
if (config.frontend.includes("web")) {
flags.push("--web");
} else {
flags.push("--no-web");
}
if (config.frontend.includes("native")) {
flags.push("--native");
} else {
flags.push("--no-native");
}
}
if (config.addons.length > 0) {
for (const addon of config.addons) {
flags.push(`--${addon}`);