ai-cli-online 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/README.zh-CN.md +170 -0
- package/bin/ai-cli-online.mjs +89 -0
- package/install-service.sh +319 -0
- package/package.json +57 -0
- package/server/.env.example +18 -0
- package/server/dist/auth.d.ts +3 -0
- package/server/dist/auth.js +9 -0
- package/server/dist/claude.d.ts +16 -0
- package/server/dist/claude.js +141 -0
- package/server/dist/db.d.ts +7 -0
- package/server/dist/db.js +73 -0
- package/server/dist/files.d.ts +15 -0
- package/server/dist/files.js +56 -0
- package/server/dist/index.d.ts +1 -0
- package/server/dist/index.js +466 -0
- package/server/dist/plans.d.ts +7 -0
- package/server/dist/plans.js +120 -0
- package/server/dist/pty.d.ts +15 -0
- package/server/dist/pty.js +75 -0
- package/server/dist/storage.d.ts +22 -0
- package/server/dist/storage.js +149 -0
- package/server/dist/tmux.d.ts +40 -0
- package/server/dist/tmux.js +191 -0
- package/server/dist/types.d.ts +1 -0
- package/server/dist/types.js +1 -0
- package/server/dist/websocket.d.ts +4 -0
- package/server/dist/websocket.js +304 -0
- package/server/package.json +32 -0
- package/shared/dist/types.d.ts +40 -0
- package/shared/dist/types.js +1 -0
- package/shared/package.json +20 -0
- package/start.sh +39 -0
- package/web/dist/assets/index-79TY7o1G.css +32 -0
- package/web/dist/assets/index-mcWZLwbP.js +235 -0
- package/web/dist/assets/pdf-Tk4_4Bu3.js +12 -0
- package/web/dist/assets/pdf.worker-BA9kU3Pw.mjs +61080 -0
- package/web/dist/favicon.svg +5 -0
- package/web/dist/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/web/dist/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/web/dist/fonts/MapleMono-CN-Bold.woff2 +0 -0
- package/web/dist/fonts/MapleMono-CN-Regular.woff2 +0 -0
- package/web/dist/index.html +17 -0
- package/web/package.json +32 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# ============================================
|
|
5
|
+
# AI-CLI-Online systemd 服务安装脚本
|
|
6
|
+
# 用法: sudo bash install-service.sh
|
|
7
|
+
# ============================================
|
|
8
|
+
|
|
9
|
+
SERVICE_NAME="ai-cli-online"
|
|
10
|
+
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
|
11
|
+
|
|
12
|
+
# --- 检测环境 ---
|
|
13
|
+
|
|
14
|
+
# 项目目录 = 脚本所在目录
|
|
15
|
+
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
16
|
+
|
|
17
|
+
# 运行用户 (优先 SUDO_USER,回退当前用户)
|
|
18
|
+
RUN_USER="${SUDO_USER:-$(whoami)}"
|
|
19
|
+
RUN_HOME=$(eval echo "~${RUN_USER}")
|
|
20
|
+
|
|
21
|
+
# Node.js 路径
|
|
22
|
+
NODE_BIN=$(su - "$RUN_USER" -c "which node" 2>/dev/null || true)
|
|
23
|
+
if [[ -z "$NODE_BIN" ]]; then
|
|
24
|
+
echo "[错误] 未找到 node,请先安装 Node.js >= 18"
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
NODE_DIR=$(dirname "$NODE_BIN")
|
|
28
|
+
NODE_VERSION=$("$NODE_BIN" --version)
|
|
29
|
+
|
|
30
|
+
NPM_BIN=$(su - "$RUN_USER" -c "which npm" 2>/dev/null || true)
|
|
31
|
+
if [[ -z "$NPM_BIN" ]]; then
|
|
32
|
+
echo "[错误] 未找到 npm"
|
|
33
|
+
exit 1
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# 检查 tmux
|
|
37
|
+
if ! command -v tmux &>/dev/null; then
|
|
38
|
+
echo "[错误] 未找到 tmux,请先安装: sudo apt install tmux"
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# --- 确认信息 ---
|
|
43
|
+
|
|
44
|
+
echo "================================"
|
|
45
|
+
echo " AI-CLI-Online 服务安装"
|
|
46
|
+
echo "================================"
|
|
47
|
+
echo ""
|
|
48
|
+
echo " 项目目录: $PROJECT_DIR"
|
|
49
|
+
echo " 运行用户: $RUN_USER"
|
|
50
|
+
echo " Node.js: $NODE_BIN ($NODE_VERSION)"
|
|
51
|
+
echo " npm: $NPM_BIN"
|
|
52
|
+
echo " 服务文件: $SERVICE_FILE"
|
|
53
|
+
echo ""
|
|
54
|
+
|
|
55
|
+
# 非交互模式 (传 -y 跳过确认)
|
|
56
|
+
if [[ "${1:-}" != "-y" ]]; then
|
|
57
|
+
read -rp "确认安装? [Y/n] " answer
|
|
58
|
+
if [[ "$answer" =~ ^[Nn] ]]; then
|
|
59
|
+
echo "已取消"
|
|
60
|
+
exit 0
|
|
61
|
+
fi
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# --- 生成 service 文件 ---
|
|
65
|
+
|
|
66
|
+
cat > "$SERVICE_FILE" <<EOF
|
|
67
|
+
[Unit]
|
|
68
|
+
Description=AI-CLI-Online Web Terminal
|
|
69
|
+
After=network.target
|
|
70
|
+
|
|
71
|
+
[Service]
|
|
72
|
+
Type=simple
|
|
73
|
+
User=${RUN_USER}
|
|
74
|
+
WorkingDirectory=${PROJECT_DIR}/server
|
|
75
|
+
Environment=PATH=${NODE_DIR}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
76
|
+
Environment=NODE_ENV=production
|
|
77
|
+
EnvironmentFile=-${PROJECT_DIR}/server/.env
|
|
78
|
+
ExecStartPre=${NPM_BIN} run --prefix ${PROJECT_DIR} build
|
|
79
|
+
ExecStart=${NODE_BIN} dist/index.js
|
|
80
|
+
Restart=on-failure
|
|
81
|
+
RestartSec=5
|
|
82
|
+
|
|
83
|
+
# 进程管理
|
|
84
|
+
KillMode=mixed
|
|
85
|
+
KillSignal=SIGTERM
|
|
86
|
+
TimeoutStopSec=10
|
|
87
|
+
|
|
88
|
+
# 安全加固
|
|
89
|
+
NoNewPrivileges=true
|
|
90
|
+
ProtectSystem=strict
|
|
91
|
+
ReadWritePaths=${RUN_HOME}
|
|
92
|
+
PrivateTmp=true
|
|
93
|
+
|
|
94
|
+
[Install]
|
|
95
|
+
WantedBy=multi-user.target
|
|
96
|
+
EOF
|
|
97
|
+
|
|
98
|
+
echo "[完成] 已写入 $SERVICE_FILE"
|
|
99
|
+
|
|
100
|
+
# --- 启用服务 ---
|
|
101
|
+
|
|
102
|
+
systemctl daemon-reload
|
|
103
|
+
systemctl enable "$SERVICE_NAME"
|
|
104
|
+
|
|
105
|
+
echo ""
|
|
106
|
+
echo "================================"
|
|
107
|
+
echo " systemd 服务安装完成"
|
|
108
|
+
echo "================================"
|
|
109
|
+
echo ""
|
|
110
|
+
|
|
111
|
+
# ==========================================================
|
|
112
|
+
# 可选: nginx 反向代理配置
|
|
113
|
+
# ==========================================================
|
|
114
|
+
|
|
115
|
+
SETUP_NGINX="n"
|
|
116
|
+
if command -v nginx &>/dev/null; then
|
|
117
|
+
echo ""
|
|
118
|
+
echo "检测到 nginx 已安装,是否配置反向代理?"
|
|
119
|
+
echo " - 自动生成 nginx 站点配置"
|
|
120
|
+
echo " - 自动设置 WebSocket 代理"
|
|
121
|
+
echo " - 自动设置 HTTPS_ENABLED=false (由 nginx 做 SSL 终端)"
|
|
122
|
+
echo ""
|
|
123
|
+
if [[ "${1:-}" == "-y" ]]; then
|
|
124
|
+
SETUP_NGINX="y"
|
|
125
|
+
else
|
|
126
|
+
read -rp "配置 nginx 反向代理? [y/N] " SETUP_NGINX
|
|
127
|
+
fi
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
if [[ "$SETUP_NGINX" =~ ^[Yy] ]]; then
|
|
131
|
+
# 读取端口 (从 .env 或默认 3001)
|
|
132
|
+
BACKEND_PORT="3001"
|
|
133
|
+
ENV_FILE="${PROJECT_DIR}/server/.env"
|
|
134
|
+
if [[ -f "$ENV_FILE" ]]; then
|
|
135
|
+
ENV_PORT=$(grep -E '^PORT=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 | tr -d ' "'"'" || true)
|
|
136
|
+
if [[ -n "$ENV_PORT" ]]; then
|
|
137
|
+
BACKEND_PORT="$ENV_PORT"
|
|
138
|
+
fi
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
# 交互获取域名
|
|
142
|
+
read -rp "域名 (留空则使用 _ 匹配所有): " NGINX_DOMAIN
|
|
143
|
+
NGINX_DOMAIN="${NGINX_DOMAIN:-_}"
|
|
144
|
+
|
|
145
|
+
# 交互获取监听端口
|
|
146
|
+
read -rp "nginx 监听端口 [443]: " NGINX_LISTEN_PORT
|
|
147
|
+
NGINX_LISTEN_PORT="${NGINX_LISTEN_PORT:-443}"
|
|
148
|
+
|
|
149
|
+
# SSL 配置
|
|
150
|
+
NGINX_SSL_BLOCK=""
|
|
151
|
+
if [[ "$NGINX_LISTEN_PORT" == "443" ]]; then
|
|
152
|
+
echo ""
|
|
153
|
+
echo "SSL 证书配置:"
|
|
154
|
+
echo " 1) 已有证书 (输入路径)"
|
|
155
|
+
echo " 2) 自签名证书 (自动生成)"
|
|
156
|
+
echo " 3) 不使用 SSL (改为监听 80 端口)"
|
|
157
|
+
read -rp "选择 [1/2/3]: " SSL_CHOICE
|
|
158
|
+
|
|
159
|
+
case "${SSL_CHOICE:-1}" in
|
|
160
|
+
1)
|
|
161
|
+
read -rp "证书文件路径 (.crt/.pem): " SSL_CERT
|
|
162
|
+
read -rp "私钥文件路径 (.key): " SSL_KEY
|
|
163
|
+
if [[ ! -f "$SSL_CERT" || ! -f "$SSL_KEY" ]]; then
|
|
164
|
+
echo "[错误] 证书或私钥文件不存在"
|
|
165
|
+
exit 1
|
|
166
|
+
fi
|
|
167
|
+
NGINX_SSL_BLOCK=" ssl_certificate ${SSL_CERT};
|
|
168
|
+
ssl_certificate_key ${SSL_KEY};
|
|
169
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
170
|
+
ssl_ciphers HIGH:!aNULL:!MD5;"
|
|
171
|
+
;;
|
|
172
|
+
2)
|
|
173
|
+
SELF_SIGN_DIR="/etc/nginx/ssl"
|
|
174
|
+
mkdir -p "$SELF_SIGN_DIR"
|
|
175
|
+
SELF_CERT="${SELF_SIGN_DIR}/${SERVICE_NAME}.crt"
|
|
176
|
+
SELF_KEY="${SELF_SIGN_DIR}/${SERVICE_NAME}.key"
|
|
177
|
+
echo "[SSL] 生成自签名证书..."
|
|
178
|
+
openssl req -x509 -nodes -days 3650 \
|
|
179
|
+
-newkey rsa:2048 \
|
|
180
|
+
-keyout "$SELF_KEY" \
|
|
181
|
+
-out "$SELF_CERT" \
|
|
182
|
+
-subj "/CN=${NGINX_DOMAIN}" \
|
|
183
|
+
2>/dev/null
|
|
184
|
+
echo "[SSL] 证书: $SELF_CERT"
|
|
185
|
+
echo "[SSL] 私钥: $SELF_KEY"
|
|
186
|
+
NGINX_SSL_BLOCK=" ssl_certificate ${SELF_CERT};
|
|
187
|
+
ssl_certificate_key ${SELF_KEY};
|
|
188
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
189
|
+
ssl_ciphers HIGH:!aNULL:!MD5;"
|
|
190
|
+
;;
|
|
191
|
+
3)
|
|
192
|
+
NGINX_LISTEN_PORT="80"
|
|
193
|
+
;;
|
|
194
|
+
esac
|
|
195
|
+
fi
|
|
196
|
+
|
|
197
|
+
# 构建 listen 指令
|
|
198
|
+
if [[ -n "$NGINX_SSL_BLOCK" ]]; then
|
|
199
|
+
LISTEN_DIRECTIVE="listen ${NGINX_LISTEN_PORT} ssl;"
|
|
200
|
+
else
|
|
201
|
+
LISTEN_DIRECTIVE="listen ${NGINX_LISTEN_PORT};"
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
# 生成 nginx 配置
|
|
205
|
+
NGINX_CONF="/etc/nginx/sites-available/${SERVICE_NAME}"
|
|
206
|
+
cat > "$NGINX_CONF" <<NGINX_EOF
|
|
207
|
+
# AI-CLI-Online nginx reverse proxy
|
|
208
|
+
# Auto-generated by install-service.sh
|
|
209
|
+
|
|
210
|
+
server {
|
|
211
|
+
${LISTEN_DIRECTIVE}
|
|
212
|
+
server_name ${NGINX_DOMAIN};
|
|
213
|
+
|
|
214
|
+
${NGINX_SSL_BLOCK}
|
|
215
|
+
|
|
216
|
+
# 文件上传大小限制 (与 multer 100MB 限制匹配)
|
|
217
|
+
client_max_body_size 100m;
|
|
218
|
+
|
|
219
|
+
location / {
|
|
220
|
+
proxy_pass http://127.0.0.1:${BACKEND_PORT};
|
|
221
|
+
proxy_http_version 1.1;
|
|
222
|
+
proxy_set_header Host \$host;
|
|
223
|
+
proxy_set_header X-Real-IP \$remote_addr;
|
|
224
|
+
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
225
|
+
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
location /ws {
|
|
229
|
+
proxy_pass http://127.0.0.1:${BACKEND_PORT}/ws;
|
|
230
|
+
proxy_http_version 1.1;
|
|
231
|
+
proxy_set_header Upgrade \$http_upgrade;
|
|
232
|
+
proxy_set_header Connection "upgrade";
|
|
233
|
+
proxy_set_header Host \$host;
|
|
234
|
+
proxy_set_header X-Real-IP \$remote_addr;
|
|
235
|
+
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
236
|
+
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
237
|
+
proxy_read_timeout 86400s;
|
|
238
|
+
proxy_send_timeout 86400s;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
NGINX_EOF
|
|
242
|
+
|
|
243
|
+
echo "[nginx] 已写入 $NGINX_CONF"
|
|
244
|
+
|
|
245
|
+
# 启用站点 (symlink to sites-enabled)
|
|
246
|
+
NGINX_ENABLED="/etc/nginx/sites-enabled/${SERVICE_NAME}"
|
|
247
|
+
if [[ -L "$NGINX_ENABLED" ]]; then
|
|
248
|
+
rm "$NGINX_ENABLED"
|
|
249
|
+
fi
|
|
250
|
+
ln -s "$NGINX_CONF" "$NGINX_ENABLED"
|
|
251
|
+
echo "[nginx] 已启用站点"
|
|
252
|
+
|
|
253
|
+
# 设置 server/.env 的 HTTPS_ENABLED=false 和 TRUST_PROXY=1
|
|
254
|
+
if [[ -f "$ENV_FILE" ]]; then
|
|
255
|
+
# 更新已有的 HTTPS_ENABLED
|
|
256
|
+
if grep -q '^HTTPS_ENABLED=' "$ENV_FILE"; then
|
|
257
|
+
sed -i 's/^HTTPS_ENABLED=.*/HTTPS_ENABLED=false/' "$ENV_FILE"
|
|
258
|
+
else
|
|
259
|
+
echo 'HTTPS_ENABLED=false' >> "$ENV_FILE"
|
|
260
|
+
fi
|
|
261
|
+
# 更新已有的 TRUST_PROXY
|
|
262
|
+
if grep -q '^TRUST_PROXY=' "$ENV_FILE"; then
|
|
263
|
+
sed -i 's/^TRUST_PROXY=.*/TRUST_PROXY=1/' "$ENV_FILE"
|
|
264
|
+
else
|
|
265
|
+
echo 'TRUST_PROXY=1' >> "$ENV_FILE"
|
|
266
|
+
fi
|
|
267
|
+
echo "[env] 已设置 HTTPS_ENABLED=false, TRUST_PROXY=1"
|
|
268
|
+
else
|
|
269
|
+
cat > "$ENV_FILE" <<ENV_EOF
|
|
270
|
+
PORT=${BACKEND_PORT}
|
|
271
|
+
HOST=0.0.0.0
|
|
272
|
+
HTTPS_ENABLED=false
|
|
273
|
+
TRUST_PROXY=1
|
|
274
|
+
AUTH_TOKEN=
|
|
275
|
+
DEFAULT_WORKING_DIR=${RUN_HOME}
|
|
276
|
+
ENV_EOF
|
|
277
|
+
chown "$RUN_USER:$RUN_USER" "$ENV_FILE" 2>/dev/null || true
|
|
278
|
+
echo "[env] 已创建 $ENV_FILE (HTTPS_ENABLED=false, TRUST_PROXY=1)"
|
|
279
|
+
fi
|
|
280
|
+
|
|
281
|
+
# 测试 nginx 配置
|
|
282
|
+
echo "[nginx] 测试配置..."
|
|
283
|
+
if nginx -t 2>&1; then
|
|
284
|
+
systemctl reload nginx
|
|
285
|
+
echo "[nginx] 配置已生效"
|
|
286
|
+
else
|
|
287
|
+
echo "[警告] nginx 配置测试失败,请手动检查: $NGINX_CONF"
|
|
288
|
+
fi
|
|
289
|
+
|
|
290
|
+
echo ""
|
|
291
|
+
echo "================================"
|
|
292
|
+
echo " nginx 反向代理配置完成"
|
|
293
|
+
echo "================================"
|
|
294
|
+
echo ""
|
|
295
|
+
if [[ -n "$NGINX_SSL_BLOCK" ]]; then
|
|
296
|
+
echo " 访问地址: https://${NGINX_DOMAIN}:${NGINX_LISTEN_PORT}"
|
|
297
|
+
else
|
|
298
|
+
echo " 访问地址: http://${NGINX_DOMAIN}:${NGINX_LISTEN_PORT}"
|
|
299
|
+
fi
|
|
300
|
+
echo " 后端端口: ${BACKEND_PORT}"
|
|
301
|
+
echo " 站点配置: $NGINX_CONF"
|
|
302
|
+
echo ""
|
|
303
|
+
echo " 卸载 nginx 配置:"
|
|
304
|
+
echo " sudo rm ${NGINX_ENABLED} ${NGINX_CONF} && sudo nginx -s reload"
|
|
305
|
+
echo ""
|
|
306
|
+
fi
|
|
307
|
+
|
|
308
|
+
# --- 最终提示 ---
|
|
309
|
+
|
|
310
|
+
echo "================================"
|
|
311
|
+
echo " 常用命令"
|
|
312
|
+
echo "================================"
|
|
313
|
+
echo ""
|
|
314
|
+
echo " 启动服务: sudo systemctl start $SERVICE_NAME"
|
|
315
|
+
echo " 查看状态: sudo systemctl status $SERVICE_NAME"
|
|
316
|
+
echo " 查看日志: sudo journalctl -u $SERVICE_NAME -f"
|
|
317
|
+
echo " 停止服务: sudo systemctl stop $SERVICE_NAME"
|
|
318
|
+
echo " 卸载服务: sudo systemctl disable $SERVICE_NAME && sudo rm $SERVICE_FILE"
|
|
319
|
+
echo ""
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-cli-online",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "AI-Cli Online - Web Terminal for Claude Code via xterm.js + tmux",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ai-cli-online": "./bin/ai-cli-online.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/huacheng/ai-cli-online.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/huacheng/ai-cli-online#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/huacheng/ai-cli-online/issues"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"terminal",
|
|
22
|
+
"web-terminal",
|
|
23
|
+
"xterm",
|
|
24
|
+
"tmux",
|
|
25
|
+
"claude",
|
|
26
|
+
"claude-code",
|
|
27
|
+
"cli"
|
|
28
|
+
],
|
|
29
|
+
"files": [
|
|
30
|
+
"bin/",
|
|
31
|
+
"shared/dist/",
|
|
32
|
+
"shared/package.json",
|
|
33
|
+
"server/dist/",
|
|
34
|
+
"server/package.json",
|
|
35
|
+
"server/.env.example",
|
|
36
|
+
"web/dist/",
|
|
37
|
+
"web/package.json",
|
|
38
|
+
"start.sh",
|
|
39
|
+
"install-service.sh"
|
|
40
|
+
],
|
|
41
|
+
"workspaces": [
|
|
42
|
+
"shared",
|
|
43
|
+
"server",
|
|
44
|
+
"web"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"dev": "concurrently \"npm run dev:server\" \"npm run dev:web\"",
|
|
48
|
+
"dev:server": "npm run dev --workspace=server",
|
|
49
|
+
"dev:web": "npm run dev --workspace=web",
|
|
50
|
+
"build": "npm run build --workspace=shared && npm run build --workspace=server && npm run build --workspace=web",
|
|
51
|
+
"start": "npm run start --workspace=server"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"concurrently": "^8.2.2",
|
|
55
|
+
"typescript": "^5.9.3"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Server port
|
|
2
|
+
PORT=3001
|
|
3
|
+
|
|
4
|
+
# Bind address
|
|
5
|
+
HOST=0.0.0.0
|
|
6
|
+
|
|
7
|
+
# Enable HTTPS (set to false when behind nginx reverse proxy)
|
|
8
|
+
HTTPS_ENABLED=true
|
|
9
|
+
|
|
10
|
+
# Authentication token (REQUIRED for production)
|
|
11
|
+
# Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
12
|
+
AUTH_TOKEN=
|
|
13
|
+
|
|
14
|
+
# Trust proxy (set to 1 when behind nginx/reverse proxy)
|
|
15
|
+
# TRUST_PROXY=1
|
|
16
|
+
|
|
17
|
+
# Default working directory for terminal sessions
|
|
18
|
+
DEFAULT_WORKING_DIR=/home/ubuntu
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'crypto';
|
|
2
|
+
/** Constant-time string comparison using HMAC to prevent timing side-channel attacks.
|
|
3
|
+
* HMAC digests are always 32 bytes, so comparison is constant-time regardless of input lengths. */
|
|
4
|
+
export function safeTokenCompare(a, b) {
|
|
5
|
+
const key = 'ai-cli-online-token-compare';
|
|
6
|
+
const hmacA = createHmac('sha256', key).update(a).digest();
|
|
7
|
+
const hmacB = createHmac('sha256', key).update(b).digest();
|
|
8
|
+
return timingSafeEqual(hmacA, hmacB);
|
|
9
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ClaudeCodeResult } from './types.js';
|
|
2
|
+
export interface ClaudeCodeOptions {
|
|
3
|
+
workingDir: string;
|
|
4
|
+
message: string;
|
|
5
|
+
sessionId?: string;
|
|
6
|
+
isNewSession?: boolean;
|
|
7
|
+
onData?: (data: string) => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Execute Claude Code CLI with streaming JSON output for real-time updates
|
|
11
|
+
*/
|
|
12
|
+
export declare function executeClaudeCode(options: ClaudeCodeOptions): Promise<ClaudeCodeResult>;
|
|
13
|
+
/**
|
|
14
|
+
* Check if Claude Code CLI is available
|
|
15
|
+
*/
|
|
16
|
+
export declare function checkClaudeCodeAvailable(): Promise<boolean>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
const CLAUDE_PATH = process.env.CLAUDE_PATH || '/home/ubuntu/.local/bin/claude';
|
|
3
|
+
/**
|
|
4
|
+
* Execute Claude Code CLI with streaming JSON output for real-time updates
|
|
5
|
+
*/
|
|
6
|
+
export async function executeClaudeCode(options) {
|
|
7
|
+
const { workingDir, message, sessionId, isNewSession, onData } = options;
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
// Use --output-format stream-json for real streaming (not --print which buffers)
|
|
10
|
+
const args = ['--output-format', 'stream-json', '--dangerously-skip-permissions', '--verbose'];
|
|
11
|
+
// Handle session management
|
|
12
|
+
if (sessionId) {
|
|
13
|
+
if (isNewSession) {
|
|
14
|
+
// First message in conversation - create new session with specified ID
|
|
15
|
+
args.push('--session-id', sessionId);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
// Subsequent messages - resume existing session
|
|
19
|
+
args.push('--resume', sessionId);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Add the message as the last argument
|
|
23
|
+
args.push(message);
|
|
24
|
+
console.log(`[Claude] Executing in ${workingDir}: ${CLAUDE_PATH}`, args);
|
|
25
|
+
const proc = spawn(CLAUDE_PATH, args, {
|
|
26
|
+
cwd: workingDir,
|
|
27
|
+
env: {
|
|
28
|
+
...process.env,
|
|
29
|
+
CI: 'true', // Non-interactive mode
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
let fullOutput = '';
|
|
33
|
+
let streamedText = '';
|
|
34
|
+
let buffer = '';
|
|
35
|
+
// Process stdout line by line (each line is a JSON event)
|
|
36
|
+
proc.stdout.on('data', (data) => {
|
|
37
|
+
buffer += data.toString();
|
|
38
|
+
// Process complete lines
|
|
39
|
+
const lines = buffer.split('\n');
|
|
40
|
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
if (!line.trim())
|
|
43
|
+
continue;
|
|
44
|
+
try {
|
|
45
|
+
const event = JSON.parse(line);
|
|
46
|
+
console.log(`[Claude stream] Event type: ${event.type}`);
|
|
47
|
+
// Extract text from various event types
|
|
48
|
+
let text = '';
|
|
49
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
50
|
+
// Final message with full content
|
|
51
|
+
text = event.message.content;
|
|
52
|
+
fullOutput = text;
|
|
53
|
+
}
|
|
54
|
+
else if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
55
|
+
// Streaming delta
|
|
56
|
+
text = event.delta.text;
|
|
57
|
+
streamedText += text;
|
|
58
|
+
fullOutput = streamedText;
|
|
59
|
+
}
|
|
60
|
+
else if (event.type === 'content_block_start' && event.content_block?.text) {
|
|
61
|
+
text = event.content_block.text;
|
|
62
|
+
streamedText += text;
|
|
63
|
+
fullOutput = streamedText;
|
|
64
|
+
}
|
|
65
|
+
// Send streaming update if we got text
|
|
66
|
+
if (text && onData) {
|
|
67
|
+
console.log(`[Claude stream] Sending chunk: ${text.length} chars`);
|
|
68
|
+
onData(text);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
// Not JSON, might be raw output - just append it
|
|
73
|
+
console.log(`[Claude raw] ${line}`);
|
|
74
|
+
if (line.trim()) {
|
|
75
|
+
fullOutput += line + '\n';
|
|
76
|
+
if (onData) {
|
|
77
|
+
onData(line + '\n');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
proc.stderr.on('data', (data) => {
|
|
84
|
+
const text = data.toString();
|
|
85
|
+
console.log(`[Claude stderr] ${text}`);
|
|
86
|
+
});
|
|
87
|
+
proc.on('close', (exitCode) => {
|
|
88
|
+
// Process any remaining buffer
|
|
89
|
+
if (buffer.trim()) {
|
|
90
|
+
try {
|
|
91
|
+
const event = JSON.parse(buffer);
|
|
92
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
93
|
+
fullOutput = event.message.content;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
fullOutput += buffer;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
console.log(`[Claude] Process exited with code ${exitCode}`);
|
|
101
|
+
if (exitCode === 0) {
|
|
102
|
+
resolve({
|
|
103
|
+
success: true,
|
|
104
|
+
output: fullOutput.trim() || streamedText.trim(),
|
|
105
|
+
sessionId,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
resolve({
|
|
110
|
+
success: false,
|
|
111
|
+
output: fullOutput.trim() || streamedText.trim(),
|
|
112
|
+
error: `Process exited with code ${exitCode}`,
|
|
113
|
+
sessionId,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
proc.on('error', (err) => {
|
|
118
|
+
console.error(`[Claude] Process error:`, err);
|
|
119
|
+
resolve({
|
|
120
|
+
success: false,
|
|
121
|
+
output: '',
|
|
122
|
+
error: err.message,
|
|
123
|
+
sessionId,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Check if Claude Code CLI is available
|
|
130
|
+
*/
|
|
131
|
+
export async function checkClaudeCodeAvailable() {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
const proc = spawn(CLAUDE_PATH, ['--version']);
|
|
134
|
+
proc.on('close', (code) => {
|
|
135
|
+
resolve(code === 0);
|
|
136
|
+
});
|
|
137
|
+
proc.on('error', () => {
|
|
138
|
+
resolve(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function getSetting(tokenHash: string, key: string): string | null;
|
|
2
|
+
export declare function saveSetting(tokenHash: string, key: string, value: string): void;
|
|
3
|
+
export declare function getDraft(sessionName: string): string;
|
|
4
|
+
export declare function saveDraft(sessionName: string, content: string): void;
|
|
5
|
+
export declare function deleteDraft(sessionName: string): void;
|
|
6
|
+
export declare function cleanupOldDrafts(maxAgeDays?: number): number;
|
|
7
|
+
export declare function closeDb(): void;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const DATA_DIR = join(__dirname, '../data');
|
|
7
|
+
const DB_PATH = join(DATA_DIR, 'ai-cli-online.db');
|
|
8
|
+
if (!existsSync(DATA_DIR)) {
|
|
9
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
const db = new Database(DB_PATH);
|
|
12
|
+
db.pragma('journal_mode = WAL');
|
|
13
|
+
db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS drafts (
|
|
15
|
+
session_name TEXT PRIMARY KEY,
|
|
16
|
+
content TEXT NOT NULL DEFAULT '',
|
|
17
|
+
updated_at INTEGER NOT NULL
|
|
18
|
+
)
|
|
19
|
+
`);
|
|
20
|
+
db.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
22
|
+
token_hash TEXT NOT NULL,
|
|
23
|
+
key TEXT NOT NULL,
|
|
24
|
+
value TEXT NOT NULL,
|
|
25
|
+
updated_at INTEGER NOT NULL,
|
|
26
|
+
PRIMARY KEY (token_hash, key)
|
|
27
|
+
)
|
|
28
|
+
`);
|
|
29
|
+
// --- Drafts statements ---
|
|
30
|
+
const stmtGet = db.prepare('SELECT content FROM drafts WHERE session_name = ?');
|
|
31
|
+
const stmtUpsert = db.prepare(`
|
|
32
|
+
INSERT INTO drafts (session_name, content, updated_at) VALUES (?, ?, ?)
|
|
33
|
+
ON CONFLICT(session_name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at
|
|
34
|
+
`);
|
|
35
|
+
const stmtDelete = db.prepare('DELETE FROM drafts WHERE session_name = ?');
|
|
36
|
+
const stmtCleanup = db.prepare('DELETE FROM drafts WHERE updated_at < ?');
|
|
37
|
+
// --- Settings statements ---
|
|
38
|
+
const stmtSettingGet = db.prepare('SELECT value FROM settings WHERE token_hash = ? AND key = ?');
|
|
39
|
+
const stmtSettingUpsert = db.prepare(`
|
|
40
|
+
INSERT INTO settings (token_hash, key, value, updated_at) VALUES (?, ?, ?, ?)
|
|
41
|
+
ON CONFLICT(token_hash, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
42
|
+
`);
|
|
43
|
+
export function getSetting(tokenHash, key) {
|
|
44
|
+
const row = stmtSettingGet.get(tokenHash, key);
|
|
45
|
+
return row?.value ?? null;
|
|
46
|
+
}
|
|
47
|
+
export function saveSetting(tokenHash, key, value) {
|
|
48
|
+
stmtSettingUpsert.run(tokenHash, key, value, Date.now());
|
|
49
|
+
}
|
|
50
|
+
// --- Draft functions ---
|
|
51
|
+
export function getDraft(sessionName) {
|
|
52
|
+
const row = stmtGet.get(sessionName);
|
|
53
|
+
return row?.content ?? '';
|
|
54
|
+
}
|
|
55
|
+
export function saveDraft(sessionName, content) {
|
|
56
|
+
if (!content) {
|
|
57
|
+
stmtDelete.run(sessionName);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
stmtUpsert.run(sessionName, content, Date.now());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function deleteDraft(sessionName) {
|
|
64
|
+
stmtDelete.run(sessionName);
|
|
65
|
+
}
|
|
66
|
+
export function cleanupOldDrafts(maxAgeDays = 7) {
|
|
67
|
+
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
68
|
+
const result = stmtCleanup.run(cutoff);
|
|
69
|
+
return result.changes;
|
|
70
|
+
}
|
|
71
|
+
export function closeDb() {
|
|
72
|
+
db.close();
|
|
73
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FileEntry } from './types.js';
|
|
2
|
+
export type { FileEntry };
|
|
3
|
+
export declare const MAX_UPLOAD_SIZE: number;
|
|
4
|
+
export declare const MAX_DOWNLOAD_SIZE: number;
|
|
5
|
+
/** List files in a directory, directories first, then alphabetical */
|
|
6
|
+
export declare function listFiles(dirPath: string): Promise<FileEntry[]>;
|
|
7
|
+
/**
|
|
8
|
+
* Validate and resolve a requested path against a base CWD.
|
|
9
|
+
* Returns the resolved absolute path, or null if invalid.
|
|
10
|
+
*
|
|
11
|
+
* Since users already have full shell access, this mainly prevents
|
|
12
|
+
* REST API path traversal via encoded sequences like `../../../etc/passwd`.
|
|
13
|
+
* We resolve the path and ensure it's an absolute path that exists.
|
|
14
|
+
*/
|
|
15
|
+
export declare function validatePath(requested: string, baseCwd: string): Promise<string | null>;
|