@ramonclaudio/create-vexpo 0.1.3 → 0.1.5
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.
- package/LICENSE +21 -0
- package/README.md +29 -15
- package/dist/index.js +44 -14
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +17 -1
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +3 -3
- package/dist/templates/default/.maestro/auth.yaml +229 -0
- package/dist/templates/default/.maestro/launch.yaml +5 -5
- package/dist/templates/default/.maestro/tour.yaml +294 -0
- package/dist/templates/default/.maestro/zz-delete-restore.yaml +174 -0
- package/dist/templates/default/AGENTS.md +3 -2
- package/dist/templates/default/DESIGN.md +41 -41
- package/dist/templates/default/README.md +46 -40
- package/dist/templates/default/SETUP.md +34 -19
- package/dist/templates/default/_easignore +0 -1
- package/dist/templates/default/_env.example +15 -10
- package/dist/templates/default/app.config.ts +5 -5
- package/dist/templates/default/convex/pushTokens.ts +1 -26
- package/dist/templates/default/convex/rateLimit.ts +1 -21
- package/dist/templates/default/convex/users.ts +1 -49
- package/dist/templates/default/convex/validators.ts +0 -10
- package/dist/templates/default/package.json +1 -1
- package/dist/templates/default/scripts/README.md +24 -8
- package/dist/templates/default/scripts/clean.ts +3 -3
- package/dist/templates/default/scripts/gen-update-cert.mjs +3 -1
- package/dist/templates/default/src/app/(app)/_layout.tsx +15 -1
- package/dist/templates/default/src/app/(app)/auth/forgot-password.tsx +3 -0
- package/dist/templates/default/src/app/(app)/auth/reset-password.tsx +3 -0
- package/dist/templates/default/src/app/(app)/auth/sign-up.tsx +3 -1
- package/dist/templates/default/src/app/(app)/privacy.tsx +3 -2
- package/dist/templates/default/src/app/(app)/restore-account.tsx +2 -2
- package/dist/templates/default/src/app/(app)/sessions.tsx +15 -5
- package/dist/templates/default/src/components/ui/convex-error.tsx +0 -7
- package/dist/templates/default/src/constants/ui.ts +0 -11
- package/dist/templates/default/src/lib/dev-menu.ts +11 -2
- package/dist/templates/default/src/lib/preferences.ts +9 -0
- package/package.json +3 -2
|
@@ -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/
|
|
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}
|
|
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 `
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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] =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
54
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.5",
|
|
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
|
|
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",
|