diff --git a/cmd/helper/main.go b/cmd/helper/main.go index 4a00975..5ad61d8 100644 --- a/cmd/helper/main.go +++ b/cmd/helper/main.go @@ -1,10 +1,18 @@ package main import ( + "flag" _ "time/tzdata" + + "github.com/acepanel/helper/pkg/config" ) func main() { + verbose := flag.Bool("v", false, "verbose mode") + flag.Parse() + + config.Global.Verbose = *verbose + helper, err := initHelper() if err != nil { panic(err) diff --git a/internal/service/installer.go b/internal/service/installer.go index 928f497..ba59cc0 100644 --- a/internal/service/installer.go +++ b/internal/service/installer.go @@ -21,6 +21,7 @@ import ( // Installer 安装器接口 type Installer interface { Install(ctx context.Context, cfg *types.InstallConfig, progress chan<- types.Progress) error + SetVerboseCallback(cb system.VerboseCallback) } type installer struct { @@ -48,6 +49,10 @@ func NewInstaller( } } +func (i *installer) SetVerboseCallback(cb system.VerboseCallback) { + i.executor.SetVerboseCallback(cb) +} + func (i *installer) Install(ctx context.Context, cfg *types.InstallConfig, progress chan<- types.Progress) error { steps := []struct { name string diff --git a/internal/service/mounter.go b/internal/service/mounter.go index 9019148..7fe510d 100644 --- a/internal/service/mounter.go +++ b/internal/service/mounter.go @@ -17,6 +17,7 @@ type Mounter interface { ListDisks(ctx context.Context) ([]types.DiskInfo, error) IsPartitioned(disk string) bool Mount(ctx context.Context, cfg *types.MountConfig, progress ProgressCallback) error + SetVerboseCallback(cb system.VerboseCallback) } type mounter struct { @@ -32,6 +33,10 @@ func NewMounter(detector system.Detector, executor system.Executor) Mounter { } } +func (m *mounter) SetVerboseCallback(cb system.VerboseCallback) { + m.executor.SetVerboseCallback(cb) +} + func (m *mounter) ListDisks(ctx context.Context) ([]types.DiskInfo, error) { return m.detector.ListDisks(ctx) } diff --git a/internal/service/uninstaller.go b/internal/service/uninstaller.go index baecf5a..ffa3b2f 100644 --- a/internal/service/uninstaller.go +++ b/internal/service/uninstaller.go @@ -15,6 +15,7 @@ type ProgressCallback func(step, message string) // Uninstaller 卸载器接口 type Uninstaller interface { Uninstall(ctx context.Context, setupPath string, progress ProgressCallback) error + SetVerboseCallback(cb system.VerboseCallback) } type uninstaller struct { @@ -36,6 +37,10 @@ func NewUninstaller( } } +func (u *uninstaller) SetVerboseCallback(cb system.VerboseCallback) { + u.executor.SetVerboseCallback(cb) +} + func (u *uninstaller) Uninstall(ctx context.Context, setupPath string, progress ProgressCallback) error { // 检查root权限 if err := u.detector.CheckRoot(); err != nil { diff --git a/internal/system/detector.go b/internal/system/detector.go index 579352a..1af1152 100644 --- a/internal/system/detector.go +++ b/internal/system/detector.go @@ -91,7 +91,7 @@ func (d *detector) detectOS() types.OSType { return types.OSDebian case "ubuntu": return types.OSUbuntu - case "rhel", "centos", "rocky", "almalinux", "fedora": + case "rhel", "centos", "rocky", "almalinux", "fedora", "tencentos": return types.OSRHEL default: // 检查ID_LIKE diff --git a/internal/system/executor.go b/internal/system/executor.go index eb6eaee..3bbd908 100644 --- a/internal/system/executor.go +++ b/internal/system/executor.go @@ -4,8 +4,12 @@ import ( "bytes" "context" "errors" + "fmt" "io" "os/exec" + "strings" + + "github.com/acepanel/helper/pkg/config" ) // CommandResult 命令执行结果 @@ -15,6 +19,9 @@ type CommandResult struct { Stderr string } +// VerboseCallback verbose 模式回调 +type VerboseCallback func(cmd string) + // Executor 命令执行器接口 type Executor interface { // Run 执行命令并等待完成 @@ -23,16 +30,36 @@ type Executor interface { RunWithInput(ctx context.Context, input string, name string, args ...string) (*CommandResult, error) // RunStream 执行命令并流式输出 RunStream(ctx context.Context, stdout, stderr io.Writer, name string, args ...string) error + // SetVerboseCallback 设置 verbose 回调 + SetVerboseCallback(cb VerboseCallback) } -type executor struct{} +type executor struct { + verboseCallback VerboseCallback +} // NewExecutor 创建执行器 func NewExecutor() Executor { return &executor{} } +func (e *executor) SetVerboseCallback(cb VerboseCallback) { + e.verboseCallback = cb +} + +func (e *executor) logVerbose(name string, args ...string) { + if config.Global.Verbose && e.verboseCallback != nil { + cmdStr := name + if len(args) > 0 { + cmdStr += " " + strings.Join(args, " ") + } + e.verboseCallback(cmdStr) + } +} + func (e *executor) Run(ctx context.Context, name string, args ...string) (*CommandResult, error) { + e.logVerbose(name, args...) + cmd := exec.CommandContext(ctx, name, args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -49,6 +76,11 @@ func (e *executor) Run(ctx context.Context, name string, args ...string) (*Comma if errors.As(err, &exitErr) { result.ExitCode = exitErr.ExitCode() } + // 包含 stderr 信息到错误中 + stderrStr := strings.TrimSpace(stderr.String()) + if stderrStr != "" { + return result, fmt.Errorf("%w: %s", err, stderrStr) + } return result, err } @@ -56,6 +88,8 @@ func (e *executor) Run(ctx context.Context, name string, args ...string) (*Comma } func (e *executor) RunWithInput(ctx context.Context, input string, name string, args ...string) (*CommandResult, error) { + e.logVerbose(name, args...) + cmd := exec.CommandContext(ctx, name, args...) cmd.Stdin = bytes.NewBufferString(input) var stdout, stderr bytes.Buffer @@ -73,6 +107,11 @@ func (e *executor) RunWithInput(ctx context.Context, input string, name string, if errors.As(err, &exitErr) { result.ExitCode = exitErr.ExitCode() } + // 包含 stderr 信息到错误中 + stderrStr := strings.TrimSpace(stderr.String()) + if stderrStr != "" { + return result, fmt.Errorf("%w: %s", err, stderrStr) + } return result, err } @@ -80,6 +119,8 @@ func (e *executor) RunWithInput(ctx context.Context, input string, name string, } func (e *executor) RunStream(ctx context.Context, stdout, stderr io.Writer, name string, args ...string) error { + e.logVerbose(name, args...) + cmd := exec.CommandContext(ctx, name, args...) cmd.Stdout = stdout cmd.Stderr = stderr diff --git a/internal/ui/app.go b/internal/ui/app.go index 1746ad4..d78e57e 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -8,9 +8,11 @@ import ( "strings" "time" + "github.com/acepanel/helper/pkg/config" "github.com/acepanel/helper/pkg/embed" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" @@ -45,6 +47,7 @@ const ( // 消息类型 type ( progressMsg types.Progress + verboseMsg string // verbose 模式命令输出 installCompleteMsg struct { err error info string @@ -90,6 +93,10 @@ type App struct { installDone bool installInfo string // 安装完成后的面板信息 + // verbose 模式 + verboseLogs []string + verboseViewport viewport.Model + // 卸载 uninstallCountdown int uninstallSkipCount int // 连按enter跳过倒计时的计数 @@ -146,6 +153,13 @@ func NewApp(installer service.Installer, uninstaller service.Uninstaller, mounte // 初始化进度条 app.installProgress = progress.New(progress.WithDefaultGradient()) + // 初始化 verbose viewport + app.verboseViewport = viewport.New(80, 8) + app.verboseViewport.Style = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorMuted). + Padding(0, 1) + return app } @@ -342,6 +356,16 @@ func (a *App) updateInstall(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case verboseMsg: + a.verboseLogs = append(a.verboseLogs, string(msg)) + // 保留最近 100 条 + if len(a.verboseLogs) > 100 { + a.verboseLogs = a.verboseLogs[len(a.verboseLogs)-100:] + } + a.verboseViewport.SetContent(strings.Join(a.verboseLogs, "\n")) + a.verboseViewport.GotoBottom() + return a, nil + case installCompleteMsg: a.installRunning = false a.installDone = true @@ -366,6 +390,7 @@ func (a *App) updateInstall(msg tea.Msg) (tea.Model, tea.Cmd) { if a.installForm.State == huh.StateCompleted { if a.installConfirmed { a.installRunning = true + a.verboseLogs = nil // 清空 verbose 日志 return a, tea.Batch(a.installSpinner.Tick, a.startInstall()) } a.state = ViewMainMenu @@ -390,6 +415,15 @@ func (a *App) startInstall() tea.Cmd { } }() + // 设置 verbose 回调 + if config.Global.Verbose { + a.installer.SetVerboseCallback(func(cmd string) { + if a.program != nil { + a.program.Send(verboseMsg("$ " + cmd)) + } + }) + } + cfg := &types.InstallConfig{ SetupPath: a.setupPath, AutoSwap: true, @@ -440,6 +474,13 @@ func (a *App) viewInstall() string { sb.WriteString(LogStyle.Render(log)) sb.WriteString("\n") } + // verbose 模式显示命令日志 + if config.Global.Verbose && len(a.verboseLogs) > 0 { + sb.WriteString("\n") + sb.WriteString(MutedStyle.Render("Commands:")) + sb.WriteString("\n") + sb.WriteString(a.verboseViewport.View()) + } } else { sb.WriteString(a.installForm.View()) } @@ -498,6 +539,15 @@ func (a *App) updateUninstall(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case verboseMsg: + a.verboseLogs = append(a.verboseLogs, string(msg)) + if len(a.verboseLogs) > 100 { + a.verboseLogs = a.verboseLogs[len(a.verboseLogs)-100:] + } + a.verboseViewport.SetContent(strings.Join(a.verboseLogs, "\n")) + a.verboseViewport.GotoBottom() + return a, nil + case uninstallCompleteMsg: a.uninstallRunning = false a.uninstallDone = true @@ -523,6 +573,7 @@ func (a *App) updateUninstall(msg tea.Msg) (tea.Model, tea.Cmd) { if a.uninstallConfirm { a.uninstallState = 3 a.uninstallRunning = true + a.verboseLogs = nil // 清空 verbose 日志 return a, tea.Batch(a.uninstallSpinner.Tick, a.startUninstall()) } a.state = ViewMainMenu @@ -557,6 +608,16 @@ func (a *App) tickCountdown() tea.Cmd { func (a *App) startUninstall() tea.Cmd { return func() tea.Msg { ctx := context.Background() + + // 设置 verbose 回调 + if config.Global.Verbose { + a.uninstaller.SetVerboseCallback(func(cmd string) { + if a.program != nil { + a.program.Send(verboseMsg("$ " + cmd)) + } + }) + } + err := a.uninstaller.Uninstall(ctx, a.setupPath, func(step, message string) { if a.program != nil { a.program.Send(progressMsg{Step: step, Message: message}) @@ -608,6 +669,13 @@ func (a *App) viewUninstall() string { sb.WriteString(LogStyle.Render(log)) sb.WriteString("\n") } + // verbose 模式显示命令日志 + if config.Global.Verbose && len(a.verboseLogs) > 0 { + sb.WriteString("\n") + sb.WriteString(MutedStyle.Render("Commands:")) + sb.WriteString("\n") + sb.WriteString(a.verboseViewport.View()) + } case 4: // done if a.uninstallErr != nil { @@ -678,6 +746,15 @@ func (a *App) updateMount(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case verboseMsg: + a.verboseLogs = append(a.verboseLogs, string(msg)) + if len(a.verboseLogs) > 100 { + a.verboseLogs = a.verboseLogs[len(a.verboseLogs)-100:] + } + a.verboseViewport.SetContent(strings.Join(a.verboseLogs, "\n")) + a.verboseViewport.GotoBottom() + return a, nil + case mountCompleteMsg: a.mountRunning = false a.mountDone = true @@ -738,6 +815,7 @@ func (a *App) updateMount(msg tea.Msg) (tea.Model, tea.Cmd) { if a.mountConfirmed { a.mountState = 5 a.mountRunning = true + a.verboseLogs = nil // 清空 verbose 日志 return a, tea.Batch(a.mountSpinner.Tick, a.startMount()) } a.state = ViewMainMenu @@ -819,6 +897,16 @@ func (a *App) initMountConfirmForm() { func (a *App) startMount() tea.Cmd { return func() tea.Msg { ctx := context.Background() + + // 设置 verbose 回调 + if config.Global.Verbose { + a.mounter.SetVerboseCallback(func(cmd string) { + if a.program != nil { + a.program.Send(verboseMsg("$ " + cmd)) + } + }) + } + err := a.mounter.Mount(ctx, &a.mountConfig, func(step, message string) { if a.program != nil { a.program.Send(progressMsg{Step: step, Message: message}) @@ -885,6 +973,13 @@ func (a *App) viewMount() string { sb.WriteString(LogStyle.Render(log)) sb.WriteString("\n") } + // verbose 模式显示命令日志 + if config.Global.Verbose && len(a.verboseLogs) > 0 { + sb.WriteString("\n") + sb.WriteString(MutedStyle.Render("Commands:")) + sb.WriteString("\n") + sb.WriteString(a.verboseViewport.View()) + } } return sb.String() diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..76293e5 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,9 @@ +package config + +// Config 全局配置 +type Config struct { + Verbose bool +} + +// Global 全局配置实例 +var Global = &Config{}