umpordez 1.3.2 → 1.4.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.
@@ -49,7 +49,12 @@
49
49
  "Bash(npm pack *)",
50
50
  "Read(//Users/ligeiro/dev/dropa/**)",
51
51
  "Read(//Users/ligeiro/dev/growerheleper-v2/app/**)",
52
- "Bash(git mv *)"
52
+ "Bash(git mv *)",
53
+ "Bash(npm view *)",
54
+ "Bash(tar -xzf expo-57.0.4.tgz package/bundledNativeModules.json package/package.json)",
55
+ "Bash(npx expo-doctor *)",
56
+ "Bash(pkill -f \"expo start --port 8090\")",
57
+ "Bash(git *)"
53
58
  ]
54
59
  }
55
60
  }
package/cli.mjs CHANGED
@@ -41,7 +41,7 @@ function showHelp() {
41
41
  console.log(` umpordez --version ${dim('Show version')}`);
42
42
  console.log('');
43
43
  console.log(green.bold('What you get:'));
44
- console.log(` ${green('>')} Presets: everything / web-only / mobile+site / mobile+server / just-mobile`);
44
+ console.log(` ${green('>')} Pick components one-by-one: server API / admin webapp / site / mobile`);
45
45
  console.log(` ${green('>')} Multi-tenant auth (cookie-based JWT)`);
46
46
  console.log(` ${green('>')} Admin API + Site API (Express)`);
47
47
  console.log(` ${green('>')} React admin panel (Vite + shadcn/ui)`);
package/create.mjs CHANGED
@@ -7,7 +7,10 @@ import { fileURLToPath } from 'node:url';
7
7
  import {
8
8
  buildReplacements,
9
9
  APP_LANDING_SWAPS,
10
- SITE_VARIANT_DIR
10
+ SITE_VARIANT_DIR,
11
+ includesForComponents,
12
+ rootFilesForComponents,
13
+ presetNameForComponents
11
14
  } from './template-variables.mjs';
12
15
  import { generateIcons } from './icons.mjs';
13
16
  import { writeInitialManifest } from './update.mjs';
@@ -28,71 +31,12 @@ const BINARY_EXTENSIONS = new Set([
28
31
  '.pdf', '.mp3', '.mp4', '.webm', '.ogg',
29
32
  ]);
30
33
 
31
- // Presets describe which top-level template directories ship in a new
32
- // project. The CLI scrubs the rest after copying — keeps the template
33
- // structure unconditional and the create-time logic dead simple.
34
- const PRESETS = {
35
- everything: {
36
- label: 'Everything (server + admin UI + site + mobile)',
37
- includes: ['server', 'ui/admin', 'ui/site', 'mobile', 'misc',
38
- 'scripts', 'doc'],
39
- wantsMobile: true,
40
- wantsServer: true,
41
- wantsAdminUi: true,
42
- wantsSite: true,
43
- },
44
- 'web-only': {
45
- label: 'Web only (server + admin UI + site, no mobile)',
46
- includes: ['server', 'ui/admin', 'ui/site', 'misc', 'doc'],
47
- wantsMobile: false,
48
- wantsServer: true,
49
- wantsAdminUi: true,
50
- wantsSite: true,
51
- },
52
- 'mobile-and-site': {
53
- label: 'Mobile app + landing page (no server, no admin)',
54
- includes: ['mobile', 'ui/site', 'scripts', 'doc'],
55
- wantsMobile: true,
56
- wantsServer: false,
57
- wantsAdminUi: false,
58
- wantsSite: true,
59
- // Swaps the SaaS landing for the app-marketing landing
60
- // (App Store / Play buttons, pricing, download CTA).
61
- appLanding: true,
62
- },
63
- 'mobile-and-server': {
64
- label: 'Mobile + server only (no admin UI, no site)',
65
- includes: ['server', 'mobile', 'misc', 'scripts', 'doc'],
66
- wantsMobile: true,
67
- wantsServer: true,
68
- wantsAdminUi: false,
69
- wantsSite: false,
70
- },
71
- 'just-mobile': {
72
- label: 'Just mobile (assumes external API)',
73
- includes: ['mobile', 'scripts', 'doc'],
74
- wantsMobile: true,
75
- wantsServer: false,
76
- wantsAdminUi: false,
77
- wantsSite: false,
78
- },
79
- };
80
-
81
- const ROOT_FILES_BY_PRESET = {
82
- everything: ['CLAUDE.md', 'AGENTS.md', 'README.md',
83
- 'architecture.md', 'code.md',
84
- 'install.sh', 'seed.sh', 'dev.sh'],
85
- 'web-only': ['CLAUDE.md', 'AGENTS.md', 'README.md',
86
- 'architecture.md', 'code.md',
87
- 'install.sh', 'seed.sh', 'dev.sh'],
88
- 'mobile-and-site': ['CLAUDE.md', 'AGENTS.md', 'README.md',
89
- 'code.md', 'install.sh', 'dev.sh'],
90
- 'mobile-and-server': ['CLAUDE.md', 'AGENTS.md', 'README.md',
91
- 'architecture.md', 'code.md',
92
- 'install.sh', 'seed.sh', 'dev.sh'],
93
- 'just-mobile': ['CLAUDE.md', 'README.md', 'code.md',
94
- 'install.sh', 'dev.sh'],
95
- };
34
+ // A project is any combination of four components picked one-by-one
35
+ // at create time (see template-variables.mjs for the component
36
+ // template-paths mapping shared with `umpordez update`). The CLI
37
+ // copies the whole template then scrubs what wasn't picked — keeps
38
+ // the template structure unconditional and the create-time logic
39
+ // dead simple.
96
40
 
97
41
  function slugify(text) {
98
42
  return text
@@ -113,23 +57,124 @@ function toReverseDomain(domain) {
113
57
  return parts.reverse().join('.');
114
58
  }
115
59
 
60
+ // Ask for each component one-by-one instead of a fixed preset list.
61
+ // The questions adapt to earlier answers:
62
+ // - the server API pitch changes when a mobile app was picked (a
63
+ // mobile-only backend for auth / IAP validation / file serving
64
+ // is a first-class use case, not "everything minus stuff")
65
+ // - the admin webapp is only offered when there's an API for it
66
+ // - the landing-page flavor is only asked when it's genuinely
67
+ // ambiguous (site + mobile + admin webapp)
68
+ async function promptComponents() {
69
+ const { mobile } = await inquirer.prompt([{
70
+ type: 'confirm',
71
+ name: 'mobile',
72
+ message: 'Mobile app? (Expo + React Native + offline SQLite '
73
+ + '+ IAP)',
74
+ default: true,
75
+ }]);
76
+
77
+ const { server } = await inquirer.prompt([{
78
+ type: 'confirm',
79
+ name: 'server',
80
+ message: mobile
81
+ ? 'Server API? (Express + PostgreSQL — powers auth, '
82
+ + 'IAP validation, file serving and backups for the '
83
+ + 'app)'
84
+ : 'Server API? (Express + PostgreSQL, multi-tenant auth)',
85
+ default: true,
86
+ }]);
87
+
88
+ let adminUi = false;
89
+ if (server) {
90
+ ({ adminUi } = await inquirer.prompt([{
91
+ type: 'confirm',
92
+ name: 'adminUi',
93
+ message: 'Admin webapp? (React SPA + shadcn/ui, '
94
+ + 'role-based)',
95
+ default: true,
96
+ }]));
97
+ } else {
98
+ console.log(dim(
99
+ ' (admin webapp skipped — it needs the server API)'
100
+ ));
101
+ }
102
+
103
+ const { site } = await inquirer.prompt([{
104
+ type: 'confirm',
105
+ name: 'site',
106
+ message: 'Public site? (EJS + i18n + SEO landing page)',
107
+ default: true,
108
+ }]);
109
+
110
+ const components = { server, adminUi, site, mobile };
111
+
112
+ if (!server && !adminUi && !site && !mobile) {
113
+ console.log(red(' Pick at least one component.'));
114
+ console.log('');
115
+ return promptComponents();
116
+ }
117
+
118
+ // Landing flavor. Only ambiguous when the site coexists with
119
+ // both an app to advertise and an admin webapp to sign up into.
120
+ let appLanding = false;
121
+ if (site && mobile && !adminUi) {
122
+ appLanding = true;
123
+ console.log(dim(
124
+ ' (landing page: app-store flavor — download CTAs, '
125
+ + 'no signup to point at)'
126
+ ));
127
+ } else if (site && mobile && adminUi) {
128
+ const { landing } = await inquirer.prompt([{
129
+ type: 'list',
130
+ name: 'landing',
131
+ message: 'Landing page flavor:',
132
+ choices: [
133
+ { name: 'SaaS (CTAs → admin signup)', value: 'saas' },
134
+ { name: 'App store (download buttons + pricing)',
135
+ value: 'app' },
136
+ ],
137
+ default: 'saas',
138
+ }]);
139
+ appLanding = landing === 'app';
140
+ }
141
+
142
+ // Sign-in providers for the mobile app. Email+password always
143
+ // ships; Apple and Google are opt-out. Disabling Apple also
144
+ // strips the entitlement + config plugin from app.json so local
145
+ // iOS builds don't require the capability on the provisioning
146
+ // profile.
147
+ let authApple = true;
148
+ let authGoogle = true;
149
+ if (mobile) {
150
+ ({ authApple } = await inquirer.prompt([{
151
+ type: 'confirm',
152
+ name: 'authApple',
153
+ message: 'Mobile sign-in with Apple? (email+password '
154
+ + 'always included)',
155
+ default: true,
156
+ }]));
157
+ ({ authGoogle } = await inquirer.prompt([{
158
+ type: 'confirm',
159
+ name: 'authGoogle',
160
+ message: 'Mobile sign-in with Google? (server-side '
161
+ + 'OAuth flow)',
162
+ default: true,
163
+ }]));
164
+ }
165
+
166
+ return { components, appLanding, authApple, authGoogle };
167
+ }
168
+
116
169
  async function promptProjectDetails() {
117
170
  console.log(green.bold(' Project Setup'));
118
171
  console.log(chalk.hex('#2c2c2c')(' ' + '─'.repeat(40)));
119
172
  console.log('');
120
173
 
121
- const { preset } = await inquirer.prompt([{
122
- type: 'list',
123
- name: 'preset',
124
- message: 'What do you want to generate?',
125
- choices: Object.entries(PRESETS).map(([value, def]) => ({
126
- name: def.label,
127
- value,
128
- })),
129
- default: 'everything',
130
- }]);
131
-
132
- const presetDef = PRESETS[preset];
174
+ const {
175
+ components, appLanding, authApple, authGoogle
176
+ } = await promptComponents();
177
+ const preset = presetNameForComponents(components);
133
178
 
134
179
  const { name } = await inquirer.prompt([{
135
180
  type: 'input',
@@ -165,7 +210,7 @@ async function promptProjectDetails() {
165
210
  },
166
211
  ];
167
212
 
168
- const serverQuestions = !presetDef.wantsServer ? [] : [
213
+ const serverQuestions = !components.server ? [] : [
169
214
  {
170
215
  type: 'input',
171
216
  name: 'dbName',
@@ -204,7 +249,7 @@ async function promptProjectDetails() {
204
249
  },
205
250
  ];
206
251
 
207
- const webUiQuestions = !presetDef.wantsAdminUi ? [] : [
252
+ const webUiQuestions = !components.adminUi ? [] : [
208
253
  {
209
254
  type: 'input',
210
255
  name: 'adminUiPort',
@@ -214,7 +259,7 @@ async function promptProjectDetails() {
214
259
  },
215
260
  ];
216
261
 
217
- const siteQuestions = !presetDef.wantsSite ? [] : [
262
+ const siteQuestions = !components.site ? [] : [
218
263
  {
219
264
  type: 'input',
220
265
  name: 'sitePort',
@@ -254,7 +299,7 @@ async function promptProjectDetails() {
254
299
  },
255
300
  ];
256
301
 
257
- const mobileQuestions = !presetDef.wantsMobile ? [] : [
302
+ const mobileQuestions = !components.mobile ? [] : [
258
303
  {
259
304
  type: 'input',
260
305
  name: 'iosBundleId',
@@ -306,7 +351,10 @@ async function promptProjectDetails() {
306
351
 
307
352
  return {
308
353
  preset,
309
- presetDef,
354
+ components,
355
+ appLanding,
356
+ authApple,
357
+ authGoogle,
310
358
  name: name.trim(),
311
359
  // Defaults for the parts the user didn't see — they're still
312
360
  // referenced by replacements (some unconditionally), so we fill
@@ -334,54 +382,67 @@ function validatePort(v) {
334
382
  : 'Must be a valid port number';
335
383
  }
336
384
 
385
+ function componentsLabel(c) {
386
+ const parts = [];
387
+ if (c.server) parts.push('server API');
388
+ if (c.adminUi) parts.push('admin webapp');
389
+ if (c.site) parts.push('site');
390
+ if (c.mobile) parts.push('mobile');
391
+ return parts.join(' + ');
392
+ }
393
+
337
394
  function showSummary(details) {
338
- const { presetDef } = details;
395
+ const c = details.components;
339
396
  console.log('');
340
397
  console.log(green.bold(' Project Summary'));
341
398
  console.log(chalk.hex('#2c2c2c')(' ' + '─'.repeat(40)));
342
399
  console.log('');
343
- console.log(` ${chalk.bold('Preset:')} ${cyan(presetDef.label)}`);
400
+ console.log(` ${chalk.bold('Components:')} ${cyan(componentsLabel(c))}`);
344
401
  console.log(` ${chalk.bold('Name:')} ${cyan(details.name)}`);
345
402
  console.log(` ${chalk.bold('Slug:')} ${details.slug}`);
346
403
  console.log(` ${chalk.bold('Description:')} ${details.description || dim('(none)')}`);
347
404
  console.log(` ${chalk.bold('Domain:')} ${cyan(details.domain)}`);
348
- if (presetDef.wantsServer) {
405
+ if (c.server) {
349
406
  console.log(` ${chalk.bold('Database:')} ${details.dbName}`);
350
407
  console.log(` ${chalk.bold('Admin:')} ${details.adminEmail}`);
351
408
  }
352
409
  console.log(` ${chalk.bold('Output:')} ${details.outputDir}`);
353
410
  const ports = [];
354
- if (presetDef.wantsServer) ports.push(`API ${cyan(details.apiPort)} | Site API ${cyan(details.siteApiPort)}`);
355
- if (presetDef.wantsAdminUi) ports.push(`Admin ${cyan(details.adminUiPort)}`);
356
- if (presetDef.wantsSite) ports.push(`Site ${cyan(details.sitePort)}`);
411
+ if (c.server) ports.push(`API ${cyan(details.apiPort)} | Site API ${cyan(details.siteApiPort)}`);
412
+ if (c.adminUi) ports.push(`Admin ${cyan(details.adminUiPort)}`);
413
+ if (c.site) ports.push(`Site ${cyan(details.sitePort)}`);
357
414
  if (ports.length) {
358
415
  console.log(` ${chalk.bold('Ports:')} ${ports.join(' | ')}`);
359
416
  }
360
417
  console.log(` ${chalk.bold('Color:')} ${chalk.hex(details.primaryColor)('██')} ${details.primaryColor}`);
361
418
  console.log(` ${chalk.bold('Theme:')} ${details.defaultTheme}`);
362
- if (presetDef.wantsSite) {
419
+ if (c.site) {
363
420
  console.log(` ${chalk.bold('Landing:')} ${cyan(
364
- presetDef.appLanding ? 'app store (download)' : 'SaaS (signup)'
421
+ details.appLanding ? 'app store (download)' : 'SaaS (signup)'
365
422
  )}`);
366
423
  }
367
- if (presetDef.wantsMobile) {
424
+ if (c.mobile) {
368
425
  console.log(` ${chalk.bold('iOS:')} ${details.iosBundleId}`);
369
426
  console.log(` ${chalk.bold('Android:')} ${details.androidPackage}`);
370
427
  console.log(` ${chalk.bold('Scheme:')} ${details.mobileScheme}://`);
371
428
  console.log(` ${chalk.bold('Splash:')} ${details.splashStyle === 'animated'
372
429
  ? 'animated (glow + logo)'
373
430
  : 'instant show-up'}`);
431
+ const providers = ['email+password'];
432
+ if (details.authApple) providers.push('Apple');
433
+ if (details.authGoogle) providers.push('Google');
434
+ console.log(` ${chalk.bold('Sign-in:')} ${providers.join(' | ')}`);
374
435
  }
375
436
  console.log('');
376
437
  console.log(dim(' Generates:'));
377
- if (presetDef.wantsServer) console.log(dim(` ${green('>')} Admin API + Site API (Express + TS)`));
378
- if (presetDef.wantsAdminUi) console.log(dim(` ${green('>')} React admin panel (Vite + shadcn/ui)`));
379
- if (presetDef.wantsSite) console.log(dim(` ${green('>')} ${presetDef.appLanding
438
+ if (c.server) console.log(dim(` ${green('>')} Admin API + Site API (Express + TS)`));
439
+ if (c.adminUi) console.log(dim(` ${green('>')} React admin panel (Vite + shadcn/ui)`));
440
+ if (c.site) console.log(dim(` ${green('>')} ${details.appLanding
380
441
  ? 'App landing page (store buttons + pricing)'
381
442
  : 'Public site (EJS + Tailwind)'}`));
382
- if (presetDef.wantsMobile) console.log(dim(` ${green('>')} Mobile app (Expo + TS + auth + theme)`));
383
- if (presetDef.wantsMobile) console.log(dim(` ${green('>')} Store launch kit (listing, copy, changelog, screenshots)`));
384
- if (presetDef.wantsServer) console.log(dim(` ${green('>')} PostgreSQL migrations + seed`));
443
+ if (c.mobile) console.log(dim(` ${green('>')} Mobile app (Expo + TS + auth + theme)`));
444
+ if (c.mobile) console.log(dim(` ${green('>')} Store launch kit (listing, copy, changelog, screenshots)`));
445
+ if (c.server) console.log(dim(` ${green('>')} PostgreSQL migrations + seed`));
385
446
  console.log('');
386
447
  }
387
448
 
@@ -428,12 +489,12 @@ const ALWAYS_KEEP = new Set(['.claude']);
428
489
  // renamed back to `.gitignore` during the file-rename pass below.
429
490
  const ALWAYS_KEEP_FILES = new Set(['gitignore', '.editorconfig']);
430
491
 
431
- export async function copyTemplate(outputDir, preset) {
432
- const presetDef = PRESETS[preset];
492
+ export async function copyTemplate(outputDir, components, appLanding) {
493
+ const includes = includesForComponents(components);
433
494
  await fs.promises.cp(TEMPLATE_DIR, outputDir, { recursive: true });
434
495
 
435
496
  // Top-level dirs to keep
436
- const keep = new Set([...presetDef.includes, ...ALWAYS_KEEP]);
497
+ const keep = new Set([...includes, ...ALWAYS_KEEP]);
437
498
  const entries = await fs.promises.readdir(outputDir, { withFileTypes: true });
438
499
 
439
500
  for (const entry of entries) {
@@ -442,7 +503,7 @@ export async function copyTemplate(outputDir, preset) {
442
503
  }
443
504
  // Special-case ui/: keep only the requested children
444
505
  if (entry.name === 'ui') {
445
- const wantedChildren = presetDef.includes
506
+ const wantedChildren = includes
446
507
  .filter(p => p.startsWith('ui/'))
447
508
  .map(p => p.slice('ui/'.length));
448
509
  if (wantedChildren.length === 0) {
@@ -475,7 +536,7 @@ export async function copyTemplate(outputDir, preset) {
475
536
  }
476
537
 
477
538
  // Top-level files: drop dev.sh / seed.sh etc. when not relevant
478
- const allowedFiles = new Set(ROOT_FILES_BY_PRESET[preset] ?? []);
539
+ const allowedFiles = new Set(rootFilesForComponents(components));
479
540
  const rootEntries = await fs.promises.readdir(outputDir, {
480
541
  withFileTypes: true
481
542
  });
@@ -489,11 +550,11 @@ export async function copyTemplate(outputDir, preset) {
489
550
  }
490
551
  }
491
552
 
492
- // Landing-page variant swap. App-focused presets replace the SaaS
553
+ // Landing-page variant swap. App-focused projects replace the SaaS
493
554
  // landing with the app-marketing one before placeholder replacement
494
555
  // runs (so the swapped files get their placeholders filled too). The
495
556
  // _variants staging dir is always removed — it never ships.
496
- if (presetDef.appLanding) {
557
+ if (appLanding) {
497
558
  for (const [variant, canonical] of APP_LANDING_SWAPS) {
498
559
  const from = path.join(outputDir, variant);
499
560
  const to = path.join(outputDir, canonical);
@@ -551,9 +612,11 @@ export async function createProject() {
551
612
 
552
613
  console.log('');
553
614
 
554
- // [1/5] Copy template (preset-aware pruning)
615
+ // [1/5] Copy template (component-aware pruning)
555
616
  console.log(green('[1/5]') + ' Copying template files...');
556
- await copyTemplate(outputDir, details.preset);
617
+ await copyTemplate(
618
+ outputDir, details.components, details.appLanding
619
+ );
557
620
 
558
621
  // [2/5] Replace placeholders
559
622
  const files = await getAllFiles(outputDir);
@@ -604,7 +667,7 @@ export async function createProject() {
604
667
  // primary color + first letter of the project name. A failure
605
668
  // here is fatal (without these files, expo prebuild + the app
606
669
  // splash both break), so we surface the error instead of swallowing.
607
- if (details.presetDef.wantsMobile) {
670
+ if (details.components.mobile) {
608
671
  const mobileAssets = path.join(
609
672
  outputDir, 'mobile', details.slug, 'assets'
610
673
  );
@@ -629,7 +692,7 @@ export async function createProject() {
629
692
  'seed.sh',
630
693
  'server/knex.sh',
631
694
  ];
632
- if (details.presetDef.wantsMobile) {
695
+ if (details.components.mobile) {
633
696
  scripts.push(
634
697
  'scripts/bump-version.sh',
635
698
  'scripts/prebuild.sh',
@@ -641,7 +704,7 @@ export async function createProject() {
641
704
  'scripts/screenshots-capture.sh'
642
705
  );
643
706
  }
644
- if (details.presetDef.wantsServer) {
707
+ if (details.components.server) {
645
708
  scripts.push('misc/build-local.sh');
646
709
  }
647
710
 
@@ -706,10 +769,10 @@ async function renameDirs(root, slug) {
706
769
  }
707
770
 
708
771
  function showNextSteps(details) {
709
- const { presetDef } = details;
772
+ const c = details.components;
710
773
  console.log(chalk.bold(' Next steps:'));
711
774
  console.log(` ${green('1.')} cd ${details.outputDir}`);
712
- if (presetDef.wantsServer) {
775
+ if (c.server) {
713
776
  console.log(` ${green('2.')} ./install.sh`);
714
777
  console.log(` ${green('3.')} ./seed.sh`);
715
778
  console.log(` ${green('4.')} ./dev.sh`);
@@ -720,16 +783,16 @@ function showNextSteps(details) {
720
783
  console.log(` ${green('3.')} ./dev.sh`);
721
784
  }
722
785
  console.log('');
723
- if (presetDef.wantsAdminUi) {
786
+ if (c.adminUi) {
724
787
  console.log(dim(` Admin panel: ${cyan(`http://localhost:${details.adminUiPort}`)}`));
725
788
  }
726
- if (presetDef.wantsServer) {
789
+ if (c.server) {
727
790
  console.log(dim(` Admin API: ${cyan(`http://localhost:${details.apiPort}`)}`));
728
791
  }
729
- if (presetDef.wantsSite) {
792
+ if (c.site) {
730
793
  console.log(dim(` Site: ${cyan(`http://localhost:${details.sitePort}`)}`));
731
794
  }
732
- if (presetDef.wantsMobile) {
795
+ if (c.mobile) {
733
796
  console.log(dim(` Mobile: cd mobile/${details.slug} && npm run ios | android | web`));
734
797
  console.log(dim(` Store kit: mobile/${details.slug}/docs/ (listing, copy, screenshots) + CHANGELOG.md`));
735
798
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "umpordez",
3
3
  "type": "module",
4
- "version": "1.3.2",
4
+ "version": "1.4.0",
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": {
@@ -55,10 +55,11 @@ URLs:
55
55
  └── .claude/settings.json Permission allowlist for routine commands
56
56
  ```
57
57
 
58
- Folders that the preset didn't ship are simply absent. `dev.sh`
59
- and `install.sh` guard on existence per directory, so the same
60
- script works for every preset (everything / web-only /
61
- mobile-and-site / mobile-and-server / just-mobile).
58
+ Folders the scaffold didn't ship are simply absent. Components
59
+ (server API / admin webapp / public site / mobile app) are picked
60
+ one-by-one at create time; `dev.sh` and `install.sh` guard on
61
+ existence per directory, so the same script works for every
62
+ combination.
62
63
 
63
64
  ## Multi-tenancy model
64
65
 
@@ -16,7 +16,7 @@ integrations.
16
16
  ```bash
17
17
  ./install.sh # install all node deps + scaffold .env files
18
18
  ./seed.sh # create db + run migrations + seed admin user
19
- ./dev.sh # start every web service this preset shipped
19
+ ./dev.sh # start every service this project shipped
20
20
  ./dev.sh --mobile # also start Expo Metro
21
21
  ```
22
22
 
@@ -60,9 +60,9 @@ URLs (web):
60
60
  └── misc/ systemd units, nginx samples
61
61
  ```
62
62
 
63
- Folders the preset didn't ship are simply absent — `dev.sh` /
63
+ Folders the scaffold didn't ship are simply absent — `dev.sh` /
64
64
  `install.sh` guard on directory existence so the scripts work for
65
- every preset.
65
+ every component combination.
66
66
 
67
67
  ## Multi-tenancy model
68
68
 
@@ -26,8 +26,7 @@ src/
26
26
  │ ├── backup/ Two-phase S3 backup (premium)
27
27
  │ ├── content/ Versioned catalog + HMAC-signed URLs
28
28
  │ ├── iap/ react-native-iap wrapped lazily
29
- ├── notifications/ Custom-native, cold-start tap dispatch
30
- │ └── health/ Apple Health + Health Connect unified
29
+ └── notifications/ Custom-native, cold-start tap dispatch
31
30
  └── components/
32
31
  ├── AnimatedSplash.tsx
33
32
  ├── ToastConfig.tsx
@@ -95,6 +94,16 @@ hasCompletedOnboarding
95
94
  Use `./scripts/prebuild.sh` (refuses --clean). To genuinely
96
95
  reset, move customizations into `plugins/` first.
97
96
 
97
+ - **Dependency versions follow the Expo SDK.** Never bump
98
+ `react`, `react-native`, or any `expo-*` / `react-native-*`
99
+ native package by hand — run `npx expo install --check` (or
100
+ `--fix`) so everything stays on the SDK's bundled versions.
101
+ `typescript` is intentionally in `expo.install.exclude`
102
+ (package.json): the SDK wants TS 6 but `react-i18next@15`
103
+ peer-requires TS 5 — keep `~5.9.x` until react-i18next is
104
+ bumped to v17+ (which drags i18next to v26 and drops the
105
+ `compatibilityJSON: 'v3'` Hermes escape hatch in `src/i18n/`).
106
+
98
107
  ## Splash
99
108
 
100
109
  `config.ts` exports `SPLASH_ANIMATED` (set at scaffold time). When
@@ -7,24 +7,11 @@
7
7
  "orientation": "portrait",
8
8
  "icon": "./assets/icon.png",
9
9
  "userInterfaceStyle": "automatic",
10
- "splash": {
11
- "image": "./assets/splash-icon.png",
12
- "resizeMode": "contain",
13
- "backgroundColor": "{{PRIMARY_COLOR}}"
14
- },
15
10
  "ios": {
16
11
  "supportsTablet": false,
17
- "bundleIdentifier": "{{IOS_BUNDLE_ID}}",
18
- "usesAppleSignIn": true,
19
- "entitlements": {
20
- "com.apple.developer.healthkit": true
21
- },
12
+ "bundleIdentifier": "{{IOS_BUNDLE_ID}}",{{IOS_APPLE_SIGNIN_JSON}}
22
13
  "infoPlist": {
23
- "ITSAppUsesNonExemptEncryption": false,
24
- "NSHealthShareUsageDescription":
25
- "{{PROJECT_NAME}} reads activity data to keep your fitness summaries accurate.",
26
- "NSHealthUpdateUsageDescription":
27
- "{{PROJECT_NAME}} writes session data so it shows up in Apple Health alongside your other apps."
14
+ "ITSAppUsesNonExemptEncryption": false
28
15
  }
29
16
  },
30
17
  "android": {
@@ -43,6 +30,15 @@
43
30
  "favicon": "./assets/favicon.png"
44
31
  },
45
32
  "plugins": [
33
+ [
34
+ "expo-splash-screen",
35
+ {
36
+ "image": "./assets/splash-icon.png",
37
+ "imageWidth": 200,
38
+ "resizeMode": "contain",
39
+ "backgroundColor": "{{PRIMARY_COLOR}}"
40
+ }
41
+ ],
46
42
  [
47
43
  "expo-build-properties",
48
44
  {
@@ -54,23 +50,9 @@
54
50
  }
55
51
  }
56
52
  ],
57
- "expo-web-browser",
58
- "expo-apple-authentication",
53
+ "expo-web-browser",{{APPLE_AUTH_PLUGIN_JSON}}
59
54
  "expo-sqlite",
60
- "react-native-iap",
61
- [
62
- "react-native-health",
63
- {
64
- "healthSharePermission":
65
- "{{PROJECT_NAME}} reads activity data to keep your fitness summaries accurate.",
66
- "healthUpdatePermission":
67
- "{{PROJECT_NAME}} writes session data so it shows up in Apple Health alongside your other apps."
68
- }
69
- ],
70
- [
71
- "./plugins/withAndroidHealthConnect",
72
- { "permissions": ["READ_STEPS", "WRITE_EXERCISE"] }
73
- ]
55
+ "react-native-iap"
74
56
  ],
75
57
  "extra": {
76
58
  "eas": {