Initial commit: TaskPool React panel
- React frontend with route-level code splitting - Backend rebranded from Baihu to TaskPool - DB brand migration script and local compatibility
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package builtininstall
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/engigu/taskpool/cmd/clibase"
|
||||
"github.com/engigu/taskpool/internal/utils"
|
||||
)
|
||||
|
||||
func printHelp() {
|
||||
clibase.PrintSubCommandUsage("任务池内建依赖安装工具", "taskpool builtininstall", "", nil)
|
||||
}
|
||||
|
||||
// Run 执行内建包安装逻辑
|
||||
func Run(args []string) {
|
||||
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("builtininstall", flag.ExitOnError)
|
||||
fs.Usage = printHelp
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(">> [Builtin] 开始为 mise 环境安装内建包...")
|
||||
|
||||
// 1. 确定内建包路径
|
||||
// 优先使用 /www/builtin (Docker 环境),否则尝试相对于二进制文件的当前目录
|
||||
builtinPath := "/www/builtin"
|
||||
if _, err := os.Stat(builtinPath); os.IsNotExist(err) {
|
||||
// 回退到当前目录下的 builtin
|
||||
pwd, _ := os.Getwd()
|
||||
builtinPath = filepath.Join(pwd, "builtin")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(builtinPath); os.IsNotExist(err) {
|
||||
fmt.Printf(">> [Builtin] 错误: 找不到内建包目录: %s\n", builtinPath)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 安装 Node.js 包
|
||||
installForLanguage("node", filepath.Join(builtinPath, "nodejs"))
|
||||
|
||||
// 3. 安装 Python 包
|
||||
installForLanguage("python", filepath.Join(builtinPath, "python"))
|
||||
|
||||
fmt.Println(">> [Builtin] 内建包安装流程完成")
|
||||
}
|
||||
|
||||
func installForLanguage(lang, pkgPath string) {
|
||||
if _, err := os.Stat(pkgPath); os.IsNotExist(err) {
|
||||
fmt.Printf(">> [Builtin] 警告: %s 的内建包目录不存在: %s\n", lang, pkgPath)
|
||||
return
|
||||
}
|
||||
|
||||
versions, err := utils.ListMiseInstalledVersions(lang)
|
||||
if err != nil {
|
||||
fmt.Printf(">> [Builtin] 错误: 获取 %s 的 mise 版本列表失败: %v\n", lang, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
fmt.Printf(">> [Builtin] 未发现已安装的 %s 版本,跳过\n", lang)
|
||||
return
|
||||
}
|
||||
|
||||
for _, v := range versions {
|
||||
fmt.Printf(">> [Builtin] 正在为 %s@%s 安装内建包...\n", lang, v)
|
||||
|
||||
var subCmdArgs []string
|
||||
if lang == "node" {
|
||||
// 使用 npm i -g 进行全局安装
|
||||
subCmdArgs = []string{"npm", "i", "-g", pkgPath}
|
||||
} else {
|
||||
// python 改为标准安装 (非 -e),避免 Docker 内软链接可能导致的路径丢失问题
|
||||
subCmdArgs = []string{"pip", "install", "--force-reinstall", pkgPath}
|
||||
}
|
||||
|
||||
// 构建参数列表: [mise, exec, lang@v, --, cmd...]
|
||||
fullArgs := utils.BuildMiseCommandArgsSimple(subCmdArgs, lang, v)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd", append([]string{"/c"}, fullArgs...)...)
|
||||
} else {
|
||||
cmd = exec.Command(fullArgs[0], fullArgs[1:]...)
|
||||
}
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Printf(">> [Builtin] 错误: 为 %s@%s 安装失败: %v\n输出: %s\n", lang, v, err, string(out))
|
||||
} else {
|
||||
fmt.Printf(">> [Builtin] 为 %s@%s 安装成功\n", lang, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package clibase
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/engigu/taskpool/internal/bootstrap"
|
||||
"github.com/engigu/taskpool/internal/services"
|
||||
)
|
||||
|
||||
// InitContext 统一封装命令行所需的初始化上下文逻辑
|
||||
func InitContext(requireSettings bool) error {
|
||||
bootstrap.InitBasicForCmd()
|
||||
if requireSettings {
|
||||
settingsService := services.NewSettingsService()
|
||||
if err := settingsService.InitSettings(); err != nil {
|
||||
return fmt.Errorf("初始化系统设置失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintDBConfigHint 打印标准化的连接或检索失败时的排查指引
|
||||
func PrintDBConfigHint(commandExample string) {
|
||||
fmt.Println(">> 提示: 程序当前可能连接到了默认的空 SQLite 数据库。")
|
||||
fmt.Println(">> 若您的生产环境使用的是 MySQL 或指定路径配置,请在执行命令时携带配置文件路径环境变量,例如:")
|
||||
fmt.Printf(">> BH_CONFIG_PATH=/app/data/config.ini taskpool %s\n", commandExample)
|
||||
}
|
||||
|
||||
// PrintSubCommandUsage 打印一致风格的子程序帮助信息
|
||||
func PrintSubCommandUsage(title, usageStr, exampleStr string, fs *flag.FlagSet) {
|
||||
fmt.Fprintf(os.Stderr, "\n%s\n\n", title)
|
||||
fmt.Fprintf(os.Stderr, "用法:\n")
|
||||
fmt.Fprintf(os.Stderr, " %s\n\n", usageStr)
|
||||
if fs != nil {
|
||||
fmt.Fprintf(os.Stderr, "参数说明:\n")
|
||||
fs.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
}
|
||||
if exampleStr != "" {
|
||||
fmt.Fprintf(os.Stderr, "示例:\n")
|
||||
fmt.Fprintf(os.Stderr, "%s\n\n", exampleStr)
|
||||
}
|
||||
}
|
||||
|
||||
// VisualFormat 根据字符的视觉显示列宽(中文字符/宽字符计为2列,ASCII计为1列),
|
||||
// 将字符串进行精确等宽填充或安全截断追加 "..",确保混合字符输出下控制台表格严丝合缝强制对齐。
|
||||
func VisualFormat(s string, targetVisualWidth int) string {
|
||||
w := 0
|
||||
var sb strings.Builder
|
||||
runes := []rune(s)
|
||||
|
||||
// 先计算总视觉宽度
|
||||
totalW := 0
|
||||
for _, r := range runes {
|
||||
if r > 127 {
|
||||
totalW += 2
|
||||
} else {
|
||||
totalW += 1
|
||||
}
|
||||
}
|
||||
|
||||
if totalW <= targetVisualWidth {
|
||||
return s + strings.Repeat(" ", targetVisualWidth-totalW)
|
||||
}
|
||||
|
||||
// 如果总宽度超出,进行精准截断并追加 ".."
|
||||
maxContentW := targetVisualWidth - 2
|
||||
for _, r := range runes {
|
||||
rw := 1
|
||||
if r > 127 {
|
||||
rw = 2
|
||||
}
|
||||
if w+rw > maxContentW {
|
||||
break
|
||||
}
|
||||
sb.WriteRune(r)
|
||||
w += rw
|
||||
}
|
||||
|
||||
res := sb.String() + ".."
|
||||
// 补齐末尾可能相差的1个空格列宽
|
||||
if w+2 < targetVisualWidth {
|
||||
res += strings.Repeat(" ", targetVisualWidth-(w+2))
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package clibase
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/engigu/taskpool/internal/bootstrap"
|
||||
)
|
||||
|
||||
// CallInternalAPI 封装底层进程间 HTTP 通信,统一处理网络连接错误及业务级异常提取
|
||||
func CallInternalAPI(method, endpoint string, payload any) ([]byte, error) {
|
||||
bodyBytes, statusCode, err := bootstrap.SendInternalRequest(method, endpoint, payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法连接到主程序后台服务: %w", err)
|
||||
}
|
||||
|
||||
if statusCode != 200 {
|
||||
return bodyBytes, fmt.Errorf("后台服务响应异常 (状态码: %d): %s", statusCode, strings.TrimSpace(string(bodyBytes)))
|
||||
}
|
||||
|
||||
// 尝试通用结构体嗅探,提取业务级逻辑拒绝原因
|
||||
var res struct {
|
||||
Data struct {
|
||||
Success *bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &res); err == nil {
|
||||
if res.Data.Success != nil && !*res.Data.Success {
|
||||
errReason := res.Data.Error
|
||||
if errReason == "" {
|
||||
errReason = strings.TrimSpace(string(bodyBytes))
|
||||
}
|
||||
return bodyBytes, fmt.Errorf("%s", errReason)
|
||||
}
|
||||
}
|
||||
|
||||
return bodyBytes, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package clibase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AnsiRegex 匹配终端 ANSI 控制序列的通用正则表达式
|
||||
var AnsiRegex = regexp.MustCompile("\x1b\\[[0-9;]*[a-zA-Z]")
|
||||
|
||||
// CleanWriter 过滤输出流中的终端回车符覆写及 ANSI 色彩代码
|
||||
type CleanWriter struct {
|
||||
out io.Writer
|
||||
buf []byte
|
||||
}
|
||||
|
||||
// NewCleanWriter 构造输出清洗器
|
||||
func NewCleanWriter(out io.Writer) *CleanWriter {
|
||||
return &CleanWriter{out: out}
|
||||
}
|
||||
|
||||
func (c *CleanWriter) Write(p []byte) (n int, err error) {
|
||||
c.buf = append(c.buf, p...)
|
||||
|
||||
for {
|
||||
idx := bytes.IndexAny(c.buf, "\r\n")
|
||||
if idx == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
if c.buf[idx] == '\r' && idx == len(c.buf)-1 {
|
||||
// 跨块截断的回车,等待下一块
|
||||
break
|
||||
}
|
||||
|
||||
char := c.buf[idx]
|
||||
line := string(c.buf[:idx])
|
||||
c.buf = c.buf[idx+1:]
|
||||
|
||||
if char == '\r' && len(c.buf) > 0 && c.buf[0] == '\n' {
|
||||
c.buf = c.buf[1:]
|
||||
char = '\n'
|
||||
}
|
||||
|
||||
s := AnsiRegex.ReplaceAllString(line, "")
|
||||
|
||||
if char == '\r' {
|
||||
continue // 忽略终端进度条的同行覆盖
|
||||
}
|
||||
|
||||
if s != "" {
|
||||
c.out.Write([]byte(s + "\n"))
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Flush 输出末尾缓冲
|
||||
func (c *CleanWriter) Flush() {
|
||||
if len(c.buf) > 0 {
|
||||
s := string(c.buf)
|
||||
s = strings.TrimSuffix(s, "\r")
|
||||
s = AnsiRegex.ReplaceAllString(s, "")
|
||||
if s != "" {
|
||||
c.out.Write([]byte(s + "\n"))
|
||||
}
|
||||
c.buf = nil
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/engigu/taskpool/cmd/builtininstall"
|
||||
"github.com/engigu/taskpool/cmd/depinstall"
|
||||
"github.com/engigu/taskpool/cmd/reposync"
|
||||
"github.com/engigu/taskpool/cmd/resetpwd"
|
||||
"github.com/engigu/taskpool/cmd/restore"
|
||||
"github.com/engigu/taskpool/cmd/task"
|
||||
"github.com/engigu/taskpool/cmd/version"
|
||||
"github.com/engigu/taskpool/cmd/webui"
|
||||
// "github.com/engigu/taskpool/cmd/migrate"
|
||||
)
|
||||
|
||||
// CommandHandler 定义命令执行函数
|
||||
type CommandHandler func(args []string)
|
||||
|
||||
// Handlers 维护了除了 server 之外的命令的执行入口
|
||||
var Handlers = map[string]CommandHandler{
|
||||
"reposync": reposync.Run,
|
||||
"resetpwd": resetpwd.Run,
|
||||
"restore": restore.Run,
|
||||
"builtininstall": builtininstall.Run,
|
||||
"task": task.Run,
|
||||
"webui": webui.Run,
|
||||
"version": version.Run,
|
||||
"-v": version.Run,
|
||||
"-V": version.Run,
|
||||
"depinstall": depinstall.Run,
|
||||
// "migrate": migrate.Run,
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package depinstall
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/engigu/taskpool/cmd/clibase"
|
||||
"github.com/engigu/taskpool/internal/database"
|
||||
"github.com/engigu/taskpool/internal/models"
|
||||
"github.com/engigu/taskpool/internal/services"
|
||||
"github.com/engigu/taskpool/internal/services/deps"
|
||||
"github.com/engigu/taskpool/internal/utils"
|
||||
)
|
||||
|
||||
// Run 依赖自动补全命令入口
|
||||
func Run(args []string) {
|
||||
if len(args) == 0 {
|
||||
fmt.Println("用法: taskpool depinstall <log_id>")
|
||||
return
|
||||
}
|
||||
|
||||
logID := args[0]
|
||||
fmt.Println(">> 提示: 依赖自动补全功能目前仅支持 Python 和 Node.js 环境,如有其他环境需求请及时反馈。")
|
||||
|
||||
// 初始化基础环境和数据库连接
|
||||
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("==================================================================")
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/engigu/taskpool/internal/bootstrap"
|
||||
"github.com/engigu/taskpool/internal/services"
|
||||
)
|
||||
|
||||
func Run(args []string) {
|
||||
fmt.Println("Starting Migration V3...")
|
||||
// 初始化基础环境(配置和数据库,但不运行常规 Migrate,因为我们想手动控)
|
||||
// 不过 bootstrap.New() 会调用 Migrate().
|
||||
// 我们可以调用 InitBasic()
|
||||
app := bootstrap.InitBasicForCmd()
|
||||
if app == nil {
|
||||
fmt.Println("Failed to initialize app")
|
||||
return
|
||||
}
|
||||
|
||||
// 此时数据库已经连接,Migrate() 已经运行过了(因为 bootstrap.InitBasic 调用了 app.initDatabase)
|
||||
// 由于我们在 Migrate() 中集成了 RunMigrationV3(),所以其实已经跑过了。
|
||||
// 如果用户想重复跑,或者单独跑:
|
||||
err := services.RunMigrationV3()
|
||||
if err != nil {
|
||||
fmt.Printf("Migration failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Migration V3 completed successfully.")
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
package reposync
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/engigu/taskpool/cmd/clibase"
|
||||
"github.com/engigu/taskpool/internal/constant"
|
||||
"github.com/engigu/taskpool/internal/services/repo"
|
||||
"github.com/engigu/taskpool/internal/utils"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SourceType string
|
||||
SourceURL string
|
||||
TargetPath string
|
||||
Branch string
|
||||
Path string
|
||||
SingleFile bool
|
||||
Proxy string
|
||||
ProxyURL string
|
||||
AuthToken string
|
||||
HttpProxy string
|
||||
WhitelistPaths string // Comma or vertical line separated paths to preserve or filter (whitelist)
|
||||
Blacklist string // Script filter blacklist keywords, vertical line separated
|
||||
Dependence string // Script dependence file keywords, vertical line separated
|
||||
Extensions string // Script file extensions, vertical line separated
|
||||
TaskID string
|
||||
RepoTaskID string
|
||||
TaskLanguages string
|
||||
TaskTimeout int
|
||||
CommentToTask string
|
||||
PreCommand string
|
||||
PostCommand string
|
||||
RepoName string
|
||||
}
|
||||
|
||||
func Run(args []string) {
|
||||
fs := flag.NewFlagSet("reposync", flag.ExitOnError)
|
||||
var cfg Config
|
||||
fs.StringVar(&cfg.SourceType, "source-type", "git", "Source type: git or url")
|
||||
fs.StringVar(&cfg.SourceURL, "source-url", "", "Source url")
|
||||
fs.StringVar(&cfg.TargetPath, "target-path", "", "Target path")
|
||||
fs.StringVar(&cfg.Branch, "branch", "", "Branch")
|
||||
fs.StringVar(&cfg.Path, "path", "", "Path for sparse checkout")
|
||||
fs.BoolVar(&cfg.SingleFile, "single-file", false, "Single file mode")
|
||||
fs.StringVar(&cfg.Proxy, "proxy", "none", "Proxy type")
|
||||
fs.StringVar(&cfg.ProxyURL, "proxy-url", "", "Custom proxy url")
|
||||
fs.StringVar(&cfg.AuthToken, "auth-token", "", "Auth token")
|
||||
fs.StringVar(&cfg.HttpProxy, "http-proxy", "", "Http proxy")
|
||||
fs.StringVar(&cfg.WhitelistPaths, "whitelist-paths", "", "Separated paths to preserve or filter (whitelist)")
|
||||
fs.StringVar(&cfg.Blacklist, "blacklist", "", "Script filter blacklist keywords (| separated)")
|
||||
fs.StringVar(&cfg.Dependence, "dependence", "", "Script dependence keywords (| separated)")
|
||||
fs.StringVar(&cfg.Extensions, "extensions", "", "Script extensions (| separated)")
|
||||
fs.StringVar(&cfg.TaskID, "task-id", "", "Task ID for metadata")
|
||||
fs.StringVar(&cfg.TaskLanguages, "task-langs", "", "Configured languages (JSON)")
|
||||
fs.StringVar(&cfg.RepoTaskID, "repo-task-id", "", "Original Task ID")
|
||||
fs.IntVar(&cfg.TaskTimeout, "task-timeout", 30, "Task timeout (minutes)")
|
||||
fs.StringVar(&cfg.CommentToTask, "commenttotask", "false", "Compatible with QL format script comment parsing (true/false)")
|
||||
fs.StringVar(&cfg.PreCommand, "pre-command", "", "Default pre-command for discovered tasks")
|
||||
fs.StringVar(&cfg.PostCommand, "post-command", "", "Default post-command for discovered tasks")
|
||||
fs.StringVar(&cfg.RepoName, "repo-name", "", "Custom repository directory name")
|
||||
|
||||
printHelp := func() {
|
||||
fmt.Fprintf(os.Stderr, "\n任务池仓库同步工具 (Reposync)\n\n")
|
||||
fmt.Fprintf(os.Stderr, "用法:\n")
|
||||
fmt.Fprintf(os.Stderr, " taskpool reposync [参数]\n\n")
|
||||
fmt.Fprintf(os.Stderr, "参数详情:\n")
|
||||
fs.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\n示例:\n")
|
||||
fmt.Fprintf(os.Stderr, " taskpool reposync --source-url https://github.com/xxx/repo.git --target-path $SCRIPTS_DIR$/repo1\n\n")
|
||||
}
|
||||
|
||||
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
fs.Usage = printHelp
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.SourceURL == "" {
|
||||
fmt.Fprintf(os.Stderr, "错误: 必须提供 --source-url 参数\n")
|
||||
fs.Usage()
|
||||
return
|
||||
}
|
||||
|
||||
// 处理 $SCRIPTS_DIR$ 代号替换
|
||||
if strings.Contains(cfg.TargetPath, constant.ScriptsDirPlaceholder) {
|
||||
scriptsDir := os.Getenv("BH_SCRIPTS_DIR")
|
||||
if scriptsDir != "" {
|
||||
cfg.TargetPath = filepath.Clean(strings.ReplaceAll(cfg.TargetPath, constant.ScriptsDirPlaceholder, scriptsDir))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("========================================")
|
||||
fmt.Println(" 仓库同步任务开始 ")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("[1/3] 解析同步参数: %s\n", strings.Join(args, " "))
|
||||
|
||||
if cfg.SourceType == "git" {
|
||||
fmt.Printf("[2/3] 正在通过 Git 同步内容...\n")
|
||||
syncGit(cfg)
|
||||
} else {
|
||||
fmt.Printf("[2/3] 正在通过 URL 下载内容...\n")
|
||||
syncURL(cfg)
|
||||
}
|
||||
|
||||
// 执行前置指令
|
||||
if cfg.PreCommand != "" {
|
||||
fmt.Printf("[准备] 执行同步前指令: %s\n", cfg.PreCommand)
|
||||
// 计算当前仓库真实的物理路径
|
||||
repoDir := getActualRepoDir(cfg)
|
||||
fmt.Printf("[准备] 工作目录: %s\n", repoDir)
|
||||
fmt.Printf("[准备] 注入环境变量: CURR_REPO_DIR=%s\n", repoDir)
|
||||
|
||||
shell, shellArgs := utils.GetShellCommand(cfg.PreCommand)
|
||||
envs := append(os.Environ(), "CURR_REPO_DIR="+repoDir)
|
||||
runCmd(append([]string{shell}, shellArgs...), repoDir, envs)
|
||||
}
|
||||
|
||||
// 执行脚本过滤(仅限 git 模式,url 加载通常为单文件,暂不处理过滤)
|
||||
if cfg.SourceType == "git" {
|
||||
fmt.Printf("[3/3] 正在执行脚本过滤与文件清理...\n")
|
||||
filterFiles(cfg)
|
||||
|
||||
if cfg.TaskID != "" {
|
||||
upsertedIDs, deletedIDs := repo.ParseRepoScriptsAndAddCron(cfg.TaskID, os.Stdout, cfg.CommentToTask == "true")
|
||||
if len(upsertedIDs) > 0 || len(deletedIDs) > 0 {
|
||||
notifyMainServerToSyncRepoTasks(cfg.TaskID, upsertedIDs, deletedIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 执行后置指令
|
||||
if cfg.PostCommand != "" {
|
||||
fmt.Printf("[收尾] 执行同步后指令: %s\n", cfg.PostCommand)
|
||||
|
||||
// 计算当前仓库真实的物理路径
|
||||
repoDir := getActualRepoDir(cfg)
|
||||
fmt.Printf("[收尾] 工作目录: %s\n", repoDir)
|
||||
fmt.Printf("[收尾] 注入环境变量: CURR_REPO_DIR=%s\n", repoDir)
|
||||
|
||||
shell, shellArgs := utils.GetShellCommand(cfg.PostCommand)
|
||||
envs := append(os.Environ(), "CURR_REPO_DIR="+repoDir)
|
||||
runCmd(append([]string{shell}, shellArgs...), repoDir, envs)
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println(" 仓库同步任务完成 ")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
|
||||
func getActualRepoDir(cfg Config) string {
|
||||
if cfg.SourceType == "git" {
|
||||
repoName := cfg.RepoName
|
||||
if repoName == "" {
|
||||
repoName = utils.GetRepoIdentifier(cfg.SourceURL, cfg.Branch)
|
||||
}
|
||||
if repoName == "." {
|
||||
return cfg.TargetPath
|
||||
}
|
||||
return filepath.Join(cfg.TargetPath, repoName)
|
||||
}
|
||||
return cfg.TargetPath
|
||||
}
|
||||
|
||||
func notifyMainServerToSyncRepoTasks(repoID string, upsertedIDs []string, deletedIDs []string) {
|
||||
_, err := clibase.CallInternalAPI("POST", "/internal/tasks/sync-repo-status", map[string]interface{}{
|
||||
"repo_id": repoID,
|
||||
"upserted_ids": upsertedIDs,
|
||||
"deleted_ids": deletedIDs,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf(">> [通知] 调度器同步失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println(">> [通知] 已成功将变动任务增量同步至主程序调度器")
|
||||
}
|
||||
|
||||
func syncGit(cfg Config) {
|
||||
env := os.Environ()
|
||||
|
||||
if isRawFileURL(cfg.SourceURL) {
|
||||
fmt.Println("检测到 raw 文件 URL,自动切换到 URL 下载模式")
|
||||
syncURL(cfg)
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.HttpProxy != "" {
|
||||
env = append(env, "http_proxy="+cfg.HttpProxy, "https_proxy="+cfg.HttpProxy)
|
||||
}
|
||||
|
||||
repoURL := buildProxyURL(cfg.SourceURL, cfg.Proxy, cfg.ProxyURL)
|
||||
if cfg.AuthToken != "" && strings.HasPrefix(repoURL, "https://") {
|
||||
repoURL = strings.Replace(repoURL, "https://", "https://"+cfg.AuthToken+"@", 1)
|
||||
}
|
||||
|
||||
dest := cfg.TargetPath
|
||||
|
||||
if cfg.Path != "" && cfg.SingleFile {
|
||||
syncGitFile(cfg, repoURL, env)
|
||||
return
|
||||
}
|
||||
|
||||
gitDir := filepath.Join(dest, ".git")
|
||||
if isDir(dest) && !pathExists(gitDir) {
|
||||
repoName := cfg.RepoName
|
||||
if repoName == "" {
|
||||
repoName = utils.GetRepoIdentifier(cfg.SourceURL, cfg.Branch)
|
||||
}
|
||||
if repoName != "." {
|
||||
dest = filepath.Join(dest, repoName)
|
||||
fmt.Printf("目标路径自动追加仓库名: %s\n", dest)
|
||||
gitDir = filepath.Join(dest, ".git")
|
||||
} else {
|
||||
fmt.Printf("目标路径使用当前目录 (不追加仓库名): %s\n", dest)
|
||||
}
|
||||
}
|
||||
|
||||
restore := preserve(dest, cfg.WhitelistPaths)
|
||||
defer restore()
|
||||
|
||||
if pathExists(gitDir) {
|
||||
fmt.Println("检测到已存在仓库,正在更新...")
|
||||
runCmd([]string{"git", "fetch", "--all"}, dest, env)
|
||||
|
||||
targetBranch := cfg.Branch
|
||||
if targetBranch != "" {
|
||||
// 如果切换了分支,或者当前分支偏离,强制切换并对齐远程
|
||||
runCmd([]string{"git", "checkout", "-B", targetBranch, "origin/" + targetBranch}, dest, env)
|
||||
} else {
|
||||
targetBranch = getCurrentBranch(dest, env)
|
||||
}
|
||||
|
||||
if targetBranch != "" {
|
||||
fmt.Printf("执行强制同步 (reset --hard origin/%s)\n", targetBranch)
|
||||
runCmd([]string{"git", "reset", "--hard", "origin/" + targetBranch}, dest, env)
|
||||
} else {
|
||||
runCmd([]string{"git", "pull", "--rebase"}, dest, env)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("执行 git clone")
|
||||
parentDir := filepath.Dir(dest)
|
||||
if parentDir != "" {
|
||||
os.MkdirAll(parentDir, 0755)
|
||||
}
|
||||
|
||||
if pathExists(dest) && !isDirEmpty(dest) {
|
||||
// If we still have files after preservation, warn but maybe continue if it's just leftovers that git can handle?
|
||||
// Actually git clone requires an empty dir.
|
||||
fmt.Printf("警告: 目标目录 '%s' 不为空,尝试清理非保护文件...\n", dest)
|
||||
// Optional: delete everything else? User might not want that.
|
||||
// For now, keep the error but it's less likely to occur if preservation moved things out.
|
||||
fmt.Println("提示: 请清空目标目录或指定一个新目录")
|
||||
os.Exit(1)
|
||||
}
|
||||
// If dest exists but is empty now, git clone might still complain if the directory itself exists?
|
||||
// No, git clone works if dir is empty.
|
||||
|
||||
cloneCmd := []string{"git", "clone", "--depth", "1"}
|
||||
if cfg.Branch != "" {
|
||||
cloneCmd = append(cloneCmd, "-b", cfg.Branch)
|
||||
}
|
||||
|
||||
if cfg.Path != "" {
|
||||
cloneCmd = append(cloneCmd, "--filter=blob:none", "--no-checkout", repoURL, dest)
|
||||
runCmd(cloneCmd, "", env)
|
||||
runCmd([]string{"git", "sparse-checkout", "init", "--cone"}, dest, env)
|
||||
runCmd([]string{"git", "sparse-checkout", "set", cfg.Path}, dest, env)
|
||||
runCmd([]string{"git", "checkout"}, dest, env)
|
||||
} else {
|
||||
cloneCmd = append(cloneCmd, repoURL, dest)
|
||||
runCmd(cloneCmd, "", env)
|
||||
}
|
||||
}
|
||||
fmt.Println("同步完成")
|
||||
}
|
||||
|
||||
func syncURL(cfg Config) {
|
||||
downloadURL := buildProxyURL(cfg.SourceURL, cfg.Proxy, cfg.ProxyURL)
|
||||
fmt.Printf("下载地址: %s\n", downloadURL)
|
||||
dest := cfg.TargetPath
|
||||
|
||||
if isDir(dest) || strings.HasSuffix(dest, string(os.PathSeparator)) || strings.HasSuffix(dest, "/") {
|
||||
urlPath := strings.Split(cfg.SourceURL, "?")[0]
|
||||
filename := filepath.Base(urlPath)
|
||||
if filename == "" {
|
||||
filename = "downloaded_file"
|
||||
}
|
||||
dest = filepath.Join(dest, filename)
|
||||
fmt.Printf("目标文件: %s\n", dest)
|
||||
}
|
||||
|
||||
restore := preserve(cfg.TargetPath, cfg.WhitelistPaths)
|
||||
defer restore()
|
||||
|
||||
downloadFile(downloadURL, dest, cfg.AuthToken)
|
||||
}
|
||||
|
||||
func syncGitFile(cfg Config, repoURL string, env []string) {
|
||||
sourceURL := cfg.SourceURL
|
||||
filePath := cfg.Path
|
||||
dest := cfg.TargetPath
|
||||
|
||||
if isDir(dest) || strings.HasSuffix(dest, string(os.PathSeparator)) || strings.HasSuffix(dest, "/") {
|
||||
filename := filepath.Base(filePath)
|
||||
dest = filepath.Join(dest, filename)
|
||||
fmt.Printf("检测到目标路径为目录 '%s',自动修正为: '%s'\n", cfg.TargetPath, dest)
|
||||
}
|
||||
|
||||
branch := cfg.Branch
|
||||
if branch == "" {
|
||||
branch = getRemoteDefaultBranch(repoURL, env)
|
||||
}
|
||||
|
||||
cleanURL := strings.TrimSuffix(cfg.SourceURL, ".git")
|
||||
rawURL := ""
|
||||
|
||||
if strings.Contains(sourceURL, "github.com") {
|
||||
base := strings.Replace(strings.TrimSuffix(cfg.SourceURL, ".git"), "github.com", "raw.githubusercontent.com", 1)
|
||||
rawURL = fmt.Sprintf("%s/%s/%s", base, branch, filePath)
|
||||
} else if strings.Contains(sourceURL, "gitlab.com") {
|
||||
rawURL = fmt.Sprintf("%s/-/raw/%s/%s", cleanURL, branch, filePath)
|
||||
} else if strings.Contains(sourceURL, "gitee.com") {
|
||||
rawURL = fmt.Sprintf("%s/raw/%s/%s", cleanURL, branch, filePath)
|
||||
} else {
|
||||
rawURL = fmt.Sprintf("%s/raw/%s/%s", cleanURL, branch, filePath)
|
||||
}
|
||||
|
||||
rawURL = buildProxyURL(rawURL, cfg.Proxy, cfg.ProxyURL)
|
||||
downloadFile(rawURL, dest, cfg.AuthToken)
|
||||
}
|
||||
|
||||
func getRemoteDefaultBranch(repoURL string, env []string) string {
|
||||
fmt.Printf("正在检测远程仓库默认分支: %s\n", repoURL)
|
||||
cmd := exec.Command("git", "ls-remote", "--symref", repoURL, "HEAD")
|
||||
cmd.Env = env
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 && parts[0] == "ref:" && strings.Contains(parts[1], "refs/heads/") {
|
||||
branch := strings.TrimPrefix(parts[1], "refs/heads/")
|
||||
fmt.Printf("检测到默认分支: %s\n", branch)
|
||||
return branch
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println("无法检测到默认分支,回退使用 'main'")
|
||||
return "main"
|
||||
}
|
||||
|
||||
func buildProxyURL(url string, proxyType string, proxyURL string) string {
|
||||
if proxyType == "" || proxyType == "none" {
|
||||
return url
|
||||
}
|
||||
|
||||
// 如果 URL 已经包含明显的代理前缀 (如用户手动填写的 http://ghproxy.com/...)
|
||||
// 则跳过内置代理逻辑
|
||||
if strings.Contains(url, "googo.win") || (proxyType == "custom" && strings.HasPrefix(url, proxyURL)) {
|
||||
return url
|
||||
}
|
||||
|
||||
base := ""
|
||||
if proxyType == "ghproxy" {
|
||||
base = "https://gh-proxy.com/"
|
||||
} else if proxyType == "mirror" {
|
||||
base = "https://mirror.ghproxy.com/"
|
||||
} else if proxyType == "custom" && proxyURL != "" {
|
||||
base = strings.TrimSuffix(proxyURL, "/") + "/"
|
||||
}
|
||||
|
||||
if base != "" && strings.HasPrefix(url, "http") && !strings.HasPrefix(url, base) {
|
||||
return base + url
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func downloadFile(url, dest, authToken string) {
|
||||
fmt.Printf("下载地址: %s\n", url)
|
||||
fmt.Printf("目标路径: %s\n", dest)
|
||||
|
||||
parentDir := filepath.Dir(dest)
|
||||
if parentDir != "" {
|
||||
os.MkdirAll(parentDir, 0755)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("下载准备失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if authToken != "" {
|
||||
req.Header.Set("Authorization", "token "+authToken)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; reposync)")
|
||||
|
||||
client := &http.Client{Timeout: 300 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("下载请求失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
fmt.Printf("下载失败, HTTP 状态码: %d\n", resp.StatusCode)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
fmt.Printf("创建文件失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
n, err := io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("写入数据失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("文件大小: %d 字节\n", n)
|
||||
fmt.Println("下载完成")
|
||||
}
|
||||
|
||||
func isRawFileURL(url string) bool {
|
||||
rawPatterns := []string{
|
||||
"raw.githubusercontent.com",
|
||||
"/raw/",
|
||||
"/-/raw/",
|
||||
"/blob/",
|
||||
}
|
||||
for _, p := range rawPatterns {
|
||||
if strings.Contains(url, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runCmd(args []string, dir string, env []string) {
|
||||
fmt.Printf(">> %s\n", strings.Join(args, " "))
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = env
|
||||
|
||||
cw := clibase.NewCleanWriter(os.Stdout)
|
||||
cmd.Stdout = cw
|
||||
cmd.Stderr = cw
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
cw.Flush()
|
||||
fmt.Printf("命令执行失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cw.Flush()
|
||||
}
|
||||
|
||||
func isDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.IsDir()
|
||||
}
|
||||
|
||||
func pathExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil || !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func isDirEmpty(path string) bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Readdirnames(1)
|
||||
return err == io.EOF
|
||||
}
|
||||
|
||||
func getCurrentBranch(dir string, env []string) string {
|
||||
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = env
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// preserve moves specified paths to a temporary location and returns a function to restore them
|
||||
func preserve(baseDir string, paths string) func() {
|
||||
if paths == "" || !pathExists(baseDir) {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
preservedList := strings.Split(paths, ",")
|
||||
// 优化:将临时目录创建在 baseDir 同一级或内部,确保在同一个文件系统,使得 Rename 是 O(1) 瞬时完成的
|
||||
tmpParent, err := os.MkdirTemp(baseDir, ".taskpool_sync_preserve_*")
|
||||
if err != nil {
|
||||
fmt.Printf("警告: 无法在目标目录创建临时目录用于保留文件: %v\n", err)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
type preservedItem struct {
|
||||
relPath string
|
||||
tmpPath string
|
||||
}
|
||||
var items []preservedItem
|
||||
processed := make(map[string]bool)
|
||||
|
||||
for _, p := range preservedList {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Support glob matching
|
||||
pattern := filepath.Join(baseDir, p)
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
fmt.Printf("警告: 路径模式无效 %s: %v\n", p, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// If literal path exists but Glob didn't find it (common for direct dir reference), add it manually
|
||||
if len(matches) == 0 && pathExists(pattern) {
|
||||
matches = []string{pattern}
|
||||
}
|
||||
|
||||
for _, fullPath := range matches {
|
||||
relPath, err := filepath.Rel(baseDir, fullPath)
|
||||
// 同时要排除掉临时目录本身以及上级路径
|
||||
if err != nil || strings.HasPrefix(relPath, "..") || relPath == "." || strings.HasPrefix(relPath, ".taskpool_sync_preserve") {
|
||||
continue
|
||||
}
|
||||
|
||||
if processed[relPath] {
|
||||
continue
|
||||
}
|
||||
processed[relPath] = true
|
||||
|
||||
tmpPath := filepath.Join(tmpParent, relPath)
|
||||
os.MkdirAll(filepath.Dir(tmpPath), 0755)
|
||||
|
||||
fmt.Printf("正在保护路径: %s\n", relPath)
|
||||
if err := os.Rename(fullPath, tmpPath); err == nil {
|
||||
items = append(items, preservedItem{relPath: relPath, tmpPath: tmpPath})
|
||||
} else {
|
||||
// Rename might fail across filesystems, try copy
|
||||
if err := utils.CopyPath(fullPath, tmpPath); err == nil {
|
||||
os.RemoveAll(fullPath)
|
||||
items = append(items, preservedItem{relPath: relPath, tmpPath: tmpPath})
|
||||
} else {
|
||||
fmt.Printf("警告: 无法保护路径 %s: %v\n", relPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return func() {
|
||||
// Restore in reverse order to handle nested structures correctly if they were picked up separately
|
||||
for i := len(items) - 1; i >= 0; i-- {
|
||||
item := items[i]
|
||||
destPath := filepath.Join(baseDir, item.relPath)
|
||||
os.MkdirAll(filepath.Dir(destPath), 0755)
|
||||
|
||||
if pathExists(destPath) {
|
||||
fmt.Printf("目标已存在,覆盖恢复保护路径: %s\n", item.relPath)
|
||||
os.RemoveAll(destPath)
|
||||
} else {
|
||||
fmt.Printf("正在恢复保护路径: %s\n", item.relPath)
|
||||
}
|
||||
|
||||
if err := os.Rename(item.tmpPath, destPath); err != nil {
|
||||
// Fallback to copy
|
||||
utils.CopyPath(item.tmpPath, destPath)
|
||||
}
|
||||
}
|
||||
os.RemoveAll(tmpParent)
|
||||
}
|
||||
}
|
||||
|
||||
// filterFiles performs script filtering based on whitelist, blacklist, dependence and extensions.
|
||||
func filterFiles(cfg Config) {
|
||||
// If no filtering is specified, do nothing.
|
||||
if cfg.WhitelistPaths == "" && cfg.Blacklist == "" && cfg.Dependence == "" && cfg.Extensions == "" {
|
||||
return
|
||||
}
|
||||
|
||||
dest := getActualRepoDir(cfg)
|
||||
|
||||
fmt.Printf("开始执行脚本过滤: %s\n", dest)
|
||||
|
||||
whitelist := splitKeywords(cfg.WhitelistPaths)
|
||||
blacklist := splitKeywords(cfg.Blacklist)
|
||||
dependence := splitKeywords(cfg.Dependence)
|
||||
extensions := splitKeywords(cfg.Extensions)
|
||||
|
||||
// We'll collect files to delete to avoid modifying while walking if possible.
|
||||
// But os.RemoveAll is fine.
|
||||
|
||||
count := 0
|
||||
filepath.Walk(dest, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if info.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(dest, path)
|
||||
rel = filepath.ToSlash(rel)
|
||||
filename := info.Name()
|
||||
|
||||
// 1. Check dependence: always keep
|
||||
if matchesAny(rel, filename, dependence) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. Check extensions: delete if not matched and extensions is specified
|
||||
if len(extensions) > 0 {
|
||||
ext := strings.TrimPrefix(filepath.Ext(filename), ".")
|
||||
matchedExt := false
|
||||
for _, e := range extensions {
|
||||
if strings.EqualFold(ext, strings.TrimPrefix(e, ".")) {
|
||||
matchedExt = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matchedExt {
|
||||
fmt.Printf("过滤文件 (后缀不符): %s\n", rel)
|
||||
os.Remove(path)
|
||||
count++
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check blacklist: delete if matched
|
||||
if matchesAny(rel, filename, blacklist) {
|
||||
fmt.Printf("过滤文件 (黑名单): %s\n", rel)
|
||||
os.Remove(path)
|
||||
count++
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. Check whitelist: delete if NOT matched and whitelist is specified
|
||||
if len(whitelist) > 0 {
|
||||
if !matchesAny(rel, filename, whitelist) {
|
||||
fmt.Printf("过滤文件 (不在白名单): %s\n", rel)
|
||||
os.Remove(path)
|
||||
count++
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if count > 0 {
|
||||
fmt.Printf("过滤完成,共删除 %d 个不符合要求的文件\n", count)
|
||||
// Try to clean up empty directories
|
||||
cleanEmptyDirs(dest)
|
||||
}
|
||||
}
|
||||
|
||||
func splitKeywords(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
// Try to split by common separators for compatibility
|
||||
var parts []string
|
||||
if strings.Contains(s, "|") {
|
||||
parts = strings.Split(s, "|")
|
||||
} else if strings.Contains(s, ",") {
|
||||
parts = strings.Split(s, ",")
|
||||
} else {
|
||||
parts = []string{s}
|
||||
}
|
||||
|
||||
var res []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
res = append(res, p)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func matchesAny(rel, filename string, keywords []string) bool {
|
||||
if len(keywords) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, k := range keywords {
|
||||
// 1. 尝试作为正则整体进行匹配,默认不区分大小写 (?i)
|
||||
// 如果关键字不包含正则元字符,则补齐 (?i) 开启忽略大小写
|
||||
pattern := k
|
||||
if !strings.HasPrefix(pattern, "(?i)") {
|
||||
pattern = "(?i)" + pattern
|
||||
}
|
||||
|
||||
reg, err := regexp.Compile(pattern)
|
||||
if err == nil {
|
||||
// 优先匹配文件名(解决 ^jd[^_] 这种锚点在相对路径下失效的问题)
|
||||
if reg.MatchString(filename) || reg.MatchString(rel) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// 回退逻辑:全小写包含判断
|
||||
kLower := strings.ToLower(k)
|
||||
if strings.Contains(strings.ToLower(rel), kLower) || strings.Contains(strings.ToLower(filename), kLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cleanEmptyDirs(root string) {
|
||||
// Post-order traversal to clean up empty dirs
|
||||
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if path == root {
|
||||
return nil
|
||||
}
|
||||
if info.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Actually we need to do this recursively or multiple times.
|
||||
// A simpler way:
|
||||
entries, _ := os.ReadDir(root)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
if entry.Name() == ".git" {
|
||||
continue
|
||||
}
|
||||
dirPath := filepath.Join(root, entry.Name())
|
||||
cleanEmptyDirs(dirPath)
|
||||
// Check if now empty
|
||||
if isDirEmpty(dirPath) {
|
||||
os.Remove(dirPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package resetpwd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/engigu/taskpool/cmd/clibase"
|
||||
"github.com/engigu/taskpool/internal/services"
|
||||
"github.com/engigu/taskpool/internal/utils"
|
||||
)
|
||||
|
||||
func printHelp() {
|
||||
clibase.PrintSubCommandUsage("任务池用户密码重置工具", "taskpool resetpwd [用户名]", " taskpool resetpwd admin", nil)
|
||||
}
|
||||
|
||||
func Run(args []string) {
|
||||
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("resetpwd", flag.ExitOnError)
|
||||
fs.Usage = printHelp
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := clibase.InitContext(true); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
userService := services.NewUserService()
|
||||
|
||||
var username string
|
||||
parsedArgs := fs.Args()
|
||||
if len(parsedArgs) >= 1 {
|
||||
username = parsedArgs[0]
|
||||
} else {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
fmt.Printf("此操作将重置用户 [%s] 的密码,是否继续? (y/N): ", username)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
|
||||
if answer != "y" && answer != "yes" {
|
||||
fmt.Println("操作已取消。")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("请输入用户 [%s] 的新密码 (留空则自动随机生成): ", username)
|
||||
inputPwd, _ := reader.ReadString('\n')
|
||||
newPassword := strings.TrimSpace(inputPwd)
|
||||
if newPassword == "" {
|
||||
newPassword = utils.RandomString(12)
|
||||
fmt.Println("未输入密码,系统已自动生成。")
|
||||
}
|
||||
|
||||
user := userService.GetUserByUsername(username)
|
||||
if user == nil {
|
||||
fmt.Printf("找不到用户 [%s]\n", username)
|
||||
clibase.PrintDBConfigHint("resetpwd " + username)
|
||||
return
|
||||
}
|
||||
|
||||
err := userService.UpdatePassword(user.ID, newPassword)
|
||||
if err != nil {
|
||||
fmt.Printf("重置密码失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("--------------------------------------------------")
|
||||
fmt.Printf("用户 [%s] 密码已重置成功:\n", username)
|
||||
fmt.Printf("新密码: %s\n", newPassword)
|
||||
fmt.Println("请妥善保管您的新密码,并登录后及时修改。")
|
||||
fmt.Println("--------------------------------------------------")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package restore
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/engigu/taskpool/cmd/clibase"
|
||||
"github.com/engigu/taskpool/internal/services"
|
||||
)
|
||||
|
||||
func printHelp() {
|
||||
clibase.PrintSubCommandUsage("任务池系统数据恢复工具", "taskpool restore <备份文件.zip>", " taskpool restore backup_20231027.zip", nil)
|
||||
}
|
||||
|
||||
func Run(args []string) {
|
||||
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("restore", flag.ExitOnError)
|
||||
fs.Usage = printHelp
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
parsedArgs := fs.Args()
|
||||
if len(parsedArgs) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "错误: 必须提供备份文件路径\n")
|
||||
fs.Usage()
|
||||
return
|
||||
}
|
||||
|
||||
backupFile := parsedArgs[0]
|
||||
absPath, err := filepath.Abs(backupFile)
|
||||
if err != nil {
|
||||
fmt.Printf("文件路径解析失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||
fmt.Printf("错误: 备份文件 '%s' 不存在\n", absPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 必须初始化环境与数据库才能恢复数据
|
||||
clibase.InitContext(false)
|
||||
|
||||
backupService := services.NewBackupService()
|
||||
fmt.Printf("正在从 '%s' 恢复系统数据,请勿强制中断...\n", absPath)
|
||||
err = backupService.Restore(absPath)
|
||||
if err != nil {
|
||||
fmt.Printf("恢复备份失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("--------------------------------------------------")
|
||||
fmt.Println("系统备份恢复成功!")
|
||||
fmt.Println("注意:部分设定可能需要重启后台服务后才能完全生效。")
|
||||
fmt.Println("--------------------------------------------------")
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/engigu/taskpool/cmd/clibase"
|
||||
"github.com/engigu/taskpool/internal/constant"
|
||||
"github.com/engigu/taskpool/internal/database"
|
||||
"github.com/engigu/taskpool/internal/models"
|
||||
"github.com/engigu/taskpool/internal/utils"
|
||||
)
|
||||
|
||||
// 打印主帮助
|
||||
func printMainHelp() {
|
||||
fmt.Fprintf(os.Stderr, "\n任务池任务命令行管理工具 (Task CLI)\n\n")
|
||||
fmt.Fprintf(os.Stderr, "说明:\n")
|
||||
fmt.Fprintf(os.Stderr, " 本工具原生兼容管理普通任务 (task) 与仓库同步任务 (repo)。\n")
|
||||
fmt.Fprintf(os.Stderr, " 操作目标支持传入精确任务ID、任务名称模糊/精准查找,或使用快捷字面量 'repo' 一键操作主力仓库。\n\n")
|
||||
fmt.Fprintf(os.Stderr, "用法:\n")
|
||||
fmt.Fprintf(os.Stderr, " taskpool task <子命令> [参数]\n\n")
|
||||
fmt.Fprintf(os.Stderr, "可用子命令:\n")
|
||||
fmt.Fprintf(os.Stderr, " list 查询并输出任务列表\n")
|
||||
fmt.Fprintf(os.Stderr, " run 手动立即触发执行指定的任务或仓库\n")
|
||||
fmt.Fprintf(os.Stderr, " enable 启用指定的任务或仓库(同步加入后台调度队列)\n")
|
||||
fmt.Fprintf(os.Stderr, " disable 禁用指定的任务或仓库(同步从后台调度队列摘除)\n")
|
||||
fmt.Fprintf(os.Stderr, " status 查看指定任务或仓库最近一次执行的完整输出与状态\n")
|
||||
fmt.Fprintf(os.Stderr, " history 查看指定任务或仓库近期的多次执行流水记录\n\n")
|
||||
fmt.Fprintf(os.Stderr, "使用 'taskpool task <子命令> --help' 查看具体子命令的参数说明和示例。\n\n")
|
||||
}
|
||||
|
||||
// resolveTaskID 智能解析目标任务ID:支持直接传入真实ID、任务名称,或传入 "repo" 快捷操作系统中唯一的仓库同步任务
|
||||
func resolveTaskID(input string) string {
|
||||
var t models.Task
|
||||
// 1. 尝试按精确 ID 匹配
|
||||
if res := database.DB.Where("id = ?", input).Limit(1).Find(&t); res.Error == nil && res.RowsAffected > 0 {
|
||||
return t.ID
|
||||
}
|
||||
|
||||
// 2. 如果输入字面量为 "repo",尝试匹配 type = 'repo' 的记录
|
||||
if strings.ToLower(input) == "repo" {
|
||||
var repos []models.Task
|
||||
if res := database.DB.Where("type = ?", constant.TaskTypeRepo).Find(&repos); res.Error == nil {
|
||||
if len(repos) == 1 {
|
||||
fmt.Printf(">> 智能匹配到唯一的仓库任务: [%s] (ID: %s)\n", repos[0].Name, repos[0].ID)
|
||||
return repos[0].ID
|
||||
} else if len(repos) > 1 {
|
||||
fmt.Fprintf(os.Stderr, ">> 提示: 系统中存在多个 repo 类型的仓库任务,请指定具体的仓库名称或ID进行精确操作。\n")
|
||||
return input
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 尝试按名称精准或模糊匹配
|
||||
var namedTasks []models.Task
|
||||
if res := database.DB.Where("name = ?", input).Find(&namedTasks); res.Error == nil && len(namedTasks) > 0 {
|
||||
if len(namedTasks) == 1 {
|
||||
fmt.Printf(">> 智能匹配到目标任务: [%s] (ID: %s)\n", namedTasks[0].Name, namedTasks[0].ID)
|
||||
return namedTasks[0].ID
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, ">> 提示: 存在多个同名任务 [%s],请使用精确的任务ID进行操作。\n", input)
|
||||
return input
|
||||
}
|
||||
|
||||
// 尝试名称模糊匹配 (LIKE)
|
||||
if res := database.DB.Where("name LIKE ?", "%"+input+"%").Find(&namedTasks); res.Error == nil && len(namedTasks) == 1 {
|
||||
fmt.Printf(">> 模糊匹配到唯一的任务: [%s] (ID: %s)\n", namedTasks[0].Name, namedTasks[0].ID)
|
||||
return namedTasks[0].ID
|
||||
}
|
||||
|
||||
// 默认原样返回
|
||||
return input
|
||||
}
|
||||
|
||||
// Run 任务命令行入口
|
||||
func Run(args []string) {
|
||||
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" {
|
||||
printMainHelp()
|
||||
return
|
||||
}
|
||||
|
||||
subCommand := args[0]
|
||||
subArgs := args[1:]
|
||||
|
||||
switch subCommand {
|
||||
case "list":
|
||||
runList(subArgs)
|
||||
case "run":
|
||||
runExecute(subArgs)
|
||||
case "enable", "disable":
|
||||
runToggle(subCommand, subArgs)
|
||||
case "status":
|
||||
runStatus(subArgs)
|
||||
case "history":
|
||||
runHistory(subArgs)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "未知子命令: %s\n", subCommand)
|
||||
printMainHelp()
|
||||
}
|
||||
}
|
||||
|
||||
func runList(args []string) {
|
||||
fs := flag.NewFlagSet("list", flag.ExitOnError)
|
||||
namePtr := fs.String("name", "", "按任务名称或备注进行模糊筛选")
|
||||
typePtr := fs.String("type", "", "按任务类型筛选 (例如: task, repo)")
|
||||
pagePtr := fs.Int("page", 1, "查询页码")
|
||||
sizePtr := fs.Int("size", 20, "每页展示条数")
|
||||
|
||||
fs.Usage = func() {
|
||||
clibase.PrintSubCommandUsage("任务池任务列表查询工具", "taskpool task list [参数]", " taskpool task list\n taskpool task list -page 2 -size 10\n taskpool task list -name \"签到\"", fs)
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
clibase.InitContext(false)
|
||||
|
||||
var total int64
|
||||
query := database.DB.Model(&models.Task{})
|
||||
if *namePtr != "" {
|
||||
query = query.Where("name LIKE ? OR remark LIKE ?", "%"+*namePtr+"%", "%"+*namePtr+"%")
|
||||
}
|
||||
if *typePtr != "" {
|
||||
query = query.Where("type = ?", *typePtr)
|
||||
}
|
||||
query.Count(&total)
|
||||
|
||||
offset := (*pagePtr - 1) * *sizePtr
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var tasks []models.Task
|
||||
query.Order("created_at DESC").Limit(*sizePtr).Offset(offset).Find(&tasks)
|
||||
|
||||
fmt.Println(strings.Repeat("=", 90))
|
||||
fmt.Printf("%s | %s | %s | %s | %s\n",
|
||||
clibase.VisualFormat("任务ID", 20),
|
||||
clibase.VisualFormat("任务名称", 28),
|
||||
clibase.VisualFormat("Cron规则", 18),
|
||||
clibase.VisualFormat("类型", 6),
|
||||
clibase.VisualFormat("状态", 6),
|
||||
)
|
||||
fmt.Println(strings.Repeat("-", 90))
|
||||
for _, t := range tasks {
|
||||
cron := t.Schedule
|
||||
if cron == "" {
|
||||
cron = "-"
|
||||
}
|
||||
status := "启用"
|
||||
if !utils.DerefBool(t.Enabled, true) {
|
||||
status = "禁用"
|
||||
}
|
||||
fmt.Printf("%s | %s | %s | %s | %s\n",
|
||||
clibase.VisualFormat(t.ID, 20),
|
||||
clibase.VisualFormat(t.Name, 28),
|
||||
clibase.VisualFormat(cron, 18),
|
||||
clibase.VisualFormat(t.Type, 6),
|
||||
clibase.VisualFormat(status, 6),
|
||||
)
|
||||
}
|
||||
fmt.Println(strings.Repeat("=", 90))
|
||||
totalPages := (total + int64(*sizePtr) - 1) / int64(*sizePtr)
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
fmt.Printf("共查询到 %d 个任务记录,当前展示第 %d/%d 页 (每页 %d 条)。\n", total, *pagePtr, totalPages, *sizePtr)
|
||||
fmt.Printf("提示: 追加参数 (例如 '-page 2 -size 50') 即可灵活查看指定页码或调整展示数量。\n")
|
||||
}
|
||||
|
||||
func runExecute(args []string) {
|
||||
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
||||
fs.Usage = func() {
|
||||
clibase.PrintSubCommandUsage("任务池手动任务触发工具", "taskpool task run <任务ID/名称/repo>", " taskpool task run a1b2c3d4\n taskpool task run \"自动签到\"\n taskpool task run repo", nil)
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
parsedArgs := fs.Args()
|
||||
if len(parsedArgs) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n")
|
||||
fs.Usage()
|
||||
return
|
||||
}
|
||||
taskID := parsedArgs[0]
|
||||
|
||||
clibase.InitContext(false)
|
||||
taskID = resolveTaskID(taskID)
|
||||
|
||||
_, err := clibase.CallInternalAPI("POST", "/internal/tasks/execute/"+taskID, map[string]interface{}{})
|
||||
if err != nil {
|
||||
fmt.Printf(">> 任务触发失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(">> 任务 [%s] 触发指令下发成功!已进入后台调度队列排队或执行。\n", taskID)
|
||||
fmt.Printf(">> 提示: 可以使用 'taskpool task status %s' 查看近期执行输出。\n", taskID)
|
||||
}
|
||||
|
||||
func runToggle(action string, args []string) {
|
||||
fs := flag.NewFlagSet(action, flag.ExitOnError)
|
||||
actionName := "启用"
|
||||
targetEnabled := true
|
||||
if action == "disable" {
|
||||
actionName = "禁用"
|
||||
targetEnabled = false
|
||||
}
|
||||
|
||||
fs.Usage = func() {
|
||||
clibase.PrintSubCommandUsage(fmt.Sprintf("任务池任务%s工具", actionName), fmt.Sprintf("taskpool task %s <任务ID/名称/repo>", action), fmt.Sprintf(" taskpool task %s a1b2c3d4\n taskpool task %s repo", action, action), nil)
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
parsedArgs := fs.Args()
|
||||
if len(parsedArgs) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n")
|
||||
fs.Usage()
|
||||
return
|
||||
}
|
||||
taskID := parsedArgs[0]
|
||||
|
||||
clibase.InitContext(false)
|
||||
taskID = resolveTaskID(taskID)
|
||||
|
||||
_, err := clibase.CallInternalAPI("POST", "/internal/tasks/toggle/"+taskID, map[string]interface{}{
|
||||
"enabled": targetEnabled,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf(">> 切换状态操作失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(">> 任务 [%s] 已成功%s!\n", taskID, actionName)
|
||||
}
|
||||
|
||||
func runStatus(args []string) {
|
||||
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||
fs.Usage = func() {
|
||||
clibase.PrintSubCommandUsage("任务池任务执行状态与日志查看工具", "taskpool task status <任务ID/名称/repo> [日志ID]", " taskpool task status a1b2c3d4\n taskpool task status repo\n taskpool task status \"自动签到\"", nil)
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
parsedArgs := fs.Args()
|
||||
if len(parsedArgs) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n")
|
||||
fs.Usage()
|
||||
return
|
||||
}
|
||||
taskID := parsedArgs[0]
|
||||
var specificLogID string
|
||||
if len(parsedArgs) > 1 {
|
||||
specificLogID = parsedArgs[1]
|
||||
}
|
||||
|
||||
clibase.InitContext(false)
|
||||
taskID = resolveTaskID(taskID)
|
||||
|
||||
var taskLog models.TaskLog
|
||||
query := database.DB.Where("task_id = ?", taskID)
|
||||
if specificLogID != "" {
|
||||
query = query.Where("id = ?", specificLogID)
|
||||
}
|
||||
res := query.Order("created_at DESC").Limit(1).Find(&taskLog)
|
||||
if res.Error != nil || res.RowsAffected == 0 {
|
||||
if specificLogID != "" {
|
||||
fmt.Printf("找不到任务 [%s] 指定日志ID [%s] 的记录。\n", taskID, specificLogID)
|
||||
} else {
|
||||
fmt.Printf("找不到任务 [%s] 的任何执行记录。\n", taskID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var task models.Task
|
||||
database.DB.Where("id = ?", taskID).Limit(1).Find(&task)
|
||||
taskName := taskID
|
||||
if task.Name != "" {
|
||||
taskName = task.Name
|
||||
}
|
||||
|
||||
statusText := "运行中"
|
||||
switch taskLog.Status {
|
||||
case constant.TaskStatusSuccess:
|
||||
statusText = "成功"
|
||||
case constant.TaskStatusFailed:
|
||||
statusText = "失败"
|
||||
case constant.TaskStatusTimeout:
|
||||
statusText = "超时"
|
||||
case constant.TaskStatusCancelled:
|
||||
statusText = "已取消"
|
||||
}
|
||||
|
||||
fmt.Println("====================================================================================================")
|
||||
fmt.Printf("任务名称: %s (ID: %s)\n", taskName, taskID)
|
||||
fmt.Printf("日志记录: %s\n", taskLog.ID)
|
||||
fmt.Printf("执行命令: %s\n", string(taskLog.Command))
|
||||
fmt.Printf("最终状态: %s (耗时: %d 毫秒, 退出码: %d)\n", statusText, taskLog.Duration, taskLog.ExitCode)
|
||||
if taskLog.StartTime != nil {
|
||||
fmt.Printf("开始时间: %s\n", taskLog.StartTime.Time().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
fmt.Println("----------------------------------------------------------------------------------------------------")
|
||||
fmt.Println("[日志输出内容]")
|
||||
|
||||
// 解压
|
||||
decompressed, err := utils.DecompressFromBase64(string(taskLog.Output))
|
||||
if err != nil {
|
||||
fmt.Printf("[无法解压日志输出: %v]\n", err)
|
||||
} else {
|
||||
// 清理多余回车和终端 ANSI 转义字符
|
||||
cleanText := strings.ReplaceAll(decompressed, "\r\n", "\n")
|
||||
cleanText = clibase.AnsiRegex.ReplaceAllString(cleanText, "")
|
||||
fmt.Println(strings.TrimSpace(cleanText))
|
||||
}
|
||||
|
||||
if string(taskLog.Error) != "" {
|
||||
fmt.Println("\n[系统捕获异常]")
|
||||
fmt.Println(string(taskLog.Error))
|
||||
}
|
||||
fmt.Println("====================================================================================================")
|
||||
}
|
||||
|
||||
func runHistory(args []string) {
|
||||
fs := flag.NewFlagSet("history", flag.ExitOnError)
|
||||
limitPtr := fs.Int("limit", 10, "展示的最近历史记录条数")
|
||||
|
||||
fs.Usage = func() {
|
||||
clibase.PrintSubCommandUsage("任务池任务执行历史查看工具", "taskpool task history <任务ID/名称/repo> [参数]", " taskpool task history a1b2c3d4\n taskpool task history repo\n taskpool task history repo -limit 20", fs)
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
parsedArgs := fs.Args()
|
||||
if len(parsedArgs) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n")
|
||||
fs.Usage()
|
||||
return
|
||||
}
|
||||
taskID := parsedArgs[0]
|
||||
|
||||
clibase.InitContext(false)
|
||||
taskID = resolveTaskID(taskID)
|
||||
|
||||
var task models.Task
|
||||
database.DB.Where("id = ?", taskID).Limit(1).Find(&task)
|
||||
taskName := taskID
|
||||
if task.Name != "" {
|
||||
taskName = task.Name
|
||||
}
|
||||
|
||||
var logs []models.TaskLog
|
||||
database.DB.Where("task_id = ?", taskID).Order("created_at DESC").Limit(*limitPtr).Find(&logs)
|
||||
|
||||
fmt.Println("====================================================================================================")
|
||||
fmt.Printf("任务流水: %s (ID: %s) 的近期执行记录 (最多展示 %d 条)\n", taskName, taskID, *limitPtr)
|
||||
fmt.Println("----------------------------------------------------------------------------------------------------")
|
||||
fmt.Printf("%-20s | %-8s | %-6s | %-12s | %-20s\n", "日志ID", "状态", "退出码", "耗时", "开始时间")
|
||||
fmt.Println("----------------------------------------------------------------------------------------------------")
|
||||
|
||||
if len(logs) == 0 {
|
||||
fmt.Println("未查询到任何历史执行记录。")
|
||||
} else {
|
||||
for _, l := range logs {
|
||||
statusText := "运行中"
|
||||
switch l.Status {
|
||||
case constant.TaskStatusSuccess:
|
||||
statusText = "成功"
|
||||
case constant.TaskStatusFailed:
|
||||
statusText = "失败"
|
||||
case constant.TaskStatusTimeout:
|
||||
statusText = "超时"
|
||||
case constant.TaskStatusCancelled:
|
||||
statusText = "已取消"
|
||||
}
|
||||
|
||||
startStr := "-"
|
||||
if l.StartTime != nil {
|
||||
startStr = l.StartTime.Time().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
durationStr := fmt.Sprintf("%d ms", l.Duration)
|
||||
|
||||
fmt.Printf("%-20s | %-8s | %-6d | %-12s | %-20s\n", l.ID, statusText, l.ExitCode, durationStr, startStr)
|
||||
}
|
||||
}
|
||||
fmt.Println("====================================================================================================")
|
||||
fmt.Printf("提示: 结合命令 'taskpool task status %s <日志ID>' 查看特定历史日志内容。\n", taskID)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/engigu/taskpool/internal/constant"
|
||||
)
|
||||
|
||||
func Run(args []string) {
|
||||
fmt.Printf("taskpool %s (Build time: %s)\n", constant.Version, constant.BuildTime)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/engigu/taskpool/cmd/clibase"
|
||||
"github.com/engigu/taskpool/internal/services"
|
||||
)
|
||||
|
||||
func printMainHelp() {
|
||||
fmt.Fprintf(os.Stderr, "\n任务池 WebUI 命令行管理工具\n\n")
|
||||
fmt.Fprintf(os.Stderr, "用法:\n")
|
||||
fmt.Fprintf(os.Stderr, " taskpool webui <子命令> [参数]\n\n")
|
||||
fmt.Fprintf(os.Stderr, "可用子命令:\n")
|
||||
fmt.Fprintf(os.Stderr, " list 列出当前安装的所有前端资源包\n")
|
||||
fmt.Fprintf(os.Stderr, " set 设置激活指定的 WebUI\n")
|
||||
fmt.Fprintf(os.Stderr, " reset 一键回退到系统默认的内置 WebUI\n")
|
||||
fmt.Fprintf(os.Stderr, " delete 删除指定的 WebUI 资源包\n\n")
|
||||
}
|
||||
|
||||
func Run(args []string) {
|
||||
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" {
|
||||
printMainHelp()
|
||||
return
|
||||
}
|
||||
|
||||
subCommand := args[0]
|
||||
subArgs := args[1:]
|
||||
|
||||
switch subCommand {
|
||||
case "list":
|
||||
runList(subArgs)
|
||||
case "set":
|
||||
runSet(subArgs)
|
||||
case "reset":
|
||||
runReset(subArgs)
|
||||
case "delete":
|
||||
runDelete(subArgs)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "未知子命令: %s\n", subCommand)
|
||||
printMainHelp()
|
||||
}
|
||||
}
|
||||
|
||||
func initServices() *services.WebUIService {
|
||||
clibase.InitContext(false)
|
||||
settingsService := services.NewSettingsService()
|
||||
return services.NewWebUIService(settingsService)
|
||||
}
|
||||
|
||||
func runList(args []string) {
|
||||
svc := initServices()
|
||||
list, err := svc.GetWebUIs()
|
||||
if err != nil {
|
||||
fmt.Printf(">> 获取WebUI列表失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
settingsService := services.NewSettingsService()
|
||||
activeWebUI := settingsService.Get("site", "active_webui")
|
||||
if activeWebUI == "" {
|
||||
activeWebUI = "default"
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("=", 100))
|
||||
fmt.Printf("%s | %s | %s | %s | %s\n",
|
||||
clibase.VisualFormat("名称", 20),
|
||||
clibase.VisualFormat("版本", 12),
|
||||
clibase.VisualFormat("作者", 15),
|
||||
clibase.VisualFormat("状态", 10),
|
||||
clibase.VisualFormat("描述", 30),
|
||||
)
|
||||
fmt.Println(strings.Repeat("-", 100))
|
||||
|
||||
for _, w := range list {
|
||||
status := "-"
|
||||
if w.Name == activeWebUI {
|
||||
status = "使用中"
|
||||
}
|
||||
|
||||
fmt.Printf("%s | %s | %s | %s | %s\n",
|
||||
clibase.VisualFormat(w.Name, 20),
|
||||
clibase.VisualFormat(w.Version, 12),
|
||||
clibase.VisualFormat(w.Author, 15),
|
||||
clibase.VisualFormat(status, 10),
|
||||
clibase.VisualFormat(w.Description, 30),
|
||||
)
|
||||
}
|
||||
fmt.Println(strings.Repeat("=", 100))
|
||||
}
|
||||
|
||||
func runSet(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "错误: 缺少目标 WebUI 名称。\n用法: taskpool webui set <name>\n")
|
||||
return
|
||||
}
|
||||
name := args[0]
|
||||
svc := initServices()
|
||||
err := svc.SetActiveWebUI(name)
|
||||
if err != nil {
|
||||
fmt.Printf(">> 设置激活WebUI失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf(">> 成功激活 WebUI: %s\n", name)
|
||||
}
|
||||
|
||||
func runReset(args []string) {
|
||||
svc := initServices()
|
||||
err := svc.SetActiveWebUI("default")
|
||||
if err != nil {
|
||||
fmt.Printf(">> 回退默认WebUI失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println(">> 成功回退到内置默认 WebUI")
|
||||
}
|
||||
|
||||
func runDelete(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "错误: 缺少目标 WebUI 名称。\n用法: taskpool webui delete <name>\n")
|
||||
return
|
||||
}
|
||||
name := args[0]
|
||||
svc := initServices()
|
||||
err := svc.DeleteWebUI(name)
|
||||
if err != nil {
|
||||
fmt.Printf(">> 删除WebUI失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf(">> 成功删除 WebUI: %s\n", name)
|
||||
}
|
||||
Reference in New Issue
Block a user