@htyf-mp/cli 0.2.0-alpha.6 → 0.2.0-alpha.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@htyf-mp/cli",
3
- "version": "0.2.0-alpha.6",
3
+ "version": "0.2.0-alpha.8",
4
4
  "description": "红糖云服app小程序模板-cli",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,6 +43,8 @@
43
43
  "@callstack/repack-plugin-reanimated": "^5.1.3",
44
44
  "@inquirer/prompts": "^7.8.0",
45
45
  "@inquirer/select": "^4.3.1",
46
+ "@module-federation/dts-plugin": "^0.21.6",
47
+ "@module-federation/enhanced": "^0.21.6",
46
48
  "adm-zip": "0.5.10",
47
49
  "boxen": "^8.0.1",
48
50
  "chalk": "^5.4.1",
@@ -50,6 +52,7 @@
50
52
  "fs-extra": "^11.3.0",
51
53
  "gradient-string": "^3.0.0",
52
54
  "inquirer": "^12.9.0",
55
+ "loader-utils": "^3.3.1",
53
56
  "lodash": "^4.17.21",
54
57
  "md5": "^2.3.0",
55
58
  "ora": "^8.2.0",
@@ -61,6 +64,7 @@
61
64
  "superstatic": "^9.2.0",
62
65
  "systeminformation": "^5.27.7",
63
66
  "terser-webpack-plugin": "^5.3.14",
67
+ "typescript": "^5.9.3",
64
68
  "uuid": "^9.0.1",
65
69
  "webpack": "^5.101.0",
66
70
  "webpack-cli": "^6.0.1"
@@ -1,12 +1,44 @@
1
- import path from 'path';
1
+ import path from 'node:path';
2
2
  import fse from 'fs-extra';
3
- import { fileURLToPath } from 'url';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { getOptions } from 'loader-utils';
4
5
 
6
+ // ==================== 常量定义 ====================
5
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
- const sharedPkg = fse.readJSONSync(path.join(__dirname, '../shared-output.json'));
8
+ const nodeModulesPath = path.join(__dirname, '../node_modules');
9
+
10
+ // 预编译正则表达式以提高性能
11
+ const PNPM_PACKAGE_REGEX = /node_modules\/(?:\.pnpm\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)/;
12
+ const DEFAULT_EXPORT_REGEX = /var\s+_default\s*=\s*exports(?:\.default|\["default"\])\s*=/g;
13
+ const DEPENDENCY_PLACEHOLDER_REGEX = /__HTYF_DEPENDENCY__/g;
14
+ const LEADING_WHITESPACE_REGEX = /^[ \t]+/gm;
7
15
 
8
16
  // 白名单:这些包即使有 native 代码也不需要检查(系统级或已特殊处理)
9
- const WHITELIST = [];
17
+ const WHITELIST = new Set();
18
+
19
+ // 错误消息分隔线
20
+ const ERROR_SEPARATOR = '═══════════════════════════════════════';
21
+
22
+ // ==================== 缓存和配置 ====================
23
+ const versionCache = new Map();
24
+ const nativeCodeCache = new Map();
25
+
26
+ // 延迟加载共享配置,避免启动时错误
27
+ let sharedPkg = null;
28
+ function getSharedConfig() {
29
+ if (sharedPkg === null) {
30
+ try {
31
+ const configPath = path.join(__dirname, './shared-output.json');
32
+ sharedPkg = fse.pathExistsSync(configPath) ? fse.readJSONSync(configPath) : {};
33
+ } catch (error) {
34
+ console.warn(`[HtyfModulesPlugin] 读取共享配置失败: ${error.message}`);
35
+ sharedPkg = {};
36
+ }
37
+ }
38
+ return sharedPkg;
39
+ }
40
+
41
+ // ==================== 工具函数 ====================
10
42
 
11
43
  /**
12
44
  * 从模块路径中提取包名
@@ -14,41 +46,68 @@ const WHITELIST = [];
14
46
  * @returns {string|null} 包名,如果无法提取则返回 null
15
47
  */
16
48
  function extractPackageName(moduleContext) {
17
- if (!moduleContext || !moduleContext.includes('node_modules')) {
49
+ if (!moduleContext?.includes('node_modules')) {
18
50
  return null;
19
51
  }
20
52
 
21
- const parts = moduleContext.split('node_modules/');
22
- if (parts.length < 2) {
23
- return null;
24
- }
53
+ const match = moduleContext.match(PNPM_PACKAGE_REGEX);
54
+ return match?.[1] || null;
55
+ }
25
56
 
26
- const packagePath = parts[1];
27
- const segments = packagePath.split('/').filter(Boolean);
28
-
29
- if (segments.length === 0) {
30
- return null;
57
+ /**
58
+ * 检查包是否包含 native 代码(带缓存)
59
+ * @param {string} packageName - 包名
60
+ * @returns {boolean} 是否包含 native 代码
61
+ */
62
+ function hasNativeCode(packageName) {
63
+ if (!packageName) {
64
+ return false;
31
65
  }
32
66
 
33
- // 处理 scoped packages (@scope/package)
34
- if (segments[0].startsWith('@') && segments.length >= 2) {
35
- return `${segments[0]}/${segments[1]}`;
67
+ // 使用缓存避免重复的文件系统访问
68
+ if (nativeCodeCache.has(packageName)) {
69
+ return nativeCodeCache.get(packageName);
36
70
  }
37
71
 
38
- return segments[0];
72
+ const packagePath = path.join(nodeModulesPath, packageName);
73
+ const iosPath = path.join(packagePath, 'ios');
74
+ const androidPath = path.join(packagePath, 'android');
75
+
76
+ const hasNative = fse.pathExistsSync(iosPath) || fse.pathExistsSync(androidPath);
77
+ nativeCodeCache.set(packageName, hasNative);
78
+
79
+ return hasNative;
39
80
  }
40
81
 
41
82
  /**
42
- * 检查包是否包含 native 代码
83
+ * 获取包版本(带缓存)
43
84
  * @param {string} packageName - 包名
44
- * @returns {boolean} 是否包含 native 代码
85
+ * @returns {string|null} 包版本,如果无法获取则返回 null
45
86
  */
46
- function hasNativeCode(packageName) {
47
- const nodeModulesPath = path.join(__dirname, '../node_modules');
48
- const iosPath = path.join(nodeModulesPath, packageName, 'ios');
49
- const androidPath = path.join(nodeModulesPath, packageName, 'android');
50
-
51
- return fse.pathExistsSync(iosPath) || fse.pathExistsSync(androidPath);
87
+ function getPackageVersion(packageName) {
88
+ if (!packageName) {
89
+ return null;
90
+ }
91
+
92
+ if (versionCache.has(packageName)) {
93
+ return versionCache.get(packageName);
94
+ }
95
+
96
+ const pkgJsonPath = path.join(nodeModulesPath, packageName, 'package.json');
97
+ let version = null;
98
+
99
+ try {
100
+ if (fse.pathExistsSync(pkgJsonPath)) {
101
+ const pkgJson = fse.readJsonSync(pkgJsonPath);
102
+ version = pkgJson.version || null;
103
+ }
104
+ } catch (error) {
105
+ // 静默失败,返回 null
106
+ version = null;
107
+ }
108
+
109
+ versionCache.set(packageName, version);
110
+ return version;
52
111
  }
53
112
 
54
113
  /**
@@ -57,7 +116,8 @@ function hasNativeCode(packageName) {
57
116
  * @returns {boolean} 是否在共享配置中
58
117
  */
59
118
  function isInSharedConfig(packageName) {
60
- return sharedPkg && sharedPkg.hasOwnProperty(packageName);
119
+ const config = getSharedConfig();
120
+ return packageName && Object.hasOwn(config, packageName);
61
121
  }
62
122
 
63
123
  /**
@@ -66,7 +126,7 @@ function isInSharedConfig(packageName) {
66
126
  * @returns {boolean} 是否在白名单中
67
127
  */
68
128
  function isWhitelisted(packageName) {
69
- return WHITELIST.includes(packageName);
129
+ return packageName && WHITELIST.has(packageName);
70
130
  }
71
131
 
72
132
  /**
@@ -75,28 +135,66 @@ function isWhitelisted(packageName) {
75
135
  * @returns {string} 格式化的错误消息
76
136
  */
77
137
  function formatErrorMessage(unsupportedPackages) {
138
+ if (!unsupportedPackages?.length) {
139
+ return '';
140
+ }
141
+
78
142
  const lines = [
79
143
  '',
80
- '═══════════════════════════════════════',
144
+ ERROR_SEPARATOR,
81
145
  ' 红糖云服暂不支持以下第三方 native 组件',
82
- '═══════════════════════════════════════',
146
+ ERROR_SEPARATOR,
147
+ '',
148
+ ...unsupportedPackages.map((pkg, index) => ` ${index + 1}. ${pkg}`),
149
+ '',
150
+ ERROR_SEPARATOR,
83
151
  '',
84
152
  ];
85
153
 
86
- unsupportedPackages.forEach((pkg, index) => {
87
- lines.push(` ${index + 1}. ${pkg}`);
88
- });
89
-
90
- lines.push('');
91
- lines.push('═══════════════════════════════════════');
92
- lines.push('');
93
-
94
154
  return lines.join('\n');
95
155
  }
96
156
 
157
+ /**
158
+ * 去除模板字符串中的前导空白
159
+ * @param {TemplateStringsArray} strings - 模板字符串数组
160
+ * @param {...any} values - 插值变量
161
+ * @returns {string} 处理后的字符串
162
+ */
97
163
  function trimTemplateStrings(strings, ...values) {
98
- let fullString = strings.reduce((acc, str, i) => acc + str + (values[i] || ''), '');
99
- return fullString.replace(/^[ \t]+/gm, '');
164
+ const fullString = strings.reduce((acc, str, i) => acc + str + (values[i] || ''), '');
165
+ return fullString.replace(LEADING_WHITESPACE_REGEX, '');
166
+ }
167
+
168
+ // ==================== Webpack Loader ====================
169
+
170
+ /**
171
+ * Webpack自定义loader,用于在目标模块的源码前后插入额外的代码片段。
172
+ * 可通过options.prependCode / options.appendCode分别指定插入内容。
173
+ *
174
+ * @param {string|Buffer} source 原始模块内容
175
+ * @returns {string} 处理后的模块内容
176
+ */
177
+ export default function injectAppInfoLoader(source) {
178
+ this.cacheable?.();
179
+
180
+ // 将 Buffer 转换为字符串
181
+ const originalSource = Buffer.isBuffer(source) ? source.toString('utf-8') : source;
182
+
183
+ // 注入依赖信息属性
184
+ return `
185
+ ${originalSource}
186
+ try {
187
+ Object.defineProperty(_default, '__HTYF_MP_DEPENDENCY__', {
188
+ value: JSON.parse(__HTYF_DEPENDENCY_STRING_DATA__),
189
+ writable: false,
190
+ enumerable: false,
191
+ configurable: false
192
+ });
193
+ _default = exports.default = _default;
194
+ } catch (error) {
195
+ console.error(error);
196
+ }
197
+ `;
100
198
  }
101
199
 
102
200
  export class HtyfModulesPlugin {
@@ -104,42 +202,31 @@ export class HtyfModulesPlugin {
104
202
  this.config = appConfig;
105
203
  }
106
204
 
205
+ /**
206
+ * 应用插件到 Webpack 编译器
207
+ * @param {Compiler} compiler - Webpack 编译器实例
208
+ */
107
209
  apply(compiler) {
108
210
  const config = this.config;
109
211
 
110
- // 分析第三方 native 模块
212
+ // 分析第三方依赖并生成报告
111
213
  compiler.hooks.emit.tapAsync('AnalyzeThirdPartyModulesPlugin', (compilation, callback) => {
112
214
  try {
113
- const nativeModules = new Set();
215
+ const { dependencies, nativeModules } = this.collectDependencyInfo(compilation);
114
216
 
115
- // 收集所有包含 native 代码的第三方模块
116
- compilation.modules.forEach(module => {
117
- if (!module.context) {
118
- return;
119
- }
217
+ // 获取 manifest 路径,如果未配置则使用默认路径(webpack output 路径下的 manifest.json)
218
+ const outputPath = compilation.outputOptions.path || compiler.options.output?.path;
219
+ const manifestPath = this.config.manifest || (outputPath ? path.join(outputPath, 'manifest.json') : null);
120
220
 
121
- const packageName = extractPackageName(module.context);
122
- if (!packageName) {
123
- return;
124
- }
221
+ // 持久化依赖报告
222
+ this.persistDependencyReport(dependencies, manifestPath);
125
223
 
126
- if (hasNativeCode(packageName)) {
127
- nativeModules.add(packageName);
128
- }
129
- });
130
-
131
- // 检查不支持的包
132
- const unsupportedPackages = Array.from(nativeModules).filter(pkg => {
133
- // 跳过白名单中的包
134
- if (isWhitelisted(pkg)) {
135
- return false;
136
- }
224
+ // 筛选出不支持的 native 包
225
+ const unsupportedPackages = Array.from(nativeModules).filter(
226
+ pkg => !isWhitelisted(pkg) && !isInSharedConfig(pkg)
227
+ );
137
228
 
138
- // 如果不在共享配置中,则不支持
139
- return !isInSharedConfig(pkg);
140
- });
141
-
142
- // 如果有不支持的包,抛出错误
229
+ // 如果存在不支持的包,抛出错误
143
230
  if (unsupportedPackages.length > 0) {
144
231
  const errorMessage = formatErrorMessage(unsupportedPackages);
145
232
  console.error(errorMessage);
@@ -152,6 +239,8 @@ export class HtyfModulesPlugin {
152
239
  return;
153
240
  }
154
241
 
242
+ // 替换依赖占位符
243
+ this.replaceDependencyPlaceholders(compilation, dependencies);
155
244
  callback();
156
245
  } catch (error) {
157
246
  callback(error);
@@ -163,22 +252,22 @@ export class HtyfModulesPlugin {
163
252
  compiler.hooks.emit.tapAsync('WrapCodePlugin', (compilation, callback) => {
164
253
  try {
165
254
  // 遍历所有编译过的资源文件
166
- for (const filename in compilation.assets) {
167
- if (compilation.assets.hasOwnProperty(filename)) {
168
- // 获取文件内容
169
- const source = compilation.assets[filename].source();
255
+ for (const filename of Object.keys(compilation.assets)) {
256
+ const asset = compilation.assets[filename];
257
+ if (!asset || typeof asset.source !== 'function') {
258
+ continue;
259
+ }
170
260
 
171
- // 包裹代码
172
- const wrappedSource = trimTemplateStrings`
261
+ const source = asset.source();
262
+ const wrappedSource = trimTemplateStrings`
173
263
  ${source}
174
- `;
264
+ `;
175
265
 
176
- // 更新文件内容
177
- compilation.assets[filename] = {
178
- source: () => wrappedSource,
179
- size: () => wrappedSource.length,
180
- };
181
- }
266
+ // 更新文件内容
267
+ compilation.assets[filename] = {
268
+ source: () => wrappedSource,
269
+ size: () => wrappedSource.length,
270
+ };
182
271
  }
183
272
  callback();
184
273
  } catch (error) {
@@ -187,4 +276,116 @@ export class HtyfModulesPlugin {
187
276
  });
188
277
  }
189
278
  }
279
+
280
+ /**
281
+ * 收集依赖信息
282
+ * @param {Compilation} compilation - Webpack 编译对象
283
+ * @returns {{dependencies: Object, nativeModules: Set<string>}} 依赖信息
284
+ */
285
+ collectDependencyInfo(compilation) {
286
+ const dependencyMap = new Map();
287
+ const nativeModules = new Set();
288
+
289
+ for (const module of compilation.modules) {
290
+ if (!module.context) {
291
+ continue;
292
+ }
293
+
294
+ const packageName = extractPackageName(module.context);
295
+ if (!packageName) {
296
+ continue;
297
+ }
298
+
299
+ // 如果已经处理过该包,跳过
300
+ if (dependencyMap.has(packageName)) {
301
+ const existing = dependencyMap.get(packageName);
302
+ // 如果之前不是 native 模块,再次检查(可能之前检查失败)
303
+ if (!existing.nativeModule && hasNativeCode(packageName)) {
304
+ existing.nativeModule = true;
305
+ nativeModules.add(packageName);
306
+ }
307
+ continue;
308
+ }
309
+
310
+ // 首次处理该包
311
+ const nativeModule = hasNativeCode(packageName);
312
+ const dependencyInfo = {
313
+ name: packageName,
314
+ version: getPackageVersion(packageName),
315
+ nativeModule,
316
+ };
317
+
318
+ dependencyMap.set(packageName, dependencyInfo);
319
+
320
+ if (nativeModule) {
321
+ nativeModules.add(packageName);
322
+ }
323
+ }
324
+
325
+ // 将 Map 转换为普通对象
326
+ const dependencies = Object.fromEntries(dependencyMap);
327
+
328
+ return {
329
+ dependencies,
330
+ nativeModules,
331
+ };
332
+ }
333
+
334
+ /**
335
+ * 持久化依赖报告到文件
336
+ * @param {Object} dependencies - 依赖信息对象
337
+ * @param {string|null} manifestPath - manifest 文件路径,如果未提供则使用配置中的 manifest
338
+ */
339
+ persistDependencyReport(dependencies = {}, manifestPath = null) {
340
+ const manifest = manifestPath || this.config.manifest;
341
+ if (!manifest) {
342
+ return;
343
+ }
344
+
345
+ try {
346
+ const reportDir = path.dirname(manifest);
347
+ fse.ensureDirSync(reportDir);
348
+ fse.writeJsonSync(
349
+ manifest,
350
+ { packages: dependencies },
351
+ { spaces: 2 }
352
+ );
353
+ } catch (error) {
354
+ console.warn(`[HtyfModulesPlugin] 写入依赖报告失败: ${error.message}`);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * 替换依赖占位符
360
+ * @param {Compilation} compilation - Webpack 编译对象
361
+ * @param {Object} dependencies - 依赖信息对象
362
+ */
363
+ replaceDependencyPlaceholders(compilation, dependencies = {}) {
364
+ // 将依赖信息序列化为 JSON 字符串,并进行安全的字符串转义
365
+ const jsonString = JSON.stringify(dependencies);
366
+ // 转义反斜杠和单引号,用于嵌入到单引号字符串中
367
+ const escapedString = jsonString
368
+ .replace(/\\/g, '\\\\') // 转义反斜杠
369
+ .replace(/'/g, "\\'"); // 转义单引号
370
+ const stringLiteral = `'${escapedString}'`;
371
+
372
+ // 遍历所有资源文件,替换占位符
373
+ for (const filename of Object.keys(compilation.assets)) {
374
+ const asset = compilation.assets[filename];
375
+ if (!asset || typeof asset.source !== 'function') {
376
+ continue;
377
+ }
378
+
379
+ const source = asset.source().toString();
380
+ if (!source.includes('__HTYF_DEPENDENCY_STRING_DATA__')) {
381
+ continue;
382
+ }
383
+
384
+ const replacedSource = source.replace(DEPENDENCY_PLACEHOLDER_REGEX, stringLiteral);
385
+ compilation.assets[filename] = {
386
+ source: () => replacedSource,
387
+ size: () => replacedSource.length,
388
+ };
389
+ }
390
+ }
190
391
  }
package/src/build.mjs CHANGED
@@ -1,16 +1,18 @@
1
1
  import path from 'path';
2
- import fs from 'fs';
3
2
  import fse from 'fs-extra';
4
3
  import AdmZip from 'adm-zip';
4
+ import { fileURLToPath } from 'node:url';
5
5
  import { Logger } from './logger.mjs';
6
6
  import { CONSTANTS } from './constants.mjs';
7
7
  import { FileSystemUtils } from './file-system.mjs';
8
8
  import { CommandExecutor } from './command-executor.mjs';
9
9
  import { getMiniAppScriptId } from './utils-functions.mjs';
10
- import { fileURLToPath } from 'node:url';
10
+ import { getProjectRoot, guessGodotProjectDir } from './utils.mjs';
11
+ import { exportGodot, promptGodotOptions } from './export_godot.mjs';
11
12
 
12
13
  const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = path.dirname(__filename);
15
+ const PROJECT_ROOT = getProjectRoot();
14
16
 
15
17
  /**
16
18
  * 压缩文件夹为zip文件
@@ -22,16 +24,16 @@ export async function handleZip(inputPath, outputPath) {
22
24
  Logger.info(`开始压缩文件夹: ${inputPath}`);
23
25
  Logger.info(`输出文件: ${outputPath}`);
24
26
 
25
- if (!fs.existsSync(inputPath)) {
27
+ if (!(await fse.pathExists(inputPath))) {
26
28
  throw new Error(`输入路径不存在: ${inputPath}`);
27
29
  }
28
30
 
29
- const stats = fs.statSync(inputPath);
31
+ const stats = await fse.stat(inputPath);
30
32
  if (!stats.isDirectory()) {
31
33
  throw new Error(`输入路径不是目录: ${inputPath}`);
32
34
  }
33
35
 
34
- const files = fs.readdirSync(inputPath);
36
+ const files = await fse.readdir(inputPath);
35
37
  Logger.debug(`目录内容: ${files.length} 个文件/文件夹`);
36
38
 
37
39
  if (files.length === 0) {
@@ -56,7 +58,7 @@ export async function handleZip(inputPath, outputPath) {
56
58
  await admzip.writeZipPromise(zipPath);
57
59
  Logger.success(`压缩完成: ${zipPath}`);
58
60
 
59
- const zipStats = fs.statSync(zipPath);
61
+ const zipStats = await fse.stat(zipPath);
60
62
  Logger.info(`ZIP文件大小: ${FileSystemUtils.formatFileSize(zipStats.size)}`);
61
63
 
62
64
  return zipPath;
@@ -71,7 +73,7 @@ export async function handleZip(inputPath, outputPath) {
71
73
  * @param {object} newAppInfo - 应用信息
72
74
  * @returns {Promise<string>} 压缩包路径
73
75
  */
74
- export async function mpBuildShell(newAppInfo) {
76
+ export async function mpBuildShell(newAppInfo, isGodot) {
75
77
  Logger.info(`开始构建小程序包...`);
76
78
  Logger.info(`应用: ${newAppInfo.name}`);
77
79
  Logger.info(`应用ID: ${newAppInfo.appid}`);
@@ -94,22 +96,22 @@ export async function mpBuildShell(newAppInfo) {
94
96
  throw new Error('无法创建输出目录');
95
97
  }
96
98
 
97
- const version = newAppInfo.version;
98
- const appid = newAppInfo.appid;
99
+ const { version, appid } = newAppInfo;
99
100
  const scriptName = getMiniAppScriptId(appid, version);
100
101
 
101
102
  const appJson = {
102
103
  type: 'app',
103
104
  engines: '2.0.0',
104
105
  name: newAppInfo.name,
105
- appid: appid,
106
- version: version,
106
+ appid,
107
+ version,
107
108
  appUrlConfig: newAppInfo.appUrlConfig || `${newAppInfo.host}/app.json`,
108
109
  zipUrl: newAppInfo.zipUrl || `${newAppInfo.host}/dist.dgz`
109
110
  };
110
111
 
111
- const outputPath = path.join(mpOutputPath, './dist');
112
- const rootIndexPath = path.join(tempPath, '../index.js');
112
+ let outputPath = path.join(mpOutputPath, 'dist');
113
+ const distPackagePath = path.join(mpOutputPath, 'dist.dgz');
114
+ const rootIndexPath = path.join(tempPath, '..', 'index.js');
113
115
 
114
116
  const mpOptions = {
115
117
  name: scriptName,
@@ -117,25 +119,46 @@ export async function mpBuildShell(newAppInfo) {
117
119
  exposes: {
118
120
  'App': newAppInfo.entry,
119
121
  },
120
- outputPath: path.join(mpOutputPath, './build'),
122
+ outputPath: path.join(mpOutputPath, 'build'),
121
123
  extraChunksPath: outputPath,
124
+ manifest: path.join(mpOutputPath, 'build'),
122
125
  };
123
126
 
124
127
  const appJsonPath = path.join(mpOutputPath, 'app.json');
125
- fs.writeFileSync(appJsonPath, JSON.stringify(appJson, undefined, 2));
128
+ await fse.writeJson(appJsonPath, appJson, { spaces: 2 });
126
129
  const webpackConfigPath = path.join(__dirname, 'webpack.config.mjs');
127
130
 
128
- const command = `npx cross-env APP_ROOT_INDEX_PATH=${rootIndexPath} APP_EXPOSES_OPTIONS=${encodeURI(JSON.stringify(mpOptions))} webpack --config ${webpackConfigPath}`;
129
131
 
130
132
  try {
131
- await CommandExecutor.execute(command, {
132
- description: '构建小程序包',
133
- timeout: 120000,
134
- showCommand: false
135
- });
136
-
137
- fs.copyFileSync(appJsonPath, path.join(outputPath, `./app.json`));
138
- const zipPath = await handleZip(outputPath, path.join(outputPath, `../dist.dgz`));
133
+ if (!isGodot) {
134
+ const command = `npx cross-env APP_ROOT_INDEX_PATH=${rootIndexPath} APP_EXPOSES_OPTIONS=${encodeURI(JSON.stringify(mpOptions))} webpack --config ${webpackConfigPath}`;
135
+ await CommandExecutor.execute(command, {
136
+ description: '构建小程序包',
137
+ timeout: 120000,
138
+ showCommand: false
139
+ });
140
+ } else {
141
+ Logger.info('检测到 Godot 项目,开始执行 Godot 导出流程...');
142
+ const defaultPlatform = ['android', 'ios'].includes(CONSTANTS.BUNDLE_PLATFORM)
143
+ ? CONSTANTS.BUNDLE_PLATFORM
144
+ : 'ios';
145
+ const godotDefaults = {
146
+ targetBaseDir: outputPath,
147
+ projectDir: guessGodotProjectDir(PROJECT_ROOT),
148
+ name: newAppInfo.name || 'GodotApp',
149
+ preset: defaultPlatform === 'android' ? 'Android' : 'iOS',
150
+ platform: defaultPlatform
151
+ };
152
+ const godotOptions = await promptGodotOptions(godotDefaults);
153
+ outputPath = path.resolve(godotOptions.targetBaseDir);
154
+ await fse.ensureDir(outputPath);
155
+ await fse.emptyDir(outputPath);
156
+ await exportGodot(godotOptions);
157
+ }
158
+
159
+ await fse.ensureDir(outputPath);
160
+ await fse.copy(appJsonPath, path.join(outputPath, 'app.json'));
161
+ const zipPath = await handleZip(outputPath, distPackagePath);
139
162
  Logger.success(`压缩包已创建: ${zipPath}`);
140
163
  return zipPath;
141
164
  } catch (error) {
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { accessSync, constants as fsConstants } from 'node:fs';
6
+ import process from 'node:process';
7
+ import { homedir } from 'node:os';
8
+ import fs from 'fs-extra';
9
+ import AdmZip from 'adm-zip';
10
+ import { execa } from 'execa';
11
+ import inquirer from 'inquirer';
12
+
13
+ const DEFAULT_GODOT_BIN = '/Applications/Godot.app/Contents/MacOS/Godot';
14
+ const GODOT_CACHE_DIR = path.join(homedir() || process.cwd(), '.htyf');
15
+ const GODOT_CACHE_FILE = path.join(GODOT_CACHE_DIR, 'godot-config.json');
16
+
17
+ const PLATFORMS = {
18
+ IOS: 'ios',
19
+ ANDROID: 'android'
20
+ };
21
+
22
+ const PRESET_NAMES = {
23
+ [PLATFORMS.IOS]: 'iOS',
24
+ [PLATFORMS.ANDROID]: 'Android'
25
+ };
26
+
27
+ export async function promptGodotOptions(defaults = {}) {
28
+ const answers = await inquirer.prompt([
29
+ {
30
+ type: 'input',
31
+ name: 'targetBaseDir',
32
+ message: '请输入导出目标根目录:',
33
+ default: defaults.targetBaseDir || path.resolve(process.cwd(), './dist'),
34
+ validate: (input) => !!input.trim() || '目标根目录不能为空'
35
+ },
36
+ {
37
+ type: 'input',
38
+ name: 'projectDir',
39
+ message: '请输入 Godot 项目目录:',
40
+ default: defaults.projectDir || process.cwd(),
41
+ validate: (input) => !!input.trim() || '项目目录不能为空'
42
+ },
43
+ {
44
+ type: 'input',
45
+ name: 'name',
46
+ message: '请输入导出文件名称 (不含扩展名):',
47
+ default: defaults.name || 'GodotExport',
48
+ validate: (input) => !!input.trim() || '导出文件名称不能为空'
49
+ },
50
+ {
51
+ type: 'input',
52
+ name: 'preset',
53
+ message: '请输入 Godot 导出预设名称:',
54
+ default: defaults.preset || '',
55
+ validate: (input) => !!input.trim() || '导出预设名称不能为空'
56
+ },
57
+ {
58
+ type: 'list',
59
+ name: 'platform',
60
+ message: '请选择导出平台:',
61
+ choices: [
62
+ { name: 'iOS', value: PLATFORMS.IOS },
63
+ { name: 'Android', value: PLATFORMS.ANDROID }
64
+ ],
65
+ default: defaults.platform || 'ios'
66
+ }
67
+ ]);
68
+
69
+ return {
70
+ targetBaseDir: answers.targetBaseDir.trim(),
71
+ projectDir: answers.projectDir.trim(),
72
+ name: answers.name.trim(),
73
+ preset: answers.preset.trim(),
74
+ platform: answers.platform
75
+ };
76
+ }
77
+
78
+ function isExecutable(filePath) {
79
+ if (!filePath) return false;
80
+ if (!fs.existsSync(filePath)) return false;
81
+
82
+ try {
83
+ accessSync(filePath, fsConstants.X_OK);
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ function readCachedGodotPath() {
91
+ try {
92
+ if (!fs.existsSync(GODOT_CACHE_FILE)) {
93
+ return '';
94
+ }
95
+ const data = fs.readJsonSync(GODOT_CACHE_FILE);
96
+ return typeof data?.godotPath === 'string' ? data.godotPath : '';
97
+ } catch {
98
+ return '';
99
+ }
100
+ }
101
+
102
+ async function writeCachedGodotPath(godotPath) {
103
+ try {
104
+ await fs.ensureDir(GODOT_CACHE_DIR);
105
+ await fs.writeJson(GODOT_CACHE_FILE, { godotPath }, { spaces: 2 });
106
+ } catch {
107
+ // cache failures should not block execution
108
+ }
109
+ }
110
+
111
+ async function promptForGodotBinaryPath(defaultPath) {
112
+ const { godotPath } = await inquirer.prompt([
113
+ {
114
+ type: 'input',
115
+ name: 'godotPath',
116
+ message: '请输入 Godot Editor 可执行文件路径:',
117
+ default: defaultPath,
118
+ validate: (input) => !!input.trim() || '路径不能为空'
119
+ }
120
+ ]);
121
+
122
+ return godotPath.trim();
123
+ }
124
+
125
+ async function resolveGodotBinary() {
126
+ const override = process.env.GODOT_EDITOR ? process.env.GODOT_EDITOR.trim() : '';
127
+ if (override && isExecutable(override)) {
128
+ return override;
129
+ }
130
+
131
+ const cached = readCachedGodotPath();
132
+ if (cached && isExecutable(cached)) {
133
+ return cached;
134
+ }
135
+
136
+ if (isExecutable(DEFAULT_GODOT_BIN)) {
137
+ await writeCachedGodotPath(DEFAULT_GODOT_BIN);
138
+ return DEFAULT_GODOT_BIN;
139
+ }
140
+
141
+ const fallbackDefault = override || cached || DEFAULT_GODOT_BIN;
142
+ const godotPath = await promptForGodotBinaryPath(fallbackDefault);
143
+
144
+ if (!isExecutable(godotPath)) {
145
+ throw new Error(`Godot 二进制文件不可执行: ${godotPath}`);
146
+ }
147
+
148
+ await writeCachedGodotPath(godotPath);
149
+ return godotPath;
150
+ }
151
+
152
+ async function runGodotCommand(godotBin, args) {
153
+ await execa(godotBin, args, { stdio: 'inherit' });
154
+ }
155
+
156
+ async function handleAndroidExport({ godotBin, projectDir, preset, targetBaseDir, name }) {
157
+ const assetsBaseDir = path.join(targetBaseDir, 'android', 'app', 'src', 'main', 'assets');
158
+ await fs.ensureDir(assetsBaseDir);
159
+
160
+ const zipPath = path.join(assetsBaseDir, `${name}.zip`);
161
+ await runGodotCommand(godotBin, ['--headless', '--path', projectDir, '--export-pack', preset, zipPath]);
162
+
163
+ const targetDir = path.join(assetsBaseDir, name);
164
+ await fs.remove(targetDir);
165
+ await fs.ensureDir(targetDir);
166
+
167
+ const zip = new AdmZip(zipPath);
168
+ zip.extractAllTo(targetDir, true);
169
+ await fs.remove(zipPath);
170
+ }
171
+
172
+ async function handleIosExport({ godotBin, projectDir, preset, targetBaseDir, name }) {
173
+ const assetsBaseDir = path.join(targetBaseDir, 'ios');
174
+ await fs.ensureDir(assetsBaseDir);
175
+ const outputPath = path.join(assetsBaseDir, `${name}.pck`);
176
+ await runGodotCommand(godotBin, ['--headless', '--path', projectDir, '--export-pack', preset, outputPath]);
177
+ }
178
+
179
+ export async function exportGodot(options) {
180
+ const godotBin = await resolveGodotBinary();
181
+ const { platform, projectDir, preset, targetBaseDir, name } = options;
182
+
183
+ // 执行两次 import 以确保资源正确导入
184
+ const importArgs = ['--headless', '--path', projectDir, '--import'];
185
+ await runGodotCommand(godotBin, importArgs);
186
+ await runGodotCommand(godotBin, importArgs);
187
+
188
+ switch (platform) {
189
+ case PLATFORMS.IOS:
190
+ await handleIosExport({ godotBin, projectDir, preset, targetBaseDir, name });
191
+ break;
192
+ case PLATFORMS.ANDROID:
193
+ await handleAndroidExport({ godotBin, projectDir, preset, targetBaseDir, name });
194
+ break;
195
+ default:
196
+ throw new Error(`Unsupported platform: ${platform}. Supported: ${Object.values(PLATFORMS).join(', ')}`);
197
+ }
198
+ }
199
+
200
+ async function main() {
201
+ const options = await promptGodotOptions();
202
+ await exportGodot(options);
203
+ }
204
+
205
+ const isCliExecution = (() => {
206
+ if (!process.argv[1]) {
207
+ return false;
208
+ }
209
+ const cliUrl = pathToFileURL(process.argv[1]).href;
210
+ return cliUrl === import.meta.url;
211
+ })();
212
+
213
+ if (isCliExecution) {
214
+ main().catch((error) => {
215
+ console.error(error.message || error);
216
+ process.exitCode = 1;
217
+ });
218
+ }
219
+
package/src/index.mjs CHANGED
@@ -136,7 +136,10 @@ async function Start(action) {
136
136
 
137
137
  // 显示应用选择界面
138
138
  try {
139
- const appInfo = fse.readJsonSync(path.join(projectPath, 'app.json')).htyf;
139
+ const appConfigPath = path.join(projectPath, 'app.json');
140
+ const appInfo = fse.readJsonSync(appConfigPath).htyf;
141
+ const isGodot = fse.existsSync(path.join(projectPath, 'game.godot'));
142
+
140
143
  if (!appInfo) {
141
144
  Logger.error('应用配置不存在,请先在app.json中配置htyf');
142
145
  return;
@@ -185,10 +188,10 @@ async function Start(action) {
185
188
 
186
189
  switch (action) {
187
190
  case ACTION_TYPES.MP_BUILD:
188
- await mpBuildShell(newAppInfo);
191
+ await mpBuildShell(newAppInfo, isGodot);
189
192
  break;
190
193
  case ACTION_TYPES.MP_DEBUG:
191
- await mpDebugShell(newAppInfo);
194
+ await mpDebugShell(newAppInfo, isGodot);
192
195
  break;
193
196
  default:
194
197
  Logger.error(`未知的操作类型: ${action}`);
package/src/utils.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from 'path';
2
+ import fs from 'fs';
2
3
  import { fileURLToPath } from 'url';
3
4
 
4
5
  const __filename = fileURLToPath(import.meta.url);
@@ -9,6 +10,28 @@ const __dirname = path.dirname(__filename);
9
10
  * @returns {string} 项目根目录路径
10
11
  */
11
12
  export function getProjectRoot() {
12
- let currentPath = process.cwd() || __dirname;
13
- return currentPath;
13
+ return process.cwd() || __dirname;
14
+ }
15
+
16
+ /**
17
+ * 猜测 Godot 项目目录
18
+ * @param {string} rootDir - 根目录
19
+ * @returns {string} Godot 项目目录路径
20
+ */
21
+ export function guessGodotProjectDir(rootDir = getProjectRoot()) {
22
+ const candidates = [
23
+ rootDir,
24
+ path.join(rootDir, 'project'),
25
+ path.join(rootDir, 'game'),
26
+ path.join(rootDir, 'godot')
27
+ ];
28
+
29
+ for (const dir of candidates) {
30
+ const projectFile = path.join(dir, 'project.godot');
31
+ const gameFile = path.join(dir, 'game.godot');
32
+ if (fs.existsSync(projectFile) || fs.existsSync(gameFile)) {
33
+ return dir;
34
+ }
35
+ }
36
+ return rootDir;
14
37
  }
@@ -156,7 +156,10 @@ export default (env = {}) => {
156
156
  shareStrategy: 'loaded-first',
157
157
  }),
158
158
  new HtyfModulesPlugin({
159
- codeSigning: true
159
+ codeSigning: true,
160
+ appid: mpOptions.appid,
161
+ version: mpOptions.version,
162
+ manifest: mpOptions.manifest,
160
163
  }),
161
164
  new webpack.optimize.LimitChunkCountPlugin({
162
165
  maxChunks: 2, // 限制最大chunk数,便于远程加载