Jacob
2026-10-12 4a45efcec76cbe047d069aef04e315082ff0775d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
/**
 * 命令行发行(不上传)+ 自动用微信开发者工具打开产物
 *
 * 用法:
 *   node scripts/publish.js sit            # 发行 SIT 版本并打开微信开发者工具
 *   node scripts/publish.js prod           # 发行 PROD 版本并打开微信开发者工具
 *   node scripts/publish.js sit --no-gen   # 不改 manifest.json(要求它已存在)
 *   node scripts/publish.js sit --no-open  # 只编译,不打开微信开发者工具
 *   node scripts/publish.js sit --dry-run  # 只打印将要执行的命令
 *
 * 流程:从 manifest.json.demo 生成 manifest.json(按目标环境注入 appid)→ 发行编译 →
 *       校正产物 appid → 用微信开发者工具打开产物
 *
 * 环境值统一取自 config/env/<env>.js(唯一数据源),接口地址等运行时参数由包内按 appid 自动识别;
 * manifest.json 是生成物(已 .gitignore),因此 git 永远没有环境噪音。
 * 微信开发者工具打开的产物 appid = unpackage/dist/build/mp-weixin/project.config.json 的 appid
 * (构建时由 manifest 生成,这里再兜底校正一次)。
 *
 * 外部工具(HBuilderX cli.exe、微信开发者工具 cli.bat)不写死绝对路径,按顺序自动查找:
 *   1) 环境变量 HBUILDERX_CLI / WX_DEVTOOLS_CLI
 *   2) 系统 PATH
 *   3) 常见安装目录(Program Files、用户目录、各磁盘常见软件目录)
 *   4) 磁盘根目录浅层扫描(≤2 层)
 * 找到后会把其所在目录加入本次进程 PATH(便于按名字直接调用),找不到则报错并给出解决办法。
 */
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const manifest = require('./manifest');
 
const ROOT = path.resolve(__dirname, '..');
const ENV_DIR = path.join(ROOT, 'config', 'env');
const DIST_DIR = path.join(ROOT, 'unpackage', 'dist', 'build', 'mp-weixin');
const DIST_CONFIG = path.join(DIST_DIR, 'project.config.json');
 
/* ------------------------- 外部工具定位 ------------------------- */
 
// 扫描时要跳过的系统/无关目录
const SKIP_DIRS = /^(\$recycle\.bin|system volume information|windows|winsxs|node_modules|\.git|\.cache|programdata)$/i;
 
const HX_TOOL = {
   envKey: 'HBUILDERX_CLI',
   label: 'HBuilderX 命令行 cli.exe',
   names: ['cli.exe'],
   subDirs: ['HBuilderX', 'HBuilderX\\HBuilderX'],
   dirPattern: /^hbuilderx/i,
   ok: function(file) { return /hbuilderx/i.test(file); },
   hint: 'HBuilderX 安装/解压目录下的 cli.exe(如 X:\\HBuilderX\\cli.exe)',
   extra: ''
};
 
const WX_TOOL = {
   envKey: 'WX_DEVTOOLS_CLI',
   label: '微信开发者工具命令行 cli.bat',
   names: ['cli.bat'],
   subDirs: ['微信web开发者工具', '微信开发者工具', 'Tencent\\微信web开发者工具', 'Tencent\\微信开发者工具'],
   dirPattern: /微信(web)?开发者工具/,
   hint: '微信开发者工具安装目录下的 cli.bat(如 X:\\Program Files (x86)\\Tencent\\微信web开发者工具\\cli.bat)',
   extra: '    3) 或加 --no-open,只编译不自动打开产物\n'
};
 
function fail(msg) {
   console.error('[release] ' + msg);
   process.exit(1);
}
 
function uniq(list) {
   return list.filter(function(v, i) { return v && list.indexOf(v) === i; });
}
 
function isFile(p) {
   try { return fs.statSync(p).isFile(); } catch (e) { return false; }
}
 
function isDir(p) {
   try { return fs.statSync(p).isDirectory(); } catch (e) { return false; }
}
 
/** 本地固定磁盘(C:、D: …);拿不到时退回环境变量里的盘符(结果缓存,只探测一次) */
let DRIVES = null;
function fixedDrives() {
   if (DRIVES) return DRIVES;
   const ps = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command',
       "[System.IO.DriveInfo]::GetDrives() | Where-Object { $_.DriveType -eq 'Fixed' } | ForEach-Object { $_.Name }"
   ], { encoding: 'utf8', windowsHide: true });
   const fromPs = (ps.stdout || '').split(/\r?\n/)
       .map(function(s) { return s.trim().replace(/\\$/, ''); })
       .filter(function(s) { return /^[A-Za-z]:$/.test(s); });
   const fromEnv = [process.env.SystemDrive, process.env.HOMEDRIVE];
   DRIVES = uniq(fromPs.concat(fromEnv).map(function(d) { return (d || '').replace(/\\$/, ''); }));
   return DRIVES;
}
 
/** 在系统 PATH 中按文件名查找,ok() 用于排除同名无关程序 */
function findOnPath(names, ok) {
   const dirs = (process.env.PATH || '').split(path.delimiter);
   for (let i = 0; i < dirs.length; i++) {
       const dir = dirs[i].replace(/^"|"$/g, '');
       if (!dir) continue;
       for (let j = 0; j < names.length; j++) {
           const file = path.join(dir, names[j]);
           if (isFile(file) && (!ok || ok(file))) return file;
       }
   }
   return '';
}
 
/** 常见安装根目录:Program Files、用户目录、各磁盘根与常见软件目录 */
function baseDirs() {
   const bases = [
       process.env.ProgramFiles,
       process.env['ProgramFiles(x86)'],
       process.env.LOCALAPPDATA,
       process.env.APPDATA,
       process.env.USERPROFILE,
       process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs'),
       process.env.USERPROFILE && path.join(process.env.USERPROFILE, 'Desktop'),
       process.env.USERPROFILE && path.join(process.env.USERPROFILE, 'Downloads')
   ];
   fixedDrives().forEach(function(d) {
       bases.push(d + '\\', d + '\\Program Files', d + '\\Program Files (x86)',
           d + '\\soft', d + '\\software', d + '\\Programs', d + '\\develop');
   });
   return uniq(bases).filter(isDir);
}
 
/** 磁盘浅层扫描:找名字符合特征的目录(兜底手段) */
function scanPlausibleDirs(pattern) {
   const found = [];
   fixedDrives().forEach(function(root) {
       let level1 = [];
       try { level1 = fs.readdirSync(root + '\\', { withFileTypes: true }); } catch (e) { return; }
       level1.forEach(function(e1) {
           if (!e1.isDirectory() || SKIP_DIRS.test(e1.name)) return;
           const p1 = path.join(root + '\\', e1.name);
           if (pattern.test(e1.name)) { found.push(p1); return; }
           let level2 = [];
           try { level2 = fs.readdirSync(p1, { withFileTypes: true }); } catch (e) { return; }
           level2.forEach(function(e2) {
               if (e2.isDirectory() && pattern.test(e2.name)) found.push(path.join(p1, e2.name));
           });
       });
   });
   return found;
}
 
/** 定位工具:返回 { file, source },找不到返回 null */
function resolveTool(cfg) {
   // 1) 环境变量显式指定
   const fromEnv = (process.env[cfg.envKey] || '').trim();
   if (fromEnv) {
       if (isFile(fromEnv)) return { file: fromEnv, source: '环境变量 ' + cfg.envKey };
       fail('环境变量 ' + cfg.envKey + ' 指向的文件不存在:' + fromEnv);
   }
 
   // 2) 系统 PATH
   const onPath = findOnPath(cfg.names, cfg.ok);
   if (onPath) return { file: onPath, source: '系统 PATH' };
 
   // 3) 常见安装目录 + 磁盘浅层扫描
   const dirs = baseDirs().concat(scanPlausibleDirs(cfg.dirPattern));
   for (let i = 0; i < dirs.length; i++) {
       for (let j = 0; j < cfg.subDirs.length; j++) {
           for (let k = 0; k < cfg.names.length; k++) {
               const file = path.join(dirs[i], cfg.subDirs[j], cfg.names[k]);
               if (isFile(file)) return { file: file, source: '常见安装目录' };
           }
       }
   }
   return null;
}
 
/** 找不到工具时的报错提示 */
function toolFail(cfg) {
   fail('未找到 ' + cfg.label + '\n' +
       '  已查找:环境变量 ' + cfg.envKey + ' → 系统 PATH → 常见安装目录 → 磁盘浅层扫描\n' +
       '  典型位置:' + cfg.hint + '\n' +
       '  解决办法(任选其一):\n' +
       '    1) 把安装目录加入 PATH:set PATH=%PATH%;<安装目录>\n' +
       '    2) 用环境变量指定:set ' + cfg.envKey + '=<cli 完整路径>\n' +
       cfg.extra);
}
 
/** 把工具所在目录加入本次进程 PATH,便于按名字直接调用 */
function putOnPath(file) {
   const dir = path.dirname(file);
   const cur = process.env.PATH || '';
   if (cur.split(path.delimiter).indexOf(dir) < 0) process.env.PATH = dir + path.delimiter + cur;
}
 
/* ------------------------- 参数解析 ------------------------- */
 
const argv = process.argv.slice(2);
const envName = (argv.find(function(a) { return a.charAt(0) !== '-'; }) || '').trim();
const noGen = argv.indexOf('--no-gen') > -1;
const noOpen = argv.indexOf('--no-open') > -1;
const dryRun = argv.indexOf('--dry-run') > -1;
 
const OWN_ARGS = ['--no-gen', '--no-open', '--dry-run'];
// 透传给 HBuilderX cli 的额外参数(保留 "参数 值" 成对关系)
const passthrough = [];
let envNameSkipped = false;
argv.forEach(function(a) {
   if (OWN_ARGS.indexOf(a) > -1) return;
   if (!envNameSkipped && a === envName) {
       envNameSkipped = true;
       return;
   }
   passthrough.push(a);
});
 
function readAppId(file) {
   try {
       return JSON.parse(fs.readFileSync(file, 'utf8')).appid || '';
   } catch (e) {
       return '';
   }
}
 
/** 回写 appid(正则替换,保留原格式) */
function writeAppId(file, appid) {
   const prev = fs.readFileSync(file, 'utf8');
   const next = prev.replace(/"appid"(\s*:\s*)"[^"]*"/, function(m, sep) {
       return '"appid"' + sep + '"' + appid + '"';
   });
   if (next !== prev) fs.writeFileSync(file, next, 'utf8');
   return readAppId(file);
}
 
function main() {
   const available = fs.existsSync(ENV_DIR)
       ? fs.readdirSync(ENV_DIR).filter(f => f.endsWith('.js') && f !== 'index.js').map(f => f.replace(/\.js$/, ''))
       : [];
 
   if (!envName) fail('请指定环境名,可选:' + available.join(' / ') + '\n  例:node scripts/publish.js sit');
   if (available.indexOf(envName) < 0) fail('未找到 config/env/' + envName + '.js,可选:' + available.join(' / '));
 
   const env = require(path.join(ENV_DIR, envName + '.js'));
 
   // 定位外部工具(不写死路径)
   console.log('[release] 查找 HBuilderX / 微信开发者工具…');
   const hx = resolveTool(HX_TOOL);
   if (!hx) toolFail(HX_TOOL);
   const wx = noOpen ? null : resolveTool(WX_TOOL);
   if (!noOpen && !wx) toolFail(WX_TOOL);
   putOnPath(hx.file);
   if (wx) putOnPath(wx.file);
 
   // 目标编译产物由 manifest.json 决定,而 manifest.json 是生成物
   if (noGen && !fs.existsSync(manifest.TARGET)) {
       fail('--no-gen 需要 manifest.json 已存在,请先运行:node scripts/set-env.js ' + envName);
   }
 
   console.log('[release] 环境         : ' + envName + '(' + env.ENV_NAME + ')');
   console.log('[release] 小程序appid  : ' + env.APPID);
   console.log('[release] 接口地址     : ' + env.HTTP_REQUEST_URL);
   console.log('[release] 产物目录     : ' + DIST_DIR);
   console.log('[release] HBuilderX    : ' + hx.file + '(' + hx.source + ')');
   if (wx) console.log('[release] 开发者工具   : ' + wx.file + '(' + wx.source + ')');
   console.log('[release] manifest.json: ' + (noGen
       ? '不改动(--no-gen)'
       : '由 manifest.json.demo 生成(appid ' + env.APPID + ',生成物不入库)'));
 
   const cliArgs = ['publish', '--platform', 'mp-weixin', '--project', ROOT].concat(passthrough);
   // 路径含空格/中文,交给 cmd 解析更稳(--no-open 时没有微信开发者工具,不拼这条命令)
   const openCmd = wx ? '"' + wx.file + '" open --project "' + DIST_DIR + '"' : '';
 
   if (dryRun) {
       console.log('[release] 0) 生成: manifest.json ← manifest.json.demo' + (noGen ? '(已指定 --no-gen,跳过)' : ''));
       console.log('[release] 1) 编译: "' + hx.file + '" ' + cliArgs.join(' '));
       console.log('[release] 2) 产物 appid 回写为 ' + env.APPID + ' → ' + DIST_CONFIG);
       if (!noOpen) console.log('[release] 3) 打开: ' + openCmd);
       console.log('[release] --dry-run,未执行');
       return;
   }
 
   // 1) 生成 manifest.json(HBuilderX 的编译输入,模板:manifest.json.demo)
   if (noGen) {
       console.log('[release] 跳过 manifest.json 生成(--no-gen)');
   } else {
       manifest.generate(env);
   }
 
   // 2) 发行编译(产物:unpackage/dist/build/mp-weixin)
   console.log('[release] 开始发行编译,日志见 HBuilderX 控制台…');
   const build = spawnSync(hx.file, cliArgs, { cwd: ROOT, stdio: 'inherit' });
   if (build.error) fail('调用 cli.exe 失败:' + build.error.message);
   if (build.status !== 0) fail('cli.exe 退出码 ' + build.status + ',请查看 HBuilderX 控制台输出');
 
   // 3) 校正产物 appid(正常应与 manifest 一致,这里兜底保证)
   if (!fs.existsSync(DIST_CONFIG)) {
       fail('发行产物缺少 project.config.json:' + DIST_CONFIG + '\n  请确认 HBuilderX 编译是否成功');
   }
   const before = readAppId(DIST_CONFIG);
   const after = writeAppId(DIST_CONFIG, env.APPID);
   if (!after) fail('回写产物 appid 失败:' + DIST_CONFIG);
   console.log('[release] 产物 appid : ' + before + (before === after ? '(无需调整)' : ' → ' + after));
 
   // 4) 用微信开发者工具打开产物(首次需在「设置-安全设置」中开启服务端口)
   if (!noOpen) {
       console.log('[release] 打开微信开发者工具…');
       const open = spawnSync(openCmd, { cwd: ROOT, stdio: 'inherit', shell: true });
       if (open.error || open.status !== 0) {
           console.log('[release] 自动打开失败(常见原因:未开启服务端口、微信开发者工具版本较旧)');
           console.log('[release] 请手动打开微信开发者工具 → 导入项目 → 选择目录:' + DIST_DIR);
       }
   }
 
   console.log('[release] 完成。微信开发者工具中的项目 appid = ' + after + ',包内会自动使用 ' + env.HTTP_REQUEST_URL);
}
 
main();