Files
ClassHelper/packages/classisland-plugin/scripts/build.mjs
T
LoveChengke 82c85b59c1 feat(dist,deploy,website): 交付产物统一归集到 releases/<组件>/<版本>,补齐官网与 Linux 验收
三个打包脚本原先各写各的输出目录,现在一律直接落在仓库根 releases/<组件>/<版本>/,分层固定为安装包/免安装/构建中间;dist-win 用 --config.directories.output 覆盖 electron-builder 的目录后再整理,显式传入时不覆盖。插件侧顺手修掉一个既有 bug:原先 rmSync 整个组件目录,重打一次就把上一版的留档抹掉,现在只清当前版本目录。verify-packaged 的 --exe 默认值跟着改到新落点。

deploy/verify-linux.sh 修掉管道在 pipefail 下的 SIGPIPE 误判(命中被算成失败);CI workflow 的出包与上传路径同步;electron-builder.yml 显式指定安装包图标。

website/ 是产品官网(纯静态、无构建步骤,不进 pnpm workspace),含 beUI 动效 token 与实机留档图;客户端冒烟相应加了采集留档图用的外观开关。
2026-10-06 15:31:38 +08:00

181 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ClassIsland 联动插件构建脚本:`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin`
*
* 用法:
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
* node scripts/build.mjs --cipx # 追加打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
*
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
* 1. `dotnet` 默认把 NuGet 缓存与临时文件写在 C 盘(`%USERPROFILE%\.nuget`、`%TEMP%`),
* 本机 C 盘紧张时会直接构建失败。这里统一改到仓库内 `.cache/`(已 gitignore)。
* 2. 插件包(.cipx)的最后一步要调 PowerShell 生成哈希清单,SDK 默认用 `pwsh`;
* 没有 pwsh 的机器会失败,因此这里探测一次并显式传给 MSBuild。
* 3. 构建完顺手校验"清单 / 程序集 / 版本号"三者一致,避免打出装不上的包。
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pluginDir, '..', '..');
const cacheDir = path.join(repoRoot, '.cache');
const releasesRoot = path.join(repoRoot, 'releases', 'classisland-plugin');
const argv = process.argv.slice(2);
const withCipx = argv.includes('--cipx');
/** `--` 之后的参数原样透传给 dotnet build;其余以 - 开头的参数同样透传 */
const passthrough = argv.filter((item) => item !== '--cipx' && item !== '--');
const log = (message) => console.log(`[classisland-plugin] ${message}`);
/* ------------------------------------------------------------ 1. 环境准备 */
const env = { ...process.env };
for (const sub of ['nuget', 'tmp', 'dotnet-home']) {
fs.mkdirSync(path.join(cacheDir, sub), { recursive: true });
}
const setDefault = (key, value) => {
if (!env[key]) env[key] = value;
};
setDefault('NUGET_PACKAGES', path.join(cacheDir, 'nuget'));
setDefault('TEMP', path.join(cacheDir, 'tmp'));
setDefault('TMP', env.TEMP);
setDefault('DOTNET_CLI_HOME', path.join(cacheDir, 'dotnet-home'));
env.DOTNET_NOLOGO = '1';
env.DOTNET_CLI_TELEMETRY_OPTOUT = '1';
/** 可执行文件探测:先看显式配置,再问 PATH,最后试常见安装路径 */
function resolveExecutable(envKey, names, fallbacks) {
const candidates = [];
if (env[envKey]) candidates.push(env[envKey]);
const finder = process.platform === 'win32' ? 'where' : 'which';
for (const name of names) {
const found = spawnSync(finder, [name], { encoding: 'utf8', env });
if (found.status === 0) {
for (const line of String(found.stdout ?? '').split(/\r?\n/)) candidates.push(line.trim());
}
}
candidates.push(...fallbacks);
for (const candidate of candidates) {
if (!candidate) continue;
try {
if (fs.statSync(candidate).isFile()) return candidate;
} catch {
/* 继续试下一个 */
}
}
return null;
}
const dotnet = resolveExecutable(
'DOTNET_EXE',
['dotnet'],
process.platform === 'win32'
? [path.join(env.ProgramFiles ?? 'C:\\Program Files', 'dotnet', 'dotnet.exe')]
: ['/usr/local/bin/dotnet', '/usr/bin/dotnet'],
);
if (!dotnet) {
throw new Error('未找到 dotnet SDK(需要 .NET 8 SDK)。安装后用 DOTNET_EXE 指定完整路径。');
}
const powerShell = resolveExecutable(
'POWERSHELL_EXE',
['pwsh', 'powershell'],
process.platform === 'win32'
? [path.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')]
: [],
);
/* ------------------------------------------------------------ 2. 构建 */
const buildArgs = ['build', '-c', 'Release'];
if (withCipx) {
buildArgs.push('-p:CreateCipx=true');
// SDK 的 cipx 目标默认调 pwsh;这里按探测结果显式指定,避免"没有 pwsh 就打包失败"
if (powerShell) buildArgs.push(`-p:PowershellBinaryName=${powerShell}`);
}
buildArgs.push(...passthrough);
log(`dotnet build(缓存目录 ${path.relative(repoRoot, cacheDir)})`);
const build = spawnSync(dotnet, buildArgs, { cwd: pluginDir, stdio: 'inherit', env });
if (build.error) throw new Error(`无法启动 dotnet:${build.error.message}`);
if (build.status !== 0) throw new Error(`dotnet build 失败(退出码 ${build.status})`);
/* ------------------------------------------------------------ 3. 产物自检 */
const outputDir = path.join(pluginDir, 'bin', 'Release');
const manifestPath = path.join(outputDir, 'manifest.yml');
const assemblyPath = path.join(outputDir, 'ClassHelper.ClassIslandPlugin.dll');
for (const required of [manifestPath, assemblyPath, path.join(outputDir, 'icon.png')]) {
if (!fs.existsSync(required)) {
throw new Error(`构建产物缺少 ${path.relative(pluginDir, required)},ClassIsland 会加载失败`);
}
}
const manifest = fs.readFileSync(manifestPath, 'utf8');
// prettier 会把 YAML 字符串统一成单引号,因此两种引号都要能剥掉
const entrance = /^entranceAssembly:\s*(.+?)\s*$/m.exec(manifest)?.[1]?.replace(/^['"]|['"]$/g, '');
if (entrance !== path.basename(assemblyPath)) {
throw new Error(
`manifest.yml 的 entranceAssembly(${entrance})与程序集(${path.basename(assemblyPath)})不一致`,
);
}
const manifestVersion = /^version:\s*([\d.]+)/m.exec(manifest)?.[1];
const bridgeSource = fs.readFileSync(path.join(pluginDir, 'src', 'Services', 'BridgeService.cs'), 'utf8');
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
if (!manifestVersion || manifestVersion !== codeVersion) {
throw new Error(
`版本号不一致:manifest.yml=${manifestVersion ?? '缺失'},BridgeService.cs=${codeVersion ?? '缺失'}`,
);
}
log(`产物自检通过(版本 ${manifestVersion},入口程序集 ${entrance})`);
/* ------------------------------------------------------------ 4. 打包归集 */
if (!withCipx) {
log('完成。加 --cipx 可同时打包成 ClassIsland 插件包(.cipx)');
process.exit(0);
}
const cipxDir = path.join(pluginDir, 'cipx');
const cipxFile = path.join(cipxDir, 'ClassHelper.ClassIslandPlugin.cipx');
if (!fs.existsSync(cipxFile)) {
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
}
// 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
// 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
// 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
const pluginVersion = manifestVersion.split('.').slice(0, 3).join('.');
const versionDir = path.join(releasesRoot, pluginVersion);
const installerDir = path.join(versionDir, '安装包');
const portableDir = path.join(versionDir, '免安装', 'ClassHelper.ClassIslandPlugin');
fs.rmSync(installerDir, { recursive: true, force: true });
fs.rmSync(path.dirname(portableDir), { recursive: true, force: true });
fs.mkdirSync(installerDir, { recursive: true });
fs.mkdirSync(portableDir, { recursive: true });
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
for (const name of fs.readdirSync(outputDir)) {
const from = path.join(outputDir, name);
if (!fs.statSync(from).isFile()) continue;
// 依赖清单与调试符号对加载没影响,但会让老师误以为"要装一堆东西",因此只带最小集合
if (!['ClassHelper.ClassIslandPlugin.dll', 'manifest.yml', 'icon.png', 'README.md'].includes(name)) {
continue;
}
fs.copyFileSync(from, path.join(portableDir, name));
}
for (const file of fs.readdirSync(cipxDir)) {
// .cipx 是插件包本体,校验清单跟着版本目录走
const to = file.toLowerCase().endsWith('.cipx') ? installerDir : versionDir;
fs.copyFileSync(path.join(cipxDir, file), path.join(to, file));
}
log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
for (const file of fs.readdirSync(portableDir)) log(` 免安装/ClassHelper.ClassIslandPlugin/${file}`);