mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-21 15:55:50 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ed0e63095 | ||
|
|
0201db55ac | ||
|
|
7a9f53b63f | ||
|
|
dbc8a85cd7 | ||
|
|
3b3e4fb1ea | ||
|
|
6607b13364 | ||
|
|
cc09c304ec | ||
|
|
3c2e046bf9 | ||
|
|
bd6417c715 | ||
|
|
4bf9f04a4d | ||
|
|
c0f7735110 |
22
.githooks/pre-commit
Normal file
22
.githooks/pre-commit
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# pre-commit hook: 对暂存的文件运行 Biome 检查
|
||||||
|
# 仅检查 src/ 下的 .ts/.tsx/.js/.jsx 文件
|
||||||
|
|
||||||
|
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^src/.*\.(ts|tsx|js|jsx)$')
|
||||||
|
|
||||||
|
if [ -z "$STAGED_FILES" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running Biome lint on staged files..."
|
||||||
|
|
||||||
|
# 使用 biome lint 对暂存文件进行检查(仅 lint,不格式化,不自动修复)
|
||||||
|
echo "$STAGED_FILES" | xargs bunx biome lint --no-errors-on-unmatched
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "Biome lint failed. Fix errors or use --no-verify to bypass."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -6,13 +6,13 @@
|
|||||||
[](https://github.com/claude-code-best/claude-code/blob/main/LICENSE)
|
[](https://github.com/claude-code-best/claude-code/blob/main/LICENSE)
|
||||||
[](https://github.com/claude-code-best/claude-code/commits/main)
|
[](https://github.com/claude-code-best/claude-code/commits/main)
|
||||||
[](https://bun.sh/)
|
[](https://bun.sh/)
|
||||||
[](https://discord.gg/uApuzJWGKX)
|
[](https://discord.gg/qZU6zS7Q)
|
||||||
|
|
||||||
> Which Claude do you like? The open source one is the best.
|
> Which Claude do you like? The open source one is the best.
|
||||||
|
|
||||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
||||||
|
|
||||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
|
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q)
|
||||||
|
|
||||||
| 特性 | 说明 | 文档 |
|
| 特性 | 说明 | 文档 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -195,10 +195,9 @@
|
|||||||
},
|
},
|
||||||
"packages/acp-link": {
|
"packages/acp-link": {
|
||||||
"name": "acp-link",
|
"name": "acp-link",
|
||||||
"version": "1.1.0",
|
"version": "1.0.1",
|
||||||
"bin": {
|
"bin": {
|
||||||
"acp-link": "dist/cli/bin.js",
|
"acp-link": "dist/cli/bin.js",
|
||||||
"acp-manager": "dist/manager/bin.js",
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.19.0",
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
@@ -212,7 +211,6 @@
|
|||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.12",
|
|
||||||
"@types/selfsigned": "^2.0.4",
|
"@types/selfsigned": "^2.0.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"version": "1.5.0",
|
"version": "1.4.4",
|
||||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ acp-link --https /path/to/agent
|
|||||||
# Disable authentication (dangerous)
|
# Disable authentication (dangerous)
|
||||||
acp-link --no-auth /path/to/agent
|
acp-link --no-auth /path/to/agent
|
||||||
|
|
||||||
# Register to RCS with a specific channel group
|
|
||||||
acp-link --group my-team /path/to/agent
|
|
||||||
|
|
||||||
# Pass arguments to the agent (use -- to separate)
|
# Pass arguments to the agent (use -- to separate)
|
||||||
acp-link /path/to/agent -- --verbose --model gpt-4
|
acp-link /path/to/agent -- --verbose --model gpt-4
|
||||||
```
|
```
|
||||||
@@ -52,7 +49,7 @@ acp-link /path/to/agent -- --verbose --model gpt-4
|
|||||||
|
|
||||||
```
|
```
|
||||||
USAGE
|
USAGE
|
||||||
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] [--group value] <command>...
|
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
|
||||||
acp-link --help
|
acp-link --help
|
||||||
acp-link --version
|
acp-link --version
|
||||||
|
|
||||||
@@ -62,7 +59,6 @@ FLAGS
|
|||||||
[--debug] Enable debug logging to file
|
[--debug] Enable debug logging to file
|
||||||
[--no-auth] Disable authentication (dangerous)
|
[--no-auth] Disable authentication (dangerous)
|
||||||
[--https] Enable HTTPS with self-signed cert
|
[--https] Enable HTTPS with self-signed cert
|
||||||
[--group] Channel group ID for RCS registration (letters, digits, hyphens, underscores only)
|
|
||||||
-h --help Print help information and exit
|
-h --help Print help information and exit
|
||||||
-v --version Print version information and exit
|
-v --version Print version information and exit
|
||||||
|
|
||||||
@@ -88,34 +84,6 @@ ws://localhost:9315/ws?token=<your-token>
|
|||||||
|
|
||||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
|
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
|
||||||
|
|
||||||
## RCS Upstream
|
|
||||||
|
|
||||||
acp-link can register to a Remote Control Server (RCS) for remote access. Set the following environment variables:
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `ACP_RCS_URL` | RCS server URL (e.g. `http://rcs.example.com:3000`) |
|
|
||||||
| `ACP_RCS_TOKEN` | API token for RCS authentication |
|
|
||||||
| `ACP_RCS_GROUP` | Channel group ID to lock the agent into (letters, digits, `-`, `_` only) |
|
|
||||||
|
|
||||||
You can also use `--group <id>` on the CLI. The CLI flag takes priority over the env var.
|
|
||||||
|
|
||||||
## Manager UI
|
|
||||||
|
|
||||||
通过 `--manager` flag 启动独立的管理服务(不启动代理):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动 Manager(默认端口 9315)
|
|
||||||
acp-link --manager
|
|
||||||
|
|
||||||
# 指定端口
|
|
||||||
acp-link --manager --port 3210
|
|
||||||
```
|
|
||||||
|
|
||||||
在浏览器打开 `http://localhost:<port>` 即可访问管理界面,创建、停止、删除多个 acp-link 子进程实例并实时查看日志。
|
|
||||||
|
|
||||||
通过 Manager UI 创建的子进程会自动跳过 Manager UI。
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "acp-link",
|
"name": "acp-link",
|
||||||
"version": "2.0.0",
|
"version": "1.0.1",
|
||||||
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
|
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
|
||||||
"author": "claude-code-best",
|
"author": "claude-code-best",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -14,15 +14,12 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
|
"dev": "bun run src/cli/bin.ts",
|
||||||
"dev:remote": "ACP_RCS_URL=https://remote-control.claude-code-best.win/ ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
|
|
||||||
"dev:manager": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts --manager",
|
|
||||||
"prepublishOnly": "bun run build"
|
"prepublishOnly": "bun run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/selfsigned": "^2.0.4",
|
"@types/selfsigned": "^2.0.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1"
|
||||||
"@types/bun": "^1.3.12"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.19.0",
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export const command = buildCommand({
|
|||||||
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
|
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
|
||||||
"Use -- to pass arguments to the agent:\n" +
|
"Use -- to pass arguments to the agent:\n" +
|
||||||
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
|
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
|
||||||
"Use --manager to start the Manager Web UI instead:\n" +
|
|
||||||
" acp-link --manager\n\n" +
|
|
||||||
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
|
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -42,22 +40,6 @@ export const command = buildCommand({
|
|||||||
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
manager: {
|
|
||||||
kind: "boolean",
|
|
||||||
brief: "Start Manager Web UI (no proxy)",
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
group: {
|
|
||||||
kind: "parsed",
|
|
||||||
parse: (value: string) => {
|
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
|
||||||
throw new Error(`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
brief: "Channel group ID for RCS registration (env: ACP_RCS_GROUP)",
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
positional: {
|
positional: {
|
||||||
kind: "array",
|
kind: "array",
|
||||||
@@ -66,12 +48,12 @@ export const command = buildCommand({
|
|||||||
parse: String,
|
parse: String,
|
||||||
placeholder: "command",
|
placeholder: "command",
|
||||||
},
|
},
|
||||||
minimum: 0,
|
minimum: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
func: async function (
|
func: async function (
|
||||||
this: LocalContext,
|
this: LocalContext,
|
||||||
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; manager: boolean; group: string | undefined },
|
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean },
|
||||||
...args: readonly string[]
|
...args: readonly string[]
|
||||||
) {
|
) {
|
||||||
const port = flags.port;
|
const port = flags.port;
|
||||||
@@ -79,21 +61,6 @@ export const command = buildCommand({
|
|||||||
const debug = flags.debug;
|
const debug = flags.debug;
|
||||||
const noAuth = flags["no-auth"];
|
const noAuth = flags["no-auth"];
|
||||||
const https = flags.https;
|
const https = flags.https;
|
||||||
const manager = flags.manager;
|
|
||||||
const group = flags.group;
|
|
||||||
|
|
||||||
// Manager mode: start web UI only, no proxy
|
|
||||||
if (manager) {
|
|
||||||
const { startManager } = await import("../manager/index.js");
|
|
||||||
await startManager(port);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy mode: agent command is required
|
|
||||||
if (args.length === 0) {
|
|
||||||
console.error("Error: agent command is required (or use --manager)");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const [command, ...agentArgs] = args;
|
const [command, ...agentArgs] = args;
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
|
|
||||||
@@ -118,6 +85,6 @@ export const command = buildCommand({
|
|||||||
|
|
||||||
// Import and run the server
|
// Import and run the server
|
||||||
const { startServer } = await import("../server.js");
|
const { startServer } = await import("../server.js");
|
||||||
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https, group });
|
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
export const MANAGER_HTML = `<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>ACP Manager</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
background: #f8f7f5;
|
|
||||||
color: #1a1a1a;
|
|
||||||
padding: 24px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
h1 { font-size: 20px; font-weight: 600; margin-bottom: 20px; color: #1a1a1a; }
|
|
||||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
|
||||||
.create-form {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e5e2de;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.form-group label { font-size: 12px; color: #888; }
|
|
||||||
.form-group input {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #d5d2ce;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
color: #1a1a1a;
|
|
||||||
font-size: 14px;
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
.form-group input.wide { width: 400px; }
|
|
||||||
button {
|
|
||||||
background: #d77757;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
button:hover { background: #c4694b; }
|
|
||||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
button.danger { background: #a63d3d; }
|
|
||||||
button.danger:hover { background: #c44a4a; }
|
|
||||||
button.small { padding: 4px 10px; font-size: 12px; }
|
|
||||||
.instances { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.instance-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e5e2de;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.instance-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 16px;
|
|
||||||
gap: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.instance-header:hover { background: #f5f3f0; }
|
|
||||||
.status-dot {
|
|
||||||
width: 10px; height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.status-dot.running { background: #4ade80; box-shadow: 0 0 6px #4ade8066; }
|
|
||||||
.status-dot.stopped { background: #aaa; }
|
|
||||||
.status-dot.failed { background: #f87171; box-shadow: 0 0 6px #f8717166; }
|
|
||||||
.instance-info { flex: 1; display: flex; gap: 16px; align-items: center; font-size: 13px; }
|
|
||||||
.instance-info .group { font-weight: 600; color: #d77757; }
|
|
||||||
.instance-info .cmd { color: #888; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.instance-info .pid { color: #999; font-size: 12px; }
|
|
||||||
.instance-info .uptime { color: #999; font-size: 12px; }
|
|
||||||
.instance-actions { display: flex; gap: 6px; }
|
|
||||||
.expand-icon { color: #999; font-size: 12px; transition: transform 0.2s; }
|
|
||||||
.expand-icon.open { transform: rotate(90deg); }
|
|
||||||
.log-panel {
|
|
||||||
display: none;
|
|
||||||
border-top: 1px solid #e5e2de;
|
|
||||||
background: #faf9f7;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.log-panel.visible { display: block; }
|
|
||||||
.log-line { white-space: pre-wrap; word-break: break-all; }
|
|
||||||
.log-line.stdout { color: #333; }
|
|
||||||
.log-line.stderr { color: #d94040; }
|
|
||||||
.empty { color: #999; text-align: center; padding: 40px; font-size: 14px; }
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
body { padding: 12px; }
|
|
||||||
.create-form { flex-wrap: wrap; }
|
|
||||||
.form-group input, .form-group input.wide { width: 100%; }
|
|
||||||
.form-group { flex: 1 1 120px; min-width: 0; }
|
|
||||||
.instance-header { flex-wrap: wrap; padding: 10px 12px; gap: 8px; }
|
|
||||||
.instance-info { flex-wrap: wrap; gap: 6px; font-size: 12px; }
|
|
||||||
.instance-info .cmd { max-width: 100%; }
|
|
||||||
button.small { padding: 8px 14px; min-height: 44px; font-size: 13px; }
|
|
||||||
.log-panel { max-height: 50vh; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
<h1>ACP Manager</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="create-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Group</label>
|
|
||||||
<input type="text" id="inp-group" placeholder="my-group" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>ACP Command</label>
|
|
||||||
<input type="text" id="inp-command" class="wide" placeholder="/path/to/agent --verbose" />
|
|
||||||
</div>
|
|
||||||
<button id="btn-create">Create</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="instances" id="instance-list"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
var listEl = document.getElementById('instance-list');
|
|
||||||
var esMap = {};
|
|
||||||
var instances = [];
|
|
||||||
var inpGroup = document.getElementById('inp-group');
|
|
||||||
var inpCommand = document.getElementById('inp-command');
|
|
||||||
var btnCreate = document.getElementById('btn-create');
|
|
||||||
|
|
||||||
// localStorage persistence
|
|
||||||
function loadForm() {
|
|
||||||
try {
|
|
||||||
inpGroup.value = localStorage.getItem('acp-mgr-group') || '';
|
|
||||||
inpCommand.value = localStorage.getItem('acp-mgr-command') || '';
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
function saveForm() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('acp-mgr-group', inpGroup.value);
|
|
||||||
localStorage.setItem('acp-mgr-command', inpCommand.value);
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
inpGroup.addEventListener('input', saveForm);
|
|
||||||
inpCommand.addEventListener('input', saveForm);
|
|
||||||
loadForm();
|
|
||||||
|
|
||||||
btnCreate.addEventListener('click', function() {
|
|
||||||
var group = inpGroup.value.trim();
|
|
||||||
var command = inpCommand.value.trim();
|
|
||||||
if (!group || !command) return alert('Both fields required');
|
|
||||||
btnCreate.disabled = true;
|
|
||||||
fetch('/api/instances', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({ group: group, command: command }),
|
|
||||||
}).then(function() { fetchInstances(); })
|
|
||||||
.finally(function() { btnCreate.disabled = false; });
|
|
||||||
});
|
|
||||||
|
|
||||||
// event delegation for instance actions
|
|
||||||
listEl.addEventListener('click', function(e) {
|
|
||||||
var btn = e.target.closest('[data-action]');
|
|
||||||
if (btn) {
|
|
||||||
e.stopPropagation();
|
|
||||||
var id = btn.getAttribute('data-id');
|
|
||||||
var action = btn.getAttribute('data-action');
|
|
||||||
if (action === 'stop') stopInstance(id);
|
|
||||||
else if (action === 'delete') deleteInstance(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var header = e.target.closest('.instance-header');
|
|
||||||
if (header) {
|
|
||||||
var cardId = header.closest('.instance-card').getAttribute('data-id');
|
|
||||||
toggleLog(cardId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchInstances() {
|
|
||||||
var res = await fetch('/api/instances');
|
|
||||||
instances = await res.json();
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function uptime(start) {
|
|
||||||
var s = Math.floor((Date.now() - start) / 1000);
|
|
||||||
if (s < 60) return s + 's';
|
|
||||||
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
|
|
||||||
return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(s) {
|
|
||||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
if (instances.length === 0) {
|
|
||||||
listEl.innerHTML = '<div class="empty">No instances. Create one above.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Diff-based update: only rebuild cards whose status changed
|
|
||||||
var existingCards = {};
|
|
||||||
listEl.querySelectorAll('.instance-card').forEach(function(card) {
|
|
||||||
existingCards[card.getAttribute('data-id')] = card;
|
|
||||||
});
|
|
||||||
|
|
||||||
var newIds = new Set(instances.map(function(i) { return i.id; }));
|
|
||||||
|
|
||||||
// Remove cards that no longer exist
|
|
||||||
for (var eid in existingCards) {
|
|
||||||
if (!newIds.has(eid)) {
|
|
||||||
closeLog(eid);
|
|
||||||
existingCards[eid].remove();
|
|
||||||
delete existingCards[eid];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update or create cards in order
|
|
||||||
instances.forEach(function(inst) {
|
|
||||||
var card = existingCards[inst.id];
|
|
||||||
if (!card) {
|
|
||||||
// New instance — create card
|
|
||||||
card = document.createElement('div');
|
|
||||||
card.className = 'instance-card';
|
|
||||||
card.setAttribute('data-id', inst.id);
|
|
||||||
card.innerHTML =
|
|
||||||
'<div class="instance-header">' +
|
|
||||||
'<span class="expand-icon">▶</span>' +
|
|
||||||
'<span class="status-dot"></span>' +
|
|
||||||
'<div class="instance-info">' +
|
|
||||||
'<span class="group"></span>' +
|
|
||||||
'<span class="cmd"></span>' +
|
|
||||||
'<span class="pid"></span>' +
|
|
||||||
'<span class="uptime"></span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="instance-actions"></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="log-panel" id="log-' + inst.id + '"></div>';
|
|
||||||
listEl.appendChild(card);
|
|
||||||
}
|
|
||||||
// Update card content
|
|
||||||
card.querySelector('.status-dot').className = 'status-dot ' + inst.status;
|
|
||||||
card.querySelector('.group').textContent = inst.group;
|
|
||||||
card.querySelector('.cmd').textContent = inst.command;
|
|
||||||
card.querySelector('.pid').textContent = inst.pid ? 'PID ' + inst.pid : '';
|
|
||||||
card.querySelector('.uptime').textContent = inst.status === 'running' ? uptime(inst.startTime) : '';
|
|
||||||
|
|
||||||
// Update action buttons
|
|
||||||
var actions = card.querySelector('.instance-actions');
|
|
||||||
var prevStatus = card.getAttribute('data-status');
|
|
||||||
if (prevStatus !== inst.status) {
|
|
||||||
card.setAttribute('data-status', inst.status);
|
|
||||||
actions.innerHTML = inst.status === 'running'
|
|
||||||
? '<button class="small danger" data-action="stop" data-id="' + inst.id + '">Stop</button>'
|
|
||||||
: '<button class="small danger" data-action="delete" data-id="' + inst.id + '">Delete</button>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopInstance(id) {
|
|
||||||
var btn = listEl.querySelector('[data-action="stop"][data-id="' + id + '"]');
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
await fetch('/api/instances/' + id + '/stop', { method: 'POST' });
|
|
||||||
await fetchInstances();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteInstance(id) {
|
|
||||||
var btn = listEl.querySelector('[data-action="delete"][data-id="' + id + '"]');
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
await fetch('/api/instances/' + id, { method: 'DELETE' });
|
|
||||||
closeLog(id);
|
|
||||||
await fetchInstances();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleLog(id) {
|
|
||||||
var panel = document.getElementById('log-' + id);
|
|
||||||
if (!panel) return;
|
|
||||||
if (panel.classList.contains('visible')) {
|
|
||||||
closeLog(id);
|
|
||||||
} else {
|
|
||||||
openLog(id);
|
|
||||||
}
|
|
||||||
var icon = listEl.querySelector('[data-id="' + id + '"] .expand-icon');
|
|
||||||
if (icon) icon.classList.toggle('open', panel.classList.contains('visible'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function openLog(id) {
|
|
||||||
var panel = document.getElementById('log-' + id);
|
|
||||||
if (!panel) return;
|
|
||||||
panel.classList.add('visible');
|
|
||||||
panel.innerHTML = '';
|
|
||||||
var es = new EventSource('/api/instances/' + id + '/logs');
|
|
||||||
esMap[id] = es;
|
|
||||||
var scrollPending = false;
|
|
||||||
es.onmessage = function(e) {
|
|
||||||
try {
|
|
||||||
var entry = JSON.parse(e.data);
|
|
||||||
var line = document.createElement('div');
|
|
||||||
line.className = 'log-line ' + entry.stream;
|
|
||||||
var time = new Date(entry.timestamp).toLocaleTimeString();
|
|
||||||
line.textContent = '[' + time + '] ' + entry.text;
|
|
||||||
panel.appendChild(line);
|
|
||||||
if (panel.children.length > 500) panel.removeChild(panel.firstChild);
|
|
||||||
if (!scrollPending) {
|
|
||||||
scrollPending = true;
|
|
||||||
requestAnimationFrame(function() {
|
|
||||||
panel.scrollTop = panel.scrollHeight;
|
|
||||||
scrollPending = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch(err) {}
|
|
||||||
};
|
|
||||||
es.onerror = function() {
|
|
||||||
es.close();
|
|
||||||
delete esMap[id];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLog(id) {
|
|
||||||
if (esMap[id]) {
|
|
||||||
esMap[id].close();
|
|
||||||
delete esMap[id];
|
|
||||||
}
|
|
||||||
var panel = document.getElementById('log-' + id);
|
|
||||||
if (panel) panel.classList.remove('visible');
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchInstances();
|
|
||||||
setInterval(fetchInstances, 3000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { serve } from "@hono/node-server";
|
|
||||||
import { ProcessManager } from "./manager.js";
|
|
||||||
import { createApp } from "./routes.js";
|
|
||||||
|
|
||||||
export async function startManager(port: number): Promise<void> {
|
|
||||||
const manager = new ProcessManager();
|
|
||||||
const app = createApp(manager);
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
||||||
|
|
||||||
let shuttingDown = false;
|
|
||||||
const shutdown = async () => {
|
|
||||||
if (shuttingDown) return;
|
|
||||||
shuttingDown = true;
|
|
||||||
console.log("Shutting down...");
|
|
||||||
await manager.shutdownAll();
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
process.on("SIGTERM", shutdown);
|
|
||||||
process.on("SIGINT", shutdown);
|
|
||||||
|
|
||||||
const server = serve({ fetch: app.fetch, port });
|
|
||||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
||||||
if (err.code === "EADDRINUSE") {
|
|
||||||
console.error(`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`);
|
|
||||||
} else {
|
|
||||||
console.error(`\n Error: ${err.message}\n`);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log();
|
|
||||||
console.log(` 🖥️ ACP Manager`);
|
|
||||||
console.log();
|
|
||||||
console.log(` URL: http://localhost:${port}`);
|
|
||||||
console.log();
|
|
||||||
console.log(` Press Ctrl+C to stop`);
|
|
||||||
console.log();
|
|
||||||
|
|
||||||
// Keep running
|
|
||||||
await new Promise(() => {});
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import type { AcpInstance, InstanceSummary, LogEntry } from "./types.js";
|
|
||||||
|
|
||||||
function log(tag: string, msg: string) {
|
|
||||||
const ts = new Date().toISOString();
|
|
||||||
console.log(`[${ts}] [${tag}] ${msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_LOG_LINES = 2000;
|
|
||||||
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
||||||
|
|
||||||
export class ProcessManager {
|
|
||||||
private instances = new Map<string, AcpInstance>();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
private processes = new Map<string, any>();
|
|
||||||
|
|
||||||
create(group: string, command: string): AcpInstance {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
const instance: AcpInstance = {
|
|
||||||
id,
|
|
||||||
group,
|
|
||||||
command,
|
|
||||||
status: "running",
|
|
||||||
pid: undefined,
|
|
||||||
startTime: Date.now(),
|
|
||||||
exitCode: null,
|
|
||||||
logs: [],
|
|
||||||
subscribers: new Set(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const args = this.parseCommand(command);
|
|
||||||
const fullArgs = ["--group", group, ...args];
|
|
||||||
|
|
||||||
const proc = Bun.spawn(["acp-link", ...fullArgs], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
env: { ...Bun.env, ACP_CHILD: "1" },
|
|
||||||
});
|
|
||||||
|
|
||||||
instance.pid = proc.pid;
|
|
||||||
this.instances.set(id, instance);
|
|
||||||
this.processes.set(id, proc);
|
|
||||||
log("manager", `created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(" ")}"`);
|
|
||||||
|
|
||||||
this.pipeStream(proc.stdout, id, "stdout");
|
|
||||||
this.pipeStream(proc.stderr, id, "stderr");
|
|
||||||
|
|
||||||
proc.exited.then((code) => {
|
|
||||||
instance.status = code === 0 ? "stopped" : "failed";
|
|
||||||
instance.exitCode = code;
|
|
||||||
instance.pid = undefined;
|
|
||||||
this.processes.delete(id);
|
|
||||||
log("manager", `instance ${id.slice(0, 8)} ${instance.status} exit=${code}`);
|
|
||||||
this.notifyStatus(instance);
|
|
||||||
});
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(id: string): boolean {
|
|
||||||
const proc = this.processes.get(id);
|
|
||||||
if (!proc) return false;
|
|
||||||
const inst = this.instances.get(id);
|
|
||||||
log("manager", `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`);
|
|
||||||
proc.kill("SIGTERM");
|
|
||||||
// Immediately mark as stopped to prevent stale state
|
|
||||||
if (inst) {
|
|
||||||
inst.status = "stopped";
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(id: string): boolean {
|
|
||||||
const instance = this.instances.get(id);
|
|
||||||
if (!instance) return false;
|
|
||||||
if (instance.status === "running") return false;
|
|
||||||
instance.subscribers.clear();
|
|
||||||
this.instances.delete(id);
|
|
||||||
log("manager", `removed instance ${id.slice(0, 8)} group=${instance.group}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
list(): InstanceSummary[] {
|
|
||||||
return Array.from(this.instances.values()).map(this.toSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
get(id: string): AcpInstance | undefined {
|
|
||||||
return this.instances.get(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(id: string, callback: (entry: LogEntry) => void): () => void {
|
|
||||||
const instance = this.instances.get(id);
|
|
||||||
if (!instance) return () => {};
|
|
||||||
instance.subscribers.add(callback);
|
|
||||||
return () => instance.subscribers.delete(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
async shutdownAll(): Promise<void> {
|
|
||||||
const running = Array.from(this.processes.entries());
|
|
||||||
if (running.length === 0) return;
|
|
||||||
|
|
||||||
log("manager", `shutting down ${running.length} running instance(s)...`);
|
|
||||||
for (const [id, proc] of running) {
|
|
||||||
try {
|
|
||||||
proc.kill("SIGTERM");
|
|
||||||
log("manager", `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`);
|
|
||||||
} catch {
|
|
||||||
// already dead
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = new Promise<void>((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS));
|
|
||||||
await Promise.race([
|
|
||||||
Promise.all(running.map(([, proc]) => proc.exited.catch(() => {}))),
|
|
||||||
timeout,
|
|
||||||
]);
|
|
||||||
|
|
||||||
for (const [id, proc] of running) {
|
|
||||||
try {
|
|
||||||
proc.kill("SIGKILL");
|
|
||||||
log("manager", `sent SIGKILL to ${id.slice(0, 8)}`);
|
|
||||||
} catch {
|
|
||||||
// already dead
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log("manager", "all instances shut down");
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseCommand(command: string): string[] {
|
|
||||||
const args: string[] = [];
|
|
||||||
let current = "";
|
|
||||||
let inQuote: string | null = null;
|
|
||||||
|
|
||||||
for (const ch of command) {
|
|
||||||
if (inQuote) {
|
|
||||||
if (ch === inQuote) {
|
|
||||||
inQuote = null;
|
|
||||||
} else {
|
|
||||||
current += ch;
|
|
||||||
}
|
|
||||||
} else if (ch === '"' || ch === "'") {
|
|
||||||
inQuote = ch;
|
|
||||||
} else if (ch === " " || ch === "\t") {
|
|
||||||
if (current) {
|
|
||||||
args.push(current);
|
|
||||||
current = "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
current += ch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (current) args.push(current);
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
private pipeStream(
|
|
||||||
readable: ReadableStream<Uint8Array>,
|
|
||||||
instanceId: string,
|
|
||||||
stream: "stdout" | "stderr",
|
|
||||||
) {
|
|
||||||
const reader = readable.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
const processChunk = () => {
|
|
||||||
reader
|
|
||||||
.read()
|
|
||||||
.then(({ done, value }) => {
|
|
||||||
if (done) {
|
|
||||||
if (buffer) this.appendLog(instanceId, buffer, stream);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() ?? "";
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line) this.appendLog(instanceId, line, stream);
|
|
||||||
}
|
|
||||||
processChunk();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// stream ended or error
|
|
||||||
});
|
|
||||||
};
|
|
||||||
processChunk();
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendLog(instanceId: string, text: string, stream: "stdout" | "stderr") {
|
|
||||||
const instance = this.instances.get(instanceId);
|
|
||||||
if (!instance) return;
|
|
||||||
|
|
||||||
const entry: LogEntry = { timestamp: Date.now(), stream, text };
|
|
||||||
instance.logs.push(entry);
|
|
||||||
if (instance.logs.length > MAX_LOG_LINES) {
|
|
||||||
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sub of instance.subscribers) {
|
|
||||||
try {
|
|
||||||
sub(entry);
|
|
||||||
} catch {
|
|
||||||
// subscriber error, remove it
|
|
||||||
instance.subscribers.delete(sub);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private notifyStatus(instance: AcpInstance) {
|
|
||||||
const statusEntry: LogEntry = {
|
|
||||||
timestamp: Date.now(),
|
|
||||||
stream: "stderr",
|
|
||||||
text: `[${instance.status}] exit code: ${instance.exitCode}`,
|
|
||||||
};
|
|
||||||
for (const sub of instance.subscribers) {
|
|
||||||
try {
|
|
||||||
sub(statusEntry);
|
|
||||||
} catch {
|
|
||||||
instance.subscribers.delete(sub);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private toSummary(inst: AcpInstance): InstanceSummary {
|
|
||||||
return {
|
|
||||||
id: inst.id,
|
|
||||||
group: inst.group,
|
|
||||||
command: inst.command,
|
|
||||||
status: inst.status,
|
|
||||||
pid: inst.pid,
|
|
||||||
startTime: inst.startTime,
|
|
||||||
exitCode: inst.exitCode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import type { ProcessManager } from "./manager.js";
|
|
||||||
import { MANAGER_HTML } from "./html.js";
|
|
||||||
|
|
||||||
function logReq(method: string, path: string, status?: number) {
|
|
||||||
const ts = new Date().toISOString();
|
|
||||||
const suffix = status != null ? ` -> ${status}` : "";
|
|
||||||
console.log(`[${ts}] [http] ${method} ${path}${suffix}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createApp(manager: ProcessManager): Hono {
|
|
||||||
const app = new Hono();
|
|
||||||
|
|
||||||
app.get("/", (c) => {
|
|
||||||
logReq("GET", "/", 200);
|
|
||||||
return c.html(MANAGER_HTML);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/api/instances", (c) => {
|
|
||||||
const list = manager.list();
|
|
||||||
logReq("GET", "/api/instances", 200);
|
|
||||||
return c.json(list);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/instances", async (c) => {
|
|
||||||
let body: { group?: string; command?: string };
|
|
||||||
try {
|
|
||||||
body = await c.req.json<{ group?: string; command?: string }>();
|
|
||||||
} catch {
|
|
||||||
logReq("POST", "/api/instances", 400);
|
|
||||||
return c.json({ error: "invalid JSON body" }, 400);
|
|
||||||
}
|
|
||||||
if (!body.group?.trim() || !body.command?.trim()) {
|
|
||||||
logReq("POST", "/api/instances", 400);
|
|
||||||
return c.json({ error: "group and command are required" }, 400);
|
|
||||||
}
|
|
||||||
const instance = manager.create(body.group.trim(), body.command.trim());
|
|
||||||
logReq("POST", `/api/instances group=${body.group}`, 201);
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
id: instance.id,
|
|
||||||
group: instance.group,
|
|
||||||
command: instance.command,
|
|
||||||
status: instance.status,
|
|
||||||
pid: instance.pid,
|
|
||||||
startTime: instance.startTime,
|
|
||||||
exitCode: instance.exitCode,
|
|
||||||
},
|
|
||||||
201,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/instances/:id/stop", (c) => {
|
|
||||||
const id = c.req.param("id");
|
|
||||||
const inst = manager.get(id);
|
|
||||||
if (!inst) {
|
|
||||||
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 404);
|
|
||||||
return c.json({ error: "not found" }, 404);
|
|
||||||
}
|
|
||||||
if (inst.status !== "running") {
|
|
||||||
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 400);
|
|
||||||
return c.json({ error: "not running" }, 400);
|
|
||||||
}
|
|
||||||
manager.stop(inst.id);
|
|
||||||
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 200);
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete("/api/instances/:id", (c) => {
|
|
||||||
const id = c.req.param("id");
|
|
||||||
const inst = manager.get(id);
|
|
||||||
if (!inst) {
|
|
||||||
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 404);
|
|
||||||
return c.json({ error: "not found" }, 404);
|
|
||||||
}
|
|
||||||
if (inst.status === "running") {
|
|
||||||
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 400);
|
|
||||||
return c.json({ error: "still running" }, 400);
|
|
||||||
}
|
|
||||||
manager.remove(inst.id);
|
|
||||||
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 200);
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/api/instances/:id/logs", (c) => {
|
|
||||||
const id = c.req.param("id");
|
|
||||||
const inst = manager.get(id);
|
|
||||||
if (!inst) {
|
|
||||||
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs`, 404);
|
|
||||||
return c.json({ error: "not found" }, 404);
|
|
||||||
}
|
|
||||||
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs SSE`);
|
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
|
|
||||||
const send = (data: string) => {
|
|
||||||
try {
|
|
||||||
controller.enqueue(encoder.encode(data));
|
|
||||||
} catch {
|
|
||||||
// stream closed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// send historical logs
|
|
||||||
for (const log of inst.logs) {
|
|
||||||
send(`data: ${JSON.stringify(log)}\n\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// subscribe to new logs
|
|
||||||
const unsub = manager.subscribe(inst.id, (entry) => {
|
|
||||||
send(`data: ${JSON.stringify(entry)}\n\n`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// keepalive every 15s
|
|
||||||
const keepalive = setInterval(() => {
|
|
||||||
send(": keepalive\n\n");
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
unsub();
|
|
||||||
clearInterval(keepalive);
|
|
||||||
logReq("SSE", `/api/instances/${id.slice(0, 8)}/logs closed`);
|
|
||||||
try {
|
|
||||||
controller.close();
|
|
||||||
} catch {
|
|
||||||
// already closed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
c.req.raw.signal.addEventListener("abort", cleanup, { once: true });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/event-stream",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
Connection: "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Catch-all: log unmatched routes for debugging
|
|
||||||
app.all("*", (c) => {
|
|
||||||
logReq(c.req.method, c.req.path, 404);
|
|
||||||
return c.json({ error: "not found", path: c.req.path }, 404);
|
|
||||||
});
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
export type InstanceStatus = "running" | "stopped" | "failed";
|
|
||||||
|
|
||||||
export interface AcpInstance {
|
|
||||||
id: string;
|
|
||||||
group: string;
|
|
||||||
command: string;
|
|
||||||
status: InstanceStatus;
|
|
||||||
pid: number | undefined;
|
|
||||||
startTime: number;
|
|
||||||
exitCode: number | null;
|
|
||||||
logs: LogEntry[];
|
|
||||||
subscribers: Set<(entry: LogEntry) => void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogEntry {
|
|
||||||
timestamp: number;
|
|
||||||
stream: "stdout" | "stderr";
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateInstanceRequest {
|
|
||||||
group: string;
|
|
||||||
command: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InstanceSummary {
|
|
||||||
id: string;
|
|
||||||
group: string;
|
|
||||||
command: string;
|
|
||||||
status: InstanceStatus;
|
|
||||||
pid: number | undefined;
|
|
||||||
startTime: number;
|
|
||||||
exitCode: number | null;
|
|
||||||
}
|
|
||||||
@@ -22,8 +22,6 @@ export interface ServerConfig {
|
|||||||
https?: boolean;
|
https?: boolean;
|
||||||
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
|
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
|
||||||
permissionMode?: string;
|
permissionMode?: string;
|
||||||
/** Channel group ID for RCS registration */
|
|
||||||
group?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending permission request
|
// Pending permission request
|
||||||
@@ -610,16 +608,11 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
|||||||
// Initialize RCS upstream client if configured
|
// Initialize RCS upstream client if configured
|
||||||
const rcsUrl = process.env.ACP_RCS_URL;
|
const rcsUrl = process.env.ACP_RCS_URL;
|
||||||
const rcsToken = process.env.ACP_RCS_TOKEN;
|
const rcsToken = process.env.ACP_RCS_TOKEN;
|
||||||
const rcsGroup = config.group || process.env.ACP_RCS_GROUP;
|
|
||||||
if (rcsGroup && !/^[a-zA-Z0-9_-]+$/.test(rcsGroup)) {
|
|
||||||
throw new Error(`Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`);
|
|
||||||
}
|
|
||||||
if (rcsUrl) {
|
if (rcsUrl) {
|
||||||
rcsUpstream = new RcsUpstreamClient({
|
rcsUpstream = new RcsUpstreamClient({
|
||||||
rcsUrl,
|
rcsUrl,
|
||||||
apiToken: rcsToken || "",
|
apiToken: rcsToken || "",
|
||||||
agentName: command,
|
agentName: command,
|
||||||
channelGroupId: rcsGroup || undefined,
|
|
||||||
maxSessions: 1,
|
maxSessions: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -883,16 +876,20 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
|||||||
authEnabled: !!AUTH_TOKEN,
|
authEnabled: !!AUTH_TOKEN,
|
||||||
}, "started");
|
}, "started");
|
||||||
|
|
||||||
// Graceful shutdown — close RCS upstream
|
|
||||||
const shutdown = async () => {
|
|
||||||
if (rcsUpstream) {
|
|
||||||
await rcsUpstream.close();
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
process.on("SIGINT", shutdown);
|
|
||||||
process.on("SIGTERM", shutdown);
|
|
||||||
|
|
||||||
// Keep the server running
|
// Keep the server running
|
||||||
await new Promise(() => {});
|
await new Promise(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown — close RCS upstream on process exit
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
if (rcsUpstream) {
|
||||||
|
await rcsUpstream.close();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
process.on("SIGTERM", async () => {
|
||||||
|
if (rcsUpstream) {
|
||||||
|
await rcsUpstream.close();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "esnext",
|
"module": "NodeNext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|
||||||
// Node.js module resolution
|
// Node.js module resolution
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "NodeNext",
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
|
|
||||||
// Output
|
// Output
|
||||||
@@ -30,8 +30,7 @@
|
|||||||
// Some stricter flags (disabled by default)
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false
|
||||||
"types": ["bun"],
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "src/__tests__"]
|
"exclude": ["node_modules", "dist", "src/__tests__"]
|
||||||
|
|||||||
@@ -90,20 +90,9 @@ export function getUuidFromRequest(c: Context): string | undefined {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* UUID-based auth for Web UI routes (no-login mode).
|
* UUID-based auth for Web UI routes (no-login mode).
|
||||||
* Accepts UUID in query param/header, OR a valid API key via Authorization header.
|
* Requires a UUID in query param or header, injects it into context as c.set("uuid").
|
||||||
*/
|
*/
|
||||||
export async function uuidAuth(c: Context, next: Next) {
|
export async function uuidAuth(c: Context, next: Next) {
|
||||||
// Try API key auth via Authorization header
|
|
||||||
const bearer = extractBearerToken(c);
|
|
||||||
if (bearer && validateApiKey(bearer)) {
|
|
||||||
// Valid API key — generate a stable UUID from the key for downstream use
|
|
||||||
const uuid = getUuidFromRequest(c);
|
|
||||||
c.set("uuid", uuid || bearer);
|
|
||||||
await next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to UUID auth
|
|
||||||
const uuid = getUuidFromRequest(c);
|
const uuid = getUuidFromRequest(c);
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);
|
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
|
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
|
||||||
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||||
import { storeBindSession } from "../../store";
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -10,13 +9,6 @@ app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
|
|||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const username = c.get("username");
|
const username = c.get("username");
|
||||||
const result = registerEnvironment({ ...body, username });
|
const result = registerEnvironment({ ...body, username });
|
||||||
// Bind ACP session to the group ID so the web UI can find it by group
|
|
||||||
if (result.session_id) {
|
|
||||||
const groupId = body.bridge_id as string | undefined;
|
|
||||||
if (groupId) {
|
|
||||||
storeBindSession(result.session_id, groupId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c.json(result, 200);
|
return c.json(result, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
storeUpdateEnvironment,
|
storeUpdateEnvironment,
|
||||||
storeListActiveEnvironments,
|
storeListActiveEnvironments,
|
||||||
storeListActiveEnvironmentsByUsername,
|
storeListActiveEnvironmentsByUsername,
|
||||||
storeListSessionsByEnvironment,
|
|
||||||
} from "../store";
|
} from "../store";
|
||||||
import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api";
|
import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api";
|
||||||
import type { EnvironmentRecord } from "../store";
|
import type { EnvironmentRecord } from "../store";
|
||||||
@@ -21,7 +20,6 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse {
|
|||||||
username: row.username,
|
username: row.username,
|
||||||
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
|
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
|
||||||
worker_type: row.workerType,
|
worker_type: row.workerType,
|
||||||
channel_group_id: row.bridgeId,
|
|
||||||
capabilities: row.capabilities,
|
capabilities: row.capabilities,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -43,19 +41,14 @@ export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata
|
|||||||
});
|
});
|
||||||
|
|
||||||
let sessionId: string | undefined;
|
let sessionId: string | undefined;
|
||||||
// ACP agents: reuse existing session or create one
|
// ACP agents: auto-create a session so they appear in the dashboard sessions list
|
||||||
if (workerType === "acp") {
|
if (workerType === "acp") {
|
||||||
const existing = storeListSessionsByEnvironment(record.id);
|
const session = storeCreateSession({
|
||||||
if (existing.length > 0) {
|
environmentId: record.id,
|
||||||
sessionId = existing[0].id;
|
title: req.machine_name || "ACP Agent",
|
||||||
} else {
|
source: "acp",
|
||||||
const session = storeCreateSession({
|
});
|
||||||
environmentId: record.id,
|
sessionId = session.id;
|
||||||
title: req.machine_name || "ACP Agent",
|
|
||||||
source: "acp",
|
|
||||||
});
|
|
||||||
sessionId = session.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };
|
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };
|
||||||
|
|||||||
@@ -98,14 +98,13 @@ export function storeDeleteToken(token: string): boolean {
|
|||||||
|
|
||||||
// ---------- Environment ----------
|
// ---------- Environment ----------
|
||||||
|
|
||||||
/** Find an active or offline environment by machineName (optionally filtered by workerType).
|
/** Find an active environment by machineName (optionally filtered by workerType) */
|
||||||
* Includes "offline" so ACP agents can be reused on reconnect. */
|
|
||||||
export function storeFindEnvironmentByMachineName(
|
export function storeFindEnvironmentByMachineName(
|
||||||
machineName: string,
|
machineName: string,
|
||||||
workerType?: string,
|
workerType?: string,
|
||||||
): EnvironmentRecord | undefined {
|
): EnvironmentRecord | undefined {
|
||||||
for (const rec of environments.values()) {
|
for (const rec of environments.values()) {
|
||||||
if (rec.machineName === machineName && (rec.status === "active" || rec.status === "offline")) {
|
if (rec.machineName === machineName && rec.status === "active") {
|
||||||
if (!workerType || rec.workerType === workerType) {
|
if (!workerType || rec.workerType === workerType) {
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
@@ -314,32 +313,12 @@ export function storeGetSessionOwners(sessionId: string): Set<string> | undefine
|
|||||||
|
|
||||||
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
|
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
|
||||||
const result: SessionRecord[] = [];
|
const result: SessionRecord[] = [];
|
||||||
const resultIds = new Set<string>();
|
|
||||||
|
|
||||||
// Collect sessions already owned by this UUID
|
|
||||||
for (const [sessionId, owners] of sessionOwners) {
|
for (const [sessionId, owners] of sessionOwners) {
|
||||||
if (owners.has(uuid)) {
|
if (owners.has(uuid)) {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (session) {
|
if (session) result.push(session);
|
||||||
result.push(session);
|
|
||||||
resultIds.add(sessionId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-bind orphaned sessions (no owner — typically ACP agent sessions created via REST registration)
|
|
||||||
for (const [sessionId, session] of sessions) {
|
|
||||||
if (resultIds.has(sessionId)) continue;
|
|
||||||
const owners = sessionOwners.get(sessionId);
|
|
||||||
// No owners map entry at all, or empty owners set
|
|
||||||
const isOrphaned = !owners || owners.size === 0;
|
|
||||||
if (isOrphaned) {
|
|
||||||
storeBindSession(sessionId, uuid);
|
|
||||||
result.push(session);
|
|
||||||
resultIds.add(sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ export interface EnvironmentResponse {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
last_poll_at: number | null;
|
last_poll_at: number | null;
|
||||||
worker_type?: string;
|
worker_type?: string;
|
||||||
channel_group_id?: string | null;
|
|
||||||
capabilities?: Record<string, unknown> | null;
|
capabilities?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, lazy, Suspense } from "react";
|
import { useState, useEffect, useCallback, lazy, Suspense } from "react";
|
||||||
import { Navbar } from "./components/Navbar";
|
import { Navbar } from "./components/Navbar";
|
||||||
import { IdentityPanel } from "./components/IdentityPanel";
|
import { IdentityPanel } from "./components/IdentityPanel";
|
||||||
import { TokenManagerDialog } from "./components/TokenManagerDialog";
|
|
||||||
import { ThemeProvider } from "./lib/theme";
|
import { ThemeProvider } from "./lib/theme";
|
||||||
import { getUuid, setUuid, apiBind, setActiveApiToken } from "./api/client";
|
import { getUuid, setUuid, apiBind } from "./api/client";
|
||||||
import { ACPDirectView } from "./components/ACPDirectView";
|
import { ACPDirectView } from "./components/ACPDirectView";
|
||||||
import { useTokens } from "./hooks/useTokens";
|
|
||||||
|
|
||||||
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
|
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
|
||||||
const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail })));
|
const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail })));
|
||||||
@@ -13,18 +11,7 @@ const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
const [identityOpen, setIdentityOpen] = useState(false);
|
const [identityOpen, setIdentityOpen] = useState(false);
|
||||||
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
|
|
||||||
const [acpDirect, setAcpDirect] = useState<{ url: string; token: string } | null>(null);
|
const [acpDirect, setAcpDirect] = useState<{ url: string; token: string } | null>(null);
|
||||||
const { tokens, activeTokenId, activeLabel, activeTokenValue, setActiveTokenId, addToken, removeToken, updateToken } = useTokens();
|
|
||||||
|
|
||||||
// Sync active token to API client
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveApiToken(activeTokenValue);
|
|
||||||
}, [activeTokenValue]);
|
|
||||||
|
|
||||||
const handleSetActiveToken = useCallback((id: string) => {
|
|
||||||
setActiveTokenId(id);
|
|
||||||
}, [setActiveTokenId]);
|
|
||||||
|
|
||||||
// Simple hash-based router
|
// Simple hash-based router
|
||||||
const parseRoute = useCallback(() => {
|
const parseRoute = useCallback(() => {
|
||||||
@@ -110,8 +97,6 @@ export default function App() {
|
|||||||
<div className="flex h-screen flex-col bg-surface-0 text-text-primary">
|
<div className="flex h-screen flex-col bg-surface-0 text-text-primary">
|
||||||
<Navbar
|
<Navbar
|
||||||
onIdentityClick={() => setIdentityOpen(true)}
|
onIdentityClick={() => setIdentityOpen(true)}
|
||||||
onTokenClick={() => setTokenDialogOpen(true)}
|
|
||||||
activeTokenLabel={currentSessionId ? undefined : activeLabel}
|
|
||||||
sessionTitle={currentSessionId || (acpDirect ? "ACP" : undefined)}
|
sessionTitle={currentSessionId || (acpDirect ? "ACP" : undefined)}
|
||||||
onBack={(currentSessionId || acpDirect) ? navigateToDashboard : undefined}
|
onBack={(currentSessionId || acpDirect) ? navigateToDashboard : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -129,17 +114,6 @@ export default function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<IdentityPanel open={identityOpen} onClose={() => setIdentityOpen(false)} />
|
<IdentityPanel open={identityOpen} onClose={() => setIdentityOpen(false)} />
|
||||||
|
|
||||||
<TokenManagerDialog
|
|
||||||
open={tokenDialogOpen}
|
|
||||||
onClose={() => setTokenDialogOpen(false)}
|
|
||||||
tokens={tokens}
|
|
||||||
activeTokenId={activeTokenId}
|
|
||||||
onSetActive={handleSetActiveToken}
|
|
||||||
onAdd={addToken}
|
|
||||||
onRemove={removeToken}
|
|
||||||
onUpdate={updateToken}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,35 +24,11 @@ export function setUuid(uuid: string): void {
|
|||||||
localStorage.setItem("rcs_uuid", uuid);
|
localStorage.setItem("rcs_uuid", uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Active API token for Authorization header (set by useTokens) */
|
|
||||||
let _activeToken: string | null = null;
|
|
||||||
|
|
||||||
export function setActiveApiToken(token: string | null): void {
|
|
||||||
_activeToken = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getActiveApiToken(): string | null {
|
|
||||||
return _activeToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
const uuid = getUuid();
|
||||||
if (_activeToken) {
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
headers["Authorization"] = `Bearer ${_activeToken}`;
|
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
|
||||||
}
|
|
||||||
|
|
||||||
// When using Bearer token auth, backend derives UUID from the token — no need to send query param.
|
|
||||||
// Otherwise fall back to UUID auth via query param.
|
|
||||||
let url: string;
|
|
||||||
if (_activeToken) {
|
|
||||||
const sep = path.includes("?") ? "&" : "?";
|
|
||||||
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(_activeToken)}`;
|
|
||||||
} else {
|
|
||||||
const uuid = getUuid();
|
|
||||||
const sep = path.includes("?") ? "&" : "?";
|
|
||||||
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
|
|
||||||
}
|
|
||||||
const opts: RequestInit = { method, headers };
|
const opts: RequestInit = { method, headers };
|
||||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { ThemeToggle } from "../../components/ui/theme-toggle";
|
import { ThemeToggle } from "../../components/ui/theme-toggle";
|
||||||
import { ChevronLeft, LayoutGrid, UserPlus, KeyRound } from "lucide-react";
|
import { ChevronLeft, LayoutGrid, UserPlus } from "lucide-react";
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
onIdentityClick: () => void;
|
onIdentityClick: () => void;
|
||||||
onTokenClick: () => void;
|
|
||||||
activeTokenLabel?: string | null;
|
|
||||||
sessionTitle?: string;
|
sessionTitle?: string;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Navbar({ onIdentityClick, onTokenClick, activeTokenLabel, sessionTitle, onBack }: NavbarProps) {
|
export function Navbar({ onIdentityClick, sessionTitle, onBack }: NavbarProps) {
|
||||||
return (
|
return (
|
||||||
<nav className="sticky top-0 z-40 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
<nav className="sticky top-0 z-40 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
||||||
<div className="mx-auto flex h-11 sm:h-12 max-w-5xl items-center justify-between px-3 sm:px-4">
|
<div className="mx-auto flex h-11 sm:h-12 max-w-5xl items-center justify-between px-3 sm:px-4">
|
||||||
@@ -53,19 +51,6 @@ export function Navbar({ onIdentityClick, onTokenClick, activeTokenLabel, sessio
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button
|
|
||||||
onClick={onTokenClick}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm transition-colors",
|
|
||||||
activeTokenLabel
|
|
||||||
? "bg-brand/10 text-brand hover:bg-brand/20"
|
|
||||||
: "text-text-secondary hover:bg-surface-2 hover:text-text-primary"
|
|
||||||
)}
|
|
||||||
title="Token Manager"
|
|
||||||
>
|
|
||||||
<KeyRound className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline max-w-24 truncate">{activeTokenLabel || "No Token"}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={onIdentityClick}
|
onClick={onIdentityClick}
|
||||||
className="flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 hover:text-text-primary transition-colors"
|
className="flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 hover:text-text-primary transition-colors"
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import type { TokenEntry } from "../hooks/useTokens";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from "../../components/ui/dialog";
|
|
||||||
import { Check, Copy, Eye, EyeOff, Pencil, Plus, Trash2, X } from "lucide-react";
|
|
||||||
|
|
||||||
interface TokenManagerDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
tokens: TokenEntry[];
|
|
||||||
activeTokenId: string | null;
|
|
||||||
onSetActive: (id: string) => void;
|
|
||||||
onAdd: (token: string, label: string) => string | null;
|
|
||||||
onRemove: (id: string) => void;
|
|
||||||
onUpdate: (id: string, label: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TokenManagerDialog({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
tokens,
|
|
||||||
activeTokenId,
|
|
||||||
onSetActive,
|
|
||||||
onAdd,
|
|
||||||
onRemove,
|
|
||||||
onUpdate,
|
|
||||||
}: TokenManagerDialogProps) {
|
|
||||||
const [newToken, setNewToken] = useState("");
|
|
||||||
const [newLabel, setNewLabel] = useState("");
|
|
||||||
const [addError, setAddError] = useState("");
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [editLabel, setEditLabel] = useState("");
|
|
||||||
const [visibleTokenId, setVisibleTokenId] = useState<string | null>(null);
|
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleCopy = (id: string, token: string) => {
|
|
||||||
navigator.clipboard.writeText(token).then(() => {
|
|
||||||
setCopiedId(id);
|
|
||||||
setTimeout(() => setCopiedId(null), 1500);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
const error = onAdd(newToken, newLabel);
|
|
||||||
if (error) {
|
|
||||||
setAddError(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setNewToken("");
|
|
||||||
setNewLabel("");
|
|
||||||
setAddError("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartEdit = (entry: TokenEntry) => {
|
|
||||||
setEditingId(entry.id);
|
|
||||||
setEditLabel(entry.label);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEdit = (id: string) => {
|
|
||||||
onUpdate(id, editLabel.trim() || "Unnamed");
|
|
||||||
setEditingId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSwitch = (id: string) => {
|
|
||||||
onSetActive(id);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
|
||||||
<DialogContent className="max-w-md rounded-2xl border-border bg-surface-1 p-6 shadow-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="font-display text-lg font-semibold text-text-primary">
|
|
||||||
Token Manager
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm text-text-muted">
|
|
||||||
Manage API tokens for RCS authentication.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Token list */}
|
|
||||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
|
||||||
{tokens.map((entry) => (
|
|
||||||
<div key={entry.id} className="group flex items-center gap-1">
|
|
||||||
{editingId === entry.id ? (
|
|
||||||
<div className="flex flex-1 items-center gap-2 rounded-lg bg-surface-2 px-3 py-1.5">
|
|
||||||
<input
|
|
||||||
value={editLabel}
|
|
||||||
onChange={(e) => setEditLabel(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleSaveEdit(entry.id);
|
|
||||||
if (e.key === "Escape") setEditingId(null);
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-sm text-text-primary focus:border-brand focus:outline-none"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSaveEdit(entry.id)}
|
|
||||||
className="text-brand hover:text-brand-light transition-colors"
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingId(null)}
|
|
||||||
className="text-text-muted hover:text-text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSwitch(entry.id)}
|
|
||||||
className={`flex flex-1 items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors ${
|
|
||||||
activeTokenId === entry.id
|
|
||||||
? "bg-brand/10 text-brand"
|
|
||||||
: "text-text-secondary hover:bg-surface-2"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-start min-w-0">
|
|
||||||
<span className="font-medium truncate w-full">{entry.label}</span>
|
|
||||||
<span className="text-xs text-text-muted font-mono">
|
|
||||||
{visibleTokenId === entry.id
|
|
||||||
? entry.token
|
|
||||||
: `${entry.token.slice(0, 6)}${"\u2022".repeat(6)}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{activeTokenId === entry.id && <Check className="h-4 w-4 flex-shrink-0" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setVisibleTokenId(visibleTokenId === entry.id ? null : entry.id)}
|
|
||||||
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
|
|
||||||
title="Toggle token visibility"
|
|
||||||
>
|
|
||||||
{visibleTokenId === entry.id ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopy(entry.id, entry.token)}
|
|
||||||
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
|
|
||||||
title="Copy token"
|
|
||||||
>
|
|
||||||
{copiedId === entry.id ? <Check className="h-3.5 w-3.5 text-status-active" /> : <Copy className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleStartEdit(entry)}
|
|
||||||
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
|
|
||||||
title="Edit label"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onRemove(entry.id)}
|
|
||||||
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-status-error transition-all"
|
|
||||||
title="Delete token"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{tokens.length === 0 && (
|
|
||||||
<div className="py-4 text-center text-sm text-text-muted">
|
|
||||||
No tokens saved yet. Add one below.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add form */}
|
|
||||||
<div className="border-t border-border pt-4 space-y-3">
|
|
||||||
<div className="text-sm font-medium text-text-secondary">Add Token</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newToken}
|
|
||||||
onChange={(e) => {
|
|
||||||
setNewToken(e.target.value);
|
|
||||||
setAddError("");
|
|
||||||
}}
|
|
||||||
placeholder="API Token"
|
|
||||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none font-mono"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleAdd();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newLabel}
|
|
||||||
onChange={(e) => setNewLabel(e.target.value)}
|
|
||||||
placeholder="Label (optional)"
|
|
||||||
className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleAdd();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={!newToken.trim()}
|
|
||||||
className="rounded-lg bg-brand px-3 py-2 text-white hover:bg-brand-light disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{addError && <div className="text-xs text-status-error">{addError}</div>}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { useState, useCallback } from "react";
|
|
||||||
|
|
||||||
export interface TokenEntry {
|
|
||||||
id: string;
|
|
||||||
token: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOKENS_KEY = "rcs_tokens";
|
|
||||||
const ACTIVE_TOKEN_KEY = "rcs_uuid";
|
|
||||||
const DEFAULT_ID = "__default__";
|
|
||||||
|
|
||||||
function generateId(): string {
|
|
||||||
return `tk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ensure the existing rcs_uuid is present as the default token entry */
|
|
||||||
function ensureDefault(tokens: TokenEntry[]): TokenEntry[] {
|
|
||||||
if (tokens.some((t) => t.id === DEFAULT_ID)) return tokens;
|
|
||||||
let uuid: string | null = null;
|
|
||||||
try {
|
|
||||||
uuid = localStorage.getItem("rcs_uuid");
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
if (!uuid) return tokens;
|
|
||||||
return [{ id: DEFAULT_ID, token: uuid, label: "Default" }, ...tokens];
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadTokens(): TokenEntry[] {
|
|
||||||
let tokens: TokenEntry[] = [];
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(TOKENS_KEY);
|
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (Array.isArray(parsed)) tokens = parsed;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return ensureDefault(tokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadActiveTokenId(tokens: TokenEntry[]): string {
|
|
||||||
// Try saved active token
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(ACTIVE_TOKEN_KEY);
|
|
||||||
if (saved && tokens.some((t) => t.id === saved)) return saved;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
// Fall back to default (rcs_uuid) entry
|
|
||||||
const defaultEntry = tokens.find((t) => t.id === DEFAULT_ID);
|
|
||||||
if (defaultEntry) return defaultEntry.id;
|
|
||||||
// Fall back to first entry
|
|
||||||
return tokens[0]?.id ?? DEFAULT_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTokens() {
|
|
||||||
const [tokens, setTokens] = useState<TokenEntry[]>(loadTokens);
|
|
||||||
const [activeTokenId, setActiveTokenIdState] = useState<string>(() => loadActiveTokenId(loadTokens()));
|
|
||||||
|
|
||||||
const persistTokens = useCallback((next: TokenEntry[]) => {
|
|
||||||
setTokens(next);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(TOKENS_KEY, JSON.stringify(next));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setActiveTokenId = useCallback((id: string) => {
|
|
||||||
setActiveTokenIdState(id);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(ACTIVE_TOKEN_KEY, id);
|
|
||||||
location.reload(); // Reload to ensure api client picks up new token from localStorage
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addToken = useCallback((token: string, label: string): string | null => {
|
|
||||||
const trimmed = token.trim();
|
|
||||||
if (!trimmed) return "Token is required";
|
|
||||||
const entry: TokenEntry = { id: generateId(), token: trimmed, label: label.trim() || trimmed.slice(0, 8) };
|
|
||||||
const next = [...tokens, entry];
|
|
||||||
persistTokens(next);
|
|
||||||
return null;
|
|
||||||
}, [tokens, persistTokens]);
|
|
||||||
|
|
||||||
const removeToken = useCallback((id: string) => {
|
|
||||||
if (id === DEFAULT_ID) return; // Cannot remove default
|
|
||||||
const next = tokens.filter((t) => t.id !== id);
|
|
||||||
persistTokens(next);
|
|
||||||
if (activeTokenId === id) {
|
|
||||||
setActiveTokenId(DEFAULT_ID);
|
|
||||||
}
|
|
||||||
}, [tokens, persistTokens, activeTokenId, setActiveTokenId]);
|
|
||||||
|
|
||||||
const updateToken = useCallback((id: string, label: string) => {
|
|
||||||
const next = tokens.map((t) => t.id === id ? { ...t, label } : t);
|
|
||||||
persistTokens(next);
|
|
||||||
}, [tokens, persistTokens]);
|
|
||||||
|
|
||||||
const activeToken = tokens.find((t) => t.id === activeTokenId) ?? tokens[0] ?? null;
|
|
||||||
const activeLabel = activeToken?.label ?? "Default";
|
|
||||||
const activeTokenValue = activeToken?.token ?? null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
tokens,
|
|
||||||
activeTokenId,
|
|
||||||
activeToken,
|
|
||||||
activeLabel,
|
|
||||||
activeTokenValue,
|
|
||||||
setActiveTokenId,
|
|
||||||
addToken,
|
|
||||||
removeToken,
|
|
||||||
updateToken,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ export interface Environment {
|
|||||||
status: string;
|
status: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
worker_type?: string;
|
worker_type?: string;
|
||||||
channel_group_id?: string | null;
|
|
||||||
capabilities?: Record<string, unknown> | null;
|
capabilities?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { clearAccount, DEFAULT_BASE_URL, loadAccount, saveAccount } from './accounts.js'
|
import { clearAccount, DEFAULT_BASE_URL, loadAccount, saveAccount } from './accounts.js'
|
||||||
import { startLogin, waitForLogin } from './login.js'
|
import { startLogin, waitForLogin } from './login.js'
|
||||||
import { confirmPairing } from './pairing.js'
|
import { confirmPairing } from './pairing.js'
|
||||||
import { runWeixinMcpServer } from './server.js'
|
|
||||||
import type { WeixinServerDeps } from './server.js'
|
|
||||||
|
|
||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
@@ -94,18 +92,18 @@ function runAccess(args: string[]): void {
|
|||||||
|
|
||||||
export async function handleWeixinCli(
|
export async function handleWeixinCli(
|
||||||
args: string[],
|
args: string[],
|
||||||
serverDeps?: WeixinServerDeps,
|
serveHandler?: () => Promise<void>,
|
||||||
version?: string,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [subcommand, ...rest] = args
|
const [subcommand, ...rest] = args
|
||||||
|
|
||||||
switch (subcommand) {
|
switch (subcommand) {
|
||||||
case 'serve':
|
case 'serve':
|
||||||
if (!serverDeps) {
|
if (serveHandler) {
|
||||||
|
await serveHandler()
|
||||||
|
} else {
|
||||||
process.stderr.write('[weixin] serve handler not available in this context.\n')
|
process.stderr.write('[weixin] serve handler not available in this context.\n')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
await runWeixinMcpServer(version ?? '0.0.0', serverDeps)
|
|
||||||
return
|
return
|
||||||
case 'login':
|
case 'login':
|
||||||
await runLogin(rest[0] === 'clear')
|
await runLogin(rest[0] === 'clear')
|
||||||
|
|||||||
@@ -96,20 +96,16 @@ export type {
|
|||||||
|
|
||||||
// Permission state
|
// Permission state
|
||||||
export {
|
export {
|
||||||
|
ChannelPermissionRequestParams,
|
||||||
setActivePermissionChat,
|
setActivePermissionChat,
|
||||||
getActivePermissionChat,
|
getActivePermissionChat,
|
||||||
savePendingPermission,
|
savePendingPermission,
|
||||||
consumePendingPermission,
|
consumePendingPermission,
|
||||||
} from './permissions.js'
|
} from './permissions.js'
|
||||||
export type {
|
export type {
|
||||||
ChannelPermissionRequestParams,
|
|
||||||
PendingPermissionRequest,
|
PendingPermissionRequest,
|
||||||
ActivePermissionChat,
|
ActivePermissionChat,
|
||||||
} from './permissions.js'
|
} from './permissions.js'
|
||||||
|
|
||||||
// Server (MCP)
|
|
||||||
export { createWeixinMcpServer, runWeixinMcpServer } from './server.js'
|
|
||||||
export type { WeixinServerDeps } from './server.js'
|
|
||||||
|
|
||||||
// CLI
|
// CLI
|
||||||
export { handleWeixinCli } from './cli.js'
|
export { handleWeixinCli } from './cli.js'
|
||||||
|
|||||||
@@ -4,45 +4,9 @@ import { sendMessage } from './api.js'
|
|||||||
import { guessMediaType, uploadFile } from './media.js'
|
import { guessMediaType, uploadFile } from './media.js'
|
||||||
import { MessageItemType, MessageState, MessageType } from './types.js'
|
import { MessageItemType, MessageState, MessageType } from './types.js'
|
||||||
|
|
||||||
function stripCodeBlocks(text: string): string {
|
|
||||||
// Non-regex approach to avoid ReDoS on inputs with many ``` sequences.
|
|
||||||
let result = ''
|
|
||||||
let i = 0
|
|
||||||
while (i < text.length) {
|
|
||||||
if (text.startsWith('```', i)) {
|
|
||||||
// Skip the opening fence (including optional language tag on same line)
|
|
||||||
let j = i + 3
|
|
||||||
// skip to end of first line (the fence line itself)
|
|
||||||
while (j < text.length && text[j] !== '\n') j++
|
|
||||||
if (j < text.length) j++ // skip the \n
|
|
||||||
// Collect content until closing ```
|
|
||||||
const contentStart = j
|
|
||||||
while (j < text.length) {
|
|
||||||
if (text.startsWith('```', j)) {
|
|
||||||
result += text.slice(contentStart, j)
|
|
||||||
// skip closing fence and its trailing newline
|
|
||||||
j += 3
|
|
||||||
while (j < text.length && text[j] !== '\n') j++
|
|
||||||
if (j < text.length) j++ // skip \n
|
|
||||||
break
|
|
||||||
}
|
|
||||||
j++
|
|
||||||
}
|
|
||||||
// If no closing fence found, include rest as-is
|
|
||||||
if (j >= text.length && !text.startsWith('```', j - 3)) {
|
|
||||||
result += text.slice(i)
|
|
||||||
}
|
|
||||||
i = j
|
|
||||||
} else {
|
|
||||||
result += text[i]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function markdownToPlainText(text: string): string {
|
export function markdownToPlainText(text: string): string {
|
||||||
return stripCodeBlocks(text)
|
return text
|
||||||
|
.replace(/```[\s\S]*?\n([\s\S]*?)```/g, '$1')
|
||||||
.replace(/`([^`]+)`/g, '$1')
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
|
.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
|
||||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||||
|
|||||||
@@ -143,25 +143,8 @@ async function main(): Promise<void> {
|
|||||||
if (args[0] === 'weixin') {
|
if (args[0] === 'weixin') {
|
||||||
profileCheckpoint('cli_weixin_path')
|
profileCheckpoint('cli_weixin_path')
|
||||||
const { handleWeixinCli } = await import('@claude-code-best/weixin')
|
const { handleWeixinCli } = await import('@claude-code-best/weixin')
|
||||||
const { enableConfigs } = await import('../utils/config.js')
|
const { runWeixinMcpServer } = await import('../services/weixin/cli-serve.js')
|
||||||
const { initializeAnalyticsSink } = await import('../services/analytics/sink.js')
|
await handleWeixinCli(args.slice(1), runWeixinMcpServer)
|
||||||
const { shutdownDatadog } = await import('../services/analytics/datadog.js')
|
|
||||||
const { shutdown1PEventLogging } = await import('../services/analytics/firstPartyEventLogger.js')
|
|
||||||
const { logForDebugging } = await import('../utils/debug.js')
|
|
||||||
const { ChannelPermissionRequestNotificationSchema } = await import('../services/mcp/channelNotification.js')
|
|
||||||
await handleWeixinCli(args.slice(1), {
|
|
||||||
enableConfigs,
|
|
||||||
initializeAnalyticsSink,
|
|
||||||
shutdownDatadog,
|
|
||||||
shutdown1PEventLogging,
|
|
||||||
logForDebugging,
|
|
||||||
registerPermissionHandler(server, handler) {
|
|
||||||
server.setNotificationHandler(
|
|
||||||
ChannelPermissionRequestNotificationSchema(),
|
|
||||||
async notification => handler(notification.params),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}, MACRO.VERSION)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
src/services/weixin/cli-serve.ts
Normal file
1
src/services/weixin/cli-serve.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { runWeixinMcpServer } from './server.js'
|
||||||
@@ -5,6 +5,15 @@ import {
|
|||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
} from '@modelcontextprotocol/sdk/types.js'
|
} from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
import {
|
||||||
|
ChannelPermissionRequestNotificationSchema,
|
||||||
|
type ChannelPermissionRequestParams,
|
||||||
|
} from '../mcp/channelNotification.js'
|
||||||
|
import { initializeAnalyticsSink } from '../analytics/sink.js'
|
||||||
|
import { shutdownDatadog } from '../analytics/datadog.js'
|
||||||
|
import { shutdown1PEventLogging } from '../analytics/firstPartyEventLogger.js'
|
||||||
|
import { enableConfigs } from '../../utils/config.js'
|
||||||
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
import {
|
import {
|
||||||
CDN_BASE_URL,
|
CDN_BASE_URL,
|
||||||
DEFAULT_BASE_URL,
|
DEFAULT_BASE_URL,
|
||||||
@@ -18,21 +27,8 @@ import {
|
|||||||
sendMediaFile,
|
sendMediaFile,
|
||||||
sendText,
|
sendText,
|
||||||
TypingStatus,
|
TypingStatus,
|
||||||
} from './index.js'
|
} from '@claude-code-best/weixin'
|
||||||
import type { ParsedMessage } from './monitor.js'
|
import type { ParsedMessage } from '@claude-code-best/weixin'
|
||||||
import type { ChannelPermissionRequestParams } from './permissions.js'
|
|
||||||
|
|
||||||
export interface WeixinServerDeps {
|
|
||||||
enableConfigs(): void
|
|
||||||
initializeAnalyticsSink(): void
|
|
||||||
shutdownDatadog(): Promise<void>
|
|
||||||
shutdown1PEventLogging(): Promise<void>
|
|
||||||
logForDebugging(message: string): void
|
|
||||||
registerPermissionHandler(
|
|
||||||
server: Server,
|
|
||||||
handler: (request: ChannelPermissionRequestParams) => Promise<void>,
|
|
||||||
): void
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPermissionRequestMessage(
|
function formatPermissionRequestMessage(
|
||||||
request: ChannelPermissionRequestParams,
|
request: ChannelPermissionRequestParams,
|
||||||
@@ -49,9 +45,9 @@ function formatPermissionRequestMessage(
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWeixinMcpServer(version: string): Server {
|
export function createWeixinMcpServer(): Server {
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{ name: 'weixin', version },
|
{ name: 'weixin', version: MACRO.VERSION },
|
||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -228,60 +224,61 @@ export function createWeixinMcpServer(version: string): Server {
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runWeixinMcpServer(
|
export async function runWeixinMcpServer(): Promise<void> {
|
||||||
version: string,
|
enableConfigs()
|
||||||
deps: WeixinServerDeps,
|
initializeAnalyticsSink()
|
||||||
): Promise<void> {
|
|
||||||
deps.enableConfigs()
|
|
||||||
deps.initializeAnalyticsSink()
|
|
||||||
|
|
||||||
const account = loadAccount()
|
const account = loadAccount()
|
||||||
if (!account) {
|
if (!account) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
'[weixin] No account configured. Run `ccb weixin login` to connect your WeChat account.\n',
|
'[weixin] No account configured. Run `ccb weixin login` to connect your WeChat account.\n',
|
||||||
)
|
)
|
||||||
await Promise.all([deps.shutdown1PEventLogging(), deps.shutdownDatadog()])
|
await Promise.all([shutdown1PEventLogging(), shutdownDatadog()])
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = createWeixinMcpServer(version)
|
const server = createWeixinMcpServer()
|
||||||
const transport = new StdioServerTransport()
|
const transport = new StdioServerTransport()
|
||||||
|
|
||||||
deps.registerPermissionHandler(server, async request => {
|
server.setNotificationHandler(
|
||||||
const targetChatId = request.channel_context?.chat_id
|
ChannelPermissionRequestNotificationSchema(),
|
||||||
const targetChat = targetChatId
|
async notification => {
|
||||||
? {
|
const request = notification.params
|
||||||
chatId: targetChatId,
|
const targetChatId = request.channel_context?.chat_id
|
||||||
contextToken: getContextToken(targetChatId),
|
const targetChat = targetChatId
|
||||||
}
|
? {
|
||||||
: getActivePermissionChat()
|
chatId: targetChatId,
|
||||||
|
contextToken: getContextToken(targetChatId),
|
||||||
|
}
|
||||||
|
: getActivePermissionChat()
|
||||||
|
|
||||||
if (!targetChat) {
|
if (!targetChat) {
|
||||||
deps.logForDebugging(
|
logForDebugging(
|
||||||
`[Weixin MCP] No active chat available for permission request ${request.request_id}`,
|
`[Weixin MCP] No active chat available for permission request ${request.request_id}`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
savePendingPermission(
|
savePendingPermission(
|
||||||
request,
|
request,
|
||||||
targetChat.chatId,
|
targetChat.chatId,
|
||||||
targetChat.contextToken,
|
targetChat.contextToken,
|
||||||
)
|
)
|
||||||
await sendText({
|
await sendText({
|
||||||
to: targetChat.chatId,
|
to: targetChat.chatId,
|
||||||
text: formatPermissionRequestMessage(request),
|
text: formatPermissionRequestMessage(request),
|
||||||
baseUrl,
|
baseUrl,
|
||||||
token: account.token,
|
token: account.token,
|
||||||
contextToken: targetChat.contextToken || '',
|
contextToken: targetChat.contextToken || '',
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`[weixin] Failed to relay permission request ${request.request_id}: ${error}\n`,
|
`[weixin] Failed to relay permission request ${request.request_id}: ${error}\n`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
await server.connect(transport)
|
await server.connect(transport)
|
||||||
|
|
||||||
@@ -295,7 +292,7 @@ export async function runWeixinMcpServer(
|
|||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
controller.abort()
|
controller.abort()
|
||||||
}
|
}
|
||||||
await Promise.all([deps.shutdown1PEventLogging(), deps.shutdownDatadog()])
|
await Promise.all([shutdown1PEventLogging(), shutdownDatadog()])
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +313,7 @@ export async function runWeixinMcpServer(
|
|||||||
}
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
|
|
||||||
deps.logForDebugging('[Weixin MCP] Starting poll loop')
|
logForDebugging('[Weixin MCP] Starting poll loop')
|
||||||
await startPollLoop({
|
await startPollLoop({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
cdnBaseUrl: CDN_BASE_URL,
|
cdnBaseUrl: CDN_BASE_URL,
|
||||||
Reference in New Issue
Block a user