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 <noreply@anthropic.com>
This commit is contained in:
parent
e4d79ce952
commit
cdf74d9f30
56 changed files with 5336 additions and 5233 deletions
|
|
@ -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 <id> - show task details and claim
|
|
||||||
addTaskCommand(parent)
|
|
||||||
|
|
||||||
// core ai task:update <id> - update task
|
|
||||||
addTaskUpdateCommand(parent)
|
|
||||||
|
|
||||||
// core ai task:complete <id> - mark task complete
|
|
||||||
addTaskCompleteCommand(parent)
|
|
||||||
|
|
||||||
// core ai task:commit <id> - auto-commit with task reference
|
|
||||||
addTaskCommitCommand(parent)
|
|
||||||
|
|
||||||
// core ai task:pr <id> - 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 <id>' 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 <noreply@anthropic.com>\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
|
|
||||||
}
|
|
||||||
68
cmd/ai/ai.go
Normal file
68
cmd/ai/ai.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
267
cmd/ai/ai_git.go
Normal file
267
cmd/ai/ai_git.go
Normal file
|
|
@ -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 <noreply@anthropic.com>\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
|
||||||
|
}
|
||||||
288
cmd/ai/ai_tasks.go
Normal file
288
cmd/ai/ai_tasks.go
Normal file
|
|
@ -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 <id>' 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
134
cmd/ai/ai_updates.go
Normal file
134
cmd/ai/ai_updates.go
Normal file
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -2,28 +2,10 @@
|
||||||
package build
|
package build
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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/clir"
|
||||||
"github.com/leaanthony/debme"
|
|
||||||
"github.com/leaanthony/gosod"
|
|
||||||
"golang.org/x/net/html"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build command styles
|
// 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.StringFlag("path", "The path to the static web application files.", &fromPath)
|
||||||
fromPathCmd.Action(func() error {
|
fromPathCmd.Action(func() error {
|
||||||
if fromPath == "" {
|
if fromPath == "" {
|
||||||
return fmt.Errorf("the --path flag is required")
|
return errPathRequired
|
||||||
}
|
}
|
||||||
return runBuild(fromPath)
|
return runBuild(fromPath)
|
||||||
})
|
})
|
||||||
|
|
@ -123,7 +105,7 @@ func AddBuildCommand(app *clir.Cli) {
|
||||||
pwaCmd.StringFlag("url", "The URL of the PWA to build.", &pwaURL)
|
pwaCmd.StringFlag("url", "The URL of the PWA to build.", &pwaURL)
|
||||||
pwaCmd.Action(func() error {
|
pwaCmd.Action(func() error {
|
||||||
if pwaURL == "" {
|
if pwaURL == "" {
|
||||||
return fmt.Errorf("a URL argument is required")
|
return errURLRequired
|
||||||
}
|
}
|
||||||
return runPwaBuild(pwaURL)
|
return runPwaBuild(pwaURL)
|
||||||
})
|
})
|
||||||
|
|
@ -147,749 +129,3 @@ func AddBuildCommand(app *clir.Cli) {
|
||||||
return runBuildSDK(sdkSpec, sdkLang, sdkVersion, sdkDryRun)
|
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 <link rel=\"manifest\"> 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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
369
cmd/build/build_project.go
Normal file
369
cmd/build/build_project.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
323
cmd/build/build_pwa.go
Normal file
323
cmd/build/build_pwa.go
Normal file
|
|
@ -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 <link rel=\"manifest\"> 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
|
||||||
|
})
|
||||||
|
}
|
||||||
81
cmd/build/build_sdk.go
Normal file
81
cmd/build/build_sdk.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,12 @@
|
||||||
// - Taskfile-based projects
|
// - Taskfile-based projects
|
||||||
//
|
//
|
||||||
// Configuration via .core/build.yaml or command-line flags.
|
// 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
|
package build
|
||||||
|
|
||||||
import "github.com/leaanthony/clir"
|
import "github.com/leaanthony/clir"
|
||||||
|
|
|
||||||
31
cmd/ci/ci_changelog.go
Normal file
31
cmd/ci/ci_changelog.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
71
cmd/ci/ci_init.go
Normal file
71
cmd/ci/ci_init.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
79
cmd/ci/ci_publish.go
Normal file
79
cmd/ci/ci_publish.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -2,37 +2,17 @@
|
||||||
package ci
|
package ci
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/host-uk/core/pkg/release"
|
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CIRelease command styles
|
// Style aliases from shared
|
||||||
var (
|
var (
|
||||||
releaseHeaderStyle = lipgloss.NewStyle().
|
releaseHeaderStyle = shared.RepoNameStyle
|
||||||
Bold(true).
|
releaseSuccessStyle = shared.SuccessStyle
|
||||||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
releaseErrorStyle = shared.ErrorStyle
|
||||||
|
releaseDimStyle = shared.DimStyle
|
||||||
releaseSuccessStyle = lipgloss.NewStyle().
|
releaseValueStyle = shared.ValueStyle
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddCIReleaseCommand adds the release command and its subcommands.
|
// AddCIReleaseCommand adds the release command and its subcommands.
|
||||||
|
|
@ -84,172 +64,3 @@ func AddCIReleaseCommand(app *clir.Cli) {
|
||||||
return runCIReleaseVersion()
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
24
cmd/ci/ci_version.go
Normal file
24
cmd/ci/ci_version.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
615
cmd/dev/dev.go
615
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
|
package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/host-uk/core/pkg/devops"
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dev-specific styles
|
// Style aliases from shared package
|
||||||
var (
|
var (
|
||||||
devHeaderStyle = lipgloss.NewStyle().
|
successStyle = shared.SuccessStyle
|
||||||
Bold(true).
|
errorStyle = shared.ErrorStyle
|
||||||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
warningStyle = shared.WarningStyle
|
||||||
|
dimStyle = shared.DimStyle
|
||||||
devSuccessStyle = lipgloss.NewStyle().
|
valueStyle = shared.ValueStyle
|
||||||
Foreground(lipgloss.Color("#22c55e")). // green-500
|
headerStyle = shared.HeaderStyle
|
||||||
Bold(true)
|
repoNameStyle = shared.RepoNameStyle
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddDevCommand adds the dev environment commands to the dev parent command.
|
// Table styles for status display
|
||||||
// These are added as direct subcommands: core dev install, core dev boot, etc.
|
var (
|
||||||
func AddDevCommand(parent *clir.Command) {
|
cellStyle = lipgloss.NewStyle().
|
||||||
AddDevInstallCommand(parent)
|
Padding(0, 1)
|
||||||
AddDevBootCommand(parent)
|
|
||||||
AddDevStopCommand(parent)
|
dirtyStyle = lipgloss.NewStyle().
|
||||||
AddDevStatusCommand(parent)
|
Foreground(lipgloss.Color("#ef4444")). // red-500
|
||||||
AddDevShellCommand(parent)
|
Padding(0, 1)
|
||||||
AddDevServeCommand(parent)
|
|
||||||
AddDevTestCommand(parent)
|
aheadStyle = lipgloss.NewStyle().
|
||||||
AddDevClaudeCommand(parent)
|
Foreground(lipgloss.Color("#22c55e")). // green-500
|
||||||
AddDevUpdateCommand(parent)
|
Padding(0, 1)
|
||||||
}
|
|
||||||
|
cleanStyle = lipgloss.NewStyle().
|
||||||
// AddDevInstallCommand adds the 'dev install' command.
|
Foreground(lipgloss.Color("#6b7280")). // gray-500
|
||||||
func AddDevInstallCommand(parent *clir.Command) {
|
Padding(0, 1)
|
||||||
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" +
|
// AddCommands registers the 'dev' command and all subcommands.
|
||||||
"Downloads are cached at ~/.core/images/\n\n" +
|
func AddCommands(app *clir.Cli) {
|
||||||
"Examples:\n" +
|
devCmd := app.NewSubCommand("dev", "Multi-repo development workflow")
|
||||||
" core dev install")
|
devCmd.LongDescription("Manage multiple git repositories and GitHub integration.\n\n" +
|
||||||
|
"Uses repos.yaml to discover repositories. Falls back to scanning\n" +
|
||||||
installCmd.Action(func() error {
|
"the current directory if no registry is found.\n\n" +
|
||||||
return runDevInstall()
|
"Git Operations:\n" +
|
||||||
})
|
" work Combined status -> commit -> push workflow\n" +
|
||||||
}
|
" health Quick repo health summary\n" +
|
||||||
|
" commit Claude-assisted commit messages\n" +
|
||||||
func runDevInstall() error {
|
" push Push repos with unpushed commits\n" +
|
||||||
d, err := devops.New()
|
" pull Pull repos behind remote\n\n" +
|
||||||
if err != nil {
|
"GitHub Integration (requires gh CLI):\n" +
|
||||||
return err
|
" issues List open issues across repos\n" +
|
||||||
}
|
" reviews List PRs awaiting review\n" +
|
||||||
|
" ci Check GitHub Actions status\n" +
|
||||||
if d.IsInstalled() {
|
" impact Analyse dependency impact\n\n" +
|
||||||
fmt.Println(devSuccessStyle.Render("Dev environment already installed"))
|
"Dev Environment:\n" +
|
||||||
fmt.Println()
|
" install Download dev environment image\n" +
|
||||||
fmt.Printf("Use %s to check for updates\n", devDimStyle.Render("core dev update"))
|
" boot Start dev environment VM\n" +
|
||||||
return nil
|
" stop Stop dev environment VM\n" +
|
||||||
}
|
" shell Open shell in dev VM\n" +
|
||||||
|
" status Check dev VM status")
|
||||||
fmt.Printf("%s %s\n", devDimStyle.Render("Image:"), devops.ImageName())
|
|
||||||
fmt.Println()
|
// Git operations
|
||||||
fmt.Println("Downloading dev environment...")
|
addWorkCommand(devCmd)
|
||||||
fmt.Println()
|
addHealthCommand(devCmd)
|
||||||
|
addCommitCommand(devCmd)
|
||||||
ctx := context.Background()
|
addPushCommand(devCmd)
|
||||||
start := time.Now()
|
addPullCommand(devCmd)
|
||||||
var lastProgress int64
|
|
||||||
|
// GitHub integration
|
||||||
err = d.Install(ctx, func(downloaded, total int64) {
|
addIssuesCommand(devCmd)
|
||||||
if total > 0 {
|
addReviewsCommand(devCmd)
|
||||||
pct := int(float64(downloaded) / float64(total) * 100)
|
addCICommand(devCmd)
|
||||||
if pct != int(float64(lastProgress)/float64(total)*100) {
|
addImpactCommand(devCmd)
|
||||||
fmt.Printf("\r%s %d%%", devDimStyle.Render("Progress:"), pct)
|
|
||||||
lastProgress = downloaded
|
// API tools
|
||||||
}
|
addAPICommands(devCmd)
|
||||||
}
|
|
||||||
})
|
// Dev environment
|
||||||
|
addVMCommands(devCmd)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import (
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddAPICommands adds the 'api' command and its subcommands to the given parent command.
|
// addAPICommands adds the 'api' command and its subcommands to the given parent command.
|
||||||
func AddAPICommands(parent *clir.Command) {
|
func addAPICommands(parent *clir.Command) {
|
||||||
// Create the 'api' command
|
// Create the 'api' command
|
||||||
apiCmd := parent.NewSubCommand("api", "Tools for managing service APIs")
|
apiCmd := parent.NewSubCommand("api", "Tools for managing service APIs")
|
||||||
|
|
||||||
// Add the 'sync' command to 'api'
|
// Add the 'sync' command to 'api'
|
||||||
AddSyncCommand(apiCmd)
|
addSyncCommand(apiCmd)
|
||||||
|
|
||||||
// TODO: Add the 'test-gen' command to 'api'
|
// TODO: Add the 'test-gen' command to 'api'
|
||||||
// AddTestGenCommand(apiCmd)
|
// addTestGenCommand(apiCmd)
|
||||||
}
|
}
|
||||||
|
|
@ -9,10 +9,12 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CI-specific styles
|
||||||
var (
|
var (
|
||||||
ciSuccessStyle = lipgloss.NewStyle().
|
ciSuccessStyle = lipgloss.NewStyle().
|
||||||
Bold(true).
|
Bold(true).
|
||||||
|
|
@ -43,8 +45,8 @@ type WorkflowRun struct {
|
||||||
RepoName string `json:"-"`
|
RepoName string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddCICommand adds the 'ci' command to the given parent command.
|
// addCICommand adds the 'ci' command to the given parent command.
|
||||||
func AddCICommand(parent *clir.Command) {
|
func addCICommand(parent *clir.Command) {
|
||||||
var registryPath string
|
var registryPath string
|
||||||
var branch string
|
var branch string
|
||||||
var failedOnly bool
|
var failedOnly bool
|
||||||
|
|
@ -149,16 +151,16 @@ func runCI(registryPath string, branch string, failedOnly bool) error {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%d repos checked", len(repoList))
|
fmt.Printf("%d repos checked", len(repoList))
|
||||||
if success > 0 {
|
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 {
|
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 {
|
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 {
|
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()
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
@ -227,30 +229,30 @@ func printWorkflowRun(run WorkflowRun) {
|
||||||
var status string
|
var status string
|
||||||
switch run.Conclusion {
|
switch run.Conclusion {
|
||||||
case "success":
|
case "success":
|
||||||
status = ciSuccessStyle.Render("✓")
|
status = ciSuccessStyle.Render("v")
|
||||||
case "failure":
|
case "failure":
|
||||||
status = ciFailureStyle.Render("✗")
|
status = ciFailureStyle.Render("x")
|
||||||
case "":
|
case "":
|
||||||
if run.Status == "in_progress" {
|
if run.Status == "in_progress" {
|
||||||
status = ciPendingStyle.Render("●")
|
status = ciPendingStyle.Render("*")
|
||||||
} else if run.Status == "queued" {
|
} else if run.Status == "queued" {
|
||||||
status = ciPendingStyle.Render("○")
|
status = ciPendingStyle.Render("o")
|
||||||
} else {
|
} else {
|
||||||
status = ciSkippedStyle.Render("—")
|
status = ciSkippedStyle.Render("-")
|
||||||
}
|
}
|
||||||
case "skipped":
|
case "skipped":
|
||||||
status = ciSkippedStyle.Render("—")
|
status = ciSkippedStyle.Render("-")
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
status = ciSkippedStyle.Render("○")
|
status = ciSkippedStyle.Render("o")
|
||||||
default:
|
default:
|
||||||
status = ciSkippedStyle.Render("?")
|
status = ciSkippedStyle.Render("?")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workflow name (truncated)
|
// Workflow name (truncated)
|
||||||
workflowName := truncate(run.Name, 20)
|
workflowName := shared.Truncate(run.Name, 20)
|
||||||
|
|
||||||
// Age
|
// Age
|
||||||
age := formatAge(run.UpdatedAt)
|
age := shared.FormatAge(run.UpdatedAt)
|
||||||
|
|
||||||
fmt.Printf(" %s %-18s %-22s %s\n",
|
fmt.Printf(" %s %-18s %-22s %s\n",
|
||||||
status,
|
status,
|
||||||
|
|
@ -4,16 +4,15 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"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/git"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddCommitCommand adds the 'commit' command to the given parent command.
|
// addCommitCommand adds the 'commit' command to the given parent command.
|
||||||
func AddCommitCommand(parent *clir.Command) {
|
func addCommitCommand(parent *clir.Command) {
|
||||||
var registryPath string
|
var registryPath string
|
||||||
var all bool
|
var all bool
|
||||||
|
|
||||||
|
|
@ -116,7 +115,7 @@ func runCommit(registryPath string, all bool) error {
|
||||||
// Confirm unless --all
|
// Confirm unless --all
|
||||||
if !all {
|
if !all {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
if !confirm("Have Claude commit these repos?") {
|
if !shared.Confirm("Have Claude commit these repos?") {
|
||||||
fmt.Println("Aborted.")
|
fmt.Println("Aborted.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -130,10 +129,10 @@ func runCommit(registryPath string, all bool) error {
|
||||||
fmt.Printf("%s %s\n", dimStyle.Render("Committing"), s.Name)
|
fmt.Printf("%s %s\n", dimStyle.Render("Committing"), s.Name)
|
||||||
|
|
||||||
if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil {
|
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++
|
failed++
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" %s committed\n", successStyle.Render("✓"))
|
fmt.Printf(" %s committed\n", successStyle.Render("v"))
|
||||||
succeeded++
|
succeeded++
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
@ -148,25 +147,3 @@ func runCommit(registryPath string, all bool) error {
|
||||||
|
|
||||||
return nil
|
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 <noreply@anthropic.com>. 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()
|
|
||||||
}
|
|
||||||
|
|
@ -6,35 +6,13 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/host-uk/core/pkg/git"
|
"github.com/host-uk/core/pkg/git"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// addHealthCommand adds the 'health' command to the given parent command.
|
||||||
healthLabelStyle = lipgloss.NewStyle().
|
func addHealthCommand(parent *clir.Command) {
|
||||||
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) {
|
|
||||||
var registryPath string
|
var registryPath string
|
||||||
var verbose bool
|
var verbose bool
|
||||||
|
|
||||||
|
|
@ -108,11 +86,11 @@ func runHealth(registryPath string, verbose bool) error {
|
||||||
|
|
||||||
// Aggregate stats
|
// Aggregate stats
|
||||||
var (
|
var (
|
||||||
totalRepos = len(statuses)
|
totalRepos = len(statuses)
|
||||||
dirtyRepos []string
|
dirtyRepos []string
|
||||||
aheadRepos []string
|
aheadRepos []string
|
||||||
behindRepos []string
|
behindRepos []string
|
||||||
errorRepos []string
|
errorRepos []string
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, s := range statuses {
|
for _, s := range statuses {
|
||||||
|
|
@ -139,16 +117,16 @@ func runHealth(registryPath string, verbose bool) error {
|
||||||
// Verbose output
|
// Verbose output
|
||||||
if verbose {
|
if verbose {
|
||||||
if len(dirtyRepos) > 0 {
|
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 {
|
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 {
|
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 {
|
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()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
@ -158,62 +136,62 @@ func runHealth(registryPath string, verbose bool) error {
|
||||||
|
|
||||||
func printHealthSummary(total int, dirty, ahead, behind, errors []string) {
|
func printHealthSummary(total int, dirty, ahead, behind, errors []string) {
|
||||||
// Total repos
|
// Total repos
|
||||||
fmt.Print(healthValueStyle.Render(fmt.Sprintf("%d", total)))
|
fmt.Print(valueStyle.Render(fmt.Sprintf("%d", total)))
|
||||||
fmt.Print(healthLabelStyle.Render(" repos"))
|
fmt.Print(dimStyle.Render(" repos"))
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
fmt.Print(healthLabelStyle.Render(" │ "))
|
fmt.Print(dimStyle.Render(" | "))
|
||||||
|
|
||||||
// Dirty
|
// Dirty
|
||||||
if len(dirty) > 0 {
|
if len(dirty) > 0 {
|
||||||
fmt.Print(healthWarnStyle.Render(fmt.Sprintf("%d", len(dirty))))
|
fmt.Print(warningStyle.Render(fmt.Sprintf("%d", len(dirty))))
|
||||||
fmt.Print(healthLabelStyle.Render(" dirty"))
|
fmt.Print(dimStyle.Render(" dirty"))
|
||||||
} else {
|
} else {
|
||||||
fmt.Print(healthGoodStyle.Render("clean"))
|
fmt.Print(successStyle.Render("clean"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
fmt.Print(healthLabelStyle.Render(" │ "))
|
fmt.Print(dimStyle.Render(" | "))
|
||||||
|
|
||||||
// Ahead
|
// Ahead
|
||||||
if len(ahead) > 0 {
|
if len(ahead) > 0 {
|
||||||
fmt.Print(healthValueStyle.Render(fmt.Sprintf("%d", len(ahead))))
|
fmt.Print(valueStyle.Render(fmt.Sprintf("%d", len(ahead))))
|
||||||
fmt.Print(healthLabelStyle.Render(" to push"))
|
fmt.Print(dimStyle.Render(" to push"))
|
||||||
} else {
|
} else {
|
||||||
fmt.Print(healthGoodStyle.Render("synced"))
|
fmt.Print(successStyle.Render("synced"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
fmt.Print(healthLabelStyle.Render(" │ "))
|
fmt.Print(dimStyle.Render(" | "))
|
||||||
|
|
||||||
// Behind
|
// Behind
|
||||||
if len(behind) > 0 {
|
if len(behind) > 0 {
|
||||||
fmt.Print(healthWarnStyle.Render(fmt.Sprintf("%d", len(behind))))
|
fmt.Print(warningStyle.Render(fmt.Sprintf("%d", len(behind))))
|
||||||
fmt.Print(healthLabelStyle.Render(" to pull"))
|
fmt.Print(dimStyle.Render(" to pull"))
|
||||||
} else {
|
} else {
|
||||||
fmt.Print(healthGoodStyle.Render("up to date"))
|
fmt.Print(successStyle.Render("up to date"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errors (only if any)
|
// Errors (only if any)
|
||||||
if len(errors) > 0 {
|
if len(errors) > 0 {
|
||||||
fmt.Print(healthLabelStyle.Render(" │ "))
|
fmt.Print(dimStyle.Render(" | "))
|
||||||
fmt.Print(healthBadStyle.Render(fmt.Sprintf("%d", len(errors))))
|
fmt.Print(errorStyle.Render(fmt.Sprintf("%d", len(errors))))
|
||||||
fmt.Print(healthLabelStyle.Render(" errors"))
|
fmt.Print(dimStyle.Render(" errors"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatRepoList(repos []string) string {
|
func formatRepoList(reposList []string) string {
|
||||||
if len(repos) <= 5 {
|
if len(reposList) <= 5 {
|
||||||
return joinRepos(repos)
|
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 := ""
|
result := ""
|
||||||
for i, r := range repos {
|
for i, r := range reposList {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
result += ", "
|
result += ", "
|
||||||
}
|
}
|
||||||
|
|
@ -6,10 +6,12 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Impact-specific styles
|
||||||
var (
|
var (
|
||||||
impactDirectStyle = lipgloss.NewStyle().
|
impactDirectStyle = lipgloss.NewStyle().
|
||||||
Bold(true).
|
Bold(true).
|
||||||
|
|
@ -22,8 +24,8 @@ var (
|
||||||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddImpactCommand adds the 'impact' command to the given parent command.
|
// addImpactCommand adds the 'impact' command to the given parent command.
|
||||||
func AddImpactCommand(parent *clir.Command) {
|
func addImpactCommand(parent *clir.Command) {
|
||||||
var registryPath string
|
var registryPath string
|
||||||
|
|
||||||
impactCmd := parent.NewSubCommand("impact", "Show impact of changing a repo")
|
impactCmd := parent.NewSubCommand("impact", "Show impact of changing a repo")
|
||||||
|
|
@ -110,21 +112,21 @@ func runImpact(registryPath string, repoName string) error {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
if len(allAffected) == 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct dependents
|
// Direct dependents
|
||||||
if len(direct) > 0 {
|
if len(direct) > 0 {
|
||||||
fmt.Printf("%s %d direct dependent(s):\n",
|
fmt.Printf("%s %d direct dependent(s):\n",
|
||||||
impactDirectStyle.Render("●"),
|
impactDirectStyle.Render("*"),
|
||||||
len(direct),
|
len(direct),
|
||||||
)
|
)
|
||||||
for _, d := range direct {
|
for _, d := range direct {
|
||||||
r, _ := reg.Get(d)
|
r, _ := reg.Get(d)
|
||||||
desc := ""
|
desc := ""
|
||||||
if r != nil && r.Description != "" {
|
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)
|
fmt.Printf(" %s%s\n", d, desc)
|
||||||
}
|
}
|
||||||
|
|
@ -134,14 +136,14 @@ func runImpact(registryPath string, repoName string) error {
|
||||||
// Indirect dependents
|
// Indirect dependents
|
||||||
if len(indirect) > 0 {
|
if len(indirect) > 0 {
|
||||||
fmt.Printf("%s %d transitive dependent(s):\n",
|
fmt.Printf("%s %d transitive dependent(s):\n",
|
||||||
impactIndirectStyle.Render("○"),
|
impactIndirectStyle.Render("o"),
|
||||||
len(indirect),
|
len(indirect),
|
||||||
)
|
)
|
||||||
for _, d := range indirect {
|
for _, d := range indirect {
|
||||||
r, _ := reg.Get(d)
|
r, _ := reg.Get(d)
|
||||||
desc := ""
|
desc := ""
|
||||||
if r != nil && r.Description != "" {
|
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)
|
fmt.Printf(" %s%s\n", d, desc)
|
||||||
}
|
}
|
||||||
|
|
@ -10,10 +10,12 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Issue-specific styles
|
||||||
var (
|
var (
|
||||||
issueRepoStyle = lipgloss.NewStyle().
|
issueRepoStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
||||||
|
|
@ -60,8 +62,8 @@ type GitHubIssue struct {
|
||||||
RepoName string `json:"-"`
|
RepoName string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddIssuesCommand adds the 'issues' command to the given parent command.
|
// addIssuesCommand adds the 'issues' command to the given parent command.
|
||||||
func AddIssuesCommand(parent *clir.Command) {
|
func addIssuesCommand(parent *clir.Command) {
|
||||||
var registryPath string
|
var registryPath string
|
||||||
var limit int
|
var limit int
|
||||||
var assignee string
|
var assignee string
|
||||||
|
|
@ -204,7 +206,7 @@ func printIssue(issue GitHubIssue) {
|
||||||
// #42 [core-bio] Fix avatar upload
|
// #42 [core-bio] Fix avatar upload
|
||||||
num := issueNumberStyle.Render(fmt.Sprintf("#%d", issue.Number))
|
num := issueNumberStyle.Render(fmt.Sprintf("#%d", issue.Number))
|
||||||
repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", issue.RepoName))
|
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)
|
line := fmt.Sprintf(" %s %s %s", num, repo, title)
|
||||||
|
|
||||||
|
|
@ -227,33 +229,8 @@ func printIssue(issue GitHubIssue) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add age
|
// Add age
|
||||||
age := formatAge(issue.CreatedAt)
|
age := shared.FormatAge(issue.CreatedAt)
|
||||||
line += " " + issueAgeStyle.Render(age)
|
line += " " + issueAgeStyle.Render(age)
|
||||||
|
|
||||||
fmt.Println(line)
|
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)))
|
|
||||||
}
|
|
||||||
|
|
@ -11,8 +11,8 @@ import (
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddPullCommand adds the 'pull' command to the given parent command.
|
// addPullCommand adds the 'pull' command to the given parent command.
|
||||||
func AddPullCommand(parent *clir.Command) {
|
func addPullCommand(parent *clir.Command) {
|
||||||
var registryPath string
|
var registryPath string
|
||||||
var all bool
|
var all bool
|
||||||
|
|
||||||
|
|
@ -119,10 +119,10 @@ func runPull(registryPath string, all bool) error {
|
||||||
|
|
||||||
err := gitPull(ctx, s.Path)
|
err := gitPull(ctx, s.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error()))
|
fmt.Printf("%s\n", errorStyle.Render("x "+err.Error()))
|
||||||
failed++
|
failed++
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("%s\n", successStyle.Render("✓"))
|
fmt.Printf("%s\n", successStyle.Render("v"))
|
||||||
succeeded++
|
succeeded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,13 +5,14 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/host-uk/core/pkg/git"
|
"github.com/host-uk/core/pkg/git"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddPushCommand adds the 'push' command to the given parent command.
|
// addPushCommand adds the 'push' command to the given parent command.
|
||||||
func AddPushCommand(parent *clir.Command) {
|
func addPushCommand(parent *clir.Command) {
|
||||||
var registryPath string
|
var registryPath string
|
||||||
var force bool
|
var force bool
|
||||||
|
|
||||||
|
|
@ -108,7 +109,7 @@ func runPush(registryPath string, force bool) error {
|
||||||
// Confirm unless --force
|
// Confirm unless --force
|
||||||
if !force {
|
if !force {
|
||||||
fmt.Println()
|
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.")
|
fmt.Println("Aborted.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -127,10 +128,10 @@ func runPush(registryPath string, force bool) error {
|
||||||
var succeeded, failed int
|
var succeeded, failed int
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
if r.Success {
|
if r.Success {
|
||||||
fmt.Printf(" %s %s\n", successStyle.Render("✓"), r.Name)
|
fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name)
|
||||||
succeeded++
|
succeeded++
|
||||||
} else {
|
} 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++
|
failed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,10 +10,12 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PR-specific styles
|
||||||
var (
|
var (
|
||||||
prNumberStyle = lipgloss.NewStyle().
|
prNumberStyle = lipgloss.NewStyle().
|
||||||
Bold(true).
|
Bold(true).
|
||||||
|
|
@ -65,8 +67,8 @@ type GitHubPR struct {
|
||||||
RepoName string `json:"-"`
|
RepoName string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddReviewsCommand adds the 'reviews' command to the given parent command.
|
// addReviewsCommand adds the 'reviews' command to the given parent command.
|
||||||
func AddReviewsCommand(parent *clir.Command) {
|
func addReviewsCommand(parent *clir.Command) {
|
||||||
var registryPath string
|
var registryPath string
|
||||||
var author string
|
var author string
|
||||||
var showAll bool
|
var showAll bool
|
||||||
|
|
@ -175,13 +177,13 @@ func runReviews(registryPath string, author string, showAll bool) error {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%d open PR(s)", len(allPRs))
|
fmt.Printf("%d open PR(s)", len(allPRs))
|
||||||
if pending > 0 {
|
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 {
|
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 {
|
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()
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
@ -243,18 +245,18 @@ func printPR(pr GitHubPR) {
|
||||||
// #12 [core-php] Webhook validation
|
// #12 [core-php] Webhook validation
|
||||||
num := prNumberStyle.Render(fmt.Sprintf("#%d", pr.Number))
|
num := prNumberStyle.Render(fmt.Sprintf("#%d", pr.Number))
|
||||||
repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", pr.RepoName))
|
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)
|
author := prAuthorStyle.Render("@" + pr.Author.Login)
|
||||||
|
|
||||||
// Review status
|
// Review status
|
||||||
var status string
|
var status string
|
||||||
switch pr.ReviewDecision {
|
switch pr.ReviewDecision {
|
||||||
case "APPROVED":
|
case "APPROVED":
|
||||||
status = prApprovedStyle.Render("✓ approved")
|
status = prApprovedStyle.Render("v approved")
|
||||||
case "CHANGES_REQUESTED":
|
case "CHANGES_REQUESTED":
|
||||||
status = prChangesStyle.Render("● changes requested")
|
status = prChangesStyle.Render("* changes requested")
|
||||||
default:
|
default:
|
||||||
status = prPendingStyle.Render("○ pending review")
|
status = prPendingStyle.Render("o pending review")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draft indicator
|
// Draft indicator
|
||||||
|
|
@ -263,7 +265,7 @@ func printPR(pr GitHubPR) {
|
||||||
draft = prDraftStyle.Render(" [draft]")
|
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))
|
fmt.Printf(" %s %s %s%s %s %s %s\n", num, repo, title, draft, author, status, issueAgeStyle.Render(age))
|
||||||
}
|
}
|
||||||
|
|
@ -15,8 +15,8 @@ import (
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddSyncCommand adds the 'sync' command to the given parent command.
|
// addSyncCommand adds the 'sync' command to the given parent command.
|
||||||
func AddSyncCommand(parent *clir.Command) {
|
func addSyncCommand(parent *clir.Command) {
|
||||||
syncCmd := parent.NewSubCommand("sync", "Synchronizes the public service APIs with their internal implementations.")
|
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.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 {
|
syncCmd.Action(func() error {
|
||||||
504
cmd/dev/dev_vm.go
Normal file
504
cmd/dev/dev_vm.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// Package dev provides multi-repo development workflow commands.
|
|
||||||
package dev
|
package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -11,52 +10,14 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/host-uk/core/pkg/git"
|
"github.com/host-uk/core/pkg/git"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// addWorkCommand adds the 'work' command to the given parent command.
|
||||||
// Table styles
|
func addWorkCommand(parent *clir.Command) {
|
||||||
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) {
|
|
||||||
var statusOnly bool
|
var statusOnly bool
|
||||||
var autoCommit bool
|
var autoCommit bool
|
||||||
var registryPath string
|
var registryPath string
|
||||||
|
|
@ -156,14 +117,15 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
// Auto-commit dirty repos if requested
|
// Auto-commit dirty repos if requested
|
||||||
if autoCommit && len(dirtyRepos) > 0 {
|
if autoCommit && len(dirtyRepos) > 0 {
|
||||||
fmt.Println()
|
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()
|
fmt.Println()
|
||||||
|
|
||||||
for _, s := range dirtyRepos {
|
for _, s := range dirtyRepos {
|
||||||
if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil {
|
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 {
|
} 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()
|
fmt.Println()
|
||||||
if !confirm("Push all?") {
|
if !shared.Confirm("Push all?") {
|
||||||
fmt.Println("Aborted.")
|
fmt.Println("Aborted.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -222,9 +184,9 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
|
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
if r.Success {
|
if r.Success {
|
||||||
fmt.Printf(" %s %s\n", successStyle.Render("✓"), r.Name)
|
fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name)
|
||||||
} else {
|
} 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
|
// Print separator
|
||||||
fmt.Println(strings.Repeat("─", nameWidth+2+10+11+8+7))
|
fmt.Println(strings.Repeat("-", nameWidth+2+10+11+8+7))
|
||||||
|
|
||||||
// Print rows
|
// Print rows
|
||||||
for _, s := range statuses {
|
for _, s := range statuses {
|
||||||
|
|
@ -309,12 +271,12 @@ func printStatusTable(statuses []git.RepoStatus) {
|
||||||
func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
||||||
// Load AGENTS.md context if available
|
// Load AGENTS.md context if available
|
||||||
agentsPath := filepath.Join(filepath.Dir(registryPath), "AGENTS.md")
|
agentsPath := filepath.Join(filepath.Dir(registryPath), "AGENTS.md")
|
||||||
var context string
|
var agentContext string
|
||||||
if data, err := os.ReadFile(agentsPath); err == nil {
|
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 <noreply@anthropic.com>. Be concise."
|
"Use Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>. Be concise."
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep")
|
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()
|
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"
|
|
||||||
}
|
|
||||||
326
cmd/docs/docs.go
326
cmd/docs/docs.go
|
|
@ -2,19 +2,12 @@
|
||||||
package docs
|
package docs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/host-uk/core/cmd/shared"
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Style and utility aliases
|
// Style and utility aliases from shared
|
||||||
var (
|
var (
|
||||||
repoNameStyle = shared.RepoNameStyle
|
repoNameStyle = shared.RepoNameStyle
|
||||||
successStyle = shared.SuccessStyle
|
successStyle = shared.SuccessStyle
|
||||||
|
|
@ -24,6 +17,7 @@ var (
|
||||||
confirm = shared.Confirm
|
confirm = shared.Confirm
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Package-specific styles
|
||||||
var (
|
var (
|
||||||
docsFoundStyle = lipgloss.NewStyle().
|
docsFoundStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||||
|
|
@ -35,17 +29,6 @@ var (
|
||||||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
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.
|
// AddDocsCommand adds the 'docs' command to the given parent command.
|
||||||
func AddDocsCommand(parent *clir.Cli) {
|
func AddDocsCommand(parent *clir.Cli) {
|
||||||
docsCmd := parent.NewSubCommand("docs", "Documentation management")
|
docsCmd := parent.NewSubCommand("docs", "Documentation management")
|
||||||
|
|
@ -56,308 +39,3 @@ func AddDocsCommand(parent *clir.Cli) {
|
||||||
addDocsSyncCommand(docsCmd)
|
addDocsSyncCommand(docsCmd)
|
||||||
addDocsListCommand(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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
83
cmd/docs/list.go
Normal file
83
cmd/docs/list.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
115
cmd/docs/scan.go
Normal file
115
cmd/docs/scan.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
147
cmd/docs/sync.go
Normal file
147
cmd/docs/sync.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
95
cmd/doctor/checks.go
Normal file
95
cmd/doctor/checks.go
Normal file
|
|
@ -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, ""
|
||||||
|
}
|
||||||
|
|
@ -3,18 +3,12 @@ package doctor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/host-uk/core/cmd/shared"
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Style aliases
|
// Style aliases from shared
|
||||||
var (
|
var (
|
||||||
successStyle = shared.SuccessStyle
|
successStyle = shared.SuccessStyle
|
||||||
errorStyle = shared.ErrorStyle
|
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 {
|
func runDoctor(verbose bool) error {
|
||||||
fmt.Println("Checking development environment...")
|
fmt.Println("Checking development environment...")
|
||||||
fmt.Println()
|
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
|
var passed, failed, optional int
|
||||||
|
|
||||||
|
// Check required tools
|
||||||
fmt.Println("Required:")
|
fmt.Println("Required:")
|
||||||
for _, c := range checks {
|
for _, c := range requiredChecks {
|
||||||
if !c.required {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ok, version := runCheck(c)
|
ok, version := runCheck(c)
|
||||||
if ok {
|
if ok {
|
||||||
if verbose && version != "" {
|
if verbose && version != "" {
|
||||||
|
|
@ -139,11 +53,9 @@ func runDoctor(verbose bool) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check optional tools
|
||||||
fmt.Println("\nOptional:")
|
fmt.Println("\nOptional:")
|
||||||
for _, c := range checks {
|
for _, c := range optionalChecks {
|
||||||
if c.required {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ok, version := runCheck(c)
|
ok, version := runCheck(c)
|
||||||
if ok {
|
if ok {
|
||||||
if verbose && version != "" {
|
if verbose && version != "" {
|
||||||
|
|
@ -158,7 +70,7 @@ func runDoctor(verbose bool) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check SSH
|
// Check GitHub access
|
||||||
fmt.Println("\nGitHub Access:")
|
fmt.Println("\nGitHub Access:")
|
||||||
if checkGitHubSSH() {
|
if checkGitHubSSH() {
|
||||||
fmt.Printf(" %s SSH key found\n", successStyle.Render("✓"))
|
fmt.Printf(" %s SSH key found\n", successStyle.Render("✓"))
|
||||||
|
|
@ -176,38 +88,7 @@ func runDoctor(verbose bool) error {
|
||||||
|
|
||||||
// Check workspace
|
// Check workspace
|
||||||
fmt.Println("\nWorkspace:")
|
fmt.Println("\nWorkspace:")
|
||||||
registryPath, err := repos.FindRegistry()
|
checkWorkspace()
|
||||||
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("○"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
@ -221,63 +102,3 @@ func runDoctor(verbose bool) error {
|
||||||
fmt.Printf("%s Environment ready\n", successStyle.Render("Doctor:"))
|
fmt.Printf("%s Environment ready\n", successStyle.Render("Doctor:"))
|
||||||
return nil
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
77
cmd/doctor/environment.go
Normal file
77
cmd/doctor/environment.go
Normal file
|
|
@ -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("○"))
|
||||||
|
}
|
||||||
|
}
|
||||||
24
cmd/doctor/install.go
Normal file
24
cmd/doctor/install.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
595
cmd/go/go.go
595
cmd/go/go.go
|
|
@ -4,14 +4,6 @@
|
||||||
package gocmd
|
package gocmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/host-uk/core/cmd/shared"
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
@ -44,590 +36,3 @@ func AddGoCommands(parent *clir.Cli) {
|
||||||
addGoModCommand(goCmd)
|
addGoModCommand(goCmd)
|
||||||
addGoWorkCommand(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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
77
cmd/go/go_format.go
Normal file
77
cmd/go/go_format.go
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
334
cmd/go/go_test_cmd.go
Normal file
334
cmd/go/go_test_cmd.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
207
cmd/go/go_tools.go
Normal file
207
cmd/go/go_tools.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
580
cmd/pkg/pkg.go
580
cmd/pkg/pkg.go
|
|
@ -2,19 +2,7 @@
|
||||||
package pkg
|
package pkg
|
||||||
|
|
||||||
import (
|
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/cmd/shared"
|
||||||
"github.com/host-uk/core/pkg/cache"
|
|
||||||
"github.com/host-uk/core/pkg/repos"
|
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -45,571 +33,3 @@ func AddPkgCommands(parent *clir.Cli) {
|
||||||
addPkgUpdateCommand(pkgCmd)
|
addPkgUpdateCommand(pkgCmd)
|
||||||
addPkgOutdatedCommand(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/<repo-name>", 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
155
cmd/pkg/pkg_install.go
Normal file
155
cmd/pkg/pkg_install.go
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
252
cmd/pkg/pkg_manage.go
Normal file
252
cmd/pkg/pkg_manage.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
199
cmd/pkg/pkg_search.go
Normal file
199
cmd/pkg/pkg_search.go
Normal file
|
|
@ -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/<repo-name>", 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
|
||||||
|
}
|
||||||
|
|
@ -2,19 +2,11 @@
|
||||||
package setup
|
package setup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/host-uk/core/cmd/shared"
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Style aliases
|
// Style aliases from shared package
|
||||||
var (
|
var (
|
||||||
repoNameStyle = shared.RepoNameStyle
|
repoNameStyle = shared.RepoNameStyle
|
||||||
successStyle = shared.SuccessStyle
|
successStyle = shared.SuccessStyle
|
||||||
|
|
@ -24,9 +16,9 @@ var (
|
||||||
|
|
||||||
// Default organization and devops repo for bootstrap
|
// Default organization and devops repo for bootstrap
|
||||||
const (
|
const (
|
||||||
defaultOrg = "host-uk"
|
defaultOrg = "host-uk"
|
||||||
devopsRepo = "core-devops"
|
devopsRepo = "core-devops"
|
||||||
devopsReposYaml = "repos.yaml"
|
devopsReposYaml = "repos.yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddSetupCommand adds the 'setup' command to the given parent command.
|
// 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)
|
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")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
165
cmd/setup/setup_bootstrap.go
Normal file
165
cmd/setup/setup_bootstrap.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
239
cmd/setup/setup_registry.go
Normal file
239
cmd/setup/setup_registry.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
287
cmd/setup/setup_repo.go
Normal file
287
cmd/setup/setup_repo.go
Normal file
|
|
@ -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 ""
|
||||||
|
}
|
||||||
363
cmd/test/test.go
363
cmd/test/test.go
|
|
@ -4,42 +4,22 @@
|
||||||
package testcmd
|
package testcmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"runtime"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/host-uk/core/cmd/shared"
|
||||||
"github.com/leaanthony/clir"
|
"github.com/leaanthony/clir"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Test command styles
|
// Style aliases from shared
|
||||||
var (
|
var (
|
||||||
testHeaderStyle = lipgloss.NewStyle().
|
testHeaderStyle = shared.RepoNameStyle
|
||||||
Bold(true).
|
testPassStyle = shared.SuccessStyle
|
||||||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
testFailStyle = shared.ErrorStyle
|
||||||
|
testSkipStyle = shared.WarningStyle
|
||||||
testPassStyle = lipgloss.NewStyle().
|
testDimStyle = shared.DimStyle
|
||||||
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
|
|
||||||
|
|
||||||
|
// Coverage-specific styles
|
||||||
|
var (
|
||||||
testCovHighStyle = lipgloss.NewStyle().
|
testCovHighStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||||
|
|
||||||
|
|
@ -85,326 +65,3 @@ func AddTestCommand(parent *clir.Cli) {
|
||||||
return runTest(verbose, coverage, short, pkg, run, race, json)
|
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")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
205
cmd/test/test_output.go
Normal file
205
cmd/test/test_output.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
142
cmd/test/test_runner.go
Normal file
142
cmd/test/test_runner.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -16,5 +16,5 @@ import "github.com/leaanthony/clir"
|
||||||
|
|
||||||
// AddCommands registers the 'vm' command and all subcommands.
|
// AddCommands registers the 'vm' command and all subcommands.
|
||||||
func AddCommands(app *clir.Cli) {
|
func AddCommands(app *clir.Cli) {
|
||||||
AddContainerCommands(app)
|
AddVMCommands(app)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// Package vm provides LinuxKit VM management commands.
|
|
||||||
package vm
|
package vm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -14,28 +13,6 @@ import (
|
||||||
"github.com/leaanthony/clir"
|
"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.
|
// addVMRunCommand adds the 'run' command under vm.
|
||||||
func addVMRunCommand(parent *clir.Command) {
|
func addVMRunCommand(parent *clir.Command) {
|
||||||
var (
|
var (
|
||||||
|
|
|
||||||
|
|
@ -9,25 +9,10 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/host-uk/core/cmd/shared"
|
|
||||||
"github.com/host-uk/core/pkg/container"
|
"github.com/host-uk/core/pkg/container"
|
||||||
"github.com/leaanthony/clir"
|
"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.
|
// addVMTemplatesCommand adds the 'templates' command under vm.
|
||||||
func addVMTemplatesCommand(parent *clir.Command) {
|
func addVMTemplatesCommand(parent *clir.Command) {
|
||||||
templatesCmd := parent.NewSubCommand("templates", "Manage LinuxKit templates")
|
templatesCmd := parent.NewSubCommand("templates", "Manage LinuxKit templates")
|
||||||
|
|
|
||||||
44
cmd/vm/vm.go
Normal file
44
cmd/vm/vm.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue