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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/README.zh-CN.md +170 -0
  4. package/bin/ai-cli-online.mjs +89 -0
  5. package/install-service.sh +319 -0
  6. package/package.json +57 -0
  7. package/server/.env.example +18 -0
  8. package/server/dist/auth.d.ts +3 -0
  9. package/server/dist/auth.js +9 -0
  10. package/server/dist/claude.d.ts +16 -0
  11. package/server/dist/claude.js +141 -0
  12. package/server/dist/db.d.ts +7 -0
  13. package/server/dist/db.js +73 -0
  14. package/server/dist/files.d.ts +15 -0
  15. package/server/dist/files.js +56 -0
  16. package/server/dist/index.d.ts +1 -0
  17. package/server/dist/index.js +466 -0
  18. package/server/dist/plans.d.ts +7 -0
  19. package/server/dist/plans.js +120 -0
  20. package/server/dist/pty.d.ts +15 -0
  21. package/server/dist/pty.js +75 -0
  22. package/server/dist/storage.d.ts +22 -0
  23. package/server/dist/storage.js +149 -0
  24. package/server/dist/tmux.d.ts +40 -0
  25. package/server/dist/tmux.js +191 -0
  26. package/server/dist/types.d.ts +1 -0
  27. package/server/dist/types.js +1 -0
  28. package/server/dist/websocket.d.ts +4 -0
  29. package/server/dist/websocket.js +304 -0
  30. package/server/package.json +32 -0
  31. package/shared/dist/types.d.ts +40 -0
  32. package/shared/dist/types.js +1 -0
  33. package/shared/package.json +20 -0
  34. package/start.sh +39 -0
  35. package/web/dist/assets/index-79TY7o1G.css +32 -0
  36. package/web/dist/assets/index-mcWZLwbP.js +235 -0
  37. package/web/dist/assets/pdf-Tk4_4Bu3.js +12 -0
  38. package/web/dist/assets/pdf.worker-BA9kU3Pw.mjs +61080 -0
  39. package/web/dist/favicon.svg +5 -0
  40. package/web/dist/fonts/JetBrainsMono-Bold.woff2 +0 -0
  41. package/web/dist/fonts/JetBrainsMono-Regular.woff2 +0 -0
  42. package/web/dist/fonts/MapleMono-CN-Bold.woff2 +0 -0
  43. package/web/dist/fonts/MapleMono-CN-Regular.woff2 +0 -0
  44. package/web/dist/index.html +17 -0
  45. 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,3 @@
1
+ /** Constant-time string comparison using HMAC to prevent timing side-channel attacks.
2
+ * HMAC digests are always 32 bytes, so comparison is constant-time regardless of input lengths. */
3
+ export declare function safeTokenCompare(a: string, b: string): boolean;
@@ -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>;