@htyf-mp/cli 0.2.18 → 0.2.20

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/README.md CHANGED
@@ -1,21 +1,114 @@
1
- # 红糖云服app 小程序模板 CLI
1
+ ## 红糖云服小程序模板 CLI
2
2
 
3
- 用于构建红糖云服小程序的前端框架模板
3
+ 用于创建和管理 **红糖云服 App 小程序** 项目(应用 / 游戏 / Web / 插件),包含:
4
4
 
5
- ## 使用
5
+ - 初始化小程序项目模版
6
+ - 构建小程序资源包(打包)
7
+ - 启动真机调试服务
8
+ - 清理构建产物与临时文件
6
9
 
7
- 生成一个新项目:
10
+ - 官网:[`https://mp.dagouzhi.com/`](https://mp.dagouzhi.com/)
11
+ - GitHub:[`https://github.com/htyf-mp-community`](https://github.com/htyf-mp-community)
8
12
 
9
- ```sh
13
+ ## 安装与快速开始
14
+
15
+ 建议直接使用 npx:
16
+
17
+ ```bash
10
18
  npx @htyf-mp/cli
11
19
  ```
12
20
 
13
- 接下来,使用CLI配置项目名和Appid,或者稍后再配置,最后选择模板类型
21
+ 按照交互提示完成:
22
+
23
+ 1. **输入应用目录名**(例如 `my-htyf-mp`,用于创建项目文件夹)。
24
+ 2. **输入应用显示名称**(2–4 个中文/字母/数字)。
25
+ 3. **选择模板类型**:
26
+ - `app-template`:React Native 应用小程序(对应 `type: 'app'`)。
27
+ - `game-template`:Godot 游戏小程序(对应 `type: 'game'`)。
28
+ - `web-template`:Web/H5 小程序(对应 `type: 'web'`)。
29
+ 4. 选择模板仓库镜像(当前默认使用 GitHub)。
30
+
31
+ CLI 会自动:
32
+
33
+ - 克隆官方模版仓库到临时目录。
34
+ - 清理无关模版,仅保留所选模板。
35
+ - 生成 `app.json` 中的 `htyf` 配置(包含 `appid`、`name`、`zipUrl`、`appUrlConfig` 等)。
36
+ - 将模板拷贝到你指定的项目目录中。
37
+
38
+ > 各模板的具体用法,请参考生成项目中 `_apps_temp_` / `_game_temp_` / `_web_temp_` 目录下的 `README.md`。
39
+
40
+ ## 项目操作命令
41
+
42
+ 进入生成好的项目根目录后,直接运行 CLI(支持 `node` 或已全局安装的命令):
43
+
44
+ ### 交互式主菜单
45
+
46
+ ```bash
47
+ node src/index.mjs
48
+ ```
49
+
50
+ 在交互菜单中可以选择:
51
+
52
+ - 🆕 初始化新小程序项目(`init`)
53
+ - 🔍 小程序 - 打包小程序(`mp-build`)
54
+ - 📦 小程序 - 真机调试(`mp-debug`)
55
+ - 🧹 清理模式 - 清理临时文件(`clean`)
56
+ - 👋 退出
57
+
58
+ ### 构建小程序(打包)
59
+
60
+ ```bash
61
+ node src/index.mjs
62
+ ```
63
+
64
+ 在菜单中选择 **“小程序 - 打包小程序”**:
65
+
66
+ 1. CLI 会读取项目根目录下 `app.json` 中的 `htyf` 配置。
67
+ 2. 自动检测是否为 Godot 项目(是否存在 `project.godot`):
68
+ - 存在:按 **Godot 游戏** 流程打包。
69
+ - 不存在:按 **普通小程序(React Native / Web)** 流程打包。
70
+ 3. 提示输入版本号(默认在当前版本基础上自动 +1,例如 `1.0.0 -> 1.0.1`)。
71
+ 4. 更新 `app.json` 中的版本号并执行构建,将资源输出到 `dist` 目录。
72
+
73
+ 构建完成后,可以将产物上传到 `zipUrl` 指定的地址,供红糖云服 App 拉取与更新。
74
+
75
+ ### 真机调试
76
+
77
+ 在主菜单中选择 **“小程序 - 真机调试”**:
78
+
79
+ - CLI 会启动调试服务器,更新 `app.json` 中的调试地址。
80
+ - 在红糖云服 App 中配置好对应的调试入口,即可在真机上实时预览与调试。
81
+
82
+ > 具体调试行为(如 QR 码、连接方式)以红糖云服 App 当前版本为准。
14
83
 
15
- ### 运行应用程序
84
+ ### 清理命令
16
85
 
17
- 进入`mini-apps-template`或`mini-game-template`目录并运行:
18
- > 具体模板使用,可参考模板内的README.md
19
- ```sh
20
- npm start
86
+ 支持通过参数直接清理,无需进入交互界面:
87
+
88
+ ```bash
89
+ node src/index.mjs --clean all # 清理所有临时文件
90
+ node src/index.mjs --clean build # 清理构建输出
91
+ node src/index.mjs --clean temp # 清理临时目录
92
+ node src/index.mjs --clean logs # 清理日志文件
93
+ node src/index.mjs --clean cache # 清理缓存文件
21
94
  ```
95
+
96
+ 也可以在交互菜单中选择 **“清理模式 - 清理临时文件”**,按提示选择清理范围。
97
+
98
+ ## app.json 与 htyf 配置
99
+
100
+ 项目根目录下会有一个 `app.json`,其中包含 `htyf` 字段,CLI 会从这里读取并更新应用配置:
101
+
102
+ - 重要字段包括:
103
+ - `type`: `'app' | 'game' | 'web' | 'plugin'`
104
+ - `appid`: 小程序唯一 ID
105
+ - `name`: 应用名称
106
+ - `zipUrl`: 构建产物 zip 包地址
107
+ - `appUrlConfig`: 线上配置地址
108
+ - `version`: 版本号(由 CLI 帮你自动递增或手动修改)
109
+
110
+ > 不同模板下的 `app.json` 字段说明和推荐配置,可在各自模板目录的 README 中查看:
111
+ > - React Native 应用小程序:`_apps_temp_/README.md`
112
+ > - Godot 游戏小程序:`_game_temp_/README.md`
113
+ > - Web/H5 小程序:`_web_temp_/RADME.md`
114
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@htyf-mp/cli",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "红糖云服app小程序模板-cli",
5
5
  "type": "module",
6
6
  "bin": {
package/src/build.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  import path from 'path';
9
9
  import fse from 'fs-extra';
10
10
  import AdmZip from 'adm-zip';
11
+ import QRCode from 'qrcode';
11
12
  import { fileURLToPath } from 'node:url';
12
13
  import { Logger } from './logger.mjs';
13
14
  import { CONSTANTS } from './constants.mjs';
@@ -22,6 +23,98 @@ const __filename = fileURLToPath(import.meta.url);
22
23
  const __dirname = path.dirname(__filename);
23
24
  const PROJECT_ROOT = getProjectRoot();
24
25
 
26
+ /**
27
+ * 复制目录内容(不包含最外层目录名)
28
+ * @param {string} srcDir
29
+ * @param {string} destDir
30
+ */
31
+ async function copyDirContents(srcDir, destDir) {
32
+ await fse.ensureDir(destDir);
33
+ const entries = await fse.readdir(srcDir);
34
+ for (const entry of entries) {
35
+ await fse.copy(path.join(srcDir, entry), path.join(destDir, entry), { overwrite: true });
36
+ }
37
+ }
38
+
39
+ /**
40
+ * 执行 Web 端 H5 构建
41
+ * @param {'build'|'dev'} mode
42
+ */
43
+ async function runWebH5Build(mode = 'build') {
44
+ const cmd = mode === 'dev' ? 'npm run dev:h5' : 'npm run build:h5';
45
+ Logger.info(`开始执行 Web H5 构建: ${cmd}`);
46
+ await CommandExecutor.execute(cmd, {
47
+ description: `Web H5 ${mode === 'dev' ? '开发' : '构建'}`,
48
+ timeout: mode === 'dev' ? 120000 : 300000,
49
+ showCommand: false,
50
+ });
51
+ }
52
+
53
+ /**
54
+ * 将 taro build:h5 的输出同步到最终 zip 目录
55
+ *
56
+ * 说明:
57
+ * - taro 的 outputRoot 在模板里通常是 ./dist
58
+ * - CLI 最终会把内容打包到 ./dist/dist(outputPath)
59
+ * - 因此这里需要把 ./dist 下的 h5 静态产物复制到 ./dist/dist
60
+ *
61
+ * @param {string} projectDistRoot - taro 输出目录(一般为 ./dist)
62
+ * @param {string} zipInputDir - zip 输入目录(一般为 ./dist/dist)
63
+ */
64
+ async function syncWebH5Output(projectDistRoot, zipInputDir) {
65
+ // 先清空 zip 输入目录,避免残留
66
+ await fse.ensureDir(zipInputDir);
67
+ await fse.emptyDir(zipInputDir);
68
+
69
+ const h5Dir = path.join(projectDistRoot, 'h5');
70
+ if (await fse.pathExists(h5Dir)) {
71
+ Logger.info('检测到 taro H5 输出目录 ./dist/h5,复制其内容...');
72
+ await copyDirContents(h5Dir, zipInputDir);
73
+ return;
74
+ }
75
+
76
+ Logger.info('未检测到 ./dist/h5,尝试复制 ./dist 下的 H5 静态产物...');
77
+ const entries = await fse.readdir(projectDistRoot);
78
+
79
+ const excludeNames = new Set([
80
+ 'dist', // 避免递归复制 ./dist/dist
81
+ 'build', // RN bundle 输出目录
82
+ 'app.json',
83
+ 'manifest.json',
84
+ ]);
85
+
86
+ for (const entry of entries) {
87
+ if (excludeNames.has(entry)) continue;
88
+ if (entry.endsWith('.dgz')) continue;
89
+
90
+ await fse.copy(path.join(projectDistRoot, entry), path.join(zipInputDir, entry), { overwrite: true });
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 在构建输出目录生成二维码图片
96
+ *
97
+ * 优先使用 zipUrl,若为空则回退到 appUrlConfig。
98
+ *
99
+ * @param {string} outputPath - 构建输出目录
100
+ * @param {object} appJson - 应用配置对象
101
+ * @returns {Promise<void>}
102
+ */
103
+ async function generateBuildQrCode(outputPath, appJson) {
104
+ const qrContent = appJson?.zipUrl || appJson?.appUrlConfig;
105
+ if (!qrContent) {
106
+ Logger.warn('未配置 zipUrl/appUrlConfig,跳过二维码生成');
107
+ return;
108
+ }
109
+
110
+ const qrFilePath = path.join(outputPath, 'qrcode.png');
111
+ await QRCode.toFile(qrFilePath, qrContent, {
112
+ width: 320,
113
+ margin: 2
114
+ });
115
+ Logger.success(`二维码已生成: ${qrFilePath}`);
116
+ }
117
+
25
118
  /**
26
119
  * 压缩文件夹为 ZIP 文件
27
120
  *
@@ -116,13 +209,13 @@ export async function handleZip(inputPath, outputPath) {
116
209
  * entry: './src/index.js'
117
210
  * }, false);
118
211
  */
119
- export async function mpBuildShell(newAppInfo, isGodot = false) {
212
+ export async function mpBuildShell(newAppInfo, isGodot = false, buildOptions = {}) {
120
213
  Logger.info(`开始构建小程序包...`);
121
214
  Logger.info(`应用: ${newAppInfo.name}`);
122
215
  Logger.info(`应用ID: ${newAppInfo.appid}`);
123
216
  Logger.info(`版本: ${newAppInfo.version}`);
124
217
  Logger.info(`平台: ${CONSTANTS.BUNDLE_PLATFORM}`);
125
- Logger.info(`项目类型: ${isGodot ? 'Godot 游戏' : '普通小程序'}`);
218
+ Logger.info(`项目类型: ${isGodot ? 'Godot 游戏' : (newAppInfo?.type === 'web' ? 'Web 应用' : '普通小程序')}`);
126
219
 
127
220
  // ========== 参数验证 ==========
128
221
  if (!newAppInfo.name || !newAppInfo.appid || !newAppInfo.version) {
@@ -148,6 +241,7 @@ export async function mpBuildShell(newAppInfo, isGodot = false) {
148
241
 
149
242
  // 构建 app.json 配置
150
243
  const appType = newAppInfo.type || (isGodot ? 'game' : 'app');
244
+ const isWeb = appType === 'web';
151
245
  const appJson = {
152
246
  ...newAppInfo,
153
247
  rotate: newAppInfo.rotate || 'portrait',
@@ -177,9 +271,11 @@ export async function mpBuildShell(newAppInfo, isGodot = false) {
177
271
  };
178
272
  Logger.info('mpOptions', mpOptions);
179
273
 
180
- // 保存 app.json 到输出目录
181
274
  const appJsonPath = path.join(mpOutputPath, 'app.json');
182
- await fse.writeJson(appJsonPath, appJson, { spaces: 2 });
275
+ // 注意:Web H5 构建会清理 ./dist,因此 app.json 写入要尽量晚一点
276
+ if (!isWeb) {
277
+ await fse.writeJson(appJsonPath, appJson, { spaces: 2 });
278
+ }
183
279
  const webpackConfigPath = path.join(__dirname, 'webpack.config.mjs');
184
280
 
185
281
  // ========== 清空输出目录 ==========
@@ -208,7 +304,6 @@ export async function mpBuildShell(newAppInfo, isGodot = false) {
208
304
 
209
305
  // ========== 执行构建 ==========
210
306
  try {
211
- const isWeb = appType === 'web';
212
307
  if (!isGodot && !isWeb) {
213
308
  // ========== 普通小程序构建流程 ==========
214
309
  Logger.info('使用 React Native 构建小程序包...');
@@ -282,6 +377,21 @@ export async function mpBuildShell(newAppInfo, isGodot = false) {
282
377
  // 更新 app.json 中的 zipUrl
283
378
  appJson.zipUrl = newAppInfo.zipUrl || `${newAppInfo.host}/dist.${platformName}.dgz`;
284
379
  await fse.writeJson(appJsonPath, appJson, { spaces: 2 });
380
+ } else if (isWeb) {
381
+ // ========== Web 应用构建流程 ==========
382
+ // 正式打包走 build:h5;真机调试可通过 buildOptions.skipWebBuild 跳过
383
+ if (!buildOptions.skipWebBuild) {
384
+ await runWebH5Build('build');
385
+ await syncWebH5Output(mpOutputPath, outputPath);
386
+ } else {
387
+ Logger.info('跳过 Web H5 构建(debug 模式),仅打包 app.json');
388
+ // 保证 zipInputDir 存在,后续还要写 app.json/qrcode
389
+ await fse.ensureDir(outputPath);
390
+ await fse.emptyDir(outputPath);
391
+ }
392
+
393
+ // Web 构建后再写 app.json,避免被 taro 清理掉
394
+ await fse.writeJson(appJsonPath, appJson, { spaces: 2 });
285
395
  }
286
396
 
287
397
  // ========== 打包最终产物 ==========
@@ -291,6 +401,7 @@ export async function mpBuildShell(newAppInfo, isGodot = false) {
291
401
  if (!isGodot && appType !== 'web') {
292
402
  fse.copyFileSync(path.join(outputPath, '../manifest.json'), path.join(outputPath, 'manifest.json'));
293
403
  }
404
+ await generateBuildQrCode(outputPath, appJson);
294
405
 
295
406
  // 压缩输出目录为最终包
296
407
  const zipPath = await handleZip(outputPath, distPackagePath);
package/src/debug.mjs CHANGED
@@ -13,9 +13,38 @@ import chalk from 'chalk';
13
13
  import boxen from 'boxen';
14
14
  import gradient from 'gradient-string';
15
15
  import { Logger } from './logger.mjs';
16
+ import { CONSTANTS } from './constants.mjs';
16
17
  import { ConfigValidator } from './validators.mjs';
17
18
  import { mpBuildShell } from './build.mjs';
18
19
  import { printQrcode } from './utils-functions.mjs';
20
+ import { spawn } from 'node:child_process';
21
+
22
+ /**
23
+ * 等待 Web H5 首次编译产物就绪,避免 taro watch 后续清理 dist
24
+ * 导致最终缺少 dist/app.json 与 dist/dist.dgz。
25
+ *
26
+ * @param {{timeoutMs?: number, intervalMs?: number}} [options]
27
+ * @returns {Promise<boolean>}
28
+ */
29
+ async function waitForWebH5Output(options = {}) {
30
+ const { timeoutMs = 120000, intervalMs = 1000 } = options;
31
+ const h5Dir = path.join(CONSTANTS.MP_OUTPUT_DIR, 'h5'); // ./dist/h5
32
+
33
+ const start = Date.now();
34
+ while (Date.now() - start < timeoutMs) {
35
+ if (await fse.pathExists(h5Dir)) {
36
+ try {
37
+ const entries = await fse.readdir(h5Dir);
38
+ if (entries.length > 0) return true;
39
+ } catch (_) {
40
+ // directory may be in flux
41
+ }
42
+ }
43
+ await new Promise((r) => setTimeout(r, intervalMs));
44
+ }
45
+
46
+ return false;
47
+ }
19
48
 
20
49
  /**
21
50
  * 小程序真机调试
@@ -51,10 +80,27 @@ export async function mpDebugShell(newAppInfo, isGodot = false) {
51
80
  if (!isGodot && !isWeb && !newAppInfo.entry) {
52
81
  throw new Error('缺少必需的应用信息: entry');
53
82
  }
54
-
83
+ let devH5Process = null;
84
+ if (isWeb) {
85
+ // devH5Process = spawn('npm', ['run', 'dev:h5'], {
86
+ // stdio: 'inherit',
87
+ // shell: true,
88
+ // });
89
+ }
90
+
91
+ // Web 真机调试:等待 taro 首次编译产物就绪
92
+ // 这样后续生成的 dist/app.json 与 dist/dist.dgz 不容易被覆盖/删除。
93
+ if (isWeb) {
94
+ Logger.info('等待 Web H5 首次编译完成...');
95
+ const ok = await waitForWebH5Output();
96
+ if (!ok) {
97
+ Logger.warn('等待 Web H5 产物超时,将继续生成调试用 dist 资源');
98
+ }
99
+ }
100
+
55
101
  // ========== 构建小程序包 ==========
56
102
  Logger.info('开始构建小程序包...');
57
- const zipPath = await mpBuildShell(newAppInfo, isGodot);
103
+ const zipPath = await mpBuildShell(newAppInfo, isGodot, { skipWebBuild: isWeb });
58
104
  const assetsPath = path.dirname(zipPath);
59
105
  const appJsonPath = path.join(assetsPath, 'app.json');
60
106
 
@@ -71,6 +117,11 @@ export async function mpDebugShell(newAppInfo, isGodot = false) {
71
117
  const host = networkValidation.host;
72
118
  const port = await portfinder.getPortPromise(); // 自动查找可用端口
73
119
 
120
+ // Web 真机调试:启动 H5 dev server,并把固定地址写入打包后的 app.json 的 webUrl
121
+ // 注意:dev:h5 由 taro 侧负责启动静态服务(这里只启动命令并在 app.json 写死 URL)
122
+ const webDevUrl = process.env.WEB_H5_DEV_URL || 'http://192.168.1.28:10086';
123
+
124
+
74
125
  // 创建静态文件服务器
75
126
  const app = superstatic.server({
76
127
  port: port,
@@ -96,6 +147,7 @@ export async function mpDebugShell(newAppInfo, isGodot = false) {
96
147
  "version": applocaljson.version,
97
148
  appUrlConfig: debugerAppUrlConfig,
98
149
  zipUrl: debugerZip,
150
+ ...(isWeb ? { webUrl: webDevUrl } : {}),
99
151
  };
100
152
 
101
153
  // 更新 app.json 文件,写入调试 URL
@@ -103,6 +155,7 @@ export async function mpDebugShell(newAppInfo, isGodot = false) {
103
155
  ...fse.readJSONSync(appJsonPath),
104
156
  appUrlConfig: debugerAppUrlConfig,
105
157
  zipUrl: debugerZip,
158
+ ...(isWeb ? { webUrl: webDevUrl } : {}),
106
159
  });
107
160
 
108
161
  // 生成调试二维码 URL
@@ -145,6 +198,20 @@ export async function mpDebugShell(newAppInfo, isGodot = false) {
145
198
  const handleShutdown = () => {
146
199
  Logger.info('\n正在关闭调试服务器...');
147
200
  let isClosed = false;
201
+
202
+ // 优先停止 Web H5 dev server(debug 进程)
203
+ if (devH5Process) {
204
+ try {
205
+ // detached 后它会成为新的进程组 leader,使用负 pid 可杀整个组
206
+ process.kill(-devH5Process.pid, 'SIGTERM');
207
+ } catch (e) {
208
+ try {
209
+ devH5Process.kill('SIGTERM');
210
+ } catch (_) {
211
+ // ignore
212
+ }
213
+ }
214
+ }
148
215
 
149
216
  // 尝试关闭服务器
150
217
  server.close(() => {
@@ -192,7 +192,18 @@ export class ProjectInitializer {
192
192
  // 生成配置
193
193
  spinner.text = '正在生成项目配置...';
194
194
  const appid = this.config.generateAppId();
195
- const projectConfig = this.config.createProjectConfig(appName, displayName, appid, isGodot ? 'landscape' : 'portrait');
195
+ const projectConfig = this.config.createProjectConfig(appName, displayName, appid, 'portrait');
196
+
197
+ // 根据模板类型设置 htyf.type(app / game / web)
198
+ let appType = 'app';
199
+ if (templateType === CONSTANTS.TEMPLATE_TYPES.GAME_TEMPLATE) {
200
+ appType = 'game';
201
+ } else if (templateType === CONSTANTS.TEMPLATE_TYPES.WEB_TEMPLATE) {
202
+ appType = 'web';
203
+ } else if (templateType === CONSTANTS.TEMPLATE_TYPES.APP_TEMPLATE) {
204
+ appType = 'app';
205
+ }
206
+ projectConfig.type = appType;
196
207
  // 写入配置文件
197
208
  const configPath = path.join(appRootPath, 'app.json');
198
209
  let existingConfig = {};