cli/internal/cmd/dev/cmd_workflow.go
Snider abe74a1a3d refactor: split CLI from monorepo, import core/go as library (#1)
- Change module from forge.lthn.ai/core/go to forge.lthn.ai/core/cli
- Remove pkg/ directory (now served from core/go)
- Add require + replace for forge.lthn.ai/core/go => ../go
- Update go.work to include ../go workspace module
- Fix all internal/cmd/* imports: pkg/ refs → forge.lthn.ai/core/go/pkg/
- Rename internal/cmd/sdk package to sdkcmd (avoids conflict with pkg/sdk)
- Remove SDK library files from internal/cmd/sdk/ (now in core/go/pkg/sdk/)
- Remove duplicate RAG helper functions from internal/cmd/rag/
- Remove stale cmd/core-ide/ (now in core/ide repo)
- Update IDE variant to remove core-ide import
- Fix test assertion for new module name
- Run go mod tidy to sync dependencies

core/cli is now a pure CLI application importing core/go for packages.

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

Co-authored-by: Claude <developers@lethean.io>
Reviewed-on: #1
2026-02-16 14:24:37 +00:00

307 lines
8.1 KiB
Go

package dev
import (
"path/filepath"
"sort"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/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 ""
}