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.
- package/.claude/settings.local.json +6 -1
- package/cli.mjs +1 -1
- package/create.mjs +179 -116
- package/package.json +1 -1
- package/template/CLAUDE.md +5 -4
- package/template/README.md +3 -3
- package/template/mobile/{{PROJECT_SLUG}}/CLAUDE.md +11 -2
- package/template/mobile/{{PROJECT_SLUG}}/app.json +13 -31
- package/template/mobile/{{PROJECT_SLUG}}/package.json +22 -20
- package/template/mobile/{{PROJECT_SLUG}}/src/components/AnimatedSplash.tsx +5 -1
- package/template/mobile/{{PROJECT_SLUG}}/src/config.ts +11 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx +55 -50
- package/template-variables.mjs +127 -0
- package/update.mjs +52 -48
- package/template/mobile/{{PROJECT_SLUG}}/src/services/health/index.ts +0 -238
|
@@ -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('>')}
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 = !
|
|
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 = !
|
|
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 = !
|
|
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 = !
|
|
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
|
-
|
|
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
|
|
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('
|
|
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 (
|
|
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 (
|
|
355
|
-
if (
|
|
356
|
-
if (
|
|
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 (
|
|
419
|
+
if (c.site) {
|
|
363
420
|
console.log(` ${chalk.bold('Landing:')} ${cyan(
|
|
364
|
-
|
|
421
|
+
details.appLanding ? 'app store (download)' : 'SaaS (signup)'
|
|
365
422
|
)}`);
|
|
366
423
|
}
|
|
367
|
-
if (
|
|
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 (
|
|
378
|
-
if (
|
|
379
|
-
if (
|
|
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 (
|
|
383
|
-
if (
|
|
384
|
-
if (
|
|
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,
|
|
432
|
-
const
|
|
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([...
|
|
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 =
|
|
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(
|
|
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
|
|
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 (
|
|
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 (
|
|
615
|
+
// [1/5] Copy template (component-aware pruning)
|
|
555
616
|
console.log(green('[1/5]') + ' Copying template files...');
|
|
556
|
-
await copyTemplate(
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
772
|
+
const c = details.components;
|
|
710
773
|
console.log(chalk.bold(' Next steps:'));
|
|
711
774
|
console.log(` ${green('1.')} cd ${details.outputDir}`);
|
|
712
|
-
if (
|
|
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 (
|
|
786
|
+
if (c.adminUi) {
|
|
724
787
|
console.log(dim(` Admin panel: ${cyan(`http://localhost:${details.adminUiPort}`)}`));
|
|
725
788
|
}
|
|
726
|
-
if (
|
|
789
|
+
if (c.server) {
|
|
727
790
|
console.log(dim(` Admin API: ${cyan(`http://localhost:${details.apiPort}`)}`));
|
|
728
791
|
}
|
|
729
|
-
if (
|
|
792
|
+
if (c.site) {
|
|
730
793
|
console.log(dim(` Site: ${cyan(`http://localhost:${details.sitePort}`)}`));
|
|
731
794
|
}
|
|
732
|
-
if (
|
|
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
package/template/CLAUDE.md
CHANGED
|
@@ -55,10 +55,11 @@ URLs:
|
|
|
55
55
|
└── .claude/settings.json Permission allowlist for routine commands
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
Folders
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
package/template/README.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
│
|
|
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": {
|