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:
@@ -53,13 +53,13 @@
|
||||
"@kevisual/router": "^0.0.64",
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@kevisual/use-config": "^1.0.28",
|
||||
"@opencode-ai/plugin": "^1.1.44",
|
||||
"@opencode-ai/plugin": "^1.1.47",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/send": "^1.2.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"chalk": "^5.6.2",
|
||||
"commander": "^14.0.2",
|
||||
"commander": "^14.0.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dotenv": "^17.2.3",
|
||||
@@ -77,12 +77,12 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.978.0",
|
||||
"@kevisual/ha-api": "^0.0.6",
|
||||
"@aws-sdk/client-s3": "^3.980.0",
|
||||
"@kevisual/ha-api": "^0.0.8",
|
||||
"@kevisual/js-filter": "^0.0.5",
|
||||
"@kevisual/oss": "^0.0.16",
|
||||
"@kevisual/oss": "^0.0.18",
|
||||
"@kevisual/video-tools": "^0.0.13",
|
||||
"@opencode-ai/sdk": "^1.1.44",
|
||||
"@opencode-ai/sdk": "^1.1.47",
|
||||
"es-toolkit": "^1.44.0",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"lowdb": "^7.0.1",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
17
assistant/src/command/asst-server/reload.ts
Normal file
17
assistant/src/command/asst-server/reload.ts
Normal 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);
|
||||
79
assistant/src/command/plugins/install.ts
Normal file
79
assistant/src/command/plugins/install.ts
Normal 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);
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 通过命令行解析器解析参数
|
||||
|
||||
86
assistant/src/module/assistant/config/args.ts
Normal file
86
assistant/src/module/assistant/config/args.ts
Normal 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;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user