@ramonclaudio/create-vexpo 0.1.0 → 0.1.2

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 (174) hide show
  1. package/README.md +10 -10
  2. package/dist/index.js +8 -7
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +9 -6
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +28 -15
  5. package/dist/templates/default/.eas/workflows/e2e-tests.yml +3 -2
  6. package/dist/templates/default/.eas/workflows/pr-preview.yml +12 -21
  7. package/dist/templates/default/.eas/workflows/release.yml +3 -7
  8. package/dist/templates/default/.eas/workflows/rollback.yml +54 -28
  9. package/dist/templates/default/.eas/workflows/rollout.yml +27 -33
  10. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +1 -5
  11. package/dist/templates/default/.eas/workflows/testflight.yml +3 -7
  12. package/dist/templates/default/.github/workflows/check.yml +20 -12
  13. package/dist/templates/default/.maestro/launch.yaml +19 -10
  14. package/dist/templates/default/AGENTS.md +25 -8
  15. package/dist/templates/default/DESIGN.md +14 -10
  16. package/dist/templates/default/README.md +83 -78
  17. package/dist/templates/default/SETUP.md +159 -152
  18. package/dist/templates/default/__tests__/convex/_auth-harness.test.ts +112 -0
  19. package/dist/templates/default/__tests__/convex/_harness.ts +132 -0
  20. package/dist/templates/default/__tests__/convex/appAttest.test.ts +172 -0
  21. package/dist/templates/default/__tests__/convex/appAttestStore.test.ts +48 -0
  22. package/dist/templates/default/__tests__/convex/pushTokens-remove.test.ts +106 -0
  23. package/dist/templates/default/__tests__/convex/pushTokens-upsert.test.ts +146 -0
  24. package/dist/templates/default/__tests__/convex/users-deleteAccount.test.ts +140 -0
  25. package/dist/templates/default/__tests__/convex/users-deleteAvatar.test.ts +104 -0
  26. package/dist/templates/default/__tests__/convex/users-getMe.test.ts +98 -0
  27. package/dist/templates/default/__tests__/convex/users-getUser.test.ts +120 -0
  28. package/dist/templates/default/__tests__/convex/users-hardDeleteExpired.test.ts +67 -0
  29. package/dist/templates/default/__tests__/convex/users-restoreAccount.test.ts +96 -0
  30. package/dist/templates/default/__tests__/convex/users-updateAvatar.test.ts +92 -0
  31. package/dist/templates/default/__tests__/convex/users-updateProfile.test.ts +126 -0
  32. package/dist/templates/default/__tests__/convex/webhook.test.ts +31 -0
  33. package/dist/templates/default/__tests__/lib/deep-link.test.ts +51 -6
  34. package/dist/templates/default/__tests__/lib/schemas.test.ts +205 -0
  35. package/dist/templates/default/__tests__/lib/text-style.test.ts +31 -0
  36. package/dist/templates/default/_env.example +7 -7
  37. package/dist/templates/default/_gitattributes +1 -1
  38. package/dist/templates/default/_gitignore +17 -2
  39. package/dist/templates/default/_npmrc +7 -0
  40. package/dist/templates/default/_oxlintrc.json +1 -1
  41. package/dist/templates/default/app-store/accessibility.config.json +20 -0
  42. package/dist/templates/default/app-store/privacy.config.json +27 -0
  43. package/dist/templates/default/app.config.ts +105 -33
  44. package/dist/templates/default/app.json +1 -9
  45. package/dist/templates/default/convex/_generated/api.d.ts +12 -0
  46. package/dist/templates/default/convex/admin.ts +0 -13
  47. package/dist/templates/default/convex/appAttest.ts +467 -0
  48. package/dist/templates/default/convex/appAttestStore.ts +141 -0
  49. package/dist/templates/default/convex/apple.ts +53 -0
  50. package/dist/templates/default/convex/auth.ts +6 -45
  51. package/dist/templates/default/convex/constants.ts +2 -7
  52. package/dist/templates/default/convex/crons.ts +12 -5
  53. package/dist/templates/default/convex/email.ts +4 -24
  54. package/dist/templates/default/convex/env.ts +0 -4
  55. package/dist/templates/default/convex/errors.ts +0 -7
  56. package/dist/templates/default/convex/functions.ts +0 -26
  57. package/dist/templates/default/convex/http.ts +3 -5
  58. package/dist/templates/default/convex/log.ts +2 -25
  59. package/dist/templates/default/convex/pushSender.ts +145 -0
  60. package/dist/templates/default/convex/pushTokens.ts +110 -13
  61. package/dist/templates/default/convex/rateLimit.ts +8 -39
  62. package/dist/templates/default/convex/schema.ts +48 -5
  63. package/dist/templates/default/convex/tsconfig.json +1 -0
  64. package/dist/templates/default/convex/users.ts +143 -61
  65. package/dist/templates/default/convex/validators.ts +1 -38
  66. package/dist/templates/default/convex/webhook.ts +1 -31
  67. package/dist/templates/default/convex.json +1 -2
  68. package/dist/templates/default/metro.config.js +9 -1
  69. package/dist/templates/default/package.json +67 -70
  70. package/dist/templates/default/plugins/README.md +5 -1
  71. package/dist/templates/default/scripts/README.md +9 -9
  72. package/dist/templates/default/scripts/_run.mjs +3 -20
  73. package/dist/templates/default/scripts/clean.ts +81 -69
  74. package/dist/templates/default/scripts/gen-update-cert.mjs +98 -0
  75. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home)/index.tsx +21 -6
  76. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home,search)/_layout.tsx +9 -8
  77. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(search)/index.tsx +26 -24
  78. package/dist/templates/default/{app → src/app}/(app)/(tabs)/_layout.tsx +3 -4
  79. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/_layout.tsx +10 -6
  80. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/index.tsx +81 -51
  81. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/preferences.tsx +72 -12
  82. package/dist/templates/default/src/app/(app)/_layout.tsx +147 -0
  83. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/_layout.tsx +4 -5
  84. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/forgot-password.tsx +15 -9
  85. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/reset-password.tsx +88 -14
  86. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-in.tsx +65 -35
  87. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-up.tsx +131 -196
  88. package/dist/templates/default/src/app/(app)/debug.tsx +479 -0
  89. package/dist/templates/default/{app → src/app}/(app)/help.tsx +76 -64
  90. package/dist/templates/default/{app → src/app}/(app)/linked.tsx +21 -27
  91. package/dist/templates/default/{app → src/app}/(app)/privacy.tsx +35 -8
  92. package/dist/templates/default/src/app/(app)/profile/change-password.tsx +264 -0
  93. package/dist/templates/default/{app/(app)/profile.tsx → src/app/(app)/profile/index.tsx} +179 -255
  94. package/dist/templates/default/src/app/(app)/restore-account.tsx +192 -0
  95. package/dist/templates/default/src/app/(app)/sessions.tsx +287 -0
  96. package/dist/templates/default/src/app/(app)/welcome.tsx +194 -0
  97. package/dist/templates/default/src/app/+native-intent.tsx +25 -0
  98. package/dist/templates/default/src/app/+not-found.tsx +43 -0
  99. package/dist/templates/default/{app → src/app}/_layout.tsx +28 -37
  100. package/dist/templates/default/src/components/auth/apple-button.tsx +51 -0
  101. package/dist/templates/default/{components → src/components}/auth/otp-verification.tsx +79 -58
  102. package/dist/templates/default/{components → src/components}/auth/password-field.tsx +74 -18
  103. package/dist/templates/default/src/components/auth/segmented-toggle.tsx +71 -0
  104. package/dist/templates/default/src/components/ui/content-unavailable.tsx +81 -0
  105. package/dist/templates/default/src/components/ui/convex-error.tsx +21 -0
  106. package/dist/templates/default/src/components/ui/error-boundary.tsx +89 -0
  107. package/dist/templates/default/{components → src/components}/ui/loading-screen.tsx +5 -4
  108. package/dist/templates/default/{components → src/components}/ui/material.tsx +50 -17
  109. package/dist/templates/default/src/components/ui/offline-banner.tsx +59 -0
  110. package/dist/templates/default/{components → src/components}/ui/prominent-button.tsx +8 -11
  111. package/dist/templates/default/{components → src/components}/ui/skeleton.tsx +31 -13
  112. package/dist/templates/default/src/components/ui/status-text.tsx +64 -0
  113. package/dist/templates/default/src/components/ui/update-banner.tsx +85 -0
  114. package/dist/templates/default/{constants → src/constants}/layout.ts +0 -6
  115. package/dist/templates/default/{constants → src/constants}/theme.ts +49 -64
  116. package/dist/templates/default/{constants → src/constants}/ui.ts +13 -4
  117. package/dist/templates/default/src/hooks/use-debounce.ts +12 -0
  118. package/dist/templates/default/src/hooks/use-deep-link.ts +51 -0
  119. package/dist/templates/default/src/hooks/use-delete-account.ts +35 -0
  120. package/dist/templates/default/src/hooks/use-motion-screen-options.ts +13 -0
  121. package/dist/templates/default/src/hooks/use-network.ts +34 -0
  122. package/dist/templates/default/{hooks → src/hooks}/use-notifications.ts +39 -30
  123. package/dist/templates/default/src/hooks/use-reduce-transparency.ts +30 -0
  124. package/dist/templates/default/{hooks → src/hooks}/use-theme.ts +0 -5
  125. package/dist/templates/default/src/lib/appAttest.ts +78 -0
  126. package/dist/templates/default/src/lib/assets.ts +9 -0
  127. package/dist/templates/default/src/lib/deep-link.ts +82 -0
  128. package/dist/templates/default/{lib → src/lib}/dev-menu.ts +0 -4
  129. package/dist/templates/default/{lib → src/lib}/device.ts +1 -13
  130. package/dist/templates/default/{lib → src/lib}/dynamic-font.ts +13 -10
  131. package/dist/templates/default/src/lib/dynamic-symbol-size.ts +33 -0
  132. package/dist/templates/default/src/lib/masks.ts +21 -0
  133. package/dist/templates/default/src/lib/native-state.ts +20 -0
  134. package/dist/templates/default/{lib → src/lib}/notifications.ts +7 -45
  135. package/dist/templates/default/{lib → src/lib}/preferences.ts +0 -2
  136. package/dist/templates/default/{lib → src/lib}/schemas.ts +19 -16
  137. package/dist/templates/default/src/lib/text-style.ts +20 -0
  138. package/dist/templates/default/{lib → src/lib}/updates.ts +0 -7
  139. package/dist/templates/default/store.config.json +1 -1
  140. package/dist/templates/default/tsconfig.json +3 -1
  141. package/dist/templates/default/vitest.config.ts +8 -1
  142. package/package.json +5 -5
  143. package/dist/templates/default/app/(app)/_layout.tsx +0 -73
  144. package/dist/templates/default/app/(app)/debug.tsx +0 -389
  145. package/dist/templates/default/app/(app)/sessions.tsx +0 -191
  146. package/dist/templates/default/app/(app)/welcome.tsx +0 -140
  147. package/dist/templates/default/app/+native-intent.tsx +0 -14
  148. package/dist/templates/default/app/+not-found.tsx +0 -51
  149. package/dist/templates/default/bun.lock +0 -1860
  150. package/dist/templates/default/components/auth/segmented-toggle.tsx +0 -47
  151. package/dist/templates/default/components/ui/convex-error.tsx +0 -32
  152. package/dist/templates/default/components/ui/error-boundary.tsx +0 -57
  153. package/dist/templates/default/components/ui/offline-banner.tsx +0 -58
  154. package/dist/templates/default/components/ui/status-text.tsx +0 -49
  155. package/dist/templates/default/components/ui/update-banner.tsx +0 -82
  156. package/dist/templates/default/fingerprint.config.js +0 -9
  157. package/dist/templates/default/hooks/use-debounce.ts +0 -20
  158. package/dist/templates/default/hooks/use-deep-link.ts +0 -43
  159. package/dist/templates/default/hooks/use-network.ts +0 -11
  160. package/dist/templates/default/lib/assets.ts +0 -17
  161. package/dist/templates/default/lib/deep-link.ts +0 -71
  162. package/dist/templates/default/patches/PR-368.patch +0 -91
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. /package/dist/templates/default/{hooks → src/hooks}/use-navigation-tracking.ts +0 -0
  165. /package/dist/templates/default/{hooks → src/hooks}/use-onboarding.ts +0 -0
  166. /package/dist/templates/default/{hooks → src/hooks}/use-reduced-motion.ts +0 -0
  167. /package/dist/templates/default/{hooks → src/hooks}/use-updates.ts +0 -0
  168. /package/dist/templates/default/{lib → src/lib}/a11y.ts +0 -0
  169. /package/dist/templates/default/{lib → src/lib}/app.ts +0 -0
  170. /package/dist/templates/default/{lib → src/lib}/auth-client.ts +0 -0
  171. /package/dist/templates/default/{lib → src/lib}/convex-auth.tsx +0 -0
  172. /package/dist/templates/default/{lib → src/lib}/env.ts +0 -0
  173. /package/dist/templates/default/{lib → src/lib}/haptics.ts +0 -0
  174. /package/dist/templates/default/{lib → src/lib}/storage.ts +0 -0
@@ -1,14 +1,6 @@
1
1
  /**
2
2
  * vexpo clean script.
3
3
  *
4
- * Wipes every regenerable cache and build artifact:
5
- * - Project artifacts: node_modules, bun.lock, ios/, .expo/, dist/, convex/_generated/, tsconfig.tsbuildinfo, coverage/, .vitest-cache/, expo-env.d.ts, bun-error.*, *.log
6
- * - .eas/ per-project state (keeps .eas/workflows/)
7
- * - .DS_Store files repo-wide
8
- * - $TMPDIR caches: metro-*, haste-map-*, react-*, node-compile-cache, expo-*, RN*
9
- * - System caches: ~/Library/Caches/CocoaPods, ~/.expo
10
- * - Xcode build outputs: ~/Library/Developer/Xcode/DerivedData/<project>-*
11
- *
12
4
  * Never wiped (user data / secrets):
13
5
  * - .env / .env.* (auth values)
14
6
  * - .p8 / .p12 / AuthKey_* / SubscriptionKey_* (Apple keys)
@@ -16,16 +8,12 @@
16
8
  * - .vexpo-manual-setup/ / .rebrand-backup/
17
9
  * - .setup-state.json (opt-in via --state)
18
10
  *
19
- * Then reinstalls deps via the detected package manager.
11
+ * Kept by default so reinstall is deterministic (opt in via --all):
12
+ * - package-lock.json / bun.lock / yarn.lock (lockfile; `npm ci` or
13
+ * `<pm> install --frozen-lockfile` when present)
14
+ * - convex/_generated/ (regenerated via `npx convex codegen` after --all)
20
15
  *
21
16
  * Uses macOS `trash` for every delete so anything wiped is recoverable.
22
- *
23
- * Usage:
24
- * bun run clean full wipe + install
25
- * bun run clean --metro just Metro/Haste/Babel caches (fast, no reinstall)
26
- * bun run clean --state also wipe .setup-state.json (next setup re-probes everything)
27
- * bun run clean --no-install wipe everything but skip the reinstall
28
- * bun run clean --help
29
17
  */
30
18
 
31
19
  import { spawn as nodeSpawn } from "node:child_process";
@@ -107,13 +95,7 @@ async function fileExists(p: string): Promise<boolean> {
107
95
 
108
96
  type PM = "bun" | "pnpm" | "yarn" | "npm";
109
97
 
110
- /**
111
- * Capture which PM ran this script BEFORE any wipes. Two signals:
112
- * 1. `npm_execpath`. every modern PM (npm/bun/pnpm/yarn) sets this to its
113
- * own binary path when running scripts. Most reliable.
114
- * 2. Lockfile presence. fallback when running outside `<pm> run` (e.g.
115
- * direct `node scripts/clean.ts`). Read while the lockfile still exists.
116
- */
98
+ // Capture which PM ran this script BEFORE any wipes: --all trashes the lockfile.
117
99
  async function detectPackageManager(): Promise<PM> {
118
100
  const execpath = (process.env.npm_execpath ?? "").toLowerCase();
119
101
  if (execpath.includes("bun")) return "bun";
@@ -126,15 +108,16 @@ async function detectPackageManager(): Promise<PM> {
126
108
  return "npm";
127
109
  }
128
110
 
129
- function installCmdFor(pm: PM): string {
130
- return `${pm} install`;
111
+ function installCmdFor(pm: PM, frozen: boolean): string {
112
+ if (!frozen) return `${pm} install`;
113
+ // npm uses `ci` for frozen installs, every other PM has `--frozen-lockfile`.
114
+ if (pm === "npm") return "npm ci";
115
+ return `${pm} install --frozen-lockfile`;
131
116
  }
132
117
 
133
118
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
134
119
  process.chdir(REPO_ROOT);
135
120
 
136
- // ─── Output ──────────────────────────────────────────────────────────────────
137
-
138
121
  const RESET = "\x1b[0m";
139
122
  const BOLD = "\x1b[1m";
140
123
  const DIM = "\x1b[2m";
@@ -164,27 +147,32 @@ function section(title: string): void {
164
147
  line(`\n${BOLD}${VIOLET}${title}${RESET} ${DIM}${fill}${RESET}`);
165
148
  }
166
149
 
167
- // ─── Args ────────────────────────────────────────────────────────────────────
168
-
169
150
  const HELP = `${BOLD}vexpo clean${RESET}
170
151
 
171
152
  ${BOLD}Usage:${RESET}
172
- ${DIM}bun run clean${RESET} full wipe + bun install
173
- ${DIM}bun run clean --metro${RESET} just Metro/Haste/Babel caches
174
- ${DIM}bun run clean --state${RESET} also wipe .setup-state.json
175
- ${DIM}bun run clean --no-install${RESET} wipe everything but skip reinstall
176
- ${DIM}bun run clean --help${RESET}
177
-
178
- The full wipe removes node_modules, lockfile, ios/, .expo/, dist/,
179
- convex/_generated/, tsbuildinfo, coverage/, .vitest-cache/,
180
- expo-env.d.ts, bun-error.*, *.log, .eas/ (except workflows/),
181
- all .DS_Store files, $TMPDIR Metro/Haste/React/expo/RN caches,
182
- ~/Library/Caches/CocoaPods, ~/.expo, and the Xcode DerivedData
183
- subfolder for this project. Never touches .env files, Apple keys,
184
- store.config.json, .vexpo-manual-setup/, or .rebrand-backup/.
153
+ ${DIM}npm run clean${RESET} wipe caches, keep lockfile, frozen install
154
+ ${DIM}npm run clean --all${RESET} also wipe lockfile + convex/_generated
155
+ ${DIM}npm run clean --metro${RESET} just Metro/Haste/Babel caches
156
+ ${DIM}npm run clean --state${RESET} also wipe .setup-state.json
157
+ ${DIM}npm run clean --no-install${RESET} wipe everything but skip reinstall
158
+ ${DIM}npm run clean --help${RESET}
159
+
160
+ The default wipe removes node_modules, ios/, .expo/, dist/,
161
+ tsbuildinfo, coverage/, .vitest-cache/, expo-env.d.ts, bun-error.*,
162
+ *.log, .eas/ (except workflows/), all .DS_Store files, $TMPDIR
163
+ Metro/Haste/React/expo/RN caches, ~/Library/Caches/CocoaPods, ~/.expo,
164
+ and the Xcode DerivedData subfolder for this project. The lockfile
165
+ and convex/_generated/ are kept so reinstall is deterministic
166
+ (${DIM}npm ci${RESET}). Never touches .env files,
167
+ Apple keys, store.config.json, .vexpo-manual-setup/, or .rebrand-backup/.
168
+
169
+ ${BOLD}--all${RESET} additionally wipes the lockfile and convex/_generated/.
170
+ Reinstall resolves transitives fresh and ${DIM}npx convex codegen${RESET} runs
171
+ after install to rebuild the Convex bindings. Use when the lockfile
172
+ is suspect or you want a true clean-slate reinstall.
185
173
 
186
174
  ${BOLD}--state${RESET} additionally wipes .setup-state.json so the next
187
- ${DIM}bun run setup${RESET} re-probes every phase against external services
175
+ ${DIM}npm run setup${RESET} re-probes every phase against external services
188
176
  (slower, but the cure when state has drifted from reality).
189
177
 
190
178
  Bundlers (Metro, expo CLI, react-native start, Watchman) are stopped
@@ -193,13 +181,20 @@ held open. ${BOLD}convex dev${RESET} is left alone (it's your data layer, not a
193
181
  bundler); restart it manually if it misbehaves after a full wipe.
194
182
  `;
195
183
 
196
- let args: { metro?: boolean; state?: boolean; "no-install"?: boolean; help?: boolean };
184
+ let args: {
185
+ metro?: boolean;
186
+ state?: boolean;
187
+ all?: boolean;
188
+ "no-install"?: boolean;
189
+ help?: boolean;
190
+ };
197
191
  try {
198
192
  args = parseArgs({
199
193
  args: process.argv.slice(2),
200
194
  options: {
201
195
  metro: { type: "boolean", default: false },
202
196
  state: { type: "boolean", default: false },
197
+ all: { type: "boolean", default: false },
203
198
  "no-install": { type: "boolean", default: false },
204
199
  help: { type: "boolean", short: "h", default: false },
205
200
  },
@@ -215,8 +210,6 @@ if (args.help) {
215
210
  process.exit(0);
216
211
  }
217
212
 
218
- // ─── Helpers ─────────────────────────────────────────────────────────────────
219
-
220
213
  async function pathExists(p: string): Promise<boolean> {
221
214
  try {
222
215
  await stat(p);
@@ -246,7 +239,6 @@ async function trashPaths(paths: string[]): Promise<void> {
246
239
 
247
240
  async function expandGlob(dir: string, pattern: string): Promise<string[]> {
248
241
  if (!(await pathExists(dir))) return [];
249
- // Convert simple glob (only * supported) to regex. Sufficient for our patterns.
250
242
  const re = new RegExp(
251
243
  "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
252
244
  );
@@ -254,8 +246,6 @@ async function expandGlob(dir: string, pattern: string): Promise<string[]> {
254
246
  return entries.filter((e) => re.test(e)).map((e) => `${dir}/${e}`);
255
247
  }
256
248
 
257
- // ─── Targets ─────────────────────────────────────────────────────────────────
258
-
259
249
  const REPO = REPO_ROOT;
260
250
  const TMPDIR = process.env.TMPDIR?.replace(/\/$/, "") ?? "/tmp";
261
251
  const HOME = homedir();
@@ -273,37 +263,32 @@ async function readPkgName(): Promise<string> {
273
263
 
274
264
  const PROJECT_TARGETS = [
275
265
  "node_modules",
276
- "bun.lock",
277
266
  "ios",
278
267
  ".expo",
279
268
  "dist",
280
- "convex/_generated",
281
269
  "tsconfig.tsbuildinfo",
282
270
  "coverage",
283
271
  ".vitest-cache",
284
272
  "expo-env.d.ts",
285
273
  ];
286
274
 
287
- // Globs evaluated at REPO root. bun-error.* and *.log are cheap to wipe and
288
- // almost never wanted across runs.
275
+ // Wiped only with --all. Default leaves these alone: the lockfile stays the
276
+ // source of truth (frozen install) and convex/_generated needs a deployment
277
+ // round-trip via `npx convex codegen` to come back.
278
+ const PROJECT_TARGETS_ALL = ["bun.lock", "package-lock.json", "convex/_generated"];
279
+
289
280
  const PROJECT_GLOBS = ["bun-error.*", "*.log"];
290
281
 
291
282
  const TMP_GLOBS = ["metro-*", "haste-map-*", "react-*", "node-compile-cache", "expo-*", "RN*"];
292
283
 
293
- // ─── Steps ───────────────────────────────────────────────────────────────────
294
-
295
284
  /**
296
285
  * Stop bundlers before wiping their caches. macOS `trash` silently skips
297
286
  * files held open by a running process, so caches survive the wipe and the
298
287
  * bundler restarts onto stale state. Killing first prevents that.
299
- *
300
- * Bundlers are killed automatically. `convex dev` is the user's data layer,
301
- * not a bundler. left alone, with a warning.
302
288
  */
303
289
  async function stepStopBundlers(): Promise<void> {
304
290
  section("Stop bundlers");
305
291
 
306
- // Patterns are pgrep -f extended regex over the full command line.
307
292
  // Order: kill the parent CLI first so it can tear down its child Metro.
308
293
  const targets: { pattern: string; name: string }[] = [
309
294
  { pattern: "node .*\\.bin/expo (run:|start)", name: "expo CLI" },
@@ -321,7 +306,6 @@ async function stepStopBundlers(): Promise<void> {
321
306
  killed += pids.length;
322
307
  }
323
308
 
324
- // Watchman has its own clean shutdown. No-op if not installed (exit 127).
325
309
  const wm = await spawn(["watchman", "shutdown-server"], {
326
310
  stdio: ["ignore", "ignore", "ignore"],
327
311
  }).exited;
@@ -361,9 +345,10 @@ async function stepMetroCachesOnly(): Promise<void> {
361
345
  ok(`trashed ${matches.length} cache director${matches.length === 1 ? "y" : "ies"}`);
362
346
  }
363
347
 
364
- async function stepProjectArtifacts(): Promise<void> {
348
+ async function stepProjectArtifacts(all: boolean): Promise<void> {
365
349
  section("Project artifacts");
366
- const targets = PROJECT_TARGETS.map((t) => `${REPO}/${t}`);
350
+ const names = all ? [...PROJECT_TARGETS, ...PROJECT_TARGETS_ALL] : PROJECT_TARGETS;
351
+ const targets = names.map((t) => `${REPO}/${t}`);
367
352
  for (const pattern of PROJECT_GLOBS) {
368
353
  targets.push(...(await expandGlob(REPO, pattern)));
369
354
  }
@@ -477,8 +462,6 @@ async function stepExpoCache(): Promise<void> {
477
462
  nop("~/.expo not present");
478
463
  return;
479
464
  }
480
- // .expo holds the user-level Expo cache (devices.json, telemetry, sdk
481
- // metadata). Safe to wipe; Expo regenerates on next CLI invocation.
482
465
  await trashPaths([path]);
483
466
  ok("trashed ~/.expo");
484
467
  }
@@ -491,19 +474,46 @@ async function stepSetupState(): Promise<void> {
491
474
  return;
492
475
  }
493
476
  await trashPaths([path]);
494
- ok("trashed .setup-state.json (next `bun run setup` re-probes every phase)");
477
+ ok("trashed .setup-state.json (next `npm run setup` re-probes every phase)");
495
478
  }
496
479
 
497
480
  async function stepInstall(pm: PM): Promise<void> {
498
481
  section("Reinstall");
499
- const cmd = installCmdFor(pm).split(" ");
482
+ // Frozen install when a lockfile is on disk: deterministic, no transitive drift.
483
+ // After --all the lockfile is gone and bun resolves fresh.
484
+ const frozen = await pathExists(`${REPO}/${lockfileFor(pm)}`);
485
+ const cmd = installCmdFor(pm, frozen).split(" ");
500
486
  const proc = spawn(cmd, { stdio: ["inherit", "inherit", "inherit"] });
501
487
  const code = await proc.exited;
502
488
  if (code !== 0) throw new Error(`${cmd.join(" ")} exited with code ${code}`);
503
489
  ok(cmd.join(" "));
504
490
  }
505
491
 
506
- // ─── Entry ───────────────────────────────────────────────────────────────────
492
+ function lockfileFor(pm: PM): string {
493
+ if (pm === "bun") return "bun.lock";
494
+ if (pm === "pnpm") return "pnpm-lock.yaml";
495
+ if (pm === "yarn") return "yarn.lock";
496
+ return "package-lock.json";
497
+ }
498
+
499
+ async function stepConvexCodegen(): Promise<void> {
500
+ section("Convex codegen");
501
+ if (await pathExists(`${REPO}/convex/_generated`)) {
502
+ nop("convex/_generated/ present (skipped)");
503
+ return;
504
+ }
505
+ // No bun.lock or convex/_generated on disk after --all. `convex codegen`
506
+ // talks to the deployment to rebuild the TypeScript bindings; skip and warn
507
+ // if the env isn't wired so this never blocks a clean.
508
+ const cmd = ["npx", "convex", "codegen"];
509
+ const proc = spawn(cmd, { stdio: ["inherit", "inherit", "inherit"] });
510
+ const code = await proc.exited;
511
+ if (code !== 0) {
512
+ bad(`${cmd.join(" ")} exited with code ${code} (run it manually once Convex is reachable)`);
513
+ return;
514
+ }
515
+ ok(cmd.join(" "));
516
+ }
507
517
 
508
518
  // Wrapped in an async IIFE so the file works under both ESM (top-level await
509
519
  // supported) and CJS-via-tsx (no top-level await).
@@ -514,11 +524,12 @@ void (async () => {
514
524
  await stepStopBundlers();
515
525
  await stepMetroCachesOnly();
516
526
  } else {
517
- // Capture PM BEFORE any wipes; stepProjectArtifacts trashes the lockfile.
527
+ // Capture PM BEFORE any wipes; --all trashes the lockfile.
518
528
  const pm = await detectPackageManager();
519
529
  const pkgName = await readPkgName();
530
+ const all = args.all === true;
520
531
  await stepStopBundlers();
521
- await stepProjectArtifacts();
532
+ await stepProjectArtifacts(all);
522
533
  await stepEasState();
523
534
  await stepDsStores();
524
535
  await stepTmpdirCaches();
@@ -528,6 +539,7 @@ void (async () => {
528
539
  if (args.state) await stepSetupState();
529
540
  if (!args["no-install"]) {
530
541
  await stepInstall(pm);
542
+ if (all) await stepConvexCodegen();
531
543
  } else {
532
544
  yep(`--no-install passed; skipping ${pm} install`);
533
545
  }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-shot setup for OTA update code signing.
4
+ *
5
+ * npm run updates:gen-cert -- --name "Acme Inc."
6
+ *
7
+ * Wraps `npx expo-updates codesigning:generate` with vexpo's conventions:
8
+ * - cert goes to `./certs/certificate.pem` (committed; verified on-device)
9
+ * - private key goes to `../keys/private-key.pem` (NOT committed; lives
10
+ * as an EAS file-type env var in CI)
11
+ * - validity duration: 10 years (long enough to outlive most apps)
12
+ *
13
+ * Once the cert exists, `app.config.ts` automatically wires the
14
+ * `codeSigningCertificate` / `codeSigningMetadata` block. No manual edits.
15
+ *
16
+ * Two more steps, printed at the end, finish CI wiring:
17
+ * 1. `eas env:create --environment production --visibility secret \
18
+ * --type file --name EAS_UPDATE_PRIVATE_KEY \
19
+ * --value <path-to-private-key.pem>`
20
+ * 2. Confirm `.eas/workflows/deploy-production.yml`'s `update_ios` job
21
+ * passes `private_key_path: "$EAS_UPDATE_PRIVATE_KEY"` (ships wired).
22
+ *
23
+ * After that every OTA bundle is signed locally during `eas update` and
24
+ * verified on-device before install. A compromised EAS account or CDN
25
+ * cannot ship arbitrary JS.
26
+ *
27
+ * https://docs.expo.dev/eas-update/code-signing/
28
+ */
29
+
30
+ import { existsSync } from "node:fs";
31
+ import { resolve, dirname } from "node:path";
32
+ import { spawnSync } from "node:child_process";
33
+ import { fileURLToPath } from "node:url";
34
+ import { createInterface } from "node:readline/promises";
35
+
36
+ const HERE = dirname(fileURLToPath(import.meta.url));
37
+ const PROJECT = resolve(HERE, "..");
38
+ const CERT = resolve(PROJECT, "certs", "certificate.pem");
39
+ const KEY = resolve(PROJECT, "..", "keys", "private-key.pem");
40
+
41
+ const args = process.argv.slice(2);
42
+ const flagIndex = args.indexOf("--name");
43
+ const flagValue = flagIndex >= 0 ? args[flagIndex + 1] : undefined;
44
+
45
+ if (existsSync(CERT)) {
46
+ console.error(`Certificate already exists at ${CERT}`);
47
+ console.error("Delete it (and the matching private key) before regenerating.");
48
+ process.exit(1);
49
+ }
50
+
51
+ const commonName = await resolveCommonName(flagValue);
52
+
53
+ const result = spawnSync(
54
+ "npx",
55
+ [
56
+ "expo-updates",
57
+ "codesigning:generate",
58
+ "--certificate-output-directory",
59
+ "certs",
60
+ "--key-output-directory",
61
+ "../keys",
62
+ "--certificate-validity-duration-years",
63
+ "10",
64
+ "--certificate-common-name",
65
+ commonName,
66
+ ],
67
+ { cwd: PROJECT, stdio: "inherit" },
68
+ );
69
+
70
+ if (result.status !== 0) {
71
+ process.exit(result.status ?? 1);
72
+ }
73
+
74
+ console.log("\n--- Next steps ---");
75
+ console.log(`1. Commit ${CERT.replace(`${PROJECT}/`, "")} (it's a public cert).`);
76
+ console.log(`2. Upload the private key to EAS as a file-type secret:`);
77
+ console.log(` eas env:create --environment production --visibility secret \\`);
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.`);
80
+ console.log(
81
+ `4. The next \`expo prebuild\` picks up the cert automatically. Run \`npm run prebuild\`.`,
82
+ );
83
+
84
+ async function resolveCommonName(provided) {
85
+ if (provided && provided.trim().length > 0) return provided.trim();
86
+ if (!process.stdin.isTTY) {
87
+ console.error("Provide --name '<Organization Name>' when running non-interactively.");
88
+ process.exit(1);
89
+ }
90
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
91
+ const answer = (await rl.question("Certificate common name (organization): ")).trim();
92
+ rl.close();
93
+ if (answer.length === 0) {
94
+ console.error("Common name is required.");
95
+ process.exit(1);
96
+ }
97
+ return answer;
98
+ }
@@ -1,6 +1,14 @@
1
- import { Host, ScrollView, VStack, Text, ContentUnavailableView } from "@expo/ui/swift-ui";
2
- import { foregroundStyle, kerning, padding, frame, tint } from "@expo/ui/swift-ui/modifiers";
1
+ import { Host, ScrollView, VStack, Text } from "@expo/ui/swift-ui";
2
+ import {
3
+ foregroundStyle,
4
+ kerning,
5
+ padding,
6
+ frame,
7
+ refreshable,
8
+ tint,
9
+ } from "@expo/ui/swift-ui/modifiers";
3
10
 
11
+ import { ContentUnavailable } from "@/components/ui/content-unavailable";
4
12
  import { authClient } from "@/lib/auth-client";
5
13
  import { useDynamicFont } from "@/lib/dynamic-font";
6
14
  import { useColors } from "@/hooks/use-theme";
@@ -8,14 +16,18 @@ import { useColors } from "@/hooks/use-theme";
8
16
  export default function HomeScreen() {
9
17
  const dfont = useDynamicFont();
10
18
  const colors = useColors();
11
- const { data: session } = authClient.useSession();
19
+ const { data: session, refetch } = authClient.useSession();
12
20
 
13
21
  const name = session?.user?.name?.split(" ")[0] ?? "there";
14
22
  const now = new Date();
15
23
 
24
+ const onRefresh = async () => {
25
+ await refetch?.();
26
+ };
27
+
16
28
  return (
17
- <Host style={{ flex: 1 }}>
18
- <ScrollView modifiers={[tint(colors.primary as string)]}>
29
+ <Host testID="home-screen" style={{ flex: 1 }}>
30
+ <ScrollView modifiers={[tint(colors.primary as string), refreshable(onRefresh)]}>
19
31
  <VStack
20
32
  spacing={24}
21
33
  alignment="leading"
@@ -27,18 +39,21 @@ export default function HomeScreen() {
27
39
  modifiers={[frame({ maxWidth: Infinity, alignment: "leading" })]}
28
40
  >
29
41
  <Text
42
+ testID="home-date"
30
43
  modifiers={[dfont({ size: 14 }), foregroundStyle(colors.mutedForeground as string)]}
31
44
  >
32
45
  <Text date={now} dateStyle="date" />
33
46
  </Text>
34
47
  <Text
48
+ testID="home-greeting"
35
49
  modifiers={[dfont({ size: 32, weight: "bold", design: "rounded" }), kerning(-0.5)]}
36
50
  >
37
51
  Hey, {name}
38
52
  </Text>
39
53
  </VStack>
40
54
 
41
- <ContentUnavailableView
55
+ <ContentUnavailable
56
+ testID="home-empty"
42
57
  title="Nothing here yet"
43
58
  systemImage="square.dashed"
44
59
  description="Home screen is ready to build."
@@ -1,7 +1,7 @@
1
- import { Stack, useSegments } from "expo-router";
1
+ import { Stack } from "expo-router";
2
2
 
3
3
  import { useColors } from "@/hooks/use-theme";
4
- import { useReducedMotion } from "@/hooks/use-reduced-motion";
4
+ import { useMotionScreenOptions } from "@/hooks/use-motion-screen-options";
5
5
  import { HeaderTint } from "@/constants/theme";
6
6
  import { FontFamily } from "@/constants/layout";
7
7
 
@@ -10,15 +10,18 @@ export const unstable_settings = {
10
10
  search: { anchor: "index" },
11
11
  };
12
12
 
13
- export default function SharedLayout() {
13
+ // `segment` is the active group name in the comma-shared array, e.g.
14
+ // `(home)` or `(search)`. Per the SDK 56 shared-routes docs, this is the
15
+ // canonical entry point — no `useSegments()` cast, no magic index.
16
+ export default function SharedLayout({ segment }: { segment: string }) {
14
17
  const colors = useColors();
15
- const reduceMotion = useReducedMotion();
16
- const segments = useSegments() as string[];
17
- const isSearch = segments[2] === "(search)";
18
+ const motion = useMotionScreenOptions("default");
19
+ const isSearch = segment === "(search)";
18
20
 
19
21
  return (
20
22
  <Stack
21
23
  screenOptions={{
24
+ ...motion,
22
25
  headerTintColor: HeaderTint as string,
23
26
  headerBlurEffect: "none",
24
27
  headerShadowVisible: false,
@@ -26,8 +29,6 @@ export default function SharedLayout() {
26
29
  headerLargeStyle: { backgroundColor: "transparent" },
27
30
  headerTitleStyle: { fontFamily: FontFamily.semiBold },
28
31
  headerLargeTitleStyle: { fontFamily: FontFamily.bold },
29
- animation: reduceMotion ? "fade" : "default",
30
- animationDuration: reduceMotion ? 150 : undefined,
31
32
  }}
32
33
  >
33
34
  <Stack.Screen
@@ -1,17 +1,8 @@
1
1
  import { useMemo, useState } from "react";
2
2
  import { router, Stack } from "expo-router";
3
+ import { Host, ScrollView, Button, Text, VStack, HStack, Spacer, Image } from "@expo/ui/swift-ui";
3
4
  import {
4
- Host,
5
- ScrollView,
6
- Button,
7
- Text,
8
- VStack,
9
- HStack,
10
- Spacer,
11
- Image,
12
- ContentUnavailableView,
13
- } from "@expo/ui/swift-ui";
14
- import {
5
+ accessibilityHidden,
15
6
  background,
16
7
  buttonStyle,
17
8
  clipShape,
@@ -24,10 +15,12 @@ import {
24
15
  import type { SFSymbol } from "sf-symbols-typescript";
25
16
 
26
17
  import { useDynamicFont } from "@/lib/dynamic-font";
18
+ import { useSymbolSize } from "@/lib/dynamic-symbol-size";
27
19
  import { useColors } from "@/hooks/use-theme";
28
20
  import { useDebounce } from "@/hooks/use-debounce";
29
21
  import { useDebugEnabled } from "@/lib/preferences";
30
22
  import { haptics } from "@/lib/haptics";
23
+ import { ContentUnavailable } from "@/components/ui/content-unavailable";
31
24
 
32
25
  type Destination = {
33
26
  title: string;
@@ -114,12 +107,13 @@ function score(d: Destination, query: string): number {
114
107
  if (title.startsWith(q)) return 80;
115
108
  if (title.includes(q)) return 60;
116
109
  if (d.subtitle.toLowerCase().includes(q)) return 40;
117
- if (d.keywords.includes(q)) return 20;
110
+ if (d.keywords.toLowerCase().includes(q)) return 20;
118
111
  return 0;
119
112
  }
120
113
 
121
114
  export default function SearchScreen() {
122
115
  const dfont = useDynamicFont();
116
+ const symbolSize = useSymbolSize();
123
117
  const colors = useColors();
124
118
  const [raw, setRaw] = useState("");
125
119
  const query = useDebounce(raw, DEBOUNCE_MS);
@@ -133,11 +127,11 @@ export default function SearchScreen() {
133
127
  const results = useMemo(() => {
134
128
  if (query.trim().length === 0) return destinations;
135
129
  const trimmed = query.trim();
136
- return destinations
137
- .map((d) => ({ d, s: score(d, trimmed) }))
138
- .filter(({ s }) => s > 0)
139
- .toSorted((a, b) => b.s - a.s)
140
- .map(({ d }) => d);
130
+ const scored = destinations.map((d) => ({ d, s: score(d, trimmed) })).filter(({ s }) => s > 0);
131
+ // `.toSorted` is ES2023 and not in Hermes V1 (default in SDK 56); `.sort`
132
+ // mutates in place, and `.filter` above already returned a fresh array.
133
+ scored.sort((a, b) => b.s - a.s);
134
+ return scored.map(({ d }) => d);
141
135
  }, [query, destinations]);
142
136
 
143
137
  const open = (href: Destination["href"]) => {
@@ -155,10 +149,10 @@ export default function SearchScreen() {
155
149
  <>
156
150
  <Stack.SearchBar
157
151
  placement="automatic"
158
- placeholder="Search the app"
152
+ placeholder="Search screens"
159
153
  onChangeText={(e) => setRaw(e.nativeEvent.text)}
160
154
  />
161
- <Host style={{ flex: 1, backgroundColor: colors.background }}>
155
+ <Host testID="search-screen" style={{ flex: 1, backgroundColor: colors.background }}>
162
156
  <ScrollView
163
157
  modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
164
158
  >
@@ -168,7 +162,8 @@ export default function SearchScreen() {
168
162
  modifiers={[padding({ horizontal: 24, top: 16, bottom: 40 })]}
169
163
  >
170
164
  {results.length === 0 ? (
171
- <ContentUnavailableView
165
+ <ContentUnavailable
166
+ testID="search-empty"
172
167
  title="No results"
173
168
  systemImage="magnifyingglass"
174
169
  description={`Nothing matches "${query.trim()}"`}
@@ -181,9 +176,10 @@ export default function SearchScreen() {
181
176
  {results.map((d) => (
182
177
  <Button
183
178
  key={d.href as string}
179
+ testID={`search-result-${d.title.toLowerCase().replace(/\s+/g, "-")}`}
184
180
  modifiers={[
185
181
  buttonStyle("plain"),
186
- frame({ maxWidth: 10000 }),
182
+ frame({ maxWidth: Infinity }),
187
183
  background(colors.muted as string),
188
184
  clipShape("capsule"),
189
185
  ]}
@@ -193,11 +189,16 @@ export default function SearchScreen() {
193
189
  spacing={14}
194
190
  alignment="center"
195
191
  modifiers={[
196
- frame({ maxWidth: 10000 }),
192
+ frame({ maxWidth: Infinity }),
197
193
  padding({ horizontal: 16, vertical: 12 }),
198
194
  ]}
199
195
  >
200
- <Image systemName={d.icon} size={20} color={colors.foreground as string} />
196
+ <Image
197
+ systemName={d.icon}
198
+ size={symbolSize(20)}
199
+ color={colors.foreground as string}
200
+ modifiers={[accessibilityHidden(true)]}
201
+ />
201
202
  <VStack alignment="leading" spacing={2}>
202
203
  <Text
203
204
  modifiers={[
@@ -219,8 +220,9 @@ export default function SearchScreen() {
219
220
  <Spacer />
220
221
  <Image
221
222
  systemName="chevron.right"
222
- size={13}
223
+ size={symbolSize(13)}
223
224
  color={colors.mutedForeground as string}
225
+ modifiers={[accessibilityHidden(true)]}
224
226
  />
225
227
  </HStack>
226
228
  </Button>
@@ -6,7 +6,7 @@ import { haptics } from "@/lib/haptics";
6
6
  import { LoadingScreen } from "@/components/ui/loading-screen";
7
7
 
8
8
  export function SuspenseFallback() {
9
- return <LoadingScreen />;
9
+ return <LoadingScreen testID="tabs-loading" />;
10
10
  }
11
11
 
12
12
  export default function TabLayout() {
@@ -47,12 +47,11 @@ export default function TabLayout() {
47
47
  >
48
48
  <NativeTabs.Trigger.Icon sf={{ default: "house", selected: "house.fill" }} />
49
49
  <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
50
- <NativeTabs.Trigger.Badge hidden />
51
50
  </NativeTabs.Trigger>
52
51
 
53
52
  <NativeTabs.Trigger
54
53
  name="settings"
55
- contentStyle={{ backgroundColor: colors.background as string }}
54
+ contentStyle={{ backgroundColor: colors.background }}
56
55
  listeners={{
57
56
  tabPress: () => haptics.light(),
58
57
  }}
@@ -64,7 +63,7 @@ export default function TabLayout() {
64
63
  <NativeTabs.Trigger
65
64
  name="(search)"
66
65
  role="search"
67
- contentStyle={{ backgroundColor: colors.background as string }}
66
+ contentStyle={{ backgroundColor: colors.background }}
68
67
  listeners={{
69
68
  tabPress: () => haptics.light(),
70
69
  }}