diff --git a/README.md b/README.md index fb1b7d6..3461839 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,15 @@ Supported mobile frontends: bun create glstack ``` +The CLI asks for: + +- **Project name** -- used for the directory, the database and the `@/rpc` package +- **Web frontend** -- SvelteKit, Solid Start or none +- **Mobile frontend** -- React Native + Expo or none +- **Bundle identifier** (Expo only) -- reverse-DNS id used as both the iOS bundle identifier and the Android package, e.g. `com.example.app` +- **Go package prefix** -- e.g. `github.com/you`, giving the module path `github.com/you/` +- **Install dependencies** -- runs `bun install` in the new project + ## What you get A monorepo with the following structure: @@ -26,10 +35,11 @@ A monorepo with the following structure: - **`apps/mobile`** -- Optional [React Native + Expo](https://expo.dev/) mobile app sharing the same RPC client - **`packages/proto`** -- Protobuf service definitions with [Buf](https://buf.build/) for codegen (Go + TypeScript) - **`packages/rpc`** -- Generated TypeScript Connect RPC client shared across frontend apps +- **`scripts`** -- Codegen helpers, e.g. building the `packages/rpc` index from the generated client ### Dev environment -The template includes a [devenv.nix](https://devenv.sh/) configuration that sets up: +The template includes a [devenv.nix](https://devenv.sh/) configuration for macOS and Linux that sets up: - Go, TypeScript, Bun, Node.js - PostgreSQL with an auto-provisioned database @@ -37,10 +47,39 @@ The template includes a [devenv.nix](https://devenv.sh/) configuration that sets - [Air](https://github.com/air-verse/air) for Go live reload - [sqlc](https://sqlc.dev/) for type-safe SQL - [dbmate](https://github.com/amacneil/dbmate) for database migrations +- With Expo: the Android SDK and an emulator, plus the iOS simulator on macOS + +Start everything with: + +```sh +cd +devenv up +``` + +### Mobile (Expo) + +`devenv up` creates an Android emulator matching your CPU (arm64 or x86_64), waits for it to boot and runs the app on it. On macOS it also builds and runs the app on the iOS simulator. Both apps share one Metro bundler, so saving a file reloads both. + +The Android emulator reaches the API at `10.0.2.2:8080` and the iOS simulator at `127.0.0.1:8080`; the app picks the right one per platform. + +The native `ios/` and `android/` folders are generated from `app.json` and not committed. After changing native config or upgrading Expo, delete them and they are regenerated on the next run. + +#### iOS on macOS + +Nix can't provide Xcode, so the iOS simulator needs a few things installed on the Mac. Entering the devenv shell checks them and lists anything missing; run `ios-check` to check again. Until they are met, `devenv up` skips iOS and runs everything else. + +| Requirement | How to fix | +| --- | --- | +| Xcode | Install it from the [App Store](https://apps.apple.com/app/xcode/id497799835) | +| Xcode license and first-launch setup | `sudo xcodebuild -runFirstLaunch` | +| An iOS simulator runtime | `xcodebuild -downloadPlatform iOS` | +| An iPhone simulator | Create one in Xcode › Window › Devices and Simulators | + +CocoaPods comes from Nix. The iOS build uses `/Applications/Xcode.app` (or the Xcode chosen with `xcode-select`) outside the Nix compiler environment, so the Command Line Tools being selected is fine. ### Starter example -The generated project includes a working Todo CRUD example wired end-to-end: protobuf schema, Go service implementation, SQL queries, and frontend UI. +The generated project includes a working Todo CRUD example wired end-to-end: protobuf schema, Go service implementation, SQL queries, and frontend UI. In the mobile app, rows animate into place with [Reanimated](https://docs.swmansion.com/react-native-reanimated/) when a todo is checked off and the list re-sorts. ## License diff --git a/index.ts b/index.ts index 8613f3d..eacc5cc 100644 --- a/index.ts +++ b/index.ts @@ -4,6 +4,7 @@ import * as p from "@clack/prompts"; import { initRenderer } from '@gregorlohaus/tdir' import { z } from 'zod' import path from "node:path"; +import { readdir, rename } from "node:fs/promises"; p.intro(`create-glstack ${process.env.GLSTACK_DEV && 'isDev'}`); @@ -89,6 +90,22 @@ const destDir = path.join("./", project.name); // `results` in the bundleid prompt makes group() infer it as unknown. render(destDir, { project: { ...project, bundleid: project.bundleid as string } }, { reverseMap: true }) +// npm never publishes .gitignore/.npmrc, so the template stores them as _gitignore/_npmrc. +const dotfiles: Record = { _gitignore: ".gitignore", _npmrc: ".npmrc" } +for (const file of await readdir(destDir, { recursive: true })) { + const dotname = dotfiles[path.basename(file)] + if (dotname) await rename(path.join(destDir, file), path.join(destDir, path.dirname(file), dotname)) +} +const mapFile = Bun.file(path.join(destDir, ".tdir-map.json")) +if (await mapFile.exists()) { + const map = await mapFile.json() + for (const entry of map.files) { + const dotname = dotfiles[path.basename(entry.outputPath)] + if (dotname) entry.outputPath = path.join(path.dirname(entry.outputPath), dotname) + } + await Bun.write(mapFile, JSON.stringify(map, null, 2)) +} + const s = p.spinner(); if (project.installDeps) { s.start("Installing dependencies"); diff --git a/template/.gitignore b/template/_gitignore similarity index 100% rename from template/.gitignore rename to template/_gitignore diff --git "a/template/apps/<@if(eq(context.project.frontend,\"solid-start\"))>web/.gitignore" "b/template/apps/<@if(eq(context.project.frontend,\"solid-start\"))>web/_gitignore" similarity index 100% rename from "template/apps/<@if(eq(context.project.frontend,\"solid-start\"))>web/.gitignore" rename to "template/apps/<@if(eq(context.project.frontend,\"solid-start\"))>web/_gitignore" diff --git "a/template/apps/<@if(eq(context.project.frontend,\"svelte-kit\"))>web/.gitignore" "b/template/apps/<@if(eq(context.project.frontend,\"svelte-kit\"))>web/_gitignore" similarity index 100% rename from "template/apps/<@if(eq(context.project.frontend,\"svelte-kit\"))>web/.gitignore" rename to "template/apps/<@if(eq(context.project.frontend,\"svelte-kit\"))>web/_gitignore" diff --git "a/template/apps/<@if(eq(context.project.frontend,\"svelte-kit\"))>web/.npmrc" "b/template/apps/<@if(eq(context.project.frontend,\"svelte-kit\"))>web/_npmrc" similarity index 100% rename from "template/apps/<@if(eq(context.project.frontend,\"svelte-kit\"))>web/.npmrc" rename to "template/apps/<@if(eq(context.project.frontend,\"svelte-kit\"))>web/_npmrc" diff --git "a/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/.gitignore" "b/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/_gitignore" similarity index 100% rename from "template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/.gitignore" rename to "template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/_gitignore" diff --git "a/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/app.json" "b/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/app.json" index 442c385..7e48f0f 100644 --- "a/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/app.json" +++ "b/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/app.json" @@ -36,7 +36,11 @@ "imageWidth": 76 } } - ] + ], + "expo-font", + "expo-image", + "expo-status-bar", + "expo-web-browser" ], "experiments": { "typedRoutes": true, diff --git "a/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/package.json" "b/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/package.json" index 96c0589..1c65842 100644 --- "a/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/package.json" +++ "b/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/package.json" @@ -3,32 +3,32 @@ "main": "expo-router/entry", "version": "1.0.0", "dependencies": { - "@expo/ui": "~56.0.14", "@<@var(context.project.name)>/rpc": "workspace:*", - "expo": "~56.0.5", - "expo-constants": "~56.0.16", - "expo-crypto": "~56.0.4", - "expo-dev-client": "~56.0.18", - "expo-device": "~56.0.4", - "expo-font": "~56.0.5", - "expo-glass-effect": "~56.0.4", - "expo-image": "~56.0.9", - "expo-linking": "~56.0.12", - "expo-router": "~56.2.7", - "expo-splash-screen": "~56.0.10", - "expo-status-bar": "~56.0.4", - "expo-symbols": "~56.0.5", - "expo-system-ui": "~56.0.5", - "expo-web-browser": "~56.0.5", + "@expo/ui": "~57.0.22", + "expo": "~57.0.27", + "expo-constants": "~57.0.21", + "expo-crypto": "~57.0.3", + "expo-dev-client": "~57.0.19", + "expo-device": "~57.0.2", + "expo-font": "~57.0.4", + "expo-glass-effect": "~57.0.4", + "expo-image": "~57.0.5", + "expo-linking": "~57.0.12", + "expo-router": "~57.0.25", + "expo-splash-screen": "~57.0.9", + "expo-status-bar": "~57.0.1", + "expo-symbols": "~57.0.3", + "expo-system-ui": "~57.0.4", + "expo-web-browser": "~57.0.3", "react": "19.2.3", "react-dom": "19.2.3", - "react-native": "0.85.3", - "react-native-gesture-handler": "~2.31.1", - "react-native-reanimated": "4.3.1", + "react-native": "0.86.3", + "react-native-gesture-handler": "~2.32.0", + "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.25.2", + "react-native-screens": "~4.26.0", "react-native-web": "~0.21.0", - "react-native-worklets": "0.8.3" + "react-native-worklets": "0.10.1" }, "devDependencies": { "@types/react": "~19.2.2", diff --git "a/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/src/app/index.tsx" "b/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/src/app/index.tsx" index 8081223..e99a14f 100644 --- "a/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/src/app/index.tsx" +++ "b/template/apps/<@if(eq(context.project.mobile,\"expo\"))>mobile/src/app/index.tsx" @@ -1,13 +1,47 @@ import { createRouter, Todo } from "@<@var(context.project.name)>/rpc" import { useEffect, useState } from "react" -import { ScrollView, Button, Checkbox, Host, Column, TextInput, Text, useNativeState, Row, Icon } from "@expo/ui"; +import { Platform, PlatformColor, ScrollView, useColorScheme } from "react-native" +import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated" +import { SafeAreaView } from "react-native-safe-area-context" +import { Button, Checkbox, Host, Column, TextInput, Text, useNativeState, Row, Icon } from "@expo/ui"; import * as Crypto from "expo-crypto" -import { colorInvert, controlSize } from "@expo/ui/swift-ui/modifiers"; -import { fillMaxWidth, padding, width } from "@expo/ui/jetpack-compose/modifiers"; +import { background, colorInvert, controlSize, padding as iosPadding, shapes } from "@expo/ui/swift-ui/modifiers"; +// No numeric width() on Android: @expo/ui 57 can't convert it natively and crashes. +import { fillMaxWidth, padding, weight } from "@expo/ui/jetpack-compose/modifiers"; +// The Android emulator reaches the host via 10.0.2.2; the iOS simulator shares the host's network. +const apiUrl = Platform.OS === "android" ? "http://10.0.2.2:8080" : "http://127.0.0.1:8080" + +// SwiftUI clips a border to its corner radius, cutting off the corners, so on iOS the +// border is an outer rounded fill showing 1pt around an inner one. +const iosInputBorder = Platform.OS === "ios" ? [ + iosPadding({ all: 10 }), + background(PlatformColor("secondarySystemGroupedBackground"), shapes.roundedRectangle({ cornerRadius: 9, roundedCornerStyle: "continuous" })), + iosPadding({ all: 1 }), + background(PlatformColor("separator"), shapes.roundedRectangle({ cornerRadius: 10, roundedCornerStyle: "continuous" })), +] : [] + +// Animates its position when the list re-sorts. The Host's measured height goes through state: +// on Android a layout transition ignores the size matchContents sets natively, collapsing the row. +function AnimatedRow({ children }: { children: React.ReactNode }) { + const [height, setHeight] = useState() + return ( + + setHeight(e.nativeEvent.height)} + > + {children} + + + ) +} + export default function Index() { - const router = createRouter("http://10.0.2.2:8080") + const router = createRouter(apiUrl) const [todos, setTodos] = useState>([]) const todoToCreateTask = useNativeState("") + const colorScheme = useColorScheme() const fetchTodos = () => { router.todos.listTodos({}).then((r) => { setTodos(r.todos) @@ -15,19 +49,17 @@ export default function Index() { } useEffect(() => { fetchTodos(); - }) + }, []) const setTodoDone = (id: string, task: string) => { return (done: boolean) => { - router.todos.updateTodo({ todo: { id, task, done } }) - fetchTodos() + router.todos.updateTodo({ todo: { id, task, done } }).then(fetchTodos) } } const deleteTodo = (id: string) => { return () => { - router.todos.deleteTodo({ todo: { id } }) - fetchTodos() + router.todos.deleteTodo({ todo: { id } }).then(fetchTodos) } } @@ -45,29 +77,43 @@ export default function Index() { } return ( - - - Create Todo - -