cli/internal/cmd/dev/cmd_workflow.go
Snider 95a980bea1 feat: Batch implementation of Gemini issues (#176)
* feat(help): Add CLI help command

Fixes #136

* chore: remove binary

* feat(mcp): Add TCP transport

Fixes #126

* feat(io): Migrate pkg/mcp to use Medium abstraction

Fixes #103

* chore(io): Migrate internal/cmd/docs/* to Medium abstraction

Fixes #113

* chore(io): Migrate internal/cmd/dev/* to Medium abstraction

Fixes #114

* chore(io): Migrate internal/cmd/setup/* to Medium abstraction

* chore(io): Complete migration of internal/cmd/dev/* to Medium abstraction

* chore(io): Migrate internal/cmd/sdk, pkgcmd, and workspace to Medium abstraction

* style: fix formatting in internal/variants

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(io): simplify local Medium implementation

Rewrote to match the simpler TypeScript pattern:
- path() sanitizes and returns string directly
- Each method calls path() once
- No complex symlink validation
- Less code, less attack surface

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(mcp): update sandboxing tests for simplified Medium

The simplified io/local.Medium implementation:
- Sanitizes .. to . (no error, path is cleaned)
- Allows absolute paths through (caller validates if needed)
- Follows symlinks (no traversal blocking)

Update tests to match this simplified behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(updater): resolve PkgVersion duplicate declaration

Remove var PkgVersion from updater.go since go generate creates
const PkgVersion in version.go. Track version.go in git to ensure
builds work without running go generate first.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 04:20:18 +00:00

307 lines
8.1 KiB
Go

package dev
import (
"path/filepath"
"sort"
"strings"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/io"
)
// Workflow command flags
var (
workflowRegistryPath string
workflowDryRun bool
)
// addWorkflowCommands adds the 'workflow' subcommand and its subcommands.
func addWorkflowCommands(parent *cli.Command) {
workflowCmd := &cli.Command{
Use: "workflow",
Short: i18n.T("cmd.dev.workflow.short"),
Long: i18n.T("cmd.dev.workflow.long"),
}
// Shared flags
workflowCmd.PersistentFlags().StringVar(&workflowRegistryPath, "registry", "", i18n.T("common.flag.registry"))
// Subcommands
addWorkflowListCommand(workflowCmd)
addWorkflowSyncCommand(workflowCmd)
parent.AddCommand(workflowCmd)
}
// addWorkflowListCommand adds the 'workflow list' subcommand.
func addWorkflowListCommand(parent *cli.Command) {
listCmd := &cli.Command{
Use: "list",
Short: i18n.T("cmd.dev.workflow.list.short"),
Long: i18n.T("cmd.dev.workflow.list.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runWorkflowList(workflowRegistryPath)
},
}
parent.AddCommand(listCmd)
}
// addWorkflowSyncCommand adds the 'workflow sync' subcommand.
func addWorkflowSyncCommand(parent *cli.Command) {
syncCmd := &cli.Command{
Use: "sync <workflow>",
Short: i18n.T("cmd.dev.workflow.sync.short"),
Long: i18n.T("cmd.dev.workflow.sync.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runWorkflowSync(workflowRegistryPath, args[0], workflowDryRun)
},
}
syncCmd.Flags().BoolVar(&workflowDryRun, "dry-run", false, i18n.T("cmd.dev.workflow.sync.flag.dry_run"))
parent.AddCommand(syncCmd)
}
// runWorkflowList shows a table of repos vs workflows.
func runWorkflowList(registryPath string) error {
reg, registryDir, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
repoList := reg.List()
if len(repoList) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Sort repos by name for consistent output
sort.Slice(repoList, func(i, j int) bool {
return repoList[i].Name < repoList[j].Name
})
// Collect all unique workflow files across all repos
workflowSet := make(map[string]bool)
repoWorkflows := make(map[string]map[string]bool)
for _, repo := range repoList {
workflows := findWorkflows(repo.Path)
repoWorkflows[repo.Name] = make(map[string]bool)
for _, wf := range workflows {
workflowSet[wf] = true
repoWorkflows[repo.Name][wf] = true
}
}
// Sort workflow names
var workflowNames []string
for wf := range workflowSet {
workflowNames = append(workflowNames, wf)
}
sort.Strings(workflowNames)
if len(workflowNames) == 0 {
cli.Text(i18n.T("cmd.dev.workflow.no_workflows"))
return nil
}
// Check for template workflows in the registry directory
templateWorkflows := findWorkflows(filepath.Join(registryDir, ".github", "workflow-templates"))
if len(templateWorkflows) == 0 {
// Also check .github/workflows in the devops repo itself
templateWorkflows = findWorkflows(filepath.Join(registryDir, ".github", "workflows"))
}
templateSet := make(map[string]bool)
for _, wf := range templateWorkflows {
templateSet[wf] = true
}
// Build table
headers := []string{i18n.T("cmd.dev.workflow.header.repo")}
headers = append(headers, workflowNames...)
table := cli.NewTable(headers...)
for _, repo := range repoList {
row := []string{repo.Name}
for _, wf := range workflowNames {
if repoWorkflows[repo.Name][wf] {
row = append(row, successStyle.Render(cli.Glyph(":check:")))
} else {
row = append(row, errorStyle.Render(cli.Glyph(":cross:")))
}
}
table.AddRow(row...)
}
cli.Blank()
table.Render()
return nil
}
// runWorkflowSync copies a workflow template to all repos.
func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) error {
reg, registryDir, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Find the template workflow
templatePath := findTemplateWorkflow(registryDir, workflowFile)
if templatePath == "" {
return cli.Err("%s", i18n.T("cmd.dev.workflow.template_not_found", map[string]interface{}{"File": workflowFile}))
}
// Read template content
templateContent, err := io.Local.Read(templatePath)
if err != nil {
return cli.Wrap(err, i18n.T("cmd.dev.workflow.read_template_error"))
}
repoList := reg.List()
if len(repoList) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Sort repos by name for consistent output
sort.Slice(repoList, func(i, j int) bool {
return repoList[i].Name < repoList[j].Name
})
if dryRun {
cli.Text(i18n.T("cmd.dev.workflow.dry_run_mode"))
cli.Blank()
}
var synced, skipped, failed int
for _, repo := range repoList {
if !repo.IsGitRepo() {
skipped++
continue
}
destDir := filepath.Join(repo.Path, ".github", "workflows")
destPath := filepath.Join(destDir, workflowFile)
// Check if workflow already exists and is identical
if existingContent, err := io.Local.Read(destPath); err == nil {
if existingContent == templateContent {
cli.Print(" %s %s %s\n",
dimStyle.Render("-"),
repoNameStyle.Render(repo.Name),
dimStyle.Render(i18n.T("cmd.dev.workflow.up_to_date")))
skipped++
continue
}
}
if dryRun {
cli.Print(" %s %s %s\n",
warningStyle.Render("*"),
repoNameStyle.Render(repo.Name),
i18n.T("cmd.dev.workflow.would_sync"))
synced++
continue
}
// Create .github/workflows directory if needed
if err := io.Local.EnsureDir(destDir); err != nil {
cli.Print(" %s %s %s\n",
errorStyle.Render(cli.Glyph(":cross:")),
repoNameStyle.Render(repo.Name),
err.Error())
failed++
continue
}
// Write workflow file
if err := io.Local.Write(destPath, templateContent); err != nil {
cli.Print(" %s %s %s\n",
errorStyle.Render(cli.Glyph(":cross:")),
repoNameStyle.Render(repo.Name),
err.Error())
failed++
continue
}
cli.Print(" %s %s %s\n",
successStyle.Render(cli.Glyph(":check:")),
repoNameStyle.Render(repo.Name),
i18n.T("cmd.dev.workflow.synced"))
synced++
}
cli.Blank()
// Summary
if dryRun {
cli.Print("%s %s\n",
i18n.T("cmd.dev.workflow.would_sync_count", map[string]interface{}{"Count": synced}),
dimStyle.Render(i18n.T("cmd.dev.workflow.skipped_count", map[string]interface{}{"Count": skipped})))
cli.Text(i18n.T("cmd.dev.workflow.run_without_dry_run"))
} else {
cli.Print("%s %s\n",
successStyle.Render(i18n.T("cmd.dev.workflow.synced_count", map[string]interface{}{"Count": synced})),
dimStyle.Render(i18n.T("cmd.dev.workflow.skipped_count", map[string]interface{}{"Count": skipped})))
if failed > 0 {
cli.Print("%s\n", errorStyle.Render(i18n.T("cmd.dev.workflow.failed_count", map[string]interface{}{"Count": failed})))
}
}
return nil
}
// findWorkflows returns a list of workflow file names in a directory.
func findWorkflows(dir string) []string {
workflowsDir := filepath.Join(dir, ".github", "workflows")
// If dir already ends with workflows path, use it directly
if strings.HasSuffix(dir, "workflows") || strings.HasSuffix(dir, "workflow-templates") {
workflowsDir = dir
}
entries, err := io.Local.List(workflowsDir)
if err != nil {
return nil
}
var workflows []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
workflows = append(workflows, name)
}
}
return workflows
}
// findTemplateWorkflow finds a workflow template file in common locations.
func findTemplateWorkflow(registryDir, workflowFile string) string {
// Ensure .yml extension
if !strings.HasSuffix(workflowFile, ".yml") && !strings.HasSuffix(workflowFile, ".yaml") {
workflowFile = workflowFile + ".yml"
}
// Check common template locations
candidates := []string{
filepath.Join(registryDir, ".github", "workflow-templates", workflowFile),
filepath.Join(registryDir, ".github", "workflows", workflowFile),
filepath.Join(registryDir, "workflow-templates", workflowFile),
}
for _, candidate := range candidates {
if io.Local.IsFile(candidate) {
return candidate
}
}
return ""
}