From cdf74d9f302e61be05e78d026232681ddb6b96e0 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 00:22:47 +0000 Subject: [PATCH] refactor(cmd): split command packages into smaller files Split all cmd/* packages for maintainability, following the pattern established in cmd/php. Each package now has: - Main file with styles (using cmd/shared) and Add*Commands function - Separate files for logical command groupings Packages refactored: - cmd/dev: 13 files (was 2779 lines in one file) - cmd/build: 5 files (was 913 lines) - cmd/setup: 6 files (was 961 lines) - cmd/go: 5 files (was 655 lines) - cmd/pkg: 5 files (was 634 lines) - cmd/vm: 4 files (was 717 lines) - cmd/ai: 5 files (was 800 lines) - cmd/docs: 5 files (was 379 lines) - cmd/doctor: 5 files (was 301 lines) - cmd/test: 3 files (was 429 lines) - cmd/ci: 5 files (was 272 lines) All packages now import shared styles from cmd/shared instead of redefining them locally. Co-Authored-By: Claude Opus 4.5 --- cmd/ai/agentic.go | 731 ----------------------- cmd/ai/ai.go | 68 +++ cmd/ai/ai_git.go | 267 +++++++++ cmd/ai/ai_tasks.go | 288 ++++++++++ cmd/ai/ai_updates.go | 134 +++++ cmd/build/build.go | 768 +------------------------ cmd/build/build_project.go | 369 ++++++++++++ cmd/build/build_pwa.go | 323 +++++++++++ cmd/build/build_sdk.go | 81 +++ cmd/build/commands.go | 6 + cmd/ci/ci_changelog.go | 31 + cmd/ci/ci_init.go | 71 +++ cmd/ci/ci_publish.go | 79 +++ cmd/ci/ci_release.go | 203 +------ cmd/ci/ci_version.go | 24 + cmd/dev/commands.go | 65 --- cmd/dev/dev.go | 615 ++++---------------- cmd/dev/{api.go => dev_api.go} | 8 +- cmd/dev/{ci.go => dev_ci.go} | 32 +- cmd/dev/{commit.go => dev_commit.go} | 35 +- cmd/dev/{health.go => dev_health.go} | 90 ++- cmd/dev/{impact.go => dev_impact.go} | 16 +- cmd/dev/{issues.go => dev_issues.go} | 35 +- cmd/dev/{pull.go => dev_pull.go} | 8 +- cmd/dev/{push.go => dev_push.go} | 11 +- cmd/dev/{reviews.go => dev_reviews.go} | 22 +- cmd/dev/{sync.go => dev_sync.go} | 4 +- cmd/dev/dev_vm.go | 504 ++++++++++++++++ cmd/dev/{work.go => dev_work.go} | 74 +-- cmd/docs/docs.go | 326 +---------- cmd/docs/list.go | 83 +++ cmd/docs/scan.go | 115 ++++ cmd/docs/sync.go | 147 +++++ cmd/doctor/checks.go | 95 +++ cmd/doctor/doctor.go | 193 +------ cmd/doctor/environment.go | 77 +++ cmd/doctor/install.go | 24 + cmd/go/go.go | 595 ------------------- cmd/go/go_format.go | 77 +++ cmd/go/go_test_cmd.go | 334 +++++++++++ cmd/go/go_tools.go | 207 +++++++ cmd/pkg/pkg.go | 580 ------------------- cmd/pkg/pkg_install.go | 155 +++++ cmd/pkg/pkg_manage.go | 252 ++++++++ cmd/pkg/pkg_search.go | 199 +++++++ cmd/setup/setup.go | 663 +-------------------- cmd/setup/setup_bootstrap.go | 165 ++++++ cmd/setup/setup_registry.go | 239 ++++++++ cmd/setup/setup_repo.go | 287 +++++++++ cmd/test/test.go | 363 +----------- cmd/test/test_output.go | 205 +++++++ cmd/test/test_runner.go | 142 +++++ cmd/vm/commands.go | 2 +- cmd/vm/container.go | 23 - cmd/vm/templates.go | 15 - cmd/vm/vm.go | 44 ++ 56 files changed, 5336 insertions(+), 5233 deletions(-) delete mode 100644 cmd/ai/agentic.go create mode 100644 cmd/ai/ai.go create mode 100644 cmd/ai/ai_git.go create mode 100644 cmd/ai/ai_tasks.go create mode 100644 cmd/ai/ai_updates.go create mode 100644 cmd/build/build_project.go create mode 100644 cmd/build/build_pwa.go create mode 100644 cmd/build/build_sdk.go create mode 100644 cmd/ci/ci_changelog.go create mode 100644 cmd/ci/ci_init.go create mode 100644 cmd/ci/ci_publish.go create mode 100644 cmd/ci/ci_version.go delete mode 100644 cmd/dev/commands.go rename cmd/dev/{api.go => dev_api.go} (62%) rename cmd/dev/{ci.go => dev_ci.go} (86%) rename cmd/dev/{commit.go => dev_commit.go} (75%) rename cmd/dev/{health.go => dev_health.go} (57%) rename cmd/dev/{impact.go => dev_impact.go} (91%) rename cmd/dev/{issues.go => dev_issues.go} (88%) rename cmd/dev/{pull.go => dev_pull.go} (93%) rename cmd/dev/{push.go => dev_push.go} (89%) rename cmd/dev/{reviews.go => dev_reviews.go} (90%) rename cmd/dev/{sync.go => dev_sync.go} (97%) create mode 100644 cmd/dev/dev_vm.go rename cmd/dev/{work.go => dev_work.go} (77%) create mode 100644 cmd/docs/list.go create mode 100644 cmd/docs/scan.go create mode 100644 cmd/docs/sync.go create mode 100644 cmd/doctor/checks.go create mode 100644 cmd/doctor/environment.go create mode 100644 cmd/doctor/install.go create mode 100644 cmd/go/go_format.go create mode 100644 cmd/go/go_test_cmd.go create mode 100644 cmd/go/go_tools.go create mode 100644 cmd/pkg/pkg_install.go create mode 100644 cmd/pkg/pkg_manage.go create mode 100644 cmd/pkg/pkg_search.go create mode 100644 cmd/setup/setup_bootstrap.go create mode 100644 cmd/setup/setup_registry.go create mode 100644 cmd/setup/setup_repo.go create mode 100644 cmd/test/test_output.go create mode 100644 cmd/test/test_runner.go create mode 100644 cmd/vm/vm.go diff --git a/cmd/ai/agentic.go b/cmd/ai/agentic.go deleted file mode 100644 index 21ce5eff..00000000 --- a/cmd/ai/agentic.go +++ /dev/null @@ -1,731 +0,0 @@ -// agentic.go implements task management commands for the core-agentic service. -// -// The agentic service provides a task queue for AI-assisted development. -// Tasks can be listed, claimed, updated, and completed through these commands. -// Git integration allows automatic commits and PR creation with task references. - -package ai - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "sort" - "strings" - "time" - - "github.com/charmbracelet/lipgloss" - "github.com/host-uk/core/cmd/shared" - "github.com/host-uk/core/pkg/agentic" - "github.com/leaanthony/clir" -) - -// Style aliases for shared styles -var ( - successStyle = shared.SuccessStyle - errorStyle = shared.ErrorStyle - dimStyle = shared.DimStyle - truncate = shared.Truncate - formatAge = shared.FormatAge -) - -var ( - taskIDStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#3b82f6")) // blue-500 - - taskTitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#e2e8f0")) // gray-200 - - taskPriorityHighStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ef4444")) // red-500 - - taskPriorityMediumStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#f59e0b")) // amber-500 - - taskPriorityLowStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#22c55e")) // green-500 - - taskStatusPendingStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) // gray-500 - - taskStatusInProgressStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#3b82f6")) // blue-500 - - taskStatusCompletedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#22c55e")) // green-500 - - taskStatusBlockedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ef4444")) // red-500 - - taskLabelStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#a78bfa")) // violet-400 -) - -// AddAgenticCommands adds the agentic task management commands to the dev command. -func AddAgenticCommands(parent *clir.Command) { - // core ai tasks - list available tasks - addTasksCommand(parent) - - // core ai task - show task details and claim - addTaskCommand(parent) - - // core ai task:update - update task - addTaskUpdateCommand(parent) - - // core ai task:complete - mark task complete - addTaskCompleteCommand(parent) - - // core ai task:commit - auto-commit with task reference - addTaskCommitCommand(parent) - - // core ai task:pr - create PR for task - addTaskPRCommand(parent) -} - -func addTasksCommand(parent *clir.Command) { - var status string - var priority string - var labels string - var limit int - var project string - - cmd := parent.NewSubCommand("tasks", "List available tasks from core-agentic") - cmd.LongDescription("Lists tasks from the core-agentic service.\n\n" + - "Configuration is loaded from:\n" + - " 1. Environment variables (AGENTIC_TOKEN, AGENTIC_BASE_URL)\n" + - " 2. .env file in current directory\n" + - " 3. ~/.core/agentic.yaml\n\n" + - "Examples:\n" + - " core ai tasks\n" + - " core ai tasks --status pending --priority high\n" + - " core ai tasks --labels bug,urgent") - - cmd.StringFlag("status", "Filter by status (pending, in_progress, completed, blocked)", &status) - cmd.StringFlag("priority", "Filter by priority (critical, high, medium, low)", &priority) - cmd.StringFlag("labels", "Filter by labels (comma-separated)", &labels) - cmd.IntFlag("limit", "Max number of tasks to return (default 20)", &limit) - cmd.StringFlag("project", "Filter by project", &project) - - cmd.Action(func() error { - if limit == 0 { - limit = 20 - } - - cfg, err := agentic.LoadConfig("") - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - client := agentic.NewClientFromConfig(cfg) - - opts := agentic.ListOptions{ - Limit: limit, - Project: project, - } - - if status != "" { - opts.Status = agentic.TaskStatus(status) - } - if priority != "" { - opts.Priority = agentic.TaskPriority(priority) - } - if labels != "" { - opts.Labels = strings.Split(labels, ",") - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - tasks, err := client.ListTasks(ctx, opts) - if err != nil { - return fmt.Errorf("failed to list tasks: %w", err) - } - - if len(tasks) == 0 { - fmt.Println("No tasks found.") - return nil - } - - printTaskList(tasks) - return nil - }) -} - -func addTaskCommand(parent *clir.Command) { - var autoSelect bool - var claim bool - var showContext bool - - cmd := parent.NewSubCommand("task", "Show task details or auto-select a task") - cmd.LongDescription("Shows details of a specific task or auto-selects the highest priority task.\n\n" + - "Examples:\n" + - " core ai task abc123 # Show task details\n" + - " core ai task abc123 --claim # Show and claim the task\n" + - " core ai task abc123 --context # Show task with gathered context\n" + - " core ai task --auto # Auto-select highest priority pending task") - - cmd.BoolFlag("auto", "Auto-select highest priority pending task", &autoSelect) - cmd.BoolFlag("claim", "Claim the task after showing details", &claim) - cmd.BoolFlag("context", "Show gathered context for AI collaboration", &showContext) - - cmd.Action(func() error { - cfg, err := agentic.LoadConfig("") - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - client := agentic.NewClientFromConfig(cfg) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - var task *agentic.Task - - // Get the task ID from remaining args - args := os.Args - var taskID string - - // Find the task ID in args (after "task" subcommand) - for i, arg := range args { - if arg == "task" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { - taskID = args[i+1] - break - } - } - - if autoSelect { - // Auto-select: find highest priority pending task - tasks, err := client.ListTasks(ctx, agentic.ListOptions{ - Status: agentic.StatusPending, - Limit: 50, - }) - if err != nil { - return fmt.Errorf("failed to list tasks: %w", err) - } - - if len(tasks) == 0 { - fmt.Println("No pending tasks available.") - return nil - } - - // Sort by priority (critical > high > medium > low) - priorityOrder := map[agentic.TaskPriority]int{ - agentic.PriorityCritical: 0, - agentic.PriorityHigh: 1, - agentic.PriorityMedium: 2, - agentic.PriorityLow: 3, - } - - sort.Slice(tasks, func(i, j int) bool { - return priorityOrder[tasks[i].Priority] < priorityOrder[tasks[j].Priority] - }) - - task = &tasks[0] - claim = true // Auto-select implies claiming - } else { - if taskID == "" { - return fmt.Errorf("task ID required (or use --auto)") - } - - task, err = client.GetTask(ctx, taskID) - if err != nil { - return fmt.Errorf("failed to get task: %w", err) - } - } - - // Show context if requested - if showContext { - cwd, _ := os.Getwd() - taskCtx, err := agentic.BuildTaskContext(task, cwd) - if err != nil { - fmt.Printf("%s Failed to build context: %s\n", errorStyle.Render(">>"), err) - } else { - fmt.Println(taskCtx.FormatContext()) - } - } else { - printTaskDetails(task) - } - - if claim && task.Status == agentic.StatusPending { - fmt.Println() - fmt.Printf("%s Claiming task...\n", dimStyle.Render(">>")) - - claimedTask, err := client.ClaimTask(ctx, task.ID) - if err != nil { - return fmt.Errorf("failed to claim task: %w", err) - } - - fmt.Printf("%s Task claimed successfully!\n", successStyle.Render(">>")) - fmt.Printf(" Status: %s\n", formatTaskStatus(claimedTask.Status)) - } - - return nil - }) -} - -func addTaskUpdateCommand(parent *clir.Command) { - var status string - var progress int - var notes string - - cmd := parent.NewSubCommand("task:update", "Update task status or progress") - cmd.LongDescription("Updates a task's status, progress, or adds notes.\n\n" + - "Examples:\n" + - " core ai task:update abc123 --status in_progress\n" + - " core ai task:update abc123 --progress 50 --notes 'Halfway done'") - - cmd.StringFlag("status", "New status (pending, in_progress, completed, blocked)", &status) - cmd.IntFlag("progress", "Progress percentage (0-100)", &progress) - cmd.StringFlag("notes", "Notes about the update", ¬es) - - cmd.Action(func() error { - // Find task ID from args - args := os.Args - var taskID string - for i, arg := range args { - if arg == "task:update" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { - taskID = args[i+1] - break - } - } - - if taskID == "" { - return fmt.Errorf("task ID required") - } - - if status == "" && progress == 0 && notes == "" { - return fmt.Errorf("at least one of --status, --progress, or --notes required") - } - - cfg, err := agentic.LoadConfig("") - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - client := agentic.NewClientFromConfig(cfg) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - update := agentic.TaskUpdate{ - Progress: progress, - Notes: notes, - } - if status != "" { - update.Status = agentic.TaskStatus(status) - } - - if err := client.UpdateTask(ctx, taskID, update); err != nil { - return fmt.Errorf("failed to update task: %w", err) - } - - fmt.Printf("%s Task %s updated successfully\n", successStyle.Render(">>"), taskID) - return nil - }) -} - -func addTaskCompleteCommand(parent *clir.Command) { - var output string - var failed bool - var errorMsg string - - cmd := parent.NewSubCommand("task:complete", "Mark a task as completed") - cmd.LongDescription("Marks a task as completed with optional output and artifacts.\n\n" + - "Examples:\n" + - " core ai task:complete abc123 --output 'Feature implemented'\n" + - " core ai task:complete abc123 --failed --error 'Build failed'") - - cmd.StringFlag("output", "Summary of the completed work", &output) - cmd.BoolFlag("failed", "Mark the task as failed", &failed) - cmd.StringFlag("error", "Error message if failed", &errorMsg) - - cmd.Action(func() error { - // Find task ID from args - args := os.Args - var taskID string - for i, arg := range args { - if arg == "task:complete" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { - taskID = args[i+1] - break - } - } - - if taskID == "" { - return fmt.Errorf("task ID required") - } - - cfg, err := agentic.LoadConfig("") - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - client := agentic.NewClientFromConfig(cfg) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - result := agentic.TaskResult{ - Success: !failed, - Output: output, - ErrorMessage: errorMsg, - } - - if err := client.CompleteTask(ctx, taskID, result); err != nil { - return fmt.Errorf("failed to complete task: %w", err) - } - - if failed { - fmt.Printf("%s Task %s marked as failed\n", errorStyle.Render(">>"), taskID) - } else { - fmt.Printf("%s Task %s completed successfully\n", successStyle.Render(">>"), taskID) - } - return nil - }) -} - -func printTaskList(tasks []agentic.Task) { - fmt.Printf("\n%d task(s) found:\n\n", len(tasks)) - - for _, task := range tasks { - id := taskIDStyle.Render(task.ID) - title := taskTitleStyle.Render(truncate(task.Title, 50)) - priority := formatTaskPriority(task.Priority) - status := formatTaskStatus(task.Status) - - line := fmt.Sprintf(" %s %s %s %s", id, priority, status, title) - - if len(task.Labels) > 0 { - labels := taskLabelStyle.Render("[" + strings.Join(task.Labels, ", ") + "]") - line += " " + labels - } - - fmt.Println(line) - } - - fmt.Println() - fmt.Printf("%s\n", dimStyle.Render("Use 'core ai task ' to view details")) -} - -func printTaskDetails(task *agentic.Task) { - fmt.Println() - fmt.Printf("%s %s\n", dimStyle.Render("ID:"), taskIDStyle.Render(task.ID)) - fmt.Printf("%s %s\n", dimStyle.Render("Title:"), taskTitleStyle.Render(task.Title)) - fmt.Printf("%s %s\n", dimStyle.Render("Priority:"), formatTaskPriority(task.Priority)) - fmt.Printf("%s %s\n", dimStyle.Render("Status:"), formatTaskStatus(task.Status)) - - if task.Project != "" { - fmt.Printf("%s %s\n", dimStyle.Render("Project:"), task.Project) - } - - if len(task.Labels) > 0 { - fmt.Printf("%s %s\n", dimStyle.Render("Labels:"), taskLabelStyle.Render(strings.Join(task.Labels, ", "))) - } - - if task.ClaimedBy != "" { - fmt.Printf("%s %s\n", dimStyle.Render("Claimed by:"), task.ClaimedBy) - } - - fmt.Printf("%s %s\n", dimStyle.Render("Created:"), formatAge(task.CreatedAt)) - - fmt.Println() - fmt.Printf("%s\n", dimStyle.Render("Description:")) - fmt.Println(task.Description) - - if len(task.Files) > 0 { - fmt.Println() - fmt.Printf("%s\n", dimStyle.Render("Related files:")) - for _, f := range task.Files { - fmt.Printf(" - %s\n", f) - } - } - - if len(task.Dependencies) > 0 { - fmt.Println() - fmt.Printf("%s %s\n", dimStyle.Render("Blocked by:"), strings.Join(task.Dependencies, ", ")) - } -} - -func formatTaskPriority(p agentic.TaskPriority) string { - switch p { - case agentic.PriorityCritical: - return taskPriorityHighStyle.Render("[CRITICAL]") - case agentic.PriorityHigh: - return taskPriorityHighStyle.Render("[HIGH]") - case agentic.PriorityMedium: - return taskPriorityMediumStyle.Render("[MEDIUM]") - case agentic.PriorityLow: - return taskPriorityLowStyle.Render("[LOW]") - default: - return dimStyle.Render("[" + string(p) + "]") - } -} - -func formatTaskStatus(s agentic.TaskStatus) string { - switch s { - case agentic.StatusPending: - return taskStatusPendingStyle.Render("pending") - case agentic.StatusInProgress: - return taskStatusInProgressStyle.Render("in_progress") - case agentic.StatusCompleted: - return taskStatusCompletedStyle.Render("completed") - case agentic.StatusBlocked: - return taskStatusBlockedStyle.Render("blocked") - default: - return dimStyle.Render(string(s)) - } -} - -func addTaskCommitCommand(parent *clir.Command) { - var message string - var scope string - var push bool - - cmd := parent.NewSubCommand("task:commit", "Auto-commit changes with task reference") - cmd.LongDescription("Creates a git commit with a task reference and co-author attribution.\n\n" + - "Commit message format:\n" + - " feat(scope): description\n" + - "\n" + - " Task: #123\n" + - " Co-Authored-By: Claude \n\n" + - "Examples:\n" + - " core ai task:commit abc123 --message 'add user authentication'\n" + - " core ai task:commit abc123 -m 'fix login bug' --scope auth\n" + - " core ai task:commit abc123 -m 'update docs' --push") - - cmd.StringFlag("message", "Commit message (without task reference)", &message) - cmd.StringFlag("m", "Commit message (short form)", &message) - cmd.StringFlag("scope", "Scope for the commit type (e.g., auth, api, ui)", &scope) - cmd.BoolFlag("push", "Push changes after committing", &push) - - cmd.Action(func() error { - // Find task ID from args - args := os.Args - var taskID string - for i, arg := range args { - if arg == "task:commit" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { - taskID = args[i+1] - break - } - } - - if taskID == "" { - return fmt.Errorf("task ID required") - } - - if message == "" { - return fmt.Errorf("commit message required (--message or -m)") - } - - cfg, err := agentic.LoadConfig("") - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - client := agentic.NewClientFromConfig(cfg) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Get task details - task, err := client.GetTask(ctx, taskID) - if err != nil { - return fmt.Errorf("failed to get task: %w", err) - } - - // Build commit message with optional scope - commitType := inferCommitType(task.Labels) - var fullMessage string - if scope != "" { - fullMessage = fmt.Sprintf("%s(%s): %s", commitType, scope, message) - } else { - fullMessage = fmt.Sprintf("%s: %s", commitType, message) - } - - // Get current directory - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Check for uncommitted changes - hasChanges, err := agentic.HasUncommittedChanges(ctx, cwd) - if err != nil { - return fmt.Errorf("failed to check git status: %w", err) - } - - if !hasChanges { - fmt.Println("No uncommitted changes to commit.") - return nil - } - - // Create commit - fmt.Printf("%s Creating commit for task %s...\n", dimStyle.Render(">>"), taskID) - if err := agentic.AutoCommit(ctx, task, cwd, fullMessage); err != nil { - return fmt.Errorf("failed to commit: %w", err) - } - - fmt.Printf("%s Committed: %s\n", successStyle.Render(">>"), fullMessage) - - // Push if requested - if push { - fmt.Printf("%s Pushing changes...\n", dimStyle.Render(">>")) - if err := agentic.PushChanges(ctx, cwd); err != nil { - return fmt.Errorf("failed to push: %w", err) - } - fmt.Printf("%s Changes pushed successfully\n", successStyle.Render(">>")) - } - - return nil - }) -} - -func addTaskPRCommand(parent *clir.Command) { - var title string - var draft bool - var labels string - var base string - - cmd := parent.NewSubCommand("task:pr", "Create a pull request for a task") - cmd.LongDescription("Creates a GitHub pull request linked to a task.\n\n" + - "Requires the GitHub CLI (gh) to be installed and authenticated.\n\n" + - "Examples:\n" + - " core ai task:pr abc123\n" + - " core ai task:pr abc123 --title 'Add authentication feature'\n" + - " core ai task:pr abc123 --draft --labels 'enhancement,needs-review'\n" + - " core ai task:pr abc123 --base develop") - - cmd.StringFlag("title", "PR title (defaults to task title)", &title) - cmd.BoolFlag("draft", "Create as draft PR", &draft) - cmd.StringFlag("labels", "Labels to add (comma-separated)", &labels) - cmd.StringFlag("base", "Base branch (defaults to main)", &base) - - cmd.Action(func() error { - // Find task ID from args - args := os.Args - var taskID string - for i, arg := range args { - if arg == "task:pr" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { - taskID = args[i+1] - break - } - } - - if taskID == "" { - return fmt.Errorf("task ID required") - } - - cfg, err := agentic.LoadConfig("") - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - client := agentic.NewClientFromConfig(cfg) - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - // Get task details - task, err := client.GetTask(ctx, taskID) - if err != nil { - return fmt.Errorf("failed to get task: %w", err) - } - - // Get current directory - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Check current branch - branch, err := agentic.GetCurrentBranch(ctx, cwd) - if err != nil { - return fmt.Errorf("failed to get current branch: %w", err) - } - - if branch == "main" || branch == "master" { - return fmt.Errorf("cannot create PR from %s branch; create a feature branch first", branch) - } - - // Push current branch - fmt.Printf("%s Pushing branch %s...\n", dimStyle.Render(">>"), branch) - if err := agentic.PushChanges(ctx, cwd); err != nil { - // Try setting upstream - if _, err := runGitCommand(cwd, "push", "-u", "origin", branch); err != nil { - return fmt.Errorf("failed to push branch: %w", err) - } - } - - // Build PR options - opts := agentic.PROptions{ - Title: title, - Draft: draft, - Base: base, - } - - if labels != "" { - opts.Labels = strings.Split(labels, ",") - } - - // Create PR - fmt.Printf("%s Creating pull request...\n", dimStyle.Render(">>")) - prURL, err := agentic.CreatePR(ctx, task, cwd, opts) - if err != nil { - return fmt.Errorf("failed to create PR: %w", err) - } - - fmt.Printf("%s Pull request created!\n", successStyle.Render(">>")) - fmt.Printf(" URL: %s\n", prURL) - - return nil - }) -} - -// inferCommitType infers the commit type from task labels. -func inferCommitType(labels []string) string { - for _, label := range labels { - switch strings.ToLower(label) { - case "bug", "bugfix", "fix": - return "fix" - case "docs", "documentation": - return "docs" - case "refactor", "refactoring": - return "refactor" - case "test", "tests", "testing": - return "test" - case "chore": - return "chore" - case "style": - return "style" - case "perf", "performance": - return "perf" - case "ci": - return "ci" - case "build": - return "build" - } - } - return "feat" -} - -// runGitCommand runs a git command in the specified directory. -func runGitCommand(dir string, args ...string) (string, error) { - cmd := exec.Command("git", args...) - cmd.Dir = dir - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - if stderr.Len() > 0 { - return "", fmt.Errorf("%w: %s", err, stderr.String()) - } - return "", err - } - - return stdout.String(), nil -} diff --git a/cmd/ai/ai.go b/cmd/ai/ai.go new file mode 100644 index 00000000..cb516e42 --- /dev/null +++ b/cmd/ai/ai.go @@ -0,0 +1,68 @@ +// ai.go defines styles and the AddAgenticCommands function for AI task management. + +package ai + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/cmd/shared" + "github.com/leaanthony/clir" +) + +// Style aliases from shared package +var ( + successStyle = shared.SuccessStyle + errorStyle = shared.ErrorStyle + dimStyle = shared.DimStyle + truncate = shared.Truncate + formatAge = shared.FormatAge +) + +// Task-specific styles +var ( + taskIDStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#3b82f6")) // blue-500 + + taskTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#e2e8f0")) // gray-200 + + taskPriorityHighStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ef4444")) // red-500 + + taskPriorityMediumStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f59e0b")) // amber-500 + + taskPriorityLowStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")) // green-500 + + taskStatusPendingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 + + taskStatusInProgressStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#3b82f6")) // blue-500 + + taskStatusCompletedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")) // green-500 + + taskStatusBlockedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ef4444")) // red-500 + + taskLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#a78bfa")) // violet-400 +) + +// AddAgenticCommands adds the agentic task management commands to the ai command. +func AddAgenticCommands(parent *clir.Command) { + // Task listing and viewing + addTasksCommand(parent) + addTaskCommand(parent) + + // Task updates + addTaskUpdateCommand(parent) + addTaskCompleteCommand(parent) + + // Git integration + addTaskCommitCommand(parent) + addTaskPRCommand(parent) +} diff --git a/cmd/ai/ai_git.go b/cmd/ai/ai_git.go new file mode 100644 index 00000000..111efc8a --- /dev/null +++ b/cmd/ai/ai_git.go @@ -0,0 +1,267 @@ +// ai_git.go implements git integration commands for task commits and PRs. + +package ai + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/host-uk/core/pkg/agentic" + "github.com/leaanthony/clir" +) + +func addTaskCommitCommand(parent *clir.Command) { + var message string + var scope string + var push bool + + cmd := parent.NewSubCommand("task:commit", "Auto-commit changes with task reference") + cmd.LongDescription("Creates a git commit with a task reference and co-author attribution.\n\n" + + "Commit message format:\n" + + " feat(scope): description\n" + + "\n" + + " Task: #123\n" + + " Co-Authored-By: Claude \n\n" + + "Examples:\n" + + " core ai task:commit abc123 --message 'add user authentication'\n" + + " core ai task:commit abc123 -m 'fix login bug' --scope auth\n" + + " core ai task:commit abc123 -m 'update docs' --push") + + cmd.StringFlag("message", "Commit message (without task reference)", &message) + cmd.StringFlag("m", "Commit message (short form)", &message) + cmd.StringFlag("scope", "Scope for the commit type (e.g., auth, api, ui)", &scope) + cmd.BoolFlag("push", "Push changes after committing", &push) + + cmd.Action(func() error { + // Find task ID from args + args := os.Args + var taskID string + for i, arg := range args { + if arg == "task:commit" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + taskID = args[i+1] + break + } + } + + if taskID == "" { + return fmt.Errorf("task ID required") + } + + if message == "" { + return fmt.Errorf("commit message required (--message or -m)") + } + + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Get task details + task, err := client.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("failed to get task: %w", err) + } + + // Build commit message with optional scope + commitType := inferCommitType(task.Labels) + var fullMessage string + if scope != "" { + fullMessage = fmt.Sprintf("%s(%s): %s", commitType, scope, message) + } else { + fullMessage = fmt.Sprintf("%s: %s", commitType, message) + } + + // Get current directory + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Check for uncommitted changes + hasChanges, err := agentic.HasUncommittedChanges(ctx, cwd) + if err != nil { + return fmt.Errorf("failed to check git status: %w", err) + } + + if !hasChanges { + fmt.Println("No uncommitted changes to commit.") + return nil + } + + // Create commit + fmt.Printf("%s Creating commit for task %s...\n", dimStyle.Render(">>"), taskID) + if err := agentic.AutoCommit(ctx, task, cwd, fullMessage); err != nil { + return fmt.Errorf("failed to commit: %w", err) + } + + fmt.Printf("%s Committed: %s\n", successStyle.Render(">>"), fullMessage) + + // Push if requested + if push { + fmt.Printf("%s Pushing changes...\n", dimStyle.Render(">>")) + if err := agentic.PushChanges(ctx, cwd); err != nil { + return fmt.Errorf("failed to push: %w", err) + } + fmt.Printf("%s Changes pushed successfully\n", successStyle.Render(">>")) + } + + return nil + }) +} + +func addTaskPRCommand(parent *clir.Command) { + var title string + var draft bool + var labels string + var base string + + cmd := parent.NewSubCommand("task:pr", "Create a pull request for a task") + cmd.LongDescription("Creates a GitHub pull request linked to a task.\n\n" + + "Requires the GitHub CLI (gh) to be installed and authenticated.\n\n" + + "Examples:\n" + + " core ai task:pr abc123\n" + + " core ai task:pr abc123 --title 'Add authentication feature'\n" + + " core ai task:pr abc123 --draft --labels 'enhancement,needs-review'\n" + + " core ai task:pr abc123 --base develop") + + cmd.StringFlag("title", "PR title (defaults to task title)", &title) + cmd.BoolFlag("draft", "Create as draft PR", &draft) + cmd.StringFlag("labels", "Labels to add (comma-separated)", &labels) + cmd.StringFlag("base", "Base branch (defaults to main)", &base) + + cmd.Action(func() error { + // Find task ID from args + args := os.Args + var taskID string + for i, arg := range args { + if arg == "task:pr" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + taskID = args[i+1] + break + } + } + + if taskID == "" { + return fmt.Errorf("task ID required") + } + + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Get task details + task, err := client.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("failed to get task: %w", err) + } + + // Get current directory + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Check current branch + branch, err := agentic.GetCurrentBranch(ctx, cwd) + if err != nil { + return fmt.Errorf("failed to get current branch: %w", err) + } + + if branch == "main" || branch == "master" { + return fmt.Errorf("cannot create PR from %s branch; create a feature branch first", branch) + } + + // Push current branch + fmt.Printf("%s Pushing branch %s...\n", dimStyle.Render(">>"), branch) + if err := agentic.PushChanges(ctx, cwd); err != nil { + // Try setting upstream + if _, err := runGitCommand(cwd, "push", "-u", "origin", branch); err != nil { + return fmt.Errorf("failed to push branch: %w", err) + } + } + + // Build PR options + opts := agentic.PROptions{ + Title: title, + Draft: draft, + Base: base, + } + + if labels != "" { + opts.Labels = strings.Split(labels, ",") + } + + // Create PR + fmt.Printf("%s Creating pull request...\n", dimStyle.Render(">>")) + prURL, err := agentic.CreatePR(ctx, task, cwd, opts) + if err != nil { + return fmt.Errorf("failed to create PR: %w", err) + } + + fmt.Printf("%s Pull request created!\n", successStyle.Render(">>")) + fmt.Printf(" URL: %s\n", prURL) + + return nil + }) +} + +// inferCommitType infers the commit type from task labels. +func inferCommitType(labels []string) string { + for _, label := range labels { + switch strings.ToLower(label) { + case "bug", "bugfix", "fix": + return "fix" + case "docs", "documentation": + return "docs" + case "refactor", "refactoring": + return "refactor" + case "test", "tests", "testing": + return "test" + case "chore": + return "chore" + case "style": + return "style" + case "perf", "performance": + return "perf" + case "ci": + return "ci" + case "build": + return "build" + } + } + return "feat" +} + +// runGitCommand runs a git command in the specified directory. +func runGitCommand(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return "", fmt.Errorf("%w: %s", err, stderr.String()) + } + return "", err + } + + return stdout.String(), nil +} diff --git a/cmd/ai/ai_tasks.go b/cmd/ai/ai_tasks.go new file mode 100644 index 00000000..3cea22bd --- /dev/null +++ b/cmd/ai/ai_tasks.go @@ -0,0 +1,288 @@ +// ai_tasks.go implements task listing and viewing commands. + +package ai + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/host-uk/core/pkg/agentic" + "github.com/leaanthony/clir" +) + +func addTasksCommand(parent *clir.Command) { + var status string + var priority string + var labels string + var limit int + var project string + + cmd := parent.NewSubCommand("tasks", "List available tasks from core-agentic") + cmd.LongDescription("Lists tasks from the core-agentic service.\n\n" + + "Configuration is loaded from:\n" + + " 1. Environment variables (AGENTIC_TOKEN, AGENTIC_BASE_URL)\n" + + " 2. .env file in current directory\n" + + " 3. ~/.core/agentic.yaml\n\n" + + "Examples:\n" + + " core ai tasks\n" + + " core ai tasks --status pending --priority high\n" + + " core ai tasks --labels bug,urgent") + + cmd.StringFlag("status", "Filter by status (pending, in_progress, completed, blocked)", &status) + cmd.StringFlag("priority", "Filter by priority (critical, high, medium, low)", &priority) + cmd.StringFlag("labels", "Filter by labels (comma-separated)", &labels) + cmd.IntFlag("limit", "Max number of tasks to return (default 20)", &limit) + cmd.StringFlag("project", "Filter by project", &project) + + cmd.Action(func() error { + if limit == 0 { + limit = 20 + } + + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + opts := agentic.ListOptions{ + Limit: limit, + Project: project, + } + + if status != "" { + opts.Status = agentic.TaskStatus(status) + } + if priority != "" { + opts.Priority = agentic.TaskPriority(priority) + } + if labels != "" { + opts.Labels = strings.Split(labels, ",") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tasks, err := client.ListTasks(ctx, opts) + if err != nil { + return fmt.Errorf("failed to list tasks: %w", err) + } + + if len(tasks) == 0 { + fmt.Println("No tasks found.") + return nil + } + + printTaskList(tasks) + return nil + }) +} + +func addTaskCommand(parent *clir.Command) { + var autoSelect bool + var claim bool + var showContext bool + + cmd := parent.NewSubCommand("task", "Show task details or auto-select a task") + cmd.LongDescription("Shows details of a specific task or auto-selects the highest priority task.\n\n" + + "Examples:\n" + + " core ai task abc123 # Show task details\n" + + " core ai task abc123 --claim # Show and claim the task\n" + + " core ai task abc123 --context # Show task with gathered context\n" + + " core ai task --auto # Auto-select highest priority pending task") + + cmd.BoolFlag("auto", "Auto-select highest priority pending task", &autoSelect) + cmd.BoolFlag("claim", "Claim the task after showing details", &claim) + cmd.BoolFlag("context", "Show gathered context for AI collaboration", &showContext) + + cmd.Action(func() error { + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var task *agentic.Task + + // Get the task ID from remaining args + args := os.Args + var taskID string + + // Find the task ID in args (after "task" subcommand) + for i, arg := range args { + if arg == "task" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + taskID = args[i+1] + break + } + } + + if autoSelect { + // Auto-select: find highest priority pending task + tasks, err := client.ListTasks(ctx, agentic.ListOptions{ + Status: agentic.StatusPending, + Limit: 50, + }) + if err != nil { + return fmt.Errorf("failed to list tasks: %w", err) + } + + if len(tasks) == 0 { + fmt.Println("No pending tasks available.") + return nil + } + + // Sort by priority (critical > high > medium > low) + priorityOrder := map[agentic.TaskPriority]int{ + agentic.PriorityCritical: 0, + agentic.PriorityHigh: 1, + agentic.PriorityMedium: 2, + agentic.PriorityLow: 3, + } + + sort.Slice(tasks, func(i, j int) bool { + return priorityOrder[tasks[i].Priority] < priorityOrder[tasks[j].Priority] + }) + + task = &tasks[0] + claim = true // Auto-select implies claiming + } else { + if taskID == "" { + return fmt.Errorf("task ID required (or use --auto)") + } + + task, err = client.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("failed to get task: %w", err) + } + } + + // Show context if requested + if showContext { + cwd, _ := os.Getwd() + taskCtx, err := agentic.BuildTaskContext(task, cwd) + if err != nil { + fmt.Printf("%s Failed to build context: %s\n", errorStyle.Render(">>"), err) + } else { + fmt.Println(taskCtx.FormatContext()) + } + } else { + printTaskDetails(task) + } + + if claim && task.Status == agentic.StatusPending { + fmt.Println() + fmt.Printf("%s Claiming task...\n", dimStyle.Render(">>")) + + claimedTask, err := client.ClaimTask(ctx, task.ID) + if err != nil { + return fmt.Errorf("failed to claim task: %w", err) + } + + fmt.Printf("%s Task claimed successfully!\n", successStyle.Render(">>")) + fmt.Printf(" Status: %s\n", formatTaskStatus(claimedTask.Status)) + } + + return nil + }) +} + +func printTaskList(tasks []agentic.Task) { + fmt.Printf("\n%d task(s) found:\n\n", len(tasks)) + + for _, task := range tasks { + id := taskIDStyle.Render(task.ID) + title := taskTitleStyle.Render(truncate(task.Title, 50)) + priority := formatTaskPriority(task.Priority) + status := formatTaskStatus(task.Status) + + line := fmt.Sprintf(" %s %s %s %s", id, priority, status, title) + + if len(task.Labels) > 0 { + labels := taskLabelStyle.Render("[" + strings.Join(task.Labels, ", ") + "]") + line += " " + labels + } + + fmt.Println(line) + } + + fmt.Println() + fmt.Printf("%s\n", dimStyle.Render("Use 'core ai task ' to view details")) +} + +func printTaskDetails(task *agentic.Task) { + fmt.Println() + fmt.Printf("%s %s\n", dimStyle.Render("ID:"), taskIDStyle.Render(task.ID)) + fmt.Printf("%s %s\n", dimStyle.Render("Title:"), taskTitleStyle.Render(task.Title)) + fmt.Printf("%s %s\n", dimStyle.Render("Priority:"), formatTaskPriority(task.Priority)) + fmt.Printf("%s %s\n", dimStyle.Render("Status:"), formatTaskStatus(task.Status)) + + if task.Project != "" { + fmt.Printf("%s %s\n", dimStyle.Render("Project:"), task.Project) + } + + if len(task.Labels) > 0 { + fmt.Printf("%s %s\n", dimStyle.Render("Labels:"), taskLabelStyle.Render(strings.Join(task.Labels, ", "))) + } + + if task.ClaimedBy != "" { + fmt.Printf("%s %s\n", dimStyle.Render("Claimed by:"), task.ClaimedBy) + } + + fmt.Printf("%s %s\n", dimStyle.Render("Created:"), formatAge(task.CreatedAt)) + + fmt.Println() + fmt.Printf("%s\n", dimStyle.Render("Description:")) + fmt.Println(task.Description) + + if len(task.Files) > 0 { + fmt.Println() + fmt.Printf("%s\n", dimStyle.Render("Related files:")) + for _, f := range task.Files { + fmt.Printf(" - %s\n", f) + } + } + + if len(task.Dependencies) > 0 { + fmt.Println() + fmt.Printf("%s %s\n", dimStyle.Render("Blocked by:"), strings.Join(task.Dependencies, ", ")) + } +} + +func formatTaskPriority(p agentic.TaskPriority) string { + switch p { + case agentic.PriorityCritical: + return taskPriorityHighStyle.Render("[CRITICAL]") + case agentic.PriorityHigh: + return taskPriorityHighStyle.Render("[HIGH]") + case agentic.PriorityMedium: + return taskPriorityMediumStyle.Render("[MEDIUM]") + case agentic.PriorityLow: + return taskPriorityLowStyle.Render("[LOW]") + default: + return dimStyle.Render("[" + string(p) + "]") + } +} + +func formatTaskStatus(s agentic.TaskStatus) string { + switch s { + case agentic.StatusPending: + return taskStatusPendingStyle.Render("pending") + case agentic.StatusInProgress: + return taskStatusInProgressStyle.Render("in_progress") + case agentic.StatusCompleted: + return taskStatusCompletedStyle.Render("completed") + case agentic.StatusBlocked: + return taskStatusBlockedStyle.Render("blocked") + default: + return dimStyle.Render(string(s)) + } +} diff --git a/cmd/ai/ai_updates.go b/cmd/ai/ai_updates.go new file mode 100644 index 00000000..4f1127b7 --- /dev/null +++ b/cmd/ai/ai_updates.go @@ -0,0 +1,134 @@ +// ai_updates.go implements task update and completion commands. + +package ai + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/host-uk/core/pkg/agentic" + "github.com/leaanthony/clir" +) + +func addTaskUpdateCommand(parent *clir.Command) { + var status string + var progress int + var notes string + + cmd := parent.NewSubCommand("task:update", "Update task status or progress") + cmd.LongDescription("Updates a task's status, progress, or adds notes.\n\n" + + "Examples:\n" + + " core ai task:update abc123 --status in_progress\n" + + " core ai task:update abc123 --progress 50 --notes 'Halfway done'") + + cmd.StringFlag("status", "New status (pending, in_progress, completed, blocked)", &status) + cmd.IntFlag("progress", "Progress percentage (0-100)", &progress) + cmd.StringFlag("notes", "Notes about the update", ¬es) + + cmd.Action(func() error { + // Find task ID from args + args := os.Args + var taskID string + for i, arg := range args { + if arg == "task:update" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + taskID = args[i+1] + break + } + } + + if taskID == "" { + return fmt.Errorf("task ID required") + } + + if status == "" && progress == 0 && notes == "" { + return fmt.Errorf("at least one of --status, --progress, or --notes required") + } + + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + update := agentic.TaskUpdate{ + Progress: progress, + Notes: notes, + } + if status != "" { + update.Status = agentic.TaskStatus(status) + } + + if err := client.UpdateTask(ctx, taskID, update); err != nil { + return fmt.Errorf("failed to update task: %w", err) + } + + fmt.Printf("%s Task %s updated successfully\n", successStyle.Render(">>"), taskID) + return nil + }) +} + +func addTaskCompleteCommand(parent *clir.Command) { + var output string + var failed bool + var errorMsg string + + cmd := parent.NewSubCommand("task:complete", "Mark a task as completed") + cmd.LongDescription("Marks a task as completed with optional output and artifacts.\n\n" + + "Examples:\n" + + " core ai task:complete abc123 --output 'Feature implemented'\n" + + " core ai task:complete abc123 --failed --error 'Build failed'") + + cmd.StringFlag("output", "Summary of the completed work", &output) + cmd.BoolFlag("failed", "Mark the task as failed", &failed) + cmd.StringFlag("error", "Error message if failed", &errorMsg) + + cmd.Action(func() error { + // Find task ID from args + args := os.Args + var taskID string + for i, arg := range args { + if arg == "task:complete" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + taskID = args[i+1] + break + } + } + + if taskID == "" { + return fmt.Errorf("task ID required") + } + + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := agentic.TaskResult{ + Success: !failed, + Output: output, + ErrorMessage: errorMsg, + } + + if err := client.CompleteTask(ctx, taskID, result); err != nil { + return fmt.Errorf("failed to complete task: %w", err) + } + + if failed { + fmt.Printf("%s Task %s marked as failed\n", errorStyle.Render(">>"), taskID) + } else { + fmt.Printf("%s Task %s completed successfully\n", successStyle.Render(">>"), taskID) + } + return nil + }) +} diff --git a/cmd/build/build.go b/cmd/build/build.go index 09d4ac54..dd03c39b 100644 --- a/cmd/build/build.go +++ b/cmd/build/build.go @@ -2,28 +2,10 @@ package build import ( - "context" "embed" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" "github.com/charmbracelet/lipgloss" - buildpkg "github.com/host-uk/core/pkg/build" - "github.com/host-uk/core/pkg/build/builders" - "github.com/host-uk/core/pkg/build/signing" - "github.com/host-uk/core/pkg/sdk" "github.com/leaanthony/clir" - "github.com/leaanthony/debme" - "github.com/leaanthony/gosod" - "golang.org/x/net/html" ) // Build command styles @@ -112,7 +94,7 @@ func AddBuildCommand(app *clir.Cli) { fromPathCmd.StringFlag("path", "The path to the static web application files.", &fromPath) fromPathCmd.Action(func() error { if fromPath == "" { - return fmt.Errorf("the --path flag is required") + return errPathRequired } return runBuild(fromPath) }) @@ -123,7 +105,7 @@ func AddBuildCommand(app *clir.Cli) { pwaCmd.StringFlag("url", "The URL of the PWA to build.", &pwaURL) pwaCmd.Action(func() error { if pwaURL == "" { - return fmt.Errorf("a URL argument is required") + return errURLRequired } return runPwaBuild(pwaURL) }) @@ -147,749 +129,3 @@ func AddBuildCommand(app *clir.Cli) { return runBuildSDK(sdkSpec, sdkLang, sdkVersion, sdkDryRun) }) } - -// runProjectBuild handles the main `core build` command with auto-detection. -func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool) error { - // Get current working directory as project root - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Load configuration from .core/build.yaml (or defaults) - buildCfg, err := buildpkg.LoadConfig(projectDir) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - // Detect project type if not specified - var projectType buildpkg.ProjectType - if buildType != "" { - projectType = buildpkg.ProjectType(buildType) - } else { - projectType, err = buildpkg.PrimaryType(projectDir) - if err != nil { - return fmt.Errorf("failed to detect project type: %w", err) - } - if projectType == "" { - return fmt.Errorf("no supported project type detected in %s\n"+ - "Supported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)", projectDir) - } - } - - // Determine targets - var buildTargets []buildpkg.Target - if targetsFlag != "" { - // Parse from command line - buildTargets, err = parseTargets(targetsFlag) - if err != nil { - return err - } - } else if len(buildCfg.Targets) > 0 { - // Use config targets - buildTargets = buildCfg.ToTargets() - } else { - // Fall back to current OS/arch - buildTargets = []buildpkg.Target{ - {OS: runtime.GOOS, Arch: runtime.GOARCH}, - } - } - - // Determine output directory - if outputDir == "" { - outputDir = "dist" - } - - // Determine binary name - binaryName := buildCfg.Project.Binary - if binaryName == "" { - binaryName = buildCfg.Project.Name - } - if binaryName == "" { - binaryName = filepath.Base(projectDir) - } - - // Print build info (unless CI mode) - if !ciMode { - fmt.Printf("%s Building project\n", buildHeaderStyle.Render("Build:")) - fmt.Printf(" Type: %s\n", buildTargetStyle.Render(string(projectType))) - fmt.Printf(" Output: %s\n", buildTargetStyle.Render(outputDir)) - fmt.Printf(" Binary: %s\n", buildTargetStyle.Render(binaryName)) - fmt.Printf(" Targets: %s\n", buildTargetStyle.Render(formatTargets(buildTargets))) - fmt.Println() - } - - // Get the appropriate builder - builder, err := getBuilder(projectType) - if err != nil { - return err - } - - // Create build config for the builder - cfg := &buildpkg.Config{ - ProjectDir: projectDir, - OutputDir: outputDir, - Name: binaryName, - Version: buildCfg.Project.Name, // Could be enhanced with git describe - LDFlags: buildCfg.Build.LDFlags, - // Docker/LinuxKit specific - Dockerfile: configPath, // Reuse for Dockerfile path - LinuxKitConfig: configPath, - Push: push, - Image: imageName, - } - - // Parse formats for LinuxKit - if format != "" { - cfg.Formats = strings.Split(format, ",") - } - - // Execute build - ctx := context.Background() - artifacts, err := builder.Build(ctx, cfg, buildTargets) - if err != nil { - if !ciMode { - fmt.Printf("%s Build failed: %v\n", buildErrorStyle.Render("Error:"), err) - } - return err - } - - if !ciMode { - fmt.Printf("%s Built %d artifact(s)\n", buildSuccessStyle.Render("Success:"), len(artifacts)) - fmt.Println() - for _, artifact := range artifacts { - relPath, err := filepath.Rel(projectDir, artifact.Path) - if err != nil { - relPath = artifact.Path - } - fmt.Printf(" %s %s %s\n", - buildSuccessStyle.Render("✓"), - buildTargetStyle.Render(relPath), - buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)), - ) - } - } - - // Sign macOS binaries if enabled - signCfg := buildCfg.Sign - if notarize { - signCfg.MacOS.Notarize = true - } - if noSign { - signCfg.Enabled = false - } - - if signCfg.Enabled && runtime.GOOS == "darwin" { - if !ciMode { - fmt.Println() - fmt.Printf("%s Signing binaries...\n", buildHeaderStyle.Render("Sign:")) - } - - // Convert buildpkg.Artifact to signing.Artifact - signingArtifacts := make([]signing.Artifact, len(artifacts)) - for i, a := range artifacts { - signingArtifacts[i] = signing.Artifact{Path: a.Path, OS: a.OS, Arch: a.Arch} - } - - if err := signing.SignBinaries(ctx, signCfg, signingArtifacts); err != nil { - if !ciMode { - fmt.Printf("%s Signing failed: %v\n", buildErrorStyle.Render("Error:"), err) - } - return err - } - - if signCfg.MacOS.Notarize { - if err := signing.NotarizeBinaries(ctx, signCfg, signingArtifacts); err != nil { - if !ciMode { - fmt.Printf("%s Notarization failed: %v\n", buildErrorStyle.Render("Error:"), err) - } - return err - } - } - } - - // Archive artifacts if enabled - var archivedArtifacts []buildpkg.Artifact - if doArchive && len(artifacts) > 0 { - if !ciMode { - fmt.Println() - fmt.Printf("%s Creating archives...\n", buildHeaderStyle.Render("Archive:")) - } - - archivedArtifacts, err = buildpkg.ArchiveAll(artifacts) - if err != nil { - if !ciMode { - fmt.Printf("%s Archive failed: %v\n", buildErrorStyle.Render("Error:"), err) - } - return err - } - - if !ciMode { - for _, artifact := range archivedArtifacts { - relPath, err := filepath.Rel(projectDir, artifact.Path) - if err != nil { - relPath = artifact.Path - } - fmt.Printf(" %s %s %s\n", - buildSuccessStyle.Render("✓"), - buildTargetStyle.Render(relPath), - buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)), - ) - } - } - } - - // Compute checksums if enabled - var checksummedArtifacts []buildpkg.Artifact - if doChecksum && len(archivedArtifacts) > 0 { - if !ciMode { - fmt.Println() - fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:")) - } - - checksummedArtifacts, err = buildpkg.ChecksumAll(archivedArtifacts) - if err != nil { - if !ciMode { - fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err) - } - return err - } - - // Write CHECKSUMS.txt - checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt") - if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil { - if !ciMode { - fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err) - } - return err - } - - // Sign checksums with GPG - if signCfg.Enabled { - if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil { - if !ciMode { - fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err) - } - return err - } - } - - if !ciMode { - for _, artifact := range checksummedArtifacts { - relPath, err := filepath.Rel(projectDir, artifact.Path) - if err != nil { - relPath = artifact.Path - } - fmt.Printf(" %s %s\n", - buildSuccessStyle.Render("✓"), - buildTargetStyle.Render(relPath), - ) - fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum)) - } - - relChecksumPath, err := filepath.Rel(projectDir, checksumPath) - if err != nil { - relChecksumPath = checksumPath - } - fmt.Printf(" %s %s\n", - buildSuccessStyle.Render("✓"), - buildTargetStyle.Render(relChecksumPath), - ) - } - } else if doChecksum && len(artifacts) > 0 && !doArchive { - // Checksum raw binaries if archiving is disabled - if !ciMode { - fmt.Println() - fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:")) - } - - checksummedArtifacts, err = buildpkg.ChecksumAll(artifacts) - if err != nil { - if !ciMode { - fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err) - } - return err - } - - // Write CHECKSUMS.txt - checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt") - if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil { - if !ciMode { - fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err) - } - return err - } - - // Sign checksums with GPG - if signCfg.Enabled { - if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil { - if !ciMode { - fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err) - } - return err - } - } - - if !ciMode { - for _, artifact := range checksummedArtifacts { - relPath, err := filepath.Rel(projectDir, artifact.Path) - if err != nil { - relPath = artifact.Path - } - fmt.Printf(" %s %s\n", - buildSuccessStyle.Render("✓"), - buildTargetStyle.Render(relPath), - ) - fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum)) - } - - relChecksumPath, err := filepath.Rel(projectDir, checksumPath) - if err != nil { - relChecksumPath = checksumPath - } - fmt.Printf(" %s %s\n", - buildSuccessStyle.Render("✓"), - buildTargetStyle.Render(relChecksumPath), - ) - } - } - - // Output results for CI mode - if ciMode { - // Determine which artifacts to output (prefer checksummed > archived > raw) - var outputArtifacts []buildpkg.Artifact - if len(checksummedArtifacts) > 0 { - outputArtifacts = checksummedArtifacts - } else if len(archivedArtifacts) > 0 { - outputArtifacts = archivedArtifacts - } else { - outputArtifacts = artifacts - } - - // JSON output for CI - output, err := json.MarshalIndent(outputArtifacts, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal artifacts: %w", err) - } - fmt.Println(string(output)) - } - - return nil -} - -// parseTargets parses a comma-separated list of OS/arch pairs. -func parseTargets(targetsFlag string) ([]buildpkg.Target, error) { - parts := strings.Split(targetsFlag, ",") - var targets []buildpkg.Target - - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - - osArch := strings.Split(part, "/") - if len(osArch) != 2 { - return nil, fmt.Errorf("invalid target format %q, expected OS/arch (e.g., linux/amd64)", part) - } - - targets = append(targets, buildpkg.Target{ - OS: strings.TrimSpace(osArch[0]), - Arch: strings.TrimSpace(osArch[1]), - }) - } - - if len(targets) == 0 { - return nil, fmt.Errorf("no valid targets specified") - } - - return targets, nil -} - -// formatTargets returns a human-readable string of targets. -func formatTargets(targets []buildpkg.Target) string { - var parts []string - for _, t := range targets { - parts = append(parts, t.String()) - } - return strings.Join(parts, ", ") -} - -// getBuilder returns the appropriate builder for the project type. -func getBuilder(projectType buildpkg.ProjectType) (buildpkg.Builder, error) { - switch projectType { - case buildpkg.ProjectTypeWails: - return builders.NewWailsBuilder(), nil - case buildpkg.ProjectTypeGo: - return builders.NewGoBuilder(), nil - case buildpkg.ProjectTypeDocker: - return builders.NewDockerBuilder(), nil - case buildpkg.ProjectTypeLinuxKit: - return builders.NewLinuxKitBuilder(), nil - case buildpkg.ProjectTypeTaskfile: - return builders.NewTaskfileBuilder(), nil - case buildpkg.ProjectTypeNode: - return nil, fmt.Errorf("Node.js builder not yet implemented") - case buildpkg.ProjectTypePHP: - return nil, fmt.Errorf("PHP builder not yet implemented") - default: - return nil, fmt.Errorf("unsupported project type: %s", projectType) - } -} - -// --- SDK Build Logic --- - -func runBuildSDK(specPath, lang, version string, dryRun bool) error { - ctx := context.Background() - - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Load config - config := sdk.DefaultConfig() - if specPath != "" { - config.Spec = specPath - } - - s := sdk.New(projectDir, config) - if version != "" { - s.SetVersion(version) - } - - fmt.Printf("%s Generating SDKs\n", buildHeaderStyle.Render("Build SDK:")) - if dryRun { - fmt.Printf(" %s\n", buildDimStyle.Render("(dry-run mode)")) - } - fmt.Println() - - // Detect spec - detectedSpec, err := s.DetectSpec() - if err != nil { - fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err) - return err - } - fmt.Printf(" Spec: %s\n", buildTargetStyle.Render(detectedSpec)) - - if dryRun { - if lang != "" { - fmt.Printf(" Language: %s\n", buildTargetStyle.Render(lang)) - } else { - fmt.Printf(" Languages: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", "))) - } - fmt.Println() - fmt.Printf("%s Would generate SDKs (dry-run)\n", buildSuccessStyle.Render("OK:")) - return nil - } - - if lang != "" { - // Generate single language - if err := s.GenerateLanguage(ctx, lang); err != nil { - fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err) - return err - } - fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(lang)) - } else { - // Generate all - if err := s.Generate(ctx); err != nil { - fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err) - return err - } - fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", "))) - } - - fmt.Println() - fmt.Printf("%s SDK generation complete\n", buildSuccessStyle.Render("Success:")) - return nil -} - -// --- PWA Build Logic --- - -func runPwaBuild(pwaURL string) error { - fmt.Printf("Starting PWA build from URL: %s\n", pwaURL) - - tempDir, err := os.MkdirTemp("", "core-pwa-build-*") - if err != nil { - return fmt.Errorf("failed to create temporary directory: %w", err) - } - // defer os.RemoveAll(tempDir) // Keep temp dir for debugging - fmt.Printf("Downloading PWA to temporary directory: %s\n", tempDir) - - if err := downloadPWA(pwaURL, tempDir); err != nil { - return fmt.Errorf("failed to download PWA: %w", err) - } - - return runBuild(tempDir) -} - -func downloadPWA(baseURL, destDir string) error { - // Fetch the main HTML page - resp, err := http.Get(baseURL) - if err != nil { - return fmt.Errorf("failed to fetch URL %s: %w", baseURL, err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - // Find the manifest URL from the HTML - manifestURL, err := findManifestURL(string(body), baseURL) - if err != nil { - // If no manifest, it's not a PWA, but we can still try to package it as a simple site. - fmt.Println("Warning: no manifest file found. Proceeding with basic site download.") - if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil { - return fmt.Errorf("failed to write index.html: %w", err) - } - return nil - } - - fmt.Printf("Found manifest: %s\n", manifestURL) - - // Fetch and parse the manifest - manifest, err := fetchManifest(manifestURL) - if err != nil { - return fmt.Errorf("failed to fetch or parse manifest: %w", err) - } - - // Download all assets listed in the manifest - assets := collectAssets(manifest, manifestURL) - for _, assetURL := range assets { - if err := downloadAsset(assetURL, destDir); err != nil { - fmt.Printf("Warning: failed to download asset %s: %v\n", assetURL, err) - } - } - - // Also save the root index.html - if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil { - return fmt.Errorf("failed to write index.html: %w", err) - } - - fmt.Println("PWA download complete.") - return nil -} - -func findManifestURL(htmlContent, baseURL string) (string, error) { - doc, err := html.Parse(strings.NewReader(htmlContent)) - if err != nil { - return "", err - } - - var manifestPath string - var f func(*html.Node) - f = func(n *html.Node) { - if n.Type == html.ElementNode && n.Data == "link" { - var rel, href string - for _, a := range n.Attr { - if a.Key == "rel" { - rel = a.Val - } - if a.Key == "href" { - href = a.Val - } - } - if rel == "manifest" && href != "" { - manifestPath = href - return - } - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - f(c) - } - } - f(doc) - - if manifestPath == "" { - return "", fmt.Errorf("no tag found") - } - - base, err := url.Parse(baseURL) - if err != nil { - return "", err - } - - manifestURL, err := base.Parse(manifestPath) - if err != nil { - return "", err - } - - return manifestURL.String(), nil -} - -func fetchManifest(manifestURL string) (map[string]interface{}, error) { - resp, err := http.Get(manifestURL) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var manifest map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { - return nil, err - } - return manifest, nil -} - -func collectAssets(manifest map[string]interface{}, manifestURL string) []string { - var assets []string - base, _ := url.Parse(manifestURL) - - // Add start_url - if startURL, ok := manifest["start_url"].(string); ok { - if resolved, err := base.Parse(startURL); err == nil { - assets = append(assets, resolved.String()) - } - } - - // Add icons - if icons, ok := manifest["icons"].([]interface{}); ok { - for _, icon := range icons { - if iconMap, ok := icon.(map[string]interface{}); ok { - if src, ok := iconMap["src"].(string); ok { - if resolved, err := base.Parse(src); err == nil { - assets = append(assets, resolved.String()) - } - } - } - } - } - - return assets -} - -func downloadAsset(assetURL, destDir string) error { - resp, err := http.Get(assetURL) - if err != nil { - return err - } - defer resp.Body.Close() - - u, err := url.Parse(assetURL) - if err != nil { - return err - } - - path := filepath.Join(destDir, filepath.FromSlash(u.Path)) - if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { - return err - } - - out, err := os.Create(path) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, resp.Body) - return err -} - -// --- Standard Build Logic --- - -func runBuild(fromPath string) error { - fmt.Printf("Starting build from path: %s\n", fromPath) - - info, err := os.Stat(fromPath) - if err != nil { - return fmt.Errorf("invalid path specified: %w", err) - } - if !info.IsDir() { - return fmt.Errorf("path specified must be a directory") - } - - buildDir := ".core/build/app" - htmlDir := filepath.Join(buildDir, "html") - appName := filepath.Base(fromPath) - if strings.HasPrefix(appName, "core-pwa-build-") { - appName = "pwa-app" - } - outputExe := appName - - if err := os.RemoveAll(buildDir); err != nil { - return fmt.Errorf("failed to clean build directory: %w", err) - } - - // 1. Generate the project from the embedded template - fmt.Println("Generating application from template...") - templateFS, err := debme.FS(guiTemplate, "tmpl/gui") - if err != nil { - return fmt.Errorf("failed to anchor template filesystem: %w", err) - } - sod := gosod.New(templateFS) - if sod == nil { - return fmt.Errorf("failed to create new sod instance") - } - - templateData := map[string]string{"AppName": appName} - if err := sod.Extract(buildDir, templateData); err != nil { - return fmt.Errorf("failed to extract template: %w", err) - } - - // 2. Copy the user's web app files - fmt.Println("Copying application files...") - if err := copyDir(fromPath, htmlDir); err != nil { - return fmt.Errorf("failed to copy application files: %w", err) - } - - // 3. Compile the application - fmt.Println("Compiling application...") - - // Run go mod tidy - cmd := exec.Command("go", "mod", "tidy") - cmd.Dir = buildDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("go mod tidy failed: %w", err) - } - - // Run go build - cmd = exec.Command("go", "build", "-o", outputExe) - cmd.Dir = buildDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("go build failed: %w", err) - } - - fmt.Printf("\nBuild successful! Executable created at: %s/%s\n", buildDir, outputExe) - return nil -} - -// copyDir recursively copies a directory from src to dst. -func copyDir(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - relPath, err := filepath.Rel(src, path) - if err != nil { - return err - } - - dstPath := filepath.Join(dst, relPath) - - if info.IsDir() { - return os.MkdirAll(dstPath, info.Mode()) - } - - srcFile, err := os.Open(path) - if err != nil { - return err - } - defer srcFile.Close() - - dstFile, err := os.Create(dstPath) - if err != nil { - return err - } - defer dstFile.Close() - - _, err = io.Copy(dstFile, srcFile) - return err - }) -} diff --git a/cmd/build/build_project.go b/cmd/build/build_project.go new file mode 100644 index 00000000..b5e7d394 --- /dev/null +++ b/cmd/build/build_project.go @@ -0,0 +1,369 @@ +// build_project.go implements the main project build logic. +// +// This handles auto-detection of project types (Go, Wails, Docker, LinuxKit, Taskfile) +// and orchestrates the build process including signing, archiving, and checksums. + +package build + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + buildpkg "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/build/builders" + "github.com/host-uk/core/pkg/build/signing" +) + +// runProjectBuild handles the main `core build` command with auto-detection. +func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool) error { + // Get current working directory as project root + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Load configuration from .core/build.yaml (or defaults) + buildCfg, err := buildpkg.LoadConfig(projectDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Detect project type if not specified + var projectType buildpkg.ProjectType + if buildType != "" { + projectType = buildpkg.ProjectType(buildType) + } else { + projectType, err = buildpkg.PrimaryType(projectDir) + if err != nil { + return fmt.Errorf("failed to detect project type: %w", err) + } + if projectType == "" { + return fmt.Errorf("no supported project type detected in %s\n"+ + "Supported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)", projectDir) + } + } + + // Determine targets + var buildTargets []buildpkg.Target + if targetsFlag != "" { + // Parse from command line + buildTargets, err = parseTargets(targetsFlag) + if err != nil { + return err + } + } else if len(buildCfg.Targets) > 0 { + // Use config targets + buildTargets = buildCfg.ToTargets() + } else { + // Fall back to current OS/arch + buildTargets = []buildpkg.Target{ + {OS: runtime.GOOS, Arch: runtime.GOARCH}, + } + } + + // Determine output directory + if outputDir == "" { + outputDir = "dist" + } + + // Determine binary name + binaryName := buildCfg.Project.Binary + if binaryName == "" { + binaryName = buildCfg.Project.Name + } + if binaryName == "" { + binaryName = filepath.Base(projectDir) + } + + // Print build info (unless CI mode) + if !ciMode { + fmt.Printf("%s Building project\n", buildHeaderStyle.Render("Build:")) + fmt.Printf(" Type: %s\n", buildTargetStyle.Render(string(projectType))) + fmt.Printf(" Output: %s\n", buildTargetStyle.Render(outputDir)) + fmt.Printf(" Binary: %s\n", buildTargetStyle.Render(binaryName)) + fmt.Printf(" Targets: %s\n", buildTargetStyle.Render(formatTargets(buildTargets))) + fmt.Println() + } + + // Get the appropriate builder + builder, err := getBuilder(projectType) + if err != nil { + return err + } + + // Create build config for the builder + cfg := &buildpkg.Config{ + ProjectDir: projectDir, + OutputDir: outputDir, + Name: binaryName, + Version: buildCfg.Project.Name, // Could be enhanced with git describe + LDFlags: buildCfg.Build.LDFlags, + // Docker/LinuxKit specific + Dockerfile: configPath, // Reuse for Dockerfile path + LinuxKitConfig: configPath, + Push: push, + Image: imageName, + } + + // Parse formats for LinuxKit + if format != "" { + cfg.Formats = strings.Split(format, ",") + } + + // Execute build + ctx := context.Background() + artifacts, err := builder.Build(ctx, cfg, buildTargets) + if err != nil { + if !ciMode { + fmt.Printf("%s Build failed: %v\n", buildErrorStyle.Render("Error:"), err) + } + return err + } + + if !ciMode { + fmt.Printf("%s Built %d artifact(s)\n", buildSuccessStyle.Render("Success:"), len(artifacts)) + fmt.Println() + for _, artifact := range artifacts { + relPath, err := filepath.Rel(projectDir, artifact.Path) + if err != nil { + relPath = artifact.Path + } + fmt.Printf(" %s %s %s\n", + buildSuccessStyle.Render("*"), + buildTargetStyle.Render(relPath), + buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)), + ) + } + } + + // Sign macOS binaries if enabled + signCfg := buildCfg.Sign + if notarize { + signCfg.MacOS.Notarize = true + } + if noSign { + signCfg.Enabled = false + } + + if signCfg.Enabled && runtime.GOOS == "darwin" { + if !ciMode { + fmt.Println() + fmt.Printf("%s Signing binaries...\n", buildHeaderStyle.Render("Sign:")) + } + + // Convert buildpkg.Artifact to signing.Artifact + signingArtifacts := make([]signing.Artifact, len(artifacts)) + for i, a := range artifacts { + signingArtifacts[i] = signing.Artifact{Path: a.Path, OS: a.OS, Arch: a.Arch} + } + + if err := signing.SignBinaries(ctx, signCfg, signingArtifacts); err != nil { + if !ciMode { + fmt.Printf("%s Signing failed: %v\n", buildErrorStyle.Render("Error:"), err) + } + return err + } + + if signCfg.MacOS.Notarize { + if err := signing.NotarizeBinaries(ctx, signCfg, signingArtifacts); err != nil { + if !ciMode { + fmt.Printf("%s Notarization failed: %v\n", buildErrorStyle.Render("Error:"), err) + } + return err + } + } + } + + // Archive artifacts if enabled + var archivedArtifacts []buildpkg.Artifact + if doArchive && len(artifacts) > 0 { + if !ciMode { + fmt.Println() + fmt.Printf("%s Creating archives...\n", buildHeaderStyle.Render("Archive:")) + } + + archivedArtifacts, err = buildpkg.ArchiveAll(artifacts) + if err != nil { + if !ciMode { + fmt.Printf("%s Archive failed: %v\n", buildErrorStyle.Render("Error:"), err) + } + return err + } + + if !ciMode { + for _, artifact := range archivedArtifacts { + relPath, err := filepath.Rel(projectDir, artifact.Path) + if err != nil { + relPath = artifact.Path + } + fmt.Printf(" %s %s %s\n", + buildSuccessStyle.Render("*"), + buildTargetStyle.Render(relPath), + buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)), + ) + } + } + } + + // Compute checksums if enabled + var checksummedArtifacts []buildpkg.Artifact + if doChecksum && len(archivedArtifacts) > 0 { + checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, archivedArtifacts, signCfg, ciMode) + if err != nil { + return err + } + } else if doChecksum && len(artifacts) > 0 && !doArchive { + // Checksum raw binaries if archiving is disabled + checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, artifacts, signCfg, ciMode) + if err != nil { + return err + } + } + + // Output results for CI mode + if ciMode { + // Determine which artifacts to output (prefer checksummed > archived > raw) + var outputArtifacts []buildpkg.Artifact + if len(checksummedArtifacts) > 0 { + outputArtifacts = checksummedArtifacts + } else if len(archivedArtifacts) > 0 { + outputArtifacts = archivedArtifacts + } else { + outputArtifacts = artifacts + } + + // JSON output for CI + output, err := json.MarshalIndent(outputArtifacts, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal artifacts: %w", err) + } + fmt.Println(string(output)) + } + + return nil +} + +// computeAndWriteChecksums computes checksums for artifacts and writes CHECKSUMS.txt. +func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, artifacts []buildpkg.Artifact, signCfg signing.SignConfig, ciMode bool) ([]buildpkg.Artifact, error) { + if !ciMode { + fmt.Println() + fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:")) + } + + checksummedArtifacts, err := buildpkg.ChecksumAll(artifacts) + if err != nil { + if !ciMode { + fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err) + } + return nil, err + } + + // Write CHECKSUMS.txt + checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt") + if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil { + if !ciMode { + fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err) + } + return nil, err + } + + // Sign checksums with GPG + if signCfg.Enabled { + if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil { + if !ciMode { + fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err) + } + return nil, err + } + } + + if !ciMode { + for _, artifact := range checksummedArtifacts { + relPath, err := filepath.Rel(projectDir, artifact.Path) + if err != nil { + relPath = artifact.Path + } + fmt.Printf(" %s %s\n", + buildSuccessStyle.Render("*"), + buildTargetStyle.Render(relPath), + ) + fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum)) + } + + relChecksumPath, err := filepath.Rel(projectDir, checksumPath) + if err != nil { + relChecksumPath = checksumPath + } + fmt.Printf(" %s %s\n", + buildSuccessStyle.Render("*"), + buildTargetStyle.Render(relChecksumPath), + ) + } + + return checksummedArtifacts, nil +} + +// parseTargets parses a comma-separated list of OS/arch pairs. +func parseTargets(targetsFlag string) ([]buildpkg.Target, error) { + parts := strings.Split(targetsFlag, ",") + var targets []buildpkg.Target + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + osArch := strings.Split(part, "/") + if len(osArch) != 2 { + return nil, fmt.Errorf("invalid target format %q, expected OS/arch (e.g., linux/amd64)", part) + } + + targets = append(targets, buildpkg.Target{ + OS: strings.TrimSpace(osArch[0]), + Arch: strings.TrimSpace(osArch[1]), + }) + } + + if len(targets) == 0 { + return nil, fmt.Errorf("no valid targets specified") + } + + return targets, nil +} + +// formatTargets returns a human-readable string of targets. +func formatTargets(targets []buildpkg.Target) string { + var parts []string + for _, t := range targets { + parts = append(parts, t.String()) + } + return strings.Join(parts, ", ") +} + +// getBuilder returns the appropriate builder for the project type. +func getBuilder(projectType buildpkg.ProjectType) (buildpkg.Builder, error) { + switch projectType { + case buildpkg.ProjectTypeWails: + return builders.NewWailsBuilder(), nil + case buildpkg.ProjectTypeGo: + return builders.NewGoBuilder(), nil + case buildpkg.ProjectTypeDocker: + return builders.NewDockerBuilder(), nil + case buildpkg.ProjectTypeLinuxKit: + return builders.NewLinuxKitBuilder(), nil + case buildpkg.ProjectTypeTaskfile: + return builders.NewTaskfileBuilder(), nil + case buildpkg.ProjectTypeNode: + return nil, fmt.Errorf("Node.js builder not yet implemented") + case buildpkg.ProjectTypePHP: + return nil, fmt.Errorf("PHP builder not yet implemented") + default: + return nil, fmt.Errorf("unsupported project type: %s", projectType) + } +} diff --git a/cmd/build/build_pwa.go b/cmd/build/build_pwa.go new file mode 100644 index 00000000..6c22fe49 --- /dev/null +++ b/cmd/build/build_pwa.go @@ -0,0 +1,323 @@ +// build_pwa.go implements PWA and legacy GUI build functionality. +// +// Supports building desktop applications from: +// - Local static web application directories +// - Live PWA URLs (downloads and packages) + +package build + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/leaanthony/debme" + "github.com/leaanthony/gosod" + "golang.org/x/net/html" +) + +// Error sentinels for build commands +var ( + errPathRequired = errors.New("the --path flag is required") + errURLRequired = errors.New("a URL argument is required") +) + +// runPwaBuild downloads a PWA from URL and builds it. +func runPwaBuild(pwaURL string) error { + fmt.Printf("Starting PWA build from URL: %s\n", pwaURL) + + tempDir, err := os.MkdirTemp("", "core-pwa-build-*") + if err != nil { + return fmt.Errorf("failed to create temporary directory: %w", err) + } + // defer os.RemoveAll(tempDir) // Keep temp dir for debugging + fmt.Printf("Downloading PWA to temporary directory: %s\n", tempDir) + + if err := downloadPWA(pwaURL, tempDir); err != nil { + return fmt.Errorf("failed to download PWA: %w", err) + } + + return runBuild(tempDir) +} + +// downloadPWA fetches a PWA from a URL and saves assets locally. +func downloadPWA(baseURL, destDir string) error { + // Fetch the main HTML page + resp, err := http.Get(baseURL) + if err != nil { + return fmt.Errorf("failed to fetch URL %s: %w", baseURL, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Find the manifest URL from the HTML + manifestURL, err := findManifestURL(string(body), baseURL) + if err != nil { + // If no manifest, it's not a PWA, but we can still try to package it as a simple site. + fmt.Println("Warning: no manifest file found. Proceeding with basic site download.") + if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil { + return fmt.Errorf("failed to write index.html: %w", err) + } + return nil + } + + fmt.Printf("Found manifest: %s\n", manifestURL) + + // Fetch and parse the manifest + manifest, err := fetchManifest(manifestURL) + if err != nil { + return fmt.Errorf("failed to fetch or parse manifest: %w", err) + } + + // Download all assets listed in the manifest + assets := collectAssets(manifest, manifestURL) + for _, assetURL := range assets { + if err := downloadAsset(assetURL, destDir); err != nil { + fmt.Printf("Warning: failed to download asset %s: %v\n", assetURL, err) + } + } + + // Also save the root index.html + if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil { + return fmt.Errorf("failed to write index.html: %w", err) + } + + fmt.Println("PWA download complete.") + return nil +} + +// findManifestURL extracts the manifest URL from HTML content. +func findManifestURL(htmlContent, baseURL string) (string, error) { + doc, err := html.Parse(strings.NewReader(htmlContent)) + if err != nil { + return "", err + } + + var manifestPath string + var f func(*html.Node) + f = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "link" { + var rel, href string + for _, a := range n.Attr { + if a.Key == "rel" { + rel = a.Val + } + if a.Key == "href" { + href = a.Val + } + } + if rel == "manifest" && href != "" { + manifestPath = href + return + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + f(c) + } + } + f(doc) + + if manifestPath == "" { + return "", fmt.Errorf("no tag found") + } + + base, err := url.Parse(baseURL) + if err != nil { + return "", err + } + + manifestURL, err := base.Parse(manifestPath) + if err != nil { + return "", err + } + + return manifestURL.String(), nil +} + +// fetchManifest downloads and parses a PWA manifest. +func fetchManifest(manifestURL string) (map[string]interface{}, error) { + resp, err := http.Get(manifestURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var manifest map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { + return nil, err + } + return manifest, nil +} + +// collectAssets extracts asset URLs from a PWA manifest. +func collectAssets(manifest map[string]interface{}, manifestURL string) []string { + var assets []string + base, _ := url.Parse(manifestURL) + + // Add start_url + if startURL, ok := manifest["start_url"].(string); ok { + if resolved, err := base.Parse(startURL); err == nil { + assets = append(assets, resolved.String()) + } + } + + // Add icons + if icons, ok := manifest["icons"].([]interface{}); ok { + for _, icon := range icons { + if iconMap, ok := icon.(map[string]interface{}); ok { + if src, ok := iconMap["src"].(string); ok { + if resolved, err := base.Parse(src); err == nil { + assets = append(assets, resolved.String()) + } + } + } + } + } + + return assets +} + +// downloadAsset fetches a single asset and saves it locally. +func downloadAsset(assetURL, destDir string) error { + resp, err := http.Get(assetURL) + if err != nil { + return err + } + defer resp.Body.Close() + + u, err := url.Parse(assetURL) + if err != nil { + return err + } + + path := filepath.Join(destDir, filepath.FromSlash(u.Path)) + if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return err + } + + out, err := os.Create(path) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + return err +} + +// runBuild builds a desktop application from a local directory. +func runBuild(fromPath string) error { + fmt.Printf("Starting build from path: %s\n", fromPath) + + info, err := os.Stat(fromPath) + if err != nil { + return fmt.Errorf("invalid path specified: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("path specified must be a directory") + } + + buildDir := ".core/build/app" + htmlDir := filepath.Join(buildDir, "html") + appName := filepath.Base(fromPath) + if strings.HasPrefix(appName, "core-pwa-build-") { + appName = "pwa-app" + } + outputExe := appName + + if err := os.RemoveAll(buildDir); err != nil { + return fmt.Errorf("failed to clean build directory: %w", err) + } + + // 1. Generate the project from the embedded template + fmt.Println("Generating application from template...") + templateFS, err := debme.FS(guiTemplate, "tmpl/gui") + if err != nil { + return fmt.Errorf("failed to anchor template filesystem: %w", err) + } + sod := gosod.New(templateFS) + if sod == nil { + return fmt.Errorf("failed to create new sod instance") + } + + templateData := map[string]string{"AppName": appName} + if err := sod.Extract(buildDir, templateData); err != nil { + return fmt.Errorf("failed to extract template: %w", err) + } + + // 2. Copy the user's web app files + fmt.Println("Copying application files...") + if err := copyDir(fromPath, htmlDir); err != nil { + return fmt.Errorf("failed to copy application files: %w", err) + } + + // 3. Compile the application + fmt.Println("Compiling application...") + + // Run go mod tidy + cmd := exec.Command("go", "mod", "tidy") + cmd.Dir = buildDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("go mod tidy failed: %w", err) + } + + // Run go build + cmd = exec.Command("go", "build", "-o", outputExe) + cmd.Dir = buildDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("go build failed: %w", err) + } + + fmt.Printf("\nBuild successful! Executable created at: %s/%s\n", buildDir, outputExe) + return nil +} + +// copyDir recursively copies a directory from src to dst. +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + + dstPath := filepath.Join(dst, relPath) + + if info.IsDir() { + return os.MkdirAll(dstPath, info.Mode()) + } + + srcFile, err := os.Open(path) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.Create(dstPath) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + return err + }) +} diff --git a/cmd/build/build_sdk.go b/cmd/build/build_sdk.go new file mode 100644 index 00000000..f35cdbca --- /dev/null +++ b/cmd/build/build_sdk.go @@ -0,0 +1,81 @@ +// build_sdk.go implements SDK generation from OpenAPI specifications. +// +// Generates typed API clients for TypeScript, Python, Go, and PHP +// from OpenAPI/Swagger specifications. + +package build + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/host-uk/core/pkg/sdk" +) + +// runBuildSDK handles the `core build sdk` command. +func runBuildSDK(specPath, lang, version string, dryRun bool) error { + ctx := context.Background() + + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Load config + config := sdk.DefaultConfig() + if specPath != "" { + config.Spec = specPath + } + + s := sdk.New(projectDir, config) + if version != "" { + s.SetVersion(version) + } + + fmt.Printf("%s Generating SDKs\n", buildHeaderStyle.Render("Build SDK:")) + if dryRun { + fmt.Printf(" %s\n", buildDimStyle.Render("(dry-run mode)")) + } + fmt.Println() + + // Detect spec + detectedSpec, err := s.DetectSpec() + if err != nil { + fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err) + return err + } + fmt.Printf(" Spec: %s\n", buildTargetStyle.Render(detectedSpec)) + + if dryRun { + if lang != "" { + fmt.Printf(" Language: %s\n", buildTargetStyle.Render(lang)) + } else { + fmt.Printf(" Languages: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", "))) + } + fmt.Println() + fmt.Printf("%s Would generate SDKs (dry-run)\n", buildSuccessStyle.Render("OK:")) + return nil + } + + if lang != "" { + // Generate single language + if err := s.GenerateLanguage(ctx, lang); err != nil { + fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err) + return err + } + fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(lang)) + } else { + // Generate all + if err := s.Generate(ctx); err != nil { + fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err) + return err + } + fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", "))) + } + + fmt.Println() + fmt.Printf("%s SDK generation complete\n", buildSuccessStyle.Render("Success:")) + return nil +} diff --git a/cmd/build/commands.go b/cmd/build/commands.go index ebbf0b2b..04f27a17 100644 --- a/cmd/build/commands.go +++ b/cmd/build/commands.go @@ -8,6 +8,12 @@ // - Taskfile-based projects // // Configuration via .core/build.yaml or command-line flags. +// +// Subcommands: +// - build: Auto-detect and build the current project +// - build from-path: Build from a local static web app directory +// - build pwa: Build from a live PWA URL +// - build sdk: Generate API SDKs from OpenAPI spec package build import "github.com/leaanthony/clir" diff --git a/cmd/ci/ci_changelog.go b/cmd/ci/ci_changelog.go new file mode 100644 index 00000000..1fd19169 --- /dev/null +++ b/cmd/ci/ci_changelog.go @@ -0,0 +1,31 @@ +package ci + +import ( + "fmt" + "os" + + "github.com/host-uk/core/pkg/release" +) + +// runChangelog generates and prints a changelog. +func runChangelog(fromRef, toRef string) error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Load config for changelog settings + cfg, err := release.LoadConfig(projectDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Generate changelog + changelog, err := release.GenerateWithConfig(projectDir, fromRef, toRef, &cfg.Changelog) + if err != nil { + return fmt.Errorf("failed to generate changelog: %w", err) + } + + fmt.Println(changelog) + return nil +} diff --git a/cmd/ci/ci_init.go b/cmd/ci/ci_init.go new file mode 100644 index 00000000..e6151d5c --- /dev/null +++ b/cmd/ci/ci_init.go @@ -0,0 +1,71 @@ +package ci + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/host-uk/core/pkg/release" +) + +// runCIReleaseInit creates a release configuration interactively. +func runCIReleaseInit() error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Check if config already exists + if release.ConfigExists(projectDir) { + fmt.Printf("%s Configuration already exists at %s\n", + releaseDimStyle.Render("Note:"), + release.ConfigPath(projectDir)) + + reader := bufio.NewReader(os.Stdin) + fmt.Print("Overwrite? [y/N]: ") + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + fmt.Println("Aborted.") + return nil + } + } + + fmt.Printf("%s Creating release configuration\n", releaseHeaderStyle.Render("Init:")) + fmt.Println() + + reader := bufio.NewReader(os.Stdin) + + // Project name + defaultName := filepath.Base(projectDir) + fmt.Printf("Project name [%s]: ", defaultName) + name, _ := reader.ReadString('\n') + name = strings.TrimSpace(name) + if name == "" { + name = defaultName + } + + // Repository + fmt.Print("GitHub repository (owner/repo): ") + repo, _ := reader.ReadString('\n') + repo = strings.TrimSpace(repo) + + // Create config + cfg := release.DefaultConfig() + cfg.Project.Name = name + cfg.Project.Repository = repo + + // Write config + if err := release.WriteConfig(cfg, projectDir); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + fmt.Println() + fmt.Printf("%s Configuration written to %s\n", + releaseSuccessStyle.Render("Success:"), + release.ConfigPath(projectDir)) + + return nil +} diff --git a/cmd/ci/ci_publish.go b/cmd/ci/ci_publish.go new file mode 100644 index 00000000..36f31b57 --- /dev/null +++ b/cmd/ci/ci_publish.go @@ -0,0 +1,79 @@ +package ci + +import ( + "context" + "fmt" + "os" + + "github.com/host-uk/core/pkg/release" +) + +// runCIPublish publishes pre-built artifacts from dist/. +// It does NOT build - use `core build` first. +func runCIPublish(dryRun bool, version string, draft, prerelease bool) error { + ctx := context.Background() + + // Get current directory + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Load configuration + cfg, err := release.LoadConfig(projectDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Apply CLI overrides + if version != "" { + cfg.SetVersion(version) + } + + // Apply draft/prerelease overrides to all publishers + if draft || prerelease { + for i := range cfg.Publishers { + if draft { + cfg.Publishers[i].Draft = true + } + if prerelease { + cfg.Publishers[i].Prerelease = true + } + } + } + + // Print header + fmt.Printf("%s Publishing release\n", releaseHeaderStyle.Render("CI:")) + if dryRun { + fmt.Printf(" %s\n", releaseDimStyle.Render("(dry-run) use --we-are-go-for-launch to publish")) + } else { + fmt.Printf(" %s\n", releaseSuccessStyle.Render("GO FOR LAUNCH")) + } + fmt.Println() + + // Check for publishers + if len(cfg.Publishers) == 0 { + return fmt.Errorf("no publishers configured in .core/release.yaml") + } + + // Publish pre-built artifacts + rel, err := release.Publish(ctx, cfg, dryRun) + if err != nil { + fmt.Printf("%s %v\n", releaseErrorStyle.Render("Error:"), err) + return err + } + + // Print summary + fmt.Println() + fmt.Printf("%s Publish completed!\n", releaseSuccessStyle.Render("Success:")) + fmt.Printf(" Version: %s\n", releaseValueStyle.Render(rel.Version)) + fmt.Printf(" Artifacts: %d\n", len(rel.Artifacts)) + + if !dryRun { + for _, pub := range cfg.Publishers { + fmt.Printf(" Published: %s\n", releaseValueStyle.Render(pub.Type)) + } + } + + return nil +} diff --git a/cmd/ci/ci_release.go b/cmd/ci/ci_release.go index 5b070627..75ec8093 100644 --- a/cmd/ci/ci_release.go +++ b/cmd/ci/ci_release.go @@ -2,37 +2,17 @@ package ci import ( - "bufio" - "context" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/host-uk/core/pkg/release" + "github.com/host-uk/core/cmd/shared" "github.com/leaanthony/clir" ) -// CIRelease command styles +// Style aliases from shared var ( - releaseHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#3b82f6")) // blue-500 - - releaseSuccessStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#22c55e")) // green-500 - - releaseErrorStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ef4444")) // red-500 - - releaseDimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) // gray-500 - - releaseValueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#e2e8f0")) // gray-200 + releaseHeaderStyle = shared.RepoNameStyle + releaseSuccessStyle = shared.SuccessStyle + releaseErrorStyle = shared.ErrorStyle + releaseDimStyle = shared.DimStyle + releaseValueStyle = shared.ValueStyle ) // AddCIReleaseCommand adds the release command and its subcommands. @@ -84,172 +64,3 @@ func AddCIReleaseCommand(app *clir.Cli) { return runCIReleaseVersion() }) } - -// runCIPublish publishes pre-built artifacts from dist/. -// It does NOT build - use `core build` first. -func runCIPublish(dryRun bool, version string, draft, prerelease bool) error { - ctx := context.Background() - - // Get current directory - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Load configuration - cfg, err := release.LoadConfig(projectDir) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - // Apply CLI overrides - if version != "" { - cfg.SetVersion(version) - } - - // Apply draft/prerelease overrides to all publishers - if draft || prerelease { - for i := range cfg.Publishers { - if draft { - cfg.Publishers[i].Draft = true - } - if prerelease { - cfg.Publishers[i].Prerelease = true - } - } - } - - // Print header - fmt.Printf("%s Publishing release\n", releaseHeaderStyle.Render("CI:")) - if dryRun { - fmt.Printf(" %s\n", releaseDimStyle.Render("(dry-run) use --we-are-go-for-launch to publish")) - } else { - fmt.Printf(" %s\n", releaseSuccessStyle.Render("🚀 GO FOR LAUNCH")) - } - fmt.Println() - - // Check for publishers - if len(cfg.Publishers) == 0 { - return fmt.Errorf("no publishers configured in .core/release.yaml") - } - - // Publish pre-built artifacts - rel, err := release.Publish(ctx, cfg, dryRun) - if err != nil { - fmt.Printf("%s %v\n", releaseErrorStyle.Render("Error:"), err) - return err - } - - // Print summary - fmt.Println() - fmt.Printf("%s Publish completed!\n", releaseSuccessStyle.Render("Success:")) - fmt.Printf(" Version: %s\n", releaseValueStyle.Render(rel.Version)) - fmt.Printf(" Artifacts: %d\n", len(rel.Artifacts)) - - if !dryRun { - for _, pub := range cfg.Publishers { - fmt.Printf(" Published: %s\n", releaseValueStyle.Render(pub.Type)) - } - } - - return nil -} - -// runCIReleaseInit creates a release configuration interactively. -func runCIReleaseInit() error { - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Check if config already exists - if release.ConfigExists(projectDir) { - fmt.Printf("%s Configuration already exists at %s\n", - releaseDimStyle.Render("Note:"), - release.ConfigPath(projectDir)) - - reader := bufio.NewReader(os.Stdin) - fmt.Print("Overwrite? [y/N]: ") - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(strings.ToLower(response)) - if response != "y" && response != "yes" { - fmt.Println("Aborted.") - return nil - } - } - - fmt.Printf("%s Creating release configuration\n", releaseHeaderStyle.Render("Init:")) - fmt.Println() - - reader := bufio.NewReader(os.Stdin) - - // Project name - defaultName := filepath.Base(projectDir) - fmt.Printf("Project name [%s]: ", defaultName) - name, _ := reader.ReadString('\n') - name = strings.TrimSpace(name) - if name == "" { - name = defaultName - } - - // Repository - fmt.Print("GitHub repository (owner/repo): ") - repo, _ := reader.ReadString('\n') - repo = strings.TrimSpace(repo) - - // Create config - cfg := release.DefaultConfig() - cfg.Project.Name = name - cfg.Project.Repository = repo - - // Write config - if err := release.WriteConfig(cfg, projectDir); err != nil { - return fmt.Errorf("failed to write config: %w", err) - } - - fmt.Println() - fmt.Printf("%s Configuration written to %s\n", - releaseSuccessStyle.Render("Success:"), - release.ConfigPath(projectDir)) - - return nil -} - -// runChangelog generates and prints a changelog. -func runChangelog(fromRef, toRef string) error { - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Load config for changelog settings - cfg, err := release.LoadConfig(projectDir) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - // Generate changelog - changelog, err := release.GenerateWithConfig(projectDir, fromRef, toRef, &cfg.Changelog) - if err != nil { - return fmt.Errorf("failed to generate changelog: %w", err) - } - - fmt.Println(changelog) - return nil -} - -// runCIReleaseVersion shows the determined version. -func runCIReleaseVersion() error { - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - version, err := release.DetermineVersion(projectDir) - if err != nil { - return fmt.Errorf("failed to determine version: %w", err) - } - - fmt.Printf("Version: %s\n", releaseValueStyle.Render(version)) - return nil -} diff --git a/cmd/ci/ci_version.go b/cmd/ci/ci_version.go new file mode 100644 index 00000000..c9e2b133 --- /dev/null +++ b/cmd/ci/ci_version.go @@ -0,0 +1,24 @@ +package ci + +import ( + "fmt" + "os" + + "github.com/host-uk/core/pkg/release" +) + +// runCIReleaseVersion shows the determined version. +func runCIReleaseVersion() error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + version, err := release.DetermineVersion(projectDir) + if err != nil { + return fmt.Errorf("failed to determine version: %w", err) + } + + fmt.Printf("Version: %s\n", releaseValueStyle.Render(version)) + return nil +} diff --git a/cmd/dev/commands.go b/cmd/dev/commands.go deleted file mode 100644 index 305be7b4..00000000 --- a/cmd/dev/commands.go +++ /dev/null @@ -1,65 +0,0 @@ -// Package dev provides multi-repo development workflow commands. -// -// This package manages git operations across multiple repositories defined in -// repos.yaml. It also provides GitHub integration and dev environment management. -// -// Commands: -// - work: Combined status, commit, and push workflow -// - health: Quick health check across all repos -// - commit: Claude-assisted commit message generation -// - push: Push repos with unpushed commits -// - pull: Pull repos that are behind remote -// - sync: Sync all repos with remote (pull + push) -// - issues: List GitHub issues across repos -// - reviews: List PRs needing review -// - ci: Check GitHub Actions CI status -// - impact: Analyse dependency impact of changes -// - install/boot/stop: Dev environment VM management -package dev - -import "github.com/leaanthony/clir" - -// AddCommands registers the 'dev' command and all subcommands. -func AddCommands(app *clir.Cli) { - devCmd := app.NewSubCommand("dev", "Multi-repo development workflow") - devCmd.LongDescription("Manage multiple git repositories and GitHub integration.\n\n" + - "Uses repos.yaml to discover repositories. Falls back to scanning\n" + - "the current directory if no registry is found.\n\n" + - "Git Operations:\n" + - " work Combined status → commit → push workflow\n" + - " health Quick repo health summary\n" + - " commit Claude-assisted commit messages\n" + - " push Push repos with unpushed commits\n" + - " pull Pull repos behind remote\n" + - " sync Sync all repos (pull + push)\n\n" + - "GitHub Integration (requires gh CLI):\n" + - " issues List open issues across repos\n" + - " reviews List PRs awaiting review\n" + - " ci Check GitHub Actions status\n" + - " impact Analyse dependency impact\n\n" + - "Dev Environment:\n" + - " install Download dev environment image\n" + - " boot Start dev environment VM\n" + - " stop Stop dev environment VM\n" + - " shell Open shell in dev VM\n" + - " status Check dev VM status") - - // Git operations - AddWorkCommand(devCmd) - AddHealthCommand(devCmd) - AddCommitCommand(devCmd) - AddPushCommand(devCmd) - AddPullCommand(devCmd) - - // GitHub integration - AddIssuesCommand(devCmd) - AddReviewsCommand(devCmd) - AddCICommand(devCmd) - AddImpactCommand(devCmd) - - // API tools - AddAPICommands(devCmd) - - // Dev environment - AddDevCommand(devCmd) -} diff --git a/cmd/dev/dev.go b/cmd/dev/dev.go index a54505d4..278d9a43 100644 --- a/cmd/dev/dev.go +++ b/cmd/dev/dev.go @@ -1,529 +1,108 @@ +// Package dev provides multi-repo development workflow commands. +// +// Git Operations: +// - work: Combined status, commit, and push workflow +// - health: Quick health check across all repos +// - commit: Claude-assisted commit message generation +// - push: Push repos with unpushed commits +// - pull: Pull repos that are behind remote +// +// GitHub Integration (requires gh CLI): +// - issues: List open issues across repos +// - reviews: List PRs needing review +// - ci: Check GitHub Actions CI status +// - impact: Analyse dependency impact of changes +// +// API Tools: +// - api sync: Synchronize public service APIs +// +// Dev Environment (VM management): +// - install: Download dev environment image +// - boot: Start dev environment VM +// - stop: Stop dev environment VM +// - status: Check dev VM status +// - shell: Open shell in dev VM +// - serve: Mount project and start dev server +// - test: Run tests in dev environment +// - claude: Start sandboxed Claude session +// - update: Check for and apply updates package dev import ( - "context" - "fmt" - "os" - "time" - "github.com/charmbracelet/lipgloss" - "github.com/host-uk/core/pkg/devops" + "github.com/host-uk/core/cmd/shared" "github.com/leaanthony/clir" ) -// Dev-specific styles +// Style aliases from shared package var ( - devHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#3b82f6")) // blue-500 - - devSuccessStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#22c55e")). // green-500 - Bold(true) - - devErrorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ef4444")). // red-500 - Bold(true) - - devDimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) // gray-500 - - devValueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#e2e8f0")) // gray-200 - - devWarningStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#f59e0b")) // amber-500 + successStyle = shared.SuccessStyle + errorStyle = shared.ErrorStyle + warningStyle = shared.WarningStyle + dimStyle = shared.DimStyle + valueStyle = shared.ValueStyle + headerStyle = shared.HeaderStyle + repoNameStyle = shared.RepoNameStyle ) -// AddDevCommand adds the dev environment commands to the dev parent command. -// These are added as direct subcommands: core dev install, core dev boot, etc. -func AddDevCommand(parent *clir.Command) { - AddDevInstallCommand(parent) - AddDevBootCommand(parent) - AddDevStopCommand(parent) - AddDevStatusCommand(parent) - AddDevShellCommand(parent) - AddDevServeCommand(parent) - AddDevTestCommand(parent) - AddDevClaudeCommand(parent) - AddDevUpdateCommand(parent) -} - -// AddDevInstallCommand adds the 'dev install' command. -func AddDevInstallCommand(parent *clir.Command) { - installCmd := parent.NewSubCommand("install", "Download and install the dev environment image") - installCmd.LongDescription("Downloads the platform-specific dev environment image.\n\n" + - "The image includes Go, PHP, Node.js, Python, Docker, and Claude CLI.\n" + - "Downloads are cached at ~/.core/images/\n\n" + - "Examples:\n" + - " core dev install") - - installCmd.Action(func() error { - return runDevInstall() - }) -} - -func runDevInstall() error { - d, err := devops.New() - if err != nil { - return err - } - - if d.IsInstalled() { - fmt.Println(devSuccessStyle.Render("Dev environment already installed")) - fmt.Println() - fmt.Printf("Use %s to check for updates\n", devDimStyle.Render("core dev update")) - return nil - } - - fmt.Printf("%s %s\n", devDimStyle.Render("Image:"), devops.ImageName()) - fmt.Println() - fmt.Println("Downloading dev environment...") - fmt.Println() - - ctx := context.Background() - start := time.Now() - var lastProgress int64 - - err = d.Install(ctx, func(downloaded, total int64) { - if total > 0 { - pct := int(float64(downloaded) / float64(total) * 100) - if pct != int(float64(lastProgress)/float64(total)*100) { - fmt.Printf("\r%s %d%%", devDimStyle.Render("Progress:"), pct) - lastProgress = downloaded - } - } - }) - - fmt.Println() // Clear progress line - - if err != nil { - return fmt.Errorf("install failed: %w", err) - } - - elapsed := time.Since(start).Round(time.Second) - fmt.Println() - fmt.Printf("%s in %s\n", devSuccessStyle.Render("Installed"), elapsed) - fmt.Println() - fmt.Printf("Start with: %s\n", devDimStyle.Render("core dev boot")) - - return nil -} - -// AddDevBootCommand adds the 'devops boot' command. -func AddDevBootCommand(parent *clir.Command) { - var memory int - var cpus int - var fresh bool - - bootCmd := parent.NewSubCommand("boot", "Start the dev environment") - bootCmd.LongDescription("Boots the dev environment VM.\n\n" + - "Examples:\n" + - " core dev boot\n" + - " core dev boot --memory 8192 --cpus 4\n" + - " core dev boot --fresh") - - bootCmd.IntFlag("memory", "Memory in MB (default: 4096)", &memory) - bootCmd.IntFlag("cpus", "Number of CPUs (default: 2)", &cpus) - bootCmd.BoolFlag("fresh", "Stop existing and start fresh", &fresh) - - bootCmd.Action(func() error { - return runDevBoot(memory, cpus, fresh) - }) -} - -func runDevBoot(memory, cpus int, fresh bool) error { - d, err := devops.New() - if err != nil { - return err - } - - if !d.IsInstalled() { - return fmt.Errorf("dev environment not installed (run 'core dev install' first)") - } - - opts := devops.DefaultBootOptions() - if memory > 0 { - opts.Memory = memory - } - if cpus > 0 { - opts.CPUs = cpus - } - opts.Fresh = fresh - - fmt.Printf("%s %dMB, %d CPUs\n", devDimStyle.Render("Config:"), opts.Memory, opts.CPUs) - fmt.Println() - fmt.Println("Booting dev environment...") - - ctx := context.Background() - if err := d.Boot(ctx, opts); err != nil { - return err - } - - fmt.Println() - fmt.Println(devSuccessStyle.Render("Dev environment running")) - fmt.Println() - fmt.Printf("Connect with: %s\n", devDimStyle.Render("core dev shell")) - fmt.Printf("SSH port: %s\n", devDimStyle.Render("2222")) - - return nil -} - -// AddDevStopCommand adds the 'devops stop' command. -func AddDevStopCommand(parent *clir.Command) { - stopCmd := parent.NewSubCommand("stop", "Stop the dev environment") - stopCmd.LongDescription("Stops the running dev environment VM.\n\n" + - "Examples:\n" + - " core dev stop") - - stopCmd.Action(func() error { - return runDevStop() - }) -} - -func runDevStop() error { - d, err := devops.New() - if err != nil { - return err - } - - ctx := context.Background() - running, err := d.IsRunning(ctx) - if err != nil { - return err - } - - if !running { - fmt.Println(devDimStyle.Render("Dev environment is not running")) - return nil - } - - fmt.Println("Stopping dev environment...") - - if err := d.Stop(ctx); err != nil { - return err - } - - fmt.Println(devSuccessStyle.Render("Stopped")) - return nil -} - -// AddDevStatusCommand adds the 'devops status' command. -func AddDevStatusCommand(parent *clir.Command) { - statusCmd := parent.NewSubCommand("status", "Show dev environment status") - statusCmd.LongDescription("Shows the current status of the dev environment.\n\n" + - "Examples:\n" + - " core dev status") - - statusCmd.Action(func() error { - return runDevStatus() - }) -} - -func runDevStatus() error { - d, err := devops.New() - if err != nil { - return err - } - - ctx := context.Background() - status, err := d.Status(ctx) - if err != nil { - return err - } - - fmt.Println(devHeaderStyle.Render("Dev Environment Status")) - fmt.Println() - - // Installation status - if status.Installed { - fmt.Printf("%s %s\n", devDimStyle.Render("Installed:"), devSuccessStyle.Render("Yes")) - if status.ImageVersion != "" { - fmt.Printf("%s %s\n", devDimStyle.Render("Version:"), status.ImageVersion) - } - } else { - fmt.Printf("%s %s\n", devDimStyle.Render("Installed:"), devErrorStyle.Render("No")) - fmt.Println() - fmt.Printf("Install with: %s\n", devDimStyle.Render("core dev install")) - return nil - } - - fmt.Println() - - // Running status - if status.Running { - fmt.Printf("%s %s\n", devDimStyle.Render("Status:"), devSuccessStyle.Render("Running")) - fmt.Printf("%s %s\n", devDimStyle.Render("Container:"), status.ContainerID[:8]) - fmt.Printf("%s %dMB\n", devDimStyle.Render("Memory:"), status.Memory) - fmt.Printf("%s %d\n", devDimStyle.Render("CPUs:"), status.CPUs) - fmt.Printf("%s %d\n", devDimStyle.Render("SSH Port:"), status.SSHPort) - fmt.Printf("%s %s\n", devDimStyle.Render("Uptime:"), formatDevUptime(status.Uptime)) - } else { - fmt.Printf("%s %s\n", devDimStyle.Render("Status:"), devDimStyle.Render("Stopped")) - fmt.Println() - fmt.Printf("Start with: %s\n", devDimStyle.Render("core dev boot")) - } - - return nil -} - -func formatDevUptime(d time.Duration) string { - if d < time.Minute { - return fmt.Sprintf("%ds", int(d.Seconds())) - } - if d < time.Hour { - return fmt.Sprintf("%dm", int(d.Minutes())) - } - if d < 24*time.Hour { - return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) - } - return fmt.Sprintf("%dd %dh", int(d.Hours()/24), int(d.Hours())%24) -} - -// AddDevShellCommand adds the 'devops shell' command. -func AddDevShellCommand(parent *clir.Command) { - var console bool - - shellCmd := parent.NewSubCommand("shell", "Connect to the dev environment") - shellCmd.LongDescription("Opens an interactive shell in the dev environment.\n\n" + - "Uses SSH by default, or serial console with --console.\n\n" + - "Examples:\n" + - " core dev shell\n" + - " core dev shell --console\n" + - " core dev shell -- ls -la") - - shellCmd.BoolFlag("console", "Use serial console instead of SSH", &console) - - shellCmd.Action(func() error { - args := shellCmd.OtherArgs() - return runDevShell(console, args) - }) -} - -func runDevShell(console bool, command []string) error { - d, err := devops.New() - if err != nil { - return err - } - - opts := devops.ShellOptions{ - Console: console, - Command: command, - } - - ctx := context.Background() - return d.Shell(ctx, opts) -} - -// AddDevServeCommand adds the 'devops serve' command. -func AddDevServeCommand(parent *clir.Command) { - var port int - var path string - - serveCmd := parent.NewSubCommand("serve", "Mount project and start dev server") - serveCmd.LongDescription("Mounts the current project into the dev environment and starts a dev server.\n\n" + - "Auto-detects the appropriate serve command based on project files.\n\n" + - "Examples:\n" + - " core dev serve\n" + - " core dev serve --port 3000\n" + - " core dev serve --path public") - - serveCmd.IntFlag("port", "Port to serve on (default: 8000)", &port) - serveCmd.StringFlag("path", "Subdirectory to serve", &path) - - serveCmd.Action(func() error { - return runDevServe(port, path) - }) -} - -func runDevServe(port int, path string) error { - d, err := devops.New() - if err != nil { - return err - } - - projectDir, err := os.Getwd() - if err != nil { - return err - } - - opts := devops.ServeOptions{ - Port: port, - Path: path, - } - - ctx := context.Background() - return d.Serve(ctx, projectDir, opts) -} - -// AddDevTestCommand adds the 'devops test' command. -func AddDevTestCommand(parent *clir.Command) { - var name string - - testCmd := parent.NewSubCommand("test", "Run tests in the dev environment") - testCmd.LongDescription("Runs tests in the dev environment.\n\n" + - "Auto-detects the test command based on project files, or uses .core/test.yaml.\n\n" + - "Examples:\n" + - " core dev test\n" + - " core dev test --name integration\n" + - " core dev test -- go test -v ./...") - - testCmd.StringFlag("name", "Run named test command from .core/test.yaml", &name) - - testCmd.Action(func() error { - args := testCmd.OtherArgs() - return runDevTest(name, args) - }) -} - -func runDevTest(name string, command []string) error { - d, err := devops.New() - if err != nil { - return err - } - - projectDir, err := os.Getwd() - if err != nil { - return err - } - - opts := devops.TestOptions{ - Name: name, - Command: command, - } - - ctx := context.Background() - return d.Test(ctx, projectDir, opts) -} - -// AddDevClaudeCommand adds the 'devops claude' command. -func AddDevClaudeCommand(parent *clir.Command) { - var noAuth bool - var model string - var authFlags []string - - claudeCmd := parent.NewSubCommand("claude", "Start sandboxed Claude session") - claudeCmd.LongDescription("Starts a Claude Code session inside the dev environment sandbox.\n\n" + - "Provides isolation while forwarding selected credentials.\n" + - "Auto-boots the dev environment if not running.\n\n" + - "Auth options (default: all):\n" + - " gh - GitHub CLI auth\n" + - " anthropic - Anthropic API key\n" + - " ssh - SSH agent forwarding\n" + - " git - Git config (name, email)\n\n" + - "Examples:\n" + - " core dev claude\n" + - " core dev claude --model opus\n" + - " core dev claude --auth gh,anthropic\n" + - " core dev claude --no-auth") - - claudeCmd.BoolFlag("no-auth", "Don't forward any auth credentials", &noAuth) - claudeCmd.StringFlag("model", "Model to use (opus, sonnet)", &model) - claudeCmd.StringsFlag("auth", "Selective auth forwarding (gh,anthropic,ssh,git)", &authFlags) - - claudeCmd.Action(func() error { - return runDevClaude(noAuth, model, authFlags) - }) -} - -func runDevClaude(noAuth bool, model string, authFlags []string) error { - d, err := devops.New() - if err != nil { - return err - } - - projectDir, err := os.Getwd() - if err != nil { - return err - } - - opts := devops.ClaudeOptions{ - NoAuth: noAuth, - Model: model, - Auth: authFlags, - } - - ctx := context.Background() - return d.Claude(ctx, projectDir, opts) -} - -// AddDevUpdateCommand adds the 'devops update' command. -func AddDevUpdateCommand(parent *clir.Command) { - var apply bool - - updateCmd := parent.NewSubCommand("update", "Check for and apply updates") - updateCmd.LongDescription("Checks for dev environment updates and optionally applies them.\n\n" + - "Examples:\n" + - " core dev update\n" + - " core dev update --apply") - - updateCmd.BoolFlag("apply", "Download and apply the update", &apply) - - updateCmd.Action(func() error { - return runDevUpdate(apply) - }) -} - -func runDevUpdate(apply bool) error { - d, err := devops.New() - if err != nil { - return err - } - - ctx := context.Background() - - fmt.Println("Checking for updates...") - fmt.Println() - - current, latest, hasUpdate, err := d.CheckUpdate(ctx) - if err != nil { - return fmt.Errorf("failed to check for updates: %w", err) - } - - fmt.Printf("%s %s\n", devDimStyle.Render("Current:"), devValueStyle.Render(current)) - fmt.Printf("%s %s\n", devDimStyle.Render("Latest:"), devValueStyle.Render(latest)) - fmt.Println() - - if !hasUpdate { - fmt.Println(devSuccessStyle.Render("Already up to date")) - return nil - } - - fmt.Println(devWarningStyle.Render("Update available")) - fmt.Println() - - if !apply { - fmt.Printf("Run %s to update\n", devDimStyle.Render("core dev update --apply")) - return nil - } - - // Stop if running - running, _ := d.IsRunning(ctx) - if running { - fmt.Println("Stopping current instance...") - _ = d.Stop(ctx) - } - - fmt.Println("Downloading update...") - fmt.Println() - - start := time.Now() - err = d.Install(ctx, func(downloaded, total int64) { - if total > 0 { - pct := int(float64(downloaded) / float64(total) * 100) - fmt.Printf("\r%s %d%%", devDimStyle.Render("Progress:"), pct) - } - }) - - fmt.Println() - - if err != nil { - return fmt.Errorf("update failed: %w", err) - } - - elapsed := time.Since(start).Round(time.Second) - fmt.Println() - fmt.Printf("%s in %s\n", devSuccessStyle.Render("Updated"), elapsed) - - return nil +// Table styles for status display +var ( + cellStyle = lipgloss.NewStyle(). + Padding(0, 1) + + dirtyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ef4444")). // red-500 + Padding(0, 1) + + aheadStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")). // green-500 + Padding(0, 1) + + cleanStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")). // gray-500 + Padding(0, 1) +) + +// AddCommands registers the 'dev' command and all subcommands. +func AddCommands(app *clir.Cli) { + devCmd := app.NewSubCommand("dev", "Multi-repo development workflow") + devCmd.LongDescription("Manage multiple git repositories and GitHub integration.\n\n" + + "Uses repos.yaml to discover repositories. Falls back to scanning\n" + + "the current directory if no registry is found.\n\n" + + "Git Operations:\n" + + " work Combined status -> commit -> push workflow\n" + + " health Quick repo health summary\n" + + " commit Claude-assisted commit messages\n" + + " push Push repos with unpushed commits\n" + + " pull Pull repos behind remote\n\n" + + "GitHub Integration (requires gh CLI):\n" + + " issues List open issues across repos\n" + + " reviews List PRs awaiting review\n" + + " ci Check GitHub Actions status\n" + + " impact Analyse dependency impact\n\n" + + "Dev Environment:\n" + + " install Download dev environment image\n" + + " boot Start dev environment VM\n" + + " stop Stop dev environment VM\n" + + " shell Open shell in dev VM\n" + + " status Check dev VM status") + + // Git operations + addWorkCommand(devCmd) + addHealthCommand(devCmd) + addCommitCommand(devCmd) + addPushCommand(devCmd) + addPullCommand(devCmd) + + // GitHub integration + addIssuesCommand(devCmd) + addReviewsCommand(devCmd) + addCICommand(devCmd) + addImpactCommand(devCmd) + + // API tools + addAPICommands(devCmd) + + // Dev environment + addVMCommands(devCmd) } diff --git a/cmd/dev/api.go b/cmd/dev/dev_api.go similarity index 62% rename from cmd/dev/api.go rename to cmd/dev/dev_api.go index 52cda1cc..1060fb1c 100644 --- a/cmd/dev/api.go +++ b/cmd/dev/dev_api.go @@ -4,14 +4,14 @@ import ( "github.com/leaanthony/clir" ) -// AddAPICommands adds the 'api' command and its subcommands to the given parent command. -func AddAPICommands(parent *clir.Command) { +// addAPICommands adds the 'api' command and its subcommands to the given parent command. +func addAPICommands(parent *clir.Command) { // Create the 'api' command apiCmd := parent.NewSubCommand("api", "Tools for managing service APIs") // Add the 'sync' command to 'api' - AddSyncCommand(apiCmd) + addSyncCommand(apiCmd) // TODO: Add the 'test-gen' command to 'api' - // AddTestGenCommand(apiCmd) + // addTestGenCommand(apiCmd) } diff --git a/cmd/dev/ci.go b/cmd/dev/dev_ci.go similarity index 86% rename from cmd/dev/ci.go rename to cmd/dev/dev_ci.go index 2a7b19e8..de8a3171 100644 --- a/cmd/dev/ci.go +++ b/cmd/dev/dev_ci.go @@ -9,10 +9,12 @@ import ( "time" "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) +// CI-specific styles var ( ciSuccessStyle = lipgloss.NewStyle(). Bold(true). @@ -43,8 +45,8 @@ type WorkflowRun struct { RepoName string `json:"-"` } -// AddCICommand adds the 'ci' command to the given parent command. -func AddCICommand(parent *clir.Command) { +// addCICommand adds the 'ci' command to the given parent command. +func addCICommand(parent *clir.Command) { var registryPath string var branch string var failedOnly bool @@ -149,16 +151,16 @@ func runCI(registryPath string, branch string, failedOnly bool) error { fmt.Println() fmt.Printf("%d repos checked", len(repoList)) if success > 0 { - fmt.Printf(" · %s", ciSuccessStyle.Render(fmt.Sprintf("%d passing", success))) + fmt.Printf(" * %s", ciSuccessStyle.Render(fmt.Sprintf("%d passing", success))) } if failed > 0 { - fmt.Printf(" · %s", ciFailureStyle.Render(fmt.Sprintf("%d failing", failed))) + fmt.Printf(" * %s", ciFailureStyle.Render(fmt.Sprintf("%d failing", failed))) } if pending > 0 { - fmt.Printf(" · %s", ciPendingStyle.Render(fmt.Sprintf("%d pending", pending))) + fmt.Printf(" * %s", ciPendingStyle.Render(fmt.Sprintf("%d pending", pending))) } if len(noCI) > 0 { - fmt.Printf(" · %s", ciSkippedStyle.Render(fmt.Sprintf("%d no CI", len(noCI)))) + fmt.Printf(" * %s", ciSkippedStyle.Render(fmt.Sprintf("%d no CI", len(noCI)))) } fmt.Println() fmt.Println() @@ -227,30 +229,30 @@ func printWorkflowRun(run WorkflowRun) { var status string switch run.Conclusion { case "success": - status = ciSuccessStyle.Render("✓") + status = ciSuccessStyle.Render("v") case "failure": - status = ciFailureStyle.Render("✗") + status = ciFailureStyle.Render("x") case "": if run.Status == "in_progress" { - status = ciPendingStyle.Render("●") + status = ciPendingStyle.Render("*") } else if run.Status == "queued" { - status = ciPendingStyle.Render("○") + status = ciPendingStyle.Render("o") } else { - status = ciSkippedStyle.Render("—") + status = ciSkippedStyle.Render("-") } case "skipped": - status = ciSkippedStyle.Render("—") + status = ciSkippedStyle.Render("-") case "cancelled": - status = ciSkippedStyle.Render("○") + status = ciSkippedStyle.Render("o") default: status = ciSkippedStyle.Render("?") } // Workflow name (truncated) - workflowName := truncate(run.Name, 20) + workflowName := shared.Truncate(run.Name, 20) // Age - age := formatAge(run.UpdatedAt) + age := shared.FormatAge(run.UpdatedAt) fmt.Printf(" %s %-18s %-22s %s\n", status, diff --git a/cmd/dev/commit.go b/cmd/dev/dev_commit.go similarity index 75% rename from cmd/dev/commit.go rename to cmd/dev/dev_commit.go index cb628b76..383836f7 100644 --- a/cmd/dev/commit.go +++ b/cmd/dev/dev_commit.go @@ -4,16 +4,15 @@ import ( "context" "fmt" "os" - "os/exec" - "path/filepath" + "github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/pkg/git" "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) -// AddCommitCommand adds the 'commit' command to the given parent command. -func AddCommitCommand(parent *clir.Command) { +// addCommitCommand adds the 'commit' command to the given parent command. +func addCommitCommand(parent *clir.Command) { var registryPath string var all bool @@ -116,7 +115,7 @@ func runCommit(registryPath string, all bool) error { // Confirm unless --all if !all { fmt.Println() - if !confirm("Have Claude commit these repos?") { + if !shared.Confirm("Have Claude commit these repos?") { fmt.Println("Aborted.") return nil } @@ -130,10 +129,10 @@ func runCommit(registryPath string, all bool) error { fmt.Printf("%s %s\n", dimStyle.Render("Committing"), s.Name) if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil { - fmt.Printf(" %s %s\n", errorStyle.Render("✗"), err) + fmt.Printf(" %s %s\n", errorStyle.Render("x"), err) failed++ } else { - fmt.Printf(" %s committed\n", successStyle.Render("✓")) + fmt.Printf(" %s committed\n", successStyle.Render("v")) succeeded++ } fmt.Println() @@ -148,25 +147,3 @@ func runCommit(registryPath string, all bool) error { return nil } - -// claudeCommit is defined in work.go but we need it here too -// This version includes better output handling -func claudeCommitWithOutput(ctx context.Context, repoPath, repoName, registryPath string) error { - // Load AGENTS.md context if available - agentsPath := filepath.Join(filepath.Dir(registryPath), "AGENTS.md") - var agentContext string - if data, err := os.ReadFile(agentsPath); err == nil { - agentContext = string(data) + "\n\n" - } - - prompt := agentContext + "Review the uncommitted changes and create an appropriate commit. " + - "Use Co-Authored-By: Claude Opus 4.5 . Be concise." - - cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep") - cmd.Dir = repoPath - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - - return cmd.Run() -} diff --git a/cmd/dev/health.go b/cmd/dev/dev_health.go similarity index 57% rename from cmd/dev/health.go rename to cmd/dev/dev_health.go index 92deb4c8..420da226 100644 --- a/cmd/dev/health.go +++ b/cmd/dev/dev_health.go @@ -6,35 +6,13 @@ import ( "os" "sort" - "github.com/charmbracelet/lipgloss" "github.com/host-uk/core/pkg/git" "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) -var ( - healthLabelStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) // gray-500 - - healthValueStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#e2e8f0")) // gray-200 - - healthGoodStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#22c55e")) // green-500 - - healthWarnStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#f59e0b")) // amber-500 - - healthBadStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ef4444")) // red-500 -) - -// AddHealthCommand adds the 'health' command to the given parent command. -func AddHealthCommand(parent *clir.Command) { +// addHealthCommand adds the 'health' command to the given parent command. +func addHealthCommand(parent *clir.Command) { var registryPath string var verbose bool @@ -108,11 +86,11 @@ func runHealth(registryPath string, verbose bool) error { // Aggregate stats var ( - totalRepos = len(statuses) - dirtyRepos []string - aheadRepos []string - behindRepos []string - errorRepos []string + totalRepos = len(statuses) + dirtyRepos []string + aheadRepos []string + behindRepos []string + errorRepos []string ) for _, s := range statuses { @@ -139,16 +117,16 @@ func runHealth(registryPath string, verbose bool) error { // Verbose output if verbose { if len(dirtyRepos) > 0 { - fmt.Printf("%s %s\n", healthWarnStyle.Render("Dirty:"), formatRepoList(dirtyRepos)) + fmt.Printf("%s %s\n", warningStyle.Render("Dirty:"), formatRepoList(dirtyRepos)) } if len(aheadRepos) > 0 { - fmt.Printf("%s %s\n", healthGoodStyle.Render("Ahead:"), formatRepoList(aheadRepos)) + fmt.Printf("%s %s\n", successStyle.Render("Ahead:"), formatRepoList(aheadRepos)) } if len(behindRepos) > 0 { - fmt.Printf("%s %s\n", healthWarnStyle.Render("Behind:"), formatRepoList(behindRepos)) + fmt.Printf("%s %s\n", warningStyle.Render("Behind:"), formatRepoList(behindRepos)) } if len(errorRepos) > 0 { - fmt.Printf("%s %s\n", healthBadStyle.Render("Errors:"), formatRepoList(errorRepos)) + fmt.Printf("%s %s\n", errorStyle.Render("Errors:"), formatRepoList(errorRepos)) } fmt.Println() } @@ -158,62 +136,62 @@ func runHealth(registryPath string, verbose bool) error { func printHealthSummary(total int, dirty, ahead, behind, errors []string) { // Total repos - fmt.Print(healthValueStyle.Render(fmt.Sprintf("%d", total))) - fmt.Print(healthLabelStyle.Render(" repos")) + fmt.Print(valueStyle.Render(fmt.Sprintf("%d", total))) + fmt.Print(dimStyle.Render(" repos")) // Separator - fmt.Print(healthLabelStyle.Render(" │ ")) + fmt.Print(dimStyle.Render(" | ")) // Dirty if len(dirty) > 0 { - fmt.Print(healthWarnStyle.Render(fmt.Sprintf("%d", len(dirty)))) - fmt.Print(healthLabelStyle.Render(" dirty")) + fmt.Print(warningStyle.Render(fmt.Sprintf("%d", len(dirty)))) + fmt.Print(dimStyle.Render(" dirty")) } else { - fmt.Print(healthGoodStyle.Render("clean")) + fmt.Print(successStyle.Render("clean")) } // Separator - fmt.Print(healthLabelStyle.Render(" │ ")) + fmt.Print(dimStyle.Render(" | ")) // Ahead if len(ahead) > 0 { - fmt.Print(healthValueStyle.Render(fmt.Sprintf("%d", len(ahead)))) - fmt.Print(healthLabelStyle.Render(" to push")) + fmt.Print(valueStyle.Render(fmt.Sprintf("%d", len(ahead)))) + fmt.Print(dimStyle.Render(" to push")) } else { - fmt.Print(healthGoodStyle.Render("synced")) + fmt.Print(successStyle.Render("synced")) } // Separator - fmt.Print(healthLabelStyle.Render(" │ ")) + fmt.Print(dimStyle.Render(" | ")) // Behind if len(behind) > 0 { - fmt.Print(healthWarnStyle.Render(fmt.Sprintf("%d", len(behind)))) - fmt.Print(healthLabelStyle.Render(" to pull")) + fmt.Print(warningStyle.Render(fmt.Sprintf("%d", len(behind)))) + fmt.Print(dimStyle.Render(" to pull")) } else { - fmt.Print(healthGoodStyle.Render("up to date")) + fmt.Print(successStyle.Render("up to date")) } // Errors (only if any) if len(errors) > 0 { - fmt.Print(healthLabelStyle.Render(" │ ")) - fmt.Print(healthBadStyle.Render(fmt.Sprintf("%d", len(errors)))) - fmt.Print(healthLabelStyle.Render(" errors")) + fmt.Print(dimStyle.Render(" | ")) + fmt.Print(errorStyle.Render(fmt.Sprintf("%d", len(errors)))) + fmt.Print(dimStyle.Render(" errors")) } fmt.Println() } -func formatRepoList(repos []string) string { - if len(repos) <= 5 { - return joinRepos(repos) +func formatRepoList(reposList []string) string { + if len(reposList) <= 5 { + return joinRepos(reposList) } - return joinRepos(repos[:5]) + fmt.Sprintf(" +%d more", len(repos)-5) + return joinRepos(reposList[:5]) + fmt.Sprintf(" +%d more", len(reposList)-5) } -func joinRepos(repos []string) string { +func joinRepos(reposList []string) string { result := "" - for i, r := range repos { + for i, r := range reposList { if i > 0 { result += ", " } diff --git a/cmd/dev/impact.go b/cmd/dev/dev_impact.go similarity index 91% rename from cmd/dev/impact.go rename to cmd/dev/dev_impact.go index 83ccc521..d0187b7f 100644 --- a/cmd/dev/impact.go +++ b/cmd/dev/dev_impact.go @@ -6,10 +6,12 @@ import ( "sort" "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) +// Impact-specific styles var ( impactDirectStyle = lipgloss.NewStyle(). Bold(true). @@ -22,8 +24,8 @@ var ( Foreground(lipgloss.Color("#22c55e")) // green-500 ) -// AddImpactCommand adds the 'impact' command to the given parent command. -func AddImpactCommand(parent *clir.Command) { +// addImpactCommand adds the 'impact' command to the given parent command. +func addImpactCommand(parent *clir.Command) { var registryPath string impactCmd := parent.NewSubCommand("impact", "Show impact of changing a repo") @@ -110,21 +112,21 @@ func runImpact(registryPath string, repoName string) error { fmt.Println() if len(allAffected) == 0 { - fmt.Printf("%s No repos depend on %s\n", impactSafeStyle.Render("✓"), repoName) + fmt.Printf("%s No repos depend on %s\n", impactSafeStyle.Render("v"), repoName) return nil } // Direct dependents if len(direct) > 0 { fmt.Printf("%s %d direct dependent(s):\n", - impactDirectStyle.Render("●"), + impactDirectStyle.Render("*"), len(direct), ) for _, d := range direct { r, _ := reg.Get(d) desc := "" if r != nil && r.Description != "" { - desc = dimStyle.Render(" - " + truncate(r.Description, 40)) + desc = dimStyle.Render(" - " + shared.Truncate(r.Description, 40)) } fmt.Printf(" %s%s\n", d, desc) } @@ -134,14 +136,14 @@ func runImpact(registryPath string, repoName string) error { // Indirect dependents if len(indirect) > 0 { fmt.Printf("%s %d transitive dependent(s):\n", - impactIndirectStyle.Render("○"), + impactIndirectStyle.Render("o"), len(indirect), ) for _, d := range indirect { r, _ := reg.Get(d) desc := "" if r != nil && r.Description != "" { - desc = dimStyle.Render(" - " + truncate(r.Description, 40)) + desc = dimStyle.Render(" - " + shared.Truncate(r.Description, 40)) } fmt.Printf(" %s%s\n", d, desc) } diff --git a/cmd/dev/issues.go b/cmd/dev/dev_issues.go similarity index 88% rename from cmd/dev/issues.go rename to cmd/dev/dev_issues.go index 8af522d0..67db407d 100644 --- a/cmd/dev/issues.go +++ b/cmd/dev/dev_issues.go @@ -10,10 +10,12 @@ import ( "time" "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) +// Issue-specific styles var ( issueRepoStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#6b7280")) // gray-500 @@ -60,8 +62,8 @@ type GitHubIssue struct { RepoName string `json:"-"` } -// AddIssuesCommand adds the 'issues' command to the given parent command. -func AddIssuesCommand(parent *clir.Command) { +// addIssuesCommand adds the 'issues' command to the given parent command. +func addIssuesCommand(parent *clir.Command) { var registryPath string var limit int var assignee string @@ -204,7 +206,7 @@ func printIssue(issue GitHubIssue) { // #42 [core-bio] Fix avatar upload num := issueNumberStyle.Render(fmt.Sprintf("#%d", issue.Number)) repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", issue.RepoName)) - title := issueTitleStyle.Render(truncate(issue.Title, 60)) + title := issueTitleStyle.Render(shared.Truncate(issue.Title, 60)) line := fmt.Sprintf(" %s %s %s", num, repo, title) @@ -227,33 +229,8 @@ func printIssue(issue GitHubIssue) { } // Add age - age := formatAge(issue.CreatedAt) + age := shared.FormatAge(issue.CreatedAt) line += " " + issueAgeStyle.Render(age) fmt.Println(line) } - -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max-3] + "..." -} - -func formatAge(t time.Time) string { - d := time.Since(t) - - if d < time.Hour { - return fmt.Sprintf("%dm ago", int(d.Minutes())) - } - if d < 24*time.Hour { - return fmt.Sprintf("%dh ago", int(d.Hours())) - } - if d < 7*24*time.Hour { - return fmt.Sprintf("%dd ago", int(d.Hours()/24)) - } - if d < 30*24*time.Hour { - return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7))) - } - return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30))) -} diff --git a/cmd/dev/pull.go b/cmd/dev/dev_pull.go similarity index 93% rename from cmd/dev/pull.go rename to cmd/dev/dev_pull.go index 0357adf2..fd010b8b 100644 --- a/cmd/dev/pull.go +++ b/cmd/dev/dev_pull.go @@ -11,8 +11,8 @@ import ( "github.com/leaanthony/clir" ) -// AddPullCommand adds the 'pull' command to the given parent command. -func AddPullCommand(parent *clir.Command) { +// addPullCommand adds the 'pull' command to the given parent command. +func addPullCommand(parent *clir.Command) { var registryPath string var all bool @@ -119,10 +119,10 @@ func runPull(registryPath string, all bool) error { err := gitPull(ctx, s.Path) if err != nil { - fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error())) + fmt.Printf("%s\n", errorStyle.Render("x "+err.Error())) failed++ } else { - fmt.Printf("%s\n", successStyle.Render("✓")) + fmt.Printf("%s\n", successStyle.Render("v")) succeeded++ } } diff --git a/cmd/dev/push.go b/cmd/dev/dev_push.go similarity index 89% rename from cmd/dev/push.go rename to cmd/dev/dev_push.go index 6166b1af..e3757385 100644 --- a/cmd/dev/push.go +++ b/cmd/dev/dev_push.go @@ -5,13 +5,14 @@ import ( "fmt" "os" + "github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/pkg/git" "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) -// AddPushCommand adds the 'push' command to the given parent command. -func AddPushCommand(parent *clir.Command) { +// addPushCommand adds the 'push' command to the given parent command. +func addPushCommand(parent *clir.Command) { var registryPath string var force bool @@ -108,7 +109,7 @@ func runPush(registryPath string, force bool) error { // Confirm unless --force if !force { fmt.Println() - if !confirm(fmt.Sprintf("Push %d commit(s) to %d repo(s)?", totalCommits, len(aheadRepos))) { + if !shared.Confirm(fmt.Sprintf("Push %d commit(s) to %d repo(s)?", totalCommits, len(aheadRepos))) { fmt.Println("Aborted.") return nil } @@ -127,10 +128,10 @@ func runPush(registryPath string, force bool) error { var succeeded, failed int for _, r := range results { if r.Success { - fmt.Printf(" %s %s\n", successStyle.Render("✓"), r.Name) + fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name) succeeded++ } else { - fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), r.Name, r.Error) + fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, r.Error) failed++ } } diff --git a/cmd/dev/reviews.go b/cmd/dev/dev_reviews.go similarity index 90% rename from cmd/dev/reviews.go rename to cmd/dev/dev_reviews.go index dd85c7d8..ce63ed37 100644 --- a/cmd/dev/reviews.go +++ b/cmd/dev/dev_reviews.go @@ -10,10 +10,12 @@ import ( "time" "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) +// PR-specific styles var ( prNumberStyle = lipgloss.NewStyle(). Bold(true). @@ -65,8 +67,8 @@ type GitHubPR struct { RepoName string `json:"-"` } -// AddReviewsCommand adds the 'reviews' command to the given parent command. -func AddReviewsCommand(parent *clir.Command) { +// addReviewsCommand adds the 'reviews' command to the given parent command. +func addReviewsCommand(parent *clir.Command) { var registryPath string var author string var showAll bool @@ -175,13 +177,13 @@ func runReviews(registryPath string, author string, showAll bool) error { fmt.Println() fmt.Printf("%d open PR(s)", len(allPRs)) if pending > 0 { - fmt.Printf(" · %s", prPendingStyle.Render(fmt.Sprintf("%d pending", pending))) + fmt.Printf(" * %s", prPendingStyle.Render(fmt.Sprintf("%d pending", pending))) } if approved > 0 { - fmt.Printf(" · %s", prApprovedStyle.Render(fmt.Sprintf("%d approved", approved))) + fmt.Printf(" * %s", prApprovedStyle.Render(fmt.Sprintf("%d approved", approved))) } if changesRequested > 0 { - fmt.Printf(" · %s", prChangesStyle.Render(fmt.Sprintf("%d changes requested", changesRequested))) + fmt.Printf(" * %s", prChangesStyle.Render(fmt.Sprintf("%d changes requested", changesRequested))) } fmt.Println() fmt.Println() @@ -243,18 +245,18 @@ func printPR(pr GitHubPR) { // #12 [core-php] Webhook validation num := prNumberStyle.Render(fmt.Sprintf("#%d", pr.Number)) repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", pr.RepoName)) - title := prTitleStyle.Render(truncate(pr.Title, 50)) + title := prTitleStyle.Render(shared.Truncate(pr.Title, 50)) author := prAuthorStyle.Render("@" + pr.Author.Login) // Review status var status string switch pr.ReviewDecision { case "APPROVED": - status = prApprovedStyle.Render("✓ approved") + status = prApprovedStyle.Render("v approved") case "CHANGES_REQUESTED": - status = prChangesStyle.Render("● changes requested") + status = prChangesStyle.Render("* changes requested") default: - status = prPendingStyle.Render("○ pending review") + status = prPendingStyle.Render("o pending review") } // Draft indicator @@ -263,7 +265,7 @@ func printPR(pr GitHubPR) { draft = prDraftStyle.Render(" [draft]") } - age := formatAge(pr.CreatedAt) + age := shared.FormatAge(pr.CreatedAt) fmt.Printf(" %s %s %s%s %s %s %s\n", num, repo, title, draft, author, status, issueAgeStyle.Render(age)) } diff --git a/cmd/dev/sync.go b/cmd/dev/dev_sync.go similarity index 97% rename from cmd/dev/sync.go rename to cmd/dev/dev_sync.go index 3de7860d..580b12fc 100644 --- a/cmd/dev/sync.go +++ b/cmd/dev/dev_sync.go @@ -15,8 +15,8 @@ import ( "golang.org/x/text/language" ) -// AddSyncCommand adds the 'sync' command to the given parent command. -func AddSyncCommand(parent *clir.Command) { +// addSyncCommand adds the 'sync' command to the given parent command. +func addSyncCommand(parent *clir.Command) { syncCmd := parent.NewSubCommand("sync", "Synchronizes the public service APIs with their internal implementations.") syncCmd.LongDescription("This command scans the 'pkg' directory for services and ensures that the\ntop-level public API for each service is in sync with its internal implementation.\nIt automatically generates the necessary Go files with type aliases.") syncCmd.Action(func() error { diff --git a/cmd/dev/dev_vm.go b/cmd/dev/dev_vm.go new file mode 100644 index 00000000..66a14444 --- /dev/null +++ b/cmd/dev/dev_vm.go @@ -0,0 +1,504 @@ +package dev + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/host-uk/core/pkg/devops" + "github.com/leaanthony/clir" +) + +// addVMCommands adds the dev environment VM commands to the dev parent command. +// These are added as direct subcommands: core dev install, core dev boot, etc. +func addVMCommands(parent *clir.Command) { + addVMInstallCommand(parent) + addVMBootCommand(parent) + addVMStopCommand(parent) + addVMStatusCommand(parent) + addVMShellCommand(parent) + addVMServeCommand(parent) + addVMTestCommand(parent) + addVMClaudeCommand(parent) + addVMUpdateCommand(parent) +} + +// addVMInstallCommand adds the 'dev install' command. +func addVMInstallCommand(parent *clir.Command) { + installCmd := parent.NewSubCommand("install", "Download and install the dev environment image") + installCmd.LongDescription("Downloads the platform-specific dev environment image.\n\n" + + "The image includes Go, PHP, Node.js, Python, Docker, and Claude CLI.\n" + + "Downloads are cached at ~/.core/images/\n\n" + + "Examples:\n" + + " core dev install") + + installCmd.Action(func() error { + return runVMInstall() + }) +} + +func runVMInstall() error { + d, err := devops.New() + if err != nil { + return err + } + + if d.IsInstalled() { + fmt.Println(successStyle.Render("Dev environment already installed")) + fmt.Println() + fmt.Printf("Use %s to check for updates\n", dimStyle.Render("core dev update")) + return nil + } + + fmt.Printf("%s %s\n", dimStyle.Render("Image:"), devops.ImageName()) + fmt.Println() + fmt.Println("Downloading dev environment...") + fmt.Println() + + ctx := context.Background() + start := time.Now() + var lastProgress int64 + + err = d.Install(ctx, func(downloaded, total int64) { + if total > 0 { + pct := int(float64(downloaded) / float64(total) * 100) + if pct != int(float64(lastProgress)/float64(total)*100) { + fmt.Printf("\r%s %d%%", dimStyle.Render("Progress:"), pct) + lastProgress = downloaded + } + } + }) + + fmt.Println() // Clear progress line + + if err != nil { + return fmt.Errorf("install failed: %w", err) + } + + elapsed := time.Since(start).Round(time.Second) + fmt.Println() + fmt.Printf("%s in %s\n", successStyle.Render("Installed"), elapsed) + fmt.Println() + fmt.Printf("Start with: %s\n", dimStyle.Render("core dev boot")) + + return nil +} + +// addVMBootCommand adds the 'devops boot' command. +func addVMBootCommand(parent *clir.Command) { + var memory int + var cpus int + var fresh bool + + bootCmd := parent.NewSubCommand("boot", "Start the dev environment") + bootCmd.LongDescription("Boots the dev environment VM.\n\n" + + "Examples:\n" + + " core dev boot\n" + + " core dev boot --memory 8192 --cpus 4\n" + + " core dev boot --fresh") + + bootCmd.IntFlag("memory", "Memory in MB (default: 4096)", &memory) + bootCmd.IntFlag("cpus", "Number of CPUs (default: 2)", &cpus) + bootCmd.BoolFlag("fresh", "Stop existing and start fresh", &fresh) + + bootCmd.Action(func() error { + return runVMBoot(memory, cpus, fresh) + }) +} + +func runVMBoot(memory, cpus int, fresh bool) error { + d, err := devops.New() + if err != nil { + return err + } + + if !d.IsInstalled() { + return fmt.Errorf("dev environment not installed (run 'core dev install' first)") + } + + opts := devops.DefaultBootOptions() + if memory > 0 { + opts.Memory = memory + } + if cpus > 0 { + opts.CPUs = cpus + } + opts.Fresh = fresh + + fmt.Printf("%s %dMB, %d CPUs\n", dimStyle.Render("Config:"), opts.Memory, opts.CPUs) + fmt.Println() + fmt.Println("Booting dev environment...") + + ctx := context.Background() + if err := d.Boot(ctx, opts); err != nil { + return err + } + + fmt.Println() + fmt.Println(successStyle.Render("Dev environment running")) + fmt.Println() + fmt.Printf("Connect with: %s\n", dimStyle.Render("core dev shell")) + fmt.Printf("SSH port: %s\n", dimStyle.Render("2222")) + + return nil +} + +// addVMStopCommand adds the 'devops stop' command. +func addVMStopCommand(parent *clir.Command) { + stopCmd := parent.NewSubCommand("stop", "Stop the dev environment") + stopCmd.LongDescription("Stops the running dev environment VM.\n\n" + + "Examples:\n" + + " core dev stop") + + stopCmd.Action(func() error { + return runVMStop() + }) +} + +func runVMStop() error { + d, err := devops.New() + if err != nil { + return err + } + + ctx := context.Background() + running, err := d.IsRunning(ctx) + if err != nil { + return err + } + + if !running { + fmt.Println(dimStyle.Render("Dev environment is not running")) + return nil + } + + fmt.Println("Stopping dev environment...") + + if err := d.Stop(ctx); err != nil { + return err + } + + fmt.Println(successStyle.Render("Stopped")) + return nil +} + +// addVMStatusCommand adds the 'devops status' command. +func addVMStatusCommand(parent *clir.Command) { + statusCmd := parent.NewSubCommand("vm-status", "Show dev environment status") + statusCmd.LongDescription("Shows the current status of the dev environment.\n\n" + + "Examples:\n" + + " core dev vm-status") + + statusCmd.Action(func() error { + return runVMStatus() + }) +} + +func runVMStatus() error { + d, err := devops.New() + if err != nil { + return err + } + + ctx := context.Background() + status, err := d.Status(ctx) + if err != nil { + return err + } + + fmt.Println(headerStyle.Render("Dev Environment Status")) + fmt.Println() + + // Installation status + if status.Installed { + fmt.Printf("%s %s\n", dimStyle.Render("Installed:"), successStyle.Render("Yes")) + if status.ImageVersion != "" { + fmt.Printf("%s %s\n", dimStyle.Render("Version:"), status.ImageVersion) + } + } else { + fmt.Printf("%s %s\n", dimStyle.Render("Installed:"), errorStyle.Render("No")) + fmt.Println() + fmt.Printf("Install with: %s\n", dimStyle.Render("core dev install")) + return nil + } + + fmt.Println() + + // Running status + if status.Running { + fmt.Printf("%s %s\n", dimStyle.Render("Status:"), successStyle.Render("Running")) + fmt.Printf("%s %s\n", dimStyle.Render("Container:"), status.ContainerID[:8]) + fmt.Printf("%s %dMB\n", dimStyle.Render("Memory:"), status.Memory) + fmt.Printf("%s %d\n", dimStyle.Render("CPUs:"), status.CPUs) + fmt.Printf("%s %d\n", dimStyle.Render("SSH Port:"), status.SSHPort) + fmt.Printf("%s %s\n", dimStyle.Render("Uptime:"), formatVMUptime(status.Uptime)) + } else { + fmt.Printf("%s %s\n", dimStyle.Render("Status:"), dimStyle.Render("Stopped")) + fmt.Println() + fmt.Printf("Start with: %s\n", dimStyle.Render("core dev boot")) + } + + return nil +} + +func formatVMUptime(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) + } + return fmt.Sprintf("%dd %dh", int(d.Hours()/24), int(d.Hours())%24) +} + +// addVMShellCommand adds the 'devops shell' command. +func addVMShellCommand(parent *clir.Command) { + var console bool + + shellCmd := parent.NewSubCommand("shell", "Connect to the dev environment") + shellCmd.LongDescription("Opens an interactive shell in the dev environment.\n\n" + + "Uses SSH by default, or serial console with --console.\n\n" + + "Examples:\n" + + " core dev shell\n" + + " core dev shell --console\n" + + " core dev shell -- ls -la") + + shellCmd.BoolFlag("console", "Use serial console instead of SSH", &console) + + shellCmd.Action(func() error { + args := shellCmd.OtherArgs() + return runVMShell(console, args) + }) +} + +func runVMShell(console bool, command []string) error { + d, err := devops.New() + if err != nil { + return err + } + + opts := devops.ShellOptions{ + Console: console, + Command: command, + } + + ctx := context.Background() + return d.Shell(ctx, opts) +} + +// addVMServeCommand adds the 'devops serve' command. +func addVMServeCommand(parent *clir.Command) { + var port int + var path string + + serveCmd := parent.NewSubCommand("serve", "Mount project and start dev server") + serveCmd.LongDescription("Mounts the current project into the dev environment and starts a dev server.\n\n" + + "Auto-detects the appropriate serve command based on project files.\n\n" + + "Examples:\n" + + " core dev serve\n" + + " core dev serve --port 3000\n" + + " core dev serve --path public") + + serveCmd.IntFlag("port", "Port to serve on (default: 8000)", &port) + serveCmd.StringFlag("path", "Subdirectory to serve", &path) + + serveCmd.Action(func() error { + return runVMServe(port, path) + }) +} + +func runVMServe(port int, path string) error { + d, err := devops.New() + if err != nil { + return err + } + + projectDir, err := os.Getwd() + if err != nil { + return err + } + + opts := devops.ServeOptions{ + Port: port, + Path: path, + } + + ctx := context.Background() + return d.Serve(ctx, projectDir, opts) +} + +// addVMTestCommand adds the 'devops test' command. +func addVMTestCommand(parent *clir.Command) { + var name string + + testCmd := parent.NewSubCommand("test", "Run tests in the dev environment") + testCmd.LongDescription("Runs tests in the dev environment.\n\n" + + "Auto-detects the test command based on project files, or uses .core/test.yaml.\n\n" + + "Examples:\n" + + " core dev test\n" + + " core dev test --name integration\n" + + " core dev test -- go test -v ./...") + + testCmd.StringFlag("name", "Run named test command from .core/test.yaml", &name) + + testCmd.Action(func() error { + args := testCmd.OtherArgs() + return runVMTest(name, args) + }) +} + +func runVMTest(name string, command []string) error { + d, err := devops.New() + if err != nil { + return err + } + + projectDir, err := os.Getwd() + if err != nil { + return err + } + + opts := devops.TestOptions{ + Name: name, + Command: command, + } + + ctx := context.Background() + return d.Test(ctx, projectDir, opts) +} + +// addVMClaudeCommand adds the 'devops claude' command. +func addVMClaudeCommand(parent *clir.Command) { + var noAuth bool + var model string + var authFlags []string + + claudeCmd := parent.NewSubCommand("claude", "Start sandboxed Claude session") + claudeCmd.LongDescription("Starts a Claude Code session inside the dev environment sandbox.\n\n" + + "Provides isolation while forwarding selected credentials.\n" + + "Auto-boots the dev environment if not running.\n\n" + + "Auth options (default: all):\n" + + " gh - GitHub CLI auth\n" + + " anthropic - Anthropic API key\n" + + " ssh - SSH agent forwarding\n" + + " git - Git config (name, email)\n\n" + + "Examples:\n" + + " core dev claude\n" + + " core dev claude --model opus\n" + + " core dev claude --auth gh,anthropic\n" + + " core dev claude --no-auth") + + claudeCmd.BoolFlag("no-auth", "Don't forward any auth credentials", &noAuth) + claudeCmd.StringFlag("model", "Model to use (opus, sonnet)", &model) + claudeCmd.StringsFlag("auth", "Selective auth forwarding (gh,anthropic,ssh,git)", &authFlags) + + claudeCmd.Action(func() error { + return runVMClaude(noAuth, model, authFlags) + }) +} + +func runVMClaude(noAuth bool, model string, authFlags []string) error { + d, err := devops.New() + if err != nil { + return err + } + + projectDir, err := os.Getwd() + if err != nil { + return err + } + + opts := devops.ClaudeOptions{ + NoAuth: noAuth, + Model: model, + Auth: authFlags, + } + + ctx := context.Background() + return d.Claude(ctx, projectDir, opts) +} + +// addVMUpdateCommand adds the 'devops update' command. +func addVMUpdateCommand(parent *clir.Command) { + var apply bool + + updateCmd := parent.NewSubCommand("update", "Check for and apply updates") + updateCmd.LongDescription("Checks for dev environment updates and optionally applies them.\n\n" + + "Examples:\n" + + " core dev update\n" + + " core dev update --apply") + + updateCmd.BoolFlag("apply", "Download and apply the update", &apply) + + updateCmd.Action(func() error { + return runVMUpdate(apply) + }) +} + +func runVMUpdate(apply bool) error { + d, err := devops.New() + if err != nil { + return err + } + + ctx := context.Background() + + fmt.Println("Checking for updates...") + fmt.Println() + + current, latest, hasUpdate, err := d.CheckUpdate(ctx) + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + fmt.Printf("%s %s\n", dimStyle.Render("Current:"), valueStyle.Render(current)) + fmt.Printf("%s %s\n", dimStyle.Render("Latest:"), valueStyle.Render(latest)) + fmt.Println() + + if !hasUpdate { + fmt.Println(successStyle.Render("Already up to date")) + return nil + } + + fmt.Println(warningStyle.Render("Update available")) + fmt.Println() + + if !apply { + fmt.Printf("Run %s to update\n", dimStyle.Render("core dev update --apply")) + return nil + } + + // Stop if running + running, _ := d.IsRunning(ctx) + if running { + fmt.Println("Stopping current instance...") + _ = d.Stop(ctx) + } + + fmt.Println("Downloading update...") + fmt.Println() + + start := time.Now() + err = d.Install(ctx, func(downloaded, total int64) { + if total > 0 { + pct := int(float64(downloaded) / float64(total) * 100) + fmt.Printf("\r%s %d%%", dimStyle.Render("Progress:"), pct) + } + }) + + fmt.Println() + + if err != nil { + return fmt.Errorf("update failed: %w", err) + } + + elapsed := time.Since(start).Round(time.Second) + fmt.Println() + fmt.Printf("%s in %s\n", successStyle.Render("Updated"), elapsed) + + return nil +} diff --git a/cmd/dev/work.go b/cmd/dev/dev_work.go similarity index 77% rename from cmd/dev/work.go rename to cmd/dev/dev_work.go index a28a44cf..e40af5ec 100644 --- a/cmd/dev/work.go +++ b/cmd/dev/dev_work.go @@ -1,4 +1,3 @@ -// Package dev provides multi-repo development workflow commands. package dev import ( @@ -11,52 +10,14 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/pkg/git" "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) -var ( - // Table styles - headerStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#3b82f6")). // blue-500 - Padding(0, 1) - - cellStyle = lipgloss.NewStyle(). - Padding(0, 1) - - dirtyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ef4444")). // red-500 - Padding(0, 1) - - aheadStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#22c55e")). // green-500 - Padding(0, 1) - - cleanStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")). // gray-500 - Padding(0, 1) - - repoNameStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#e2e8f0")). // gray-200 - Padding(0, 1) - - successStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#22c55e")). // green-500 - Bold(true) - - errorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ef4444")). // red-500 - Bold(true) - - dimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) // gray-500 -) - -// AddWorkCommand adds the 'work' command to the given parent command. -func AddWorkCommand(parent *clir.Command) { +// addWorkCommand adds the 'work' command to the given parent command. +func addWorkCommand(parent *clir.Command) { var statusOnly bool var autoCommit bool var registryPath string @@ -156,14 +117,15 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error { // Auto-commit dirty repos if requested if autoCommit && len(dirtyRepos) > 0 { fmt.Println() - fmt.Printf("%s\n", headerStyle.Render("Committing dirty repos with Claude...")) + hdrStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#3b82f6")) + fmt.Printf("%s\n", hdrStyle.Render("Committing dirty repos with Claude...")) fmt.Println() for _, s := range dirtyRepos { if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil { - fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), s.Name, err) + fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err) } else { - fmt.Printf(" %s %s\n", successStyle.Render("✓"), s.Name) + fmt.Printf(" %s %s\n", successStyle.Render("v"), s.Name) } } @@ -205,7 +167,7 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error { } fmt.Println() - if !confirm("Push all?") { + if !shared.Confirm("Push all?") { fmt.Println("Aborted.") return nil } @@ -222,9 +184,9 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error { for _, r := range results { if r.Success { - fmt.Printf(" %s %s\n", successStyle.Render("✓"), r.Name) + fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name) } else { - fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), r.Name, r.Error) + fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, r.Error) } } @@ -252,7 +214,7 @@ func printStatusTable(statuses []git.RepoStatus) { ) // Print separator - fmt.Println(strings.Repeat("─", nameWidth+2+10+11+8+7)) + fmt.Println(strings.Repeat("-", nameWidth+2+10+11+8+7)) // Print rows for _, s := range statuses { @@ -309,12 +271,12 @@ func printStatusTable(statuses []git.RepoStatus) { func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error { // Load AGENTS.md context if available agentsPath := filepath.Join(filepath.Dir(registryPath), "AGENTS.md") - var context string + var agentContext string if data, err := os.ReadFile(agentsPath); err == nil { - context = string(data) + "\n\n" + agentContext = string(data) + "\n\n" } - prompt := context + "Review the uncommitted changes and create an appropriate commit. " + + prompt := agentContext + "Review the uncommitted changes and create an appropriate commit. " + "Use Co-Authored-By: Claude Opus 4.5 . Be concise." cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep") @@ -325,11 +287,3 @@ func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) return cmd.Run() } - -func confirm(prompt string) bool { - fmt.Printf("%s [y/N] ", prompt) - var response string - fmt.Scanln(&response) - response = strings.ToLower(strings.TrimSpace(response)) - return response == "y" || response == "yes" -} diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index cf53462c..0592a41e 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -2,19 +2,12 @@ package docs import ( - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - "github.com/charmbracelet/lipgloss" "github.com/host-uk/core/cmd/shared" - "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) -// Style and utility aliases +// Style and utility aliases from shared var ( repoNameStyle = shared.RepoNameStyle successStyle = shared.SuccessStyle @@ -24,6 +17,7 @@ var ( confirm = shared.Confirm ) +// Package-specific styles var ( docsFoundStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#22c55e")) // green-500 @@ -35,17 +29,6 @@ var ( Foreground(lipgloss.Color("#3b82f6")) // blue-500 ) -// RepoDocInfo holds documentation info for a repo -type RepoDocInfo struct { - Name string - Path string - HasDocs bool - Readme string - ClaudeMd string - Changelog string - DocsFiles []string // All files in docs/ directory (recursive) -} - // AddDocsCommand adds the 'docs' command to the given parent command. func AddDocsCommand(parent *clir.Cli) { docsCmd := parent.NewSubCommand("docs", "Documentation management") @@ -56,308 +39,3 @@ func AddDocsCommand(parent *clir.Cli) { addDocsSyncCommand(docsCmd) addDocsListCommand(docsCmd) } - -func addDocsSyncCommand(parent *clir.Command) { - var registryPath string - var dryRun bool - var outputDir string - - syncCmd := parent.NewSubCommand("sync", "Sync documentation to core-php/docs/packages/") - syncCmd.StringFlag("registry", "Path to repos.yaml", ®istryPath) - syncCmd.BoolFlag("dry-run", "Show what would be synced without copying", &dryRun) - syncCmd.StringFlag("output", "Output directory (default: core-php/docs/packages)", &outputDir) - - syncCmd.Action(func() error { - return runDocsSync(registryPath, outputDir, dryRun) - }) -} - -func addDocsListCommand(parent *clir.Command) { - var registryPath string - - listCmd := parent.NewSubCommand("list", "List documentation across repos") - listCmd.StringFlag("registry", "Path to repos.yaml", ®istryPath) - - listCmd.Action(func() error { - return runDocsList(registryPath) - }) -} - -// packageOutputName maps repo name to output folder name -func packageOutputName(repoName string) string { - // core -> go (the Go framework) - if repoName == "core" { - return "go" - } - // core-admin -> admin, core-api -> api, etc. - if strings.HasPrefix(repoName, "core-") { - return strings.TrimPrefix(repoName, "core-") - } - return repoName -} - -// shouldSyncRepo returns true if this repo should be synced -func shouldSyncRepo(repoName string) bool { - // Skip core-php (it's the destination) - if repoName == "core-php" { - return false - } - // Skip template - if repoName == "core-template" { - return false - } - return true -} - -func runDocsSync(registryPath string, outputDir string, dryRun bool) error { - // Find or use provided registry - reg, basePath, err := loadRegistry(registryPath) - if err != nil { - return err - } - - // Default output to core-php/docs/packages relative to registry - if outputDir == "" { - outputDir = filepath.Join(basePath, "core-php", "docs", "packages") - } - - // Scan all repos for docs - var docsInfo []RepoDocInfo - for _, repo := range reg.List() { - if !shouldSyncRepo(repo.Name) { - continue - } - info := scanRepoDocs(repo) - if info.HasDocs && len(info.DocsFiles) > 0 { - docsInfo = append(docsInfo, info) - } - } - - if len(docsInfo) == 0 { - fmt.Println("No documentation found in any repos.") - return nil - } - - fmt.Printf("\n%s %d repo(s) with docs/ directories\n\n", dimStyle.Render("Found"), len(docsInfo)) - - // Show what will be synced - var totalFiles int - for _, info := range docsInfo { - totalFiles += len(info.DocsFiles) - outName := packageOutputName(info.Name) - fmt.Printf(" %s → %s %s\n", - repoNameStyle.Render(info.Name), - docsFileStyle.Render("packages/"+outName+"/"), - dimStyle.Render(fmt.Sprintf("(%d files)", len(info.DocsFiles)))) - - for _, f := range info.DocsFiles { - fmt.Printf(" %s\n", dimStyle.Render(f)) - } - } - - fmt.Printf("\n%s %d files from %d repos → %s\n", - dimStyle.Render("Total:"), totalFiles, len(docsInfo), outputDir) - - if dryRun { - fmt.Printf("\n%s\n", dimStyle.Render("Dry run - no files copied")) - return nil - } - - // Confirm - fmt.Println() - if !confirm("Sync?") { - fmt.Println("Aborted.") - return nil - } - - // Sync docs - fmt.Println() - var synced int - for _, info := range docsInfo { - outName := packageOutputName(info.Name) - repoOutDir := filepath.Join(outputDir, outName) - - // Clear existing directory - os.RemoveAll(repoOutDir) - - if err := os.MkdirAll(repoOutDir, 0755); err != nil { - fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err) - continue - } - - // Copy all docs files - docsDir := filepath.Join(info.Path, "docs") - for _, f := range info.DocsFiles { - src := filepath.Join(docsDir, f) - dst := filepath.Join(repoOutDir, f) - os.MkdirAll(filepath.Dir(dst), 0755) - if err := copyFile(src, dst); err != nil { - fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), f, err) - } - } - - fmt.Printf(" %s %s → packages/%s/\n", successStyle.Render("✓"), info.Name, outName) - synced++ - } - - fmt.Printf("\n%s Synced %d packages\n", successStyle.Render("Done:"), synced) - - return nil -} - -func runDocsList(registryPath string) error { - reg, _, err := loadRegistry(registryPath) - if err != nil { - return err - } - - fmt.Printf("\n%-20s %-8s %-8s %-10s %s\n", - headerStyle.Render("Repo"), - headerStyle.Render("README"), - headerStyle.Render("CLAUDE"), - headerStyle.Render("CHANGELOG"), - headerStyle.Render("docs/"), - ) - fmt.Println(strings.Repeat("─", 70)) - - var withDocs, withoutDocs int - for _, repo := range reg.List() { - info := scanRepoDocs(repo) - - readme := docsMissingStyle.Render("—") - if info.Readme != "" { - readme = docsFoundStyle.Render("✓") - } - - claude := docsMissingStyle.Render("—") - if info.ClaudeMd != "" { - claude = docsFoundStyle.Render("✓") - } - - changelog := docsMissingStyle.Render("—") - if info.Changelog != "" { - changelog = docsFoundStyle.Render("✓") - } - - docsDir := docsMissingStyle.Render("—") - if len(info.DocsFiles) > 0 { - docsDir = docsFoundStyle.Render(fmt.Sprintf("%d files", len(info.DocsFiles))) - } - - fmt.Printf("%-20s %-8s %-8s %-10s %s\n", - repoNameStyle.Render(info.Name), - readme, - claude, - changelog, - docsDir, - ) - - if info.HasDocs { - withDocs++ - } else { - withoutDocs++ - } - } - - fmt.Println() - fmt.Printf("%s %d with docs, %d without\n", - dimStyle.Render("Coverage:"), - withDocs, - withoutDocs, - ) - - return nil -} - -func loadRegistry(registryPath string) (*repos.Registry, string, error) { - var reg *repos.Registry - var err error - var basePath string - - if registryPath != "" { - reg, err = repos.LoadRegistry(registryPath) - if err != nil { - return nil, "", fmt.Errorf("failed to load registry: %w", err) - } - basePath = filepath.Dir(registryPath) - } else { - registryPath, err = repos.FindRegistry() - if err == nil { - reg, err = repos.LoadRegistry(registryPath) - if err != nil { - return nil, "", fmt.Errorf("failed to load registry: %w", err) - } - basePath = filepath.Dir(registryPath) - } else { - cwd, _ := os.Getwd() - reg, err = repos.ScanDirectory(cwd) - if err != nil { - return nil, "", fmt.Errorf("failed to scan directory: %w", err) - } - basePath = cwd - } - } - - return reg, basePath, nil -} - -func scanRepoDocs(repo *repos.Repo) RepoDocInfo { - info := RepoDocInfo{ - Name: repo.Name, - Path: repo.Path, - } - - // Check for README.md - readme := filepath.Join(repo.Path, "README.md") - if _, err := os.Stat(readme); err == nil { - info.Readme = readme - info.HasDocs = true - } - - // Check for CLAUDE.md - claudeMd := filepath.Join(repo.Path, "CLAUDE.md") - if _, err := os.Stat(claudeMd); err == nil { - info.ClaudeMd = claudeMd - info.HasDocs = true - } - - // Check for CHANGELOG.md - changelog := filepath.Join(repo.Path, "CHANGELOG.md") - if _, err := os.Stat(changelog); err == nil { - info.Changelog = changelog - info.HasDocs = true - } - - // Recursively scan docs/ directory for .md files - docsDir := filepath.Join(repo.Path, "docs") - if _, err := os.Stat(docsDir); err == nil { - filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - // Skip plans/ directory - if d.IsDir() && d.Name() == "plans" { - return filepath.SkipDir - } - // Skip non-markdown files - if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") { - return nil - } - // Get relative path from docs/ - relPath, _ := filepath.Rel(docsDir, path) - info.DocsFiles = append(info.DocsFiles, relPath) - info.HasDocs = true - return nil - }) - } - - return info -} - -func copyFile(src, dst string) error { - data, err := os.ReadFile(src) - if err != nil { - return err - } - return os.WriteFile(dst, data, 0644) -} diff --git a/cmd/docs/list.go b/cmd/docs/list.go new file mode 100644 index 00000000..b371e5b6 --- /dev/null +++ b/cmd/docs/list.go @@ -0,0 +1,83 @@ +package docs + +import ( + "fmt" + "strings" + + "github.com/leaanthony/clir" +) + +func addDocsListCommand(parent *clir.Command) { + var registryPath string + + listCmd := parent.NewSubCommand("list", "List documentation across repos") + listCmd.StringFlag("registry", "Path to repos.yaml", ®istryPath) + + listCmd.Action(func() error { + return runDocsList(registryPath) + }) +} + +func runDocsList(registryPath string) error { + reg, _, err := loadRegistry(registryPath) + if err != nil { + return err + } + + fmt.Printf("\n%-20s %-8s %-8s %-10s %s\n", + headerStyle.Render("Repo"), + headerStyle.Render("README"), + headerStyle.Render("CLAUDE"), + headerStyle.Render("CHANGELOG"), + headerStyle.Render("docs/"), + ) + fmt.Println(strings.Repeat("─", 70)) + + var withDocs, withoutDocs int + for _, repo := range reg.List() { + info := scanRepoDocs(repo) + + readme := docsMissingStyle.Render("—") + if info.Readme != "" { + readme = docsFoundStyle.Render("✓") + } + + claude := docsMissingStyle.Render("—") + if info.ClaudeMd != "" { + claude = docsFoundStyle.Render("✓") + } + + changelog := docsMissingStyle.Render("—") + if info.Changelog != "" { + changelog = docsFoundStyle.Render("✓") + } + + docsDir := docsMissingStyle.Render("—") + if len(info.DocsFiles) > 0 { + docsDir = docsFoundStyle.Render(fmt.Sprintf("%d files", len(info.DocsFiles))) + } + + fmt.Printf("%-20s %-8s %-8s %-10s %s\n", + repoNameStyle.Render(info.Name), + readme, + claude, + changelog, + docsDir, + ) + + if info.HasDocs { + withDocs++ + } else { + withoutDocs++ + } + } + + fmt.Println() + fmt.Printf("%s %d with docs, %d without\n", + dimStyle.Render("Coverage:"), + withDocs, + withoutDocs, + ) + + return nil +} diff --git a/cmd/docs/scan.go b/cmd/docs/scan.go new file mode 100644 index 00000000..e8d089b6 --- /dev/null +++ b/cmd/docs/scan.go @@ -0,0 +1,115 @@ +package docs + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/host-uk/core/pkg/repos" +) + +// RepoDocInfo holds documentation info for a repo +type RepoDocInfo struct { + Name string + Path string + HasDocs bool + Readme string + ClaudeMd string + Changelog string + DocsFiles []string // All files in docs/ directory (recursive) +} + +func loadRegistry(registryPath string) (*repos.Registry, string, error) { + var reg *repos.Registry + var err error + var basePath string + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return nil, "", fmt.Errorf("failed to load registry: %w", err) + } + basePath = filepath.Dir(registryPath) + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return nil, "", fmt.Errorf("failed to load registry: %w", err) + } + basePath = filepath.Dir(registryPath) + } else { + cwd, _ := os.Getwd() + reg, err = repos.ScanDirectory(cwd) + if err != nil { + return nil, "", fmt.Errorf("failed to scan directory: %w", err) + } + basePath = cwd + } + } + + return reg, basePath, nil +} + +func scanRepoDocs(repo *repos.Repo) RepoDocInfo { + info := RepoDocInfo{ + Name: repo.Name, + Path: repo.Path, + } + + // Check for README.md + readme := filepath.Join(repo.Path, "README.md") + if _, err := os.Stat(readme); err == nil { + info.Readme = readme + info.HasDocs = true + } + + // Check for CLAUDE.md + claudeMd := filepath.Join(repo.Path, "CLAUDE.md") + if _, err := os.Stat(claudeMd); err == nil { + info.ClaudeMd = claudeMd + info.HasDocs = true + } + + // Check for CHANGELOG.md + changelog := filepath.Join(repo.Path, "CHANGELOG.md") + if _, err := os.Stat(changelog); err == nil { + info.Changelog = changelog + info.HasDocs = true + } + + // Recursively scan docs/ directory for .md files + docsDir := filepath.Join(repo.Path, "docs") + if _, err := os.Stat(docsDir); err == nil { + filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + // Skip plans/ directory + if d.IsDir() && d.Name() == "plans" { + return filepath.SkipDir + } + // Skip non-markdown files + if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") { + return nil + } + // Get relative path from docs/ + relPath, _ := filepath.Rel(docsDir, path) + info.DocsFiles = append(info.DocsFiles, relPath) + info.HasDocs = true + return nil + }) + } + + return info +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0644) +} diff --git a/cmd/docs/sync.go b/cmd/docs/sync.go new file mode 100644 index 00000000..7c47f804 --- /dev/null +++ b/cmd/docs/sync.go @@ -0,0 +1,147 @@ +package docs + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/leaanthony/clir" +) + +func addDocsSyncCommand(parent *clir.Command) { + var registryPath string + var dryRun bool + var outputDir string + + syncCmd := parent.NewSubCommand("sync", "Sync documentation to core-php/docs/packages/") + syncCmd.StringFlag("registry", "Path to repos.yaml", ®istryPath) + syncCmd.BoolFlag("dry-run", "Show what would be synced without copying", &dryRun) + syncCmd.StringFlag("output", "Output directory (default: core-php/docs/packages)", &outputDir) + + syncCmd.Action(func() error { + return runDocsSync(registryPath, outputDir, dryRun) + }) +} + +// packageOutputName maps repo name to output folder name +func packageOutputName(repoName string) string { + // core -> go (the Go framework) + if repoName == "core" { + return "go" + } + // core-admin -> admin, core-api -> api, etc. + if strings.HasPrefix(repoName, "core-") { + return strings.TrimPrefix(repoName, "core-") + } + return repoName +} + +// shouldSyncRepo returns true if this repo should be synced +func shouldSyncRepo(repoName string) bool { + // Skip core-php (it's the destination) + if repoName == "core-php" { + return false + } + // Skip template + if repoName == "core-template" { + return false + } + return true +} + +func runDocsSync(registryPath string, outputDir string, dryRun bool) error { + // Find or use provided registry + reg, basePath, err := loadRegistry(registryPath) + if err != nil { + return err + } + + // Default output to core-php/docs/packages relative to registry + if outputDir == "" { + outputDir = filepath.Join(basePath, "core-php", "docs", "packages") + } + + // Scan all repos for docs + var docsInfo []RepoDocInfo + for _, repo := range reg.List() { + if !shouldSyncRepo(repo.Name) { + continue + } + info := scanRepoDocs(repo) + if info.HasDocs && len(info.DocsFiles) > 0 { + docsInfo = append(docsInfo, info) + } + } + + if len(docsInfo) == 0 { + fmt.Println("No documentation found in any repos.") + return nil + } + + fmt.Printf("\n%s %d repo(s) with docs/ directories\n\n", dimStyle.Render("Found"), len(docsInfo)) + + // Show what will be synced + var totalFiles int + for _, info := range docsInfo { + totalFiles += len(info.DocsFiles) + outName := packageOutputName(info.Name) + fmt.Printf(" %s → %s %s\n", + repoNameStyle.Render(info.Name), + docsFileStyle.Render("packages/"+outName+"/"), + dimStyle.Render(fmt.Sprintf("(%d files)", len(info.DocsFiles)))) + + for _, f := range info.DocsFiles { + fmt.Printf(" %s\n", dimStyle.Render(f)) + } + } + + fmt.Printf("\n%s %d files from %d repos → %s\n", + dimStyle.Render("Total:"), totalFiles, len(docsInfo), outputDir) + + if dryRun { + fmt.Printf("\n%s\n", dimStyle.Render("Dry run - no files copied")) + return nil + } + + // Confirm + fmt.Println() + if !confirm("Sync?") { + fmt.Println("Aborted.") + return nil + } + + // Sync docs + fmt.Println() + var synced int + for _, info := range docsInfo { + outName := packageOutputName(info.Name) + repoOutDir := filepath.Join(outputDir, outName) + + // Clear existing directory + os.RemoveAll(repoOutDir) + + if err := os.MkdirAll(repoOutDir, 0755); err != nil { + fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err) + continue + } + + // Copy all docs files + docsDir := filepath.Join(info.Path, "docs") + for _, f := range info.DocsFiles { + src := filepath.Join(docsDir, f) + dst := filepath.Join(repoOutDir, f) + os.MkdirAll(filepath.Dir(dst), 0755) + if err := copyFile(src, dst); err != nil { + fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), f, err) + } + } + + fmt.Printf(" %s %s → packages/%s/\n", successStyle.Render("✓"), info.Name, outName) + synced++ + } + + fmt.Printf("\n%s Synced %d packages\n", successStyle.Render("Done:"), synced) + + return nil +} diff --git a/cmd/doctor/checks.go b/cmd/doctor/checks.go new file mode 100644 index 00000000..6b155301 --- /dev/null +++ b/cmd/doctor/checks.go @@ -0,0 +1,95 @@ +package doctor + +import ( + "os/exec" + "strings" +) + +// check represents a tool check configuration +type check struct { + name string + description string + command string + args []string + versionFlag string +} + +// requiredChecks are tools that must be installed +var requiredChecks = []check{ + { + name: "Git", + description: "Version control", + command: "git", + args: []string{"--version"}, + versionFlag: "--version", + }, + { + name: "GitHub CLI", + description: "GitHub integration (issues, PRs, CI)", + command: "gh", + args: []string{"--version"}, + versionFlag: "--version", + }, + { + name: "PHP", + description: "Laravel packages", + command: "php", + args: []string{"-v"}, + versionFlag: "-v", + }, + { + name: "Composer", + description: "PHP dependencies", + command: "composer", + args: []string{"--version"}, + versionFlag: "--version", + }, + { + name: "Node.js", + description: "Frontend builds", + command: "node", + args: []string{"--version"}, + versionFlag: "--version", + }, +} + +// optionalChecks are tools that are nice to have +var optionalChecks = []check{ + { + name: "pnpm", + description: "Fast package manager", + command: "pnpm", + args: []string{"--version"}, + versionFlag: "--version", + }, + { + name: "Claude Code", + description: "AI-assisted development", + command: "claude", + args: []string{"--version"}, + versionFlag: "--version", + }, + { + name: "Docker", + description: "Container runtime", + command: "docker", + args: []string{"--version"}, + versionFlag: "--version", + }, +} + +// runCheck executes a tool check and returns success status and version info +func runCheck(c check) (bool, string) { + cmd := exec.Command(c.command, c.args...) + output, err := cmd.CombinedOutput() + if err != nil { + return false, "" + } + + // Extract first line as version + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) > 0 { + return true, strings.TrimSpace(lines[0]) + } + return true, "" +} diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 76343615..fbf3a20e 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -3,18 +3,12 @@ package doctor import ( "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" "github.com/host-uk/core/cmd/shared" - "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) -// Style aliases +// Style aliases from shared var ( successStyle = shared.SuccessStyle errorStyle = shared.ErrorStyle @@ -36,95 +30,15 @@ func AddDoctorCommand(parent *clir.Cli) { }) } -type check struct { - name string - description string - command string - args []string - required bool - versionFlag string -} - func runDoctor(verbose bool) error { fmt.Println("Checking development environment...") fmt.Println() - checks := []check{ - // Required tools - { - name: "Git", - description: "Version control", - command: "git", - args: []string{"--version"}, - required: true, - versionFlag: "--version", - }, - { - name: "GitHub CLI", - description: "GitHub integration (issues, PRs, CI)", - command: "gh", - args: []string{"--version"}, - required: true, - versionFlag: "--version", - }, - { - name: "PHP", - description: "Laravel packages", - command: "php", - args: []string{"-v"}, - required: true, - versionFlag: "-v", - }, - { - name: "Composer", - description: "PHP dependencies", - command: "composer", - args: []string{"--version"}, - required: true, - versionFlag: "--version", - }, - { - name: "Node.js", - description: "Frontend builds", - command: "node", - args: []string{"--version"}, - required: true, - versionFlag: "--version", - }, - // Optional tools - { - name: "pnpm", - description: "Fast package manager", - command: "pnpm", - args: []string{"--version"}, - required: false, - versionFlag: "--version", - }, - { - name: "Claude Code", - description: "AI-assisted development", - command: "claude", - args: []string{"--version"}, - required: false, - versionFlag: "--version", - }, - { - name: "Docker", - description: "Container runtime", - command: "docker", - args: []string{"--version"}, - required: false, - versionFlag: "--version", - }, - } - var passed, failed, optional int + // Check required tools fmt.Println("Required:") - for _, c := range checks { - if !c.required { - continue - } + for _, c := range requiredChecks { ok, version := runCheck(c) if ok { if verbose && version != "" { @@ -139,11 +53,9 @@ func runDoctor(verbose bool) error { } } + // Check optional tools fmt.Println("\nOptional:") - for _, c := range checks { - if c.required { - continue - } + for _, c := range optionalChecks { ok, version := runCheck(c) if ok { if verbose && version != "" { @@ -158,7 +70,7 @@ func runDoctor(verbose bool) error { } } - // Check SSH + // Check GitHub access fmt.Println("\nGitHub Access:") if checkGitHubSSH() { fmt.Printf(" %s SSH key found\n", successStyle.Render("✓")) @@ -176,38 +88,7 @@ func runDoctor(verbose bool) error { // Check workspace fmt.Println("\nWorkspace:") - registryPath, err := repos.FindRegistry() - if err == nil { - fmt.Printf(" %s Found repos.yaml at %s\n", successStyle.Render("✓"), registryPath) - - reg, err := repos.LoadRegistry(registryPath) - if err == nil { - basePath := reg.BasePath - if basePath == "" { - basePath = "./packages" - } - if !filepath.IsAbs(basePath) { - basePath = filepath.Join(filepath.Dir(registryPath), basePath) - } - if strings.HasPrefix(basePath, "~/") { - home, _ := os.UserHomeDir() - basePath = filepath.Join(home, basePath[2:]) - } - - // Count existing repos - allRepos := reg.List() - var cloned int - for _, repo := range allRepos { - repoPath := filepath.Join(basePath, repo.Name) - if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { - cloned++ - } - } - fmt.Printf(" %s %d/%d repos cloned\n", successStyle.Render("✓"), cloned, len(allRepos)) - } - } else { - fmt.Printf(" %s No repos.yaml found (run from workspace directory)\n", dimStyle.Render("○")) - } + checkWorkspace() // Summary fmt.Println() @@ -221,63 +102,3 @@ func runDoctor(verbose bool) error { fmt.Printf("%s Environment ready\n", successStyle.Render("Doctor:")) return nil } - -func runCheck(c check) (bool, string) { - cmd := exec.Command(c.command, c.args...) - output, err := cmd.CombinedOutput() - if err != nil { - return false, "" - } - - // Extract first line as version - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(lines) > 0 { - return true, strings.TrimSpace(lines[0]) - } - return true, "" -} - -func checkGitHubSSH() bool { - // Just check if SSH keys exist - don't try to authenticate - // (key might be locked/passphrase protected) - home, err := os.UserHomeDir() - if err != nil { - return false - } - - sshDir := filepath.Join(home, ".ssh") - keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"} - - for _, key := range keyPatterns { - keyPath := filepath.Join(sshDir, key) - if _, err := os.Stat(keyPath); err == nil { - return true - } - } - - return false -} - -func checkGitHubCLI() bool { - cmd := exec.Command("gh", "auth", "status") - output, _ := cmd.CombinedOutput() - // Check for any successful login (even if there's also a failing token) - return strings.Contains(string(output), "Logged in to") -} - -func printInstallInstructions() { - switch runtime.GOOS { - case "darwin": - fmt.Println(" brew install git gh php composer node pnpm docker") - fmt.Println(" brew install --cask claude") - case "linux": - fmt.Println(" # Install via your package manager or:") - fmt.Println(" # Git: apt install git") - fmt.Println(" # GitHub CLI: https://cli.github.com/") - fmt.Println(" # PHP: apt install php8.3-cli") - fmt.Println(" # Node: https://nodejs.org/") - fmt.Println(" # pnpm: npm install -g pnpm") - default: - fmt.Println(" See documentation for your OS") - } -} diff --git a/cmd/doctor/environment.go b/cmd/doctor/environment.go new file mode 100644 index 00000000..ed144689 --- /dev/null +++ b/cmd/doctor/environment.go @@ -0,0 +1,77 @@ +package doctor + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/host-uk/core/pkg/repos" +) + +// checkGitHubSSH checks if SSH keys exist for GitHub access +func checkGitHubSSH() bool { + // Just check if SSH keys exist - don't try to authenticate + // (key might be locked/passphrase protected) + home, err := os.UserHomeDir() + if err != nil { + return false + } + + sshDir := filepath.Join(home, ".ssh") + keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"} + + for _, key := range keyPatterns { + keyPath := filepath.Join(sshDir, key) + if _, err := os.Stat(keyPath); err == nil { + return true + } + } + + return false +} + +// checkGitHubCLI checks if the GitHub CLI is authenticated +func checkGitHubCLI() bool { + cmd := exec.Command("gh", "auth", "status") + output, _ := cmd.CombinedOutput() + // Check for any successful login (even if there's also a failing token) + return strings.Contains(string(output), "Logged in to") +} + +// checkWorkspace checks for repos.yaml and counts cloned repos +func checkWorkspace() { + registryPath, err := repos.FindRegistry() + if err == nil { + fmt.Printf(" %s Found repos.yaml at %s\n", successStyle.Render("✓"), registryPath) + + reg, err := repos.LoadRegistry(registryPath) + if err == nil { + basePath := reg.BasePath + if basePath == "" { + basePath = "./packages" + } + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(filepath.Dir(registryPath), basePath) + } + if strings.HasPrefix(basePath, "~/") { + home, _ := os.UserHomeDir() + basePath = filepath.Join(home, basePath[2:]) + } + + // Count existing repos + allRepos := reg.List() + var cloned int + for _, repo := range allRepos { + repoPath := filepath.Join(basePath, repo.Name) + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + cloned++ + } + } + fmt.Printf(" %s %d/%d repos cloned\n", successStyle.Render("✓"), cloned, len(allRepos)) + } + } else { + fmt.Printf(" %s No repos.yaml found (run from workspace directory)\n", dimStyle.Render("○")) + } +} diff --git a/cmd/doctor/install.go b/cmd/doctor/install.go new file mode 100644 index 00000000..3477d0f4 --- /dev/null +++ b/cmd/doctor/install.go @@ -0,0 +1,24 @@ +package doctor + +import ( + "fmt" + "runtime" +) + +// printInstallInstructions prints OS-specific installation instructions +func printInstallInstructions() { + switch runtime.GOOS { + case "darwin": + fmt.Println(" brew install git gh php composer node pnpm docker") + fmt.Println(" brew install --cask claude") + case "linux": + fmt.Println(" # Install via your package manager or:") + fmt.Println(" # Git: apt install git") + fmt.Println(" # GitHub CLI: https://cli.github.com/") + fmt.Println(" # PHP: apt install php8.3-cli") + fmt.Println(" # Node: https://nodejs.org/") + fmt.Println(" # pnpm: npm install -g pnpm") + default: + fmt.Println(" See documentation for your OS") + } +} diff --git a/cmd/go/go.go b/cmd/go/go.go index 52e0aaec..7b1ca163 100644 --- a/cmd/go/go.go +++ b/cmd/go/go.go @@ -4,14 +4,6 @@ package gocmd import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - - "github.com/charmbracelet/lipgloss" "github.com/host-uk/core/cmd/shared" "github.com/leaanthony/clir" ) @@ -44,590 +36,3 @@ func AddGoCommands(parent *clir.Cli) { addGoModCommand(goCmd) addGoWorkCommand(goCmd) } - -func addGoTestCommand(parent *clir.Command) { - var ( - coverage bool - pkg string - run string - short bool - race bool - json bool - verbose bool - ) - - testCmd := parent.NewSubCommand("test", "Run tests with coverage") - testCmd.LongDescription("Run Go tests with coverage reporting.\n\n" + - "Sets MACOSX_DEPLOYMENT_TARGET=26.0 to suppress linker warnings.\n" + - "Filters noisy output and provides colour-coded coverage.\n\n" + - "Examples:\n" + - " core go test\n" + - " core go test --coverage\n" + - " core go test --pkg ./pkg/crypt\n" + - " core go test --run TestHash") - - testCmd.BoolFlag("coverage", "Show detailed per-package coverage", &coverage) - testCmd.StringFlag("pkg", "Package to test (default: ./...)", &pkg) - testCmd.StringFlag("run", "Run only tests matching regexp", &run) - testCmd.BoolFlag("short", "Run only short tests", &short) - testCmd.BoolFlag("race", "Enable race detector", &race) - testCmd.BoolFlag("json", "Output JSON results", &json) - testCmd.BoolFlag("v", "Verbose output", &verbose) - - testCmd.Action(func() error { - return runGoTest(coverage, pkg, run, short, race, json, verbose) - }) -} - -func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose bool) error { - if pkg == "" { - pkg = "./..." - } - - args := []string{"test"} - - if coverage { - args = append(args, "-cover") - } else { - args = append(args, "-cover") - } - - if run != "" { - args = append(args, "-run", run) - } - if short { - args = append(args, "-short") - } - if race { - args = append(args, "-race") - } - if verbose { - args = append(args, "-v") - } - - args = append(args, pkg) - - if !jsonOut { - fmt.Printf("%s Running tests\n", dimStyle.Render("Test:")) - fmt.Printf(" %s %s\n", dimStyle.Render("Package:"), pkg) - fmt.Println() - } - - cmd := exec.Command("go", args...) - cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0") - cmd.Dir, _ = os.Getwd() - - output, err := cmd.CombinedOutput() - outputStr := string(output) - - // Filter linker warnings - lines := strings.Split(outputStr, "\n") - var filtered []string - for _, line := range lines { - if !strings.Contains(line, "ld: warning:") { - filtered = append(filtered, line) - } - } - outputStr = strings.Join(filtered, "\n") - - // Parse results - passed, failed, skipped := parseTestResults(outputStr) - cov := parseOverallCoverage(outputStr) - - if jsonOut { - fmt.Printf(`{"passed":%d,"failed":%d,"skipped":%d,"coverage":%.1f,"exit_code":%d}`, - passed, failed, skipped, cov, cmd.ProcessState.ExitCode()) - fmt.Println() - return err - } - - // Print filtered output if verbose or failed - if verbose || err != nil { - fmt.Println(outputStr) - } - - // Summary - if err == nil { - fmt.Printf(" %s %d passed\n", successStyle.Render("✓"), passed) - } else { - fmt.Printf(" %s %d passed, %d failed\n", errorStyle.Render("✗"), passed, failed) - } - - if cov > 0 { - covStyle := successStyle - if cov < 50 { - covStyle = errorStyle - } else if cov < 80 { - covStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b")) - } - fmt.Printf("\n %s %s\n", dimStyle.Render("Coverage:"), covStyle.Render(fmt.Sprintf("%.1f%%", cov))) - } - - if err == nil { - fmt.Printf("\n%s\n", successStyle.Render("PASS All tests passed")) - } else { - fmt.Printf("\n%s\n", errorStyle.Render("FAIL Some tests failed")) - } - - return err -} - -func parseTestResults(output string) (passed, failed, skipped int) { - passRe := regexp.MustCompile(`(?m)^ok\s+`) - failRe := regexp.MustCompile(`(?m)^FAIL\s+`) - skipRe := regexp.MustCompile(`(?m)^\?\s+`) - - passed = len(passRe.FindAllString(output, -1)) - failed = len(failRe.FindAllString(output, -1)) - skipped = len(skipRe.FindAllString(output, -1)) - return -} - -func parseOverallCoverage(output string) float64 { - re := regexp.MustCompile(`coverage:\s+([\d.]+)%`) - matches := re.FindAllStringSubmatch(output, -1) - if len(matches) == 0 { - return 0 - } - - var total float64 - for _, m := range matches { - var cov float64 - fmt.Sscanf(m[1], "%f", &cov) - total += cov - } - return total / float64(len(matches)) -} - -func addGoCovCommand(parent *clir.Command) { - var ( - pkg string - html bool - open bool - threshold float64 - ) - - covCmd := parent.NewSubCommand("cov", "Run tests with coverage report") - covCmd.LongDescription("Run tests and generate coverage report.\n\n" + - "Examples:\n" + - " core go cov # Run with coverage summary\n" + - " core go cov --html # Generate HTML report\n" + - " core go cov --open # Generate and open HTML report\n" + - " core go cov --threshold 80 # Fail if coverage < 80%") - - covCmd.StringFlag("pkg", "Package to test (default: ./...)", &pkg) - covCmd.BoolFlag("html", "Generate HTML coverage report", &html) - covCmd.BoolFlag("open", "Generate and open HTML report in browser", &open) - covCmd.Float64Flag("threshold", "Minimum coverage percentage (exit 1 if below)", &threshold) - - covCmd.Action(func() error { - if pkg == "" { - // Auto-discover packages with tests - pkgs, err := findTestPackages(".") - if err != nil { - return fmt.Errorf("failed to discover test packages: %w", err) - } - if len(pkgs) == 0 { - return fmt.Errorf("no test packages found") - } - pkg = strings.Join(pkgs, " ") - } - - // Create temp file for coverage data - covFile, err := os.CreateTemp("", "coverage-*.out") - if err != nil { - return fmt.Errorf("failed to create coverage file: %w", err) - } - covPath := covFile.Name() - covFile.Close() - defer os.Remove(covPath) - - fmt.Printf("%s Running tests with coverage\n", dimStyle.Render("Coverage:")) - // Truncate package list if too long for display - displayPkg := pkg - if len(displayPkg) > 60 { - displayPkg = displayPkg[:57] + "..." - } - fmt.Printf(" %s %s\n", dimStyle.Render("Package:"), displayPkg) - fmt.Println() - - // Run tests with coverage - // We need to split pkg into individual arguments if it contains spaces - pkgArgs := strings.Fields(pkg) - args := append([]string{"test", "-coverprofile=" + covPath, "-covermode=atomic"}, pkgArgs...) - - cmd := exec.Command("go", args...) - cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - testErr := cmd.Run() - - // Get coverage percentage - covCmd := exec.Command("go", "tool", "cover", "-func="+covPath) - covOutput, err := covCmd.Output() - if err != nil { - if testErr != nil { - return testErr - } - return fmt.Errorf("failed to get coverage: %w", err) - } - - // Parse total coverage from last line - lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n") - var totalCov float64 - if len(lines) > 0 { - lastLine := lines[len(lines)-1] - // Format: "total: (statements) XX.X%" - if strings.Contains(lastLine, "total:") { - parts := strings.Fields(lastLine) - if len(parts) >= 3 { - covStr := strings.TrimSuffix(parts[len(parts)-1], "%") - fmt.Sscanf(covStr, "%f", &totalCov) - } - } - } - - // Print coverage summary - fmt.Println() - covStyle := successStyle - if totalCov < 50 { - covStyle = errorStyle - } else if totalCov < 80 { - covStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b")) - } - fmt.Printf(" %s %s\n", dimStyle.Render("Total:"), covStyle.Render(fmt.Sprintf("%.1f%%", totalCov))) - - // Generate HTML if requested - if html || open { - htmlPath := "coverage.html" - htmlCmd := exec.Command("go", "tool", "cover", "-html="+covPath, "-o="+htmlPath) - if err := htmlCmd.Run(); err != nil { - return fmt.Errorf("failed to generate HTML: %w", err) - } - fmt.Printf(" %s %s\n", dimStyle.Render("HTML:"), htmlPath) - - if open { - // Open in browser - var openCmd *exec.Cmd - switch { - case exec.Command("which", "open").Run() == nil: - openCmd = exec.Command("open", htmlPath) - case exec.Command("which", "xdg-open").Run() == nil: - openCmd = exec.Command("xdg-open", htmlPath) - default: - fmt.Printf(" %s\n", dimStyle.Render("(open manually)")) - } - if openCmd != nil { - openCmd.Run() - } - } - } - - // Check threshold - if threshold > 0 && totalCov < threshold { - fmt.Printf("\n%s Coverage %.1f%% is below threshold %.1f%%\n", - errorStyle.Render("FAIL"), totalCov, threshold) - return fmt.Errorf("coverage below threshold") - } - - if testErr != nil { - return testErr - } - - fmt.Printf("\n%s\n", successStyle.Render("OK")) - return nil - }) -} - -func findTestPackages(root string) ([]string, error) { - pkgMap := make(map[string]bool) - err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") { - dir := filepath.Dir(path) - if !strings.HasPrefix(dir, ".") { - dir = "./" + dir - } - pkgMap[dir] = true - } - return nil - }) - if err != nil { - return nil, err - } - - var pkgs []string - for pkg := range pkgMap { - pkgs = append(pkgs, pkg) - } - return pkgs, nil -} - -func addGoFmtCommand(parent *clir.Command) { - var ( - fix bool - diff bool - check bool - ) - - fmtCmd := parent.NewSubCommand("fmt", "Format Go code") - fmtCmd.LongDescription("Format Go code using gofmt or goimports.\n\n" + - "Examples:\n" + - " core go fmt # Check formatting\n" + - " core go fmt --fix # Fix formatting\n" + - " core go fmt --diff # Show diff") - - fmtCmd.BoolFlag("fix", "Fix formatting in place", &fix) - fmtCmd.BoolFlag("diff", "Show diff of changes", &diff) - fmtCmd.BoolFlag("check", "Check only, exit 1 if not formatted", &check) - - fmtCmd.Action(func() error { - args := []string{} - if fix { - args = append(args, "-w") - } - if diff { - args = append(args, "-d") - } - if !fix && !diff { - args = append(args, "-l") - } - args = append(args, ".") - - // Try goimports first, fall back to gofmt - var cmd *exec.Cmd - if _, err := exec.LookPath("goimports"); err == nil { - cmd = exec.Command("goimports", args...) - } else { - cmd = exec.Command("gofmt", args...) - } - - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - }) -} - -func addGoLintCommand(parent *clir.Command) { - var fix bool - - lintCmd := parent.NewSubCommand("lint", "Run golangci-lint") - lintCmd.LongDescription("Run golangci-lint on the codebase.\n\n" + - "Examples:\n" + - " core go lint\n" + - " core go lint --fix") - - lintCmd.BoolFlag("fix", "Fix issues automatically", &fix) - - lintCmd.Action(func() error { - args := []string{"run"} - if fix { - args = append(args, "--fix") - } - - cmd := exec.Command("golangci-lint", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - }) -} - -func addGoInstallCommand(parent *clir.Command) { - var verbose bool - var noCgo bool - - installCmd := parent.NewSubCommand("install", "Install Go binary") - installCmd.LongDescription("Install Go binary to $GOPATH/bin.\n\n" + - "Examples:\n" + - " core go install # Install current module\n" + - " core go install ./cmd/core # Install specific path\n" + - " core go install --no-cgo # Pure Go (no C dependencies)\n" + - " core go install -v # Verbose output") - - installCmd.BoolFlag("v", "Verbose output", &verbose) - installCmd.BoolFlag("no-cgo", "Disable CGO (CGO_ENABLED=0)", &noCgo) - - installCmd.Action(func() error { - // Get install path from args or default to current dir - args := installCmd.OtherArgs() - installPath := "./..." - if len(args) > 0 { - installPath = args[0] - } - - // Detect if we're in a module with cmd/ subdirectories or a root main.go - if installPath == "./..." { - if _, err := os.Stat("core.go"); err == nil { - installPath = "." - } else if entries, err := os.ReadDir("cmd"); err == nil && len(entries) > 0 { - installPath = "./cmd/..." - } else if _, err := os.Stat("main.go"); err == nil { - installPath = "." - } - } - - fmt.Printf("%s Installing\n", dimStyle.Render("Install:")) - fmt.Printf(" %s %s\n", dimStyle.Render("Path:"), installPath) - if noCgo { - fmt.Printf(" %s %s\n", dimStyle.Render("CGO:"), "disabled") - } - - cmdArgs := []string{"install"} - if verbose { - cmdArgs = append(cmdArgs, "-v") - } - cmdArgs = append(cmdArgs, installPath) - - cmd := exec.Command("go", cmdArgs...) - if noCgo { - cmd.Env = append(os.Environ(), "CGO_ENABLED=0") - } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - fmt.Printf("\n%s\n", errorStyle.Render("FAIL Install failed")) - return err - } - - // Show where it was installed - gopath := os.Getenv("GOPATH") - if gopath == "" { - home, _ := os.UserHomeDir() - gopath = filepath.Join(home, "go") - } - binDir := filepath.Join(gopath, "bin") - - fmt.Printf("\n%s Installed to %s\n", successStyle.Render("OK"), binDir) - return nil - }) -} - -func addGoModCommand(parent *clir.Command) { - modCmd := parent.NewSubCommand("mod", "Module management") - modCmd.LongDescription("Go module management commands.\n\n" + - "Commands:\n" + - " tidy Add missing and remove unused modules\n" + - " download Download modules to local cache\n" + - " verify Verify dependencies\n" + - " graph Print module dependency graph") - - // tidy - tidyCmd := modCmd.NewSubCommand("tidy", "Tidy go.mod") - tidyCmd.Action(func() error { - cmd := exec.Command("go", "mod", "tidy") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - }) - - // download - downloadCmd := modCmd.NewSubCommand("download", "Download modules") - downloadCmd.Action(func() error { - cmd := exec.Command("go", "mod", "download") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - }) - - // verify - verifyCmd := modCmd.NewSubCommand("verify", "Verify dependencies") - verifyCmd.Action(func() error { - cmd := exec.Command("go", "mod", "verify") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - }) - - // graph - graphCmd := modCmd.NewSubCommand("graph", "Print dependency graph") - graphCmd.Action(func() error { - cmd := exec.Command("go", "mod", "graph") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - }) -} - -func addGoWorkCommand(parent *clir.Command) { - workCmd := parent.NewSubCommand("work", "Workspace management") - workCmd.LongDescription("Go workspace management commands.\n\n" + - "Commands:\n" + - " sync Sync go.work with modules\n" + - " init Initialize go.work\n" + - " use Add module to workspace") - - // sync - syncCmd := workCmd.NewSubCommand("sync", "Sync workspace") - syncCmd.Action(func() error { - cmd := exec.Command("go", "work", "sync") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - }) - - // init - initCmd := workCmd.NewSubCommand("init", "Initialize workspace") - initCmd.Action(func() error { - cmd := exec.Command("go", "work", "init") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return err - } - // Auto-add current module if go.mod exists - if _, err := os.Stat("go.mod"); err == nil { - cmd = exec.Command("go", "work", "use", ".") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - } - return nil - }) - - // use - useCmd := workCmd.NewSubCommand("use", "Add module to workspace") - useCmd.Action(func() error { - args := useCmd.OtherArgs() - if len(args) == 0 { - // Auto-detect modules - modules := findGoModules(".") - if len(modules) == 0 { - return fmt.Errorf("no go.mod files found") - } - for _, mod := range modules { - cmd := exec.Command("go", "work", "use", mod) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return err - } - fmt.Printf("Added %s\n", mod) - } - return nil - } - - cmdArgs := append([]string{"work", "use"}, args...) - cmd := exec.Command("go", cmdArgs...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - }) -} - -func findGoModules(root string) []string { - var modules []string - filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if info.Name() == "go.mod" && path != "go.mod" { - modules = append(modules, filepath.Dir(path)) - } - return nil - }) - return modules -} diff --git a/cmd/go/go_format.go b/cmd/go/go_format.go new file mode 100644 index 00000000..1a89ab23 --- /dev/null +++ b/cmd/go/go_format.go @@ -0,0 +1,77 @@ +package gocmd + +import ( + "os" + "os/exec" + + "github.com/leaanthony/clir" +) + +func addGoFmtCommand(parent *clir.Command) { + var ( + fix bool + diff bool + check bool + ) + + fmtCmd := parent.NewSubCommand("fmt", "Format Go code") + fmtCmd.LongDescription("Format Go code using gofmt or goimports.\n\n" + + "Examples:\n" + + " core go fmt # Check formatting\n" + + " core go fmt --fix # Fix formatting\n" + + " core go fmt --diff # Show diff") + + fmtCmd.BoolFlag("fix", "Fix formatting in place", &fix) + fmtCmd.BoolFlag("diff", "Show diff of changes", &diff) + fmtCmd.BoolFlag("check", "Check only, exit 1 if not formatted", &check) + + fmtCmd.Action(func() error { + args := []string{} + if fix { + args = append(args, "-w") + } + if diff { + args = append(args, "-d") + } + if !fix && !diff { + args = append(args, "-l") + } + args = append(args, ".") + + // Try goimports first, fall back to gofmt + var cmd *exec.Cmd + if _, err := exec.LookPath("goimports"); err == nil { + cmd = exec.Command("goimports", args...) + } else { + cmd = exec.Command("gofmt", args...) + } + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }) +} + +func addGoLintCommand(parent *clir.Command) { + var fix bool + + lintCmd := parent.NewSubCommand("lint", "Run golangci-lint") + lintCmd.LongDescription("Run golangci-lint on the codebase.\n\n" + + "Examples:\n" + + " core go lint\n" + + " core go lint --fix") + + lintCmd.BoolFlag("fix", "Fix issues automatically", &fix) + + lintCmd.Action(func() error { + args := []string{"run"} + if fix { + args = append(args, "--fix") + } + + cmd := exec.Command("golangci-lint", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }) +} diff --git a/cmd/go/go_test_cmd.go b/cmd/go/go_test_cmd.go new file mode 100644 index 00000000..181848e0 --- /dev/null +++ b/cmd/go/go_test_cmd.go @@ -0,0 +1,334 @@ +package gocmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/leaanthony/clir" +) + +func addGoTestCommand(parent *clir.Command) { + var ( + coverage bool + pkg string + run string + short bool + race bool + json bool + verbose bool + ) + + testCmd := parent.NewSubCommand("test", "Run tests with coverage") + testCmd.LongDescription("Run Go tests with coverage reporting.\n\n" + + "Sets MACOSX_DEPLOYMENT_TARGET=26.0 to suppress linker warnings.\n" + + "Filters noisy output and provides colour-coded coverage.\n\n" + + "Examples:\n" + + " core go test\n" + + " core go test --coverage\n" + + " core go test --pkg ./pkg/crypt\n" + + " core go test --run TestHash") + + testCmd.BoolFlag("coverage", "Show detailed per-package coverage", &coverage) + testCmd.StringFlag("pkg", "Package to test (default: ./...)", &pkg) + testCmd.StringFlag("run", "Run only tests matching regexp", &run) + testCmd.BoolFlag("short", "Run only short tests", &short) + testCmd.BoolFlag("race", "Enable race detector", &race) + testCmd.BoolFlag("json", "Output JSON results", &json) + testCmd.BoolFlag("v", "Verbose output", &verbose) + + testCmd.Action(func() error { + return runGoTest(coverage, pkg, run, short, race, json, verbose) + }) +} + +func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose bool) error { + if pkg == "" { + pkg = "./..." + } + + args := []string{"test"} + + if coverage { + args = append(args, "-cover") + } else { + args = append(args, "-cover") + } + + if run != "" { + args = append(args, "-run", run) + } + if short { + args = append(args, "-short") + } + if race { + args = append(args, "-race") + } + if verbose { + args = append(args, "-v") + } + + args = append(args, pkg) + + if !jsonOut { + fmt.Printf("%s Running tests\n", dimStyle.Render("Test:")) + fmt.Printf(" %s %s\n", dimStyle.Render("Package:"), pkg) + fmt.Println() + } + + cmd := exec.Command("go", args...) + cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0") + cmd.Dir, _ = os.Getwd() + + output, err := cmd.CombinedOutput() + outputStr := string(output) + + // Filter linker warnings + lines := strings.Split(outputStr, "\n") + var filtered []string + for _, line := range lines { + if !strings.Contains(line, "ld: warning:") { + filtered = append(filtered, line) + } + } + outputStr = strings.Join(filtered, "\n") + + // Parse results + passed, failed, skipped := parseTestResults(outputStr) + cov := parseOverallCoverage(outputStr) + + if jsonOut { + fmt.Printf(`{"passed":%d,"failed":%d,"skipped":%d,"coverage":%.1f,"exit_code":%d}`, + passed, failed, skipped, cov, cmd.ProcessState.ExitCode()) + fmt.Println() + return err + } + + // Print filtered output if verbose or failed + if verbose || err != nil { + fmt.Println(outputStr) + } + + // Summary + if err == nil { + fmt.Printf(" %s %d passed\n", successStyle.Render("✓"), passed) + } else { + fmt.Printf(" %s %d passed, %d failed\n", errorStyle.Render("✗"), passed, failed) + } + + if cov > 0 { + covStyle := successStyle + if cov < 50 { + covStyle = errorStyle + } else if cov < 80 { + covStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b")) + } + fmt.Printf("\n %s %s\n", dimStyle.Render("Coverage:"), covStyle.Render(fmt.Sprintf("%.1f%%", cov))) + } + + if err == nil { + fmt.Printf("\n%s\n", successStyle.Render("PASS All tests passed")) + } else { + fmt.Printf("\n%s\n", errorStyle.Render("FAIL Some tests failed")) + } + + return err +} + +func parseTestResults(output string) (passed, failed, skipped int) { + passRe := regexp.MustCompile(`(?m)^ok\s+`) + failRe := regexp.MustCompile(`(?m)^FAIL\s+`) + skipRe := regexp.MustCompile(`(?m)^\?\s+`) + + passed = len(passRe.FindAllString(output, -1)) + failed = len(failRe.FindAllString(output, -1)) + skipped = len(skipRe.FindAllString(output, -1)) + return +} + +func parseOverallCoverage(output string) float64 { + re := regexp.MustCompile(`coverage:\s+([\d.]+)%`) + matches := re.FindAllStringSubmatch(output, -1) + if len(matches) == 0 { + return 0 + } + + var total float64 + for _, m := range matches { + var cov float64 + fmt.Sscanf(m[1], "%f", &cov) + total += cov + } + return total / float64(len(matches)) +} + +func addGoCovCommand(parent *clir.Command) { + var ( + pkg string + html bool + open bool + threshold float64 + ) + + covCmd := parent.NewSubCommand("cov", "Run tests with coverage report") + covCmd.LongDescription("Run tests and generate coverage report.\n\n" + + "Examples:\n" + + " core go cov # Run with coverage summary\n" + + " core go cov --html # Generate HTML report\n" + + " core go cov --open # Generate and open HTML report\n" + + " core go cov --threshold 80 # Fail if coverage < 80%") + + covCmd.StringFlag("pkg", "Package to test (default: ./...)", &pkg) + covCmd.BoolFlag("html", "Generate HTML coverage report", &html) + covCmd.BoolFlag("open", "Generate and open HTML report in browser", &open) + covCmd.Float64Flag("threshold", "Minimum coverage percentage (exit 1 if below)", &threshold) + + covCmd.Action(func() error { + if pkg == "" { + // Auto-discover packages with tests + pkgs, err := findTestPackages(".") + if err != nil { + return fmt.Errorf("failed to discover test packages: %w", err) + } + if len(pkgs) == 0 { + return fmt.Errorf("no test packages found") + } + pkg = strings.Join(pkgs, " ") + } + + // Create temp file for coverage data + covFile, err := os.CreateTemp("", "coverage-*.out") + if err != nil { + return fmt.Errorf("failed to create coverage file: %w", err) + } + covPath := covFile.Name() + covFile.Close() + defer os.Remove(covPath) + + fmt.Printf("%s Running tests with coverage\n", dimStyle.Render("Coverage:")) + // Truncate package list if too long for display + displayPkg := pkg + if len(displayPkg) > 60 { + displayPkg = displayPkg[:57] + "..." + } + fmt.Printf(" %s %s\n", dimStyle.Render("Package:"), displayPkg) + fmt.Println() + + // Run tests with coverage + // We need to split pkg into individual arguments if it contains spaces + pkgArgs := strings.Fields(pkg) + args := append([]string{"test", "-coverprofile=" + covPath, "-covermode=atomic"}, pkgArgs...) + + cmd := exec.Command("go", args...) + cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + testErr := cmd.Run() + + // Get coverage percentage + covCmd := exec.Command("go", "tool", "cover", "-func="+covPath) + covOutput, err := covCmd.Output() + if err != nil { + if testErr != nil { + return testErr + } + return fmt.Errorf("failed to get coverage: %w", err) + } + + // Parse total coverage from last line + lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n") + var totalCov float64 + if len(lines) > 0 { + lastLine := lines[len(lines)-1] + // Format: "total: (statements) XX.X%" + if strings.Contains(lastLine, "total:") { + parts := strings.Fields(lastLine) + if len(parts) >= 3 { + covStr := strings.TrimSuffix(parts[len(parts)-1], "%") + fmt.Sscanf(covStr, "%f", &totalCov) + } + } + } + + // Print coverage summary + fmt.Println() + covStyle := successStyle + if totalCov < 50 { + covStyle = errorStyle + } else if totalCov < 80 { + covStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b")) + } + fmt.Printf(" %s %s\n", dimStyle.Render("Total:"), covStyle.Render(fmt.Sprintf("%.1f%%", totalCov))) + + // Generate HTML if requested + if html || open { + htmlPath := "coverage.html" + htmlCmd := exec.Command("go", "tool", "cover", "-html="+covPath, "-o="+htmlPath) + if err := htmlCmd.Run(); err != nil { + return fmt.Errorf("failed to generate HTML: %w", err) + } + fmt.Printf(" %s %s\n", dimStyle.Render("HTML:"), htmlPath) + + if open { + // Open in browser + var openCmd *exec.Cmd + switch { + case exec.Command("which", "open").Run() == nil: + openCmd = exec.Command("open", htmlPath) + case exec.Command("which", "xdg-open").Run() == nil: + openCmd = exec.Command("xdg-open", htmlPath) + default: + fmt.Printf(" %s\n", dimStyle.Render("(open manually)")) + } + if openCmd != nil { + openCmd.Run() + } + } + } + + // Check threshold + if threshold > 0 && totalCov < threshold { + fmt.Printf("\n%s Coverage %.1f%% is below threshold %.1f%%\n", + errorStyle.Render("FAIL"), totalCov, threshold) + return fmt.Errorf("coverage below threshold") + } + + if testErr != nil { + return testErr + } + + fmt.Printf("\n%s\n", successStyle.Render("OK")) + return nil + }) +} + +func findTestPackages(root string) ([]string, error) { + pkgMap := make(map[string]bool) + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") { + dir := filepath.Dir(path) + if !strings.HasPrefix(dir, ".") { + dir = "./" + dir + } + pkgMap[dir] = true + } + return nil + }) + if err != nil { + return nil, err + } + + var pkgs []string + for pkg := range pkgMap { + pkgs = append(pkgs, pkg) + } + return pkgs, nil +} diff --git a/cmd/go/go_tools.go b/cmd/go/go_tools.go new file mode 100644 index 00000000..dfba18bb --- /dev/null +++ b/cmd/go/go_tools.go @@ -0,0 +1,207 @@ +package gocmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/leaanthony/clir" +) + +func addGoInstallCommand(parent *clir.Command) { + var verbose bool + var noCgo bool + + installCmd := parent.NewSubCommand("install", "Install Go binary") + installCmd.LongDescription("Install Go binary to $GOPATH/bin.\n\n" + + "Examples:\n" + + " core go install # Install current module\n" + + " core go install ./cmd/core # Install specific path\n" + + " core go install --no-cgo # Pure Go (no C dependencies)\n" + + " core go install -v # Verbose output") + + installCmd.BoolFlag("v", "Verbose output", &verbose) + installCmd.BoolFlag("no-cgo", "Disable CGO (CGO_ENABLED=0)", &noCgo) + + installCmd.Action(func() error { + // Get install path from args or default to current dir + args := installCmd.OtherArgs() + installPath := "./..." + if len(args) > 0 { + installPath = args[0] + } + + // Detect if we're in a module with cmd/ subdirectories or a root main.go + if installPath == "./..." { + if _, err := os.Stat("core.go"); err == nil { + installPath = "." + } else if entries, err := os.ReadDir("cmd"); err == nil && len(entries) > 0 { + installPath = "./cmd/..." + } else if _, err := os.Stat("main.go"); err == nil { + installPath = "." + } + } + + fmt.Printf("%s Installing\n", dimStyle.Render("Install:")) + fmt.Printf(" %s %s\n", dimStyle.Render("Path:"), installPath) + if noCgo { + fmt.Printf(" %s %s\n", dimStyle.Render("CGO:"), "disabled") + } + + cmdArgs := []string{"install"} + if verbose { + cmdArgs = append(cmdArgs, "-v") + } + cmdArgs = append(cmdArgs, installPath) + + cmd := exec.Command("go", cmdArgs...) + if noCgo { + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Printf("\n%s\n", errorStyle.Render("FAIL Install failed")) + return err + } + + // Show where it was installed + gopath := os.Getenv("GOPATH") + if gopath == "" { + home, _ := os.UserHomeDir() + gopath = filepath.Join(home, "go") + } + binDir := filepath.Join(gopath, "bin") + + fmt.Printf("\n%s Installed to %s\n", successStyle.Render("OK"), binDir) + return nil + }) +} + +func addGoModCommand(parent *clir.Command) { + modCmd := parent.NewSubCommand("mod", "Module management") + modCmd.LongDescription("Go module management commands.\n\n" + + "Commands:\n" + + " tidy Add missing and remove unused modules\n" + + " download Download modules to local cache\n" + + " verify Verify dependencies\n" + + " graph Print module dependency graph") + + // tidy + tidyCmd := modCmd.NewSubCommand("tidy", "Tidy go.mod") + tidyCmd.Action(func() error { + cmd := exec.Command("go", "mod", "tidy") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }) + + // download + downloadCmd := modCmd.NewSubCommand("download", "Download modules") + downloadCmd.Action(func() error { + cmd := exec.Command("go", "mod", "download") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }) + + // verify + verifyCmd := modCmd.NewSubCommand("verify", "Verify dependencies") + verifyCmd.Action(func() error { + cmd := exec.Command("go", "mod", "verify") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }) + + // graph + graphCmd := modCmd.NewSubCommand("graph", "Print dependency graph") + graphCmd.Action(func() error { + cmd := exec.Command("go", "mod", "graph") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }) +} + +func addGoWorkCommand(parent *clir.Command) { + workCmd := parent.NewSubCommand("work", "Workspace management") + workCmd.LongDescription("Go workspace management commands.\n\n" + + "Commands:\n" + + " sync Sync go.work with modules\n" + + " init Initialize go.work\n" + + " use Add module to workspace") + + // sync + syncCmd := workCmd.NewSubCommand("sync", "Sync workspace") + syncCmd.Action(func() error { + cmd := exec.Command("go", "work", "sync") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }) + + // init + initCmd := workCmd.NewSubCommand("init", "Initialize workspace") + initCmd.Action(func() error { + cmd := exec.Command("go", "work", "init") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + // Auto-add current module if go.mod exists + if _, err := os.Stat("go.mod"); err == nil { + cmd = exec.Command("go", "work", "use", ".") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + return nil + }) + + // use + useCmd := workCmd.NewSubCommand("use", "Add module to workspace") + useCmd.Action(func() error { + args := useCmd.OtherArgs() + if len(args) == 0 { + // Auto-detect modules + modules := findGoModules(".") + if len(modules) == 0 { + return fmt.Errorf("no go.mod files found") + } + for _, mod := range modules { + cmd := exec.Command("go", "work", "use", mod) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + fmt.Printf("Added %s\n", mod) + } + return nil + } + + cmdArgs := append([]string{"work", "use"}, args...) + cmd := exec.Command("go", cmdArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }) +} + +func findGoModules(root string) []string { + var modules []string + filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.Name() == "go.mod" && path != "go.mod" { + modules = append(modules, filepath.Dir(path)) + } + return nil + }) + return modules +} diff --git a/cmd/pkg/pkg.go b/cmd/pkg/pkg.go index 0f7f9a4d..52170cfc 100644 --- a/cmd/pkg/pkg.go +++ b/cmd/pkg/pkg.go @@ -2,19 +2,7 @@ package pkg import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "time" - "github.com/host-uk/core/cmd/shared" - "github.com/host-uk/core/pkg/cache" - "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) @@ -45,571 +33,3 @@ func AddPkgCommands(parent *clir.Cli) { addPkgUpdateCommand(pkgCmd) addPkgOutdatedCommand(pkgCmd) } - -// addPkgSearchCommand adds the 'pkg search' command. -func addPkgSearchCommand(parent *clir.Command) { - var org string - var pattern string - var repoType string - var limit int - var refresh bool - - searchCmd := parent.NewSubCommand("search", "Search GitHub for packages") - searchCmd.LongDescription("Searches GitHub for repositories matching a pattern.\n" + - "Uses gh CLI for authenticated search. Results are cached for 1 hour.\n\n" + - "Examples:\n" + - " core pkg search # List all host-uk repos\n" + - " core pkg search --pattern 'core-*' # Search for core-* repos\n" + - " core pkg search --org mycompany # Search different org\n" + - " core pkg search --refresh # Bypass cache") - - searchCmd.StringFlag("org", "GitHub organization (default: host-uk)", &org) - searchCmd.StringFlag("pattern", "Repo name pattern (* for wildcard)", &pattern) - searchCmd.StringFlag("type", "Filter by type in name (mod, services, plug, website)", &repoType) - searchCmd.IntFlag("limit", "Max results (default 50)", &limit) - searchCmd.BoolFlag("refresh", "Bypass cache and fetch fresh data", &refresh) - - searchCmd.Action(func() error { - if org == "" { - org = "host-uk" - } - if pattern == "" { - pattern = "*" - } - if limit == 0 { - limit = 50 - } - return runPkgSearch(org, pattern, repoType, limit, refresh) - }) -} - -type ghRepo struct { - Name string `json:"name"` - FullName string `json:"full_name"` - Description string `json:"description"` - Visibility string `json:"visibility"` - UpdatedAt string `json:"updated_at"` - Language string `json:"language"` -} - -func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error { - // Initialize cache in workspace .core/ directory - var cacheDir string - if regPath, err := repos.FindRegistry(); err == nil { - cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache") - } - - c, err := cache.New(cacheDir, 0) - if err != nil { - c = nil - } - - cacheKey := cache.GitHubReposKey(org) - var ghRepos []ghRepo - var fromCache bool - - // Try cache first (unless refresh requested) - if c != nil && !refresh { - if found, err := c.Get(cacheKey, &ghRepos); found && err == nil { - fromCache = true - age := c.Age(cacheKey) - fmt.Printf("%s %s %s\n", dimStyle.Render("Cache:"), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second)))) - } - } - - // Fetch from GitHub if not cached - if !fromCache { - if !ghAuthenticated() { - return fmt.Errorf("gh CLI not authenticated. Run: gh auth login") - } - - if os.Getenv("GH_TOKEN") != "" { - fmt.Printf("%s GH_TOKEN env var is set - this may cause auth issues\n", dimStyle.Render("Note:")) - fmt.Printf("%s Unset it with: unset GH_TOKEN\n\n", dimStyle.Render("")) - } - - fmt.Printf("%s %s... ", dimStyle.Render("Fetching:"), org) - - cmd := exec.Command("gh", "repo", "list", org, - "--json", "name,description,visibility,updatedAt,primaryLanguage", - "--limit", fmt.Sprintf("%d", limit)) - output, err := cmd.CombinedOutput() - - if err != nil { - fmt.Println() - errStr := strings.TrimSpace(string(output)) - if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") { - return fmt.Errorf("authentication failed - try: unset GH_TOKEN && gh auth login") - } - return fmt.Errorf("search failed: %s", errStr) - } - - if err := json.Unmarshal(output, &ghRepos); err != nil { - return fmt.Errorf("failed to parse results: %w", err) - } - - if c != nil { - _ = c.Set(cacheKey, ghRepos) - } - - fmt.Printf("%s\n", successStyle.Render("✓")) - } - - // Filter by glob pattern and type - var filtered []ghRepo - for _, r := range ghRepos { - if !matchGlob(pattern, r.Name) { - continue - } - if repoType != "" && !strings.Contains(r.Name, repoType) { - continue - } - filtered = append(filtered, r) - } - - if len(filtered) == 0 { - fmt.Println("No repositories found matching pattern.") - return nil - } - - sort.Slice(filtered, func(i, j int) bool { - return filtered[i].Name < filtered[j].Name - }) - - fmt.Printf("Found %d repositories:\n\n", len(filtered)) - - for _, r := range filtered { - visibility := "" - if r.Visibility == "private" { - visibility = dimStyle.Render(" [private]") - } - - desc := r.Description - if len(desc) > 50 { - desc = desc[:47] + "..." - } - if desc == "" { - desc = dimStyle.Render("(no description)") - } - - fmt.Printf(" %s%s\n", repoNameStyle.Render(r.Name), visibility) - fmt.Printf(" %s\n", desc) - } - - fmt.Println() - fmt.Printf("Install with: %s\n", dimStyle.Render(fmt.Sprintf("core pkg install %s/", org))) - - return nil -} - -// matchGlob does simple glob matching with * wildcards -func matchGlob(pattern, name string) bool { - if pattern == "*" || pattern == "" { - return true - } - - parts := strings.Split(pattern, "*") - pos := 0 - for i, part := range parts { - if part == "" { - continue - } - idx := strings.Index(name[pos:], part) - if idx == -1 { - return false - } - if i == 0 && !strings.HasPrefix(pattern, "*") && idx != 0 { - return false - } - pos += idx + len(part) - } - if !strings.HasSuffix(pattern, "*") && pos != len(name) { - return false - } - return true -} - -// addPkgInstallCommand adds the 'pkg install' command. -func addPkgInstallCommand(parent *clir.Command) { - var targetDir string - var addToRegistry bool - - installCmd := parent.NewSubCommand("install", "Clone a package from GitHub") - installCmd.LongDescription("Clones a repository from GitHub.\n\n" + - "Examples:\n" + - " core pkg install host-uk/core-php\n" + - " core pkg install host-uk/core-tenant --dir ./packages\n" + - " core pkg install host-uk/core-admin --add") - - installCmd.StringFlag("dir", "Target directory (default: ./packages or current dir)", &targetDir) - installCmd.BoolFlag("add", "Add to repos.yaml registry", &addToRegistry) - - installCmd.Action(func() error { - args := installCmd.OtherArgs() - if len(args) == 0 { - return fmt.Errorf("repository is required (e.g., core pkg install host-uk/core-php)") - } - return runPkgInstall(args[0], targetDir, addToRegistry) - }) -} - -func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error { - ctx := context.Background() - - // Parse org/repo - parts := strings.Split(repoArg, "/") - if len(parts) != 2 { - return fmt.Errorf("invalid repo format: use org/repo (e.g., host-uk/core-php)") - } - org, repoName := parts[0], parts[1] - - // Determine target directory - if targetDir == "" { - if regPath, err := repos.FindRegistry(); err == nil { - if reg, err := repos.LoadRegistry(regPath); err == nil { - targetDir = reg.BasePath - if targetDir == "" { - targetDir = "./packages" - } - if !filepath.IsAbs(targetDir) { - targetDir = filepath.Join(filepath.Dir(regPath), targetDir) - } - } - } - if targetDir == "" { - targetDir = "." - } - } - - if strings.HasPrefix(targetDir, "~/") { - home, _ := os.UserHomeDir() - targetDir = filepath.Join(home, targetDir[2:]) - } - - repoPath := filepath.Join(targetDir, repoName) - - if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { - fmt.Printf("%s %s already exists at %s\n", dimStyle.Render("Skip:"), repoName, repoPath) - return nil - } - - if err := os.MkdirAll(targetDir, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - fmt.Printf("%s %s/%s\n", dimStyle.Render("Installing:"), org, repoName) - fmt.Printf("%s %s\n", dimStyle.Render("Target:"), repoPath) - fmt.Println() - - fmt.Printf(" %s... ", dimStyle.Render("Cloning")) - err := gitClone(ctx, org, repoName, repoPath) - if err != nil { - fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error())) - return err - } - fmt.Printf("%s\n", successStyle.Render("✓")) - - if addToRegistry { - if err := addToRegistryFile(org, repoName); err != nil { - fmt.Printf(" %s add to registry: %s\n", errorStyle.Render("✗"), err) - } else { - fmt.Printf(" %s added to repos.yaml\n", successStyle.Render("✓")) - } - } - - fmt.Println() - fmt.Printf("%s Installed %s\n", successStyle.Render("Done:"), repoName) - - return nil -} - -func addToRegistryFile(org, repoName string) error { - regPath, err := repos.FindRegistry() - if err != nil { - return fmt.Errorf("no repos.yaml found") - } - - reg, err := repos.LoadRegistry(regPath) - if err != nil { - return err - } - - if _, exists := reg.Get(repoName); exists { - return nil - } - - f, err := os.OpenFile(regPath, os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return err - } - defer f.Close() - - repoType := detectRepoType(repoName) - entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n", - repoName, repoType) - - _, err = f.WriteString(entry) - return err -} - -func detectRepoType(name string) string { - lower := strings.ToLower(name) - if strings.Contains(lower, "-mod-") || strings.HasSuffix(lower, "-mod") { - return "module" - } - if strings.Contains(lower, "-plug-") || strings.HasSuffix(lower, "-plug") { - return "plugin" - } - if strings.Contains(lower, "-services-") || strings.HasSuffix(lower, "-services") { - return "service" - } - if strings.Contains(lower, "-website-") || strings.HasSuffix(lower, "-website") { - return "website" - } - if strings.HasPrefix(lower, "core-") { - return "package" - } - return "package" -} - -// addPkgListCommand adds the 'pkg list' command. -func addPkgListCommand(parent *clir.Command) { - listCmd := parent.NewSubCommand("list", "List installed packages") - listCmd.LongDescription("Lists all packages in the current workspace.\n\n" + - "Reads from repos.yaml or scans for git repositories.\n\n" + - "Examples:\n" + - " core pkg list") - - listCmd.Action(func() error { - return runPkgList() - }) -} - -func runPkgList() error { - regPath, err := repos.FindRegistry() - if err != nil { - return fmt.Errorf("no repos.yaml found - run from workspace directory") - } - - reg, err := repos.LoadRegistry(regPath) - if err != nil { - return fmt.Errorf("failed to load registry: %w", err) - } - - basePath := reg.BasePath - if basePath == "" { - basePath = "." - } - if !filepath.IsAbs(basePath) { - basePath = filepath.Join(filepath.Dir(regPath), basePath) - } - - allRepos := reg.List() - if len(allRepos) == 0 { - fmt.Println("No packages in registry.") - return nil - } - - fmt.Printf("%s\n\n", repoNameStyle.Render("Installed Packages")) - - var installed, missing int - for _, r := range allRepos { - repoPath := filepath.Join(basePath, r.Name) - exists := false - if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { - exists = true - installed++ - } else { - missing++ - } - - status := successStyle.Render("✓") - if !exists { - status = dimStyle.Render("○") - } - - desc := r.Description - if len(desc) > 40 { - desc = desc[:37] + "..." - } - if desc == "" { - desc = dimStyle.Render("(no description)") - } - - fmt.Printf(" %s %s\n", status, repoNameStyle.Render(r.Name)) - fmt.Printf(" %s\n", desc) - } - - fmt.Println() - fmt.Printf("%s %d installed, %d missing\n", dimStyle.Render("Total:"), installed, missing) - - if missing > 0 { - fmt.Printf("\nInstall missing: %s\n", dimStyle.Render("core setup")) - } - - return nil -} - -// addPkgUpdateCommand adds the 'pkg update' command. -func addPkgUpdateCommand(parent *clir.Command) { - var all bool - - updateCmd := parent.NewSubCommand("update", "Update installed packages") - updateCmd.LongDescription("Pulls latest changes for installed packages.\n\n" + - "Examples:\n" + - " core pkg update core-php # Update specific package\n" + - " core pkg update --all # Update all packages") - - updateCmd.BoolFlag("all", "Update all packages", &all) - - updateCmd.Action(func() error { - args := updateCmd.OtherArgs() - if !all && len(args) == 0 { - return fmt.Errorf("specify package name or use --all") - } - return runPkgUpdate(args, all) - }) -} - -func runPkgUpdate(packages []string, all bool) error { - regPath, err := repos.FindRegistry() - if err != nil { - return fmt.Errorf("no repos.yaml found") - } - - reg, err := repos.LoadRegistry(regPath) - if err != nil { - return fmt.Errorf("failed to load registry: %w", err) - } - - basePath := reg.BasePath - if basePath == "" { - basePath = "." - } - if !filepath.IsAbs(basePath) { - basePath = filepath.Join(filepath.Dir(regPath), basePath) - } - - var toUpdate []string - if all { - for _, r := range reg.List() { - toUpdate = append(toUpdate, r.Name) - } - } else { - toUpdate = packages - } - - fmt.Printf("%s Updating %d package(s)\n\n", dimStyle.Render("Update:"), len(toUpdate)) - - var updated, skipped, failed int - for _, name := range toUpdate { - repoPath := filepath.Join(basePath, name) - - if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { - fmt.Printf(" %s %s (not installed)\n", dimStyle.Render("○"), name) - skipped++ - continue - } - - fmt.Printf(" %s %s... ", dimStyle.Render("↓"), name) - - cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only") - output, err := cmd.CombinedOutput() - if err != nil { - fmt.Printf("%s\n", errorStyle.Render("✗")) - fmt.Printf(" %s\n", strings.TrimSpace(string(output))) - failed++ - continue - } - - if strings.Contains(string(output), "Already up to date") { - fmt.Printf("%s\n", dimStyle.Render("up to date")) - } else { - fmt.Printf("%s\n", successStyle.Render("✓")) - } - updated++ - } - - fmt.Println() - fmt.Printf("%s %d updated, %d skipped, %d failed\n", - dimStyle.Render("Done:"), updated, skipped, failed) - - return nil -} - -// addPkgOutdatedCommand adds the 'pkg outdated' command. -func addPkgOutdatedCommand(parent *clir.Command) { - outdatedCmd := parent.NewSubCommand("outdated", "Check for outdated packages") - outdatedCmd.LongDescription("Checks which packages have unpulled commits.\n\n" + - "Examples:\n" + - " core pkg outdated") - - outdatedCmd.Action(func() error { - return runPkgOutdated() - }) -} - -func runPkgOutdated() error { - regPath, err := repos.FindRegistry() - if err != nil { - return fmt.Errorf("no repos.yaml found") - } - - reg, err := repos.LoadRegistry(regPath) - if err != nil { - return fmt.Errorf("failed to load registry: %w", err) - } - - basePath := reg.BasePath - if basePath == "" { - basePath = "." - } - if !filepath.IsAbs(basePath) { - basePath = filepath.Join(filepath.Dir(regPath), basePath) - } - - fmt.Printf("%s Checking for updates...\n\n", dimStyle.Render("Outdated:")) - - var outdated, upToDate, notInstalled int - var outdatedList []string - - for _, r := range reg.List() { - repoPath := filepath.Join(basePath, r.Name) - - if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { - notInstalled++ - continue - } - - // Fetch updates - exec.Command("git", "-C", repoPath, "fetch", "--quiet").Run() - - // Check if behind - cmd := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}") - output, err := cmd.Output() - if err != nil { - continue - } - - count := strings.TrimSpace(string(output)) - if count != "0" { - fmt.Printf(" %s %s (%s commits behind)\n", - errorStyle.Render("↓"), repoNameStyle.Render(r.Name), count) - outdated++ - outdatedList = append(outdatedList, r.Name) - } else { - upToDate++ - } - } - - fmt.Println() - if outdated == 0 { - fmt.Printf("%s All packages up to date\n", successStyle.Render("Done:")) - } else { - fmt.Printf("%s %d outdated, %d up to date\n", - dimStyle.Render("Summary:"), outdated, upToDate) - fmt.Printf("\nUpdate with: %s\n", dimStyle.Render("core pkg update --all")) - } - - return nil -} diff --git a/cmd/pkg/pkg_install.go b/cmd/pkg/pkg_install.go new file mode 100644 index 00000000..c4aa47f9 --- /dev/null +++ b/cmd/pkg/pkg_install.go @@ -0,0 +1,155 @@ +package pkg + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/host-uk/core/pkg/repos" + "github.com/leaanthony/clir" +) + +// addPkgInstallCommand adds the 'pkg install' command. +func addPkgInstallCommand(parent *clir.Command) { + var targetDir string + var addToRegistry bool + + installCmd := parent.NewSubCommand("install", "Clone a package from GitHub") + installCmd.LongDescription("Clones a repository from GitHub.\n\n" + + "Examples:\n" + + " core pkg install host-uk/core-php\n" + + " core pkg install host-uk/core-tenant --dir ./packages\n" + + " core pkg install host-uk/core-admin --add") + + installCmd.StringFlag("dir", "Target directory (default: ./packages or current dir)", &targetDir) + installCmd.BoolFlag("add", "Add to repos.yaml registry", &addToRegistry) + + installCmd.Action(func() error { + args := installCmd.OtherArgs() + if len(args) == 0 { + return fmt.Errorf("repository is required (e.g., core pkg install host-uk/core-php)") + } + return runPkgInstall(args[0], targetDir, addToRegistry) + }) +} + +func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error { + ctx := context.Background() + + // Parse org/repo + parts := strings.Split(repoArg, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repo format: use org/repo (e.g., host-uk/core-php)") + } + org, repoName := parts[0], parts[1] + + // Determine target directory + if targetDir == "" { + if regPath, err := repos.FindRegistry(); err == nil { + if reg, err := repos.LoadRegistry(regPath); err == nil { + targetDir = reg.BasePath + if targetDir == "" { + targetDir = "./packages" + } + if !filepath.IsAbs(targetDir) { + targetDir = filepath.Join(filepath.Dir(regPath), targetDir) + } + } + } + if targetDir == "" { + targetDir = "." + } + } + + if strings.HasPrefix(targetDir, "~/") { + home, _ := os.UserHomeDir() + targetDir = filepath.Join(home, targetDir[2:]) + } + + repoPath := filepath.Join(targetDir, repoName) + + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + fmt.Printf("%s %s already exists at %s\n", dimStyle.Render("Skip:"), repoName, repoPath) + return nil + } + + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + fmt.Printf("%s %s/%s\n", dimStyle.Render("Installing:"), org, repoName) + fmt.Printf("%s %s\n", dimStyle.Render("Target:"), repoPath) + fmt.Println() + + fmt.Printf(" %s... ", dimStyle.Render("Cloning")) + err := gitClone(ctx, org, repoName, repoPath) + if err != nil { + fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error())) + return err + } + fmt.Printf("%s\n", successStyle.Render("✓")) + + if addToRegistry { + if err := addToRegistryFile(org, repoName); err != nil { + fmt.Printf(" %s add to registry: %s\n", errorStyle.Render("✗"), err) + } else { + fmt.Printf(" %s added to repos.yaml\n", successStyle.Render("✓")) + } + } + + fmt.Println() + fmt.Printf("%s Installed %s\n", successStyle.Render("Done:"), repoName) + + return nil +} + +func addToRegistryFile(org, repoName string) error { + regPath, err := repos.FindRegistry() + if err != nil { + return fmt.Errorf("no repos.yaml found") + } + + reg, err := repos.LoadRegistry(regPath) + if err != nil { + return err + } + + if _, exists := reg.Get(repoName); exists { + return nil + } + + f, err := os.OpenFile(regPath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + repoType := detectRepoType(repoName) + entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n", + repoName, repoType) + + _, err = f.WriteString(entry) + return err +} + +func detectRepoType(name string) string { + lower := strings.ToLower(name) + if strings.Contains(lower, "-mod-") || strings.HasSuffix(lower, "-mod") { + return "module" + } + if strings.Contains(lower, "-plug-") || strings.HasSuffix(lower, "-plug") { + return "plugin" + } + if strings.Contains(lower, "-services-") || strings.HasSuffix(lower, "-services") { + return "service" + } + if strings.Contains(lower, "-website-") || strings.HasSuffix(lower, "-website") { + return "website" + } + if strings.HasPrefix(lower, "core-") { + return "package" + } + return "package" +} diff --git a/cmd/pkg/pkg_manage.go b/cmd/pkg/pkg_manage.go new file mode 100644 index 00000000..96debefa --- /dev/null +++ b/cmd/pkg/pkg_manage.go @@ -0,0 +1,252 @@ +package pkg + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/host-uk/core/pkg/repos" + "github.com/leaanthony/clir" +) + +// addPkgListCommand adds the 'pkg list' command. +func addPkgListCommand(parent *clir.Command) { + listCmd := parent.NewSubCommand("list", "List installed packages") + listCmd.LongDescription("Lists all packages in the current workspace.\n\n" + + "Reads from repos.yaml or scans for git repositories.\n\n" + + "Examples:\n" + + " core pkg list") + + listCmd.Action(func() error { + return runPkgList() + }) +} + +func runPkgList() error { + regPath, err := repos.FindRegistry() + if err != nil { + return fmt.Errorf("no repos.yaml found - run from workspace directory") + } + + reg, err := repos.LoadRegistry(regPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + + basePath := reg.BasePath + if basePath == "" { + basePath = "." + } + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(filepath.Dir(regPath), basePath) + } + + allRepos := reg.List() + if len(allRepos) == 0 { + fmt.Println("No packages in registry.") + return nil + } + + fmt.Printf("%s\n\n", repoNameStyle.Render("Installed Packages")) + + var installed, missing int + for _, r := range allRepos { + repoPath := filepath.Join(basePath, r.Name) + exists := false + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + exists = true + installed++ + } else { + missing++ + } + + status := successStyle.Render("✓") + if !exists { + status = dimStyle.Render("○") + } + + desc := r.Description + if len(desc) > 40 { + desc = desc[:37] + "..." + } + if desc == "" { + desc = dimStyle.Render("(no description)") + } + + fmt.Printf(" %s %s\n", status, repoNameStyle.Render(r.Name)) + fmt.Printf(" %s\n", desc) + } + + fmt.Println() + fmt.Printf("%s %d installed, %d missing\n", dimStyle.Render("Total:"), installed, missing) + + if missing > 0 { + fmt.Printf("\nInstall missing: %s\n", dimStyle.Render("core setup")) + } + + return nil +} + +// addPkgUpdateCommand adds the 'pkg update' command. +func addPkgUpdateCommand(parent *clir.Command) { + var all bool + + updateCmd := parent.NewSubCommand("update", "Update installed packages") + updateCmd.LongDescription("Pulls latest changes for installed packages.\n\n" + + "Examples:\n" + + " core pkg update core-php # Update specific package\n" + + " core pkg update --all # Update all packages") + + updateCmd.BoolFlag("all", "Update all packages", &all) + + updateCmd.Action(func() error { + args := updateCmd.OtherArgs() + if !all && len(args) == 0 { + return fmt.Errorf("specify package name or use --all") + } + return runPkgUpdate(args, all) + }) +} + +func runPkgUpdate(packages []string, all bool) error { + regPath, err := repos.FindRegistry() + if err != nil { + return fmt.Errorf("no repos.yaml found") + } + + reg, err := repos.LoadRegistry(regPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + + basePath := reg.BasePath + if basePath == "" { + basePath = "." + } + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(filepath.Dir(regPath), basePath) + } + + var toUpdate []string + if all { + for _, r := range reg.List() { + toUpdate = append(toUpdate, r.Name) + } + } else { + toUpdate = packages + } + + fmt.Printf("%s Updating %d package(s)\n\n", dimStyle.Render("Update:"), len(toUpdate)) + + var updated, skipped, failed int + for _, name := range toUpdate { + repoPath := filepath.Join(basePath, name) + + if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { + fmt.Printf(" %s %s (not installed)\n", dimStyle.Render("○"), name) + skipped++ + continue + } + + fmt.Printf(" %s %s... ", dimStyle.Render("↓"), name) + + cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only") + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("%s\n", errorStyle.Render("✗")) + fmt.Printf(" %s\n", strings.TrimSpace(string(output))) + failed++ + continue + } + + if strings.Contains(string(output), "Already up to date") { + fmt.Printf("%s\n", dimStyle.Render("up to date")) + } else { + fmt.Printf("%s\n", successStyle.Render("✓")) + } + updated++ + } + + fmt.Println() + fmt.Printf("%s %d updated, %d skipped, %d failed\n", + dimStyle.Render("Done:"), updated, skipped, failed) + + return nil +} + +// addPkgOutdatedCommand adds the 'pkg outdated' command. +func addPkgOutdatedCommand(parent *clir.Command) { + outdatedCmd := parent.NewSubCommand("outdated", "Check for outdated packages") + outdatedCmd.LongDescription("Checks which packages have unpulled commits.\n\n" + + "Examples:\n" + + " core pkg outdated") + + outdatedCmd.Action(func() error { + return runPkgOutdated() + }) +} + +func runPkgOutdated() error { + regPath, err := repos.FindRegistry() + if err != nil { + return fmt.Errorf("no repos.yaml found") + } + + reg, err := repos.LoadRegistry(regPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + + basePath := reg.BasePath + if basePath == "" { + basePath = "." + } + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(filepath.Dir(regPath), basePath) + } + + fmt.Printf("%s Checking for updates...\n\n", dimStyle.Render("Outdated:")) + + var outdated, upToDate, notInstalled int + + for _, r := range reg.List() { + repoPath := filepath.Join(basePath, r.Name) + + if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { + notInstalled++ + continue + } + + // Fetch updates + exec.Command("git", "-C", repoPath, "fetch", "--quiet").Run() + + // Check if behind + cmd := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}") + output, err := cmd.Output() + if err != nil { + continue + } + + count := strings.TrimSpace(string(output)) + if count != "0" { + fmt.Printf(" %s %s (%s commits behind)\n", + errorStyle.Render("↓"), repoNameStyle.Render(r.Name), count) + outdated++ + } else { + upToDate++ + } + } + + fmt.Println() + if outdated == 0 { + fmt.Printf("%s All packages up to date\n", successStyle.Render("Done:")) + } else { + fmt.Printf("%s %d outdated, %d up to date\n", + dimStyle.Render("Summary:"), outdated, upToDate) + fmt.Printf("\nUpdate with: %s\n", dimStyle.Render("core pkg update --all")) + } + + return nil +} diff --git a/cmd/pkg/pkg_search.go b/cmd/pkg/pkg_search.go new file mode 100644 index 00000000..d20f2ffc --- /dev/null +++ b/cmd/pkg/pkg_search.go @@ -0,0 +1,199 @@ +package pkg + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/host-uk/core/pkg/cache" + "github.com/host-uk/core/pkg/repos" + "github.com/leaanthony/clir" +) + +// addPkgSearchCommand adds the 'pkg search' command. +func addPkgSearchCommand(parent *clir.Command) { + var org string + var pattern string + var repoType string + var limit int + var refresh bool + + searchCmd := parent.NewSubCommand("search", "Search GitHub for packages") + searchCmd.LongDescription("Searches GitHub for repositories matching a pattern.\n" + + "Uses gh CLI for authenticated search. Results are cached for 1 hour.\n\n" + + "Examples:\n" + + " core pkg search # List all host-uk repos\n" + + " core pkg search --pattern 'core-*' # Search for core-* repos\n" + + " core pkg search --org mycompany # Search different org\n" + + " core pkg search --refresh # Bypass cache") + + searchCmd.StringFlag("org", "GitHub organization (default: host-uk)", &org) + searchCmd.StringFlag("pattern", "Repo name pattern (* for wildcard)", &pattern) + searchCmd.StringFlag("type", "Filter by type in name (mod, services, plug, website)", &repoType) + searchCmd.IntFlag("limit", "Max results (default 50)", &limit) + searchCmd.BoolFlag("refresh", "Bypass cache and fetch fresh data", &refresh) + + searchCmd.Action(func() error { + if org == "" { + org = "host-uk" + } + if pattern == "" { + pattern = "*" + } + if limit == 0 { + limit = 50 + } + return runPkgSearch(org, pattern, repoType, limit, refresh) + }) +} + +type ghRepo struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + UpdatedAt string `json:"updated_at"` + Language string `json:"language"` +} + +func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error { + // Initialize cache in workspace .core/ directory + var cacheDir string + if regPath, err := repos.FindRegistry(); err == nil { + cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache") + } + + c, err := cache.New(cacheDir, 0) + if err != nil { + c = nil + } + + cacheKey := cache.GitHubReposKey(org) + var ghRepos []ghRepo + var fromCache bool + + // Try cache first (unless refresh requested) + if c != nil && !refresh { + if found, err := c.Get(cacheKey, &ghRepos); found && err == nil { + fromCache = true + age := c.Age(cacheKey) + fmt.Printf("%s %s %s\n", dimStyle.Render("Cache:"), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second)))) + } + } + + // Fetch from GitHub if not cached + if !fromCache { + if !ghAuthenticated() { + return fmt.Errorf("gh CLI not authenticated. Run: gh auth login") + } + + if os.Getenv("GH_TOKEN") != "" { + fmt.Printf("%s GH_TOKEN env var is set - this may cause auth issues\n", dimStyle.Render("Note:")) + fmt.Printf("%s Unset it with: unset GH_TOKEN\n\n", dimStyle.Render("")) + } + + fmt.Printf("%s %s... ", dimStyle.Render("Fetching:"), org) + + cmd := exec.Command("gh", "repo", "list", org, + "--json", "name,description,visibility,updatedAt,primaryLanguage", + "--limit", fmt.Sprintf("%d", limit)) + output, err := cmd.CombinedOutput() + + if err != nil { + fmt.Println() + errStr := strings.TrimSpace(string(output)) + if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") { + return fmt.Errorf("authentication failed - try: unset GH_TOKEN && gh auth login") + } + return fmt.Errorf("search failed: %s", errStr) + } + + if err := json.Unmarshal(output, &ghRepos); err != nil { + return fmt.Errorf("failed to parse results: %w", err) + } + + if c != nil { + _ = c.Set(cacheKey, ghRepos) + } + + fmt.Printf("%s\n", successStyle.Render("✓")) + } + + // Filter by glob pattern and type + var filtered []ghRepo + for _, r := range ghRepos { + if !matchGlob(pattern, r.Name) { + continue + } + if repoType != "" && !strings.Contains(r.Name, repoType) { + continue + } + filtered = append(filtered, r) + } + + if len(filtered) == 0 { + fmt.Println("No repositories found matching pattern.") + return nil + } + + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].Name < filtered[j].Name + }) + + fmt.Printf("Found %d repositories:\n\n", len(filtered)) + + for _, r := range filtered { + visibility := "" + if r.Visibility == "private" { + visibility = dimStyle.Render(" [private]") + } + + desc := r.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + if desc == "" { + desc = dimStyle.Render("(no description)") + } + + fmt.Printf(" %s%s\n", repoNameStyle.Render(r.Name), visibility) + fmt.Printf(" %s\n", desc) + } + + fmt.Println() + fmt.Printf("Install with: %s\n", dimStyle.Render(fmt.Sprintf("core pkg install %s/", org))) + + return nil +} + +// matchGlob does simple glob matching with * wildcards +func matchGlob(pattern, name string) bool { + if pattern == "*" || pattern == "" { + return true + } + + parts := strings.Split(pattern, "*") + pos := 0 + for i, part := range parts { + if part == "" { + continue + } + idx := strings.Index(name[pos:], part) + if idx == -1 { + return false + } + if i == 0 && !strings.HasPrefix(pattern, "*") && idx != 0 { + return false + } + pos += idx + len(part) + } + if !strings.HasSuffix(pattern, "*") && pos != len(name) { + return false + } + return true +} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 0fced437..51e159a6 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -2,19 +2,11 @@ package setup import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "github.com/host-uk/core/cmd/shared" - "github.com/host-uk/core/pkg/repos" "github.com/leaanthony/clir" ) -// Style aliases +// Style aliases from shared package var ( repoNameStyle = shared.RepoNameStyle successStyle = shared.SuccessStyle @@ -24,9 +16,9 @@ var ( // Default organization and devops repo for bootstrap const ( - defaultOrg = "host-uk" - devopsRepo = "core-devops" - devopsReposYaml = "repos.yaml" + defaultOrg = "host-uk" + devopsRepo = "core-devops" + devopsReposYaml = "repos.yaml" ) // AddSetupCommand adds the 'setup' command to the given parent command. @@ -60,650 +52,3 @@ func AddSetupCommand(parent *clir.Cli) { return runSetupOrchestrator(registryPath, only, dryRun, all, name, build) }) } - -// runSetupOrchestrator decides between registry mode and bootstrap mode. -func runSetupOrchestrator(registryPath, only string, dryRun, all bool, projectName string, runBuild bool) error { - ctx := context.Background() - - // Try to find an existing registry - var foundRegistry string - var err error - - if registryPath != "" { - foundRegistry = registryPath - } else { - foundRegistry, err = repos.FindRegistry() - } - - // If registry exists, use registry mode - if err == nil && foundRegistry != "" { - return runRegistrySetup(ctx, foundRegistry, only, dryRun, all, runBuild) - } - - // No registry found - enter bootstrap mode - return runBootstrap(ctx, only, dryRun, all, projectName, runBuild) -} - -// runBootstrap handles the case where no repos.yaml exists. -func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectName string, runBuild bool) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - fmt.Printf("%s Bootstrap mode (no repos.yaml found)\n", dimStyle.Render(">>")) - - var targetDir string - - // Check if current directory is empty - empty, err := isDirEmpty(cwd) - if err != nil { - return fmt.Errorf("failed to check directory: %w", err) - } - - if empty { - // Clone into current directory - targetDir = cwd - fmt.Printf("%s Cloning into current directory\n", dimStyle.Render(">>")) - } else { - // Directory has content - check if it's a git repo root - isRepo := isGitRepoRoot(cwd) - - if isRepo && isTerminal() && !all { - // Offer choice: setup working directory or create package - choice, err := promptSetupChoice() - if err != nil { - return fmt.Errorf("failed to get choice: %w", err) - } - - if choice == "setup" { - // Setup this working directory with .core/ config - return runRepoSetup(cwd, dryRun) - } - // Otherwise continue to "create package" flow - } - - // Create package flow - need a project name - if projectName == "" { - if !isTerminal() || all { - projectName = defaultOrg - } else { - projectName, err = promptProjectName(defaultOrg) - if err != nil { - return fmt.Errorf("failed to get project name: %w", err) - } - } - } - - targetDir = filepath.Join(cwd, projectName) - fmt.Printf("%s Creating project directory: %s\n", dimStyle.Render(">>"), projectName) - - if !dryRun { - if err := os.MkdirAll(targetDir, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - } - } - - // Clone core-devops first - devopsPath := filepath.Join(targetDir, devopsRepo) - if _, err := os.Stat(filepath.Join(devopsPath, ".git")); os.IsNotExist(err) { - fmt.Printf("%s Cloning %s...\n", dimStyle.Render(">>"), devopsRepo) - - if !dryRun { - if err := gitClone(ctx, defaultOrg, devopsRepo, devopsPath); err != nil { - return fmt.Errorf("failed to clone %s: %w", devopsRepo, err) - } - fmt.Printf("%s %s cloned\n", successStyle.Render(">>"), devopsRepo) - } else { - fmt.Printf(" Would clone %s/%s to %s\n", defaultOrg, devopsRepo, devopsPath) - } - } else { - fmt.Printf("%s %s already exists\n", dimStyle.Render(">>"), devopsRepo) - } - - // Load the repos.yaml from core-devops - registryPath := filepath.Join(devopsPath, devopsReposYaml) - - if dryRun { - fmt.Printf("\n%s Would load registry from %s and present package wizard\n", dimStyle.Render(">>"), registryPath) - return nil - } - - reg, err := repos.LoadRegistry(registryPath) - if err != nil { - return fmt.Errorf("failed to load registry from %s: %w", devopsRepo, err) - } - - // Override base path to target directory - reg.BasePath = targetDir - - // Now run the regular setup with the loaded registry - return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild) -} - -// runRegistrySetup loads a registry from path and runs setup. -func runRegistrySetup(ctx context.Context, registryPath, only string, dryRun, all, runBuild bool) error { - reg, err := repos.LoadRegistry(registryPath) - if err != nil { - return fmt.Errorf("failed to load registry: %w", err) - } - - return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild) -} - -// runRegistrySetupWithReg runs setup with an already-loaded registry. -func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryPath, only string, dryRun, all, runBuild bool) error { - fmt.Printf("%s %s\n", dimStyle.Render("Registry:"), registryPath) - fmt.Printf("%s %s\n", dimStyle.Render("Org:"), reg.Org) - - // Determine base path for cloning - basePath := reg.BasePath - if basePath == "" { - basePath = "./packages" - } - // Resolve relative to registry location - if !filepath.IsAbs(basePath) { - basePath = filepath.Join(filepath.Dir(registryPath), basePath) - } - // Expand ~ - if strings.HasPrefix(basePath, "~/") { - home, _ := os.UserHomeDir() - basePath = filepath.Join(home, basePath[2:]) - } - - fmt.Printf("%s %s\n", dimStyle.Render("Target:"), basePath) - - // Parse type filter - var typeFilter []string - if only != "" { - for _, t := range strings.Split(only, ",") { - typeFilter = append(typeFilter, strings.TrimSpace(t)) - } - fmt.Printf("%s %s\n", dimStyle.Render("Filter:"), only) - } - - // Ensure base path exists - if !dryRun { - if err := os.MkdirAll(basePath, 0755); err != nil { - return fmt.Errorf("failed to create packages directory: %w", err) - } - } - - // Get all available repos - allRepos := reg.List() - - // Determine which repos to clone - var toClone []*repos.Repo - var skipped, exists int - - // Use wizard in interactive mode, unless --all specified - useWizard := isTerminal() && !all && !dryRun - - if useWizard { - selected, err := runPackageWizard(reg, typeFilter) - if err != nil { - return fmt.Errorf("wizard error: %w", err) - } - - // Build set of selected repos - selectedSet := make(map[string]bool) - for _, name := range selected { - selectedSet[name] = true - } - - // Filter repos based on selection - for _, repo := range allRepos { - if !selectedSet[repo.Name] { - skipped++ - continue - } - - // Check if already exists - repoPath := filepath.Join(basePath, repo.Name) - if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { - exists++ - continue - } - - toClone = append(toClone, repo) - } - } else { - // Non-interactive: filter by type - typeFilterSet := make(map[string]bool) - for _, t := range typeFilter { - typeFilterSet[t] = true - } - - for _, repo := range allRepos { - // Skip if type filter doesn't match (when filter is specified) - if len(typeFilterSet) > 0 && !typeFilterSet[repo.Type] { - skipped++ - continue - } - - // Skip if clone: false - if repo.Clone != nil && !*repo.Clone { - skipped++ - continue - } - - // Check if already exists - repoPath := filepath.Join(basePath, repo.Name) - if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { - exists++ - continue - } - - toClone = append(toClone, repo) - } - } - - // Summary - fmt.Println() - fmt.Printf("%d to clone, %d exist, %d skipped\n", len(toClone), exists, skipped) - - if len(toClone) == 0 { - fmt.Println("\nNothing to clone.") - return nil - } - - if dryRun { - fmt.Println("\nWould clone:") - for _, repo := range toClone { - fmt.Printf(" %s (%s)\n", repoNameStyle.Render(repo.Name), repo.Type) - } - return nil - } - - // Confirm in interactive mode - if useWizard { - confirmed, err := confirmClone(len(toClone), basePath) - if err != nil { - return err - } - if !confirmed { - fmt.Println("Cancelled.") - return nil - } - } - - // Clone repos - fmt.Println() - var succeeded, failed int - - for _, repo := range toClone { - fmt.Printf(" %s %s... ", dimStyle.Render("Cloning"), repo.Name) - - repoPath := filepath.Join(basePath, repo.Name) - - err := gitClone(ctx, reg.Org, repo.Name, repoPath) - if err != nil { - fmt.Printf("%s\n", errorStyle.Render("x "+err.Error())) - failed++ - } else { - fmt.Printf("%s\n", successStyle.Render("done")) - succeeded++ - } - } - - // Summary - fmt.Println() - fmt.Printf("%s %d cloned", successStyle.Render("Done:"), succeeded) - if failed > 0 { - fmt.Printf(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) - } - if exists > 0 { - fmt.Printf(", %d already exist", exists) - } - fmt.Println() - - // Run build if requested - if runBuild && succeeded > 0 { - fmt.Println() - fmt.Printf("%s Running build...\n", dimStyle.Render(">>")) - buildCmd := exec.Command("core", "build") - buildCmd.Dir = basePath - buildCmd.Stdout = os.Stdout - buildCmd.Stderr = os.Stderr - if err := buildCmd.Run(); err != nil { - return fmt.Errorf("build failed: %w", err) - } - } - - return nil -} - -// isGitRepoRoot returns true if the directory is a git repository root. -func isGitRepoRoot(path string) bool { - _, err := os.Stat(filepath.Join(path, ".git")) - return err == nil -} - -// runRepoSetup sets up the current repository with .core/ configuration. -func runRepoSetup(repoPath string, dryRun bool) error { - fmt.Printf("%s Setting up repository: %s\n", dimStyle.Render(">>"), repoPath) - - // Detect project type - projectType := detectProjectType(repoPath) - fmt.Printf("%s Detected project type: %s\n", dimStyle.Render(">>"), projectType) - - // Create .core directory - coreDir := filepath.Join(repoPath, ".core") - if !dryRun { - if err := os.MkdirAll(coreDir, 0755); err != nil { - return fmt.Errorf("failed to create .core directory: %w", err) - } - } - - // Generate configs based on project type - name := filepath.Base(repoPath) - configs := map[string]string{ - "build.yaml": generateBuildConfig(repoPath, projectType), - "release.yaml": generateReleaseConfig(name, projectType), - "test.yaml": generateTestConfig(projectType), - } - - if dryRun { - fmt.Printf("\n%s Would create:\n", dimStyle.Render(">>")) - for filename, content := range configs { - fmt.Printf("\n %s:\n", filepath.Join(coreDir, filename)) - // Indent content for display - for _, line := range strings.Split(content, "\n") { - fmt.Printf(" %s\n", line) - } - } - return nil - } - - for filename, content := range configs { - configPath := filepath.Join(coreDir, filename) - if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", filename, err) - } - fmt.Printf("%s Created %s\n", successStyle.Render(">>"), configPath) - } - - return nil -} - -// detectProjectType identifies the project type from files present. -func detectProjectType(path string) string { - // Check in priority order - if _, err := os.Stat(filepath.Join(path, "wails.json")); err == nil { - return "wails" - } - if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil { - return "go" - } - if _, err := os.Stat(filepath.Join(path, "composer.json")); err == nil { - return "php" - } - if _, err := os.Stat(filepath.Join(path, "package.json")); err == nil { - return "node" - } - return "unknown" -} - -// generateBuildConfig creates a build.yaml configuration based on project type. -func generateBuildConfig(path, projectType string) string { - name := filepath.Base(path) - - switch projectType { - case "go", "wails": - return fmt.Sprintf(`version: 1 -project: - name: %s - description: Go application - main: ./cmd/%s - binary: %s -build: - cgo: false - flags: - - -trimpath - ldflags: - - -s - - -w -targets: - - os: linux - arch: amd64 - - os: linux - arch: arm64 - - os: darwin - arch: amd64 - - os: darwin - arch: arm64 - - os: windows - arch: amd64 -`, name, name, name) - - case "php": - return fmt.Sprintf(`version: 1 -project: - name: %s - description: PHP application - type: php -build: - dockerfile: Dockerfile - image: %s -`, name, name) - - case "node": - return fmt.Sprintf(`version: 1 -project: - name: %s - description: Node.js application - type: node -build: - script: npm run build - output: dist -`, name) - - default: - return fmt.Sprintf(`version: 1 -project: - name: %s - description: Application -`, name) - } -} - -// generateReleaseConfig creates a release.yaml configuration. -func generateReleaseConfig(name, projectType string) string { - // Try to detect GitHub repo from git remote - repo := detectGitHubRepo() - if repo == "" { - repo = "owner/" + name - } - - base := fmt.Sprintf(`version: 1 -project: - name: %s - repository: %s -`, name, repo) - - switch projectType { - case "go", "wails": - return base + ` -changelog: - include: - - feat - - fix - - perf - - refactor - exclude: - - chore - - docs - - style - - test - -publishers: - - type: github - draft: false - prerelease: false -` - case "php": - return base + ` -changelog: - include: - - feat - - fix - - perf - -publishers: - - type: github - draft: false -` - default: - return base + ` -changelog: - include: - - feat - - fix - -publishers: - - type: github -` - } -} - -// generateTestConfig creates a test.yaml configuration. -func generateTestConfig(projectType string) string { - switch projectType { - case "go", "wails": - return `version: 1 - -commands: - - name: unit - run: go test ./... - - name: coverage - run: go test -coverprofile=coverage.out ./... - - name: race - run: go test -race ./... - -env: - CGO_ENABLED: "0" -` - case "php": - return `version: 1 - -commands: - - name: unit - run: vendor/bin/pest --parallel - - name: types - run: vendor/bin/phpstan analyse - - name: lint - run: vendor/bin/pint --test - -env: - APP_ENV: testing - DB_CONNECTION: sqlite -` - case "node": - return `version: 1 - -commands: - - name: unit - run: npm test - - name: lint - run: npm run lint - - name: typecheck - run: npm run typecheck - -env: - NODE_ENV: test -` - default: - return `version: 1 - -commands: - - name: test - run: echo "No tests configured" -` - } -} - -// detectGitHubRepo tries to extract owner/repo from git remote. -func detectGitHubRepo() string { - cmd := exec.Command("git", "remote", "get-url", "origin") - output, err := cmd.Output() - if err != nil { - return "" - } - - url := strings.TrimSpace(string(output)) - - // Handle SSH format: git@github.com:owner/repo.git - if strings.HasPrefix(url, "git@github.com:") { - repo := strings.TrimPrefix(url, "git@github.com:") - repo = strings.TrimSuffix(repo, ".git") - return repo - } - - // Handle HTTPS format: https://github.com/owner/repo.git - if strings.Contains(url, "github.com/") { - parts := strings.Split(url, "github.com/") - if len(parts) == 2 { - repo := strings.TrimSuffix(parts[1], ".git") - return repo - } - } - - return "" -} - -// isDirEmpty returns true if the directory is empty or contains only hidden files. -func isDirEmpty(path string) (bool, error) { - entries, err := os.ReadDir(path) - if err != nil { - return false, err - } - - for _, e := range entries { - name := e.Name() - // Ignore common hidden/metadata files - if name == ".DS_Store" || name == ".git" || name == ".gitignore" { - continue - } - // Any other non-hidden file means directory is not empty - if !strings.HasPrefix(name, ".") { - return false, nil - } - } - - return true, nil -} - -func gitClone(ctx context.Context, org, repo, path string) error { - // Try gh clone first with HTTPS (works without SSH keys) - if ghAuthenticated() { - // Use HTTPS URL directly to bypass git_protocol config - httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo) - cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path) - output, err := cmd.CombinedOutput() - if err == nil { - return nil - } - errStr := strings.TrimSpace(string(output)) - // Only fall through to SSH if it's an auth error - if !strings.Contains(errStr, "Permission denied") && - !strings.Contains(errStr, "could not read") { - return fmt.Errorf("%s", errStr) - } - } - - // Fallback to git clone via SSH - url := fmt.Sprintf("git@github.com:%s/%s.git", org, repo) - cmd := exec.CommandContext(ctx, "git", "clone", url, path) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("%s", strings.TrimSpace(string(output))) - } - return nil -} - -func ghAuthenticated() bool { - cmd := exec.Command("gh", "auth", "status") - output, _ := cmd.CombinedOutput() - return strings.Contains(string(output), "Logged in") -} diff --git a/cmd/setup/setup_bootstrap.go b/cmd/setup/setup_bootstrap.go new file mode 100644 index 00000000..26b7ca26 --- /dev/null +++ b/cmd/setup/setup_bootstrap.go @@ -0,0 +1,165 @@ +// setup_bootstrap.go implements bootstrap mode for new workspaces. +// +// Bootstrap mode is activated when no repos.yaml exists in the current +// directory or any parent. It clones core-devops first, then uses its +// repos.yaml to present the package wizard. + +package setup + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/host-uk/core/pkg/repos" +) + +// runSetupOrchestrator decides between registry mode and bootstrap mode. +func runSetupOrchestrator(registryPath, only string, dryRun, all bool, projectName string, runBuild bool) error { + ctx := context.Background() + + // Try to find an existing registry + var foundRegistry string + var err error + + if registryPath != "" { + foundRegistry = registryPath + } else { + foundRegistry, err = repos.FindRegistry() + } + + // If registry exists, use registry mode + if err == nil && foundRegistry != "" { + return runRegistrySetup(ctx, foundRegistry, only, dryRun, all, runBuild) + } + + // No registry found - enter bootstrap mode + return runBootstrap(ctx, only, dryRun, all, projectName, runBuild) +} + +// runBootstrap handles the case where no repos.yaml exists. +func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectName string, runBuild bool) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + fmt.Printf("%s Bootstrap mode (no repos.yaml found)\n", dimStyle.Render(">>")) + + var targetDir string + + // Check if current directory is empty + empty, err := isDirEmpty(cwd) + if err != nil { + return fmt.Errorf("failed to check directory: %w", err) + } + + if empty { + // Clone into current directory + targetDir = cwd + fmt.Printf("%s Cloning into current directory\n", dimStyle.Render(">>")) + } else { + // Directory has content - check if it's a git repo root + isRepo := isGitRepoRoot(cwd) + + if isRepo && isTerminal() && !all { + // Offer choice: setup working directory or create package + choice, err := promptSetupChoice() + if err != nil { + return fmt.Errorf("failed to get choice: %w", err) + } + + if choice == "setup" { + // Setup this working directory with .core/ config + return runRepoSetup(cwd, dryRun) + } + // Otherwise continue to "create package" flow + } + + // Create package flow - need a project name + if projectName == "" { + if !isTerminal() || all { + projectName = defaultOrg + } else { + projectName, err = promptProjectName(defaultOrg) + if err != nil { + return fmt.Errorf("failed to get project name: %w", err) + } + } + } + + targetDir = filepath.Join(cwd, projectName) + fmt.Printf("%s Creating project directory: %s\n", dimStyle.Render(">>"), projectName) + + if !dryRun { + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + } + } + + // Clone core-devops first + devopsPath := filepath.Join(targetDir, devopsRepo) + if _, err := os.Stat(filepath.Join(devopsPath, ".git")); os.IsNotExist(err) { + fmt.Printf("%s Cloning %s...\n", dimStyle.Render(">>"), devopsRepo) + + if !dryRun { + if err := gitClone(ctx, defaultOrg, devopsRepo, devopsPath); err != nil { + return fmt.Errorf("failed to clone %s: %w", devopsRepo, err) + } + fmt.Printf("%s %s cloned\n", successStyle.Render(">>"), devopsRepo) + } else { + fmt.Printf(" Would clone %s/%s to %s\n", defaultOrg, devopsRepo, devopsPath) + } + } else { + fmt.Printf("%s %s already exists\n", dimStyle.Render(">>"), devopsRepo) + } + + // Load the repos.yaml from core-devops + registryPath := filepath.Join(devopsPath, devopsReposYaml) + + if dryRun { + fmt.Printf("\n%s Would load registry from %s and present package wizard\n", dimStyle.Render(">>"), registryPath) + return nil + } + + reg, err := repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry from %s: %w", devopsRepo, err) + } + + // Override base path to target directory + reg.BasePath = targetDir + + // Now run the regular setup with the loaded registry + return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild) +} + +// isGitRepoRoot returns true if the directory is a git repository root. +func isGitRepoRoot(path string) bool { + _, err := os.Stat(filepath.Join(path, ".git")) + return err == nil +} + +// isDirEmpty returns true if the directory is empty or contains only hidden files. +func isDirEmpty(path string) (bool, error) { + entries, err := os.ReadDir(path) + if err != nil { + return false, err + } + + for _, e := range entries { + name := e.Name() + // Ignore common hidden/metadata files + if name == ".DS_Store" || name == ".git" || name == ".gitignore" { + continue + } + // Any other non-hidden file means directory is not empty + if len(name) > 0 && name[0] != '.' { + return false, nil + } + } + + return true, nil +} diff --git a/cmd/setup/setup_registry.go b/cmd/setup/setup_registry.go new file mode 100644 index 00000000..87e64e02 --- /dev/null +++ b/cmd/setup/setup_registry.go @@ -0,0 +1,239 @@ +// setup_registry.go implements registry mode for cloning packages. +// +// Registry mode is activated when a repos.yaml exists. It reads the registry +// and clones all (or selected) packages into the configured packages directory. + +package setup + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/host-uk/core/cmd/shared" + "github.com/host-uk/core/pkg/repos" +) + +// runRegistrySetup loads a registry from path and runs setup. +func runRegistrySetup(ctx context.Context, registryPath, only string, dryRun, all, runBuild bool) error { + reg, err := repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + + return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild) +} + +// runRegistrySetupWithReg runs setup with an already-loaded registry. +func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryPath, only string, dryRun, all, runBuild bool) error { + fmt.Printf("%s %s\n", dimStyle.Render("Registry:"), registryPath) + fmt.Printf("%s %s\n", dimStyle.Render("Org:"), reg.Org) + + // Determine base path for cloning + basePath := reg.BasePath + if basePath == "" { + basePath = "./packages" + } + // Resolve relative to registry location + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(filepath.Dir(registryPath), basePath) + } + // Expand ~ + if strings.HasPrefix(basePath, "~/") { + home, _ := os.UserHomeDir() + basePath = filepath.Join(home, basePath[2:]) + } + + fmt.Printf("%s %s\n", dimStyle.Render("Target:"), basePath) + + // Parse type filter + var typeFilter []string + if only != "" { + for _, t := range strings.Split(only, ",") { + typeFilter = append(typeFilter, strings.TrimSpace(t)) + } + fmt.Printf("%s %s\n", dimStyle.Render("Filter:"), only) + } + + // Ensure base path exists + if !dryRun { + if err := os.MkdirAll(basePath, 0755); err != nil { + return fmt.Errorf("failed to create packages directory: %w", err) + } + } + + // Get all available repos + allRepos := reg.List() + + // Determine which repos to clone + var toClone []*repos.Repo + var skipped, exists int + + // Use wizard in interactive mode, unless --all specified + useWizard := isTerminal() && !all && !dryRun + + if useWizard { + selected, err := runPackageWizard(reg, typeFilter) + if err != nil { + return fmt.Errorf("wizard error: %w", err) + } + + // Build set of selected repos + selectedSet := make(map[string]bool) + for _, name := range selected { + selectedSet[name] = true + } + + // Filter repos based on selection + for _, repo := range allRepos { + if !selectedSet[repo.Name] { + skipped++ + continue + } + + // Check if already exists + repoPath := filepath.Join(basePath, repo.Name) + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + exists++ + continue + } + + toClone = append(toClone, repo) + } + } else { + // Non-interactive: filter by type + typeFilterSet := make(map[string]bool) + for _, t := range typeFilter { + typeFilterSet[t] = true + } + + for _, repo := range allRepos { + // Skip if type filter doesn't match (when filter is specified) + if len(typeFilterSet) > 0 && !typeFilterSet[repo.Type] { + skipped++ + continue + } + + // Skip if clone: false + if repo.Clone != nil && !*repo.Clone { + skipped++ + continue + } + + // Check if already exists + repoPath := filepath.Join(basePath, repo.Name) + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + exists++ + continue + } + + toClone = append(toClone, repo) + } + } + + // Summary + fmt.Println() + fmt.Printf("%d to clone, %d exist, %d skipped\n", len(toClone), exists, skipped) + + if len(toClone) == 0 { + fmt.Println("\nNothing to clone.") + return nil + } + + if dryRun { + fmt.Println("\nWould clone:") + for _, repo := range toClone { + fmt.Printf(" %s (%s)\n", repoNameStyle.Render(repo.Name), repo.Type) + } + return nil + } + + // Confirm in interactive mode + if useWizard { + confirmed, err := confirmClone(len(toClone), basePath) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Cancelled.") + return nil + } + } + + // Clone repos + fmt.Println() + var succeeded, failed int + + for _, repo := range toClone { + fmt.Printf(" %s %s... ", dimStyle.Render("Cloning"), repo.Name) + + repoPath := filepath.Join(basePath, repo.Name) + + err := gitClone(ctx, reg.Org, repo.Name, repoPath) + if err != nil { + fmt.Printf("%s\n", errorStyle.Render("x "+err.Error())) + failed++ + } else { + fmt.Printf("%s\n", successStyle.Render("done")) + succeeded++ + } + } + + // Summary + fmt.Println() + fmt.Printf("%s %d cloned", successStyle.Render("Done:"), succeeded) + if failed > 0 { + fmt.Printf(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) + } + if exists > 0 { + fmt.Printf(", %d already exist", exists) + } + fmt.Println() + + // Run build if requested + if runBuild && succeeded > 0 { + fmt.Println() + fmt.Printf("%s Running build...\n", dimStyle.Render(">>")) + buildCmd := exec.Command("core", "build") + buildCmd.Dir = basePath + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) + } + } + + return nil +} + +// gitClone clones a repository using gh CLI or git. +func gitClone(ctx context.Context, org, repo, path string) error { + // Try gh clone first with HTTPS (works without SSH keys) + if shared.GhAuthenticated() { + // Use HTTPS URL directly to bypass git_protocol config + httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo) + cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path) + output, err := cmd.CombinedOutput() + if err == nil { + return nil + } + errStr := strings.TrimSpace(string(output)) + // Only fall through to SSH if it's an auth error + if !strings.Contains(errStr, "Permission denied") && + !strings.Contains(errStr, "could not read") { + return fmt.Errorf("%s", errStr) + } + } + + // Fallback to git clone via SSH + url := fmt.Sprintf("git@github.com:%s/%s.git", org, repo) + cmd := exec.CommandContext(ctx, "git", "clone", url, path) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(output))) + } + return nil +} diff --git a/cmd/setup/setup_repo.go b/cmd/setup/setup_repo.go new file mode 100644 index 00000000..4d49c894 --- /dev/null +++ b/cmd/setup/setup_repo.go @@ -0,0 +1,287 @@ +// setup_repo.go implements repository setup with .core/ configuration. +// +// When running setup in an existing git repository, this generates +// build.yaml, release.yaml, and test.yaml configurations based on +// detected project type. + +package setup + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// runRepoSetup sets up the current repository with .core/ configuration. +func runRepoSetup(repoPath string, dryRun bool) error { + fmt.Printf("%s Setting up repository: %s\n", dimStyle.Render(">>"), repoPath) + + // Detect project type + projectType := detectProjectType(repoPath) + fmt.Printf("%s Detected project type: %s\n", dimStyle.Render(">>"), projectType) + + // Create .core directory + coreDir := filepath.Join(repoPath, ".core") + if !dryRun { + if err := os.MkdirAll(coreDir, 0755); err != nil { + return fmt.Errorf("failed to create .core directory: %w", err) + } + } + + // Generate configs based on project type + name := filepath.Base(repoPath) + configs := map[string]string{ + "build.yaml": generateBuildConfig(repoPath, projectType), + "release.yaml": generateReleaseConfig(name, projectType), + "test.yaml": generateTestConfig(projectType), + } + + if dryRun { + fmt.Printf("\n%s Would create:\n", dimStyle.Render(">>")) + for filename, content := range configs { + fmt.Printf("\n %s:\n", filepath.Join(coreDir, filename)) + // Indent content for display + for _, line := range strings.Split(content, "\n") { + fmt.Printf(" %s\n", line) + } + } + return nil + } + + for filename, content := range configs { + configPath := filepath.Join(coreDir, filename) + if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", filename, err) + } + fmt.Printf("%s Created %s\n", successStyle.Render(">>"), configPath) + } + + return nil +} + +// detectProjectType identifies the project type from files present. +func detectProjectType(path string) string { + // Check in priority order + if _, err := os.Stat(filepath.Join(path, "wails.json")); err == nil { + return "wails" + } + if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil { + return "go" + } + if _, err := os.Stat(filepath.Join(path, "composer.json")); err == nil { + return "php" + } + if _, err := os.Stat(filepath.Join(path, "package.json")); err == nil { + return "node" + } + return "unknown" +} + +// generateBuildConfig creates a build.yaml configuration based on project type. +func generateBuildConfig(path, projectType string) string { + name := filepath.Base(path) + + switch projectType { + case "go", "wails": + return fmt.Sprintf(`version: 1 +project: + name: %s + description: Go application + main: ./cmd/%s + binary: %s +build: + cgo: false + flags: + - -trimpath + ldflags: + - -s + - -w +targets: + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: darwin + arch: amd64 + - os: darwin + arch: arm64 + - os: windows + arch: amd64 +`, name, name, name) + + case "php": + return fmt.Sprintf(`version: 1 +project: + name: %s + description: PHP application + type: php +build: + dockerfile: Dockerfile + image: %s +`, name, name) + + case "node": + return fmt.Sprintf(`version: 1 +project: + name: %s + description: Node.js application + type: node +build: + script: npm run build + output: dist +`, name) + + default: + return fmt.Sprintf(`version: 1 +project: + name: %s + description: Application +`, name) + } +} + +// generateReleaseConfig creates a release.yaml configuration. +func generateReleaseConfig(name, projectType string) string { + // Try to detect GitHub repo from git remote + repo := detectGitHubRepo() + if repo == "" { + repo = "owner/" + name + } + + base := fmt.Sprintf(`version: 1 +project: + name: %s + repository: %s +`, name, repo) + + switch projectType { + case "go", "wails": + return base + ` +changelog: + include: + - feat + - fix + - perf + - refactor + exclude: + - chore + - docs + - style + - test + +publishers: + - type: github + draft: false + prerelease: false +` + case "php": + return base + ` +changelog: + include: + - feat + - fix + - perf + +publishers: + - type: github + draft: false +` + default: + return base + ` +changelog: + include: + - feat + - fix + +publishers: + - type: github +` + } +} + +// generateTestConfig creates a test.yaml configuration. +func generateTestConfig(projectType string) string { + switch projectType { + case "go", "wails": + return `version: 1 + +commands: + - name: unit + run: go test ./... + - name: coverage + run: go test -coverprofile=coverage.out ./... + - name: race + run: go test -race ./... + +env: + CGO_ENABLED: "0" +` + case "php": + return `version: 1 + +commands: + - name: unit + run: vendor/bin/pest --parallel + - name: types + run: vendor/bin/phpstan analyse + - name: lint + run: vendor/bin/pint --test + +env: + APP_ENV: testing + DB_CONNECTION: sqlite +` + case "node": + return `version: 1 + +commands: + - name: unit + run: npm test + - name: lint + run: npm run lint + - name: typecheck + run: npm run typecheck + +env: + NODE_ENV: test +` + default: + return `version: 1 + +commands: + - name: test + run: echo "No tests configured" +` + } +} + +// detectGitHubRepo tries to extract owner/repo from git remote. +func detectGitHubRepo() string { + cmd := exec.Command("git", "remote", "get-url", "origin") + output, err := cmd.Output() + if err != nil { + return "" + } + + url := strings.TrimSpace(string(output)) + + // Handle SSH format: git@github.com:owner/repo.git + if strings.HasPrefix(url, "git@github.com:") { + repo := strings.TrimPrefix(url, "git@github.com:") + repo = strings.TrimSuffix(repo, ".git") + return repo + } + + // Handle HTTPS format: https://github.com/owner/repo.git + if strings.Contains(url, "github.com/") { + parts := strings.Split(url, "github.com/") + if len(parts) == 2 { + repo := strings.TrimSuffix(parts[1], ".git") + return repo + } + } + + return "" +} diff --git a/cmd/test/test.go b/cmd/test/test.go index fce01a94..7350103c 100644 --- a/cmd/test/test.go +++ b/cmd/test/test.go @@ -4,42 +4,22 @@ package testcmd import ( - "bufio" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "sort" - "strconv" - "strings" - "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/cmd/shared" "github.com/leaanthony/clir" ) -// Test command styles +// Style aliases from shared var ( - testHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#3b82f6")) // blue-500 - - testPassStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#22c55e")). // green-500 - Bold(true) - - testFailStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ef4444")). // red-500 - Bold(true) - - testSkipStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#f59e0b")) // amber-500 - - testDimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) // gray-500 + testHeaderStyle = shared.RepoNameStyle + testPassStyle = shared.SuccessStyle + testFailStyle = shared.ErrorStyle + testSkipStyle = shared.WarningStyle + testDimStyle = shared.DimStyle +) +// Coverage-specific styles +var ( testCovHighStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#22c55e")) // green-500 @@ -85,326 +65,3 @@ func AddTestCommand(parent *clir.Cli) { return runTest(verbose, coverage, short, pkg, run, race, json) }) } - -type packageCoverage struct { - name string - coverage float64 - hasCov bool -} - -func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error { - // Detect if we're in a Go project - if _, err := os.Stat("go.mod"); os.IsNotExist(err) { - return fmt.Errorf("no go.mod found - run from a Go project directory") - } - - // Build command arguments - args := []string{"test"} - - // Default to ./... if no package specified - if pkg == "" { - pkg = "./..." - } - - // Add flags - if verbose { - args = append(args, "-v") - } - if short { - args = append(args, "-short") - } - if run != "" { - args = append(args, "-run", run) - } - if race { - args = append(args, "-race") - } - - // Always add coverage - args = append(args, "-cover") - - // Add package pattern - args = append(args, pkg) - - // Create command - cmd := exec.Command("go", args...) - cmd.Dir, _ = os.Getwd() - - // Set environment to suppress macOS linker warnings - cmd.Env = append(os.Environ(), getMacOSDeploymentTarget()) - - if !jsonOutput { - fmt.Printf("%s Running tests\n", testHeaderStyle.Render("Test:")) - fmt.Printf(" Package: %s\n", testDimStyle.Render(pkg)) - if run != "" { - fmt.Printf(" Filter: %s\n", testDimStyle.Render(run)) - } - fmt.Println() - } - - // Capture output for parsing - var stdout, stderr strings.Builder - - if verbose && !jsonOutput { - // Stream output in verbose mode, but also capture for parsing - cmd.Stdout = io.MultiWriter(os.Stdout, &stdout) - cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) - } else { - // Capture output for parsing - cmd.Stdout = &stdout - cmd.Stderr = &stderr - } - - err := cmd.Run() - exitCode := 0 - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } - } - - // Combine stdout and stderr for parsing, filtering linker warnings - combined := filterLinkerWarnings(stdout.String() + "\n" + stderr.String()) - - // Parse results - results := parseTestOutput(combined) - - if jsonOutput { - // JSON output for CI/agents - printJSONResults(results, exitCode) - if exitCode != 0 { - return fmt.Errorf("tests failed") - } - return nil - } - - // Print summary - if !verbose { - printTestSummary(results, coverage) - } else if coverage { - // In verbose mode, still show coverage summary at end - fmt.Println() - printCoverageSummary(results) - } - - if exitCode != 0 { - fmt.Printf("\n%s Tests failed\n", testFailStyle.Render("FAIL")) - return fmt.Errorf("tests failed") - } - - fmt.Printf("\n%s All tests passed\n", testPassStyle.Render("PASS")) - return nil -} - -func getMacOSDeploymentTarget() string { - if runtime.GOOS == "darwin" { - // Use deployment target matching current macOS to suppress linker warnings - return "MACOSX_DEPLOYMENT_TARGET=26.0" - } - return "" -} - -func filterLinkerWarnings(output string) string { - // Filter out ld: warning lines that pollute the output - var filtered []string - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() { - line := scanner.Text() - // Skip linker warnings - if strings.HasPrefix(line, "ld: warning:") { - continue - } - // Skip test binary build comments - if strings.HasPrefix(line, "# ") && strings.HasSuffix(line, ".test") { - continue - } - filtered = append(filtered, line) - } - return strings.Join(filtered, "\n") -} - -type testResults struct { - packages []packageCoverage - passed int - failed int - skipped int - totalCov float64 - covCount int - failedPkgs []string -} - -func parseTestOutput(output string) testResults { - results := testResults{} - - // Regex patterns - handle both timed and cached test results - // Example: ok github.com/host-uk/core/pkg/crypt 0.015s coverage: 91.2% of statements - // Example: ok github.com/host-uk/core/pkg/crypt (cached) coverage: 91.2% of statements - okPattern := regexp.MustCompile(`^ok\s+(\S+)\s+(?:[\d.]+s|\(cached\))(?:\s+coverage:\s+([\d.]+)%)?`) - failPattern := regexp.MustCompile(`^FAIL\s+(\S+)`) - skipPattern := regexp.MustCompile(`^\?\s+(\S+)\s+\[no test files\]`) - coverPattern := regexp.MustCompile(`coverage:\s+([\d.]+)%`) - - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() { - line := scanner.Text() - - if matches := okPattern.FindStringSubmatch(line); matches != nil { - pkg := packageCoverage{name: matches[1]} - if len(matches) > 2 && matches[2] != "" { - cov, _ := strconv.ParseFloat(matches[2], 64) - pkg.coverage = cov - pkg.hasCov = true - results.totalCov += cov - results.covCount++ - } - results.packages = append(results.packages, pkg) - results.passed++ - } else if matches := failPattern.FindStringSubmatch(line); matches != nil { - results.failed++ - results.failedPkgs = append(results.failedPkgs, matches[1]) - } else if matches := skipPattern.FindStringSubmatch(line); matches != nil { - results.skipped++ - } else if matches := coverPattern.FindStringSubmatch(line); matches != nil { - // Catch any additional coverage lines - cov, _ := strconv.ParseFloat(matches[1], 64) - if cov > 0 { - // Find the last package without coverage and update it - for i := len(results.packages) - 1; i >= 0; i-- { - if !results.packages[i].hasCov { - results.packages[i].coverage = cov - results.packages[i].hasCov = true - results.totalCov += cov - results.covCount++ - break - } - } - } - } - } - - return results -} - -func printTestSummary(results testResults, showCoverage bool) { - // Print pass/fail summary - total := results.passed + results.failed - if total > 0 { - fmt.Printf(" %s %d passed", testPassStyle.Render("✓"), results.passed) - if results.failed > 0 { - fmt.Printf(" %s %d failed", testFailStyle.Render("✗"), results.failed) - } - if results.skipped > 0 { - fmt.Printf(" %s %d skipped", testSkipStyle.Render("○"), results.skipped) - } - fmt.Println() - } - - // Print failed packages - if len(results.failedPkgs) > 0 { - fmt.Printf("\n Failed packages:\n") - for _, pkg := range results.failedPkgs { - fmt.Printf(" %s %s\n", testFailStyle.Render("✗"), pkg) - } - } - - // Print coverage - if showCoverage { - printCoverageSummary(results) - } else if results.covCount > 0 { - avgCov := results.totalCov / float64(results.covCount) - fmt.Printf("\n Coverage: %s\n", formatCoverage(avgCov)) - } -} - -func printCoverageSummary(results testResults) { - if len(results.packages) == 0 { - return - } - - fmt.Printf("\n %s\n", testHeaderStyle.Render("Coverage by package:")) - - // Sort packages by name - sort.Slice(results.packages, func(i, j int) bool { - return results.packages[i].name < results.packages[j].name - }) - - // Find max package name length for alignment - maxLen := 0 - for _, pkg := range results.packages { - name := shortenPackageName(pkg.name) - if len(name) > maxLen { - maxLen = len(name) - } - } - - // Print each package - for _, pkg := range results.packages { - if !pkg.hasCov { - continue - } - name := shortenPackageName(pkg.name) - padding := strings.Repeat(" ", maxLen-len(name)+2) - fmt.Printf(" %s%s%s\n", name, padding, formatCoverage(pkg.coverage)) - } - - // Print average - if results.covCount > 0 { - avgCov := results.totalCov / float64(results.covCount) - padding := strings.Repeat(" ", maxLen-7+2) - fmt.Printf("\n %s%s%s\n", testHeaderStyle.Render("Average"), padding, formatCoverage(avgCov)) - } -} - -func formatCoverage(cov float64) string { - var style lipgloss.Style - switch { - case cov >= 80: - style = testCovHighStyle - case cov >= 50: - style = testCovMedStyle - default: - style = testCovLowStyle - } - return style.Render(fmt.Sprintf("%.1f%%", cov)) -} - -func shortenPackageName(name string) string { - // Remove common prefixes - prefixes := []string{ - "github.com/host-uk/core/", - "github.com/host-uk/", - } - for _, prefix := range prefixes { - if strings.HasPrefix(name, prefix) { - return strings.TrimPrefix(name, prefix) - } - } - return filepath.Base(name) -} - -func printJSONResults(results testResults, exitCode int) { - // Simple JSON output for agents - fmt.Printf("{\n") - fmt.Printf(" \"passed\": %d,\n", results.passed) - fmt.Printf(" \"failed\": %d,\n", results.failed) - fmt.Printf(" \"skipped\": %d,\n", results.skipped) - if results.covCount > 0 { - avgCov := results.totalCov / float64(results.covCount) - fmt.Printf(" \"coverage\": %.1f,\n", avgCov) - } - fmt.Printf(" \"exit_code\": %d,\n", exitCode) - if len(results.failedPkgs) > 0 { - fmt.Printf(" \"failed_packages\": [\n") - for i, pkg := range results.failedPkgs { - comma := "," - if i == len(results.failedPkgs)-1 { - comma = "" - } - fmt.Printf(" %q%s\n", pkg, comma) - } - fmt.Printf(" ]\n") - } else { - fmt.Printf(" \"failed_packages\": []\n") - } - fmt.Printf("}\n") -} diff --git a/cmd/test/test_output.go b/cmd/test/test_output.go new file mode 100644 index 00000000..e61de078 --- /dev/null +++ b/cmd/test/test_output.go @@ -0,0 +1,205 @@ +package testcmd + +import ( + "bufio" + "fmt" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +type packageCoverage struct { + name string + coverage float64 + hasCov bool +} + +type testResults struct { + packages []packageCoverage + passed int + failed int + skipped int + totalCov float64 + covCount int + failedPkgs []string +} + +func parseTestOutput(output string) testResults { + results := testResults{} + + // Regex patterns - handle both timed and cached test results + // Example: ok github.com/host-uk/core/pkg/crypt 0.015s coverage: 91.2% of statements + // Example: ok github.com/host-uk/core/pkg/crypt (cached) coverage: 91.2% of statements + okPattern := regexp.MustCompile(`^ok\s+(\S+)\s+(?:[\d.]+s|\(cached\))(?:\s+coverage:\s+([\d.]+)%)?`) + failPattern := regexp.MustCompile(`^FAIL\s+(\S+)`) + skipPattern := regexp.MustCompile(`^\?\s+(\S+)\s+\[no test files\]`) + coverPattern := regexp.MustCompile(`coverage:\s+([\d.]+)%`) + + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + + if matches := okPattern.FindStringSubmatch(line); matches != nil { + pkg := packageCoverage{name: matches[1]} + if len(matches) > 2 && matches[2] != "" { + cov, _ := strconv.ParseFloat(matches[2], 64) + pkg.coverage = cov + pkg.hasCov = true + results.totalCov += cov + results.covCount++ + } + results.packages = append(results.packages, pkg) + results.passed++ + } else if matches := failPattern.FindStringSubmatch(line); matches != nil { + results.failed++ + results.failedPkgs = append(results.failedPkgs, matches[1]) + } else if matches := skipPattern.FindStringSubmatch(line); matches != nil { + results.skipped++ + } else if matches := coverPattern.FindStringSubmatch(line); matches != nil { + // Catch any additional coverage lines + cov, _ := strconv.ParseFloat(matches[1], 64) + if cov > 0 { + // Find the last package without coverage and update it + for i := len(results.packages) - 1; i >= 0; i-- { + if !results.packages[i].hasCov { + results.packages[i].coverage = cov + results.packages[i].hasCov = true + results.totalCov += cov + results.covCount++ + break + } + } + } + } + } + + return results +} + +func printTestSummary(results testResults, showCoverage bool) { + // Print pass/fail summary + total := results.passed + results.failed + if total > 0 { + fmt.Printf(" %s %d passed", testPassStyle.Render("✓"), results.passed) + if results.failed > 0 { + fmt.Printf(" %s %d failed", testFailStyle.Render("✗"), results.failed) + } + if results.skipped > 0 { + fmt.Printf(" %s %d skipped", testSkipStyle.Render("○"), results.skipped) + } + fmt.Println() + } + + // Print failed packages + if len(results.failedPkgs) > 0 { + fmt.Printf("\n Failed packages:\n") + for _, pkg := range results.failedPkgs { + fmt.Printf(" %s %s\n", testFailStyle.Render("✗"), pkg) + } + } + + // Print coverage + if showCoverage { + printCoverageSummary(results) + } else if results.covCount > 0 { + avgCov := results.totalCov / float64(results.covCount) + fmt.Printf("\n Coverage: %s\n", formatCoverage(avgCov)) + } +} + +func printCoverageSummary(results testResults) { + if len(results.packages) == 0 { + return + } + + fmt.Printf("\n %s\n", testHeaderStyle.Render("Coverage by package:")) + + // Sort packages by name + sort.Slice(results.packages, func(i, j int) bool { + return results.packages[i].name < results.packages[j].name + }) + + // Find max package name length for alignment + maxLen := 0 + for _, pkg := range results.packages { + name := shortenPackageName(pkg.name) + if len(name) > maxLen { + maxLen = len(name) + } + } + + // Print each package + for _, pkg := range results.packages { + if !pkg.hasCov { + continue + } + name := shortenPackageName(pkg.name) + padding := strings.Repeat(" ", maxLen-len(name)+2) + fmt.Printf(" %s%s%s\n", name, padding, formatCoverage(pkg.coverage)) + } + + // Print average + if results.covCount > 0 { + avgCov := results.totalCov / float64(results.covCount) + padding := strings.Repeat(" ", maxLen-7+2) + fmt.Printf("\n %s%s%s\n", testHeaderStyle.Render("Average"), padding, formatCoverage(avgCov)) + } +} + +func formatCoverage(cov float64) string { + var style lipgloss.Style + switch { + case cov >= 80: + style = testCovHighStyle + case cov >= 50: + style = testCovMedStyle + default: + style = testCovLowStyle + } + return style.Render(fmt.Sprintf("%.1f%%", cov)) +} + +func shortenPackageName(name string) string { + // Remove common prefixes + prefixes := []string{ + "github.com/host-uk/core/", + "github.com/host-uk/", + } + for _, prefix := range prefixes { + if strings.HasPrefix(name, prefix) { + return strings.TrimPrefix(name, prefix) + } + } + return filepath.Base(name) +} + +func printJSONResults(results testResults, exitCode int) { + // Simple JSON output for agents + fmt.Printf("{\n") + fmt.Printf(" \"passed\": %d,\n", results.passed) + fmt.Printf(" \"failed\": %d,\n", results.failed) + fmt.Printf(" \"skipped\": %d,\n", results.skipped) + if results.covCount > 0 { + avgCov := results.totalCov / float64(results.covCount) + fmt.Printf(" \"coverage\": %.1f,\n", avgCov) + } + fmt.Printf(" \"exit_code\": %d,\n", exitCode) + if len(results.failedPkgs) > 0 { + fmt.Printf(" \"failed_packages\": [\n") + for i, pkg := range results.failedPkgs { + comma := "," + if i == len(results.failedPkgs)-1 { + comma = "" + } + fmt.Printf(" %q%s\n", pkg, comma) + } + fmt.Printf(" ]\n") + } else { + fmt.Printf(" \"failed_packages\": []\n") + } + fmt.Printf("}\n") +} diff --git a/cmd/test/test_runner.go b/cmd/test/test_runner.go new file mode 100644 index 00000000..16e5f07e --- /dev/null +++ b/cmd/test/test_runner.go @@ -0,0 +1,142 @@ +package testcmd + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" +) + +func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error { + // Detect if we're in a Go project + if _, err := os.Stat("go.mod"); os.IsNotExist(err) { + return fmt.Errorf("no go.mod found - run from a Go project directory") + } + + // Build command arguments + args := []string{"test"} + + // Default to ./... if no package specified + if pkg == "" { + pkg = "./..." + } + + // Add flags + if verbose { + args = append(args, "-v") + } + if short { + args = append(args, "-short") + } + if run != "" { + args = append(args, "-run", run) + } + if race { + args = append(args, "-race") + } + + // Always add coverage + args = append(args, "-cover") + + // Add package pattern + args = append(args, pkg) + + // Create command + cmd := exec.Command("go", args...) + cmd.Dir, _ = os.Getwd() + + // Set environment to suppress macOS linker warnings + cmd.Env = append(os.Environ(), getMacOSDeploymentTarget()) + + if !jsonOutput { + fmt.Printf("%s Running tests\n", testHeaderStyle.Render("Test:")) + fmt.Printf(" Package: %s\n", testDimStyle.Render(pkg)) + if run != "" { + fmt.Printf(" Filter: %s\n", testDimStyle.Render(run)) + } + fmt.Println() + } + + // Capture output for parsing + var stdout, stderr strings.Builder + + if verbose && !jsonOutput { + // Stream output in verbose mode, but also capture for parsing + cmd.Stdout = io.MultiWriter(os.Stdout, &stdout) + cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) + } else { + // Capture output for parsing + cmd.Stdout = &stdout + cmd.Stderr = &stderr + } + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + } + + // Combine stdout and stderr for parsing, filtering linker warnings + combined := filterLinkerWarnings(stdout.String() + "\n" + stderr.String()) + + // Parse results + results := parseTestOutput(combined) + + if jsonOutput { + // JSON output for CI/agents + printJSONResults(results, exitCode) + if exitCode != 0 { + return fmt.Errorf("tests failed") + } + return nil + } + + // Print summary + if !verbose { + printTestSummary(results, coverage) + } else if coverage { + // In verbose mode, still show coverage summary at end + fmt.Println() + printCoverageSummary(results) + } + + if exitCode != 0 { + fmt.Printf("\n%s Tests failed\n", testFailStyle.Render("FAIL")) + return fmt.Errorf("tests failed") + } + + fmt.Printf("\n%s All tests passed\n", testPassStyle.Render("PASS")) + return nil +} + +func getMacOSDeploymentTarget() string { + if runtime.GOOS == "darwin" { + // Use deployment target matching current macOS to suppress linker warnings + return "MACOSX_DEPLOYMENT_TARGET=26.0" + } + return "" +} + +func filterLinkerWarnings(output string) string { + // Filter out ld: warning lines that pollute the output + var filtered []string + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + // Skip linker warnings + if strings.HasPrefix(line, "ld: warning:") { + continue + } + // Skip test binary build comments + if strings.HasPrefix(line, "# ") && strings.HasSuffix(line, ".test") { + continue + } + filtered = append(filtered, line) + } + return strings.Join(filtered, "\n") +} diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index a0d1bd41..b18e9ab7 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -16,5 +16,5 @@ import "github.com/leaanthony/clir" // AddCommands registers the 'vm' command and all subcommands. func AddCommands(app *clir.Cli) { - AddContainerCommands(app) + AddVMCommands(app) } diff --git a/cmd/vm/container.go b/cmd/vm/container.go index 66fbaa0b..314b2534 100644 --- a/cmd/vm/container.go +++ b/cmd/vm/container.go @@ -1,4 +1,3 @@ -// Package vm provides LinuxKit VM management commands. package vm import ( @@ -14,28 +13,6 @@ import ( "github.com/leaanthony/clir" ) -// AddContainerCommands adds container-related commands under 'vm' to the CLI. -func AddContainerCommands(parent *clir.Cli) { - vmCmd := parent.NewSubCommand("vm", "LinuxKit VM management") - vmCmd.LongDescription("Manage LinuxKit virtual machines.\n\n" + - "LinuxKit VMs are lightweight, immutable VMs built from YAML templates.\n" + - "They run using qemu or hyperkit depending on your system.\n\n" + - "Commands:\n" + - " run Run a VM from image or template\n" + - " ps List running VMs\n" + - " stop Stop a running VM\n" + - " logs View VM logs\n" + - " exec Execute command in VM\n" + - " templates Manage LinuxKit templates") - - addVMRunCommand(vmCmd) - addVMPsCommand(vmCmd) - addVMStopCommand(vmCmd) - addVMLogsCommand(vmCmd) - addVMExecCommand(vmCmd) - addVMTemplatesCommand(vmCmd) -} - // addVMRunCommand adds the 'run' command under vm. func addVMRunCommand(parent *clir.Command) { var ( diff --git a/cmd/vm/templates.go b/cmd/vm/templates.go index 49315af7..140dcd96 100644 --- a/cmd/vm/templates.go +++ b/cmd/vm/templates.go @@ -9,25 +9,10 @@ import ( "strings" "text/tabwriter" - "github.com/charmbracelet/lipgloss" - "github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/pkg/container" "github.com/leaanthony/clir" ) -// Style aliases -var ( - repoNameStyle = shared.RepoNameStyle - successStyle = shared.SuccessStyle - errorStyle = shared.ErrorStyle - dimStyle = shared.DimStyle -) - -var ( - varStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b")) - defaultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Italic(true) -) - // addVMTemplatesCommand adds the 'templates' command under vm. func addVMTemplatesCommand(parent *clir.Command) { templatesCmd := parent.NewSubCommand("templates", "Manage LinuxKit templates") diff --git a/cmd/vm/vm.go b/cmd/vm/vm.go new file mode 100644 index 00000000..0a0f39ad --- /dev/null +++ b/cmd/vm/vm.go @@ -0,0 +1,44 @@ +// Package vm provides LinuxKit VM management commands. +package vm + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/cmd/shared" + "github.com/leaanthony/clir" +) + +// Style aliases from shared +var ( + repoNameStyle = shared.RepoNameStyle + successStyle = shared.SuccessStyle + errorStyle = shared.ErrorStyle + dimStyle = shared.DimStyle +) + +// VM-specific styles +var ( + varStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b")) + defaultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Italic(true) +) + +// AddVMCommands adds container-related commands under 'vm' to the CLI. +func AddVMCommands(parent *clir.Cli) { + vmCmd := parent.NewSubCommand("vm", "LinuxKit VM management") + vmCmd.LongDescription("Manage LinuxKit virtual machines.\n\n" + + "LinuxKit VMs are lightweight, immutable VMs built from YAML templates.\n" + + "They run using qemu or hyperkit depending on your system.\n\n" + + "Commands:\n" + + " run Run a VM from image or template\n" + + " ps List running VMs\n" + + " stop Stop a running VM\n" + + " logs View VM logs\n" + + " exec Execute command in VM\n" + + " templates Manage LinuxKit templates") + + addVMRunCommand(vmCmd) + addVMPsCommand(vmCmd) + addVMStopCommand(vmCmd) + addVMLogsCommand(vmCmd) + addVMExecCommand(vmCmd) + addVMTemplatesCommand(vmCmd) +}