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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
buildpkg "github.com/host-uk/core/pkg/build"
|
||||
"github.com/host-uk/core/pkg/build/builders"
|
||||
"github.com/host-uk/core/pkg/build/signing"
|
||||
"github.com/host-uk/core/pkg/sdk"
|
||||
"github.com/leaanthony/clir"
|
||||
"github.com/leaanthony/debme"
|
||||
"github.com/leaanthony/gosod"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// Build command styles
|
||||
|
|
@ -112,7 +94,7 @@ func AddBuildCommand(app *clir.Cli) {
|
|||
fromPathCmd.StringFlag("path", "The path to the static web application files.", &fromPath)
|
||||
fromPathCmd.Action(func() error {
|
||||
if fromPath == "" {
|
||||
return fmt.Errorf("the --path flag is required")
|
||||
return errPathRequired
|
||||
}
|
||||
return runBuild(fromPath)
|
||||
})
|
||||
|
|
@ -123,7 +105,7 @@ func AddBuildCommand(app *clir.Cli) {
|
|||
pwaCmd.StringFlag("url", "The URL of the PWA to build.", &pwaURL)
|
||||
pwaCmd.Action(func() error {
|
||||
if pwaURL == "" {
|
||||
return fmt.Errorf("a URL argument is required")
|
||||
return errURLRequired
|
||||
}
|
||||
return runPwaBuild(pwaURL)
|
||||
})
|
||||
|
|
@ -147,749 +129,3 @@ func AddBuildCommand(app *clir.Cli) {
|
|||
return runBuildSDK(sdkSpec, sdkLang, sdkVersion, sdkDryRun)
|
||||
})
|
||||
}
|
||||
|
||||
// runProjectBuild handles the main `core build` command with auto-detection.
|
||||
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool) error {
|
||||
// Get current working directory as project root
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Load configuration from .core/build.yaml (or defaults)
|
||||
buildCfg, err := buildpkg.LoadConfig(projectDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Detect project type if not specified
|
||||
var projectType buildpkg.ProjectType
|
||||
if buildType != "" {
|
||||
projectType = buildpkg.ProjectType(buildType)
|
||||
} else {
|
||||
projectType, err = buildpkg.PrimaryType(projectDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to detect project type: %w", err)
|
||||
}
|
||||
if projectType == "" {
|
||||
return fmt.Errorf("no supported project type detected in %s\n"+
|
||||
"Supported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)", projectDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine targets
|
||||
var buildTargets []buildpkg.Target
|
||||
if targetsFlag != "" {
|
||||
// Parse from command line
|
||||
buildTargets, err = parseTargets(targetsFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(buildCfg.Targets) > 0 {
|
||||
// Use config targets
|
||||
buildTargets = buildCfg.ToTargets()
|
||||
} else {
|
||||
// Fall back to current OS/arch
|
||||
buildTargets = []buildpkg.Target{
|
||||
{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||
}
|
||||
}
|
||||
|
||||
// Determine output directory
|
||||
if outputDir == "" {
|
||||
outputDir = "dist"
|
||||
}
|
||||
|
||||
// Determine binary name
|
||||
binaryName := buildCfg.Project.Binary
|
||||
if binaryName == "" {
|
||||
binaryName = buildCfg.Project.Name
|
||||
}
|
||||
if binaryName == "" {
|
||||
binaryName = filepath.Base(projectDir)
|
||||
}
|
||||
|
||||
// Print build info (unless CI mode)
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Building project\n", buildHeaderStyle.Render("Build:"))
|
||||
fmt.Printf(" Type: %s\n", buildTargetStyle.Render(string(projectType)))
|
||||
fmt.Printf(" Output: %s\n", buildTargetStyle.Render(outputDir))
|
||||
fmt.Printf(" Binary: %s\n", buildTargetStyle.Render(binaryName))
|
||||
fmt.Printf(" Targets: %s\n", buildTargetStyle.Render(formatTargets(buildTargets)))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Get the appropriate builder
|
||||
builder, err := getBuilder(projectType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create build config for the builder
|
||||
cfg := &buildpkg.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: outputDir,
|
||||
Name: binaryName,
|
||||
Version: buildCfg.Project.Name, // Could be enhanced with git describe
|
||||
LDFlags: buildCfg.Build.LDFlags,
|
||||
// Docker/LinuxKit specific
|
||||
Dockerfile: configPath, // Reuse for Dockerfile path
|
||||
LinuxKitConfig: configPath,
|
||||
Push: push,
|
||||
Image: imageName,
|
||||
}
|
||||
|
||||
// Parse formats for LinuxKit
|
||||
if format != "" {
|
||||
cfg.Formats = strings.Split(format, ",")
|
||||
}
|
||||
|
||||
// Execute build
|
||||
ctx := context.Background()
|
||||
artifacts, err := builder.Build(ctx, cfg, buildTargets)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Build failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Built %d artifact(s)\n", buildSuccessStyle.Render("Success:"), len(artifacts))
|
||||
fmt.Println()
|
||||
for _, artifact := range artifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Sign macOS binaries if enabled
|
||||
signCfg := buildCfg.Sign
|
||||
if notarize {
|
||||
signCfg.MacOS.Notarize = true
|
||||
}
|
||||
if noSign {
|
||||
signCfg.Enabled = false
|
||||
}
|
||||
|
||||
if signCfg.Enabled && runtime.GOOS == "darwin" {
|
||||
if !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Signing binaries...\n", buildHeaderStyle.Render("Sign:"))
|
||||
}
|
||||
|
||||
// Convert buildpkg.Artifact to signing.Artifact
|
||||
signingArtifacts := make([]signing.Artifact, len(artifacts))
|
||||
for i, a := range artifacts {
|
||||
signingArtifacts[i] = signing.Artifact{Path: a.Path, OS: a.OS, Arch: a.Arch}
|
||||
}
|
||||
|
||||
if err := signing.SignBinaries(ctx, signCfg, signingArtifacts); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if signCfg.MacOS.Notarize {
|
||||
if err := signing.NotarizeBinaries(ctx, signCfg, signingArtifacts); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Notarization failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Archive artifacts if enabled
|
||||
var archivedArtifacts []buildpkg.Artifact
|
||||
if doArchive && len(artifacts) > 0 {
|
||||
if !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Creating archives...\n", buildHeaderStyle.Render("Archive:"))
|
||||
}
|
||||
|
||||
archivedArtifacts, err = buildpkg.ArchiveAll(artifacts)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Archive failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
for _, artifact := range archivedArtifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute checksums if enabled
|
||||
var checksummedArtifacts []buildpkg.Artifact
|
||||
if doChecksum && len(archivedArtifacts) > 0 {
|
||||
if !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
|
||||
}
|
||||
|
||||
checksummedArtifacts, err = buildpkg.ChecksumAll(archivedArtifacts)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Write CHECKSUMS.txt
|
||||
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
|
||||
if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Sign checksums with GPG
|
||||
if signCfg.Enabled {
|
||||
if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
for _, artifact := range checksummedArtifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
)
|
||||
fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum))
|
||||
}
|
||||
|
||||
relChecksumPath, err := filepath.Rel(projectDir, checksumPath)
|
||||
if err != nil {
|
||||
relChecksumPath = checksumPath
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relChecksumPath),
|
||||
)
|
||||
}
|
||||
} else if doChecksum && len(artifacts) > 0 && !doArchive {
|
||||
// Checksum raw binaries if archiving is disabled
|
||||
if !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
|
||||
}
|
||||
|
||||
checksummedArtifacts, err = buildpkg.ChecksumAll(artifacts)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Write CHECKSUMS.txt
|
||||
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
|
||||
if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Sign checksums with GPG
|
||||
if signCfg.Enabled {
|
||||
if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
for _, artifact := range checksummedArtifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
)
|
||||
fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum))
|
||||
}
|
||||
|
||||
relChecksumPath, err := filepath.Rel(projectDir, checksumPath)
|
||||
if err != nil {
|
||||
relChecksumPath = checksumPath
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relChecksumPath),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Output results for CI mode
|
||||
if ciMode {
|
||||
// Determine which artifacts to output (prefer checksummed > archived > raw)
|
||||
var outputArtifacts []buildpkg.Artifact
|
||||
if len(checksummedArtifacts) > 0 {
|
||||
outputArtifacts = checksummedArtifacts
|
||||
} else if len(archivedArtifacts) > 0 {
|
||||
outputArtifacts = archivedArtifacts
|
||||
} else {
|
||||
outputArtifacts = artifacts
|
||||
}
|
||||
|
||||
// JSON output for CI
|
||||
output, err := json.MarshalIndent(outputArtifacts, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal artifacts: %w", err)
|
||||
}
|
||||
fmt.Println(string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTargets parses a comma-separated list of OS/arch pairs.
|
||||
func parseTargets(targetsFlag string) ([]buildpkg.Target, error) {
|
||||
parts := strings.Split(targetsFlag, ",")
|
||||
var targets []buildpkg.Target
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
osArch := strings.Split(part, "/")
|
||||
if len(osArch) != 2 {
|
||||
return nil, fmt.Errorf("invalid target format %q, expected OS/arch (e.g., linux/amd64)", part)
|
||||
}
|
||||
|
||||
targets = append(targets, buildpkg.Target{
|
||||
OS: strings.TrimSpace(osArch[0]),
|
||||
Arch: strings.TrimSpace(osArch[1]),
|
||||
})
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return nil, fmt.Errorf("no valid targets specified")
|
||||
}
|
||||
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// formatTargets returns a human-readable string of targets.
|
||||
func formatTargets(targets []buildpkg.Target) string {
|
||||
var parts []string
|
||||
for _, t := range targets {
|
||||
parts = append(parts, t.String())
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// getBuilder returns the appropriate builder for the project type.
|
||||
func getBuilder(projectType buildpkg.ProjectType) (buildpkg.Builder, error) {
|
||||
switch projectType {
|
||||
case buildpkg.ProjectTypeWails:
|
||||
return builders.NewWailsBuilder(), nil
|
||||
case buildpkg.ProjectTypeGo:
|
||||
return builders.NewGoBuilder(), nil
|
||||
case buildpkg.ProjectTypeDocker:
|
||||
return builders.NewDockerBuilder(), nil
|
||||
case buildpkg.ProjectTypeLinuxKit:
|
||||
return builders.NewLinuxKitBuilder(), nil
|
||||
case buildpkg.ProjectTypeTaskfile:
|
||||
return builders.NewTaskfileBuilder(), nil
|
||||
case buildpkg.ProjectTypeNode:
|
||||
return nil, fmt.Errorf("Node.js builder not yet implemented")
|
||||
case buildpkg.ProjectTypePHP:
|
||||
return nil, fmt.Errorf("PHP builder not yet implemented")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported project type: %s", projectType)
|
||||
}
|
||||
}
|
||||
|
||||
// --- SDK Build Logic ---
|
||||
|
||||
func runBuildSDK(specPath, lang, version string, dryRun bool) error {
|
||||
ctx := context.Background()
|
||||
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Load config
|
||||
config := sdk.DefaultConfig()
|
||||
if specPath != "" {
|
||||
config.Spec = specPath
|
||||
}
|
||||
|
||||
s := sdk.New(projectDir, config)
|
||||
if version != "" {
|
||||
s.SetVersion(version)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Generating SDKs\n", buildHeaderStyle.Render("Build SDK:"))
|
||||
if dryRun {
|
||||
fmt.Printf(" %s\n", buildDimStyle.Render("(dry-run mode)"))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Detect spec
|
||||
detectedSpec, err := s.DetectSpec()
|
||||
if err != nil {
|
||||
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" Spec: %s\n", buildTargetStyle.Render(detectedSpec))
|
||||
|
||||
if dryRun {
|
||||
if lang != "" {
|
||||
fmt.Printf(" Language: %s\n", buildTargetStyle.Render(lang))
|
||||
} else {
|
||||
fmt.Printf(" Languages: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", ")))
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Would generate SDKs (dry-run)\n", buildSuccessStyle.Render("OK:"))
|
||||
return nil
|
||||
}
|
||||
|
||||
if lang != "" {
|
||||
// Generate single language
|
||||
if err := s.GenerateLanguage(ctx, lang); err != nil {
|
||||
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(lang))
|
||||
} else {
|
||||
// Generate all
|
||||
if err := s.Generate(ctx); err != nil {
|
||||
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", ")))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s SDK generation complete\n", buildSuccessStyle.Render("Success:"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- PWA Build Logic ---
|
||||
|
||||
func runPwaBuild(pwaURL string) error {
|
||||
fmt.Printf("Starting PWA build from URL: %s\n", pwaURL)
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "core-pwa-build-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary directory: %w", err)
|
||||
}
|
||||
// defer os.RemoveAll(tempDir) // Keep temp dir for debugging
|
||||
fmt.Printf("Downloading PWA to temporary directory: %s\n", tempDir)
|
||||
|
||||
if err := downloadPWA(pwaURL, tempDir); err != nil {
|
||||
return fmt.Errorf("failed to download PWA: %w", err)
|
||||
}
|
||||
|
||||
return runBuild(tempDir)
|
||||
}
|
||||
|
||||
func downloadPWA(baseURL, destDir string) error {
|
||||
// Fetch the main HTML page
|
||||
resp, err := http.Get(baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch URL %s: %w", baseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Find the manifest URL from the HTML
|
||||
manifestURL, err := findManifestURL(string(body), baseURL)
|
||||
if err != nil {
|
||||
// If no manifest, it's not a PWA, but we can still try to package it as a simple site.
|
||||
fmt.Println("Warning: no manifest file found. Proceeding with basic site download.")
|
||||
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write index.html: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found manifest: %s\n", manifestURL)
|
||||
|
||||
// Fetch and parse the manifest
|
||||
manifest, err := fetchManifest(manifestURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch or parse manifest: %w", err)
|
||||
}
|
||||
|
||||
// Download all assets listed in the manifest
|
||||
assets := collectAssets(manifest, manifestURL)
|
||||
for _, assetURL := range assets {
|
||||
if err := downloadAsset(assetURL, destDir); err != nil {
|
||||
fmt.Printf("Warning: failed to download asset %s: %v\n", assetURL, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Also save the root index.html
|
||||
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write index.html: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("PWA download complete.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func findManifestURL(htmlContent, baseURL string) (string, error) {
|
||||
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var manifestPath string
|
||||
var f func(*html.Node)
|
||||
f = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "link" {
|
||||
var rel, href string
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == "rel" {
|
||||
rel = a.Val
|
||||
}
|
||||
if a.Key == "href" {
|
||||
href = a.Val
|
||||
}
|
||||
}
|
||||
if rel == "manifest" && href != "" {
|
||||
manifestPath = href
|
||||
return
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
f(c)
|
||||
}
|
||||
}
|
||||
f(doc)
|
||||
|
||||
if manifestPath == "" {
|
||||
return "", fmt.Errorf("no <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
|
||||
//
|
||||
// Configuration via .core/build.yaml or command-line flags.
|
||||
//
|
||||
// Subcommands:
|
||||
// - build: Auto-detect and build the current project
|
||||
// - build from-path: Build from a local static web app directory
|
||||
// - build pwa: Build from a live PWA URL
|
||||
// - build sdk: Generate API SDKs from OpenAPI spec
|
||||
package build
|
||||
|
||||
import "github.com/leaanthony/clir"
|
||||
|
|
|
|||
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
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/pkg/release"
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// CIRelease command styles
|
||||
// Style aliases from shared
|
||||
var (
|
||||
releaseHeaderStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
||||
|
||||
releaseSuccessStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||
|
||||
releaseErrorStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#ef4444")) // red-500
|
||||
|
||||
releaseDimStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
||||
|
||||
releaseValueStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#e2e8f0")) // gray-200
|
||||
releaseHeaderStyle = shared.RepoNameStyle
|
||||
releaseSuccessStyle = shared.SuccessStyle
|
||||
releaseErrorStyle = shared.ErrorStyle
|
||||
releaseDimStyle = shared.DimStyle
|
||||
releaseValueStyle = shared.ValueStyle
|
||||
)
|
||||
|
||||
// AddCIReleaseCommand adds the release command and its subcommands.
|
||||
|
|
@ -84,172 +64,3 @@ func AddCIReleaseCommand(app *clir.Cli) {
|
|||
return runCIReleaseVersion()
|
||||
})
|
||||
}
|
||||
|
||||
// runCIPublish publishes pre-built artifacts from dist/.
|
||||
// It does NOT build - use `core build` first.
|
||||
func runCIPublish(dryRun bool, version string, draft, prerelease bool) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get current directory
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
cfg, err := release.LoadConfig(projectDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Apply CLI overrides
|
||||
if version != "" {
|
||||
cfg.SetVersion(version)
|
||||
}
|
||||
|
||||
// Apply draft/prerelease overrides to all publishers
|
||||
if draft || prerelease {
|
||||
for i := range cfg.Publishers {
|
||||
if draft {
|
||||
cfg.Publishers[i].Draft = true
|
||||
}
|
||||
if prerelease {
|
||||
cfg.Publishers[i].Prerelease = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print header
|
||||
fmt.Printf("%s Publishing release\n", releaseHeaderStyle.Render("CI:"))
|
||||
if dryRun {
|
||||
fmt.Printf(" %s\n", releaseDimStyle.Render("(dry-run) use --we-are-go-for-launch to publish"))
|
||||
} else {
|
||||
fmt.Printf(" %s\n", releaseSuccessStyle.Render("🚀 GO FOR LAUNCH"))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Check for publishers
|
||||
if len(cfg.Publishers) == 0 {
|
||||
return fmt.Errorf("no publishers configured in .core/release.yaml")
|
||||
}
|
||||
|
||||
// Publish pre-built artifacts
|
||||
rel, err := release.Publish(ctx, cfg, dryRun)
|
||||
if err != nil {
|
||||
fmt.Printf("%s %v\n", releaseErrorStyle.Render("Error:"), err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Print summary
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Publish completed!\n", releaseSuccessStyle.Render("Success:"))
|
||||
fmt.Printf(" Version: %s\n", releaseValueStyle.Render(rel.Version))
|
||||
fmt.Printf(" Artifacts: %d\n", len(rel.Artifacts))
|
||||
|
||||
if !dryRun {
|
||||
for _, pub := range cfg.Publishers {
|
||||
fmt.Printf(" Published: %s\n", releaseValueStyle.Render(pub.Type))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCIReleaseInit creates a release configuration interactively.
|
||||
func runCIReleaseInit() error {
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if config already exists
|
||||
if release.ConfigExists(projectDir) {
|
||||
fmt.Printf("%s Configuration already exists at %s\n",
|
||||
releaseDimStyle.Render("Note:"),
|
||||
release.ConfigPath(projectDir))
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("Overwrite? [y/N]: ")
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
if response != "y" && response != "yes" {
|
||||
fmt.Println("Aborted.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("%s Creating release configuration\n", releaseHeaderStyle.Render("Init:"))
|
||||
fmt.Println()
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
// Project name
|
||||
defaultName := filepath.Base(projectDir)
|
||||
fmt.Printf("Project name [%s]: ", defaultName)
|
||||
name, _ := reader.ReadString('\n')
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = defaultName
|
||||
}
|
||||
|
||||
// Repository
|
||||
fmt.Print("GitHub repository (owner/repo): ")
|
||||
repo, _ := reader.ReadString('\n')
|
||||
repo = strings.TrimSpace(repo)
|
||||
|
||||
// Create config
|
||||
cfg := release.DefaultConfig()
|
||||
cfg.Project.Name = name
|
||||
cfg.Project.Repository = repo
|
||||
|
||||
// Write config
|
||||
if err := release.WriteConfig(cfg, projectDir); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Configuration written to %s\n",
|
||||
releaseSuccessStyle.Render("Success:"),
|
||||
release.ConfigPath(projectDir))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runChangelog generates and prints a changelog.
|
||||
func runChangelog(fromRef, toRef string) error {
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Load config for changelog settings
|
||||
cfg, err := release.LoadConfig(projectDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Generate changelog
|
||||
changelog, err := release.GenerateWithConfig(projectDir, fromRef, toRef, &cfg.Changelog)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate changelog: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(changelog)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCIReleaseVersion shows the determined version.
|
||||
func runCIReleaseVersion() error {
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
version, err := release.DetermineVersion(projectDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine version: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Version: %s\n", releaseValueStyle.Render(version))
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/pkg/devops"
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// Dev-specific styles
|
||||
// Style aliases from shared package
|
||||
var (
|
||||
devHeaderStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
||||
|
||||
devSuccessStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#22c55e")). // green-500
|
||||
Bold(true)
|
||||
|
||||
devErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#ef4444")). // red-500
|
||||
Bold(true)
|
||||
|
||||
devDimStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
||||
|
||||
devValueStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#e2e8f0")) // gray-200
|
||||
|
||||
devWarningStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#f59e0b")) // amber-500
|
||||
successStyle = shared.SuccessStyle
|
||||
errorStyle = shared.ErrorStyle
|
||||
warningStyle = shared.WarningStyle
|
||||
dimStyle = shared.DimStyle
|
||||
valueStyle = shared.ValueStyle
|
||||
headerStyle = shared.HeaderStyle
|
||||
repoNameStyle = shared.RepoNameStyle
|
||||
)
|
||||
|
||||
// AddDevCommand adds the dev environment commands to the dev parent command.
|
||||
// These are added as direct subcommands: core dev install, core dev boot, etc.
|
||||
func AddDevCommand(parent *clir.Command) {
|
||||
AddDevInstallCommand(parent)
|
||||
AddDevBootCommand(parent)
|
||||
AddDevStopCommand(parent)
|
||||
AddDevStatusCommand(parent)
|
||||
AddDevShellCommand(parent)
|
||||
AddDevServeCommand(parent)
|
||||
AddDevTestCommand(parent)
|
||||
AddDevClaudeCommand(parent)
|
||||
AddDevUpdateCommand(parent)
|
||||
}
|
||||
|
||||
// AddDevInstallCommand adds the 'dev install' command.
|
||||
func AddDevInstallCommand(parent *clir.Command) {
|
||||
installCmd := parent.NewSubCommand("install", "Download and install the dev environment image")
|
||||
installCmd.LongDescription("Downloads the platform-specific dev environment image.\n\n" +
|
||||
"The image includes Go, PHP, Node.js, Python, Docker, and Claude CLI.\n" +
|
||||
"Downloads are cached at ~/.core/images/\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev install")
|
||||
|
||||
installCmd.Action(func() error {
|
||||
return runDevInstall()
|
||||
})
|
||||
}
|
||||
|
||||
func runDevInstall() error {
|
||||
d, err := devops.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsInstalled() {
|
||||
fmt.Println(devSuccessStyle.Render("Dev environment already installed"))
|
||||
fmt.Println()
|
||||
fmt.Printf("Use %s to check for updates\n", devDimStyle.Render("core dev update"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", devDimStyle.Render("Image:"), devops.ImageName())
|
||||
fmt.Println()
|
||||
fmt.Println("Downloading dev environment...")
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
start := time.Now()
|
||||
var lastProgress int64
|
||||
|
||||
err = d.Install(ctx, func(downloaded, total int64) {
|
||||
if total > 0 {
|
||||
pct := int(float64(downloaded) / float64(total) * 100)
|
||||
if pct != int(float64(lastProgress)/float64(total)*100) {
|
||||
fmt.Printf("\r%s %d%%", devDimStyle.Render("Progress:"), pct)
|
||||
lastProgress = downloaded
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fmt.Println() // Clear progress line
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("install failed: %w", err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start).Round(time.Second)
|
||||
fmt.Println()
|
||||
fmt.Printf("%s in %s\n", devSuccessStyle.Render("Installed"), elapsed)
|
||||
fmt.Println()
|
||||
fmt.Printf("Start with: %s\n", devDimStyle.Render("core dev boot"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddDevBootCommand adds the 'devops boot' command.
|
||||
func AddDevBootCommand(parent *clir.Command) {
|
||||
var memory int
|
||||
var cpus int
|
||||
var fresh bool
|
||||
|
||||
bootCmd := parent.NewSubCommand("boot", "Start the dev environment")
|
||||
bootCmd.LongDescription("Boots the dev environment VM.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev boot\n" +
|
||||
" core dev boot --memory 8192 --cpus 4\n" +
|
||||
" core dev boot --fresh")
|
||||
|
||||
bootCmd.IntFlag("memory", "Memory in MB (default: 4096)", &memory)
|
||||
bootCmd.IntFlag("cpus", "Number of CPUs (default: 2)", &cpus)
|
||||
bootCmd.BoolFlag("fresh", "Stop existing and start fresh", &fresh)
|
||||
|
||||
bootCmd.Action(func() error {
|
||||
return runDevBoot(memory, cpus, fresh)
|
||||
})
|
||||
}
|
||||
|
||||
func runDevBoot(memory, cpus int, fresh bool) error {
|
||||
d, err := devops.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !d.IsInstalled() {
|
||||
return fmt.Errorf("dev environment not installed (run 'core dev install' first)")
|
||||
}
|
||||
|
||||
opts := devops.DefaultBootOptions()
|
||||
if memory > 0 {
|
||||
opts.Memory = memory
|
||||
}
|
||||
if cpus > 0 {
|
||||
opts.CPUs = cpus
|
||||
}
|
||||
opts.Fresh = fresh
|
||||
|
||||
fmt.Printf("%s %dMB, %d CPUs\n", devDimStyle.Render("Config:"), opts.Memory, opts.CPUs)
|
||||
fmt.Println()
|
||||
fmt.Println("Booting dev environment...")
|
||||
|
||||
ctx := context.Background()
|
||||
if err := d.Boot(ctx, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(devSuccessStyle.Render("Dev environment running"))
|
||||
fmt.Println()
|
||||
fmt.Printf("Connect with: %s\n", devDimStyle.Render("core dev shell"))
|
||||
fmt.Printf("SSH port: %s\n", devDimStyle.Render("2222"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddDevStopCommand adds the 'devops stop' command.
|
||||
func AddDevStopCommand(parent *clir.Command) {
|
||||
stopCmd := parent.NewSubCommand("stop", "Stop the dev environment")
|
||||
stopCmd.LongDescription("Stops the running dev environment VM.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev stop")
|
||||
|
||||
stopCmd.Action(func() error {
|
||||
return runDevStop()
|
||||
})
|
||||
}
|
||||
|
||||
func runDevStop() error {
|
||||
d, err := devops.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
running, err := d.IsRunning(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !running {
|
||||
fmt.Println(devDimStyle.Render("Dev environment is not running"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Stopping dev environment...")
|
||||
|
||||
if err := d.Stop(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(devSuccessStyle.Render("Stopped"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddDevStatusCommand adds the 'devops status' command.
|
||||
func AddDevStatusCommand(parent *clir.Command) {
|
||||
statusCmd := parent.NewSubCommand("status", "Show dev environment status")
|
||||
statusCmd.LongDescription("Shows the current status of the dev environment.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev status")
|
||||
|
||||
statusCmd.Action(func() error {
|
||||
return runDevStatus()
|
||||
})
|
||||
}
|
||||
|
||||
func runDevStatus() error {
|
||||
d, err := devops.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
status, err := d.Status(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(devHeaderStyle.Render("Dev Environment Status"))
|
||||
fmt.Println()
|
||||
|
||||
// Installation status
|
||||
if status.Installed {
|
||||
fmt.Printf("%s %s\n", devDimStyle.Render("Installed:"), devSuccessStyle.Render("Yes"))
|
||||
if status.ImageVersion != "" {
|
||||
fmt.Printf("%s %s\n", devDimStyle.Render("Version:"), status.ImageVersion)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("%s %s\n", devDimStyle.Render("Installed:"), devErrorStyle.Render("No"))
|
||||
fmt.Println()
|
||||
fmt.Printf("Install with: %s\n", devDimStyle.Render("core dev install"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Running status
|
||||
if status.Running {
|
||||
fmt.Printf("%s %s\n", devDimStyle.Render("Status:"), devSuccessStyle.Render("Running"))
|
||||
fmt.Printf("%s %s\n", devDimStyle.Render("Container:"), status.ContainerID[:8])
|
||||
fmt.Printf("%s %dMB\n", devDimStyle.Render("Memory:"), status.Memory)
|
||||
fmt.Printf("%s %d\n", devDimStyle.Render("CPUs:"), status.CPUs)
|
||||
fmt.Printf("%s %d\n", devDimStyle.Render("SSH Port:"), status.SSHPort)
|
||||
fmt.Printf("%s %s\n", devDimStyle.Render("Uptime:"), formatDevUptime(status.Uptime))
|
||||
} else {
|
||||
fmt.Printf("%s %s\n", devDimStyle.Render("Status:"), devDimStyle.Render("Stopped"))
|
||||
fmt.Println()
|
||||
fmt.Printf("Start with: %s\n", devDimStyle.Render("core dev boot"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatDevUptime(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
return fmt.Sprintf("%dd %dh", int(d.Hours()/24), int(d.Hours())%24)
|
||||
}
|
||||
|
||||
// AddDevShellCommand adds the 'devops shell' command.
|
||||
func AddDevShellCommand(parent *clir.Command) {
|
||||
var console bool
|
||||
|
||||
shellCmd := parent.NewSubCommand("shell", "Connect to the dev environment")
|
||||
shellCmd.LongDescription("Opens an interactive shell in the dev environment.\n\n" +
|
||||
"Uses SSH by default, or serial console with --console.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev shell\n" +
|
||||
" core dev shell --console\n" +
|
||||
" core dev shell -- ls -la")
|
||||
|
||||
shellCmd.BoolFlag("console", "Use serial console instead of SSH", &console)
|
||||
|
||||
shellCmd.Action(func() error {
|
||||
args := shellCmd.OtherArgs()
|
||||
return runDevShell(console, args)
|
||||
})
|
||||
}
|
||||
|
||||
func runDevShell(console bool, command []string) error {
|
||||
d, err := devops.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := devops.ShellOptions{
|
||||
Console: console,
|
||||
Command: command,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return d.Shell(ctx, opts)
|
||||
}
|
||||
|
||||
// AddDevServeCommand adds the 'devops serve' command.
|
||||
func AddDevServeCommand(parent *clir.Command) {
|
||||
var port int
|
||||
var path string
|
||||
|
||||
serveCmd := parent.NewSubCommand("serve", "Mount project and start dev server")
|
||||
serveCmd.LongDescription("Mounts the current project into the dev environment and starts a dev server.\n\n" +
|
||||
"Auto-detects the appropriate serve command based on project files.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev serve\n" +
|
||||
" core dev serve --port 3000\n" +
|
||||
" core dev serve --path public")
|
||||
|
||||
serveCmd.IntFlag("port", "Port to serve on (default: 8000)", &port)
|
||||
serveCmd.StringFlag("path", "Subdirectory to serve", &path)
|
||||
|
||||
serveCmd.Action(func() error {
|
||||
return runDevServe(port, path)
|
||||
})
|
||||
}
|
||||
|
||||
func runDevServe(port int, path string) error {
|
||||
d, err := devops.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := devops.ServeOptions{
|
||||
Port: port,
|
||||
Path: path,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return d.Serve(ctx, projectDir, opts)
|
||||
}
|
||||
|
||||
// AddDevTestCommand adds the 'devops test' command.
|
||||
func AddDevTestCommand(parent *clir.Command) {
|
||||
var name string
|
||||
|
||||
testCmd := parent.NewSubCommand("test", "Run tests in the dev environment")
|
||||
testCmd.LongDescription("Runs tests in the dev environment.\n\n" +
|
||||
"Auto-detects the test command based on project files, or uses .core/test.yaml.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev test\n" +
|
||||
" core dev test --name integration\n" +
|
||||
" core dev test -- go test -v ./...")
|
||||
|
||||
testCmd.StringFlag("name", "Run named test command from .core/test.yaml", &name)
|
||||
|
||||
testCmd.Action(func() error {
|
||||
args := testCmd.OtherArgs()
|
||||
return runDevTest(name, args)
|
||||
})
|
||||
}
|
||||
|
||||
func runDevTest(name string, command []string) error {
|
||||
d, err := devops.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := devops.TestOptions{
|
||||
Name: name,
|
||||
Command: command,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return d.Test(ctx, projectDir, opts)
|
||||
}
|
||||
|
||||
// AddDevClaudeCommand adds the 'devops claude' command.
|
||||
func AddDevClaudeCommand(parent *clir.Command) {
|
||||
var noAuth bool
|
||||
var model string
|
||||
var authFlags []string
|
||||
|
||||
claudeCmd := parent.NewSubCommand("claude", "Start sandboxed Claude session")
|
||||
claudeCmd.LongDescription("Starts a Claude Code session inside the dev environment sandbox.\n\n" +
|
||||
"Provides isolation while forwarding selected credentials.\n" +
|
||||
"Auto-boots the dev environment if not running.\n\n" +
|
||||
"Auth options (default: all):\n" +
|
||||
" gh - GitHub CLI auth\n" +
|
||||
" anthropic - Anthropic API key\n" +
|
||||
" ssh - SSH agent forwarding\n" +
|
||||
" git - Git config (name, email)\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev claude\n" +
|
||||
" core dev claude --model opus\n" +
|
||||
" core dev claude --auth gh,anthropic\n" +
|
||||
" core dev claude --no-auth")
|
||||
|
||||
claudeCmd.BoolFlag("no-auth", "Don't forward any auth credentials", &noAuth)
|
||||
claudeCmd.StringFlag("model", "Model to use (opus, sonnet)", &model)
|
||||
claudeCmd.StringsFlag("auth", "Selective auth forwarding (gh,anthropic,ssh,git)", &authFlags)
|
||||
|
||||
claudeCmd.Action(func() error {
|
||||
return runDevClaude(noAuth, model, authFlags)
|
||||
})
|
||||
}
|
||||
|
||||
func runDevClaude(noAuth bool, model string, authFlags []string) error {
|
||||
d, err := devops.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := devops.ClaudeOptions{
|
||||
NoAuth: noAuth,
|
||||
Model: model,
|
||||
Auth: authFlags,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return d.Claude(ctx, projectDir, opts)
|
||||
}
|
||||
|
||||
// AddDevUpdateCommand adds the 'devops update' command.
|
||||
func AddDevUpdateCommand(parent *clir.Command) {
|
||||
var apply bool
|
||||
|
||||
updateCmd := parent.NewSubCommand("update", "Check for and apply updates")
|
||||
updateCmd.LongDescription("Checks for dev environment updates and optionally applies them.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev update\n" +
|
||||
" core dev update --apply")
|
||||
|
||||
updateCmd.BoolFlag("apply", "Download and apply the update", &apply)
|
||||
|
||||
updateCmd.Action(func() error {
|
||||
return runDevUpdate(apply)
|
||||
})
|
||||
}
|
||||
|
||||
func runDevUpdate(apply bool) error {
|
||||
d, err := devops.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
fmt.Println("Checking for updates...")
|
||||
fmt.Println()
|
||||
|
||||
current, latest, hasUpdate, err := d.CheckUpdate(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for updates: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", devDimStyle.Render("Current:"), devValueStyle.Render(current))
|
||||
fmt.Printf("%s %s\n", devDimStyle.Render("Latest:"), devValueStyle.Render(latest))
|
||||
fmt.Println()
|
||||
|
||||
if !hasUpdate {
|
||||
fmt.Println(devSuccessStyle.Render("Already up to date"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println(devWarningStyle.Render("Update available"))
|
||||
fmt.Println()
|
||||
|
||||
if !apply {
|
||||
fmt.Printf("Run %s to update\n", devDimStyle.Render("core dev update --apply"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop if running
|
||||
running, _ := d.IsRunning(ctx)
|
||||
if running {
|
||||
fmt.Println("Stopping current instance...")
|
||||
_ = d.Stop(ctx)
|
||||
}
|
||||
|
||||
fmt.Println("Downloading update...")
|
||||
fmt.Println()
|
||||
|
||||
start := time.Now()
|
||||
err = d.Install(ctx, func(downloaded, total int64) {
|
||||
if total > 0 {
|
||||
pct := int(float64(downloaded) / float64(total) * 100)
|
||||
fmt.Printf("\r%s %d%%", devDimStyle.Render("Progress:"), pct)
|
||||
}
|
||||
})
|
||||
|
||||
fmt.Println()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start).Round(time.Second)
|
||||
fmt.Println()
|
||||
fmt.Printf("%s in %s\n", devSuccessStyle.Render("Updated"), elapsed)
|
||||
|
||||
return nil
|
||||
// Table styles for status display
|
||||
var (
|
||||
cellStyle = lipgloss.NewStyle().
|
||||
Padding(0, 1)
|
||||
|
||||
dirtyStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#ef4444")). // red-500
|
||||
Padding(0, 1)
|
||||
|
||||
aheadStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#22c55e")). // green-500
|
||||
Padding(0, 1)
|
||||
|
||||
cleanStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6b7280")). // gray-500
|
||||
Padding(0, 1)
|
||||
)
|
||||
|
||||
// AddCommands registers the 'dev' command and all subcommands.
|
||||
func AddCommands(app *clir.Cli) {
|
||||
devCmd := app.NewSubCommand("dev", "Multi-repo development workflow")
|
||||
devCmd.LongDescription("Manage multiple git repositories and GitHub integration.\n\n" +
|
||||
"Uses repos.yaml to discover repositories. Falls back to scanning\n" +
|
||||
"the current directory if no registry is found.\n\n" +
|
||||
"Git Operations:\n" +
|
||||
" work Combined status -> commit -> push workflow\n" +
|
||||
" health Quick repo health summary\n" +
|
||||
" commit Claude-assisted commit messages\n" +
|
||||
" push Push repos with unpushed commits\n" +
|
||||
" pull Pull repos behind remote\n\n" +
|
||||
"GitHub Integration (requires gh CLI):\n" +
|
||||
" issues List open issues across repos\n" +
|
||||
" reviews List PRs awaiting review\n" +
|
||||
" ci Check GitHub Actions status\n" +
|
||||
" impact Analyse dependency impact\n\n" +
|
||||
"Dev Environment:\n" +
|
||||
" install Download dev environment image\n" +
|
||||
" boot Start dev environment VM\n" +
|
||||
" stop Stop dev environment VM\n" +
|
||||
" shell Open shell in dev VM\n" +
|
||||
" status Check dev VM status")
|
||||
|
||||
// Git operations
|
||||
addWorkCommand(devCmd)
|
||||
addHealthCommand(devCmd)
|
||||
addCommitCommand(devCmd)
|
||||
addPushCommand(devCmd)
|
||||
addPullCommand(devCmd)
|
||||
|
||||
// GitHub integration
|
||||
addIssuesCommand(devCmd)
|
||||
addReviewsCommand(devCmd)
|
||||
addCICommand(devCmd)
|
||||
addImpactCommand(devCmd)
|
||||
|
||||
// API tools
|
||||
addAPICommands(devCmd)
|
||||
|
||||
// Dev environment
|
||||
addVMCommands(devCmd)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import (
|
|||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// AddAPICommands adds the 'api' command and its subcommands to the given parent command.
|
||||
func AddAPICommands(parent *clir.Command) {
|
||||
// addAPICommands adds the 'api' command and its subcommands to the given parent command.
|
||||
func addAPICommands(parent *clir.Command) {
|
||||
// Create the 'api' command
|
||||
apiCmd := parent.NewSubCommand("api", "Tools for managing service APIs")
|
||||
|
||||
// Add the 'sync' command to 'api'
|
||||
AddSyncCommand(apiCmd)
|
||||
addSyncCommand(apiCmd)
|
||||
|
||||
// TODO: Add the 'test-gen' command to 'api'
|
||||
// AddTestGenCommand(apiCmd)
|
||||
// addTestGenCommand(apiCmd)
|
||||
}
|
||||
|
|
@ -9,10 +9,12 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// CI-specific styles
|
||||
var (
|
||||
ciSuccessStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
|
|
@ -43,8 +45,8 @@ type WorkflowRun struct {
|
|||
RepoName string `json:"-"`
|
||||
}
|
||||
|
||||
// AddCICommand adds the 'ci' command to the given parent command.
|
||||
func AddCICommand(parent *clir.Command) {
|
||||
// addCICommand adds the 'ci' command to the given parent command.
|
||||
func addCICommand(parent *clir.Command) {
|
||||
var registryPath string
|
||||
var branch string
|
||||
var failedOnly bool
|
||||
|
|
@ -149,16 +151,16 @@ func runCI(registryPath string, branch string, failedOnly bool) error {
|
|||
fmt.Println()
|
||||
fmt.Printf("%d repos checked", len(repoList))
|
||||
if success > 0 {
|
||||
fmt.Printf(" · %s", ciSuccessStyle.Render(fmt.Sprintf("%d passing", success)))
|
||||
fmt.Printf(" * %s", ciSuccessStyle.Render(fmt.Sprintf("%d passing", success)))
|
||||
}
|
||||
if failed > 0 {
|
||||
fmt.Printf(" · %s", ciFailureStyle.Render(fmt.Sprintf("%d failing", failed)))
|
||||
fmt.Printf(" * %s", ciFailureStyle.Render(fmt.Sprintf("%d failing", failed)))
|
||||
}
|
||||
if pending > 0 {
|
||||
fmt.Printf(" · %s", ciPendingStyle.Render(fmt.Sprintf("%d pending", pending)))
|
||||
fmt.Printf(" * %s", ciPendingStyle.Render(fmt.Sprintf("%d pending", pending)))
|
||||
}
|
||||
if len(noCI) > 0 {
|
||||
fmt.Printf(" · %s", ciSkippedStyle.Render(fmt.Sprintf("%d no CI", len(noCI))))
|
||||
fmt.Printf(" * %s", ciSkippedStyle.Render(fmt.Sprintf("%d no CI", len(noCI))))
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println()
|
||||
|
|
@ -227,30 +229,30 @@ func printWorkflowRun(run WorkflowRun) {
|
|||
var status string
|
||||
switch run.Conclusion {
|
||||
case "success":
|
||||
status = ciSuccessStyle.Render("✓")
|
||||
status = ciSuccessStyle.Render("v")
|
||||
case "failure":
|
||||
status = ciFailureStyle.Render("✗")
|
||||
status = ciFailureStyle.Render("x")
|
||||
case "":
|
||||
if run.Status == "in_progress" {
|
||||
status = ciPendingStyle.Render("●")
|
||||
status = ciPendingStyle.Render("*")
|
||||
} else if run.Status == "queued" {
|
||||
status = ciPendingStyle.Render("○")
|
||||
status = ciPendingStyle.Render("o")
|
||||
} else {
|
||||
status = ciSkippedStyle.Render("—")
|
||||
status = ciSkippedStyle.Render("-")
|
||||
}
|
||||
case "skipped":
|
||||
status = ciSkippedStyle.Render("—")
|
||||
status = ciSkippedStyle.Render("-")
|
||||
case "cancelled":
|
||||
status = ciSkippedStyle.Render("○")
|
||||
status = ciSkippedStyle.Render("o")
|
||||
default:
|
||||
status = ciSkippedStyle.Render("?")
|
||||
}
|
||||
|
||||
// Workflow name (truncated)
|
||||
workflowName := truncate(run.Name, 20)
|
||||
workflowName := shared.Truncate(run.Name, 20)
|
||||
|
||||
// Age
|
||||
age := formatAge(run.UpdatedAt)
|
||||
age := shared.FormatAge(run.UpdatedAt)
|
||||
|
||||
fmt.Printf(" %s %-18s %-22s %s\n",
|
||||
status,
|
||||
|
|
@ -4,16 +4,15 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/git"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// AddCommitCommand adds the 'commit' command to the given parent command.
|
||||
func AddCommitCommand(parent *clir.Command) {
|
||||
// addCommitCommand adds the 'commit' command to the given parent command.
|
||||
func addCommitCommand(parent *clir.Command) {
|
||||
var registryPath string
|
||||
var all bool
|
||||
|
||||
|
|
@ -116,7 +115,7 @@ func runCommit(registryPath string, all bool) error {
|
|||
// Confirm unless --all
|
||||
if !all {
|
||||
fmt.Println()
|
||||
if !confirm("Have Claude commit these repos?") {
|
||||
if !shared.Confirm("Have Claude commit these repos?") {
|
||||
fmt.Println("Aborted.")
|
||||
return nil
|
||||
}
|
||||
|
|
@ -130,10 +129,10 @@ func runCommit(registryPath string, all bool) error {
|
|||
fmt.Printf("%s %s\n", dimStyle.Render("Committing"), s.Name)
|
||||
|
||||
if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil {
|
||||
fmt.Printf(" %s %s\n", errorStyle.Render("✗"), err)
|
||||
fmt.Printf(" %s %s\n", errorStyle.Render("x"), err)
|
||||
failed++
|
||||
} else {
|
||||
fmt.Printf(" %s committed\n", successStyle.Render("✓"))
|
||||
fmt.Printf(" %s committed\n", successStyle.Render("v"))
|
||||
succeeded++
|
||||
}
|
||||
fmt.Println()
|
||||
|
|
@ -148,25 +147,3 @@ func runCommit(registryPath string, all bool) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// claudeCommit is defined in work.go but we need it here too
|
||||
// This version includes better output handling
|
||||
func claudeCommitWithOutput(ctx context.Context, repoPath, repoName, registryPath string) error {
|
||||
// Load AGENTS.md context if available
|
||||
agentsPath := filepath.Join(filepath.Dir(registryPath), "AGENTS.md")
|
||||
var agentContext string
|
||||
if data, err := os.ReadFile(agentsPath); err == nil {
|
||||
agentContext = string(data) + "\n\n"
|
||||
}
|
||||
|
||||
prompt := agentContext + "Review the uncommitted changes and create an appropriate commit. " +
|
||||
"Use Co-Authored-By: Claude Opus 4.5 <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"
|
||||
"sort"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/pkg/git"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
var (
|
||||
healthLabelStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
||||
|
||||
healthValueStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#e2e8f0")) // gray-200
|
||||
|
||||
healthGoodStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||
|
||||
healthWarnStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#f59e0b")) // amber-500
|
||||
|
||||
healthBadStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#ef4444")) // red-500
|
||||
)
|
||||
|
||||
// AddHealthCommand adds the 'health' command to the given parent command.
|
||||
func AddHealthCommand(parent *clir.Command) {
|
||||
// addHealthCommand adds the 'health' command to the given parent command.
|
||||
func addHealthCommand(parent *clir.Command) {
|
||||
var registryPath string
|
||||
var verbose bool
|
||||
|
||||
|
|
@ -139,16 +117,16 @@ func runHealth(registryPath string, verbose bool) error {
|
|||
// Verbose output
|
||||
if verbose {
|
||||
if len(dirtyRepos) > 0 {
|
||||
fmt.Printf("%s %s\n", healthWarnStyle.Render("Dirty:"), formatRepoList(dirtyRepos))
|
||||
fmt.Printf("%s %s\n", warningStyle.Render("Dirty:"), formatRepoList(dirtyRepos))
|
||||
}
|
||||
if len(aheadRepos) > 0 {
|
||||
fmt.Printf("%s %s\n", healthGoodStyle.Render("Ahead:"), formatRepoList(aheadRepos))
|
||||
fmt.Printf("%s %s\n", successStyle.Render("Ahead:"), formatRepoList(aheadRepos))
|
||||
}
|
||||
if len(behindRepos) > 0 {
|
||||
fmt.Printf("%s %s\n", healthWarnStyle.Render("Behind:"), formatRepoList(behindRepos))
|
||||
fmt.Printf("%s %s\n", warningStyle.Render("Behind:"), formatRepoList(behindRepos))
|
||||
}
|
||||
if len(errorRepos) > 0 {
|
||||
fmt.Printf("%s %s\n", healthBadStyle.Render("Errors:"), formatRepoList(errorRepos))
|
||||
fmt.Printf("%s %s\n", errorStyle.Render("Errors:"), formatRepoList(errorRepos))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
|
@ -158,62 +136,62 @@ func runHealth(registryPath string, verbose bool) error {
|
|||
|
||||
func printHealthSummary(total int, dirty, ahead, behind, errors []string) {
|
||||
// Total repos
|
||||
fmt.Print(healthValueStyle.Render(fmt.Sprintf("%d", total)))
|
||||
fmt.Print(healthLabelStyle.Render(" repos"))
|
||||
fmt.Print(valueStyle.Render(fmt.Sprintf("%d", total)))
|
||||
fmt.Print(dimStyle.Render(" repos"))
|
||||
|
||||
// Separator
|
||||
fmt.Print(healthLabelStyle.Render(" │ "))
|
||||
fmt.Print(dimStyle.Render(" | "))
|
||||
|
||||
// Dirty
|
||||
if len(dirty) > 0 {
|
||||
fmt.Print(healthWarnStyle.Render(fmt.Sprintf("%d", len(dirty))))
|
||||
fmt.Print(healthLabelStyle.Render(" dirty"))
|
||||
fmt.Print(warningStyle.Render(fmt.Sprintf("%d", len(dirty))))
|
||||
fmt.Print(dimStyle.Render(" dirty"))
|
||||
} else {
|
||||
fmt.Print(healthGoodStyle.Render("clean"))
|
||||
fmt.Print(successStyle.Render("clean"))
|
||||
}
|
||||
|
||||
// Separator
|
||||
fmt.Print(healthLabelStyle.Render(" │ "))
|
||||
fmt.Print(dimStyle.Render(" | "))
|
||||
|
||||
// Ahead
|
||||
if len(ahead) > 0 {
|
||||
fmt.Print(healthValueStyle.Render(fmt.Sprintf("%d", len(ahead))))
|
||||
fmt.Print(healthLabelStyle.Render(" to push"))
|
||||
fmt.Print(valueStyle.Render(fmt.Sprintf("%d", len(ahead))))
|
||||
fmt.Print(dimStyle.Render(" to push"))
|
||||
} else {
|
||||
fmt.Print(healthGoodStyle.Render("synced"))
|
||||
fmt.Print(successStyle.Render("synced"))
|
||||
}
|
||||
|
||||
// Separator
|
||||
fmt.Print(healthLabelStyle.Render(" │ "))
|
||||
fmt.Print(dimStyle.Render(" | "))
|
||||
|
||||
// Behind
|
||||
if len(behind) > 0 {
|
||||
fmt.Print(healthWarnStyle.Render(fmt.Sprintf("%d", len(behind))))
|
||||
fmt.Print(healthLabelStyle.Render(" to pull"))
|
||||
fmt.Print(warningStyle.Render(fmt.Sprintf("%d", len(behind))))
|
||||
fmt.Print(dimStyle.Render(" to pull"))
|
||||
} else {
|
||||
fmt.Print(healthGoodStyle.Render("up to date"))
|
||||
fmt.Print(successStyle.Render("up to date"))
|
||||
}
|
||||
|
||||
// Errors (only if any)
|
||||
if len(errors) > 0 {
|
||||
fmt.Print(healthLabelStyle.Render(" │ "))
|
||||
fmt.Print(healthBadStyle.Render(fmt.Sprintf("%d", len(errors))))
|
||||
fmt.Print(healthLabelStyle.Render(" errors"))
|
||||
fmt.Print(dimStyle.Render(" | "))
|
||||
fmt.Print(errorStyle.Render(fmt.Sprintf("%d", len(errors))))
|
||||
fmt.Print(dimStyle.Render(" errors"))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func formatRepoList(repos []string) string {
|
||||
if len(repos) <= 5 {
|
||||
return joinRepos(repos)
|
||||
func formatRepoList(reposList []string) string {
|
||||
if len(reposList) <= 5 {
|
||||
return joinRepos(reposList)
|
||||
}
|
||||
return joinRepos(repos[:5]) + fmt.Sprintf(" +%d more", len(repos)-5)
|
||||
return joinRepos(reposList[:5]) + fmt.Sprintf(" +%d more", len(reposList)-5)
|
||||
}
|
||||
|
||||
func joinRepos(repos []string) string {
|
||||
func joinRepos(reposList []string) string {
|
||||
result := ""
|
||||
for i, r := range repos {
|
||||
for i, r := range reposList {
|
||||
if i > 0 {
|
||||
result += ", "
|
||||
}
|
||||
|
|
@ -6,10 +6,12 @@ import (
|
|||
"sort"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// Impact-specific styles
|
||||
var (
|
||||
impactDirectStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
|
|
@ -22,8 +24,8 @@ var (
|
|||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||
)
|
||||
|
||||
// AddImpactCommand adds the 'impact' command to the given parent command.
|
||||
func AddImpactCommand(parent *clir.Command) {
|
||||
// addImpactCommand adds the 'impact' command to the given parent command.
|
||||
func addImpactCommand(parent *clir.Command) {
|
||||
var registryPath string
|
||||
|
||||
impactCmd := parent.NewSubCommand("impact", "Show impact of changing a repo")
|
||||
|
|
@ -110,21 +112,21 @@ func runImpact(registryPath string, repoName string) error {
|
|||
fmt.Println()
|
||||
|
||||
if len(allAffected) == 0 {
|
||||
fmt.Printf("%s No repos depend on %s\n", impactSafeStyle.Render("✓"), repoName)
|
||||
fmt.Printf("%s No repos depend on %s\n", impactSafeStyle.Render("v"), repoName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Direct dependents
|
||||
if len(direct) > 0 {
|
||||
fmt.Printf("%s %d direct dependent(s):\n",
|
||||
impactDirectStyle.Render("●"),
|
||||
impactDirectStyle.Render("*"),
|
||||
len(direct),
|
||||
)
|
||||
for _, d := range direct {
|
||||
r, _ := reg.Get(d)
|
||||
desc := ""
|
||||
if r != nil && r.Description != "" {
|
||||
desc = dimStyle.Render(" - " + truncate(r.Description, 40))
|
||||
desc = dimStyle.Render(" - " + shared.Truncate(r.Description, 40))
|
||||
}
|
||||
fmt.Printf(" %s%s\n", d, desc)
|
||||
}
|
||||
|
|
@ -134,14 +136,14 @@ func runImpact(registryPath string, repoName string) error {
|
|||
// Indirect dependents
|
||||
if len(indirect) > 0 {
|
||||
fmt.Printf("%s %d transitive dependent(s):\n",
|
||||
impactIndirectStyle.Render("○"),
|
||||
impactIndirectStyle.Render("o"),
|
||||
len(indirect),
|
||||
)
|
||||
for _, d := range indirect {
|
||||
r, _ := reg.Get(d)
|
||||
desc := ""
|
||||
if r != nil && r.Description != "" {
|
||||
desc = dimStyle.Render(" - " + truncate(r.Description, 40))
|
||||
desc = dimStyle.Render(" - " + shared.Truncate(r.Description, 40))
|
||||
}
|
||||
fmt.Printf(" %s%s\n", d, desc)
|
||||
}
|
||||
|
|
@ -10,10 +10,12 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// Issue-specific styles
|
||||
var (
|
||||
issueRepoStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
||||
|
|
@ -60,8 +62,8 @@ type GitHubIssue struct {
|
|||
RepoName string `json:"-"`
|
||||
}
|
||||
|
||||
// AddIssuesCommand adds the 'issues' command to the given parent command.
|
||||
func AddIssuesCommand(parent *clir.Command) {
|
||||
// addIssuesCommand adds the 'issues' command to the given parent command.
|
||||
func addIssuesCommand(parent *clir.Command) {
|
||||
var registryPath string
|
||||
var limit int
|
||||
var assignee string
|
||||
|
|
@ -204,7 +206,7 @@ func printIssue(issue GitHubIssue) {
|
|||
// #42 [core-bio] Fix avatar upload
|
||||
num := issueNumberStyle.Render(fmt.Sprintf("#%d", issue.Number))
|
||||
repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", issue.RepoName))
|
||||
title := issueTitleStyle.Render(truncate(issue.Title, 60))
|
||||
title := issueTitleStyle.Render(shared.Truncate(issue.Title, 60))
|
||||
|
||||
line := fmt.Sprintf(" %s %s %s", num, repo, title)
|
||||
|
||||
|
|
@ -227,33 +229,8 @@ func printIssue(issue GitHubIssue) {
|
|||
}
|
||||
|
||||
// Add age
|
||||
age := formatAge(issue.CreatedAt)
|
||||
age := shared.FormatAge(issue.CreatedAt)
|
||||
line += " " + issueAgeStyle.Render(age)
|
||||
|
||||
fmt.Println(line)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
|
||||
func formatAge(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
}
|
||||
if d < 7*24*time.Hour {
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
}
|
||||
if d < 30*24*time.Hour {
|
||||
return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7)))
|
||||
}
|
||||
return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30)))
|
||||
}
|
||||
|
|
@ -11,8 +11,8 @@ import (
|
|||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// AddPullCommand adds the 'pull' command to the given parent command.
|
||||
func AddPullCommand(parent *clir.Command) {
|
||||
// addPullCommand adds the 'pull' command to the given parent command.
|
||||
func addPullCommand(parent *clir.Command) {
|
||||
var registryPath string
|
||||
var all bool
|
||||
|
||||
|
|
@ -119,10 +119,10 @@ func runPull(registryPath string, all bool) error {
|
|||
|
||||
err := gitPull(ctx, s.Path)
|
||||
if err != nil {
|
||||
fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error()))
|
||||
fmt.Printf("%s\n", errorStyle.Render("x "+err.Error()))
|
||||
failed++
|
||||
} else {
|
||||
fmt.Printf("%s\n", successStyle.Render("✓"))
|
||||
fmt.Printf("%s\n", successStyle.Render("v"))
|
||||
succeeded++
|
||||
}
|
||||
}
|
||||
|
|
@ -5,13 +5,14 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/git"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// AddPushCommand adds the 'push' command to the given parent command.
|
||||
func AddPushCommand(parent *clir.Command) {
|
||||
// addPushCommand adds the 'push' command to the given parent command.
|
||||
func addPushCommand(parent *clir.Command) {
|
||||
var registryPath string
|
||||
var force bool
|
||||
|
||||
|
|
@ -108,7 +109,7 @@ func runPush(registryPath string, force bool) error {
|
|||
// Confirm unless --force
|
||||
if !force {
|
||||
fmt.Println()
|
||||
if !confirm(fmt.Sprintf("Push %d commit(s) to %d repo(s)?", totalCommits, len(aheadRepos))) {
|
||||
if !shared.Confirm(fmt.Sprintf("Push %d commit(s) to %d repo(s)?", totalCommits, len(aheadRepos))) {
|
||||
fmt.Println("Aborted.")
|
||||
return nil
|
||||
}
|
||||
|
|
@ -127,10 +128,10 @@ func runPush(registryPath string, force bool) error {
|
|||
var succeeded, failed int
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
fmt.Printf(" %s %s\n", successStyle.Render("✓"), r.Name)
|
||||
fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name)
|
||||
succeeded++
|
||||
} else {
|
||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), r.Name, r.Error)
|
||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, r.Error)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
|
@ -10,10 +10,12 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// PR-specific styles
|
||||
var (
|
||||
prNumberStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
|
|
@ -65,8 +67,8 @@ type GitHubPR struct {
|
|||
RepoName string `json:"-"`
|
||||
}
|
||||
|
||||
// AddReviewsCommand adds the 'reviews' command to the given parent command.
|
||||
func AddReviewsCommand(parent *clir.Command) {
|
||||
// addReviewsCommand adds the 'reviews' command to the given parent command.
|
||||
func addReviewsCommand(parent *clir.Command) {
|
||||
var registryPath string
|
||||
var author string
|
||||
var showAll bool
|
||||
|
|
@ -175,13 +177,13 @@ func runReviews(registryPath string, author string, showAll bool) error {
|
|||
fmt.Println()
|
||||
fmt.Printf("%d open PR(s)", len(allPRs))
|
||||
if pending > 0 {
|
||||
fmt.Printf(" · %s", prPendingStyle.Render(fmt.Sprintf("%d pending", pending)))
|
||||
fmt.Printf(" * %s", prPendingStyle.Render(fmt.Sprintf("%d pending", pending)))
|
||||
}
|
||||
if approved > 0 {
|
||||
fmt.Printf(" · %s", prApprovedStyle.Render(fmt.Sprintf("%d approved", approved)))
|
||||
fmt.Printf(" * %s", prApprovedStyle.Render(fmt.Sprintf("%d approved", approved)))
|
||||
}
|
||||
if changesRequested > 0 {
|
||||
fmt.Printf(" · %s", prChangesStyle.Render(fmt.Sprintf("%d changes requested", changesRequested)))
|
||||
fmt.Printf(" * %s", prChangesStyle.Render(fmt.Sprintf("%d changes requested", changesRequested)))
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println()
|
||||
|
|
@ -243,18 +245,18 @@ func printPR(pr GitHubPR) {
|
|||
// #12 [core-php] Webhook validation
|
||||
num := prNumberStyle.Render(fmt.Sprintf("#%d", pr.Number))
|
||||
repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", pr.RepoName))
|
||||
title := prTitleStyle.Render(truncate(pr.Title, 50))
|
||||
title := prTitleStyle.Render(shared.Truncate(pr.Title, 50))
|
||||
author := prAuthorStyle.Render("@" + pr.Author.Login)
|
||||
|
||||
// Review status
|
||||
var status string
|
||||
switch pr.ReviewDecision {
|
||||
case "APPROVED":
|
||||
status = prApprovedStyle.Render("✓ approved")
|
||||
status = prApprovedStyle.Render("v approved")
|
||||
case "CHANGES_REQUESTED":
|
||||
status = prChangesStyle.Render("● changes requested")
|
||||
status = prChangesStyle.Render("* changes requested")
|
||||
default:
|
||||
status = prPendingStyle.Render("○ pending review")
|
||||
status = prPendingStyle.Render("o pending review")
|
||||
}
|
||||
|
||||
// Draft indicator
|
||||
|
|
@ -263,7 +265,7 @@ func printPR(pr GitHubPR) {
|
|||
draft = prDraftStyle.Render(" [draft]")
|
||||
}
|
||||
|
||||
age := formatAge(pr.CreatedAt)
|
||||
age := shared.FormatAge(pr.CreatedAt)
|
||||
|
||||
fmt.Printf(" %s %s %s%s %s %s %s\n", num, repo, title, draft, author, status, issueAgeStyle.Render(age))
|
||||
}
|
||||
|
|
@ -15,8 +15,8 @@ import (
|
|||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// AddSyncCommand adds the 'sync' command to the given parent command.
|
||||
func AddSyncCommand(parent *clir.Command) {
|
||||
// addSyncCommand adds the 'sync' command to the given parent command.
|
||||
func addSyncCommand(parent *clir.Command) {
|
||||
syncCmd := parent.NewSubCommand("sync", "Synchronizes the public service APIs with their internal implementations.")
|
||||
syncCmd.LongDescription("This command scans the 'pkg' directory for services and ensures that the\ntop-level public API for each service is in sync with its internal implementation.\nIt automatically generates the necessary Go files with type aliases.")
|
||||
syncCmd.Action(func() error {
|
||||
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
|
||||
|
||||
import (
|
||||
|
|
@ -11,52 +10,14 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/git"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
var (
|
||||
// Table styles
|
||||
headerStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#3b82f6")). // blue-500
|
||||
Padding(0, 1)
|
||||
|
||||
cellStyle = lipgloss.NewStyle().
|
||||
Padding(0, 1)
|
||||
|
||||
dirtyStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#ef4444")). // red-500
|
||||
Padding(0, 1)
|
||||
|
||||
aheadStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#22c55e")). // green-500
|
||||
Padding(0, 1)
|
||||
|
||||
cleanStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6b7280")). // gray-500
|
||||
Padding(0, 1)
|
||||
|
||||
repoNameStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#e2e8f0")). // gray-200
|
||||
Padding(0, 1)
|
||||
|
||||
successStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#22c55e")). // green-500
|
||||
Bold(true)
|
||||
|
||||
errorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#ef4444")). // red-500
|
||||
Bold(true)
|
||||
|
||||
dimStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
||||
)
|
||||
|
||||
// AddWorkCommand adds the 'work' command to the given parent command.
|
||||
func AddWorkCommand(parent *clir.Command) {
|
||||
// addWorkCommand adds the 'work' command to the given parent command.
|
||||
func addWorkCommand(parent *clir.Command) {
|
||||
var statusOnly bool
|
||||
var autoCommit bool
|
||||
var registryPath string
|
||||
|
|
@ -156,14 +117,15 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
|||
// Auto-commit dirty repos if requested
|
||||
if autoCommit && len(dirtyRepos) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", headerStyle.Render("Committing dirty repos with Claude..."))
|
||||
hdrStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#3b82f6"))
|
||||
fmt.Printf("%s\n", hdrStyle.Render("Committing dirty repos with Claude..."))
|
||||
fmt.Println()
|
||||
|
||||
for _, s := range dirtyRepos {
|
||||
if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil {
|
||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), s.Name, err)
|
||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
||||
} else {
|
||||
fmt.Printf(" %s %s\n", successStyle.Render("✓"), s.Name)
|
||||
fmt.Printf(" %s %s\n", successStyle.Render("v"), s.Name)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -205,7 +167,7 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
|||
}
|
||||
|
||||
fmt.Println()
|
||||
if !confirm("Push all?") {
|
||||
if !shared.Confirm("Push all?") {
|
||||
fmt.Println("Aborted.")
|
||||
return nil
|
||||
}
|
||||
|
|
@ -222,9 +184,9 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
|||
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
fmt.Printf(" %s %s\n", successStyle.Render("✓"), r.Name)
|
||||
fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name)
|
||||
} else {
|
||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), r.Name, r.Error)
|
||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, r.Error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -252,7 +214,7 @@ func printStatusTable(statuses []git.RepoStatus) {
|
|||
)
|
||||
|
||||
// Print separator
|
||||
fmt.Println(strings.Repeat("─", nameWidth+2+10+11+8+7))
|
||||
fmt.Println(strings.Repeat("-", nameWidth+2+10+11+8+7))
|
||||
|
||||
// Print rows
|
||||
for _, s := range statuses {
|
||||
|
|
@ -309,12 +271,12 @@ func printStatusTable(statuses []git.RepoStatus) {
|
|||
func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
||||
// Load AGENTS.md context if available
|
||||
agentsPath := filepath.Join(filepath.Dir(registryPath), "AGENTS.md")
|
||||
var context string
|
||||
var agentContext string
|
||||
if data, err := os.ReadFile(agentsPath); err == nil {
|
||||
context = string(data) + "\n\n"
|
||||
agentContext = string(data) + "\n\n"
|
||||
}
|
||||
|
||||
prompt := context + "Review the uncommitted changes and create an appropriate commit. " +
|
||||
prompt := agentContext + "Review the uncommitted changes and create an appropriate commit. " +
|
||||
"Use Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>. Be concise."
|
||||
|
||||
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep")
|
||||
|
|
@ -325,11 +287,3 @@ func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string)
|
|||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func confirm(prompt string) bool {
|
||||
fmt.Printf("%s [y/N] ", prompt)
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
return response == "y" || response == "yes"
|
||||
}
|
||||
326
cmd/docs/docs.go
326
cmd/docs/docs.go
|
|
@ -2,19 +2,12 @@
|
|||
package docs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// Style and utility aliases
|
||||
// Style and utility aliases from shared
|
||||
var (
|
||||
repoNameStyle = shared.RepoNameStyle
|
||||
successStyle = shared.SuccessStyle
|
||||
|
|
@ -24,6 +17,7 @@ var (
|
|||
confirm = shared.Confirm
|
||||
)
|
||||
|
||||
// Package-specific styles
|
||||
var (
|
||||
docsFoundStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||
|
|
@ -35,17 +29,6 @@ var (
|
|||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
||||
)
|
||||
|
||||
// RepoDocInfo holds documentation info for a repo
|
||||
type RepoDocInfo struct {
|
||||
Name string
|
||||
Path string
|
||||
HasDocs bool
|
||||
Readme string
|
||||
ClaudeMd string
|
||||
Changelog string
|
||||
DocsFiles []string // All files in docs/ directory (recursive)
|
||||
}
|
||||
|
||||
// AddDocsCommand adds the 'docs' command to the given parent command.
|
||||
func AddDocsCommand(parent *clir.Cli) {
|
||||
docsCmd := parent.NewSubCommand("docs", "Documentation management")
|
||||
|
|
@ -56,308 +39,3 @@ func AddDocsCommand(parent *clir.Cli) {
|
|||
addDocsSyncCommand(docsCmd)
|
||||
addDocsListCommand(docsCmd)
|
||||
}
|
||||
|
||||
func addDocsSyncCommand(parent *clir.Command) {
|
||||
var registryPath string
|
||||
var dryRun bool
|
||||
var outputDir string
|
||||
|
||||
syncCmd := parent.NewSubCommand("sync", "Sync documentation to core-php/docs/packages/")
|
||||
syncCmd.StringFlag("registry", "Path to repos.yaml", ®istryPath)
|
||||
syncCmd.BoolFlag("dry-run", "Show what would be synced without copying", &dryRun)
|
||||
syncCmd.StringFlag("output", "Output directory (default: core-php/docs/packages)", &outputDir)
|
||||
|
||||
syncCmd.Action(func() error {
|
||||
return runDocsSync(registryPath, outputDir, dryRun)
|
||||
})
|
||||
}
|
||||
|
||||
func addDocsListCommand(parent *clir.Command) {
|
||||
var registryPath string
|
||||
|
||||
listCmd := parent.NewSubCommand("list", "List documentation across repos")
|
||||
listCmd.StringFlag("registry", "Path to repos.yaml", ®istryPath)
|
||||
|
||||
listCmd.Action(func() error {
|
||||
return runDocsList(registryPath)
|
||||
})
|
||||
}
|
||||
|
||||
// packageOutputName maps repo name to output folder name
|
||||
func packageOutputName(repoName string) string {
|
||||
// core -> go (the Go framework)
|
||||
if repoName == "core" {
|
||||
return "go"
|
||||
}
|
||||
// core-admin -> admin, core-api -> api, etc.
|
||||
if strings.HasPrefix(repoName, "core-") {
|
||||
return strings.TrimPrefix(repoName, "core-")
|
||||
}
|
||||
return repoName
|
||||
}
|
||||
|
||||
// shouldSyncRepo returns true if this repo should be synced
|
||||
func shouldSyncRepo(repoName string) bool {
|
||||
// Skip core-php (it's the destination)
|
||||
if repoName == "core-php" {
|
||||
return false
|
||||
}
|
||||
// Skip template
|
||||
if repoName == "core-template" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func runDocsSync(registryPath string, outputDir string, dryRun bool) error {
|
||||
// Find or use provided registry
|
||||
reg, basePath, err := loadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Default output to core-php/docs/packages relative to registry
|
||||
if outputDir == "" {
|
||||
outputDir = filepath.Join(basePath, "core-php", "docs", "packages")
|
||||
}
|
||||
|
||||
// Scan all repos for docs
|
||||
var docsInfo []RepoDocInfo
|
||||
for _, repo := range reg.List() {
|
||||
if !shouldSyncRepo(repo.Name) {
|
||||
continue
|
||||
}
|
||||
info := scanRepoDocs(repo)
|
||||
if info.HasDocs && len(info.DocsFiles) > 0 {
|
||||
docsInfo = append(docsInfo, info)
|
||||
}
|
||||
}
|
||||
|
||||
if len(docsInfo) == 0 {
|
||||
fmt.Println("No documentation found in any repos.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s %d repo(s) with docs/ directories\n\n", dimStyle.Render("Found"), len(docsInfo))
|
||||
|
||||
// Show what will be synced
|
||||
var totalFiles int
|
||||
for _, info := range docsInfo {
|
||||
totalFiles += len(info.DocsFiles)
|
||||
outName := packageOutputName(info.Name)
|
||||
fmt.Printf(" %s → %s %s\n",
|
||||
repoNameStyle.Render(info.Name),
|
||||
docsFileStyle.Render("packages/"+outName+"/"),
|
||||
dimStyle.Render(fmt.Sprintf("(%d files)", len(info.DocsFiles))))
|
||||
|
||||
for _, f := range info.DocsFiles {
|
||||
fmt.Printf(" %s\n", dimStyle.Render(f))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s %d files from %d repos → %s\n",
|
||||
dimStyle.Render("Total:"), totalFiles, len(docsInfo), outputDir)
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("\n%s\n", dimStyle.Render("Dry run - no files copied"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm
|
||||
fmt.Println()
|
||||
if !confirm("Sync?") {
|
||||
fmt.Println("Aborted.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync docs
|
||||
fmt.Println()
|
||||
var synced int
|
||||
for _, info := range docsInfo {
|
||||
outName := packageOutputName(info.Name)
|
||||
repoOutDir := filepath.Join(outputDir, outName)
|
||||
|
||||
// Clear existing directory
|
||||
os.RemoveAll(repoOutDir)
|
||||
|
||||
if err := os.MkdirAll(repoOutDir, 0755); err != nil {
|
||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Copy all docs files
|
||||
docsDir := filepath.Join(info.Path, "docs")
|
||||
for _, f := range info.DocsFiles {
|
||||
src := filepath.Join(docsDir, f)
|
||||
dst := filepath.Join(repoOutDir, f)
|
||||
os.MkdirAll(filepath.Dir(dst), 0755)
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s → packages/%s/\n", successStyle.Render("✓"), info.Name, outName)
|
||||
synced++
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Synced %d packages\n", successStyle.Render("Done:"), synced)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDocsList(registryPath string) error {
|
||||
reg, _, err := loadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("\n%-20s %-8s %-8s %-10s %s\n",
|
||||
headerStyle.Render("Repo"),
|
||||
headerStyle.Render("README"),
|
||||
headerStyle.Render("CLAUDE"),
|
||||
headerStyle.Render("CHANGELOG"),
|
||||
headerStyle.Render("docs/"),
|
||||
)
|
||||
fmt.Println(strings.Repeat("─", 70))
|
||||
|
||||
var withDocs, withoutDocs int
|
||||
for _, repo := range reg.List() {
|
||||
info := scanRepoDocs(repo)
|
||||
|
||||
readme := docsMissingStyle.Render("—")
|
||||
if info.Readme != "" {
|
||||
readme = docsFoundStyle.Render("✓")
|
||||
}
|
||||
|
||||
claude := docsMissingStyle.Render("—")
|
||||
if info.ClaudeMd != "" {
|
||||
claude = docsFoundStyle.Render("✓")
|
||||
}
|
||||
|
||||
changelog := docsMissingStyle.Render("—")
|
||||
if info.Changelog != "" {
|
||||
changelog = docsFoundStyle.Render("✓")
|
||||
}
|
||||
|
||||
docsDir := docsMissingStyle.Render("—")
|
||||
if len(info.DocsFiles) > 0 {
|
||||
docsDir = docsFoundStyle.Render(fmt.Sprintf("%d files", len(info.DocsFiles)))
|
||||
}
|
||||
|
||||
fmt.Printf("%-20s %-8s %-8s %-10s %s\n",
|
||||
repoNameStyle.Render(info.Name),
|
||||
readme,
|
||||
claude,
|
||||
changelog,
|
||||
docsDir,
|
||||
)
|
||||
|
||||
if info.HasDocs {
|
||||
withDocs++
|
||||
} else {
|
||||
withoutDocs++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %d with docs, %d without\n",
|
||||
dimStyle.Render("Coverage:"),
|
||||
withDocs,
|
||||
withoutDocs,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadRegistry(registryPath string) (*repos.Registry, string, error) {
|
||||
var reg *repos.Registry
|
||||
var err error
|
||||
var basePath string
|
||||
|
||||
if registryPath != "" {
|
||||
reg, err = repos.LoadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to load registry: %w", err)
|
||||
}
|
||||
basePath = filepath.Dir(registryPath)
|
||||
} else {
|
||||
registryPath, err = repos.FindRegistry()
|
||||
if err == nil {
|
||||
reg, err = repos.LoadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to load registry: %w", err)
|
||||
}
|
||||
basePath = filepath.Dir(registryPath)
|
||||
} else {
|
||||
cwd, _ := os.Getwd()
|
||||
reg, err = repos.ScanDirectory(cwd)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to scan directory: %w", err)
|
||||
}
|
||||
basePath = cwd
|
||||
}
|
||||
}
|
||||
|
||||
return reg, basePath, nil
|
||||
}
|
||||
|
||||
func scanRepoDocs(repo *repos.Repo) RepoDocInfo {
|
||||
info := RepoDocInfo{
|
||||
Name: repo.Name,
|
||||
Path: repo.Path,
|
||||
}
|
||||
|
||||
// Check for README.md
|
||||
readme := filepath.Join(repo.Path, "README.md")
|
||||
if _, err := os.Stat(readme); err == nil {
|
||||
info.Readme = readme
|
||||
info.HasDocs = true
|
||||
}
|
||||
|
||||
// Check for CLAUDE.md
|
||||
claudeMd := filepath.Join(repo.Path, "CLAUDE.md")
|
||||
if _, err := os.Stat(claudeMd); err == nil {
|
||||
info.ClaudeMd = claudeMd
|
||||
info.HasDocs = true
|
||||
}
|
||||
|
||||
// Check for CHANGELOG.md
|
||||
changelog := filepath.Join(repo.Path, "CHANGELOG.md")
|
||||
if _, err := os.Stat(changelog); err == nil {
|
||||
info.Changelog = changelog
|
||||
info.HasDocs = true
|
||||
}
|
||||
|
||||
// Recursively scan docs/ directory for .md files
|
||||
docsDir := filepath.Join(repo.Path, "docs")
|
||||
if _, err := os.Stat(docsDir); err == nil {
|
||||
filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Skip plans/ directory
|
||||
if d.IsDir() && d.Name() == "plans" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// Skip non-markdown files
|
||||
if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
|
||||
return nil
|
||||
}
|
||||
// Get relative path from docs/
|
||||
relPath, _ := filepath.Rel(docsDir, path)
|
||||
info.DocsFiles = append(info.DocsFiles, relPath)
|
||||
info.HasDocs = true
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, 0644)
|
||||
}
|
||||
|
|
|
|||
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 (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// Style aliases
|
||||
// Style aliases from shared
|
||||
var (
|
||||
successStyle = shared.SuccessStyle
|
||||
errorStyle = shared.ErrorStyle
|
||||
|
|
@ -36,95 +30,15 @@ func AddDoctorCommand(parent *clir.Cli) {
|
|||
})
|
||||
}
|
||||
|
||||
type check struct {
|
||||
name string
|
||||
description string
|
||||
command string
|
||||
args []string
|
||||
required bool
|
||||
versionFlag string
|
||||
}
|
||||
|
||||
func runDoctor(verbose bool) error {
|
||||
fmt.Println("Checking development environment...")
|
||||
fmt.Println()
|
||||
|
||||
checks := []check{
|
||||
// Required tools
|
||||
{
|
||||
name: "Git",
|
||||
description: "Version control",
|
||||
command: "git",
|
||||
args: []string{"--version"},
|
||||
required: true,
|
||||
versionFlag: "--version",
|
||||
},
|
||||
{
|
||||
name: "GitHub CLI",
|
||||
description: "GitHub integration (issues, PRs, CI)",
|
||||
command: "gh",
|
||||
args: []string{"--version"},
|
||||
required: true,
|
||||
versionFlag: "--version",
|
||||
},
|
||||
{
|
||||
name: "PHP",
|
||||
description: "Laravel packages",
|
||||
command: "php",
|
||||
args: []string{"-v"},
|
||||
required: true,
|
||||
versionFlag: "-v",
|
||||
},
|
||||
{
|
||||
name: "Composer",
|
||||
description: "PHP dependencies",
|
||||
command: "composer",
|
||||
args: []string{"--version"},
|
||||
required: true,
|
||||
versionFlag: "--version",
|
||||
},
|
||||
{
|
||||
name: "Node.js",
|
||||
description: "Frontend builds",
|
||||
command: "node",
|
||||
args: []string{"--version"},
|
||||
required: true,
|
||||
versionFlag: "--version",
|
||||
},
|
||||
// Optional tools
|
||||
{
|
||||
name: "pnpm",
|
||||
description: "Fast package manager",
|
||||
command: "pnpm",
|
||||
args: []string{"--version"},
|
||||
required: false,
|
||||
versionFlag: "--version",
|
||||
},
|
||||
{
|
||||
name: "Claude Code",
|
||||
description: "AI-assisted development",
|
||||
command: "claude",
|
||||
args: []string{"--version"},
|
||||
required: false,
|
||||
versionFlag: "--version",
|
||||
},
|
||||
{
|
||||
name: "Docker",
|
||||
description: "Container runtime",
|
||||
command: "docker",
|
||||
args: []string{"--version"},
|
||||
required: false,
|
||||
versionFlag: "--version",
|
||||
},
|
||||
}
|
||||
|
||||
var passed, failed, optional int
|
||||
|
||||
// Check required tools
|
||||
fmt.Println("Required:")
|
||||
for _, c := range checks {
|
||||
if !c.required {
|
||||
continue
|
||||
}
|
||||
for _, c := range requiredChecks {
|
||||
ok, version := runCheck(c)
|
||||
if ok {
|
||||
if verbose && version != "" {
|
||||
|
|
@ -139,11 +53,9 @@ func runDoctor(verbose bool) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Check optional tools
|
||||
fmt.Println("\nOptional:")
|
||||
for _, c := range checks {
|
||||
if c.required {
|
||||
continue
|
||||
}
|
||||
for _, c := range optionalChecks {
|
||||
ok, version := runCheck(c)
|
||||
if ok {
|
||||
if verbose && version != "" {
|
||||
|
|
@ -158,7 +70,7 @@ func runDoctor(verbose bool) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Check SSH
|
||||
// Check GitHub access
|
||||
fmt.Println("\nGitHub Access:")
|
||||
if checkGitHubSSH() {
|
||||
fmt.Printf(" %s SSH key found\n", successStyle.Render("✓"))
|
||||
|
|
@ -176,38 +88,7 @@ func runDoctor(verbose bool) error {
|
|||
|
||||
// Check workspace
|
||||
fmt.Println("\nWorkspace:")
|
||||
registryPath, err := repos.FindRegistry()
|
||||
if err == nil {
|
||||
fmt.Printf(" %s Found repos.yaml at %s\n", successStyle.Render("✓"), registryPath)
|
||||
|
||||
reg, err := repos.LoadRegistry(registryPath)
|
||||
if err == nil {
|
||||
basePath := reg.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "./packages"
|
||||
}
|
||||
if !filepath.IsAbs(basePath) {
|
||||
basePath = filepath.Join(filepath.Dir(registryPath), basePath)
|
||||
}
|
||||
if strings.HasPrefix(basePath, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
basePath = filepath.Join(home, basePath[2:])
|
||||
}
|
||||
|
||||
// Count existing repos
|
||||
allRepos := reg.List()
|
||||
var cloned int
|
||||
for _, repo := range allRepos {
|
||||
repoPath := filepath.Join(basePath, repo.Name)
|
||||
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
|
||||
cloned++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %s %d/%d repos cloned\n", successStyle.Render("✓"), cloned, len(allRepos))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" %s No repos.yaml found (run from workspace directory)\n", dimStyle.Render("○"))
|
||||
}
|
||||
checkWorkspace()
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
|
|
@ -221,63 +102,3 @@ func runDoctor(verbose bool) error {
|
|||
fmt.Printf("%s Environment ready\n", successStyle.Render("Doctor:"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCheck(c check) (bool, string) {
|
||||
cmd := exec.Command(c.command, c.args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Extract first line as version
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) > 0 {
|
||||
return true, strings.TrimSpace(lines[0])
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func checkGitHubSSH() bool {
|
||||
// Just check if SSH keys exist - don't try to authenticate
|
||||
// (key might be locked/passphrase protected)
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"}
|
||||
|
||||
for _, key := range keyPatterns {
|
||||
keyPath := filepath.Join(sshDir, key)
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func checkGitHubCLI() bool {
|
||||
cmd := exec.Command("gh", "auth", "status")
|
||||
output, _ := cmd.CombinedOutput()
|
||||
// Check for any successful login (even if there's also a failing token)
|
||||
return strings.Contains(string(output), "Logged in to")
|
||||
}
|
||||
|
||||
func printInstallInstructions() {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
fmt.Println(" brew install git gh php composer node pnpm docker")
|
||||
fmt.Println(" brew install --cask claude")
|
||||
case "linux":
|
||||
fmt.Println(" # Install via your package manager or:")
|
||||
fmt.Println(" # Git: apt install git")
|
||||
fmt.Println(" # GitHub CLI: https://cli.github.com/")
|
||||
fmt.Println(" # PHP: apt install php8.3-cli")
|
||||
fmt.Println(" # Node: https://nodejs.org/")
|
||||
fmt.Println(" # pnpm: npm install -g pnpm")
|
||||
default:
|
||||
fmt.Println(" See documentation for your OS")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
|
@ -44,590 +36,3 @@ func AddGoCommands(parent *clir.Cli) {
|
|||
addGoModCommand(goCmd)
|
||||
addGoWorkCommand(goCmd)
|
||||
}
|
||||
|
||||
func addGoTestCommand(parent *clir.Command) {
|
||||
var (
|
||||
coverage bool
|
||||
pkg string
|
||||
run string
|
||||
short bool
|
||||
race bool
|
||||
json bool
|
||||
verbose bool
|
||||
)
|
||||
|
||||
testCmd := parent.NewSubCommand("test", "Run tests with coverage")
|
||||
testCmd.LongDescription("Run Go tests with coverage reporting.\n\n" +
|
||||
"Sets MACOSX_DEPLOYMENT_TARGET=26.0 to suppress linker warnings.\n" +
|
||||
"Filters noisy output and provides colour-coded coverage.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core go test\n" +
|
||||
" core go test --coverage\n" +
|
||||
" core go test --pkg ./pkg/crypt\n" +
|
||||
" core go test --run TestHash")
|
||||
|
||||
testCmd.BoolFlag("coverage", "Show detailed per-package coverage", &coverage)
|
||||
testCmd.StringFlag("pkg", "Package to test (default: ./...)", &pkg)
|
||||
testCmd.StringFlag("run", "Run only tests matching regexp", &run)
|
||||
testCmd.BoolFlag("short", "Run only short tests", &short)
|
||||
testCmd.BoolFlag("race", "Enable race detector", &race)
|
||||
testCmd.BoolFlag("json", "Output JSON results", &json)
|
||||
testCmd.BoolFlag("v", "Verbose output", &verbose)
|
||||
|
||||
testCmd.Action(func() error {
|
||||
return runGoTest(coverage, pkg, run, short, race, json, verbose)
|
||||
})
|
||||
}
|
||||
|
||||
func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose bool) error {
|
||||
if pkg == "" {
|
||||
pkg = "./..."
|
||||
}
|
||||
|
||||
args := []string{"test"}
|
||||
|
||||
if coverage {
|
||||
args = append(args, "-cover")
|
||||
} else {
|
||||
args = append(args, "-cover")
|
||||
}
|
||||
|
||||
if run != "" {
|
||||
args = append(args, "-run", run)
|
||||
}
|
||||
if short {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
if race {
|
||||
args = append(args, "-race")
|
||||
}
|
||||
if verbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
|
||||
args = append(args, pkg)
|
||||
|
||||
if !jsonOut {
|
||||
fmt.Printf("%s Running tests\n", dimStyle.Render("Test:"))
|
||||
fmt.Printf(" %s %s\n", dimStyle.Render("Package:"), pkg)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0")
|
||||
cmd.Dir, _ = os.Getwd()
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
outputStr := string(output)
|
||||
|
||||
// Filter linker warnings
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
var filtered []string
|
||||
for _, line := range lines {
|
||||
if !strings.Contains(line, "ld: warning:") {
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
}
|
||||
outputStr = strings.Join(filtered, "\n")
|
||||
|
||||
// Parse results
|
||||
passed, failed, skipped := parseTestResults(outputStr)
|
||||
cov := parseOverallCoverage(outputStr)
|
||||
|
||||
if jsonOut {
|
||||
fmt.Printf(`{"passed":%d,"failed":%d,"skipped":%d,"coverage":%.1f,"exit_code":%d}`,
|
||||
passed, failed, skipped, cov, cmd.ProcessState.ExitCode())
|
||||
fmt.Println()
|
||||
return err
|
||||
}
|
||||
|
||||
// Print filtered output if verbose or failed
|
||||
if verbose || err != nil {
|
||||
fmt.Println(outputStr)
|
||||
}
|
||||
|
||||
// Summary
|
||||
if err == nil {
|
||||
fmt.Printf(" %s %d passed\n", successStyle.Render("✓"), passed)
|
||||
} else {
|
||||
fmt.Printf(" %s %d passed, %d failed\n", errorStyle.Render("✗"), passed, failed)
|
||||
}
|
||||
|
||||
if cov > 0 {
|
||||
covStyle := successStyle
|
||||
if cov < 50 {
|
||||
covStyle = errorStyle
|
||||
} else if cov < 80 {
|
||||
covStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b"))
|
||||
}
|
||||
fmt.Printf("\n %s %s\n", dimStyle.Render("Coverage:"), covStyle.Render(fmt.Sprintf("%.1f%%", cov)))
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
fmt.Printf("\n%s\n", successStyle.Render("PASS All tests passed"))
|
||||
} else {
|
||||
fmt.Printf("\n%s\n", errorStyle.Render("FAIL Some tests failed"))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func parseTestResults(output string) (passed, failed, skipped int) {
|
||||
passRe := regexp.MustCompile(`(?m)^ok\s+`)
|
||||
failRe := regexp.MustCompile(`(?m)^FAIL\s+`)
|
||||
skipRe := regexp.MustCompile(`(?m)^\?\s+`)
|
||||
|
||||
passed = len(passRe.FindAllString(output, -1))
|
||||
failed = len(failRe.FindAllString(output, -1))
|
||||
skipped = len(skipRe.FindAllString(output, -1))
|
||||
return
|
||||
}
|
||||
|
||||
func parseOverallCoverage(output string) float64 {
|
||||
re := regexp.MustCompile(`coverage:\s+([\d.]+)%`)
|
||||
matches := re.FindAllStringSubmatch(output, -1)
|
||||
if len(matches) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var total float64
|
||||
for _, m := range matches {
|
||||
var cov float64
|
||||
fmt.Sscanf(m[1], "%f", &cov)
|
||||
total += cov
|
||||
}
|
||||
return total / float64(len(matches))
|
||||
}
|
||||
|
||||
func addGoCovCommand(parent *clir.Command) {
|
||||
var (
|
||||
pkg string
|
||||
html bool
|
||||
open bool
|
||||
threshold float64
|
||||
)
|
||||
|
||||
covCmd := parent.NewSubCommand("cov", "Run tests with coverage report")
|
||||
covCmd.LongDescription("Run tests and generate coverage report.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core go cov # Run with coverage summary\n" +
|
||||
" core go cov --html # Generate HTML report\n" +
|
||||
" core go cov --open # Generate and open HTML report\n" +
|
||||
" core go cov --threshold 80 # Fail if coverage < 80%")
|
||||
|
||||
covCmd.StringFlag("pkg", "Package to test (default: ./...)", &pkg)
|
||||
covCmd.BoolFlag("html", "Generate HTML coverage report", &html)
|
||||
covCmd.BoolFlag("open", "Generate and open HTML report in browser", &open)
|
||||
covCmd.Float64Flag("threshold", "Minimum coverage percentage (exit 1 if below)", &threshold)
|
||||
|
||||
covCmd.Action(func() error {
|
||||
if pkg == "" {
|
||||
// Auto-discover packages with tests
|
||||
pkgs, err := findTestPackages(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to discover test packages: %w", err)
|
||||
}
|
||||
if len(pkgs) == 0 {
|
||||
return fmt.Errorf("no test packages found")
|
||||
}
|
||||
pkg = strings.Join(pkgs, " ")
|
||||
}
|
||||
|
||||
// Create temp file for coverage data
|
||||
covFile, err := os.CreateTemp("", "coverage-*.out")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create coverage file: %w", err)
|
||||
}
|
||||
covPath := covFile.Name()
|
||||
covFile.Close()
|
||||
defer os.Remove(covPath)
|
||||
|
||||
fmt.Printf("%s Running tests with coverage\n", dimStyle.Render("Coverage:"))
|
||||
// Truncate package list if too long for display
|
||||
displayPkg := pkg
|
||||
if len(displayPkg) > 60 {
|
||||
displayPkg = displayPkg[:57] + "..."
|
||||
}
|
||||
fmt.Printf(" %s %s\n", dimStyle.Render("Package:"), displayPkg)
|
||||
fmt.Println()
|
||||
|
||||
// Run tests with coverage
|
||||
// We need to split pkg into individual arguments if it contains spaces
|
||||
pkgArgs := strings.Fields(pkg)
|
||||
args := append([]string{"test", "-coverprofile=" + covPath, "-covermode=atomic"}, pkgArgs...)
|
||||
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
testErr := cmd.Run()
|
||||
|
||||
// Get coverage percentage
|
||||
covCmd := exec.Command("go", "tool", "cover", "-func="+covPath)
|
||||
covOutput, err := covCmd.Output()
|
||||
if err != nil {
|
||||
if testErr != nil {
|
||||
return testErr
|
||||
}
|
||||
return fmt.Errorf("failed to get coverage: %w", err)
|
||||
}
|
||||
|
||||
// Parse total coverage from last line
|
||||
lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n")
|
||||
var totalCov float64
|
||||
if len(lines) > 0 {
|
||||
lastLine := lines[len(lines)-1]
|
||||
// Format: "total: (statements) XX.X%"
|
||||
if strings.Contains(lastLine, "total:") {
|
||||
parts := strings.Fields(lastLine)
|
||||
if len(parts) >= 3 {
|
||||
covStr := strings.TrimSuffix(parts[len(parts)-1], "%")
|
||||
fmt.Sscanf(covStr, "%f", &totalCov)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print coverage summary
|
||||
fmt.Println()
|
||||
covStyle := successStyle
|
||||
if totalCov < 50 {
|
||||
covStyle = errorStyle
|
||||
} else if totalCov < 80 {
|
||||
covStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b"))
|
||||
}
|
||||
fmt.Printf(" %s %s\n", dimStyle.Render("Total:"), covStyle.Render(fmt.Sprintf("%.1f%%", totalCov)))
|
||||
|
||||
// Generate HTML if requested
|
||||
if html || open {
|
||||
htmlPath := "coverage.html"
|
||||
htmlCmd := exec.Command("go", "tool", "cover", "-html="+covPath, "-o="+htmlPath)
|
||||
if err := htmlCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to generate HTML: %w", err)
|
||||
}
|
||||
fmt.Printf(" %s %s\n", dimStyle.Render("HTML:"), htmlPath)
|
||||
|
||||
if open {
|
||||
// Open in browser
|
||||
var openCmd *exec.Cmd
|
||||
switch {
|
||||
case exec.Command("which", "open").Run() == nil:
|
||||
openCmd = exec.Command("open", htmlPath)
|
||||
case exec.Command("which", "xdg-open").Run() == nil:
|
||||
openCmd = exec.Command("xdg-open", htmlPath)
|
||||
default:
|
||||
fmt.Printf(" %s\n", dimStyle.Render("(open manually)"))
|
||||
}
|
||||
if openCmd != nil {
|
||||
openCmd.Run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check threshold
|
||||
if threshold > 0 && totalCov < threshold {
|
||||
fmt.Printf("\n%s Coverage %.1f%% is below threshold %.1f%%\n",
|
||||
errorStyle.Render("FAIL"), totalCov, threshold)
|
||||
return fmt.Errorf("coverage below threshold")
|
||||
}
|
||||
|
||||
if testErr != nil {
|
||||
return testErr
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s\n", successStyle.Render("OK"))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func findTestPackages(root string) ([]string, error) {
|
||||
pkgMap := make(map[string]bool)
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") {
|
||||
dir := filepath.Dir(path)
|
||||
if !strings.HasPrefix(dir, ".") {
|
||||
dir = "./" + dir
|
||||
}
|
||||
pkgMap[dir] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pkgs []string
|
||||
for pkg := range pkgMap {
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
func addGoFmtCommand(parent *clir.Command) {
|
||||
var (
|
||||
fix bool
|
||||
diff bool
|
||||
check bool
|
||||
)
|
||||
|
||||
fmtCmd := parent.NewSubCommand("fmt", "Format Go code")
|
||||
fmtCmd.LongDescription("Format Go code using gofmt or goimports.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core go fmt # Check formatting\n" +
|
||||
" core go fmt --fix # Fix formatting\n" +
|
||||
" core go fmt --diff # Show diff")
|
||||
|
||||
fmtCmd.BoolFlag("fix", "Fix formatting in place", &fix)
|
||||
fmtCmd.BoolFlag("diff", "Show diff of changes", &diff)
|
||||
fmtCmd.BoolFlag("check", "Check only, exit 1 if not formatted", &check)
|
||||
|
||||
fmtCmd.Action(func() error {
|
||||
args := []string{}
|
||||
if fix {
|
||||
args = append(args, "-w")
|
||||
}
|
||||
if diff {
|
||||
args = append(args, "-d")
|
||||
}
|
||||
if !fix && !diff {
|
||||
args = append(args, "-l")
|
||||
}
|
||||
args = append(args, ".")
|
||||
|
||||
// Try goimports first, fall back to gofmt
|
||||
var cmd *exec.Cmd
|
||||
if _, err := exec.LookPath("goimports"); err == nil {
|
||||
cmd = exec.Command("goimports", args...)
|
||||
} else {
|
||||
cmd = exec.Command("gofmt", args...)
|
||||
}
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
})
|
||||
}
|
||||
|
||||
func addGoLintCommand(parent *clir.Command) {
|
||||
var fix bool
|
||||
|
||||
lintCmd := parent.NewSubCommand("lint", "Run golangci-lint")
|
||||
lintCmd.LongDescription("Run golangci-lint on the codebase.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core go lint\n" +
|
||||
" core go lint --fix")
|
||||
|
||||
lintCmd.BoolFlag("fix", "Fix issues automatically", &fix)
|
||||
|
||||
lintCmd.Action(func() error {
|
||||
args := []string{"run"}
|
||||
if fix {
|
||||
args = append(args, "--fix")
|
||||
}
|
||||
|
||||
cmd := exec.Command("golangci-lint", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
})
|
||||
}
|
||||
|
||||
func addGoInstallCommand(parent *clir.Command) {
|
||||
var verbose bool
|
||||
var noCgo bool
|
||||
|
||||
installCmd := parent.NewSubCommand("install", "Install Go binary")
|
||||
installCmd.LongDescription("Install Go binary to $GOPATH/bin.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core go install # Install current module\n" +
|
||||
" core go install ./cmd/core # Install specific path\n" +
|
||||
" core go install --no-cgo # Pure Go (no C dependencies)\n" +
|
||||
" core go install -v # Verbose output")
|
||||
|
||||
installCmd.BoolFlag("v", "Verbose output", &verbose)
|
||||
installCmd.BoolFlag("no-cgo", "Disable CGO (CGO_ENABLED=0)", &noCgo)
|
||||
|
||||
installCmd.Action(func() error {
|
||||
// Get install path from args or default to current dir
|
||||
args := installCmd.OtherArgs()
|
||||
installPath := "./..."
|
||||
if len(args) > 0 {
|
||||
installPath = args[0]
|
||||
}
|
||||
|
||||
// Detect if we're in a module with cmd/ subdirectories or a root main.go
|
||||
if installPath == "./..." {
|
||||
if _, err := os.Stat("core.go"); err == nil {
|
||||
installPath = "."
|
||||
} else if entries, err := os.ReadDir("cmd"); err == nil && len(entries) > 0 {
|
||||
installPath = "./cmd/..."
|
||||
} else if _, err := os.Stat("main.go"); err == nil {
|
||||
installPath = "."
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("%s Installing\n", dimStyle.Render("Install:"))
|
||||
fmt.Printf(" %s %s\n", dimStyle.Render("Path:"), installPath)
|
||||
if noCgo {
|
||||
fmt.Printf(" %s %s\n", dimStyle.Render("CGO:"), "disabled")
|
||||
}
|
||||
|
||||
cmdArgs := []string{"install"}
|
||||
if verbose {
|
||||
cmdArgs = append(cmdArgs, "-v")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, installPath)
|
||||
|
||||
cmd := exec.Command("go", cmdArgs...)
|
||||
if noCgo {
|
||||
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Printf("\n%s\n", errorStyle.Render("FAIL Install failed"))
|
||||
return err
|
||||
}
|
||||
|
||||
// Show where it was installed
|
||||
gopath := os.Getenv("GOPATH")
|
||||
if gopath == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
gopath = filepath.Join(home, "go")
|
||||
}
|
||||
binDir := filepath.Join(gopath, "bin")
|
||||
|
||||
fmt.Printf("\n%s Installed to %s\n", successStyle.Render("OK"), binDir)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addGoModCommand(parent *clir.Command) {
|
||||
modCmd := parent.NewSubCommand("mod", "Module management")
|
||||
modCmd.LongDescription("Go module management commands.\n\n" +
|
||||
"Commands:\n" +
|
||||
" tidy Add missing and remove unused modules\n" +
|
||||
" download Download modules to local cache\n" +
|
||||
" verify Verify dependencies\n" +
|
||||
" graph Print module dependency graph")
|
||||
|
||||
// tidy
|
||||
tidyCmd := modCmd.NewSubCommand("tidy", "Tidy go.mod")
|
||||
tidyCmd.Action(func() error {
|
||||
cmd := exec.Command("go", "mod", "tidy")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
})
|
||||
|
||||
// download
|
||||
downloadCmd := modCmd.NewSubCommand("download", "Download modules")
|
||||
downloadCmd.Action(func() error {
|
||||
cmd := exec.Command("go", "mod", "download")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
})
|
||||
|
||||
// verify
|
||||
verifyCmd := modCmd.NewSubCommand("verify", "Verify dependencies")
|
||||
verifyCmd.Action(func() error {
|
||||
cmd := exec.Command("go", "mod", "verify")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
})
|
||||
|
||||
// graph
|
||||
graphCmd := modCmd.NewSubCommand("graph", "Print dependency graph")
|
||||
graphCmd.Action(func() error {
|
||||
cmd := exec.Command("go", "mod", "graph")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
})
|
||||
}
|
||||
|
||||
func addGoWorkCommand(parent *clir.Command) {
|
||||
workCmd := parent.NewSubCommand("work", "Workspace management")
|
||||
workCmd.LongDescription("Go workspace management commands.\n\n" +
|
||||
"Commands:\n" +
|
||||
" sync Sync go.work with modules\n" +
|
||||
" init Initialize go.work\n" +
|
||||
" use Add module to workspace")
|
||||
|
||||
// sync
|
||||
syncCmd := workCmd.NewSubCommand("sync", "Sync workspace")
|
||||
syncCmd.Action(func() error {
|
||||
cmd := exec.Command("go", "work", "sync")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
})
|
||||
|
||||
// init
|
||||
initCmd := workCmd.NewSubCommand("init", "Initialize workspace")
|
||||
initCmd.Action(func() error {
|
||||
cmd := exec.Command("go", "work", "init")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Auto-add current module if go.mod exists
|
||||
if _, err := os.Stat("go.mod"); err == nil {
|
||||
cmd = exec.Command("go", "work", "use", ".")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// use
|
||||
useCmd := workCmd.NewSubCommand("use", "Add module to workspace")
|
||||
useCmd.Action(func() error {
|
||||
args := useCmd.OtherArgs()
|
||||
if len(args) == 0 {
|
||||
// Auto-detect modules
|
||||
modules := findGoModules(".")
|
||||
if len(modules) == 0 {
|
||||
return fmt.Errorf("no go.mod files found")
|
||||
}
|
||||
for _, mod := range modules {
|
||||
cmd := exec.Command("go", "work", "use", mod)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Added %s\n", mod)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cmdArgs := append([]string{"work", "use"}, args...)
|
||||
cmd := exec.Command("go", cmdArgs...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
})
|
||||
}
|
||||
|
||||
func findGoModules(root string) []string {
|
||||
var modules []string
|
||||
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.Name() == "go.mod" && path != "go.mod" {
|
||||
modules = append(modules, filepath.Dir(path))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return modules
|
||||
}
|
||||
|
|
|
|||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/cache"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
|
|
@ -45,571 +33,3 @@ func AddPkgCommands(parent *clir.Cli) {
|
|||
addPkgUpdateCommand(pkgCmd)
|
||||
addPkgOutdatedCommand(pkgCmd)
|
||||
}
|
||||
|
||||
// addPkgSearchCommand adds the 'pkg search' command.
|
||||
func addPkgSearchCommand(parent *clir.Command) {
|
||||
var org string
|
||||
var pattern string
|
||||
var repoType string
|
||||
var limit int
|
||||
var refresh bool
|
||||
|
||||
searchCmd := parent.NewSubCommand("search", "Search GitHub for packages")
|
||||
searchCmd.LongDescription("Searches GitHub for repositories matching a pattern.\n" +
|
||||
"Uses gh CLI for authenticated search. Results are cached for 1 hour.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core pkg search # List all host-uk repos\n" +
|
||||
" core pkg search --pattern 'core-*' # Search for core-* repos\n" +
|
||||
" core pkg search --org mycompany # Search different org\n" +
|
||||
" core pkg search --refresh # Bypass cache")
|
||||
|
||||
searchCmd.StringFlag("org", "GitHub organization (default: host-uk)", &org)
|
||||
searchCmd.StringFlag("pattern", "Repo name pattern (* for wildcard)", &pattern)
|
||||
searchCmd.StringFlag("type", "Filter by type in name (mod, services, plug, website)", &repoType)
|
||||
searchCmd.IntFlag("limit", "Max results (default 50)", &limit)
|
||||
searchCmd.BoolFlag("refresh", "Bypass cache and fetch fresh data", &refresh)
|
||||
|
||||
searchCmd.Action(func() error {
|
||||
if org == "" {
|
||||
org = "host-uk"
|
||||
}
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
return runPkgSearch(org, pattern, repoType, limit, refresh)
|
||||
})
|
||||
}
|
||||
|
||||
type ghRepo struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Visibility string `json:"visibility"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error {
|
||||
// Initialize cache in workspace .core/ directory
|
||||
var cacheDir string
|
||||
if regPath, err := repos.FindRegistry(); err == nil {
|
||||
cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache")
|
||||
}
|
||||
|
||||
c, err := cache.New(cacheDir, 0)
|
||||
if err != nil {
|
||||
c = nil
|
||||
}
|
||||
|
||||
cacheKey := cache.GitHubReposKey(org)
|
||||
var ghRepos []ghRepo
|
||||
var fromCache bool
|
||||
|
||||
// Try cache first (unless refresh requested)
|
||||
if c != nil && !refresh {
|
||||
if found, err := c.Get(cacheKey, &ghRepos); found && err == nil {
|
||||
fromCache = true
|
||||
age := c.Age(cacheKey)
|
||||
fmt.Printf("%s %s %s\n", dimStyle.Render("Cache:"), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second))))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from GitHub if not cached
|
||||
if !fromCache {
|
||||
if !ghAuthenticated() {
|
||||
return fmt.Errorf("gh CLI not authenticated. Run: gh auth login")
|
||||
}
|
||||
|
||||
if os.Getenv("GH_TOKEN") != "" {
|
||||
fmt.Printf("%s GH_TOKEN env var is set - this may cause auth issues\n", dimStyle.Render("Note:"))
|
||||
fmt.Printf("%s Unset it with: unset GH_TOKEN\n\n", dimStyle.Render(""))
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s... ", dimStyle.Render("Fetching:"), org)
|
||||
|
||||
cmd := exec.Command("gh", "repo", "list", org,
|
||||
"--json", "name,description,visibility,updatedAt,primaryLanguage",
|
||||
"--limit", fmt.Sprintf("%d", limit))
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
errStr := strings.TrimSpace(string(output))
|
||||
if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") {
|
||||
return fmt.Errorf("authentication failed - try: unset GH_TOKEN && gh auth login")
|
||||
}
|
||||
return fmt.Errorf("search failed: %s", errStr)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(output, &ghRepos); err != nil {
|
||||
return fmt.Errorf("failed to parse results: %w", err)
|
||||
}
|
||||
|
||||
if c != nil {
|
||||
_ = c.Set(cacheKey, ghRepos)
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n", successStyle.Render("✓"))
|
||||
}
|
||||
|
||||
// Filter by glob pattern and type
|
||||
var filtered []ghRepo
|
||||
for _, r := range ghRepos {
|
||||
if !matchGlob(pattern, r.Name) {
|
||||
continue
|
||||
}
|
||||
if repoType != "" && !strings.Contains(r.Name, repoType) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
fmt.Println("No repositories found matching pattern.")
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].Name < filtered[j].Name
|
||||
})
|
||||
|
||||
fmt.Printf("Found %d repositories:\n\n", len(filtered))
|
||||
|
||||
for _, r := range filtered {
|
||||
visibility := ""
|
||||
if r.Visibility == "private" {
|
||||
visibility = dimStyle.Render(" [private]")
|
||||
}
|
||||
|
||||
desc := r.Description
|
||||
if len(desc) > 50 {
|
||||
desc = desc[:47] + "..."
|
||||
}
|
||||
if desc == "" {
|
||||
desc = dimStyle.Render("(no description)")
|
||||
}
|
||||
|
||||
fmt.Printf(" %s%s\n", repoNameStyle.Render(r.Name), visibility)
|
||||
fmt.Printf(" %s\n", desc)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("Install with: %s\n", dimStyle.Render(fmt.Sprintf("core pkg install %s/<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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// Style aliases
|
||||
// Style aliases from shared package
|
||||
var (
|
||||
repoNameStyle = shared.RepoNameStyle
|
||||
successStyle = shared.SuccessStyle
|
||||
|
|
@ -60,650 +52,3 @@ func AddSetupCommand(parent *clir.Cli) {
|
|||
return runSetupOrchestrator(registryPath, only, dryRun, all, name, build)
|
||||
})
|
||||
}
|
||||
|
||||
// runSetupOrchestrator decides between registry mode and bootstrap mode.
|
||||
func runSetupOrchestrator(registryPath, only string, dryRun, all bool, projectName string, runBuild bool) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Try to find an existing registry
|
||||
var foundRegistry string
|
||||
var err error
|
||||
|
||||
if registryPath != "" {
|
||||
foundRegistry = registryPath
|
||||
} else {
|
||||
foundRegistry, err = repos.FindRegistry()
|
||||
}
|
||||
|
||||
// If registry exists, use registry mode
|
||||
if err == nil && foundRegistry != "" {
|
||||
return runRegistrySetup(ctx, foundRegistry, only, dryRun, all, runBuild)
|
||||
}
|
||||
|
||||
// No registry found - enter bootstrap mode
|
||||
return runBootstrap(ctx, only, dryRun, all, projectName, runBuild)
|
||||
}
|
||||
|
||||
// runBootstrap handles the case where no repos.yaml exists.
|
||||
func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectName string, runBuild bool) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Bootstrap mode (no repos.yaml found)\n", dimStyle.Render(">>"))
|
||||
|
||||
var targetDir string
|
||||
|
||||
// Check if current directory is empty
|
||||
empty, err := isDirEmpty(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check directory: %w", err)
|
||||
}
|
||||
|
||||
if empty {
|
||||
// Clone into current directory
|
||||
targetDir = cwd
|
||||
fmt.Printf("%s Cloning into current directory\n", dimStyle.Render(">>"))
|
||||
} else {
|
||||
// Directory has content - check if it's a git repo root
|
||||
isRepo := isGitRepoRoot(cwd)
|
||||
|
||||
if isRepo && isTerminal() && !all {
|
||||
// Offer choice: setup working directory or create package
|
||||
choice, err := promptSetupChoice()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get choice: %w", err)
|
||||
}
|
||||
|
||||
if choice == "setup" {
|
||||
// Setup this working directory with .core/ config
|
||||
return runRepoSetup(cwd, dryRun)
|
||||
}
|
||||
// Otherwise continue to "create package" flow
|
||||
}
|
||||
|
||||
// Create package flow - need a project name
|
||||
if projectName == "" {
|
||||
if !isTerminal() || all {
|
||||
projectName = defaultOrg
|
||||
} else {
|
||||
projectName, err = promptProjectName(defaultOrg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get project name: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetDir = filepath.Join(cwd, projectName)
|
||||
fmt.Printf("%s Creating project directory: %s\n", dimStyle.Render(">>"), projectName)
|
||||
|
||||
if !dryRun {
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone core-devops first
|
||||
devopsPath := filepath.Join(targetDir, devopsRepo)
|
||||
if _, err := os.Stat(filepath.Join(devopsPath, ".git")); os.IsNotExist(err) {
|
||||
fmt.Printf("%s Cloning %s...\n", dimStyle.Render(">>"), devopsRepo)
|
||||
|
||||
if !dryRun {
|
||||
if err := gitClone(ctx, defaultOrg, devopsRepo, devopsPath); err != nil {
|
||||
return fmt.Errorf("failed to clone %s: %w", devopsRepo, err)
|
||||
}
|
||||
fmt.Printf("%s %s cloned\n", successStyle.Render(">>"), devopsRepo)
|
||||
} else {
|
||||
fmt.Printf(" Would clone %s/%s to %s\n", defaultOrg, devopsRepo, devopsPath)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("%s %s already exists\n", dimStyle.Render(">>"), devopsRepo)
|
||||
}
|
||||
|
||||
// Load the repos.yaml from core-devops
|
||||
registryPath := filepath.Join(devopsPath, devopsReposYaml)
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("\n%s Would load registry from %s and present package wizard\n", dimStyle.Render(">>"), registryPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
reg, err := repos.LoadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load registry from %s: %w", devopsRepo, err)
|
||||
}
|
||||
|
||||
// Override base path to target directory
|
||||
reg.BasePath = targetDir
|
||||
|
||||
// Now run the regular setup with the loaded registry
|
||||
return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild)
|
||||
}
|
||||
|
||||
// runRegistrySetup loads a registry from path and runs setup.
|
||||
func runRegistrySetup(ctx context.Context, registryPath, only string, dryRun, all, runBuild bool) error {
|
||||
reg, err := repos.LoadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load registry: %w", err)
|
||||
}
|
||||
|
||||
return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild)
|
||||
}
|
||||
|
||||
// runRegistrySetupWithReg runs setup with an already-loaded registry.
|
||||
func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryPath, only string, dryRun, all, runBuild bool) error {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Registry:"), registryPath)
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Org:"), reg.Org)
|
||||
|
||||
// Determine base path for cloning
|
||||
basePath := reg.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "./packages"
|
||||
}
|
||||
// Resolve relative to registry location
|
||||
if !filepath.IsAbs(basePath) {
|
||||
basePath = filepath.Join(filepath.Dir(registryPath), basePath)
|
||||
}
|
||||
// Expand ~
|
||||
if strings.HasPrefix(basePath, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
basePath = filepath.Join(home, basePath[2:])
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Target:"), basePath)
|
||||
|
||||
// Parse type filter
|
||||
var typeFilter []string
|
||||
if only != "" {
|
||||
for _, t := range strings.Split(only, ",") {
|
||||
typeFilter = append(typeFilter, strings.TrimSpace(t))
|
||||
}
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Filter:"), only)
|
||||
}
|
||||
|
||||
// Ensure base path exists
|
||||
if !dryRun {
|
||||
if err := os.MkdirAll(basePath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create packages directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all available repos
|
||||
allRepos := reg.List()
|
||||
|
||||
// Determine which repos to clone
|
||||
var toClone []*repos.Repo
|
||||
var skipped, exists int
|
||||
|
||||
// Use wizard in interactive mode, unless --all specified
|
||||
useWizard := isTerminal() && !all && !dryRun
|
||||
|
||||
if useWizard {
|
||||
selected, err := runPackageWizard(reg, typeFilter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wizard error: %w", err)
|
||||
}
|
||||
|
||||
// Build set of selected repos
|
||||
selectedSet := make(map[string]bool)
|
||||
for _, name := range selected {
|
||||
selectedSet[name] = true
|
||||
}
|
||||
|
||||
// Filter repos based on selection
|
||||
for _, repo := range allRepos {
|
||||
if !selectedSet[repo.Name] {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
repoPath := filepath.Join(basePath, repo.Name)
|
||||
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
|
||||
exists++
|
||||
continue
|
||||
}
|
||||
|
||||
toClone = append(toClone, repo)
|
||||
}
|
||||
} else {
|
||||
// Non-interactive: filter by type
|
||||
typeFilterSet := make(map[string]bool)
|
||||
for _, t := range typeFilter {
|
||||
typeFilterSet[t] = true
|
||||
}
|
||||
|
||||
for _, repo := range allRepos {
|
||||
// Skip if type filter doesn't match (when filter is specified)
|
||||
if len(typeFilterSet) > 0 && !typeFilterSet[repo.Type] {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if clone: false
|
||||
if repo.Clone != nil && !*repo.Clone {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
repoPath := filepath.Join(basePath, repo.Name)
|
||||
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
|
||||
exists++
|
||||
continue
|
||||
}
|
||||
|
||||
toClone = append(toClone, repo)
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
fmt.Printf("%d to clone, %d exist, %d skipped\n", len(toClone), exists, skipped)
|
||||
|
||||
if len(toClone) == 0 {
|
||||
fmt.Println("\nNothing to clone.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("\nWould clone:")
|
||||
for _, repo := range toClone {
|
||||
fmt.Printf(" %s (%s)\n", repoNameStyle.Render(repo.Name), repo.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm in interactive mode
|
||||
if useWizard {
|
||||
confirmed, err := confirmClone(len(toClone), basePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !confirmed {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Clone repos
|
||||
fmt.Println()
|
||||
var succeeded, failed int
|
||||
|
||||
for _, repo := range toClone {
|
||||
fmt.Printf(" %s %s... ", dimStyle.Render("Cloning"), repo.Name)
|
||||
|
||||
repoPath := filepath.Join(basePath, repo.Name)
|
||||
|
||||
err := gitClone(ctx, reg.Org, repo.Name, repoPath)
|
||||
if err != nil {
|
||||
fmt.Printf("%s\n", errorStyle.Render("x "+err.Error()))
|
||||
failed++
|
||||
} else {
|
||||
fmt.Printf("%s\n", successStyle.Render("done"))
|
||||
succeeded++
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %d cloned", successStyle.Render("Done:"), succeeded)
|
||||
if failed > 0 {
|
||||
fmt.Printf(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed)))
|
||||
}
|
||||
if exists > 0 {
|
||||
fmt.Printf(", %d already exist", exists)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Run build if requested
|
||||
if runBuild && succeeded > 0 {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Running build...\n", dimStyle.Render(">>"))
|
||||
buildCmd := exec.Command("core", "build")
|
||||
buildCmd.Dir = basePath
|
||||
buildCmd.Stdout = os.Stdout
|
||||
buildCmd.Stderr = os.Stderr
|
||||
if err := buildCmd.Run(); err != nil {
|
||||
return fmt.Errorf("build failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isGitRepoRoot returns true if the directory is a git repository root.
|
||||
func isGitRepoRoot(path string) bool {
|
||||
_, err := os.Stat(filepath.Join(path, ".git"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// runRepoSetup sets up the current repository with .core/ configuration.
|
||||
func runRepoSetup(repoPath string, dryRun bool) error {
|
||||
fmt.Printf("%s Setting up repository: %s\n", dimStyle.Render(">>"), repoPath)
|
||||
|
||||
// Detect project type
|
||||
projectType := detectProjectType(repoPath)
|
||||
fmt.Printf("%s Detected project type: %s\n", dimStyle.Render(">>"), projectType)
|
||||
|
||||
// Create .core directory
|
||||
coreDir := filepath.Join(repoPath, ".core")
|
||||
if !dryRun {
|
||||
if err := os.MkdirAll(coreDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create .core directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate configs based on project type
|
||||
name := filepath.Base(repoPath)
|
||||
configs := map[string]string{
|
||||
"build.yaml": generateBuildConfig(repoPath, projectType),
|
||||
"release.yaml": generateReleaseConfig(name, projectType),
|
||||
"test.yaml": generateTestConfig(projectType),
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("\n%s Would create:\n", dimStyle.Render(">>"))
|
||||
for filename, content := range configs {
|
||||
fmt.Printf("\n %s:\n", filepath.Join(coreDir, filename))
|
||||
// Indent content for display
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for filename, content := range configs {
|
||||
configPath := filepath.Join(coreDir, filename)
|
||||
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", filename, err)
|
||||
}
|
||||
fmt.Printf("%s Created %s\n", successStyle.Render(">>"), configPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectProjectType identifies the project type from files present.
|
||||
func detectProjectType(path string) string {
|
||||
// Check in priority order
|
||||
if _, err := os.Stat(filepath.Join(path, "wails.json")); err == nil {
|
||||
return "wails"
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
|
||||
return "go"
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "composer.json")); err == nil {
|
||||
return "php"
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "package.json")); err == nil {
|
||||
return "node"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// generateBuildConfig creates a build.yaml configuration based on project type.
|
||||
func generateBuildConfig(path, projectType string) string {
|
||||
name := filepath.Base(path)
|
||||
|
||||
switch projectType {
|
||||
case "go", "wails":
|
||||
return fmt.Sprintf(`version: 1
|
||||
project:
|
||||
name: %s
|
||||
description: Go application
|
||||
main: ./cmd/%s
|
||||
binary: %s
|
||||
build:
|
||||
cgo: false
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s
|
||||
- -w
|
||||
targets:
|
||||
- os: linux
|
||||
arch: amd64
|
||||
- os: linux
|
||||
arch: arm64
|
||||
- os: darwin
|
||||
arch: amd64
|
||||
- os: darwin
|
||||
arch: arm64
|
||||
- os: windows
|
||||
arch: amd64
|
||||
`, name, name, name)
|
||||
|
||||
case "php":
|
||||
return fmt.Sprintf(`version: 1
|
||||
project:
|
||||
name: %s
|
||||
description: PHP application
|
||||
type: php
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
image: %s
|
||||
`, name, name)
|
||||
|
||||
case "node":
|
||||
return fmt.Sprintf(`version: 1
|
||||
project:
|
||||
name: %s
|
||||
description: Node.js application
|
||||
type: node
|
||||
build:
|
||||
script: npm run build
|
||||
output: dist
|
||||
`, name)
|
||||
|
||||
default:
|
||||
return fmt.Sprintf(`version: 1
|
||||
project:
|
||||
name: %s
|
||||
description: Application
|
||||
`, name)
|
||||
}
|
||||
}
|
||||
|
||||
// generateReleaseConfig creates a release.yaml configuration.
|
||||
func generateReleaseConfig(name, projectType string) string {
|
||||
// Try to detect GitHub repo from git remote
|
||||
repo := detectGitHubRepo()
|
||||
if repo == "" {
|
||||
repo = "owner/" + name
|
||||
}
|
||||
|
||||
base := fmt.Sprintf(`version: 1
|
||||
project:
|
||||
name: %s
|
||||
repository: %s
|
||||
`, name, repo)
|
||||
|
||||
switch projectType {
|
||||
case "go", "wails":
|
||||
return base + `
|
||||
changelog:
|
||||
include:
|
||||
- feat
|
||||
- fix
|
||||
- perf
|
||||
- refactor
|
||||
exclude:
|
||||
- chore
|
||||
- docs
|
||||
- style
|
||||
- test
|
||||
|
||||
publishers:
|
||||
- type: github
|
||||
draft: false
|
||||
prerelease: false
|
||||
`
|
||||
case "php":
|
||||
return base + `
|
||||
changelog:
|
||||
include:
|
||||
- feat
|
||||
- fix
|
||||
- perf
|
||||
|
||||
publishers:
|
||||
- type: github
|
||||
draft: false
|
||||
`
|
||||
default:
|
||||
return base + `
|
||||
changelog:
|
||||
include:
|
||||
- feat
|
||||
- fix
|
||||
|
||||
publishers:
|
||||
- type: github
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
// generateTestConfig creates a test.yaml configuration.
|
||||
func generateTestConfig(projectType string) string {
|
||||
switch projectType {
|
||||
case "go", "wails":
|
||||
return `version: 1
|
||||
|
||||
commands:
|
||||
- name: unit
|
||||
run: go test ./...
|
||||
- name: coverage
|
||||
run: go test -coverprofile=coverage.out ./...
|
||||
- name: race
|
||||
run: go test -race ./...
|
||||
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
`
|
||||
case "php":
|
||||
return `version: 1
|
||||
|
||||
commands:
|
||||
- name: unit
|
||||
run: vendor/bin/pest --parallel
|
||||
- name: types
|
||||
run: vendor/bin/phpstan analyse
|
||||
- name: lint
|
||||
run: vendor/bin/pint --test
|
||||
|
||||
env:
|
||||
APP_ENV: testing
|
||||
DB_CONNECTION: sqlite
|
||||
`
|
||||
case "node":
|
||||
return `version: 1
|
||||
|
||||
commands:
|
||||
- name: unit
|
||||
run: npm test
|
||||
- name: lint
|
||||
run: npm run lint
|
||||
- name: typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
env:
|
||||
NODE_ENV: test
|
||||
`
|
||||
default:
|
||||
return `version: 1
|
||||
|
||||
commands:
|
||||
- name: test
|
||||
run: echo "No tests configured"
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
// detectGitHubRepo tries to extract owner/repo from git remote.
|
||||
func detectGitHubRepo() string {
|
||||
cmd := exec.Command("git", "remote", "get-url", "origin")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(string(output))
|
||||
|
||||
// Handle SSH format: git@github.com:owner/repo.git
|
||||
if strings.HasPrefix(url, "git@github.com:") {
|
||||
repo := strings.TrimPrefix(url, "git@github.com:")
|
||||
repo = strings.TrimSuffix(repo, ".git")
|
||||
return repo
|
||||
}
|
||||
|
||||
// Handle HTTPS format: https://github.com/owner/repo.git
|
||||
if strings.Contains(url, "github.com/") {
|
||||
parts := strings.Split(url, "github.com/")
|
||||
if len(parts) == 2 {
|
||||
repo := strings.TrimSuffix(parts[1], ".git")
|
||||
return repo
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isDirEmpty returns true if the directory is empty or contains only hidden files.
|
||||
func isDirEmpty(path string) (bool, error) {
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
// Ignore common hidden/metadata files
|
||||
if name == ".DS_Store" || name == ".git" || name == ".gitignore" {
|
||||
continue
|
||||
}
|
||||
// Any other non-hidden file means directory is not empty
|
||||
if !strings.HasPrefix(name, ".") {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func gitClone(ctx context.Context, org, repo, path string) error {
|
||||
// Try gh clone first with HTTPS (works without SSH keys)
|
||||
if ghAuthenticated() {
|
||||
// Use HTTPS URL directly to bypass git_protocol config
|
||||
httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo)
|
||||
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
errStr := strings.TrimSpace(string(output))
|
||||
// Only fall through to SSH if it's an auth error
|
||||
if !strings.Contains(errStr, "Permission denied") &&
|
||||
!strings.Contains(errStr, "could not read") {
|
||||
return fmt.Errorf("%s", errStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to git clone via SSH
|
||||
url := fmt.Sprintf("git@github.com:%s/%s.git", org, repo)
|
||||
cmd := exec.CommandContext(ctx, "git", "clone", url, path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ghAuthenticated() bool {
|
||||
cmd := exec.Command("gh", "auth", "status")
|
||||
output, _ := cmd.CombinedOutput()
|
||||
return strings.Contains(string(output), "Logged in")
|
||||
}
|
||||
|
|
|
|||
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
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// Test command styles
|
||||
// Style aliases from shared
|
||||
var (
|
||||
testHeaderStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
||||
|
||||
testPassStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#22c55e")). // green-500
|
||||
Bold(true)
|
||||
|
||||
testFailStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#ef4444")). // red-500
|
||||
Bold(true)
|
||||
|
||||
testSkipStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#f59e0b")) // amber-500
|
||||
|
||||
testDimStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
||||
testHeaderStyle = shared.RepoNameStyle
|
||||
testPassStyle = shared.SuccessStyle
|
||||
testFailStyle = shared.ErrorStyle
|
||||
testSkipStyle = shared.WarningStyle
|
||||
testDimStyle = shared.DimStyle
|
||||
)
|
||||
|
||||
// Coverage-specific styles
|
||||
var (
|
||||
testCovHighStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||
|
||||
|
|
@ -85,326 +65,3 @@ func AddTestCommand(parent *clir.Cli) {
|
|||
return runTest(verbose, coverage, short, pkg, run, race, json)
|
||||
})
|
||||
}
|
||||
|
||||
type packageCoverage struct {
|
||||
name string
|
||||
coverage float64
|
||||
hasCov bool
|
||||
}
|
||||
|
||||
func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error {
|
||||
// Detect if we're in a Go project
|
||||
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
|
||||
return fmt.Errorf("no go.mod found - run from a Go project directory")
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
args := []string{"test"}
|
||||
|
||||
// Default to ./... if no package specified
|
||||
if pkg == "" {
|
||||
pkg = "./..."
|
||||
}
|
||||
|
||||
// Add flags
|
||||
if verbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
if short {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
if run != "" {
|
||||
args = append(args, "-run", run)
|
||||
}
|
||||
if race {
|
||||
args = append(args, "-race")
|
||||
}
|
||||
|
||||
// Always add coverage
|
||||
args = append(args, "-cover")
|
||||
|
||||
// Add package pattern
|
||||
args = append(args, pkg)
|
||||
|
||||
// Create command
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Dir, _ = os.Getwd()
|
||||
|
||||
// Set environment to suppress macOS linker warnings
|
||||
cmd.Env = append(os.Environ(), getMacOSDeploymentTarget())
|
||||
|
||||
if !jsonOutput {
|
||||
fmt.Printf("%s Running tests\n", testHeaderStyle.Render("Test:"))
|
||||
fmt.Printf(" Package: %s\n", testDimStyle.Render(pkg))
|
||||
if run != "" {
|
||||
fmt.Printf(" Filter: %s\n", testDimStyle.Render(run))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Capture output for parsing
|
||||
var stdout, stderr strings.Builder
|
||||
|
||||
if verbose && !jsonOutput {
|
||||
// Stream output in verbose mode, but also capture for parsing
|
||||
cmd.Stdout = io.MultiWriter(os.Stdout, &stdout)
|
||||
cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
|
||||
} else {
|
||||
// Capture output for parsing
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
}
|
||||
|
||||
err := cmd.Run()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
}
|
||||
}
|
||||
|
||||
// Combine stdout and stderr for parsing, filtering linker warnings
|
||||
combined := filterLinkerWarnings(stdout.String() + "\n" + stderr.String())
|
||||
|
||||
// Parse results
|
||||
results := parseTestOutput(combined)
|
||||
|
||||
if jsonOutput {
|
||||
// JSON output for CI/agents
|
||||
printJSONResults(results, exitCode)
|
||||
if exitCode != 0 {
|
||||
return fmt.Errorf("tests failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print summary
|
||||
if !verbose {
|
||||
printTestSummary(results, coverage)
|
||||
} else if coverage {
|
||||
// In verbose mode, still show coverage summary at end
|
||||
fmt.Println()
|
||||
printCoverageSummary(results)
|
||||
}
|
||||
|
||||
if exitCode != 0 {
|
||||
fmt.Printf("\n%s Tests failed\n", testFailStyle.Render("FAIL"))
|
||||
return fmt.Errorf("tests failed")
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s All tests passed\n", testPassStyle.Render("PASS"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMacOSDeploymentTarget() string {
|
||||
if runtime.GOOS == "darwin" {
|
||||
// Use deployment target matching current macOS to suppress linker warnings
|
||||
return "MACOSX_DEPLOYMENT_TARGET=26.0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func filterLinkerWarnings(output string) string {
|
||||
// Filter out ld: warning lines that pollute the output
|
||||
var filtered []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Skip linker warnings
|
||||
if strings.HasPrefix(line, "ld: warning:") {
|
||||
continue
|
||||
}
|
||||
// Skip test binary build comments
|
||||
if strings.HasPrefix(line, "# ") && strings.HasSuffix(line, ".test") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
return strings.Join(filtered, "\n")
|
||||
}
|
||||
|
||||
type testResults struct {
|
||||
packages []packageCoverage
|
||||
passed int
|
||||
failed int
|
||||
skipped int
|
||||
totalCov float64
|
||||
covCount int
|
||||
failedPkgs []string
|
||||
}
|
||||
|
||||
func parseTestOutput(output string) testResults {
|
||||
results := testResults{}
|
||||
|
||||
// Regex patterns - handle both timed and cached test results
|
||||
// Example: ok github.com/host-uk/core/pkg/crypt 0.015s coverage: 91.2% of statements
|
||||
// Example: ok github.com/host-uk/core/pkg/crypt (cached) coverage: 91.2% of statements
|
||||
okPattern := regexp.MustCompile(`^ok\s+(\S+)\s+(?:[\d.]+s|\(cached\))(?:\s+coverage:\s+([\d.]+)%)?`)
|
||||
failPattern := regexp.MustCompile(`^FAIL\s+(\S+)`)
|
||||
skipPattern := regexp.MustCompile(`^\?\s+(\S+)\s+\[no test files\]`)
|
||||
coverPattern := regexp.MustCompile(`coverage:\s+([\d.]+)%`)
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if matches := okPattern.FindStringSubmatch(line); matches != nil {
|
||||
pkg := packageCoverage{name: matches[1]}
|
||||
if len(matches) > 2 && matches[2] != "" {
|
||||
cov, _ := strconv.ParseFloat(matches[2], 64)
|
||||
pkg.coverage = cov
|
||||
pkg.hasCov = true
|
||||
results.totalCov += cov
|
||||
results.covCount++
|
||||
}
|
||||
results.packages = append(results.packages, pkg)
|
||||
results.passed++
|
||||
} else if matches := failPattern.FindStringSubmatch(line); matches != nil {
|
||||
results.failed++
|
||||
results.failedPkgs = append(results.failedPkgs, matches[1])
|
||||
} else if matches := skipPattern.FindStringSubmatch(line); matches != nil {
|
||||
results.skipped++
|
||||
} else if matches := coverPattern.FindStringSubmatch(line); matches != nil {
|
||||
// Catch any additional coverage lines
|
||||
cov, _ := strconv.ParseFloat(matches[1], 64)
|
||||
if cov > 0 {
|
||||
// Find the last package without coverage and update it
|
||||
for i := len(results.packages) - 1; i >= 0; i-- {
|
||||
if !results.packages[i].hasCov {
|
||||
results.packages[i].coverage = cov
|
||||
results.packages[i].hasCov = true
|
||||
results.totalCov += cov
|
||||
results.covCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func printTestSummary(results testResults, showCoverage bool) {
|
||||
// Print pass/fail summary
|
||||
total := results.passed + results.failed
|
||||
if total > 0 {
|
||||
fmt.Printf(" %s %d passed", testPassStyle.Render("✓"), results.passed)
|
||||
if results.failed > 0 {
|
||||
fmt.Printf(" %s %d failed", testFailStyle.Render("✗"), results.failed)
|
||||
}
|
||||
if results.skipped > 0 {
|
||||
fmt.Printf(" %s %d skipped", testSkipStyle.Render("○"), results.skipped)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Print failed packages
|
||||
if len(results.failedPkgs) > 0 {
|
||||
fmt.Printf("\n Failed packages:\n")
|
||||
for _, pkg := range results.failedPkgs {
|
||||
fmt.Printf(" %s %s\n", testFailStyle.Render("✗"), pkg)
|
||||
}
|
||||
}
|
||||
|
||||
// Print coverage
|
||||
if showCoverage {
|
||||
printCoverageSummary(results)
|
||||
} else if results.covCount > 0 {
|
||||
avgCov := results.totalCov / float64(results.covCount)
|
||||
fmt.Printf("\n Coverage: %s\n", formatCoverage(avgCov))
|
||||
}
|
||||
}
|
||||
|
||||
func printCoverageSummary(results testResults) {
|
||||
if len(results.packages) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n %s\n", testHeaderStyle.Render("Coverage by package:"))
|
||||
|
||||
// Sort packages by name
|
||||
sort.Slice(results.packages, func(i, j int) bool {
|
||||
return results.packages[i].name < results.packages[j].name
|
||||
})
|
||||
|
||||
// Find max package name length for alignment
|
||||
maxLen := 0
|
||||
for _, pkg := range results.packages {
|
||||
name := shortenPackageName(pkg.name)
|
||||
if len(name) > maxLen {
|
||||
maxLen = len(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Print each package
|
||||
for _, pkg := range results.packages {
|
||||
if !pkg.hasCov {
|
||||
continue
|
||||
}
|
||||
name := shortenPackageName(pkg.name)
|
||||
padding := strings.Repeat(" ", maxLen-len(name)+2)
|
||||
fmt.Printf(" %s%s%s\n", name, padding, formatCoverage(pkg.coverage))
|
||||
}
|
||||
|
||||
// Print average
|
||||
if results.covCount > 0 {
|
||||
avgCov := results.totalCov / float64(results.covCount)
|
||||
padding := strings.Repeat(" ", maxLen-7+2)
|
||||
fmt.Printf("\n %s%s%s\n", testHeaderStyle.Render("Average"), padding, formatCoverage(avgCov))
|
||||
}
|
||||
}
|
||||
|
||||
func formatCoverage(cov float64) string {
|
||||
var style lipgloss.Style
|
||||
switch {
|
||||
case cov >= 80:
|
||||
style = testCovHighStyle
|
||||
case cov >= 50:
|
||||
style = testCovMedStyle
|
||||
default:
|
||||
style = testCovLowStyle
|
||||
}
|
||||
return style.Render(fmt.Sprintf("%.1f%%", cov))
|
||||
}
|
||||
|
||||
func shortenPackageName(name string) string {
|
||||
// Remove common prefixes
|
||||
prefixes := []string{
|
||||
"github.com/host-uk/core/",
|
||||
"github.com/host-uk/",
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return strings.TrimPrefix(name, prefix)
|
||||
}
|
||||
}
|
||||
return filepath.Base(name)
|
||||
}
|
||||
|
||||
func printJSONResults(results testResults, exitCode int) {
|
||||
// Simple JSON output for agents
|
||||
fmt.Printf("{\n")
|
||||
fmt.Printf(" \"passed\": %d,\n", results.passed)
|
||||
fmt.Printf(" \"failed\": %d,\n", results.failed)
|
||||
fmt.Printf(" \"skipped\": %d,\n", results.skipped)
|
||||
if results.covCount > 0 {
|
||||
avgCov := results.totalCov / float64(results.covCount)
|
||||
fmt.Printf(" \"coverage\": %.1f,\n", avgCov)
|
||||
}
|
||||
fmt.Printf(" \"exit_code\": %d,\n", exitCode)
|
||||
if len(results.failedPkgs) > 0 {
|
||||
fmt.Printf(" \"failed_packages\": [\n")
|
||||
for i, pkg := range results.failedPkgs {
|
||||
comma := ","
|
||||
if i == len(results.failedPkgs)-1 {
|
||||
comma = ""
|
||||
}
|
||||
fmt.Printf(" %q%s\n", pkg, comma)
|
||||
}
|
||||
fmt.Printf(" ]\n")
|
||||
} else {
|
||||
fmt.Printf(" \"failed_packages\": []\n")
|
||||
}
|
||||
fmt.Printf("}\n")
|
||||
}
|
||||
|
|
|
|||
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.
|
||||
func AddCommands(app *clir.Cli) {
|
||||
AddContainerCommands(app)
|
||||
AddVMCommands(app)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Package vm provides LinuxKit VM management commands.
|
||||
package vm
|
||||
|
||||
import (
|
||||
|
|
@ -14,28 +13,6 @@ import (
|
|||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// AddContainerCommands adds container-related commands under 'vm' to the CLI.
|
||||
func AddContainerCommands(parent *clir.Cli) {
|
||||
vmCmd := parent.NewSubCommand("vm", "LinuxKit VM management")
|
||||
vmCmd.LongDescription("Manage LinuxKit virtual machines.\n\n" +
|
||||
"LinuxKit VMs are lightweight, immutable VMs built from YAML templates.\n" +
|
||||
"They run using qemu or hyperkit depending on your system.\n\n" +
|
||||
"Commands:\n" +
|
||||
" run Run a VM from image or template\n" +
|
||||
" ps List running VMs\n" +
|
||||
" stop Stop a running VM\n" +
|
||||
" logs View VM logs\n" +
|
||||
" exec Execute command in VM\n" +
|
||||
" templates Manage LinuxKit templates")
|
||||
|
||||
addVMRunCommand(vmCmd)
|
||||
addVMPsCommand(vmCmd)
|
||||
addVMStopCommand(vmCmd)
|
||||
addVMLogsCommand(vmCmd)
|
||||
addVMExecCommand(vmCmd)
|
||||
addVMTemplatesCommand(vmCmd)
|
||||
}
|
||||
|
||||
// addVMRunCommand adds the 'run' command under vm.
|
||||
func addVMRunCommand(parent *clir.Command) {
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -9,25 +9,10 @@ import (
|
|||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/cmd/shared"
|
||||
"github.com/host-uk/core/pkg/container"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
// Style aliases
|
||||
var (
|
||||
repoNameStyle = shared.RepoNameStyle
|
||||
successStyle = shared.SuccessStyle
|
||||
errorStyle = shared.ErrorStyle
|
||||
dimStyle = shared.DimStyle
|
||||
)
|
||||
|
||||
var (
|
||||
varStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b"))
|
||||
defaultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Italic(true)
|
||||
)
|
||||
|
||||
// addVMTemplatesCommand adds the 'templates' command under vm.
|
||||
func addVMTemplatesCommand(parent *clir.Command) {
|
||||
templatesCmd := parent.NewSubCommand("templates", "Manage LinuxKit templates")
|
||||
|
|
|
|||
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