create-techtiten-app 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +88 -2
  2. package/bin/index.mjs +345 -0
  3. package/bin/presets.mjs +68 -0
  4. package/package.json +38 -4
  5. package/templates/mobile/ads/src/ads/Banner.tsx +14 -0
  6. package/templates/mobile/ads/src/ads/Banner.web.tsx +4 -0
  7. package/templates/mobile/common/app.config.ts +36 -0
  8. package/templates/mobile/common/config/plugins.json +1 -0
  9. package/templates/mobile/common/distribution/whatsnew/whatsnew-en-US +1 -0
  10. package/templates/mobile/common/plugins/withSigning.js +63 -0
  11. package/templates/mobile/common/src/AppProviders.tsx +22 -0
  12. package/templates/mobile/common/src/api/client.ts +16 -0
  13. package/templates/mobile/common/src/screens/Home.tsx +48 -0
  14. package/templates/mobile/common/src/screens/Settings.tsx +22 -0
  15. package/templates/mobile/common/src/theme/index.tsx +17 -0
  16. package/templates/mobile/common/src/ui/index.tsx +41 -0
  17. package/templates/mobile/common/tsconfig.json +8 -0
  18. package/templates/mobile/nav-stack/app/_layout.tsx +33 -0
  19. package/templates/mobile/nav-stack/app/index.tsx +1 -0
  20. package/templates/mobile/nav-stack/app/settings.tsx +1 -0
  21. package/templates/mobile/nav-tabs/app/(tabs)/_layout.tsx +25 -0
  22. package/templates/mobile/nav-tabs/app/(tabs)/index.tsx +1 -0
  23. package/templates/mobile/nav-tabs/app/(tabs)/settings.tsx +1 -0
  24. package/templates/mobile/nav-tabs/app/_layout.tsx +25 -0
  25. package/templates/mobile/noads/src/ads/Banner.tsx +4 -0
  26. package/templates/mobile/redux/src/state/Providers.tsx +13 -0
  27. package/templates/mobile/redux/src/state/index.ts +16 -0
  28. package/templates/mobile/redux/src/state/store.ts +33 -0
  29. package/templates/mobile/zustand/src/state/Providers.tsx +7 -0
  30. package/templates/mobile/zustand/src/state/index.ts +25 -0
  31. package/templates/packages/shared/package.json +7 -0
  32. package/templates/packages/shared/src/format.ts +18 -0
  33. package/templates/packages/shared/src/index.ts +5 -0
  34. package/templates/packages/shared/src/money.ts +13 -0
  35. package/templates/packages/shared/src/schemas.ts +9 -0
  36. package/templates/packages/theme/package.json +7 -0
  37. package/templates/packages/theme/src/index.ts +26 -0
  38. package/templates/root/.prettierignore +7 -0
  39. package/templates/root/.prettierrc +5 -0
  40. package/templates/root/AGENTS.md +16 -0
  41. package/templates/root/README.md +31 -0
  42. package/templates/root/STATUS.md +13 -0
  43. package/templates/root/_github/workflows/android.yml +79 -0
  44. package/templates/root/_github/workflows/web-check.yml +19 -0
  45. package/templates/root/_gitignore +33 -0
  46. package/templates/root/package.json +21 -0
  47. package/templates/web/src/app/(site)/page.tsx +18 -0
  48. package/templates/web/src/app/admin/layout.tsx +17 -0
  49. package/templates/web/src/app/admin/page.tsx +32 -0
  50. package/templates/web/src/app/api/cron/[job]/route.ts +17 -0
  51. package/templates/web/src/app/api/health/route.ts +8 -0
  52. package/templates/web/src/app/globals.css +23 -0
  53. package/templates/web/src/app/layout.tsx +24 -0
  54. package/templates/web/src/proxy.ts +26 -0
  55. package/templates/web/src/server/jobs.ts +9 -0
package/README.md CHANGED
@@ -1,3 +1,89 @@
1
- # Temporary Holding Version
1
+ # create-techtiten-app
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One command for a new app, with the same structure every time:
4
+
5
+ ```
6
+ npx create-techtiten-app my-app
7
+ ```
8
+
9
+ ![What you get: the starter phone screens, website and admin panel](https://raw.githubusercontent.com/codewitharun/create-techtiten-app/main/docs/screens.png)
10
+
11
+ ```
12
+ my-app/
13
+ ├─ apps/mobile Expo (latest SDK), Expo Router, TypeScript
14
+ ├─ apps/web Next.js (latest): website + /admin panel + /api backend (optional)
15
+ ├─ packages/theme colors, spacing, radius, font sizes, shared by both apps
16
+ ├─ packages/shared functions, types, zod schemas, shared by both apps
17
+ ├─ .github/workflows Android build → Google Play (only when mobile changes) + web check
18
+ └─ AGENTS.md, STATUS.md, .gitignore (secrets blocked)
19
+ ```
20
+
21
+ ## Options
22
+ Run `npx create-techtiten-app --help` any time to see them all. It asks these questions, or you pass them as flags:
23
+
24
+ | Question | Flag | Choices |
25
+ |---|---|---|
26
+ | Display name | `--name="My App"` | anything |
27
+ | Package id | `--package=com.techtiten.myapp` | permanent once on Play |
28
+ | Web app | `--web` / `--no-web` | Next.js: website + `/admin` + `/api` |
29
+ | Navigation | `--nav=stack` / `--nav=tabs` | stack, bottom tabs |
30
+ | State | `--state=zustand` / `--state=redux` | both with TanStack Query |
31
+ | Color theme | `--theme=<preset>` | `techtiten`, `ocean`, `sunset`, `royal`, `mono`, `retro` (green LCD) |
32
+ | Brand color | `--brand=#FF3366` | optional, replaces the preset's brand color |
33
+ | Font | `--font=<font>` | `system`, `inter`, `poppins`, `space-grotesk`, `pixel` (Press Start 2P) |
34
+ | Ads | `--ads` / `--no-ads` | AdMob banner, test ads in development |
35
+
36
+ Other flags: `--yes` (no questions), `--skip-install`, `--no-git`, `--help`, `--version`.
37
+
38
+ ### Defaults (what you get if you just press Enter, or pass `--yes`)
39
+ | Option | Default |
40
+ |---|---|
41
+ | Display name | folder name in Title Case (`my-app` → `My App`) |
42
+ | Package id | `com.techtiten.` + folder name without dashes (`com.techtiten.myapp`) |
43
+ | Web app | yes |
44
+ | Navigation | stack |
45
+ | State | Zustand + TanStack Query |
46
+ | Theme | `techtiten` (light + dark, follows the phone's setting) |
47
+ | Brand color | from the theme |
48
+ | Font | system |
49
+ | Ads | no |
50
+
51
+ ### Color themes
52
+ ![Color theme presets in light and dark](https://raw.githubusercontent.com/codewitharun/create-techtiten-app/main/docs/themes.png)
53
+
54
+ Your colors end up in `packages/theme/src/colors.ts`: one file, used by the phone app and the website. Change any value there later.
55
+
56
+ ### Fonts
57
+ ![Font options](https://raw.githubusercontent.com/codewitharun/create-techtiten-app/main/docs/fonts.png)
58
+
59
+
60
+ ```
61
+ npx create-techtiten-app retro-games --yes --no-web --ads --theme=retro --font=pixel --nav=tabs
62
+ npx create-techtiten-app shop --yes --web --theme=sunset --brand=#FF3366 --font=poppins --state=redux
63
+ ```
64
+
65
+ Every generated project also has `npm run format` (Prettier), `npm run upgrade:mobile` (latest Expo SDK) and `npm run upgrade:web` (latest Next.js).
66
+
67
+ ## Add an option to this CLI
68
+ All options live in one list (`OPTIONS` in `bin/index.mjs`). Theme presets and fonts are in `bin/presets.mjs`. Add an entry and it appears in the questions, the flags and `--help`.
69
+
70
+ ## Always the latest
71
+ - The base apps come from `create-expo-app@latest` and `create-next-app@latest`, so a new project starts on the newest Expo SDK and Next.js.
72
+ - Native packages are added with `npx expo install`, which picks the versions that match that SDK. Nothing is pinned in this package.
73
+ - No `android/` or `ios/` folders are stored: they are generated at build time (`expo prebuild`), so the native side always matches the SDK.
74
+ - The Expo template's own `app.json` values (icons, splash, defaults) are kept in `apps/mobile/config/base.json`; your settings go on top in `app.config.ts`.
75
+ - One React version for the whole repo (web is pinned to the version Expo installed), so hooks never break.
76
+
77
+ ## What you get
78
+ **Mobile:** Reanimated 4 + Worklets + Gesture Handler (SDK-matched, root view already wrapped), theme provider (system/light/dark, saved), UI primitives (Screen, Card, Text, Button with haptics), state demo, API client + TanStack Query hook, release signing plugin (CI secrets or `credentials/signing.properties`), optional AdMob banner.
79
+
80
+ **Web:** landing page using the same theme colors, `/admin` dashboard behind `ADMIN_USER`/`ADMIN_PASSWORD` (`src/proxy.ts`), `GET /api/health` (shared zod schema), `POST /api/cron/[job]` protected by `CRON_API_KEY` for outside schedulers (Vercel functions are short-lived).
81
+
82
+ **CI:** `android.yml` builds a signed AAB and uploads to Play (internal track by default). Secrets: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEY_ALIAS`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_PASSWORD`, `ANDROID_SERVICE_ACCOUNT_JSON`.
83
+
84
+ ## Develop this package
85
+ ```
86
+ npm install
87
+ node bin/index.mjs test-app --yes # try it
88
+ npm publish # first time: npm login
89
+ ```
package/bin/index.mjs ADDED
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ // create-techtiten-app: scaffolds an npm-workspaces monorepo
3
+ // apps/mobile Expo (latest SDK, Expo Router, typed) always
4
+ // apps/web Next.js (latest): website + /admin + /api optional
5
+ // packages/theme colors (preset), spacing, radius, fonts shared
6
+ // packages/shared functions, types, zod schemas shared
7
+ // Latest versions every time: the base apps come from create-expo-app / create-next-app,
8
+ // and native-aware packages are added with `npx expo install` (SDK-matched). Nothing native
9
+ // is committed: android/ and ios/ are generated at build time (CNG).
10
+ //
11
+ // Every option lives in OPTIONS below: the prompts, the flags and --help are all built from it,
12
+ // so a new feature only has to be added once.
13
+ import * as p from '@clack/prompts';
14
+ import pc from 'picocolors';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
17
+ import { dirname, join, relative, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { FONTS, THEMES, buildColors, isHex } from './presets.mjs';
20
+
21
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
22
+ const TPL = join(ROOT, 'templates');
23
+ const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
24
+
25
+ const slugify = (s) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
26
+ const titleCase = (s) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
27
+ const choices = (obj) => Object.entries(obj).map(([value, o]) => ({ value, label: o.label, hint: o.hint }));
28
+
29
+ // ---------------------------------------------------------------- options (single source of truth)
30
+ const OPTIONS = [
31
+ { key: 'name', kind: 'text', flag: '--name=<text>', help: 'App display name (under the icon)', def: (a) => titleCase(a.slug), message: 'App display name' },
32
+ {
33
+ key: 'package', kind: 'text', flag: '--package=<id>', help: 'Android package / iOS bundle id (permanent once on Play)',
34
+ def: (a) => `com.techtiten.${a.slug.replace(/-/g, '')}`, message: 'Android package / iOS bundle id',
35
+ validate: (v) => (/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(v ?? '') ? undefined : 'Like com.techtiten.myapp (lowercase, dots)'),
36
+ },
37
+ { key: 'web', kind: 'bool', flag: '--web / --no-web', help: 'Next.js app next to the phone app: website + /admin panel + /api backend', def: () => true, message: 'Add a Next.js app (website + admin panel + API)?' },
38
+ {
39
+ key: 'nav', kind: 'select', flag: '--nav=<stack|tabs>', help: 'Navigation layout', def: () => 'stack', message: 'Navigation',
40
+ options: [{ value: 'stack', label: 'Stack', hint: 'screens push on top of each other' }, { value: 'tabs', label: 'Bottom tabs', hint: 'Home + Settings tabs, add more in app/(tabs)' }],
41
+ },
42
+ {
43
+ key: 'state', kind: 'select', flag: '--state=<zustand|redux>', help: 'State management (both come with TanStack Query for API data)', def: () => 'zustand', message: 'State management',
44
+ options: [{ value: 'zustand', label: 'Zustand + TanStack Query', hint: 'recommended: tiny stores' }, { value: 'redux', label: 'Redux Toolkit + TanStack Query', hint: 'more structure, Redux DevTools' }],
45
+ },
46
+ { key: 'theme', kind: 'select', flag: '--theme=<preset>', help: 'Color theme preset (light + dark)', def: () => 'techtiten', message: 'Color theme', options: choices(THEMES) },
47
+ {
48
+ key: 'brand', kind: 'text', flag: '--brand=<#RRGGBB>', help: 'Optional: your own brand color instead of the preset\'s', def: () => '', message: 'Custom brand color? (#RRGGBB, empty = use the preset)',
49
+ validate: (v) => (!v || isHex(v) ? undefined : 'Like #00C389, or leave empty'),
50
+ },
51
+ { key: 'font', kind: 'select', flag: '--font=<font>', help: 'App font (phone app and website)', def: () => 'system', message: 'Font', options: choices(FONTS) },
52
+ { key: 'ads', kind: 'bool', flag: '--ads / --no-ads', help: 'Google AdMob banner (test ads in development)', def: () => false, message: 'Google AdMob ads?' },
53
+ ];
54
+
55
+ const TOOL_FLAGS = [
56
+ ['--yes, -y', 'Skip the questions, use defaults (or the flags you pass)'],
57
+ ['--skip-install', 'Only write files, don\'t run npm install'],
58
+ ['--no-git', 'Don\'t run git init / first commit'],
59
+ ['--help, -h', 'Show this help'],
60
+ ['--version, -v', 'Show the version'],
61
+ ];
62
+
63
+ function printHelp() {
64
+ const line = (a, b) => ` ${pc.cyan(a.padEnd(28))} ${b}`;
65
+ console.log(`
66
+ ${pc.bold('create-techtiten-app')} v${VERSION}
67
+ Scaffold an Expo app (+ optional Next.js web/admin/API) with a shared theme and shared logic.
68
+ Always uses the latest Expo SDK and Next.js.
69
+
70
+ ${pc.bold('Usage')}
71
+ npx create-techtiten-app ${pc.dim('<folder>')} ${pc.dim('[options]')}
72
+
73
+ ${pc.bold('Options')}
74
+ ${OPTIONS.map((o) => line(o.flag, `${o.help}${o.options ? '' : ''}`)).join('\n')}
75
+
76
+ ${pc.bold('Choices')}
77
+ ${line('themes', Object.entries(THEMES).map(([k, t]) => `${k} (${t.label})`).join(', '))}
78
+ ${line('fonts', Object.keys(FONTS).join(', '))}
79
+
80
+ ${pc.bold('Other')}
81
+ ${TOOL_FLAGS.map(([a, b]) => line(a, b)).join('\n')}
82
+
83
+ ${pc.bold('Examples')}
84
+ npx create-techtiten-app my-app
85
+ npx create-techtiten-app retro-games --yes --no-web --ads --theme=retro --font=pixel --nav=tabs
86
+ npx create-techtiten-app shop --yes --web --theme=sunset --brand=#FF3366 --font=poppins --state=redux
87
+ `);
88
+ }
89
+
90
+ // ---------------------------------------------------------------- args
91
+ const argv = process.argv.slice(2);
92
+ const has = (...names) => names.some((n) => argv.includes(n));
93
+ const val = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.split('=').slice(1).join('=');
94
+ const positional = argv.find((a) => !a.startsWith('-'));
95
+ const YES = has('--yes', '-y');
96
+
97
+ function fromFlags(o) {
98
+ if (o.kind === 'bool') return has(`--${o.key}`) ? true : has(`--no-${o.key}`) ? false : undefined;
99
+ return val(o.key);
100
+ }
101
+
102
+ function checkFlag(o, v) {
103
+ if (v === undefined) return;
104
+ if (o.kind === 'select' && !o.options.some((x) => x.value === v)) fail(`--${o.key}=${v} isn't valid. Choose: ${o.options.map((x) => x.value).join(', ')}`);
105
+ const err = o.validate?.(v);
106
+ if (err) fail(`--${o.key}: ${err}`);
107
+ }
108
+
109
+ function fail(msg) { p.cancel(msg); process.exit(1); }
110
+ const cancel = (v) => { if (p.isCancel(v)) fail('Cancelled.'); return v; };
111
+
112
+ async function ask(o, answers) {
113
+ const given = fromFlags(o);
114
+ checkFlag(o, given);
115
+ if (given !== undefined) return given;
116
+ const def = o.def(answers);
117
+ if (YES) return def;
118
+ if (o.kind === 'bool') return cancel(await p.confirm({ message: o.message, initialValue: def }));
119
+ if (o.kind === 'select') return cancel(await p.select({ message: o.message, options: o.options, initialValue: def }));
120
+ return cancel(await p.text({ message: o.message, initialValue: def, placeholder: def || undefined, validate: o.validate }));
121
+ }
122
+
123
+ // ---------------------------------------------------------------- helpers
124
+ function run(cmd, args, cwd, { quiet = false, env = {} } = {}) {
125
+ const r = spawnSync(cmd, args, { cwd, stdio: quiet ? 'pipe' : 'inherit', shell: process.platform === 'win32', env: { ...process.env, CI: '1', EXPO_NO_TELEMETRY: '1', NEXT_TELEMETRY_DISABLED: '1', ...env } });
126
+ if (r.status !== 0) {
127
+ if (quiet) process.stderr.write(String(r.stderr ?? '') + String(r.stdout ?? ''));
128
+ throw new Error(`Command failed: ${cmd} ${args.join(' ')} (in ${cwd})`);
129
+ }
130
+ return String(r.stdout ?? '');
131
+ }
132
+
133
+ // Copy a template folder, replacing __TOKENS__ in text files. "_gitignore" -> ".gitignore",
134
+ // "_github" -> ".github" (npm drops real dot-files like .gitignore from published packages).
135
+ function copyTemplate(from, to, vars) {
136
+ if (!existsSync(from)) return;
137
+ for (const name of readdirSync(from)) {
138
+ const src = join(from, name);
139
+ let outName = name.replace(/\.tpl$/, '');
140
+ if (outName === '_gitignore') outName = '.gitignore';
141
+ if (outName === '_github') outName = '.github';
142
+ const dest = join(to, outName);
143
+ if (statSync(src).isDirectory()) { mkdirSync(dest, { recursive: true }); copyTemplate(src, dest, vars); continue; }
144
+ mkdirSync(dirname(dest), { recursive: true });
145
+ const buf = readFileSync(src);
146
+ const isText = !/\.(png|jpg|jpeg|gif|ttf|otf|woff2?|ico)$/i.test(name);
147
+ writeFileSync(dest, isText ? buf.toString('utf8').replace(/__([A-Z_]+)__/g, (m, k) => (k in vars ? vars[k] : m)) : buf);
148
+ }
149
+ }
150
+
151
+ const readJson = (f) => JSON.parse(readFileSync(f, 'utf8'));
152
+ const writeJson = (f, o) => writeFileSync(f, JSON.stringify(o, null, 2) + '\n');
153
+
154
+ // Generated files for the theme preset and the font (phone + web).
155
+ function writeThemeAndFonts(target, a) {
156
+ const colors = buildColors(a.theme, a.brand);
157
+ writeFileSync(join(target, 'packages/theme/src/colors.ts'),
158
+ `// ${THEMES[a.theme].label} preset${a.brand ? `, brand ${a.brand}` : ''} (create-techtiten-app). Edit freely.\nexport const colors = ${JSON.stringify(colors, null, 2)} as const;\n`);
159
+
160
+ const f = FONTS[a.font];
161
+ const m = join(target, 'apps/mobile/src/theme/fonts.ts');
162
+ if (!f.pkg) {
163
+ writeFileSync(m, `// System font (San Francisco / Roboto): nothing to load.\nexport const fonts: Record<'regular' | 'medium' | 'bold' | 'black', string | undefined> = { regular: undefined, medium: undefined, bold: undefined, black: undefined };\nexport const useAppFonts = () => true;\n/** Multiplies every font size (wide fonts like pixel ones need smaller text). */\nexport const fontScale = 1;\n`);
164
+ } else {
165
+ const names = [...new Set(Object.values(f.weights))];
166
+ writeFileSync(m, `// ${f.label} from ${f.pkg}. Text/Button pick the family per weight.\nimport { useFonts, ${names.join(', ')} } from '${f.pkg}';\n\nexport const fonts: Record<'regular' | 'medium' | 'bold' | 'black', string | undefined> = ${JSON.stringify(f.weights)};\n\n/** true once the font files are loaded (the root layout waits for it). */\nexport function useAppFonts() {\n const [loaded, error] = useFonts({ ${names.join(', ')} });\n return loaded || !!error;\n}\n\n/** Multiplies every font size (wide fonts like pixel ones need smaller text). */\nexport const fontScale = ${f.scale ?? 1};\n`);
167
+ }
168
+ if (a.web) {
169
+ const w = join(target, 'apps/web/src/app/fonts.ts');
170
+ if (!f.next) writeFileSync(w, `// System font.\nexport const appFontClass = '';\n`);
171
+ else {
172
+ const opts = { subsets: ['latin'], variable: '--font-app', display: 'swap', ...(f.nextWeights ? { weight: f.nextWeights } : {}) };
173
+ // next/font reads these options at build time and needs a plain object literal (unquoted keys).
174
+ const literal = '{ ' + Object.entries(opts).map(([k, v]) => `${k}: ${JSON.stringify(v).replace(/"/g, "'")}`).join(', ') + ' }';
175
+ writeFileSync(w, `// ${f.label} via next/font (self-hosted at build time, no layout shift).\nimport { ${f.next} } from 'next/font/google';\n\nconst font = ${f.next}(${literal});\nexport const appFontClass = font.variable;\n`);
176
+ }
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------- main
181
+ async function main() {
182
+ if (has('--help', '-h')) return printHelp();
183
+ if (has('--version', '-v')) return console.log(VERSION);
184
+
185
+ console.log();
186
+ p.intro(pc.bgGreen(pc.black(` create-techtiten-app v${VERSION} `)));
187
+
188
+ const dirName = positional ?? (YES ? 'my-app' : cancel(await p.text({ message: 'Project folder name', placeholder: 'my-app', validate: (v) => (slugify(v || '') ? undefined : 'Required') })));
189
+ const slug = slugify(dirName);
190
+ const target = resolve(process.cwd(), dirName);
191
+ if (existsSync(target) && readdirSync(target).length) fail(`${dirName} already exists and isn't empty.`);
192
+
193
+ const a = { slug };
194
+ for (const o of OPTIONS) a[o.key] = await ask(o, a);
195
+ if (a.brand && !a.brand.startsWith('#')) a.brand = `#${a.brand}`;
196
+
197
+ if (!YES) {
198
+ p.note([
199
+ `Name ${a.name} (${a.package})`,
200
+ `Apps mobile${a.web ? ' + web (site, admin, API)' : ''}`,
201
+ `Navigation ${a.nav} State ${a.state}`,
202
+ `Theme ${THEMES[a.theme].label}${a.brand ? ` with brand ${a.brand}` : ''} Font ${FONTS[a.font].label}`,
203
+ `Ads ${a.ads ? 'AdMob' : 'none'}`,
204
+ ].join('\n'), 'Summary');
205
+ if (!cancel(await p.confirm({ message: 'Create it?', initialValue: true }))) fail('Cancelled.');
206
+ }
207
+
208
+ const install = !has('--skip-install');
209
+ const { name: displayName, package: packageId, web, state, ads } = a;
210
+ const scope = `@${slug}`;
211
+ const vars = { APP_SLUG: slug, APP_NAME: displayName, PACKAGE_ID: packageId, SCOPE: scope, STATE: state, YEAR: String(new Date().getFullYear()), WEB_ENABLED: web ? 'true' : 'false', THEME_LABEL: THEMES[a.theme].label };
212
+ const s = p.spinner();
213
+
214
+ // 1. Root + shared packages
215
+ mkdirSync(target, { recursive: true });
216
+ copyTemplate(join(TPL, 'root'), target, vars);
217
+ copyTemplate(join(TPL, 'packages'), join(target, 'packages'), vars);
218
+ if (!web) rmSync(join(target, '.github/workflows/web-check.yml'), { force: true });
219
+ const rootPkg = readJson(join(target, 'package.json'));
220
+ if (!web) { delete rootPkg.scripts.web; delete rootPkg.scripts['build:web']; delete rootPkg.scripts['upgrade:web']; }
221
+ writeJson(join(target, 'package.json'), rootPkg);
222
+
223
+ // 2. Mobile: latest Expo blank TypeScript app, then our layer on top
224
+ s.start('Creating the Expo app (latest SDK)');
225
+ mkdirSync(join(target, 'apps'), { recursive: true });
226
+ run('npx', ['--yes', 'create-expo-app@latest', 'mobile', '--template', 'blank-typescript', '--no-install'], join(target, 'apps'), { quiet: true });
227
+ const m = join(target, 'apps/mobile');
228
+ // Keep the SDK template's own app.json values (icons, splash, platform defaults) as config/base.json.
229
+ const sdkBase = existsSync(join(m, 'app.json')) ? readJson(join(m, 'app.json')).expo ?? {} : {};
230
+ for (const k of ['name', 'slug', 'plugins']) delete sdkBase[k];
231
+ for (const f of ['App.tsx', 'index.ts', 'app.json', '.gitignore', 'LICENSE']) rmSync(join(m, f), { force: true });
232
+ rmSync(join(m, '.git'), { recursive: true, force: true });
233
+ copyTemplate(join(TPL, 'mobile/common'), m, vars);
234
+ writeJson(join(m, 'config/base.json'), sdkBase);
235
+ copyTemplate(join(TPL, `mobile/nav-${a.nav}`), m, vars);
236
+ copyTemplate(join(TPL, `mobile/${state}`), m, vars);
237
+ copyTemplate(join(TPL, ads ? 'mobile/ads' : 'mobile/noads'), m, vars);
238
+ const mp = readJson(join(m, 'package.json'));
239
+ mp.name = `${scope}/mobile`;
240
+ mp.main = 'expo-router/entry';
241
+ mp.scripts = { ...mp.scripts, start: 'expo start', android: 'expo run:android', ios: 'expo run:ios', web: 'expo start --web', prebuild: 'expo prebuild --clean', typecheck: 'tsc --noEmit' };
242
+ mp.dependencies = { ...mp.dependencies, [`${scope}/theme`]: '*', [`${scope}/shared`]: '*' };
243
+ writeJson(join(m, 'package.json'), mp);
244
+ s.stop('Expo app created');
245
+
246
+ // 3. Web: latest Next.js (App Router, TS, Tailwind, src/), then our layer
247
+ if (web) {
248
+ s.start('Creating the Next.js app (latest)');
249
+ run('npx', ['--yes', 'create-next-app@latest', 'web', '--ts', '--app', '--eslint', '--tailwind', '--src-dir', '--import-alias', '@/*', '--use-npm', '--skip-install', '--disable-git', '--yes'], join(target, 'apps'), { quiet: true });
250
+ const w = join(target, 'apps/web');
251
+ rmSync(join(w, '.gitignore'), { force: true });
252
+ rmSync(join(w, 'README.md'), { force: true });
253
+ rmSync(join(w, 'src/app/page.tsx'), { force: true }); // replaced by src/app/(site)/page.tsx
254
+ copyTemplate(join(TPL, 'web'), w, vars);
255
+ // Keep Next's own generated next.config.ts (it changes between versions) and only add
256
+ // transpilePackages so the shared TypeScript packages compile.
257
+ const nc = join(w, 'next.config.ts');
258
+ const tp = `transpilePackages: ['${scope}/theme', '${scope}/shared'],`;
259
+ if (existsSync(nc) && /const nextConfig: NextConfig = \{/.test(readFileSync(nc, 'utf8'))) {
260
+ writeFileSync(nc, readFileSync(nc, 'utf8').replace(/const nextConfig: NextConfig = \{/, (x) => `${x}\n ${tp}`));
261
+ } else {
262
+ writeFileSync(nc, `import type { NextConfig } from 'next';\n\nconst nextConfig: NextConfig = {\n ${tp}\n};\n\nexport default nextConfig;\n`);
263
+ }
264
+ const wp = readJson(join(w, 'package.json'));
265
+ wp.name = `${scope}/web`;
266
+ wp.scripts = { ...wp.scripts, typecheck: 'next typegen && tsc --noEmit' }; // typegen writes LayoutProps/RouteContext types
267
+ wp.dependencies = { ...wp.dependencies, [`${scope}/theme`]: '*', [`${scope}/shared`]: '*' };
268
+ writeJson(join(w, 'package.json'), wp);
269
+ s.stop('Next.js app created');
270
+ }
271
+
272
+ writeThemeAndFonts(target, a);
273
+ const pluginList = (added = []) => [...new Set(['expo-router', ...added, ...(ads ? ['react-native-google-mobile-ads'] : []), './plugins/withSigning.js'])];
274
+
275
+ // 4. Install: one root install, then SDK-matched native packages via expo install
276
+ if (install) {
277
+ s.start('Installing (one install for all workspaces)');
278
+ run('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
279
+ s.message('Adding Expo packages matched to this SDK');
280
+ const expoPkgs = ['expo-router', 'expo-linking', 'expo-constants', 'expo-status-bar', 'expo-haptics', 'expo-system-ui', 'expo-font', '@expo/vector-icons', 'react-native-safe-area-context', 'react-native-screens', 'react-native-reanimated', 'react-native-worklets', 'react-native-gesture-handler', '@react-native-async-storage/async-storage', 'react-dom', 'react-native-web'];
281
+ if (ads) expoPkgs.push('react-native-google-mobile-ads');
282
+ if (FONTS[a.font].pkg) expoPkgs.push(FONTS[a.font].pkg);
283
+ const expoArgs = ['expo', 'install', ...expoPkgs, '--', '--no-audit', '--no-fund', '--loglevel=error'];
284
+ // expo install adds config plugins itself, but can't edit app.config.ts. Give it a temporary
285
+ // app.json, then copy the plugins it added into config/plugins.json (read by app.config.ts).
286
+ renameSync(join(m, 'app.config.ts'), join(m, 'app.config.ts.tmp'));
287
+ writeJson(join(m, 'app.json'), { expo: { name: displayName, slug, plugins: [] } });
288
+ try {
289
+ run('npx', expoArgs, m, { quiet: true });
290
+ } catch {
291
+ // Expo's version API unreachable (proxy/offline): use the versions bundled with the installed SDK.
292
+ s.message("Expo API unreachable, using the SDK's bundled version list");
293
+ run('npx', expoArgs, m, { quiet: true, env: { EXPO_OFFLINE: '1' } });
294
+ }
295
+ const added = (readJson(join(m, 'app.json')).expo.plugins ?? []).map((x) => (Array.isArray(x) ? x[0] : x));
296
+ rmSync(join(m, 'app.json'));
297
+ renameSync(join(m, 'app.config.ts.tmp'), join(m, 'app.config.ts'));
298
+ const statePkgs = state === 'redux' ? ['@reduxjs/toolkit', 'react-redux', '@tanstack/react-query'] : ['zustand', '@tanstack/react-query'];
299
+ run('npm', ['install', ...statePkgs, '-w', `${scope}/mobile`, '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
300
+ run('npm', ['install', 'zod', '-w', `${scope}/shared`, '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
301
+ run('npm', ['install', '-D', 'prettier', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
302
+ // Now that the packages exist, turn their config plugins on.
303
+ writeJson(join(m, 'config/plugins.json'), pluginList(added));
304
+ if (web) {
305
+ // One React for the whole repo: pin web's react/react-dom to the version Expo installed,
306
+ // otherwise npm can install two Reacts and hooks break ("Invalid hook call").
307
+ const rv = readJson(join(m, 'package.json')).dependencies.react;
308
+ const w = join(target, 'apps/web');
309
+ const wp = readJson(join(w, 'package.json'));
310
+ wp.dependencies.react = rv;
311
+ wp.dependencies['react-dom'] = rv;
312
+ writeJson(join(w, 'package.json'), wp);
313
+ const rootPkg2 = readJson(join(target, 'package.json'));
314
+ rootPkg2.overrides = { ...(rootPkg2.overrides ?? {}), react: rv, 'react-dom': rv };
315
+ writeJson(join(target, 'package.json'), rootPkg2);
316
+ run('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
317
+ }
318
+ s.stop('Installed');
319
+ } else {
320
+ writeJson(join(m, 'config/plugins.json'), pluginList());
321
+ }
322
+
323
+ // 5. Git (uses your git name/email; falls back to a neutral author if none is set)
324
+ if (!has('--no-git')) {
325
+ try {
326
+ run('git', ['init', '-b', 'main'], target, { quiet: true });
327
+ run('git', ['add', '-A'], target, { quiet: true });
328
+ try { run('git', ['commit', '-m', 'Initial commit from create-techtiten-app', '--no-verify'], target, { quiet: true }); }
329
+ catch { run('git', ['-c', 'user.name=create-techtiten-app', '-c', 'user.email=noreply@techtiten.com', 'commit', '-m', 'Initial commit from create-techtiten-app', '--no-verify'], target, { quiet: true }); }
330
+ } catch { p.log.warn('git init skipped (git missing or commit failed). Run git init yourself.'); }
331
+ }
332
+
333
+ const rel = relative(process.cwd(), target) || '.';
334
+ p.note([
335
+ `cd ${rel}`,
336
+ 'npm run mobile # Expo dev server (press a for Android)',
337
+ web ? 'npm run web # Next.js on http://localhost:3000 (/admin, /api/health)' : null,
338
+ 'npm run typecheck # all workspaces',
339
+ 'npm run format # prettier',
340
+ ads ? 'Ads need a dev build (npm run android), not Expo Go.' : null,
341
+ ].filter(Boolean).join('\n'), 'Next steps');
342
+ p.outro(`${pc.green('Done.')} ${displayName} is ready in ${pc.cyan(rel)}`);
343
+ }
344
+
345
+ main().catch((e) => fail(e.message));
@@ -0,0 +1,68 @@
1
+ // Theme presets and fonts. Add one here and it shows up in the prompts and in --help.
2
+
3
+ const neutrals = {
4
+ cool: {
5
+ light: { background: '#F7F8FA', surface: '#FFFFFF', surfaceAlt: '#EEF0F3', border: '#E2E5EA', text: '#0B0D12', textMuted: '#5B6270' },
6
+ dark: { background: '#0B0B12', surface: '#15151F', surfaceAlt: '#1D1D2A', border: '#2A2A38', text: '#ECEDF2', textMuted: '#9A9CAB' },
7
+ },
8
+ navy: {
9
+ light: { background: '#F5F8FC', surface: '#FFFFFF', surfaceAlt: '#EAF0F8', border: '#DCE4EF', text: '#0A1324', textMuted: '#55627A' },
10
+ dark: { background: '#0A1020', surface: '#121A2E', surfaceAlt: '#1A2440', border: '#26314F', text: '#E8EEF8', textMuted: '#93A0BA' },
11
+ },
12
+ warm: {
13
+ light: { background: '#FBF8F5', surface: '#FFFFFF', surfaceAlt: '#F4EEE8', border: '#EADFD5', text: '#1A120D', textMuted: '#6E6058' },
14
+ dark: { background: '#140F0C', surface: '#1E1713', surfaceAlt: '#2A201A', border: '#3A2D25', text: '#F3ECE6', textMuted: '#AE9F95' },
15
+ },
16
+ plum: {
17
+ light: { background: '#F8F7FC', surface: '#FFFFFF', surfaceAlt: '#EFECF9', border: '#E1DCF2', text: '#120E22', textMuted: '#5E5875' },
18
+ dark: { background: '#0E0B1A', surface: '#171328', surfaceAlt: '#211B37', border: '#2E2649', text: '#EEEAFB', textMuted: '#A29BBF' },
19
+ },
20
+ lcd: {
21
+ light: { background: '#C7D3A6', surface: '#BCC994', surfaceAlt: '#AFBD86', border: '#8F9D68', text: '#1E2A14', textMuted: '#4A5832' },
22
+ dark: { background: '#10160C', surface: '#182012', surfaceAlt: '#212B19', border: '#33402A', text: '#B8E07A', textMuted: '#7E9A57' },
23
+ },
24
+ };
25
+
26
+ const fixed = { danger: '#E5484D', warning: '#E9A049', success: '#2EAD5B' };
27
+
28
+ // brand per mode: [brand, brandStrong, onBrand]
29
+ export const THEMES = {
30
+ techtiten: { label: 'Techtiten', hint: 'mint green + purple (FitPrep / EzySplit feel)', neutral: 'cool', light: ['#00A372', '#008A60', '#FFFFFF'], dark: ['#00C389', '#00A372', '#04110C'], accent: '#7C5CFF' },
31
+ ocean: { label: 'Ocean', hint: 'calm blue, trusted (finance, productivity)', neutral: 'navy', light: ['#2F6FED', '#1F57C8', '#FFFFFF'], dark: ['#4D8DFF', '#2F6FED', '#06122A'], accent: '#00B8D9' },
32
+ sunset: { label: 'Sunset', hint: 'orange + pink, energetic (food, fitness)', neutral: 'warm', light: ['#F2552C', '#D4401B', '#FFFFFF'], dark: ['#FF6B4A', '#F2552C', '#1A0B06'], accent: '#FF4F8B' },
33
+ royal: { label: 'Royal', hint: 'violet, premium (education, lifestyle)', neutral: 'plum', light: ['#6A4CF0', '#5435D6', '#FFFFFF'], dark: ['#8B70FF', '#6A4CF0', '#0E0820'], accent: '#FF5CA8' },
34
+ mono: { label: 'Mono', hint: 'black & white, minimal (tools, portfolio)', neutral: 'cool', light: ['#111317', '#000000', '#FFFFFF'], dark: ['#F2F3F5', '#FFFFFF', '#0B0B12'], accent: '#7A808C' },
35
+ retro: { label: 'Retro LCD', hint: 'old-phone green screen (games, nostalgia)', neutral: 'lcd', light: ['#2F3B1E', '#1E2A14', '#C7D3A6'], dark: ['#9BD14A', '#B8E07A', '#10160C'], accent: '#5E7A2E' },
36
+ };
37
+
38
+ // Mobile: @expo-google-fonts/<pkg> exports; web: next/font/google import name + options.
39
+ export const FONTS = {
40
+ system: { label: 'System', hint: 'San Francisco / Roboto, nothing to load' },
41
+ inter: { label: 'Inter', hint: 'clean UI font', pkg: '@expo-google-fonts/inter', weights: { regular: 'Inter_400Regular', medium: 'Inter_500Medium', bold: 'Inter_700Bold', black: 'Inter_800ExtraBold' }, next: 'Inter', nextWeights: null },
42
+ poppins: { label: 'Poppins', hint: 'round and friendly (consumer apps)', pkg: '@expo-google-fonts/poppins', weights: { regular: 'Poppins_400Regular', medium: 'Poppins_500Medium', bold: 'Poppins_700Bold', black: 'Poppins_800ExtraBold' }, next: 'Poppins', nextWeights: ['400', '500', '700', '800'] },
43
+ 'space-grotesk': { label: 'Space Grotesk', hint: 'techy, modern', pkg: '@expo-google-fonts/space-grotesk', weights: { regular: 'SpaceGrotesk_400Regular', medium: 'SpaceGrotesk_500Medium', bold: 'SpaceGrotesk_700Bold', black: 'SpaceGrotesk_700Bold' }, next: 'Space_Grotesk', nextWeights: null },
44
+ pixel: { label: 'Press Start 2P', hint: 'pixel/8-bit (retro games)', scale: 0.72, pkg: '@expo-google-fonts/press-start-2p', weights: { regular: 'PressStart2P_400Regular', medium: 'PressStart2P_400Regular', bold: 'PressStart2P_400Regular', black: 'PressStart2P_400Regular' }, next: 'Press_Start_2P', nextWeights: ['400'] },
45
+ };
46
+
47
+ const hex = (h) => h.replace('#', '').padEnd(6, '0').slice(0, 6);
48
+ const toRgb = (h) => [0, 2, 4].map((i) => parseInt(hex(h).slice(i, i + 2), 16));
49
+ const toHex = (rgb) => '#' + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('').toUpperCase();
50
+ const shade = (h, f) => toHex(toRgb(h).map((v) => (f < 0 ? v * (1 + f) : v + (255 - v) * f)));
51
+ const luminance = (h) => { const [r, g, b] = toRgb(h).map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
52
+ export const isHex = (v) => /^#?[0-9a-fA-F]{6}$/.test(v ?? '');
53
+
54
+ /** { light: {...}, dark: {...} } colors for packages/theme. `brand` (#RRGGBB) overrides the preset's brand. */
55
+ export function buildColors(themeKey, brand) {
56
+ const t = THEMES[themeKey];
57
+ const out = {};
58
+ for (const mode of ['light', 'dark']) {
59
+ let [b, strong, on] = t[mode];
60
+ if (brand) {
61
+ b = '#' + hex(brand).toUpperCase();
62
+ strong = shade(b, mode === 'light' ? -0.15 : 0.15);
63
+ on = luminance(b) > 0.45 ? '#0B0D12' : '#FFFFFF';
64
+ }
65
+ out[mode] = { ...neutrals[t.neutral][mode], brand: b, brandStrong: strong, onBrand: on, accent: t.accent, ...fixed };
66
+ }
67
+ return out;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "create-techtiten-app",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Scaffold a Techtiten app: Expo (Android/iOS) + optional Next.js (web, admin panel, API) with a shared theme and shared logic. Always on the latest Expo and Next.js.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-techtiten-app": "bin/index.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "templates"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20.19"
15
+ },
16
+ "keywords": [
17
+ "expo",
18
+ "react-native",
19
+ "nextjs",
20
+ "monorepo",
21
+ "template",
22
+ "create-app",
23
+ "zustand",
24
+ "redux"
25
+ ],
26
+ "author": "Arun Kumar (Techtiten)",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/codewitharun/create-techtiten-app.git"
31
+ },
32
+ "dependencies": {
33
+ "@clack/prompts": "^1.8.1",
34
+ "picocolors": "^1.1.1"
35
+ },
36
+ "homepage": "https://github.com/codewitharun/create-techtiten-app#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/codewitharun/create-techtiten-app/issues"
39
+ }
40
+ }
@@ -0,0 +1,14 @@
1
+ import { View } from 'react-native';
2
+ import { BannerAd, BannerAdSize, TestIds } from 'react-native-google-mobile-ads';
3
+
4
+ // Test ads in development, your real unit id in release builds (EXPO_PUBLIC_ADMOB_BANNER_ID).
5
+ // Ads need a dev build (npm run android), not Expo Go.
6
+ const UNIT_ID = __DEV__ ? TestIds.ADAPTIVE_BANNER : process.env.EXPO_PUBLIC_ADMOB_BANNER_ID ?? TestIds.ADAPTIVE_BANNER;
7
+
8
+ export function Banner() {
9
+ return (
10
+ <View style={{ alignItems: 'center' }}>
11
+ <BannerAd unitId={UNIT_ID} size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER} />
12
+ </View>
13
+ );
14
+ }
@@ -0,0 +1,4 @@
1
+ /** AdMob has no web SDK: the web build of the app shows no banner. */
2
+ export function Banner() {
3
+ return null;
4
+ }
@@ -0,0 +1,36 @@
1
+ import type { ExpoConfig } from 'expo/config';
2
+ import base from './config/base.json';
3
+ import pluginNames from './config/plugins.json';
4
+
5
+ // config/base.json = what this Expo SDK's own template ships (icons, splash, platform defaults),
6
+ // saved by create-techtiten-app. Our settings go on top, so new SDK defaults are never lost.
7
+ // config/plugins.json = config plugins of the installed packages.
8
+
9
+ // versionCode/buildNumber come from CI (BUILD_NUMBER = GitHub run number): every Play upload is higher.
10
+ const buildNumber = Number(process.env.BUILD_NUMBER ?? 1);
11
+
12
+ // AdMob app ids: Google's TEST ids unless ADMOB_ANDROID_APP_ID / ADMOB_IOS_APP_ID are set (CI or .env).
13
+ const pluginOptions: Record<string, object> = {
14
+ 'react-native-google-mobile-ads': {
15
+ androidAppId: process.env.ADMOB_ANDROID_APP_ID ?? 'ca-app-pub-3940256099942544~3347511713',
16
+ iosAppId: process.env.ADMOB_IOS_APP_ID ?? 'ca-app-pub-3940256099942544~1458002511',
17
+ },
18
+ };
19
+ const plugins: ExpoConfig['plugins'] = (pluginNames as string[]).map((name) => (pluginOptions[name] ? [name, pluginOptions[name]] : name));
20
+
21
+ const sdkDefaults = base as Partial<ExpoConfig>;
22
+
23
+ const config: ExpoConfig = {
24
+ ...sdkDefaults,
25
+ name: '__APP_NAME__',
26
+ slug: '__APP_SLUG__',
27
+ scheme: '__APP_SLUG__',
28
+ version: '1.0.0',
29
+ userInterfaceStyle: 'automatic',
30
+ ios: { ...sdkDefaults.ios, bundleIdentifier: '__PACKAGE_ID__', buildNumber: String(buildNumber) },
31
+ android: { ...sdkDefaults.android, package: '__PACKAGE_ID__', versionCode: buildNumber },
32
+ plugins,
33
+ experiments: { ...sdkDefaults.experiments, typedRoutes: true },
34
+ };
35
+
36
+ export default config;
@@ -0,0 +1 @@
1
+ Bug fixes and improvements.