diff --git a/AGENTS.md b/AGENTS.md index 9b46c75074..fc1ebf84a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -291,6 +291,7 @@ change directly affects build, packaging, or CI cannot protect the path. | Installer frontend or i18n runtime without packaging changes | `pnpm --dir BitFun-Installer run type-check` | | Installer Tauri/Rust changes | `cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml` | | Installer packaging, payload, install/uninstall flow, or native bundling | `pnpm run installer:build` | +| Build scripts or prerequisite changes | `pnpm run check:build-prereqs`, plus `node --test scripts/check-build-prereqs.test.mjs` when the check logic changed | ## Agent-doc priority diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1427f2a285..1532ef9bf2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,30 @@ you intentionally use `pnpm run desktop:dev:raw`. In that case, run `scripts/ci/setup-openssl-windows.ps1`, or set `OPENSSL_DIR` to a pre-built x64 OpenSSL directory and set `OPENSSL_STATIC=1`. +#### Build Prerequisites Check + +When `cargo check --workspace`, `cargo check -p bitfun-desktop`, or pnpm build +commands fail with confusing errors (e.g., "resource path doesn't exist" or +sherpa-onnx download failures), run the preflight check to identify missing +prerequisites and get actionable fix commands: + +```bash +pnpm run check:build-prereqs # check only +pnpm run check:build-prereqs -- --fix # attempt to fix missing prerequisites +``` + +The check detects: + +- Missing `node_modules` (fix: `pnpm install`) +- Missing `src/mobile-web/dist` (fix: `pnpm run prepare:mobile-web` — the + bitfun-desktop Tauri build script references this directory as a resource, + so `cargo check -p bitfun-desktop` and `cargo check --workspace` fail + without it) +- Missing sherpa-onnx prebuilt libs (the sherpa-onnx-sys build script + downloads from GitHub at build time; if the download fails on poor + connectivity, set `SHERPA_ONNX_LIB_DIR` to the prebuilt lib directory + under `target/sherpa-onnx-prebuilt/` to use the local copy) + ### Install dependencies ```bash diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index 16725d2e99..11953c8910 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -31,6 +31,28 @@ GitHub Actions 升级使用的是兼容 Node.js 24 的 action runtime,但项 时才需要手动处理。此时运行 `scripts/ci/setup-openssl-windows.ps1`,或将 `OPENSSL_DIR` 指向预编译的 x64 OpenSSL 目录,并设置 `OPENSSL_STATIC=1`。 +#### 构建前置检查 + +当 `cargo check --workspace`、`cargo check -p bitfun-desktop` 或 pnpm 构建 +命令报出难以理解的错误(如 "resource path doesn't exist" 或 sherpa-onnx +下载失败)时,运行前置检查以识别缺失的依赖并获取可操作的修复命令: + +```bash +pnpm run check:build-prereqs # 仅检查 +pnpm run check:build-prereqs -- --fix # 尝试自动修复缺失的前置依赖 +``` + +检查项包括: + +- 缺少 `node_modules`(修复:`pnpm install`) +- 缺少 `src/mobile-web/dist`(修复:`pnpm run prepare:mobile-web` — + bitfun-desktop 的 Tauri 构建脚本将该目录作为资源引用,缺失时 + `cargo check -p bitfun-desktop` 和 `cargo check --workspace` 会失败) +- 缺少 sherpa-onnx 预编译库(sherpa-onnx-sys 构建脚本会在构建时从 + GitHub 下载;若网络连通性差导致下载失败,设置 + `SHERPA_ONNX_LIB_DIR` 指向 `target/sherpa-onnx-prebuilt/` 下的预编译 + lib 目录以使用本地副本) + ### 安装依赖 ```bash diff --git a/package.json b/package.json index 8c0a663075..b7a125b6f8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "theme:color-audit:test": "node --test scripts/audit-theme-colors.test.mjs scripts/audit-cli-theme-colors.test.mjs", "theme:visual-contract": "node scripts/validate-theme-visual-contract.mjs", "check:repo-hygiene": "node scripts/check-repo-hygiene.mjs", + "check:build-prereqs": "node scripts/check-build-prereqs.mjs", "check:core-boundaries": "node scripts/check-core-boundaries.mjs", "check:core-boundaries:test": "node --test scripts/check-core-boundaries.test.mjs", "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs && node --test scripts/check-github-config.test.mjs", diff --git a/scripts/check-build-prereqs.mjs b/scripts/check-build-prereqs.mjs new file mode 100644 index 0000000000..10908401c6 --- /dev/null +++ b/scripts/check-build-prereqs.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +/** + * Build prerequisite preflight check. + * + * Detects missing build prerequisites that cause confusing cargo/pnpm failures: + * + * - Root node_modules missing → pnpm scripts fail with "node_modules missing, + * did you mean to install?" + * - src/mobile-web/dist missing → cargo check -p bitfun-desktop and + * cargo check --workspace fail with "resource path '../../mobile-web/dist' + * doesn't exist" in the bitfun-desktop build script + * - sherpa-onnx prebuilt libs missing → sherpa-onnx-sys build script attempts + * a network download from GitHub that fails on poor connectivity + * + * Usage: + * node scripts/check-build-prereqs.mjs # check only + * node scripts/check-build-prereqs.mjs --fix # attempt to fix missing prereqs + * + * When cargo check or pnpm build fails with a confusing error about missing + * resources or sherpa-onnx download failures, run this check first. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT_DIR = __dirname; +const DEFAULT_ROOT = join(SCRIPT_DIR, '..'); +const ROOT_DIR = process.env.BITFUN_BUILD_PREREQS_TEST_ROOT || DEFAULT_ROOT; +const FIX = process.argv.includes('--fix'); + +// --- Check logic (extracted for re-use and testing) --- + +function runChecks(rootDir) { + const errors = []; + const warnings = []; + + // --- Check 1: Root node_modules --- + if (!existsSync(join(rootDir, 'node_modules'))) { + errors.push({ + name: 'root node_modules', + message: 'Root node_modules is missing. pnpm scripts will not work.', + fix: ['pnpm', 'install'], + }); + } + + // --- Check 2: mobile-web dist (required by bitfun-desktop build script) --- + if (!existsSync(join(rootDir, 'src', 'mobile-web', 'dist', 'index.html'))) { + errors.push({ + name: 'mobile-web dist', + message: + "src/mobile-web/dist is missing. The bitfun-desktop Tauri build script references '../../mobile-web/dist' as a resource, so 'cargo check -p bitfun-desktop' and 'cargo check --workspace' will fail with \"resource path doesn't exist\".", + fix: ['pnpm', 'run', 'prepare:mobile-web'], + }); + } + + // --- Check 3: sherpa-onnx prebuilt libs --- + // sherpa-onnx-sys build.rs auto-detects target/sherpa-onnx-prebuilt//lib/ + // and returns immediately without downloading. Only warn for the first-build + // scenario where no prebuilt cache exists yet. + const sherpaLibDirEnv = process.env.SHERPA_ONNX_LIB_DIR; + const sherpaPrebuiltDir = join(rootDir, 'target', 'sherpa-onnx-prebuilt'); + + if (sherpaLibDirEnv) { + if (!existsSync(sherpaLibDirEnv)) { + warnings.push({ + name: 'sherpa-onnx env var', + message: `SHERPA_ONNX_LIB_DIR is set to "${sherpaLibDirEnv}" but the path does not exist.`, + }); + } + } else if (!existsSync(sherpaPrebuiltDir)) { + warnings.push({ + name: 'sherpa-onnx', + message: + 'No prebuilt sherpa-onnx cache found. The first build will download from GitHub. If connectivity is poor and the download fails, you can set SHERPA_ONNX_ARCHIVE_DIR to a directory containing the pre-downloaded archive.', + }); + } + // If prebuilt dir exists, build.rs auto-detects it — no warning needed. + + return { errors, warnings }; +} + +function collectPendingFixes(errors) { + return errors + .filter((e) => e.fix) + .map((e) => ({ name: e.name, fix: e.fix })); +} + +function reportResults({ errors, warnings }) { + if (errors.length > 0) { + console.error('Build prerequisite check failed:\n'); + for (const e of errors) { + console.error(` [FAIL] ${e.name}: ${e.message}`); + if (e.fix) { + console.error(` Fix: ${e.fix.join(' ')}`); + } + } + console.error(); + } + + if (warnings.length > 0) { + console.warn('Build prerequisite warnings:\n'); + for (const w of warnings) { + console.warn(` [WARN] ${w.name}: ${w.message}`); + } + console.warn(); + } +} + +function runFixes(pendingFixes, rootDir) { + let allSucceeded = true; + for (const { name, fix } of pendingFixes) { + const [cmd, ...args] = fix; + console.log(`$ ${fix.join(' ')}`); + try { + execFileSync(cmd, args, { stdio: 'inherit', cwd: rootDir }); + } catch { + console.error(`Fix command failed: ${fix.join(' ')}\n`); + allSucceeded = false; + } + console.log(); + } + return allSucceeded; +} + +// --- Main --- + +const firstResult = runChecks(ROOT_DIR); + +if (firstResult.errors.length === 0 && firstResult.warnings.length === 0) { + console.log('Build prerequisite check passed.'); + process.exit(0); +} + +reportResults(firstResult); + +if (firstResult.errors.length > 0) { + const pendingFixes = collectPendingFixes(firstResult.errors); + + if (FIX && pendingFixes.length > 0) { + console.log('Attempting fixes...\n'); + const allSucceeded = runFixes(pendingFixes, ROOT_DIR); + + if (allSucceeded) { + console.log('Re-checking prerequisites...\n'); + const secondResult = runChecks(ROOT_DIR); + reportResults(secondResult); + + if (secondResult.errors.length === 0) { + console.log('All errors resolved after fix.'); + process.exit(0); + } + console.error('Some errors remain after fix.'); + process.exit(1); + } + console.error('Some fix attempts failed. See errors above.'); + process.exit(1); + } + + console.error( + 'Run with --fix to attempt automatic fixes for missing prerequisites.', + ); + process.exit(1); +} + +// Only warnings, no errors +process.exit(0); diff --git a/scripts/check-build-prereqs.test.mjs b/scripts/check-build-prereqs.test.mjs new file mode 100644 index 0000000000..ef12f4a175 --- /dev/null +++ b/scripts/check-build-prereqs.test.mjs @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const scriptPath = path.join(repoRoot, 'scripts/check-build-prereqs.mjs'); + +function createTestRoot({ nodeModules = false, mobileWebDist = false, sherpaOnnx = null } = {}) { + const root = mkdtempSync(path.join(tmpdir(), 'bitfun-build-prereqs-')); + + if (nodeModules) { + mkdirSync(path.join(root, 'node_modules'), { recursive: true }); + } + + if (mobileWebDist) { + const distDir = path.join(root, 'src', 'mobile-web', 'dist'); + mkdirSync(distDir, { recursive: true }); + writeFileSync(path.join(distDir, 'index.html'), ''); + } + + if (sherpaOnnx) { + for (const version of sherpaOnnx) { + const libDir = path.join( + root, + 'target', + 'sherpa-onnx-prebuilt', + version, + 'lib', + ); + mkdirSync(libDir, { recursive: true }); + writeFileSync(path.join(libDir, 'libsherpa-onnx-c-api.a'), ''); + } + } + + return root; +} + +function createFakePnpm() { + const binDir = mkdtempSync(path.join(tmpdir(), 'bitfun-fake-pnpm-')); + const pnpmPath = path.join(binDir, 'pnpm'); + writeFileSync( + pnpmPath, + `#!/usr/bin/env node +const { mkdirSync, writeFileSync } = require('fs'); +const args = process.argv.slice(2); +if (args[0] === 'install') { + mkdirSync('node_modules', { recursive: true }); +} else if (args[0] === 'run' && args[1] === 'prepare:mobile-web') { + mkdirSync('src/mobile-web/dist', { recursive: true }); + writeFileSync('src/mobile-web/dist/index.html', ''); +} +`, + ); + chmodSync(pnpmPath, 0o755); + return binDir; +} + +function runCheck(root, { fix = false, extraPath = null, sherpaEnv = null } = {}) { + const env = { + ...process.env, + BITFUN_BUILD_PREREQS_TEST_ROOT: root, + }; + if (extraPath) { + env.PATH = `${extraPath}${path.delimiter}${env.PATH || ''}`; + } + if (sherpaEnv !== null) { + if (sherpaEnv === '') { + delete env.SHERPA_ONNX_LIB_DIR; + } else { + env.SHERPA_ONNX_LIB_DIR = sherpaEnv; + } + } + + const args = fix ? [scriptPath, '--fix'] : [scriptPath]; + + return spawnSync(process.execPath, args, { + env, + encoding: 'utf8', + }); +} + +test('passes when all prerequisites are present (including sherpa-onnx prebuilt)', (t) => { + const root = createTestRoot({ + nodeModules: true, + mobileWebDist: true, + sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], + }); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const result = runCheck(root, { sherpaEnv: '' }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Build prerequisite check passed/); + assert.doesNotMatch(result.stderr, /\[WARN\]/); +}); + +test('fails when root node_modules is missing', (t) => { + const root = createTestRoot({ + mobileWebDist: true, + sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], + }); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const result = runCheck(root, { sherpaEnv: '' }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /\[FAIL\] root node_modules/); + assert.match(result.stderr, /Fix: pnpm install/); +}); + +test('fails when mobile-web dist is missing', (t) => { + const root = createTestRoot({ + nodeModules: true, + sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], + }); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const result = runCheck(root, { sherpaEnv: '' }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /\[FAIL\] mobile-web dist/); + assert.match(result.stderr, /Fix: pnpm run prepare:mobile-web/); +}); + +test('warns when sherpa-onnx prebuilt dir does not exist (first build)', (t) => { + const root = createTestRoot({ nodeModules: true, mobileWebDist: true }); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const result = runCheck(root, { sherpaEnv: '' }); + + assert.equal(result.status, 0); + assert.match(result.stderr, /\[WARN\] sherpa-onnx/); + assert.match(result.stderr, /first build will download from GitHub/); +}); + +test('exits with error code when both errors and warnings are present', (t) => { + const root = createTestRoot({ mobileWebDist: false }); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const result = runCheck(root, { sherpaEnv: '' }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /\[FAIL\]/); + assert.match(result.stderr, /\[WARN\] sherpa-onnx/); +}); + +test('--fix runs fix commands, re-verifies, and exits 0 when errors resolved', (t) => { + const binDir = createFakePnpm(); + const root = createTestRoot(); + t.after(() => { + rmSync(root, { recursive: true, force: true }); + rmSync(binDir, { recursive: true, force: true }); + }); + + const result = runCheck(root, { fix: true, extraPath: binDir, sherpaEnv: '' }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Attempting fixes/); + assert.match(result.stdout, /\$ pnpm install/); + assert.match(result.stdout, /\$ pnpm run prepare:mobile-web/); + assert.match(result.stdout, /Re-checking prerequisites/); + assert.match(result.stdout, /All errors resolved/); +});