@ramonclaudio/create-vexpo 0.1.0

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 +50 -0
  2. package/dist/index.js +183 -0
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
  5. package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
  6. package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
  7. package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
  8. package/dist/templates/default/.eas/workflows/release.yml +44 -0
  9. package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
  10. package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
  11. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
  12. package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
  13. package/dist/templates/default/.github/workflows/check.yml +28 -0
  14. package/dist/templates/default/.maestro/launch.yaml +18 -0
  15. package/dist/templates/default/AGENTS.md +79 -0
  16. package/dist/templates/default/DESIGN.md +331 -0
  17. package/dist/templates/default/LICENSE +21 -0
  18. package/dist/templates/default/README.md +153 -0
  19. package/dist/templates/default/SETUP.md +618 -0
  20. package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
  21. package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
  22. package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
  23. package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
  24. package/dist/templates/default/_easignore +22 -0
  25. package/dist/templates/default/_editorconfig +9 -0
  26. package/dist/templates/default/_env.example +34 -0
  27. package/dist/templates/default/_fingerprintignore +24 -0
  28. package/dist/templates/default/_gitattributes +7 -0
  29. package/dist/templates/default/_gitignore +69 -0
  30. package/dist/templates/default/_oxfmtrc.json +3 -0
  31. package/dist/templates/default/_oxlintrc.json +34 -0
  32. package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
  33. package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
  34. package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
  35. package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
  36. package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
  37. package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
  38. package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
  39. package/dist/templates/default/app/(app)/_layout.tsx +73 -0
  40. package/dist/templates/default/app/(app)/debug.tsx +389 -0
  41. package/dist/templates/default/app/(app)/help.tsx +254 -0
  42. package/dist/templates/default/app/(app)/linked.tsx +116 -0
  43. package/dist/templates/default/app/(app)/privacy.tsx +159 -0
  44. package/dist/templates/default/app/(app)/profile.tsx +915 -0
  45. package/dist/templates/default/app/(app)/sessions.tsx +191 -0
  46. package/dist/templates/default/app/(app)/welcome.tsx +140 -0
  47. package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
  48. package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
  49. package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
  50. package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
  51. package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
  52. package/dist/templates/default/app/+native-intent.tsx +14 -0
  53. package/dist/templates/default/app/+not-found.tsx +51 -0
  54. package/dist/templates/default/app/_layout.tsx +102 -0
  55. package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
  56. package/dist/templates/default/app-store/screenshots/README.md +13 -0
  57. package/dist/templates/default/app.config.ts +201 -0
  58. package/dist/templates/default/app.json +11 -0
  59. package/dist/templates/default/assets/brand-icon-dark.png +0 -0
  60. package/dist/templates/default/assets/brand-icon-light.png +0 -0
  61. package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
  62. package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
  63. package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
  64. package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
  65. package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
  66. package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
  67. package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
  68. package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
  69. package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
  70. package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
  71. package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
  72. package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
  73. package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
  74. package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
  75. package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
  76. package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
  77. package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
  78. package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
  79. package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
  80. package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
  81. package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
  82. package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
  83. package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
  84. package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
  85. package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
  86. package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
  87. package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
  88. package/dist/templates/default/assets/icon.png +0 -0
  89. package/dist/templates/default/assets/sounds/notification.wav +0 -0
  90. package/dist/templates/default/assets/splash-image-dark.png +0 -0
  91. package/dist/templates/default/assets/splash-image-light.png +0 -0
  92. package/dist/templates/default/bun.lock +1860 -0
  93. package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
  94. package/dist/templates/default/components/auth/password-field.tsx +121 -0
  95. package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
  96. package/dist/templates/default/components/ui/convex-error.tsx +32 -0
  97. package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
  98. package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
  99. package/dist/templates/default/components/ui/material.tsx +94 -0
  100. package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
  101. package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
  102. package/dist/templates/default/components/ui/skeleton.tsx +107 -0
  103. package/dist/templates/default/components/ui/status-text.tsx +49 -0
  104. package/dist/templates/default/components/ui/update-banner.tsx +82 -0
  105. package/dist/templates/default/constants/layout.ts +102 -0
  106. package/dist/templates/default/constants/theme.ts +401 -0
  107. package/dist/templates/default/constants/ui.ts +77 -0
  108. package/dist/templates/default/convex/_generated/api.d.ts +77 -0
  109. package/dist/templates/default/convex/_generated/api.js +23 -0
  110. package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
  111. package/dist/templates/default/convex/_generated/server.d.ts +143 -0
  112. package/dist/templates/default/convex/_generated/server.js +93 -0
  113. package/dist/templates/default/convex/admin.ts +102 -0
  114. package/dist/templates/default/convex/auth.config.ts +6 -0
  115. package/dist/templates/default/convex/auth.ts +335 -0
  116. package/dist/templates/default/convex/constants.ts +46 -0
  117. package/dist/templates/default/convex/convex.config.ts +11 -0
  118. package/dist/templates/default/convex/crons.ts +42 -0
  119. package/dist/templates/default/convex/email.ts +109 -0
  120. package/dist/templates/default/convex/env.ts +31 -0
  121. package/dist/templates/default/convex/errors.ts +33 -0
  122. package/dist/templates/default/convex/functions.ts +54 -0
  123. package/dist/templates/default/convex/http.ts +176 -0
  124. package/dist/templates/default/convex/log.ts +81 -0
  125. package/dist/templates/default/convex/pushTokens.ts +114 -0
  126. package/dist/templates/default/convex/rateLimit.ts +92 -0
  127. package/dist/templates/default/convex/schema.ts +28 -0
  128. package/dist/templates/default/convex/tsconfig.json +18 -0
  129. package/dist/templates/default/convex/users.ts +279 -0
  130. package/dist/templates/default/convex/validators.ts +74 -0
  131. package/dist/templates/default/convex/webhook.ts +193 -0
  132. package/dist/templates/default/convex.json +6 -0
  133. package/dist/templates/default/eas.json +56 -0
  134. package/dist/templates/default/fingerprint.config.js +9 -0
  135. package/dist/templates/default/hooks/use-debounce.ts +20 -0
  136. package/dist/templates/default/hooks/use-deep-link.ts +43 -0
  137. package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
  138. package/dist/templates/default/hooks/use-network.ts +11 -0
  139. package/dist/templates/default/hooks/use-notifications.ts +107 -0
  140. package/dist/templates/default/hooks/use-onboarding.ts +15 -0
  141. package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
  142. package/dist/templates/default/hooks/use-theme.ts +53 -0
  143. package/dist/templates/default/hooks/use-updates.ts +86 -0
  144. package/dist/templates/default/lib/a11y.ts +5 -0
  145. package/dist/templates/default/lib/app.ts +14 -0
  146. package/dist/templates/default/lib/assets.ts +17 -0
  147. package/dist/templates/default/lib/auth-client.ts +21 -0
  148. package/dist/templates/default/lib/convex-auth.tsx +79 -0
  149. package/dist/templates/default/lib/deep-link.ts +71 -0
  150. package/dist/templates/default/lib/dev-menu.ts +119 -0
  151. package/dist/templates/default/lib/device.ts +40 -0
  152. package/dist/templates/default/lib/dynamic-font.ts +49 -0
  153. package/dist/templates/default/lib/env.ts +10 -0
  154. package/dist/templates/default/lib/haptics.ts +24 -0
  155. package/dist/templates/default/lib/notifications.ts +276 -0
  156. package/dist/templates/default/lib/preferences.ts +45 -0
  157. package/dist/templates/default/lib/schemas.ts +137 -0
  158. package/dist/templates/default/lib/storage.ts +47 -0
  159. package/dist/templates/default/lib/updates.ts +107 -0
  160. package/dist/templates/default/metro.config.js +14 -0
  161. package/dist/templates/default/package.json +129 -0
  162. package/dist/templates/default/patches/PR-368.patch +91 -0
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. package/dist/templates/default/plugins/README.md +9 -0
  165. package/dist/templates/default/plugins/with-auto-signing.js +45 -0
  166. package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
  167. package/dist/templates/default/scripts/README.md +36 -0
  168. package/dist/templates/default/scripts/_run.mjs +77 -0
  169. package/dist/templates/default/scripts/clean.ts +543 -0
  170. package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
  171. package/dist/templates/default/store.config.json +58 -0
  172. package/dist/templates/default/tsconfig.json +13 -0
  173. package/dist/templates/default/vitest.config.ts +21 -0
  174. package/package.json +69 -0
@@ -0,0 +1,36 @@
1
+ # scripts
2
+
3
+ Build and maintenance scripts. Setup orchestration lives in the published `vexpo` CLI (run via `bunx vexpo`), not here.
4
+
5
+ ## What's in this directory
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. |
12
+
13
+ Anything else (preflight checks, env validation, version bumps) lives in the `vexpo` CLI or in `eas-cli` directly.
14
+
15
+ ## Setup orchestration
16
+
17
+ Use the `vexpo` CLI:
18
+
19
+ ```bash
20
+ bunx vexpo lite # dev-mode setup (Convex + Better Auth only)
21
+ bunx vexpo full # full provisioning to TestFlight-ready
22
+ bunx vexpo doctor # cross-source drift detection
23
+ bunx vexpo env push # sync from .env.local + .env.prod to Convex/EAS
24
+ bunx vexpo apple asc-key # validate ASC API key
25
+ bunx vexpo apple services-id # attach SIWA capability to App ID
26
+ bunx vexpo apple jwt # sign client_secret JWT, push to Convex
27
+ ```
28
+
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).
30
+
31
+ The CLI itself ships from [`vexpo` on npm](https://www.npmjs.com/package/vexpo). Source lives at [`github.com/ramonclaudio/vexpo`](https://github.com/ramonclaudio/vexpo).
32
+
33
+ ## Conventions
34
+
35
+ - All deletions go through `trash`. Recoverable from macOS Trash.
36
+ - Scripts here are intentionally minimal. The heavy logic is in the CLI.
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime-agnostic launcher for vexpo's TypeScript setup scripts.
4
+ *
5
+ * Picks the first available runtime that handles full TypeScript syntax:
6
+ *
7
+ * 1. bun. native TS, fastest startup
8
+ * 2. tsx (devDep). esbuild-based TS runner, works under any node 18+
9
+ *
10
+ * Note: we don't use node's `--experimental-strip-types` because it's
11
+ * strip-only. it doesn't transform syntax (parameter properties, enums,
12
+ * namespaces, etc all error out). bun and tsx handle full TS.
13
+ *
14
+ * Then re-execs the target script with the selected runtime, forwarding
15
+ * argv and stdio. Exit code mirrors the child.
16
+ *
17
+ * Usage (from package.json scripts):
18
+ * node scripts/_run.mjs scripts/setup.ts [args...]
19
+ */
20
+
21
+ import { spawn, spawnSync } from "node:child_process";
22
+ import { existsSync } from "node:fs";
23
+ import { resolve, dirname } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const HERE = dirname(fileURLToPath(import.meta.url));
27
+ const REPO = resolve(HERE, "..");
28
+
29
+ const [, , target, ...rawRest] = process.argv;
30
+ if (!target) {
31
+ console.error("usage: _run.mjs <script.ts> [args...]");
32
+ process.exit(2);
33
+ }
34
+
35
+ // pnpm passes a literal `--` separator before forwarded args. npm and yarn
36
+ // strip it. Drop a leading `--` so all PMs behave the same.
37
+ const rest = rawRest[0] === "--" ? rawRest.slice(1) : rawRest;
38
+
39
+ function which(bin) {
40
+ const r = spawnSync(process.platform === "win32" ? "where" : "which", [bin], {
41
+ stdio: ["ignore", "pipe", "ignore"],
42
+ encoding: "utf8",
43
+ });
44
+ if (r.status !== 0) return null;
45
+ return r.stdout.trim().split("\n")[0] || null;
46
+ }
47
+
48
+ function pickRuntime() {
49
+ // Already running under bun? Use it directly (fastest).
50
+ if (process.versions.bun) return { cmd: process.execPath, args: [target] };
51
+
52
+ // bun on PATH
53
+ const bun = which("bun");
54
+ if (bun) return { cmd: bun, args: [target] };
55
+
56
+ // tsx fallback (any node 18+)
57
+ const tsx = resolve(REPO, "node_modules", ".bin", "tsx");
58
+ if (existsSync(tsx)) return { cmd: tsx, args: [target] };
59
+
60
+ // tsx via npx as last resort
61
+ const npx = which("npx");
62
+ if (npx) return { cmd: npx, args: ["tsx", target] };
63
+
64
+ console.error(`vexpo full needs bun or tsx to run TypeScript.`);
65
+ console.error(` install bun: curl -fsSL https://bun.sh/install | bash`);
66
+ console.error(` or run: npm install (vexpo ships tsx as a devDep)`);
67
+ process.exit(1);
68
+ }
69
+
70
+ const runtime = pickRuntime();
71
+ const child = spawn(runtime.cmd, [...runtime.args, ...rest], {
72
+ stdio: "inherit",
73
+ cwd: REPO,
74
+ });
75
+ child.on("exit", (code, signal) => {
76
+ process.exit(code ?? (signal ? 1 : 0));
77
+ });
@@ -0,0 +1,543 @@
1
+ /**
2
+ * vexpo clean script.
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
+ * Never wiped (user data / secrets):
13
+ * - .env / .env.* (auth values)
14
+ * - .p8 / .p12 / AuthKey_* / SubscriptionKey_* (Apple keys)
15
+ * - store.config.json (rebrand work; setup recreates from .example if missing)
16
+ * - .vexpo-manual-setup/ / .rebrand-backup/
17
+ * - .setup-state.json (opt-in via --state)
18
+ *
19
+ * Then reinstalls deps via the detected package manager.
20
+ *
21
+ * 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
+ */
30
+
31
+ import { spawn as nodeSpawn } from "node:child_process";
32
+ import { access, readdir, readFile, stat } from "node:fs/promises";
33
+ import { homedir } from "node:os";
34
+ import { dirname, resolve } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { parseArgs } from "node:util";
37
+
38
+ type StdioOption = "inherit" | "pipe" | "ignore";
39
+
40
+ type SpawnOpts = {
41
+ stdio?: StdioOption[];
42
+ stdin?: StdioOption;
43
+ stdout?: StdioOption;
44
+ stderr?: StdioOption;
45
+ };
46
+
47
+ function spawn(argv: readonly string[], opts: SpawnOpts = {}): { exited: Promise<number> } {
48
+ const stdio = opts.stdio ?? [
49
+ opts.stdin ?? "inherit",
50
+ opts.stdout ?? "inherit",
51
+ opts.stderr ?? "inherit",
52
+ ];
53
+ const proc = nodeSpawn(argv[0]!, argv.slice(1), { stdio });
54
+ return {
55
+ exited: new Promise<number>((resolve) => {
56
+ proc.on("close", (code) => resolve(code ?? 1));
57
+ // ENOENT (command not found) emits 'error' without 'close'. Treat as
58
+ // the standard shell "not found" exit so callers can `if (code === 0)`.
59
+ proc.on("error", () => resolve(127));
60
+ }),
61
+ };
62
+ }
63
+
64
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
65
+
66
+ /**
67
+ * Find PIDs whose full command line matches `pattern` (extended regex, via
68
+ * `pgrep -f`). Excludes our own PID and PPID so the script never kills its
69
+ * own bash parent or itself.
70
+ */
71
+ async function pgrepF(pattern: string): Promise<number[]> {
72
+ return new Promise((resolve) => {
73
+ const proc = nodeSpawn("pgrep", ["-f", pattern], {
74
+ stdio: ["ignore", "pipe", "ignore"],
75
+ });
76
+ let buf = "";
77
+ proc.stdout?.on("data", (c) => (buf += c.toString()));
78
+ proc.on("error", () => resolve([]));
79
+ proc.on("close", () => {
80
+ const self = process.pid;
81
+ const parent = typeof process.ppid === "number" ? process.ppid : -1;
82
+ const pids = buf
83
+ .split("\n")
84
+ .filter(Boolean)
85
+ .map((s) => Number.parseInt(s, 10))
86
+ .filter((n) => Number.isFinite(n) && n !== self && n !== parent);
87
+ resolve(pids);
88
+ });
89
+ });
90
+ }
91
+
92
+ async function trySignal(pids: readonly number[], signal: "TERM" | "KILL"): Promise<void> {
93
+ if (pids.length === 0) return;
94
+ await spawn(["kill", `-${signal}`, ...pids.map(String)], {
95
+ stdio: ["ignore", "ignore", "ignore"],
96
+ }).exited;
97
+ }
98
+
99
+ async function fileExists(p: string): Promise<boolean> {
100
+ try {
101
+ await access(p);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ type PM = "bun" | "pnpm" | "yarn" | "npm";
109
+
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
+ */
117
+ async function detectPackageManager(): Promise<PM> {
118
+ const execpath = (process.env.npm_execpath ?? "").toLowerCase();
119
+ if (execpath.includes("bun")) return "bun";
120
+ if (execpath.includes("pnpm")) return "pnpm";
121
+ if (execpath.includes("yarn")) return "yarn";
122
+ if (execpath.includes("npm")) return "npm";
123
+ if (await fileExists("bun.lock")) return "bun";
124
+ if (await fileExists("pnpm-lock.yaml")) return "pnpm";
125
+ if (await fileExists("yarn.lock")) return "yarn";
126
+ return "npm";
127
+ }
128
+
129
+ function installCmdFor(pm: PM): string {
130
+ return `${pm} install`;
131
+ }
132
+
133
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
134
+ process.chdir(REPO_ROOT);
135
+
136
+ // ─── Output ──────────────────────────────────────────────────────────────────
137
+
138
+ const RESET = "\x1b[0m";
139
+ const BOLD = "\x1b[1m";
140
+ const DIM = "\x1b[2m";
141
+ function ansiHex(hex: string): string {
142
+ const m = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex);
143
+ if (!m) return "";
144
+ return `\x1b[38;2;${parseInt(m[1], 16)};${parseInt(m[2], 16)};${parseInt(m[3], 16)}m`;
145
+ }
146
+ const GREEN = ansiHex("#22c55e");
147
+ const YELLOW = ansiHex("#f59e0b");
148
+ const RED = ansiHex("#ef4444");
149
+ const VIOLET = ansiHex("#a78bfa");
150
+
151
+ const line = (s = "") => process.stderr.write(s + "\n");
152
+ const ok = (msg: string) => line(` ${GREEN}ok${RESET} ${msg}`);
153
+ const nop = (msg: string) => line(` ${DIM}-- ${msg}${RESET}`);
154
+ const yep = (msg: string) => line(` ${YELLOW}!!${RESET} ${msg}`);
155
+ const bad = (msg: string) => line(` ${RED}xx${RESET} ${RED}${msg}${RESET}`);
156
+
157
+ function stringWidth(s: string): number {
158
+ return [...s].length;
159
+ }
160
+
161
+ function section(title: string): void {
162
+ const w = process.stderr.columns ?? process.stdout.columns ?? 80;
163
+ const fill = "─".repeat(Math.max(0, w - stringWidth(title) - 3));
164
+ line(`\n${BOLD}${VIOLET}${title}${RESET} ${DIM}${fill}${RESET}`);
165
+ }
166
+
167
+ // ─── Args ────────────────────────────────────────────────────────────────────
168
+
169
+ const HELP = `${BOLD}vexpo clean${RESET}
170
+
171
+ ${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/.
185
+
186
+ ${BOLD}--state${RESET} additionally wipes .setup-state.json so the next
187
+ ${DIM}bun run setup${RESET} re-probes every phase against external services
188
+ (slower, but the cure when state has drifted from reality).
189
+
190
+ Bundlers (Metro, expo CLI, react-native start, Watchman) are stopped
191
+ automatically before the wipe so macOS ${DIM}trash${RESET} can't silently skip files
192
+ held open. ${BOLD}convex dev${RESET} is left alone (it's your data layer, not a
193
+ bundler); restart it manually if it misbehaves after a full wipe.
194
+ `;
195
+
196
+ let args: { metro?: boolean; state?: boolean; "no-install"?: boolean; help?: boolean };
197
+ try {
198
+ args = parseArgs({
199
+ args: process.argv.slice(2),
200
+ options: {
201
+ metro: { type: "boolean", default: false },
202
+ state: { type: "boolean", default: false },
203
+ "no-install": { type: "boolean", default: false },
204
+ help: { type: "boolean", short: "h", default: false },
205
+ },
206
+ strict: true,
207
+ }).values;
208
+ } catch (err) {
209
+ bad(err instanceof Error ? err.message : String(err));
210
+ process.exit(2);
211
+ }
212
+
213
+ if (args.help) {
214
+ line(HELP);
215
+ process.exit(0);
216
+ }
217
+
218
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
219
+
220
+ async function pathExists(p: string): Promise<boolean> {
221
+ try {
222
+ await stat(p);
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Pass paths to `trash` (recoverable). Skips paths that don't exist so the
231
+ * macOS `trash` CLI doesn't error out on missing entries.
232
+ */
233
+ async function trashPaths(paths: string[]): Promise<void> {
234
+ const existing: string[] = [];
235
+ for (const p of paths) {
236
+ if (await pathExists(p)) existing.push(p);
237
+ }
238
+ if (existing.length === 0) return;
239
+ const proc = spawn(["trash", ...existing], {
240
+ stdin: "ignore",
241
+ stdout: "ignore",
242
+ stderr: "ignore",
243
+ });
244
+ await proc.exited;
245
+ }
246
+
247
+ async function expandGlob(dir: string, pattern: string): Promise<string[]> {
248
+ if (!(await pathExists(dir))) return [];
249
+ // Convert simple glob (only * supported) to regex. Sufficient for our patterns.
250
+ const re = new RegExp(
251
+ "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
252
+ );
253
+ const entries = await readdir(dir);
254
+ return entries.filter((e) => re.test(e)).map((e) => `${dir}/${e}`);
255
+ }
256
+
257
+ // ─── Targets ─────────────────────────────────────────────────────────────────
258
+
259
+ const REPO = REPO_ROOT;
260
+ const TMPDIR = process.env.TMPDIR?.replace(/\/$/, "") ?? "/tmp";
261
+ const HOME = homedir();
262
+
263
+ async function readPkgName(): Promise<string> {
264
+ try {
265
+ const pkg: unknown = JSON.parse(await readFile(`${REPO}/package.json`, "utf8"));
266
+ if (typeof pkg === "object" && pkg !== null && "name" in pkg) {
267
+ const { name } = pkg;
268
+ if (typeof name === "string") return name;
269
+ }
270
+ } catch {}
271
+ return "vexpo";
272
+ }
273
+
274
+ const PROJECT_TARGETS = [
275
+ "node_modules",
276
+ "bun.lock",
277
+ "ios",
278
+ ".expo",
279
+ "dist",
280
+ "convex/_generated",
281
+ "tsconfig.tsbuildinfo",
282
+ "coverage",
283
+ ".vitest-cache",
284
+ "expo-env.d.ts",
285
+ ];
286
+
287
+ // Globs evaluated at REPO root. bun-error.* and *.log are cheap to wipe and
288
+ // almost never wanted across runs.
289
+ const PROJECT_GLOBS = ["bun-error.*", "*.log"];
290
+
291
+ const TMP_GLOBS = ["metro-*", "haste-map-*", "react-*", "node-compile-cache", "expo-*", "RN*"];
292
+
293
+ // ─── Steps ───────────────────────────────────────────────────────────────────
294
+
295
+ /**
296
+ * Stop bundlers before wiping their caches. macOS `trash` silently skips
297
+ * files held open by a running process, so caches survive the wipe and the
298
+ * 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
+ */
303
+ async function stepStopBundlers(): Promise<void> {
304
+ section("Stop bundlers");
305
+
306
+ // Patterns are pgrep -f extended regex over the full command line.
307
+ // Order: kill the parent CLI first so it can tear down its child Metro.
308
+ const targets: { pattern: string; name: string }[] = [
309
+ { pattern: "node .*\\.bin/expo (run:|start)", name: "expo CLI" },
310
+ { pattern: "node .*@expo/cli/build/bin/cli", name: "expo CLI (forked)" },
311
+ { pattern: "node .*metro/src/cli\\.js", name: "Metro" },
312
+ { pattern: "node .*react-native start", name: "react-native start" },
313
+ ];
314
+
315
+ let killed = 0;
316
+ for (const { pattern, name } of targets) {
317
+ const pids = await pgrepF(pattern);
318
+ if (pids.length === 0) continue;
319
+ await trySignal(pids, "TERM");
320
+ ok(`stopped ${name} (${pids.length} ${pids.length === 1 ? "process" : "processes"})`);
321
+ killed += pids.length;
322
+ }
323
+
324
+ // Watchman has its own clean shutdown. No-op if not installed (exit 127).
325
+ const wm = await spawn(["watchman", "shutdown-server"], {
326
+ stdio: ["ignore", "ignore", "ignore"],
327
+ }).exited;
328
+ if (wm === 0) {
329
+ ok("stopped Watchman");
330
+ killed += 1;
331
+ }
332
+
333
+ if (killed === 0) {
334
+ nop("no bundlers running");
335
+ } else {
336
+ // Drain SIGTERM, then SIGKILL stragglers so the wipe can't race them.
337
+ await sleep(500);
338
+ for (const { pattern } of targets) {
339
+ const pids = await pgrepF(pattern);
340
+ if (pids.length > 0) await trySignal(pids, "KILL");
341
+ }
342
+ }
343
+
344
+ const convex = await pgrepF("\\.bin/convex dev");
345
+ if (convex.length > 0) {
346
+ yep("convex dev is still running. Restart it after the wipe if it misbehaves.");
347
+ }
348
+ }
349
+
350
+ async function stepMetroCachesOnly(): Promise<void> {
351
+ section("Metro caches");
352
+ const matches: string[] = [];
353
+ for (const pattern of ["metro-*", "haste-map-*", "node-compile-cache"]) {
354
+ matches.push(...(await expandGlob(TMPDIR, pattern)));
355
+ }
356
+ if (matches.length === 0) {
357
+ nop("nothing to wipe under $TMPDIR");
358
+ return;
359
+ }
360
+ await trashPaths(matches);
361
+ ok(`trashed ${matches.length} cache director${matches.length === 1 ? "y" : "ies"}`);
362
+ }
363
+
364
+ async function stepProjectArtifacts(): Promise<void> {
365
+ section("Project artifacts");
366
+ const targets = PROJECT_TARGETS.map((t) => `${REPO}/${t}`);
367
+ for (const pattern of PROJECT_GLOBS) {
368
+ targets.push(...(await expandGlob(REPO, pattern)));
369
+ }
370
+ const existing: string[] = [];
371
+ for (const t of targets) {
372
+ if (await pathExists(t)) existing.push(t);
373
+ }
374
+ if (existing.length === 0) {
375
+ nop("nothing to wipe");
376
+ return;
377
+ }
378
+ await trashPaths(existing);
379
+ for (const t of existing) ok(`trashed ${t.replace(REPO + "/", "")}`);
380
+ }
381
+
382
+ /**
383
+ * Wipe `.eas/` per-project CLI state but keep `.eas/workflows/` (tracked YAML).
384
+ * EAS regenerates everything else on its next invocation.
385
+ */
386
+ async function stepEasState(): Promise<void> {
387
+ section(".eas state");
388
+ const easDir = `${REPO}/.eas`;
389
+ if (!(await pathExists(easDir))) {
390
+ nop(".eas/ not present");
391
+ return;
392
+ }
393
+ const entries = await readdir(easDir);
394
+ const targets = entries.filter((name) => name !== "workflows").map((name) => `${easDir}/${name}`);
395
+ if (targets.length === 0) {
396
+ nop("only .eas/workflows/ present (kept)");
397
+ return;
398
+ }
399
+ await trashPaths(targets);
400
+ ok(
401
+ `trashed ${targets.length} .eas/ ${targets.length === 1 ? "entry" : "entries"} (kept workflows/)`,
402
+ );
403
+ }
404
+
405
+ async function stepDsStores(): Promise<void> {
406
+ section("macOS .DS_Store");
407
+ const { stdout } = await new Promise<{ stdout: string }>((resolve) => {
408
+ const proc = nodeSpawn(
409
+ "find",
410
+ [REPO, "-name", ".DS_Store", "-not", "-path", "*/node_modules/*"],
411
+ { stdio: ["ignore", "pipe", "ignore"] },
412
+ );
413
+ let buf = "";
414
+ proc.stdout?.on("data", (c) => (buf += c.toString()));
415
+ proc.on("close", () => resolve({ stdout: buf }));
416
+ });
417
+ const matches = stdout.split("\n").filter(Boolean);
418
+ if (matches.length === 0) {
419
+ nop("none found");
420
+ return;
421
+ }
422
+ await trashPaths(matches);
423
+ ok(`trashed ${matches.length} .DS_Store ${matches.length === 1 ? "file" : "files"}`);
424
+ }
425
+
426
+ async function stepTmpdirCaches(): Promise<void> {
427
+ section("$TMPDIR caches");
428
+ const matches: string[] = [];
429
+ for (const pattern of TMP_GLOBS) {
430
+ matches.push(...(await expandGlob(TMPDIR, pattern)));
431
+ }
432
+ if (matches.length === 0) {
433
+ nop("nothing to wipe under $TMPDIR");
434
+ return;
435
+ }
436
+ await trashPaths(matches);
437
+ ok(`trashed ${matches.length} cache entr${matches.length === 1 ? "y" : "ies"} under $TMPDIR`);
438
+ }
439
+
440
+ async function stepCocoaPodsCache(): Promise<void> {
441
+ section("CocoaPods cache");
442
+ const path = `${HOME}/Library/Caches/CocoaPods`;
443
+ if (!(await pathExists(path))) {
444
+ nop("not present");
445
+ return;
446
+ }
447
+ await trashPaths([path]);
448
+ ok("trashed ~/Library/Caches/CocoaPods");
449
+ }
450
+
451
+ async function stepXcodeDerivedData(pkgName: string): Promise<void> {
452
+ section("Xcode DerivedData");
453
+ const root = `${HOME}/Library/Developer/Xcode/DerivedData`;
454
+ if (!(await pathExists(root))) {
455
+ nop("DerivedData not present");
456
+ return;
457
+ }
458
+ // Match folders that start with the project's pkg name OR the iOS
459
+ // bundle's display name. We filter by leading prefix so we never touch
460
+ // other projects' caches.
461
+ const matches = [
462
+ ...(await expandGlob(root, `${pkgName}-*`)),
463
+ ...(await expandGlob(root, "Vexpo-*")),
464
+ ];
465
+ if (matches.length === 0) {
466
+ nop("no matching DerivedData entries");
467
+ return;
468
+ }
469
+ await trashPaths(matches);
470
+ ok(`trashed ${matches.length} DerivedData ${matches.length === 1 ? "entry" : "entries"}`);
471
+ }
472
+
473
+ async function stepExpoCache(): Promise<void> {
474
+ section("Expo CLI cache");
475
+ const path = `${HOME}/.expo`;
476
+ if (!(await pathExists(path))) {
477
+ nop("~/.expo not present");
478
+ return;
479
+ }
480
+ // .expo holds the user-level Expo cache (devices.json, telemetry, sdk
481
+ // metadata). Safe to wipe; Expo regenerates on next CLI invocation.
482
+ await trashPaths([path]);
483
+ ok("trashed ~/.expo");
484
+ }
485
+
486
+ async function stepSetupState(): Promise<void> {
487
+ section("Setup state");
488
+ const path = `${REPO}/.setup-state.json`;
489
+ if (!(await pathExists(path))) {
490
+ nop(".setup-state.json not present");
491
+ return;
492
+ }
493
+ await trashPaths([path]);
494
+ ok("trashed .setup-state.json (next `bun run setup` re-probes every phase)");
495
+ }
496
+
497
+ async function stepInstall(pm: PM): Promise<void> {
498
+ section("Reinstall");
499
+ const cmd = installCmdFor(pm).split(" ");
500
+ const proc = spawn(cmd, { stdio: ["inherit", "inherit", "inherit"] });
501
+ const code = await proc.exited;
502
+ if (code !== 0) throw new Error(`${cmd.join(" ")} exited with code ${code}`);
503
+ ok(cmd.join(" "));
504
+ }
505
+
506
+ // ─── Entry ───────────────────────────────────────────────────────────────────
507
+
508
+ // Wrapped in an async IIFE so the file works under both ESM (top-level await
509
+ // supported) and CJS-via-tsx (no top-level await).
510
+ void (async () => {
511
+ const startedAt = performance.now();
512
+ try {
513
+ if (args.metro) {
514
+ await stepStopBundlers();
515
+ await stepMetroCachesOnly();
516
+ } else {
517
+ // Capture PM BEFORE any wipes; stepProjectArtifacts trashes the lockfile.
518
+ const pm = await detectPackageManager();
519
+ const pkgName = await readPkgName();
520
+ await stepStopBundlers();
521
+ await stepProjectArtifacts();
522
+ await stepEasState();
523
+ await stepDsStores();
524
+ await stepTmpdirCaches();
525
+ await stepCocoaPodsCache();
526
+ await stepXcodeDerivedData(pkgName);
527
+ await stepExpoCache();
528
+ if (args.state) await stepSetupState();
529
+ if (!args["no-install"]) {
530
+ await stepInstall(pm);
531
+ } else {
532
+ yep(`--no-install passed; skipping ${pm} install`);
533
+ }
534
+ }
535
+ const elapsed = ((performance.now() - startedAt) / 1000).toFixed(2);
536
+ line(`\n ${GREEN}ok${RESET} clean complete in ${elapsed}s\n`);
537
+ } catch (err) {
538
+ line();
539
+ if (err instanceof Error) bad(err.message);
540
+ else bad(String(err));
541
+ process.exit(1);
542
+ }
543
+ })();