umpordez 1.3.2 → 1.5.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 +9 -1
- package/cli.mjs +1 -1
- package/create.mjs +194 -116
- package/package.json +1 -1
- package/template/AGENTS.md +1 -1
- package/template/CLAUDE.md +6 -5
- package/template/README.md +4 -4
- package/template/doc/index.html +5 -2
- package/template/mobile/{{PROJECT_SLUG}}/CLAUDE.md +18 -6
- package/template/mobile/{{PROJECT_SLUG}}/app.json +14 -34
- package/template/mobile/{{PROJECT_SLUG}}/eslint.config.js +12 -1
- package/template/mobile/{{PROJECT_SLUG}}/native/README.md +3 -3
- package/template/mobile/{{PROJECT_SLUG}}/package.json +22 -22
- package/template/mobile/{{PROJECT_SLUG}}/src/components/AnimatedSplash.tsx +10 -2
- package/template/mobile/{{PROJECT_SLUG}}/src/components/PremiumGate.tsx +4 -1
- package/template/mobile/{{PROJECT_SLUG}}/src/config.ts +21 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx +55 -50
- package/template/mobile/{{PROJECT_SLUG}}/src/services/content/signing.ts +2 -1
- package/template/mobile/{{PROJECT_SLUG}}/src/services/iap/index.ts +17 -22
- package/template-variables.mjs +183 -0
- package/update.mjs +52 -48
- package/template/mobile/{{PROJECT_SLUG}}/src/services/health/index.ts +0 -238
|
@@ -49,7 +49,15 @@
|
|
|
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 *)",
|
|
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')"
|
|
53
61
|
]
|
|
54
62
|
}
|
|
55
63
|
}
|
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,135 @@ 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
|
+
let iap = true;
|
|
150
|
+
if (mobile) {
|
|
151
|
+
({ authApple } = await inquirer.prompt([{
|
|
152
|
+
type: 'confirm',
|
|
153
|
+
name: 'authApple',
|
|
154
|
+
message: 'Mobile sign-in with Apple? (email+password '
|
|
155
|
+
+ 'always included)',
|
|
156
|
+
default: true,
|
|
157
|
+
}]));
|
|
158
|
+
({ authGoogle } = await inquirer.prompt([{
|
|
159
|
+
type: 'confirm',
|
|
160
|
+
name: 'authGoogle',
|
|
161
|
+
message: 'Mobile sign-in with Google? (server-side '
|
|
162
|
+
+ 'OAuth flow)',
|
|
163
|
+
default: true,
|
|
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
|
+
}]));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { components, appLanding, authApple, authGoogle, iap };
|
|
178
|
+
}
|
|
179
|
+
|
|
116
180
|
async function promptProjectDetails() {
|
|
117
181
|
console.log(green.bold(' Project Setup'));
|
|
118
182
|
console.log(chalk.hex('#2c2c2c')(' ' + '─'.repeat(40)));
|
|
119
183
|
console.log('');
|
|
120
184
|
|
|
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];
|
|
185
|
+
const {
|
|
186
|
+
components, appLanding, authApple, authGoogle, iap
|
|
187
|
+
} = await promptComponents();
|
|
188
|
+
const preset = presetNameForComponents(components);
|
|
133
189
|
|
|
134
190
|
const { name } = await inquirer.prompt([{
|
|
135
191
|
type: 'input',
|
|
@@ -165,7 +221,7 @@ async function promptProjectDetails() {
|
|
|
165
221
|
},
|
|
166
222
|
];
|
|
167
223
|
|
|
168
|
-
const serverQuestions = !
|
|
224
|
+
const serverQuestions = !components.server ? [] : [
|
|
169
225
|
{
|
|
170
226
|
type: 'input',
|
|
171
227
|
name: 'dbName',
|
|
@@ -204,7 +260,7 @@ async function promptProjectDetails() {
|
|
|
204
260
|
},
|
|
205
261
|
];
|
|
206
262
|
|
|
207
|
-
const webUiQuestions = !
|
|
263
|
+
const webUiQuestions = !components.adminUi ? [] : [
|
|
208
264
|
{
|
|
209
265
|
type: 'input',
|
|
210
266
|
name: 'adminUiPort',
|
|
@@ -214,7 +270,7 @@ async function promptProjectDetails() {
|
|
|
214
270
|
},
|
|
215
271
|
];
|
|
216
272
|
|
|
217
|
-
const siteQuestions = !
|
|
273
|
+
const siteQuestions = !components.site ? [] : [
|
|
218
274
|
{
|
|
219
275
|
type: 'input',
|
|
220
276
|
name: 'sitePort',
|
|
@@ -254,7 +310,7 @@ async function promptProjectDetails() {
|
|
|
254
310
|
},
|
|
255
311
|
];
|
|
256
312
|
|
|
257
|
-
const mobileQuestions = !
|
|
313
|
+
const mobileQuestions = !components.mobile ? [] : [
|
|
258
314
|
{
|
|
259
315
|
type: 'input',
|
|
260
316
|
name: 'iosBundleId',
|
|
@@ -306,7 +362,11 @@ async function promptProjectDetails() {
|
|
|
306
362
|
|
|
307
363
|
return {
|
|
308
364
|
preset,
|
|
309
|
-
|
|
365
|
+
components,
|
|
366
|
+
appLanding,
|
|
367
|
+
authApple,
|
|
368
|
+
authGoogle,
|
|
369
|
+
iap,
|
|
310
370
|
name: name.trim(),
|
|
311
371
|
// Defaults for the parts the user didn't see — they're still
|
|
312
372
|
// referenced by replacements (some unconditionally), so we fill
|
|
@@ -334,54 +394,70 @@ function validatePort(v) {
|
|
|
334
394
|
: 'Must be a valid port number';
|
|
335
395
|
}
|
|
336
396
|
|
|
397
|
+
function componentsLabel(c) {
|
|
398
|
+
const parts = [];
|
|
399
|
+
if (c.server) parts.push('server API');
|
|
400
|
+
if (c.adminUi) parts.push('admin webapp');
|
|
401
|
+
if (c.site) parts.push('site');
|
|
402
|
+
if (c.mobile) parts.push('mobile');
|
|
403
|
+
return parts.join(' + ');
|
|
404
|
+
}
|
|
405
|
+
|
|
337
406
|
function showSummary(details) {
|
|
338
|
-
const
|
|
407
|
+
const c = details.components;
|
|
339
408
|
console.log('');
|
|
340
409
|
console.log(green.bold(' Project Summary'));
|
|
341
410
|
console.log(chalk.hex('#2c2c2c')(' ' + '─'.repeat(40)));
|
|
342
411
|
console.log('');
|
|
343
|
-
console.log(` ${chalk.bold('
|
|
412
|
+
console.log(` ${chalk.bold('Components:')} ${cyan(componentsLabel(c))}`);
|
|
344
413
|
console.log(` ${chalk.bold('Name:')} ${cyan(details.name)}`);
|
|
345
414
|
console.log(` ${chalk.bold('Slug:')} ${details.slug}`);
|
|
346
415
|
console.log(` ${chalk.bold('Description:')} ${details.description || dim('(none)')}`);
|
|
347
416
|
console.log(` ${chalk.bold('Domain:')} ${cyan(details.domain)}`);
|
|
348
|
-
if (
|
|
417
|
+
if (c.server) {
|
|
349
418
|
console.log(` ${chalk.bold('Database:')} ${details.dbName}`);
|
|
350
419
|
console.log(` ${chalk.bold('Admin:')} ${details.adminEmail}`);
|
|
351
420
|
}
|
|
352
421
|
console.log(` ${chalk.bold('Output:')} ${details.outputDir}`);
|
|
353
422
|
const ports = [];
|
|
354
|
-
if (
|
|
355
|
-
if (
|
|
356
|
-
if (
|
|
423
|
+
if (c.server) ports.push(`API ${cyan(details.apiPort)} | Site API ${cyan(details.siteApiPort)}`);
|
|
424
|
+
if (c.adminUi) ports.push(`Admin ${cyan(details.adminUiPort)}`);
|
|
425
|
+
if (c.site) ports.push(`Site ${cyan(details.sitePort)}`);
|
|
357
426
|
if (ports.length) {
|
|
358
427
|
console.log(` ${chalk.bold('Ports:')} ${ports.join(' | ')}`);
|
|
359
428
|
}
|
|
360
429
|
console.log(` ${chalk.bold('Color:')} ${chalk.hex(details.primaryColor)('██')} ${details.primaryColor}`);
|
|
361
430
|
console.log(` ${chalk.bold('Theme:')} ${details.defaultTheme}`);
|
|
362
|
-
if (
|
|
431
|
+
if (c.site) {
|
|
363
432
|
console.log(` ${chalk.bold('Landing:')} ${cyan(
|
|
364
|
-
|
|
433
|
+
details.appLanding ? 'app store (download)' : 'SaaS (signup)'
|
|
365
434
|
)}`);
|
|
366
435
|
}
|
|
367
|
-
if (
|
|
436
|
+
if (c.mobile) {
|
|
368
437
|
console.log(` ${chalk.bold('iOS:')} ${details.iosBundleId}`);
|
|
369
438
|
console.log(` ${chalk.bold('Android:')} ${details.androidPackage}`);
|
|
370
439
|
console.log(` ${chalk.bold('Scheme:')} ${details.mobileScheme}://`);
|
|
371
440
|
console.log(` ${chalk.bold('Splash:')} ${details.splashStyle === 'animated'
|
|
372
441
|
? 'animated (glow + logo)'
|
|
373
442
|
: 'instant show-up'}`);
|
|
443
|
+
const providers = ['email+password'];
|
|
444
|
+
if (details.authApple) providers.push('Apple');
|
|
445
|
+
if (details.authGoogle) providers.push('Google');
|
|
446
|
+
console.log(` ${chalk.bold('Sign-in:')} ${providers.join(' | ')}`);
|
|
447
|
+
console.log(` ${chalk.bold('IAP:')} ${details.iap
|
|
448
|
+
? 'premium subscriptions (expo-iap)'
|
|
449
|
+
: 'none (all features unlocked)'}`);
|
|
374
450
|
}
|
|
375
451
|
console.log('');
|
|
376
452
|
console.log(dim(' Generates:'));
|
|
377
|
-
if (
|
|
378
|
-
if (
|
|
379
|
-
if (
|
|
453
|
+
if (c.server) console.log(dim(` ${green('>')} Admin API + Site API (Express + TS)`));
|
|
454
|
+
if (c.adminUi) console.log(dim(` ${green('>')} React admin panel (Vite + shadcn/ui)`));
|
|
455
|
+
if (c.site) console.log(dim(` ${green('>')} ${details.appLanding
|
|
380
456
|
? 'App landing page (store buttons + pricing)'
|
|
381
457
|
: 'Public site (EJS + Tailwind)'}`));
|
|
382
|
-
if (
|
|
383
|
-
if (
|
|
384
|
-
if (
|
|
458
|
+
if (c.mobile) console.log(dim(` ${green('>')} Mobile app (Expo + TS + auth + theme)`));
|
|
459
|
+
if (c.mobile) console.log(dim(` ${green('>')} Store launch kit (listing, copy, changelog, screenshots)`));
|
|
460
|
+
if (c.server) console.log(dim(` ${green('>')} PostgreSQL migrations + seed`));
|
|
385
461
|
console.log('');
|
|
386
462
|
}
|
|
387
463
|
|
|
@@ -428,12 +504,12 @@ const ALWAYS_KEEP = new Set(['.claude']);
|
|
|
428
504
|
// renamed back to `.gitignore` during the file-rename pass below.
|
|
429
505
|
const ALWAYS_KEEP_FILES = new Set(['gitignore', '.editorconfig']);
|
|
430
506
|
|
|
431
|
-
export async function copyTemplate(outputDir,
|
|
432
|
-
const
|
|
507
|
+
export async function copyTemplate(outputDir, components, appLanding) {
|
|
508
|
+
const includes = includesForComponents(components);
|
|
433
509
|
await fs.promises.cp(TEMPLATE_DIR, outputDir, { recursive: true });
|
|
434
510
|
|
|
435
511
|
// Top-level dirs to keep
|
|
436
|
-
const keep = new Set([...
|
|
512
|
+
const keep = new Set([...includes, ...ALWAYS_KEEP]);
|
|
437
513
|
const entries = await fs.promises.readdir(outputDir, { withFileTypes: true });
|
|
438
514
|
|
|
439
515
|
for (const entry of entries) {
|
|
@@ -442,7 +518,7 @@ export async function copyTemplate(outputDir, preset) {
|
|
|
442
518
|
}
|
|
443
519
|
// Special-case ui/: keep only the requested children
|
|
444
520
|
if (entry.name === 'ui') {
|
|
445
|
-
const wantedChildren =
|
|
521
|
+
const wantedChildren = includes
|
|
446
522
|
.filter(p => p.startsWith('ui/'))
|
|
447
523
|
.map(p => p.slice('ui/'.length));
|
|
448
524
|
if (wantedChildren.length === 0) {
|
|
@@ -475,7 +551,7 @@ export async function copyTemplate(outputDir, preset) {
|
|
|
475
551
|
}
|
|
476
552
|
|
|
477
553
|
// Top-level files: drop dev.sh / seed.sh etc. when not relevant
|
|
478
|
-
const allowedFiles = new Set(
|
|
554
|
+
const allowedFiles = new Set(rootFilesForComponents(components));
|
|
479
555
|
const rootEntries = await fs.promises.readdir(outputDir, {
|
|
480
556
|
withFileTypes: true
|
|
481
557
|
});
|
|
@@ -489,11 +565,11 @@ export async function copyTemplate(outputDir, preset) {
|
|
|
489
565
|
}
|
|
490
566
|
}
|
|
491
567
|
|
|
492
|
-
// Landing-page variant swap. App-focused
|
|
568
|
+
// Landing-page variant swap. App-focused projects replace the SaaS
|
|
493
569
|
// landing with the app-marketing one before placeholder replacement
|
|
494
570
|
// runs (so the swapped files get their placeholders filled too). The
|
|
495
571
|
// _variants staging dir is always removed — it never ships.
|
|
496
|
-
if (
|
|
572
|
+
if (appLanding) {
|
|
497
573
|
for (const [variant, canonical] of APP_LANDING_SWAPS) {
|
|
498
574
|
const from = path.join(outputDir, variant);
|
|
499
575
|
const to = path.join(outputDir, canonical);
|
|
@@ -551,9 +627,11 @@ export async function createProject() {
|
|
|
551
627
|
|
|
552
628
|
console.log('');
|
|
553
629
|
|
|
554
|
-
// [1/5] Copy template (
|
|
630
|
+
// [1/5] Copy template (component-aware pruning)
|
|
555
631
|
console.log(green('[1/5]') + ' Copying template files...');
|
|
556
|
-
await copyTemplate(
|
|
632
|
+
await copyTemplate(
|
|
633
|
+
outputDir, details.components, details.appLanding
|
|
634
|
+
);
|
|
557
635
|
|
|
558
636
|
// [2/5] Replace placeholders
|
|
559
637
|
const files = await getAllFiles(outputDir);
|
|
@@ -604,7 +682,7 @@ export async function createProject() {
|
|
|
604
682
|
// primary color + first letter of the project name. A failure
|
|
605
683
|
// here is fatal (without these files, expo prebuild + the app
|
|
606
684
|
// splash both break), so we surface the error instead of swallowing.
|
|
607
|
-
if (details.
|
|
685
|
+
if (details.components.mobile) {
|
|
608
686
|
const mobileAssets = path.join(
|
|
609
687
|
outputDir, 'mobile', details.slug, 'assets'
|
|
610
688
|
);
|
|
@@ -629,7 +707,7 @@ export async function createProject() {
|
|
|
629
707
|
'seed.sh',
|
|
630
708
|
'server/knex.sh',
|
|
631
709
|
];
|
|
632
|
-
if (details.
|
|
710
|
+
if (details.components.mobile) {
|
|
633
711
|
scripts.push(
|
|
634
712
|
'scripts/bump-version.sh',
|
|
635
713
|
'scripts/prebuild.sh',
|
|
@@ -641,7 +719,7 @@ export async function createProject() {
|
|
|
641
719
|
'scripts/screenshots-capture.sh'
|
|
642
720
|
);
|
|
643
721
|
}
|
|
644
|
-
if (details.
|
|
722
|
+
if (details.components.server) {
|
|
645
723
|
scripts.push('misc/build-local.sh');
|
|
646
724
|
}
|
|
647
725
|
|
|
@@ -706,10 +784,10 @@ async function renameDirs(root, slug) {
|
|
|
706
784
|
}
|
|
707
785
|
|
|
708
786
|
function showNextSteps(details) {
|
|
709
|
-
const
|
|
787
|
+
const c = details.components;
|
|
710
788
|
console.log(chalk.bold(' Next steps:'));
|
|
711
789
|
console.log(` ${green('1.')} cd ${details.outputDir}`);
|
|
712
|
-
if (
|
|
790
|
+
if (c.server) {
|
|
713
791
|
console.log(` ${green('2.')} ./install.sh`);
|
|
714
792
|
console.log(` ${green('3.')} ./seed.sh`);
|
|
715
793
|
console.log(` ${green('4.')} ./dev.sh`);
|
|
@@ -720,16 +798,16 @@ function showNextSteps(details) {
|
|
|
720
798
|
console.log(` ${green('3.')} ./dev.sh`);
|
|
721
799
|
}
|
|
722
800
|
console.log('');
|
|
723
|
-
if (
|
|
801
|
+
if (c.adminUi) {
|
|
724
802
|
console.log(dim(` Admin panel: ${cyan(`http://localhost:${details.adminUiPort}`)}`));
|
|
725
803
|
}
|
|
726
|
-
if (
|
|
804
|
+
if (c.server) {
|
|
727
805
|
console.log(dim(` Admin API: ${cyan(`http://localhost:${details.apiPort}`)}`));
|
|
728
806
|
}
|
|
729
|
-
if (
|
|
807
|
+
if (c.site) {
|
|
730
808
|
console.log(dim(` Site: ${cyan(`http://localhost:${details.sitePort}`)}`));
|
|
731
809
|
}
|
|
732
|
-
if (
|
|
810
|
+
if (c.mobile) {
|
|
733
811
|
console.log(dim(` Mobile: cd mobile/${details.slug} && npm run ios | android | web`));
|
|
734
812
|
console.log(dim(` Store kit: mobile/${details.slug}/docs/ (listing, copy, screenshots) + CHANGELOG.md`));
|
|
735
813
|
}
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
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
|
|
|
@@ -96,7 +97,7 @@ HTTP request builds a fresh `Context` with every model instantiated
|
|
|
96
97
|
| 1 | auth flows, theme, navigation, splash, toasts, typed fetch |
|
|
97
98
|
| 2 | offline SQLite (USER vs SHARED tables), cloud backup/restore |
|
|
98
99
|
| 3 | server-driven content sync + HMAC-signed presigned asset URLs |
|
|
99
|
-
| 4 | IAP via `
|
|
100
|
+
| 4 | IAP via `expo-iap` (optional at scaffold), premium gate, foreground re-check |
|
|
100
101
|
| 5 | custom-native local notifications with cold-start tap dispatch |
|
|
101
102
|
| 6 | Apple Health + Android Health Connect unified surface |
|
|
102
103
|
| 7 | native cookbook (Hello iOS/Android + Live Activity scaffold) |
|
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
|
|
|
@@ -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 (`
|
|
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) |
|
package/template/doc/index.html
CHANGED
|
@@ -514,8 +514,11 @@ POST /api/logout</code></pre>
|
|
|
514
514
|
<section id="iap">
|
|
515
515
|
<h2>IAP & premium <span class="badge">Phase 4</span></h2>
|
|
516
516
|
<p>
|
|
517
|
-
<code>
|
|
518
|
-
|
|
517
|
+
<code>expo-iap</code> lazy-loaded with try/require so
|
|
518
|
+
Expo Go gets graceful no-ops. Optional at scaffold
|
|
519
|
+
time — 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);
|
|
@@ -25,9 +25,8 @@ 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/
|
|
29
|
-
│
|
|
30
|
-
│ └── health/ Apple Health + Health Connect unified
|
|
28
|
+
│ ├── iap/ expo-iap wrapped lazily (optional)
|
|
29
|
+
│ └── notifications/ Custom-native, cold-start tap dispatch
|
|
31
30
|
└── components/
|
|
32
31
|
├── AnimatedSplash.tsx
|
|
33
32
|
├── ToastConfig.tsx
|
|
@@ -87,14 +86,27 @@ hasCompletedOnboarding
|
|
|
87
86
|
- **Every user-facing string goes through `t()`.** Even pt-BR
|
|
88
87
|
default strings. No bare strings in JSX.
|
|
89
88
|
|
|
90
|
-
- **
|
|
91
|
-
|
|
92
|
-
`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.
|
|
93
95
|
|
|
94
96
|
- **`expo prebuild --clean` will silently delete native code.**
|
|
95
97
|
Use `./scripts/prebuild.sh` (refuses --clean). To genuinely
|
|
96
98
|
reset, move customizations into `plugins/` first.
|
|
97
99
|
|
|
100
|
+
- **Dependency versions follow the Expo SDK.** Never bump
|
|
101
|
+
`react`, `react-native`, or any `expo-*` / `react-native-*`
|
|
102
|
+
native package by hand — run `npx expo install --check` (or
|
|
103
|
+
`--fix`) so everything stays on the SDK's bundled versions.
|
|
104
|
+
`typescript` is intentionally in `expo.install.exclude`
|
|
105
|
+
(package.json): the SDK wants TS 6 but `react-i18next@15`
|
|
106
|
+
peer-requires TS 5 — keep `~5.9.x` until react-i18next is
|
|
107
|
+
bumped to v17+ (which drags i18next to v26 and drops the
|
|
108
|
+
`compatibilityJSON: 'v3'` Hermes escape hatch in `src/i18n/`).
|
|
109
|
+
|
|
98
110
|
## Splash
|
|
99
111
|
|
|
100
112
|
`config.ts` exports `SPLASH_ANIMATED` (set at scaffold time). When
|