gitinstall 1.1.0__py3-none-any.whl

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 (59) hide show
  1. gitinstall/__init__.py +61 -0
  2. gitinstall/_sdk.py +541 -0
  3. gitinstall/academic.py +831 -0
  4. gitinstall/admin.html +327 -0
  5. gitinstall/auto_update.py +384 -0
  6. gitinstall/autopilot.py +349 -0
  7. gitinstall/badge.py +476 -0
  8. gitinstall/checkpoint.py +330 -0
  9. gitinstall/cicd.py +499 -0
  10. gitinstall/clawhub.html +718 -0
  11. gitinstall/config_schema.py +353 -0
  12. gitinstall/db.py +984 -0
  13. gitinstall/db_backend.py +445 -0
  14. gitinstall/dep_chain.py +337 -0
  15. gitinstall/dependency_audit.py +1153 -0
  16. gitinstall/detector.py +542 -0
  17. gitinstall/doctor.py +493 -0
  18. gitinstall/education.py +869 -0
  19. gitinstall/enterprise.py +802 -0
  20. gitinstall/error_fixer.py +953 -0
  21. gitinstall/event_bus.py +251 -0
  22. gitinstall/executor.py +577 -0
  23. gitinstall/feature_flags.py +138 -0
  24. gitinstall/fetcher.py +921 -0
  25. gitinstall/huggingface.py +922 -0
  26. gitinstall/hw_detect.py +988 -0
  27. gitinstall/i18n.py +664 -0
  28. gitinstall/installer_registry.py +362 -0
  29. gitinstall/knowledge_base.py +379 -0
  30. gitinstall/license_check.py +605 -0
  31. gitinstall/llm.py +569 -0
  32. gitinstall/log.py +236 -0
  33. gitinstall/main.py +1408 -0
  34. gitinstall/mcp_agent.py +841 -0
  35. gitinstall/mcp_server.py +386 -0
  36. gitinstall/monorepo.py +810 -0
  37. gitinstall/multi_source.py +425 -0
  38. gitinstall/onboard.py +276 -0
  39. gitinstall/planner.py +222 -0
  40. gitinstall/planner_helpers.py +323 -0
  41. gitinstall/planner_known_projects.py +1010 -0
  42. gitinstall/planner_templates.py +996 -0
  43. gitinstall/remote_gpu.py +633 -0
  44. gitinstall/resilience.py +608 -0
  45. gitinstall/run_tests.py +572 -0
  46. gitinstall/skills.py +476 -0
  47. gitinstall/tool_schemas.py +324 -0
  48. gitinstall/trending.py +279 -0
  49. gitinstall/uninstaller.py +415 -0
  50. gitinstall/validate_top100.py +607 -0
  51. gitinstall/watchdog.py +180 -0
  52. gitinstall/web.py +1277 -0
  53. gitinstall/web_ui.html +2277 -0
  54. gitinstall-1.1.0.dist-info/METADATA +275 -0
  55. gitinstall-1.1.0.dist-info/RECORD +59 -0
  56. gitinstall-1.1.0.dist-info/WHEEL +5 -0
  57. gitinstall-1.1.0.dist-info/entry_points.txt +3 -0
  58. gitinstall-1.1.0.dist-info/licenses/LICENSE +21 -0
  59. gitinstall-1.1.0.dist-info/top_level.txt +1 -0
gitinstall/web.py ADDED
@@ -0,0 +1,1277 @@
1
+ """
2
+ web.py - gitinstall Web UI 服务器
3
+ =================================
4
+
5
+ 在浏览器中交互式安装 GitHub 开源项目。
6
+
7
+ 启动方式:
8
+ python tools/main.py web # 默认 8080 端口
9
+ python tools/main.py web --port 9090 # 指定端口
10
+ python tools/web.py # 直接运行
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac as _hmac
17
+ import json
18
+ import logging
19
+ import os
20
+ import re
21
+ import secrets
22
+ import ssl
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ import urllib.parse
27
+ import urllib.request
28
+ from collections import defaultdict
29
+ from http.server import HTTPServer, BaseHTTPRequestHandler
30
+ from io import StringIO
31
+ from pathlib import Path
32
+ from socketserver import ThreadingMixIn
33
+
34
+ # ── 确保 tools 目录可导入 ──
35
+ _THIS_DIR = Path(__file__).resolve().parent
36
+ if str(_THIS_DIR) not in sys.path:
37
+ sys.path.insert(0, str(_THIS_DIR))
38
+
39
+ import db as _db
40
+ from log import get_logger
41
+ from i18n import t
42
+
43
+ logger = get_logger(__name__)
44
+
45
+ # ── 请求体大小限制(1 MB) ──
46
+ MAX_BODY_SIZE = 1 * 1024 * 1024
47
+
48
+ # ── Rate Limiting(内存,按 IP) ──
49
+ _rate_limits: dict[str, list[float]] = defaultdict(list)
50
+ _rate_lock = __import__("threading").Lock()
51
+
52
+ # 单位:秒窗口内最大请求数 {路由前缀: (窗口秒数, 最大次数)}
53
+ _RATE_RULES: dict[str, tuple[int, int]] = {
54
+ "/api/login": (60, 10), # 登录:60s 内最多 10 次
55
+ "/api/register": (60, 5), # 注册:60s 内最多 5 次
56
+ "/api/forgot-password": (60, 3), # 忘记密码:60s 内最多 3 次
57
+ "/api/reset-password": (60, 5), # 重置密码:60s 内最多 5 次
58
+ "/api/admin/set": (60, 3), # 管理员设置:60s 内最多 3 次
59
+ "/api/plan": (60, 15), # 生成计划:60s 内最多 15 次
60
+ "/api/install": (60, 10), # 安装:60s 内最多 10 次
61
+ "/api/search": (60, 30), # 搜索:60s 内最多 30 次
62
+ "/api/audit": (60, 15), # 审计:60s 内最多 15 次
63
+ "/api/license": (60, 15), # 许可证:60s 内最多 15 次
64
+ "/api/updates": (60, 20), # 更新检查:60s 内最多 20 次
65
+ "/api/uninstall": (60, 5), # 卸载:60s 内最多 5 次
66
+ "/api/chain": (60, 10), # 依赖链:60s 内最多 10 次
67
+ "/api/kb/search": (60, 20), # 知识库搜索:60s 内最多 20 次
68
+ }
69
+
70
+
71
+ def _check_rate_limit(ip: str, path: str) -> bool:
72
+ """检查是否超出频率限制。返回 True 表示被限制。"""
73
+ rule = _RATE_RULES.get(path)
74
+ if not rule:
75
+ return False
76
+ window, max_count = rule
77
+ key = f"{ip}:{path}"
78
+ now = time.time()
79
+ with _rate_lock:
80
+ hits = _rate_limits[key]
81
+ # 清除过期记录
82
+ cutoff = now - window
83
+ _rate_limits[key] = [t_ for t_ in hits if t_ > cutoff]
84
+ if len(_rate_limits[key]) >= max_count:
85
+ return True
86
+ _rate_limits[key].append(now)
87
+ return False
88
+
89
+
90
+ # ── CSRF Token 管理 ──
91
+ _csrf_tokens: dict[str, float] = {} # {token: timestamp}
92
+ _csrf_lock = __import__("threading").Lock()
93
+ _CSRF_TTL = 3600 # 1 小时有效
94
+
95
+
96
+ def _generate_csrf_token() -> str:
97
+ """生成 CSRF token"""
98
+ token = secrets.token_urlsafe(32)
99
+ now = time.time()
100
+ with _csrf_lock:
101
+ # 清理过期 token
102
+ expired = [k for k, ts in _csrf_tokens.items() if now - ts > _CSRF_TTL]
103
+ for k in expired:
104
+ del _csrf_tokens[k]
105
+ _csrf_tokens[token] = now
106
+ return token
107
+
108
+
109
+ def _validate_csrf_token(token: str) -> bool:
110
+ """验证 CSRF token"""
111
+ if not token:
112
+ return False
113
+ with _csrf_lock:
114
+ ts = _csrf_tokens.get(token)
115
+ if ts is None:
116
+ return False
117
+ if time.time() - ts > _CSRF_TTL:
118
+ del _csrf_tokens[token]
119
+ return False
120
+ # 一次性使用:验证后删除
121
+ del _csrf_tokens[token]
122
+ return True
123
+
124
+
125
+ # ── 计划缓存(内存,最多保留 20 条,5 分钟过期) ──
126
+ _plan_cache: dict[str, tuple[float, dict]] = {} # {plan_id: (timestamp, result)}
127
+ _PLAN_TTL = 300 # 5 分钟
128
+
129
+
130
+ def _make_plan_id() -> str:
131
+ """生成高熵 plan_id(256-bit 安全随机)"""
132
+ return secrets.token_urlsafe(32)
133
+
134
+
135
+ def _cache_plan(plan_id: str, result: dict) -> None:
136
+ now = time.time()
137
+ # 先清理过期条目
138
+ expired = [k for k, (ts, _) in _plan_cache.items() if now - ts > _PLAN_TTL]
139
+ for k in expired:
140
+ del _plan_cache[k]
141
+ _plan_cache[plan_id] = (now, result)
142
+ if len(_plan_cache) > 20:
143
+ oldest_key = min(_plan_cache, key=lambda k: _plan_cache[k][0])
144
+ del _plan_cache[oldest_key]
145
+
146
+
147
+ def _pop_plan(plan_id: str) -> dict | None:
148
+ """取出并移除缓存的计划,同时检查是否过期。"""
149
+ entry = _plan_cache.pop(plan_id, None)
150
+ if entry is None:
151
+ return None
152
+ ts, result = entry
153
+ if time.time() - ts > _PLAN_TTL:
154
+ return None # 已过期
155
+ return result
156
+
157
+
158
+ # ── SSE 并发连接限制(防资源耗尽 DoS) ──
159
+ _active_installs: dict[str, int] = defaultdict(int)
160
+ _active_installs_lock = __import__("threading").Lock()
161
+ MAX_CONCURRENT_INSTALLS_PER_IP = 3
162
+
163
+ # ── 全局并发 plan 生成限制 ──
164
+ _active_plans = 0
165
+ _plan_lock = __import__("threading").Lock()
166
+ MAX_CONCURRENT_PLANS = 3
167
+
168
+
169
+ # ─────────────────────────────────────────────
170
+ # HTTP 服务器
171
+ # ─────────────────────────────────────────────
172
+
173
+ class _ThreadedServer(ThreadingMixIn, HTTPServer):
174
+ daemon_threads = True
175
+ allow_reuse_address = True
176
+
177
+
178
+ class _Handler(BaseHTTPRequestHandler):
179
+ """gitinstall Web UI 请求处理器"""
180
+
181
+ # 隐藏服务器版本信息
182
+ server_version = "gitinstall"
183
+ sys_version = ""
184
+
185
+ # 抑制默认日志(使用 logging 替代)
186
+ def log_message(self, fmt, *args):
187
+ logger.debug(fmt, *args)
188
+
189
+ # ── 安全响应头 ────────────────────────────
190
+
191
+ def _add_security_headers(self):
192
+ """为所有响应添加安全头"""
193
+ self.send_header("X-Content-Type-Options", "nosniff")
194
+ self.send_header("X-Frame-Options", "DENY")
195
+ self.send_header("X-XSS-Protection", "1; mode=block")
196
+ self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
197
+ self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.loli.net; font-src 'self' https://fonts.gstatic.com https://gstatic.loli.net; img-src 'self' data:; connect-src 'self'")
198
+
199
+ # ── Rate Limiting 检查 ────────────────────
200
+
201
+ def _rate_limited(self, path: str) -> bool:
202
+ """如果被限制则返回 429 并返回 True"""
203
+ ip = self._client_ip()
204
+ if _check_rate_limit(ip, path):
205
+ body = json.dumps({"status": "error", "message": t("api.rate_limited")}, ensure_ascii=False).encode()
206
+ self.send_response(429)
207
+ self.send_header("Content-Type", "application/json; charset=utf-8")
208
+ self.send_header("Content-Length", str(len(body)))
209
+ self.send_header("Retry-After", "60")
210
+ self._add_security_headers()
211
+ self.end_headers()
212
+ self.wfile.write(body)
213
+ return True
214
+ return False
215
+
216
+ def _check_csrf(self) -> bool:
217
+ """检查 POST 请求的 CSRF token。返回 True 表示通过。"""
218
+ # Bearer token API 调用免 CSRF(非浏览器场景)
219
+ auth = self.headers.get("Authorization", "")
220
+ if auth.startswith("Bearer "):
221
+ return True
222
+ # 检查 X-CSRF-Token header
223
+ csrf_token = self.headers.get("X-CSRF-Token", "")
224
+ if _validate_csrf_token(csrf_token):
225
+ return True
226
+ # CSRF 验证失败
227
+ body = json.dumps({"status": "error", "message": "CSRF token invalid or missing"}, ensure_ascii=False).encode()
228
+ self.send_response(403)
229
+ self.send_header("Content-Type", "application/json; charset=utf-8")
230
+ self.send_header("Content-Length", str(len(body)))
231
+ self._add_security_headers()
232
+ self.end_headers()
233
+ self.wfile.write(body)
234
+ return False
235
+
236
+ # ── 路由 ──────────────────────────────────
237
+
238
+ def do_GET(self):
239
+ parsed = urllib.parse.urlparse(self.path)
240
+ path = parsed.path
241
+ qs = urllib.parse.parse_qs(parsed.query)
242
+
243
+ # ── API v1 路由别名(向前兼容)──
244
+ if path.startswith("/api/v1/"):
245
+ path = "/api/" + path[8:] # strip /api/v1/ → /api/
246
+
247
+ routes = {
248
+ "/": self._serve_ui,
249
+ "/admin": self._serve_admin,
250
+ "/clawhub": self._serve_clawhub,
251
+ "/health": self._health_check,
252
+ "/readiness": self._readiness_check,
253
+ "/api/csrf-token": self._api_csrf_token,
254
+ "/api/detect": self._api_detect,
255
+ "/api/doctor": self._api_doctor,
256
+ "/api/platforms": self._api_platforms,
257
+ "/api/trending": self._api_trending,
258
+ "/api/trending/refresh": self._api_trending_refresh,
259
+ "/api/stats": self._api_stats,
260
+ "/api/user": self._api_user,
261
+ "/api/audit": self._api_audit,
262
+ "/api/license": self._api_license,
263
+ "/api/updates": self._api_updates,
264
+ "/api/flags": self._api_flags,
265
+ "/api/registry": self._api_registry,
266
+ "/api/events": self._api_events,
267
+ "/api/kb/stats": self._api_kb_stats,
268
+ }
269
+ if path == "/api/install":
270
+ if self._rate_limited(path):
271
+ return
272
+ return self._api_install_stream(qs)
273
+ if path == "/api/search":
274
+ if self._rate_limited(path):
275
+ return
276
+ return self._api_search(qs)
277
+ handler = routes.get(path)
278
+ if handler:
279
+ handler()
280
+ else:
281
+ self.send_error(404)
282
+
283
+ def do_POST(self):
284
+ parsed = urllib.parse.urlparse(self.path)
285
+ path = parsed.path
286
+
287
+ # ── API v1 路由别名 ──
288
+ if path.startswith("/api/v1/"):
289
+ path = "/api/" + path[8:]
290
+
291
+ if self._rate_limited(path):
292
+ return
293
+
294
+ # CSRF 保护:所有 POST 请求需要 CSRF token 或 Bearer auth
295
+ if not self._check_csrf():
296
+ return
297
+
298
+ post_routes = {
299
+ "/api/plan": self._api_plan,
300
+ "/api/register": self._api_register,
301
+ "/api/login": self._api_login,
302
+ "/api/forgot-password": self._api_forgot_password,
303
+ "/api/reset-password": self._api_reset_password,
304
+ "/api/admin/set": self._api_admin_set,
305
+ "/api/uninstall": self._api_uninstall,
306
+ "/api/chain": self._api_chain,
307
+ "/api/kb/search": self._api_kb_search,
308
+ }
309
+ handler = post_routes.get(path)
310
+ if handler:
311
+ handler()
312
+ else:
313
+ self.send_error(404)
314
+
315
+ # ── 工具:获取客户端 IP ─────────────────
316
+
317
+ def _client_ip(self) -> str:
318
+ # 仅在反向代理场景信任 X-Forwarded-For(由 nginx 设置)
319
+ # 直接暴露时应只用 client_address
320
+ xff = self.headers.get("X-Forwarded-For")
321
+ if xff and self.client_address[0] in ("127.0.0.1", "::1"):
322
+ return xff.split(",")[0].strip()
323
+ return self.client_address[0]
324
+
325
+ # ── 页面 ──────────────────────────────────
326
+
327
+ def _serve_ui(self):
328
+ html_path = _THIS_DIR / "web_ui.html"
329
+ if not html_path.exists():
330
+ self.send_error(500, "web_ui.html not found")
331
+ return
332
+ content = html_path.read_bytes()
333
+ self.send_response(200)
334
+ self.send_header("Content-Type", "text/html; charset=utf-8")
335
+ self.send_header("Content-Length", str(len(content)))
336
+ self._add_security_headers()
337
+ self.end_headers()
338
+ self.wfile.write(content)
339
+ try:
340
+ _db.record_event("page_view", ip=self._client_ip())
341
+ except Exception:
342
+ pass
343
+
344
+ def _serve_admin(self):
345
+ html_path = _THIS_DIR / "admin.html"
346
+ if not html_path.exists():
347
+ self.send_error(500, "admin.html not found")
348
+ return
349
+ content = html_path.read_bytes()
350
+ self.send_response(200)
351
+ self.send_header("Content-Type", "text/html; charset=utf-8")
352
+ self.send_header("Content-Length", str(len(content)))
353
+ self._add_security_headers()
354
+ self.end_headers()
355
+ self.wfile.write(content)
356
+
357
+ def _serve_clawhub(self):
358
+ html_path = _THIS_DIR / "clawhub.html"
359
+ if not html_path.exists():
360
+ self.send_error(500, "clawhub.html not found")
361
+ return
362
+ content = html_path.read_bytes()
363
+ self.send_response(200)
364
+ self.send_header("Content-Type", "text/html; charset=utf-8")
365
+ self.send_header("Content-Length", str(len(content)))
366
+ self._add_security_headers()
367
+ self.end_headers()
368
+ self.wfile.write(content)
369
+
370
+ # ── Health Check / Readiness ─────────────
371
+
372
+ def _health_check(self):
373
+ """健康检查端点(用于负载均衡/Kubernetes 探针)"""
374
+ checks = {"status": "ok", "timestamp": time.time()}
375
+ # 检查数据库连接
376
+ try:
377
+ _db.init_db()
378
+ conn = _db._get_conn()
379
+ conn.execute("SELECT 1").fetchone()
380
+ checks["db"] = "ok"
381
+ except Exception:
382
+ checks["db"] = "error"
383
+ checks["status"] = "degraded"
384
+ self._json(checks, 200 if checks["status"] == "ok" else 503)
385
+
386
+ def _readiness_check(self):
387
+ """就绪检查端点"""
388
+ self._json({"status": "ok", "ready": True, "version": "1.0.0"})
389
+
390
+ # ── CSRF Token 端点 ──────────────────────
391
+
392
+ def _api_csrf_token(self):
393
+ """获取 CSRF token(GET 请求,浏览器调用后附加到 POST 请求头中)"""
394
+ token = _generate_csrf_token()
395
+ self._json({"csrf_token": token})
396
+
397
+ # ── API: 环境检测 ─────────────────────────
398
+
399
+ def _api_detect(self):
400
+ from detector import EnvironmentDetector
401
+ env = EnvironmentDetector().detect()
402
+ # 只返回必要信息,不暴露完整系统指纹
403
+ safe_env = {
404
+ "os": env.get("os", {}).get("type", ""),
405
+ "arch": env.get("os", {}).get("arch", ""),
406
+ "gpu": env.get("gpu", {}).get("type", "none"),
407
+ "has_python": bool(env.get("runtimes", {}).get("python")),
408
+ "has_node": bool(env.get("runtimes", {}).get("node")),
409
+ "has_docker": bool(env.get("runtimes", {}).get("docker")),
410
+ }
411
+ self._json({"status": "ok", "env": safe_env})
412
+
413
+ # ── API: 环境诊断 ─────────────────────────
414
+
415
+ def _api_doctor(self):
416
+ try:
417
+ from doctor import run_doctor, doctor_to_dict
418
+ report = run_doctor()
419
+ self._json(doctor_to_dict(report))
420
+ except Exception as e:
421
+ logger.warning("doctor failed: %s", e)
422
+ self._json({"status": "error", "message": t("api.query_failed")}, 502)
423
+
424
+ # ── API: 支持平台 ─────────────────────────
425
+
426
+ def _api_platforms(self):
427
+ try:
428
+ from multi_source import get_supported_platforms
429
+ platforms = get_supported_platforms()
430
+ self._json({"status": "ok", "platforms": platforms})
431
+ except Exception as e:
432
+ logger.warning("platforms query failed: %s", e)
433
+ self._json({"status": "error", "message": t("api.query_failed")}, 502)
434
+
435
+ # ── API: 热门项目 ─────────────────────────
436
+
437
+ def _api_trending(self):
438
+ """返回当前热门开源项目列表(动态爬取 + 缓存)"""
439
+ from trending import get_trending
440
+ projects = get_trending()
441
+ self._json({"status": "ok", "projects": projects})
442
+ try:
443
+ _db.record_event("trending_view", ip=self._client_ip())
444
+ except Exception:
445
+ pass
446
+
447
+ def _api_trending_refresh(self):
448
+ """强制刷新热门项目缓存(仅管理员)"""
449
+ token = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
450
+ if not _db.is_admin(token):
451
+ return self._json({"status": "error", "message": t("auth.admin_required")}, 403)
452
+ from trending import get_trending
453
+ projects = get_trending(force_refresh=True)
454
+ self._json({"status": "ok", "projects": projects, "refreshed": True})
455
+
456
+ # ── API: GitHub 搜索 ──────────────────────
457
+
458
+ def _api_search(self, qs: dict):
459
+ """搜索 GitHub 仓库"""
460
+ query = qs.get("q", [""])[0].strip()
461
+ if not query:
462
+ return self._json({"status": "error", "message": t("api.search_keyword_required")}, 400)
463
+
464
+ url = "https://api.github.com/search/repositories?" + urllib.parse.urlencode({
465
+ "q": query, "sort": "stars", "per_page": "12",
466
+ })
467
+ req = urllib.request.Request(url, headers={
468
+ "Accept": "application/vnd.github.v3+json",
469
+ "User-Agent": "gitinstall/1.0",
470
+ })
471
+ try:
472
+ with urllib.request.urlopen(req, timeout=10) as resp:
473
+ data = json.loads(resp.read())
474
+ results = []
475
+ for item in data.get("items", []):
476
+ results.append({
477
+ "repo": item["full_name"],
478
+ "name": item["name"],
479
+ "desc": (item.get("description") or "")[:120],
480
+ "stars": item.get("stargazers_count", 0),
481
+ "lang": item.get("language") or "",
482
+ })
483
+ self._json({"status": "ok", "results": results, "total": data.get("total_count", 0)})
484
+ try:
485
+ _db.record_event("search", project=query, ip=self._client_ip())
486
+ except Exception:
487
+ pass
488
+ except Exception as e:
489
+ logger.warning("搜索失败: %s", e)
490
+ self._json({"status": "error", "message": t("api.search_failed")}, 502)
491
+
492
+ # ── API: 生成计划 ─────────────────────────
493
+
494
+ def _api_plan(self):
495
+ global _active_plans
496
+ # 全局并发 plan 限制(防资源耗尽)
497
+ with _plan_lock:
498
+ if _active_plans >= MAX_CONCURRENT_PLANS:
499
+ return self._json({"status": "error", "message": t("api.system_busy")}, 503)
500
+ _active_plans += 1
501
+ try:
502
+ self._do_api_plan()
503
+ finally:
504
+ with _plan_lock:
505
+ _active_plans -= 1
506
+
507
+ def _do_api_plan(self):
508
+ body = self._read_body()
509
+ try:
510
+ data = json.loads(body)
511
+ except json.JSONDecodeError:
512
+ return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
513
+
514
+ project = data.get("project", "").strip()
515
+ if not project:
516
+ return self._json({"status": "error", "message": t("api.project_required")}, 400)
517
+
518
+ llm = data.get("llm")
519
+ local_mode = data.get("local", False)
520
+
521
+ # 抑制 cmd_plan 的 stderr 输出(进度信息)
522
+ old_stderr = sys.stderr
523
+ sys.stderr = StringIO()
524
+ try:
525
+ from main import cmd_plan
526
+ result = cmd_plan(project, llm_force=llm, use_local=local_mode)
527
+ finally:
528
+ log = sys.stderr.getvalue()
529
+ sys.stderr = old_stderr
530
+
531
+ result["_log"] = log
532
+
533
+ # 缓存计划供 install 使用
534
+ if result.get("status") == "ok":
535
+ plan_id = _make_plan_id()
536
+ _cache_plan(plan_id, result)
537
+ result["plan_id"] = plan_id
538
+
539
+ # 记录事件 + 保存历史
540
+ try:
541
+ plan_data = result.get("plan", {})
542
+ _db.record_event(
543
+ "plan_generated",
544
+ project=project,
545
+ detail={"strategy": result.get("strategy"), "confidence": result.get("confidence")},
546
+ ip=self._client_ip(),
547
+ )
548
+ _db.save_plan_history(
549
+ project=project,
550
+ strategy=result.get("strategy"),
551
+ confidence=result.get("confidence"),
552
+ steps=plan_data.get("steps"),
553
+ )
554
+ except Exception:
555
+ pass
556
+
557
+ # 保护核心技术:不向客户端暴露内部策略/调试信息
558
+ for _k in ("_log", "strategy", "confidence", "_stderr"):
559
+ result.pop(_k, None)
560
+
561
+ self._json(result)
562
+
563
+ # ── API: 安装(SSE 流) ──────────────────
564
+
565
+ def _api_install_stream(self, qs: dict):
566
+ plan_id = qs.get("plan_id", [""])[0]
567
+ project = qs.get("project", [""])[0]
568
+ install_dir = qs.get("install_dir", [""])[0]
569
+
570
+ if not plan_id and not project:
571
+ self.send_error(400, "Missing plan_id or project")
572
+ return
573
+
574
+ # 并发安装限制(防资源耗尽)
575
+ ip = self._client_ip()
576
+ with _active_installs_lock:
577
+ if _active_installs[ip] >= MAX_CONCURRENT_INSTALLS_PER_IP:
578
+ body = json.dumps({"status": "error", "message": t("api.too_many_installs")}, ensure_ascii=False).encode()
579
+ self.send_response(429)
580
+ self.send_header("Content-Type", "application/json; charset=utf-8")
581
+ self.send_header("Content-Length", str(len(body)))
582
+ self._add_security_headers()
583
+ self.end_headers()
584
+ self.wfile.write(body)
585
+ return
586
+ _active_installs[ip] += 1
587
+
588
+ try:
589
+ self._do_install_stream(qs, plan_id, project, install_dir)
590
+ finally:
591
+ with _active_installs_lock:
592
+ _active_installs[ip] = max(0, _active_installs[ip] - 1)
593
+
594
+ def _do_install_stream(self, qs: dict, plan_id: str, project: str, install_dir: str):
595
+
596
+ # SSE headers
597
+ self.send_response(200)
598
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
599
+ self.send_header("Cache-Control", "no-cache")
600
+ self.send_header("X-Accel-Buffering", "no")
601
+ self._add_security_headers()
602
+ self.end_headers()
603
+
604
+ alive = True
605
+
606
+ def sse(event: str, data: dict) -> bool:
607
+ nonlocal alive
608
+ if not alive:
609
+ return False
610
+ msg = f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
611
+ try:
612
+ self.wfile.write(msg.encode())
613
+ self.wfile.flush()
614
+ return True
615
+ except (BrokenPipeError, ConnectionResetError, OSError):
616
+ alive = False
617
+ return False
618
+
619
+ # 1. 取计划(优先用缓存)
620
+ plan_result = _pop_plan(plan_id) if plan_id else None
621
+
622
+ if not plan_result:
623
+ if not project:
624
+ sse("step_error", {"message": t("install.plan_expired")})
625
+ sse("done", {"success": False, "message": t("install.plan_expired")})
626
+ return
627
+ sse("phase", {"name": "plan", "message": t("install.regenerating_plan")})
628
+ old_stderr = sys.stderr
629
+ sys.stderr = StringIO()
630
+ try:
631
+ from main import cmd_plan
632
+ plan_result = cmd_plan(project)
633
+ finally:
634
+ sys.stderr = old_stderr
635
+ if plan_result.get("status") != "ok":
636
+ sse("step_error", {"message": plan_result.get("message", t("install.plan_failed"))})
637
+ sse("done", {"success": False, "message": t("install.plan_failed")})
638
+ return
639
+
640
+ plan = plan_result["plan"]
641
+ steps = plan.get("steps", [])
642
+
643
+ if not steps:
644
+ sse("step_error", {"message": t("install.no_steps")})
645
+ sse("done", {"success": False, "message": t("install.no_steps")})
646
+ return
647
+
648
+ sse("plan", {
649
+ "project": plan_result.get("project", project),
650
+ "steps": steps,
651
+ "launch_command": plan.get("launch_command", ""),
652
+ })
653
+
654
+ # 2. 逐步执行
655
+ home = os.path.realpath(os.path.expanduser("~"))
656
+ if install_dir:
657
+ work_dir = os.path.realpath(os.path.expanduser(install_dir))
658
+ # 安全检查:只允许在用户家目录下安装
659
+ if not work_dir.startswith(home + os.sep) and work_dir != home:
660
+ sse("step_error", {"message": t("install.dir_security")})
661
+ sse("done", {"success": False, "message": t("install.dir_invalid")})
662
+ return
663
+ os.makedirs(work_dir, exist_ok=True)
664
+ # TOCTOU 防护:mkdir 后再次验证路径
665
+ work_dir = os.path.realpath(work_dir)
666
+ if not work_dir.startswith(home + os.sep) and work_dir != home:
667
+ sse("step_error", {"message": t("install.dir_path_changed")})
668
+ sse("done", {"success": False, "message": t("install.dir_invalid")})
669
+ return
670
+ else:
671
+ work_dir = home
672
+ all_ok = True
673
+ t_total = time.time()
674
+
675
+ from executor import check_command_safety
676
+
677
+ for i, step in enumerate(steps):
678
+ cmd = step.get("command", "").strip()
679
+ desc = step.get("description", "")
680
+
681
+ if not sse("step_start", {
682
+ "index": i, "total": len(steps),
683
+ "description": desc, "command": cmd,
684
+ }):
685
+ return
686
+
687
+ if not cmd:
688
+ sse("step_done", {"index": i, "success": True, "duration": 0})
689
+ continue
690
+
691
+ # 命令安全检查
692
+ is_safe, safety_msg = check_command_safety(cmd)
693
+ if not is_safe:
694
+ sse("step_error", {"index": i, "message": safety_msg})
695
+ sse("done", {"success": False, "message": t("install.unsafe_command")})
696
+ return
697
+
698
+ t0 = time.time()
699
+ try:
700
+ proc = subprocess.Popen(
701
+ cmd, shell=True,
702
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
703
+ cwd=work_dir, text=True, bufsize=1,
704
+ env={**os.environ, "PYTHONUNBUFFERED": "1"},
705
+ )
706
+ output_lines = 0
707
+ MAX_OUTPUT_LINES = 10000
708
+ for line in proc.stdout:
709
+ output_lines += 1
710
+ if output_lines > MAX_OUTPUT_LINES:
711
+ if output_lines == MAX_OUTPUT_LINES + 1:
712
+ sse("output", {"index": i, "line": t("install.output_truncated")})
713
+ continue
714
+ if not sse("output", {"index": i, "line": line.rstrip("\n")}):
715
+ proc.kill()
716
+ return
717
+ proc.wait(timeout=600)
718
+ dur = round(time.time() - t0, 1)
719
+ ok = proc.returncode == 0
720
+
721
+ sse("step_done", {
722
+ "index": i, "success": ok,
723
+ "exit_code": proc.returncode, "duration": dur,
724
+ })
725
+ if not ok:
726
+ all_ok = False
727
+ sse("step_error", {
728
+ "index": i,
729
+ "message": t("install.cmd_exit_code", code=proc.returncode),
730
+ })
731
+ break
732
+
733
+ except subprocess.TimeoutExpired:
734
+ proc.kill()
735
+ all_ok = False
736
+ sse("step_done", {"index": i, "success": False, "duration": 600})
737
+ sse("step_error", {"index": i, "message": t("install.cmd_timeout", minutes=10)})
738
+ break
739
+ except Exception as e:
740
+ all_ok = False
741
+ dur = round(time.time() - t0, 1)
742
+ sse("step_done", {"index": i, "success": False, "duration": dur})
743
+ sse("step_error", {"index": i, "message": str(e)})
744
+ break
745
+
746
+ total_dur = round(time.time() - t_total, 1)
747
+ launch = plan.get("launch_command", "")
748
+ sse("done", {
749
+ "success": all_ok,
750
+ "message": t("install.complete") if all_ok else t("install.not_complete"),
751
+ "launch_command": launch,
752
+ "total_duration": total_dur,
753
+ })
754
+
755
+ # 记录安装结果
756
+ try:
757
+ evt = "install_done" if all_ok else "install_failed"
758
+ proj = plan_result.get("project", project)
759
+ _db.record_event(evt, project=proj, detail={"duration": total_dur}, ip=self._client_ip())
760
+ except Exception:
761
+ pass
762
+
763
+ # ── API: 统计信息 ─────────────────
764
+
765
+ def _api_stats(self):
766
+ """返回统计信息(仅管理员)"""
767
+ token = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
768
+ if not _db.is_admin(token):
769
+ return self._json({"status": "error", "message": t("auth.admin_required")}, 403)
770
+ try:
771
+ stats = _db.get_stats()
772
+ stats["recent_installs"] = _db.get_recent_installs(10)
773
+ self._json({"status": "ok", **stats})
774
+ except Exception:
775
+ logger.exception("获取统计信息失败")
776
+ self._json({"status": "error", "message": t("server.stats_error")}, 500)
777
+
778
+ # ── API: 设置管理员 ───────────────
779
+
780
+ def _api_admin_set(self):
781
+ """
782
+ 首次设置管理员:需要环境变量 GITINSTALL_ADMIN_SECRET 匹配。
783
+ 后续可由已有管理员提升他人。
784
+ """
785
+ body = self._read_body()
786
+ try:
787
+ data = json.loads(body)
788
+ except json.JSONDecodeError:
789
+ return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
790
+
791
+ target_user_id = data.get("user_id")
792
+ if not target_user_id:
793
+ return self._json({"status": "error", "message": t("api.missing_user_id")}, 400)
794
+
795
+ # 方式 1:通过 admin_secret(首次设置管理员)
796
+ admin_secret = data.get("admin_secret", "")
797
+ env_secret = os.environ.get("GITINSTALL_ADMIN_SECRET", "")
798
+ if env_secret and admin_secret == env_secret:
799
+ _db.set_admin(target_user_id, True)
800
+ return self._json({"status": "ok", "message": t("auth.admin_set_ok")})
801
+
802
+ # 方式 2:已有管理员授权
803
+ token = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
804
+ if _db.is_admin(token):
805
+ _db.set_admin(target_user_id, True)
806
+ return self._json({"status": "ok", "message": t("auth.admin_set_ok")})
807
+
808
+ self._json({"status": "error", "message": t("auth.no_permission")}, 403)
809
+
810
+ # ── API: 用户信息 ─────────────────
811
+
812
+ def _api_user(self):
813
+ """获取当前用户信息 + 配额"""
814
+ token = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
815
+ user = _db.validate_token(token) if token else None
816
+ quota = _db.check_quota(
817
+ user_id=user["id"] if user else None,
818
+ ip=self._client_ip(),
819
+ )
820
+ result = {"status": "ok", "quota": quota}
821
+ if user:
822
+ result["user"] = {"id": user["id"], "username": user["username"], "tier": user["tier"]}
823
+ self._json(result)
824
+
825
+ # ── API: 注册 ─────────────────────
826
+
827
+ def _api_register(self):
828
+ body = self._read_body()
829
+ try:
830
+ data = json.loads(body)
831
+ except json.JSONDecodeError:
832
+ return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
833
+ result = _db.register_user(
834
+ username=data.get("username", ""),
835
+ email=data.get("email", ""),
836
+ password=data.get("password", ""),
837
+ )
838
+ code = 200 if result["status"] == "ok" else 400
839
+ # 注册成功后发送欢迎邮件(后台发送,不阻塞响应)
840
+ if result["status"] == "ok":
841
+ try:
842
+ import threading as _th
843
+ _th.Thread(
844
+ target=_db.send_welcome_email,
845
+ args=(data.get("email", ""), data.get("username", "")),
846
+ daemon=True,
847
+ ).start()
848
+ except Exception:
849
+ pass
850
+ self._json(result, code)
851
+
852
+ # ── API: 登录 ─────────────────────
853
+
854
+ def _api_login(self):
855
+ body = self._read_body()
856
+ try:
857
+ data = json.loads(body)
858
+ except json.JSONDecodeError:
859
+ return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
860
+ result = _db.login_user(
861
+ email=data.get("email", ""),
862
+ password=data.get("password", ""),
863
+ )
864
+ code = 200 if result["status"] == "ok" else 401
865
+ self._json(result, code)
866
+
867
+ # ── API: 忘记密码 ─────────────────
868
+
869
+ def _api_forgot_password(self):
870
+ body = self._read_body()
871
+ try:
872
+ data = json.loads(body)
873
+ except json.JSONDecodeError:
874
+ return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
875
+ email = data.get("email", "").strip()
876
+ if not email:
877
+ return self._json({"status": "error", "message": t("auth.enter_email")}, 400)
878
+
879
+ result = _db.create_reset_token(email)
880
+ if result["token"]:
881
+ # 后台发送重置邮件
882
+ try:
883
+ import threading as _th
884
+ _th.Thread(
885
+ target=_db.send_reset_email,
886
+ args=(email, result["username"], result["token"]),
887
+ daemon=True,
888
+ ).start()
889
+ except Exception:
890
+ pass
891
+ # 无论邮箱是否存在,统一返回成功(防止枚举邮箱)
892
+ self._json({"status": "ok", "message": t("auth.reset_email_sent")})
893
+
894
+ # ── API: 重置密码 ─────────────────
895
+
896
+ def _api_reset_password(self):
897
+ body = self._read_body()
898
+ try:
899
+ data = json.loads(body)
900
+ except json.JSONDecodeError:
901
+ return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
902
+ token = data.get("token", "")
903
+ new_password = data.get("password", "")
904
+ if not token or not new_password:
905
+ return self._json({"status": "error", "message": t("auth.params_incomplete")}, 400)
906
+ result = _db.reset_password(token, new_password)
907
+ code = 200 if result["status"] == "ok" else 400
908
+ self._json(result, code)
909
+
910
+ # ── API: 依赖安全审计 ─────────────────────
911
+
912
+ def _api_audit(self):
913
+ parsed = urllib.parse.urlparse(self.path)
914
+ qs = urllib.parse.parse_qs(parsed.query)
915
+ project = qs.get("project", [""])[0].strip()
916
+ if not project:
917
+ return self._json({"status": "error", "message": t("api.missing_param", param="project")}, 400)
918
+ if not re.match(r'^[\w\-\.]+/[\w\-\.]+$', project):
919
+ return self._json({"status": "error", "message": t("api.param_format_error")}, 400)
920
+ try:
921
+ from fetcher import fetch_project
922
+ from dependency_audit import audit_project, audit_to_dict
923
+ info = fetch_project(project)
924
+ if not info.dependency_files:
925
+ return self._json({"status": "ok", "message": t("audit.no_deps"), "results": []})
926
+ results = audit_project(info.dependency_files)
927
+ self._json({"status": "ok", **audit_to_dict(results)})
928
+ try:
929
+ _db.record_event("audit", project=project, ip=self._client_ip())
930
+ except Exception:
931
+ pass
932
+ except Exception as e:
933
+ logger.warning("audit failed: %s", e)
934
+ self._json({"status": "error", "message": t("audit.failed")}, 502)
935
+
936
+ # ── API: 许可证检查 ────────────────────────
937
+
938
+ def _api_license(self):
939
+ parsed = urllib.parse.urlparse(self.path)
940
+ qs = urllib.parse.parse_qs(parsed.query)
941
+ project = qs.get("project", [""])[0].strip()
942
+ if not project:
943
+ return self._json({"status": "error", "message": t("api.missing_param", param="project")}, 400)
944
+ if not re.match(r'^[\w\-\.]+/[\w\-\.]+$', project):
945
+ return self._json({"status": "error", "message": t("api.param_format_error")}, 400)
946
+ try:
947
+ parts = project.split("/")
948
+ from license_check import fetch_license_from_github, analyze_license, license_to_dict
949
+ spdx_id, license_text = fetch_license_from_github(parts[0], parts[1])
950
+ if not spdx_id and not license_text:
951
+ return self._json({"status": "ok", "message": t("license.no_license"), "risk": "warning"})
952
+ result = analyze_license(spdx_id, license_text)
953
+ self._json({"status": "ok", **license_to_dict(result)})
954
+ try:
955
+ _db.record_event("license_check", project=project, ip=self._client_ip())
956
+ except Exception:
957
+ pass
958
+ except Exception as e:
959
+ logger.warning("license check failed: %s", e)
960
+ self._json({"status": "error", "message": t("license.check_failed")}, 502)
961
+
962
+ # ── API: 更新检查 ──────────────────────────
963
+
964
+ def _api_updates(self):
965
+ try:
966
+ from auto_update import InstallTracker, check_all_updates, updates_to_dict
967
+ tracker = InstallTracker()
968
+ parsed = urllib.parse.urlparse(self.path)
969
+ qs = urllib.parse.parse_qs(parsed.query)
970
+ action = qs.get("action", ["list"])[0]
971
+
972
+ if action == "check":
973
+ results = check_all_updates(tracker)
974
+ self._json({"status": "ok", **updates_to_dict(results)})
975
+ else:
976
+ projects = tracker.list_installed()
977
+ self._json({
978
+ "status": "ok",
979
+ "installed": [p.to_dict() for p in projects],
980
+ "total": len(projects),
981
+ })
982
+ try:
983
+ _db.record_event("updates_check", ip=self._client_ip())
984
+ except Exception:
985
+ pass
986
+ except Exception as e:
987
+ logger.warning("update check failed: %s", e)
988
+ self._json({"status": "error", "message": t("update.check_failed")}, 502)
989
+
990
+ # ── API: 卸载项目 ──────────────────────────
991
+
992
+ def _api_uninstall(self):
993
+ body = self._read_body()
994
+ try:
995
+ data = json.loads(body)
996
+ except json.JSONDecodeError:
997
+ return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
998
+
999
+ project = data.get("project", "").strip()
1000
+ if not project:
1001
+ return self._json({"status": "error", "message": t("api.missing_param", param="project")}, 400)
1002
+ if not re.match(r'^[\w\-\.]+/[\w\-\.]+$', project):
1003
+ return self._json({"status": "error", "message": t("api.param_format_error")}, 400)
1004
+
1005
+ keep_config = data.get("keep_config", False)
1006
+ confirm = data.get("confirm", False)
1007
+
1008
+ try:
1009
+ from auto_update import InstallTracker
1010
+ from uninstaller import plan_uninstall, execute_uninstall, uninstall_to_dict
1011
+ from fetcher import parse_repo_identifier
1012
+
1013
+ owner, repo = parse_repo_identifier(project)
1014
+ tracker = InstallTracker()
1015
+ proj = tracker.get_project(owner, repo)
1016
+
1017
+ if not proj:
1018
+ return self._json({"status": "error", "message": t("uninstall.not_found", project=project)}, 404)
1019
+
1020
+ plan = plan_uninstall(owner, repo, proj.install_dir,
1021
+ keep_config=keep_config, clean_only=False)
1022
+
1023
+ if not confirm:
1024
+ return self._json({"status": "ok", "action": "dry_run", **uninstall_to_dict(plan)})
1025
+
1026
+ result = execute_uninstall(plan, keep_config=keep_config)
1027
+ if result["success"]:
1028
+ tracker.remove_project(owner, repo)
1029
+
1030
+ self._json({"status": "ok" if result["success"] else "partial", **result})
1031
+ try:
1032
+ _db.record_event("uninstall", project=project, ip=self._client_ip())
1033
+ except Exception:
1034
+ pass
1035
+ except Exception as e:
1036
+ logger.warning("uninstall failed: %s", e)
1037
+ self._json({"status": "error", "message": t("uninstall.failed")}, 502)
1038
+
1039
+ # ── API: 功能开关 ─────────────────────────
1040
+
1041
+ def _api_flags(self):
1042
+ try:
1043
+ from feature_flags import get_all_status, format_flags_table
1044
+ status = get_all_status()
1045
+ self._json({"status": "ok", "flags": status})
1046
+ except Exception as e:
1047
+ logger.warning("flags query failed: %s", e)
1048
+ self._json({"status": "error", "message": t("api.query_failed")}, 502)
1049
+
1050
+ # ── API: 安装器注册表 ─────────────────────
1051
+
1052
+ def _api_registry(self):
1053
+ try:
1054
+ from installer_registry import InstallerRegistry
1055
+ registry = InstallerRegistry()
1056
+ self._json({
1057
+ "status": "ok",
1058
+ "total": len(registry.list_all()),
1059
+ "available": [i.info.name for i in registry.list_available()],
1060
+ "installers": registry.to_dict(),
1061
+ })
1062
+ except Exception as e:
1063
+ logger.warning("registry query failed: %s", e)
1064
+ self._json({"status": "error", "message": t("api.query_failed")}, 502)
1065
+
1066
+ # ── API: 事件历史 ─────────────────────────
1067
+
1068
+ def _api_events(self):
1069
+ try:
1070
+ from event_bus import get_event_bus
1071
+ bus = get_event_bus()
1072
+ history = bus.get_history(limit=50)
1073
+ self._json({
1074
+ "status": "ok",
1075
+ "events": [e.to_dict() for e in history],
1076
+ "total": len(history),
1077
+ })
1078
+ except Exception as e:
1079
+ logger.warning("events query failed: %s", e)
1080
+ self._json({"status": "error", "message": t("api.query_failed")}, 502)
1081
+
1082
+ # ── API: 知识库统计 ───────────────────────
1083
+
1084
+ def _api_kb_stats(self):
1085
+ try:
1086
+ from knowledge_base import KnowledgeBase
1087
+ kb = KnowledgeBase()
1088
+ stats = kb.get_stats()
1089
+ self._json({"status": "ok", **stats})
1090
+ except Exception as e:
1091
+ logger.warning("kb stats query failed: %s", e)
1092
+ self._json({"status": "error", "message": t("api.query_failed")}, 502)
1093
+
1094
+ # ── API: 知识库搜索 ───────────────────────
1095
+
1096
+ def _api_kb_search(self):
1097
+ try:
1098
+ body = json.loads(self._read_body())
1099
+ except (json.JSONDecodeError, ValueError):
1100
+ return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
1101
+ query = str(body.get("query", "")).strip()
1102
+ if not query:
1103
+ return self._json({"status": "error", "message": t("api.missing_param", param="query")}, 400)
1104
+ try:
1105
+ from knowledge_base import KnowledgeBase
1106
+ kb = KnowledgeBase()
1107
+ results = kb.search(project=query, limit=10)
1108
+ self._json({
1109
+ "status": "ok",
1110
+ "results": [{
1111
+ "project": r.entry.project,
1112
+ "score": r.score,
1113
+ "success": r.entry.success,
1114
+ "strategy": r.entry.strategy,
1115
+ "reasons": r.match_reasons,
1116
+ } for r in results],
1117
+ })
1118
+ except Exception as e:
1119
+ logger.warning("kb search failed: %s", e)
1120
+ self._json({"status": "error", "message": t("api.search_failed")}, 502)
1121
+
1122
+ # ── API: 依赖链分析 ───────────────────────
1123
+
1124
+ def _api_chain(self):
1125
+ try:
1126
+ body = json.loads(self._read_body())
1127
+ except (json.JSONDecodeError, ValueError):
1128
+ return self._json({"status": "error", "message": t("api.invalid_json")}, 400)
1129
+ project = str(body.get("project", "")).strip()
1130
+ if not project:
1131
+ return self._json({"status": "error", "message": t("api.missing_param", param="project")}, 400)
1132
+ try:
1133
+ from main import cmd_plan
1134
+ from dep_chain import build_chain_from_plan
1135
+ plan_result = cmd_plan(project)
1136
+ if plan_result.get("status") != "ok":
1137
+ return self._json(plan_result, 400)
1138
+ chain = build_chain_from_plan(plan_result["plan"])
1139
+ self._json({
1140
+ "status": "ok",
1141
+ "project": project,
1142
+ "chain": chain.to_dict(),
1143
+ "has_cycle": chain.has_cycle(),
1144
+ })
1145
+ except Exception as e:
1146
+ logger.warning("chain analysis failed: %s", e)
1147
+ self._json({"status": "error", "message": t("api.query_failed")}, 502)
1148
+
1149
+ # ── 工具方法 ──────────────────────────────
1150
+
1151
+ def _json(self, data: dict, code: int = 200):
1152
+ body = json.dumps(data, ensure_ascii=False, indent=2).encode()
1153
+ self.send_response(code)
1154
+ self.send_header("Content-Type", "application/json; charset=utf-8")
1155
+ self.send_header("Content-Length", str(len(body)))
1156
+ self._add_security_headers()
1157
+ self.end_headers()
1158
+ self.wfile.write(body)
1159
+
1160
+ def _read_body(self) -> str:
1161
+ length = int(self.headers.get("Content-Length", 0))
1162
+ if length > MAX_BODY_SIZE:
1163
+ raise ValueError(t("api.body_too_large"))
1164
+ if length <= 0:
1165
+ return ""
1166
+ return self.rfile.read(length).decode("utf-8")
1167
+
1168
+
1169
+ # ─────────────────────────────────────────────
1170
+ # 启动入口
1171
+ # ─────────────────────────────────────────────
1172
+
1173
+ def start_server(
1174
+ port: int = 8080,
1175
+ host: str = "",
1176
+ open_browser: bool = True,
1177
+ ssl_certfile: str = None,
1178
+ ssl_keyfile: str = None,
1179
+ ):
1180
+ """启动 gitinstall Web UI 服务器
1181
+
1182
+ Args:
1183
+ port: 端口号,默认 8080
1184
+ host: 绑定地址,默认读取 GITINSTALL_HOST 环境变量,否则 127.0.0.1
1185
+ open_browser: 是否自动打开浏览器
1186
+ ssl_certfile: TLS 证书文件路径(启用 HTTPS)
1187
+ ssl_keyfile: TLS 私钥文件路径
1188
+ """
1189
+ from log import configure
1190
+ configure()
1191
+
1192
+ bind_host = host or os.environ.get("GITINSTALL_HOST", "127.0.0.1")
1193
+
1194
+ # 环境变量覆盖 TLS 配置
1195
+ ssl_certfile = ssl_certfile or os.environ.get("GITINSTALL_TLS_CERT", "")
1196
+ ssl_keyfile = ssl_keyfile or os.environ.get("GITINSTALL_TLS_KEY", "")
1197
+ use_tls = bool(ssl_certfile and ssl_keyfile)
1198
+
1199
+ # 尝试多个端口
1200
+ server = None
1201
+ for p in range(port, port + 10):
1202
+ try:
1203
+ server = _ThreadedServer((bind_host, p), _Handler)
1204
+ port = p
1205
+ break
1206
+ except OSError:
1207
+ continue
1208
+
1209
+ if not server:
1210
+ print(t("server.port_unavailable", start=port, end=port + 9))
1211
+ sys.exit(1)
1212
+
1213
+ # 启用 HTTPS/TLS
1214
+ if use_tls:
1215
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
1216
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
1217
+ ctx.load_cert_chain(ssl_certfile, ssl_keyfile)
1218
+ server.socket = ctx.wrap_socket(server.socket, server_side=True)
1219
+
1220
+ protocol = "https" if use_tls else "http"
1221
+ display_host = bind_host if bind_host != "0.0.0.0" else "127.0.0.1"
1222
+ url = f"{protocol}://{display_host}:{port}"
1223
+ print()
1224
+ print(" ┌──────────────────────────────────────┐")
1225
+ print(" │ 🚀 gitinstall Web UI │")
1226
+ print(f" │ 🌐 {url:<30s} │")
1227
+ if use_tls:
1228
+ print(" │ 🔒 HTTPS/TLS enabled │")
1229
+ if bind_host == "0.0.0.0":
1230
+ print(f" │ {t('server.listening_all'):<35s} │")
1231
+ print(f" │ {t('server.exposed_warning'):<35s} │")
1232
+ print(" │ Ctrl+C to stop │")
1233
+ print(" └──────────────────────────────────────┘")
1234
+ print()
1235
+
1236
+ logger.info(t("server.started", host=bind_host, port=port))
1237
+
1238
+ # 定期清理过期 session(每小时一次)
1239
+ def _periodic_cleanup():
1240
+ import threading
1241
+ while True:
1242
+ time.sleep(3600)
1243
+ try:
1244
+ n = _db.cleanup_expired_sessions()
1245
+ if n:
1246
+ logger.info(t("server.session_cleanup", n=n))
1247
+ except Exception:
1248
+ logger.exception("清理过期会话失败")
1249
+
1250
+ _cleanup_thread = __import__("threading").Thread(target=_periodic_cleanup, daemon=True)
1251
+ _cleanup_thread.start()
1252
+
1253
+ if open_browser:
1254
+ import webbrowser
1255
+ webbrowser.open(url)
1256
+
1257
+ try:
1258
+ server.serve_forever()
1259
+ except KeyboardInterrupt:
1260
+ print(f"\n{t('server.stopped')}")
1261
+ server.server_close()
1262
+
1263
+
1264
+ if __name__ == "__main__":
1265
+ import argparse
1266
+ p = argparse.ArgumentParser(description="gitinstall Web UI")
1267
+ p.add_argument("--port", type=int, default=8080, help="Port (default 8080)")
1268
+ p.add_argument("--no-open", action="store_true", help="Don't open browser")
1269
+ p.add_argument("--ssl-cert", default="", help="TLS cert file for HTTPS")
1270
+ p.add_argument("--ssl-key", default="", help="TLS key file for HTTPS")
1271
+ args = p.parse_args()
1272
+ start_server(
1273
+ port=args.port,
1274
+ open_browser=not args.no_open,
1275
+ ssl_certfile=args.ssl_cert or None,
1276
+ ssl_keyfile=args.ssl_key or None,
1277
+ )