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

@@ -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,
});
};