From 3354f3d5ed05c6107af1faaf601e299639241707 Mon Sep 17 00:00:00 2001 From: duorameng <2997944583@qq.com> Date: Fri, 10 Jul 2026 08:45:26 +0800 Subject: [PATCH] feat(deps): implement dependency auto-completion and interactive installation CLI (#147) --- cmd/cmd.go | 2 + cmd/depinstall/depinstall.go | 183 ++++++++++++++++++ internal/constant/commands.go | 4 + internal/controllers/dependency_controller.go | 22 +++ internal/router/api_routes.go | 1 + internal/services/deps/detector.go | 103 ++++++++++ internal/services/deps/detector_test.go | 62 ++++++ web/src/api/index.ts | 3 +- web/src/views/tasks/RepoDialog.vue | 2 +- web/src/views/tasks/TaskDialog.vue | 15 +- web/src/views/tasks/Tasks.vue | 52 ++++- 11 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 cmd/depinstall/depinstall.go create mode 100644 internal/services/deps/detector.go create mode 100644 internal/services/deps/detector_test.go diff --git a/cmd/cmd.go b/cmd/cmd.go index 5480be2..ff80bd1 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/engigu/baihu-panel/cmd/builtininstall" + "github.com/engigu/baihu-panel/cmd/depinstall" "github.com/engigu/baihu-panel/cmd/reposync" "github.com/engigu/baihu-panel/cmd/resetpwd" "github.com/engigu/baihu-panel/cmd/restore" @@ -25,5 +26,6 @@ var Handlers = map[string]CommandHandler{ "version": version.Run, "-v": version.Run, "-V": version.Run, + "depinstall": depinstall.Run, // "migrate": migrate.Run, } diff --git a/cmd/depinstall/depinstall.go b/cmd/depinstall/depinstall.go new file mode 100644 index 0000000..38d86b2 --- /dev/null +++ b/cmd/depinstall/depinstall.go @@ -0,0 +1,183 @@ +package depinstall + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/engigu/baihu-panel/cmd/clibase" + "github.com/engigu/baihu-panel/internal/database" + "github.com/engigu/baihu-panel/internal/models" + "github.com/engigu/baihu-panel/internal/services" + "github.com/engigu/baihu-panel/internal/services/deps" + "github.com/engigu/baihu-panel/internal/utils" +) + +// Run 依赖自动补全命令入口 +func Run(args []string) { + if len(args) == 0 { + fmt.Println("用法: baihu depinstall ") + return + } + + logID := args[0] + + // 初始化基础环境和数据库连接 + if err := clibase.InitContext(true); err != nil { + fmt.Printf(">> 初始化环境失败: %v\n", err) + return + } + + var log models.TaskLog + if err := database.DB.Where("id = ?", logID).First(&log).Error; err != nil { + fmt.Printf(">> 未找到指定的任务日志 (ID: %s): %v\n", logID, err) + return + } + + var task models.Task + if err := database.DB.Where("id = ?", log.TaskID).First(&task).Error; err != nil { + fmt.Printf(">> 未找到对应的任务 (TaskID: %s): %v\n", log.TaskID, err) + return + } + + logOutput, err := utils.DecompressFromBase64(string(log.Output)) + if err != nil { + fmt.Printf(">> 解压日志失败: %v\n", err) + return + } + + // 找出任务配置的语言 + taskLangs := task.GetLanguages() + if len(taskLangs) == 0 { + fmt.Println(">> 提示: 当前任务未配置具体语言环境,请手动指定语言类型(例如 python3, node 等):") + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + fmt.Println(">> 已取消补全。") + return + } + taskLangs = append(taskLangs, map[string]string{ + "name": input, + "version": "", + }) + } + + var allDetected []string + langToPkgMap := make(map[string][]string) + + for _, langMap := range taskLangs { + langName := langMap["name"] + if langName == "" { + continue + } + detected, found := deps.DetectMissingDependencies(langName, logOutput) + if found { + langToPkgMap[langName] = detected + allDetected = append(allDetected, detected...) + } + } + + reader := bufio.NewReader(os.Stdin) + + // 如果没有检测到任何缺失的包,允许用户手动输入 + if len(allDetected) == 0 { + fmt.Println(">> 分析完毕: 未从最近一次的任务运行日志中检测到缺失依赖模式。") + fmt.Println(">> 您可以手动输入想要安装的依赖包名称(多个包用空格分隔,若不安装请直接回车退出):") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + fmt.Println(">> 已退出依赖补全。") + return + } + // 默认分配到任务的第一个语言环境 + defaultLang := taskLangs[0]["name"] + langToPkgMap[defaultLang] = strings.Fields(input) + allDetected = append(allDetected, langToPkgMap[defaultLang]...) + } else { + fmt.Println(">> 分析结果: 从运行日志中检测到以下缺失依赖包:") + for langName, pkgs := range langToPkgMap { + fmt.Printf(" [%s]: %s\n", langName, strings.Join(pkgs, ", ")) + } + fmt.Println(">> 是否确认自动安装上述依赖包?(y/N):") + confirm, _ := reader.ReadString('\n') + confirm = strings.TrimSpace(strings.ToLower(confirm)) + if confirm != "y" && confirm != "yes" { + fmt.Println(">> 用户已取消安装操作。") + return + } + } + + fmt.Println("==================================================================") + fmt.Println(">> 开始执行依赖安装,请稍候...") + fmt.Println("==================================================================") + + var failedPkgs []string + depService := services.NewDependencyService() + + for langName, pkgs := range langToPkgMap { + var langVersion string + for _, lm := range taskLangs { + if lm["name"] == langName { + langVersion = lm["version"] + break + } + } + + m := deps.GetManager(langName) + if m == nil { + fmt.Printf(">> 错误: 不支持的语言类型: %s\n", langName) + failedPkgs = append(failedPkgs, pkgs...) + continue + } + + for _, pkg := range pkgs { + dep := &models.Dependency{ + Name: pkg, + Language: langName, + LangVersion: langVersion, + } + + cmdStr, err := m.GetInstallCommand(dep) + if err != nil { + fmt.Printf(">> 无法生成 %s 包 [%s] 的安装命令: %v\n", langName, pkg, err) + failedPkgs = append(failedPkgs, pkg) + continue + } + + // 去除命令末尾的 success/failed echo 重定向,因为我们需要捕获退出状态并在控制台展示原始流程 + if idx := strings.Index(cmdStr, " && echo"); idx != -1 { + cmdStr = cmdStr[:idx] + } + + fmt.Printf(">> 正在安装 [%s] -> 执行指令: %s\n", pkg, cmdStr) + + execCmd := utils.NewShellCommandCmd(cmdStr) + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + execCmd.Stdin = os.Stdin + + runErr := execCmd.Run() + if runErr != nil { + fmt.Printf(">> 【失败】依赖包 [%s] 安装出错。\n\n", pkg) + failedPkgs = append(failedPkgs, pkg) + } else { + fmt.Printf(">> 【成功】依赖包 [%s] 安装成功!\n\n", pkg) + // 成功后记录到依赖表 + _ = depService.Create(dep) + } + } + } + + fmt.Println("==================================================================") + if len(failedPkgs) > 0 { + fmt.Printf(">> 依赖补全已结束。其中以下依赖包安装失败,请用户自行判断/手动处理:\n") + for _, fp := range failedPkgs { + fmt.Printf(" - %s\n", fp) + } + } else { + fmt.Println(">> 恭喜!所有依赖包安装成功!") + } + fmt.Println("==================================================================") +} diff --git a/internal/constant/commands.go b/internal/constant/commands.go index d0958ad..2c9eac5 100644 --- a/internal/constant/commands.go +++ b/internal/constant/commands.go @@ -32,6 +32,10 @@ var Commands = []CommandInfo{ Name: "task", Description: "系统级任务的列表查询、触发运行、启停控制及状态查看", }, + { + Name: "depinstall", + Description: "一键补全指定任务执行日志中的缺失依赖包", + }, { Name: "version", Description: "查看当前系统版本号 (同 -v, -V)", diff --git a/internal/controllers/dependency_controller.go b/internal/controllers/dependency_controller.go index 1b4d085..5cdf470 100644 --- a/internal/controllers/dependency_controller.go +++ b/internal/controllers/dependency_controller.go @@ -1,6 +1,8 @@ package controllers import ( + "fmt" + "os" "strings" "github.com/engigu/baihu-panel/internal/models" @@ -397,3 +399,23 @@ func (c *DependencyController) ParseAndImport(ctx *gin.Context) { "command": cmd, }) } + +// GetDepInstallCommand 获取自动补全的命令,返回给前端执行 +func (c *DependencyController) GetDepInstallCommand(ctx *gin.Context) { + logID := ctx.Query("log_id") + if logID == "" { + utils.BadRequest(ctx, "参数错误: log_id 不能为空") + return + } + + execPath, err := os.Executable() + if err != nil { + execPath = "baihu" // 兜底 + } + + // 构造命令,比如: "F:\workspace\baihu-panel\baihu.exe" depinstall + cmdStr := fmt.Sprintf("%q depinstall %s", execPath, logID) + utils.Success(ctx, gin.H{ + "command": cmdStr, + }) +} diff --git a/internal/router/api_routes.go b/internal/router/api_routes.go index f44943d..c4dba4c 100644 --- a/internal/router/api_routes.go +++ b/internal/router/api_routes.go @@ -213,6 +213,7 @@ func registerDependencyRoutes(g *gin.RouterGroup, c *Controllers) { deps.POST("/batch-install-cmd", c.Dependency.GetBatchInstallCommand) deps.POST("/import", c.Dependency.ParseAndImport) deps.GET("/installed", c.Dependency.GetInstalled) + deps.GET("/install-suggest-cmd", c.Dependency.GetDepInstallCommand) } } diff --git a/internal/services/deps/detector.go b/internal/services/deps/detector.go new file mode 100644 index 0000000..9970bfd --- /dev/null +++ b/internal/services/deps/detector.go @@ -0,0 +1,103 @@ +package deps + +import ( + "regexp" + "strings" +) + +// Detector 依赖检测器接口 +type Detector interface { + Detect(logContent string) []string +} + +// PythonDetector Python 依赖检测器 +type PythonDetector struct{} + +func (d *PythonDetector) Detect(logContent string) []string { + var pkgs []string + seen := make(map[string]bool) + pythonRegex1 := regexp.MustCompile(`ModuleNotFoundError: No module named '([^']+)'`) + pythonRegex2 := regexp.MustCompile(`No module named ([a-zA-Z0-9_\-]+)`) + + matches := pythonRegex1.FindAllStringSubmatch(logContent, -1) + for _, m := range matches { + if len(m) > 1 { + name := strings.TrimSpace(m[1]) + if name != "" && !seen[name] { + seen[name] = true + pkgs = append(pkgs, name) + } + } + } + matches2 := pythonRegex2.FindAllStringSubmatch(logContent, -1) + for _, m := range matches2 { + if len(m) > 1 { + name := strings.TrimSpace(m[1]) + if name != "" && !seen[name] { + seen[name] = true + pkgs = append(pkgs, name) + } + } + } + return pkgs +} + +// NodeDetector Node.js 依赖检测器 +type NodeDetector struct{} + +func (d *NodeDetector) Detect(logContent string) []string { + var pkgs []string + seen := make(map[string]bool) + nodeRegex1 := regexp.MustCompile(`Error: Cannot find module '([^']+)'`) + nodeRegex2 := regexp.MustCompile(`Cannot find module '([^']+)'`) + + matches := nodeRegex1.FindAllStringSubmatch(logContent, -1) + for _, m := range matches { + if len(m) > 1 { + name := strings.TrimSpace(m[1]) + if name != "" && !seen[name] { + seen[name] = true + pkgs = append(pkgs, name) + } + } + } + matches2 := nodeRegex2.FindAllStringSubmatch(logContent, -1) + for _, m := range matches2 { + if len(m) > 1 { + name := strings.TrimSpace(m[1]) + if name != "" && !seen[name] { + seen[name] = true + pkgs = append(pkgs, name) + } + } + } + return pkgs +} + +var languageDetectors = map[string]Detector{ + "python": &PythonDetector{}, + "python3": &PythonDetector{}, + "node": &NodeDetector{}, + "js": &NodeDetector{}, + "ts": &NodeDetector{}, + "bun": &NodeDetector{}, +} + +// DetectMissingDependencies 从日志内容中检测缺失的依赖包名 +func DetectMissingDependencies(language, logContent string) ([]string, bool) { + lang := strings.ToLower(language) + var det Detector + for key, d := range languageDetectors { + if strings.Contains(lang, key) { + det = d + break + } + } + + if det == nil { + return nil, false + } + + pkgs := det.Detect(logContent) + return pkgs, len(pkgs) > 0 +} diff --git a/internal/services/deps/detector_test.go b/internal/services/deps/detector_test.go new file mode 100644 index 0000000..1ab4d87 --- /dev/null +++ b/internal/services/deps/detector_test.go @@ -0,0 +1,62 @@ +package deps + +import ( + "testing" +) + +func TestDetectMissingDependencies(t *testing.T) { + tests := []struct { + name string + language string + logContent string + wantPkgs []string + wantFound bool + }{ + { + name: "Python ModuleNotFoundError", + language: "python3", + logContent: "Traceback (most recent call last):\n File \"main.py\", line 1, in \n import requests\nModuleNotFoundError: No module named 'requests'", + wantPkgs: []string{"requests"}, + wantFound: true, + }, + { + name: "Python No module named", + language: "python", + logContent: "ImportError: No module named yaml", + wantPkgs: []string{"yaml"}, + wantFound: true, + }, + { + name: "Node Error Cannot find module", + language: "node", + logContent: "Error: Cannot find module 'axios'\nRequire stack:\n- /app/index.js", + wantPkgs: []string{"axios"}, + wantFound: true, + }, + { + name: "No match", + language: "python", + logContent: "Success running script", + wantPkgs: nil, + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPkgs, gotFound := DetectMissingDependencies(tt.language, tt.logContent) + if gotFound != tt.wantFound { + t.Errorf("DetectMissingDependencies() gotFound = %v, want %v", gotFound, tt.wantFound) + } + if len(gotPkgs) != len(tt.wantPkgs) { + t.Errorf("DetectMissingDependencies() gotPkgs = %v, want %v", gotPkgs, tt.wantPkgs) + return + } + for i, p := range gotPkgs { + if p != tt.wantPkgs[i] { + t.Errorf("DetectMissingDependencies() gotPkgs[%d] = %v, want %v", i, p, tt.wantPkgs[i]) + } + } + }) + } +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 76c56f0..c00f4e5 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -360,7 +360,8 @@ export const api = { const query = new URLSearchParams({ language }) if (lang_version) query.set('lang_version', lang_version) return request(`/deps/installed?${query}`) - } + }, + getInstallSuggestCmd: (logID: string) => request<{ command: string }>(`/deps/install-suggest-cmd?log_id=${logID}`) }, agents: { list: () => request('/agents'), diff --git a/web/src/views/tasks/RepoDialog.vue b/web/src/views/tasks/RepoDialog.vue index 10ad49b..972e777 100644 --- a/web/src/views/tasks/RepoDialog.vue +++ b/web/src/views/tasks/RepoDialog.vue @@ -318,7 +318,7 @@ async function save() {