This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
@@ -0,0 +1,217 @@
import type { CAC } from 'cac';
import { access, mkdtemp, readFile, rm } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { extname, join } from 'node:path';
import { execa, getStagedFiles } from '@vben/node-utils';
const require = createRequire(import.meta.url);
const circularScannerCli =
require.resolve('circular-dependency-scanner/dist/cli.js');
// 默认配置
const DEFAULT_CONFIG = {
allowedExtensions: ['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
ignoreDirs: [
'dist',
'.turbo',
'output',
'.cache',
'scripts',
'internal',
'packages/effects/request/src/',
'packages/@core/ui-kit/menu-ui/src/',
'packages/@core/ui-kit/popup-ui/src/',
],
threshold: 0, // 循环依赖的阈值
} as const;
// 类型定义
type CircularDependencyResult = string[];
interface CheckCircularConfig {
allowedExtensions?: string[];
ignoreDirs?: string[];
threshold?: number;
}
interface CommandOptions {
config?: CheckCircularConfig;
staged: boolean;
verbose: boolean;
}
// 缓存机制
const cache = new Map<string, CircularDependencyResult[]>();
async function detectCircularDependencies({
cwd,
ignorePattern,
staged,
}: {
cwd: string;
ignorePattern: string;
staged: boolean;
}): Promise<CircularDependencyResult[]> {
const tempDir = await mkdtemp(join(tmpdir(), 'vsh-check-circular-'));
const outputFile = join(tempDir, 'circles.json');
try {
const args = [circularScannerCli, cwd, '--output', outputFile];
if (staged) {
args.push('--absolute');
}
args.push('--ignore', ignorePattern);
await execa(process.execPath, args, {
cwd,
});
await access(outputFile);
const output = await readFile(outputFile, 'utf8');
return JSON.parse(output) as CircularDependencyResult[];
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return [];
}
throw error;
} finally {
await rm(tempDir, { force: true, recursive: true });
}
}
/**
* 格式化循环依赖的输出
* @param circles - 循环依赖结果
*/
function formatCircles(circles: CircularDependencyResult[]): void {
if (circles.length === 0) {
console.log('✅ No circular dependencies found');
return;
}
console.log('⚠️ Circular dependencies found:');
circles.forEach((circle, index) => {
console.log(`\nCircular dependency #${index + 1}:`);
circle.forEach((file) => console.log(`${file}`));
});
}
/**
* 检查项目中的循环依赖
* @param options - 检查选项
* @param options.staged - 是否只检查暂存区文件
* @param options.verbose - 是否显示详细信息
* @param options.config - 自定义配置
* @returns Promise<void>
*/
async function checkCircular({
config = {},
staged,
verbose,
}: CommandOptions): Promise<void> {
try {
// 合并配置
const finalConfig = {
...DEFAULT_CONFIG,
...config,
};
// 生成忽略模式
const ignorePattern = `**/{${finalConfig.ignoreDirs.join(',')}}/**`;
// 检查缓存
const cacheKey = `${staged}-${process.cwd()}-${ignorePattern}`;
if (cache.has(cacheKey)) {
const cachedResults = cache.get(cacheKey);
if (cachedResults && verbose) {
formatCircles(cachedResults);
}
return;
}
// 检测循环依赖
const results = await detectCircularDependencies({
cwd: process.cwd(),
ignorePattern,
staged,
});
if (staged) {
let files = await getStagedFiles();
const allowedExtensions = new Set(finalConfig.allowedExtensions);
// 过滤文件列表
files = files.filter((file) => allowedExtensions.has(extname(file)));
const circularFiles: CircularDependencyResult[] = [];
for (const file of files) {
for (const result of results) {
const resultFiles = result.flat();
if (resultFiles.includes(file)) {
circularFiles.push(result);
}
}
}
// 更新缓存
cache.set(cacheKey, circularFiles);
if (verbose) {
formatCircles(circularFiles);
}
} else {
// 更新缓存
cache.set(cacheKey, results);
if (verbose) {
formatCircles(results);
}
}
// 如果发现循环依赖,只输出警告信息
if (results.length > 0) {
console.log(
'\n⚠️ Warning: Circular dependencies found, please check and fix',
);
}
} catch (error) {
console.error(
'❌ Error checking circular dependencies:',
error instanceof Error ? error.message : error,
);
}
}
/**
* 定义检查循环依赖的命令
* @param cac - CAC实例
*/
function defineCheckCircularCommand(cac: CAC): void {
cac
.command('check-circular')
.option('--staged', 'Only check staged files')
.option('--verbose', 'Show detailed information')
.option('--threshold <number>', 'Threshold for circular dependencies', {
default: 0,
})
.option('--ignore-dirs <dirs>', 'Directories to ignore, comma separated')
.usage('Analyze project circular dependencies')
.action(async ({ ignoreDirs, staged, threshold, verbose }) => {
const config: CheckCircularConfig = {
threshold: Number(threshold),
...(ignoreDirs && { ignoreDirs: ignoreDirs.split(',') }),
};
await checkCircular({
config,
staged,
verbose: verbose ?? true,
});
});
}
export { type CheckCircularConfig, defineCheckCircularCommand };
@@ -0,0 +1,147 @@
import type { CAC } from 'cac';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { execa } from '@vben/node-utils';
const require = createRequire(import.meta.url);
const knipMain = require.resolve('knip');
const knipCli = join(dirname(knipMain), '..', 'bin', 'knip.js');
const DEFAULT_CONFIG = {
ignore: ['dist/**', 'docs/**', 'node_modules/**', 'public/**'],
ignoreBinaries: [] as string[],
ignoreDependencies: [
'@iconify/json',
'@vben-core/design',
'@vben/commitlint-config',
'@vben/eslint-config',
'@vben/stylelint-config',
'@vben/tailwind-config',
'@vben/vite-config',
'@vben/oxlint-config',
'playwright',
'rimraf',
'tailwindcss',
],
ignoreWorkspaces: ['internal/lint-configs/*', 'scripts/*'],
};
interface KnipDependency {
col: number;
line: number;
name: string;
pos: number;
}
interface KnipFileIssue {
dependencies: KnipDependency[];
devDependencies: KnipDependency[];
file: string;
optionalPeerDependencies: KnipDependency[];
}
interface KnipResult {
issues: KnipFileIssue[];
}
/**
* 格式化依赖检查结果
* @param result - 依赖检查结果
*/
function formatResult(result: KnipResult): void {
let hasIssues = false;
for (const issue of result.issues) {
const hasDeps = issue.dependencies.length > 0;
const hasDevDeps = issue.devDependencies.length > 0;
if (!hasDeps && !hasDevDeps) {
continue;
}
hasIssues = true;
console.log(`\n📦 ${issue.file}`);
if (hasDeps) {
console.log('⚠️ Unused dependencies:');
for (const dep of issue.dependencies) {
console.log(` - ${dep.name}`);
}
}
if (hasDevDeps) {
console.log('⚠️ Unused devDependencies:');
for (const dep of issue.devDependencies) {
console.log(` - ${dep.name}`);
}
}
}
if (!hasIssues) {
console.log('\n✅ Dependency check completed, no issues found');
}
}
/**
* 运行依赖检查
*/
async function runKnipCheck(): Promise<void> {
const cwd = process.cwd();
const tempDir = await mkdtemp(join(tmpdir(), 'vsh-check-dep-'));
const configFile = join(tempDir, 'knip.json');
try {
await writeFile(configFile, JSON.stringify(DEFAULT_CONFIG));
const args = [
knipCli,
'--config',
configFile,
'--include',
'dependencies',
'--reporter',
'json',
'--no-config-hints',
];
await execa(process.execPath, args, { cwd });
console.log('\n✅ Dependency check completed, no issues found');
} catch (error: unknown) {
const execaError = error as {
exitCode?: number;
stdout?: string;
};
if (execaError.exitCode === 1 && execaError.stdout) {
const result: KnipResult = JSON.parse(execaError.stdout);
formatResult(result);
return;
}
console.error(
'❌ Dependency check failed:',
error instanceof Error ? error.message : error,
);
} finally {
await rm(tempDir, { force: true, recursive: true });
}
}
/**
* 定义依赖检查命令
* @param cac - CAC实例
*/
function defineCheckDepCommand(cac: CAC): void {
cac
.command('check-dep')
.usage('Analyze project dependencies using knip')
.action(async () => {
await runKnipCheck();
});
}
export { defineCheckDepCommand };
@@ -0,0 +1,78 @@
import type { CAC } from 'cac';
import { join, relative } from 'node:path';
import {
colors,
consola,
findMonorepoRoot,
formatFile,
getPackages,
gitAdd,
outputJSON,
toPosixPath,
} from '@vben/node-utils';
const CODE_WORKSPACE_FILE = join('vben-admin.code-workspace');
interface CodeWorkspaceCommandOptions {
autoCommit?: boolean;
spaces?: number;
}
async function createCodeWorkspace({
autoCommit = false,
spaces = 2,
}: CodeWorkspaceCommandOptions) {
const { packages, rootDir } = await getPackages();
let folders = packages.map((pkg) => {
const { dir, packageJson } = pkg;
return {
name: packageJson.name,
path: toPosixPath(relative(rootDir, dir)),
};
});
folders = folders.filter(Boolean);
const monorepoRoot = findMonorepoRoot();
const outputPath = join(monorepoRoot, CODE_WORKSPACE_FILE);
await outputJSON(outputPath, { folders }, spaces);
await formatFile(outputPath);
if (autoCommit) {
await gitAdd(CODE_WORKSPACE_FILE, monorepoRoot);
}
}
async function runCodeWorkspace({
autoCommit,
spaces,
}: CodeWorkspaceCommandOptions) {
await createCodeWorkspace({
autoCommit,
spaces,
});
if (autoCommit) {
return;
}
consola.log('');
consola.success(colors.green(`${CODE_WORKSPACE_FILE} is updated!`));
consola.log('');
}
function defineCodeWorkspaceCommand(cac: CAC) {
cac
.command('code-workspace')
.usage('Update the `.code-workspace` file')
.option('--spaces [number]', '.code-workspace JSON file spaces.', {
default: 2,
})
.option('--auto-commit', 'auto commit .code-workspace JSON file.', {
default: false,
})
.action(runCodeWorkspace);
}
export { defineCodeWorkspaceCommand };
+77
View File
@@ -0,0 +1,77 @@
import { colors, consola } from '@vben/node-utils';
import { cac } from 'cac';
import { version } from '../package.json';
import { defineCheckCircularCommand } from './check-circular';
import { defineCheckDepCommand } from './check-dep';
import { defineCodeWorkspaceCommand } from './code-workspace';
import { defineLintCommand } from './lint';
import { definePubLintCommand } from './publint';
// 命令描述
const COMMAND_DESCRIPTIONS = {
'check-circular': 'Check for circular dependencies',
'check-dep': 'Check for unused dependencies',
'code-workspace': 'Manage VS Code workspace settings',
lint: 'Run linting on the project',
publint: 'Check package.json files for publishing standards',
} as const;
/**
* Initialize and run the CLI
*/
async function main(): Promise<void> {
try {
const vsh = cac('vsh');
// Register commands
defineLintCommand(vsh);
definePubLintCommand(vsh);
defineCodeWorkspaceCommand(vsh);
defineCheckCircularCommand(vsh);
defineCheckDepCommand(vsh);
// Set up CLI
vsh.usage('vsh <command> [options]');
vsh.help();
vsh.version(version);
// Parse arguments without auto-running to detect unknown commands
// Note: cac v7 removed EventEmitter; use matchedCommand after parse instead
vsh.parse(undefined, { run: false });
if (!vsh.matchedCommand && vsh.args.length > 0) {
const unknownCmd = String(vsh.args[0]);
consola.error(
colors.red(`Invalid command: ${unknownCmd}`),
'\n',
colors.yellow('Available commands:'),
'\n',
Object.entries(COMMAND_DESCRIPTIONS)
.map(([name, desc]) => ` ${colors.cyan(name)} - ${desc}`)
.join('\n'),
);
process.exit(1);
}
await vsh.runMatchedCommand();
} catch (error) {
consola.error(
colors.red('An unexpected error occurred:'),
'\n',
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
// Run the CLI
main().catch((error) => {
consola.error(
colors.red('Failed to start CLI:'),
'\n',
error instanceof Error ? error.message : error,
);
process.exit(1);
});
+146
View File
@@ -0,0 +1,146 @@
import type { CAC } from 'cac';
import { execSync } from 'node:child_process';
import { availableParallelism, freemem } from 'node:os';
import { execa } from '@vben/node-utils';
interface LintCommandOptions {
/**
* Format lint problem.
*/
format?: boolean;
/**
* Number of threads for oxfmt and oxlint (default: 2).
*/
threads?: number;
}
/**
* CPU 逻辑核心数阈值 4 核。
* - 小于等于该值:视为低配机器,检查命令串行执行,避免瞬时占用飙升。
*/
const CPU_CORE_THRESHOLD = 4;
/**
* 可用内存阈值 8 GB。
* 仅当空余内存大于阈值且当前可用内存大于该值时,
* oxfmt / oxlint 默认线程数才提升到 4,否则使用 2。
*/
const FREE_MEMORY_THRESHOLD = 8 * 1024 ** 3;
/**
* 一条命令:可执行文件名 + 参数数组。
*
* execa v10 移除了 execaCommandexeca(file, args?, options?) 的第一个参数是
* 「可执行文件名」而非整条命令行;且默认不走 shell。因此统一用 [file, args] 数组形式,
* 参数逐个传入、无需 shell 转义,跨平台行为一致(避免把整条字符串误当文件名)。
*/
type Command = [file: string, args: string[]];
/** 执行单条命令。 */
function runCommand([file, args]: Command) {
return execa(file, args, { stdio: 'inherit' });
}
/** 将命令还原为可读字符串,用于失败信息展示。 */
function formatCommand([file, args]: Command) {
return [file, ...args].join(' ');
}
/**
* 串行执行所有命令:一次只运行一个进程。
* 保证一次能看到所有工具的报错(配置低的机器更友好)。
*/
async function runSerial(commands: Command[]) {
const failed: Command[] = [];
for (const command of commands) {
try {
await runCommand(command);
} catch {
failed.push(command);
}
}
if (failed.length > 0) {
throw new Error(
`Lint failed:\n${failed
.map((command) => ` - ${formatCommand(command)}`)
.join('\n')}`,
);
}
}
/**
* 并行执行所有命令:同时启动全部进程。
* 任一进程失败时,强制结束其余仍在运行的进程,避免产生遗漏进程。
*/
async function runParallel(commands: Command[]) {
const subprocesses = commands.map((command) => runCommand(command));
try {
await Promise.all(subprocesses);
} catch (error) {
for (const subprocess of subprocesses) {
try {
if (process.platform === 'win32' && subprocess.pid) {
execSync(`taskkill /F /T /PID ${subprocess.pid}`, {
stdio: 'ignore',
});
} else {
subprocess.kill('SIGKILL');
}
} catch {
// process may have already exited
}
}
await Promise.allSettled(subprocesses);
throw error;
}
}
async function runLint({ format, threads }: LintCommandOptions) {
// process.env.FORCE_COLOR = '3';
const cpuCores = availableParallelism();
// CPU 核心数充足且可用内存充足时,默认线程数提升到 4,否则维持 2;
// 用户通过 --threads 显式指定时优先使用其值。
const defaultThreads =
cpuCores > CPU_CORE_THRESHOLD && freemem() > FREE_MEMORY_THRESHOLD ? 4 : 2;
const threadsArg = `--threads=${threads || defaultThreads}`;
if (format) {
await runSerial([
['oxlint', ['--fix', '--type-aware', threadsArg]],
['oxfmt', [threadsArg]],
['eslint', ['.', '--cache', '--fix']],
['stylelint', ['**/*.{vue,css,less,scss}', '--cache', '--fix']],
]);
return;
}
const commands: Command[] = [
['oxlint', ['--type-aware', threadsArg]],
['oxfmt', ['--check', threadsArg]],
['eslint', ['.', '--cache']],
['stylelint', ['**/*.{vue,css,less,scss}', '--cache']],
];
// 低配机器(CPU 核心数较少)串行执行,避免多进程并发导致瞬时占用飙升;
// 高配机器并行执行以缩短整体耗时。
await (cpuCores <= CPU_CORE_THRESHOLD
? runSerial(commands)
: runParallel(commands));
}
function defineLintCommand(cac: CAC) {
cac
.command('lint')
.usage('Batch execute project lint check.')
.option('--format', 'Format lint problem.')
.option('--threads <count>', 'Number of threads for oxfmt and oxlint.')
.action(runLint);
}
export { defineLintCommand };
+185
View File
@@ -0,0 +1,185 @@
import type { CAC } from 'cac';
import type { Result } from 'publint';
import { basename, dirname, join } from 'node:path';
import {
colors,
consola,
ensureFile,
findMonorepoRoot,
generatorContentHash,
getPackages,
outputJSON,
readJSON,
UNICODE,
} from '@vben/node-utils';
import { publint } from 'publint';
import { formatMessage } from 'publint/utils';
const CACHE_FILE = join(
'node_modules',
'.cache',
'publint',
'.pkglintcache.json',
);
interface PubLintCommandOptions {
/**
* Only errors are checked, no program exit is performed
*/
check?: boolean;
}
/**
* Get files that require lint
* @param files
*/
async function getLintFiles(files: string[] = []) {
const lintFiles: string[] = [];
if (files?.length > 0) {
return files.filter((file) => basename(file) === 'package.json');
}
const { packages } = await getPackages();
for (const { dir } of packages) {
lintFiles.push(join(dir, 'package.json'));
}
return lintFiles;
}
function getCacheFile() {
const root = findMonorepoRoot();
return join(root, CACHE_FILE);
}
async function readCache(cacheFile: string) {
try {
await ensureFile(cacheFile);
return await readJSON(cacheFile);
} catch {
return {};
}
}
async function runPublint(files: string[], { check }: PubLintCommandOptions) {
const lintFiles = await getLintFiles(files);
const cacheFile = getCacheFile();
const cacheData = await readCache(cacheFile);
const cache: Record<string, { hash: string; result: Result }> = cacheData;
const results = await Promise.all(
lintFiles.map(async (file) => {
try {
const pkgJson = await readJSON(file);
if (pkgJson.private) {
return null;
}
Reflect.deleteProperty(pkgJson, 'dependencies');
Reflect.deleteProperty(pkgJson, 'devDependencies');
Reflect.deleteProperty(pkgJson, 'peerDependencies');
const content = JSON.stringify(pkgJson);
const hash = generatorContentHash(content);
const publintResult: Result =
cache?.[file]?.hash === hash
? (cache?.[file]?.result ?? [])
: await publint({
level: 'suggestion',
pkgDir: dirname(file),
strict: true,
});
cache[file] = {
hash,
result: publintResult,
};
return { pkgJson, pkgPath: file, publintResult };
} catch {
return null;
}
}),
);
await outputJSON(cacheFile, cache);
printResult(results, check);
}
function printResult(
results: Array<null | {
pkgJson: Record<string, number | string>;
pkgPath: string;
publintResult: Result;
}>,
check?: boolean,
) {
let errorCount = 0;
let warningCount = 0;
let suggestionsCount = 0;
for (const result of results) {
if (!result) {
continue;
}
const { pkgJson, pkgPath, publintResult } = result;
const messages = publintResult?.messages ?? [];
if (messages?.length < 1) {
continue;
}
consola.log('');
consola.log(pkgPath);
for (const message of messages) {
switch (message.type) {
case 'error': {
errorCount++;
break;
}
case 'suggestion': {
suggestionsCount++;
break;
}
case 'warning': {
warningCount++;
break;
}
// No default
}
const ruleUrl = `https://publint.dev/rules#${message.code.toLocaleLowerCase()}`;
consola.log(
` ${formatMessage(message, pkgJson)}${colors.dim(` ${ruleUrl}`)}`,
);
}
}
const totalCount = warningCount + errorCount + suggestionsCount;
if (totalCount > 0) {
consola.error(
colors.red(
`${UNICODE.FAILURE} ${totalCount} problem (${errorCount} errors, ${warningCount} warnings, ${suggestionsCount} suggestions)`,
),
);
!check && process.exit(1);
} else {
consola.log(colors.green(`${UNICODE.SUCCESS} No problem`));
}
}
function definePubLintCommand(cac: CAC) {
cac
.command('publint [...files]')
.usage('Check if the monorepo package conforms to the publint standard.')
.option('--check', 'Only errors are checked, no program exit is performed.')
.action(runPublint);
}
export { definePubLintCommand };