@htyf-mp/cli 0.2.0-alpha.9 → 0.2.1

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.9",
3
+ "version": "0.2.1",
4
4
  "description": "红糖云服app小程序模板-cli",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fse from 'fs-extra';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { getOptions } from 'loader-utils';
5
4
 
6
5
  // ==================== 常量定义 ====================
7
6
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
package/src/build.mjs CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * 构建模块
3
+ * 负责小程序和 Godot 游戏的打包构建
4
+ *
5
+ * @module build
6
+ */
7
+
1
8
  import path from 'path';
2
9
  import fse from 'fs-extra';
3
10
  import AdmZip from 'adm-zip';
@@ -10,29 +17,40 @@ import { getMiniAppScriptId } from './utils-functions.mjs';
10
17
  import { getProjectRoot, guessGodotProjectDir } from './utils.mjs';
11
18
  import { exportGodot, promptGodotOptions } from './export_godot.mjs';
12
19
 
20
+ // 获取当前文件所在目录
13
21
  const __filename = fileURLToPath(import.meta.url);
14
22
  const __dirname = path.dirname(__filename);
15
23
  const PROJECT_ROOT = getProjectRoot();
16
24
 
17
25
  /**
18
- * 压缩文件夹为zip文件
19
- * @param {string} inputPath - 输入路径
20
- * @param {string} outputPath - 输出路径
21
- * @returns {Promise<string>} 压缩包路径
26
+ * 压缩文件夹为 ZIP 文件
27
+ *
28
+ * 将指定目录压缩为 ZIP 格式,自动过滤 .map 文件(源码映射文件)
29
+ *
30
+ * @param {string} inputPath - 要压缩的目录路径
31
+ * @param {string} outputPath - 输出的 ZIP 文件路径
32
+ * @returns {Promise<string>} 压缩包完整路径
33
+ * @throws {Error} 当输入路径不存在或不是目录时抛出错误
34
+ *
35
+ * @example
36
+ * const zipPath = await handleZip('./dist', './dist.dgz');
22
37
  */
23
38
  export async function handleZip(inputPath, outputPath) {
24
39
  Logger.info(`开始压缩文件夹: ${inputPath}`);
25
40
  Logger.info(`输出文件: ${outputPath}`);
26
41
 
42
+ // 验证输入路径是否存在
27
43
  if (!(await fse.pathExists(inputPath))) {
28
44
  throw new Error(`输入路径不存在: ${inputPath}`);
29
45
  }
30
46
 
47
+ // 验证是否为目录
31
48
  const stats = await fse.stat(inputPath);
32
49
  if (!stats.isDirectory()) {
33
50
  throw new Error(`输入路径不是目录: ${inputPath}`);
34
51
  }
35
52
 
53
+ // 检查目录是否为空
36
54
  const files = await fse.readdir(inputPath);
37
55
  Logger.debug(`目录内容: ${files.length} 个文件/文件夹`);
38
56
 
@@ -44,6 +62,7 @@ export async function handleZip(inputPath, outputPath) {
44
62
  const admzip = new AdmZip();
45
63
 
46
64
  try {
65
+ // 添加目录到 ZIP,过滤 .map 文件(源码映射文件,不需要打包)
47
66
  await admzip.addLocalFolderPromise(inputPath, {
48
67
  filter: (filename) => {
49
68
  if (filename.endsWith('.map')) {
@@ -55,9 +74,11 @@ export async function handleZip(inputPath, outputPath) {
55
74
  }
56
75
  });
57
76
 
77
+ // 写入 ZIP 文件
58
78
  await admzip.writeZipPromise(zipPath);
59
79
  Logger.success(`压缩完成: ${zipPath}`);
60
80
 
81
+ // 显示压缩包大小
61
82
  const zipStats = await fse.stat(zipPath);
62
83
  Logger.info(`ZIP文件大小: ${FileSystemUtils.formatFileSize(zipStats.size)}`);
63
84
 
@@ -70,37 +91,64 @@ export async function handleZip(inputPath, outputPath) {
70
91
 
71
92
  /**
72
93
  * 构建小程序包
73
- * @param {object} newAppInfo - 应用信息
74
- * @returns {Promise<string>} 压缩包路径
94
+ *
95
+ * 支持两种类型的项目构建:
96
+ * 1. 普通小程序项目:使用 webpack 打包
97
+ * 2. Godot 游戏项目:使用 Godot 导出工具打包
98
+ *
99
+ * @param {object} newAppInfo - 应用信息对象
100
+ * @param {string} newAppInfo.name - 应用名称
101
+ * @param {string} newAppInfo.appid - 应用ID
102
+ * @param {string} newAppInfo.version - 应用版本
103
+ * @param {string} [newAppInfo.entry] - 入口文件(普通小程序必需)
104
+ * @param {string} [newAppInfo.host] - 服务器地址
105
+ * @param {string} [newAppInfo.appUrlConfig] - 应用配置URL
106
+ * @param {string} [newAppInfo.zipUrl] - 压缩包URL
107
+ * @param {boolean} [isGodot=false] - 是否为 Godot 项目
108
+ * @returns {Promise<string>} 压缩包完整路径
109
+ * @throws {Error} 当必需字段缺失或构建失败时抛出错误
110
+ *
111
+ * @example
112
+ * const zipPath = await mpBuildShell({
113
+ * name: '我的应用',
114
+ * appid: 'com.example.app',
115
+ * version: '1.0.0',
116
+ * entry: './src/index.js'
117
+ * }, false);
75
118
  */
76
- export async function mpBuildShell(newAppInfo, isGodot) {
119
+ export async function mpBuildShell(newAppInfo, isGodot = false) {
77
120
  Logger.info(`开始构建小程序包...`);
78
121
  Logger.info(`应用: ${newAppInfo.name}`);
79
122
  Logger.info(`应用ID: ${newAppInfo.appid}`);
80
123
  Logger.info(`版本: ${newAppInfo.version}`);
81
124
  Logger.info(`平台: ${CONSTANTS.BUNDLE_PLATFORM}`);
125
+ Logger.info(`项目类型: ${isGodot ? 'Godot 游戏' : '普通小程序'}`);
82
126
 
83
- // 验证必需字段
127
+ // ========== 参数验证 ==========
84
128
  if (!newAppInfo.name || !newAppInfo.appid || !newAppInfo.version) {
85
129
  throw new Error('缺少必需的应用信息: name, appid, version');
86
130
  }
87
131
 
88
- // 创建临时目录
132
+ // ========== 目录准备 ==========
133
+ // 创建临时目录(用于存放构建过程中的临时文件)
89
134
  const tempPath = CONSTANTS.TEMP_DIR;
90
135
  if (!FileSystemUtils.ensureDirectory(tempPath)) {
91
136
  throw new Error('无法创建临时目录');
92
137
  }
93
138
 
139
+ // 创建输出目录(最终构建产物存放位置)
94
140
  const mpOutputPath = CONSTANTS.MP_OUTPUT_DIR;
95
141
  if (!FileSystemUtils.ensureDirectory(mpOutputPath)) {
96
142
  throw new Error('无法创建输出目录');
97
143
  }
98
144
 
145
+ // ========== 应用配置准备 ==========
99
146
  const { version, appid } = newAppInfo;
100
147
  const scriptName = getMiniAppScriptId(appid, version);
101
148
 
149
+ // 构建 app.json 配置
102
150
  const appJson = {
103
- type: 'app',
151
+ type: isGodot ? 'game' : 'app', // Godot 项目类型为 'game',普通小程序为 'app'
104
152
  engines: '2.0.0',
105
153
  name: newAppInfo.name,
106
154
  appid,
@@ -109,55 +157,105 @@ export async function mpBuildShell(newAppInfo, isGodot) {
109
157
  zipUrl: newAppInfo.zipUrl || `${newAppInfo.host}/dist.dgz`
110
158
  };
111
159
 
112
- let outputPath = path.join(mpOutputPath, 'dist');
113
- const distPackagePath = path.join(mpOutputPath, 'dist.dgz');
160
+ // 输出路径配置
161
+ let outputPath = path.join(mpOutputPath, 'dist'); // 最终输出目录
162
+ let distPackagePath = path.join(mpOutputPath, 'dist.dgz'); // 最终压缩包路径
114
163
  const rootIndexPath = path.join(tempPath, '..', 'index.js');
115
-
164
+ // Webpack 构建选项(仅普通小程序使用)
116
165
  const mpOptions = {
117
166
  name: scriptName,
118
167
  filename: `${scriptName}.bundle`,
119
168
  exposes: {
120
169
  'App': newAppInfo.entry,
121
170
  },
122
- outputPath: path.join(mpOutputPath, 'build'),
171
+ outputPath: path.join(mpOutputPath, 'build'), // webpack 构建输出目录
123
172
  extraChunksPath: outputPath,
124
- manifest: path.join(mpOutputPath, 'build'),
173
+ manifest: path.join(mpOutputPath, 'manifest.json'),
125
174
  };
175
+ Logger.info('mpOptions', mpOptions);
126
176
 
177
+ // 保存 app.json 到输出目录
127
178
  const appJsonPath = path.join(mpOutputPath, 'app.json');
128
179
  await fse.writeJson(appJsonPath, appJson, { spaces: 2 });
129
180
  const webpackConfigPath = path.join(__dirname, 'webpack.config.mjs');
130
181
 
182
+ // ========== 清空输出目录 ==========
183
+ // 确保每次构建都是干净的状态,避免旧文件残留
184
+ Logger.info('清空输出目录...');
185
+ await fse.ensureDir(outputPath);
186
+ await fse.emptyDir(outputPath);
187
+
188
+ // 清空 webpack 构建目录
189
+ // 如果 buildPath 是一个文件,先删除它,然后创建目录
190
+ const buildPath = path.join(mpOutputPath, 'build');
191
+ if (await fse.pathExists(buildPath)) {
192
+ const stats = await fse.stat(buildPath);
193
+ if (stats.isFile()) {
194
+ // 如果是文件,删除它
195
+ await fse.remove(buildPath);
196
+ Logger.info(`已删除文件: ${buildPath}`);
197
+ }
198
+ // 确保是目录并清空
199
+ await fse.ensureDir(buildPath);
200
+ await fse.emptyDir(buildPath);
201
+ } else {
202
+ // 如果不存在,直接创建目录
203
+ await fse.ensureDir(buildPath);
204
+ }
131
205
 
206
+ // ========== 执行构建 ==========
132
207
  try {
133
208
  if (!isGodot) {
209
+ // ========== 普通小程序构建流程 ==========
210
+ Logger.info('使用 Webpack 构建小程序包...');
134
211
  const command = `npx cross-env APP_ROOT_INDEX_PATH=${rootIndexPath} APP_EXPOSES_OPTIONS=${encodeURI(JSON.stringify(mpOptions))} webpack --config ${webpackConfigPath}`;
135
212
  await CommandExecutor.execute(command, {
136
213
  description: '构建小程序包',
137
- timeout: 120000,
214
+ timeout: 120000, // 2 分钟超时
138
215
  showCommand: false
139
216
  });
140
217
  } else {
218
+ // ========== Godot 游戏构建流程 ==========
141
219
  Logger.info('检测到 Godot 项目,开始执行 Godot 导出流程...');
220
+
221
+ // 根据平台常量确定默认平台
142
222
  const defaultPlatform = ['android', 'ios'].includes(CONSTANTS.BUNDLE_PLATFORM)
143
223
  ? CONSTANTS.BUNDLE_PLATFORM
144
224
  : 'ios';
225
+
226
+ // 准备 Godot 导出选项的默认值
145
227
  const godotDefaults = {
146
228
  targetBaseDir: outputPath,
147
229
  projectDir: guessGodotProjectDir(PROJECT_ROOT),
148
- name: newAppInfo.name || 'GodotApp',
230
+ name: newAppInfo.appid || 'GodotApp',
149
231
  preset: defaultPlatform === 'android' ? 'Android' : 'iOS',
150
232
  platform: defaultPlatform
151
233
  };
234
+
235
+ // 提示用户输入或确认 Godot 导出选项
152
236
  const godotOptions = await promptGodotOptions(godotDefaults);
153
237
  outputPath = path.resolve(godotOptions.targetBaseDir);
154
238
  await fse.ensureDir(outputPath);
155
- await fse.emptyDir(outputPath);
239
+
240
+ // 执行 Godot 导出
156
241
  await exportGodot(godotOptions);
242
+
243
+ // Godot 项目使用平台特定的文件名(如 dist.ios.dgz 或 dist.android.dgz)
244
+ const platformName = godotOptions.platform === 'ios' ? 'ios' : 'android';
245
+ distPackagePath = path.join(mpOutputPath, `dist.${platformName}.dgz`);
246
+
247
+ // 更新 app.json 中的 zipUrl
248
+ appJson.zipUrl = newAppInfo.zipUrl || `${newAppInfo.host}/dist.${platformName}.dgz`;
249
+ await fse.writeJson(appJsonPath, appJson, { spaces: 2 });
157
250
  }
158
251
 
252
+ // ========== 打包最终产物 ==========
253
+ // 将 app.json 复制到输出目录
159
254
  await fse.ensureDir(outputPath);
160
255
  await fse.copy(appJsonPath, path.join(outputPath, 'app.json'));
256
+ fse.copyFileSync(path.join(outputPath, '../manifest.json'), path.join(outputPath, 'manifest.json'));
257
+
258
+ // 压缩输出目录为最终包
161
259
  const zipPath = await handleZip(outputPath, distPackagePath);
162
260
  Logger.success(`压缩包已创建: ${zipPath}`);
163
261
  return zipPath;
package/src/debug.mjs CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * 调试模块
3
+ * 负责启动小程序真机调试服务器
4
+ *
5
+ * @module debug
6
+ */
7
+
1
8
  import path from 'path';
2
9
  import fse from 'fs-extra';
3
10
  import superstatic from 'superstatic';
@@ -12,45 +19,76 @@ import { printQrcode } from './utils-functions.mjs';
12
19
 
13
20
  /**
14
21
  * 小程序真机调试
22
+ *
23
+ * 功能流程:
24
+ * 1. 构建小程序包
25
+ * 2. 验证网络配置
26
+ * 3. 启动本地调试服务器
27
+ * 4. 生成调试二维码
28
+ *
15
29
  * @param {object} newAppInfo - 应用信息
30
+ * @param {string} newAppInfo.name - 应用名称
31
+ * @param {string} newAppInfo.appid - 应用ID
32
+ * @param {string} newAppInfo.version - 应用版本
33
+ * @param {string} [newAppInfo.entry] - 入口文件(普通小程序必需,Godot 项目不需要)
34
+ * @param {boolean} [isGodot=false] - 是否为 Godot 项目
35
+ * @throws {Error} 当必需字段缺失或网络配置验证失败时抛出错误
36
+ *
37
+ * @example
38
+ * await mpDebugShell({
39
+ * name: '我的应用',
40
+ * appid: 'com.example.app',
41
+ * version: '1.0.0',
42
+ * entry: './src/index.js'
43
+ * }, false);
16
44
  */
17
- export async function mpDebugShell(newAppInfo) {
45
+ export async function mpDebugShell(newAppInfo, isGodot = false) {
18
46
  try {
19
- // 验证必需字段
20
- if (!newAppInfo.entry) {
47
+ // ========== 参数验证 ==========
48
+ // Godot 项目不需要 entry 字段,普通小程序需要
49
+ if (!isGodot && !newAppInfo.entry) {
21
50
  throw new Error('缺少必需的应用信息: entry');
22
51
  }
23
52
 
24
- const zipPath = await mpBuildShell(newAppInfo);
53
+ // ========== 构建小程序包 ==========
54
+ Logger.info('开始构建小程序包...');
55
+ const zipPath = await mpBuildShell(newAppInfo, isGodot);
25
56
  const assetsPath = path.dirname(zipPath);
26
57
  const appJsonPath = path.join(assetsPath, 'app.json');
27
58
 
28
59
  Logger.info(`资源路径: ${assetsPath}`);
29
60
  const applocaljson = fse.readJsonSync(appJsonPath);
30
61
 
31
- // 验证网络配置
62
+ // ========== 网络配置验证 ==========
32
63
  const networkValidation = await ConfigValidator.validateNetworkConfig();
33
64
  if (!networkValidation.isValid) {
34
65
  throw new Error(`网络配置验证失败: ${networkValidation.error}`);
35
66
  }
36
67
 
68
+ // ========== 启动调试服务器 ==========
37
69
  const host = networkValidation.host;
38
- const port = await portfinder.getPortPromise();
70
+ const port = await portfinder.getPortPromise(); // 自动查找可用端口
39
71
 
72
+ // 创建静态文件服务器
40
73
  const app = superstatic.server({
41
74
  port: port,
42
75
  address: host,
43
- cwd: assetsPath
76
+ cwd: assetsPath // 服务器根目录
44
77
  });
45
78
 
79
+ // 启动服务器
46
80
  const server = app.listen(function () {
47
81
  const address = server.address();
48
82
 
83
+ // ========== 生成调试配置 ==========
84
+ // 构建调试用的 URL
49
85
  const debugerAppUrlConfig = `http://${host}:${address.port}/app.json`;
50
86
  const debugerZip = `http://${host}:${address.port}/dist.dgz`;
87
+
88
+ // 构建调试参数
51
89
  const args = {
52
90
  ...applocaljson,
53
- development: true,
91
+ development: true, // 标记为开发模式
54
92
  "name": applocaljson.name,
55
93
  "appid": applocaljson.appid,
56
94
  "version": applocaljson.version,
@@ -58,15 +96,17 @@ export async function mpDebugShell(newAppInfo) {
58
96
  zipUrl: debugerZip,
59
97
  };
60
98
 
99
+ // 更新 app.json 文件,写入调试 URL
61
100
  fse.writeJSONSync(appJsonPath, {
62
101
  ...fse.readJSONSync(appJsonPath),
63
102
  appUrlConfig: debugerAppUrlConfig,
64
103
  zipUrl: debugerZip,
65
104
  });
66
105
 
106
+ // 生成调试二维码 URL
67
107
  const qrcodeUrl = `https://share.dagouzhi.com/#/pages/index/index?data=${encodeURIComponent(JSON.stringify(args))}`;
68
108
 
69
- // 显示调试信息
109
+ // ========== 显示调试信息 ==========
70
110
  console.log('\n');
71
111
  console.log(boxen(
72
112
  gradient.rainbow('小程序真机调试') + '\n\n' +
@@ -82,23 +122,29 @@ export async function mpDebugShell(newAppInfo) {
82
122
  }
83
123
  ));
84
124
 
125
+ // 显示调试配置 JSON
85
126
  console.log('\n' + chalk.yellow('调试配置:'));
86
127
  console.log(JSON.stringify(args, undefined, 2));
87
128
 
129
+ // 显示调试链接
88
130
  console.log('\n' + chalk.green('调试链接:'));
89
131
  console.log(qrcodeUrl);
90
132
 
133
+ // 显示二维码
91
134
  console.log('\n' + chalk.blue('二维码:'));
92
135
  printQrcode(qrcodeUrl);
93
136
 
94
137
  Logger.info('调试服务器已启动,按 Ctrl+C 停止');
95
138
  });
96
139
 
97
- // 优雅关闭 - 移除全局处理,添加专用的服务器关闭处理
140
+ // ========== 优雅关闭处理 ==========
141
+ // 移除全局的信号处理,添加专用的服务器关闭处理
142
+ // 这样可以确保服务器能够正确关闭,避免资源泄漏
98
143
  const handleShutdown = () => {
99
144
  Logger.info('\n正在关闭调试服务器...');
100
145
  let isClosed = false;
101
146
 
147
+ // 尝试关闭服务器
102
148
  server.close(() => {
103
149
  if (!isClosed) {
104
150
  isClosed = true;
@@ -108,6 +154,7 @@ export async function mpDebugShell(newAppInfo) {
108
154
  });
109
155
 
110
156
  // 如果服务器在 3 秒内没有关闭,强制退出
157
+ // 防止服务器关闭卡死导致进程无法退出
111
158
  setTimeout(() => {
112
159
  if (!isClosed) {
113
160
  isClosed = true;
@@ -117,12 +164,12 @@ export async function mpDebugShell(newAppInfo) {
117
164
  }, 3000);
118
165
  };
119
166
 
120
- // 移除全局的信号处理,添加专用的服务器关闭处理
121
- // 这样可以确保服务器能够正确关闭
167
+ // 注册信号处理器
168
+ // 移除可能存在的全局处理器,避免冲突
122
169
  process.removeAllListeners('SIGINT');
123
170
  process.removeAllListeners('SIGTERM');
124
- process.once('SIGINT', handleShutdown);
125
- process.once('SIGTERM', handleShutdown);
171
+ process.once('SIGINT', handleShutdown); // Ctrl+C
172
+ process.once('SIGTERM', handleShutdown); // 终止信号
126
173
 
127
174
  } catch (error) {
128
175
  Logger.error(`小程序调试失败: ${error.message}`);
@@ -1,5 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Godot 导出模块
5
+ * 负责处理 Godot 游戏项目的导出和打包
6
+ *
7
+ * @module export_godot
8
+ */
9
+
3
10
  import path from 'node:path';
4
11
  import { pathToFileURL } from 'node:url';
5
12
  import { accessSync, constants as fsConstants } from 'node:fs';
@@ -10,38 +17,71 @@ import AdmZip from 'adm-zip';
10
17
  import { execa } from 'execa';
11
18
  import inquirer from 'inquirer';
12
19
 
20
+ // ========== 常量定义 ==========
21
+
22
+ /** Godot 编辑器默认路径(macOS) */
13
23
  const DEFAULT_GODOT_BIN = '/Applications/Godot.app/Contents/MacOS/Godot';
24
+
25
+ /** Godot 配置缓存目录 */
14
26
  const GODOT_CACHE_DIR = path.join(homedir() || process.cwd(), '.htyf');
27
+
28
+ /** Godot 配置缓存文件路径 */
15
29
  const GODOT_CACHE_FILE = path.join(GODOT_CACHE_DIR, 'godot-config.json');
16
30
 
31
+ /** 支持的平台枚举 */
17
32
  const PLATFORMS = {
18
33
  IOS: 'ios',
19
34
  ANDROID: 'android'
20
35
  };
21
36
 
37
+ /** 平台预设名称映射 */
22
38
  const PRESET_NAMES = {
23
39
  [PLATFORMS.IOS]: 'iOS',
24
40
  [PLATFORMS.ANDROID]: 'Android'
25
41
  };
26
42
 
43
+ /**
44
+ * 提示用户输入或确认 Godot 导出选项
45
+ *
46
+ * 如果所有必需参数都已提供,则直接返回,不进行交互式询问
47
+ * 否则通过命令行交互获取用户输入
48
+ *
49
+ * @param {object} [defaults={}] - 默认选项
50
+ * @param {string} [defaults.targetBaseDir] - 导出目标根目录
51
+ * @param {string} [defaults.projectDir] - Godot 项目目录
52
+ * @param {string} [defaults.appid] - 应用ID(用作导出文件名)
53
+ * @param {string} [defaults.preset] - Godot 导出预设名称
54
+ * @param {string} [defaults.platform] - 导出平台('ios' 或 'android')
55
+ * @returns {Promise<object>} Godot 导出选项对象
56
+ *
57
+ * @example
58
+ * const options = await promptGodotOptions({
59
+ * targetBaseDir: './dist',
60
+ * projectDir: './game',
61
+ * appid: 'com.example.game',
62
+ * preset: 'iOS',
63
+ * platform: 'ios'
64
+ * });
65
+ */
27
66
  export async function promptGodotOptions(defaults = {}) {
28
- // 如果所有必需的值都已提供,直接返回,不进行询问
67
+ // 如果所有必需的值都已提供,直接返回,不进行询问(用于自动化场景)
29
68
  if (
30
69
  defaults.targetBaseDir &&
31
70
  defaults.projectDir &&
32
- defaults.name &&
71
+ defaults.appid &&
33
72
  defaults.preset &&
34
73
  defaults.platform
35
74
  ) {
36
75
  return {
37
76
  targetBaseDir: String(defaults.targetBaseDir).trim(),
38
77
  projectDir: String(defaults.projectDir).trim(),
39
- name: String(defaults.name).trim(),
78
+ name: String(defaults.appid).trim(),
40
79
  preset: String(defaults.preset).trim(),
41
80
  platform: defaults.platform
42
81
  };
43
82
  }
44
83
 
84
+ // 通过交互式命令行获取用户输入
45
85
  const answers = await inquirer.prompt([
46
86
  {
47
87
  type: 'input',
@@ -92,6 +132,12 @@ export async function promptGodotOptions(defaults = {}) {
92
132
  };
93
133
  }
94
134
 
135
+ /**
136
+ * 检查文件是否可执行
137
+ *
138
+ * @param {string} filePath - 文件路径
139
+ * @returns {boolean} 文件是否可执行
140
+ */
95
141
  function isExecutable(filePath) {
96
142
  if (!filePath) return false;
97
143
  if (!fs.existsSync(filePath)) return false;
@@ -104,6 +150,11 @@ function isExecutable(filePath) {
104
150
  }
105
151
  }
106
152
 
153
+ /**
154
+ * 读取缓存的 Godot 编辑器路径
155
+ *
156
+ * @returns {string} 缓存的路径,如果不存在则返回空字符串
157
+ */
107
158
  function readCachedGodotPath() {
108
159
  try {
109
160
  if (!fs.existsSync(GODOT_CACHE_FILE)) {
@@ -116,15 +167,26 @@ function readCachedGodotPath() {
116
167
  }
117
168
  }
118
169
 
170
+ /**
171
+ * 保存 Godot 编辑器路径到缓存
172
+ *
173
+ * @param {string} godotPath - Godot 编辑器路径
174
+ */
119
175
  async function writeCachedGodotPath(godotPath) {
120
176
  try {
121
177
  await fs.ensureDir(GODOT_CACHE_DIR);
122
178
  await fs.writeJson(GODOT_CACHE_FILE, { godotPath }, { spaces: 2 });
123
179
  } catch {
124
- // cache failures should not block execution
180
+ // 缓存失败不应该阻塞执行
125
181
  }
126
182
  }
127
183
 
184
+ /**
185
+ * 提示用户输入 Godot 编辑器路径
186
+ *
187
+ * @param {string} defaultPath - 默认路径
188
+ * @returns {Promise<string>} 用户输入的路径
189
+ */
128
190
  async function promptForGodotBinaryPath(defaultPath) {
129
191
  const { godotPath } = await inquirer.prompt([
130
192
  {
@@ -139,69 +201,142 @@ async function promptForGodotBinaryPath(defaultPath) {
139
201
  return godotPath.trim();
140
202
  }
141
203
 
204
+ /**
205
+ * 解析并获取 Godot 编辑器可执行文件路径
206
+ *
207
+ * 优先级:环境变量 > 缓存 > 默认路径
208
+ * 总是会询问用户,但将缓存值或默认值作为默认输入
209
+ *
210
+ * @returns {Promise<string>} Godot 编辑器可执行文件路径
211
+ * @throws {Error} 当路径不可执行时抛出错误
212
+ */
142
213
  async function resolveGodotBinary() {
214
+ // 获取可能的默认值(优先级:环境变量 > 缓存 > 默认路径)
143
215
  const override = process.env.GODOT_EDITOR ? process.env.GODOT_EDITOR.trim() : '';
144
- if (override && isExecutable(override)) {
145
- return override;
146
- }
147
-
148
216
  const cached = readCachedGodotPath();
149
- if (cached && isExecutable(cached)) {
150
- return cached;
151
- }
152
-
153
- if (isExecutable(DEFAULT_GODOT_BIN)) {
154
- await writeCachedGodotPath(DEFAULT_GODOT_BIN);
155
- return DEFAULT_GODOT_BIN;
156
- }
157
-
158
- const fallbackDefault = override || cached || DEFAULT_GODOT_BIN;
159
- const godotPath = await promptForGodotBinaryPath(fallbackDefault);
160
-
217
+
218
+ // 确定默认值
219
+ let defaultPath = override || cached || DEFAULT_GODOT_BIN;
220
+
221
+ // 总是询问用户,但将缓存值或默认值作为默认输入
222
+ const godotPath = await promptForGodotBinaryPath(defaultPath);
223
+
224
+ // 验证路径是否可执行
161
225
  if (!isExecutable(godotPath)) {
162
226
  throw new Error(`Godot 二进制文件不可执行: ${godotPath}`);
163
227
  }
164
228
 
229
+ // 保存用户输入作为下次的默认值
165
230
  await writeCachedGodotPath(godotPath);
166
231
  return godotPath;
167
232
  }
168
233
 
234
+ /**
235
+ * 执行 Godot 命令行命令
236
+ *
237
+ * @param {string} godotBin - Godot 编辑器可执行文件路径
238
+ * @param {string[]} args - 命令参数
239
+ */
169
240
  async function runGodotCommand(godotBin, args) {
170
241
  await execa(godotBin, args, { stdio: 'inherit' });
171
242
  }
172
243
 
244
+ /**
245
+ * 处理 Android 平台导出
246
+ *
247
+ * Android 导出流程:
248
+ * 1. 使用 Godot 导出为 ZIP 包
249
+ * 2. 解压 ZIP 包到指定目录
250
+ * 3. 清理临时 ZIP 文件
251
+ *
252
+ * @param {object} options - 导出选项
253
+ * @param {string} options.godotBin - Godot 编辑器路径
254
+ * @param {string} options.projectDir - Godot 项目目录
255
+ * @param {string} options.preset - 导出预设名称
256
+ * @param {string} options.targetBaseDir - 目标根目录
257
+ * @param {string} options.name - 导出文件名(不含扩展名)
258
+ */
173
259
  async function handleAndroidExport({ godotBin, projectDir, preset, targetBaseDir, name }) {
260
+ // Android 导出路径:targetBaseDir/android/app/src/main/assets/
174
261
  const assetsBaseDir = path.join(targetBaseDir, 'android', 'app', 'src', 'main', 'assets');
175
262
  await fs.ensureDir(assetsBaseDir);
176
263
 
264
+ // 使用 Godot 导出为 ZIP 包
177
265
  const zipPath = path.join(assetsBaseDir, `${name}.zip`);
178
266
  await runGodotCommand(godotBin, ['--headless', '--path', projectDir, '--export-pack', preset, zipPath]);
179
267
 
268
+ // 解压 ZIP 包到目标目录
180
269
  const targetDir = path.join(assetsBaseDir, name);
181
270
  await fs.remove(targetDir);
182
271
  await fs.ensureDir(targetDir);
183
272
 
184
273
  const zip = new AdmZip(zipPath);
185
274
  zip.extractAllTo(targetDir, true);
275
+
276
+ // 清理临时 ZIP 文件
186
277
  await fs.remove(zipPath);
187
278
  }
188
279
 
280
+ /**
281
+ * 处理 iOS 平台导出
282
+ *
283
+ * iOS 导出流程:
284
+ * 1. 使用 Godot 直接导出为 PCK 文件
285
+ *
286
+ * @param {object} options - 导出选项
287
+ * @param {string} options.godotBin - Godot 编辑器路径
288
+ * @param {string} options.projectDir - Godot 项目目录
289
+ * @param {string} options.preset - 导出预设名称
290
+ * @param {string} options.targetBaseDir - 目标根目录
291
+ * @param {string} options.name - 导出文件名(不含扩展名)
292
+ */
189
293
  async function handleIosExport({ godotBin, projectDir, preset, targetBaseDir, name }) {
294
+ // iOS 导出路径:targetBaseDir/ios/
190
295
  const assetsBaseDir = path.join(targetBaseDir, 'ios');
191
296
  await fs.ensureDir(assetsBaseDir);
297
+
298
+ // 使用 Godot 直接导出为 PCK 文件
192
299
  const outputPath = path.join(assetsBaseDir, `${name}.pck`);
193
300
  await runGodotCommand(godotBin, ['--headless', '--path', projectDir, '--export-pack', preset, outputPath]);
194
301
  }
195
302
 
303
+ /**
304
+ * 导出 Godot 项目
305
+ *
306
+ * 主要流程:
307
+ * 1. 解析并获取 Godot 编辑器路径
308
+ * 2. 执行资源导入(执行两次以确保资源正确导入)
309
+ * 3. 根据平台执行相应的导出流程
310
+ *
311
+ * @param {object} options - 导出选项
312
+ * @param {string} options.platform - 导出平台('ios' 或 'android')
313
+ * @param {string} options.projectDir - Godot 项目目录
314
+ * @param {string} options.preset - Godot 导出预设名称
315
+ * @param {string} options.targetBaseDir - 目标根目录
316
+ * @param {string} options.name - 导出文件名(不含扩展名)
317
+ * @throws {Error} 当平台不支持时抛出错误
318
+ *
319
+ * @example
320
+ * await exportGodot({
321
+ * platform: 'ios',
322
+ * projectDir: './game',
323
+ * preset: 'iOS',
324
+ * targetBaseDir: './dist',
325
+ * name: 'MyGame'
326
+ * });
327
+ */
196
328
  export async function exportGodot(options) {
329
+ // 解析并获取 Godot 编辑器路径
197
330
  const godotBin = await resolveGodotBinary();
198
331
  const { platform, projectDir, preset, targetBaseDir, name } = options;
199
332
 
200
333
  // 执行两次 import 以确保资源正确导入
334
+ // 某些情况下,第一次导入可能不完整,执行两次可以确保所有资源都被正确导入
201
335
  const importArgs = ['--headless', '--path', projectDir, '--import'];
202
336
  await runGodotCommand(godotBin, importArgs);
203
337
  await runGodotCommand(godotBin, importArgs);
204
338
 
339
+ // 根据平台执行相应的导出流程
205
340
  switch (platform) {
206
341
  case PLATFORMS.IOS:
207
342
  await handleIosExport({ godotBin, projectDir, preset, targetBaseDir, name });
@@ -214,11 +349,21 @@ export async function exportGodot(options) {
214
349
  }
215
350
  }
216
351
 
352
+ /**
353
+ * 主函数(当文件作为 CLI 直接执行时)
354
+ *
355
+ * 如果该文件被直接执行(而非作为模块导入),则运行主函数
356
+ */
217
357
  async function main() {
218
358
  const options = await promptGodotOptions();
219
359
  await exportGodot(options);
220
360
  }
221
361
 
362
+ /**
363
+ * 检查当前文件是否作为 CLI 直接执行
364
+ *
365
+ * @returns {boolean} 是否为 CLI 执行
366
+ */
222
367
  const isCliExecution = (() => {
223
368
  if (!process.argv[1]) {
224
369
  return false;
@@ -227,6 +372,7 @@ const isCliExecution = (() => {
227
372
  return cliUrl === import.meta.url;
228
373
  })();
229
374
 
375
+ // 如果作为 CLI 直接执行,运行主函数
230
376
  if (isCliExecution) {
231
377
  main().catch((error) => {
232
378
  console.error(error.message || error);
package/src/index.mjs CHANGED
@@ -1,11 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * React Native 应用 CLI 工具
5
- * 用于管理多应用项目的开发、构建和部署
4
+ * 红糖云服应用 CLI 工具主入口
5
+ *
6
+ * 功能:
7
+ * - 初始化新小程序项目
8
+ * - 小程序打包构建
9
+ * - 小程序真机调试
10
+ * - 清理临时文件
11
+ *
12
+ * 支持项目类型:
13
+ * - 普通小程序项目(使用 webpack 构建)
14
+ * - Godot 游戏项目(使用 Godot 导出工具)
6
15
  *
7
16
  * @author CLI Team
8
17
  * @version 2.2.0
18
+ * @module index
9
19
  */
10
20
 
11
21
  import inquirer from 'inquirer';
@@ -27,12 +37,18 @@ import { incrementVersion, updateAppConfig } from './utils-functions.mjs';
27
37
 
28
38
  const projectPath = getProjectRoot();
29
39
 
30
- // 全局状态管理,用于优雅退出
40
+ // ========== 全局状态管理 ==========
41
+ // 用于优雅退出,避免重复退出操作
31
42
  let isExiting = false;
32
43
  let currentProcess = null;
33
44
 
34
45
  /**
35
- * 优雅退出处理
46
+ * 优雅退出处理函数
47
+ *
48
+ * 当收到退出信号(如 Ctrl+C)时:
49
+ * 1. 尝试关闭正在运行的进程
50
+ * 2. 延迟退出,给清理操作一些时间
51
+ * 3. 如果已经在退出中,强制退出
36
52
  */
37
53
  function handleGracefulExit() {
38
54
  if (isExiting) {
@@ -64,14 +80,22 @@ function handleGracefulExit() {
64
80
  }, 100);
65
81
  }
66
82
 
67
- // 注册全局信号处理(使用 once 确保只注册一次)
83
+ // ========== 注册全局信号处理 ==========
84
+ // 使用 once 确保只注册一次
68
85
  // 注意:某些模块(如 debug.mjs)可能会移除这些监听器并添加自己的处理
69
- process.on('SIGINT', handleGracefulExit);
70
- process.on('SIGTERM', handleGracefulExit);
86
+ process.on('SIGINT', handleGracefulExit); // Ctrl+C
87
+ process.on('SIGTERM', handleGracefulExit); // 终止信号
71
88
 
72
89
  /**
73
90
  * 主启动函数
74
- * @param {string} action - 操作类型
91
+ *
92
+ * 根据操作类型执行相应的功能:
93
+ * - MP_BUILD: 构建小程序包
94
+ * - MP_DEBUG: 启动真机调试服务器
95
+ * - CLEAN: 清理临时文件
96
+ * - QUIT: 退出程序
97
+ *
98
+ * @param {string} action - 操作类型(ACTION_TYPES 中的值)
75
99
  */
76
100
  async function Start(action) {
77
101
  const actionNames = {
@@ -83,11 +107,13 @@ async function Start(action) {
83
107
 
84
108
  Logger.info(`当前操作: ${actionNames[action] || '未知操作'}`);
85
109
 
110
+ // ========== 退出操作 ==========
86
111
  if (action === ACTION_TYPES.QUIT) {
87
112
  Logger.info('再见!');
88
113
  return;
89
114
  }
90
115
 
116
+ // ========== 清理操作 ==========
91
117
  if (action === ACTION_TYPES.CLEAN) {
92
118
  const { cleanType } = await inquirer.prompt([
93
119
  {
@@ -134,17 +160,23 @@ async function Start(action) {
134
160
  return;
135
161
  }
136
162
 
137
- // 显示应用选择界面
163
+ // ========== 构建/调试操作 ==========
138
164
  try {
165
+ // 读取应用配置
139
166
  const appConfigPath = path.join(projectPath, 'app.json');
140
167
  const appInfo = fse.readJsonSync(appConfigPath).htyf;
168
+
169
+ // 检测是否为 Godot 项目(通过检查是否存在 project.godot 文件)
141
170
  const isGodot = fse.existsSync(path.join(projectPath, 'project.godot'));
142
- Logger.info(`是否是Game项目: ${isGodot}`);
171
+ Logger.info(`项目类型: ${isGodot ? 'Godot 游戏项目' : '普通小程序项目'}`);
172
+
143
173
  if (!appInfo) {
144
174
  Logger.error('应用配置不存在,请先在app.json中配置htyf');
145
175
  return;
146
176
  }
147
177
 
178
+ // ========== 版本处理 ==========
179
+ // 自动递增版本号作为默认值
148
180
  const version = appInfo?.version;
149
181
  const incrementedVersion = version ? incrementVersion(version) : '1.0.0';
150
182
  const { versionName } = await inquirer.prompt([
@@ -163,15 +195,18 @@ async function Start(action) {
163
195
  Logger.info(`用户选择:`);
164
196
  Logger.info(`版本名称: ${versionName}`);
165
197
 
198
+ // ========== 更新应用配置 ==========
199
+ // 合并用户输入的版本号到应用信息中
166
200
  const newAppInfo = lodash.merge({}, appInfo, {
167
201
  version: versionName,
168
202
  });
169
203
 
204
+ // 更新 app.json 文件
170
205
  if (!updateAppConfig(newAppInfo)) {
171
206
  return;
172
207
  }
173
208
 
174
- // 显示操作信息
209
+ // ========== 显示应用信息 ==========
175
210
  console.log('\n' + boxen(
176
211
  chalk.cyan('应用信息') + '\n\n' +
177
212
  chalk.white('名称: ') + chalk.yellow(`${newAppInfo.name}`) + '\n' +
@@ -184,13 +219,16 @@ async function Start(action) {
184
219
  }
185
220
  ));
186
221
 
187
- Logger.info('执行初始化...');
222
+ Logger.info('开始执行操作...');
188
223
 
224
+ // ========== 执行相应操作 ==========
189
225
  switch (action) {
190
226
  case ACTION_TYPES.MP_BUILD:
227
+ // 构建小程序包
191
228
  await mpBuildShell(newAppInfo, isGodot);
192
229
  break;
193
230
  case ACTION_TYPES.MP_DEBUG:
231
+ // 启动真机调试服务器
194
232
  await mpDebugShell(newAppInfo, isGodot);
195
233
  break;
196
234
  default:
@@ -203,7 +241,8 @@ async function Start(action) {
203
241
  }
204
242
  }
205
243
 
206
- // 主程序入口
244
+ // ========== 主程序入口 ==========
245
+ // 显示欢迎信息
207
246
  console.log('\n' + boxen(
208
247
  gradient.rainbow('🎯 红糖云服 应用 CLI 工具 v2.2.0') + '\n' +
209
248
  chalk.gray('用于管理多应用项目的开发、构建和部署') + '\n' +
@@ -217,7 +256,9 @@ console.log('\n' + boxen(
217
256
  }
218
257
  ));
219
258
 
220
- // 设置日志级别
259
+ // ========== 命令行参数处理 ==========
260
+
261
+ // 处理调试模式参数
221
262
  if (process.argv.includes('--debug')) {
222
263
  setLogLevel(LOG_LEVELS.DEBUG);
223
264
  Logger.debug('调试模式已启用');
@@ -226,7 +267,7 @@ if (process.argv.includes('--debug')) {
226
267
  // 记录启动日志
227
268
  Logger.writeToFile('CLI 工具启动', 'INFO');
228
269
 
229
- // 处理命令行清理参数
270
+ // 处理清理参数(命令行直接清理,不进入交互界面)
230
271
  if (process.argv.includes('--clean')) {
231
272
  const cleanIndex = process.argv.indexOf('--clean');
232
273
  const cleanType = process.argv[cleanIndex + 1] || 'all';
@@ -60,13 +60,31 @@ export default (env = {}) => {
60
60
  if (appExposesOptions) {
61
61
  mpOptions = JSON.parse(decodeURI(appExposesOptions));
62
62
  }
63
+
64
+ // 处理 exposes 路径,确保路径正确解析
65
+ // Module Federation 需要相对于 context 的路径,如果路径不以 ./ 开头,需要添加
66
+ if (mpOptions.exposes) {
67
+ const processedExposes = {};
68
+ for (const [key, value] of Object.entries(mpOptions.exposes)) {
69
+ // 如果路径不是绝对路径且不以 ./ 开头,添加 ./ 前缀
70
+ if (value && typeof value === 'string' && !path.isAbsolute(value) && !value.startsWith('./') && !value.startsWith('../')) {
71
+ processedExposes[key] = `./${value}`;
72
+ } else {
73
+ processedExposes[key] = value;
74
+ }
75
+ }
76
+ mpOptions.exposes = processedExposes;
77
+ }
78
+
63
79
  console.log('\n');
64
- console.log('=====mpOptions=====');
80
+ console.log('=====mpOptions (after processing)=====');
65
81
  console.log('\n');
66
82
  console.log('context:', projectRoot);
67
83
  console.log('\n');
68
84
  console.log('entry:', entry);
69
85
  console.log('\n');
86
+ console.log('exposes (processed):', JSON.stringify(mpOptions.exposes, null, 2));
87
+ console.log('\n');
70
88
  console.log(JSON.stringify(mpOptions, null, 2));
71
89
  console.log('\n');
72
90
  console.log('=====mpOptions=====');
@@ -77,6 +95,8 @@ export default (env = {}) => {
77
95
  entry,
78
96
  resolve: {
79
97
  ..._options,
98
+ // 确保扩展名解析正确
99
+ extensions: ['.tsx', '.ts', '.jsx', '.js', '.json', ...(_options.extensions || [])],
80
100
  },
81
101
  output: {
82
102
  // 默认输出路径,host模式下会被覆盖
@@ -1,105 +0,0 @@
1
-
2
- import path from 'path';
3
- import fse from 'fs-extra'
4
- import { fileURLToPath } from 'url'
5
-
6
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
- const sharedPkg = fse.readJSONSync(path.join(__dirname, './shared-output.json'))
8
-
9
- function trimTemplateStrings(strings, ...values) {
10
- let fullString = strings.reduce((acc, str, i) => acc + str + (values[i] || ''), '');
11
- return fullString.replace(/^[ \t]+/gm, '');
12
- }
13
-
14
- export class HtyfModulesPlugin {
15
- constructor(appConfig) {
16
- this.config = appConfig;
17
- }
18
- apply(compiler) {
19
- const config = this.config;
20
- compiler.hooks.emit.tapAsync('AnalyzeThirdPartyModulesPlugin', (compilation, callback) => {
21
- let thirdPartyModules = new Set();
22
-
23
- compilation.modules.forEach(module => {
24
- if (module.context && module.context.includes('node_modules')) {
25
- let packageName = module.context.split('node_modules/')[1].split('/')[0];
26
- if (packageName.startsWith('@')) {
27
- packageName = module.context.split('node_modules/')[1].split('/').splice(0,2).join('/');
28
- }
29
- const ios_path = path.join(__dirname, '../node_modules/', packageName, 'ios');
30
- const and_path = path.join(__dirname, '../node_modules/', packageName, 'android');
31
- const isNative = fse.pathExistsSync(ios_path) || fse.pathExistsSync(and_path);
32
- if (isNative) {
33
- thirdPartyModules.add(packageName);
34
- }
35
- }
36
- });
37
- const nonsupport = []
38
- thirdPartyModules = Array.from(thirdPartyModules);
39
- for (const key in thirdPartyModules) {
40
- const pkg = thirdPartyModules[key];
41
- if (!sharedPkg[pkg]) {
42
- if (
43
- pkg !== 'expo-splash-screen' &&
44
- pkg !== '@callstack/repack' &&
45
- pkg !== 'react-native-iap'
46
- ) {
47
- nonsupport.push(pkg)
48
- }
49
- }
50
- }
51
- if (nonsupport.length) {
52
- const msgArr = ['红糖云服暂不支持以上第三方native组件']
53
- console.log(' ')
54
- console.log('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')
55
- console.log('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')
56
- console.log(' ')
57
- console.log('红糖云服暂不支持以下第三方native组件: ')
58
- for (const key in nonsupport) {
59
- const element = nonsupport[key];
60
- const _msg = `${Number(key) + 1}. ${element}`;
61
- console.log(_msg)
62
- msgArr.push(_msg)
63
- }
64
- console.log(' ')
65
- console.log('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')
66
- console.log('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')
67
- console.log(' ')
68
- console.log(' ')
69
- callback(msgArr.join(`\n`));
70
- process.exit(1);
71
- } else {
72
- callback();
73
- }
74
- });
75
- if (config.codeSigning) {
76
- compiler.hooks.emit.tapAsync('WrapCodePlugin', (compilation, callback) => {
77
- // 遍历所有编译过的资源文件
78
- for (const filename in compilation.assets) {
79
- if (compilation.assets.hasOwnProperty(filename)) {
80
- // 获取文件内容
81
- let source = compilation.assets[filename].source();
82
-
83
- // 包裹代码
84
- const wrappedSource = trimTemplateStrings`
85
- try {
86
- ${source}
87
- } catch (error) {
88
- const that = globalThis || global || self || window;
89
- that.__htyf_error_callback__ && that.__htyf_error_callback__(error, that)
90
- console.error(error)
91
- }
92
- `;
93
-
94
- // 更新文件内容
95
- compilation.assets[filename] = {
96
- source: () => wrappedSource,
97
- size: () => wrappedSource.length
98
- };
99
- }
100
- }
101
- callback();
102
- });
103
- }
104
- }
105
- }