import { ProjectConfig } from '@build-script/rushstack-config-loader'; import { ExitCode } from '@idlebox/common'; import { logger } from '@idlebox/logger'; import { findUpUntilSync, shutdown } from '@idlebox/node'; import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { split as splitCmd } from 'split-cmd'; import { context } from './args.js'; import { projectRoot, selfRoot } from './paths.js'; interface IPackageBinary { package: string; binary?: string; arguments?: readonly string[]; } interface ICommandInput { title?: string; command: string | readonly string[] | IPackageBinary; watch?: string | readonly string[] | boolean; cwd?: string; env?: Record; } interface IConfigFileInput { build: (string | ICommandInput)[]; commands: Record; clean: string[]; } export interface ICommand { title: string; command: readonly string[]; cwd: string; env: Record; } export interface IConfigFile { buildTitles: readonly string[]; build: ReadonlyMap; unusedBuild: Record; clean: readonly string[]; additionalPaths: readonly string[]; } function watchModeCmd(command: string | readonly string[], watch?: string | readonly string[] | boolean, watchMode?: boolean): readonly string[] { const cmdArr = typeof command === 'string' ? splitCmd(command) : command; if (!watchMode) { return cmdArr; } if (typeof watch === 'boolean') { throw new Error(`无效的 watch 值: ${watch}. 期望的是字符串或数组`); } if (!watch) watch = ['-w']; return [...cmdArr, ...watch]; } function loadConfigFile(watchMode: boolean): IConfigFile { const config = new ProjectConfig(projectRoot, undefined, logger); const schemaFile = resolve(selfRoot, 'commands.schema.json'); const configFile = config.getJsonConfigInfo('commands'); logger.debug`使用配置文件: long<${configFile.effective}>`; const input: IConfigFileInput = config.loadBothJson('commands', schemaFile, { array(left, right, keyPath) { switch (keyPath[0]) { case 'build': if (Array.isArray(right)) { return right; } else { return left; } case 'clean': { if (!Array.isArray(right)) { return left; } const s = new Set([...left, ...right]); return [...s.values()]; } default: return right; } }, }); const buildMap = new Map(); const buildTitles: string[] = []; for (let item of input.build) { if (typeof item === 'string') { const found = input.commands[item]; if (!found) { const files = []; const info = config.getJsonConfigInfo('commands'); if (info.project.exists) { files.push(info.project.path); } if (info.rig.exists) { files.push(info.rig.path); } logger.info`配置文件list<${files}>`; logger.error`命令 ${input.build.indexOf(item)}"${item}" 不在"commands"中 list<${Object.keys(input.commands)}>`; shutdown(ExitCode.USAGE); } item = found; } if (item.watch === false && watchMode) { let debug_title = item.title; if (!debug_title) { if (Array.isArray(item.command)) { debug_title = item.command[0]; } else if (typeof item.command === 'string') { debug_title = item.command; } else if (typeof item.command === 'object' && 'package' in item.command) { debug_title = item.command.package; } } logger.log`命令"${debug_title}"被设置了watch=false,跳过执行`; continue; } const cmd = resolveCommand(config, item, watchMode); if (buildMap.has(cmd.title)) { throw new Error(`命令"${cmd.title}"重复,请在继续之前重命名它`); } buildMap.set(cmd.title, cmd); buildTitles.push(cmd.title); } const unused: Record = {}; for (const [name, item] of Object.entries(input.commands)) { const title = item.title ?? name; if (buildMap.has(title)) { continue; } unused[title] = resolveCommand(config, item, watchMode); } const additionalPaths: string[] = []; if (config.rigConfig.rigFound) { const nmPath = findUpUntilSync({ file: 'node_modules', from: config.rigConfig.getResolvedProfileFolder() }); if (!nmPath) { throw new Error(`在 rig 配置 "${config.rigConfig.getResolvedProfileFolder()}" 中未找到 "node_modules" 文件夹`); } additionalPaths.push(resolve(nmPath, '.bin')); } const clean = []; for (const item of input.clean) { const abs = resolve(projectRoot, item); if (!abs.startsWith(projectRoot)) { throw new Error(`无效的清理路径"${item}",超出项目范围 "${projectRoot}"`); } clean.push(abs); } return { buildTitles, build: buildMap, unusedBuild: unused, clean, additionalPaths: additionalPaths.toReversed(), }; } function resolveCommand(config: ProjectConfig, input: ICommandInput, watchMode: boolean): ICommand { const cmd = input.command; if (Array.isArray(cmd)) { const copy = cmd.slice(); resolveCommandIsFile(config, copy); return { title: input.title ?? guessTitle(cmd), command: watchModeCmd(copy, input.watch, watchMode), cwd: resolve(projectRoot, input.cwd || '.'), env: input.env ?? {}, }; } else if (typeof cmd === 'object' && 'package' in cmd) { const obj = parsePackagedBinary(config, input, watchMode); return obj; } else { throw TypeError(`无效的命令类型: ${typeof cmd}。期望的类型是字符串、数组或对象`); } } function guessTitle(command: string | readonly string[]): string { if (typeof command === 'string') { return command.split(' ')[0]; } if (Array.isArray(command)) { return command[0]; } throw new Error(`无效的命令: ${Array.isArray(command) ? command.join(' ') : command}`); } function parsePackagedBinary(config: ProjectConfig, item: ICommandInput, watchMode: boolean): ICommand { const cmd = item.command as IPackageBinary; const pkgRootPath = fileURLToPath(config.resolvePackageRoot(cmd.package)); const pkgJsonPath = resolve(pkgRootPath, 'package.json'); let title = item.title; if (!title) { // biome-ignore lint/style/noNonNullAssertion: split must have 0 title = cmd.package.split('/')[0]!; if (cmd.binary && cmd.binary !== title) { title += `:${cmd.binary}`; } } const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')); const typeStr = typeof pkg.bin === 'string'; const typeMap = !!cmd.binary; if (typeStr && typeMap) { throw new Error(`"${pkgJsonPath}"中的"bin"字段是字符串,不能在"commands.json" 中指定"binary"`); } else if (!typeStr && !typeMap) { throw new Error(`"${pkgJsonPath}"中的"bin"字段不是字符串,必须在"commands.json"中指定"binary"`); } let binPath: string; const binVal = typeStr ? pkg.bin : pkg.bin[cmd.binary as string]; if (binVal) { binPath = resolve(pkgJsonPath, '..', binVal); } else if (typeMap && cmd.binary) { const path = resolve(pkgJsonPath, '..', cmd.binary); if (existsSync(path)) { binPath = path; } else { throw new Error(`"${pkgJsonPath}"中的"bin"字段没有键"${cmd.binary}";并且"${path}"看起来不像是一个文件`); } } else { throw new Error(`"${pkgJsonPath}"中的"bin"字段没有"${cmd.binary}"键`); } return { title: title, command: watchModeCmd([process.execPath, binPath, ...(cmd.arguments ?? [])], item.watch, watchMode), cwd: resolve(projectRoot, item.cwd || '.'), env: item.env ?? {}, }; } /** * 如果command第一个元素看似是一个文件,则解析成绝对路径并添加node前缀。 * 直接修改command数组。 * * @param config * @param command */ function resolveCommandIsFile(config: ProjectConfig, command: string[]) { if (!command[0].endsWith('.ts')) return; const r = config.getFileInfo(command[0]); if (!r.effective) { return; // will error later } command.splice(0, 1, process.execPath, r.effective); } export let config: IConfigFile; export function loadConfig() { config = loadConfigFile(context().watchMode); logger.verbose`成功加载配置文件: ${config}`; }