@flun/node-mobile-app 4.0.8 → 4.0.9

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/src/copy.js CHANGED
@@ -1,321 +1,321 @@
1
- import path from 'path';
2
- import fs from 'fs';
3
- import {
4
- ensureDir, removeDir, readJson, isExcluded, copyFileIfChanged, writeIfChanged, buildEslintConfigSource
5
- } from './utils.js';
6
- import { resolveSdkPath } from './config.js';
7
- import { createRequire } from 'module';
8
- import chalk from 'chalk';
9
-
10
- const BUILD_DIR_NAME = 'node-mobile-app-build',
11
- TEMPLATE_EXCLUDE = [
12
- 'build/', '.gradle/', '.cxx/', '.kotlin/',
13
- 'app/build/', 'app/.cxx/',
14
- 'Pods/', 'local.properties', '*.bak', 'gradle.properties.bak',
15
- ],
16
- /**
17
- * 内部:递归覆盖式复制模板,跳过 keepSet 命中的文件
18
- */
19
- copyDirOverwrite = (src, dst, exclude, buildDir, keepSet) => {
20
- ensureDir(dst);
21
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
22
- if (isExcluded(entry.name, exclude)) continue;
23
- const s = path.join(src, entry.name), d = path.join(dst, entry.name);
24
- if (entry.isDirectory()) copyDirOverwrite(s, d, exclude, buildDir, keepSet);
25
- else if (entry.isFile()) {
26
- const rel = path.relative(buildDir, d).replace(/\\/g, '/');
27
- if (keepSet.has(rel)) continue;
28
- copyFileIfChanged(s, d);
29
- }
30
- }
31
- },
32
- /**
33
- * 内部:增量复制用户项目到 nodejs-project
34
- * 保留 node_modules 等由其他步骤生成的产物
35
- */
36
- copyDirIncremental = (src, dst, exclude) => {
37
- ensureDir(dst);
38
-
39
- const dstKeep = new Set(['node_modules', 'main.js', 'package.json', 'package-lock.json']),
40
- srcNames = new Set(fs.readdirSync(src));
41
- // 1. 删除 dst 里不该存在的条目
42
- for (const name of fs.readdirSync(dst)) {
43
- if (dstKeep.has(name)) continue;
44
- if (!srcNames.has(name) || isExcluded(name, exclude)) removeDir(path.join(dst, name));
45
- }
46
- // 2. 增量复制
47
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
48
- if (isExcluded(entry.name, exclude)) continue;
49
- if (dstKeep.has(entry.name)) continue;
50
- const s = path.join(src, entry.name), d = path.join(dst, entry.name);
51
- if (entry.isDirectory()) copyDirIncremental(s, d, exclude);
52
- else if (entry.isFile()) copyFileIfChanged(s, d);
53
- }
54
- },
55
- /**
56
- * 获取 build 目录路径:<userProject>/node-mobile-app-build
57
- */
58
- getBuildDir = userProjectDir => path.join(userProjectDir, BUILD_DIR_NAME),
59
-
60
- /**
61
- * 把 buildDir/package.json 的依赖版本同步为主包 dependencies 的版本
62
- * - 依赖名单来自 buildDir/package.json(模板声明需要哪些依赖)
63
- * - 版本号来自 pkgRoot/package.json 的 dependencies(单一数据源)
64
- * - 若主包缺少某个依赖,抛错中断(避免模板占位值被真正使用)
65
- */
66
- syncTemplateDependencies = (pkgRoot, buildDir) => {
67
- const buildPkgPath = path.join(buildDir, 'package.json');
68
- if (!fs.existsSync(buildPkgPath)) return;
69
- const mainPkgPath = path.join(pkgRoot, 'package.json');
70
- if (!fs.existsSync(mainPkgPath)) return;
71
- const buildPkg = readJson(buildPkgPath), mainPkg = readJson(mainPkgPath);
72
- if (!buildPkg.dependencies) return;
73
-
74
- const mainDeps = mainPkg.dependencies || {}, missing = [];
75
- let depsUpdated = 0, needWrite = false;
76
-
77
- // 移除仅用于模板占位说明的 _comment 字段,避免带进最终 build 工程
78
- if ('_comment' in buildPkg) delete buildPkg._comment, needWrite = true;
79
- for (const name of Object.keys(buildPkg.dependencies)) {
80
- const v = mainDeps[name];
81
- if (!v) { missing.push(name); continue; }
82
- if (buildPkg.dependencies[name] !== v) buildPkg.dependencies[name] = v, depsUpdated++, needWrite = true;
83
- }
84
-
85
- if (needWrite) {
86
- writeIfChanged(buildPkgPath, JSON.stringify(buildPkg, null, 2) + '\n');
87
- if (depsUpdated > 0)
88
- console.log(chalk.green(' ✓ 已同步 ' + depsUpdated + ' 个依赖版本到 ' + BUILD_DIR_NAME + '/package.json'));
89
- }
90
-
91
- if (missing.length > 0) {
92
- throw new Error(
93
- 'template/package.json 声明的以下依赖在主包 dependencies 中不存在:\n' +
94
- ' ' + missing.join(', ') + '\n' +
95
- ' 请在 @flun/node-mobile-app 的 package.json 中补上对应声明(版本号随意,\n' +
96
- ' 但必须存在,以便 syncTemplateDependencies 覆盖模板里的占位值);'
97
- );
98
- }
99
- },
100
- /**
101
- * 把主包 node_modules 里被 npm 嵌套(未提升到用户根)的依赖,按需链接到用户根 node_modules 下,
102
- * 让 RN CLI / Metro 从用户根也能解析到它们;
103
- * - 判据:从用户根视角解析不到,才建链接;能解析则跳过;
104
- * - 范围:遍历主包 node_modules 顶层全部条目(含 @scope/ 下每个子项),不列举任何包名;
105
- * - 落点:只在用户根 node_modules 缺失条目时建 symlink,不覆盖已有内容、不碰 package.json / package-lock.json;
106
- * - 建链接失败:中断报错,不静默;
107
- */
108
- linkNestedDeps = (pkgRoot, buildDir) => {
109
- const pkgNm = path.join(pkgRoot, 'node_modules');
110
- if (!fs.existsSync(pkgNm)) return;
111
-
112
- const userProjectDir = path.dirname(buildDir), userNm = path.join(userProjectDir, 'node_modules'),
113
- req = createRequire(path.join(userProjectDir, 'index.js')), candidates = [];
114
- for (const entry of fs.readdirSync(pkgNm, { withFileTypes: true })) {
115
- if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
116
- if (entry.name.startsWith('@')) {
117
- const scopeDir = path.join(pkgNm, entry.name);
118
- for (const sub of fs.readdirSync(scopeDir, { withFileTypes: true }))
119
- if (sub.isDirectory()) candidates.push(entry.name + '/' + sub.name);
120
- } else candidates.push(entry.name);
121
- }
122
-
123
- let linked = 0;
124
- for (const name of candidates) {
125
- try { req.resolve(name); continue; } catch { /* 解析不到,进入兜底 */ }
126
- const src = path.join(pkgNm, name);
127
- if (!fs.existsSync(src)) continue;
128
- const realSrc = fs.realpathSync(src), dst = path.join(userNm, name);
129
- let occupied = false;
130
- try { fs.lstatSync(dst); occupied = true; } catch { /* 不存在 */ }
131
- if (occupied) continue;
132
- ensureDir(path.dirname(dst));
133
- try {
134
- fs.symlinkSync(realSrc, dst, 'junction'), linked++;
135
- } catch (err) {
136
- throw new Error(
137
- '建立兜底链接失败:' + dst + ' -> ' + realSrc + '\n' +
138
- ' 原因:' + err.message + '\n' +
139
- ' 请检查 node_modules 是否被其它程序占用后重试'
140
- );
141
- }
142
- }
143
- if (linked > 0) console.log(chalk.green(' ✓ 已兜底链接 ' + linked + ' 个嵌套依赖到用户根 node_modules'));
144
- },
145
- /**
146
- * 把 CLI 包里的 template/ 逐文件覆盖到 build 目录
147
- * - 只覆盖模板里存在的文件,不删除 buildDir 里已有的其他内容(保留 Gradle 增量缓存)
148
- * - 模板文件被改写时自动同步;新增文件自动添加
149
- * - 若模板里删除了文件,旧副本残留由 CLI 版本指纹触发全量重建处理
150
- */
151
- copyTemplate = (pkgRoot, buildDir, config) => {
152
- ensureDir(buildDir);
153
-
154
- const templateDir = path.join(pkgRoot, 'template');
155
- if (!fs.existsSync(templateDir)) throw new Error('CLI 包缺少 template/ 目录: ' + templateDir);
156
-
157
- const keepSet = new Set((config && config.keepFiles) || []);
158
- copyDirOverwrite(templateDir, buildDir, TEMPLATE_EXCLUDE, buildDir, keepSet);
159
- fs.writeFileSync(path.join(buildDir, 'eslint.config.js'), buildEslintConfigSource(), 'utf-8');
160
- syncTemplateDependencies(pkgRoot, buildDir);
161
- linkNestedDeps(pkgRoot, buildDir);
162
- console.log(chalk.green(' ✓ 模板已更新到 ' + BUILD_DIR_NAME + '/'));
163
- if (keepSet.size > 0) console.log(chalk.gray(' 保留文件: ' + Array.from(keepSet).join(', ')));
164
- },
165
- /**
166
- * 复制用户 Node 项目到 <buildDir>/nodejs-assets/nodejs-project
167
- * 复制后重写 package.json:只保留 dependencies,剔除 devDependencies;用 config 的 allowScripts
168
- */
169
- copyUserProject = (userProjectDir, buildDir, config) => {
170
- const targetDir = path.join(buildDir, 'nodejs-assets', 'nodejs-project');
171
- ensureDir(targetDir);
172
-
173
- const exclude = [
174
- ...config.excludeFiles, BUILD_DIR_NAME + '/',
175
- 'mobileAppConfig.js', 'mobileAppConfig.mjs', 'mobileAppConfig.cjs'
176
- ];
177
-
178
- // 增量复制:内容变化的文件才覆盖,保留 node_modules 和其他未变文件
179
- copyDirIncremental(userProjectDir, targetDir, exclude);
180
- // 黑名单式过滤:只删 devDependencies 和 allowScripts,其余字段原样保留
181
- const userPkgPath = path.join(userProjectDir, 'package.json');
182
- if (!fs.existsSync(userPkgPath)) throw new Error('用户项目缺少 package.json');
183
- const pkg = readJson(userPkgPath), out = { ...pkg };
184
- delete out.devDependencies;
185
- out.allowScripts =
186
- (config.allowScripts && Object.keys(config.allowScripts).length) ? config.allowScripts : { node: true };
187
- writeIfChanged(path.join(targetDir, 'package.json'), JSON.stringify(out, null, 2) + '\n');
188
-
189
- console.log(chalk.green(' ✓ 已同步用户项目到 nodejs-assets/nodejs-project'));
190
- console.log(chalk.gray(' 模块类型: ' + (out.type === 'module' ? 'ESM' : 'CJS')));
191
- console.log(chalk.gray(' 生产依赖: ' + Object.keys(out.dependencies || {}).length + ' 个'));
192
-
193
- return { pkg: out, moduleType: out.type === 'module' ? 'module' : 'commonjs', targetDir };
194
- },
195
- /**
196
- * 生成 <buildDir>/android/local.properties(Gradle 需要 sdk.dir)
197
- */
198
- writeLocalProperties = (buildDir, config) => {
199
- const sdkPath = resolveSdkPath(config);
200
- if (!sdkPath)
201
- return console.warn(chalk.yellow(' ⚠️ 未找到 Android SDK,请在 mobileAppConfig.js 里配置 android.sdkPath'));
202
-
203
- const localProps = path.join(buildDir, 'android', 'local.properties'),
204
- escaped = sdkPath.replace(/\\/g, '\\\\').replace(/:/g, '\\:');
205
- fs.writeFileSync(localProps, `sdk.dir=${escaped}\n`, 'utf-8');
206
- console.log(chalk.green(' ✓ 写入 sdk.dir: ' + sdkPath));
207
- },
208
- /**
209
- * 确保用户项目根存在 serverPath 指向的入口文件
210
- * 不存在时从 CLI 内置 cli-assets/server.js 复制一份
211
- */
212
- ensureUserEntry = (userProjectDir, config, pkgRoot) => {
213
- const entryPath = path.join(userProjectDir, config.serverPath);
214
- if (fs.existsSync(entryPath)) return { created: false, path: entryPath };
215
-
216
- const fallback = path.join(pkgRoot, 'cli-assets', 'server.js');
217
- if (!fs.existsSync(fallback)) throw new Error('入口文件不存在: ' + config.serverPath + '(CLI 内置模板也缺失)');
218
-
219
- ensureDir(path.dirname(entryPath)), fs.copyFileSync(fallback, entryPath);
220
- console.log(chalk.green(' ✓ 生成默认入口文件: ' + config.serverPath));
221
- console.log(chalk.gray(' (因为用户项目里没有这个文件,从 CLI 内置模板复制)'));
222
-
223
- return { created: true, path: entryPath };
224
- },
225
- /**
226
- * 确保用户项目根存在 mobileAppConfig.js
227
- * 不存在时从 CLI 包根复制一份(带注释的模板)
228
- */
229
- ensureUserConfig = (userProjectDir, pkgRoot) => {
230
- const configPath = path.join(userProjectDir, 'mobileAppConfig.js');
231
- if (fs.existsSync(configPath)) return { created: false, path: configPath };
232
-
233
- const fallback = path.join(pkgRoot, 'mobileAppConfig.js');
234
- if (!fs.existsSync(fallback)) throw new Error('CLI 缺少 mobileAppConfig.js 模板');
235
-
236
- fs.copyFileSync(fallback, configPath);
237
- console.log(chalk.green(' ✓ 生成默认配置文件: mobileAppConfig.js'));
238
- console.log(chalk.gray(' (因为用户项目里没有这个文件,从 CLI 内置模板复制)'));
239
-
240
- return { created: true, path: configPath };
241
- },
242
- /**
243
- * 按 config.android.abiFilters 修改 buildDir 里的 app/build.gradle:
244
- * - defaultConfig.ndk.abiFilters
245
- * - gradle.properties 的 reactNativeArchitectures
246
- * 插件 build.gradle 读的是 project(":app").android.defaultConfig.ndk.abiFilters,
247
- * 所以必须改这里,改插件自身的 abiFilters 无效;
248
- */
249
- patchAndroidAbi = (buildDir, config) => {
250
- const file = path.join(buildDir, 'android', 'app', 'build.gradle');
251
- if (!fs.existsSync(file)) return console.warn(chalk.yellow(' ⚠️ 未找到 app/build.gradle,跳过 ABI patch'));
252
-
253
- const abiList = (config.android.abiFilters || []).map((x) => `"${x}"`).join(', ');
254
- let c = fs.readFileSync(file, 'utf-8');
255
- const before = c;
256
-
257
- // defaultConfig 里的 ndk { abiFilters ... }
258
- c = c.replace(/(defaultConfig\s*\{[\s\S]*?ndk\s*\{\s*abiFilters\s+)("[^"]*"(?:\s*,\s*"[^"]*")*)/, `$1${abiList}`);
259
- // 兜底:没有 ndk {} 块时,插到 defaultConfig 里
260
- if (!/ndk\s*\{\s*abiFilters/.test(c))
261
- c = c.replace(/(defaultConfig\s*\{)/, `$1\n ndk {\n abiFilters ${abiList}\n }`);
262
- // 不 patch splits.abi.include:AGP 禁止它和 ndk.abiFilters 同时存在
263
- if (c !== before) writeIfChanged(file, c), console.log(chalk.green(` ✓ 已设置 app ndk.abiFilters = [${abiList}]`));
264
-
265
- // 同步 gradle.properties 的 reactNativeArchitectures
266
- const propsFile = path.join(buildDir, 'android', 'gradle.properties');
267
- if (fs.existsSync(propsFile)) {
268
- let p = fs.readFileSync(propsFile, 'utf-8');
269
- const abiPlain = (config.android.abiFilters || []).join(','), before2 = p;
270
- p = p.replace(/^reactNativeArchitectures=.*/gm, `reactNativeArchitectures=${abiPlain}`);
271
- if (p !== before2) writeIfChanged(propsFile, p),
272
- console.log(chalk.green(` ✓ 已设置 reactNativeArchitectures = ${abiPlain}`));
273
- }
274
- },
275
- /**
276
- * 把用户在 mobileAppConfig.js 里声明的 extraRnDependencies 注入 buildDir/package.json
277
- * 版本号从用户根 node_modules 读实际装的版本
278
- * 用户没装的会中断并提示安装命令
279
- */
280
- patchExtraRnDependencies = (buildDir, userProjectDir, config) => {
281
- const extras = config.extraRnDependencies || [];
282
- if (extras.length === 0) return;
283
-
284
- const buildPkgPath = path.join(buildDir, 'package.json');
285
- if (!fs.existsSync(buildPkgPath))
286
- return console.warn(chalk.yellow(' ⚠️ 未找到 buildDir/package.json,跳过 extraRnDependencies'));
287
-
288
- const buildPkg = readJson(buildPkgPath), missing = [];
289
- if (!buildPkg.dependencies) buildPkg.dependencies = {};
290
- let added = 0;
291
- for (const name of extras) {
292
- const pkgJson = path.join(userProjectDir, 'node_modules', name, 'package.json');
293
- if (!fs.existsSync(pkgJson)) {
294
- missing.push(name); continue;
295
- }
296
- try {
297
- const v = JSON.parse(fs.readFileSync(pkgJson, 'utf-8')).version;
298
- if (buildPkg.dependencies[name] !== v) buildPkg.dependencies[name] = v, added++;
299
- } catch { missing.push(name); }
300
- }
301
-
302
- if (added > 0) {
303
- writeIfChanged(buildPkgPath, JSON.stringify(buildPkg, null, 2) + '\n');
304
- console.log(chalk.green(` ✓ 已注入 ${added} 个额外 RN 依赖到 buildDir/package.json`));
305
- }
306
-
307
- if (missing.length > 0) {
308
- throw new Error(
309
- 'extraRnDependencies 里以下包未安装:\n' +
310
- ' ' + missing.join(', ') + '\n' +
311
- ' 请先在项目根执行:\n' +
312
- ' npm install ' + missing.join(' ') + '\n' +
313
- ' 然后重新运行 node-mobile-app test'
314
- );
315
- }
316
- };
317
-
318
- export {
319
- getBuildDir, copyTemplate, copyUserProject, writeLocalProperties, ensureUserEntry, ensureUserConfig, patchAndroidAbi,
320
- patchExtraRnDependencies
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import {
4
+ ensureDir, removeDir, readJson, isExcluded, copyFileIfChanged, writeIfChanged, buildEslintConfigSource
5
+ } from './utils.js';
6
+ import { resolveSdkPath } from './config.js';
7
+ import { createRequire } from 'module';
8
+ import chalk from 'chalk';
9
+
10
+ const BUILD_DIR_NAME = 'node-mobile-app-build',
11
+ TEMPLATE_EXCLUDE = [
12
+ 'build/', '.gradle/', '.cxx/', '.kotlin/',
13
+ 'app/build/', 'app/.cxx/',
14
+ 'Pods/', 'local.properties', '*.bak', 'gradle.properties.bak',
15
+ ],
16
+ /**
17
+ * 内部:递归覆盖式复制模板,跳过 keepSet 命中的文件
18
+ */
19
+ copyDirOverwrite = (src, dst, exclude, buildDir, keepSet) => {
20
+ ensureDir(dst);
21
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
22
+ if (isExcluded(entry.name, exclude)) continue;
23
+ const s = path.join(src, entry.name), d = path.join(dst, entry.name);
24
+ if (entry.isDirectory()) copyDirOverwrite(s, d, exclude, buildDir, keepSet);
25
+ else if (entry.isFile()) {
26
+ const rel = path.relative(buildDir, d).replace(/\\/g, '/');
27
+ if (keepSet.has(rel)) continue;
28
+ copyFileIfChanged(s, d);
29
+ }
30
+ }
31
+ },
32
+ /**
33
+ * 内部:增量复制用户项目到 nodejs-project
34
+ * 保留 node_modules 等由其他步骤生成的产物
35
+ */
36
+ copyDirIncremental = (src, dst, exclude) => {
37
+ ensureDir(dst);
38
+
39
+ const dstKeep = new Set(['node_modules', 'main.js', 'package.json', 'package-lock.json']),
40
+ srcNames = new Set(fs.readdirSync(src));
41
+ // 1. 删除 dst 里不该存在的条目
42
+ for (const name of fs.readdirSync(dst)) {
43
+ if (dstKeep.has(name)) continue;
44
+ if (!srcNames.has(name) || isExcluded(name, exclude)) removeDir(path.join(dst, name));
45
+ }
46
+ // 2. 增量复制
47
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
48
+ if (isExcluded(entry.name, exclude)) continue;
49
+ if (dstKeep.has(entry.name)) continue;
50
+ const s = path.join(src, entry.name), d = path.join(dst, entry.name);
51
+ if (entry.isDirectory()) copyDirIncremental(s, d, exclude);
52
+ else if (entry.isFile()) copyFileIfChanged(s, d);
53
+ }
54
+ },
55
+ /**
56
+ * 获取 build 目录路径:<userProject>/node-mobile-app-build
57
+ */
58
+ getBuildDir = userProjectDir => path.join(userProjectDir, BUILD_DIR_NAME),
59
+
60
+ /**
61
+ * 把 buildDir/package.json 的依赖版本同步为主包 dependencies 的版本
62
+ * - 依赖名单来自 buildDir/package.json(模板声明需要哪些依赖)
63
+ * - 版本号来自 pkgRoot/package.json 的 dependencies(单一数据源)
64
+ * - 若主包缺少某个依赖,抛错中断(避免模板占位值被真正使用)
65
+ */
66
+ syncTemplateDependencies = (pkgRoot, buildDir) => {
67
+ const buildPkgPath = path.join(buildDir, 'package.json');
68
+ if (!fs.existsSync(buildPkgPath)) return;
69
+ const mainPkgPath = path.join(pkgRoot, 'package.json');
70
+ if (!fs.existsSync(mainPkgPath)) return;
71
+ const buildPkg = readJson(buildPkgPath), mainPkg = readJson(mainPkgPath);
72
+ if (!buildPkg.dependencies) return;
73
+
74
+ const mainDeps = mainPkg.dependencies || {}, missing = [];
75
+ let depsUpdated = 0, needWrite = false;
76
+
77
+ // 移除仅用于模板占位说明的 _comment 字段,避免带进最终 build 工程
78
+ if ('_comment' in buildPkg) delete buildPkg._comment, needWrite = true;
79
+ for (const name of Object.keys(buildPkg.dependencies)) {
80
+ const v = mainDeps[name];
81
+ if (!v) { missing.push(name); continue; }
82
+ if (buildPkg.dependencies[name] !== v) buildPkg.dependencies[name] = v, depsUpdated++, needWrite = true;
83
+ }
84
+
85
+ if (needWrite) {
86
+ writeIfChanged(buildPkgPath, JSON.stringify(buildPkg, null, 2) + '\n');
87
+ if (depsUpdated > 0)
88
+ console.log(chalk.green(' ✓ 已同步 ' + depsUpdated + ' 个依赖版本到 ' + BUILD_DIR_NAME + '/package.json'));
89
+ }
90
+
91
+ if (missing.length > 0) {
92
+ throw new Error(
93
+ 'template/package.json 声明的以下依赖在主包 dependencies 中不存在:\n' +
94
+ ' ' + missing.join(', ') + '\n' +
95
+ ' 请在 @flun/node-mobile-app 的 package.json 中补上对应声明(版本号随意,\n' +
96
+ ' 但必须存在,以便 syncTemplateDependencies 覆盖模板里的占位值);'
97
+ );
98
+ }
99
+ },
100
+ /**
101
+ * 把主包 node_modules 里被 npm 嵌套(未提升到用户根)的依赖,按需链接到用户根 node_modules 下,
102
+ * 让 RN CLI / Metro 从用户根也能解析到它们;
103
+ * - 判据:从用户根视角解析不到,才建链接;能解析则跳过;
104
+ * - 范围:遍历主包 node_modules 顶层全部条目(含 @scope/ 下每个子项),不列举任何包名;
105
+ * - 落点:只在用户根 node_modules 缺失条目时建 symlink,不覆盖已有内容、不碰 package.json / package-lock.json;
106
+ * - 建链接失败:中断报错,不静默;
107
+ */
108
+ linkNestedDeps = (pkgRoot, buildDir) => {
109
+ const pkgNm = path.join(pkgRoot, 'node_modules');
110
+ if (!fs.existsSync(pkgNm)) return;
111
+
112
+ const userProjectDir = path.dirname(buildDir), userNm = path.join(userProjectDir, 'node_modules'),
113
+ req = createRequire(path.join(userProjectDir, 'index.js')), candidates = [];
114
+ for (const entry of fs.readdirSync(pkgNm, { withFileTypes: true })) {
115
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
116
+ if (entry.name.startsWith('@')) {
117
+ const scopeDir = path.join(pkgNm, entry.name);
118
+ for (const sub of fs.readdirSync(scopeDir, { withFileTypes: true }))
119
+ if (sub.isDirectory()) candidates.push(entry.name + '/' + sub.name);
120
+ } else candidates.push(entry.name);
121
+ }
122
+
123
+ let linked = 0;
124
+ for (const name of candidates) {
125
+ try { req.resolve(name); continue; } catch { /* 解析不到,进入兜底 */ }
126
+ const src = path.join(pkgNm, name);
127
+ if (!fs.existsSync(src)) continue;
128
+ const realSrc = fs.realpathSync(src), dst = path.join(userNm, name);
129
+ let occupied = false;
130
+ try { fs.lstatSync(dst); occupied = true; } catch { /* 不存在 */ }
131
+ if (occupied) continue;
132
+ ensureDir(path.dirname(dst));
133
+ try {
134
+ fs.symlinkSync(realSrc, dst, 'junction'), linked++;
135
+ } catch (err) {
136
+ throw new Error(
137
+ '建立兜底链接失败:' + dst + ' -> ' + realSrc + '\n' +
138
+ ' 原因:' + err.message + '\n' +
139
+ ' 请检查 node_modules 是否被其它程序占用后重试'
140
+ );
141
+ }
142
+ }
143
+ if (linked > 0) console.log(chalk.green(' ✓ 已兜底链接 ' + linked + ' 个嵌套依赖到用户根 node_modules'));
144
+ },
145
+ /**
146
+ * 把 CLI 包里的 template/ 逐文件覆盖到 build 目录
147
+ * - 只覆盖模板里存在的文件,不删除 buildDir 里已有的其他内容(保留 Gradle 增量缓存)
148
+ * - 模板文件被改写时自动同步;新增文件自动添加
149
+ * - 若模板里删除了文件,旧副本残留由 CLI 版本指纹触发全量重建处理
150
+ */
151
+ copyTemplate = (pkgRoot, buildDir, config) => {
152
+ ensureDir(buildDir);
153
+
154
+ const templateDir = path.join(pkgRoot, 'template');
155
+ if (!fs.existsSync(templateDir)) throw new Error('CLI 包缺少 template/ 目录: ' + templateDir);
156
+
157
+ const keepSet = new Set((config && config.keepFiles) || []);
158
+ copyDirOverwrite(templateDir, buildDir, TEMPLATE_EXCLUDE, buildDir, keepSet);
159
+ fs.writeFileSync(path.join(buildDir, 'eslint.config.js'), buildEslintConfigSource(), 'utf-8');
160
+ syncTemplateDependencies(pkgRoot, buildDir);
161
+ linkNestedDeps(pkgRoot, buildDir);
162
+ console.log(chalk.green(' ✓ 模板已更新到 ' + BUILD_DIR_NAME + '/'));
163
+ if (keepSet.size > 0) console.log(chalk.gray(' 保留文件: ' + Array.from(keepSet).join(', ')));
164
+ },
165
+ /**
166
+ * 复制用户 Node 项目到 <buildDir>/nodejs-assets/nodejs-project
167
+ * 复制后重写 package.json:只保留 dependencies,剔除 devDependencies;用 config 的 allowScripts
168
+ */
169
+ copyUserProject = (userProjectDir, buildDir, config) => {
170
+ const targetDir = path.join(buildDir, 'nodejs-assets', 'nodejs-project');
171
+ ensureDir(targetDir);
172
+
173
+ const exclude = [
174
+ ...config.excludeFiles, BUILD_DIR_NAME + '/',
175
+ 'mobileAppConfig.js', 'mobileAppConfig.mjs', 'mobileAppConfig.cjs'
176
+ ];
177
+
178
+ // 增量复制:内容变化的文件才覆盖,保留 node_modules 和其他未变文件
179
+ copyDirIncremental(userProjectDir, targetDir, exclude);
180
+ // 黑名单式过滤:只删 devDependencies 和 allowScripts,其余字段原样保留
181
+ const userPkgPath = path.join(userProjectDir, 'package.json');
182
+ if (!fs.existsSync(userPkgPath)) throw new Error('用户项目缺少 package.json');
183
+ const pkg = readJson(userPkgPath), out = { ...pkg };
184
+ delete out.devDependencies;
185
+ out.allowScripts =
186
+ (config.allowScripts && Object.keys(config.allowScripts).length) ? config.allowScripts : { node: true };
187
+ writeIfChanged(path.join(targetDir, 'package.json'), JSON.stringify(out, null, 2) + '\n');
188
+
189
+ console.log(chalk.green(' ✓ 已同步用户项目到 nodejs-assets/nodejs-project'));
190
+ console.log(chalk.gray(' 模块类型: ' + (out.type === 'module' ? 'ESM' : 'CJS')));
191
+ console.log(chalk.gray(' 生产依赖: ' + Object.keys(out.dependencies || {}).length + ' 个'));
192
+
193
+ return { pkg: out, moduleType: out.type === 'module' ? 'module' : 'commonjs', targetDir };
194
+ },
195
+ /**
196
+ * 生成 <buildDir>/android/local.properties(Gradle 需要 sdk.dir)
197
+ */
198
+ writeLocalProperties = (buildDir, config) => {
199
+ const sdkPath = resolveSdkPath(config);
200
+ if (!sdkPath)
201
+ return console.warn(chalk.yellow(' ⚠️ 未找到 Android SDK,请在 mobileAppConfig.js 里配置 android.sdkPath'));
202
+
203
+ const localProps = path.join(buildDir, 'android', 'local.properties'),
204
+ escaped = sdkPath.replace(/\\/g, '\\\\').replace(/:/g, '\\:');
205
+ fs.writeFileSync(localProps, `sdk.dir=${escaped}\n`, 'utf-8');
206
+ console.log(chalk.green(' ✓ 写入 sdk.dir: ' + sdkPath));
207
+ },
208
+ /**
209
+ * 确保用户项目根存在 serverPath 指向的入口文件
210
+ * 不存在时从 CLI 内置 cli-assets/server.js 复制一份
211
+ */
212
+ ensureUserEntry = (userProjectDir, config, pkgRoot) => {
213
+ const entryPath = path.join(userProjectDir, config.serverPath);
214
+ if (fs.existsSync(entryPath)) return { created: false, path: entryPath };
215
+
216
+ const fallback = path.join(pkgRoot, 'cli-assets', 'server.js');
217
+ if (!fs.existsSync(fallback)) throw new Error('入口文件不存在: ' + config.serverPath + '(CLI 内置模板也缺失)');
218
+
219
+ ensureDir(path.dirname(entryPath)), fs.copyFileSync(fallback, entryPath);
220
+ console.log(chalk.green(' ✓ 生成默认入口文件: ' + config.serverPath));
221
+ console.log(chalk.gray(' (因为用户项目里没有这个文件,从 CLI 内置模板复制)'));
222
+
223
+ return { created: true, path: entryPath };
224
+ },
225
+ /**
226
+ * 确保用户项目根存在 mobileAppConfig.js
227
+ * 不存在时从 CLI 包根复制一份(带注释的模板)
228
+ */
229
+ ensureUserConfig = (userProjectDir, pkgRoot) => {
230
+ const configPath = path.join(userProjectDir, 'mobileAppConfig.js');
231
+ if (fs.existsSync(configPath)) return { created: false, path: configPath };
232
+
233
+ const fallback = path.join(pkgRoot, 'mobileAppConfig.js');
234
+ if (!fs.existsSync(fallback)) throw new Error('CLI 缺少 mobileAppConfig.js 模板');
235
+
236
+ fs.copyFileSync(fallback, configPath);
237
+ console.log(chalk.green(' ✓ 生成默认配置文件: mobileAppConfig.js'));
238
+ console.log(chalk.gray(' (因为用户项目里没有这个文件,从 CLI 内置模板复制)'));
239
+
240
+ return { created: true, path: configPath };
241
+ },
242
+ /**
243
+ * 按 config.android.abiFilters 修改 buildDir 里的 app/build.gradle:
244
+ * - defaultConfig.ndk.abiFilters
245
+ * - gradle.properties 的 reactNativeArchitectures
246
+ * 插件 build.gradle 读的是 project(":app").android.defaultConfig.ndk.abiFilters,
247
+ * 所以必须改这里,改插件自身的 abiFilters 无效;
248
+ */
249
+ patchAndroidAbi = (buildDir, config) => {
250
+ const file = path.join(buildDir, 'android', 'app', 'build.gradle');
251
+ if (!fs.existsSync(file)) return console.warn(chalk.yellow(' ⚠️ 未找到 app/build.gradle,跳过 ABI patch'));
252
+
253
+ const abiList = (config.android.abiFilters || []).map((x) => `"${x}"`).join(', ');
254
+ let c = fs.readFileSync(file, 'utf-8');
255
+ const before = c;
256
+
257
+ // defaultConfig 里的 ndk { abiFilters ... }
258
+ c = c.replace(/(defaultConfig\s*\{[\s\S]*?ndk\s*\{\s*abiFilters\s+)("[^"]*"(?:\s*,\s*"[^"]*")*)/, `$1${abiList}`);
259
+ // 兜底:没有 ndk {} 块时,插到 defaultConfig 里
260
+ if (!/ndk\s*\{\s*abiFilters/.test(c))
261
+ c = c.replace(/(defaultConfig\s*\{)/, `$1\n ndk {\n abiFilters ${abiList}\n }`);
262
+ // 不 patch splits.abi.include:AGP 禁止它和 ndk.abiFilters 同时存在
263
+ if (c !== before) writeIfChanged(file, c), console.log(chalk.green(` ✓ 已设置 app ndk.abiFilters = [${abiList}]`));
264
+
265
+ // 同步 gradle.properties 的 reactNativeArchitectures
266
+ const propsFile = path.join(buildDir, 'android', 'gradle.properties');
267
+ if (fs.existsSync(propsFile)) {
268
+ let p = fs.readFileSync(propsFile, 'utf-8');
269
+ const abiPlain = (config.android.abiFilters || []).join(','), before2 = p;
270
+ p = p.replace(/^reactNativeArchitectures=.*/gm, `reactNativeArchitectures=${abiPlain}`);
271
+ if (p !== before2) writeIfChanged(propsFile, p),
272
+ console.log(chalk.green(` ✓ 已设置 reactNativeArchitectures = ${abiPlain}`));
273
+ }
274
+ },
275
+ /**
276
+ * 把用户在 mobileAppConfig.js 里声明的 extraRnDependencies 注入 buildDir/package.json
277
+ * 版本号从用户根 node_modules 读实际装的版本
278
+ * 用户没装的会中断并提示安装命令
279
+ */
280
+ patchExtraRnDependencies = (buildDir, userProjectDir, config) => {
281
+ const extras = config.extraRnDependencies || [];
282
+ if (extras.length === 0) return;
283
+
284
+ const buildPkgPath = path.join(buildDir, 'package.json');
285
+ if (!fs.existsSync(buildPkgPath))
286
+ return console.warn(chalk.yellow(' ⚠️ 未找到 buildDir/package.json,跳过 extraRnDependencies'));
287
+
288
+ const buildPkg = readJson(buildPkgPath), missing = [];
289
+ if (!buildPkg.dependencies) buildPkg.dependencies = {};
290
+ let added = 0;
291
+ for (const name of extras) {
292
+ const pkgJson = path.join(userProjectDir, 'node_modules', name, 'package.json');
293
+ if (!fs.existsSync(pkgJson)) {
294
+ missing.push(name); continue;
295
+ }
296
+ try {
297
+ const v = JSON.parse(fs.readFileSync(pkgJson, 'utf-8')).version;
298
+ if (buildPkg.dependencies[name] !== v) buildPkg.dependencies[name] = v, added++;
299
+ } catch { missing.push(name); }
300
+ }
301
+
302
+ if (added > 0) {
303
+ writeIfChanged(buildPkgPath, JSON.stringify(buildPkg, null, 2) + '\n');
304
+ console.log(chalk.green(` ✓ 已注入 ${added} 个额外 RN 依赖到 buildDir/package.json`));
305
+ }
306
+
307
+ if (missing.length > 0) {
308
+ throw new Error(
309
+ 'extraRnDependencies 里以下包未安装:\n' +
310
+ ' ' + missing.join(', ') + '\n' +
311
+ ' 请先在项目根执行:\n' +
312
+ ' npm install ' + missing.join(' ') + '\n' +
313
+ ' 然后重新运行 node-mobile-app test'
314
+ );
315
+ }
316
+ };
317
+
318
+ export {
319
+ getBuildDir, copyTemplate, copyUserProject, writeLocalProperties, ensureUserEntry, ensureUserConfig, patchAndroidAbi,
320
+ patchExtraRnDependencies
321
321
  };