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.
- gitinstall/__init__.py +61 -0
- gitinstall/_sdk.py +541 -0
- gitinstall/academic.py +831 -0
- gitinstall/admin.html +327 -0
- gitinstall/auto_update.py +384 -0
- gitinstall/autopilot.py +349 -0
- gitinstall/badge.py +476 -0
- gitinstall/checkpoint.py +330 -0
- gitinstall/cicd.py +499 -0
- gitinstall/clawhub.html +718 -0
- gitinstall/config_schema.py +353 -0
- gitinstall/db.py +984 -0
- gitinstall/db_backend.py +445 -0
- gitinstall/dep_chain.py +337 -0
- gitinstall/dependency_audit.py +1153 -0
- gitinstall/detector.py +542 -0
- gitinstall/doctor.py +493 -0
- gitinstall/education.py +869 -0
- gitinstall/enterprise.py +802 -0
- gitinstall/error_fixer.py +953 -0
- gitinstall/event_bus.py +251 -0
- gitinstall/executor.py +577 -0
- gitinstall/feature_flags.py +138 -0
- gitinstall/fetcher.py +921 -0
- gitinstall/huggingface.py +922 -0
- gitinstall/hw_detect.py +988 -0
- gitinstall/i18n.py +664 -0
- gitinstall/installer_registry.py +362 -0
- gitinstall/knowledge_base.py +379 -0
- gitinstall/license_check.py +605 -0
- gitinstall/llm.py +569 -0
- gitinstall/log.py +236 -0
- gitinstall/main.py +1408 -0
- gitinstall/mcp_agent.py +841 -0
- gitinstall/mcp_server.py +386 -0
- gitinstall/monorepo.py +810 -0
- gitinstall/multi_source.py +425 -0
- gitinstall/onboard.py +276 -0
- gitinstall/planner.py +222 -0
- gitinstall/planner_helpers.py +323 -0
- gitinstall/planner_known_projects.py +1010 -0
- gitinstall/planner_templates.py +996 -0
- gitinstall/remote_gpu.py +633 -0
- gitinstall/resilience.py +608 -0
- gitinstall/run_tests.py +572 -0
- gitinstall/skills.py +476 -0
- gitinstall/tool_schemas.py +324 -0
- gitinstall/trending.py +279 -0
- gitinstall/uninstaller.py +415 -0
- gitinstall/validate_top100.py +607 -0
- gitinstall/watchdog.py +180 -0
- gitinstall/web.py +1277 -0
- gitinstall/web_ui.html +2277 -0
- gitinstall-1.1.0.dist-info/METADATA +275 -0
- gitinstall-1.1.0.dist-info/RECORD +59 -0
- gitinstall-1.1.0.dist-info/WHEEL +5 -0
- gitinstall-1.1.0.dist-info/entry_points.txt +3 -0
- gitinstall-1.1.0.dist-info/licenses/LICENSE +21 -0
- gitinstall-1.1.0.dist-info/top_level.txt +1 -0
gitinstall/executor.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""
|
|
2
|
+
executor.py - 安全的跨平台命令执行器
|
|
3
|
+
=====================================
|
|
4
|
+
|
|
5
|
+
设计原则:
|
|
6
|
+
1. 安全第一:执行前过滤危险命令,有白名单机制
|
|
7
|
+
2. 实时输出:边执行边打印,用户知道发生了什么
|
|
8
|
+
3. 错误恢复:捕获报错,调用 LLM 分析修复方案
|
|
9
|
+
4. 跨平台:macOS / Linux / Windows 三端适配
|
|
10
|
+
5. 可回滚:记录每一步,出错可提示用户撤销
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from log import get_logger
|
|
27
|
+
from i18n import t
|
|
28
|
+
|
|
29
|
+
logger = get_logger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ─────────────────────────────────────────────
|
|
33
|
+
# 数据结构
|
|
34
|
+
# ─────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class StepResult:
|
|
38
|
+
step_id: int
|
|
39
|
+
command: str
|
|
40
|
+
success: bool
|
|
41
|
+
stdout: str
|
|
42
|
+
stderr: str
|
|
43
|
+
exit_code: int
|
|
44
|
+
duration_sec: float
|
|
45
|
+
error_message: str = ""
|
|
46
|
+
fix_applied: bool = False
|
|
47
|
+
fix_command: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class InstallResult:
|
|
52
|
+
project: str
|
|
53
|
+
success: bool
|
|
54
|
+
steps: list[StepResult] = field(default_factory=list)
|
|
55
|
+
launch_command: str = ""
|
|
56
|
+
install_dir: str = ""
|
|
57
|
+
error_summary: str = ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ─────────────────────────────────────────────
|
|
61
|
+
# 危险命令检测
|
|
62
|
+
# ─────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
# 绝对禁止执行的命令模式(正则)
|
|
65
|
+
BLOCKED_PATTERNS = [
|
|
66
|
+
r'(?:^|/)rm\s+-[^\s]*r[^\s]*\s+/', # rm -rf /
|
|
67
|
+
r'(?:^|/)rm\s+-[^\s]*r[^\s]*\s+~\b', # rm -rf ~
|
|
68
|
+
r':\(\)\s*\{', # fork bomb
|
|
69
|
+
r'\bformat\s+[cCdDeE]:', # Windows format
|
|
70
|
+
r'\bmkfs\.\w', # mkfs.*
|
|
71
|
+
r'\bdd\s+if=/', # dd if=/dev/...
|
|
72
|
+
r'(?:^|/)chmod\s+777\s+/', # chmod 777 /
|
|
73
|
+
r'(?:^|/)chown\s+.*\s+/', # chown ... /
|
|
74
|
+
r'>\s*/dev/sda', # overwrite disk
|
|
75
|
+
r'\biptables\s+-F', # flush firewall
|
|
76
|
+
r'\buserdel\s+-r\b', # delete user
|
|
77
|
+
r'shutdown\s+-[hrPf]', # system shutdown
|
|
78
|
+
r'\breboot\b', # reboot
|
|
79
|
+
r'curl[^\n]+\|\s*sudo\s+(?:bash|sh)\b', # curl | sudo bash
|
|
80
|
+
r'wget[^\n]+\|\s*sudo\s+(?:bash|sh)\b', # wget | sudo sh
|
|
81
|
+
# ── G1: 编码绕过防护 ──
|
|
82
|
+
r'base64\s+(?:-d|--decode)\s*\|', # base64 -d | ...
|
|
83
|
+
r'xxd\s+(?:-r|--revert)\s*\|', # xxd -r | ...
|
|
84
|
+
r"""python3?\s+-c\s+.*(?:base64|codecs|decode|exec|eval|import\s+os|import\s+subprocess|import\s+shutil)""", # python -c payload
|
|
85
|
+
r'printf\s+[\'"]\\x[0-9a-f].*\|\s*(?:bash|sh)', # printf hex | bash
|
|
86
|
+
r'echo\s+-e\s+.*\\x[0-9a-f].*\|\s*(?:bash|sh)', # echo -e hex | bash
|
|
87
|
+
# ── G2: heredoc 绕过防护 ──
|
|
88
|
+
r'(?:bash|sh|zsh)\s*<<', # bash << heredoc
|
|
89
|
+
r'\beval\s+', # eval "..."
|
|
90
|
+
# ── G4: 额外磁盘/设备防护 ──
|
|
91
|
+
r'\bdd\s+.*\bof=\s*/dev/', # dd of=/dev/xxx
|
|
92
|
+
r'>\s*/dev/(?:sd|nvme|vd|hd)', # redirect to disk
|
|
93
|
+
# ── 补充:rm -rf 不带空格变体 ──
|
|
94
|
+
r'rm\s+-rf\s+/(?:\s|$)', # rm -rf / (strict)
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
# 需要用户额外确认的高风险命令
|
|
98
|
+
WARN_PATTERNS = [
|
|
99
|
+
(r'\bsudo\b', "⚠️ 该命令需要管理员权限"),
|
|
100
|
+
(r'\bchmod\b', "⚠️ 该命令修改文件权限"),
|
|
101
|
+
(r'\bcurl[^\n]+\|', "⚠️ 该命令从网络下载并执行脚本"),
|
|
102
|
+
(r'\bwget[^\n]+\|', "⚠️ 该命令从网络下载并执行脚本"),
|
|
103
|
+
(r'\bdocker run[^\n]+--privileged', "⚠️ 该 Docker 容器以特权模式运行"),
|
|
104
|
+
(r'\$\{?\w+\}?\s*/', "⚠️ 命令使用变量引用路径,可能含风险"),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _strip_comments(cmd: str) -> str:
|
|
109
|
+
"""移除 shell 行尾注释(简单场景,不处理引号内的 #)。"""
|
|
110
|
+
return re.sub(r'(?<!\S)#.*$', '', cmd, flags=re.MULTILINE)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def check_command_safety(command: str) -> tuple[bool, str]:
|
|
114
|
+
"""
|
|
115
|
+
检查命令安全性。
|
|
116
|
+
Returns:
|
|
117
|
+
(is_safe, warning_message)
|
|
118
|
+
is_safe=False 表示绝对禁止,warning_message 非空表示需要确认
|
|
119
|
+
"""
|
|
120
|
+
# G6: 先移除注释,防止注释混淆
|
|
121
|
+
command_clean = _strip_comments(command)
|
|
122
|
+
|
|
123
|
+
# 拆分 &&、||、; 分隔的子命令段,每段都要检查
|
|
124
|
+
segments = re.split(r'\s*(?:&&|\|\||;)\s*', command_clean)
|
|
125
|
+
for segment in segments:
|
|
126
|
+
segment = segment.strip()
|
|
127
|
+
if not segment:
|
|
128
|
+
continue
|
|
129
|
+
for pattern in BLOCKED_PATTERNS:
|
|
130
|
+
if re.search(pattern, segment, re.IGNORECASE):
|
|
131
|
+
return False, f"🚫 危险命令,已拒绝执行:{command}"
|
|
132
|
+
|
|
133
|
+
# 也对完整命令做一次检查(跨段的管道模式)
|
|
134
|
+
for pattern in BLOCKED_PATTERNS:
|
|
135
|
+
if re.search(pattern, command_clean, re.IGNORECASE):
|
|
136
|
+
return False, f"🚫 危险命令,已拒绝执行:{command}"
|
|
137
|
+
|
|
138
|
+
warnings = []
|
|
139
|
+
for pattern, msg in WARN_PATTERNS:
|
|
140
|
+
if re.search(pattern, command_clean, re.IGNORECASE):
|
|
141
|
+
warnings.append(msg)
|
|
142
|
+
|
|
143
|
+
return True, "\n".join(warnings)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ─────────────────────────────────────────────
|
|
147
|
+
# 路径适配
|
|
148
|
+
# ─────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
def adapt_path_for_os(path: str) -> str:
|
|
151
|
+
"""将路径中的 ~ 展开,并处理 Windows 路径分隔符"""
|
|
152
|
+
path = path.replace("~", str(Path.home()))
|
|
153
|
+
if platform.system() == "Windows":
|
|
154
|
+
# 只转换本地路径中的分隔符,不影响 URL
|
|
155
|
+
if not re.match(r'https?://', path):
|
|
156
|
+
path = path.replace("/", "\\")
|
|
157
|
+
return path
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_shell() -> list[str]:
|
|
161
|
+
"""根据平台返回合适的 shell 前缀"""
|
|
162
|
+
if platform.system() == "Windows":
|
|
163
|
+
# 优先 PowerShell,其次 cmd
|
|
164
|
+
if os.path.exists(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"):
|
|
165
|
+
return ["powershell", "-NonInteractive", "-Command"]
|
|
166
|
+
return ["cmd", "/c"]
|
|
167
|
+
else:
|
|
168
|
+
shell = os.environ.get("SHELL", "/bin/bash")
|
|
169
|
+
return [shell, "-c"]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ─────────────────────────────────────────────
|
|
173
|
+
# 命令执行器
|
|
174
|
+
# ─────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
class CommandExecutor:
|
|
177
|
+
"""
|
|
178
|
+
安全的命令执行器,支持实时输出、超时控制、错误捕获。
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
work_dir: Optional[str] = None,
|
|
184
|
+
timeout_sec: int = 600, # 默认 10 分钟超时
|
|
185
|
+
verbose: bool = True,
|
|
186
|
+
):
|
|
187
|
+
self.work_dir = work_dir or str(Path.home())
|
|
188
|
+
self.timeout_sec = timeout_sec
|
|
189
|
+
self.verbose = verbose
|
|
190
|
+
self._current_dir = self.work_dir
|
|
191
|
+
self._base_env = os.environ.copy()
|
|
192
|
+
self._env = self._base_env.copy()
|
|
193
|
+
|
|
194
|
+
def reset(self, work_dir: Optional[str] = None):
|
|
195
|
+
"""重置工作目录(用于回退策略切换)"""
|
|
196
|
+
if work_dir:
|
|
197
|
+
self.work_dir = work_dir
|
|
198
|
+
self._current_dir = self.work_dir
|
|
199
|
+
self._env = self._base_env.copy()
|
|
200
|
+
|
|
201
|
+
def _activate_virtualenv(self, command: str, cwd: str) -> StepResult | None:
|
|
202
|
+
"""处理虚拟环境激活命令,并将环境持久化到后续步骤。"""
|
|
203
|
+
activate_cmd = command.strip()
|
|
204
|
+
activate_path = ""
|
|
205
|
+
|
|
206
|
+
if activate_cmd.startswith("source "):
|
|
207
|
+
activate_path = activate_cmd[len("source "):].strip().strip('"').strip("'")
|
|
208
|
+
elif activate_cmd.endswith("activate") or activate_cmd.endswith("Activate.ps1"):
|
|
209
|
+
activate_path = activate_cmd.strip().strip('"').strip("'")
|
|
210
|
+
else:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
activate_file = Path(adapt_path_for_os(activate_path))
|
|
214
|
+
if not activate_file.is_absolute():
|
|
215
|
+
activate_file = Path(cwd) / activate_file
|
|
216
|
+
activate_file = activate_file.resolve()
|
|
217
|
+
|
|
218
|
+
if not activate_file.is_file():
|
|
219
|
+
# Windows: 尝试 Scripts\activate 替代 bin/activate
|
|
220
|
+
if platform.system() == "Windows":
|
|
221
|
+
alt = activate_file.parent.parent / "Scripts" / "activate"
|
|
222
|
+
if alt.is_file():
|
|
223
|
+
activate_file = alt
|
|
224
|
+
else:
|
|
225
|
+
return None
|
|
226
|
+
else:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
bin_dir = activate_file.parent
|
|
230
|
+
venv_dir = bin_dir.parent
|
|
231
|
+
old_path = self._env.get("PATH", "")
|
|
232
|
+
path_parts = [part for part in old_path.split(os.pathsep) if part]
|
|
233
|
+
bin_dir_str = str(bin_dir)
|
|
234
|
+
path_parts = [part for part in path_parts if part != bin_dir_str]
|
|
235
|
+
self._env["VIRTUAL_ENV"] = str(venv_dir)
|
|
236
|
+
self._env["PATH"] = os.pathsep.join([bin_dir_str] + path_parts)
|
|
237
|
+
|
|
238
|
+
return StepResult(0, command, True, "", "", 0, 0.0)
|
|
239
|
+
|
|
240
|
+
def run(self, command: str, working_dir: Optional[str] = None) -> StepResult:
|
|
241
|
+
"""
|
|
242
|
+
执行单条命令。
|
|
243
|
+
|
|
244
|
+
- 自动处理 cd 命令(更新当前目录状态)
|
|
245
|
+
- 实时打印输出
|
|
246
|
+
- 返回详细执行结果
|
|
247
|
+
"""
|
|
248
|
+
cwd = working_dir or self._current_dir
|
|
249
|
+
cwd = adapt_path_for_os(cwd)
|
|
250
|
+
command = command.strip()
|
|
251
|
+
|
|
252
|
+
# 处理 cd 命令(不真正执行,只更新目录状态)
|
|
253
|
+
if command.startswith("cd "):
|
|
254
|
+
target = command[3:].strip().strip('"').strip("'")
|
|
255
|
+
target = adapt_path_for_os(target)
|
|
256
|
+
# 处理相对路径
|
|
257
|
+
if not os.path.isabs(target):
|
|
258
|
+
target = str(Path(cwd) / target)
|
|
259
|
+
if os.path.isdir(target):
|
|
260
|
+
self._current_dir = target
|
|
261
|
+
return StepResult(0, command, True, "", "", 0, 0.0)
|
|
262
|
+
else:
|
|
263
|
+
return StepResult(
|
|
264
|
+
0, command, False, "", f"目录不存在: {target}", 1, 0.0,
|
|
265
|
+
error_message=f"目录不存在: {target}",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
venv_result = self._activate_virtualenv(command, cwd)
|
|
269
|
+
if venv_result is not None:
|
|
270
|
+
return venv_result
|
|
271
|
+
|
|
272
|
+
# 处理 mkdir 命令(提前创建目录)
|
|
273
|
+
if re.match(r'mkdir\s+(?:-p\s+)?(.+)', command):
|
|
274
|
+
match = re.match(r'mkdir\s+(?:-p\s+)?(.+)', command)
|
|
275
|
+
if match:
|
|
276
|
+
dir_path = adapt_path_for_os(match.group(1).strip())
|
|
277
|
+
if not os.path.isabs(dir_path):
|
|
278
|
+
dir_path = str(Path(cwd) / dir_path)
|
|
279
|
+
Path(dir_path).mkdir(parents=True, exist_ok=True)
|
|
280
|
+
return StepResult(0, command, True, "", "", 0, 0.0)
|
|
281
|
+
|
|
282
|
+
if self.verbose:
|
|
283
|
+
logger.info(" $ %s", command)
|
|
284
|
+
|
|
285
|
+
start = time.time()
|
|
286
|
+
stdout_lines = []
|
|
287
|
+
stderr_lines = []
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
# 确保目录存在
|
|
291
|
+
Path(cwd).mkdir(parents=True, exist_ok=True)
|
|
292
|
+
|
|
293
|
+
proc = subprocess.Popen(
|
|
294
|
+
command,
|
|
295
|
+
shell=True,
|
|
296
|
+
cwd=cwd,
|
|
297
|
+
env=self._env,
|
|
298
|
+
stdout=subprocess.PIPE,
|
|
299
|
+
stderr=subprocess.PIPE,
|
|
300
|
+
text=True,
|
|
301
|
+
encoding="utf-8",
|
|
302
|
+
errors="replace",
|
|
303
|
+
# Windows 需要这个避免弹出额外窗口
|
|
304
|
+
creationflags=subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# 实时读取输出
|
|
308
|
+
import threading
|
|
309
|
+
|
|
310
|
+
def read_stream(stream, lines, prefix=""):
|
|
311
|
+
for line in stream:
|
|
312
|
+
lines.append(line.rstrip())
|
|
313
|
+
if self.verbose:
|
|
314
|
+
sys.stdout.write(f" {prefix}{line}")
|
|
315
|
+
sys.stdout.flush()
|
|
316
|
+
|
|
317
|
+
t_out = threading.Thread(target=read_stream, args=(proc.stdout, stdout_lines))
|
|
318
|
+
t_err = threading.Thread(target=read_stream, args=(proc.stderr, stderr_lines, "⚠ "))
|
|
319
|
+
t_out.start()
|
|
320
|
+
t_err.start()
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
proc.wait(timeout=self.timeout_sec)
|
|
324
|
+
except subprocess.TimeoutExpired:
|
|
325
|
+
# 渐进式关闭:先 SIGTERM(graceful),等 5 秒,再 SIGKILL
|
|
326
|
+
import signal
|
|
327
|
+
if platform.system() != "Windows":
|
|
328
|
+
try:
|
|
329
|
+
proc.send_signal(signal.SIGTERM)
|
|
330
|
+
proc.wait(timeout=5)
|
|
331
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
332
|
+
proc.kill()
|
|
333
|
+
else:
|
|
334
|
+
proc.kill()
|
|
335
|
+
return StepResult(
|
|
336
|
+
0, command, False, "", "命令超时", -1,
|
|
337
|
+
time.time() - start,
|
|
338
|
+
error_message=f"命令执行超时({self.timeout_sec}秒)",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
t_out.join()
|
|
342
|
+
t_err.join()
|
|
343
|
+
|
|
344
|
+
duration = time.time() - start
|
|
345
|
+
success = proc.returncode == 0
|
|
346
|
+
stdout = "\n".join(stdout_lines)
|
|
347
|
+
stderr = "\n".join(stderr_lines)
|
|
348
|
+
|
|
349
|
+
return StepResult(
|
|
350
|
+
step_id=0,
|
|
351
|
+
command=command,
|
|
352
|
+
success=success,
|
|
353
|
+
stdout=stdout[-3000:], # 只保留最后 3000 字符
|
|
354
|
+
stderr=stderr[-3000:],
|
|
355
|
+
exit_code=proc.returncode,
|
|
356
|
+
duration_sec=round(duration, 1),
|
|
357
|
+
error_message=stderr[:500] if not success else "",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
except Exception as e:
|
|
361
|
+
return StepResult(
|
|
362
|
+
0, command, False, "", str(e), -1,
|
|
363
|
+
time.time() - start,
|
|
364
|
+
error_message=str(e),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ─────────────────────────────────────────────
|
|
369
|
+
# 安装计划执行器
|
|
370
|
+
# ─────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
class InstallExecutor:
|
|
373
|
+
"""
|
|
374
|
+
执行完整安装计划,支持自动错误修复。
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
def __init__(self, llm_provider=None, verbose: bool = True):
|
|
378
|
+
self.llm = llm_provider
|
|
379
|
+
self.verbose = verbose
|
|
380
|
+
self.executor = CommandExecutor(verbose=verbose)
|
|
381
|
+
|
|
382
|
+
def execute_plan(self, plan: dict, project_name: str = "") -> InstallResult:
|
|
383
|
+
"""
|
|
384
|
+
执行 LLM 生成的安装计划。
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
plan: {"steps": [...], "launch_command": "...", ...}
|
|
388
|
+
project_name: 项目名称(用于日志)
|
|
389
|
+
"""
|
|
390
|
+
steps = plan.get("steps", [])
|
|
391
|
+
launch_command = plan.get("launch_command", "")
|
|
392
|
+
|
|
393
|
+
result = InstallResult(
|
|
394
|
+
project=project_name,
|
|
395
|
+
success=False,
|
|
396
|
+
launch_command=launch_command,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
logger.info("="*50)
|
|
400
|
+
logger.info(t("exec.install_start", project=project_name, steps=len(steps)))
|
|
401
|
+
logger.info("="*50)
|
|
402
|
+
|
|
403
|
+
for i, step in enumerate(steps, 1):
|
|
404
|
+
command = step.get("command", "").strip()
|
|
405
|
+
description = step.get("description", f"步骤 {i}")
|
|
406
|
+
working_dir = step.get("working_dir", "")
|
|
407
|
+
|
|
408
|
+
if not command:
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
# 扩展路径中的 ~
|
|
412
|
+
if working_dir:
|
|
413
|
+
working_dir = adapt_path_for_os(working_dir)
|
|
414
|
+
|
|
415
|
+
logger.info(t("exec.step_progress", current=i, total=len(steps), description=description))
|
|
416
|
+
|
|
417
|
+
# 安全检查
|
|
418
|
+
is_safe, warning = check_command_safety(command)
|
|
419
|
+
if not is_safe:
|
|
420
|
+
logger.warning(warning)
|
|
421
|
+
result.error_summary = warning
|
|
422
|
+
return result
|
|
423
|
+
|
|
424
|
+
if warning and self.verbose:
|
|
425
|
+
logger.warning(warning)
|
|
426
|
+
|
|
427
|
+
# 执行命令
|
|
428
|
+
step_result = self.executor.run(command, working_dir or None)
|
|
429
|
+
step_result.step_id = i
|
|
430
|
+
result.steps.append(step_result)
|
|
431
|
+
|
|
432
|
+
if step_result.success:
|
|
433
|
+
dur = step_result.duration_sec
|
|
434
|
+
logger.info(t("exec.step_done", duration=dur))
|
|
435
|
+
else:
|
|
436
|
+
logger.error(t("exec.step_failed", code=step_result.exit_code))
|
|
437
|
+
|
|
438
|
+
# 尝试自动修复(规则引擎优先,LLM 兜底)
|
|
439
|
+
if step_result.stderr or step_result.stdout:
|
|
440
|
+
fixed = self._try_fix(step_result, i, len(steps))
|
|
441
|
+
if fixed:
|
|
442
|
+
step_result.fix_applied = True
|
|
443
|
+
result.steps[-1] = step_result
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
# 无法修复,终止
|
|
447
|
+
result.error_summary = (
|
|
448
|
+
f"第 {i} 步失败:{description}\n"
|
|
449
|
+
f"命令:{command}\n"
|
|
450
|
+
f"报错:{step_result.stderr[:500]}"
|
|
451
|
+
)
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
# 记录安装目录(通常是第一个 git clone 后的目录)
|
|
455
|
+
result.install_dir = self.executor._current_dir
|
|
456
|
+
result.success = True
|
|
457
|
+
logger.info("="*50)
|
|
458
|
+
logger.info(t("exec.install_done", project=project_name))
|
|
459
|
+
if launch_command:
|
|
460
|
+
logger.info(t("exec.launch_cmd", cmd=launch_command))
|
|
461
|
+
logger.info(t("exec.install_dir", dir=result.install_dir))
|
|
462
|
+
logger.info("="*50)
|
|
463
|
+
|
|
464
|
+
return result
|
|
465
|
+
|
|
466
|
+
def _try_fix(self, step_result: StepResult, step_num: int, total: int) -> bool:
|
|
467
|
+
"""
|
|
468
|
+
尝试自动修复失败的命令。
|
|
469
|
+
策略:规则引擎优先(确定性修复)→ LLM 兜底(智能分析)。
|
|
470
|
+
返回 True 表示修复成功。
|
|
471
|
+
"""
|
|
472
|
+
# ── 阶段1:规则引擎(无需 LLM,毫秒级响应)──
|
|
473
|
+
try:
|
|
474
|
+
from .error_fixer import diagnose
|
|
475
|
+
except ImportError:
|
|
476
|
+
from error_fixer import diagnose
|
|
477
|
+
|
|
478
|
+
fix = diagnose(step_result.command, step_result.stderr, step_result.stdout)
|
|
479
|
+
if fix:
|
|
480
|
+
logger.info(t("exec.rule_diagnosis", cause=fix.root_cause, confidence=fix.confidence))
|
|
481
|
+
|
|
482
|
+
if not fix.fix_commands and not fix.retry_original:
|
|
483
|
+
if fix.outcome == "trusted_failure":
|
|
484
|
+
step_result.error_message = fix.root_cause
|
|
485
|
+
return False
|
|
486
|
+
# 无需修复,直接标记成功(如 git clone 目录已存在、npm audit 警告)
|
|
487
|
+
step_result.success = True
|
|
488
|
+
step_result.fix_applied = True
|
|
489
|
+
step_result.fix_command = "(跳过)"
|
|
490
|
+
return True
|
|
491
|
+
|
|
492
|
+
# 执行修复命令
|
|
493
|
+
for fix_cmd in fix.fix_commands:
|
|
494
|
+
logger.info(t("exec.fix_cmd", cmd=fix_cmd))
|
|
495
|
+
fix_result = self.executor.run(fix_cmd)
|
|
496
|
+
if not fix_result.success:
|
|
497
|
+
logger.warning(t("exec.fix_failed"))
|
|
498
|
+
break
|
|
499
|
+
else:
|
|
500
|
+
# 所有修复命令成功
|
|
501
|
+
if fix.retry_original:
|
|
502
|
+
logger.info(t("exec.retrying"))
|
|
503
|
+
retry_result = self.executor.run(step_result.command)
|
|
504
|
+
if retry_result.success:
|
|
505
|
+
logger.info(t("exec.rule_fix_ok"))
|
|
506
|
+
step_result.fix_applied = True
|
|
507
|
+
step_result.fix_command = " && ".join(fix.fix_commands)
|
|
508
|
+
step_result.success = True
|
|
509
|
+
return True
|
|
510
|
+
else:
|
|
511
|
+
# fix_commands 自身就是替代方案,不需要重试原命令
|
|
512
|
+
logger.info(t("exec.rule_fix_ok"))
|
|
513
|
+
step_result.fix_applied = True
|
|
514
|
+
step_result.fix_command = " && ".join(fix.fix_commands)
|
|
515
|
+
step_result.success = True
|
|
516
|
+
return True
|
|
517
|
+
|
|
518
|
+
# ── 阶段2:LLM 智能修复(需要 provider 可用)──
|
|
519
|
+
if not self.llm:
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
logger.info(t("exec.llm_fallback"))
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
from .llm import ERROR_FIX_SYSTEM_PROMPT
|
|
526
|
+
except ImportError:
|
|
527
|
+
from llm import ERROR_FIX_SYSTEM_PROMPT
|
|
528
|
+
user_prompt = f"""
|
|
529
|
+
报错命令:{step_result.command}
|
|
530
|
+
|
|
531
|
+
STDERR 输出:
|
|
532
|
+
{step_result.stderr[:2000]}
|
|
533
|
+
|
|
534
|
+
STDOUT 输出(最后部分):
|
|
535
|
+
{step_result.stdout[-1000:]}
|
|
536
|
+
|
|
537
|
+
系统:{platform.system()} {platform.machine()}
|
|
538
|
+
"""
|
|
539
|
+
try:
|
|
540
|
+
response = self.llm.complete(ERROR_FIX_SYSTEM_PROMPT, user_prompt, max_tokens=1024)
|
|
541
|
+
fix_data = json.loads(response)
|
|
542
|
+
fix_commands = fix_data.get("fix_commands", [])
|
|
543
|
+
root_cause = fix_data.get("root_cause", "")
|
|
544
|
+
|
|
545
|
+
if root_cause:
|
|
546
|
+
logger.info(t("exec.llm_root_cause", cause=root_cause))
|
|
547
|
+
|
|
548
|
+
if not fix_commands:
|
|
549
|
+
return False
|
|
550
|
+
|
|
551
|
+
# 执行修复命令
|
|
552
|
+
for fix_cmd in fix_commands:
|
|
553
|
+
# 安全检查:LLM 生成的修复命令也必须通过安全过滤
|
|
554
|
+
is_safe, safety_msg = check_command_safety(fix_cmd)
|
|
555
|
+
if not is_safe:
|
|
556
|
+
logger.warning(t("exec.fix_rejected", cmd=fix_cmd))
|
|
557
|
+
return False
|
|
558
|
+
logger.info(t("exec.fix_cmd", cmd=fix_cmd))
|
|
559
|
+
fix_result = self.executor.run(fix_cmd)
|
|
560
|
+
if not fix_result.success:
|
|
561
|
+
logger.error(t("exec.fix_cmd_failed"))
|
|
562
|
+
return False
|
|
563
|
+
|
|
564
|
+
# 重新执行原命令
|
|
565
|
+
logger.info(t("exec.retrying"))
|
|
566
|
+
retry_result = self.executor.run(step_result.command)
|
|
567
|
+
if retry_result.success:
|
|
568
|
+
logger.info(t("exec.llm_fix_ok"))
|
|
569
|
+
step_result.fix_applied = True
|
|
570
|
+
step_result.fix_command = " && ".join(fix_commands)
|
|
571
|
+
step_result.success = True
|
|
572
|
+
return True
|
|
573
|
+
|
|
574
|
+
except (json.JSONDecodeError, Exception) as e:
|
|
575
|
+
logger.warning(t("exec.llm_fix_error", error=e))
|
|
576
|
+
|
|
577
|
+
return False
|