diff --git a/cmd/clean.go b/cmd/clean.go new file mode 100644 index 0000000..5b090ed --- /dev/null +++ b/cmd/clean.go @@ -0,0 +1,167 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + "sync" + + gh "github.com/maxbeizer/gh-fleet/internal/github" +) + +func runClean(args []string) error { + fs := flag.NewFlagSet("clean", flag.ContinueOnError) + configDir := fs.String("config", ".", "directory containing fleet.toml") + dryRun := fs.Bool("dry-run", false, "preview deletions without applying") + if err := fs.Parse(args); err != nil { + return err + } + + cfg, err := loadConfig(*configDir) + if err != nil { + return err + } + + repos, err := discoverRepos(cfg) + if err != nil { + return err + } + + fmt.Println(boldStyle.Render("Fleet Clean — stale sync branches")) + if *dryRun { + fmt.Println(dimStyle.Render(" (dry-run mode)")) + } + fmt.Println() + + type branchResult struct { + repo string + branch string + hasOpenPR bool + deleted bool + err error + } + + // Collect branches concurrently + type repoBranches struct { + repo string + branches []string + err error + } + rb := make([]repoBranches, len(repos)) + + var wg sync.WaitGroup + sem := make(chan struct{}, 10) + + for i, r := range repos { + rb[i].repo = r.Name + wg.Add(1) + go func(idx int, repo gh.Repo) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + branches, err := gh.ListSyncBranches(cfg.Owner, repo.Name) + rb[idx].branches = branches + rb[idx].err = err + }(i, r) + } + wg.Wait() + + // Process each branch + var results []branchResult + for _, r := range rb { + if r.err != nil { + fmt.Fprintf(os.Stderr, " %s %s %s\n", + errStyle.Render("❌"), r.repo, dimStyle.Render(r.err.Error())) + continue + } + for _, b := range r.branches { + results = append(results, branchResult{repo: r.repo, branch: b}) + } + } + + if len(results) == 0 { + fmt.Printf(" %s No stale sync branches found.\n", okStyle.Render("✅")) + return nil + } + + // Check open PRs and delete concurrently + var wg2 sync.WaitGroup + sem2 := make(chan struct{}, 10) + + for i := range results { + wg2.Add(1) + go func(idx int) { + defer wg2.Done() + sem2 <- struct{}{} + defer func() { <-sem2 }() + + r := &results[idx] + hasPR, err := gh.BranchHasOpenPR(cfg.Owner, r.repo, r.branch) + if err != nil { + r.err = err + return + } + r.hasOpenPR = hasPR + + if !hasPR && !*dryRun { + gh.DeleteBranch(cfg.Owner, r.repo, r.branch) + r.deleted = true + } + }(i) + } + wg2.Wait() + + // Print results + totalDeleted := 0 + totalSkipped := 0 + totalErrors := 0 + + for _, r := range results { + if r.err != nil { + fmt.Fprintf(os.Stderr, " %s %s %s %s\n", + errStyle.Render("❌"), r.repo, r.branch, dimStyle.Render(r.err.Error())) + totalErrors++ + continue + } + + if r.hasOpenPR { + fmt.Printf(" %s %s %s %s\n", + warnStyle.Render("⏭"), r.repo, r.branch, dimStyle.Render("(has open PR)")) + totalSkipped++ + continue + } + + if *dryRun { + fmt.Printf(" %s %s %s %s\n", + warnStyle.Render("🗑"), r.repo, r.branch, dimStyle.Render("(would delete)")) + totalDeleted++ + } else { + fmt.Printf(" %s %s %s %s\n", + okStyle.Render("🗑"), r.repo, r.branch, dimStyle.Render("(deleted)")) + totalDeleted++ + } + } + + fmt.Println() + if *dryRun && totalDeleted > 0 { + fmt.Printf("%s stale branches to delete. Run without --dry-run to apply.\n", + warnStyle.Render(fmt.Sprintf("%d", totalDeleted))) + } else if !*dryRun && totalDeleted > 0 { + fmt.Printf("%s Deleted %d stale branches.\n", + okStyle.Render("✅"), totalDeleted) + } + if totalSkipped > 0 { + fmt.Printf("%s Skipped %d branches with open PRs.\n", + warnStyle.Render("⏭"), totalSkipped) + } + if totalErrors > 0 { + fmt.Fprintf(os.Stderr, "%s %d branches had errors.\n", + errStyle.Render("⚠️"), totalErrors) + } + if totalDeleted == 0 && totalSkipped == 0 && totalErrors == 0 { + fmt.Printf(" %s No stale sync branches found.\n", okStyle.Render("✅")) + } + + return nil +} diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 0000000..3d93a57 --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +func runDoctor(args []string) error { + fs := flag.NewFlagSet("doctor", flag.ContinueOnError) + configDir := fs.String("config", ".", "directory containing fleet.toml") + if err := fs.Parse(args); err != nil { + return err + } + + cfg, err := loadConfig(*configDir) + if err != nil { + return err + } + + fmt.Println(boldStyle.Render("Fleet Doctor")) + fmt.Println() + + failed := false + pass := func(msg string) { + fmt.Printf(" %s %s\n", okStyle.Render("✅"), msg) + } + fail := func(msg string) { + fmt.Printf(" %s %s\n", errStyle.Render("❌"), msg) + failed = true + } + + // owner is non-empty + if cfg.Owner != "" { + pass("owner is set") + } else { + fail("owner is empty") + } + + // catalog.output is set + if cfg.Catalog.Output != "" { + pass("catalog.output is set") + } else { + fail("catalog.output is not set") + } + + // catalog.header file exists if configured + if cfg.Catalog.Header != "" { + headerPath := filepath.Join(cfg.Dir, cfg.Catalog.Header) + if _, err := os.Stat(headerPath); err == nil { + pass(fmt.Sprintf("catalog.header file exists (%s)", cfg.Catalog.Header)) + } else { + fail(fmt.Sprintf("catalog.header file missing: %s", cfg.Catalog.Header)) + } + } + + // All canonical files exist on disk + for _, sf := range cfg.Sync.Files { + canonPath := filepath.Join(cfg.Dir, sf.Canon) + if _, err := os.Stat(canonPath); err == nil { + pass(fmt.Sprintf("canon file exists: %s", sf.Canon)) + } else { + fail(fmt.Sprintf("canon file missing: %s", sf.Canon)) + } + } + + // Template files contain the "extension-template" placeholder + for _, sf := range cfg.Sync.Files { + if !sf.Template { + continue + } + canonPath := filepath.Join(cfg.Dir, sf.Canon) + data, err := os.ReadFile(canonPath) + if err != nil { + continue // already reported as missing above + } + if strings.Contains(string(data), "extension-template") { + pass(fmt.Sprintf("template has extension-template placeholder: %s", sf.Canon)) + } else { + fail(fmt.Sprintf("template missing extension-template placeholder: %s", sf.Canon)) + } + } + + // Collect all template file contents for variable checks + varRefPattern := regexp.MustCompile(`\$\{([A-Z_][A-Z0-9_]*)\}`) + referencedVars := map[string]bool{} + for _, sf := range cfg.Sync.Files { + if !sf.Template { + continue + } + canonPath := filepath.Join(cfg.Dir, sf.Canon) + data, err := os.ReadFile(canonPath) + if err != nil { + continue + } + for _, match := range varRefPattern.FindAllStringSubmatch(string(data), -1) { + referencedVars[match[1]] = true + } + } + + // All template_vars keys are used in at least one template file + for key := range cfg.Sync.TemplateVars { + if referencedVars[key] { + pass(fmt.Sprintf("template_var %s is used", key)) + } else { + fail(fmt.Sprintf("template_var %s is defined but never referenced in any template", key)) + } + } + + // No template files reference variables not defined in template_vars + for varName := range referencedVars { + if _, ok := cfg.Sync.TemplateVars[varName]; ok { + pass(fmt.Sprintf("template ref ${%s} is defined", varName)) + } else { + fail(fmt.Sprintf("template ref ${%s} is not defined in template_vars", varName)) + } + } + + fmt.Println() + if failed { + fmt.Println(errStyle.Render("Some checks failed.")) + return fmt.Errorf("doctor found issues") + } + fmt.Println(okStyle.Render("All checks passed.")) + return nil +} diff --git a/cmd/pr.go b/cmd/pr.go new file mode 100644 index 0000000..a11bdec --- /dev/null +++ b/cmd/pr.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + "sort" + "strings" + + gh "github.com/maxbeizer/gh-fleet/internal/github" +) + +func runPR(args []string) error { + fs := flag.NewFlagSet("pr", flag.ContinueOnError) + configDir := fs.String("config", ".", "directory containing fleet.toml") + fileFilter := fs.String("file", "", "only show/act on PRs for this synced file") + merge := fs.Bool("merge", false, "squash-merge all listed PRs") + admin := fs.Bool("admin", false, "use --admin to bypass branch protection (with --merge)") + close := fs.Bool("close", false, "close all listed PRs with a comment") + dryRun := fs.Bool("dry-run", false, "preview merge/close actions without executing") + if err := fs.Parse(args); err != nil { + return err + } + + if *merge && *close { + return fmt.Errorf("cannot use --merge and --close together") + } + + cfg, err := loadConfig(*configDir) + if err != nil { + return err + } + + repos, err := discoverRepos(cfg) + if err != nil { + return err + } + + repoNames := make([]string, len(repos)) + for i, r := range repos { + repoNames[i] = r.Name + } + + fmt.Fprintf(os.Stderr, "Fetching fleet PRs across %d repos...\n", len(repoNames)) + allPRs := gh.FetchFleetPRs(cfg.Owner, repoNames) + + if len(allPRs) == 0 { + fmt.Println(okStyle.Render("No open fleet PRs found.")) + return nil + } + + // Group PRs by synced file (derived from branch name "fleet/sync-") + grouped := groupPRsByFile(allPRs) + + // Apply file filter + if *fileFilter != "" { + filtered := make(map[string][]gh.FleetPR) + for file, prs := range grouped { + if strings.EqualFold(file, *fileFilter) { + filtered[file] = prs + } + } + grouped = filtered + if len(grouped) == 0 { + fmt.Printf("No fleet PRs found for file %s\n", dimStyle.Render(*fileFilter)) + return nil + } + } + + // Display grouped PRs + files := sortedFileKeys(grouped) + total := 0 + for _, file := range files { + prs := grouped[file] + total += len(prs) + fmt.Printf("\n%s %s\n", boldStyle.Render("●"), boldStyle.Render(file)) + for _, pr := range prs { + fmt.Printf(" %s %s %s\n", + warnStyle.Render(fmt.Sprintf("#%d", pr.Number)), + pr.Repo, + dimStyle.Render(pr.URL), + ) + } + } + fmt.Printf("\n%s\n", dimStyle.Render(fmt.Sprintf("%d open fleet PR(s) across %d file(s)", total, len(files)))) + + // Handle --merge + if *merge { + fmt.Println() + for _, file := range files { + for _, pr := range grouped[file] { + label := fmt.Sprintf("%s/%s#%d", cfg.Owner, pr.Repo, pr.Number) + if *dryRun { + fmt.Printf(" %s %s\n", dimStyle.Render("[dry-run] would merge"), label) + continue + } + fmt.Fprintf(os.Stderr, " Merging %s...\n", label) + if err := gh.MergePR(cfg.Owner, pr.Repo, pr.Number, *admin); err != nil { + fmt.Printf(" %s %s: %v\n", errStyle.Render("✗"), label, err) + } else { + fmt.Printf(" %s %s\n", okStyle.Render("✓ merged"), label) + } + } + } + } + + // Handle --close + if *close { + comment := "Closed by gh-fleet pr --close" + fmt.Println() + for _, file := range files { + for _, pr := range grouped[file] { + label := fmt.Sprintf("%s/%s#%d", cfg.Owner, pr.Repo, pr.Number) + if *dryRun { + fmt.Printf(" %s %s\n", dimStyle.Render("[dry-run] would close"), label) + continue + } + fmt.Fprintf(os.Stderr, " Closing %s...\n", label) + if err := gh.ClosePR(cfg.Owner, pr.Repo, pr.Number, comment); err != nil { + fmt.Printf(" %s %s: %v\n", errStyle.Render("✗"), label, err) + } else { + fmt.Printf(" %s %s\n", okStyle.Render("✓ closed"), label) + } + } + } + } + + return nil +} + +// groupPRsByFile groups PRs by synced file name derived from the branch name. +// Branch "fleet/sync-makefile" → file "Makefile" (best-effort reconstruction). +func groupPRsByFile(prs []gh.FleetPR) map[string][]gh.FleetPR { + grouped := make(map[string][]gh.FleetPR) + for _, pr := range prs { + file := fileFromBranch(pr.Branch) + grouped[file] = append(grouped[file], pr) + } + // Sort PRs within each group by repo name + for file := range grouped { + sort.Slice(grouped[file], func(i, j int) bool { + return grouped[file][i].Repo < grouped[file][j].Repo + }) + } + return grouped +} + +// fileFromBranch extracts the synced file name from a branch like "fleet/sync-makefile". +func fileFromBranch(branch string) string { + name := strings.TrimPrefix(branch, "fleet/sync-") + // The sync command replaces dots with hyphens, but we can't perfectly + // reverse that. Return as-is for grouping. + return name +} + +func sortedFileKeys(m map[string][]gh.FleetPR) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/root.go b/cmd/root.go index 0e329fe..ebc4f03 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -40,6 +40,12 @@ func Execute(version string) error { return runStatus(args) case "settings": return runSettings(args) + case "doctor": + return runDoctor(args) + case "clean": + return runClean(args) + case "pr": + return runPR(args) default: return fmt.Errorf("unknown command: %s\nRun 'gh fleet -h' for usage", subcmd) } @@ -53,7 +59,10 @@ Usage: Commands: catalog Regenerate README with extension catalog + clean Delete stale fleet/sync-* branches with no open PRs + doctor Validate fleet.toml configuration drift Detect configuration drift across repos + pr List and manage open fleet PRs across repos settings Enforce repo settings across the fleet sync Push canonical files to out-of-sync repos status Quick health matrix across all extension repos diff --git a/cmd/sync.go b/cmd/sync.go index d0947bb..d671cac 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -6,11 +6,17 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/maxbeizer/gh-fleet/internal/fleet" gh "github.com/maxbeizer/gh-fleet/internal/github" ) +type syncResult struct { + lines []string + changes int +} + func runSync(args []string) error { fs := flag.NewFlagSet("sync", flag.ContinueOnError) configDir := fs.String("config", ".", "directory containing fleet.toml") @@ -58,59 +64,91 @@ func runSync(args []string) error { fmt.Printf("\n%s → %s\n", boldStyle.Render(sf.Canon), sf.Target) - for _, r := range repos { - content := string(canonContent) - - // Apply template variables - if sf.Template { - extName := strings.TrimPrefix(r.Name, "gh-") - content = strings.ReplaceAll(content, "extension-template", extName) - for k, v := range cfg.Sync.TemplateVars { - content = strings.ReplaceAll(content, fmt.Sprintf("${%s}", k), v) + results := make([]syncResult, len(repos)) + var wg sync.WaitGroup + sem := make(chan struct{}, 5) + + for i, r := range repos { + wg.Add(1) + go func(idx int, r gh.Repo, sf fleet.SyncFile) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + res := &results[idx] + content := string(canonContent) + + // Apply template variables + if sf.Template { + extName := strings.TrimPrefix(r.Name, "gh-") + content = strings.ReplaceAll(content, "extension-template", extName) + for k, v := range cfg.Sync.TemplateVars { + content = strings.ReplaceAll(content, fmt.Sprintf("${%s}", k), v) + } } - } - - // Fetch current content - remote, err := gh.FetchFileContent(cfg.Owner, r.Name, sf.Target) - if err != nil { - // File doesn't exist — needs sync - fmt.Printf(" %s %s %s\n", errStyle.Render("➕"), r.Name, dimStyle.Render("(missing)")) - totalChanges++ - if !*dryRun { - fileContent := content - if sf.SkipIfExists { - fileContent = scaffoldCopilotInstructions(r) + // Fetch current content + remote, err := gh.FetchFileContent(cfg.Owner, r.Name, sf.Target) + if err != nil { + // File doesn't exist — needs sync + res.lines = append(res.lines, fmt.Sprintf(" %s %s %s", errStyle.Render("➕"), r.Name, dimStyle.Render("(missing)"))) + res.changes++ + + if sf.SkipIfExists && *dryRun { + preview := scaffoldCopilotInstructions(r) + previewLines := strings.SplitN(preview, "\n", 6) + if len(previewLines) > 5 { + previewLines = previewLines[:5] + } + for _, pl := range previewLines { + res.lines = append(res.lines, fmt.Sprintf(" %s", dimStyle.Render(pl))) + } } - if err := syncFile(cfg.Owner, r.Name, sf.Target, fileContent); err != nil { - fmt.Fprintf(os.Stderr, " ❌ %v\n", err) - } else { - fmt.Printf(" %s PR created\n", okStyle.Render("✅")) + + if !*dryRun { + fileContent := content + if sf.SkipIfExists { + fileContent = scaffoldCopilotInstructions(r) + } + if err := syncFile(cfg.Owner, r.Name, sf.Target, fileContent); err != nil { + res.lines = append(res.lines, fmt.Sprintf(" ❌ %v", err)) + } else { + res.lines = append(res.lines, fmt.Sprintf(" %s PR created", okStyle.Render("✅"))) + } } + return } - continue - } - // Compare - if strings.TrimSpace(remote) != strings.TrimSpace(content) { - if sf.SkipIfExists { - fmt.Printf(" %s %s %s\n", dimStyle.Render("⊘"), r.Name, dimStyle.Render("(exists, skipped)")) - continue - } + // Compare + if strings.TrimSpace(remote) != strings.TrimSpace(content) { + if sf.SkipIfExists { + res.lines = append(res.lines, fmt.Sprintf(" %s %s %s", dimStyle.Render("⊘"), r.Name, dimStyle.Render("(exists, skipped)"))) + return + } - fmt.Printf(" %s %s %s\n", warnStyle.Render("⇄"), r.Name, dimStyle.Render("(differs)")) - totalChanges++ + res.lines = append(res.lines, fmt.Sprintf(" %s %s %s", warnStyle.Render("⇄"), r.Name, dimStyle.Render("(differs)"))) + res.changes++ - if !*dryRun { - if err := syncFile(cfg.Owner, r.Name, sf.Target, content); err != nil { - fmt.Fprintf(os.Stderr, " ❌ %v\n", err) - } else { - fmt.Printf(" %s PR created\n", okStyle.Render("✅")) + if !*dryRun { + if err := syncFile(cfg.Owner, r.Name, sf.Target, content); err != nil { + res.lines = append(res.lines, fmt.Sprintf(" ❌ %v", err)) + } else { + res.lines = append(res.lines, fmt.Sprintf(" %s PR created", okStyle.Render("✅"))) + } } + } else { + res.lines = append(res.lines, fmt.Sprintf(" %s %s", okStyle.Render("✅"), r.Name)) } - } else { - fmt.Printf(" %s %s\n", okStyle.Render("✅"), r.Name) + }(i, r, sf) + } + wg.Wait() + + // Print results in order and tally changes + for _, res := range results { + for _, line := range res.lines { + fmt.Println(line) } + totalChanges += res.changes } } diff --git a/internal/github/api.go b/internal/github/api.go index a39d5ef..3a7a278 100644 --- a/internal/github/api.go +++ b/internal/github/api.go @@ -215,6 +215,45 @@ func UpdateRepoSettings(owner, repo string, settings RepoSettings) error { return nil } +// ListSyncBranches returns branch names matching fleet/sync-* for a repo. +func ListSyncBranches(owner, repo string) ([]string, error) { + out, err := exec.Command("gh", "api", + fmt.Sprintf("repos/%s/%s/git/matching-refs/heads/fleet/sync-", owner, repo), + "-q", ".[].ref", + ).Output() + if err != nil { + return nil, fmt.Errorf("listing sync branches for %s/%s: %w", owner, repo, err) + } + + var branches []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Strip refs/heads/ prefix + branches = append(branches, strings.TrimPrefix(line, "refs/heads/")) + } + return branches, nil +} + +// BranchHasOpenPR checks whether a branch has an open pull request. +func BranchHasOpenPR(owner, repo, branch string) (bool, error) { + out, err := exec.Command("gh", "pr", "list", + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--head", branch, + "--state", "open", + "--json", "number", + "-q", "length", + ).Output() + if err != nil { + return false, fmt.Errorf("checking PRs for %s/%s branch %s: %w", owner, repo, branch, err) + } + + count := strings.TrimSpace(string(out)) + return count != "" && count != "0", nil +} + // DeleteBranch deletes a remote branch, ignoring errors if it doesn't exist. func DeleteBranch(owner, repo, branch string) { exec.Command("gh", "api", diff --git a/internal/github/pr.go b/internal/github/pr.go new file mode 100644 index 0000000..37695ff --- /dev/null +++ b/internal/github/pr.go @@ -0,0 +1,101 @@ +package github + +import ( + "encoding/json" + "fmt" + "os/exec" + "strconv" + "strings" + "sync" +) + +// FleetPR represents an open pull request created by gh-fleet sync. +type FleetPR struct { + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + Repo string `json:"-"` + Branch string `json:"headRefName"` +} + +// ListFleetPRs returns all open PRs whose head branch starts with "fleet/sync-". +func ListFleetPRs(owner, repo string) ([]FleetPR, error) { + out, err := exec.Command("gh", "pr", "list", + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--head", "fleet/sync-", + "--state", "open", + "--json", "number,title,url,headRefName", + ).Output() + if err != nil { + return nil, fmt.Errorf("listing PRs for %s/%s: %w", owner, repo, err) + } + + var prs []FleetPR + if err := json.Unmarshal(out, &prs); err != nil { + return nil, fmt.Errorf("parsing PRs for %s/%s: %w", owner, repo, err) + } + for i := range prs { + prs[i].Repo = repo + } + return prs, nil +} + +// FetchFleetPRs fetches fleet PRs from multiple repos concurrently. +func FetchFleetPRs(owner string, repos []string) []FleetPR { + var mu sync.Mutex + var all []FleetPR + var wg sync.WaitGroup + sem := make(chan struct{}, 10) + + for _, repo := range repos { + wg.Add(1) + go func(r string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + prs, err := ListFleetPRs(owner, r) + if err != nil { + return // skip repos with errors + } + mu.Lock() + all = append(all, prs...) + mu.Unlock() + }(repo) + } + wg.Wait() + return all +} + +// MergePR squash-merges a pull request using admin privileges. +func MergePR(owner, repo string, number int, admin bool) error { + args := []string{"pr", "merge", + strconv.Itoa(number), + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--squash", + } + if admin { + args = append(args, "--admin") + } + out, err := exec.Command("gh", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("merging %s/%s#%d: %w\n%s", owner, repo, number, err, strings.TrimSpace(string(out))) + } + return nil +} + +// ClosePR closes a pull request with an optional comment. +func ClosePR(owner, repo string, number int, comment string) error { + args := []string{"pr", "close", + strconv.Itoa(number), + "--repo", fmt.Sprintf("%s/%s", owner, repo), + } + if comment != "" { + args = append(args, "--comment", comment) + } + out, err := exec.Command("gh", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("closing %s/%s#%d: %w\n%s", owner, repo, number, err, strings.TrimSpace(string(out))) + } + return nil +}