codingpixel-expo-app 0.1.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/bin/cli.js +9 -0
  4. package/dist/babel.js +198 -0
  5. package/dist/bootstrap.js +54 -0
  6. package/dist/fonts.js +98 -0
  7. package/dist/index.js +120 -0
  8. package/dist/install.js +243 -0
  9. package/dist/overlay.js +141 -0
  10. package/dist/patch.js +212 -0
  11. package/dist/prompts.js +115 -0
  12. package/dist/scaffold.js +43 -0
  13. package/dist/sdkNotes.js +19 -0
  14. package/dist/util.js +48 -0
  15. package/package.json +56 -0
  16. package/templates/base/assets/fonts/.gitkeep +0 -0
  17. package/templates/base/assets/images/.gitkeep +0 -0
  18. package/templates/base/assets/index.ts +8 -0
  19. package/templates/base/src/app/_layout.tsx +39 -0
  20. package/templates/base/src/app/index.tsx +13 -0
  21. package/templates/base/src/app/routes.tsx +9 -0
  22. package/templates/base/src/core/hooks/.gitkeep +0 -0
  23. package/templates/base/src/core/redux/hooks.ts +6 -0
  24. package/templates/base/src/core/redux/mmkvStorage.ts +18 -0
  25. package/templates/base/src/core/redux/reducers.ts +20 -0
  26. package/templates/base/src/core/redux/slices/userSlice.ts +31 -0
  27. package/templates/base/src/core/redux/store.ts +28 -0
  28. package/templates/base/src/core/services/.gitkeep +0 -0
  29. package/templates/base/src/core/tanstack/index.tsx +93 -0
  30. package/templates/base/src/core/tanstack/tanstack-keys.ts +42 -0
  31. package/templates/base/src/core/utils/config.ts +50 -0
  32. package/templates/base/src/core/utils/constants.ts +11 -0
  33. package/templates/base/src/core/utils/endpoints.ts +29 -0
  34. package/templates/base/src/core/utils/types.ts +47 -0
  35. package/templates/base/src/core/utils/validation.ts +10 -0
  36. package/templates/base/src/features/.gitkeep +0 -0
  37. package/templates/base/src/ui/appComponents/AppRefreshControl/index.tsx +39 -0
  38. package/templates/base/src/ui/appComponents/appButton/index.tsx +122 -0
  39. package/templates/base/src/ui/appComponents/appColumnView/index.tsx +320 -0
  40. package/templates/base/src/ui/appComponents/appFlashList/index.tsx +191 -0
  41. package/templates/base/src/ui/appComponents/appFlatList/index.tsx +172 -0
  42. package/templates/base/src/ui/appComponents/appIcon/index.tsx +105 -0
  43. package/templates/base/src/ui/appComponents/appInput/index.tsx +226 -0
  44. package/templates/base/src/ui/appComponents/appKeyboardAvoidingView/index.tsx +168 -0
  45. package/templates/base/src/ui/appComponents/appKeyboardAwareScrollView/index.tsx +188 -0
  46. package/templates/base/src/ui/appComponents/appLogger/index.tsx +22 -0
  47. package/templates/base/src/ui/appComponents/appPressable/index.tsx +220 -0
  48. package/templates/base/src/ui/appComponents/appRowView/index.tsx +320 -0
  49. package/templates/base/src/ui/appComponents/appSafeAreaInsets/index.tsx +24 -0
  50. package/templates/base/src/ui/appComponents/appScrollView/index.tsx +166 -0
  51. package/templates/base/src/ui/appComponents/appSkeleton/index.tsx +22 -0
  52. package/templates/base/src/ui/appComponents/appStatusBar/index.tsx +8 -0
  53. package/templates/base/src/ui/appComponents/appTabHeader/index.tsx +111 -0
  54. package/templates/base/src/ui/appComponents/appText/index.tsx +258 -0
  55. package/templates/base/src/ui/appComponents/appTextWrapper/index.tsx +84 -0
  56. package/templates/base/src/ui/appComponents/appWrapper/index.tsx +56 -0
  57. package/templates/base/src/ui/appComponents/customActivityIndicator/index.tsx +34 -0
  58. package/templates/base/src/ui/appComponents/customGetPermissionModal/index.tsx +62 -0
  59. package/templates/base/src/ui/appComponents/customModal/index.tsx +26 -0
  60. package/templates/base/src/ui/appComponents/customTextInput/index.tsx +143 -0
  61. package/templates/base/src/ui/components/.gitkeep +0 -0
  62. package/templates/base/src/ui/components/avatarBlock/index.tsx +42 -0
  63. package/templates/base/src/ui/components/backgroundGradient/index.tsx +24 -0
  64. package/templates/base/src/ui/components/errorFallback/index.tsx +40 -0
  65. package/templates/base/src/ui/iconComponents/IconFontAwesome6/index.tsx +22 -0
  66. package/templates/base/src/ui/iconComponents/IoniconsIcons/index.tsx +33 -0
  67. package/templates/base/src/ui/iconComponents/antDesignicons/index.tsx +22 -0
  68. package/templates/base/src/ui/iconComponents/featherIcons/index.tsx +22 -0
  69. package/templates/base/src/ui/iconComponents/materialCommunityIcons/index.tsx +24 -0
  70. package/templates/base/src/ui/iconComponents/materialIcons/index.tsx +23 -0
  71. package/templates/base/src/ui/iconComponents/octiconsIcons/index.tsx +23 -0
  72. package/templates/base/src/ui/theme/allFileStyles.ts +39 -0
  73. package/templates/base/src/ui/theme/colors.ts +54 -0
  74. package/templates/base/src/ui/theme/fonts.ts +3 -0
  75. package/templates/base/src/ui/theme/responsive.ts +27 -0
  76. package/templates/bottom-sheet/src/ui/appComponents/BottomSheetKeyboardAwareScrollView/index.tsx +111 -0
  77. package/templates/bottom-sheet/src/ui/appComponents/appBottomSheetBackdrop/index.tsx +27 -0
  78. package/templates/bottom-sheet/src/ui/appComponents/appBottomSheetScrollView/index.tsx +99 -0
  79. package/templates/bottom-sheet/src/ui/appComponents/appBottomSheetView/index.tsx +173 -0
  80. package/templates/bottom-sheet/src/ui/appComponents/customBottomSheetModal/index.tsx +17 -0
  81. package/templates/claude-command/init-app.md +41 -0
  82. package/templates/image-picker/media-constants.snippet.ts +18 -0
  83. package/templates/image-picker/src/core/services/PermissionService.ts +48 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 codingpixel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # `codingpixel-expo-app`
2
+
3
+ Opinionated Expo SDK 54+ app scaffolder. Mirrors the [MyRoster](https://github.com/) project's component / redux / theme conventions; layers them on top of `create-expo-app`'s `blank-typescript` template; runs all native-dep installs through `expo install` so versions match the target SDK; ships a single lockfile (yarn OR npm — never both).
4
+
5
+ ## Quickstart
6
+
7
+ ```bash
8
+ npx codingpixel-expo-app my-app
9
+ cd my-app
10
+ npx expo prebuild
11
+ yarn ios # or: npm run ios / yarn android / npm run android
12
+ ```
13
+
14
+ The bin name is `codingpixel-expo` (used after global install). Invoke through `npx codingpixel-expo-app` (the package name) for one-shot runs.
15
+
16
+ ## Not Expo Go-compatible
17
+
18
+ This template ships native modules that Expo Go can't load:
19
+
20
+ - `react-native-mmkv` (redux-persist storage)
21
+ - `react-native-gesture-handler`
22
+ - `react-native-reanimated` (+ `react-native-worklets`)
23
+ - `react-native-keyboard-controller`
24
+ - `@gorhom/bottom-sheet` (when bottom-sheet support enabled)
25
+
26
+ You **must** prebuild + run a custom dev-client:
27
+
28
+ ```bash
29
+ npx expo prebuild
30
+ yarn ios # or yarn android / npm run ios / npm run android
31
+ ```
32
+
33
+ Subsequent runs use the dev-client + bundler (`yarn start`).
34
+
35
+ ## Fonts
36
+
37
+ Fonts are intentionally disabled in v0.1.x — generated `src/ui/theme/fonts.ts` exports `Fonts = {} as const` + `FontKey = never`, and `_layout.tsx` ships without `useFonts`. Apps that need custom fonts wire `expo-font` themselves (drop `.ttf`s into `assets/fonts/`, populate `Fonts`, add `useFonts(...)` + a loading guard in `_layout.tsx`).
38
+
39
+ `expo-font` is already in `dependencies`, so no extra install is needed.
40
+
41
+ ## `@/*` alias
42
+
43
+ The template overrides `expo/tsconfig.base`'s default by setting `@/*` → `src/*`. All MyRoster-style aliases (`@theme/*`, `@utils/*`, `@redux/*`, `@core/*`, `@services/*`, `@hooks/*`, `@appComponents/*`, `@components/*`, `@icons/*`, `@features/*`, `@assets`) resolve to their concrete dirs in `src/`. See `tsconfig.json` `compilerOptions.paths`.
44
+
45
+ If your generated app's `tsconfig.json` already has a `@/*` mapping, the CLI preserves it + warns.
46
+
47
+ ## First-time dev-client build
48
+
49
+ iOS:
50
+ - Xcode + CocoaPods installed (`sudo gem install cocoapods` if missing).
51
+ - `npx expo prebuild` then `yarn ios` (or `npm run ios`) — opens the iOS simulator with your custom dev-client.
52
+
53
+ Android:
54
+ - Android SDK + emulator running (or a USB-attached device with debugging enabled).
55
+ - `JAVA_HOME` pointing at JDK 17.
56
+ - `npx expo prebuild` then `yarn android` (or `npm run android`).
57
+
58
+ ## Expo SDK compatibility
59
+
60
+ Built + tested against Expo SDK 54.x (probed via `docs/SDK_NOTES.md` at template-author time). Native dep versions are inherited at scaffold time via `expo install`, so subsequent SDK bumps update the deps without a CLI release.
61
+
62
+ If `expo install` reports version-resolution failures, the CLI runs an isolation retry (`retryWithIsolation`) — drops the failing dep, retries the rest, and surfaces the failed name + verbatim Expo stderr at the end.
63
+
64
+ ## Environment variables (non-interactive runs)
65
+
66
+ Required when stdin is not a TTY (e.g. slash-command flows).
67
+
68
+ | Var | Type | Notes |
69
+ |---|---|---|
70
+ | `EXPO_INCLUDE_BOTTOM_SHEET` | `"0"` or `"1"` | Other values throw before any fs mutation. |
71
+ | `EXPO_INCLUDE_IMAGE_PICKER` | `"0"` or `"1"` | Same. |
72
+ | `EXPO_PACKAGE_MANAGER` | `"yarn"` or `"npm"` | Optional override; auto-detect otherwise. Other values throw. |
73
+
74
+ `EXPO_PRIMARY_FONT` / `EXPO_SECONDARY_FONT` are silently ignored (fonts disabled — see "Fonts" above).
75
+
76
+ ## Bin name
77
+
78
+ - npm package: `codingpixel-expo-app` (unscoped, public).
79
+ - Bin: `codingpixel-expo`.
80
+ - Run as `npx codingpixel-expo-app <dir>` (the package name) — `npx codingpixel-expo` only resolves after a `yarn global add` / `npm i -g` of this package.
81
+
82
+ ## Recovery from mid-run failures
83
+
84
+ The CLI mutates `<target>` in place. If a patch throws partway through:
85
+
86
+ 1. **Patches are idempotent.** Fix the root cause (e.g. install missing PATH entry, retry network) and re-run the same command. The CLI converges.
87
+ 2. **Or:** `rm -rf <target>` and start fresh.
88
+
89
+ The CLI **never** auto-deletes the target dir.
90
+
91
+ ## Contributing / publish
92
+
93
+ ```bash
94
+ yarn install
95
+ yarn build
96
+ yarn test
97
+ yarn audit:templates # also runs as prepublishOnly
98
+ ```
99
+
100
+ `prepublishOnly` chains `npm run build && npm run test && npm run audit:templates` — `audit:templates` greps mirrored files for unmapped MyRoster prefixes + Fonts-as-type misuse, blocking publish on stale audits. See `docs/MIRROR_NOTES.md` for the deviation log.
101
+
102
+ Publish:
103
+
104
+ ```bash
105
+ npm publish
106
+ ```
107
+
108
+ (Unscoped packages publish public by default — no `--access` flag needed.)
109
+
110
+ ## License
111
+
112
+ MIT.
package/bin/cli.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import("../dist/index.js").catch((err) => {
3
+ if (err?.code === "ERR_MODULE_NOT_FOUND") {
4
+ console.error("Build artifacts missing. Did you run `yarn build`?");
5
+ process.exit(1);
6
+ }
7
+ console.error(err);
8
+ process.exit(1);
9
+ });
package/dist/babel.js ADDED
@@ -0,0 +1,198 @@
1
+ // Phase 7 step 4 — `babel.config.js` AST merge.
2
+ //
3
+ // Why AST + not regex:
4
+ // create-expo-app's default `babel.config.js` is a `module.exports = function`
5
+ // returning an object literal. We need to insert/dedup `plugins[]` entries
6
+ // while preserving comments, presets, env-conditional branches, etc. Regex
7
+ // gets fragile fast; AST is the only safe path.
8
+ //
9
+ // Why plugin order matters:
10
+ // reanimated/worklets plugin MUST be the LAST entry in `plugins[]` per
11
+ // upstream docs. `module-resolver` (when we insert it) goes at the FRONT
12
+ // so its presence can never displace the worklets last-slot reservation.
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { parse } from "@babel/parser";
16
+ import * as _traverseNS from "@babel/traverse";
17
+ import * as _generateNS from "@babel/generator";
18
+ import * as t from "@babel/types";
19
+ import { fileExists, log } from "./util.js";
20
+ // ESM/CJS interop — Node's ESM loader wraps CJS modules so @babel/traverse +
21
+ // @babel/generator surface as `{ default: { default: <fn>, ...other } }` from
22
+ // `import * as ns`. Unwrap up to two levels so the callable is reached
23
+ // regardless of how the underlying CJS module is shaped (some Babel versions
24
+ // expose the function directly on .default; newer ones nest one level deeper).
25
+ function unwrap(ns) {
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ let cur = ns;
28
+ for (let i = 0; i < 3; i++) {
29
+ if (typeof cur === "function")
30
+ return cur;
31
+ if (cur && typeof cur.default !== "undefined") {
32
+ cur = cur.default;
33
+ continue;
34
+ }
35
+ break;
36
+ }
37
+ return cur;
38
+ }
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ const traverse = unwrap(_traverseNS);
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ const generate = unwrap(_generateNS);
43
+ const isWorklets = (entry) => {
44
+ const name = nameOfEntry(entry);
45
+ return /^react-native-worklets(-core)?\/plugin$/.test(name);
46
+ };
47
+ const isModuleResolver = (entry) => nameOfEntry(entry) === "module-resolver";
48
+ function nameOfEntry(entry) {
49
+ if (Array.isArray(entry))
50
+ return String(entry[0]);
51
+ return String(entry);
52
+ }
53
+ /** Get the string name from an AST plugin entry (string literal or array literal whose first elem is a string). */
54
+ function pluginEntryName(node) {
55
+ if (t.isStringLiteral(node))
56
+ return node.value;
57
+ if (t.isArrayExpression(node)) {
58
+ const first = node.elements[0];
59
+ if (first && t.isStringLiteral(first))
60
+ return first.value;
61
+ }
62
+ return "";
63
+ }
64
+ /** Build an AST node for `["module-resolver", { alias: <map> }]`. */
65
+ function buildModuleResolverEntry(aliasMap) {
66
+ const aliasObject = t.objectExpression(Object.entries(aliasMap).map(([from, to]) => t.objectProperty(t.stringLiteral(from), t.stringLiteral(to))));
67
+ return t.arrayExpression([
68
+ t.stringLiteral("module-resolver"),
69
+ t.objectExpression([t.objectProperty(t.identifier("alias"), aliasObject)]),
70
+ ]);
71
+ }
72
+ /**
73
+ * Patch `<target>/babel.config.js`. Idempotent. Throws on parse failure or on
74
+ * unsupported config form (e.g. .ts/.cjs/.mjs only).
75
+ */
76
+ export function patchBabel(target, opts) {
77
+ // File-extension detection: .ts/.cjs/.mjs without .js → unsupported.
78
+ const jsPath = path.join(target, "babel.config.js");
79
+ for (const alt of ["babel.config.ts", "babel.config.cjs", "babel.config.mjs"]) {
80
+ if (fileExists(path.join(target, alt)) && !fileExists(jsPath)) {
81
+ throw new Error(`manual babel config (${alt}) not yet supported — patchBabel handles babel.config.js only.`);
82
+ }
83
+ }
84
+ // Deviation #8 (docs/MIRROR_NOTES.md): SDK 54 blank-typescript no longer
85
+ // ships `babel.config.js` — preset-only via expo-router auto-config.
86
+ // Create the minimal stub here; patcher AST below then layers our entries on top.
87
+ if (!fileExists(jsPath)) {
88
+ const stub = `module.exports = function (api) {
89
+ api.cache(true);
90
+ return {
91
+ presets: ['babel-preset-expo'],
92
+ plugins: [],
93
+ };
94
+ };
95
+ `;
96
+ fs.writeFileSync(jsPath, stub);
97
+ log.step("babel.config.js missing — wrote default stub before patching.");
98
+ }
99
+ const source = fs.readFileSync(jsPath, "utf8");
100
+ const ast = parse(source, {
101
+ sourceType: "script", // create-expo-app default is CJS module.exports
102
+ plugins: [],
103
+ });
104
+ // Locate the returned ObjectExpression (return value of the exported function).
105
+ // `let configObj: any` so traverse-callback assignments don't trip TS narrowing.
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
+ let configObj = null;
108
+ traverse(ast, {
109
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
110
+ ReturnStatement(p) {
111
+ if (configObj)
112
+ return;
113
+ const arg = p.node.argument;
114
+ if (arg && t.isObjectExpression(arg)) {
115
+ configObj = arg;
116
+ p.stop();
117
+ }
118
+ },
119
+ });
120
+ if (!configObj) {
121
+ // Fallback: top-level `module.exports = { ... }` form (no function wrapper).
122
+ traverse(ast, {
123
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
124
+ AssignmentExpression(p) {
125
+ if (configObj)
126
+ return;
127
+ if (t.isMemberExpression(p.node.left) &&
128
+ t.isIdentifier(p.node.left.object, { name: "module" }) &&
129
+ t.isIdentifier(p.node.left.property, { name: "exports" }) &&
130
+ t.isObjectExpression(p.node.right)) {
131
+ configObj = p.node.right;
132
+ p.stop();
133
+ }
134
+ },
135
+ });
136
+ }
137
+ if (!configObj) {
138
+ throw new Error("patchBabel: could not locate config ObjectExpression in babel.config.js");
139
+ }
140
+ const config = configObj;
141
+ // Find or create `plugins` property.
142
+ let pluginsProp = config.properties.find((p) => t.isObjectProperty(p) &&
143
+ ((t.isIdentifier(p.key) && p.key.name === "plugins") ||
144
+ (t.isStringLiteral(p.key) && p.key.value === "plugins")));
145
+ if (!pluginsProp) {
146
+ pluginsProp = t.objectProperty(t.identifier("plugins"), t.arrayExpression([]));
147
+ config.properties.push(pluginsProp);
148
+ }
149
+ if (!t.isArrayExpression(pluginsProp.value)) {
150
+ throw new Error("patchBabel: plugins is not an array literal — cannot patch.");
151
+ }
152
+ const plugins = pluginsProp.value;
153
+ // ----- Step 0: decision prelude -----
154
+ const namesOnly = plugins.elements
155
+ .filter((e) => e !== null)
156
+ .map(pluginEntryName);
157
+ const moduleResolverPresent = namesOnly.includes("module-resolver");
158
+ const moduleResolverIsLast = namesOnly.length > 0 && namesOnly[namesOnly.length - 1] === "module-resolver";
159
+ const workletsPresent = namesOnly.some((n) => /^react-native-worklets(-core)?\/plugin$/.test(n));
160
+ const workletsNeedsInsertion = !opts.workletsAutoIncluded && !workletsPresent;
161
+ // ----- Step A: module-resolver -----
162
+ if (moduleResolverIsLast && workletsNeedsInsertion) {
163
+ throw new Error("module-resolver cannot occupy the final plugin slot when worklets plugin is required; " +
164
+ "please reorder babel.config.js plugins manually.");
165
+ }
166
+ if (!moduleResolverPresent) {
167
+ plugins.elements.unshift(buildModuleResolverEntry(opts.aliasMap));
168
+ }
169
+ // ----- Step B: worklets -----
170
+ if (workletsNeedsInsertion) {
171
+ plugins.elements.push(t.stringLiteral(`${opts.workletsPkg}/plugin`));
172
+ }
173
+ // ----- Step C: post-patch invariant assert with user-mutation tolerance -----
174
+ const finalNames = plugins.elements
175
+ .filter((e) => e !== null)
176
+ .map(pluginEntryName);
177
+ const workletsIdx = finalNames.findIndex((n) => /^react-native-worklets(-core)?\/plugin$/.test(n));
178
+ if (workletsIdx !== -1 && workletsIdx !== finalNames.length - 1) {
179
+ const trailing = finalNames.slice(workletsIdx + 1);
180
+ const OURS = new Set(["module-resolver"]);
181
+ const oursAfter = trailing.filter((n) => OURS.has(n));
182
+ const userAfter = trailing.filter((n) => !OURS.has(n));
183
+ if (oursAfter.length > 0) {
184
+ throw new Error(`babel.config.js: our patch corrupted plugin order — worklets must be last. ` +
185
+ `Offending trailing entries from our patch: ${oursAfter.join(", ")}`);
186
+ }
187
+ if (userAfter.length > 0) {
188
+ log.warn(`babel.config.js: plugin(s) [${userAfter.join(", ")}] appear after worklets — ` +
189
+ `reanimated requires worklets last; please reorder.`);
190
+ }
191
+ }
192
+ // Regenerate.
193
+ const out = generate(ast, { retainLines: false, comments: true }, source).code;
194
+ fs.writeFileSync(jsPath, out + (out.endsWith("\n") ? "" : "\n"));
195
+ // sanity: also verify the runtime predicates still match what we computed.
196
+ void isWorklets;
197
+ void isModuleResolver;
198
+ }
@@ -0,0 +1,54 @@
1
+ import path from "node:path";
2
+ import prompts from "prompts";
3
+ import { dirExists, ensureDir, fileExists, isDirEmpty } from "./util.js";
4
+ /**
5
+ * Resolve the target directory + app name from a positional CLI arg.
6
+ *
7
+ * Modes (per PLAN_V5.md Phase 1):
8
+ * - `arg === undefined | ""` → prompt for name; recurse with answer.
9
+ * - `arg === "."` → use cwd. Throws if `package.json` already present.
10
+ * - `arg` non-empty → join with cwd, mkdir -p; throws if non-empty.
11
+ *
12
+ * Hard rejects (security + clarity):
13
+ * - Absolute paths (Phase 1 step 2).
14
+ * - `..`-traversal (Phase 1 step 2).
15
+ */
16
+ export async function resolveTargetDir(arg) {
17
+ // Empty-arg branch — prompt then recurse.
18
+ if (arg === undefined || arg === "") {
19
+ if (!process.stdin.isTTY) {
20
+ throw new Error('No app name provided and stdin is not a TTY. Pass a directory: `codingpixel-expo my-app`.');
21
+ }
22
+ const ans = await prompts({
23
+ type: "text",
24
+ name: "name",
25
+ message: "App name?",
26
+ validate: (v) => (v.trim() === "" ? "Required" : true),
27
+ });
28
+ if (!ans.name)
29
+ throw new Error("Aborted.");
30
+ return resolveTargetDir(String(ans.name).trim());
31
+ }
32
+ // Reject absolute paths + ..-traversal BEFORE any path math.
33
+ if (path.isAbsolute(arg)) {
34
+ throw new Error(`Absolute paths not allowed: "${arg}". Use a directory name relative to cwd.`);
35
+ }
36
+ if (arg.split(/[/\\]/).some((seg) => seg === "..")) {
37
+ throw new Error(`Path traversal not allowed: "${arg}". Use a name relative to cwd.`);
38
+ }
39
+ // Current-dir branch.
40
+ if (arg === ".") {
41
+ const cwd = process.cwd();
42
+ if (fileExists(path.join(cwd, "package.json"))) {
43
+ throw new Error(`Refusing to scaffold into "." — package.json already present in ${cwd}.`);
44
+ }
45
+ return { dir: cwd, name: path.basename(cwd) };
46
+ }
47
+ // Named-dir branch.
48
+ const dir = path.join(process.cwd(), arg);
49
+ if (dirExists(dir) && !isDirEmpty(dir)) {
50
+ throw new Error(`Target directory not empty: ${dir}`);
51
+ }
52
+ ensureDir(dir);
53
+ return { dir, name: path.basename(dir) };
54
+ }
package/dist/fonts.js ADDED
@@ -0,0 +1,98 @@
1
+ // Phase 6 generators. All return strings to be spliced into sentinel positions.
2
+ // Decision-locked: fonts type is object literal `as const` + `FontKey`
3
+ // (NOT TS `enum` — Babel-unsafe under Hermes).
4
+ /**
5
+ * Build the `Fonts` object literal + `FontKey` type. Family naming convention:
6
+ * - `<FontName>-Regular` (always)
7
+ * - `<FontName>-Medium`, `-SemiBold`, `-Bold` (always)
8
+ * - if `secondary` non-empty: also `<SecondaryName>-Regular`, etc. as
9
+ * `SECONDARY_REGULAR`, `SECONDARY_MEDIUM`, `SECONDARY_SEMIBOLD`, `SECONDARY_BOLD`
10
+ *
11
+ * Empty `primary` → `Fonts = {}` + `FontKey = never` (apps using `Fonts.X` will
12
+ * fail to typecheck; intended — user opted out).
13
+ */
14
+ export function generateFontsObject(primary, secondary) {
15
+ const out = [];
16
+ out.push("export const Fonts = {");
17
+ if (primary) {
18
+ out.push(` REGULAR: "${primary}-Regular",`);
19
+ out.push(` MEDIUM: "${primary}-Medium",`);
20
+ out.push(` SEMIBOLD: "${primary}-SemiBold",`);
21
+ out.push(` BOLD: "${primary}-Bold",`);
22
+ }
23
+ if (secondary) {
24
+ out.push(` SECONDARY_REGULAR: "${secondary}-Regular",`);
25
+ out.push(` SECONDARY_MEDIUM: "${secondary}-Medium",`);
26
+ out.push(` SECONDARY_SEMIBOLD: "${secondary}-SemiBold",`);
27
+ out.push(` SECONDARY_BOLD: "${secondary}-Bold",`);
28
+ }
29
+ out.push("} as const;");
30
+ out.push("");
31
+ out.push("export type FontKey = keyof typeof Fonts;");
32
+ return out.join("\n");
33
+ }
34
+ /**
35
+ * Build the three pieces inserted into `_layout.tsx`:
36
+ * - import line(s) for `useFonts` + the asset .ttf require()s
37
+ * - the `useFonts(...)` hook call (must be inside the component body)
38
+ * - the `if (!loaded) return null;` guard (also inside component body, after
39
+ * the hook so React's rules-of-hooks stay satisfied)
40
+ *
41
+ * Empty `primary` → all three return empty strings → sentinel lines drop.
42
+ */
43
+ export function generateUseFontsBlocks(primary, secondary) {
44
+ if (!primary) {
45
+ return { importBlock: "", hookBlock: "", guardBlock: "" };
46
+ }
47
+ const fontPairs = [
48
+ [`${primary}-Regular`, `${primary}-Regular.ttf`],
49
+ [`${primary}-Medium`, `${primary}-Medium.ttf`],
50
+ [`${primary}-SemiBold`, `${primary}-SemiBold.ttf`],
51
+ [`${primary}-Bold`, `${primary}-Bold.ttf`],
52
+ ];
53
+ if (secondary) {
54
+ fontPairs.push([`${secondary}-Regular`, `${secondary}-Regular.ttf`], [`${secondary}-Medium`, `${secondary}-Medium.ttf`], [`${secondary}-SemiBold`, `${secondary}-SemiBold.ttf`], [`${secondary}-Bold`, `${secondary}-Bold.ttf`]);
55
+ }
56
+ const importBlock = `import { useFonts } from "expo-font";`;
57
+ const mapEntries = fontPairs
58
+ .map(([key, file]) => ` "${key}": require("../../assets/fonts/${file}"),`)
59
+ .join("\n");
60
+ const hookBlock = ` const [loaded] = useFonts({\n${mapEntries}\n });`;
61
+ const guardBlock = ` if (!loaded) return null;`;
62
+ return { importBlock, hookBlock, guardBlock };
63
+ }
64
+ /**
65
+ * Build the three pieces wrapping the routing tree with `BottomSheetModalProvider`:
66
+ * - import line for `BottomSheetModalProvider`
67
+ * - opening JSX
68
+ * - closing JSX
69
+ *
70
+ * `bottomSheet === false` → all three empty → sentinel lines drop.
71
+ */
72
+ export function generateBottomSheetProviderBlocks(bottomSheet) {
73
+ if (!bottomSheet) {
74
+ return { importBlock: "", openBlock: "", closeBlock: "" };
75
+ }
76
+ return {
77
+ importBlock: `import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";`,
78
+ openBlock: ` <BottomSheetModalProvider>`,
79
+ closeBlock: ` </BottomSheetModalProvider>`,
80
+ };
81
+ }
82
+ /**
83
+ * Convenience: build the full sentinel-replacement map for `patchLayout`.
84
+ * Includes both layout sentinels and the fonts.ts sentinel.
85
+ */
86
+ export function buildLayoutReplacements(answers) {
87
+ const fonts = generateUseFontsBlocks(answers.primaryFont, answers.secondaryFont);
88
+ const bs = generateBottomSheetProviderBlocks(answers.bottomSheet);
89
+ return {
90
+ USE_FONTS_IMPORT: fonts.importBlock,
91
+ USE_FONTS_HOOK: fonts.hookBlock,
92
+ USE_FONTS_GUARD: fonts.guardBlock,
93
+ BOTTOM_SHEET_PROVIDER_IMPORT: bs.importBlock,
94
+ BOTTOM_SHEET_PROVIDER_OPEN: bs.openBlock,
95
+ BOTTOM_SHEET_PROVIDER_CLOSE: bs.closeBlock,
96
+ FONTS_OBJECT: generateFontsObject(answers.primaryFont, answers.secondaryFont),
97
+ };
98
+ }
package/dist/index.js ADDED
@@ -0,0 +1,120 @@
1
+ // Entry point. Order per PLAN_V5.md (consolidated v5-r6):
2
+ // validateEnvVars
3
+ // → resolveTargetDir
4
+ // → runCreateExpoApp + cleanupBlankTemplate
5
+ // → gatherAnswers
6
+ // → applyBase + applyBottomSheet + applyImagePicker
7
+ // → patchAppJson + patchExpoRouterEntry + patchAppJsonPlugins
8
+ // → patchConstants + patchLayout
9
+ // → patchPackageJsonScripts + patchTsconfig + patchBabel
10
+ // → installNativeDeps
11
+ // → ensureLockfile
12
+ // → success message
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { resolveTargetDir } from "./bootstrap.js";
16
+ import { gatherAnswers, validateEnvVars } from "./prompts.js";
17
+ import { cleanupBlankTemplate, runCreateExpoApp } from "./scaffold.js";
18
+ import { applyBase, applyBottomSheet, applyImagePicker } from "./overlay.js";
19
+ import { patchAppJson, patchAppJsonPlugins, patchConstants, patchExpoRouterEntry, patchLayout, patchPackageJsonScripts, patchTsconfig, } from "./patch.js";
20
+ import { patchBabel } from "./babel.js";
21
+ import { buildLayoutReplacements } from "./fonts.js";
22
+ import { ensureLockfile, installNativeDeps } from "./install.js";
23
+ import { log } from "./util.js";
24
+ import { readSDKNotes } from "./sdkNotes.js";
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = path.dirname(__filename);
27
+ /**
28
+ * Locate `templates/` directory. In the published package layout, it sits at
29
+ * `<pkgRoot>/templates/`; this file lives at `<pkgRoot>/dist/index.js`. So one
30
+ * level up from `dist/` reaches the package root.
31
+ */
32
+ function resolveTemplatesRoot() {
33
+ return path.resolve(__dirname, "..", "templates");
34
+ }
35
+ /**
36
+ * Locate `docs/SDK_NOTES.md`. Same layout assumption as `resolveTemplatesRoot`.
37
+ */
38
+ function resolveSdkNotesPath() {
39
+ return path.resolve(__dirname, "..", "docs", "SDK_NOTES.md");
40
+ }
41
+ async function main() {
42
+ validateEnvVars();
43
+ const arg = process.argv[2];
44
+ const target = await resolveTargetDir(arg);
45
+ log.info(`Target dir: ${target.dir}`);
46
+ log.info(`App name: ${target.name}`);
47
+ await runCreateExpoApp(target.dir, target.name);
48
+ cleanupBlankTemplate(target.dir);
49
+ const answers = await gatherAnswers();
50
+ log.info(`Answers: primaryFont="${answers.primaryFont}" secondaryFont="${answers.secondaryFont}" ` +
51
+ `bottomSheet=${answers.bottomSheet} imagePicker=${answers.imagePicker} pm=${answers.packageManager}`);
52
+ // ---- Apply templates ----
53
+ const templatesRoot = resolveTemplatesRoot();
54
+ log.step("Overlaying templates/base/ …");
55
+ applyBase(target.dir, templatesRoot);
56
+ if (answers.bottomSheet) {
57
+ log.step("Overlaying templates/bottom-sheet/ …");
58
+ applyBottomSheet(target.dir, templatesRoot);
59
+ }
60
+ if (answers.imagePicker) {
61
+ log.step("Overlaying templates/image-picker/ …");
62
+ applyImagePicker(target.dir, templatesRoot);
63
+ }
64
+ // ---- Patches ----
65
+ log.step("Patching app.json + expo-router entry …");
66
+ patchAppJson(target.dir, target.name, answers);
67
+ patchExpoRouterEntry(target.dir);
68
+ patchAppJsonPlugins(target.dir, answers);
69
+ log.step("Splicing constants + layout sentinels …");
70
+ patchConstants(target.dir, templatesRoot, answers);
71
+ patchLayout(target.dir, buildLayoutReplacements(answers));
72
+ // ---- Read SDK_NOTES.md probe outcomes ----
73
+ const sdk = readSDKNotes(resolveSdkNotesPath());
74
+ const flagsOk = sdk.get("FLAGS_OK") === "1";
75
+ const probePass = sdk.get("PROBE_PASS") === "1";
76
+ const workletsAutoIncluded = sdk.get("BABEL_PRESET_AUTO_INCLUDES_WORKLETS") === "1";
77
+ const workletsPkg = sdk.get("WORKLETS_PKG") === "react-native-worklets-core"
78
+ ? "react-native-worklets-core"
79
+ : "react-native-worklets";
80
+ const expoBaseUrlInherited = sdk.get("EXPO_TSCONFIG_BASEURL") !== "null";
81
+ log.step("Patching package.json scripts + tsconfig + babel.config.js …");
82
+ patchPackageJsonScripts(target.dir);
83
+ patchTsconfig(target.dir, { expoBaseUrlInherited });
84
+ patchBabel(target.dir, {
85
+ workletsAutoIncluded,
86
+ workletsPkg,
87
+ aliasMap: {
88
+ "@": "./src",
89
+ "@theme": "./src/ui/theme",
90
+ "@utils": "./src/core/utils",
91
+ "@redux": "./src/core/redux",
92
+ "@core": "./src/core",
93
+ "@services": "./src/core/services",
94
+ "@hooks": "./src/core/hooks",
95
+ "@appComponents": "./src/ui/appComponents",
96
+ "@components": "./src/ui/components",
97
+ "@icons": "./src/ui/iconComponents",
98
+ "@features": "./src/features",
99
+ "@assets": "./assets",
100
+ },
101
+ });
102
+ // ---- Install + verify ----
103
+ await installNativeDeps(target.dir, answers, { flagsOk, workletsPkg });
104
+ const lockOutcome = await ensureLockfile(target.dir, answers, { probePass, flagsOk });
105
+ // ---- Success ----
106
+ const cmdPm = lockOutcome.producedPm === "yarn" ? "yarn" : "npm run";
107
+ log.success(`Project ready at ${target.dir}`);
108
+ log.raw("");
109
+ log.raw(` cd ${path.relative(process.cwd(), target.dir) || target.name}`);
110
+ log.raw(` npx expo prebuild`);
111
+ log.raw(` ${cmdPm} ios # or: ${cmdPm} android`);
112
+ log.raw("");
113
+ log.info("First-time dev-client build details: README → 'First-time dev-client build'.");
114
+ }
115
+ main().catch((err) => {
116
+ log.error(err instanceof Error ? err.message : String(err));
117
+ log.warn("If the failure is mid-patch, the target dir may be in an inconsistent state. " +
118
+ "Re-run the CLI (patches are idempotent and will converge), or `rm -rf <path>` and start fresh.");
119
+ process.exit(1);
120
+ });