Compare commits
100 Commits
ba3b2190ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 14f2dad837 | |||
| 915f7aff6b | |||
| 15d81c4f68 | |||
| 48dafc6040 | |||
| 2ccda9f008 | |||
| 6cf3beae94 | |||
| a583fdc211 | |||
| 59b3961ff9 | |||
| ddfdb63598 | |||
| 18a6fd4cfe | |||
| 028a6ac726 | |||
| a911334459 | |||
| 3f899dbd5f | |||
| bb2b129343 | |||
|
|
2eeaf991b9 | ||
| ddba845ce7 | |||
| a4e04e7afa | |||
|
|
398c41a512 | ||
|
|
dbd044ec66 | ||
| 2b55c2bd03 | |||
| 89470346be | |||
| 9f20e149a0 | |||
| 26b4ffa3a2 | |||
| 43992d896f | |||
| 5e5f4f6543 | |||
| fee5076e16 | |||
| 91d4fed474 | |||
| 5395449751 | |||
| 2cb12644ea | |||
| e1b86aa809 | |||
| 2f79925e3d | |||
| 0b5a0557ee | |||
| b9b4c993f4 | |||
| 99f01e2b94 | |||
| cc043bfd7e | |||
| 327f115ef2 | |||
| 6a375db5a1 | |||
| a5ce44ba70 | |||
| 2011ea818c | |||
| 146fde274e | |||
| 8549a4aa53 | |||
| 4f541748da | |||
| 4723efbbd2 | |||
| 3de89b6985 | |||
| a90f98ef3f | |||
| 9647fcf48f | |||
| f1f2d81201 | |||
| 9f580a7709 | |||
| 91409861b9 | |||
| ff7e6aa152 | |||
| a52ac525bf | |||
| 371d66b289 | |||
| 4bdebd66d4 | |||
| 03be62cfe6 | |||
| f12fea7246 | |||
| 8007315c66 | |||
| 11ac3f9de9 | |||
| 36628c8279 | |||
| b3c5e7d68d | |||
| 864766be4a | |||
| bafe51f140 | |||
| bf4c0c6d71 | |||
| 2ca71aea5d | |||
| fb2b2d4d6f | |||
| 91fdd6abc3 | |||
| c77578805a | |||
| ca1c3706b2 | |||
| 6e1ffe173a | |||
| 5b610fd600 | |||
| b9624e4f6f | |||
| 8f29ddb449 | |||
| 22de8cad52 | |||
| 73d98a1209 | |||
| 6b96a22c7a | |||
| 2393cbefbb | |||
| 0ca5989a40 | |||
| 48f2695367 | |||
| 7d4bc37c09 | |||
| f3f1a1d058 | |||
| 4aeb3637bf | |||
| 5b83f7a6d1 | |||
| 9127df2600 | |||
| 8118daa4e2 | |||
| eca7b42377 | |||
| ee33208e6c | |||
| 94e331e376 | |||
| 77186a02a2 | |||
| c018ffd422 | |||
| b6bdfd872a | |||
| 3064682514 | |||
| 2dee724ec8 | |||
| 502fef024f | |||
| 744b0e088b | |||
| 87824c0021 | |||
| a97f015205 | |||
| fe272e0d27 | |||
| 7471602ac1 | |||
| fcb3ec8993 | |||
| 1a9b1c9e06 | |||
| dce97eee82 |
44
.cnb.yml
Normal file
44
.cnb.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# .cnb.yml
|
||||||
|
include:
|
||||||
|
- https://cnb.cool/kevisual/cnb/-/blob/main/.cnb/template.yml
|
||||||
|
|
||||||
|
.common_env: &common_env
|
||||||
|
env:
|
||||||
|
TO_REPO: kevisual/cli
|
||||||
|
TO_URL: git.xiongxiao.me
|
||||||
|
imports:
|
||||||
|
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
|
||||||
|
|
||||||
|
$:
|
||||||
|
vscode:
|
||||||
|
- docker:
|
||||||
|
image: docker.cnb.cool/kevisual/dev-env:latest
|
||||||
|
services:
|
||||||
|
- vscode
|
||||||
|
- docker
|
||||||
|
imports: !reference [.common_env, imports]
|
||||||
|
# 开发环境启动后会执行的任务
|
||||||
|
# stages:
|
||||||
|
# - name: pnpm install
|
||||||
|
# script: pnpm install
|
||||||
|
stages: !reference [.dev_tempalte, stages]
|
||||||
|
|
||||||
|
.common_sync_to_gitea: &common_sync_to_gitea
|
||||||
|
- <<: *common_env
|
||||||
|
services: !reference [.common_sync_to_gitea_template, services]
|
||||||
|
stages: !reference [.common_sync_to_gitea_template, stages]
|
||||||
|
|
||||||
|
.common_sync_from_gitea: &common_sync_from_gitea
|
||||||
|
- <<: *common_env
|
||||||
|
services: !reference [.common_sync_from_gitea_template, services]
|
||||||
|
stages: !reference [.common_sync_from_gitea_template, stages]
|
||||||
|
|
||||||
|
main:
|
||||||
|
web_trigger_sync_to_gitea:
|
||||||
|
- <<: *common_sync_to_gitea
|
||||||
|
web_trigger_sync_from_gitea:
|
||||||
|
- <<: *common_sync_from_gitea
|
||||||
|
api_trigger_sync_to_gitea:
|
||||||
|
- <<: *common_sync_to_gitea
|
||||||
|
api_trigger_sync_from_gitea:
|
||||||
|
- <<: *common_sync_from_gitea
|
||||||
11
.cnb/web_trigger.yml
Normal file
11
.cnb/web_trigger.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# .cnb/web_trigger.yml
|
||||||
|
branch:
|
||||||
|
# 如下按钮在分支名以 release 开头的分支详情页面显示
|
||||||
|
- reg: "^main"
|
||||||
|
buttons:
|
||||||
|
- name: 同步代码到gitea
|
||||||
|
description: 同步代码到gitea
|
||||||
|
event: web_trigger_sync_to_gitea
|
||||||
|
- name: 同步gitea代码到当前仓库
|
||||||
|
description: 同步gitea代码到当前仓库
|
||||||
|
event: web_trigger_sync_from_gitea
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,7 +4,10 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
|
|
||||||
pack-dist
|
pack-dist
|
||||||
apps
|
|
||||||
assistant-app
|
assistant-app
|
||||||
|
|
||||||
build
|
build
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
jwt
|
||||||
1
.npmrc
1
.npmrc
@@ -1,2 +1,3 @@
|
|||||||
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
|
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
|
||||||
|
//npm.cnb.cool/kevisual/registry/-/packages/:_authToken=${CNB_API_KEY}
|
||||||
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
||||||
1
.opencode/plugin/agenat.ts
Normal file
1
.opencode/plugin/agenat.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AgentPlugin } from "../../assistant/src/main.ts";
|
||||||
2
apps/.gitignore
vendored
2
apps/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
container
|
|
||||||
root
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "apps",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "index.js",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC"
|
|
||||||
}
|
|
||||||
2
assistant/.gitignore
vendored
2
assistant/.gitignore
vendored
@@ -11,3 +11,5 @@ assistant-app
|
|||||||
!.env*example
|
!.env*example
|
||||||
|
|
||||||
libs
|
libs
|
||||||
|
|
||||||
|
cache-file
|
||||||
1
assistant/.opencode/plugin/agent.ts
Normal file
1
assistant/.opencode/plugin/agent.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AgentPlugin } from "../../src/main.ts";
|
||||||
35
assistant/.opencode/skills/kill-opencode/SKILL.md
Normal file
35
assistant/.opencode/skills/kill-opencode/SKILL.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: kill-opencode
|
||||||
|
description: 自动查找并杀死所有opencode相关的进程,确保系统资源释放。
|
||||||
|
tags:
|
||||||
|
- opencode
|
||||||
|
- process-management
|
||||||
|
- automation
|
||||||
|
---
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# kill_opencode.sh - 自动杀死所有opencode进程
|
||||||
|
echo "正在查找opencode进程..."
|
||||||
|
ps aux | grep opencode | grep -v grep
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "正在杀死所有opencode进程..."
|
||||||
|
pkill -f opencode
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# 检查是否还有残留进程
|
||||||
|
remaining=$(ps aux | grep opencode | grep -v grep | wc -l)
|
||||||
|
if [ $remaining -gt 0 ]; then
|
||||||
|
echo "发现 $remaining 个顽固进程,使用强制杀死模式..."
|
||||||
|
pkill -9 -f opencode
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "opencode进程清理完成!"
|
||||||
|
else
|
||||||
|
echo "未找到opencode进程"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 验证是否已完全清理
|
||||||
|
echo "当前opencode进程状态:"
|
||||||
|
ps aux | grep opencode | grep -v grep || echo "没有运行中的opencode进程"
|
||||||
|
```
|
||||||
204
assistant/AGENTS.md
Normal file
204
assistant/AGENTS.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## 构建、代码检查和测试命令
|
||||||
|
|
||||||
|
### 核心命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发
|
||||||
|
bun run dev # 运行主入口 (src/run.ts)
|
||||||
|
bun run dev:server # 启动服务器(热重载)
|
||||||
|
bun run dev:cnb # 使用自定义配置目录启动服务器
|
||||||
|
bun run dev:share # 测试远程应用
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
bun run build # 完整生产构建(先清理 dist)
|
||||||
|
bun run build:lib # 构建库文件
|
||||||
|
bun run postbuild:lib # 生成 TypeScript 类型定义
|
||||||
|
|
||||||
|
# 包管理
|
||||||
|
pnpm install # 安装依赖(使用 pnpm v10.28.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- **Node.js**: >=22.0.0
|
||||||
|
- **运行时**: Bun(开发环境)/ Node.js(生产环境)
|
||||||
|
- **包管理器**: pnpm@10.28.0(强制要求)
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
- `ASSISTANT_CONFIG_DIR`: 覆盖助手配置文件目录路径
|
||||||
|
- `BUN_PATH`: 覆盖 Bun 可执行文件路径
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 代码风格规范
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
- **严格模式**: 通过扩展配置启用(`@kevisual/types/json/backend.json`)
|
||||||
|
- **模块格式**: 仅 ESM(`package.json` 中的 `"type": "module"`)
|
||||||
|
- **目标**: NodeNext 模块解析
|
||||||
|
- **路径别名**: 使用 `@/*` 作为本地导入别名(如 `import { Foo } from '@/module/foo'`)
|
||||||
|
|
||||||
|
### 导入规则
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Node.js 内置模块必须使用 node: 协议
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
// 外部包(不使用路径别名)
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { program } from 'commander';
|
||||||
|
|
||||||
|
// 本地模块(使用 @ 别名)
|
||||||
|
import { AssistantInit } from '@/services/init/index.ts';
|
||||||
|
|
||||||
|
// 类型导入
|
||||||
|
import type { AssistantConfig } from '@/module/assistant/index.ts';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 命名规范
|
||||||
|
|
||||||
|
| 类型 | 规范 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| 类 | PascalCase | `AssistantInit`, `AssistantApp`, `AssistantQuery` |
|
||||||
|
| 接口 | PascalCase | `ProxyInfo`, `AssistantInitOptions` |
|
||||||
|
| 类型 | PascalCase | `AssistantConfigData` |
|
||||||
|
| 函数 | camelCase | `getBunPath`, `checkFileExists`, `parseHomeArg` |
|
||||||
|
| 变量 | camelCase | `configDir`, `assistantConfig`, `isPortAvailable` |
|
||||||
|
| 私有字段 | `#` 前缀 + camelCase | `#query`, `#config` |
|
||||||
|
| 常量 | UPPER_SNAKE_CASE 或 camelCase | `commonPaths`, `randomId` |
|
||||||
|
| 文件名 | kebab-case | `proxy-page-index.ts`, `query-login.ts` |
|
||||||
|
| 目录名 | kebab-case | `query-login`, `hot-api` |
|
||||||
|
|
||||||
|
### 代码模式
|
||||||
|
|
||||||
|
#### 错误处理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 同步操作包装
|
||||||
|
try {
|
||||||
|
const result = fs.readFileSync(path);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取文件错误:', error.message);
|
||||||
|
continue; // 或 return/throw
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步操作包装
|
||||||
|
try {
|
||||||
|
const result = await someAsyncOperation();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('操作失败:', error.message);
|
||||||
|
process.exit(1); // 或 throw/return undefined
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 上下文/依赖注入
|
||||||
|
|
||||||
|
使用 `@kevisual/use-config` 进行上下文管理:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const assistantConfig = useContextKey<AssistantInit>('assistantConfig', () => {
|
||||||
|
return new AssistantInit({
|
||||||
|
path: configDir,
|
||||||
|
init: isInit,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 路由模式
|
||||||
|
|
||||||
|
使用 `@kevisual/router` 的 App 和 SimpleRouter:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.route({
|
||||||
|
path: 'router',
|
||||||
|
key: 'list',
|
||||||
|
description: '获取路由列表',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
ctx.body = { list };
|
||||||
|
}).addTo(app);
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSDoc 文档
|
||||||
|
|
||||||
|
为公共 API、复杂函数和类型添加文档:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 助手初始化类
|
||||||
|
* @class AssistantInit
|
||||||
|
*/
|
||||||
|
export class AssistantInit extends AssistantConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} p - 要解析的路径
|
||||||
|
* @returns {string} 解析后的绝对路径
|
||||||
|
*/
|
||||||
|
export const w = (p: string) => path.join(__dirname, p);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件组织
|
||||||
|
|
||||||
|
- **路由**: `src/routes/` 和 `src/routes-simple/`
|
||||||
|
- **模块**: `src/module/`(功能模块)
|
||||||
|
- **服务**: `src/services/`(公共服务)
|
||||||
|
- **命令**: `src/command/`(CLI 命令)
|
||||||
|
- **查询**: `src/query/`(API/查询逻辑)
|
||||||
|
- **入口文件**:
|
||||||
|
- `src/index.ts` - 主 CLI 入口
|
||||||
|
- `src/server.ts` - 服务器入口
|
||||||
|
- `src/run.ts` - 开发运行器
|
||||||
|
- `src/run-server.ts` - 服务器运行器
|
||||||
|
|
||||||
|
### 控制台输出
|
||||||
|
|
||||||
|
使用 `chalk` 实现彩色输出:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log(chalk.blue('助手路径不存在,正在创建...'));
|
||||||
|
console.log(chalk.yellow('助手路径已存在'), chalk.green(assistantConfig.configDir));
|
||||||
|
console.error(chalk.red('启动服务器错误:'), error.message);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 系统检测
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
const bunExecutableName = isWindows ? 'bun.exe' : 'bun';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 端口处理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import getPort, { portNumbers } from 'get-port';
|
||||||
|
|
||||||
|
const port = await getPort({ port: 51515 });
|
||||||
|
const availablePort = await getPort({ port: portNumbers(51515, 52000) });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键依赖
|
||||||
|
|
||||||
|
- `@kevisual/router` - 应用路由
|
||||||
|
- `@kevisual/query` - API 查询
|
||||||
|
- `@kevisual/use-config` - 配置/上下文
|
||||||
|
- `commander` - CLI 参数解析
|
||||||
|
- `chalk` - 终端颜色
|
||||||
|
- `ws` - WebSocket(通过 `@kevisual/ws`)
|
||||||
|
- `pm2` - 进程管理(生产环境)
|
||||||
|
|
||||||
|
### 重要说明
|
||||||
|
|
||||||
|
1. 开发时始终使用 `bun`,但构建输出以 Node.js 为目标
|
||||||
|
2. 打包时外部包必须在 `bun.config.mjs` 的 `external` 数组中声明
|
||||||
|
3. 库构建排除 `pm2`
|
||||||
|
4. 以 `ENVISION_*` 为前缀的环境变量在构建时可用
|
||||||
|
5. 项目在业务逻辑中广泛使用中文注释
|
||||||
|
6. 私有类成员应使用 `#` 前缀语法
|
||||||
|
7. 操作前始终使用 `checkFileExists` 检查文件是否存在
|
||||||
@@ -6,6 +6,7 @@ import fs from 'node:fs';
|
|||||||
// bun run src/index.ts --
|
// bun run src/index.ts --
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const external = ['pm2', '@kevisual/hot-api', '@nut-tree-fork/nut-js', 'bun'];
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} p
|
* @param {string} p
|
||||||
@@ -20,11 +21,10 @@ await Bun.build({
|
|||||||
naming: {
|
naming: {
|
||||||
entry: 'assistant.js',
|
entry: 'assistant.js',
|
||||||
},
|
},
|
||||||
external: ['pm2'],
|
external,
|
||||||
define: {
|
define: {
|
||||||
ENVISION_VERSION: JSON.stringify(pkg.version),
|
ENVISION_VERSION: JSON.stringify(pkg.version),
|
||||||
},
|
},
|
||||||
env: 'ENVISION_*',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await Bun.build({
|
await Bun.build({
|
||||||
@@ -38,8 +38,29 @@ await Bun.build({
|
|||||||
define: {
|
define: {
|
||||||
ENVISION_VERSION: JSON.stringify(pkg.version),
|
ENVISION_VERSION: JSON.stringify(pkg.version),
|
||||||
},
|
},
|
||||||
external: ['pm2'],
|
external,
|
||||||
env: 'ENVISION_*',
|
});
|
||||||
|
|
||||||
|
await Bun.build({
|
||||||
|
target: 'node',
|
||||||
|
format: 'esm',
|
||||||
|
entrypoints: [w('./src/main.ts')],
|
||||||
|
outdir: w('./dist'),
|
||||||
|
naming: {
|
||||||
|
entry: 'assistant-opencode.js',
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
ENVISION_VERSION: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
|
external,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dts = 'dts -i src/main.ts -o assistant-opencode.d.ts';
|
||||||
|
Bun.spawnSync({
|
||||||
|
cmd: ['sh', '-c', dts],
|
||||||
|
cwd: __dirname,
|
||||||
|
stdout: 'inherit',
|
||||||
|
stderr: 'inherit',
|
||||||
});
|
});
|
||||||
// const copyDist = ['dist', 'bin'];
|
// const copyDist = ['dist', 'bin'];
|
||||||
const copyDist = ['dist'];
|
const copyDist = ['dist'];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kevisual/assistant-cli",
|
"name": "@kevisual/assistant-cli",
|
||||||
"version": "0.0.5",
|
"version": "0.0.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/assistant.mjs",
|
"main": "dist/assistant.mjs",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
],
|
],
|
||||||
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"packageManager": "pnpm@10.24.0",
|
"packageManager": "pnpm@10.28.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -20,12 +20,12 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run src/run.ts ",
|
"dev": "bun run src/run.ts ",
|
||||||
"dev:server": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 bun --watch src/run-server.ts --home ",
|
"dev:server": "bun --watch src/run-server.ts ",
|
||||||
"dev:share": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 bun --watch src/test/remote-app.ts ",
|
"dev:cnb": "ASSISTANT_CONFIG_DIR=/workspace bun --watch src/run-server.ts ",
|
||||||
|
"dev:share": "bun --watch src/test/remote-app.ts ",
|
||||||
"build:lib": "bun run bun-lib.config.mjs",
|
"build:lib": "bun run bun-lib.config.mjs",
|
||||||
"postbuild:lib": "dts -i src/lib.ts -o assistant-lib.d.ts -d libs -t",
|
"postbuild:lib": "dts -i src/lib.ts -o assistant-lib.d.ts -d libs -t",
|
||||||
"build": "rimraf dist && bun run bun.config.mjs",
|
"build": "rimraf dist && bun run bun.config.mjs"
|
||||||
"postbuild": "pnpm run build:lib"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"ev-assistant": "bin/assistant.js",
|
"ev-assistant": "bin/assistant.js",
|
||||||
@@ -41,18 +41,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kevisual/ai": "^0.0.12",
|
"@kevisual/ai": "^0.0.22",
|
||||||
|
"@kevisual/api": "^0.0.26",
|
||||||
"@kevisual/load": "^0.0.6",
|
"@kevisual/load": "^0.0.6",
|
||||||
"@kevisual/local-app-manager": "^0.1.28",
|
"@kevisual/local-app-manager": "^0.1.32",
|
||||||
"@kevisual/logger": "^0.0.4",
|
"@kevisual/logger": "^0.0.4",
|
||||||
"@kevisual/query": "0.0.29",
|
"@kevisual/query": "0.0.38",
|
||||||
"@kevisual/query-login": "0.0.7",
|
"@kevisual/query-login": "0.0.7",
|
||||||
"@kevisual/router": "^0.0.33",
|
"@kevisual/router": "^0.0.62",
|
||||||
"@kevisual/types": "^0.0.10",
|
"@kevisual/types": "^0.0.12",
|
||||||
"@kevisual/use-config": "^1.0.21",
|
"@kevisual/use-config": "^1.0.28",
|
||||||
"@types/bun": "^1.3.3",
|
"@opencode-ai/plugin": "^1.1.36",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/bun": "^1.3.6",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^25.0.10",
|
||||||
"@types/send": "^1.2.1",
|
"@types/send": "^1.2.1",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
@@ -61,11 +62,11 @@
|
|||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"get-port": "^7.1.0",
|
"get-port": "^7.1.0",
|
||||||
"inquirer": "^13.0.1",
|
"inquirer": "^13.2.1",
|
||||||
"lodash-es": "^4.17.21",
|
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"send": "^1.2.0",
|
"send": "^1.2.1",
|
||||||
"supports-color": "^10.2.2",
|
"supports-color": "^10.2.2",
|
||||||
|
"table": "^6.9.0",
|
||||||
"ws": "npm:@kevisual/ws"
|
"ws": "npm:@kevisual/ws"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -75,10 +76,17 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eventemitter3": "^5.0.1",
|
"@aws-sdk/client-s3": "^3.975.0",
|
||||||
|
"@kevisual/ha-api": "^0.0.6",
|
||||||
|
"@kevisual/js-filter": "^0.0.5",
|
||||||
|
"@kevisual/oss": "^0.0.16",
|
||||||
|
"@kevisual/video-tools": "^0.0.13",
|
||||||
|
"@opencode-ai/sdk": "^1.1.36",
|
||||||
|
"es-toolkit": "^1.44.0",
|
||||||
|
"eventemitter3": "^5.0.4",
|
||||||
"lowdb": "^7.0.1",
|
"lowdb": "^7.0.1",
|
||||||
"lru-cache": "^11.2.2",
|
"lru-cache": "^11.2.4",
|
||||||
"pm2": "^6.0.14",
|
"pm2": "^6.0.14",
|
||||||
"unstorage": "^1.17.3"
|
"unstorage": "^1.17.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
# assistant cli
|
# assistant cli
|
||||||
|
|
||||||
## 初始化路径
|
## 安装
|
||||||
|
|
||||||
## 启动服务
|
```bash
|
||||||
|
npm i -g @kevisual/cli --registry=https://npm.cnb.cool/kevisual/registry/-/packages/
|
||||||
|
```
|
||||||
|
## 环境变量配置项
|
||||||
|
|
||||||
## 配置
|
- ASSISTANT_CONFIG_DIR
|
||||||
|
- 说明:指定 assistant 的配置文件目录
|
||||||
|
- 示例:`export ASSISTANT_CONFIG_DIR=/path/to/your/config/dir`
|
||||||
|
|
||||||
|
|
||||||
|
## 启动命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
asst server -s -p 8686
|
||||||
|
```
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { App } from '@kevisual/router';
|
import { App } from '@kevisual/router';
|
||||||
import { HttpsPem } from '@/module/assistant/https/sign.ts';
|
import { SimpleRouter } from '@kevisual/router/simple'
|
||||||
|
// import { App } from '@kevisual/router/src/app.ts';
|
||||||
// import { AssistantConfig } from '@/module/assistant/index.ts';
|
// import { AssistantConfig } from '@/module/assistant/index.ts';
|
||||||
|
|
||||||
import { AssistantInit, parseHomeArg } from '@/services/init/index.ts';
|
import { AssistantInit, parseHomeArg } from '@/services/init/index.ts';
|
||||||
import { configDir as HomeConfigDir } from '@/module/assistant/config/index.ts';
|
import { configDir as HomeConfigDir } from '@/module/assistant/config/index.ts';
|
||||||
import { useContextKey } from '@kevisual/use-config/context';
|
import { useContextKey } from '@kevisual/use-config/context';
|
||||||
import { AssistantQuery } from '@/module/assistant/query/index.ts';
|
import { AssistantQuery } from '@/module/assistant/query/index.ts';
|
||||||
|
|
||||||
const manualParse = parseHomeArg(HomeConfigDir);
|
const manualParse = parseHomeArg(HomeConfigDir);
|
||||||
const _configDir = manualParse.configDir;
|
const _configDir = manualParse.configDir;
|
||||||
export const configDir = AssistantInit.detectConfigDir(_configDir);
|
export const configDir = AssistantInit.detectConfigDir(_configDir);
|
||||||
@@ -13,6 +16,7 @@ export const assistantConfig = useContextKey<AssistantInit>('assistantConfig', (
|
|||||||
return new AssistantInit({
|
return new AssistantInit({
|
||||||
path: configDir,
|
path: configDir,
|
||||||
init: isInit,
|
init: isInit,
|
||||||
|
initWorkspace: manualParse.isOpencode ? false : true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,8 +24,6 @@ export const assistantQuery = useContextKey('assistantQuery', () => {
|
|||||||
return new AssistantQuery(assistantConfig);
|
return new AssistantQuery(assistantConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
const httpsPem = new HttpsPem(assistantConfig);
|
|
||||||
|
|
||||||
export const runtime = useContextKey('runtime', () => {
|
export const runtime = useContextKey('runtime', () => {
|
||||||
return {
|
return {
|
||||||
type: 'client',
|
type: 'client',
|
||||||
@@ -29,32 +31,30 @@ export const runtime = useContextKey('runtime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const app: App = useContextKey<App>('app', () => {
|
export const app: App = useContextKey<App>('app', () => {
|
||||||
const init = isInit;
|
|
||||||
if (init) {
|
|
||||||
const config = assistantConfig.getConfig();
|
|
||||||
|
|
||||||
if (config?.https?.type !== 'https') {
|
|
||||||
console.log('http模式', 'http');
|
|
||||||
return new App({
|
return new App({
|
||||||
serverOptions: {
|
serverOptions: {
|
||||||
path: '/client/router',
|
path: '/client/router',
|
||||||
httpType: 'http',
|
httpType: 'http',
|
||||||
cors: {
|
cors: {
|
||||||
origin: '*',
|
origin: '*',
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
io: true
|
||||||
}
|
|
||||||
}
|
|
||||||
return new App({
|
|
||||||
serverOptions: {
|
|
||||||
path: '/client/router',
|
|
||||||
httpType: 'https',
|
|
||||||
httpsCert: httpsPem.cert,
|
|
||||||
httpsKey: httpsPem.key,
|
|
||||||
cors: {
|
|
||||||
origin: '*',
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const simpleRouter = useContextKey('simpleRouter', () => {
|
||||||
|
return new SimpleRouter();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'router',
|
||||||
|
key: 'list',
|
||||||
|
description: '获取路由列表',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const list = ctx.app.getList((item) => {
|
||||||
|
if (item?.path?.includes('auth') || item?.id?.includes('auth')) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
ctx.body = { list }
|
||||||
|
}).addTo(app);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AssistantApp } from '@/module/assistant/index.ts';
|
import { AssistantApp } from '@/module/assistant/index.ts';
|
||||||
import { program, Command, assistantConfig } from '@/program.ts';
|
import { program, Command, assistantConfig } from '@/program.ts';
|
||||||
import { AppDownload } from '@/services/app/index.ts';
|
import { AppDownload } from '@/services/app/index.ts';
|
||||||
|
import { table } from 'table';
|
||||||
|
|
||||||
const appManagerCommand = new Command('app-manager').alias('am').description('Manage Assistant Apps 管理本地的应用模块');
|
const appManagerCommand = new Command('app-manager').alias('am').description('Manage Assistant Apps 管理本地的应用模块');
|
||||||
program.addCommand(appManagerCommand);
|
program.addCommand(appManagerCommand);
|
||||||
@@ -8,11 +9,43 @@ program.addCommand(appManagerCommand);
|
|||||||
appManagerCommand
|
appManagerCommand
|
||||||
.command('list')
|
.command('list')
|
||||||
.description('List all installed apps')
|
.description('List all installed apps')
|
||||||
.action(async () => {
|
.option('-s, --status <status>', '列出状态信息, 可选值: running, stopped, inactive')
|
||||||
|
.option('-w, --wide', '显示更多信息')
|
||||||
|
.action(async (opts) => {
|
||||||
const manager = new AssistantApp(assistantConfig);
|
const manager = new AssistantApp(assistantConfig);
|
||||||
await manager.loadConfig();
|
await manager.loadConfig();
|
||||||
const showInfos = manager.getAllAppShowInfo();
|
let showInfos = manager.getAllAppShowInfo();
|
||||||
console.log('Installed Apps:', showInfos);
|
const isWide = opts.wide ?? false;
|
||||||
|
let header = [];
|
||||||
|
if (!isWide) {
|
||||||
|
showInfos = showInfos.map((item) => {
|
||||||
|
return { key: item.key, status: item.status };
|
||||||
|
});
|
||||||
|
header = ['Key', 'Status'];
|
||||||
|
}
|
||||||
|
if (opts.status) {
|
||||||
|
const showList = showInfos.filter(info => info.status === opts.status);
|
||||||
|
if (showList.length === 0) {
|
||||||
|
console.log(`No apps with status: ${opts.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const teables = showList.map(item => Object.values(item));
|
||||||
|
teables.unshift(header);
|
||||||
|
console.log('App Start Info:\n')
|
||||||
|
console.log(table(teables));
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (showInfos.length === 0) {
|
||||||
|
console.log('No installed apps found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
header = Object.keys(showInfos[0]);;
|
||||||
|
const teables = showInfos.map(item => Object.values(item));
|
||||||
|
teables.unshift(header);
|
||||||
|
|
||||||
|
console.log('Installed Apps:\n')
|
||||||
|
console.log(table(teables));
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
appManagerCommand
|
appManagerCommand
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { spawnSync } from 'node:child_process';
|
|||||||
const command = new Command('server')
|
const command = new Command('server')
|
||||||
.description('启动服务')
|
.description('启动服务')
|
||||||
.option('-d, --daemon', '是否以守护进程方式运行')
|
.option('-d, --daemon', '是否以守护进程方式运行')
|
||||||
.option('-n, --name <name>', '服务名称')
|
.option('-n, --name <name>', '服务名称', 'assistant-server')
|
||||||
.option('-p, --port <port>', '服务端口')
|
.option('-p, --port <port>', '服务端口')
|
||||||
.option('-s, --start', '是否启动服务')
|
.option('-s, --start', '是否启动服务')
|
||||||
.option('-i, --home', '是否以home方式运行')
|
.option('-e, --interpreter <interpreter>', '指定使用的解释器', 'bun')
|
||||||
.action((options) => {
|
.action((options) => {
|
||||||
const { port } = options;
|
const { port } = options;
|
||||||
const [_interpreter, execPath] = process.argv;
|
const [_interpreter, execPath] = process.argv;
|
||||||
@@ -24,8 +24,8 @@ const command = new Command('server')
|
|||||||
if (port) {
|
if (port) {
|
||||||
shellCommands.push(`-p ${port}`);
|
shellCommands.push(`-p ${port}`);
|
||||||
}
|
}
|
||||||
if (options.home) {
|
if (options.interpreter) {
|
||||||
shellCommands.push('--home');
|
shellCommands.push(`-e ${options.interpreter}`);
|
||||||
}
|
}
|
||||||
const basename = _interpreter.split('/').pop();
|
const basename = _interpreter.split('/').pop();
|
||||||
|
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ import inquirer from 'inquirer';
|
|||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
|
||||||
type InitCommandOptions = {
|
type InitCommandOptions = {
|
||||||
path?: string;
|
workspace?: string;
|
||||||
};
|
};
|
||||||
const Init = new Command('init')
|
const Init = new Command('init')
|
||||||
.description('初始化一个助手客户端,生成配置文件。')
|
.description('初始化一个助手客户端,生成配置文件。')
|
||||||
.option('-p --path <path>', '助手路径,默认为执行命令的目录,如果助手路径不存在则创建。')
|
.option('-w --workspace <workspace>', '助手路径,默认为执行命令的目录,如果助手路径不存在则创建。')
|
||||||
.action((opts: InitCommandOptions) => {
|
.action((opts: InitCommandOptions) => {
|
||||||
// 如果path参数存在,检测path是否是相对路径,如果是相对路径,则转换为绝对路径
|
// 如果workspace参数存在,检测workspace是否是相对路径,如果是相对路径,则转换为绝对路径
|
||||||
if (opts.path && !opts.path.startsWith('/')) {
|
if (opts.workspace && !opts.workspace.startsWith('/')) {
|
||||||
opts.path = path.join(process.cwd(), opts.path);
|
opts.workspace = path.join(process.cwd(), opts.workspace);
|
||||||
} else if (opts.path) {
|
} else if (opts.workspace) {
|
||||||
opts.path = path.resolve(opts.path);
|
opts.workspace = path.resolve(opts.workspace);
|
||||||
}
|
}
|
||||||
const configDir = AssistantInit.detectConfigDir(opts.path);
|
const configDir = AssistantInit.detectConfigDir(opts.workspace);
|
||||||
console.log('configDir', configDir);
|
console.log('configDir', configDir);
|
||||||
const assistantInit = new AssistantInit({
|
const assistantInit = new AssistantInit({
|
||||||
path: configDir,
|
path: configDir,
|
||||||
|
|||||||
@@ -1,34 +1,97 @@
|
|||||||
import { program, Command, assistantConfig } from '@/program.ts';
|
import { program, Command, assistantConfig } from '@/program.ts';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
const runScriptsCommand = new Command('run-scripts')
|
import path from 'node:path';
|
||||||
.alias('run')
|
import fs from 'node:fs';
|
||||||
.arguments('<cmd> [env]')
|
import { AssistantApp, checkFileExists } from '@/lib.ts';
|
||||||
.description('运行脚本,在assistant.config.json中配置的脚本')
|
import { logger } from '@/module/logger.ts';
|
||||||
.action(async (cmd, env) => {
|
import { LoadApp, StopApp } from '@/module/local-apps/src/modules/manager.ts';
|
||||||
|
|
||||||
|
const createRandomApp = (opts: { app: any, package: any, pwd: string, status?: string }) => {
|
||||||
|
const { app, package: packageJson, pwd } = opts;
|
||||||
|
if (!app.status) {
|
||||||
|
app.status = opts.status || 'running'
|
||||||
|
}
|
||||||
|
if (!app.key) {
|
||||||
|
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
||||||
|
app.key = packageJson.basename || `${'unknown-app'}-${randomSuffix}`;
|
||||||
|
}
|
||||||
|
app.path = pwd;
|
||||||
|
if (app.type === 'pm2-system-app' && !app.pm2Options) {
|
||||||
|
app.pm2Options = {
|
||||||
|
cwd: pwd,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
const start = new Command('start')
|
||||||
|
.description('获取package.json中app参数并启动对应的app')
|
||||||
|
.option('-s --save', '保存应用信息到assistant配置中', false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
// assistantConfig.checkMounted();
|
||||||
|
const pwd = process.cwd();
|
||||||
|
const packageJsonPath = path.join(pwd, 'package.json');
|
||||||
|
if (checkFileExists(packageJsonPath) === false) {
|
||||||
|
logger.error('package.json 在当前目录未找到,请在包含 package.json 的目录下运行此命令。');
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||||
|
const appKey = packageJson.app;
|
||||||
|
if (!appKey) {
|
||||||
|
logger.error('package.json 中未找到 app 字段,请确保在 package.json 中正确配置 app 字段。');
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const app = createRandomApp({ app: packageJson.app, package: packageJson, pwd });
|
||||||
|
if (app.type !== 'system-app') {
|
||||||
|
const load = await LoadApp(app, {});
|
||||||
|
if (!load) {
|
||||||
|
logger.error(`未能加载应用, 请确保应用名称正确且已安装。`, app.type);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LoadApp(app, {}).then(() => {
|
||||||
|
logger.info(`系统应用已启动: ${app.key}`);
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.error(`启动系统应用失败: ${app.key}`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.save) {
|
||||||
assistantConfig.checkMounted();
|
assistantConfig.checkMounted();
|
||||||
const configs = assistantConfig.getCacheAssistantConfig();
|
const manager = new AssistantApp(assistantConfig, app);
|
||||||
const scripts = configs?.scripts || {};
|
await manager.loadConfig();
|
||||||
try {
|
await manager.saveAppInfo(app);
|
||||||
const script = scripts[cmd];
|
|
||||||
if (!script) {
|
|
||||||
console.error(`Script "${cmd}" not found.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// console.log(`Running script "${script}"...`);
|
|
||||||
const command = [script, ...(env ? [env] : [])].join(' ');
|
|
||||||
// console.log(`Command: ${command}`, env);
|
|
||||||
const res = spawnSync(command, { shell: true, stdio: 'inherit', cwd: assistantConfig.configDir });
|
|
||||||
if (res.error) {
|
|
||||||
console.error(`Error running script "${cmd}":`, res.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (res.status !== 0) {
|
|
||||||
console.error(`Script "${cmd}" exited with code ${res.status}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`Script "${cmd}" run successfully.`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to run script "${cmd}":`, error);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
program.addCommand(runScriptsCommand);
|
|
||||||
|
program.addCommand(start);
|
||||||
|
|
||||||
|
const stop = new Command('stop')
|
||||||
|
.description('获取package.json中app参数并停止对应的app')
|
||||||
|
.option('-t --todo <todo>', '停止应用,在pm2中如果为stop则停止,如果为remove则删除,默认为stop', 'stop')
|
||||||
|
.option('-s --save', '保存应用信息到assistant配置中', false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
// assistantConfig.checkMounted();
|
||||||
|
const pwd = process.cwd();
|
||||||
|
const packageJsonPath = path.join(pwd, 'package.json');
|
||||||
|
if (checkFileExists(packageJsonPath) === false) {
|
||||||
|
logger.error('package.json 在当前目录未找到,请在包含 package.json 的目录下运行此命令。');
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||||
|
const appKey = packageJson.app;
|
||||||
|
if (!appKey) {
|
||||||
|
logger.error('package.json 中未找到 app 字段,请确保在 package.json 中正确配置 app 字段。');
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const app = createRandomApp({ app: packageJson.app, package: packageJson, pwd, status: 'stopped' });
|
||||||
|
await StopApp(app, { todo: opts.todo });
|
||||||
|
if (opts.save) {
|
||||||
|
assistantConfig.checkMounted();
|
||||||
|
const manager = new AssistantApp(assistantConfig, app);
|
||||||
|
await manager.loadConfig();
|
||||||
|
await manager.removeApp(app.key, { deleteFile: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program.addCommand(stop);
|
||||||
|
|
||||||
|
|||||||
9
assistant/src/main.ts
Normal file
9
assistant/src/main.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { app } from './app.ts';
|
||||||
|
import { type Plugin } from "@opencode-ai/plugin"
|
||||||
|
import { createRouterAgentPluginFn } from '@kevisual/router/opencode';
|
||||||
|
import './routes/index.ts';
|
||||||
|
import './routes-simple/index.ts';
|
||||||
|
|
||||||
|
export const AgentPlugin: Plugin = createRouterAgentPluginFn({
|
||||||
|
router: app,
|
||||||
|
})
|
||||||
@@ -4,10 +4,18 @@ import fs from 'fs';
|
|||||||
import { checkFileExists, createDir } from '../file/index.ts';
|
import { checkFileExists, createDir } from '../file/index.ts';
|
||||||
import { ProxyInfo } from '../proxy/proxy.ts';
|
import { ProxyInfo } from '../proxy/proxy.ts';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import { logger } from '@/module/logger.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(homedir(), 'kevisual/assistant-app'));
|
export const configDir = createDir(path.join(kevisualDir, 'assistant-app'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 助手配置文件初始化
|
* 助手配置文件初始化
|
||||||
@@ -61,52 +69,115 @@ export const initConfig = (configRootPath: string) => {
|
|||||||
export type ReturnInitConfigType = ReturnType<typeof initConfig>;
|
export type ReturnInitConfigType = ReturnType<typeof initConfig>;
|
||||||
|
|
||||||
type AuthPermission = {
|
type AuthPermission = {
|
||||||
type?: 'auth-proxy' | 'public' | 'private' | 'project';
|
share?: 'public' | 'private' | 'protected';
|
||||||
username?: string; // 用户名
|
username?: string; // 用户名
|
||||||
admin?: Omit<AuthPermission, 'admin'>;
|
admin?: string[];
|
||||||
};
|
};
|
||||||
export type AssistantConfigData = {
|
export type AssistantConfigData = {
|
||||||
pageApi?: string; // https://kevisual.cn
|
app?: {
|
||||||
|
/**
|
||||||
|
* 应用ID, 唯一标识,识别是那个设备
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
|
/**
|
||||||
|
* 应用地址
|
||||||
|
*/
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
token?: string;
|
token?: string;
|
||||||
registry?: string; // https://kevisual.cn
|
registry?: string; // https://kevisual.cn
|
||||||
|
/**
|
||||||
|
* 前端代理,比如/root/home 转到https://kevisual.cn/root/home
|
||||||
|
* path?: string;
|
||||||
|
* target?: string;
|
||||||
|
* pathname?: string;
|
||||||
|
* 例子: { path: '/root/home', target: 'https://kevisual.cn', pathname: '/root/home' }
|
||||||
|
*/
|
||||||
proxy?: ProxyInfo[];
|
proxy?: ProxyInfo[];
|
||||||
apiProxyList?: ProxyInfo[];
|
/**
|
||||||
|
* Router代理, 会自动获取 {path: 'router', key: 'list'}的路由信息,然后注入到整个router应用当中.
|
||||||
|
* 例子: { proxy: [ { type: 'router', api: 'https://localhost:50002/api/router' } ] }
|
||||||
|
* base: 是否使用 /api/router的基础路径,默认false
|
||||||
|
* lightcode: 是否启用lightcode路由,默认false
|
||||||
|
*/
|
||||||
|
router?: {
|
||||||
|
proxy: ProxyInfo[];
|
||||||
|
base?: boolean;
|
||||||
|
lightcode?: boolean;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* API 代理配置, 比如,api开头的,v1开头的等等
|
||||||
|
*/
|
||||||
|
api?: {
|
||||||
|
proxy?: ProxyInfo[];
|
||||||
|
}
|
||||||
description?: string;
|
description?: string;
|
||||||
/**
|
/**
|
||||||
* 服务启动
|
* 服务启动,
|
||||||
|
* path是配置 127.0.0.1
|
||||||
|
* port是配置端口号
|
||||||
*/
|
*/
|
||||||
server?: {
|
server?: {
|
||||||
path?: string;
|
path?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 被远程调用配置
|
||||||
|
* url: 远程应用地址 https://kevisual.cn/ws/proxy
|
||||||
|
* enabled: 是否启用远程应用
|
||||||
|
*/
|
||||||
share?: {
|
share?: {
|
||||||
url: string;
|
url: string;
|
||||||
enabled?: boolean; // 是否启用远程应用
|
enabled?: boolean;
|
||||||
name: string;
|
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 对pages目录文件监听
|
||||||
|
*/
|
||||||
watch?: {
|
watch?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* 首页
|
* 首页, 访问 `/` 自动会打开的首页地址
|
||||||
|
* 例如: /root/home
|
||||||
*/
|
*/
|
||||||
home?: string;
|
home?: string;
|
||||||
|
/**
|
||||||
|
* 启用AI代理
|
||||||
|
*/
|
||||||
ai?: {
|
ai?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
provider?: string | 'DeepSeek' | 'SiliconFlow';
|
provider?: string | 'DeepSeek' | 'Custom';
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
};
|
};
|
||||||
scripts?: {
|
/** 阿里云的语音识别服务,模型 qwen3-asr-flash-realtime */
|
||||||
[key: string]: string;
|
asr?: {
|
||||||
};
|
enabled?: boolean;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 认证和权限配置
|
||||||
|
* share: protected 需要认证代理访问(默认), public 公开访问, private 私有访问
|
||||||
|
* share 是对外共享 pages 目录下的页面
|
||||||
|
*/
|
||||||
auth?: AuthPermission;
|
auth?: AuthPermission;
|
||||||
https?: {
|
storage?: AssistantStorage[];
|
||||||
type?: 'https' | 'http';
|
|
||||||
keyPath?: string; // 证书私钥路径
|
|
||||||
certPath?: string; // 证书路径
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
type AssistantStorage = FileStorage | S3Storage;
|
||||||
|
type FileStorage = {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
type: 'local'
|
||||||
|
}
|
||||||
|
type S3Storage = {
|
||||||
|
id: string;
|
||||||
|
bucket: string;
|
||||||
|
region: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
endpoint?: string;
|
||||||
|
type: 's3' | 'minio';
|
||||||
|
}
|
||||||
let assistantConfig: AssistantConfigData;
|
let assistantConfig: AssistantConfigData;
|
||||||
type AssistantConfigOptions = {
|
type AssistantConfigOptions = {
|
||||||
configDir?: string;
|
configDir?: string;
|
||||||
@@ -132,22 +203,25 @@ export class AssistantConfig {
|
|||||||
}
|
}
|
||||||
return this.#configPath;
|
return this.#configPath;
|
||||||
}
|
}
|
||||||
init() {
|
init(configDir?: string) {
|
||||||
this.configPath = initConfig(this.configDir);
|
this.configPath = initConfig(configDir || this.configDir);
|
||||||
this.isMountedConfig = true;
|
this.isMountedConfig = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
checkMounted() {
|
checkMounted() {
|
||||||
if (!this.isMountedConfig) {
|
if (!this.isMountedConfig) {
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getConfigPath() { }
|
getConfigPath() { }
|
||||||
getConfig() {
|
getConfig(): AssistantConfigData {
|
||||||
try {
|
try {
|
||||||
if (!checkFileExists(this.configPath.configPath)) {
|
if (!checkFileExists(this.configPath.configPath)) {
|
||||||
fs.writeFileSync(this.configPath.configPath, JSON.stringify({ proxy: [] }, null, 2));
|
fs.writeFileSync(this.configPath.configPath, JSON.stringify({ proxy: [] }, null, 2));
|
||||||
return {
|
return {
|
||||||
pageApi: '',
|
app: {
|
||||||
|
url: 'https://kevisual.cn',
|
||||||
|
},
|
||||||
proxy: [],
|
proxy: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -156,7 +230,9 @@ export class AssistantConfig {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('file read', error.message);
|
console.error('file read', error.message);
|
||||||
return {
|
return {
|
||||||
pageApi: '',
|
app: {
|
||||||
|
url: 'https://kevisual.cn',
|
||||||
|
},
|
||||||
proxy: [],
|
proxy: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -169,14 +245,19 @@ export class AssistantConfig {
|
|||||||
}
|
}
|
||||||
getRegistry() {
|
getRegistry() {
|
||||||
const config = this.getCacheAssistantConfig();
|
const config = this.getCacheAssistantConfig();
|
||||||
return config?.registry || config?.pageApi;
|
return config?.registry || config?.app?.url || 'https://kevisual.cn';
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 设置 assistant-config.json 配置
|
* 设置 assistant-config.json 配置
|
||||||
* @param config
|
* @param config
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
setConfig(config?: AssistantConfigData) {
|
setConfig(config?: AssistantConfigData, force?: boolean) {
|
||||||
|
if (force) {
|
||||||
|
this.config = config || {};
|
||||||
|
fs.writeFileSync(this.configPath.configPath, JSON.stringify(this.config, null, 2));
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
const myConfig = this.getCacheAssistantConfig();
|
const myConfig = this.getCacheAssistantConfig();
|
||||||
const newConfig = { ...myConfig, ...config };
|
const newConfig = { ...myConfig, ...config };
|
||||||
this.config = newConfig;
|
this.config = newConfig;
|
||||||
@@ -288,7 +369,7 @@ type AppConfig = {
|
|||||||
list: any[];
|
list: any[];
|
||||||
};
|
};
|
||||||
export function parseArgs(args: string[]) {
|
export function parseArgs(args: string[]) {
|
||||||
const result: { root?: string; home?: boolean; help?: boolean } = {};
|
const result: { root?: string; home?: boolean; help?: boolean } = { home: true };
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
// 处理 root 参数
|
// 处理 root 参数
|
||||||
@@ -298,14 +379,13 @@ export function parseArgs(args: string[]) {
|
|||||||
i++; // 跳过下一个参数,因为它是值
|
i++; // 跳过下一个参数,因为它是值
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 处理 home 参数
|
|
||||||
// if (arg === '--home') {
|
|
||||||
result.home = true;
|
|
||||||
// }
|
|
||||||
if (arg === '--help' || arg === '-h') {
|
if (arg === '--help' || arg === '-h') {
|
||||||
result.help = true;
|
result.help = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (result.root) {
|
||||||
|
result.home = false;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -315,6 +395,7 @@ export function parseArgs(args: string[]) {
|
|||||||
*/
|
*/
|
||||||
export const parseHomeArg = (homedir?: string) => {
|
export const parseHomeArg = (homedir?: string) => {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
const execPath = process.execPath;
|
||||||
const options = parseArgs(args);
|
const options = parseArgs(args);
|
||||||
let _configDir = undefined;
|
let _configDir = undefined;
|
||||||
if (options.home && homedir) {
|
if (options.home && homedir) {
|
||||||
@@ -322,7 +403,10 @@ export const parseHomeArg = (homedir?: string) => {
|
|||||||
} else if (options.root) {
|
} else if (options.root) {
|
||||||
_configDir = options.root;
|
_configDir = options.root;
|
||||||
}
|
}
|
||||||
|
const checkUrl = ['.opencode', 'bin/opencode', 'opencode.exe']
|
||||||
|
const isOpencode = checkUrl.some((item) => execPath.includes(item))
|
||||||
return {
|
return {
|
||||||
|
isOpencode,
|
||||||
options,
|
options,
|
||||||
configDir: _configDir,
|
configDir: _configDir,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export const checkFileDir = (filePath: string, create = true) => {
|
|||||||
return exist;
|
return exist;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createDir = (dirPath: string) => {
|
export const createDir = (dirPath: string, isCreate = true) => {
|
||||||
|
if (!isCreate) return dirPath;
|
||||||
if (!checkFileExists(dirPath)) {
|
if (!checkFileExists(dirPath)) {
|
||||||
fs.mkdirSync(dirPath, { recursive: true });
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|||||||
52
assistant/src/module/assistant/html/login.ts
Normal file
52
assistant/src/module/assistant/html/login.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export const renderNoAuthAndLogin = (text: string) => {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login Required</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 15px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background-color: #007BFF;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<p>${text}</p>
|
||||||
|
<a href="/root/home">转到首页</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { createCert } from '@kevisual/router/sign';
|
|
||||||
import path from 'node:path';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import { AssistantConfig } from '../config/index.ts';
|
|
||||||
import { checkFileExists } from '../file/index.ts';
|
|
||||||
import { chalk } from '@/module/chalk.ts';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
type Attributes = {
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
type AltNames = {
|
|
||||||
type: number;
|
|
||||||
value?: string;
|
|
||||||
ip?: string;
|
|
||||||
};
|
|
||||||
export class HttpsPem {
|
|
||||||
assistantConfig: AssistantConfig;
|
|
||||||
key: string;
|
|
||||||
cert: string;
|
|
||||||
isHttps = false;
|
|
||||||
constructor(assistantConfig: AssistantConfig) {
|
|
||||||
this.assistantConfig = assistantConfig;
|
|
||||||
this.#initKeyCert();
|
|
||||||
}
|
|
||||||
#initKeyCert() {
|
|
||||||
this.assistantConfig.checkMounted();
|
|
||||||
const config = this.assistantConfig.getConfig();
|
|
||||||
if (config.https) {
|
|
||||||
const httpsType = config.https?.type || 'https';
|
|
||||||
if (httpsType !== 'https') {
|
|
||||||
// console.log(chalk.yellow('当前配置文件 https.type 不是 https, 不使用证书'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.isHttps = true;
|
|
||||||
if (config.https.keyPath) {
|
|
||||||
const keyPath = config.https.keyPath;
|
|
||||||
const certPath = config.https.certPath;
|
|
||||||
if (checkFileExists(keyPath) && checkFileExists(certPath)) {
|
|
||||||
this.key = fs.readFileSync(keyPath, 'utf-8');
|
|
||||||
this.cert = fs.readFileSync(certPath, 'utf-8');
|
|
||||||
console.log(chalk.green('使用配置文件 https.keyPath 和 https.certPath 的证书'), keyPath, certPath);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
console.log(chalk.red('证书路径不存在,请检查配置文件 https.keyPath 和 https.certPath 是否正确'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(!this.isHttps) return;
|
|
||||||
const { key, cert } = this.getCert();
|
|
||||||
this.key = key;
|
|
||||||
this.cert = cert;
|
|
||||||
}
|
|
||||||
getPemDir() {
|
|
||||||
const configDir = this.assistantConfig.configPath?.configDir || process.cwd();
|
|
||||||
const pemDir = path.join(configDir, 'pem');
|
|
||||||
if (!checkFileExists(pemDir)) {
|
|
||||||
fs.mkdirSync(pemDir, { recursive: true });
|
|
||||||
}
|
|
||||||
return pemDir;
|
|
||||||
}
|
|
||||||
getCert() {
|
|
||||||
if (!this.assistantConfig.init) return;
|
|
||||||
const pemDir = this.getPemDir();
|
|
||||||
const pemPath = {
|
|
||||||
key: path.join(pemDir, 'https-private-key.pem'),
|
|
||||||
cert: path.join(pemDir, 'https-cert.pem'),
|
|
||||||
config: path.join(pemDir, 'https-config.json'),
|
|
||||||
};
|
|
||||||
const writeCreate = (opts: { key: string; cert: string; data: { createTime: number; expireTime: number } }) => {
|
|
||||||
fs.writeFileSync(pemPath.key, opts.key);
|
|
||||||
fs.writeFileSync(pemPath.cert, opts.cert);
|
|
||||||
fs.writeFileSync(pemPath.config, JSON.stringify(opts.data, null, 2));
|
|
||||||
};
|
|
||||||
if (!checkFileExists(pemPath.key) || !checkFileExists(pemPath.cert)) {
|
|
||||||
const { key, cert, data } = this.createCert();
|
|
||||||
writeCreate({ key, cert, data });
|
|
||||||
console.log(chalk.green('证书创建成功,浏览器需要导入当前证书'));
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
cert,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!checkFileExists(pemPath.config)) {
|
|
||||||
const data = this.createExpireData();
|
|
||||||
fs.writeFileSync(pemPath.config, JSON.stringify(data, null, 2));
|
|
||||||
}
|
|
||||||
const key = fs.readFileSync(pemPath.key, 'utf-8');
|
|
||||||
const cert = fs.readFileSync(pemPath.cert, 'utf-8');
|
|
||||||
const config = fs.readFileSync(pemPath.config, 'utf-8');
|
|
||||||
let expireTime = 0;
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(config);
|
|
||||||
expireTime = data.expireTime;
|
|
||||||
if (typeof expireTime !== 'number') {
|
|
||||||
throw new Error('expireTime is not a number');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(chalk.red('证书配置文件损坏,重新生成证书'));
|
|
||||||
}
|
|
||||||
const now = new Date().getTime();
|
|
||||||
if (now > expireTime) {
|
|
||||||
this.removeCert();
|
|
||||||
const { key, cert, data } = this.createCert();
|
|
||||||
writeCreate({ key, cert, data });
|
|
||||||
console.log(chalk.green('证书更新成功, 浏览器需要重新导入当前证书'));
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
cert,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
cert,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
createExpireData() {
|
|
||||||
const expireTime = new Date().getTime() + 365 * 24 * 60 * 60 * 1000;
|
|
||||||
const expireDate = dayjs(expireTime).format('YYYY-MM-DD HH:mm:ss');
|
|
||||||
return {
|
|
||||||
description: '手动导入证书到浏览器, https-cert.pem文件, 具体使用教程访问 https://kevisual.cn/root/pem-docs/',
|
|
||||||
createTime: new Date().getTime(),
|
|
||||||
expireDate,
|
|
||||||
expireTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
* 重新生成证书
|
|
||||||
*/
|
|
||||||
removeCert() {
|
|
||||||
const pemDir = this.getPemDir();
|
|
||||||
const pemPath = {
|
|
||||||
key: path.join(pemDir, 'https-private-key.pem'),
|
|
||||||
cert: path.join(pemDir, 'https-cert.pem'),
|
|
||||||
};
|
|
||||||
const oldPath = {
|
|
||||||
key: path.join(pemDir, 'https-private-key.pem.bak'),
|
|
||||||
cert: path.join(pemDir, 'https-cert.pem.bak'),
|
|
||||||
};
|
|
||||||
if (checkFileExists(pemPath.key)) {
|
|
||||||
fs.renameSync(pemPath.key, oldPath.key);
|
|
||||||
}
|
|
||||||
if (checkFileExists(pemPath.cert)) {
|
|
||||||
fs.renameSync(pemPath.cert, oldPath.cert);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
* 创建证书
|
|
||||||
* @param attrs 证书属性
|
|
||||||
* @param altNames 证书备用名称
|
|
||||||
*/
|
|
||||||
createCert(attrs?: Attributes[], altNames?: AltNames[]) {
|
|
||||||
const attributes = attrs || [];
|
|
||||||
const altNamesList = altNames || [];
|
|
||||||
const { key, cert } = createCert(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: 'commonName',
|
|
||||||
value: 'localhost',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'organizationName',
|
|
||||||
value: 'kevisual',
|
|
||||||
},
|
|
||||||
...attributes,
|
|
||||||
],
|
|
||||||
altNamesList,
|
|
||||||
);
|
|
||||||
const data = this.createExpireData();
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
cert,
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import { Manager } from '@kevisual/local-app-manager/manager';
|
import { Manager } from '../../local-apps/src/modules/manager.ts';
|
||||||
import type { AssistantConfig } from '@/module/assistant/index.ts';
|
import type { AssistantConfig } from '@/module/assistant/index.ts';
|
||||||
import { parseIfJson } from '@/module/assistant/index.ts';
|
import { parseIfJson } from '@/module/assistant/index.ts';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import glob from 'fast-glob';
|
import glob from 'fast-glob';
|
||||||
import type { App } from '@kevisual/router';
|
import type { App } from '@kevisual/router';
|
||||||
|
import { RemoteApp } from '@/module/remote-app/remote-app.ts';
|
||||||
|
import { logger } from '@/module/logger.ts';
|
||||||
|
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';
|
||||||
export class AssistantApp extends Manager {
|
export class AssistantApp extends Manager {
|
||||||
config: AssistantConfig;
|
config: AssistantConfig;
|
||||||
pagesPath: string;
|
pagesPath: string;
|
||||||
|
remoteIsConnected = false;
|
||||||
|
attemptedConnectTimes = 0;
|
||||||
|
remoteApp: RemoteApp | null = null;
|
||||||
|
remoteUrl: string | null = null;
|
||||||
constructor(config: AssistantConfig, mainApp?: App) {
|
constructor(config: AssistantConfig, mainApp?: App) {
|
||||||
config.checkMounted();
|
config.checkMounted();
|
||||||
const appsPath = config?.configPath?.appsDir || path.join(process.cwd(), 'apps');
|
const appsPath = config?.configPath?.appsDir || path.join(process.cwd(), 'apps');
|
||||||
@@ -44,13 +54,15 @@ export class AssistantApp extends Manager {
|
|||||||
}
|
}
|
||||||
async getPageAndAppList() {
|
async getPageAndAppList() {
|
||||||
const root = this.config.configPath.configDir;
|
const root = this.config.configPath.configDir;
|
||||||
const pages = await glob([root + '/apps/*/package.json', root + '/pages/*/*/package.json'], {
|
const path1 = 'apps/*/*/package.json';
|
||||||
|
const path2 = 'pages/*/*/package.json';
|
||||||
|
const pages = await glob([path1, path2], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
onlyFiles: true,
|
onlyFiles: true,
|
||||||
});
|
});
|
||||||
const pagesParse = pages.map((page) => {
|
const pagesParse = pages.map((page) => {
|
||||||
const relativePath = path.relative(root, page);
|
const relativePath = page;
|
||||||
const contentStr = fs.readFileSync(path.join(page), 'utf-8');
|
const contentStr = fs.readFileSync(path.join(root, page), 'utf-8');
|
||||||
const content = parseIfJson(contentStr);
|
const content = parseIfJson(contentStr);
|
||||||
if (!content.appType) {
|
if (!content.appType) {
|
||||||
const isWeb = relativePath.startsWith('pages/');
|
const isWeb = relativePath.startsWith('pages/');
|
||||||
@@ -63,4 +75,140 @@ export class AssistantApp extends Manager {
|
|||||||
});
|
});
|
||||||
return pagesParse;
|
return pagesParse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async initRemoteApp(opts?: { token?: string, enabled?: boolean }) {
|
||||||
|
const config = this.config.getConfig();
|
||||||
|
const share = config?.share;
|
||||||
|
const enabled = opts?.enabled ?? share?.enabled ?? false;
|
||||||
|
if (share && enabled !== false) {
|
||||||
|
if (this.remoteApp) {
|
||||||
|
this.remoteApp.ws?.close();
|
||||||
|
this.remoteApp = null;
|
||||||
|
this.remoteIsConnected = false;
|
||||||
|
}
|
||||||
|
const token = config?.token || opts?.token || getEnvToken() as string;
|
||||||
|
const url = new URL(share.url || 'https://kevisual.cn/ws/proxy');
|
||||||
|
const id = config?.app?.id;
|
||||||
|
if (token && url && id) {
|
||||||
|
const remoteApp = new RemoteApp({
|
||||||
|
url: url.toString(),
|
||||||
|
token,
|
||||||
|
id,
|
||||||
|
app: this.mainApp,
|
||||||
|
});
|
||||||
|
const isConnect = await remoteApp.isConnect();
|
||||||
|
if (isConnect) {
|
||||||
|
remoteApp.listenProxy();
|
||||||
|
this.remoteIsConnected = true;
|
||||||
|
// 清理已有的 close 事件监听器,防止多重绑定
|
||||||
|
remoteApp.emitter.removeAllListeners('close');
|
||||||
|
remoteApp.emitter.on('close', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (remoteApp.isError) {
|
||||||
|
console.error('远程应用发生错误,不重连');
|
||||||
|
} else {
|
||||||
|
this.reconnectRemoteApp();
|
||||||
|
}
|
||||||
|
}, 5 * 1000); // 第一次断开5秒后重连
|
||||||
|
});
|
||||||
|
logger.debug('链接到了远程应用服务器');
|
||||||
|
const appId = id;
|
||||||
|
const username = config?.auth.username || 'unknown';
|
||||||
|
const url = new URL(`/${username}/v1/${appId}`, config?.registry || 'https://kevisual.cn/');
|
||||||
|
this.remoteUrl = url.toString();
|
||||||
|
console.log('远程地址', this.remoteUrl);
|
||||||
|
} else {
|
||||||
|
console.log('Not connected to remote app server');
|
||||||
|
}
|
||||||
|
this.remoteApp = remoteApp;
|
||||||
|
} else {
|
||||||
|
if (!token) {
|
||||||
|
logger.error('Token是远程应用连接必须的参数');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async initRouterApp() {
|
||||||
|
const config = this.config.getConfig();
|
||||||
|
const routerProxy = config?.router?.proxy || [];
|
||||||
|
const base = config.router?.base ?? false;
|
||||||
|
const lightcode = config.router?.lightcode ?? true;
|
||||||
|
if (base) {
|
||||||
|
routerProxy.push({
|
||||||
|
type: 'router',
|
||||||
|
router: {
|
||||||
|
url: `${this.config.getRegistry()}/api/router`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (lightcode) {
|
||||||
|
routerProxy.push({
|
||||||
|
type: 'lightcode',
|
||||||
|
lightcode: {
|
||||||
|
check: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (routerProxy.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const proxyInfo of routerProxy) {
|
||||||
|
if (proxyInfo.type !== 'router' && proxyInfo.type !== 'lightcode') {
|
||||||
|
console.warn('路由的type必须是"router", 或者lightcode');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (proxyInfo.type === 'lightcode') {
|
||||||
|
initLightCode({
|
||||||
|
router: this.mainApp,
|
||||||
|
config: this.config
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const url = proxyInfo.router!.url;
|
||||||
|
if (!url) {
|
||||||
|
console.warn('路由的api地址不能为空', proxyInfo.router);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const query = new Query({ url });
|
||||||
|
try {
|
||||||
|
initApi({
|
||||||
|
router: this.mainApp,
|
||||||
|
item: {
|
||||||
|
type: 'api',
|
||||||
|
api: {
|
||||||
|
url,
|
||||||
|
query: query as any,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exclude: "WHERE path = 'auth' OR path = 'router' OR path = 'call'",
|
||||||
|
})
|
||||||
|
console.log('Router API 已初始化', url.toString());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Router API 初始化失败', url.toString(), err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async reconnectRemoteApp() {
|
||||||
|
console.log('重新连接到远程应用服务器...', this.attemptedConnectTimes);
|
||||||
|
const remoteApp = this.remoteApp;;
|
||||||
|
if (remoteApp) {
|
||||||
|
// 先关闭旧的 WebSocket,防止竞态条件
|
||||||
|
if (remoteApp.ws) {
|
||||||
|
remoteApp.ws.close();
|
||||||
|
}
|
||||||
|
remoteApp.init();
|
||||||
|
this.attemptedConnectTimes += 1;
|
||||||
|
const isConnect = await remoteApp.isConnect();
|
||||||
|
if (isConnect) {
|
||||||
|
remoteApp.listenProxy();
|
||||||
|
this.attemptedConnectTimes = 0;
|
||||||
|
console.log('重新连接到了远程应用服务器');
|
||||||
|
} else {
|
||||||
|
this.reconnectRemoteApp();
|
||||||
|
setTimeout(() => {
|
||||||
|
this.initRouterApp()
|
||||||
|
}, 30 * 1000 + this.attemptedConnectTimes * 10 * 1000); // 30秒后重连 + 每次增加10秒
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,49 +5,39 @@ import path from 'node:path';
|
|||||||
import { ProxyInfo } from './proxy.ts';
|
import { ProxyInfo } from './proxy.ts';
|
||||||
import { checkFileExists } from '../file/index.ts';
|
import { checkFileExists } from '../file/index.ts';
|
||||||
import { log } from '@/module/logger.ts';
|
import { log } from '@/module/logger.ts';
|
||||||
|
import { pipeFileStream } from './pipe.ts';
|
||||||
|
import { getContentType } from './module/mime.ts';
|
||||||
|
|
||||||
export const fileProxy = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
|
export const fileProxy2 = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
|
||||||
// url开头的文件
|
res.statusCode = 501;
|
||||||
const url = new URL(req.url, 'http://localhost');
|
const url = new URL(req.url, 'http://localhost');
|
||||||
const [user, key, _info] = url.pathname.split('/');
|
const { rootPath, indexPath = '' } = proxyApi?.file || {}
|
||||||
const pathname = url.pathname.slice(1);
|
if (!rootPath) {
|
||||||
const { indexPath = '', target = '', rootPath = process.cwd() } = proxyApi;
|
res.end(`系统未配置根路径 rootPath id:[${proxyApi?.file?.id}]`);
|
||||||
if (!indexPath) {
|
|
||||||
return res.end('Not Found indexPath');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// 检测文件是否存在,如果文件不存在,则返回404
|
|
||||||
let filePath = '';
|
|
||||||
let exist = false;
|
|
||||||
if (_info) {
|
|
||||||
filePath = path.join(rootPath, target, pathname);
|
|
||||||
exist = checkFileExists(filePath, true);
|
|
||||||
}
|
|
||||||
if (!exist) {
|
|
||||||
filePath = path.join(rootPath, target, indexPath);
|
|
||||||
exist = checkFileExists(filePath, true);
|
|
||||||
}
|
|
||||||
log.debug('filePath', { filePath, exist });
|
|
||||||
|
|
||||||
if (!exist) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end('Not Found File');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ext = path.extname(filePath);
|
const pathname = url.pathname;
|
||||||
let maxAge = 24 * 60 * 60 * 1000; // 24小时
|
let targetFilepath = pathname.replace(proxyApi.path || '', '');
|
||||||
if (ext === '.html') {
|
if (targetFilepath.endsWith('/')) {
|
||||||
maxAge = 0;
|
// 没有指定文件,访问index.html
|
||||||
|
targetFilepath += 'index.html';
|
||||||
}
|
}
|
||||||
let sendFilePath = path.relative(rootPath, filePath);
|
const filePath = path.join(rootPath || process.cwd(), targetFilepath);
|
||||||
const file = send(req, sendFilePath, {
|
const indexTargetPath = path.join(rootPath || process.cwd(), indexPath);
|
||||||
root: rootPath,
|
let sendPath = filePath;
|
||||||
maxAge,
|
if (!checkFileExists(filePath)) {
|
||||||
});
|
res.setHeader('X-Proxy-File', 'false');
|
||||||
file.pipe(res);
|
if (indexPath && checkFileExists(indexTargetPath)) {
|
||||||
} catch (error) {
|
sendPath = indexTargetPath;
|
||||||
|
} else {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.end('Error:Not Found File');
|
res.end(`文件不存在, 路径: ${filePath}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
res.setHeader('X-Proxy-File', 'true');
|
||||||
|
}
|
||||||
|
const contentType = getContentType(sendPath);
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
pipeFileStream(sendPath, res);
|
||||||
};
|
};
|
||||||
@@ -2,11 +2,16 @@ import http from 'node:http';
|
|||||||
import https from 'node:https';
|
import https from 'node:https';
|
||||||
import { rewriteCookieDomain } from '../https/cookie-rewrite.ts';
|
import { rewriteCookieDomain } from '../https/cookie-rewrite.ts';
|
||||||
import { ProxyInfo } from './proxy.ts';
|
import { ProxyInfo } from './proxy.ts';
|
||||||
|
import { pipeProxyReq, pipeProxyRes, pipeStream } from './pipe.ts';
|
||||||
export const defaultApiProxy = [
|
export const defaultApiProxy = [
|
||||||
{
|
{
|
||||||
path: '/api/router',
|
path: '/api/router',
|
||||||
target: 'https://kevisual.cn',
|
target: 'https://kevisual.cn',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/api/s1',
|
||||||
|
target: 'https://kevisual.cn',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/v1',
|
path: '/v1',
|
||||||
target: 'https://kevisual.cn',
|
target: 'https://kevisual.cn',
|
||||||
@@ -18,7 +23,7 @@ export const defaultApiProxy = [
|
|||||||
* @param paths ['/api/router', '/v1' ]
|
* @param paths ['/api/router', '/v1' ]
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const createApiProxy = (api: string, paths: string[] = ['/api', '/v1']) => {
|
export const createApiProxy = (api: string, paths: string[] = ['/api', '/v1']): ProxyInfo[] => {
|
||||||
const pathList = paths.map((item) => {
|
const pathList = paths.map((item) => {
|
||||||
return {
|
return {
|
||||||
path: item,
|
path: item,
|
||||||
@@ -81,18 +86,26 @@ export const httpProxy = (req: http.IncomingMessage, res: http.ServerResponse, p
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
options.rejectUnauthorized = false; // 忽略证书错误
|
options.rejectUnauthorized = false; // 忽略证书错误
|
||||||
}
|
}
|
||||||
|
if (res.headersSent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 创建代理请求
|
// 创建代理请求
|
||||||
const proxyReq = httpProxy.request(options, (proxyRes) => {
|
const proxyReq = httpProxy.request(options, (proxyRes) => {
|
||||||
// Modify the 'set-cookie' headers using rewriteCookieDomain
|
// 检查响应头是否已经发送
|
||||||
if (proxyRes.headers['set-cookie']) {
|
if (res.headersSent) {
|
||||||
proxyRes.headers['set-cookie'] = proxyRes.headers['set-cookie'].map((cookie) => rewriteCookieDomain(cookie, cookieHost));
|
return;
|
||||||
console.log('rewritten set-cookie:', proxyRes.headers['set-cookie']);
|
}
|
||||||
|
// 复制响应头并修改 'set-cookie'
|
||||||
|
const responseHeaders = { ...proxyRes.headers };
|
||||||
|
if (responseHeaders['set-cookie']) {
|
||||||
|
responseHeaders['set-cookie'] = responseHeaders['set-cookie'].map((cookie) => rewriteCookieDomain(cookie, cookieHost));
|
||||||
|
console.log('rewritten set-cookie:', responseHeaders['set-cookie']);
|
||||||
}
|
}
|
||||||
// 将代理服务器的响应头和状态码返回给客户端
|
// 将代理服务器的响应头和状态码返回给客户端
|
||||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
res.writeHead(proxyRes.statusCode, responseHeaders);
|
||||||
// 将代理响应流写入客户端响应
|
// 将代理响应流写入客户端响应
|
||||||
proxyRes.pipe(res, { end: true });
|
// proxyRes.pipe(res, { end: true });
|
||||||
|
pipeProxyRes(proxyRes, res);
|
||||||
});
|
});
|
||||||
// 处理代理请求的错误事件
|
// 处理代理请求的错误事件
|
||||||
proxyReq.on('error', (err) => {
|
proxyReq.on('error', (err) => {
|
||||||
@@ -101,6 +114,8 @@ export const httpProxy = (req: http.IncomingMessage, res: http.ServerResponse, p
|
|||||||
res.write(`Proxy request error: ${err.message}`);
|
res.write(`Proxy request error: ${err.message}`);
|
||||||
});
|
});
|
||||||
// 处理 POST 请求的请求体(传递数据到目标服务器),end:true 表示当请求体结束时,关闭请求
|
// 处理 POST 请求的请求体(传递数据到目标服务器),end:true 表示当请求体结束时,关闭请求
|
||||||
req.pipe(proxyReq, { end: true });
|
// req.pipe(proxyReq, { end: true });
|
||||||
return;
|
pipeProxyReq(req, proxyReq, res);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,3 @@ export * from './proxy.ts';
|
|||||||
export * from './file-proxy.ts';
|
export * from './file-proxy.ts';
|
||||||
export { default as send } from 'send';
|
export { default as send } from 'send';
|
||||||
export * from './http-proxy.ts';
|
export * from './http-proxy.ts';
|
||||||
export * from './ws-proxy.ts';
|
|
||||||
50
assistant/src/module/assistant/proxy/module/mime.ts
Normal file
50
assistant/src/module/assistant/proxy/module/mime.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import path from 'path';
|
||||||
|
// 获取文件的 content-type
|
||||||
|
export const getContentType = (filePath: string) => {
|
||||||
|
const extname = path.extname(filePath);
|
||||||
|
const contentType = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.mjs': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.md': 'text/markdown; charset=utf-8', // utf-8配置
|
||||||
|
'.ico': 'image/x-icon', // Favicon 图标
|
||||||
|
'.webp': 'image/webp', // WebP 图像格式
|
||||||
|
'.webm': 'video/webm', // WebM 视频格式
|
||||||
|
'.ogg': 'audio/ogg', // Ogg 音频格式
|
||||||
|
'.mp3': 'audio/mpeg', // MP3 音频格式
|
||||||
|
'.m4a': 'audio/mp4', // M4A 音频格式
|
||||||
|
'.m3u8': 'application/vnd.apple.mpegurl', // HLS 播放列表
|
||||||
|
'.ts': 'video/mp2t', // MPEG Transport Stream
|
||||||
|
'.pdf': 'application/pdf', // PDF 文档
|
||||||
|
'.doc': 'application/msword', // Word 文档
|
||||||
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // Word 文档 (新版)
|
||||||
|
'.ppt': 'application/vnd.ms-powerpoint', // PowerPoint 演示文稿
|
||||||
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // PowerPoint (新版)
|
||||||
|
'.xls': 'application/vnd.ms-excel', // Excel 表格
|
||||||
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // Excel 表格 (新版)
|
||||||
|
'.csv': 'text/csv; charset=utf-8', // CSV 文件
|
||||||
|
'.xml': 'application/xml; charset=utf-8', // XML 文件
|
||||||
|
'.rtf': 'application/rtf', // RTF 文本文件
|
||||||
|
'.eot': 'application/vnd.ms-fontobject', // Embedded OpenType 字体
|
||||||
|
'.ttf': 'font/ttf', // TrueType 字体
|
||||||
|
'.woff': 'font/woff', // Web Open Font Format 1.0
|
||||||
|
'.woff2': 'font/woff2', // Web Open Font Format 2.0
|
||||||
|
'.otf': 'font/otf', // OpenType 字体
|
||||||
|
'.wasm': 'application/wasm', // WebAssembly 文件
|
||||||
|
'.pem': 'application/x-pem-file', // PEM 证书文件
|
||||||
|
'.crt': 'application/x-x509-ca-cert', // CRT 证书文件
|
||||||
|
'.yaml': 'application/x-yaml; charset=utf-8', // YAML 文件
|
||||||
|
'.yml': 'application/x-yaml; charset=utf-8', // YAML 文件(别名)
|
||||||
|
'.zip': 'application/octet-stream',
|
||||||
|
};
|
||||||
|
return contentType[extname] || 'application/octet-stream';
|
||||||
|
};
|
||||||
128
assistant/src/module/assistant/proxy/pipe.ts
Normal file
128
assistant/src/module/assistant/proxy/pipe.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import * as http from 'http';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { isBun } from './utils.ts';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import { logger } from '@/module/logger.ts';
|
||||||
|
/**
|
||||||
|
* 文件流管道传输函数
|
||||||
|
* 将指定文件的内容通过流的方式传输给客户端响应
|
||||||
|
* @param filePath 要传输的文件路径
|
||||||
|
* @param res HTTP服务器响应对象
|
||||||
|
*/
|
||||||
|
export const pipeFileStream = (filePath: string, res: http.ServerResponse) => {
|
||||||
|
const readStream = fs.createReadStream(filePath);
|
||||||
|
if (isBun) {
|
||||||
|
// Bun环境下的流处理方式
|
||||||
|
res.pipe(readStream as any);
|
||||||
|
} else {
|
||||||
|
// Node.js标准环境下的流处理方式,end:true表示在流结束时自动关闭响应
|
||||||
|
readStream.pipe(res, { end: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用流管道传输函数
|
||||||
|
* 将可读流的数据传输给客户端响应
|
||||||
|
* @param readStream 可读流对象
|
||||||
|
* @param res HTTP服务器响应对象
|
||||||
|
*/
|
||||||
|
export const pipeStream = (readStream: fs.ReadStream | Readable, res: http.ServerResponse) => {
|
||||||
|
if (isBun) {
|
||||||
|
// Bun环境下的流处理方式
|
||||||
|
res.pipe(readStream as any);
|
||||||
|
} else {
|
||||||
|
// Node.js标准环境下的流处理方式
|
||||||
|
readStream.pipe(res, { end: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理响应流传输函数
|
||||||
|
* 将代理服务器返回的响应数据传输给客户端
|
||||||
|
* 处理从目标服务器收到的响应流并转发给原始客户端
|
||||||
|
* @param proxyRes 代理服务器的响应对象
|
||||||
|
* @param res HTTP服务器响应对象
|
||||||
|
*/
|
||||||
|
export const pipeProxyRes = (proxyRes: http.IncomingMessage, res: http.ServerResponse) => {
|
||||||
|
if (isBun) {
|
||||||
|
// Bun环境下需要手动收集数据并end,因为Bun的pipe机制与Node.js不同
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
// 监听数据到达事件,收集所有数据块
|
||||||
|
proxyRes.on('data', (chunk: Buffer) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
if (proxyRes.url === '/api/router') {
|
||||||
|
console.log(proxyRes.url, proxyRes.statusCode);
|
||||||
|
}
|
||||||
|
// 监听数据结束事件,将收集的数据合并并发送给客户端
|
||||||
|
proxyRes.on('end', () => {
|
||||||
|
const result = Buffer.concat(chunks).toString();
|
||||||
|
res.end(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听错误事件,处理代理响应过程中的错误
|
||||||
|
proxyRes.on('error', (error) => {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: error.message }));
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Node.js标准环境下直接使用pipe进行流传输
|
||||||
|
proxyRes.pipe(res, { end: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理请求流传输函数
|
||||||
|
* 将客户端的请求数据传输给代理服务器
|
||||||
|
* 处理来自客户端的请求流并转发给目标服务器
|
||||||
|
* @param req 客户端的请求对象
|
||||||
|
* @param proxyReq 代理服务器的请求对象
|
||||||
|
*/
|
||||||
|
export const pipeProxyReq = async (req: http.IncomingMessage, proxyReq: http.ClientRequest, res: any) => {
|
||||||
|
if (isBun) {
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const bunRequest = req.bun.request;
|
||||||
|
const contentType = req.headers['content-type'] || '';
|
||||||
|
if (contentType.includes('multipart/form-data')) {
|
||||||
|
console.log('Processing multipart/form-data');
|
||||||
|
const arrayBuffer = await bunRequest.arrayBuffer();
|
||||||
|
|
||||||
|
// 设置请求头(在写入数据之前)
|
||||||
|
proxyReq.setHeader('content-type', contentType);
|
||||||
|
proxyReq.setHeader('content-length', arrayBuffer.byteLength.toString());
|
||||||
|
|
||||||
|
// 写入数据并结束请求
|
||||||
|
if (arrayBuffer.byteLength > 0) {
|
||||||
|
proxyReq.write(Buffer.from(arrayBuffer));
|
||||||
|
}
|
||||||
|
proxyReq.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.debug('Bun pipeProxyReq content-type', contentType);
|
||||||
|
// @ts-ignore
|
||||||
|
const bodyString = req.body;
|
||||||
|
bodyString && proxyReq.write(bodyString);
|
||||||
|
proxyReq.end();
|
||||||
|
} catch (error) {
|
||||||
|
proxyReq.destroy(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Node.js标准环境下直接使用pipe进行流传输
|
||||||
|
req.pipe(proxyReq, { end: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const pipeBusboy = async (req: http.IncomingMessage, res: http.ServerResponse, busboy: any) => {
|
||||||
|
if (isBun) {
|
||||||
|
// @ts-ignore
|
||||||
|
const bunRequest = req.bun.request;
|
||||||
|
const arrayBuffer = await bunRequest.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
busboy.end(buffer);
|
||||||
|
} else {
|
||||||
|
req.pipe(busboy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
import { httpProxy } from './http-proxy.ts';
|
||||||
|
import { s3Proxy } from './s3.ts';
|
||||||
|
import { fileProxy2 } from './file-proxy.ts';
|
||||||
export type ProxyInfo = {
|
export type ProxyInfo = {
|
||||||
/**
|
/**
|
||||||
* 代理路径, 比如/root/center, 匹配的路径
|
* 代理路径, 比如/root/home, 匹配的路径
|
||||||
*/
|
*/
|
||||||
path?: string;
|
path?: string;
|
||||||
/**
|
/**
|
||||||
* 目标地址
|
* 目标url地址,比如http://localhost:3000
|
||||||
*/
|
*/
|
||||||
target?: string;
|
target?: string;
|
||||||
/**
|
/**
|
||||||
* 类型
|
* 类型
|
||||||
*/
|
*/
|
||||||
type?: 'file' | 'dynamic' | 'minio' | 'http';
|
type?: 'file' | 'dynamic' | 'minio' | 'http' | 's3' | 'router' | 'lightcode';
|
||||||
/**
|
/**
|
||||||
* 目标的 pathname, 默认为请求的url.pathname, 设置了pathname,则会使用pathname作为请求的url.pathname
|
* 目标的 pathname, 默认为请求的url.pathname, 设置了pathname,则会使用pathname作为请求的url.pathname
|
||||||
* @default undefined
|
* @default undefined
|
||||||
@@ -22,39 +26,44 @@ export type ProxyInfo = {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
ws?: boolean;
|
ws?: boolean;
|
||||||
|
s3?: {
|
||||||
/**
|
/**
|
||||||
* 首要文件,比如index.html, type为fileProxy代理有用 设置了首要文件,如果文件不存在,则访问首要文件
|
* 如何id存在,使用assistan-config的storage配置
|
||||||
*/
|
*/
|
||||||
|
id?: string;
|
||||||
|
bucket?: string;
|
||||||
|
region?: string;
|
||||||
|
accessKeyId?: string;
|
||||||
|
secretAccessKey?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
},
|
||||||
|
file?: {
|
||||||
|
id?: string;
|
||||||
indexPath?: string;
|
indexPath?: string;
|
||||||
/**
|
|
||||||
* 根路径, 默认是process.cwd(), type为fileProxy代理有用,必须为绝对路径
|
|
||||||
*/
|
|
||||||
rootPath?: string;
|
rootPath?: string;
|
||||||
|
},
|
||||||
|
router?: {
|
||||||
|
id?: string;
|
||||||
|
url?: string;
|
||||||
|
},
|
||||||
|
lightcode?: {
|
||||||
|
id?: string;
|
||||||
|
/**
|
||||||
|
* 是否检测远程服务更新
|
||||||
|
*/
|
||||||
|
check?: boolean;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiList = {
|
export const proxy = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
|
||||||
path: string;
|
if (proxyApi.type === 'http' || !proxyApi.type) {
|
||||||
/**
|
return httpProxy(req, res, proxyApi);
|
||||||
* url或者相对路径
|
}
|
||||||
*/
|
console.log('proxyApi', proxyApi);
|
||||||
target: string;
|
if (proxyApi.type === 's3') {
|
||||||
/**
|
return s3Proxy(req, res, proxyApi);
|
||||||
* 目标地址
|
}
|
||||||
*/
|
if (proxyApi.type === 'file') {
|
||||||
ws?: boolean;
|
return fileProxy2(req, res, proxyApi);
|
||||||
/**
|
}
|
||||||
* 类型
|
}
|
||||||
*/
|
|
||||||
type?: 'static' | 'dynamic' | 'minio';
|
|
||||||
}[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
|
|
||||||
[
|
|
||||||
{
|
|
||||||
path: '/api/v1/user',
|
|
||||||
target: 'http://localhost:3000/api/v1/user',
|
|
||||||
type: 'dynamic',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
*/
|
|
||||||
79
assistant/src/module/assistant/proxy/s3.ts
Normal file
79
assistant/src/module/assistant/proxy/s3.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import { ProxyInfo } from './proxy.ts';
|
||||||
|
import http from 'http';
|
||||||
|
import { OssBase } from '@kevisual/oss/s3.ts';
|
||||||
|
import { pipeStream } from './pipe.ts';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
const mapS3 = new Map<string, S3Client>();
|
||||||
|
|
||||||
|
export const s3Proxy = async (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
|
||||||
|
const s3 = proxyApi.s3;
|
||||||
|
if (!s3) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('S3 config not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let findClient = mapS3.get(s3.accessKeyId);
|
||||||
|
let s3Client: S3Client;
|
||||||
|
if (findClient) {
|
||||||
|
s3Client = findClient;
|
||||||
|
} else {
|
||||||
|
s3Client = new S3Client({
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: s3.accessKeyId,
|
||||||
|
secretAccessKey: s3.secretAccessKey,
|
||||||
|
},
|
||||||
|
region: s3.region,
|
||||||
|
endpoint: s3.endpoint,
|
||||||
|
});
|
||||||
|
mapS3.set(s3.accessKeyId, s3Client);
|
||||||
|
}
|
||||||
|
const oss = new OssBase({
|
||||||
|
client: s3Client!,
|
||||||
|
bucketName: s3.bucket,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestUrl = new URL(req.url, 'http://localhost');
|
||||||
|
const proxyPath = proxyApi.path || '';
|
||||||
|
let objectPath = requestUrl.pathname.replace(proxyPath + '/', '');
|
||||||
|
if (objectPath.startsWith(s3.bucket + '/')) {
|
||||||
|
objectPath = objectPath.replace(s3.bucket + '/', '');
|
||||||
|
}
|
||||||
|
if (objectPath.endsWith('/')) {
|
||||||
|
// 获取目录列表
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
const list = await oss.listObjects(objectPath);
|
||||||
|
res.end(JSON.stringify(list, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
oss.getObject(objectPath).then((response) => {
|
||||||
|
if (!response.Body) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Object not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 设置响应头
|
||||||
|
if (response.ContentType) {
|
||||||
|
res.setHeader('Content-Type', response.ContentType);
|
||||||
|
}
|
||||||
|
if (response.ContentLength) {
|
||||||
|
res.setHeader('Content-Length', response.ContentLength);
|
||||||
|
}
|
||||||
|
if (response.LastModified) {
|
||||||
|
res.setHeader('Last-Modified', response.LastModified.toUTCString());
|
||||||
|
}
|
||||||
|
if (response.ETag) {
|
||||||
|
res.setHeader('ETag', response.ETag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// response.Body 已经是 Readable 流,直接 pipe 到 res
|
||||||
|
// (response.Body as Readable).pipe(res);
|
||||||
|
pipeStream(response.Body as Readable, res);
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('S3 getObject error:', err);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end(`S3 error: ${err.message}`);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
6
assistant/src/module/assistant/proxy/utils.ts
Normal file
6
assistant/src/module/assistant/proxy/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const isBun = typeof Bun !== 'undefined' && Bun?.version != null;
|
||||||
|
|
||||||
|
export const isNode = typeof process !== 'undefined' && process?.versions != null && process.versions?.node != null;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
export const isDeno = typeof Deno !== 'undefined' && Deno?.version != null && Deno?.version?.deno != null;
|
||||||
@@ -4,6 +4,7 @@ import type { Http2Server } from 'node:http2';
|
|||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { ProxyInfo } from './proxy.ts';
|
import { ProxyInfo } from './proxy.ts';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
|
import { isBun, isNode } from './utils.ts';
|
||||||
|
|
||||||
export const wss = new WebSocketServer({
|
export const wss = new WebSocketServer({
|
||||||
noServer: true,
|
noServer: true,
|
||||||
@@ -97,7 +98,13 @@ export const wsProxy = (server: HttpServer | HttpsServer | Http2Server, config:
|
|||||||
await wssApp.handleConnection(ws, req);
|
await wssApp.handleConnection(ws, req);
|
||||||
});
|
});
|
||||||
// 处理升级请求
|
// 处理升级请求
|
||||||
|
if (!isBun) {
|
||||||
server.on('upgrade', (request, socket, head) => {
|
server.on('upgrade', (request, socket, head) => {
|
||||||
wssApp.upgrade(request, socket, head);
|
wssApp.upgrade(request, socket, head);
|
||||||
});
|
});
|
||||||
|
} else if (isBun) {
|
||||||
|
// @ts-ignore
|
||||||
|
console.warn('Bun WebSocket proxy is not implemented yet.');
|
||||||
|
console.log('server', server)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import type { AssistantConfig } from '@/module/assistant/index.ts';
|
|
||||||
import { WebSocket } from 'ws';
|
|
||||||
import type { App } from '@kevisual/router';
|
|
||||||
import { EventEmitter } from 'eventemitter3';
|
|
||||||
import { logger } from '@/module/logger.ts';
|
|
||||||
type RemoteAppOptions = {
|
|
||||||
app?: App;
|
|
||||||
assistantConfig?: AssistantConfig;
|
|
||||||
emitter?: EventEmitter;
|
|
||||||
};
|
|
||||||
export class RemoteApp {
|
|
||||||
mainApp: App;
|
|
||||||
assistantConfig: AssistantConfig;
|
|
||||||
url: string;
|
|
||||||
name: string;
|
|
||||||
enabled: boolean;
|
|
||||||
emitter: EventEmitter;
|
|
||||||
isConnected: boolean;
|
|
||||||
ws: WebSocket;
|
|
||||||
constructor(opts?: RemoteAppOptions) {
|
|
||||||
this.mainApp = opts?.app;
|
|
||||||
this.assistantConfig = opts?.assistantConfig;
|
|
||||||
const share = this.assistantConfig?.getConfig()?.share;
|
|
||||||
this.emitter = opts?.emitter || new EventEmitter();
|
|
||||||
if (share) {
|
|
||||||
const { url, name, enabled } = share;
|
|
||||||
this.url = url;
|
|
||||||
this.name = name;
|
|
||||||
this.enabled = enabled ?? false;
|
|
||||||
if (this.enabled) {
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async isConnect(): Promise<boolean> {
|
|
||||||
const that = this;
|
|
||||||
if (this.isConnected) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!this.enabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
resolve(false);
|
|
||||||
that.emitter.off('open', listenOnce);
|
|
||||||
}, 5000);
|
|
||||||
const listenOnce = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
that.isConnected = true;
|
|
||||||
resolve(true);
|
|
||||||
};
|
|
||||||
that.emitter.once('open', listenOnce);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
getWsURL(url: string) {
|
|
||||||
const { protocol } = new URL(url);
|
|
||||||
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsURL = url.toString().replace(protocol, wsProtocol);
|
|
||||||
return wsURL;
|
|
||||||
}
|
|
||||||
async init() {
|
|
||||||
if (!this.url) {
|
|
||||||
throw new Error('No url provided for remote app');
|
|
||||||
}
|
|
||||||
if (!this.name) {
|
|
||||||
throw new Error('No name provided for remote app');
|
|
||||||
}
|
|
||||||
console.log('Connecting to remote app:', this.name, this.url, this.getWsURL(this.url));
|
|
||||||
const ws = new WebSocket(this.getWsURL(this.url), {
|
|
||||||
rejectUnauthorized: true,
|
|
||||||
});
|
|
||||||
const that = this;
|
|
||||||
ws.on('open', that.onOpen.bind(that));
|
|
||||||
ws.on('close', that.onClose.bind(that));
|
|
||||||
ws.on('message', that.onMessage.bind(that));
|
|
||||||
ws.on('error', that.onError.bind(that));
|
|
||||||
this.ws = ws;
|
|
||||||
}
|
|
||||||
onOpen() {
|
|
||||||
this.emitter.emit('open', this.name);
|
|
||||||
}
|
|
||||||
onClose() {
|
|
||||||
this.emitter.emit('close', this.name);
|
|
||||||
}
|
|
||||||
onMessage(data: any) {
|
|
||||||
this.emitter.emit('message', data);
|
|
||||||
}
|
|
||||||
onError(error: any) {
|
|
||||||
console.error('Error in remote app:', this.name, error);
|
|
||||||
this.emitter.emit('error', error);
|
|
||||||
}
|
|
||||||
on(event: 'open' | 'close' | 'message' | 'error', listener: (data: any) => void) {
|
|
||||||
this.emitter.on(event, listener);
|
|
||||||
return () => {
|
|
||||||
this.emitter.off(event, listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
sendData(data: any) {}
|
|
||||||
json(data: any) {
|
|
||||||
this.ws.send(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
listenProxy() {
|
|
||||||
const remoteApp = this;
|
|
||||||
const app = this.mainApp;
|
|
||||||
const listenFn = async (event: any) => {
|
|
||||||
const data = event.toString();
|
|
||||||
logger.debug('Received message:', data);
|
|
||||||
const body = JSON.parse(data);
|
|
||||||
const message = body.data || {};
|
|
||||||
if (body?.type !== 'proxy') return;
|
|
||||||
if (!body.id) {
|
|
||||||
remoteApp.json({
|
|
||||||
id: body.id,
|
|
||||||
data: {
|
|
||||||
code: 400,
|
|
||||||
message: 'id is required',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await app.call(message);
|
|
||||||
remoteApp.json({
|
|
||||||
id: body.id,
|
|
||||||
data: {
|
|
||||||
code: res.code,
|
|
||||||
data: res.body,
|
|
||||||
message: res.message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
remoteApp.emitter.on('message', listenFn);
|
|
||||||
return () => {
|
|
||||||
remoteApp.emitter.off('message', listenFn);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
assistant/src/module/file-hash.ts
Normal file
16
assistant/src/module/file-hash.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
export const getHash = (file: string) => {
|
||||||
|
if (!fs.existsSync(file)) return '';
|
||||||
|
const buffer = fs.readFileSync(file); // 不指定编码,返回 Buffer
|
||||||
|
return crypto.createHash('md5').update(buffer).digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBufferHash = (buffer: Buffer) => {
|
||||||
|
return crypto.createHash('md5').update(buffer).digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStringHash = (str: string) => {
|
||||||
|
return crypto.createHash('md5').update(str).digest('hex');
|
||||||
|
}
|
||||||
74
assistant/src/module/get-bun-path.ts
Normal file
74
assistant/src/module/get-bun-path.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
export const getBunPath = (): string => {
|
||||||
|
// 在不同平台上获取 bun 可执行文件的路径
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
const bunExecutableName = isWindows ? 'bun.exe' : 'bun';
|
||||||
|
|
||||||
|
// 尝试从环境变量中获取 BUN_PATH
|
||||||
|
if (process.env.BUN_PATH) {
|
||||||
|
return process.env.BUN_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 Windows 上,尝试查找通过 npm/nvm 安装的 bun
|
||||||
|
if (isWindows) {
|
||||||
|
try {
|
||||||
|
// 获取全局 node_modules 路径
|
||||||
|
const globalNodeModules = execSync('npm root -g', { encoding: 'utf-8' }).trim();
|
||||||
|
const bunExePath = path.join(globalNodeModules, 'bun', 'bin', 'bun.exe');
|
||||||
|
|
||||||
|
if (fs.existsSync(bunExePath)) {
|
||||||
|
return bunExePath;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略错误,继续尝试其他路径
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从 which/where 命令获取 bun 路径
|
||||||
|
try {
|
||||||
|
const bunPath = execSync('where bun', { encoding: 'utf-8' }).trim().split('\n')[0];
|
||||||
|
if (bunPath && bunPath.endsWith('.exe')) {
|
||||||
|
return bunPath;
|
||||||
|
}
|
||||||
|
// 如果 where bun 返回的是 shell 脚本,查找对应的 .exe
|
||||||
|
if (bunPath) {
|
||||||
|
const bunDir = path.dirname(bunPath);
|
||||||
|
const bunExePath = path.join(bunDir, 'node_modules', 'bun', 'bin', 'bun.exe');
|
||||||
|
if (fs.existsSync(bunExePath)) {
|
||||||
|
return bunExePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unix-like 系统
|
||||||
|
try {
|
||||||
|
const bunPath = execSync('which bun', { encoding: 'utf-8' }).trim();
|
||||||
|
if (bunPath && fs.existsSync(bunPath)) {
|
||||||
|
return bunPath;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常见的 bun 安装路径
|
||||||
|
const commonPaths = [
|
||||||
|
'/usr/local/bin/bun',
|
||||||
|
'/usr/bin/bun',
|
||||||
|
'C:\\Program Files\\Bun\\bun.exe',
|
||||||
|
'C:\\Bun\\bun.exe',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of commonPaths) {
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果找不到,返回默认的 bun 名称,假设在 PATH 中
|
||||||
|
return bunExecutableName;
|
||||||
|
}
|
||||||
22
assistant/src/module/get-header-token.ts
Normal file
22
assistant/src/module/get-header-token.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import http from 'http';
|
||||||
|
import * as cookie from '@kevisual/router/src/server/cookie.ts';
|
||||||
|
|
||||||
|
export const getTokenFromRequest = (req: http.IncomingMessage) => {
|
||||||
|
let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || '';
|
||||||
|
const url = new URL(req.url || '', 'http://localhost');
|
||||||
|
if (!token) {
|
||||||
|
token = url.searchParams.get('token') || '';
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
const parsedCookies = cookie.parse(req.headers.cookie || '');
|
||||||
|
token = parsedCookies.token || '';
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
token = token.replace('Bearer ', '');
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTokenFromContext = (ctx: any) => {
|
||||||
|
return ctx.query.token;
|
||||||
|
}
|
||||||
39
assistant/src/module/http-token.ts
Normal file
39
assistant/src/module/http-token.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useKey } from '@kevisual/use-config';
|
||||||
|
import http from 'node:http';
|
||||||
|
export const error = (msg: string, code = 500) => {
|
||||||
|
return JSON.stringify({ code, message: msg });
|
||||||
|
};
|
||||||
|
const cookie = {
|
||||||
|
parse: (cookieStr: string) => {
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
const cookiePairs = cookieStr.split(';');
|
||||||
|
for (const pair of cookiePairs) {
|
||||||
|
const [key, value] = pair.split('=').map((v) => v.trim());
|
||||||
|
if (key && value) {
|
||||||
|
cookies[key] = decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const getToken = async (req: http.IncomingMessage) => {
|
||||||
|
let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || '';
|
||||||
|
const url = new URL(req.url || '', 'http://localhost');
|
||||||
|
if (!token) {
|
||||||
|
token = url.searchParams.get('token') || '';
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
const parsedCookies = cookie.parse(req.headers.cookie || '');
|
||||||
|
token = parsedCookies.token || '';
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
token = token.replace('Bearer ', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEnvToken = () => {
|
||||||
|
const envTokne = useKey('KEVISUAL_TOKEN') || '';
|
||||||
|
return envTokne;
|
||||||
|
}
|
||||||
17
assistant/src/module/ip/get-ip.ts
Normal file
17
assistant/src/module/ip/get-ip.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
export const getIpv4 = (): string => {
|
||||||
|
const interfaces = os.networkInterfaces();
|
||||||
|
for (const name of Object.keys(interfaces)) {
|
||||||
|
const iface = interfaces[name];
|
||||||
|
if (iface) {
|
||||||
|
for (const alias of iface) {
|
||||||
|
if (alias.family === 'IPv4' && !alias.internal) {
|
||||||
|
return alias.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('本机IPv4地址:', getIpv4());
|
||||||
177
assistant/src/module/light-code/index.ts
Normal file
177
assistant/src/module/light-code/index.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { App, QueryRouterServer } from '@kevisual/router';
|
||||||
|
import { AssistantInit } from '../../services/init/index.ts';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs, { write } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import glob from 'fast-glob';
|
||||||
|
import { runCode } from './run.ts';
|
||||||
|
const codeDemoId = '0e700dc8-90dd-41b7-91dd-336ea51de3d2'
|
||||||
|
import { filter } from "@kevisual/js-filter";
|
||||||
|
import { getHash, getStringHash } from '../file-hash.ts';
|
||||||
|
import { AssistantConfig } from '@/lib.ts';
|
||||||
|
|
||||||
|
const codeDemo = `// 这是一个示例代码文件
|
||||||
|
import {App} from '@kevisual/router';
|
||||||
|
|
||||||
|
const app = new App();
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'hello',
|
||||||
|
description: 'LightCode 示例路由',
|
||||||
|
metadata: {
|
||||||
|
tags: ['light-code', 'example'],
|
||||||
|
},
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
console.log('tokenUser:', ctx.query?.tokenUser);
|
||||||
|
ctx.body = 'Hello from LightCode!';
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
app.wait();
|
||||||
|
`;
|
||||||
|
const writeCodeDemo = async (appDir: string) => {
|
||||||
|
const lightcodeDir = path.join(appDir, 'light-code', 'code');
|
||||||
|
const demoPath = path.join(lightcodeDir, `${codeDemoId}.ts`);
|
||||||
|
fs.writeFileSync(demoPath, codeDemo, 'utf-8');
|
||||||
|
}
|
||||||
|
// writeCodeDemo(path.join(os.homedir(), 'kevisual', 'assistant-app', 'apps'));
|
||||||
|
|
||||||
|
type opts = {
|
||||||
|
router: QueryRouterServer | App
|
||||||
|
config: AssistantConfig | AssistantInit
|
||||||
|
sync?: boolean
|
||||||
|
}
|
||||||
|
type LightCodeFile = {
|
||||||
|
id?: string, code?: string, hash?: string, filepath: string
|
||||||
|
}
|
||||||
|
export const initLightCode = async (opts: opts) => {
|
||||||
|
// 注册 light-code 路由
|
||||||
|
console.log('初始化 light-code 路由');
|
||||||
|
const config = opts.config as AssistantInit;
|
||||||
|
const app = opts.router;
|
||||||
|
const token = config.getConfig()?.token || '';
|
||||||
|
const query = config.query;
|
||||||
|
const sync = opts.sync ?? true;
|
||||||
|
if (!config || !app) {
|
||||||
|
console.error('initLightCode 缺少必要参数, config 或 app');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const appDir = config.configPath.appsDir;
|
||||||
|
const lightcodeDir = path.join(appDir, 'light-code', 'code');
|
||||||
|
if (!fs.existsSync(lightcodeDir)) {
|
||||||
|
fs.mkdirSync(lightcodeDir, { recursive: true });
|
||||||
|
}
|
||||||
|
let diffList: LightCodeFile[] = [];
|
||||||
|
|
||||||
|
const codeFiles = glob.sync(['**/*.ts', '**/*.js'], {
|
||||||
|
cwd: lightcodeDir,
|
||||||
|
onlyFiles: true,
|
||||||
|
}).map(file => {
|
||||||
|
return {
|
||||||
|
filepath: path.join(lightcodeDir, file),
|
||||||
|
// hash: getHash(path.join(lightcodeDir, file))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sync) {
|
||||||
|
const queryRes = await query.post({
|
||||||
|
path: 'light-code',
|
||||||
|
key: 'list',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
if (queryRes.code === 200) {
|
||||||
|
const lightQueryList = queryRes.data?.list || [];
|
||||||
|
for (const item of lightQueryList) {
|
||||||
|
const codeHash = getStringHash(item.code || '');
|
||||||
|
diffList.push({ id: item.id!, code: item.code || '', hash: codeHash, filepath: path.join(lightcodeDir, `${item.id}.ts`) });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const codeFileSet = new Set(codeFiles.map(f => f.filepath));
|
||||||
|
// 需要新增的文件 (在 diffList 中但不在 codeFiles 中)
|
||||||
|
const toAdd = diffList.filter(d => !codeFileSet.has(d.filepath));
|
||||||
|
// 需要删除的文件 (在 codeFiles 中但不在 diffList 中)
|
||||||
|
const toDelete = codeFiles.filter(f => !diffList.some(d => d.filepath === f.filepath));
|
||||||
|
// 需要更新的文件 (两边都有但 hash 不同)
|
||||||
|
const toUpdate = diffList.filter(d => codeFileSet.has(d.filepath) && d.hash !== getHash(d.filepath));
|
||||||
|
const unchanged = diffList.filter(d => codeFileSet.has(d.filepath) && d.hash === getHash(d.filepath));
|
||||||
|
|
||||||
|
// 执行新增
|
||||||
|
for (const item of toAdd) {
|
||||||
|
fs.writeFileSync(item.filepath, item.code, 'utf-8');
|
||||||
|
// console.log(`新增 light-code 文件: ${item.filepath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行删除
|
||||||
|
for (const filepath of toDelete) {
|
||||||
|
fs.unlinkSync(filepath.filepath);
|
||||||
|
// console.log(`删除 light-code 文件: ${filepath.filepath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行更新
|
||||||
|
for (const item of toUpdate) {
|
||||||
|
fs.writeFileSync(item.filepath, item.code, 'utf-8');
|
||||||
|
// console.log(`更新 light-code 文件: ${item.filepath}`);
|
||||||
|
}
|
||||||
|
// 记录未更新的文件
|
||||||
|
// const lightCodeList = [...toAdd, ...unchanged].map(d => ({
|
||||||
|
// filepath: d.filepath,
|
||||||
|
// hash: d.hash
|
||||||
|
// }));
|
||||||
|
} else {
|
||||||
|
console.error('light-code 同步失败', queryRes.message);
|
||||||
|
diffList = codeFiles;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
diffList = codeFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
for (const file of diffList) {
|
||||||
|
const tsPath = file.filepath;
|
||||||
|
const runRes = await runCode(tsPath, { path: 'router', key: 'list' }, { timeout: 10000 });
|
||||||
|
if (runRes.success) {
|
||||||
|
const res = runRes.data;
|
||||||
|
if (res.code === 200) {
|
||||||
|
const list = res.data?.list || [];
|
||||||
|
for (const routerItem of list) {
|
||||||
|
if (routerItem.path?.includes('auth') || routerItem.path?.includes('router') || routerItem.path?.includes('call')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// console.log(`注册 light-code 路由: [${routerItem.path}] ${routerItem.id} 来自文件: ${file.filepath}`);
|
||||||
|
const metadata = routerItem.metadata || {};
|
||||||
|
if (metadata.tags && Array.isArray(metadata.tags)) {
|
||||||
|
metadata.tags.push('light-code');
|
||||||
|
} else {
|
||||||
|
metadata.tags = ['light-code'];
|
||||||
|
}
|
||||||
|
app.route({
|
||||||
|
id: routerItem.id,
|
||||||
|
path: `${routerItem.id}__${routerItem.path}`,
|
||||||
|
key: routerItem.key,
|
||||||
|
description: routerItem.description || '',
|
||||||
|
metadata,
|
||||||
|
middleware: ['auth'],
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state?.tokenUser || {};
|
||||||
|
const query = { ...ctx.query, tokenUser }
|
||||||
|
const runRes2 = await runCode(tsPath, query, { timeout: 30000 });
|
||||||
|
if (runRes2.success) {
|
||||||
|
const res2 = runRes2.data;
|
||||||
|
if (res2.code === 200) {
|
||||||
|
ctx.body = res2.data;
|
||||||
|
} else {
|
||||||
|
ctx.throw(res2.code, res2.message || 'Lightcode 路由执行失败');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.throw(runRes2.error || 'Lightcode 路由执行失败');
|
||||||
|
}
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('light-code 路由执行失败', runRes.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`light-code 路由注册成功`, `注册${diffList.length}个路由`);
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ type RunCode = {
|
|||||||
};
|
};
|
||||||
error?: any
|
error?: any
|
||||||
timestamp?: string
|
timestamp?: string
|
||||||
[key: string]: any
|
output?: string
|
||||||
}
|
}
|
||||||
export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?: RunCodeOptions): Promise<RunCode> => {
|
export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?: RunCodeOptions): Promise<RunCode> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -43,7 +43,7 @@ export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?:
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let output = ''
|
||||||
const timeoutMs = opts?.timeout || 30000; // 默认30秒超时
|
const timeoutMs = opts?.timeout || 30000; // 默认30秒超时
|
||||||
|
|
||||||
let child
|
let child
|
||||||
@@ -70,6 +70,7 @@ export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?:
|
|||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = true
|
resolved = true
|
||||||
cleanup()
|
cleanup()
|
||||||
|
result.output = output
|
||||||
resolve(result)
|
resolve(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,7 +78,11 @@ export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?:
|
|||||||
try {
|
try {
|
||||||
// 使用 Bun 的 fork 模式启动子进程
|
// 使用 Bun 的 fork 模式启动子进程
|
||||||
child = fork(tsPath, [], {
|
child = fork(tsPath, [], {
|
||||||
silent: true // 启用 stdio 重定向
|
silent: true, // 启用 stdio 重定向
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BUN_CHILD_PROCESS: 'true' // 标记为子进程
|
||||||
|
}
|
||||||
})
|
})
|
||||||
// 监听来自子进程的消息
|
// 监听来自子进程的消息
|
||||||
child.on('message', (msg: RunCode) => {
|
child.on('message', (msg: RunCode) => {
|
||||||
@@ -94,6 +99,11 @@ export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?:
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (child.stdout) {
|
||||||
|
child.stdout.on('data', (data) => {
|
||||||
|
output += data.toString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 监听子进程退出事件
|
// 监听子进程退出事件
|
||||||
child.on('exit', (code, signal) => {
|
child.on('exit', (code, signal) => {
|
||||||
|
|||||||
7
assistant/src/module/local-apps/.gitignore
vendored
Normal file
7
assistant/src/module/local-apps/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
/apps
|
||||||
|
dist
|
||||||
|
/logs
|
||||||
|
|
||||||
|
/app.config.json5
|
||||||
|
/app.config.json
|
||||||
3
assistant/src/module/local-apps/.npmrc
Normal file
3
assistant/src/module/local-apps/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
|
||||||
|
@abearxiong:registry=https://npm.pkg.github.com
|
||||||
|
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
||||||
87
assistant/src/module/local-apps/apps.config.json
Normal file
87
assistant/src/module/local-apps/apps.config.json
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"key": "demo",
|
||||||
|
"status": "inactive",
|
||||||
|
"type": "system-app",
|
||||||
|
"description": "",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"runtime": [],
|
||||||
|
"entry": "demo-dist/app.mjs",
|
||||||
|
"path": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps/apps/demo",
|
||||||
|
"origin": {
|
||||||
|
"key": "demo",
|
||||||
|
"entry": "demo-dist/app.mjs",
|
||||||
|
"type": "system-app"
|
||||||
|
},
|
||||||
|
"timestamp": 1764867997389
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "test-ts",
|
||||||
|
"status": "inactive",
|
||||||
|
"type": "system-app",
|
||||||
|
"description": "",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"runtime": [],
|
||||||
|
"entry": "main.ts",
|
||||||
|
"path": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps/apps/test-ts",
|
||||||
|
"origin": {
|
||||||
|
"key": "test-ts",
|
||||||
|
"entry": "main.ts",
|
||||||
|
"type": "system-app"
|
||||||
|
},
|
||||||
|
"timestamp": 1764867997389
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "test",
|
||||||
|
"status": "inactive",
|
||||||
|
"type": "pm2-system-app",
|
||||||
|
"description": "",
|
||||||
|
"version": "0.0.3",
|
||||||
|
"runtime": [],
|
||||||
|
"entry": "dist/ws.js",
|
||||||
|
"path": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps/apps/test",
|
||||||
|
"origin": {
|
||||||
|
"key": "test",
|
||||||
|
"entry": "dist/ws.js",
|
||||||
|
"type": "pm2-system-app"
|
||||||
|
},
|
||||||
|
"pm2Options": {
|
||||||
|
"cwd": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps"
|
||||||
|
},
|
||||||
|
"timestamp": 1764867997389
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "root/test-log",
|
||||||
|
"status": "inactive",
|
||||||
|
"type": "script-app",
|
||||||
|
"description": "",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"runtime": [],
|
||||||
|
"entry": "main.ts",
|
||||||
|
"path": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps/apps/root/test-log",
|
||||||
|
"origin": {
|
||||||
|
"key": "root/test-log",
|
||||||
|
"entry": "main.ts",
|
||||||
|
"type": "script-app"
|
||||||
|
},
|
||||||
|
"timestamp": 1764867997389
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "root/test-no",
|
||||||
|
"status": "inactive",
|
||||||
|
"type": "script-app",
|
||||||
|
"description": "",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"runtime": [],
|
||||||
|
"entry": "main.ts",
|
||||||
|
"path": "/home/ubuntu/kevisual/cli/assistant/src/module/local-apps/apps/root/test-no",
|
||||||
|
"origin": {
|
||||||
|
"entry": "main.ts",
|
||||||
|
"type": "script-app",
|
||||||
|
"key": "root/test-no"
|
||||||
|
},
|
||||||
|
"timestamp": 1764868133979
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
45
assistant/src/module/local-apps/bun.config.ts
Normal file
45
assistant/src/module/local-apps/bun.config.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { build } from 'bun';
|
||||||
|
|
||||||
|
const external = ['pm2', '@kevisual/router', '@kevisual/use-config'];
|
||||||
|
// https://bun.sh/docs/bundler
|
||||||
|
await Bun.build({
|
||||||
|
target: 'node',
|
||||||
|
format: 'esm',
|
||||||
|
entrypoints: ['./src/app.ts'],
|
||||||
|
outdir: './dist',
|
||||||
|
naming: {
|
||||||
|
entry: 'app.js',
|
||||||
|
},
|
||||||
|
external,
|
||||||
|
minify: false,
|
||||||
|
sourcemap: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const dts = 'dts -i src/app.ts -o app.d.ts';
|
||||||
|
await Bun.spawn({
|
||||||
|
cmd: dts.split(' '),
|
||||||
|
stdout: 'inherit',
|
||||||
|
stderr: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
await Bun.build({
|
||||||
|
target: 'node',
|
||||||
|
format: 'esm',
|
||||||
|
entrypoints: ['./src/manager.ts'],
|
||||||
|
outdir: './dist',
|
||||||
|
naming: {
|
||||||
|
entry: 'manager.js',
|
||||||
|
},
|
||||||
|
external,
|
||||||
|
minify: false,
|
||||||
|
sourcemap: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const dtsManager = 'dts -i src/manager.ts -o manager.d.ts';
|
||||||
|
await Bun.spawn({
|
||||||
|
cmd: dtsManager.split(' '),
|
||||||
|
stdout: 'inherit',
|
||||||
|
stderr: 'inherit',
|
||||||
|
});
|
||||||
|
console.log('Build completed.');
|
||||||
18
assistant/src/module/local-apps/demos/demo/demo-dist/app.mjs
Normal file
18
assistant/src/module/local-apps/demos/demo/demo-dist/app.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { App } from '@kevisual/router';
|
||||||
|
const app = new App();
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'demo',
|
||||||
|
key: 'get'
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
ctx.body = 'Hello, World!';
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
const loadApp = (mainApp, appInfo) => {
|
||||||
|
mainApp.importApp(app);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { app, loadApp };
|
||||||
19
assistant/src/module/local-apps/demos/demo/package.json
Normal file
19
assistant/src/module/local-apps/demos/demo/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "demo",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"main": "demo-dist/app.mjs",
|
||||||
|
"app": {
|
||||||
|
"entry": "demo-dist/app.mjs",
|
||||||
|
"type": "system-app"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"ai"
|
||||||
|
],
|
||||||
|
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Logger } from '@kevisual/logger';
|
||||||
|
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
|
logger.info('This is a test log message from the main.ts file of the test app.');
|
||||||
|
logger.warn('This is a test warning message from the main.ts file of the test app.');
|
||||||
|
logger.error('This is a test error message from the main.ts file of the test app.');
|
||||||
|
logger.debug('This is a test debug message from the main.ts file of the test app.');
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "test-ts",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"basename": "/root/test-log",
|
||||||
|
"app": {
|
||||||
|
"entry": "main.ts",
|
||||||
|
"type": "pm2-system-app"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"main.ts"
|
||||||
|
],
|
||||||
|
"keywords": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Logger } from '@kevisual/logger';
|
||||||
|
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
|
logger.info('This is a test log message from the main.ts file of the test app.');
|
||||||
|
logger.warn('This is a test warning message from the main.ts file of the test app.');
|
||||||
|
logger.error('This is a test error message from the main.ts file of the test app.');
|
||||||
|
logger.debug('This is a test debug message from the main.ts file of the test app.');
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "test-no",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"basename": "/root/test-no",
|
||||||
|
"app": {
|
||||||
|
"entry": "main.ts",
|
||||||
|
"type": "script-app"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
3
assistant/src/module/local-apps/mod.ts
Normal file
3
assistant/src/module/local-apps/mod.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { Pm2Manager, Pm2Opts } from "./src/modules/pm2.ts";
|
||||||
|
|
||||||
|
export { Manager } from "./src/modules/manager.ts";
|
||||||
48
assistant/src/module/local-apps/package.json
Normal file
48
assistant/src/module/local-apps/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "@kevisual/local-app-manager",
|
||||||
|
"version": "0.1.32",
|
||||||
|
"description": "",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"entry": "app.js",
|
||||||
|
"type": "system-app"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run src/app.ts",
|
||||||
|
"build": "rimraf dist && bun run bun.config.ts"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"devDependencies": {
|
||||||
|
"@kevisual/router": "0.0.33",
|
||||||
|
"@kevisual/types": "^0.0.10",
|
||||||
|
"@kevisual/use-config": "^1.0.21",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"fast-glob": "^3.3.3",
|
||||||
|
"lodash-es": "^4.17.21"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/app.mjs",
|
||||||
|
"types": "./dist/app.d.ts",
|
||||||
|
"require": "./dist/app.mjs"
|
||||||
|
},
|
||||||
|
"./manager": {
|
||||||
|
"import": "./dist/manager.mjs",
|
||||||
|
"types": "./dist/manager.d.ts",
|
||||||
|
"require": "./dist/manager.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pm2": "^6.0.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
191
assistant/src/module/local-apps/readme.md
Normal file
191
assistant/src/module/local-apps/readme.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# 本地应用加载
|
||||||
|
|
||||||
|
主要目的,加载微应用模块。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export enum AppType {
|
||||||
|
/**
|
||||||
|
* run in (import way)
|
||||||
|
*/
|
||||||
|
SystemApp = 'system-app',
|
||||||
|
/**
|
||||||
|
* fork 执行
|
||||||
|
*/
|
||||||
|
MicroApp = 'micro-app',
|
||||||
|
GatewayApp = 'gateway-app',
|
||||||
|
/**
|
||||||
|
* pm2 启动
|
||||||
|
*/
|
||||||
|
Pm2SystemApp = 'pm2-system-app'
|
||||||
|
}
|
||||||
|
export type Runtime = 'client' | 'server';
|
||||||
|
export type AppInfo = {
|
||||||
|
key: string;
|
||||||
|
status?: 'inactive' | 'running' | 'stop' | 'error'; // 运行状态
|
||||||
|
version?: string; // 版本
|
||||||
|
type?: AppType; // 默认类型
|
||||||
|
description?: string; // 描述
|
||||||
|
runtime?: Runtime[]; // 运行时
|
||||||
|
timestamp?: number; // 时间戳, 每次更新更新时间戳
|
||||||
|
process?: any; // 进程
|
||||||
|
|
||||||
|
origin?: Record<string, any>; // 原始数据
|
||||||
|
entry?: string; // 入口文件
|
||||||
|
path?: string; // 文件路径
|
||||||
|
env?: Record<string, any>; // 环境变量
|
||||||
|
engine?: string; // runtime, python node deno bun etc
|
||||||
|
/**
|
||||||
|
* pm2 选项, 仅仅当是AppType.Pm2SystemApp的时候生效
|
||||||
|
* pm2 选项可以参考 https://pm2.keymetrics.io/docs/usage/application-declaration/
|
||||||
|
*/
|
||||||
|
pm2Options?: StartOptions; // pm2 选项
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
pm2 pm2Options
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface StartOptions {
|
||||||
|
/**
|
||||||
|
* Enable or disable auto start after process added (default: true).
|
||||||
|
*/
|
||||||
|
autostart?: boolean;
|
||||||
|
/**
|
||||||
|
* Enable or disable auto restart after process failure (default: true).
|
||||||
|
*/
|
||||||
|
autorestart?: boolean;
|
||||||
|
/**
|
||||||
|
* List of exit codes that should allow the process to stop (skip autorestart).
|
||||||
|
*/
|
||||||
|
stop_exit_codes?: number[];
|
||||||
|
/**
|
||||||
|
* An arbitrary name that can be used to interact with (e.g. restart) the process
|
||||||
|
* later in other commands. Defaults to the script name without its extension
|
||||||
|
* (eg “testScript” for “testScript.js”)
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* The path of the script to run
|
||||||
|
*/
|
||||||
|
script?: string;
|
||||||
|
/**
|
||||||
|
* A string or array of strings composed of arguments to pass to the script.
|
||||||
|
*/
|
||||||
|
args?: string | string[];
|
||||||
|
/**
|
||||||
|
* A string or array of strings composed of arguments to call the interpreter process with.
|
||||||
|
* Eg “–harmony” or [”–harmony”,”–debug”]. Only applies if interpreter is something other
|
||||||
|
* than “none” (its “node” by default).
|
||||||
|
*/
|
||||||
|
interpreter_args?: string | string[];
|
||||||
|
/**
|
||||||
|
* The working directory to start the process with.
|
||||||
|
*/
|
||||||
|
cwd?: string;
|
||||||
|
/**
|
||||||
|
* (Default: “~/.pm2/logs/app_name-out.log”) The path to a file to append stdout output to.
|
||||||
|
* Can be the same file as error.
|
||||||
|
*/
|
||||||
|
output?: string;
|
||||||
|
/**
|
||||||
|
* (Default: “~/.pm2/logs/app_name-error.err”) The path to a file to append stderr output to. Can be the same file as output.
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
/**
|
||||||
|
* The display format for log timestamps (eg “YYYY-MM-DD HH:mm Z”). The format is a moment display format.
|
||||||
|
*/
|
||||||
|
log_date_format?: string;
|
||||||
|
/**
|
||||||
|
* Default: “~/.pm2/logs/~/.pm2/pids/app_name-id.pid”)
|
||||||
|
* The path to a file to write the pid of the started process. The file will be overwritten.
|
||||||
|
* Note that the file is not used in any way by pm2 and so the user is free to manipulate or
|
||||||
|
* remove that file at any time. The file will be deleted when the process is stopped or the daemon killed.
|
||||||
|
*/
|
||||||
|
pid?: string;
|
||||||
|
/**
|
||||||
|
* The minimum uptime of the script before it’s considered successfully started.
|
||||||
|
*/
|
||||||
|
min_uptime?: number;
|
||||||
|
/**
|
||||||
|
* The maximum number of times in a row a script will be restarted if it exits in less than min_uptime.
|
||||||
|
*/
|
||||||
|
max_restarts?: number;
|
||||||
|
/**
|
||||||
|
* If sets and script’s memory usage goes about the configured number, pm2 restarts the script.
|
||||||
|
* Uses human-friendly suffixes: ‘K’ for kilobytes, ‘M’ for megabytes, ‘G’ for gigabytes’, etc. Eg “150M”.
|
||||||
|
*/
|
||||||
|
max_memory_restart?: number | string;
|
||||||
|
/**
|
||||||
|
* Arguments to pass to the interpreter
|
||||||
|
*/
|
||||||
|
node_args?: string | string[];
|
||||||
|
/**
|
||||||
|
* Prefix logs with time
|
||||||
|
*/
|
||||||
|
time?: boolean;
|
||||||
|
/**
|
||||||
|
* This will make PM2 listen for that event. In your application you will need to add process.send('ready');
|
||||||
|
* when you want your application to be considered as ready.
|
||||||
|
*/
|
||||||
|
wait_ready?: boolean;
|
||||||
|
/**
|
||||||
|
* (Default: 1600)
|
||||||
|
* The number of milliseconds to wait after a stop or restart command issues a SIGINT signal to kill the
|
||||||
|
* script forceably with a SIGKILL signal.
|
||||||
|
*/
|
||||||
|
kill_timeout?: number;
|
||||||
|
/**
|
||||||
|
* (Default: 0) Number of millseconds to wait before restarting a script that has exited.
|
||||||
|
*/
|
||||||
|
restart_delay?: number;
|
||||||
|
/**
|
||||||
|
* (Default: “node”) The interpreter for your script (eg “python”, “ruby”, “bash”, etc).
|
||||||
|
* The value “none” will execute the ‘script’ as a binary executable.
|
||||||
|
*/
|
||||||
|
interpreter?: string;
|
||||||
|
/**
|
||||||
|
* (Default: ‘fork’) If sets to ‘cluster’, will enable clustering
|
||||||
|
* (running multiple instances of the script).
|
||||||
|
*/
|
||||||
|
exec_mode?: string;
|
||||||
|
/**
|
||||||
|
* (Default: 1) How many instances of script to create. Only relevant in exec_mode ‘cluster’.
|
||||||
|
*/
|
||||||
|
instances?: number;
|
||||||
|
/**
|
||||||
|
* (Default: false) If true, merges the log files for all instances of script into one stderr log
|
||||||
|
* and one stdout log. Only applies in ‘cluster’ mode. For example, if you have 4 instances of
|
||||||
|
* ‘test.js’ started via pm2, normally you would have 4 stdout log files and 4 stderr log files,
|
||||||
|
* but with this option set to true you would only have one stdout file and one stderr file.
|
||||||
|
*/
|
||||||
|
merge_logs?: boolean;
|
||||||
|
/**
|
||||||
|
* If set to true, the application will be restarted on change of the script file.
|
||||||
|
*/
|
||||||
|
watch?: boolean | string[];
|
||||||
|
/**
|
||||||
|
* (Default: false) By default, pm2 will only start a script if that script isn’t
|
||||||
|
* already running (a script is a path to an application, not the name of an application
|
||||||
|
* already running). If force is set to true, pm2 will start a new instance of that script.
|
||||||
|
*/
|
||||||
|
force?: boolean;
|
||||||
|
ignore_watch?: string[];
|
||||||
|
cron?: any;
|
||||||
|
execute_command?: any;
|
||||||
|
write?: any;
|
||||||
|
source_map_support?: any;
|
||||||
|
disable_source_map_support?: any;
|
||||||
|
/**
|
||||||
|
* The environment variables to pass on to the process.
|
||||||
|
*/
|
||||||
|
env?: { [key: string]: string };
|
||||||
|
/**
|
||||||
|
* NameSpace for the process
|
||||||
|
* @default 'default'
|
||||||
|
* @example 'production'
|
||||||
|
* @example 'development'
|
||||||
|
* @example 'staging'
|
||||||
|
*/
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
3
assistant/src/module/local-apps/src/app.ts
Normal file
3
assistant/src/module/local-apps/src/app.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { App } from '@kevisual/router';
|
||||||
|
import { useContextKey } from '@kevisual/use-config/context';
|
||||||
|
export const app = useContextKey('app', () => new App());
|
||||||
42
assistant/src/module/local-apps/src/index.ts
Normal file
42
assistant/src/module/local-apps/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { App } from '@kevisual/router';
|
||||||
|
import { app } from './app.ts';
|
||||||
|
|
||||||
|
import { manager, loadManager } from './manager.ts';
|
||||||
|
|
||||||
|
import './routes/list.ts';
|
||||||
|
|
||||||
|
export { app, manager, loadManager };
|
||||||
|
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const DEV_SERVER = import.meta.env?.DEV_SERVER || false;
|
||||||
|
if (DEV_SERVER) {
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'auth',
|
||||||
|
key: 'admin',
|
||||||
|
id: 'auth-admin'
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
// ctx.body = 'admin';
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'test',
|
||||||
|
key: 'test'
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
ctx.body = app.router.routes.map((item) => {
|
||||||
|
return {
|
||||||
|
path: item.path,
|
||||||
|
key: item.key
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
app.listen(9787, () => {
|
||||||
|
console.log('Server is running on port 9787');
|
||||||
|
});
|
||||||
|
loadManager({ configFilename: 'b.json' });
|
||||||
|
}
|
||||||
23
assistant/src/module/local-apps/src/manager.ts
Normal file
23
assistant/src/module/local-apps/src/manager.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { app } from './app.ts';
|
||||||
|
import { LoadOptions, Manager } from './modules/manager.ts';
|
||||||
|
|
||||||
|
export const manager = new Manager({
|
||||||
|
mainApp: app
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载manager的内容
|
||||||
|
*/
|
||||||
|
export const loadManager = (opts?: LoadOptions) => {
|
||||||
|
const load = () => {
|
||||||
|
manager
|
||||||
|
.load(opts)
|
||||||
|
.then(() => {
|
||||||
|
console.log('load apps success');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('load apps error', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
setTimeout(load, 1000);
|
||||||
|
};
|
||||||
82
assistant/src/module/local-apps/src/modules/app-file.ts
Normal file
82
assistant/src/module/local-apps/src/modules/app-file.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useFileStore } from '@kevisual/use-config/file-store';
|
||||||
|
import { fileIsExist, getConfigFile } from '@kevisual/use-config/env';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { AppInfo } from './manager.ts';
|
||||||
|
|
||||||
|
export const getAppsPath = () => {
|
||||||
|
const appsPath = process.env.APPS_PATH;
|
||||||
|
if (appsPath) {
|
||||||
|
const resolvePath = path.resolve(appsPath);
|
||||||
|
if (fileIsExist(resolvePath)) {
|
||||||
|
return resolvePath;
|
||||||
|
} else {
|
||||||
|
fs.mkdirSync(resolvePath, { recursive: true });
|
||||||
|
return resolvePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return useFileStore('apps', { needExists: true });
|
||||||
|
};
|
||||||
|
export type AppInfoConfig = {
|
||||||
|
list: AppInfo[];
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 加载应用信息
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const loadAppInfo = async (appsPath: string = 'apps', filename = 'apps.config.json'): Promise<AppInfoConfig> => {
|
||||||
|
let configFile = getConfigFile({
|
||||||
|
cwd: appsPath,
|
||||||
|
fileName: filename
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configFile) {
|
||||||
|
configFile = path.join(appsPath, '..', filename);
|
||||||
|
fs.writeFileSync(configFile, JSON.stringify({ list: [] }));
|
||||||
|
return { list: [] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const config = fs.readFileSync(configFile, 'utf-8');
|
||||||
|
const v = JSON.parse(config);
|
||||||
|
if (!v.list) {
|
||||||
|
v.list = [];
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('读取配置文件失败', e.message);
|
||||||
|
return { list: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* 保存应用信息
|
||||||
|
* @param data
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const saveAppInfo = async (data: any, appsPath: string, filename = 'apps.config.json') => {
|
||||||
|
const configFile = getConfigFile({
|
||||||
|
fileName: filename,
|
||||||
|
cwd: appsPath
|
||||||
|
});
|
||||||
|
if (!configFile) {
|
||||||
|
console.error('未找到配置文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(configFile, JSON.stringify(data, null, 2));
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 删除应用信息
|
||||||
|
* @param key
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const deleteFileAppInfo = async (key: string, appsPath: string) => {
|
||||||
|
// 标准化key中的路径分隔符,统一使用系统路径分隔符
|
||||||
|
const normalizedKey = key.replace(/\//g, path.sep);
|
||||||
|
const directory = path.join(appsPath, normalizedKey);
|
||||||
|
if (!fileIsExist(directory)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.rmSync(directory, { recursive: true });
|
||||||
|
};
|
||||||
596
assistant/src/module/local-apps/src/modules/manager.ts
Normal file
596
assistant/src/module/local-apps/src/modules/manager.ts
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
import type { App } from '@kevisual/router';
|
||||||
|
import { loadAppInfo, AppInfoConfig, saveAppInfo, getAppsPath } from './app-file.ts';
|
||||||
|
import { fork } from 'node:child_process';
|
||||||
|
import { merge } from 'es-toolkit';
|
||||||
|
import { deleteFileAppInfo } from './app-file.ts';
|
||||||
|
import { fileIsExist } from '@kevisual/use-config/env';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import glob from 'fast-glob'
|
||||||
|
import { Pm2Manager, Pm2Connect, checkInstall } from './pm2.ts';
|
||||||
|
import type { StartOptions } from 'pm2';
|
||||||
|
export { Pm2Manager, Pm2Connect };
|
||||||
|
// 共享
|
||||||
|
export const existDenpend = [
|
||||||
|
'sequelize', // commonjs
|
||||||
|
'pg', // commonjs
|
||||||
|
'@kevisual/router', // 共享模块
|
||||||
|
'@kevisual/use-config', // 共享模块
|
||||||
|
'ioredis', // commonjs
|
||||||
|
'socket.io', // commonjs
|
||||||
|
'minio', // commonjs
|
||||||
|
];
|
||||||
|
export enum AppType {
|
||||||
|
/**
|
||||||
|
* run in (import way)
|
||||||
|
*/
|
||||||
|
SystemApp = 'system-app',
|
||||||
|
/**
|
||||||
|
* fork 执行
|
||||||
|
*/
|
||||||
|
MicroApp = 'micro-app',
|
||||||
|
GatewayApp = 'gateway-app',
|
||||||
|
/**
|
||||||
|
* pm2 启动
|
||||||
|
*/
|
||||||
|
Pm2SystemApp = 'pm2-system-app',
|
||||||
|
ScriptApp = 'script-app'
|
||||||
|
}
|
||||||
|
export type Runtime = 'client' | 'server';
|
||||||
|
export type AppInfo = {
|
||||||
|
key: string;
|
||||||
|
status?: 'inactive' | 'running' | 'stop' | 'error' | 'finished'; // 运行状态
|
||||||
|
version?: string; // 版本
|
||||||
|
type?: AppType; // 默认类型
|
||||||
|
description?: string; // 描述
|
||||||
|
runtime?: Runtime[]; // 运行时
|
||||||
|
timestamp?: number; // 时间戳, 每次更新更新时间戳
|
||||||
|
process?: any; // 进程
|
||||||
|
|
||||||
|
origin?: Record<string, any>; // 原始数据
|
||||||
|
entry?: string; // 入口文件
|
||||||
|
path?: string; // 文件路径
|
||||||
|
env?: Record<string, any>; // 环境变量
|
||||||
|
engine?: string; // runtime, python node deno bun etc
|
||||||
|
init?: boolean; // 是否需要初始化安装npm等依赖
|
||||||
|
/**
|
||||||
|
* pm2 选项, 仅仅当是AppType.Pm2SystemApp的时候生效
|
||||||
|
* pm2 选项可以参考 https://pm2.keymetrics.io/docs/usage/application-declaration/
|
||||||
|
*/
|
||||||
|
pm2Options?: StartOptions; // pm2 选项
|
||||||
|
};
|
||||||
|
export const onAppShowInfo = (app: AppInfo) => {
|
||||||
|
return {
|
||||||
|
key: app.key,
|
||||||
|
status: app.status,
|
||||||
|
engine: app.engine,
|
||||||
|
type: app.type,
|
||||||
|
description: app.description,
|
||||||
|
version: app.version
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export const createAppShowInfo = (app: any) => {
|
||||||
|
return {
|
||||||
|
key: app.key,
|
||||||
|
status: app.status,
|
||||||
|
type: app.type,
|
||||||
|
engine: app.engine,
|
||||||
|
description: app.description,
|
||||||
|
version: app.version
|
||||||
|
};
|
||||||
|
};
|
||||||
|
type managerOptions = {
|
||||||
|
mainApp?: App;
|
||||||
|
/**
|
||||||
|
* apps文件夹的路径
|
||||||
|
* @default process.env.APPS_PATH
|
||||||
|
* @default useFileStore('apps', { needExists: true })
|
||||||
|
* @example /path/to/apps
|
||||||
|
*/
|
||||||
|
appsPath?: string;
|
||||||
|
/**
|
||||||
|
* apps.config.json的路径
|
||||||
|
* @default appsPath/apps.config.json
|
||||||
|
* @example /path/to/apps/apps.config.json
|
||||||
|
*/
|
||||||
|
configFilename?: string;
|
||||||
|
};
|
||||||
|
export type LoadOptions = { runtime?: Runtime } & managerOptions;
|
||||||
|
export class Manager<T extends AppInfo = AppInfo> {
|
||||||
|
apps: Map<string, T>;
|
||||||
|
mainApp?: App;
|
||||||
|
appInfo: AppInfoConfig;
|
||||||
|
appsPath: string;
|
||||||
|
configFilename: string;
|
||||||
|
constructor(opts: managerOptions) {
|
||||||
|
this.apps = new Map();
|
||||||
|
this.mainApp = opts.mainApp;
|
||||||
|
this.appInfo = {} as any;
|
||||||
|
this.appsPath = opts?.appsPath || getAppsPath();
|
||||||
|
this.configFilename = opts?.configFilename || 'apps.config.json';
|
||||||
|
}
|
||||||
|
#pm2Connect?: Pm2Connect;
|
||||||
|
/**
|
||||||
|
* 检查key是否存在
|
||||||
|
* @param key
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
checkKey(key: string) {
|
||||||
|
return this.apps.has(key);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 获取app信息
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
|
async loadApp(app: T) {
|
||||||
|
const mainApp = this.mainApp;
|
||||||
|
this.apps.set(app.key, app);
|
||||||
|
return await LoadApp(app, { mainApp, pm2Connect: this.#pm2Connect });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* create new app info
|
||||||
|
* @param app
|
||||||
|
*/
|
||||||
|
async saveAppInfo(app: T, newTimeData = false) {
|
||||||
|
const list = this.appInfo.list || [];
|
||||||
|
if (newTimeData) {
|
||||||
|
app.timestamp = Date.now();
|
||||||
|
}
|
||||||
|
const { process, ...info } = app;
|
||||||
|
const has = list.findIndex((item) => item.key === app.key);
|
||||||
|
|
||||||
|
if (has >= 0) {
|
||||||
|
list[has] = info;
|
||||||
|
} else {
|
||||||
|
list.push(info);
|
||||||
|
}
|
||||||
|
this.appInfo.list = list;
|
||||||
|
await saveAppInfo(this.appInfo, this.appsPath, this.configFilename);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 加载配置
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async loadConfig(loadApps = false) {
|
||||||
|
if (loadApps) {
|
||||||
|
return await this.load();
|
||||||
|
}
|
||||||
|
const appInfos = await loadAppInfo(this.appsPath, this.configFilename);
|
||||||
|
this.appInfo = appInfos;
|
||||||
|
const list = (appInfos?.list || []) as T[];
|
||||||
|
for (const app of list) {
|
||||||
|
this.apps.set(app.key, app);
|
||||||
|
}
|
||||||
|
return this.apps;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 初始化应用的时候加载
|
||||||
|
*/
|
||||||
|
async load(opts?: LoadOptions) {
|
||||||
|
// 从apps文件夹列表当中中加载app信息
|
||||||
|
if (opts?.configFilename) {
|
||||||
|
this.configFilename = opts.configFilename;
|
||||||
|
}
|
||||||
|
if (opts?.appsPath) {
|
||||||
|
this.appsPath = opts.appsPath;
|
||||||
|
}
|
||||||
|
if (opts?.mainApp) {
|
||||||
|
this.mainApp = opts.mainApp;
|
||||||
|
}
|
||||||
|
const appInfos = await loadAppInfo(this.appsPath, this.configFilename);
|
||||||
|
this.appInfo = appInfos;
|
||||||
|
const _List = (appInfos?.list as T[]) || [];
|
||||||
|
const list = _List.filter((item) => {
|
||||||
|
if (opts?.runtime && item.runtime) {
|
||||||
|
return item.runtime?.includes(opts.runtime);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const pm2Connect = new Pm2Connect(false);
|
||||||
|
this.#pm2Connect = pm2Connect;
|
||||||
|
try {
|
||||||
|
await pm2Connect.connect();
|
||||||
|
} catch (e) {
|
||||||
|
console.log('pm2 connect error', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const app of list) {
|
||||||
|
try {
|
||||||
|
const loaded = await this.loadApp(app);
|
||||||
|
if (!loaded) {
|
||||||
|
// 加载失败,如果是running状态,设置为error
|
||||||
|
if (app.status === 'running') {
|
||||||
|
app.status = 'error';
|
||||||
|
console.log('load app error', app); // save app error info
|
||||||
|
await this.saveAppInfo(app);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// console.log('load app success', app);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`load app error====[${app.type} | ${app.key}]\n`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pm2Connect.disconnect();
|
||||||
|
this.#pm2Connect = null;
|
||||||
|
}
|
||||||
|
async add(app: T) {
|
||||||
|
if (this.checkKey(app.key)) {
|
||||||
|
console.error('key is loaded');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.saveAppInfo(app, true);
|
||||||
|
this.loadApp(app);
|
||||||
|
}
|
||||||
|
// 启动
|
||||||
|
async start(key: string) {
|
||||||
|
const app = this.apps.get(key);
|
||||||
|
if (!app) {
|
||||||
|
console.error('app not found', key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (app.status === 'running' && app.type === AppType.SystemApp) {
|
||||||
|
console.log(`app ${key} is running`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.status = 'running';
|
||||||
|
this.loadApp(app);
|
||||||
|
await this.saveAppInfo(app);
|
||||||
|
}
|
||||||
|
// 停止
|
||||||
|
async stop(key: string) {
|
||||||
|
const app = this.apps.get(key);
|
||||||
|
if (!app) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await StopApp(app, { pm2Connect: this.#pm2Connect, todo: 'stop' });
|
||||||
|
await this.saveAppInfo(app);
|
||||||
|
}
|
||||||
|
async restart(key: string) {
|
||||||
|
const app = this.apps.get(key);
|
||||||
|
if (!app) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (app.status !== 'running') {
|
||||||
|
await this.start(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (app.status === 'running' && app.type === AppType.Pm2SystemApp) {
|
||||||
|
const pm2Manager = new Pm2Manager({
|
||||||
|
appName: app.key
|
||||||
|
});
|
||||||
|
await pm2Manager.restart();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// 重新启动
|
||||||
|
await this.stop(key);
|
||||||
|
await this.start(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取app信息, 用于展示
|
||||||
|
* @param key
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
getAppShowInfo(key: string) {
|
||||||
|
const app = this.apps.get(key);
|
||||||
|
if (!app) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return onAppShowInfo(app);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取所有app信息, 用于展示
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
getAllAppShowInfo() {
|
||||||
|
const list = [];
|
||||||
|
for (const [key, value] of this.apps) {
|
||||||
|
list.push(onAppShowInfo(value));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 更新app信息, 用于展示, 加上一些功能,启动,停止程序
|
||||||
|
* @param key
|
||||||
|
* @param info
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async updateAppInfo(key: string, info: Partial<T>) {
|
||||||
|
const app = this.apps.get(key);
|
||||||
|
if (!app) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
merge(app, info);
|
||||||
|
this.loadApp(app);
|
||||||
|
await this.saveAppInfo(app);
|
||||||
|
return onAppShowInfo(app);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 删除app信息
|
||||||
|
* @param key
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async removeApp(key: string, opts?: { deleteFile?: boolean }) {
|
||||||
|
const app = this.apps.get(key);
|
||||||
|
const deleteFile = opts?.deleteFile ?? true;
|
||||||
|
if (!app) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (app.process) {
|
||||||
|
app.process.kill();
|
||||||
|
}
|
||||||
|
this.apps.delete(key);
|
||||||
|
this.appInfo.list = this.appInfo.list.filter((item) => item.key !== key);
|
||||||
|
await saveAppInfo(this.appInfo, this.appsPath, this.configFilename);
|
||||||
|
try {
|
||||||
|
if (app.type === AppType.Pm2SystemApp) {
|
||||||
|
const pm2Manager = new Pm2Manager({
|
||||||
|
appName: app.key
|
||||||
|
});
|
||||||
|
await pm2Manager.deleteProcess();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('delete pm2 process error', e);
|
||||||
|
}
|
||||||
|
if (!deleteFile) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
deleteFileAppInfo(key, this.appsPath);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('delete file app error', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Detect micro app,检测apps的没有加载进来的app模块
|
||||||
|
* @param manager
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async detectApp(opts: { autoClear?: boolean } = {}) {
|
||||||
|
const manager = this;
|
||||||
|
const { autoClear = true } = opts;
|
||||||
|
const list = manager.getAllAppShowInfo();
|
||||||
|
const appPathKeys = await getAppPathKeys(this.appsPath);
|
||||||
|
let hasDeletedList: any[] = [];
|
||||||
|
console.log('App path keys', appPathKeys);
|
||||||
|
if (autoClear) {
|
||||||
|
hasDeletedList = list.filter((item) => !appPathKeys.find((key) => item.key === key));
|
||||||
|
console.log('Has deleted', hasDeletedList);
|
||||||
|
for (const item of hasDeletedList) {
|
||||||
|
try {
|
||||||
|
await manager.removeApp(item.key);
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const notIn = appPathKeys.filter((key) => !list.find((item) => item.key === key));
|
||||||
|
console.log('Not in', notIn);
|
||||||
|
const loadInfo = [];
|
||||||
|
if (notIn.length <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const key of notIn) {
|
||||||
|
try {
|
||||||
|
const { showAppInfo } = await installAppFromKey(key, this.appsPath);
|
||||||
|
await manager.add(showAppInfo as any);
|
||||||
|
loadInfo.push(`Load ${key} success`);
|
||||||
|
} catch (e) {
|
||||||
|
loadInfo.push(`Load ${key} error:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { list: loadInfo, newApps: notIn, hasDeletedList };
|
||||||
|
}
|
||||||
|
async reload(key: string) {
|
||||||
|
const manager = this;
|
||||||
|
const app = this.apps.get(key);
|
||||||
|
if (!app) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const appStatus = app.status;
|
||||||
|
try {
|
||||||
|
await manager.removeApp(key);
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { showAppInfo } = await installAppFromKey(key, this.appsPath);
|
||||||
|
await manager.add(showAppInfo as any);
|
||||||
|
if (appStatus === 'running') {
|
||||||
|
await manager.start(key);
|
||||||
|
}
|
||||||
|
console.log('reload app success', key);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('reload app error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const LoadApp = async (app: AppInfo, opts?: { mainApp?: any, pm2Connect?: any }) => {
|
||||||
|
const mainApp = opts?.mainApp;
|
||||||
|
const pm2Connect = opts?.pm2Connect;
|
||||||
|
if (app.status !== 'running') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!fileIsExist(app.path)) {
|
||||||
|
console.error('app is not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const pathEntry = path.join(app.path, app.entry);
|
||||||
|
if (!fileIsExist(pathEntry)) {
|
||||||
|
console.error('file entry not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const entry = app.entry + `?timestamp=${app?.timestamp}`;
|
||||||
|
// 注册路由
|
||||||
|
if (app.type === AppType.MicroApp) {
|
||||||
|
const childProcess = fork(app.entry, [], {
|
||||||
|
stdio: 'inherit', // 共享主进程的标准输入输出
|
||||||
|
cwd: app.path,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...app.env,
|
||||||
|
APP_KEY: app.key,
|
||||||
|
APP_PATH: app.path,
|
||||||
|
APP_ENTRY: entry
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.process = childProcess;
|
||||||
|
} else if (app.type === AppType.SystemApp) {
|
||||||
|
const pathEntryAndTimestamp = path.join(app.path, entry);
|
||||||
|
// Windows下需要使用file://协议,并将反斜杠转换为正斜杠
|
||||||
|
const importPath = process.platform === 'win32'
|
||||||
|
? 'file:///' + pathEntryAndTimestamp.replace(/\\/g, '/')
|
||||||
|
: pathEntryAndTimestamp;
|
||||||
|
const module = await import(importPath);
|
||||||
|
if (module.loadApp && mainApp) {
|
||||||
|
await module.loadApp?.(mainApp, app);
|
||||||
|
}
|
||||||
|
} else if (app.type === AppType.GatewayApp) {
|
||||||
|
console.log('gateway app not support');
|
||||||
|
} else if (app.type === AppType.Pm2SystemApp) {
|
||||||
|
const pathEntry = path.join(app.path, app.entry);
|
||||||
|
const pm2Manager = new Pm2Manager({
|
||||||
|
appName: app.key,
|
||||||
|
script: pathEntry,
|
||||||
|
pm2Connect: pm2Connect
|
||||||
|
});
|
||||||
|
if (app?.init) {
|
||||||
|
const isInstall = await checkInstall(app);
|
||||||
|
if (!isInstall) {
|
||||||
|
console.log('install failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pm2Options: StartOptions = app.pm2Options || {};
|
||||||
|
if (app?.engine) {
|
||||||
|
pm2Options.interpreter = pm2Options.interpreter || app?.engine;
|
||||||
|
}
|
||||||
|
if (!pm2Options.cwd) {
|
||||||
|
pm2Options.cwd = path.join(app.path, '../..');
|
||||||
|
}
|
||||||
|
await pm2Manager.start(pm2Options);
|
||||||
|
} else if (app.type === AppType.ScriptApp) {
|
||||||
|
// console.log('script app 直接运行,不需要启动');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error('app type not support', app.type);
|
||||||
|
}
|
||||||
|
console.log(`load ${app.type} success`, app.key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StopApp = async (app: AppInfo, opts?: { pm2Connect?: Pm2Connect, todo?: 'stop' | 'remove' | 'restart' }) => {
|
||||||
|
const key = app.key;
|
||||||
|
const pm2Connect = opts?.pm2Connect;
|
||||||
|
const todo = opts?.todo || 'stop';
|
||||||
|
if (app.status === 'stop' && app.type === AppType.SystemApp) {
|
||||||
|
console.log(`app ${key} is stopped`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.status = 'stop';
|
||||||
|
if (app.type === AppType.MicroApp) {
|
||||||
|
if (app.process) {
|
||||||
|
app.process.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (app.type === AppType.Pm2SystemApp) {
|
||||||
|
const pm2Manager = new Pm2Manager({
|
||||||
|
appName: app.key,
|
||||||
|
script: app.entry,
|
||||||
|
pm2Connect: pm2Connect
|
||||||
|
});
|
||||||
|
await pm2Manager[todo]?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 安装app通过key
|
||||||
|
* @param key
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const installAppFromKey = async (key: string, _appPath: string) => {
|
||||||
|
// 标准化key中的路径分隔符,统一使用系统路径分隔符
|
||||||
|
const normalizedKey = key.replace(/\//g, path.sep);
|
||||||
|
const directory = path.join(_appPath, normalizedKey);
|
||||||
|
if (!fileIsExist(directory)) {
|
||||||
|
console.error('App not found', directory);
|
||||||
|
throw new Error('App not found');
|
||||||
|
}
|
||||||
|
const pkgs = path.join(directory, 'package.json');
|
||||||
|
if (!fileIsExist(pkgs)) {
|
||||||
|
throw new Error('Invalid package.json, need package.json in app directory');
|
||||||
|
}
|
||||||
|
const json = fs.readFileSync(pkgs, 'utf-8');
|
||||||
|
const pkg = JSON.parse(json);
|
||||||
|
const { name, version, app } = pkg;
|
||||||
|
if (!name || !version || !app) {
|
||||||
|
console.error('need name, version and app in package.json');
|
||||||
|
throw new Error('Invalid package.json format, need name, version and app');
|
||||||
|
}
|
||||||
|
const readmeFile = path.join(directory, 'README.md');
|
||||||
|
const readmeFile2 = path.join(directory, 'readme.md');
|
||||||
|
let readmeDesc = '';
|
||||||
|
if (fileIsExist(readmeFile)) {
|
||||||
|
readmeDesc = fs.readFileSync(readmeFile, 'utf-8');
|
||||||
|
} else if (fileIsExist(readmeFile2)) {
|
||||||
|
readmeDesc = fs.readFileSync(readmeFile2, 'utf-8');
|
||||||
|
}
|
||||||
|
let showAppInfo: AppInfo = {
|
||||||
|
key,
|
||||||
|
status: 'inactive',
|
||||||
|
type: app?.type || 'system-app',
|
||||||
|
description: app?.description || pkg?.description || readmeDesc || '',
|
||||||
|
version,
|
||||||
|
runtime: app?.runtime || [],
|
||||||
|
//
|
||||||
|
entry: app?.entry || '',
|
||||||
|
path: directory,
|
||||||
|
origin: app
|
||||||
|
};
|
||||||
|
app.key = key;
|
||||||
|
if (app.type === 'pm2-system-app') {
|
||||||
|
const pm2Cwd = path.join(_appPath, '..');
|
||||||
|
showAppInfo.pm2Options = { cwd: pm2Cwd, ...app.pm2Options };
|
||||||
|
}
|
||||||
|
return { pkg, showAppInfo };
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 读取apps文件夹下的所有文件夹,对filename进行过滤
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const getAppPathKeys = async (_appPath: string) => {
|
||||||
|
const directory = path.resolve(_appPath);
|
||||||
|
const root = directory;
|
||||||
|
// 使用 posix 风格路径用于 glob 模式,确保跨平台兼容
|
||||||
|
const path1 = '*/package.json';
|
||||||
|
const path2 = '*/*/package.json';
|
||||||
|
const appsPackages = await glob([path1, path2], {
|
||||||
|
cwd: root,
|
||||||
|
onlyFiles: true,
|
||||||
|
absolute: false,
|
||||||
|
ignore: ['**/node_modules/**']
|
||||||
|
});
|
||||||
|
const appPathKeys = appsPackages.map((pkg) => {
|
||||||
|
const dir = path.dirname(pkg);
|
||||||
|
// 直接使用dirname的结果,因为glob已经返回相对于root的路径
|
||||||
|
// 只需要标准化路径分隔符
|
||||||
|
return dir.replace(/\\/g, '/');
|
||||||
|
});
|
||||||
|
return appPathKeys;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
export const clearMicroApp = (link: string) => {
|
||||||
|
try {
|
||||||
|
const moduleUrl = new URL(link, import.meta.url);
|
||||||
|
// 使用 fileURLToPath 确保 Windows 和 Unix 路径兼容
|
||||||
|
const modulePath = fileURLToPath(moduleUrl);
|
||||||
|
delete require.cache[modulePath];
|
||||||
|
console.log(`Module ${link} has been unloaded.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to unload module ${link}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
249
assistant/src/module/local-apps/src/modules/pm2.ts
Normal file
249
assistant/src/module/local-apps/src/modules/pm2.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import pm2, { ProcessDescription, StartOptions } from 'pm2';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import type { AppInfo } from './manager.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化脚本路径,确保在 Windows 平台上兼容 PM2
|
||||||
|
* @param scriptPath 脚本路径
|
||||||
|
* @returns 规范化后的路径
|
||||||
|
*/
|
||||||
|
export const normalizeScriptPath = (scriptPath: string): string => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// 在 Windows 上,将反斜杠转换为正斜杠,PM2 更好地支持正斜杠
|
||||||
|
return scriptPath.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
return scriptPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const connect = async (noDaemonMode: boolean = false) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pm2.connect(noDaemonMode, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('pm2 connect error', err);
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const disconnect = promisify(pm2.disconnect).bind(pm2);
|
||||||
|
export const start = promisify(pm2.start).bind(pm2);
|
||||||
|
export const stop = promisify(pm2.stop).bind(pm2);
|
||||||
|
export const restart = promisify(pm2.restart).bind(pm2);
|
||||||
|
export const reload = promisify(pm2.reload).bind(pm2);
|
||||||
|
const deleteProcess = promisify(pm2.delete).bind(pm2);
|
||||||
|
const list = promisify(pm2.list).bind(pm2);
|
||||||
|
export type Pm2Opts = {
|
||||||
|
appName: string;
|
||||||
|
script?: string;
|
||||||
|
pm2Connect?: Pm2Connect;
|
||||||
|
/**
|
||||||
|
* 启动程序的路径,比如node deno bun interpreter
|
||||||
|
*/
|
||||||
|
interpreter?: string;
|
||||||
|
env?: Record<string, any>;
|
||||||
|
};
|
||||||
|
export const checkInstall = async (app: AppInfo) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 启动前,先执行pnpm install , 如果失败,则不启动
|
||||||
|
const install = spawn('pnpm', ['install'], {
|
||||||
|
cwd: app.path,
|
||||||
|
stdio: 'inherit'
|
||||||
|
});
|
||||||
|
install.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.log('install failed');
|
||||||
|
return resolve(false);
|
||||||
|
}
|
||||||
|
console.log('install success');
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export class Pm2Connect {
|
||||||
|
needConnect = true;
|
||||||
|
isConnected = false;
|
||||||
|
constructor(needConnect = true) {
|
||||||
|
this.needConnect = needConnect;
|
||||||
|
}
|
||||||
|
async sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async checkConnect() {
|
||||||
|
const that = this;
|
||||||
|
try {
|
||||||
|
if (this.needConnect && !this.isConnected) {
|
||||||
|
const data = await connect();
|
||||||
|
that.isConnected = !!data;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('pm2 check connect error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async checkDisconnect(runOpts?: RunOptions) {
|
||||||
|
const needExit = runOpts?.needExit ?? true;
|
||||||
|
if (this.needConnect && this.isConnected && needExit) {
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async connect() {
|
||||||
|
try {
|
||||||
|
await connect();
|
||||||
|
this.isConnected = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('pm2 connect error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async disconnect() {
|
||||||
|
try {
|
||||||
|
pm2.disconnect();
|
||||||
|
await this.sleep(1000);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('pm2 disconnect error', e);
|
||||||
|
}
|
||||||
|
this.isConnected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type RunOptions = {
|
||||||
|
/**
|
||||||
|
* 是否在操作完成后退出连接
|
||||||
|
*/
|
||||||
|
needExit?: boolean;
|
||||||
|
};
|
||||||
|
export class Pm2Manager {
|
||||||
|
pm2Connect: Pm2Connect;
|
||||||
|
/**
|
||||||
|
* app name
|
||||||
|
*/
|
||||||
|
appName: string;
|
||||||
|
/**
|
||||||
|
* 启动脚本的路径
|
||||||
|
*/
|
||||||
|
script?: string;
|
||||||
|
/**
|
||||||
|
* 启动脚本的路径
|
||||||
|
*/
|
||||||
|
interpreter?: string;
|
||||||
|
/**
|
||||||
|
* 批量更新的时候不需要一直connect和关闭
|
||||||
|
*/
|
||||||
|
needConnect = true;
|
||||||
|
isConnect = false;
|
||||||
|
env: any;
|
||||||
|
constructor(opts: Pm2Opts) {
|
||||||
|
this.appName = opts.appName;
|
||||||
|
this.script = opts.script;
|
||||||
|
this.interpreter = opts.interpreter;
|
||||||
|
this.pm2Connect = opts.pm2Connect || new Pm2Connect();
|
||||||
|
this.env = opts.env || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(runOpts?: RunOptions): Promise<ProcessDescription[]> {
|
||||||
|
const _runOpts = { needExit: false, ...runOpts };
|
||||||
|
try {
|
||||||
|
await this.pm2Connect.checkConnect();
|
||||||
|
const apps = await list();
|
||||||
|
return apps;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('pm2 run error', e);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
this.pm2Connect.checkDisconnect(_runOpts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 启动
|
||||||
|
*/
|
||||||
|
async start(options?: StartOptions, runOpts?: RunOptions) {
|
||||||
|
const { appName, script, env, interpreter } = this;
|
||||||
|
const needExit = runOpts?.needExit ?? true;
|
||||||
|
if (!script) {
|
||||||
|
console.error('script is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 规范化脚本路径以兼容 Windows
|
||||||
|
const normalizedScript = normalizeScriptPath(script);
|
||||||
|
const starter: StartOptions = {
|
||||||
|
name: appName,
|
||||||
|
script: normalizedScript, // examples: ./agent/main.ts
|
||||||
|
// execute_command: execPath,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
interpreter: interpreter,
|
||||||
|
...options,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
...env,
|
||||||
|
...options?.env
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await this.pm2Connect.checkConnect();
|
||||||
|
const apps = await this.list({ needExit: false });
|
||||||
|
const app = apps.find((app) => app.name === appName);
|
||||||
|
if (app && app.pid === 0) {
|
||||||
|
await start(starter);
|
||||||
|
return;
|
||||||
|
} else if (!app) {
|
||||||
|
await start(starter);
|
||||||
|
} else {
|
||||||
|
console.log(`pm2 app ${appName} is running`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error', e);
|
||||||
|
} finally {
|
||||||
|
this.pm2Connect.checkDisconnect({ needExit });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止
|
||||||
|
*/
|
||||||
|
async stop(runOpts?: RunOptions) {
|
||||||
|
try {
|
||||||
|
await this.pm2Connect.checkConnect();
|
||||||
|
const apps = await this.list({ needExit: false });
|
||||||
|
const app = apps.find((app) => app.name === this.appName);
|
||||||
|
if (app && app.pid !== 0) {
|
||||||
|
await stop(app.name);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error', e);
|
||||||
|
} finally {
|
||||||
|
this.pm2Connect.checkDisconnect(runOpts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async restart(runOpts?: RunOptions) {
|
||||||
|
try {
|
||||||
|
await this.pm2Connect.checkConnect();
|
||||||
|
const apps = await this.list({ needExit: false });
|
||||||
|
const app = apps.find((app) => app.name === this.appName);
|
||||||
|
if (app) {
|
||||||
|
await restart(app.name);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error', e);
|
||||||
|
} finally {
|
||||||
|
this.pm2Connect.checkDisconnect(runOpts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async remove(runOpts?: RunOptions) {
|
||||||
|
this.deleteProcess(runOpts);
|
||||||
|
}
|
||||||
|
async deleteProcess(runOpts?: RunOptions) {
|
||||||
|
try {
|
||||||
|
await this.pm2Connect.checkConnect();
|
||||||
|
const apps = await this.list({ needExit: false });
|
||||||
|
const app = apps.find((app) => app.name === this.appName);
|
||||||
|
if (app) {
|
||||||
|
await deleteProcess(app.name);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error', e);
|
||||||
|
} finally {
|
||||||
|
this.pm2Connect.checkDisconnect(runOpts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
assistant/src/module/local-apps/src/routes/list.ts
Normal file
115
assistant/src/module/local-apps/src/routes/list.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { app } from '../app.ts';
|
||||||
|
import { manager } from '../manager.ts';
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'local-apps',
|
||||||
|
key: 'detect',
|
||||||
|
description: 'Detect local apps',
|
||||||
|
middleware: ['auth-admin']
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const res = await manager.detectApp();
|
||||||
|
ctx.body = res;
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'local-apps',
|
||||||
|
key: 'list',
|
||||||
|
middleware: ['auth-admin'],
|
||||||
|
description: 'List local apps'
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const list = manager.getAllAppShowInfo();
|
||||||
|
ctx.body = list;
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'local-apps',
|
||||||
|
key: 'updateStatus',
|
||||||
|
middleware: ['auth-admin'],
|
||||||
|
description: 'Update app status, start or stop, parmas: status, appKey'
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const { status, appKey } = ctx.query;
|
||||||
|
if (!status || !appKey) {
|
||||||
|
ctx.body = 'status or key is required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const app = manager.apps.get(appKey);
|
||||||
|
if (!app) {
|
||||||
|
ctx.throw(404, 'App not found');
|
||||||
|
}
|
||||||
|
if (status === 'start') {
|
||||||
|
await manager.start(appKey);
|
||||||
|
} else if (status === 'stop') {
|
||||||
|
await manager.stop(appKey);
|
||||||
|
}
|
||||||
|
const appShow = manager.getAppShowInfo(appKey);
|
||||||
|
ctx.body = appShow;
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'local-apps',
|
||||||
|
key: 'operate',
|
||||||
|
middleware: ['auth-admin'],
|
||||||
|
description: 'Operate app, parmas: appKey, action: start, stop, restart, removeApp, reload'
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const { appKey, action } = ctx.query;
|
||||||
|
if (!appKey || !action) {
|
||||||
|
ctx.throw(400, 'appKey or action is required');
|
||||||
|
}
|
||||||
|
if (!['start', 'stop', 'restart', 'removeApp', 'reload'].includes(action)) {
|
||||||
|
ctx.throw(400, 'action is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = manager.apps.get(appKey);
|
||||||
|
if (!app) {
|
||||||
|
ctx.throw(404, 'App not found');
|
||||||
|
}
|
||||||
|
await manager[action](appKey);
|
||||||
|
const appShow = manager.getAppShowInfo(appKey);
|
||||||
|
ctx.body = appShow;
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'local-apps',
|
||||||
|
key: 'update',
|
||||||
|
middleware: ['auth-admin'],
|
||||||
|
description: 'Update app info'
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const { key } = ctx.query.data || {};
|
||||||
|
if (!key) {
|
||||||
|
ctx.body = 'key is required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const appInfo = await manager.updateAppInfo(key, ctx.query.data);
|
||||||
|
ctx.body = appInfo;
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'local-apps',
|
||||||
|
key: 'delete',
|
||||||
|
middleware: ['auth-admin']
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const { appKey } = ctx.query;
|
||||||
|
if (!appKey) {
|
||||||
|
ctx.throw(400, 'key is required');
|
||||||
|
}
|
||||||
|
const res = await manager.removeApp(appKey);
|
||||||
|
ctx.body = res ? 'ok' : 'fail';
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
31
assistant/src/module/local-apps/src/uitls/npm.ts
Normal file
31
assistant/src/module/local-apps/src/uitls/npm.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
export const checkPnpm = () => {
|
||||||
|
try {
|
||||||
|
spawnSync('pnpm', ['--version']);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type InstallDepsOptions = {
|
||||||
|
appPath: string;
|
||||||
|
isProduction?: boolean;
|
||||||
|
sync?: boolean;
|
||||||
|
};
|
||||||
|
export const installDeps = (opts: InstallDepsOptions) => {
|
||||||
|
const { appPath } = opts;
|
||||||
|
const isProduction = opts.isProduction ?? true;
|
||||||
|
const params = ['i'];
|
||||||
|
if (isProduction) {
|
||||||
|
params.push('--production');
|
||||||
|
}
|
||||||
|
console.log('installDeps', appPath, params);
|
||||||
|
const syncSpawn = opts.sync ? spawnSync : spawn;
|
||||||
|
if (checkPnpm()) {
|
||||||
|
syncSpawn('pnpm', params, { cwd: appPath, stdio: 'inherit', env: process.env });
|
||||||
|
} else {
|
||||||
|
syncSpawn('npm', params, { cwd: appPath, stdio: 'inherit', env: process.env });
|
||||||
|
}
|
||||||
|
};
|
||||||
11
assistant/src/module/local-apps/test/deno.ts
Normal file
11
assistant/src/module/local-apps/test/deno.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
console.log('Hello, world!');
|
||||||
|
import http from 'node:http';
|
||||||
|
// @ts-ignore
|
||||||
|
const isDeno = typeof Deno !== 'undefined' && Deno?.version?.deno;
|
||||||
|
console.log('isDeno', isDeno);
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
res.end('Hello, world!');
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(8010);
|
||||||
13
assistant/src/module/local-apps/test/detect.ts
Normal file
13
assistant/src/module/local-apps/test/detect.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// import { manager } from '../src/manager';
|
||||||
|
import { Manager } from '../src/modules/manager.ts';
|
||||||
|
|
||||||
|
export const manager = new Manager({
|
||||||
|
// mainApp: app
|
||||||
|
});
|
||||||
|
const main = async () => {
|
||||||
|
await manager.loadConfig();
|
||||||
|
const res = await manager.detectApp();
|
||||||
|
console.log(res);
|
||||||
|
};
|
||||||
|
|
||||||
|
main();
|
||||||
7
assistant/src/module/local-apps/test/list.ts
Normal file
7
assistant/src/module/local-apps/test/list.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { manager } from '../src/manager';
|
||||||
|
const main = async () => {
|
||||||
|
const res = manager.getAllAppShowInfo();
|
||||||
|
console.log(res);
|
||||||
|
};
|
||||||
|
|
||||||
|
main();
|
||||||
19
assistant/src/module/local-apps/test/load-app.ts
Normal file
19
assistant/src/module/local-apps/test/load-app.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { loadAppInfo } from '../src/modules/app-file';
|
||||||
|
import { Manager } from '../src/modules/manager.ts';
|
||||||
|
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const appInfo = await loadAppInfo("apps");
|
||||||
|
console.log(appInfo);
|
||||||
|
};
|
||||||
|
// main();
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
const manager = new Manager({
|
||||||
|
// mainApp: app
|
||||||
|
});
|
||||||
|
await manager.loadConfig()
|
||||||
|
await manager.start('root/test-log')
|
||||||
|
}
|
||||||
|
|
||||||
|
start()
|
||||||
21
assistant/src/module/local-apps/test/pm2-start-test.ts
Normal file
21
assistant/src/module/local-apps/test/pm2-start-test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Pm2Manager, connect, disconnect } from '../src/modules/pm2.ts';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pm2 = new Pm2Manager({
|
||||||
|
appName: 'test2',
|
||||||
|
script: 'test/deno.ts',
|
||||||
|
interpreter: 'deno'
|
||||||
|
});
|
||||||
|
const main = async () => {
|
||||||
|
console.log('pm2 start test2');
|
||||||
|
await pm2.start({
|
||||||
|
// args: ['--allow-net']
|
||||||
|
interpreter_args: ['-A']
|
||||||
|
});
|
||||||
|
console.log('pm2 disconnect test2');
|
||||||
|
console.log('pm2', pm2.isConnect);
|
||||||
|
};
|
||||||
|
main();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('err', e);
|
||||||
|
}
|
||||||
26
assistant/src/module/local-apps/test/pm2.ts
Normal file
26
assistant/src/module/local-apps/test/pm2.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Pm2Manager } from '../src/modules/pm2';
|
||||||
|
|
||||||
|
const manager = new Pm2Manager({
|
||||||
|
appName: 'test',
|
||||||
|
script: 'test.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const list = await manager.list();
|
||||||
|
console.log('list', list);
|
||||||
|
// manager.restart()
|
||||||
|
};
|
||||||
|
|
||||||
|
// main();
|
||||||
|
|
||||||
|
const pm2Demo = new Pm2Manager({
|
||||||
|
appName: 'pm2-demo'
|
||||||
|
});
|
||||||
|
|
||||||
|
const pm2Restart = async () => {
|
||||||
|
const list = await pm2Demo.list();
|
||||||
|
console.log('list', list);
|
||||||
|
pm2Demo.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
pm2Restart();
|
||||||
16
assistant/src/module/local-apps/test/start-test-ts.ts
Normal file
16
assistant/src/module/local-apps/test/start-test-ts.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// import { manager } from '../src/manager';
|
||||||
|
import { Manager } from '../src/modules/manager.ts';
|
||||||
|
|
||||||
|
export const manager = new Manager({
|
||||||
|
// mainApp: app
|
||||||
|
});
|
||||||
|
const main = async () => {
|
||||||
|
await manager.loadConfig(true);
|
||||||
|
// const res = await manager.detectApp();
|
||||||
|
// console.log(res);
|
||||||
|
const res = await manager.start('root/test-log');
|
||||||
|
// const res = await manager.stop('root/test-log');
|
||||||
|
console.log(res);
|
||||||
|
};
|
||||||
|
|
||||||
|
main();
|
||||||
33
assistant/src/module/local-apps/tsconfig.json
Normal file
33
assistant/src/module/local-apps/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"target": "esnext",
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"sourceMap": false,
|
||||||
|
"allowJs": true,
|
||||||
|
"newLine": "LF",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"typeRoots": [
|
||||||
|
"node_modules/@types",
|
||||||
|
"node_modules/@kevisual/types"
|
||||||
|
],
|
||||||
|
"declaration": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"typings.d.ts",
|
||||||
|
"src/**/*.ts", "mod.ts",
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -17,8 +17,11 @@ type ProxyType = {
|
|||||||
user: string;
|
user: string;
|
||||||
key: string;
|
key: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
type?: 'file';
|
||||||
|
file: {
|
||||||
indexPath: string;
|
indexPath: string;
|
||||||
absolutePath?: string;
|
absolutePath: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
export type LocalProxyOpts = {
|
export type LocalProxyOpts = {
|
||||||
watch?: boolean; // 是否监听文件变化
|
watch?: boolean; // 是否监听文件变化
|
||||||
@@ -44,7 +47,7 @@ export class LocalProxy {
|
|||||||
initFromAssistantConfig(assistantConfig?: AssistantConfig) {
|
initFromAssistantConfig(assistantConfig?: AssistantConfig) {
|
||||||
if (!assistantConfig) return;
|
if (!assistantConfig) return;
|
||||||
this.pagesDir = assistantConfig.configPath?.pagesDir || '';
|
this.pagesDir = assistantConfig.configPath?.pagesDir || '';
|
||||||
this.watch = !!assistantConfig.getCacheAssistantConfig()?.watch.enabled;
|
this.watch = assistantConfig.getCacheAssistantConfig?.()?.watch?.enabled ?? true;
|
||||||
this.init();
|
this.init();
|
||||||
if (this.watch) {
|
if (this.watch) {
|
||||||
this.onWatch();
|
this.onWatch();
|
||||||
@@ -79,8 +82,10 @@ export class LocalProxy {
|
|||||||
user: user,
|
user: user,
|
||||||
key: app,
|
key: app,
|
||||||
path: `/${user}/${app}/`,
|
path: `/${user}/${app}/`,
|
||||||
|
file: {
|
||||||
indexPath: `${user}/${app}/index.html`,
|
indexPath: `${user}/${app}/index.html`,
|
||||||
absolutePath: appPath,
|
absolutePath: appPath,
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -112,14 +117,26 @@ export class LocalProxy {
|
|||||||
};
|
};
|
||||||
fs.watch(frontAppDir, { recursive: true }, (eventType, filename) => {
|
fs.watch(frontAppDir, { recursive: true }, (eventType, filename) => {
|
||||||
if (eventType === 'rename' || eventType === 'change') {
|
if (eventType === 'rename' || eventType === 'change') {
|
||||||
|
// 过滤 node_modules 目录
|
||||||
|
if (filename && filename.includes('node_modules')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 只监听 js、html、css 文件
|
||||||
|
const validExtensions = ['.js', '.html', '.css', '.json', '.png'];
|
||||||
|
const hasValidExtension = validExtensions.some(ext => filename && filename.endsWith(ext));
|
||||||
|
|
||||||
|
if (!hasValidExtension) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const filePath = path.join(frontAppDir, filename);
|
const filePath = path.join(frontAppDir, filename);
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
if (stat.isDirectory() || filename.endsWith('.html')) {
|
if (stat.isFile() || stat.isDirectory()) {
|
||||||
// 重新加载
|
// 重新加载
|
||||||
debounce(that.init.bind(that), 5 * 1000);
|
debounce(that.init.bind(that), 5 * 1000);
|
||||||
}
|
}
|
||||||
} catch (error) {}
|
} catch (error) { }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { LocalProxy, LocalProxyOpts } from './index.ts';
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { fileProxy } from './proxy/file-proxy.ts';
|
import { fileProxy } from './proxy/file-proxy.ts';
|
||||||
const localProxy = new LocalProxy({});
|
const localProxy = new LocalProxy({});
|
||||||
let home = '/root/center';
|
let home = '/root/home';
|
||||||
export const initProxy = (data: LocalProxyOpts & { home?: string }) => {
|
export const initProxy = (data: LocalProxyOpts & { home?: string }) => {
|
||||||
localProxy.pagesDir = data.pagesDir || '';
|
localProxy.pagesDir = data.pagesDir || '';
|
||||||
localProxy.watch = data.watch ?? false;
|
localProxy.watch = data.watch ?? false;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type ProxyInfo = {
|
export type ProxyInfo = {
|
||||||
/**
|
/**
|
||||||
* 代理路径, 比如/root/center, 匹配的路径
|
* 代理路径, 比如/root/home, 匹配的路径
|
||||||
*/
|
*/
|
||||||
path?: string;
|
path?: string;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import pm2 from 'pm2';
|
|||||||
import { logger } from './logger.ts';
|
import { logger } from './logger.ts';
|
||||||
|
|
||||||
export async function reload() {
|
export async function reload() {
|
||||||
|
if (process.env.PM2_HOME === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
pm2.connect((err) => {
|
pm2.connect((err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|||||||
2
assistant/src/module/remote-app/.npmrc
Normal file
2
assistant/src/module/remote-app/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
|
||||||
|
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
||||||
20
assistant/src/module/remote-app/package.json
Normal file
20
assistant/src/module/remote-app/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@kevisual/remote-app",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"main": "remote-app.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"files": [
|
||||||
|
"remote-app.ts"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
||||||
|
"license": "MIT",
|
||||||
|
"packageManager": "pnpm@10.26.0",
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
169
assistant/src/module/remote-app/remote-app.ts
Normal file
169
assistant/src/module/remote-app/remote-app.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import type { App } from '@kevisual/router';
|
||||||
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
type RemoteAppOptions = {
|
||||||
|
app?: App;
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
emitter?: EventEmitter;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
export class RemoteApp {
|
||||||
|
mainApp: App;
|
||||||
|
url: string;
|
||||||
|
id: string;
|
||||||
|
emitter: EventEmitter;
|
||||||
|
isConnected: boolean;
|
||||||
|
ws: WebSocket;
|
||||||
|
remoteIsConnected: boolean;
|
||||||
|
isError: boolean = false;
|
||||||
|
constructor(opts?: RemoteAppOptions) {
|
||||||
|
this.mainApp = opts?.app;
|
||||||
|
const token = opts.token;
|
||||||
|
const url = opts.url;
|
||||||
|
const id = opts.id;
|
||||||
|
this.emitter = opts?.emitter || new EventEmitter();
|
||||||
|
const _url = new URL(url);
|
||||||
|
if (token) {
|
||||||
|
_url.searchParams.set('token', token);
|
||||||
|
}
|
||||||
|
_url.searchParams.set('id', id);
|
||||||
|
this.url = _url.toString();
|
||||||
|
this.id = id;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
async isConnect(): Promise<boolean> {
|
||||||
|
const that = this;
|
||||||
|
if (this.isConnected) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
resolve(false);
|
||||||
|
that.emitter.off('open', listenOnce);
|
||||||
|
}, 5000);
|
||||||
|
const listenOnce = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
that.isConnected = true;
|
||||||
|
that.remoteIsConnected = true;
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
that.emitter.once('open', listenOnce);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getWsURL(url: string) {
|
||||||
|
const { protocol } = new URL(url);
|
||||||
|
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsURL = url.toString().replace(protocol, wsProtocol);
|
||||||
|
return wsURL;
|
||||||
|
}
|
||||||
|
async init() {
|
||||||
|
if (!this.url) {
|
||||||
|
throw new Error('No url provided for remote app');
|
||||||
|
}
|
||||||
|
if (!this.id) {
|
||||||
|
throw new Error('No id provided for remote app');
|
||||||
|
}
|
||||||
|
this.isError = false;
|
||||||
|
// 关闭已有连接
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
|
const ws = new WebSocket(this.getWsURL(this.url));
|
||||||
|
const that = this;
|
||||||
|
ws.onopen = function () {
|
||||||
|
that.isConnected = true;
|
||||||
|
that.onOpen();
|
||||||
|
};
|
||||||
|
ws.onclose = function () {
|
||||||
|
that.isConnected = false;
|
||||||
|
that.onClose();
|
||||||
|
}
|
||||||
|
ws.onmessage = function (event) {
|
||||||
|
that.onMessage(event.data);
|
||||||
|
}
|
||||||
|
ws.onerror = function (error) {
|
||||||
|
that.onError(error);
|
||||||
|
}
|
||||||
|
this.ws = ws;
|
||||||
|
}
|
||||||
|
onOpen() {
|
||||||
|
this.emitter.emit('open', this.id);
|
||||||
|
}
|
||||||
|
onClose() {
|
||||||
|
console.log('远程应用关闭:', this.id);
|
||||||
|
this.emitter.emit('close', this.id);
|
||||||
|
this.isConnected = false;
|
||||||
|
}
|
||||||
|
onMessage(data: any) {
|
||||||
|
this.emitter.emit('message', data);
|
||||||
|
}
|
||||||
|
onError(error: any) {
|
||||||
|
console.error('远程应用错误:', this.id, error);
|
||||||
|
this.isError = true;
|
||||||
|
this.emitter.emit('error', error);
|
||||||
|
}
|
||||||
|
on(event: 'open' | 'close' | 'message' | 'error', listener: (data: any) => void) {
|
||||||
|
this.emitter.on(event, listener);
|
||||||
|
return () => {
|
||||||
|
this.emitter.off(event, listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sendData(data: any) { }
|
||||||
|
json(data: any) {
|
||||||
|
this.ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
listenProxy() {
|
||||||
|
const remoteApp = this;
|
||||||
|
const app = this.mainApp;
|
||||||
|
const listenFn = async (event: any) => {
|
||||||
|
try {
|
||||||
|
const data = event.toString();
|
||||||
|
const body = JSON.parse(data);
|
||||||
|
const message = body.data || {};
|
||||||
|
if (body?.code === 401) {
|
||||||
|
console.error('远程应用认证失败,请检查 token 是否正确');
|
||||||
|
this.isError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (body?.type !== 'proxy') return;
|
||||||
|
if (!body.id) {
|
||||||
|
remoteApp.json({
|
||||||
|
id: body.id,
|
||||||
|
data: {
|
||||||
|
code: 400,
|
||||||
|
message: 'id is required',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
const res = await app.call(message);
|
||||||
|
remoteApp.json({
|
||||||
|
id: body.id,
|
||||||
|
data: {
|
||||||
|
code: res.code,
|
||||||
|
data: res.body,
|
||||||
|
message: res.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理远程代理请求出错:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
remoteApp.json({
|
||||||
|
id: this.id,
|
||||||
|
type: 'registryClient'
|
||||||
|
});
|
||||||
|
remoteApp.emitter.on('message', listenFn);
|
||||||
|
const closeMessage = () => {
|
||||||
|
remoteApp.emitter.off('message', listenFn);
|
||||||
|
}
|
||||||
|
remoteApp.emitter.once('close', () => {
|
||||||
|
closeMessage();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
closeMessage();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
34
assistant/src/module/upload/mv.ts
Normal file
34
assistant/src/module/upload/mv.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { AssistantConfig } from '@/module/assistant/index.ts';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
export class UploadManager {
|
||||||
|
config: AssistantConfig;
|
||||||
|
constructor(config: AssistantConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
mvFile(opts: {
|
||||||
|
temppath: string;
|
||||||
|
type: 'file' | 's3' | 'app-file';
|
||||||
|
targetPath: string;
|
||||||
|
}) {
|
||||||
|
const { type = 'file', temppath, targetPath } = opts;
|
||||||
|
const pageDir = this.config.configPath?.pagesDir!;
|
||||||
|
const appsDir = this.config.configPath?.appsDir!;
|
||||||
|
let dir = type === 'app-file' ? appsDir : pageDir;
|
||||||
|
if (type === 'file' || type === 'app-file') {
|
||||||
|
const fullTargetPath = targetPath.startsWith('/')
|
||||||
|
? targetPath
|
||||||
|
: path.join(dir, targetPath);
|
||||||
|
const targetDir = fullTargetPath.substring(0, fullTargetPath.lastIndexOf('/'));
|
||||||
|
if (!fs.existsSync(targetDir)) {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.renameSync(temppath, fullTargetPath);
|
||||||
|
return fullTargetPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadManager = (config: AssistantConfig) => {
|
||||||
|
return new UploadManager(config);
|
||||||
|
}
|
||||||
32
assistant/src/routes-simple/events.ts
Normal file
32
assistant/src/routes-simple/events.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { simpleRouter, clients, getTaskId, writeEvents, deleteOldClients, error } from './router.ts';
|
||||||
|
|
||||||
|
simpleRouter.get('/client/events', async (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
|
const taskId = getTaskId(req);
|
||||||
|
if (!taskId) {
|
||||||
|
res.end(error('task-id is required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 将客户端连接推送到 clients 数组
|
||||||
|
clients.set(taskId, { client: res, createTime: Date.now() });
|
||||||
|
writeEvents(req, { progress: 0, message: 'start' })
|
||||||
|
// 移除客户端连接
|
||||||
|
req.on('close', () => {
|
||||||
|
clients.delete(taskId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
simpleRouter.get('/client/events/close', async (req, res) => {
|
||||||
|
const taskId = getTaskId(req);
|
||||||
|
if (!taskId) {
|
||||||
|
res.end(error('task-id is required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteOldClients();
|
||||||
|
clients.delete(taskId);
|
||||||
|
res.end('ok');
|
||||||
|
});
|
||||||
1
assistant/src/routes-simple/index.ts
Normal file
1
assistant/src/routes-simple/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './upload.ts'
|
||||||
95
assistant/src/routes-simple/router.ts
Normal file
95
assistant/src/routes-simple/router.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { simpleRouter } from '@/app.ts';
|
||||||
|
import http from 'http';
|
||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
export { simpleRouter };
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
/**
|
||||||
|
* 缓存文件路径
|
||||||
|
*/
|
||||||
|
const defaultCachePath = path.join(os.homedir(), '.envision', 'cache-file');
|
||||||
|
if (!fs.existsSync(defaultCachePath)) {
|
||||||
|
fs.mkdirSync(defaultCachePath, { recursive: true });
|
||||||
|
}
|
||||||
|
export const cacheFilePath = defaultCachePath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件客户端
|
||||||
|
*/
|
||||||
|
const eventClientsInit = () => {
|
||||||
|
const clients = new Map<string, { client?: http.ServerResponse; createTime?: number;[key: string]: any }>();
|
||||||
|
return clients;
|
||||||
|
};
|
||||||
|
export const clients = useContextKey('event-clients', () => eventClientsInit());
|
||||||
|
/**
|
||||||
|
* 获取 task-id
|
||||||
|
* @param req
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const getTaskId = (req: http.IncomingMessage) => {
|
||||||
|
const url = new URL(req.url || '', 'http://localhost');
|
||||||
|
const taskId = url.searchParams.get('taskId');
|
||||||
|
if (taskId) {
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
return req.headers['task-id'] as string;
|
||||||
|
};
|
||||||
|
type EventData = {
|
||||||
|
progress: number | string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入事件
|
||||||
|
* @param req
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
export const writeEvents = (req: http.IncomingMessage, data: EventData) => {
|
||||||
|
const taskId = getTaskId(req);
|
||||||
|
if (taskId) {
|
||||||
|
const client = clients.get(taskId)?.client;
|
||||||
|
if (client) {
|
||||||
|
client.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
}
|
||||||
|
if (Number(data.progress) === 100) {
|
||||||
|
clients.delete(taskId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('taskId is remove.', taskId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 查找超出2个小时的clients,都删除了
|
||||||
|
*/
|
||||||
|
export const deleteOldClients = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [taskId, client] of clients) {
|
||||||
|
// 如果创建时间超过2个小时,则删除
|
||||||
|
if (now - client.createTime > 1000 * 60 * 60 * 2) {
|
||||||
|
clients.delete(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 解析表单数据, 如果表单数据是数组, 则取第一个,appKey, version, username 等
|
||||||
|
* @param fields 表单数据
|
||||||
|
* @param parseKeys 需要解析的键
|
||||||
|
* @returns 解析后的数据
|
||||||
|
*/
|
||||||
|
export const getKey = (fields: Record<string, any>, parseKeys: string[]) => {
|
||||||
|
let value: Record<string, any> = {};
|
||||||
|
for (const key of parseKeys) {
|
||||||
|
const v = fields[key];
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
value[key] = v[0];
|
||||||
|
} else {
|
||||||
|
value[key] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const error = (msg: string, code = 500) => {
|
||||||
|
return JSON.stringify({ code, message: msg });
|
||||||
|
};
|
||||||
179
assistant/src/routes-simple/upload.ts
Normal file
179
assistant/src/routes-simple/upload.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
|
||||||
|
import Busboy from 'busboy';
|
||||||
|
import { assistantConfig, simpleRouter } from '../app.ts'
|
||||||
|
import http from 'http';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
import { checkAuth } from '@/routes/index.ts';
|
||||||
|
import { getTokenFromRequest } from '@/module/get-header-token.ts';
|
||||||
|
import { pipeBusboy } from '@/module/assistant/proxy/pipe.ts';
|
||||||
|
import { logger } from '@/module/logger.ts';
|
||||||
|
import { cacheFilePath, getKey, writeEvents } from './router.ts';
|
||||||
|
import { getContentType } from '@kevisual/oss/services';
|
||||||
|
import { validateDirectory } from './utils.ts';
|
||||||
|
import { UploadManager } from '@/module/upload/mv.ts';
|
||||||
|
simpleRouter.get('/client/upload', async (req, res) => {
|
||||||
|
if (res.headersSent) return; // 如果响应已发送,不再处理
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ message: 'Upload endpoint reached' }));
|
||||||
|
})
|
||||||
|
export const error = (msg: string, code = 500) => {
|
||||||
|
return JSON.stringify({ code, message: msg });
|
||||||
|
};
|
||||||
|
export const parseIfJson = (data = '{}') => {
|
||||||
|
try {
|
||||||
|
const _data = JSON.parse(data);
|
||||||
|
if (typeof _data === 'object') return _data;
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const uploadManager = new UploadManager(assistantConfig)
|
||||||
|
export const uploadResources = async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||||
|
// const { tokenUser, token } = await checkAuth(req, res);
|
||||||
|
const token = getTokenFromRequest(req);
|
||||||
|
let tokenUser: any = null;
|
||||||
|
const authResult = await checkAuth({ query: { token } });
|
||||||
|
if (authResult.code === 200) {
|
||||||
|
tokenUser = authResult.data?.tokenUser;
|
||||||
|
} else {
|
||||||
|
res.end(error(authResult.message, authResult.code));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tokenUser) {
|
||||||
|
res.end(error('Token is invalid.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL(req.url || '', 'http://localhost');
|
||||||
|
const share = !!url.searchParams.get('public');
|
||||||
|
const meta = parseIfJson(url.searchParams.get('meta'));
|
||||||
|
// 使用 busboy 解析 multipart/form-data
|
||||||
|
const busboy = Busboy({ headers: req.headers, preservePath: true });
|
||||||
|
const fields: any = {};
|
||||||
|
const files: any[] = [];
|
||||||
|
const filePromises: Promise<void>[] = [];
|
||||||
|
let bytesReceived = 0;
|
||||||
|
let bytesExpected = parseInt(req.headers['content-length'] || '0');
|
||||||
|
busboy.on('field', (fieldname, value) => {
|
||||||
|
fields[fieldname] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
busboy.on('file', (fieldname, fileStream, info) => {
|
||||||
|
const { filename, encoding, mimeType } = info;
|
||||||
|
const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
|
||||||
|
const writeStream = fs.createWriteStream(tempPath);
|
||||||
|
const filePromise = new Promise<void>((resolve, reject) => {
|
||||||
|
fileStream.on('data', (chunk) => {
|
||||||
|
bytesReceived += chunk.length;
|
||||||
|
if (bytesExpected > 0) {
|
||||||
|
const progress = (bytesReceived / bytesExpected) * 100;
|
||||||
|
const data = {
|
||||||
|
progress: progress.toFixed(2),
|
||||||
|
message: `Upload progress: ${progress.toFixed(2)}%`,
|
||||||
|
};
|
||||||
|
console.log('progress-upload', JSON.stringify(data, null, 2));
|
||||||
|
writeEvents(req, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.pipe(writeStream);
|
||||||
|
|
||||||
|
writeStream.on('finish', () => {
|
||||||
|
files.push({
|
||||||
|
filepath: tempPath,
|
||||||
|
originalFilename: filename,
|
||||||
|
mimetype: mimeType,
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
writeStream.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
filePromises.push(filePromise);
|
||||||
|
});
|
||||||
|
|
||||||
|
busboy.on('finish', async () => {
|
||||||
|
// 等待所有文件写入完成
|
||||||
|
try {
|
||||||
|
await Promise.all(filePromises);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`File write error: ${err.message}`);
|
||||||
|
res.end(error(`File write error: ${err.message}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clearFiles = () => {
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (file?.filepath && fs.existsSync(file.filepath)) {
|
||||||
|
fs.unlinkSync(file.filepath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查是否有文件上传
|
||||||
|
if (files.length === 0) {
|
||||||
|
res.end(error('files is required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('fields', fields);
|
||||||
|
let { appKey, version, username, directory } = getKey(fields, ['appKey', 'version', 'username', 'directory']);
|
||||||
|
if (!appKey || !version) {
|
||||||
|
res.end(error('appKey or version is not found, please check the upload config.'));
|
||||||
|
clearFiles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { code, message } = validateDirectory(directory);
|
||||||
|
if (code !== 200) {
|
||||||
|
res.end(error(message));
|
||||||
|
clearFiles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 逐个处理每个上传的文件
|
||||||
|
const uploadedFiles = files;
|
||||||
|
|
||||||
|
const uploadResults = [];
|
||||||
|
for (let i = 0; i < uploadedFiles.length; i++) {
|
||||||
|
const file = uploadedFiles[i];
|
||||||
|
// @ts-ignore
|
||||||
|
const tempPath = file.filepath; // 文件上传时的临时路径
|
||||||
|
const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构)
|
||||||
|
// 比如 child2/b.txt
|
||||||
|
const showVersion = false;
|
||||||
|
const _version = showVersion ? `${version ? '/' + version : ''}` : '';
|
||||||
|
const _directory = directory ? `/${directory}` : '';
|
||||||
|
const minioPath = `${username || tokenUser.username || 'unknown'}/${appKey}${_version}${_directory}/${relativePath}`;
|
||||||
|
uploadResults.push({
|
||||||
|
name: relativePath,
|
||||||
|
path: minioPath,
|
||||||
|
});
|
||||||
|
const type = 'file';
|
||||||
|
uploadManager.mvFile({
|
||||||
|
type: 'file',
|
||||||
|
temppath: tempPath,
|
||||||
|
targetPath: minioPath,
|
||||||
|
})
|
||||||
|
if (type !== 'file') {
|
||||||
|
fs.unlinkSync(tempPath); // 删除临时文件
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 200,
|
||||||
|
data: {
|
||||||
|
detect: [],
|
||||||
|
upload: uploadResults,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
pipeBusboy(req, res, busboy);
|
||||||
|
}
|
||||||
|
|
||||||
|
simpleRouter.post('/client/upload', uploadResources);
|
||||||
30
assistant/src/routes-simple/utils.ts
Normal file
30
assistant/src/routes-simple/utils.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 校验directory是否合法, 合法返回200, 不合法返回500
|
||||||
|
*
|
||||||
|
* directory 不能以/开头,不能以/结尾。不能以.开头,不能以.结尾。
|
||||||
|
* 把directory的/替换掉后,只能包含数字、字母、下划线、中划线
|
||||||
|
* @param directory 目录
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const validateDirectory = (directory?: string) => {
|
||||||
|
// 对directory进行校验,不能以/开头,不能以/结尾。不能以.开头,不能以.结尾。
|
||||||
|
if (directory && (directory.startsWith('/') || directory.endsWith('/') || directory.startsWith('..') || directory.endsWith('..'))) {
|
||||||
|
return {
|
||||||
|
code: 500,
|
||||||
|
message: 'directory is invalid',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 把directory的/替换掉后,只能包含数字、字母、下划线、中划线
|
||||||
|
// 可以包含.
|
||||||
|
let _directory = directory?.replace(/\//g, '');
|
||||||
|
if (_directory && !/^[a-zA-Z0-9_.-]+$/.test(_directory)) {
|
||||||
|
return {
|
||||||
|
code: 500,
|
||||||
|
message: 'directory is invalid, only number, letter, underline and hyphen are allowed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'directory is valid',
|
||||||
|
};
|
||||||
|
};
|
||||||
32
assistant/src/routes/call/index.ts
Normal file
32
assistant/src/routes/call/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
import { app } from '../../app.ts';
|
||||||
|
import { createSkill, tool } from '@kevisual/router';
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'call',
|
||||||
|
key: '',
|
||||||
|
description: '调用',
|
||||||
|
middleware: ['auth'],
|
||||||
|
metadata: {
|
||||||
|
tags: ['opencode'],
|
||||||
|
...createSkill({
|
||||||
|
skill: 'call-app',
|
||||||
|
title: '调用app应用',
|
||||||
|
summary: '调用router的应用, 参数path, key, payload',
|
||||||
|
args: {
|
||||||
|
path: tool.schema.string().describe('应用路径,例如 cnb'),
|
||||||
|
key: tool.schema.string().optional().describe('应用key,例如 list-repos'),
|
||||||
|
payload: tool.schema.object({}).optional().describe('调用参数'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const { path, key = '' } = ctx.query;
|
||||||
|
if (!path) {
|
||||||
|
ctx.throw('路径path不能为空');
|
||||||
|
}
|
||||||
|
const res = await ctx.run({ path, key, payload: ctx.query.payload || {} }, {
|
||||||
|
...ctx
|
||||||
|
});
|
||||||
|
ctx.forward(res);
|
||||||
|
}).addTo(app)
|
||||||
@@ -21,7 +21,16 @@ app
|
|||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
const { data } = ctx.query;
|
const { data } = ctx.query;
|
||||||
ctx.body = assistantConfig.setConfig(data);
|
ctx.body = assistantConfig.setConfig(data, true);
|
||||||
reload();
|
reload();
|
||||||
})
|
})
|
||||||
.addTo(app);
|
.addTo(app);
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'config',
|
||||||
|
key: 'getId'
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const config = assistantConfig.getCacheAssistantConfig();
|
||||||
|
ctx.body = config?.app?.id || null;
|
||||||
|
|
||||||
|
}).addTo(app);
|
||||||
37
assistant/src/routes/ha-api/ha.ts
Normal file
37
assistant/src/routes/ha-api/ha.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { LightHA } from "@kevisual/ha-api";
|
||||||
|
export const lightHA = new LightHA({ token: process.env.HAAS_TOKEN || '', homeassistantURL: process.env.HAAS_URL });
|
||||||
|
|
||||||
|
export const callText = async (text: string) => {
|
||||||
|
const command = text?.trim().slice(0, 20);
|
||||||
|
type ParseCommand = {
|
||||||
|
type?: '打开' | '关闭',
|
||||||
|
appName?: string,
|
||||||
|
command?: string,
|
||||||
|
}
|
||||||
|
let obj: ParseCommand = {};
|
||||||
|
if (command.startsWith('打开')) {
|
||||||
|
obj.appName = command.replace('打开', '').trim();
|
||||||
|
obj.type = '打开';
|
||||||
|
} else if (command.startsWith('关闭')) {
|
||||||
|
obj.appName = command.replace('关闭', '').trim();
|
||||||
|
obj.type = '关闭';
|
||||||
|
}
|
||||||
|
let endTime = Date.now();
|
||||||
|
if (obj.type) {
|
||||||
|
try {
|
||||||
|
const search = await lightHA.searchLight(obj.appName || '');
|
||||||
|
console.log('searchTime', Date.now() - endTime);
|
||||||
|
if (search.id) {
|
||||||
|
await lightHA.runService({ entity_id: search.id, service: obj.type === '打开' ? 'turn_on' : 'turn_off' });
|
||||||
|
} else if (search.hasMore) {
|
||||||
|
const [first] = search.result;
|
||||||
|
await lightHA.runService({ entity_id: first.entity_id, service: obj.type === '打开' ? 'turn_on' : 'turn_off' });
|
||||||
|
} else {
|
||||||
|
console.log('未找到对应设备:', obj.appName);
|
||||||
|
}
|
||||||
|
console.log('解析到控制指令', obj);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('控制失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
assistant/src/routes/hot-api/key-sender/index.ts
Normal file
20
assistant/src/routes/hot-api/key-sender/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { app } from '@/app.ts';
|
||||||
|
// import { Hotkeys } from '@kevisual/hot-api';
|
||||||
|
import { Hotkeys } from './lib.ts';
|
||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
app.route({
|
||||||
|
path: 'key-sender',
|
||||||
|
// middleware: ['admin-auth']
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
let keys = ctx.query.keys;
|
||||||
|
if (keys.includes(' ')) {
|
||||||
|
keys = keys.replace(/\s+/g, '+');
|
||||||
|
}
|
||||||
|
const hotKeys: Hotkeys = useContextKey('hotkeys', () => new Hotkeys());
|
||||||
|
if (typeof keys === 'string') {
|
||||||
|
await hotKeys.pressHotkey({
|
||||||
|
hotkey: keys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ctx.body = 'ok';
|
||||||
|
}).addTo(app);
|
||||||
89
assistant/src/routes/hot-api/key-sender/lib.ts
Normal file
89
assistant/src/routes/hot-api/key-sender/lib.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { keyboard, Key } from "@nut-tree-fork/nut-js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制功能部分的案件映射
|
||||||
|
*/
|
||||||
|
export const keyMap: Record<string, Key> = {
|
||||||
|
'ctrl': Key.LeftControl,
|
||||||
|
'leftctrl': Key.LeftControl,
|
||||||
|
'rightctrl': Key.RightControl,
|
||||||
|
'alt': Key.LeftAlt,
|
||||||
|
'leftalt': Key.LeftAlt,
|
||||||
|
'rightalt': Key.RightAlt,
|
||||||
|
'shift': Key.LeftShift,
|
||||||
|
'leftshift': Key.LeftShift,
|
||||||
|
'rightshift': Key.RightShift,
|
||||||
|
'meta': Key.LeftSuper,
|
||||||
|
'cmd': Key.LeftCmd,
|
||||||
|
'win': Key.LeftWin,
|
||||||
|
// 根据操作系统选择 Ctrl 或 Command 键
|
||||||
|
'ctrlorcommand': process.platform === 'darwin' ? Key.LeftCmd : Key.LeftControl,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将快捷键字符串转换为 Key 枚举值
|
||||||
|
* @param hotkey
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const parseHotkey = (hotkey: string): Key[] => {
|
||||||
|
return hotkey
|
||||||
|
.toLowerCase()
|
||||||
|
.split('+')
|
||||||
|
.map(key => {
|
||||||
|
const trimmed = key.trim().toLowerCase();
|
||||||
|
// 如果是修饰键,从映射表中获取
|
||||||
|
if (keyMap[trimmed]) {
|
||||||
|
return keyMap[trimmed];
|
||||||
|
}
|
||||||
|
// 如果是字母,转换为大写并查找对应的 Key
|
||||||
|
if (trimmed.length === 1 && /[a-z]/.test(trimmed)) {
|
||||||
|
const upperKey = trimmed.toUpperCase();
|
||||||
|
return Key[upperKey as keyof typeof Key] as Key;
|
||||||
|
}
|
||||||
|
// 其他情况直接查找
|
||||||
|
return Key[trimmed as keyof typeof Key] as Key;
|
||||||
|
})
|
||||||
|
.filter((key): key is Key => key !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PressHostKeysOptions = {
|
||||||
|
hotkey: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
export const pressHotkey = async (opts: PressHostKeysOptions): Promise<boolean> => {
|
||||||
|
const { hotkey, durationMs = 100 } = opts;
|
||||||
|
const keys = parseHotkey(hotkey);
|
||||||
|
|
||||||
|
console.log('准备模拟按下快捷键:', hotkey);
|
||||||
|
// 同时按下所有键
|
||||||
|
await keyboard.pressKey(...keys);
|
||||||
|
// 短暂延迟后释放
|
||||||
|
await new Promise(resolve => setTimeout(resolve, durationMs));
|
||||||
|
// 释放所有键
|
||||||
|
await keyboard.releaseKey(...keys);
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟按下一组快捷键,支持逗号分隔的多个快捷键
|
||||||
|
* @param opts
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const pressHotkeys = async (opts: PressHostKeysOptions): Promise<boolean> => {
|
||||||
|
let { hotkey } = opts;
|
||||||
|
hotkey = hotkey.replace(/\s+/g, ''); // 去除所有空格
|
||||||
|
const hotkeyList = hotkey.split(',').map(hk => hk.trim());
|
||||||
|
if (hotkeyList.length === 0) {
|
||||||
|
return await pressHotkey({ ...opts, hotkey });
|
||||||
|
}
|
||||||
|
for (const hk of hotkeyList) {
|
||||||
|
await pressHotkey({ ...opts, hotkey: hk });
|
||||||
|
// 每个快捷键之间稍作延迟
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export class Hotkeys {
|
||||||
|
pressHotkey = pressHotkey;
|
||||||
|
pressHotkeys = pressHotkeys;
|
||||||
|
}
|
||||||
@@ -2,53 +2,101 @@ import { app, assistantConfig } from '../app.ts';
|
|||||||
import './config/index.ts';
|
import './config/index.ts';
|
||||||
import './shop-install/index.ts';
|
import './shop-install/index.ts';
|
||||||
import './ai/index.ts';
|
import './ai/index.ts';
|
||||||
import './light-code/index.ts';
|
// TODO:
|
||||||
|
// import './light-code/index.ts';
|
||||||
import './user/index.ts';
|
import './user/index.ts';
|
||||||
|
import './call/index.ts'
|
||||||
|
|
||||||
|
// TODO: 移除
|
||||||
|
// import './hot-api/key-sender/index.ts';
|
||||||
|
import './opencode/index.ts';
|
||||||
|
import './remote/index.ts';
|
||||||
|
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { authCache } from '@/module/cache/auth.ts';
|
import { authCache } from '@/module/cache/auth.ts';
|
||||||
export const getTokenUser = async (ctx: any) => {
|
import { createSkill } from '@kevisual/router';
|
||||||
|
import { logger } from '@/module/logger.ts';
|
||||||
|
const getTokenUser = async (token: string) => {
|
||||||
const query = assistantConfig.query
|
const query = assistantConfig.query
|
||||||
const res = await query.post({
|
const res = await query.post({
|
||||||
path: 'user',
|
path: 'user',
|
||||||
key: 'me',
|
key: 'me',
|
||||||
token: ctx.state.token,
|
token: token,
|
||||||
});
|
});
|
||||||
if (res.code !== 200) {
|
return res;
|
||||||
return ctx.throw(401, 'not login');
|
|
||||||
}
|
|
||||||
const tokenUser = res.data || {};
|
|
||||||
return tokenUser;
|
|
||||||
}
|
}
|
||||||
const checkAuth = async (ctx: any, isAdmin = false) => {
|
export const getTokenUserCache = async (token: string) => {
|
||||||
|
const tokenUser = await authCache.get(token);
|
||||||
|
if (tokenUser) {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
data: tokenUser,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const res = await getTokenUser(token);
|
||||||
|
if (res.code === 200) {
|
||||||
|
authCache.set(token, res.data);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
export const checkAuth = async (ctx: any, isAdmin = false) => {
|
||||||
const config = assistantConfig.getConfig();
|
const config = assistantConfig.getConfig();
|
||||||
const { auth = {} } = config;
|
const { auth = {} } = config;
|
||||||
const token = ctx.query.token;
|
const token = ctx.query.token;
|
||||||
|
logger.debug('checkAuth', ctx.query, { token });
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return ctx.throw(401, 'not login');
|
return {
|
||||||
|
code: 401,
|
||||||
|
message: '未登录',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 鉴权代理
|
// 鉴权代理
|
||||||
let tokenUser = await authCache.get(token);
|
let tokenUser = await authCache.get(token);
|
||||||
if (!tokenUser) {
|
if (!tokenUser) {
|
||||||
tokenUser = await getTokenUser(ctx);
|
const tokenUserRes = await getTokenUser(token);
|
||||||
|
if (tokenUserRes.code !== 200) {
|
||||||
|
return {
|
||||||
|
code: tokenUserRes.code,
|
||||||
|
message: '验证失败' + tokenUserRes.message,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tokenUser = tokenUserRes.data;
|
||||||
|
}
|
||||||
authCache.set(token, tokenUser);
|
authCache.set(token, tokenUser);
|
||||||
}
|
}
|
||||||
|
if (ctx.state) {
|
||||||
ctx.state = {
|
ctx.state = {
|
||||||
...ctx.state,
|
...ctx.state,
|
||||||
token,
|
token,
|
||||||
tokenUser,
|
tokenUser,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
const { username } = tokenUser;
|
const { username } = tokenUser;
|
||||||
if (!auth.username) {
|
if (!auth.username) {
|
||||||
// 初始管理员账号
|
// 初始管理员账号
|
||||||
auth.username = username;
|
auth.username = username;
|
||||||
assistantConfig.setConfig({ auth });
|
assistantConfig.setConfig({ auth });
|
||||||
}
|
}
|
||||||
if (isAdmin) {
|
if (isAdmin && auth.username) {
|
||||||
if (auth.username && auth.username !== username) {
|
const admins = config.auth?.admin || [];
|
||||||
return ctx.throw(403, 'not admin user');
|
let isCheckAdmin = false;
|
||||||
|
const admin = auth.username;
|
||||||
|
if (admin === username) {
|
||||||
|
isCheckAdmin = true;
|
||||||
}
|
}
|
||||||
|
if (!isCheckAdmin && admins.length > 0 && admins.includes(username)) {
|
||||||
|
isCheckAdmin = true;
|
||||||
|
}
|
||||||
|
if (!isCheckAdmin) {
|
||||||
|
return {
|
||||||
|
code: 403,
|
||||||
|
message: '非管理员用户',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
data: { tokenUser, token }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
app
|
app
|
||||||
@@ -58,7 +106,13 @@ app
|
|||||||
description: '获取当前登录用户信息, 第一个登录的用户为管理员用户',
|
description: '获取当前登录用户信息, 第一个登录的用户为管理员用户',
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
await checkAuth(ctx);
|
if (!ctx.query?.token && ctx.appId === app.appId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const authResult = await checkAuth(ctx);
|
||||||
|
if (authResult.code !== 200) {
|
||||||
|
ctx.throw(authResult.code, authResult.message);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.addTo(app);
|
.addTo(app);
|
||||||
app
|
app
|
||||||
@@ -68,7 +122,14 @@ app
|
|||||||
description: '管理员鉴权, 获取用户信息,并验证是否为管理员。',
|
description: '管理员鉴权, 获取用户信息,并验证是否为管理员。',
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
await checkAuth(ctx, true);
|
logger.debug('query', ctx.query);
|
||||||
|
if (!ctx.query?.token && ctx.appId === app.appId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const authResult = await checkAuth(ctx, true);
|
||||||
|
if (authResult.code !== 200) {
|
||||||
|
ctx.throw(authResult.code, authResult.message);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.addTo(app);
|
.addTo(app);
|
||||||
|
|
||||||
@@ -102,6 +163,14 @@ app
|
|||||||
path: 'client',
|
path: 'client',
|
||||||
key: 'system',
|
key: 'system',
|
||||||
description: '获取系统信息',
|
description: '获取系统信息',
|
||||||
|
metadata: {
|
||||||
|
tags: ['opencode'],
|
||||||
|
...createSkill({
|
||||||
|
skill: 'view-system-info',
|
||||||
|
title: '查看系统信息',
|
||||||
|
summary: '获取服务器操作系统平台、架构和版本信息',
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
const { platform, arch, release } = os;
|
const { platform, arch, release } = os;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { runCode } from '../../module/light-code/run.ts'
|
|||||||
|
|
||||||
// http://localhost:4005/api/router?path=call
|
// http://localhost:4005/api/router?path=call
|
||||||
app.route({
|
app.route({
|
||||||
path: 'call',
|
path: 'light-code',
|
||||||
|
key: 'call',
|
||||||
// middleware: ['auth']
|
// middleware: ['auth']
|
||||||
}).define(async (ctx) => {
|
}).define(async (ctx) => {
|
||||||
const filename = ctx.query?.filename || 'root/light-code-demo/demo-router.ts'
|
const filename = ctx.query?.filename || 'root/light-code-demo/demo-router.ts'
|
||||||
|
|||||||
1
assistant/src/routes/opencode/index.ts
Normal file
1
assistant/src/routes/opencode/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import './ls.ts'
|
||||||
127
assistant/src/routes/opencode/ls.ts
Normal file
127
assistant/src/routes/opencode/ls.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { app } from '@/app.ts'
|
||||||
|
import { createSkill, tool } from "@kevisual/router";
|
||||||
|
import { opencodeManager } from './module/open.ts'
|
||||||
|
import path from "node:path";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { useKey } from '@kevisual/use-config';
|
||||||
|
|
||||||
|
// 创建一个opencode 客户端
|
||||||
|
app.route({
|
||||||
|
path: 'opencode',
|
||||||
|
key: 'create',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '创建 OpenCode 客户端',
|
||||||
|
metadata: {
|
||||||
|
tags: ['opencode'],
|
||||||
|
...createSkill({
|
||||||
|
skill: 'create-opencode-client',
|
||||||
|
title: '创建 OpenCode 客户端',
|
||||||
|
summary: '创建 OpenCode 客户端,如果存在则复用',
|
||||||
|
args: {
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const client = await opencodeManager.getClient();
|
||||||
|
ctx.body = { content: `${opencodeManager.url} OpenCode 客户端已就绪` };
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
// 关闭 opencode 客户端
|
||||||
|
app.route({
|
||||||
|
path: 'opencode',
|
||||||
|
key: 'close',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '关闭 OpenCode 客户端',
|
||||||
|
metadata: {
|
||||||
|
tags: ['opencode'],
|
||||||
|
...createSkill({
|
||||||
|
skill: 'close-opencode-client',
|
||||||
|
title: '关闭 OpenCode 客户端',
|
||||||
|
summary: '关闭 OpenCode 客户端',
|
||||||
|
args: {
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
await opencodeManager.close();
|
||||||
|
ctx.body = { content: 'OpenCode 客户端已关闭' };
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
// 调用 path: opencode key: getUrl
|
||||||
|
app.route({
|
||||||
|
path: 'opencode',
|
||||||
|
key: 'getUrl',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '获取 OpenCode 服务 URL',
|
||||||
|
metadata: {
|
||||||
|
tags: ['opencode'],
|
||||||
|
...createSkill({
|
||||||
|
skill: 'get-opencode-url',
|
||||||
|
title: '获取 OpenCode 服务 URL',
|
||||||
|
summary: '获取当前 OpenCode 服务的 URL 地址',
|
||||||
|
args: {
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const url = await opencodeManager.getUrl();
|
||||||
|
const cnbURL = useKey('CNB_VSCODE_PROXY_URI') as string | undefined;
|
||||||
|
let content = `本地访问地址: ${url}`
|
||||||
|
if (cnbURL) {
|
||||||
|
content += `\n云端访问地址: ${cnbURL.replace('{{port}}', '5000')}`;
|
||||||
|
}
|
||||||
|
ctx.body = { content };
|
||||||
|
}).addTo(app);
|
||||||
|
// 调用 path: opencode key: ls-projects
|
||||||
|
app.route({
|
||||||
|
path: 'opencode',
|
||||||
|
key: 'ls-projects'
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const client = await opencodeManager.getClient();
|
||||||
|
const projects = await client.project.list();
|
||||||
|
const currentProject = await client.project.current();
|
||||||
|
ctx.body = {
|
||||||
|
projects,
|
||||||
|
currentProject
|
||||||
|
};
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
// 调用 path: opencode key: runProject 参数 /home/ubuntu/cli/assistant
|
||||||
|
app.route({
|
||||||
|
path: 'opencode',
|
||||||
|
key: 'runProject',
|
||||||
|
middleware: ['auth'],
|
||||||
|
metadata: {
|
||||||
|
tags: ['opencode'],
|
||||||
|
...createSkill({
|
||||||
|
skill: 'run-opencode-project',
|
||||||
|
title: '运行 OpenCode 项目',
|
||||||
|
summary: '运行一个已有的 OpenCode 项目',
|
||||||
|
args: {
|
||||||
|
projectPath: tool.schema.string().optional().describe('OpenCode 项目的路径, 默认为 /workspace')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const { projectPath = '/workspace' } = ctx.query;
|
||||||
|
try {
|
||||||
|
|
||||||
|
// const directory = path.resolve(projectPath);
|
||||||
|
// const runOpencodeCli = 'opencode run hello';
|
||||||
|
// execSync(runOpencodeCli, { cwd: directory, stdio: 'inherit' });
|
||||||
|
// ctx.body = { content: `OpenCode 项目已在路径 ${directory} 运行` };
|
||||||
|
const client = await opencodeManager.getClient();
|
||||||
|
const session = await client.session.create({
|
||||||
|
query: {
|
||||||
|
directory: projectPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('Created session:', session.data.id);
|
||||||
|
ctx.body = { content: `OpenCode 项目已在路径 ${projectPath} 运行` };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.body = { content: `运行 OpenCode 项目失败, 请手动运行命令初始化: opencode run hello` };
|
||||||
|
}
|
||||||
|
}).addTo(app);
|
||||||
127
assistant/src/routes/opencode/module/open.ts
Normal file
127
assistant/src/routes/opencode/module/open.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { createOpencode, createOpencodeClient, OpencodeClient, } from "@opencode-ai/sdk";
|
||||||
|
import { randomInt } from "es-toolkit";
|
||||||
|
import getPort from "get-port";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
|
|
||||||
|
export class OpencodeManager {
|
||||||
|
private static instance: OpencodeManager | null = null;
|
||||||
|
private client: OpencodeClient | null = null;
|
||||||
|
private server: { url: string; close(): void } | null = null;
|
||||||
|
private isInitializing: boolean = false;
|
||||||
|
private currentPort: number | null = null;
|
||||||
|
|
||||||
|
public url: string = '';
|
||||||
|
private constructor() { }
|
||||||
|
|
||||||
|
static getInstance(): OpencodeManager {
|
||||||
|
if (!OpencodeManager.instance) {
|
||||||
|
OpencodeManager.instance = new OpencodeManager();
|
||||||
|
}
|
||||||
|
return OpencodeManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient(): Promise<OpencodeClient> {
|
||||||
|
// 如果已经有 client,直接返回
|
||||||
|
if (this.client) {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在初始化,等待初始化完成
|
||||||
|
if (this.isInitializing) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return this.getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始初始化
|
||||||
|
this.isInitializing = true;
|
||||||
|
try {
|
||||||
|
const port = 5000;
|
||||||
|
const currentPort = await getPort({ port: port });
|
||||||
|
if (port === currentPort) {
|
||||||
|
const result = await createOpencode({
|
||||||
|
hostname: '0.0.0.0',
|
||||||
|
port: port
|
||||||
|
});
|
||||||
|
this.url = result.server.url;
|
||||||
|
this.client = result.client;
|
||||||
|
this.server = result.server;
|
||||||
|
return this.client;
|
||||||
|
} else {
|
||||||
|
this.client = await this.createOpencodeProject({ port });
|
||||||
|
this.url = `http://localhost:${port}`;
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.isInitializing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async createOpencodeProject({
|
||||||
|
directory,
|
||||||
|
port = 5000
|
||||||
|
}: { directory?: string, port?: number }): Promise<OpencodeClient> {
|
||||||
|
const client = createOpencodeClient({
|
||||||
|
baseUrl: `http://localhost:${port}`,
|
||||||
|
directory
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
async killPort(port: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 尝试 使用命令行去关闭 port为5000的服务
|
||||||
|
if (os.platform() === 'win32') {
|
||||||
|
// Windows 平台
|
||||||
|
execSync(`netstat -ano | findstr :${port} | findstr LISTENING`).toString().split('\n').forEach(line => {
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
const pid = parts[parts.length - 1];
|
||||||
|
if (pid) {
|
||||||
|
execSync(`taskkill /PID ${pid} /F`);
|
||||||
|
console.log(`OpencodeManager: 已关闭占用端口 ${port} 的进程 PID ${pid}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Unix-like 平台
|
||||||
|
const result = execSync(`lsof -i :${port} -t`).toString();
|
||||||
|
result.split('\n').forEach(pid => {
|
||||||
|
if (pid) {
|
||||||
|
execSync(`kill -9 ${pid}`);
|
||||||
|
console.log(`OpencodeManager: 已关闭占用端口 ${port} 的进程 PID ${pid}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to close OpenCode server:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close();
|
||||||
|
this.server = null;
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const port = 5000;
|
||||||
|
const currentPort = await getPort({ port: port });
|
||||||
|
if (port === currentPort) {
|
||||||
|
this.client = null;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
await this.killPort(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
async getUrl(): Promise<string> {
|
||||||
|
if (this.url) {
|
||||||
|
return this.url;
|
||||||
|
}
|
||||||
|
if (!this.url) {
|
||||||
|
await this.getClient();
|
||||||
|
}
|
||||||
|
return 'http://localhost:5000';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const opencodeManager = OpencodeManager.getInstance();
|
||||||
54
assistant/src/routes/remote/index.ts
Normal file
54
assistant/src/routes/remote/index.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useContextKey } from "@kevisual/context";
|
||||||
|
import { app } from "../../app.ts";
|
||||||
|
import { AssistantApp } from "@/lib.ts";
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'remote',
|
||||||
|
key: 'status',
|
||||||
|
middleware: ['admin-auth'],
|
||||||
|
description: '获取远程app连接状态',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const manager = useContextKey('manager') as AssistantApp;
|
||||||
|
if (manager?.remoteApp?.isConnect()) {
|
||||||
|
const url = manager.remoteUrl || ''
|
||||||
|
ctx.body = {
|
||||||
|
content: `远程app已经链接, 访问地址:${url}`,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.body = {
|
||||||
|
content: '远程app未连接',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'remote',
|
||||||
|
key: 'connect',
|
||||||
|
middleware: ['admin-auth'],
|
||||||
|
description: '连接远程app',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const manager = useContextKey('manager') as AssistantApp;
|
||||||
|
if (!manager) {
|
||||||
|
ctx.body = {
|
||||||
|
content: '远程app管理器未初始化',
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (manager?.remoteApp?.isConnect()) {
|
||||||
|
const url = manager.remoteUrl || ''
|
||||||
|
ctx.body = {
|
||||||
|
content: `远程app已经链接, 访问地址:${url}`,
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await manager.initRemoteApp({ enabled: true, token: ctx.query?.token }).then(() => {
|
||||||
|
ctx.body = {
|
||||||
|
content: '远程app连接成功',
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
ctx.body = {
|
||||||
|
content: `远程app连接失败: ${err.message}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}).addTo(app);
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { app, assistantConfig } from '@/app.ts';
|
import { app, assistantConfig } from '@/app.ts';
|
||||||
import { AppDownload } from '@/services/app/index.ts';
|
import { AppDownload } from '@/services/app/index.ts';
|
||||||
import { AssistantApp } from '@/module/assistant/index.ts';
|
import { AssistantApp } from '@/module/assistant/index.ts';
|
||||||
import { shopDefine } from './define.ts';
|
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
...shopDefine.get('getRegistry'),
|
path: 'shop',
|
||||||
|
key: 'get-registry',
|
||||||
|
description: '获取应用商店注册表信息',
|
||||||
middleware: ['admin-auth'],
|
middleware: ['admin-auth'],
|
||||||
metadata: {
|
metadata: {
|
||||||
admin: true,
|
admin: true,
|
||||||
@@ -19,7 +20,9 @@ app
|
|||||||
|
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
...shopDefine.get('listInstalled'),
|
path: 'shop',
|
||||||
|
key: 'list-installed',
|
||||||
|
description: '列出当前已安装的所有应用',
|
||||||
middleware: ['admin-auth'],
|
middleware: ['admin-auth'],
|
||||||
metadata: {
|
metadata: {
|
||||||
admin: true,
|
admin: true,
|
||||||
@@ -35,14 +38,16 @@ app
|
|||||||
|
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
...shopDefine.get('install'),
|
path: 'shop',
|
||||||
|
key: 'install',
|
||||||
|
description: '安装指定的应用,可以指定 id、type、force 和 yes 参数',
|
||||||
middleware: ['admin-auth'],
|
middleware: ['admin-auth'],
|
||||||
metadata: {
|
metadata: {
|
||||||
admin: true,
|
admin: true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
// https://localhost:51015/client/router?path=shop&key=install
|
// https://localhost:51515/client/router?path=shop&key=install
|
||||||
const options = ctx.query?.data || {};
|
const options = ctx.query?.data || {};
|
||||||
const { id, type, force, yes } = options;
|
const { id, type, force, yes } = options;
|
||||||
assistantConfig.checkMounted();
|
assistantConfig.checkMounted();
|
||||||
@@ -60,14 +65,16 @@ app
|
|||||||
|
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
...shopDefine.get('uninstall'),
|
path: 'shop',
|
||||||
|
key: 'uninstall',
|
||||||
|
description: '卸载指定的应用,可以指定 id 和 type 参数',
|
||||||
middleware: ['admin-auth'],
|
middleware: ['admin-auth'],
|
||||||
metadata: {
|
metadata: {
|
||||||
admin: true,
|
admin: true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
// https://localhost:51015/client/router?path=shop&key=uninstall
|
// https://localhost:51515/client/router?path=shop&key=uninstall
|
||||||
const options = ctx.query?.data || {};
|
const options = ctx.query?.data || {};
|
||||||
const { id, type, yes } = options;
|
const { id, type, yes } = options;
|
||||||
const app = new AppDownload(assistantConfig);
|
const app = new AppDownload(assistantConfig);
|
||||||
|
|||||||
@@ -6,29 +6,77 @@ app.route({
|
|||||||
description: '管理员用户登录',
|
description: '管理员用户登录',
|
||||||
}).define(async (ctx) => {
|
}).define(async (ctx) => {
|
||||||
const { username, password } = ctx.query;
|
const { username, password } = ctx.query;
|
||||||
const query = assistantConfig.query;
|
const auth = assistantConfig.getConfig().auth || {};
|
||||||
const auth = assistantConfig.getConfig().auth;
|
if (auth && auth.username && auth.username !== username) {
|
||||||
const res = await query.post({
|
return ctx.throw(403, 'login user is not admin user');
|
||||||
|
}
|
||||||
|
// 发起请求,转发客户端 cookie
|
||||||
|
const res = await fetch(`${assistantConfig.baseURL}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
path: 'user',
|
path: 'user',
|
||||||
key: 'login',
|
key: 'login',
|
||||||
data: {
|
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
},
|
}),
|
||||||
})
|
});
|
||||||
if (res.code !== 200) {
|
|
||||||
|
// 转发上游服务器返回的所有 set-cookie(支持多个 cookie)
|
||||||
|
const setCookieHeaders = res.headers.getSetCookie?.() || [];
|
||||||
|
if (setCookieHeaders.length > 0) {
|
||||||
|
// 设置多个 cookie 到原生 http.ServerResponse
|
||||||
|
ctx.res.setHeader('Set-Cookie', setCookieHeaders);
|
||||||
|
} else {
|
||||||
|
// 兼容旧版本,使用 get 方法
|
||||||
|
const setCookieHeader = res.headers.get('set-cookie');
|
||||||
|
if (setCookieHeader) {
|
||||||
|
ctx.res.setHeader('Set-Cookie', setCookieHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await res.json();
|
||||||
|
console.debug('admin login response', { res: responseData });
|
||||||
|
if (responseData.code !== 200) {
|
||||||
|
console.debug('admin login failed', { res: responseData });
|
||||||
return ctx.throw(401, 'login failed');
|
return ctx.throw(401, 'login failed');
|
||||||
}
|
}
|
||||||
const loginUser = res.data.username;
|
const me = await assistantConfig.query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'me',
|
||||||
|
token: responseData.data.token,
|
||||||
|
})
|
||||||
|
if (me.code === 200) {
|
||||||
|
const loginUser = me.data.username;
|
||||||
if (auth.username && loginUser !== auth.username) {
|
if (auth.username && loginUser !== auth.username) {
|
||||||
return ctx.throw(403, 'login user is not admin user');
|
return ctx.throw(403, 'login user is not admin user');
|
||||||
}
|
}
|
||||||
if (!auth.username) {
|
if (!auth.username) {
|
||||||
// 初始管理员账号
|
// 初始管理员账号
|
||||||
auth.username = 'admin';
|
auth.username = loginUser;
|
||||||
|
if (!auth.share) {
|
||||||
|
auth.share = 'protected';
|
||||||
|
}
|
||||||
assistantConfig.setConfig({ auth });
|
assistantConfig.setConfig({ auth });
|
||||||
|
console.log('set first admin user', { username: loginUser });
|
||||||
}
|
}
|
||||||
// 保存配置
|
// 保存配置
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = res.data;
|
ctx.body = responseData.data;
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'user',
|
||||||
|
key: 'me'
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const token = ctx.query.token;
|
||||||
|
const res = await assistantConfig.query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'me',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
ctx.forward(res);
|
||||||
}).addTo(app);
|
}).addTo(app);
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
import { app, assistantConfig } from './app.ts';
|
import { app, assistantConfig } from './app.ts';
|
||||||
import { proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts';
|
import { proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts';
|
||||||
import './routes/index.ts';
|
import './routes/index.ts';
|
||||||
|
import './routes-simple/index.ts';
|
||||||
|
|
||||||
import getPort, { portNumbers } from 'get-port';
|
import getPort, { portNumbers } from 'get-port';
|
||||||
import { program } from 'commander';
|
import { program } from 'commander';
|
||||||
import { spawnSync } from 'child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import path from 'node:path'
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { AssistantApp } from './lib.ts';
|
import { AssistantApp, checkFileExists } from './lib.ts';
|
||||||
export const runServer = async (port?: number, listenPath = '127.0.0.1') => {
|
import { getBunPath } from './module/get-bun-path.ts';
|
||||||
|
import { qwenAsr } from './services/asr/qwen-asr.ts';
|
||||||
|
export const runServer = async (port: number = 51515, listenPath = '127.0.0.1') => {
|
||||||
let _port: number | undefined;
|
let _port: number | undefined;
|
||||||
if (port) {
|
if (port) {
|
||||||
_port = await getPort({ port });
|
_port = await getPort({ port });
|
||||||
@@ -18,7 +23,7 @@ export const runServer = async (port?: number, listenPath = '127.0.0.1') => {
|
|||||||
}
|
}
|
||||||
if (!_port) {
|
if (!_port) {
|
||||||
// 检车端口可用性
|
// 检车端口可用性
|
||||||
const isPortAvailable = await getPort({ port: portNumbers(51015, 52000) });
|
const isPortAvailable = await getPort({ port: portNumbers(51515, 52000) });
|
||||||
if (!isPortAvailable) {
|
if (!isPortAvailable) {
|
||||||
console.log(`Port ${isPortAvailable} is not available`);
|
console.log(`Port ${isPortAvailable} is not available`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -32,18 +37,29 @@ export const runServer = async (port?: number, listenPath = '127.0.0.1') => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
app.listen(_port, listenPath, () => {
|
app.listen(_port, listenPath, () => {
|
||||||
const protocol = assistantConfig.getHttps().protocol;
|
let showListenPath = listenPath;
|
||||||
console.log(`Server is running on ${protocol}://${listenPath}:${_port}`);
|
if (listenPath === '::') {
|
||||||
|
showListenPath = 'localhost';
|
||||||
|
}
|
||||||
|
console.log(`Server is running on ${'http'}://${showListenPath}:${_port}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
app.server.on(proxyRoute);
|
app.server.on([{
|
||||||
proxyWs();
|
id: 'handle-all',
|
||||||
const manager = new AssistantApp(assistantConfig, app);
|
func: proxyRoute as any,
|
||||||
|
},
|
||||||
|
...proxyWs(),
|
||||||
|
qwenAsr,
|
||||||
|
]);
|
||||||
|
const manager = useContextKey('manager', new AssistantApp(assistantConfig, app));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
manager.load({ runtime: 'client' }).then(() => {
|
manager.load({ runtime: 'client' }).then(() => {
|
||||||
console.log('Assistant App Loaded');
|
console.log('Assistant App Loaded');
|
||||||
});
|
});
|
||||||
|
manager.initRemoteApp()
|
||||||
|
manager.initRouterApp()
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
app,
|
app,
|
||||||
port: _port,
|
port: _port,
|
||||||
@@ -55,36 +71,96 @@ program
|
|||||||
.option('-n, --name <name>', '服务名称', 'assistant-server')
|
.option('-n, --name <name>', '服务名称', 'assistant-server')
|
||||||
.option('-p, --port <port>', '服务端口')
|
.option('-p, --port <port>', '服务端口')
|
||||||
.option('-s, --start', '是否启动服务')
|
.option('-s, --start', '是否启动服务')
|
||||||
|
.option('-r, --root <root>', 'assistant app的根目录路径')
|
||||||
|
.option('-i, --input <input>', '启动的输入文件,例如/workspace/src/main.ts')
|
||||||
.option('-e, --interpreter <interpreter>', '指定使用的解释器', 'bun')
|
.option('-e, --interpreter <interpreter>', '指定使用的解释器', 'bun')
|
||||||
.option('-i, --home', 'home目录')
|
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
// console.log('当前执行路径:', execPath, inte);
|
// console.log('当前执行路径:', execPath, inte);
|
||||||
if (options.daemon) {
|
if (options.daemon) {
|
||||||
const [_interpreter, execPath] = process.argv;
|
const [_interpreter, execPath] = process.argv;
|
||||||
const name = options.name;
|
const name = options.name;
|
||||||
const port = options.port;
|
const port = options.port;
|
||||||
let pm2Command = `pm2 start ${execPath} --interpreter ${options.interpreter} --name ${name} -- -s `;
|
const runPath = path.resolve(execPath);
|
||||||
|
|
||||||
|
// Windows 下需要对路径进行转义处理
|
||||||
|
const escapePath = (p: string) => {
|
||||||
|
// 将反斜杠转换为正斜杠,PM2 在 Windows 上更好地支持正斜杠
|
||||||
|
let normalized = p.replace(/\\/g, '/');
|
||||||
|
// 如果路径包含空格,用引号包裹
|
||||||
|
return normalized.includes(' ') ? `"${normalized}"` : normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取解释器路径
|
||||||
|
let interpreterPath = options.interpreter;
|
||||||
|
if (options.interpreter === 'bun') {
|
||||||
|
interpreterPath = getBunPath();
|
||||||
|
console.log(chalk.gray('Bun 路径:'), interpreterPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 pm2 命令字符串
|
||||||
|
let pm2Command = `pm2 start ${escapePath(runPath)} --interpreter ${escapePath(interpreterPath)} --name ${name} -- -s`;
|
||||||
|
|
||||||
if (port) {
|
if (port) {
|
||||||
pm2Command += ` -p ${port}`;
|
pm2Command += ` -p ${port}`;
|
||||||
}
|
}
|
||||||
if (options.home) {
|
if (options.root) {
|
||||||
pm2Command += ` --home`;
|
pm2Command += ` --root ${options.root}`;
|
||||||
}
|
}
|
||||||
const result = spawnSync(pm2Command, {
|
if (options.input) {
|
||||||
|
pm2Command += ` --input ${options.input}`;
|
||||||
|
}
|
||||||
|
console.log(chalk.gray('执行命令:'), pm2Command);
|
||||||
|
console.log(chalk.gray('脚本路径:'), runPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先删除可能存在的同名进程
|
||||||
|
console.log(chalk.yellow('尝试删除旧进程...'));
|
||||||
|
spawnSync(`pm2 delete ${name}`, [], {
|
||||||
shell: true,
|
shell: true,
|
||||||
stdio: 'inherit',
|
stdio: 'pipe', // 忽略删除时的输出
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result = spawnSync(pm2Command, [], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: true,
|
||||||
|
windowsHide: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
console.error('Error starting server:', result.error);
|
console.error(chalk.red('Error starting server:'), result.error.message);
|
||||||
|
console.log(chalk.yellow('\n提示: 请检查:'));
|
||||||
|
console.log(' 1. pm2 是否已安装: npm install -g pm2');
|
||||||
|
console.log(' 2. bun 是否已安装且在 PATH 中');
|
||||||
|
console.log(' 3. 尝试手动执行:', pm2Command);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log('以守护进程方式运行');
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
console.error(chalk.red(`PM2 exited with code ${result.status}`));
|
||||||
|
console.log(chalk.yellow('\n查看详细日志:'), `pm2 logs ${name}`);
|
||||||
|
console.log(chalk.yellow('查看进程状态:'), 'pm2 list');
|
||||||
|
process.exit(result.status || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.green('✓ 以守护进程方式运行'));
|
||||||
|
console.log(chalk.gray('查看日志:'), `pm2 logs ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(chalk.red('Error starting server:'), error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
} else if (options.start) {
|
} else if (options.start) {
|
||||||
console.log('启动服务', chalk.green(assistantConfig.configDir));
|
console.log('启动服务', chalk.green(assistantConfig.configDir));
|
||||||
const config = assistantConfig.getCacheAssistantConfig();
|
const config = assistantConfig.getCacheAssistantConfig();
|
||||||
const listenPort = options.port || config?.server?.port;
|
const listenPort = parseInt(options.port || config?.server?.port);
|
||||||
const listenPath = config?.server?.path || '127.0.0.1';
|
const listenPath = config?.server?.path || '::';
|
||||||
const server = await runServer(listenPort, listenPath);
|
const server = await runServer(listenPort, listenPath);
|
||||||
|
if (options.input) {
|
||||||
|
const _input = path.resolve(options.input);
|
||||||
|
if (checkFileExists(_input)) {
|
||||||
|
await import(_input);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('请使用 -s 参数启动服务');
|
console.log('请使用 -s 参数启动服务');
|
||||||
}
|
}
|
||||||
|
|||||||
25
assistant/src/services/asr/asr-manager.ts
Normal file
25
assistant/src/services/asr/asr-manager.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
import { QwenAsrRelatime } from "@kevisual/video-tools/src/asr/index.ts";
|
||||||
|
|
||||||
|
export class AsrManger {
|
||||||
|
map: Map<string, QwenAsrRelatime>;
|
||||||
|
constructor() {
|
||||||
|
this.map = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAsr(id: string) {
|
||||||
|
const asr = this.map.get(id);
|
||||||
|
return asr;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAsr(id: string, asr: QwenAsrRelatime) {
|
||||||
|
this.map.set(id, asr);
|
||||||
|
return asr;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAsr(id: string) {
|
||||||
|
this.map.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const arrManager = new AsrManger();
|
||||||
110
assistant/src/services/asr/qwen-asr.ts
Normal file
110
assistant/src/services/asr/qwen-asr.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { QwenAsrRelatime } from "@kevisual/video-tools/src/asr/index.ts";
|
||||||
|
|
||||||
|
import { Listener, WebSocketListenerFun, WebSocketReq } from "@kevisual/router";
|
||||||
|
import { callText } from "@/routes/ha-api/ha.ts";
|
||||||
|
import { assistantConfig } from "@/app.ts";
|
||||||
|
|
||||||
|
const func: WebSocketListenerFun = async (req: WebSocketReq<{ asr: QwenAsrRelatime, msgId: string, startTime?: number, loading?: boolean }>, res) => {
|
||||||
|
const { ws, emitter, id, data } = req;
|
||||||
|
let asr = ws.data.asr;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'not found id' }));
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!asr) {
|
||||||
|
if (ws.data.loading) return;
|
||||||
|
ws.data.loading = true;
|
||||||
|
|
||||||
|
const confg = assistantConfig.getConfig();
|
||||||
|
const asrConfig = confg?.asr;
|
||||||
|
if (!asrConfig?.enabled) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'asr服务未启用' }));
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = asrConfig?.token;
|
||||||
|
if (!token) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'asr服务未配置token' }));
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onConnect = () => {
|
||||||
|
const asr = ws.data.asr as QwenAsrRelatime;
|
||||||
|
ws.send(JSON.stringify({ type: 'asr', code: 200, message: 'asr服务已连接', time: Date.now() }));
|
||||||
|
if (!asr) return;
|
||||||
|
asr.emitter.on('message', (message) => {
|
||||||
|
// console.log('ASR message', message);
|
||||||
|
});
|
||||||
|
asr.emitter.on('partial', (message) => {
|
||||||
|
// console.log('ASR message', message);
|
||||||
|
let msgId = ws.data.msgId || Math.random().toString(36).substring(2, 10);
|
||||||
|
ws.data.msgId = msgId;
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'partial',
|
||||||
|
msgId: msgId,
|
||||||
|
time: Date.now(),
|
||||||
|
text: message?.text,
|
||||||
|
raw: message?.raw,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
asr.emitter.on('result', async ({ text, raw }) => {
|
||||||
|
let msgId = ws.data.msgId;
|
||||||
|
ws.data.msgId = Math.random().toString(36).substring(2, 10);
|
||||||
|
const endTime = Date.now();
|
||||||
|
console.log('cost time', ws.data.startTime ? (endTime - ws.data.startTime) : 0);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'result',
|
||||||
|
msgId: msgId,
|
||||||
|
time: Date.now(),
|
||||||
|
text,
|
||||||
|
}));
|
||||||
|
if (!text) return;
|
||||||
|
await callText(text);
|
||||||
|
console.log('toogle light time', Date.now() - endTime);
|
||||||
|
});
|
||||||
|
asr.start();
|
||||||
|
}
|
||||||
|
// 第一次请求
|
||||||
|
asr = new QwenAsrRelatime({
|
||||||
|
token,
|
||||||
|
emitter,
|
||||||
|
onConnect,
|
||||||
|
})
|
||||||
|
ws.data.asr = asr;
|
||||||
|
ws.data.loading = false;
|
||||||
|
emitter.on('close--' + id, () => {
|
||||||
|
console.log('ASR websocket 关闭, 释放资源');
|
||||||
|
asr?.close?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const isConnected = await asr.checkConnected();
|
||||||
|
if (!isConnected) return;
|
||||||
|
if (data?.type === 'blankVoice') {
|
||||||
|
asr.sendBlank();
|
||||||
|
console.log('ASR receive data', 'blank voice');
|
||||||
|
} else if (data?.voice) {
|
||||||
|
if (!data?.isRelatime) {
|
||||||
|
console.log('ASR receive data', 'has voice', !!data?.voice, data?.isRelatime);
|
||||||
|
const time = data?.time || 0;
|
||||||
|
console.log('receiveDelay', Date.now() - time);
|
||||||
|
}
|
||||||
|
const isBrowserFormat = data.format === 'float32';
|
||||||
|
|
||||||
|
let voice: Buffer;
|
||||||
|
if (isBrowserFormat) {
|
||||||
|
voice = await asr.fixBrowerBuffer(data.voice);
|
||||||
|
} else {
|
||||||
|
voice = Buffer.from(data.voice, 'base64');
|
||||||
|
}
|
||||||
|
ws.data.startTime = Date.now();
|
||||||
|
asr.sendBuffer(voice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const qwenAsr: Listener = {
|
||||||
|
id: 'qwen-asr',
|
||||||
|
path: '/ws/asr',
|
||||||
|
io: true,
|
||||||
|
func
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user