umpordez 1.4.0 → 1.5.1

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.
@@ -54,7 +54,16 @@
54
54
  "Bash(tar -xzf expo-57.0.4.tgz package/bundledNativeModules.json package/package.json)",
55
55
  "Bash(npx expo-doctor *)",
56
56
  "Bash(pkill -f \"expo start --port 8090\")",
57
- "Bash(git *)"
57
+ "Bash(git *)",
58
+ "Bash(npm init *)",
59
+ "Bash(awk 'NR>=383 && NR<=386 {print NR\": \"substr\\($0,1,120\\)}' '/Users/ligeiro/dev/umpordez-cli/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx')",
60
+ "Bash(awk 'NR>=394 && NR<=410 {print NR\": \"substr\\($0,1,100\\)}' '/Users/ligeiro/dev/umpordez-cli/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx')",
61
+ "Bash(diskutil info *)",
62
+ "Bash(mount)",
63
+ "Bash(bash -n template/scripts/clean-appledouble.sh)",
64
+ "Bash(bash -n template/scripts/prebuild.sh)",
65
+ "Bash(bash -n template/scripts/run-ios.sh)",
66
+ "Bash(bash -n template/install.sh)"
58
67
  ]
59
68
  }
60
69
  }
package/create.mjs CHANGED
@@ -146,6 +146,7 @@ async function promptComponents() {
146
146
  // profile.
147
147
  let authApple = true;
148
148
  let authGoogle = true;
149
+ let iap = true;
149
150
  if (mobile) {
150
151
  ({ authApple } = await inquirer.prompt([{
151
152
  type: 'confirm',
@@ -161,9 +162,19 @@ async function promptComponents() {
161
162
  + 'OAuth flow)',
162
163
  default: true,
163
164
  }]));
165
+ // Disabling IAP strips the expo-iap dependency, its config
166
+ // plugin and the Android BILLING permission — PremiumGate
167
+ // then renders everything unlocked (a fully free app).
168
+ ({ iap } = await inquirer.prompt([{
169
+ type: 'confirm',
170
+ name: 'iap',
171
+ message: 'In-app purchases? (premium subscriptions via '
172
+ + 'expo-iap + server receipt validation)',
173
+ default: true,
174
+ }]));
164
175
  }
165
176
 
166
- return { components, appLanding, authApple, authGoogle };
177
+ return { components, appLanding, authApple, authGoogle, iap };
167
178
  }
168
179
 
169
180
  async function promptProjectDetails() {
@@ -172,7 +183,7 @@ async function promptProjectDetails() {
172
183
  console.log('');
173
184
 
174
185
  const {
175
- components, appLanding, authApple, authGoogle
186
+ components, appLanding, authApple, authGoogle, iap
176
187
  } = await promptComponents();
177
188
  const preset = presetNameForComponents(components);
178
189
 
@@ -355,6 +366,7 @@ async function promptProjectDetails() {
355
366
  appLanding,
356
367
  authApple,
357
368
  authGoogle,
369
+ iap,
358
370
  name: name.trim(),
359
371
  // Defaults for the parts the user didn't see — they're still
360
372
  // referenced by replacements (some unconditionally), so we fill
@@ -382,6 +394,104 @@ function validatePort(v) {
382
394
  : 'Must be a valid port number';
383
395
  }
384
396
 
397
+ // Filesystems that can't store extended attributes or symlinks. On
398
+ // macOS these make the OS scatter AppleDouble `._*` sidecar files next
399
+ // to everything npm installs — CocoaPods then fails parsing a
400
+ // `._Foo.podspec` during `expo prebuild`, and node_modules symlinks
401
+ // break. Generating an Expo project on one of these is a trap, so we
402
+ // warn before doing it.
403
+ const NON_NATIVE_FS = new Set([
404
+ 'exfat', 'msdos', 'fat32', 'vfat',
405
+ 'ntfs', 'ntfs-3g', 'fuseblk',
406
+ 'smbfs', 'cifs', 'nfs', 'afpfs', 'webdav',
407
+ ]);
408
+
409
+ // Resolve the filesystem type backing `targetPath`. The output dir
410
+ // usually doesn't exist yet, so we walk up to the nearest existing
411
+ // ancestor, then match it against the longest mount point in `mount`.
412
+ // Returns { mountPoint, fsType } or null (non-darwin, or undetectable).
413
+ function volumeInfo(targetPath) {
414
+ if (process.platform !== 'darwin') {
415
+ return null;
416
+ }
417
+
418
+ let dir = path.resolve(targetPath);
419
+ while (dir !== path.dirname(dir) && !fs.existsSync(dir)) {
420
+ dir = path.dirname(dir);
421
+ }
422
+
423
+ let mounts;
424
+ try {
425
+ mounts = execSync('mount', { encoding: 'utf-8' });
426
+ } catch {
427
+ return null;
428
+ }
429
+
430
+ // Lines look like: `/dev/disk10s1 on /Volumes/big (exfat, local, …)`
431
+ let best = null;
432
+ for (const line of mounts.split('\n')) {
433
+ const m = line.match(/ on (.+?) \(([^,)]+)/);
434
+ if (!m) {
435
+ continue;
436
+ }
437
+ const mountPoint = m[1];
438
+ const fsType = m[2].trim().toLowerCase();
439
+ const prefix = mountPoint.endsWith('/') ? mountPoint : mountPoint + '/';
440
+ if (dir === mountPoint || dir.startsWith(prefix)) {
441
+ if (!best || mountPoint.length > best.mountPoint.length) {
442
+ best = { mountPoint, fsType };
443
+ }
444
+ }
445
+ }
446
+ return best;
447
+ }
448
+
449
+ // Warn (and confirm) before generating a mobile project onto a
450
+ // filesystem macOS can't build native code on. Returns true to
451
+ // proceed, false to abort. Only meaningful when a mobile app is part
452
+ // of the project — the acute failure is CocoaPods + `expo prebuild`.
453
+ async function confirmFilesystem(outputDir, components) {
454
+ if (!components.mobile) {
455
+ return true;
456
+ }
457
+ const vol = volumeInfo(outputDir);
458
+ if (!vol || !NON_NATIVE_FS.has(vol.fsType)) {
459
+ return true;
460
+ }
461
+
462
+ console.log('');
463
+ console.log(red.bold(
464
+ ` ⚠ ${vol.mountPoint} is a ${vol.fsType} volume.`
465
+ ));
466
+ console.log(dim(
467
+ ' macOS can\'t store extended attributes or symlinks there,'
468
+ ));
469
+ console.log(dim(
470
+ ' so it scatters AppleDouble `._*` files next to everything'
471
+ ));
472
+ console.log(dim(
473
+ ' npm installs. `expo prebuild` then fails when CocoaPods'
474
+ ));
475
+ console.log(dim(
476
+ ' tries to parse a `._Foo.podspec`, and node_modules'
477
+ ));
478
+ console.log(dim(' symlinks break.'));
479
+ console.log('');
480
+ console.log(dim(
481
+ ' Recommended: generate on your internal drive (e.g. ~/dev),'
482
+ ));
483
+ console.log(dim(' an APFS / Mac OS Extended volume.'));
484
+ console.log('');
485
+
486
+ const { proceed } = await inquirer.prompt([{
487
+ type: 'confirm',
488
+ name: 'proceed',
489
+ message: `Generate on the ${vol.fsType} volume anyway?`,
490
+ default: false,
491
+ }]);
492
+ return proceed;
493
+ }
494
+
385
495
  function componentsLabel(c) {
386
496
  const parts = [];
387
497
  if (c.server) parts.push('server API');
@@ -432,6 +542,9 @@ function showSummary(details) {
432
542
  if (details.authApple) providers.push('Apple');
433
543
  if (details.authGoogle) providers.push('Google');
434
544
  console.log(` ${chalk.bold('Sign-in:')} ${providers.join(' | ')}`);
545
+ console.log(` ${chalk.bold('IAP:')} ${details.iap
546
+ ? 'premium subscriptions (expo-iap)'
547
+ : 'none (all features unlocked)'}`);
435
548
  }
436
549
  console.log('');
437
550
  console.log(dim(' Generates:'));
@@ -589,6 +702,11 @@ export async function createProject() {
589
702
 
590
703
  const outputDir = path.resolve(details.outputDir);
591
704
 
705
+ if (!await confirmFilesystem(outputDir, details.components)) {
706
+ console.log(chalk.yellow('Aborted. No files were created.'));
707
+ return;
708
+ }
709
+
592
710
  if (fs.existsSync(outputDir)) {
593
711
  const { overwrite } = await inquirer.prompt([{
594
712
  type: 'confirm',
@@ -695,6 +813,7 @@ export async function createProject() {
695
813
  if (details.components.mobile) {
696
814
  scripts.push(
697
815
  'scripts/bump-version.sh',
816
+ 'scripts/clean-appledouble.sh',
698
817
  'scripts/prebuild.sh',
699
818
  'scripts/run-ios.sh',
700
819
  'scripts/release-android.sh',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "umpordez",
3
3
  "type": "module",
4
- "version": "1.4.0",
4
+ "version": "1.5.1",
5
5
  "description": "SaaS starter kit generator — server, admin SPA, public site (i18n), mobile app (offline-first, IAP, native push)",
6
6
  "main": "cli.mjs",
7
7
  "scripts": {
@@ -312,7 +312,7 @@ if (!ok) return;
312
312
  - **Services own data access** — pages never call `getDb()` directly
313
313
  - **User-scoped writes check `demandSigned()`** — reads always work, writes no-op when gate closed
314
314
  - Every string through `t()` — no bare text in JSX
315
- - NitroModules crash in Expo Go — lazy-load with `try/require`
315
+ - Native-module crash in Expo Go (e.g. `expo-iap`) — lazy-load with `try/require`
316
316
  - Never `expo prebuild --clean` (wipes native code; use `scripts/prebuild.sh`)
317
317
 
318
318
  ### i18n Coverage
@@ -97,7 +97,7 @@ HTTP request builds a fresh `Context` with every model instantiated
97
97
  | 1 | auth flows, theme, navigation, splash, toasts, typed fetch |
98
98
  | 2 | offline SQLite (USER vs SHARED tables), cloud backup/restore |
99
99
  | 3 | server-driven content sync + HMAC-signed presigned asset URLs |
100
- | 4 | IAP via `react-native-iap`, premium gate, foreground re-check |
100
+ | 4 | IAP via `expo-iap` (optional at scaffold), premium gate, foreground re-check |
101
101
  | 5 | custom-native local notifications with cold-start tap dispatch |
102
102
  | 6 | Apple Health + Android Health Connect unified surface |
103
103
  | 7 | native cookbook (Hello iOS/Android + Live Activity scaffold) |
@@ -112,7 +112,7 @@ HTTP request builds a fresh `Context` with every model instantiated
112
112
  | 1 | auth flows, theme, navigation, splash, toasts, typed fetch |
113
113
  | 2 | offline SQLite (USER vs SHARED tables), cloud backup/restore via S3 |
114
114
  | 3 | server-driven content sync + HMAC-signed presigned asset URLs |
115
- | 4 | IAP (`react-native-iap`), receipt validation, premium gate, foreground re-check |
115
+ | 4 | IAP (`expo-iap`, optional at scaffold), receipt validation, premium gate, foreground re-check |
116
116
  | 5 | custom-native local notifications with cold-start tap dispatch |
117
117
  | 6 | Apple Health + Android Health Connect unified surface |
118
118
  | 7 | native modules cookbook (Hello iOS/Android + Live Activity scaffold + gotchas) |
@@ -514,8 +514,11 @@ POST /api/logout</code></pre>
514
514
  <section id="iap">
515
515
  <h2>IAP &amp; premium <span class="badge">Phase 4</span></h2>
516
516
  <p>
517
- <code>react-native-iap</code> v15 lazy-loaded with
518
- try/require so Expo Go gets graceful no-ops.
517
+ <code>expo-iap</code> lazy-loaded with try/require so
518
+ Expo Go gets graceful no-ops. Optional at scaffold
519
+ time &mdash; when disabled, the dependency ships
520
+ stripped and PremiumGate renders everything
521
+ unlocked.
519
522
  </p>
520
523
  <pre><code>const { activatePremium } = useAuth();
521
524
  const purchase = await purchaseSubscription(PRODUCT_IDS.annual);
@@ -5,6 +5,8 @@ build/
5
5
  dist/
6
6
  *.log
7
7
  .DS_Store
8
+ ._*
9
+ .AppleDouble
8
10
  *.swp
9
11
  *.swo
10
12
  *~
@@ -55,6 +55,10 @@ if [ -d "mobile/{{PROJECT_SLUG}}" ]; then
55
55
 
56
56
  if [[ "$(uname)" == "Darwin" ]]; then
57
57
  if [ -d "mobile/{{PROJECT_SLUG}}/ios" ]; then
58
+ # Strip AppleDouble `._*` files (exFAT/NTFS/network
59
+ # volumes) so pod install doesn't choke on a
60
+ # `._Foo.podspec`. No-op on APFS.
61
+ ./scripts/clean-appledouble.sh "mobile/{{PROJECT_SLUG}}"
58
62
  echo "Installing iOS pods..."
59
63
  (cd "mobile/{{PROJECT_SLUG}}/ios" && pod install)
60
64
  fi
@@ -25,7 +25,7 @@ src/
25
25
  │ ├── db/ SQLite (USER_TABLES vs SHARED_TABLES)
26
26
  │ ├── backup/ Two-phase S3 backup (premium)
27
27
  │ ├── content/ Versioned catalog + HMAC-signed URLs
28
- │ ├── iap/ react-native-iap wrapped lazily
28
+ │ ├── iap/ expo-iap wrapped lazily (optional)
29
29
  │ └── notifications/ Custom-native, cold-start tap dispatch
30
30
  └── components/
31
31
  ├── AnimatedSplash.tsx
@@ -86,9 +86,12 @@ hasCompletedOnboarding
86
86
  - **Every user-facing string goes through `t()`.** Even pt-BR
87
87
  default strings. No bare strings in JSX.
88
88
 
89
- - **NitroModules + Expo Go = crash on import.** Anything using
90
- nitro (e.g. `react-native-iap` v15) must be lazy-loaded with
91
- `try/require`. See `services/iap/index.ts`.
89
+ - **Native modules + Expo Go = crash on import.** Anything whose
90
+ native side isn't bundled in Expo Go (e.g. `expo-iap`) must be
91
+ lazy-loaded with `try/require`. See `services/iap/index.ts`.
92
+ IAP itself is optional at scaffold time: `config.iap === false`
93
+ means the expo-iap dependency was never installed and PremiumGate
94
+ renders everything unlocked.
92
95
 
93
96
  - **`expo prebuild --clean` will silently delete native code.**
94
97
  Use `./scripts/prebuild.sh` (refuses --clean). To genuinely
@@ -22,8 +22,7 @@
22
22
  "backgroundColor": "{{PRIMARY_COLOR}}"
23
23
  },
24
24
  "permissions": [
25
- "android.permission.INTERNET",
26
- "com.android.vending.BILLING"
25
+ "android.permission.INTERNET"{{IAP_BILLING_PERMISSION_JSON}}
27
26
  ]
28
27
  },
29
28
  "web": {
@@ -51,8 +50,7 @@
51
50
  }
52
51
  ],
53
52
  "expo-web-browser",{{APPLE_AUTH_PLUGIN_JSON}}
54
- "expo-sqlite",
55
- "react-native-iap"
53
+ "expo-sqlite"{{IAP_PLUGIN_JSON}}
56
54
  ],
57
55
  "extra": {
58
56
  "eas": {
@@ -11,7 +11,18 @@ export default [
11
11
  rules: {
12
12
  "@stylistic/indent": ["error", 4],
13
13
  "@stylistic/semi": ["error", "always"],
14
- "@stylistic/max-len": ["error", { "code": 80 }],
14
+ // ignorePattern: SVG path data (brand icons) can't be
15
+ // wrapped without corrupting the artwork.
16
+ "@stylistic/max-len": ["error", {
17
+ "code": 80,
18
+ "ignorePattern": "\\sd=\""
19
+ }],
20
+ // Underscore prefix = intentionally unused (e.g. a screen
21
+ // that takes navigation props but doesn't read them yet).
22
+ "@typescript-eslint/no-unused-vars": ["error", {
23
+ "argsIgnorePattern": "^_",
24
+ "varsIgnorePattern": "^_"
25
+ }],
15
26
  "curly": ["error", "all"]
16
27
  }
17
28
  }
@@ -89,9 +89,9 @@ these are auto-installed** — copy out what you need:
89
89
  `scripts/prebuild.sh` refuses `--clean`. To genuinely reset, move
90
90
  customizations into config plugins under `./plugins/` first.
91
91
 
92
- - **NitroModules + Expo Go = crash on import.** Anything using nitro
93
- (e.g. react-native-iap v15) needs to be lazy-loaded with
94
- `try/require` so Expo Go doesn't blow up. See
92
+ - **Native modules + Expo Go = crash on import.** Anything whose
93
+ native side isn't in Expo Go (e.g. expo-iap) needs to be
94
+ lazy-loaded with `try/require` so Expo Go doesn't blow up. See
95
95
  `src/services/iap/index.ts` for the pattern.
96
96
 
97
97
  - **iOS HealthKit needs an entitlement.** It's wired in `app.json`
@@ -28,7 +28,7 @@
28
28
  "expo-auth-session": "~57.0.2",
29
29
  "expo-build-properties": "~57.0.3",
30
30
  "expo-constants": "~57.0.3",
31
- "expo-file-system": "~57.0.0",
31
+ "expo-file-system": "~57.0.0",{{IAP_DEP_JSON}}
32
32
  "expo-linking": "~57.0.2",
33
33
  "expo-localization": "~57.0.0",
34
34
  "expo-splash-screen": "~57.0.2",
@@ -42,8 +42,6 @@
42
42
  "react-native": "0.86.0",
43
43
  "react-i18next": "^15.1.1",
44
44
  "react-native-gesture-handler": "~2.32.0",
45
- "react-native-iap": "^15.3.6",
46
- "react-native-nitro-modules": "^0.35.4",
47
45
  "react-native-reanimated": "4.5.0",
48
46
  "react-native-safe-area-context": "~5.7.0",
49
47
  "react-native-screens": "4.25.2",
@@ -25,6 +25,10 @@ const { width, height } = Dimensions.get('window');
25
25
  const BG = DARK.bg;
26
26
  const GLOW_COLOR = DARK.primary;
27
27
 
28
+ // Static asset — require() is the RN idiom for bundled images.
29
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
30
+ const SPLASH_ICON = require('../../assets/splash-icon.png');
31
+
28
32
  // Glow + ring sit at screen-centre because our generated splash-icon
29
33
  // is a trimmed glyph composited onto a transparent square canvas (see
30
34
  // icons.mjs renderFromScratch — splash mode). With resizeMode:contain
@@ -177,7 +181,7 @@ export default function AnimatedSplash({ onDone }: Props) {
177
181
  />
178
182
 
179
183
  <Animated.Image
180
- source={require('../../assets/splash-icon.png')}
184
+ source={SPLASH_ICON}
181
185
  style={[
182
186
  s.splash,
183
187
  { opacity: logoOp, transform: [{ scale: logoSc }] }
@@ -7,6 +7,7 @@ import {
7
7
  } from 'react-native';
8
8
  import { Lock } from 'lucide-react-native';
9
9
  import { useTranslation } from 'react-i18next';
10
+ import config from '../config';
10
11
  import { useAuth } from '../contexts/AuthContext';
11
12
  import { useTheme } from '../contexts/ThemeContext';
12
13
 
@@ -39,7 +40,9 @@ export default function PremiumGate({
39
40
  const finalDescription =
40
41
  description ?? t('premiumGate.defaultDescription');
41
42
 
42
- if (user?.is_premium) {
43
+ // Projects scaffolded without in-app purchases have nothing to
44
+ // sell — every feature renders unlocked.
45
+ if (!config.iap || user?.is_premium) {
43
46
  return <>{children}</>;
44
47
  }
45
48
 
@@ -56,6 +56,15 @@ export const SPLASH_ANIMATED = {{SPLASH_ANIMATED}};
56
56
  const GOOGLE_AUTH_ENABLED = {{AUTH_GOOGLE_ENABLED}};
57
57
  const APPLE_AUTH_ENABLED = {{AUTH_APPLE_ENABLED}};
58
58
 
59
+ // In-app purchases, chosen at scaffold time via `umpordez create`.
60
+ // When disabled the expo-iap dependency, config plugin and Android
61
+ // BILLING permission were stripped from the scaffold — PremiumGate
62
+ // renders its children unlocked and the iap service no-ops. To turn
63
+ // IAP on later: `npx expo install expo-iap`, add "expo-iap" to
64
+ // app.json plugins, re-add com.android.vending.BILLING, restore the
65
+ // try/require in src/services/iap/index.ts and flip this to true.
66
+ const IAP_ENABLED = {{IAP_ENABLED}};
67
+
59
68
  if (__DEV__) {
60
69
  console.log(
61
70
  `[config] API: ${baseApiUrl} | prod=${isProdBuild} `
@@ -73,5 +82,6 @@ export default {
73
82
  googleIosClientId: GOOGLE_IOS_CLIENT_ID,
74
83
  googleAuth: GOOGLE_AUTH_ENABLED,
75
84
  appleAuth: APPLE_AUTH_ENABLED,
85
+ iap: IAP_ENABLED,
76
86
  scheme: '{{MOBILE_SCHEME}}'
77
87
  };
@@ -19,7 +19,8 @@ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
19
19
 
20
20
  // CHANGE THIS to a long random string and keep it in sync with
21
21
  // server/.env CONTENT_SECRET. Same value at both ends.
22
- export const CONTENT_SECRET = '{{CONTENT_SECRET}}';
22
+ export const CONTENT_SECRET =
23
+ '{{CONTENT_SECRET}}';
23
24
 
24
25
  interface SignedHeaders {
25
26
  'X-Content-Timestamp': string;
@@ -1,7 +1,7 @@
1
1
  /**
2
- * In-App Purchase service — wraps `react-native-iap` v15 with:
2
+ * In-App Purchase service — wraps `expo-iap` (OpenIAP) with:
3
3
  *
4
- * - Lazy-load via try/require so Expo Go (no NitroModules) gets
4
+ * - Lazy-load via try/require so Expo Go (no native module) gets
5
5
  * graceful no-ops instead of crashing on import.
6
6
  * - One source of truth for product IDs (PRODUCT_IDS below — change
7
7
  * them to match what you set up in App Store Connect / Play Console).
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { Platform } from 'react-native';
16
+ import config from '../../config';
16
17
  import { fetchApi } from '../fetch-api';
17
18
  import type { ApiUser } from '../api';
18
19
 
@@ -35,22 +36,13 @@ interface IAPNative {
35
36
  isConsumable: boolean;
36
37
  }) => Promise<unknown>;
37
38
  getAvailablePurchases: () => Promise<NativePurchase[]>;
38
- deepLinkToSubscriptionsIOS?: () => Promise<unknown>;
39
- deepLinkToSubscriptions?: (args: {
40
- packageNameAndroid: string;
41
- skuAndroid: string;
42
- }) => Promise<unknown>;
39
+ deepLinkToSubscriptions?: (args?: {
40
+ packageNameAndroid?: string;
41
+ skuAndroid?: string;
42
+ } | null) => Promise<unknown>;
43
43
  }
44
44
 
45
- let _iap: IAPNative | null = null;
46
- try {
47
- // eslint-disable-next-line @typescript-eslint/no-require-imports
48
- _iap = require('react-native-iap') as IAPNative;
49
- } catch {
50
- console.info(
51
- '[iap] react-native-iap not available (Expo Go) — IAP disabled'
52
- );
53
- }
45
+ {{IAP_REQUIRE_BLOCK}}
54
46
 
55
47
  const isAvailable = !!_iap;
56
48
 
@@ -60,8 +52,8 @@ const isAvailable = !!_iap;
60
52
  // product IDs) and Google Play Console (Monetize → Subscriptions). The
61
53
  // strings can be anything but must match across all three places.
62
54
  export const PRODUCT_IDS = {
63
- monthly: '{{PROJECT_SLUG}}_premium_monthly',
64
- annual: '{{PROJECT_SLUG}}_premium_annual'
55
+ monthly: '{{PROJECT_SLUG_UNDERSCORE}}_premium_monthly',
56
+ annual: '{{PROJECT_SLUG_UNDERSCORE}}_premium_annual'
65
57
  } as const;
66
58
 
67
59
  const PRODUCT_ID_LIST = Object.values(PRODUCT_IDS);
@@ -314,7 +306,7 @@ export async function openSubscriptionManagement(): Promise<boolean> {
314
306
  }
315
307
  try {
316
308
  if (Platform.OS === 'ios') {
317
- await _iap!.deepLinkToSubscriptionsIOS?.();
309
+ await _iap!.deepLinkToSubscriptions?.();
318
310
  return true;
319
311
  }
320
312
  await _iap!.deepLinkToSubscriptions?.({
@@ -350,9 +342,9 @@ export async function validateWithServer(
350
342
  ): Promise<ValidateResponse> {
351
343
  let body: Record<string, unknown>;
352
344
  if (Platform.OS === 'ios') {
353
- // StoreKit 2 / react-native-iap v15 no longer exposes a
354
- // receipt blob — server validates via transactionId using the
355
- // App Store Server API.
345
+ // StoreKit 2 / expo-iap no longer exposes a receipt blob —
346
+ // server validates via transactionId using the App Store
347
+ // Server API.
356
348
  const originalTransactionId =
357
349
  purchase.originalTransactionIdentifierIOS
358
350
  ?? purchase.transactionId;
@@ -397,6 +389,9 @@ export async function fetchPremiumStatus(token: string): Promise<{
397
389
  ok: boolean;
398
390
  is_premium: boolean;
399
391
  }> {
392
+ if (!config.iap) {
393
+ return { ok: false, is_premium: false };
394
+ }
400
395
  try {
401
396
  return await fetchApi('/api/iap/status', { token });
402
397
  } catch (err) {
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # clean-appledouble — remove macOS AppleDouble `._*` sidecar files.
4
+ #
5
+ # macOS drops a `._<name>` metadata file next to every file that carries
6
+ # extended attributes when it lives on a filesystem that can't store
7
+ # xattrs natively (exFAT, MS-DOS/FAT, NTFS, SMB/NFS network mounts).
8
+ # CocoaPods then scans for `*.podspec`, finds `._Foo.podspec`, tries to
9
+ # parse the binary metadata as Ruby, and `pod install` — and therefore
10
+ # `expo prebuild` / `expo run:ios` — dies with:
11
+ #
12
+ # Invalid `Podfile` file: Invalid podspec file at path
13
+ # .../node_modules/.../ios/._Foo.podspec
14
+ #
15
+ # On APFS / Mac OS Extended there are none, so this is a fast no-op. Run
16
+ # it before any pod install / prebuild / native build.
17
+ #
18
+ # The real fix is to keep the project on an APFS / Mac OS Extended disk
19
+ # (e.g. ~/dev) — exFAT/NTFS regenerate these files on every npm install.
20
+ #
21
+ # Usage: ./scripts/clean-appledouble.sh [dir] (default: repo root)
22
+
23
+ set -euo pipefail
24
+
25
+ # AppleDouble files are a macOS-only artifact.
26
+ [[ "$(uname)" == "Darwin" ]] || exit 0
27
+
28
+ TARGET="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
29
+ [[ -d "$TARGET" ]] || exit 0
30
+
31
+ n=$(
32
+ find "$TARGET" -name '._*' -type f -print -delete 2>/dev/null \
33
+ | wc -l | tr -d ' '
34
+ )
35
+
36
+ if [[ "${n:-0}" -gt 0 ]]; then
37
+ echo "==> Removed $n AppleDouble ._* file(s) under $TARGET"
38
+ echo " This disk can't store macOS extended attributes (likely"
39
+ echo " exFAT/NTFS/network), so they'll return on the next npm"
40
+ echo " install. For clean native builds, move the project to an"
41
+ echo " APFS / Mac OS Extended disk (e.g. ~/dev)."
42
+ fi
@@ -24,5 +24,12 @@ for arg in "$@"; do
24
24
  esac
25
25
  done
26
26
 
27
- cd "$(dirname "$0")/../mobile/{{PROJECT_SLUG}}"
27
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
28
+ cd "$SCRIPT_DIR/../mobile/{{PROJECT_SLUG}}"
29
+
30
+ # Strip macOS AppleDouble `._*` files before prebuild — CocoaPods
31
+ # chokes on `._Foo.podspec` when the project lives on exFAT/NTFS/network
32
+ # volumes. No-op on APFS. See scripts/clean-appledouble.sh.
33
+ "$SCRIPT_DIR/clean-appledouble.sh" "$PWD"
34
+
28
35
  exec npx expo prebuild "$@"
@@ -53,6 +53,11 @@ done
53
53
 
54
54
  cd "$APP_DIR"
55
55
 
56
+ # Strip macOS AppleDouble `._*` files so the pod install triggered by
57
+ # `expo run:ios` doesn't choke on `._Foo.podspec` (exFAT/NTFS/network
58
+ # volumes). No-op on APFS. See scripts/clean-appledouble.sh.
59
+ "$SCRIPT_DIR/clean-appledouble.sh" "$APP_DIR"
60
+
56
61
  if [[ $CLEAN -eq 1 ]]; then
57
62
  echo "==> Cleaning iOS build artifacts..."
58
63
  rm -rf ios/build
@@ -52,6 +52,29 @@
52
52
  // when off (the plugin adds the Sign in
53
53
  // with Apple entitlement unconditionally,
54
54
  // so it must not ship when disabled).
55
+ // {{PROJECT_SLUG_UNDERSCORE}} — Slug with dashes → underscores. Used
56
+ // for IAP product IDs (Apple rejects
57
+ // dashes in product identifiers).
58
+ // {{IAP_ENABLED}} — "true"/"false" — in-app purchases picked
59
+ // at scaffold time. Consumed by mobile
60
+ // src/config.ts; gates PremiumGate and the
61
+ // iap service.
62
+ // {{IAP_DEP_JSON}} — package.json fragment: the `"expo-iap"`
63
+ // dependency line when IAP is on, empty
64
+ // when off (expo-iap autolinks its native
65
+ // billing code whenever installed, so the
66
+ // dep itself must not ship when disabled).
67
+ // {{IAP_PLUGIN_JSON}} — app.json fragment: the `"expo-iap"`
68
+ // plugins entry when IAP is on, empty
69
+ // when off.
70
+ // {{IAP_BILLING_PERMISSION_JSON}} — app.json fragment: the Android
71
+ // `com.android.vending.BILLING` permission
72
+ // when IAP is on, empty when off.
73
+ // {{IAP_REQUIRE_BLOCK}} — services/iap/index.ts fragment: the lazy
74
+ // try/require of expo-iap when IAP is on;
75
+ // a stub comment when off (Metro resolves
76
+ // require() statically, so the literal
77
+ // must not appear when the dep is absent).
55
78
 
56
79
  // ─── Site landing variants ────────────────────────────────────────────────────
57
80
  // The public site ships two landing flavors. The SaaS landing (CTAs →
@@ -355,6 +378,7 @@ export function buildReplacements(details) {
355
378
  '{{PROJECT_SLUG_UPPER}}': details.slug
356
379
  .toUpperCase()
357
380
  .replace(/-/g, '_'),
381
+ '{{PROJECT_SLUG_UNDERSCORE}}': details.slug.replace(/-/g, '_'),
358
382
  '{{PROJECT_DESCRIPTION}}': details.description,
359
383
  '{{DOMAIN}}': details.domain,
360
384
  '{{DB_NAME}}': details.dbName,
@@ -402,5 +426,37 @@ export function buildReplacements(details) {
402
426
  '{{APPLE_AUTH_PLUGIN_JSON}}': details.authApple === false
403
427
  ? ''
404
428
  : '\n "expo-apple-authentication",',
429
+ // In-app purchases chosen at scaffold time. Defaults to
430
+ // enabled so legacy manifests (which never recorded a
431
+ // choice) keep their current behavior on update. When
432
+ // disabled, the expo-iap dependency / config plugin /
433
+ // BILLING permission / require() all disappear — the iap
434
+ // service and PremiumGate degrade to graceful no-ops.
435
+ '{{IAP_ENABLED}}': details.iap === false ? 'false' : 'true',
436
+ '{{IAP_DEP_JSON}}': details.iap === false
437
+ ? ''
438
+ : '\n "expo-iap": "^4.4.0",',
439
+ '{{IAP_PLUGIN_JSON}}': details.iap === false
440
+ ? ''
441
+ : ',\n "expo-iap"',
442
+ '{{IAP_BILLING_PERMISSION_JSON}}': details.iap === false
443
+ ? ''
444
+ : ',\n "com.android.vending.BILLING"',
445
+ '{{IAP_REQUIRE_BLOCK}}': details.iap === false
446
+ ? '// IAP disabled at scaffold time (`umpordez create`\n'
447
+ + '// without in-app purchases) — expo-iap is not\n'
448
+ + '// installed and every function below no-ops.\n'
449
+ + 'const _iap: IAPNative | null = null;'
450
+ : 'let _iap: IAPNative | null = null;\n'
451
+ + 'try {\n'
452
+ + ' // eslint-disable-next-line '
453
+ + '@typescript-eslint/no-require-imports\n'
454
+ + ' _iap = require(\'expo-iap\') as IAPNative;\n'
455
+ + '} catch {\n'
456
+ + ' console.info(\n'
457
+ + ' \'[iap] expo-iap not available (Expo Go)'
458
+ + ' — IAP disabled\'\n'
459
+ + ' );\n'
460
+ + '}',
405
461
  };
406
462
  }