create-expo-boilerplate 1.1.1 → 1.2.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 (103) hide show
  1. package/README.md +39 -1
  2. package/dist/add.js +218 -0
  3. package/dist/generate.js +3 -1
  4. package/dist/index.js +36 -6
  5. package/dist/manifest.js +51 -0
  6. package/dist/modules/index.js +7 -0
  7. package/dist/modules/notifications.js +241 -0
  8. package/dist/modules/shared.js +67 -0
  9. package/dist/modules/subscriptions.js +223 -0
  10. package/dist/modules/types.js +57 -0
  11. package/dist/steps/format.js +11 -2
  12. package/dist/steps/javascript.js +14 -7
  13. package/dist/steps/readme.js +2 -1
  14. package/dist/steps/styling.js +1 -0
  15. package/dist/template.js +6 -3
  16. package/dist/versions.js +8 -0
  17. package/modules/notifications/files/plugins/withAndroidDefaultNotificationChannel.js +64 -0
  18. package/modules/notifications/files/src/notifications/NotificationProvider.tsx +309 -0
  19. package/modules/notifications/files/src/notifications/NotificationRoot.tsx +21 -0
  20. package/modules/notifications/files/src/notifications/README.md +763 -0
  21. package/modules/notifications/files/src/notifications/__tests__/NotificationProvider.test.tsx +358 -0
  22. package/modules/notifications/files/src/notifications/__tests__/pendingPress.test.ts +61 -0
  23. package/modules/notifications/files/src/notifications/__tests__/permissionState.test.ts +92 -0
  24. package/modules/notifications/files/src/notifications/__tests__/routing.test.ts +109 -0
  25. package/modules/notifications/files/src/notifications/__tests__/service.test.ts +189 -0
  26. package/modules/notifications/files/src/notifications/backgroundHandler.ts +158 -0
  27. package/modules/notifications/files/src/notifications/channels.ts +70 -0
  28. package/modules/notifications/files/src/notifications/components/NotificationPermissionBanner.tsx +134 -0
  29. package/modules/notifications/files/src/notifications/config.ts +179 -0
  30. package/modules/notifications/files/src/notifications/index.ts +90 -0
  31. package/modules/notifications/files/src/notifications/log.ts +57 -0
  32. package/modules/notifications/files/src/notifications/navigation.ts +11 -0
  33. package/modules/notifications/files/src/notifications/pendingPress.ts +86 -0
  34. package/modules/notifications/files/src/notifications/permissionState.ts +75 -0
  35. package/modules/notifications/files/src/notifications/routing.ts +99 -0
  36. package/modules/notifications/files/src/notifications/service.ts +476 -0
  37. package/modules/notifications/files/src/notifications/setup.ts +18 -0
  38. package/modules/notifications/files/src/notifications/types.ts +74 -0
  39. package/modules/notifications/files/src/notifications/useNotificationPermission.ts +80 -0
  40. package/modules/notifications/files/src/notifications/useNotificationSync.ts +71 -0
  41. package/modules/notifications/files/src/notifications/useNotifications.ts +13 -0
  42. package/modules/notifications/overlays/react-navigation/src/navigation/navigationRef.ts +6 -0
  43. package/modules/notifications/overlays/react-navigation/src/notifications/navigation.ts +38 -0
  44. package/modules/notifications/overlays/styling/stylesheet/src/notifications/components/NotificationPermissionBanner.tsx +134 -0
  45. package/modules/notifications/overlays/styling/tailwind/src/notifications/components/NotificationPermissionBanner.tsx +108 -0
  46. package/modules/notifications/translations/ar.json +12 -0
  47. package/modules/notifications/translations/en.json +12 -0
  48. package/modules/notifications/translations/es.json +12 -0
  49. package/modules/subscriptions/files/src/app/subscription.tsx +1 -0
  50. package/modules/subscriptions/files/src/subscription/README.md +215 -0
  51. package/modules/subscriptions/files/src/subscription/SubscriptionProvider.tsx +562 -0
  52. package/modules/subscriptions/files/src/subscription/SubscriptionRoot.tsx +30 -0
  53. package/modules/subscriptions/files/src/subscription/__tests__/SubscriptionProvider.test.tsx +931 -0
  54. package/modules/subscriptions/files/src/subscription/__tests__/UnlockModal.test.tsx +79 -0
  55. package/modules/subscriptions/files/src/subscription/__tests__/config.test.ts +23 -0
  56. package/modules/subscriptions/files/src/subscription/__tests__/introOffer.test.ts +93 -0
  57. package/modules/subscriptions/files/src/subscription/__tests__/offeringContent.test.ts +215 -0
  58. package/modules/subscriptions/files/src/subscription/__tests__/planChange.test.ts +356 -0
  59. package/modules/subscriptions/files/src/subscription/__tests__/purchaseErrors.test.ts +111 -0
  60. package/modules/subscriptions/files/src/subscription/__tests__/service.test.ts +369 -0
  61. package/modules/subscriptions/files/src/subscription/__tests__/statusNotice.test.ts +259 -0
  62. package/modules/subscriptions/files/src/subscription/__tests__/subscriptionState.test.ts +690 -0
  63. package/modules/subscriptions/files/src/subscription/components/CrossPlatformNotice.tsx +94 -0
  64. package/modules/subscriptions/files/src/subscription/components/PremiumGate.tsx +74 -0
  65. package/modules/subscriptions/files/src/subscription/components/SubscriptionStatusNotice.tsx +153 -0
  66. package/modules/subscriptions/files/src/subscription/components/UnlockModal.tsx +223 -0
  67. package/modules/subscriptions/files/src/subscription/config.ts +54 -0
  68. package/modules/subscriptions/files/src/subscription/index.ts +95 -0
  69. package/modules/subscriptions/files/src/subscription/introOffer.ts +61 -0
  70. package/modules/subscriptions/files/src/subscription/management.ts +30 -0
  71. package/modules/subscriptions/files/src/subscription/navigation.ts +15 -0
  72. package/modules/subscriptions/files/src/subscription/offeringContent.ts +259 -0
  73. package/modules/subscriptions/files/src/subscription/planChange.ts +219 -0
  74. package/modules/subscriptions/files/src/subscription/purchaseErrors.ts +140 -0
  75. package/modules/subscriptions/files/src/subscription/screens/PaywallScreen.tsx +466 -0
  76. package/modules/subscriptions/files/src/subscription/service.ts +270 -0
  77. package/modules/subscriptions/files/src/subscription/statusNotice.ts +189 -0
  78. package/modules/subscriptions/files/src/subscription/subscriptionState.ts +485 -0
  79. package/modules/subscriptions/files/src/subscription/types.ts +164 -0
  80. package/modules/subscriptions/files/src/subscription/useSubscriptionUser.ts +29 -0
  81. package/modules/subscriptions/overlays/react-navigation/src/navigation/navigationRef.ts +6 -0
  82. package/modules/subscriptions/overlays/react-navigation/src/subscription/navigation.ts +14 -0
  83. package/modules/subscriptions/overlays/styling/stylesheet/src/subscription/components/CrossPlatformNotice.tsx +94 -0
  84. package/modules/subscriptions/overlays/styling/stylesheet/src/subscription/components/SubscriptionStatusNotice.tsx +153 -0
  85. package/modules/subscriptions/overlays/styling/stylesheet/src/subscription/components/UnlockModal.tsx +227 -0
  86. package/modules/subscriptions/overlays/styling/stylesheet/src/subscription/screens/PaywallScreen.tsx +467 -0
  87. package/modules/subscriptions/overlays/styling/tailwind/src/subscription/components/CrossPlatformNotice.tsx +74 -0
  88. package/modules/subscriptions/overlays/styling/tailwind/src/subscription/components/SubscriptionStatusNotice.tsx +136 -0
  89. package/modules/subscriptions/overlays/styling/tailwind/src/subscription/components/UnlockModal.tsx +181 -0
  90. package/modules/subscriptions/overlays/styling/tailwind/src/subscription/screens/PaywallScreen.tsx +364 -0
  91. package/modules/subscriptions/translations/ar.json +128 -0
  92. package/modules/subscriptions/translations/en.json +112 -0
  93. package/modules/subscriptions/translations/es.json +112 -0
  94. package/package.json +24 -5
  95. package/template/.easignore +1 -0
  96. package/template/cliff.toml +1 -1
  97. package/template/eslint.config.js +1 -0
  98. package/template/jest.config.js +6 -4
  99. package/template/jest.setup.ts +36 -10
  100. package/template/src/localization/__tests__/i18n.test.ts +25 -0
  101. package/template/src/services/client.ts +14 -5
  102. package/template/src/shared/components/__tests__/Button.test.tsx +24 -0
  103. package/template/tsconfig.json +1 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # create-expo-boilerplate
2
2
 
3
- Scaffold a new Expo app from [React-Native-Expo-Boilterplate](https://github.com/EndLess728/React-Native-Expo-Boilterplate) — no cloning, no deleting example code by hand.
3
+ Scaffold a new Expo app from [react-native-expo-boilerplate](https://github.com/EndLess728/react-native-expo-boilerplate) ([docs](https://endless728.github.io/react-native-expo-boilerplate/)) — no cloning, no deleting example code by hand.
4
4
 
5
5
  ```bash
6
6
  npx create-expo-boilerplate my-app
@@ -48,6 +48,31 @@ Every option has the same screens and the same light/dark theme tokens (`src/sty
48
48
 
49
49
  Every combination uses the same feature-based layout (`src/features/<feature>`, `src/shared`, …). Screens only talk to state through the auth feature's hooks (`useIsLoggedIn`, `useLogin`, …), which is why switching the state library or the navigator doesn't touch a single screen.
50
50
 
51
+ ## Adding modules later
52
+
53
+ From the root of a generated project:
54
+
55
+ ```bash
56
+ npx create-expo-boilerplate add notifications
57
+ npx create-expo-boilerplate add notifications --dry-run # list what would change, write nothing
58
+ ```
59
+
60
+ | Module | What you get |
61
+ |---|---|
62
+ | `notifications` | Push (Firebase Cloud Messaging) and local notifications via NotifyKit: permission handling with a five-state banner, Android channels, foreground/background/killed delivery, allowlisted tap routing including cold start, token sync to your backend, tests. Docs land in `src/notifications/README.md`. |
63
+ | `subscriptions` | In-app subscriptions via RevenueCat: `useSubscription()` / `requirePremium()`, a premium gate sheet, a full paywall driven by the current offering, and every store state (grace period, account hold, trial, pause, cancelled, family sharing, promotional, lifetime, cross-store, plan changes, pending payments). Docs land in `src/subscription/README.md`. |
64
+
65
+ `add` reads the stack from `boilerplate.json` (written by every new project; older projects are detected from their files) and writes the variant that fits: Expo Router or React Navigation, Unistyles / StyleSheet / Tailwind, TypeScript or JavaScript. It:
66
+
67
+ - refuses to run with uncommitted changes (`--force` overrides), so the module lands as one diff you can review;
68
+ - builds everything in a staging copy first, so a failure leaves the project untouched;
69
+ - patches the files it wires into (entry, `AppProviders`, `app.config.ts`, Jest config, translations). If one of them has been customised past recognition, it doesn't guess: it applies the rest and prints the edit to make by hand;
70
+ - refuses to add a module twice.
71
+
72
+ `notifications` flags: `--token-endpoint <path>` (default `devices/push-token`), `--google-services <path>` / `--google-service-info <path>` (Firebase files to copy into `firebase/`; prompted for, and skippable), `--banner` / `--no-banner` (permission banner on Settings), plus the shared `--yes`, `--dry-run`, `--force`, `--no-install`.
73
+
74
+ `subscriptions` flags: `--entitlement <id>` (default `premium`), `--revenuecat-ios-key <key>` / `--revenuecat-android-key <key>` (public SDK keys; prompted for, and skippable).
75
+
51
76
  ## How it works
52
77
 
53
78
  The repo root **is** the template: the Expo Router + Zustand + TypeScript app with ESLint and Husky. Nobody maintains a second copy.
@@ -68,6 +93,10 @@ cli/
68
93
  │ ├── javascript.ts # TS → JS with the TypeScript compiler, last
69
94
  │ ├── readme.ts # README for the exact stack chosen
70
95
  │ └── format.ts # Prettier with the project's .prettierrc
96
+ │ ├── add.ts # `add <module>`: preflight, staging copy, JS pass, write back
97
+ │ ├── manifest.ts # boilerplate.json (stack + added modules), detection fallback
98
+ │ └── modules/ # one generator per module, registered in index.ts;
99
+ │ # shared.ts: translations merge, AppProviders wrap, nav ref
71
100
  ├── overlays/ # files that differ per option, written in TypeScript
72
101
  │ ├── react-navigation/
73
102
  │ ├── redux/{common,thunk,saga}/
@@ -75,6 +104,7 @@ cli/
75
104
  │ ├── common/ # styled components, screens, theme hooks (+ global.css, metro.config.js)
76
105
  │ ├── expo-router/ # route files that only exist with Expo Router
77
106
  │ └── docs/ # replacement sections for .skills/ui-creation.md and the screen snippet
107
+ ├── modules/<name>/ # module sources: files/ (base stack), overlays/ (per option), translations/
78
108
  └── scripts/
79
109
  ├── sync-template.mjs # copies the repo root into template/ when packing
80
110
  └── test-matrix.mjs # generates + checks every combination
@@ -107,6 +137,7 @@ npm run test:matrix # 26 core combinations
107
137
  npm run test:matrix -- --all # all 144
108
138
  npm run test:matrix -- --export # also bundle each with `expo export` (slow)
109
139
  npm run test:matrix -- --only redux-saga.react-navigation.tailwind
140
+ npm run test:matrix -- --add notifications # also add a module to each, and check it refuses a second add
110
141
  ```
111
142
 
112
143
  The script generates each project into your temp dir, installs **one** shared superset of dependencies, and checks each project for leftovers (stray markers, Zustand imports in Redux apps, Unistyles in StyleSheet / Tailwind apps, …), circular imports (madge), `tsc`, ESLint (or Prettier when ESLint is off), Jest, and `expo config`. CI runs the core set on every PR that touches the app or the CLI.
@@ -118,6 +149,13 @@ The script generates each project into your temp dir, installs **one** shared su
118
149
  3. Pin any new packages in `versions.ts`.
119
150
  4. Add the dimension to `scripts/test-matrix.mjs` and run it.
120
151
 
152
+ ### Adding a module
153
+
154
+ 1. Put its sources in `modules/<name>/files/`, written for the base stack (Expo Router, Unistyles, TypeScript) and mirroring project paths; per-option replacements go in `modules/<name>/overlays/`.
155
+ 2. Write a `ModuleGenerator` in `src/modules/<name>.ts` (prompts + flags, file copy, patches through `Patcher`, next steps) and register it in `src/modules/index.ts`.
156
+ 3. Pin its packages in `versions.ts` — the test matrix installs them.
157
+ 4. Run `npm run test:matrix -- --add <name>`. A patch that falls back to "do this by hand" fails the matrix.
158
+
121
159
  ### Upgrading the Expo SDK
122
160
 
123
161
  Upgrade the boilerplate at the repo root as usual, then bump the overlay-only packages in `src/versions.ts` (React Navigation, Redux, Uniwind / Tailwind), and run `npm run test:matrix -- --all --export`.
package/dist/add.js ADDED
@@ -0,0 +1,218 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { cp, mkdtemp, readFile, rm } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, relative, sep } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import * as p from '@clack/prompts';
8
+ import pc from 'picocolors';
9
+ import { loadManifest, isBoilerplateProject, saveManifest } from './manifest.js';
10
+ import { MODULES } from './modules/index.js';
11
+ import { CancelledError, Patcher } from './modules/types.js';
12
+ import { formatFiles } from './steps/format.js';
13
+ import { transpileFiles } from './steps/javascript.js';
14
+ import { detectPackageManager, PM } from './steps/packageManager.js';
15
+ import { commandExists, run } from './utils/exec.js';
16
+ import { ProjectFiles } from './utils/files.js';
17
+ import { PackageJsonEditor } from './utils/packageJson.js';
18
+ /** Never copied into the staging area — large, generated, or not ours to touch. */
19
+ const SKIP = new Set(['node_modules', '.git', 'ios', 'android', '.expo', 'dist', 'web-build']);
20
+ async function isDirty(dir) {
21
+ try {
22
+ return (await run('git', ['status', '--porcelain'], dir)).trim() !== '';
23
+ }
24
+ catch {
25
+ return null; // not a git repo
26
+ }
27
+ }
28
+ /** Files that differ between the staging copy and the project: added/changed, and removed. */
29
+ async function diff(project, staged) {
30
+ const before = new Set(await listOwn(project));
31
+ const after = await listOwn(staged);
32
+ const changed = [];
33
+ for (const rel of after) {
34
+ if (!before.has(rel))
35
+ changed.push(rel);
36
+ else if (!(await readFile(project.abs(rel))).equals(await readFile(staged.abs(rel)))) {
37
+ changed.push(rel);
38
+ }
39
+ }
40
+ const removed = [...before].filter((rel) => !after.includes(rel));
41
+ return { changed, removed, created: changed.filter((rel) => !before.has(rel)) };
42
+ }
43
+ async function listOwn(files) {
44
+ return (await files.list()).filter((rel) => !SKIP.has(rel.split('/')[0]));
45
+ }
46
+ export async function addModule(name, flags, cliVersion) {
47
+ const cwd = process.cwd();
48
+ const interactive = !flags.yes && Boolean(process.stdout.isTTY);
49
+ console.log();
50
+ p.intro(pc.bgCyan(pc.black(' create-expo-boilerplate add ')));
51
+ // ── Which module ─────────────────────────────────────────
52
+ if (!name) {
53
+ if (!interactive)
54
+ throw new Error(`Name a module: ${Object.keys(MODULES).join(', ')}.`);
55
+ const picked = await p.select({
56
+ message: 'Which module?',
57
+ options: Object.values(MODULES).map((m) => ({
58
+ value: m.name,
59
+ label: m.name,
60
+ hint: m.description,
61
+ })),
62
+ });
63
+ if (p.isCancel(picked))
64
+ return cancelled();
65
+ name = picked;
66
+ }
67
+ const mod = MODULES[name];
68
+ if (!mod)
69
+ throw new Error(`Unknown module "${name}". Available: ${Object.keys(MODULES).join(', ')}.`);
70
+ // ── Preflight ────────────────────────────────────────────
71
+ const project = new ProjectFiles(cwd);
72
+ if (!isBoilerplateProject(project)) {
73
+ throw new Error("This doesn't look like a create-expo-boilerplate project. Run `add` from the project root.");
74
+ }
75
+ const { manifest, detected } = await loadManifest(project);
76
+ const moduleDir = `src/${mod.dir ?? mod.name}`;
77
+ if (manifest.modules[mod.name] || project.exists(moduleDir)) {
78
+ throw new Error(`${mod.name} is already in this project (${moduleDir}/).`);
79
+ }
80
+ if (detected) {
81
+ p.log.warn(`No boilerplate.json — detected ${manifest.navigation}, ${manifest.styling}, ${manifest.state}, ${manifest.language.toUpperCase()}. It will be written now.`);
82
+ }
83
+ else if (manifest.cliVersion !== cliVersion) {
84
+ p.log.warn(`This project was created with v${manifest.cliVersion}; this is v${cliVersion}. If anything doesn't fit, run ${pc.bold(`npx create-expo-boilerplate@${manifest.cliVersion} add ${mod.name}`)}.`);
85
+ }
86
+ const dirty = await isDirty(cwd);
87
+ if (dirty && !flags.force && !flags.dryRun) {
88
+ throw new Error('You have uncommitted changes. Commit or stash them first (or pass --force), so the module lands as one reviewable diff.');
89
+ }
90
+ if (dirty === null)
91
+ p.log.warn("Not a git repository — you won't get a diff to review.");
92
+ // ── Generate into a staging copy ─────────────────────────
93
+ // Nothing touches the project until everything succeeded, which also makes
94
+ // --dry-run exact.
95
+ const stagingDir = await mkdtemp(join(tmpdir(), 'ceb-add-'));
96
+ let nextSteps = [];
97
+ let manual = [];
98
+ try {
99
+ await cp(cwd, stagingDir, {
100
+ recursive: true,
101
+ filter: (src) => !SKIP.has(relative(cwd, src).split(sep)[0]),
102
+ });
103
+ const staged = new ProjectFiles(stagingDir);
104
+ const pkg = await PackageJsonEditor.load(staged);
105
+ const patch = new Patcher(staged);
106
+ try {
107
+ nextSteps = await mod.apply({ files: staged, pkg, stack: manifest, patch, interactive, cwd }, flags);
108
+ }
109
+ catch (error) {
110
+ if (error instanceof CancelledError)
111
+ return cancelled();
112
+ throw error;
113
+ }
114
+ await pkg.save();
115
+ manual = patch.manual;
116
+ if (manifest.language === 'js') {
117
+ const created = (await diff(project, staged)).created;
118
+ await transpileFiles(staged, created.filter((rel) => /\.tsx?$/.test(rel) && !rel.endsWith('.d.ts')));
119
+ }
120
+ manifest.modules[mod.name] = { cliVersion };
121
+ await saveManifest(staged, manifest);
122
+ const { changed, removed, created } = await diff(project, staged);
123
+ // Module sources ship formatted with the template's Prettier. Re-formatting
124
+ // them with another version can disagree with the project's ESLint, so only
125
+ // edited files and freshly transpiled JS are formatted.
126
+ const toFormat = manifest.language === 'js' ? changed : changed.filter((rel) => !created.includes(rel));
127
+ await formatFiles(staged, toFormat, await projectPrettier(cwd));
128
+ // ── Report / write ─────────────────────────────────────
129
+ p.note([
130
+ ...changed.map((rel) => `${created.includes(rel) ? pc.green('+') : pc.yellow('~')} ${rel}`),
131
+ ...removed.map((rel) => `${pc.red('-')} ${rel}`),
132
+ ].join('\n'), flags.dryRun ? 'Would change' : 'Changes');
133
+ if (flags.dryRun) {
134
+ if (patch.manual.length)
135
+ p.note(patch.manual.map((m) => `• ${m}`).join('\n'), 'Would need doing by hand');
136
+ p.outro('Dry run — nothing was written.');
137
+ return;
138
+ }
139
+ for (const rel of changed) {
140
+ await project.write(rel, '');
141
+ await cp(staged.abs(rel), project.abs(rel));
142
+ }
143
+ await project.remove(...removed);
144
+ await eslintFix(project, changed);
145
+ }
146
+ finally {
147
+ await rm(stagingDir, { recursive: true, force: true });
148
+ }
149
+ // ── Install ──────────────────────────────────────────────
150
+ const pm = projectPackageManager(project);
151
+ let installed = false;
152
+ if (flags.install) {
153
+ if (await commandExists(pm)) {
154
+ // The animated spinner floods non-interactive output (CI logs, pipes).
155
+ const s = process.stdout.isTTY
156
+ ? p.spinner()
157
+ : { start: p.log.step, stop: p.log.success, error: p.log.error };
158
+ s.start(`Installing dependencies with ${pm}`);
159
+ try {
160
+ await run(pm, ['install'], cwd);
161
+ installed = true;
162
+ s.stop('Dependencies installed');
163
+ }
164
+ catch (error) {
165
+ s.error(`${pm} install failed — run it yourself to see why`);
166
+ if (process.env.DEBUG)
167
+ console.error(error);
168
+ }
169
+ }
170
+ else {
171
+ p.log.warn(`${pm} isn't installed — skipping dependency install.`);
172
+ }
173
+ }
174
+ // ── What's left ──────────────────────────────────────────
175
+ if (manual.length) {
176
+ p.note(manual.map((m) => `• ${m}`).join('\n'), pc.yellow("Couldn't patch — do these by hand"));
177
+ }
178
+ const steps = [installed ? null : PM[pm].install, ...nextSteps].filter(Boolean);
179
+ p.note(steps.map((step, i) => `${i + 1}. ${step}`).join('\n'), 'Next steps');
180
+ p.outro(`${pc.green('✔')} Added ${pc.bold(mod.name)} — docs in src/${mod.dir ?? mod.name}/README.md`);
181
+ }
182
+ function projectPackageManager(project) {
183
+ const byLockfile = Object.keys(PM).find((pm) => project.exists(PM[pm].lockfile));
184
+ return byLockfile ?? detectPackageManager() ?? 'yarn';
185
+ }
186
+ /**
187
+ * Runs the project's own ESLint autofix over the files `add` touched, when it
188
+ * is installed — import/export order rules depend on the final file (after the
189
+ * JS pass), which only the project's config can judge. Best effort.
190
+ */
191
+ async function eslintFix(project, changed) {
192
+ const bin = project.abs('node_modules/.bin/eslint');
193
+ const targets = changed.filter((rel) => /\.(jsx?|tsx?)$/.test(rel));
194
+ if (!existsSync(bin) || !project.exists('eslint.config.js') || !targets.length)
195
+ return;
196
+ try {
197
+ await run(bin, ['--fix', '--no-warn-ignored', ...targets], project.root);
198
+ }
199
+ catch {
200
+ // Remaining problems are the project's lint to report, not a failed add.
201
+ }
202
+ }
203
+ /** The project's installed Prettier, if any (see `formatFiles`). */
204
+ async function projectPrettier(cwd) {
205
+ try {
206
+ const entry = createRequire(join(cwd, 'package.json')).resolve('prettier');
207
+ const mod = await import(pathToFileURL(entry).href);
208
+ // The CommonJS entry puts the API under `default`.
209
+ const api = typeof mod.format === 'function' ? mod : mod.default;
210
+ return typeof api?.format === 'function' ? api : undefined;
211
+ }
212
+ catch {
213
+ return undefined;
214
+ }
215
+ }
216
+ function cancelled() {
217
+ p.cancel('Cancelled — nothing was changed.');
218
+ }
package/dist/generate.js CHANGED
@@ -8,6 +8,7 @@ import { applyState } from './steps/state.js';
8
8
  import { applyStyling } from './steps/styling.js';
9
9
  import { applyTooling } from './steps/tooling.js';
10
10
  import { formatProject } from './steps/format.js';
11
+ import { writeManifest } from './manifest.js';
11
12
  import { copyBaseTemplate } from './template.js';
12
13
  import { ProjectFiles } from './utils/files.js';
13
14
  import { PackageJsonEditor } from './utils/packageJson.js';
@@ -18,7 +19,7 @@ import { PackageJsonEditor } from './utils/packageJson.js';
18
19
  * Order matters: every overlay is written in TypeScript, so the TS → JS pass
19
20
  * runs after all of them, and formatting runs last.
20
21
  */
21
- export async function generateProject(options, onStep = () => { }) {
22
+ export async function generateProject(options, cliVersion, onStep = () => { }) {
22
23
  const files = new ProjectFiles(options.targetDir);
23
24
  onStep('Copying the boilerplate');
24
25
  await copyBaseTemplate(options.targetDir);
@@ -42,6 +43,7 @@ export async function generateProject(options, onStep = () => { }) {
42
43
  }
43
44
  await pkg.save();
44
45
  await writeReadme(files, options);
46
+ await writeManifest(files, options, cliVersion);
45
47
  onStep('Formatting');
46
48
  await formatProject(files);
47
49
  }
package/dist/index.js CHANGED
@@ -5,7 +5,9 @@ import { basename, join, relative, resolve } from 'node:path';
5
5
  import * as p from '@clack/prompts';
6
6
  import { Command, InvalidArgumentError } from 'commander';
7
7
  import pc from 'picocolors';
8
+ import { addModule } from './add.js';
8
9
  import { generateProject } from './generate.js';
10
+ import { MODULES } from './modules/index.js';
9
11
  import { CHOICES, defaultBundleId, DEFAULTS, STYLING_LABEL, toDisplayName, toPackageName, validateBundleId, validateProjectName, } from './options.js';
10
12
  import { detectPackageManager, PM } from './steps/packageManager.js';
11
13
  import { PACKAGE_ROOT } from './template.js';
@@ -18,8 +20,10 @@ const oneOf = (choices) => (value) => {
18
20
  return value;
19
21
  };
20
22
  const program = new Command()
23
+ // Options after `add` belong to `add`, not to project creation.
24
+ .enablePositionalOptions()
21
25
  .name('create-expo-boilerplate')
22
- .description('Scaffold an Expo app from the React-Native-Expo-Boilterplate.')
26
+ .description('Scaffold an Expo app from react-native-expo-boilerplate.')
23
27
  .version(version)
24
28
  .argument('[directory]', 'where to create the project')
25
29
  .option('--state <library>', 'zustand | redux', oneOf(CHOICES.state))
@@ -43,7 +47,34 @@ Examples:
43
47
  $ npx create-expo-boilerplate my-app
44
48
  $ npx create-expo-boilerplate my-app --state redux --redux-middleware saga --navigation react-navigation
45
49
  $ npx create-expo-boilerplate my-app --styling tailwind
46
- $ npx create-expo-boilerplate my-app --yes --js --no-husky`);
50
+ $ npx create-expo-boilerplate my-app --yes --js --no-husky
51
+
52
+ Add a module to an existing project (run from its root):
53
+ $ npx create-expo-boilerplate add notifications`)
54
+ .action(() => createProject());
55
+ const addCommand = program
56
+ .command('add')
57
+ .description('add a module to the project in the current directory')
58
+ .argument('[module]', Object.keys(MODULES).join(' | '))
59
+ .option('-y, --yes', 'accept defaults instead of prompting')
60
+ .option('--dry-run', 'show what would change without writing anything')
61
+ .option('--force', 'run even with uncommitted changes')
62
+ .option('--no-install', 'skip installing dependencies')
63
+ .addHelpText('after', `
64
+ Modules:
65
+ ${Object.values(MODULES)
66
+ .map((m) => ` ${m.name.padEnd(16)}${m.description}`)
67
+ .join('\n')}
68
+
69
+ Examples:
70
+ $ npx create-expo-boilerplate add notifications
71
+ $ npx create-expo-boilerplate add notifications --dry-run
72
+ $ npx create-expo-boilerplate add notifications --yes --token-endpoint users/me/devices`)
73
+ .action((name, flags) => addModule(name, flags, version));
74
+ for (const mod of Object.values(MODULES)) {
75
+ for (const flag of mod.flags)
76
+ addCommand.option(flag.flags, flag.description);
77
+ }
47
78
  function bail(value) {
48
79
  if (p.isCancel(value)) {
49
80
  p.cancel('Cancelled — nothing was created.');
@@ -68,8 +99,7 @@ async function isEmptyDir(dir) {
68
99
  return true; // doesn't exist yet
69
100
  }
70
101
  }
71
- async function main() {
72
- program.parse();
102
+ async function createProject() {
73
103
  const [dirArg] = program.args;
74
104
  const flags = program.opts();
75
105
  const defaults = Boolean(flags.yes) || !process.stdout.isTTY;
@@ -186,7 +216,7 @@ async function main() {
186
216
  const s = p.spinner();
187
217
  s.start('Generating project');
188
218
  try {
189
- await generateProject(options, (msg) => s.message(msg));
219
+ await generateProject(options, version, (msg) => s.message(msg));
190
220
  }
191
221
  catch (error) {
192
222
  s.error('Generation failed');
@@ -248,7 +278,7 @@ async function main() {
248
278
  .filter(Boolean)
249
279
  .join(' · ')}`);
250
280
  }
251
- main().catch((error) => {
281
+ program.parseAsync().catch((error) => {
252
282
  p.log.error(error instanceof Error ? error.message : String(error));
253
283
  process.exit(1);
254
284
  });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `boilerplate.json` — written into every generated project so `add` knows
3
+ * which stack it is patching. Projects created before it existed are detected
4
+ * from their files instead (see `detectStack`).
5
+ */
6
+ export const MANIFEST_FILE = 'boilerplate.json';
7
+ export async function writeManifest(files, options, cliVersion) {
8
+ const manifest = {
9
+ cliVersion,
10
+ navigation: options.navigation,
11
+ state: options.state,
12
+ styling: options.styling,
13
+ language: options.language,
14
+ modules: {},
15
+ };
16
+ await saveManifest(files, manifest);
17
+ }
18
+ export async function saveManifest(files, manifest) {
19
+ await files.write(MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`);
20
+ }
21
+ /** The manifest, or one rebuilt from the project's files when it's missing. */
22
+ export async function loadManifest(files) {
23
+ if (files.exists(MANIFEST_FILE)) {
24
+ return { manifest: JSON.parse(await files.read(MANIFEST_FILE)), detected: false };
25
+ }
26
+ return {
27
+ manifest: { cliVersion: 'unknown', modules: {}, ...(await detectStack(files)) },
28
+ detected: true,
29
+ };
30
+ }
31
+ async function detectStack(files) {
32
+ const pkg = JSON.parse(await files.read('package.json'));
33
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
34
+ return {
35
+ navigation: deps['expo-router'] ? 'expo-router' : 'react-navigation',
36
+ state: deps['@reduxjs/toolkit'] ? 'redux' : 'zustand',
37
+ styling: deps['react-native-unistyles']
38
+ ? 'unistyles'
39
+ : deps.uniwind
40
+ ? 'tailwind'
41
+ : 'stylesheet',
42
+ language: files.exists('tsconfig.json') ? 'ts' : 'js',
43
+ };
44
+ }
45
+ /** True when `dir` looks like a project this CLI generated. */
46
+ export function isBoilerplateProject(files) {
47
+ return (files.exists(MANIFEST_FILE) ||
48
+ (files.exists('package.json') &&
49
+ (files.exists('src/providers/AppProviders.tsx') ||
50
+ files.exists('src/providers/AppProviders.jsx'))));
51
+ }
@@ -0,0 +1,7 @@
1
+ import { notifications } from './notifications.js';
2
+ import { subscriptions } from './subscriptions.js';
3
+ /** Every module `add` can generate. Add new ones here. */
4
+ export const MODULES = {
5
+ [notifications.name]: notifications,
6
+ [subscriptions.name]: subscriptions,
7
+ };