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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/cli.js +9 -0
- package/dist/babel.js +198 -0
- package/dist/bootstrap.js +54 -0
- package/dist/fonts.js +98 -0
- package/dist/index.js +120 -0
- package/dist/install.js +243 -0
- package/dist/overlay.js +141 -0
- package/dist/patch.js +212 -0
- package/dist/prompts.js +115 -0
- package/dist/scaffold.js +43 -0
- package/dist/sdkNotes.js +19 -0
- package/dist/util.js +48 -0
- package/package.json +56 -0
- package/templates/base/assets/fonts/.gitkeep +0 -0
- package/templates/base/assets/images/.gitkeep +0 -0
- package/templates/base/assets/index.ts +8 -0
- package/templates/base/src/app/_layout.tsx +39 -0
- package/templates/base/src/app/index.tsx +13 -0
- package/templates/base/src/app/routes.tsx +9 -0
- package/templates/base/src/core/hooks/.gitkeep +0 -0
- package/templates/base/src/core/redux/hooks.ts +6 -0
- package/templates/base/src/core/redux/mmkvStorage.ts +18 -0
- package/templates/base/src/core/redux/reducers.ts +20 -0
- package/templates/base/src/core/redux/slices/userSlice.ts +31 -0
- package/templates/base/src/core/redux/store.ts +28 -0
- package/templates/base/src/core/services/.gitkeep +0 -0
- package/templates/base/src/core/tanstack/index.tsx +93 -0
- package/templates/base/src/core/tanstack/tanstack-keys.ts +42 -0
- package/templates/base/src/core/utils/config.ts +50 -0
- package/templates/base/src/core/utils/constants.ts +11 -0
- package/templates/base/src/core/utils/endpoints.ts +29 -0
- package/templates/base/src/core/utils/types.ts +47 -0
- package/templates/base/src/core/utils/validation.ts +10 -0
- package/templates/base/src/features/.gitkeep +0 -0
- package/templates/base/src/ui/appComponents/AppRefreshControl/index.tsx +39 -0
- package/templates/base/src/ui/appComponents/appButton/index.tsx +122 -0
- package/templates/base/src/ui/appComponents/appColumnView/index.tsx +320 -0
- package/templates/base/src/ui/appComponents/appFlashList/index.tsx +191 -0
- package/templates/base/src/ui/appComponents/appFlatList/index.tsx +172 -0
- package/templates/base/src/ui/appComponents/appIcon/index.tsx +105 -0
- package/templates/base/src/ui/appComponents/appInput/index.tsx +226 -0
- package/templates/base/src/ui/appComponents/appKeyboardAvoidingView/index.tsx +168 -0
- package/templates/base/src/ui/appComponents/appKeyboardAwareScrollView/index.tsx +188 -0
- package/templates/base/src/ui/appComponents/appLogger/index.tsx +22 -0
- package/templates/base/src/ui/appComponents/appPressable/index.tsx +220 -0
- package/templates/base/src/ui/appComponents/appRowView/index.tsx +320 -0
- package/templates/base/src/ui/appComponents/appSafeAreaInsets/index.tsx +24 -0
- package/templates/base/src/ui/appComponents/appScrollView/index.tsx +166 -0
- package/templates/base/src/ui/appComponents/appSkeleton/index.tsx +22 -0
- package/templates/base/src/ui/appComponents/appStatusBar/index.tsx +8 -0
- package/templates/base/src/ui/appComponents/appTabHeader/index.tsx +111 -0
- package/templates/base/src/ui/appComponents/appText/index.tsx +258 -0
- package/templates/base/src/ui/appComponents/appTextWrapper/index.tsx +84 -0
- package/templates/base/src/ui/appComponents/appWrapper/index.tsx +56 -0
- package/templates/base/src/ui/appComponents/customActivityIndicator/index.tsx +34 -0
- package/templates/base/src/ui/appComponents/customGetPermissionModal/index.tsx +62 -0
- package/templates/base/src/ui/appComponents/customModal/index.tsx +26 -0
- package/templates/base/src/ui/appComponents/customTextInput/index.tsx +143 -0
- package/templates/base/src/ui/components/.gitkeep +0 -0
- package/templates/base/src/ui/components/avatarBlock/index.tsx +42 -0
- package/templates/base/src/ui/components/backgroundGradient/index.tsx +24 -0
- package/templates/base/src/ui/components/errorFallback/index.tsx +40 -0
- package/templates/base/src/ui/iconComponents/IconFontAwesome6/index.tsx +22 -0
- package/templates/base/src/ui/iconComponents/IoniconsIcons/index.tsx +33 -0
- package/templates/base/src/ui/iconComponents/antDesignicons/index.tsx +22 -0
- package/templates/base/src/ui/iconComponents/featherIcons/index.tsx +22 -0
- package/templates/base/src/ui/iconComponents/materialCommunityIcons/index.tsx +24 -0
- package/templates/base/src/ui/iconComponents/materialIcons/index.tsx +23 -0
- package/templates/base/src/ui/iconComponents/octiconsIcons/index.tsx +23 -0
- package/templates/base/src/ui/theme/allFileStyles.ts +39 -0
- package/templates/base/src/ui/theme/colors.ts +54 -0
- package/templates/base/src/ui/theme/fonts.ts +3 -0
- package/templates/base/src/ui/theme/responsive.ts +27 -0
- package/templates/bottom-sheet/src/ui/appComponents/BottomSheetKeyboardAwareScrollView/index.tsx +111 -0
- package/templates/bottom-sheet/src/ui/appComponents/appBottomSheetBackdrop/index.tsx +27 -0
- package/templates/bottom-sheet/src/ui/appComponents/appBottomSheetScrollView/index.tsx +99 -0
- package/templates/bottom-sheet/src/ui/appComponents/appBottomSheetView/index.tsx +173 -0
- package/templates/bottom-sheet/src/ui/appComponents/customBottomSheetModal/index.tsx +17 -0
- package/templates/claude-command/init-app.md +41 -0
- package/templates/image-picker/media-constants.snippet.ts +18 -0
- package/templates/image-picker/src/core/services/PermissionService.ts +48 -0
package/dist/install.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Phase 7 step 2 — `installNativeDeps` + `retryWithIsolation`.
|
|
2
|
+
// Phase 8 step 1 — `ensureLockfile`.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execa } from "execa";
|
|
6
|
+
import { log } from "./util.js";
|
|
7
|
+
// ---------- always-installed list (SPEC §7 + Deviation #4) ----------
|
|
8
|
+
export function buildAlwaysInstalledList(workletsPkg) {
|
|
9
|
+
return [
|
|
10
|
+
// From plan §7
|
|
11
|
+
"@reduxjs/toolkit",
|
|
12
|
+
"react-redux",
|
|
13
|
+
"redux-persist",
|
|
14
|
+
"react-native-mmkv",
|
|
15
|
+
"@tanstack/react-query",
|
|
16
|
+
"axios",
|
|
17
|
+
"formik",
|
|
18
|
+
"yup",
|
|
19
|
+
"expo-router",
|
|
20
|
+
"expo-dev-client",
|
|
21
|
+
"react-native-safe-area-context",
|
|
22
|
+
"react-native-gesture-handler",
|
|
23
|
+
"react-native-screens",
|
|
24
|
+
"react-native-reanimated",
|
|
25
|
+
workletsPkg, // react-native-worklets or -core per Phase 0 Probe 3
|
|
26
|
+
"react-native-keyboard-controller",
|
|
27
|
+
"react-error-boundary",
|
|
28
|
+
"react-native-responsive-fontsize",
|
|
29
|
+
"@expo/vector-icons",
|
|
30
|
+
"@shopify/flash-list",
|
|
31
|
+
// Deviation #4 (docs/MIRROR_NOTES.md) — needed by mirrored components
|
|
32
|
+
"expo-device",
|
|
33
|
+
"expo-image",
|
|
34
|
+
"expo-font",
|
|
35
|
+
"expo-linear-gradient",
|
|
36
|
+
"react-native-logs",
|
|
37
|
+
"react-native-reanimated-skeleton",
|
|
38
|
+
// Deviation #7 — Phase 7 patchBabel injects ["module-resolver", { alias }];
|
|
39
|
+
// Metro fails on first bundle if the plugin pkg isn't installed.
|
|
40
|
+
"babel-plugin-module-resolver",
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
export function buildConditionalDeps(answers) {
|
|
44
|
+
const out = [];
|
|
45
|
+
if (answers.bottomSheet)
|
|
46
|
+
out.push("@gorhom/bottom-sheet");
|
|
47
|
+
if (answers.imagePicker)
|
|
48
|
+
out.push("expo-image-picker");
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
// ---------- error classification ----------
|
|
52
|
+
const TRANSIENT_MARKERS = [
|
|
53
|
+
"ETIMEDOUT",
|
|
54
|
+
"ECONNRESET",
|
|
55
|
+
"ENOTFOUND",
|
|
56
|
+
"EAI_AGAIN",
|
|
57
|
+
"socket hang up",
|
|
58
|
+
"network",
|
|
59
|
+
"registry returned 5",
|
|
60
|
+
"503",
|
|
61
|
+
"502",
|
|
62
|
+
];
|
|
63
|
+
export function isTransientError(stderr) {
|
|
64
|
+
const lower = stderr.toLowerCase();
|
|
65
|
+
return TRANSIENT_MARKERS.some((m) => lower.includes(m.toLowerCase()));
|
|
66
|
+
}
|
|
67
|
+
const FAILING_DEP_PATTERNS = [
|
|
68
|
+
/(?:Cannot find|Could not resolve|No matching version for|Conflicting peer dependency for)\s+["']?([@\w/.-]+)["']?/i,
|
|
69
|
+
/version resolution failed for\s+["']?([@\w/.-]+)["']?/i,
|
|
70
|
+
];
|
|
71
|
+
/**
|
|
72
|
+
* Extract the name of the failing dep from Expo CLI stderr. Returns the name
|
|
73
|
+
* only if it matches one of `remaining` (defends against false positives like
|
|
74
|
+
* package names mentioned in surrounding diagnostic context). Returns null on
|
|
75
|
+
* unparseable input.
|
|
76
|
+
*/
|
|
77
|
+
export function parseFailingDep(stderr, remaining) {
|
|
78
|
+
for (const re of FAILING_DEP_PATTERNS) {
|
|
79
|
+
const m = stderr.match(re);
|
|
80
|
+
if (m && remaining.includes(m[1]))
|
|
81
|
+
return m[1];
|
|
82
|
+
}
|
|
83
|
+
// Last-mention fallback: scan stderr for any `remaining` package name; return the last one mentioned.
|
|
84
|
+
// No `\b` boundary — scoped package names start with `@`, which is non-word so
|
|
85
|
+
// `\b@tanstack` would never match. Use `(^|[^@\w/-])` instead so we don't
|
|
86
|
+
// accidentally match a substring inside another package path.
|
|
87
|
+
let lastIdx = -1;
|
|
88
|
+
let lastDep = null;
|
|
89
|
+
for (const dep of remaining) {
|
|
90
|
+
const escaped = dep.replace(/[/\-^$.*+?()[\]{}|\\]/g, "\\$&");
|
|
91
|
+
const re = new RegExp(`(^|[^@\\w/-])${escaped}([^@\\w/-]|$)`, "g");
|
|
92
|
+
let match;
|
|
93
|
+
while ((match = re.exec(stderr)) !== null) {
|
|
94
|
+
if (match.index > lastIdx) {
|
|
95
|
+
lastIdx = match.index;
|
|
96
|
+
lastDep = dep;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return lastDep;
|
|
101
|
+
}
|
|
102
|
+
export async function retryWithIsolation(target, deps, pmFlag) {
|
|
103
|
+
let remaining = [...deps];
|
|
104
|
+
const failedTwice = new Set();
|
|
105
|
+
let lastStderr = "";
|
|
106
|
+
while (remaining.length > 0) {
|
|
107
|
+
const args = ["--yes", "expo", "install", ...remaining];
|
|
108
|
+
if (pmFlag)
|
|
109
|
+
args.push(pmFlag);
|
|
110
|
+
const result = await execa("npx", args, { cwd: target, reject: false });
|
|
111
|
+
if (result.exitCode === 0)
|
|
112
|
+
return;
|
|
113
|
+
lastStderr = result.stderr ?? "";
|
|
114
|
+
if (isTransientError(lastStderr)) {
|
|
115
|
+
throw new Error(`Network failure during expo install — check connection and retry. ` +
|
|
116
|
+
`Target dir preserved; re-run CLI to retry.\n${lastStderr}`);
|
|
117
|
+
}
|
|
118
|
+
const culprit = parseFailingDep(lastStderr, remaining);
|
|
119
|
+
if (!culprit) {
|
|
120
|
+
throw new Error(`Install failed with unparseable stderr:\n${lastStderr}`);
|
|
121
|
+
}
|
|
122
|
+
if (failedTwice.has(culprit)) {
|
|
123
|
+
throw new Error(`'${culprit}' failed twice; aborting.\n${lastStderr}`);
|
|
124
|
+
}
|
|
125
|
+
failedTwice.add(culprit);
|
|
126
|
+
remaining = remaining.filter((d) => d !== culprit);
|
|
127
|
+
log.warn(`expo install: dropping '${culprit}' and retrying remaining ${remaining.length}`);
|
|
128
|
+
}
|
|
129
|
+
if (failedTwice.size > 0) {
|
|
130
|
+
throw new Error(`Could not install: ${[...failedTwice].join(", ")}\n${lastStderr}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Phase 7 step 2 — single `expo install` call (with PM-flag or pre-seeded
|
|
135
|
+
* lockfile per Phase 0 probe outcome). On atomic failure, falls back to
|
|
136
|
+
* `retryWithIsolation` for deterministic errors only.
|
|
137
|
+
*/
|
|
138
|
+
export async function installNativeDeps(target, answers, opts) {
|
|
139
|
+
const allDeps = [
|
|
140
|
+
...buildAlwaysInstalledList(opts.workletsPkg),
|
|
141
|
+
...buildConditionalDeps(answers),
|
|
142
|
+
];
|
|
143
|
+
// Branch A: pass --yarn / --npm flag.
|
|
144
|
+
// Branch B: pre-seed lockfile, run without flag.
|
|
145
|
+
let pmFlag = null;
|
|
146
|
+
if (opts.flagsOk) {
|
|
147
|
+
pmFlag = answers.packageManager === "yarn" ? "--yarn" : "--npm";
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
seedLockfile(target, answers.packageManager);
|
|
151
|
+
}
|
|
152
|
+
// Pre-install: materialize `expo` package so `expo install` can read SDK
|
|
153
|
+
// version. create-expo-app was invoked with `--no-install`, so node_modules
|
|
154
|
+
// is empty at this point. `expo install` itself shells out to `expo` (a
|
|
155
|
+
// local module) and refuses to run without it.
|
|
156
|
+
const pmCmd = answers.packageManager === "yarn" ? "yarn" : "npm";
|
|
157
|
+
log.step(`Materializing template deps via ${pmCmd} install…`);
|
|
158
|
+
await execa(pmCmd, ["install"], { cwd: target, stdio: "inherit" });
|
|
159
|
+
log.step(`Installing ${allDeps.length} native deps via expo install (${answers.packageManager})…`);
|
|
160
|
+
const args = ["--yes", "expo", "install", ...allDeps];
|
|
161
|
+
if (pmFlag)
|
|
162
|
+
args.push(pmFlag);
|
|
163
|
+
const result = await execa("npx", args, {
|
|
164
|
+
cwd: target,
|
|
165
|
+
stdio: "inherit",
|
|
166
|
+
reject: false,
|
|
167
|
+
});
|
|
168
|
+
if (result.exitCode !== 0) {
|
|
169
|
+
// stdio: inherit means we don't have stderr captured. Re-run silently to capture.
|
|
170
|
+
const verbose = await execa("npx", args, { cwd: target, reject: false });
|
|
171
|
+
const stderr = verbose.stderr ?? "";
|
|
172
|
+
if (isTransientError(stderr)) {
|
|
173
|
+
throw new Error(`Network failure during expo install — check connection and retry. ` +
|
|
174
|
+
`Target dir preserved; re-run CLI to retry.\n${stderr}`);
|
|
175
|
+
}
|
|
176
|
+
log.warn("expo install failed atomically — falling back to retryWithIsolation");
|
|
177
|
+
await retryWithIsolation(target, allDeps, pmFlag);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Phase 7 step 2 Branch B — pre-seed the chosen PM's lockfile so Expo CLI
|
|
182
|
+
* auto-detects PM from lockfile presence (when `--yarn`/`--npm` flag is absent
|
|
183
|
+
* in installed @expo/cli).
|
|
184
|
+
*/
|
|
185
|
+
export function seedLockfile(target, pm) {
|
|
186
|
+
if (pm === "yarn") {
|
|
187
|
+
const p = path.join(target, "yarn.lock");
|
|
188
|
+
if (!fs.existsSync(p))
|
|
189
|
+
fs.writeFileSync(p, "");
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
const p = path.join(target, "package-lock.json");
|
|
193
|
+
if (!fs.existsSync(p)) {
|
|
194
|
+
const minimalNpmLock = {
|
|
195
|
+
name: path.basename(target),
|
|
196
|
+
version: "1.0.0",
|
|
197
|
+
lockfileVersion: 3,
|
|
198
|
+
requires: true,
|
|
199
|
+
packages: {},
|
|
200
|
+
};
|
|
201
|
+
fs.writeFileSync(p, JSON.stringify(minimalNpmLock, null, 2) + "\n");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
export async function ensureLockfile(target, answers, opts) {
|
|
206
|
+
if (!opts.probePass) {
|
|
207
|
+
const installCmd = answers.packageManager === "yarn" ? "yarn" : "npm";
|
|
208
|
+
log.step(`Phase 0 probe indicated explicit ${installCmd} install needed; running…`);
|
|
209
|
+
await execa(installCmd, ["install"], { cwd: target, stdio: "inherit" });
|
|
210
|
+
}
|
|
211
|
+
const yarnLock = path.join(target, "yarn.lock");
|
|
212
|
+
const npmLock = path.join(target, "package-lock.json");
|
|
213
|
+
const yarnExists = fs.existsSync(yarnLock);
|
|
214
|
+
const npmExists = fs.existsSync(npmLock);
|
|
215
|
+
if (!yarnExists && !npmExists) {
|
|
216
|
+
throw new Error("ensureLockfile: install completed but no lockfile present. " +
|
|
217
|
+
"expo install may have failed silently — check stderr above.");
|
|
218
|
+
}
|
|
219
|
+
if (yarnExists && npmExists) {
|
|
220
|
+
if (opts.flagsOk) {
|
|
221
|
+
throw new Error("Both yarn.lock and package-lock.json present after expo install --<pm> succeeded. " +
|
|
222
|
+
"Installed Expo CLI version is not honoring the PM flag. " +
|
|
223
|
+
"File an issue with Expo CLI version (npx expo --version) + this CLI's version.");
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
throw new Error("Both yarn.lock and package-lock.json present after lockfile pre-seed. " +
|
|
227
|
+
"expo install created the other PM's lockfile, ignoring the seed. " +
|
|
228
|
+
`Re-run with EXPO_PACKAGE_MANAGER=${answers.packageManager === "yarn" ? "npm" : "yarn"} as a workaround; file an issue.`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const producedPm = yarnExists ? "yarn" : "npm";
|
|
232
|
+
const mismatch = producedPm !== answers.packageManager;
|
|
233
|
+
if (mismatch) {
|
|
234
|
+
const other = answers.packageManager === "yarn" ? "package-lock.json" : "yarn.lock";
|
|
235
|
+
log.warn(`⚠️ PM MISMATCH: requested ${answers.packageManager} but Expo CLI produced ${other}.\n` +
|
|
236
|
+
`Installed Expo CLI version likely ignored the PM flag/seed.\n` +
|
|
237
|
+
`Recovery options:\n` +
|
|
238
|
+
` (1) Accept the produced PM — your project is functional.\n` +
|
|
239
|
+
` (2) Re-run with EXPO_PACKAGE_MANAGER=${producedPm} to align config with reality.\n` +
|
|
240
|
+
` (3) Delete ${other}, then run \`${answers.packageManager} install\` manually.`);
|
|
241
|
+
}
|
|
242
|
+
return { producedPm, mismatch };
|
|
243
|
+
}
|
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Phase 4 overlay engine — sentinel matcher, alias rewriter, template copier.
|
|
2
|
+
// Sentinel rules per PLAN_V5.md Phase 4 "Placeholder conventions".
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import fse from "fs-extra";
|
|
6
|
+
// ---------- Sentinels ----------
|
|
7
|
+
/** Module-position sentinel: own line, line-comment form. */
|
|
8
|
+
export const MODULE_SENTINEL = /^\s*\/\/\s*@@([A-Z_]+)@@\s*$/;
|
|
9
|
+
/** JSX-position sentinel: own line, JSX expression containing a block comment. */
|
|
10
|
+
export const JSX_SENTINEL = /^\s*\{\/\*\s*@@([A-Z_]+)@@\s*\*\/\}\s*$/;
|
|
11
|
+
/** Same shape, anywhere on the line — used to catch malformed/orphan tokens. */
|
|
12
|
+
export const ORPHAN_PROBE = /@@[A-Z_]+@@/;
|
|
13
|
+
/**
|
|
14
|
+
* Replace whole-line sentinels in `source` per `replacements`.
|
|
15
|
+
*
|
|
16
|
+
* Rules:
|
|
17
|
+
* - A line matching MODULE_SENTINEL OR JSX_SENTINEL is replaced with the
|
|
18
|
+
* corresponding string from `replacements`. If the replacement is empty,
|
|
19
|
+
* the line is dropped entirely (no orphan blank).
|
|
20
|
+
* - A line containing `@@[A-Z_]+@@` that matches NEITHER regex (e.g. inline
|
|
21
|
+
* sentinel, mismatched opener/closer, or sentinel-not-owning-line) MUST
|
|
22
|
+
* throw — these are malformed and would silently leak past Phase 6 audit.
|
|
23
|
+
* - Any token in `replacements` whose key never appears throws — catches
|
|
24
|
+
* typos / renames in the sentinel list.
|
|
25
|
+
*/
|
|
26
|
+
export function applySentinels(source, replacements, filePath) {
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const line of source.split("\n")) {
|
|
30
|
+
const moduleMatch = line.match(MODULE_SENTINEL);
|
|
31
|
+
const jsxMatch = line.match(JSX_SENTINEL);
|
|
32
|
+
if (moduleMatch || jsxMatch) {
|
|
33
|
+
const token = (moduleMatch ?? jsxMatch)[1];
|
|
34
|
+
if (!(token in replacements)) {
|
|
35
|
+
throw new Error(`Sentinel @@${token}@@ in ${filePath} has no replacement provided.`);
|
|
36
|
+
}
|
|
37
|
+
seen.add(token);
|
|
38
|
+
const replacement = replacements[token];
|
|
39
|
+
if (replacement === "") {
|
|
40
|
+
// Drop the whole line — no orphan blank.
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
out.push(replacement);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ORPHAN_PROBE.test(line)) {
|
|
47
|
+
throw new Error(`Malformed sentinel in ${filePath} (not whole-line, opener↔closer mismatched, ` +
|
|
48
|
+
`or inline inside live code): ${line.trim()}`);
|
|
49
|
+
}
|
|
50
|
+
out.push(line);
|
|
51
|
+
}
|
|
52
|
+
// Optional: warn on unused replacement keys (typo guard). Keep silent here —
|
|
53
|
+
// some templates legitimately use a subset of the global sentinel map.
|
|
54
|
+
void seen;
|
|
55
|
+
return out.join("\n");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Module-reference forms we rewrite. Plan v5-r6: extended beyond `from "x"`
|
|
59
|
+
* to cover dynamic import + require + jest.mock so future mirrored files
|
|
60
|
+
* (any form) don't silently miss.
|
|
61
|
+
*/
|
|
62
|
+
const SPECIFIER_FORMS = [
|
|
63
|
+
/(\bfrom\s+)(["'])([^"']+)(\2)/g,
|
|
64
|
+
/(\bimport\s*\(\s*)(["'])([^"']+)(\2)/g,
|
|
65
|
+
/(\brequire\s*\(\s*)(["'])([^"']+)(\2)/g,
|
|
66
|
+
/(\bjest\.mock\s*\(\s*)(["'])([^"']+)(\2)/g,
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Rewrite import specifiers per `aliasMap`.
|
|
70
|
+
*
|
|
71
|
+
* Iteration order: longest source prefix first (so a hypothetical short prefix
|
|
72
|
+
* like `@/` doesn't consume a longer-specific one like `@/assets`).
|
|
73
|
+
*
|
|
74
|
+
* Idempotent: applying `(@x → @x)` is a no-op; applying twice yields same result.
|
|
75
|
+
*/
|
|
76
|
+
export function rewriteImports(source, aliasMap) {
|
|
77
|
+
const sortedEntries = Object.entries(aliasMap).sort((a, b) => b[0].length - a[0].length);
|
|
78
|
+
function rewriteSpecifier(spec) {
|
|
79
|
+
for (const [from, to] of sortedEntries) {
|
|
80
|
+
if (spec === from || spec.startsWith(from)) {
|
|
81
|
+
return to + spec.slice(from.length);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return spec;
|
|
85
|
+
}
|
|
86
|
+
let out = source;
|
|
87
|
+
for (const re of SPECIFIER_FORMS) {
|
|
88
|
+
out = out.replace(re, (_match, head, openQuote, spec, closeQuote) => {
|
|
89
|
+
return `${head}${openQuote}${rewriteSpecifier(spec)}${closeQuote}`;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
// ---------- Copying ----------
|
|
95
|
+
/**
|
|
96
|
+
* Recursively copy `srcRoot` → `destRoot`. Idempotent: existing files at dest
|
|
97
|
+
* are overwritten (we own the template overlay, not the user).
|
|
98
|
+
*/
|
|
99
|
+
export function copyTemplate(srcRoot, destRoot) {
|
|
100
|
+
if (!fs.existsSync(srcRoot)) {
|
|
101
|
+
throw new Error(`copyTemplate: source missing: ${srcRoot}`);
|
|
102
|
+
}
|
|
103
|
+
fse.copySync(srcRoot, destRoot, { overwrite: true });
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Apply `templates/base/` overlay to `target`.
|
|
107
|
+
*
|
|
108
|
+
* `templates/base/` lives inside this CLI package. `target` is the user's
|
|
109
|
+
* generated app dir (post-create-expo-app + cleanupBlankTemplate).
|
|
110
|
+
*/
|
|
111
|
+
export function applyBase(target, templatesRoot) {
|
|
112
|
+
const baseDir = path.join(templatesRoot, "base");
|
|
113
|
+
copyTemplate(baseDir, target);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Phase 5 step 3 — overlay the 5 bottom-sheet appComponents on top of base.
|
|
117
|
+
* No-op when answers.bottomSheet === false (caller checks before invoking).
|
|
118
|
+
*/
|
|
119
|
+
export function applyBottomSheet(target, templatesRoot) {
|
|
120
|
+
const bsDir = path.join(templatesRoot, "bottom-sheet");
|
|
121
|
+
if (!fs.existsSync(bsDir))
|
|
122
|
+
return;
|
|
123
|
+
copyTemplate(bsDir, target);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Phase 5 step 3 — overlay PermissionService.ts when answers.imagePicker. The
|
|
127
|
+
* media-constants snippet is spliced separately by `patchConstants` (Phase 5
|
|
128
|
+
* step 4) — this overlay only copies the service file.
|
|
129
|
+
*/
|
|
130
|
+
export function applyImagePicker(target, templatesRoot) {
|
|
131
|
+
const ipDir = path.join(templatesRoot, "image-picker");
|
|
132
|
+
if (!fs.existsSync(ipDir))
|
|
133
|
+
return;
|
|
134
|
+
// Copy the `src/` subtree only — the snippet file lives at the root of
|
|
135
|
+
// templates/image-picker/ for `patchConstants` to read directly, and we
|
|
136
|
+
// don't want it ending up in the generated project.
|
|
137
|
+
const srcSubtree = path.join(ipDir, "src");
|
|
138
|
+
if (fs.existsSync(srcSubtree)) {
|
|
139
|
+
copyTemplate(srcSubtree, path.join(target, "src"));
|
|
140
|
+
}
|
|
141
|
+
}
|
package/dist/patch.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Patcher set per PLAN_V5.md Phases 4 (app.json, expo-router entry),
|
|
2
|
+
// 5 (constants, app.json plugins), 6 (layout/fonts sentinels), 7 (package.json
|
|
3
|
+
// scripts, tsconfig, babel.config).
|
|
4
|
+
//
|
|
5
|
+
// Patches MUST be idempotent — re-running the CLI on the same target after a
|
|
6
|
+
// mid-phase failure must converge (Phase 7 "Cross-cutting" subsection).
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import fse from "fs-extra";
|
|
10
|
+
import { applySentinels } from "./overlay.js";
|
|
11
|
+
import { fileExists, log } from "./util.js";
|
|
12
|
+
// ---------- helpers ----------
|
|
13
|
+
/** Convert a free-form app name to an Expo `scheme` (lower-kebab-case, alnum + `-`). */
|
|
14
|
+
export function slugify(name) {
|
|
15
|
+
return (name
|
|
16
|
+
.normalize("NFKD")
|
|
17
|
+
.replace(/[̀-ͯ]/g, "") // strip diacritics
|
|
18
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
19
|
+
.replace(/^-+|-+$/g, "")
|
|
20
|
+
.toLowerCase() || "app");
|
|
21
|
+
}
|
|
22
|
+
function readJson(p) {
|
|
23
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
function writeJson(p, value) {
|
|
26
|
+
fs.writeFileSync(p, JSON.stringify(value, null, 2) + "\n");
|
|
27
|
+
}
|
|
28
|
+
const nameOf = (entry) => Array.isArray(entry) ? String(entry[0]) : String(entry);
|
|
29
|
+
/**
|
|
30
|
+
* Phase 4 step 7 — set `expo.name`, `expo.slug`, `expo.scheme = slugify(name)`,
|
|
31
|
+
* add `"expo-router"` to `expo.plugins` if missing. Uses `nameOf` equality
|
|
32
|
+
* predicate so a user-customized `["expo-router", {...options}]` entry is
|
|
33
|
+
* preserved (matches Phase 5 step 5).
|
|
34
|
+
*
|
|
35
|
+
* `expo.name` + `expo.slug` are also set by create-expo-app from the positional
|
|
36
|
+
* dir arg — we set them again here (defense in depth) so the values are
|
|
37
|
+
* guaranteed even if upstream changes its naming heuristic.
|
|
38
|
+
*/
|
|
39
|
+
export function patchAppJson(target, name, _answers) {
|
|
40
|
+
const p = path.join(target, "app.json");
|
|
41
|
+
if (!fileExists(p)) {
|
|
42
|
+
throw new Error(`patchAppJson: ${p} not found.`);
|
|
43
|
+
}
|
|
44
|
+
const json = readJson(p);
|
|
45
|
+
json.expo ??= {};
|
|
46
|
+
json.expo.name = name;
|
|
47
|
+
json.expo.slug = slugify(name);
|
|
48
|
+
json.expo.scheme = slugify(name);
|
|
49
|
+
json.expo.plugins ??= [];
|
|
50
|
+
if (!json.expo.plugins.some((e) => nameOf(e) === "expo-router")) {
|
|
51
|
+
json.expo.plugins.push("expo-router");
|
|
52
|
+
}
|
|
53
|
+
writeJson(p, json);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Phase 5 step 5 — add image-picker plugin entry conditionally. Idempotent via
|
|
57
|
+
* `nameOf` equality (user-customized options object preserved).
|
|
58
|
+
*/
|
|
59
|
+
export function patchAppJsonPlugins(target, answers) {
|
|
60
|
+
if (!answers.imagePicker)
|
|
61
|
+
return;
|
|
62
|
+
const p = path.join(target, "app.json");
|
|
63
|
+
const json = readJson(p);
|
|
64
|
+
json.expo ??= {};
|
|
65
|
+
json.expo.plugins ??= [];
|
|
66
|
+
const entry = [
|
|
67
|
+
"expo-image-picker",
|
|
68
|
+
{
|
|
69
|
+
photosPermission: "The app accesses your photos to let you share them with your friends.",
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
if (!json.expo.plugins.some((e) => nameOf(e) === nameOf(entry))) {
|
|
73
|
+
json.expo.plugins.push(entry);
|
|
74
|
+
}
|
|
75
|
+
writeJson(p, json);
|
|
76
|
+
}
|
|
77
|
+
// ---------- expo-router entry + tsconfig.extends ----------
|
|
78
|
+
/**
|
|
79
|
+
* Phase 4 step 8 — set `package.json#main` to `expo-router/entry` + ensure
|
|
80
|
+
* tsconfig extends `expo/tsconfig.base`.
|
|
81
|
+
*/
|
|
82
|
+
export function patchExpoRouterEntry(target) {
|
|
83
|
+
const pkgPath = path.join(target, "package.json");
|
|
84
|
+
const pkg = readJson(pkgPath);
|
|
85
|
+
pkg.main = "expo-router/entry";
|
|
86
|
+
writeJson(pkgPath, pkg);
|
|
87
|
+
const tsPath = path.join(target, "tsconfig.json");
|
|
88
|
+
if (fileExists(tsPath)) {
|
|
89
|
+
const ts = readJson(tsPath);
|
|
90
|
+
if (!ts.extends)
|
|
91
|
+
ts.extends = "expo/tsconfig.base";
|
|
92
|
+
writeJson(tsPath, ts);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ---------- constants splice (Phase 5 step 4) ----------
|
|
96
|
+
/**
|
|
97
|
+
* Always runs — drops the `@@MEDIA_CONSTANTS@@` sentinel line cleanly even
|
|
98
|
+
* when `imagePicker === false` (fixes v3 orphan-sentinel bug). Splices the
|
|
99
|
+
* media-constants snippet on `imagePicker === true`.
|
|
100
|
+
*/
|
|
101
|
+
export function patchConstants(target, templatesRoot, answers) {
|
|
102
|
+
const p = path.join(target, "src/core/utils/constants.ts");
|
|
103
|
+
if (!fileExists(p)) {
|
|
104
|
+
throw new Error(`patchConstants: ${p} missing — was applyBase run first?`);
|
|
105
|
+
}
|
|
106
|
+
const source = fs.readFileSync(p, "utf8");
|
|
107
|
+
let replacement = "";
|
|
108
|
+
if (answers.imagePicker) {
|
|
109
|
+
const sp = path.join(templatesRoot, "image-picker/media-constants.snippet.ts");
|
|
110
|
+
replacement = fs.readFileSync(sp, "utf8").trimEnd();
|
|
111
|
+
}
|
|
112
|
+
const out = applySentinels(source, { MEDIA_CONSTANTS: replacement }, p);
|
|
113
|
+
fs.writeFileSync(p, out);
|
|
114
|
+
}
|
|
115
|
+
// ---------- layout + fonts splice (Phase 6 step 4) ----------
|
|
116
|
+
export function patchLayout(target, replacements) {
|
|
117
|
+
const layoutPath = path.join(target, "src/app/_layout.tsx");
|
|
118
|
+
const fontsPath = path.join(target, "src/ui/theme/fonts.ts");
|
|
119
|
+
for (const p of [layoutPath, fontsPath]) {
|
|
120
|
+
if (!fileExists(p)) {
|
|
121
|
+
throw new Error(`patchLayout: ${p} missing — was applyBase run first?`);
|
|
122
|
+
}
|
|
123
|
+
const before = fs.readFileSync(p, "utf8");
|
|
124
|
+
const after = applySentinels(before, replacements, p);
|
|
125
|
+
fs.writeFileSync(p, after);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// ---------- package.json scripts (Phase 7 step 1) ----------
|
|
129
|
+
export function patchPackageJsonScripts(target) {
|
|
130
|
+
const p = path.join(target, "package.json");
|
|
131
|
+
const pkg = readJson(p);
|
|
132
|
+
pkg.scripts ??= {};
|
|
133
|
+
const want = {
|
|
134
|
+
start: "expo start",
|
|
135
|
+
android: "expo start --android",
|
|
136
|
+
ios: "expo start --ios",
|
|
137
|
+
web: "expo start --web",
|
|
138
|
+
lint: "expo lint",
|
|
139
|
+
};
|
|
140
|
+
for (const [k, v] of Object.entries(want)) {
|
|
141
|
+
if (!(k in pkg.scripts))
|
|
142
|
+
pkg.scripts[k] = v;
|
|
143
|
+
}
|
|
144
|
+
writeJson(p, pkg);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* SPEC §9 path aliases.
|
|
148
|
+
* `@/*` is a catchall resolving to `src/*`; longer specifics map to their
|
|
149
|
+
* concrete dirs. Identity-mirrored from MyRoster naming so mirrored imports
|
|
150
|
+
* resolve unchanged in the generated app.
|
|
151
|
+
*/
|
|
152
|
+
const SPEC_PATHS = {
|
|
153
|
+
"@/*": ["src/*"],
|
|
154
|
+
"@theme/*": ["src/ui/theme/*"],
|
|
155
|
+
"@utils/*": ["src/core/utils/*"],
|
|
156
|
+
"@redux/*": ["src/core/redux/*"],
|
|
157
|
+
"@core/*": ["src/core/*"],
|
|
158
|
+
"@services/*": ["src/core/services/*"],
|
|
159
|
+
"@hooks/*": ["src/core/hooks/*"],
|
|
160
|
+
"@appComponents/*": ["src/ui/appComponents/*"],
|
|
161
|
+
"@components/*": ["src/ui/components/*"],
|
|
162
|
+
"@icons/*": ["src/ui/iconComponents/*"],
|
|
163
|
+
"@features/*": ["src/features/*"],
|
|
164
|
+
"@assets": ["assets"],
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Phase 7 step 3 — patch tsconfig:
|
|
168
|
+
* - Preserve `extends: "expo/tsconfig.base"`.
|
|
169
|
+
* - baseUrl resolution (3-tier):
|
|
170
|
+
* 1. user already set → preserve.
|
|
171
|
+
* 2. expo/tsconfig.base provides one (Phase 0 probe `EXPO_TSCONFIG_BASEURL` ≠ null) → no-op.
|
|
172
|
+
* 3. else → set "." here.
|
|
173
|
+
* - Deep-merge `compilerOptions.paths` with SPEC §9 aliases; preserve user's existing aliases.
|
|
174
|
+
* - Detect `@/*` collision (user already mapped `@/*` to non-`src/*` target) → warn + skip our `@/*`.
|
|
175
|
+
*
|
|
176
|
+
* `expoBaseUrlInherited` is read from Phase 0 SDK_NOTES at runtime by the caller.
|
|
177
|
+
*/
|
|
178
|
+
export function patchTsconfig(target, opts) {
|
|
179
|
+
const p = path.join(target, "tsconfig.json");
|
|
180
|
+
const ts = fileExists(p) ? readJson(p) : {};
|
|
181
|
+
if (!ts.extends)
|
|
182
|
+
ts.extends = "expo/tsconfig.base";
|
|
183
|
+
ts.compilerOptions ??= {};
|
|
184
|
+
// baseUrl 3-tier resolution.
|
|
185
|
+
const userBaseUrl = ts.compilerOptions.baseUrl;
|
|
186
|
+
if (!userBaseUrl && !opts.expoBaseUrlInherited) {
|
|
187
|
+
ts.compilerOptions.baseUrl = ".";
|
|
188
|
+
}
|
|
189
|
+
// paths deep-merge with collision detection.
|
|
190
|
+
ts.compilerOptions.paths ??= {};
|
|
191
|
+
for (const [alias, defaultTargets] of Object.entries(SPEC_PATHS)) {
|
|
192
|
+
if (alias in ts.compilerOptions.paths) {
|
|
193
|
+
// User-defined value — only warn on the `@/*` catchall collision case.
|
|
194
|
+
const existing = ts.compilerOptions.paths[alias];
|
|
195
|
+
if (alias === "@/*" && JSON.stringify(existing) !== JSON.stringify(defaultTargets)) {
|
|
196
|
+
log.warn(`tsconfig.json compilerOptions.paths["@/*"] already set to ${JSON.stringify(existing)}; preserving user value (template imports assume "src/*").`);
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
ts.compilerOptions.paths[alias] = defaultTargets;
|
|
201
|
+
}
|
|
202
|
+
writeJson(p, ts);
|
|
203
|
+
}
|
|
204
|
+
// ---------- babel.config.js (Phase 7 step 4) ----------
|
|
205
|
+
// Implemented in src/babel.ts to keep AST plumbing in one file.
|
|
206
|
+
// ---------- file copy helpers (for tests) ----------
|
|
207
|
+
/** Copy a single file into the target dir (preserving relative path). */
|
|
208
|
+
export function copyInto(target, srcAbs, relInside) {
|
|
209
|
+
const dst = path.join(target, relInside);
|
|
210
|
+
fse.ensureDirSync(path.dirname(dst));
|
|
211
|
+
fse.copySync(srcAbs, dst);
|
|
212
|
+
}
|