@flun/node-mobile-app 4.1.2 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/copy.js CHANGED
@@ -1,11 +1,11 @@
1
- import path from 'path';
1
+ import chalk from 'chalk';
2
2
  import fs from 'fs';
3
+ import { createRequire } from 'module';
4
+ import path from 'path';
5
+ import { resolveSdkPath } from './config.js';
3
6
  import {
4
- ensureDir, removeDir, readJson, isExcluded, copyFileIfChanged, writeIfChanged, buildEslintConfigSource
7
+ buildEslintConfigSource, copyFileIfChanged, ensureDir, isExcluded, readJson, removeDir, writeIfChanged
5
8
  } from './utils.js';
6
- import { resolveSdkPath } from './config.js';
7
- import { createRequire } from 'module';
8
- import chalk from 'chalk';
9
9
 
10
10
  const BUILD_DIR_NAME = 'node-mobile-app-build',
11
11
  TEMPLATE_EXCLUDE = [
@@ -13,22 +13,6 @@ const BUILD_DIR_NAME = 'node-mobile-app-build',
13
13
  'app/build/', 'app/.cxx/',
14
14
  'Pods/', 'local.properties', '*.bak', 'gradle.properties.bak',
15
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
16
  /**
33
17
  * 内部:增量复制用户项目到 nodejs-project
34
18
  * 保留 node_modules 等由其他步骤生成的产物
@@ -53,48 +37,19 @@ const BUILD_DIR_NAME = 'node-mobile-app-build',
53
37
  }
54
38
  },
55
39
  /**
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
- * - 若主包缺少某个依赖,抛错中断(避免模板占位值被真正使用)
40
+ * 内部:递归覆盖式复制模板,跳过 keepSet 命中的文件
65
41
  */
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
- );
42
+ copyDirOverwrite = (src, dst, exclude, buildDir, keepSet) => {
43
+ ensureDir(dst);
44
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
45
+ if (isExcluded(entry.name, exclude)) continue;
46
+ const s = path.join(src, entry.name), d = path.join(dst, entry.name);
47
+ if (entry.isDirectory()) copyDirOverwrite(s, d, exclude, buildDir, keepSet);
48
+ else if (entry.isFile()) {
49
+ const rel = path.relative(buildDir, d).replace(/\\/g, '/');
50
+ if (keepSet.has(rel)) continue;
51
+ copyFileIfChanged(s, d);
52
+ }
98
53
  }
99
54
  },
100
55
  /**
@@ -143,179 +98,311 @@ const BUILD_DIR_NAME = 'node-mobile-app-build',
143
98
  if (linked > 0) console.log(chalk.green(' ✓ 已兜底链接 ' + linked + ' 个嵌套依赖到用户根 node_modules'));
144
99
  },
145
100
  /**
146
- * 把 CLI 包里的 template/ 逐文件覆盖到 build 目录
147
- * - 只覆盖模板里存在的文件,不删除 buildDir 里已有的其他内容(保留 Gradle 增量缓存)
148
- * - 模板文件被改写时自动同步;新增文件自动添加
149
- * - 若模板里删除了文件,旧副本残留由 CLI 版本指纹触发全量重建处理
101
+ * 探测镜像是否可达,HEAD 请求,3 秒超时
102
+ * @param {string} url 待探测地址
103
+ * @returns {Promise<boolean>} 是否可达
150
104
  */
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(', ')));
105
+ probeUrl = async url => {
106
+ const ctrl = new AbortController(), timer = setTimeout(() => ctrl.abort(), 3000);
107
+ try {
108
+ const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal, redirect: 'follow' });
109
+ return res.ok;
110
+ } catch { return false; }
111
+ finally { clearTimeout(timer); }
164
112
  },
165
113
  /**
166
- * 复制用户 Node 项目到 <buildDir>/nodejs-assets/nodejs-project
167
- * 复制后重写 package.json:只保留 dependencies,剔除 devDependencies;用 config 的 allowScripts
114
+ * 解析或探测 Gradle 下载地址,写入 gradle-wrapper.properties
115
+ * - 用户配置 android.gradleDistributionUrl 时直接使用,失败不回退
116
+ * - 未配置时从模板解析版本号,按腾讯/阿里/官方顺序探测,官方兜底
117
+ * @param {string} buildDir 构建目录
118
+ * @param {object} config 用户配置
119
+ * @returns {Promise<void>}
168
120
  */
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}`));
121
+ patchGradleDistributionUrl = async (buildDir, config) => {
122
+ const file = path.join(buildDir, 'android', 'gradle', 'wrapper', 'gradle-wrapper.properties');
123
+ if (!fs.existsSync(file)) return;
124
+ const src = fs.readFileSync(file, 'utf-8'),
125
+ configured = config && config.android && config.android.gradleDistributionUrl;
126
+ let url = configured;
127
+ if (!url) {
128
+ const m = src.match(/gradle-(\d+\.\d+(?:\.\d+)?)-bin\.zip/);
129
+ if (!m) return console.warn(chalk.yellow(' ⚠️ 无法从模板解析 Gradle 版本号,保留原地址'));
130
+ const v = m[1], candidates = [
131
+ 'https://mirrors.cloud.tencent.com/gradle/gradle-' + v + '-bin.zip',
132
+ 'https://mirrors.aliyun.com/gradle/distributions/v' + v + '/gradle-' + v + '-bin.zip',
133
+ 'https://services.gradle.org/distributions/gradle-' + v + '-bin.zip',
134
+ ];
135
+ for (const c of candidates) if (await probeUrl(c)) { url = c; break; }
136
+ if (!url) url = candidates[candidates.length - 1],
137
+ console.warn(chalk.yellow(' ⚠️ 三个 Gradle 源均不可达,回落官方地址'));
273
138
  }
139
+ const escaped = url.replace(/:/g, '\\:'),
140
+ next = src.replace(/^distributionUrl=.*$/m, 'distributionUrl=' + escaped);
141
+ if (next !== src) writeIfChanged(file, next);
142
+ if (configured) console.log(chalk.green(' ✓ 已应用自定义 Gradle 下载地址: ' + url));
274
143
  },
275
144
  /**
276
- * 把用户在 mobileAppConfig.js 里声明的 extraRnDependencies 注入 buildDir/package.json
277
- * 版本号从用户根 node_modules 读实际装的版本
278
- * 用户没装的会中断并提示安装命令
145
+ * 把 buildDir/package.json 的依赖版本同步为主包 dependencies 的版本
146
+ * - 依赖名单来自 buildDir/package.json(模板声明需要哪些依赖)
147
+ * - 版本号来自 pkgRoot/package.json 的 dependencies(单一数据源)
148
+ * - 若主包缺少某个依赖,抛错中断(避免模板占位值被真正使用)
279
149
  */
280
- patchExtraRnDependencies = (buildDir, userProjectDir, config) => {
281
- const extras = config.extraRnDependencies || [];
282
- if (extras.length === 0) return;
283
-
150
+ syncTemplateDependencies = (pkgRoot, buildDir) => {
284
151
  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); }
152
+ if (!fs.existsSync(buildPkgPath)) return;
153
+ const mainPkgPath = path.join(pkgRoot, 'package.json');
154
+ if (!fs.existsSync(mainPkgPath)) return;
155
+ const buildPkg = readJson(buildPkgPath), mainPkg = readJson(mainPkgPath);
156
+ if (!buildPkg.dependencies) return;
157
+
158
+ const mainDeps = mainPkg.dependencies || {}, missing = [];
159
+ let depsUpdated = 0, needWrite = false;
160
+
161
+ // 移除仅用于模板占位说明的 _comment 字段,避免带进最终 build 工程
162
+ if ('_comment' in buildPkg) delete buildPkg._comment, needWrite = true;
163
+ for (const name of Object.keys(buildPkg.dependencies)) {
164
+ const v = mainDeps[name];
165
+ if (!v) { missing.push(name); continue; }
166
+ if (buildPkg.dependencies[name] !== v) buildPkg.dependencies[name] = v, depsUpdated++, needWrite = true;
300
167
  }
301
168
 
302
- if (added > 0) {
169
+ if (needWrite) {
303
170
  writeIfChanged(buildPkgPath, JSON.stringify(buildPkg, null, 2) + '\n');
304
- console.log(chalk.green(` ✓ 已注入 ${added} 个额外 RN 依赖到 buildDir/package.json`));
171
+ if (depsUpdated > 0)
172
+ console.log(chalk.green(' ✓ 已同步 ' + depsUpdated + ' 个依赖版本到 ' + BUILD_DIR_NAME + '/package.json'));
305
173
  }
306
174
 
307
175
  if (missing.length > 0) {
308
176
  throw new Error(
309
- 'extraRnDependencies 里以下包未安装:\n' +
177
+ 'template/package.json 声明的以下依赖在主包 dependencies 中不存在:\n' +
310
178
  ' ' + missing.join(', ') + '\n' +
311
- ' 请先在项目根执行:\n' +
312
- ' npm install ' + missing.join(' ') + '\n' +
313
- ' 然后重新运行 node-mobile-app test'
179
+ ' 请在 @flun/node-mobile-app 的 package.json 中补上对应声明(版本号随意,\n' +
180
+ ' 但必须存在,以便 syncTemplateDependencies 覆盖模板里的占位值);'
314
181
  );
315
182
  }
316
183
  };
317
184
 
185
+ /**
186
+ * 把 CLI 包里的 template/ 逐文件覆盖到 build 目录
187
+ * - 只覆盖模板里存在的文件,不删除 buildDir 里已有的其他内容(保留 Gradle 增量缓存)
188
+ * - 模板文件被改写时自动同步;新增文件自动添加
189
+ * - 若模板里删除了文件,旧副本残留由 CLI 版本指纹触发全量重建处理
190
+ * >查看定义:@see {@link copyTemplate}
191
+ * @param {string} pkgRoot CLI 包根目录
192
+ * @param {string} buildDir 构建目录
193
+ * @param {object} config 用户配置
194
+ * @returns {Promise<void>}
195
+ */
196
+ const copyTemplate = async (pkgRoot, buildDir, config) => {
197
+ ensureDir(buildDir);
198
+
199
+ const templateDir = path.join(pkgRoot, 'template');
200
+ if (!fs.existsSync(templateDir)) throw new Error('CLI 包缺少 template/ 目录: ' + templateDir);
201
+
202
+ const keepSet = new Set((config && config.keepFiles) || []);
203
+ copyDirOverwrite(templateDir, buildDir, TEMPLATE_EXCLUDE, buildDir, keepSet);
204
+ await patchGradleDistributionUrl(buildDir, config);
205
+ fs.writeFileSync(path.join(buildDir, 'eslint.config.js'), buildEslintConfigSource(), 'utf-8');
206
+ syncTemplateDependencies(pkgRoot, buildDir);
207
+ linkNestedDeps(pkgRoot, buildDir);
208
+ console.log(chalk.green(' ✓ 模板已更新到 ' + BUILD_DIR_NAME + '/'));
209
+ if (keepSet.size > 0) console.log(chalk.gray(' 保留文件: ' + Array.from(keepSet).join(', ')));
210
+ };
211
+
212
+ /**
213
+ * 复制用户 Node 项目到 <buildDir>/nodejs-assets/nodejs-project
214
+ * 复制后重写 package.json:只保留 dependencies,剔除 devDependencies;用 config 的 allowScripts
215
+ * >查看定义:@see {@link copyUserProject}
216
+ * @param {string} userProjectDir 用户项目目录
217
+ * @param {string} buildDir 构建目录
218
+ * @param {object} config 用户配置
219
+ * @returns {object} 含 pkg、moduleType、targetDir
220
+ */
221
+ const copyUserProject = (userProjectDir, buildDir, config) => {
222
+ const targetDir = path.join(buildDir, 'nodejs-assets', 'nodejs-project');
223
+ ensureDir(targetDir);
224
+
225
+ const exclude = [
226
+ ...config.excludeFiles, BUILD_DIR_NAME + '/',
227
+ 'mobileAppConfig.js', 'mobileAppConfig.mjs', 'mobileAppConfig.cjs'
228
+ ];
229
+
230
+ // 增量复制:内容变化的文件才覆盖,保留 node_modules 和其他未变文件
231
+ copyDirIncremental(userProjectDir, targetDir, exclude);
232
+ // 黑名单式过滤:只删 devDependencies 和 allowScripts,其余字段原样保留
233
+ const userPkgPath = path.join(userProjectDir, 'package.json');
234
+ if (!fs.existsSync(userPkgPath)) throw new Error('用户项目缺少 package.json');
235
+ const pkg = readJson(userPkgPath), out = { ...pkg };
236
+ delete out.devDependencies;
237
+ out.allowScripts =
238
+ (config.allowScripts && Object.keys(config.allowScripts).length) ? config.allowScripts : { node: true };
239
+ writeIfChanged(path.join(targetDir, 'package.json'), JSON.stringify(out, null, 2) + '\n');
240
+
241
+ console.log(chalk.green(' ✓ 已同步用户项目到 nodejs-assets/nodejs-project'));
242
+ console.log(chalk.gray(' 模块类型: ' + (out.type === 'module' ? 'ESM' : 'CJS')));
243
+ console.log(chalk.gray(' 生产依赖: ' + Object.keys(out.dependencies || {}).length + ' 个'));
244
+
245
+ return { pkg: out, moduleType: out.type === 'module' ? 'module' : 'commonjs', targetDir };
246
+ };
247
+
248
+ /**
249
+ * 确保用户项目根存在 mobileAppConfig.js
250
+ * 不存在时从 CLI 包根复制一份(带注释的模板)
251
+ * >查看定义:@see {@link ensureUserConfig}
252
+ * @param {string} userProjectDir 用户项目目录
253
+ * @param {string} pkgRoot CLI 包根目录
254
+ * @returns {object} 含 created、path
255
+ */
256
+ const ensureUserConfig = (userProjectDir, pkgRoot) => {
257
+ const configPath = path.join(userProjectDir, 'mobileAppConfig.js');
258
+ if (fs.existsSync(configPath)) return { created: false, path: configPath };
259
+
260
+ const fallback = path.join(pkgRoot, 'mobileAppConfig.js');
261
+ if (!fs.existsSync(fallback)) throw new Error('CLI 缺少 mobileAppConfig.js 模板');
262
+
263
+ fs.copyFileSync(fallback, configPath);
264
+ console.log(chalk.green(' ✓ 生成默认配置文件: mobileAppConfig.js'));
265
+ console.log(chalk.gray(' (因为用户项目里没有这个文件,从 CLI 内置模板复制)'));
266
+
267
+ return { created: true, path: configPath };
268
+ };
269
+
270
+ /**
271
+ * 确保用户项目根存在 serverPath 指向的入口文件
272
+ * 不存在时从 CLI 内置 cli-assets/server.js 复制一份
273
+ * >查看定义:@see {@link ensureUserEntry}
274
+ * @param {string} userProjectDir 用户项目目录
275
+ * @param {object} config 用户配置
276
+ * @param {string} pkgRoot CLI 包根目录
277
+ * @returns {object} 含 created、path
278
+ */
279
+ const ensureUserEntry = (userProjectDir, config, pkgRoot) => {
280
+ const entryPath = path.join(userProjectDir, config.serverPath);
281
+ if (fs.existsSync(entryPath)) return { created: false, path: entryPath };
282
+
283
+ const fallback = path.join(pkgRoot, 'cli-assets', 'server.js');
284
+ if (!fs.existsSync(fallback)) throw new Error('入口文件不存在: ' + config.serverPath + '(CLI 内置模板也缺失)');
285
+
286
+ ensureDir(path.dirname(entryPath)), fs.copyFileSync(fallback, entryPath);
287
+ console.log(chalk.green(' ✓ 生成默认入口文件: ' + config.serverPath));
288
+ console.log(chalk.gray(' (因为用户项目里没有这个文件,从 CLI 内置模板复制)'));
289
+
290
+ return { created: true, path: entryPath };
291
+ };
292
+
293
+ /**
294
+ * 获取 build 目录路径:<userProject>/node-mobile-app-build
295
+ * >查看定义:@see {@link getBuildDir}
296
+ * @param {string} userProjectDir 用户项目目录
297
+ * @returns {string} build 目录绝对路径
298
+ */
299
+ const getBuildDir = userProjectDir => path.join(userProjectDir, BUILD_DIR_NAME);
300
+
301
+ /**
302
+ * 按 config.android.abiFilters 修改 buildDir 里的 app/build.gradle:
303
+ * - defaultConfig.ndk.abiFilters
304
+ * - gradle.properties 的 reactNativeArchitectures
305
+ * 插件 build.gradle 读的是 project(":app").android.defaultConfig.ndk.abiFilters,
306
+ * 所以必须改这里,改插件自身的 abiFilters 无效;
307
+ * >查看定义:@see {@link patchAndroidAbi}
308
+ * @param {string} buildDir 构建目录
309
+ * @param {object} config 用户配置
310
+ * @returns {void}
311
+ */
312
+ const patchAndroidAbi = (buildDir, config) => {
313
+ const file = path.join(buildDir, 'android', 'app', 'build.gradle');
314
+ if (!fs.existsSync(file)) return console.warn(chalk.yellow(' ⚠️ 未找到 app/build.gradle,跳过 ABI patch'));
315
+
316
+ const abiList = (config.android.abiFilters || []).map((x) => `"${x}"`).join(', ');
317
+ let c = fs.readFileSync(file, 'utf-8');
318
+ const before = c;
319
+
320
+ // defaultConfig 里的 ndk { abiFilters ... }
321
+ c = c.replace(/(defaultConfig\s*\{[\s\S]*?ndk\s*\{\s*abiFilters\s+)("[^"]*"(?:\s*,\s*"[^"]*")*)/, `$1${abiList}`);
322
+ // 兜底:没有 ndk {} 块时,插到 defaultConfig 里
323
+ if (!/ndk\s*\{\s*abiFilters/.test(c))
324
+ c = c.replace(/(defaultConfig\s*\{)/, `$1\n ndk {\n abiFilters ${abiList}\n }`);
325
+ // 不 patch splits.abi.include:AGP 禁止它和 ndk.abiFilters 同时存在
326
+ if (c !== before) writeIfChanged(file, c), console.log(chalk.green(` ✓ 已设置 app ndk.abiFilters = [${abiList}]`));
327
+
328
+ // 同步 gradle.properties 的 reactNativeArchitectures
329
+ const propsFile = path.join(buildDir, 'android', 'gradle.properties');
330
+ if (fs.existsSync(propsFile)) {
331
+ let p = fs.readFileSync(propsFile, 'utf-8');
332
+ const abiPlain = (config.android.abiFilters || []).join(','), before2 = p;
333
+ p = p.replace(/^reactNativeArchitectures=.*/gm, `reactNativeArchitectures=${abiPlain}`);
334
+ if (p !== before2) writeIfChanged(propsFile, p),
335
+ console.log(chalk.green(` ✓ 已设置 reactNativeArchitectures = ${abiPlain}`));
336
+ }
337
+ };
338
+
339
+ /**
340
+ * 把用户在 mobileAppConfig.js 里声明的 extraRnDependencies 注入 buildDir/package.json
341
+ * 版本号从用户根 node_modules 读实际装的版本
342
+ * 用户没装的会中断并提示安装命令
343
+ * >查看定义:@see {@link patchExtraRnDependencies}
344
+ * @param {string} buildDir 构建目录
345
+ * @param {string} userProjectDir 用户项目目录
346
+ * @param {object} config 用户配置
347
+ * @returns {void}
348
+ */
349
+ const patchExtraRnDependencies = (buildDir, userProjectDir, config) => {
350
+ const extras = config.extraRnDependencies || [];
351
+ if (extras.length === 0) return;
352
+
353
+ const buildPkgPath = path.join(buildDir, 'package.json');
354
+ if (!fs.existsSync(buildPkgPath))
355
+ return console.warn(chalk.yellow(' ⚠️ 未找到 buildDir/package.json,跳过 extraRnDependencies'));
356
+
357
+ const buildPkg = readJson(buildPkgPath), missing = [];
358
+ if (!buildPkg.dependencies) buildPkg.dependencies = {};
359
+ let added = 0;
360
+ for (const name of extras) {
361
+ const pkgJson = path.join(userProjectDir, 'node_modules', name, 'package.json');
362
+ if (!fs.existsSync(pkgJson)) {
363
+ missing.push(name); continue;
364
+ }
365
+ try {
366
+ const v = JSON.parse(fs.readFileSync(pkgJson, 'utf-8')).version;
367
+ if (buildPkg.dependencies[name] !== v) buildPkg.dependencies[name] = v, added++;
368
+ } catch { missing.push(name); }
369
+ }
370
+
371
+ if (added > 0) {
372
+ writeIfChanged(buildPkgPath, JSON.stringify(buildPkg, null, 2) + '\n');
373
+ console.log(chalk.green(` ✓ 已注入 ${added} 个额外 RN 依赖到 buildDir/package.json`));
374
+ }
375
+
376
+ if (missing.length > 0) {
377
+ throw new Error(
378
+ 'extraRnDependencies 里以下包未安装:\n' +
379
+ ' ' + missing.join(', ') + '\n' +
380
+ ' 请先在项目根执行:\n' +
381
+ ' npm install ' + missing.join(' ') + '\n' +
382
+ ' 然后重新运行 node-mobile-app test'
383
+ );
384
+ }
385
+ };
386
+
387
+ /**
388
+ * 生成 <buildDir>/android/local.properties(Gradle 需要 sdk.dir)
389
+ * >查看定义:@see {@link writeLocalProperties}
390
+ * @param {string} buildDir 构建目录
391
+ * @param {object} config 用户配置
392
+ * @returns {void}
393
+ */
394
+ const writeLocalProperties = (buildDir, config) => {
395
+ const sdkPath = resolveSdkPath(config);
396
+ if (!sdkPath)
397
+ return console.warn(chalk.yellow(' ⚠️ 未找到 Android SDK,请在 mobileAppConfig.js 里配置 android.sdkPath'));
398
+
399
+ const localProps = path.join(buildDir, 'android', 'local.properties'),
400
+ escaped = sdkPath.replace(/\\/g, '\\\\').replace(/:/g, '\\:');
401
+ fs.writeFileSync(localProps, `sdk.dir=${escaped}\n`, 'utf-8');
402
+ console.log(chalk.green(' ✓ 写入 sdk.dir: ' + sdkPath));
403
+ };
404
+
318
405
  export {
319
- getBuildDir, copyTemplate, copyUserProject, writeLocalProperties, ensureUserEntry, ensureUserConfig, patchAndroidAbi,
320
- patchExtraRnDependencies
406
+ copyTemplate, copyUserProject, ensureUserConfig, ensureUserEntry, getBuildDir, patchAndroidAbi,
407
+ patchExtraRnDependencies, writeLocalProperties
321
408
  };