feat: add argument parsing and module resolution for assistant app

- Implemented argument parsing in args.ts to handle root, home, and help options.
- Added parseHomeArg and parseHelpArg functions for command line argument handling.
- Created ModuleResolver class in assistant-app-resolve.ts to resolve module paths, including scoped packages and relative paths.
- Introduced caching mechanism for package.json reads to improve performance.
- Added utility functions for checking file existence and clearing the cache.
This commit is contained in:
2026-01-31 17:42:53 +08:00
parent 51822506d7
commit a80a3ede46
11 changed files with 517 additions and 337 deletions

View File

@@ -1,6 +1,7 @@
import { program, Command } from '@/program.ts';
import { spawnSync } from 'node:child_process';
import { parseHomeArg, HomeConfigDir } from '@/module/assistant/config/args.ts';
import './reload.ts'
const command = new Command('server')
.description('启动服务')
.option('-d, --daemon', '是否以守护进程方式运行')
@@ -28,17 +29,22 @@ const command = new Command('server')
shellCommands.push(`-e ${options.interpreter}`);
}
const basename = _interpreter.split('/').pop();
const m = parseHomeArg(HomeConfigDir);
const cwd = m.isDev ? process.cwd() : m.configDir;
console.log('当前工作目录:', cwd);
if (basename.includes('bun')) {
console.log(`Assistant server shell command: bun src/run-server.ts server ${shellCommands.join(' ')}`);
const child = spawnSync(_interpreter, ['src/run-server.ts', ...shellCommands], {
stdio: 'inherit',
shell: true,
cwd: cwd,
});
} else {
console.log(`Assistant server shell command: asst-server ${shellCommands.join(' ')}`);
const child = spawnSync('asst-server', shellCommands, {
stdio: 'inherit',
shell: true,
cwd: cwd,
});
}
});

View File

@@ -0,0 +1,17 @@
import { program, Command } from '@/program.ts';
import { spawnSync } from 'node:child_process';
const reload = new Command('reload')
.description('重载正在运行的 Assistant Server 服务')
.action(() => {
console.log('正在重载 Assistant Server 服务...');
const cwd = 'pm2 restart assistant-server';
const child = spawnSync('pm2', ['restart', 'assistant-server'], {
stdio: 'inherit',
shell: true,
cwd: cwd,
});
console.log('Assistant Server 服务重载完成。');
});
program.addCommand(reload);

View File

@@ -0,0 +1,79 @@
import { program, Command, assistantConfig } from '@/program.ts';
import { spawnSync } from 'node:child_process';
import { parseHomeArg, HomeConfigDir } from '@/module/assistant/config/args.ts';
import { execCommand } from '@/module/npm-install.ts';
/**
* 解析包名,分离出安装包名和配置名称
* 例如: @kevisual/cnb/routes -> pkgName: @kevisual/cnb, configName: routes
* 例如: react -> pkgName: react, configName: react
*/
function parsePluginName(name: string): { pkgName: string; configName: string } {
if (name.startsWith('@') && name.includes('/')) {
const parts = name.split('/');
if (parts.length >= 3) {
// @scope/package/submodule -> pkgName: @scope/package, configName: @scope/package/submodule
return {
pkgName: parts.slice(0, 2).join('/'),
configName: name,
};
}
}
return { pkgName: name, configName: name };
}
const pluginCommand = new Command('plugin');
const installCommand = new Command('install')
.alias('i')
.argument('<plugin-name>')
.description('安装Routes插件').action(async (name, options) => {
const { pkgName, configName } = parsePluginName(name);
const m = parseHomeArg(HomeConfigDir);
const cwd = m.isDev ? process.cwd() : m.configDir;
const shellCommand = `pnpm i ${pkgName} -w`;
const result = execCommand(shellCommand, { cwd });
if (result.status === 0) {
const mount = assistantConfig.checkMounted();
const config = assistantConfig.getConfig();
const routes = config.routes || [];
if (!routes.includes(configName)) {
routes.push(configName);
config.routes = routes;
assistantConfig.setConfig(config);
console.log(`插件 ${configName} 安装成功并已添加到配置中。`);
} else {
console.log(`插件 ${configName} 已存在于配置中。`);
}
}
});
const uninstallCommand = new Command('remove')
.alias('r')
.argument('<plugin-name>')
.description('卸载Routes插件').action(async (name, options) => {
const { pkgName, configName } = parsePluginName(name);
const m = parseHomeArg(HomeConfigDir);
const cwd = m.isDev ? process.cwd() : m.configDir;
const shellCommand = `pnpm remove ${pkgName} -w`;
const result = execCommand(shellCommand, { cwd });
assistantConfig.checkMounted();
const config = assistantConfig.getConfig();
let routes = config.routes || [];
// 从配置中移除时,查找匹配的配置名称
const index = routes.findIndex(r => r === configName);
if (index !== -1) {
routes.splice(index, 1);
config.routes = routes;
assistantConfig.setConfig(config);
console.log(`插件 ${configName} 卸载成功并已从配置中移除。`);
} else {
console.log(`插件 ${configName} 不存在于配置中。`);
}
});
pluginCommand.addCommand(uninstallCommand);
pluginCommand.addCommand(installCommand);
program.addCommand(pluginCommand);

View File

@@ -5,6 +5,7 @@ import './command/asst-server/index.ts';
import './command/app/index.ts';
import './command/run-scripts/index.ts';
import './command/ai/index.ts';
import './command/plugins/install.ts';
/**
* 通过命令行解析器解析参数

View File

@@ -0,0 +1,86 @@
import { logger } from '@/module/logger.ts';
import path from 'path';
import { homedir } from 'os';
let kevisualDir = path.join(homedir(), 'kevisual');
const envKevisualDir = process.env.ASSISTANT_CONFIG_DIR
if (envKevisualDir) {
kevisualDir = envKevisualDir;
logger.debug('使用环境变量 ASSISTANT_CONFIG_DIR 作为 kevisual 目录:', kevisualDir);
}
export const HomeConfigDir = path.join(kevisualDir, 'assistant-app');
export function parseArgs(args: string[]) {
const result: { root?: string; home?: boolean; help?: boolean } = { home: true };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
// 处理 root 参数
if (arg === '--root') {
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
result.root = args[i + 1];
i++; // 跳过下一个参数,因为它是值
}
}
if (arg === '--help' || arg === '-h') {
result.help = true;
}
}
if (result.root) {
result.home = false;
}
return result;
}
/**
* 手动解析命令行参数
* @param homedir
* @returns
*/
export const parseHomeArg = (homedir?: string) => {
const args = process.argv.slice(2);
const execPath = process.execPath;
const options = parseArgs(args);
let _configDir: string | undefined = undefined;
if (options.home && homedir) {
_configDir = homedir;
} else if (options.root) {
_configDir = options.root;
}
const checkUrl = ['.opencode', 'bin/opencode', 'opencode.exe']
const isOpencode = checkUrl.some((item) => execPath.includes(item))
let isServer = false;
// 如果args包含 server 则认为是服务端运行。其中config中server必须存在
const checkArgs = process.argv.slice(1);
// console.log('parseHomeArg args:', checkArgs, execPath);
const isArgsServer = checkArgs.some((item) => item === 'server' || item.includes('asst-server') || item.includes('run-server.ts'));
const isDev = checkArgs.some(item => item.includes('run-server.ts'));
let isDaemon = false;
if (isArgsServer) {
// 判断 --daemon 参数, 如果有则认为是守护进程运行
if (checkArgs.includes('--daemon') || checkArgs.includes('-d')) {
isDaemon = true;
}
// 判断 -s 或者 --start 参数
if (checkArgs.includes('-s') || checkArgs.includes('--start')) {
isServer = true;
}
}
return {
isOpencode,
options,
isDev,
isDaemon,
configDir: _configDir,
isServer
};
};
export const parseHelpArg = () => {
const args = process.argv.slice(2);
const options = parseArgs(args);
return !!options?.help;
};

View File

@@ -6,17 +6,12 @@ import { ProxyInfo } from '../proxy/proxy.ts';
import dotenv from 'dotenv';
import { logger } from '@/module/logger.ts';
import { z } from 'zod'
import { HomeConfigDir } from './args.ts'
let kevisualDir = path.join(homedir(), 'kevisual');
const envKevisualDir = process.env.ASSISTANT_CONFIG_DIR
if (envKevisualDir) {
kevisualDir = envKevisualDir;
logger.debug('使用环境变量 ASSISTANT_CONFIG_DIR 作为 kevisual 目录:', kevisualDir);
}
/**
* 助手配置文件路径, 全局配置文件目录
*/
export const configDir = createDir(path.join(kevisualDir, 'assistant-app'));
export const configDir = createDir(HomeConfigDir);
/**
* 助手配置文件初始化
@@ -383,72 +378,6 @@ export class AssistantConfig {
type AppConfig = {
list: any[];
};
export function parseArgs(args: string[]) {
const result: { root?: string; home?: boolean; help?: boolean } = { home: true };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
// 处理 root 参数
if (arg === '--root') {
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
result.root = args[i + 1];
i++; // 跳过下一个参数,因为它是值
}
}
if (arg === '--help' || arg === '-h') {
result.help = true;
}
}
if (result.root) {
result.home = false;
}
return result;
}
/**
* 手动解析命令行参数
* @param homedir
* @returns
*/
export const parseHomeArg = (homedir?: string) => {
const args = process.argv.slice(2);
const execPath = process.execPath;
const options = parseArgs(args);
let _configDir = undefined;
if (options.home && homedir) {
_configDir = homedir;
} else if (options.root) {
_configDir = options.root;
}
const checkUrl = ['.opencode', 'bin/opencode', 'opencode.exe']
const isOpencode = checkUrl.some((item) => execPath.includes(item))
let isServer = false;
// 如果args包含 server 则认为是服务端运行。其中config中server必须存在
console.log('parseHomeArg args:', args);
if (args.includes('server') || args.includes('assistant-server')) {
let isDaemon = false;
// 判断 --daemon 参数, 如果有则认为是守护进程运行
if (args.includes('--daemon') || args.includes('-d')) {
isDaemon = true;
}
if (!isDaemon) {
// 判断 -s 或者 --start 参数
if (args.includes('-s') || args.includes('--start')) {
isServer = true;
}
}
}
return {
isOpencode,
options,
configDir: _configDir,
isServer
};
};
export const parseHelpArg = () => {
const args = process.argv.slice(2);
const options = parseArgs(args);
return !!options?.help;
};
export const parseIfJson = (content: string) => {
try {
@@ -458,3 +387,5 @@ export const parseIfJson = (content: string) => {
return {};
}
};
export * from './args.ts';

View File

@@ -0,0 +1,108 @@
import path from 'node:path';
import fs from 'node:fs';
export class ModuleResolver {
private root: string;
private pkgCache = new Map<string, { pkg: any; mtime: number }>();
constructor(root: string) {
this.root = root;
}
/**
* 解析模块路径
* - 绝对路径 → 直接返回
* - @开头 → 解析 scoped 包 exports/main
* - 普通包名 → 从 node_modules 查找
* - 相对路径 → 从 root 查找
*/
resolve(routePath: string): string {
if (path.isAbsolute(routePath)) {
return routePath;
}
// Scoped 包 @org/pkg/subpath
if (routePath.startsWith('@')) {
return this.resolveScopedPackage(routePath);
}
// 普通包名 pkg 或 pkg/subpath
if (!routePath.startsWith('.') && !routePath.startsWith('/')) {
const pkgPath = path.join(this.root, 'node_modules', routePath);
if (this.fileIsExists(pkgPath)) {
return pkgPath;
}
}
// 相对路径 ./xxx 或 ../xxx
const localFullPath = path.resolve(this.root, routePath);
return this.fileIsExists(localFullPath) ? localFullPath : routePath;
}
/** 解析 scoped 包 */
private resolveScopedPackage(routePath: string): string {
const parts = routePath.split('/');
const pkgName = parts.slice(0, 2).join('/'); // @org/pkg
const subPath = parts.slice(2).join('/'); // routes
const pkgPath = path.join(this.root, 'node_modules', pkgName);
const pkgJsonPath = path.join(pkgPath, 'package.json');
const pkg = this.readPackageJson(pkgJsonPath);
if (!pkg) {
return routePath;
}
const entryPath = this.resolvePackageExport(pkg, subPath);
return path.join(pkgPath, entryPath);
}
/** 解析 package.json exports/main 字段 */
private resolvePackageExport(pkg: any, subPath: string): string {
const exportsField = pkg.exports;
if (exportsField && typeof exportsField === 'object') {
const exportKey = subPath ? `./${subPath}` : '.';
if (exportsField[exportKey]) {
const entry = exportsField[exportKey];
if (typeof entry === 'object') {
return entry.import || entry.default || entry;
}
return entry;
}
}
return pkg.main || 'index.js';
}
/** 带缓存读取 package.json */
private readPackageJson(pkgJsonPath: string): any | null {
try {
const cached = this.pkgCache.get(pkgJsonPath);
const stats = fs.statSync(pkgJsonPath);
if (cached && cached.mtime === stats.mtimeMs) {
return cached.pkg;
}
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
this.pkgCache.set(pkgJsonPath, { pkg, mtime: stats.mtimeMs });
return pkg;
} catch {
return null;
}
}
/** 文件是否存在 */
private fileIsExists(filepath: string): boolean {
try {
fs.accessSync(filepath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
/** 清空缓存 */
clearCache(): void {
this.pkgCache.clear();
}
}

View File

@@ -11,6 +11,7 @@ import { getEnvToken } from '@/module/http-token.ts';
import { initApi } from '@kevisual/api/proxy'
import { Query } from '@kevisual/query';
import { initLightCode } from '@/module/light-code/index.ts';
import { ModuleResolver } from './assistant-app-resolve.ts';
export class AssistantApp extends Manager {
config: AssistantConfig;
pagesPath: string;
@@ -18,6 +19,8 @@ export class AssistantApp extends Manager {
attemptedConnectTimes = 0;
remoteApp: RemoteApp | null = null;
remoteUrl: string | null = null;
private resolver: ModuleResolver;
constructor(config: AssistantConfig, mainApp?: App) {
config.checkMounted();
const appsPath = config?.configPath?.appsDir || path.join(process.cwd(), 'apps');
@@ -31,6 +34,7 @@ export class AssistantApp extends Manager {
});
this.pagesPath = pagesPath;
this.config = config;
this.resolver = new ModuleResolver(config.configPath.configDir);
}
async pageList() {
const pages = await glob(['*/*/package.json'], {
@@ -212,21 +216,16 @@ export class AssistantApp extends Manager {
}
}
async initRoutes() {
// TODO 初始化应用内置路由
const routes = this.config.getConfig().routes || [];
for (const route of routes) {
try {
if (typeof route === 'string') {
await import(route);
console.log('安装路由', route);
} else if (typeof route === 'object' && route.path) {
const routePath = route.path;
await import(routePath);
console.log('安装路由', routePath);
}
const routeStr = typeof route === 'string' ? route : route.path;
const resolvedPath = this.resolver.resolve(routeStr);
await import(resolvedPath);
console.log('路由已初始化', route);
} catch (err) {
console.error('初始化路由失败', route, err);
}
}
}
}
}

View File

@@ -33,4 +33,14 @@ export const installDeps = async (opts: InstallDepsOptions) => {
} else {
syncSpawn('npm', params, { cwd: appPath, stdio: 'inherit', env: process.env });
}
};
export const execCommand = (command: string, options: { cwd?: string } = {}) => {
const { cwd } = options;
return spawnSync(command, {
stdio: 'inherit',
shell: true,
cwd: cwd,
env: process.env,
});
};