Playbook — Mobile & CLI
Two small-surface-area project types grouped together because the advice is short.
Mobile
Stack
- Expo + React Native + TypeScript + Expo Router. File-system routing, shared JS talent, OTA updates.
- Native code only when platform APIs require it — Bluetooth, background audio, deep OS integration. Otherwise Expo handles everything.
- EAS Build for binaries. EAS Update for OTA JS-only updates (bug fixes without app store review).
Why Expo and not plain React Native?
- OTA updates. Huge for incident response — ship a fix in minutes, not a week.
- EAS handles certs / provisioning / signing. Juniors don’t touch Xcode for a standard app.
- Prebuild ejects cleanly if you ever need to. You’re not locked in.
Project shape
app/ # Expo Router — file-system routing
(auth)/
sign-in.tsx
(tabs)/
index.tsx
settings.tsx
components/
lib/
api/ # Client for your backend
auth/
__tests__/
smoke.test.ts
maestro/
sign-in.yaml
State & data
- React Query for server state (the backend API).
- Zustand for local UI state that crosses screens.
- MMKV (
react-native-mmkv) for persistent key-value (tokens, prefs). Faster than AsyncStorage. - Forms: React Hook Form + Zod. Native input ergonomics make RHF worth it here.
Auth
- Client-facing → Clerk Expo SDK. Middleware + hooks handle session.
- Internal staff app (rare but it happens) → MSAL for React Native with the
@egca.iotenant. Same policy as web — see § Architecture — Auth. Never Entra for a client-facing mobile app. - Paired with our own backend → short-lived JWT issued by the backend after the initial Entra or Clerk login. Rotate on app resume.
Networking
- Single
apiClientinlib/api/usingfetch+ a thin wrapper for auth headers + error mapping. - Don’t install
axios.fetchis fine in 2026. - Retry with backoff on transient failures. Don’t retry on 4xx.
Offline & sync
- Most apps don’t need real offline. Don’t build it speculatively.
- If you do — TanStack Query’s persistence, or PowerSync / Replicache for proper sync. Not a DIY queue.
Styling
- NativeWind (Tailwind for RN). Consistent with our web stack, less context-switching.
- Design tokens shared with web via a
@company/tokenspackage if you’re in a monorepo.
Testing
- Jest + React Native Testing Library for unit/component.
- Maestro for E2E — YAML flows, fast, easy to write.
- Minimum: smoke test + one Maestro flow (sign-in → home). See 04-testing.
Distribution
- Internal distribution via EAS for every PR — QA gets a real build to poke at.
- App Store / Play Store via EAS Submit. Automate the upload; don’t click through Apple’s UI by hand.
CLI / scripts
Stack
- TypeScript +
tsx+commanderfor CLIs consumed by JS devs or non-technical ops users. - Python +
typerfor CLIs consumed by data/ML folks or anything that touches pandas. - Pick by your audience, not by your preference.
When a CLI is the right answer
- A task you’ll run > 3 times.
- Something ops/support needs to do without bothering engineering.
- A reproducible piece of automation. “Re-sync all client X’s invoices” belongs in a CLI, not a Teams DM.
Project shape (TypeScript)
src/
commands/
sync.ts
cleanup.ts
lib/
db.ts
cli.ts # commander entry point
tests/
smoke.test.ts
Entry point:
#!/usr/bin/env tsx
import { Command } from 'commander'
const program = new Command()
program.command('sync').action(run)
program.parse()
Project shape (Python)
src/company_cli/
commands/
sync.py
lib/
db.py
main.py # typer app
tests/
test_smoke.py
Distribution
- Most internal CLIs: just commit the repo +
READMEwith run steps. No packaging needed. - When you need a single binary (ops machine, no Node/Python runtime):
- TS →
bun build --compileorpkg. - Python →
PyInstalleroruv build.
- TS →
- Don’t package until someone asks for it.
What NOT to do
- Don’t build a CLI “framework.”
commander/typerare already the framework. - Don’t add config files for a 3-flag CLI. Environment variables + flags are fine.
- Don’t invent a plugin system. If you have 2 commands, it’s a CLI, not a platform.
- Don’t skip tests because “it’s just a script.” A CLI that silently corrupts data is worse than a web app that throws a 500.
Config & secrets in CLIs
.envloaded viadotenv(TS) orpython-dotenv(Python), same pattern as apps.- For prod-facing CLIs: read secrets from Azure Key Vault at runtime, not baked into env files.
- Dry-run flag by default on anything destructive.
--yesto actually execute.
Auth for internal CLIs
- If a CLI talks to an internal API that’s behind Entra, use the Azure CLI device-code flow:
az login --tenant egca.iothenaz account get-access-token --resource <api-id>from inside the CLI. - Alternatively, the Azure SDK’s
DefaultAzureCredentialpicks upaz logincreds automatically in TS and Python. - Never hand out long-lived service-principal secrets to devs. One service principal per CLI, stored in Key Vault.
Logging
- Structured logs (
pinoTS /structlogPython) to stdout, JSON. - Progress output to stderr so stdout can be piped to another command without noise.
- Don’t use
console.logfor anything serious. It’s not a script you’ll delete tomorrow if it’s committed.