@ramonclaudio/create-vexpo 0.1.2 → 0.1.4

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 (35) hide show
  1. package/README.md +25 -15
  2. package/dist/index.js +47 -14
  3. package/dist/templates/default/.eas/workflows/e2e-tests.yml +14 -1
  4. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +3 -3
  5. package/dist/templates/default/.maestro/auth.yaml +229 -0
  6. package/dist/templates/default/.maestro/launch.yaml +5 -5
  7. package/dist/templates/default/.maestro/tour.yaml +294 -0
  8. package/dist/templates/default/.maestro/zz-delete-restore.yaml +174 -0
  9. package/dist/templates/default/AGENTS.md +3 -2
  10. package/dist/templates/default/DESIGN.md +41 -41
  11. package/dist/templates/default/README.md +38 -41
  12. package/dist/templates/default/SETUP.md +34 -19
  13. package/dist/templates/default/_easignore +0 -1
  14. package/dist/templates/default/_env.example +15 -10
  15. package/dist/templates/default/app.config.ts +5 -5
  16. package/dist/templates/default/convex/pushTokens.ts +1 -26
  17. package/dist/templates/default/convex/rateLimit.ts +1 -21
  18. package/dist/templates/default/convex/users.ts +1 -49
  19. package/dist/templates/default/convex/validators.ts +0 -10
  20. package/dist/templates/default/package.json +15 -15
  21. package/dist/templates/default/scripts/README.md +24 -8
  22. package/dist/templates/default/scripts/clean.ts +3 -3
  23. package/dist/templates/default/scripts/gen-update-cert.mjs +3 -1
  24. package/dist/templates/default/src/app/(app)/_layout.tsx +15 -1
  25. package/dist/templates/default/src/app/(app)/auth/forgot-password.tsx +3 -0
  26. package/dist/templates/default/src/app/(app)/auth/reset-password.tsx +3 -0
  27. package/dist/templates/default/src/app/(app)/auth/sign-up.tsx +3 -1
  28. package/dist/templates/default/src/app/(app)/privacy.tsx +3 -2
  29. package/dist/templates/default/src/app/(app)/restore-account.tsx +2 -2
  30. package/dist/templates/default/src/app/(app)/sessions.tsx +15 -5
  31. package/dist/templates/default/src/components/ui/convex-error.tsx +0 -7
  32. package/dist/templates/default/src/constants/ui.ts +0 -11
  33. package/dist/templates/default/src/lib/dev-menu.ts +11 -2
  34. package/dist/templates/default/src/lib/preferences.ts +9 -0
  35. package/package.json +3 -2
@@ -4,29 +4,45 @@ Build and maintenance scripts. Setup orchestration lives in the published `vexpo
4
4
 
5
5
  ## What's in this directory
6
6
 
7
- | Script | What it does |
8
- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
9
- | `clean.ts` | Trash + reinstall. `--metro` for cache-only nuke (Metro/Babel/Haste). `--state` also wipes `.setup-state.json`. |
10
- | `rotate-apple-jwt.mjs` | Re-signs the Apple Sign In `client_secret` JWT from env vars only. Used by `.eas/workflows/rotate-apple-jwt.yml` every 90 days. |
11
- | `_run.mjs` | Runtime selector for `clean.ts`. Picks `bun` if available, falls back to `tsx`. Not used by the CLI. |
7
+ | Script | What it does |
8
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
+ | `clean.ts` | Trash + reinstall. `--metro` for cache-only nuke (Metro/Haste/node-compile-cache). `--state` also wipes `.setup-state.json`. |
10
+ | `gen-update-cert.mjs` | One-shot OTA update code-signing setup. Wraps `npx expo-updates codesigning:generate`, writes `certs/certificate.pem` (committed) and `../keys/private-key.pem` (gitignored). Run via `npm run updates:gen-cert -- --name "<Org>"`. |
11
+ | `rotate-apple-jwt.mjs` | Re-signs the Apple Sign In `client_secret` JWT from env vars only. Used by `.eas/workflows/rotate-apple-jwt.yml` every 90 days. |
12
+ | `_run.mjs` | Runtime selector for `clean.ts`. Picks `bun` if available, falls back to `tsx`. Not used by the CLI. |
12
13
 
13
14
  Anything else (preflight checks, env validation, version bumps) lives in the `vexpo` CLI or in `eas-cli` directly.
14
15
 
16
+ ## Cleaning
17
+
18
+ ```bash
19
+ npm run clean # trash + reinstall
20
+ npm run clean:metro # just Metro/Haste/node-compile-cache
21
+ npm run clean:state # also wipe .setup-state.json
22
+ ```
23
+
24
+ These run through `_run.mjs`, which picks `bun` if it's on PATH and falls back to `tsx`. To call it without the npm script: `node scripts/_run.mjs scripts/clean.ts --metro`.
25
+
15
26
  ## Setup orchestration
16
27
 
17
- Use the `vexpo` CLI:
28
+ Use the `vexpo` CLI. `lite` and `full` are alternatives, pick the path you need:
18
29
 
19
30
  ```bash
20
31
  npx vexpo lite # dev-mode setup (Convex + Better Auth only)
21
32
  npx vexpo full # full provisioning to TestFlight-ready
33
+ ```
34
+
35
+ The rest are independent maintenance commands, run them when you need them:
36
+
37
+ ```bash
22
38
  npx vexpo doctor # cross-source drift detection
23
- npx vexpo env push # sync from .env.local + .env.prod to Convex/EAS
39
+ npx vexpo env push # sync from .env.local + .env.prod to Convex and EAS
24
40
  npx vexpo apple asc-key # validate ASC API key
25
41
  npx vexpo apple services-id # attach SIWA capability to App ID
26
42
  npx vexpo apple jwt # sign client_secret JWT, push to Convex
27
43
  ```
28
44
 
29
- Version bumps run through `eas build:version:set` / `eas build:version:sync` (`appVersionSource: "remote"` in `eas.json` puts EAS in charge of the version).
45
+ Version bumps run through `eas build:version:set` or `eas build:version:sync`. `appVersionSource: "remote"` in `eas.json` puts EAS in charge of the version.
30
46
 
31
47
  The CLI itself ships from [`@ramonclaudio/vexpo` on npm](https://www.npmjs.com/package/@ramonclaudio/vexpo). Source lives at [`github.com/ramonclaudio/vexpo`](https://github.com/ramonclaudio/vexpo).
32
48
 
@@ -152,7 +152,7 @@ const HELP = `${BOLD}vexpo clean${RESET}
152
152
  ${BOLD}Usage:${RESET}
153
153
  ${DIM}npm run clean${RESET} wipe caches, keep lockfile, frozen install
154
154
  ${DIM}npm run clean --all${RESET} also wipe lockfile + convex/_generated
155
- ${DIM}npm run clean --metro${RESET} just Metro/Haste/Babel caches
155
+ ${DIM}npm run clean --metro${RESET} just Metro/Haste/node-compile caches
156
156
  ${DIM}npm run clean --state${RESET} also wipe .setup-state.json
157
157
  ${DIM}npm run clean --no-install${RESET} wipe everything but skip reinstall
158
158
  ${DIM}npm run clean --help${RESET}
@@ -172,7 +172,7 @@ after install to rebuild the Convex bindings. Use when the lockfile
172
172
  is suspect or you want a true clean-slate reinstall.
173
173
 
174
174
  ${BOLD}--state${RESET} additionally wipes .setup-state.json so the next
175
- ${DIM}npm run setup${RESET} re-probes every phase against external services
175
+ ${DIM}npx vexpo full${RESET} re-probes every phase against external services
176
176
  (slower, but the cure when state has drifted from reality).
177
177
 
178
178
  Bundlers (Metro, expo CLI, react-native start, Watchman) are stopped
@@ -474,7 +474,7 @@ async function stepSetupState(): Promise<void> {
474
474
  return;
475
475
  }
476
476
  await trashPaths([path]);
477
- ok("trashed .setup-state.json (next `npm run setup` re-probes every phase)");
477
+ ok("trashed .setup-state.json (next `npx vexpo full` re-probes every phase)");
478
478
  }
479
479
 
480
480
  async function stepInstall(pm: PM): Promise<void> {
@@ -76,7 +76,9 @@ console.log(`1. Commit ${CERT.replace(`${PROJECT}/`, "")} (it's a public cert).`
76
76
  console.log(`2. Upload the private key to EAS as a file-type secret:`);
77
77
  console.log(` eas env:create --environment production --visibility secret \\`);
78
78
  console.log(` --type file --name EAS_UPDATE_PRIVATE_KEY --value ${KEY}`);
79
- console.log(`3. Keep ${KEY} off committed surface. The .gitignore already covers it.`);
79
+ console.log(
80
+ `3. Keep ${KEY} off committed surface. It lands in ../keys/, outside the repo, so git never sees it.`,
81
+ );
80
82
  console.log(
81
83
  `4. The next \`expo prebuild\` picks up the cert automatically. Run \`npm run prebuild\`.`,
82
84
  );
@@ -1,9 +1,11 @@
1
- import { Stack } from "expo-router";
1
+ import { Stack, router } from "expo-router";
2
2
  import { useQuery } from "convex/react";
3
+ import { useEffect } from "react";
3
4
 
4
5
  import { api } from "@/convex/_generated/api";
5
6
  import { authClient } from "@/lib/auth-client";
6
7
  import { useDeepLinkHandler } from "@/hooks/use-deep-link";
8
+ import { useOnboarding } from "@/hooks/use-onboarding";
7
9
  import { useColors } from "@/hooks/use-theme";
8
10
  import { useMotionScreenOptions } from "@/hooks/use-motion-screen-options";
9
11
  import { useReducedMotion } from "@/hooks/use-reduced-motion";
@@ -28,6 +30,18 @@ export default function AppLayout() {
28
30
  const me = useQuery(api.users.getMe, isAuthenticated ? {} : "skip");
29
31
  const isAccountDeleted = !!me?.deletedAt;
30
32
 
33
+ // First-launch gate. `seen` reads SecureStore-backed localStorage
34
+ // synchronously, so there is no async flash. Wait for `me` to resolve
35
+ // (undefined while loading) before routing so a fresh authed user lands
36
+ // on welcome only once the account state is known. Welcome is registered
37
+ // inside the authed guard below, so this only fires for signed-in users.
38
+ const { seen } = useOnboarding();
39
+ useEffect(() => {
40
+ if (isAuthenticated && me !== undefined && !isAccountDeleted && !seen) {
41
+ router.replace("/welcome");
42
+ }
43
+ }, [isAuthenticated, me, isAccountDeleted, seen]);
44
+
31
45
  useDeepLinkHandler();
32
46
 
33
47
  const colors = useColors();
@@ -53,6 +53,9 @@ export default function ForgotPasswordScreen() {
53
53
  // mode, but a deeplinked navigation could still land here.
54
54
  useEffect(() => {
55
55
  if (providers !== undefined && providers.emailFeatures === false) {
56
+ announce(
57
+ "Password reset is unavailable until email verification is set up. Run npx vexpo full.",
58
+ );
56
59
  router.replace("/auth/sign-in");
57
60
  }
58
61
  }, [providers]);
@@ -72,6 +72,9 @@ export default function ResetPasswordScreen() {
72
72
  // in lite mode (`REQUIRE_EMAIL_VERIFICATION` unset).
73
73
  useEffect(() => {
74
74
  if (providers !== undefined && providers.emailFeatures === false) {
75
+ announce(
76
+ "Password reset is unavailable until email verification is set up. Run npx vexpo full.",
77
+ );
75
78
  router.replace("/auth/sign-in");
76
79
  }
77
80
  }, [providers]);
@@ -315,7 +315,9 @@ export default function SignUpScreen() {
315
315
  <Text
316
316
  modifiers={[dfont({ size: 16 }), foregroundStyle(colors.mutedForeground as string)]}
317
317
  >
318
- A verification code will be sent to confirm your email.
318
+ {emailFeatures
319
+ ? "A verification code will be sent to confirm your email."
320
+ : "Sign up and you're in. No email to confirm."}
319
321
  </Text>
320
322
  </VStack>
321
323
 
@@ -1,4 +1,4 @@
1
- import { useState, type ComponentProps } from "react";
1
+ import { type ComponentProps } from "react";
2
2
  import { openSettings } from "expo-linking";
3
3
  import {
4
4
  Host,
@@ -27,13 +27,14 @@ import { useSymbolSize } from "@/lib/dynamic-symbol-size";
27
27
  import { Button as ButtonTokens } from "@/constants/layout";
28
28
 
29
29
  import { haptics } from "@/lib/haptics";
30
+ import { useShareAnalytics } from "@/lib/preferences";
30
31
  import { useColors } from "@/hooks/use-theme";
31
32
 
32
33
  export default function PrivacyScreen() {
33
34
  const dfont = useDynamicFont();
34
35
  const symbolSize = useSymbolSize();
35
36
  const colors = useColors();
36
- const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
37
+ const [analyticsEnabled, setAnalyticsEnabled] = useShareAnalytics();
37
38
 
38
39
  const handleOpenSettings = () => {
39
40
  haptics.light();
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { router } from "expo-router";
21
- import { useActionState, useState } from "react";
21
+ import { startTransition, useActionState, useState } from "react";
22
22
  import { Image as ExpoImage } from "expo-image";
23
23
  import { Button, Host, Spacer, Text, VStack } from "@expo/ui/swift-ui";
24
24
  import {
@@ -158,7 +158,7 @@ export default function RestoreAccountScreen() {
158
158
  <ProminentButton
159
159
  testID="restore-account-restore"
160
160
  label={restorePending ? "Restoring…" : "Restore Account"}
161
- onPress={() => restore()}
161
+ onPress={() => startTransition(() => restore())}
162
162
  disabled={restorePending || signingOut}
163
163
  />
164
164
  <Button
@@ -69,7 +69,7 @@ export default function SessionsScreen() {
69
69
  const { data: current } = authClient.useSession();
70
70
  const currentToken = current?.session?.token ?? null;
71
71
  const [sessions, setSessions] = useState<SessionRow[] | null>(null);
72
- const [loadError, setLoadError] = useState(false);
72
+ const [loadError, setLoadError] = useState<"network" | "stale" | null>(null);
73
73
  const [revoking, setRevoking] = useState<string | null>(null);
74
74
  const [confirmToken, setConfirmToken] = useState<string | null>(null);
75
75
 
@@ -77,10 +77,13 @@ export default function SessionsScreen() {
77
77
  try {
78
78
  const res = await authClient.listSessions();
79
79
  if (res.error) {
80
- setLoadError(true);
80
+ // Better Auth freshness-gates session management (freshAge in
81
+ // convex/auth.ts). A stale session gets 403 SESSION_NOT_FRESH, which
82
+ // no retry can fix, only a fresh sign-in can.
83
+ setLoadError(res.error.code === "SESSION_NOT_FRESH" ? "stale" : "network");
81
84
  return;
82
85
  }
83
- setLoadError(false);
86
+ setLoadError(null);
84
87
  const rows = (res.data ?? []).map((s) => ({
85
88
  id: s.id,
86
89
  token: s.token,
@@ -91,7 +94,7 @@ export default function SessionsScreen() {
91
94
  }));
92
95
  setSessions(rows);
93
96
  } catch {
94
- setLoadError(true);
97
+ setLoadError("network");
95
98
  }
96
99
  };
97
100
 
@@ -124,7 +127,14 @@ export default function SessionsScreen() {
124
127
  return (
125
128
  <Host testID="sessions-screen" style={{ flex: 1, backgroundColor: colors.background }}>
126
129
  {sessions === null ? (
127
- loadError ? (
130
+ loadError === "stale" ? (
131
+ <ContentUnavailable
132
+ testID="sessions-stale"
133
+ title="Sign in again to manage sessions"
134
+ systemImage="lock.shield"
135
+ description="For your security, managing sessions needs a recent sign-in. Sign out, sign back in, and come back here."
136
+ />
137
+ ) : loadError ? (
128
138
  <ContentUnavailable
129
139
  testID="sessions-error"
130
140
  title="Couldn't load sessions"
@@ -1,7 +1,5 @@
1
1
  import { ConvexError } from "convex/values";
2
2
 
3
- import { ErrorText } from "./status-text";
4
-
5
3
  export function formatError(err: unknown): string {
6
4
  if (err instanceof ConvexError) {
7
5
  const data = err.data as unknown;
@@ -14,8 +12,3 @@ export function formatError(err: unknown): string {
14
12
  if (err instanceof Error) return err.message;
15
13
  return "An unexpected error occurred";
16
14
  }
17
-
18
- export function ConvexErrorView({ error, testID }: { error: unknown; testID?: string }) {
19
- if (error === undefined || error === null) return null;
20
- return <ErrorText testID={testID}>{formatError(error)}</ErrorText>;
21
- }
@@ -5,17 +5,6 @@ export const Opacity = {
5
5
  muted: 0.6,
6
6
  } as const;
7
7
 
8
- export const Material = {
9
- ultraThin: 20,
10
- thin: 40,
11
- regular: 60,
12
- thick: 80,
13
- ultraThick: 95,
14
- bar: 50,
15
- } as const;
16
-
17
- export type MaterialLevel = keyof typeof Material;
18
-
19
8
  export const Shadow = {
20
9
  sm: "0 1px 2px",
21
10
  md: "0 2px 4px",
@@ -9,6 +9,11 @@ import { checkForUpdate } from "@/lib/updates";
9
9
  import { setTheme } from "@/hooks/use-theme";
10
10
  import { reloadApp } from "./app";
11
11
 
12
+ // Mirror how `auth-client.ts` resolves the scheme so the secure-storage
13
+ // keys we clear match the ones `@better-auth/expo` actually wrote.
14
+ const rawScheme = Constants.expoConfig?.scheme;
15
+ const scheme = Array.isArray(rawScheme) ? rawScheme[0] : rawScheme;
16
+
12
17
  type SessionResponse = { data?: { session?: { id?: string } } | null };
13
18
 
14
19
  async function copyAuthSessionId() {
@@ -50,8 +55,12 @@ export function registerDevMenuItems() {
50
55
  {
51
56
  name: "Clear Secure Storage",
52
57
  callback: () => {
53
- SecureStore.deleteItemAsync("better-auth_session_token").catch(() => {});
54
- SecureStore.deleteItemAsync("better-auth_refresh_token").catch(() => {});
58
+ // `@better-auth/expo` keys its SecureStore entries off `storagePrefix`,
59
+ // which `auth-client.ts` sets to the app scheme (falling back to
60
+ // "better-auth"). It writes `<prefix>_cookie` and `<prefix>_session_data`.
61
+ const prefix = scheme ?? "better-auth";
62
+ SecureStore.deleteItemAsync(`${prefix}_cookie`).catch(() => {});
63
+ SecureStore.deleteItemAsync(`${prefix}_session_data`).catch(() => {});
55
64
  console.log("[DevMenu] Secure storage cleared");
56
65
  },
57
66
  },
@@ -7,6 +7,7 @@ export type ReduceMotionPref = "system" | "always" | "never";
7
7
  const hapticsStore = createStorage<boolean>("pref.hapticsEnabled", true);
8
8
  const reduceMotionStore = createStorage<ReduceMotionPref>("pref.reduceMotion", "system");
9
9
  const debugEnabledStore = createStorage<boolean>("pref.debugEnabled", __DEV__);
10
+ const analyticsStore = createStorage<boolean>("pref.shareAnalytics", true);
10
11
 
11
12
  export const preferences = {
12
13
  hapticsEnabled: () => hapticsStore.get(),
@@ -17,6 +18,9 @@ export const preferences = {
17
18
 
18
19
  debugEnabled: () => debugEnabledStore.get(),
19
20
  setDebugEnabled: (v: boolean) => debugEnabledStore.set(v),
21
+
22
+ shareAnalytics: () => analyticsStore.get(),
23
+ setShareAnalytics: (v: boolean) => analyticsStore.set(v),
20
24
  };
21
25
 
22
26
  export function useHapticsEnabled(): [boolean, (v: boolean) => void] {
@@ -41,3 +45,8 @@ export function useDebugEnabled(): [boolean, (v: boolean) => void] {
41
45
  );
42
46
  return [v, debugEnabledStore.set];
43
47
  }
48
+
49
+ export function useShareAnalytics(): [boolean, (v: boolean) => void] {
50
+ const v = useSyncExternalStore(analyticsStore.subscribe, analyticsStore.get, analyticsStore.get);
51
+ return [v, analyticsStore.set];
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramonclaudio/create-vexpo",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Scaffold a new vexpo project. Expo SDK 56 + Convex + Better Auth + Resend, wired for iOS, real auth, real push, real OTA, real App Store submission.",
5
5
  "keywords": [
6
6
  "better-auth",
@@ -48,7 +48,8 @@
48
48
  "dev": "tsup --watch",
49
49
  "prepublishOnly": "npm run build",
50
50
  "typecheck": "tsc --noEmit",
51
- "test": "echo '(create-vexpo: no per-package tests yet. covered by template e2e)'"
51
+ "test": "echo '(create-vexpo: no unit tests; see test:e2e)'",
52
+ "test:e2e": "npm run build && __tests__/e2e/run.sh"
52
53
  },
53
54
  "dependencies": {
54
55
  "commander": "^15.0.0",