/** * `wrnexus generate mobile` — scaffold a WebView or fully native mobile app. * * WrNexus remains the hosted SSR/API/WebSocket server. Capacitor loads that * server in a native WebView and provides the bridge for native plugins. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import { loadAppConfig } from "@wrnexus/styles"; export type MobileMode = "webview" | "native"; export interface MobileOptions { appId?: string; appName?: string; serverUrl?: string; mode?: MobileMode; } function slug(value: string): string { const cleaned = value .toLowerCase() .replace(/[^a-z0-9]+/g, "") .replace(/^\d+/, ""); return cleaned || "app"; } function projectName(root: string): string { try { const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name?: string }; if (pkg.name) return pkg.name.split("/").pop() || basename(root); } catch { // A package manifest is helpful but not required to generate the shell. } return basename(root); } function writeIfAbsent(path: string, content: string, label: string): void { if (existsSync(path)) { console.warn(` • ${label} already exists — skipped`); return; } mkdirSync(resolve(path, ".."), { recursive: true }); writeFileSync(path, content, "utf8"); console.log(` ✓ ${label}`); } /** Scaffold a Capacitor mobile wrapper under `/mobile`. */ export async function generateMobile(appRoot: string, options: MobileOptions = {}): Promise { const root = resolve(appRoot); const appConfig = await loadAppConfig(root); const configured = appConfig.mobile ?? {}; const mode = options.mode ?? configured.mode ?? "webview"; if (mode !== "webview" && mode !== "native") { throw new Error('mobile.mode must be either "webview" or "native"'); } const name = options.appName || configured.appName || projectName(root); const appId = options.appId || configured.appId || `com.example.${slug(name)}`; const serverUrl = options.serverUrl || configured.serverUrl || "http://localhost:3000"; const mobile = join(root, "mobile"); if (mode === "native") { generateNativeMobile(mobile, { name, appId, apiUrl: configured.apiUrl ?? serverUrl, configured, }); return; } const pkg = { name: `${slug(name)}-mobile`, version: "0.1.0", private: true, wrnexus: { mode: "webview" }, type: "module", scripts: { "add:ios": "cap add ios", "add:android": "cap add android", sync: "cap sync", "open:ios": "cap open ios", "open:android": "cap open android", }, dependencies: { "@capacitor/core": "^8.0.0", "@capacitor/app": "^8.0.0", "@capacitor/ios": "^8.0.0", "@capacitor/android": "^8.0.0", }, devDependencies: { "@capacitor/cli": "^8.0.0", "@capacitor/assets": "^3.0.0", typescript: "^6.0.3", }, }; const config = `import type { CapacitorConfig } from "@capacitor/cli"; import appConfig from "../wrnexus.config.ts"; const mobile = appConfig.mobile ?? {}; const serverUrl = process.env.WRNEXUS_MOBILE_URL ?? mobile.serverUrl ?? ${JSON.stringify(serverUrl)}; const config: CapacitorConfig = { appId: mobile.appId ?? ${JSON.stringify(appId)}, appName: mobile.appName ?? ${JSON.stringify(name)}, webDir: "web", appendUserAgent: mobile.userAgent ?? " WrNexusMobile", backgroundColor: mobile.backgroundColor, ...(mobile.capacitor ?? {}), server: { ...((mobile.capacitor?.server as CapacitorConfig["server"]) ?? {}), // Development bridge only: Capacitor does not recommend server.url in production. url: serverUrl, cleartext: serverUrl.startsWith("http://"), }, }; export default config; `; const errorPage = ` Connection unavailable

Connection unavailable

Check your Wi-Fi and make sure the WrNexus server is running.

`; const fallback = ` ${name}

Run the WrNexus server and then sync this mobile project.

`; const readme = `# ${name} mobile Capacitor development shell for the hosted WrNexus application. The Bun server, SSR, APIs, database, and WebSockets continue to run on your server; this directory contains the native iOS and Android projects and native plugin dependencies. > Capacitor documents \`server.url\` as a live-reload option that is not intended > for production. This shell is useful for native development and testing, but a > store release needs a bundled client build/static export that WrNexus does not > currently produce. ## Setup \`\`\`bash node --version # Capacitor 8 requires Node.js 22+ bun install bun run add:android bun run add:ios # macOS with Xcode is required bun run sync \`\`\` Set \`WRNEXUS_MOBILE_URL\` to a reachable URL before syncing the development shell: \`\`\`bash WRNEXUS_MOBILE_URL=https://app.example.com bun run sync \`\`\` For a physical device, \`localhost\` refers to the device, not your computer. Use your computer's LAN URL during development. Do not ship the generated \`server.url\` configuration as a production store build. Open the native projects with \`bun run open:android\` or \`bun run open:ios\`. Add native features with Capacitor plugins and run \`bun run sync\` afterward. `; console.log("Scaffolding Capacitor mobile app:"); writeIfAbsent( join(mobile, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`, "mobile/package.json", ); writeIfAbsent(join(mobile, "capacitor.config.ts"), config, "mobile/capacitor.config.ts"); writeIfAbsent(join(mobile, "web", "index.html"), fallback, "mobile/web/index.html"); writeIfAbsent(join(mobile, "web", "error.html"), errorPage, "mobile/web/error.html"); writeIfAbsent( join(mobile, ".gitignore"), "node_modules\nandroid/.gradle\nios/App/Pods\n", "mobile/.gitignore", ); writeIfAbsent(join(mobile, "README.md"), readme, "mobile/README.md"); console.log("\nNext: cd mobile && bun install && bun run add:android"); console.log("iOS generation requires macOS with Xcode: bun run add:ios"); } /** Convert CLI flags into generator options. */ export function mobileOptions(args: string[]): MobileOptions { const value = (flag: string) => args.find((arg) => arg.startsWith(`${flag}=`))?.slice(flag.length + 1); const modeValue = value("--mode"); if (modeValue && modeValue !== "webview" && modeValue !== "native") { throw new Error('--mode must be either "webview" or "native"'); } const mode: MobileMode | undefined = modeValue === "webview" || modeValue === "native" ? modeValue : undefined; return { appId: value("--app-id"), appName: value("--app-name"), serverUrl: value("--url"), mode, }; } function generateNativeMobile( mobile: string, input: { name: string; appId: string; apiUrl: string; configured: { scheme?: string } }, ): void { const { name, appId, apiUrl, configured } = input; const scheme = configured.scheme ?? slug(name); const pkg = { name: `${slug(name)}-mobile`, version: "0.1.0", private: true, main: "expo-router/entry", wrnexus: { mode: "native" }, scripts: { compile: "cd .. && wrnexus mobile compile", prestart: "bun run compile", start: "expo start", android: "expo run:android", ios: "expo run:ios", web: "expo start --web", prebuild: "expo prebuild", }, dependencies: { expo: "^57.0.0", "expo-router": "~57.0.4", "expo-status-bar": "~57.0.0", react: "19.2.3", "react-native": "0.86.0", "react-native-safe-area-context": "^5.6.0", "react-native-screens": "^4.23.0", }, devDependencies: { "@types/react": "^19.2.0", typescript: "^6.0.3" }, }; const expo = `import type { ExpoConfig } from "expo/config";\nimport appConfig from "../wrnexus.config.ts";\n\nconst mobile = appConfig.mobile ?? {};\nconst config: ExpoConfig = {\n name: mobile.appName ?? ${JSON.stringify(name)},\n slug: ${JSON.stringify(slug(name))},\n scheme: mobile.scheme ?? ${JSON.stringify(scheme)},\n ios: { bundleIdentifier: mobile.appId ?? ${JSON.stringify(appId)} },\n android: { package: mobile.appId ?? ${JSON.stringify(appId)} },\n plugins: ["expo-router"],\n ...(mobile.expo ?? {}),\n};\nexport default config;\n`; const env = `/** Shared connection settings for native screens. */\nexport const API_URL = process.env.EXPO_PUBLIC_WRNEXUS_URL ?? ${JSON.stringify(apiUrl)};\nexport async function api(path: string, init?: RequestInit): Promise {\n const response = await fetch(new URL(path, API_URL), init);\n if (!response.ok) throw new Error(\`WrNexus request failed: \${response.status}\`);\n return response.json() as Promise;\n}\nexport function realtimeUrl(path: string): string {\n const url = new URL(path, API_URL);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n return url.toString();\n}\n`; const screen = `import { StyleSheet, Text, View } from "react-native";\nimport { StatusBar } from "expo-status-bar";\n\nexport default function Home() {\n return ${name}Fully native WrNexus client;\n}\nconst styles = StyleSheet.create({ container: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, title: { fontSize: 28, fontWeight: "700", marginBottom: 8 } });\n`; const readme = `# ${name} native mobile\n\nThis is a fully native Expo/React Native client: it does not use a WebView. Portable pages from \`../app/pages/**/*.wrn\` compile into Expo routes with \`bun run compile\` (also run automatically before \`start\`). You can edit or add native-only TSX screens in \`app/\`, and call the shared WrNexus backend through \`src/wrnexus.ts\`.\n\n\`\`\`bash\nbun install\nbun run compile\nbun run start\nbun run android\n# macOS/Xcode: bun run ios\n\`\`\`\n\nServer API routes, authentication endpoints, uploads, and WebSockets remain reusable. Unsupported DOM-only markup fails compilation with a specific error. Override the backend per environment with \`EXPO_PUBLIC_WRNEXUS_URL\`. Add native modules with \`bunx expo install \`.\n`; console.log("Scaffolding fully native Expo mobile app:"); writeIfAbsent( join(mobile, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`, "mobile/package.json", ); writeIfAbsent(join(mobile, "app.config.ts"), expo, "mobile/app.config.ts"); writeIfAbsent(join(mobile, "app", "index.tsx"), screen, "mobile/app/index.tsx"); writeIfAbsent(join(mobile, "src", "wrnexus.ts"), env, "mobile/src/wrnexus.ts"); writeIfAbsent( join(mobile, "tsconfig.json"), `${JSON.stringify({ extends: "expo/tsconfig.base", compilerOptions: { strict: true } }, null, 2)}\n`, "mobile/tsconfig.json", ); writeIfAbsent( join(mobile, ".gitignore"), "node_modules\n.expo\nandroid\nios\n", "mobile/.gitignore", ); writeIfAbsent(join(mobile, "README.md"), readme, "mobile/README.md"); console.log("\nNext: cd mobile && bun install && bun run start"); }