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:
Snider 2026-01-30 00:22:47 +00:00
parent e4d79ce952
commit cdf74d9f30
56 changed files with 5336 additions and 5233 deletions

View file

@ -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", &notes)
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
View 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
View 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
View 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
View 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", &notes)
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
})
}

View file

@ -2,28 +2,10 @@
package build package build
import ( import (
"context"
"embed" "embed"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
buildpkg "github.com/host-uk/core/pkg/build"
"github.com/host-uk/core/pkg/build/builders"
"github.com/host-uk/core/pkg/build/signing"
"github.com/host-uk/core/pkg/sdk"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
"github.com/leaanthony/debme"
"github.com/leaanthony/gosod"
"golang.org/x/net/html"
) )
// Build command styles // Build command styles
@ -112,7 +94,7 @@ func AddBuildCommand(app *clir.Cli) {
fromPathCmd.StringFlag("path", "The path to the static web application files.", &fromPath) fromPathCmd.StringFlag("path", "The path to the static web application files.", &fromPath)
fromPathCmd.Action(func() error { fromPathCmd.Action(func() error {
if fromPath == "" { if fromPath == "" {
return fmt.Errorf("the --path flag is required") return errPathRequired
} }
return runBuild(fromPath) return runBuild(fromPath)
}) })
@ -123,7 +105,7 @@ func AddBuildCommand(app *clir.Cli) {
pwaCmd.StringFlag("url", "The URL of the PWA to build.", &pwaURL) pwaCmd.StringFlag("url", "The URL of the PWA to build.", &pwaURL)
pwaCmd.Action(func() error { pwaCmd.Action(func() error {
if pwaURL == "" { if pwaURL == "" {
return fmt.Errorf("a URL argument is required") return errURLRequired
} }
return runPwaBuild(pwaURL) return runPwaBuild(pwaURL)
}) })
@ -147,749 +129,3 @@ func AddBuildCommand(app *clir.Cli) {
return runBuildSDK(sdkSpec, sdkLang, sdkVersion, sdkDryRun) return runBuildSDK(sdkSpec, sdkLang, sdkVersion, sdkDryRun)
}) })
} }
// runProjectBuild handles the main `core build` command with auto-detection.
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool) error {
// Get current working directory as project root
projectDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
// Load configuration from .core/build.yaml (or defaults)
buildCfg, err := buildpkg.LoadConfig(projectDir)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Detect project type if not specified
var projectType buildpkg.ProjectType
if buildType != "" {
projectType = buildpkg.ProjectType(buildType)
} else {
projectType, err = buildpkg.PrimaryType(projectDir)
if err != nil {
return fmt.Errorf("failed to detect project type: %w", err)
}
if projectType == "" {
return fmt.Errorf("no supported project type detected in %s\n"+
"Supported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)", projectDir)
}
}
// Determine targets
var buildTargets []buildpkg.Target
if targetsFlag != "" {
// Parse from command line
buildTargets, err = parseTargets(targetsFlag)
if err != nil {
return err
}
} else if len(buildCfg.Targets) > 0 {
// Use config targets
buildTargets = buildCfg.ToTargets()
} else {
// Fall back to current OS/arch
buildTargets = []buildpkg.Target{
{OS: runtime.GOOS, Arch: runtime.GOARCH},
}
}
// Determine output directory
if outputDir == "" {
outputDir = "dist"
}
// Determine binary name
binaryName := buildCfg.Project.Binary
if binaryName == "" {
binaryName = buildCfg.Project.Name
}
if binaryName == "" {
binaryName = filepath.Base(projectDir)
}
// Print build info (unless CI mode)
if !ciMode {
fmt.Printf("%s Building project\n", buildHeaderStyle.Render("Build:"))
fmt.Printf(" Type: %s\n", buildTargetStyle.Render(string(projectType)))
fmt.Printf(" Output: %s\n", buildTargetStyle.Render(outputDir))
fmt.Printf(" Binary: %s\n", buildTargetStyle.Render(binaryName))
fmt.Printf(" Targets: %s\n", buildTargetStyle.Render(formatTargets(buildTargets)))
fmt.Println()
}
// Get the appropriate builder
builder, err := getBuilder(projectType)
if err != nil {
return err
}
// Create build config for the builder
cfg := &buildpkg.Config{
ProjectDir: projectDir,
OutputDir: outputDir,
Name: binaryName,
Version: buildCfg.Project.Name, // Could be enhanced with git describe
LDFlags: buildCfg.Build.LDFlags,
// Docker/LinuxKit specific
Dockerfile: configPath, // Reuse for Dockerfile path
LinuxKitConfig: configPath,
Push: push,
Image: imageName,
}
// Parse formats for LinuxKit
if format != "" {
cfg.Formats = strings.Split(format, ",")
}
// Execute build
ctx := context.Background()
artifacts, err := builder.Build(ctx, cfg, buildTargets)
if err != nil {
if !ciMode {
fmt.Printf("%s Build failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
if !ciMode {
fmt.Printf("%s Built %d artifact(s)\n", buildSuccessStyle.Render("Success:"), len(artifacts))
fmt.Println()
for _, artifact := range artifacts {
relPath, err := filepath.Rel(projectDir, artifact.Path)
if err != nil {
relPath = artifact.Path
}
fmt.Printf(" %s %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relPath),
buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
)
}
}
// Sign macOS binaries if enabled
signCfg := buildCfg.Sign
if notarize {
signCfg.MacOS.Notarize = true
}
if noSign {
signCfg.Enabled = false
}
if signCfg.Enabled && runtime.GOOS == "darwin" {
if !ciMode {
fmt.Println()
fmt.Printf("%s Signing binaries...\n", buildHeaderStyle.Render("Sign:"))
}
// Convert buildpkg.Artifact to signing.Artifact
signingArtifacts := make([]signing.Artifact, len(artifacts))
for i, a := range artifacts {
signingArtifacts[i] = signing.Artifact{Path: a.Path, OS: a.OS, Arch: a.Arch}
}
if err := signing.SignBinaries(ctx, signCfg, signingArtifacts); err != nil {
if !ciMode {
fmt.Printf("%s Signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
if signCfg.MacOS.Notarize {
if err := signing.NotarizeBinaries(ctx, signCfg, signingArtifacts); err != nil {
if !ciMode {
fmt.Printf("%s Notarization failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
}
}
// Archive artifacts if enabled
var archivedArtifacts []buildpkg.Artifact
if doArchive && len(artifacts) > 0 {
if !ciMode {
fmt.Println()
fmt.Printf("%s Creating archives...\n", buildHeaderStyle.Render("Archive:"))
}
archivedArtifacts, err = buildpkg.ArchiveAll(artifacts)
if err != nil {
if !ciMode {
fmt.Printf("%s Archive failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
if !ciMode {
for _, artifact := range archivedArtifacts {
relPath, err := filepath.Rel(projectDir, artifact.Path)
if err != nil {
relPath = artifact.Path
}
fmt.Printf(" %s %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relPath),
buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
)
}
}
}
// Compute checksums if enabled
var checksummedArtifacts []buildpkg.Artifact
if doChecksum && len(archivedArtifacts) > 0 {
if !ciMode {
fmt.Println()
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
}
checksummedArtifacts, err = buildpkg.ChecksumAll(archivedArtifacts)
if err != nil {
if !ciMode {
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
// Write CHECKSUMS.txt
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
if !ciMode {
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
// Sign checksums with GPG
if signCfg.Enabled {
if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
if !ciMode {
fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
}
if !ciMode {
for _, artifact := range checksummedArtifacts {
relPath, err := filepath.Rel(projectDir, artifact.Path)
if err != nil {
relPath = artifact.Path
}
fmt.Printf(" %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relPath),
)
fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum))
}
relChecksumPath, err := filepath.Rel(projectDir, checksumPath)
if err != nil {
relChecksumPath = checksumPath
}
fmt.Printf(" %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relChecksumPath),
)
}
} else if doChecksum && len(artifacts) > 0 && !doArchive {
// Checksum raw binaries if archiving is disabled
if !ciMode {
fmt.Println()
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
}
checksummedArtifacts, err = buildpkg.ChecksumAll(artifacts)
if err != nil {
if !ciMode {
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
// Write CHECKSUMS.txt
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
if !ciMode {
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
// Sign checksums with GPG
if signCfg.Enabled {
if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
if !ciMode {
fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
}
return err
}
}
if !ciMode {
for _, artifact := range checksummedArtifacts {
relPath, err := filepath.Rel(projectDir, artifact.Path)
if err != nil {
relPath = artifact.Path
}
fmt.Printf(" %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relPath),
)
fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum))
}
relChecksumPath, err := filepath.Rel(projectDir, checksumPath)
if err != nil {
relChecksumPath = checksumPath
}
fmt.Printf(" %s %s\n",
buildSuccessStyle.Render("✓"),
buildTargetStyle.Render(relChecksumPath),
)
}
}
// Output results for CI mode
if ciMode {
// Determine which artifacts to output (prefer checksummed > archived > raw)
var outputArtifacts []buildpkg.Artifact
if len(checksummedArtifacts) > 0 {
outputArtifacts = checksummedArtifacts
} else if len(archivedArtifacts) > 0 {
outputArtifacts = archivedArtifacts
} else {
outputArtifacts = artifacts
}
// JSON output for CI
output, err := json.MarshalIndent(outputArtifacts, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal artifacts: %w", err)
}
fmt.Println(string(output))
}
return nil
}
// parseTargets parses a comma-separated list of OS/arch pairs.
func parseTargets(targetsFlag string) ([]buildpkg.Target, error) {
parts := strings.Split(targetsFlag, ",")
var targets []buildpkg.Target
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
osArch := strings.Split(part, "/")
if len(osArch) != 2 {
return nil, fmt.Errorf("invalid target format %q, expected OS/arch (e.g., linux/amd64)", part)
}
targets = append(targets, buildpkg.Target{
OS: strings.TrimSpace(osArch[0]),
Arch: strings.TrimSpace(osArch[1]),
})
}
if len(targets) == 0 {
return nil, fmt.Errorf("no valid targets specified")
}
return targets, nil
}
// formatTargets returns a human-readable string of targets.
func formatTargets(targets []buildpkg.Target) string {
var parts []string
for _, t := range targets {
parts = append(parts, t.String())
}
return strings.Join(parts, ", ")
}
// getBuilder returns the appropriate builder for the project type.
func getBuilder(projectType buildpkg.ProjectType) (buildpkg.Builder, error) {
switch projectType {
case buildpkg.ProjectTypeWails:
return builders.NewWailsBuilder(), nil
case buildpkg.ProjectTypeGo:
return builders.NewGoBuilder(), nil
case buildpkg.ProjectTypeDocker:
return builders.NewDockerBuilder(), nil
case buildpkg.ProjectTypeLinuxKit:
return builders.NewLinuxKitBuilder(), nil
case buildpkg.ProjectTypeTaskfile:
return builders.NewTaskfileBuilder(), nil
case buildpkg.ProjectTypeNode:
return nil, fmt.Errorf("Node.js builder not yet implemented")
case buildpkg.ProjectTypePHP:
return nil, fmt.Errorf("PHP builder not yet implemented")
default:
return nil, fmt.Errorf("unsupported project type: %s", projectType)
}
}
// --- SDK Build Logic ---
func runBuildSDK(specPath, lang, version string, dryRun bool) error {
ctx := context.Background()
projectDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
// Load config
config := sdk.DefaultConfig()
if specPath != "" {
config.Spec = specPath
}
s := sdk.New(projectDir, config)
if version != "" {
s.SetVersion(version)
}
fmt.Printf("%s Generating SDKs\n", buildHeaderStyle.Render("Build SDK:"))
if dryRun {
fmt.Printf(" %s\n", buildDimStyle.Render("(dry-run mode)"))
}
fmt.Println()
// Detect spec
detectedSpec, err := s.DetectSpec()
if err != nil {
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
return err
}
fmt.Printf(" Spec: %s\n", buildTargetStyle.Render(detectedSpec))
if dryRun {
if lang != "" {
fmt.Printf(" Language: %s\n", buildTargetStyle.Render(lang))
} else {
fmt.Printf(" Languages: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", ")))
}
fmt.Println()
fmt.Printf("%s Would generate SDKs (dry-run)\n", buildSuccessStyle.Render("OK:"))
return nil
}
if lang != "" {
// Generate single language
if err := s.GenerateLanguage(ctx, lang); err != nil {
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
return err
}
fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(lang))
} else {
// Generate all
if err := s.Generate(ctx); err != nil {
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
return err
}
fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", ")))
}
fmt.Println()
fmt.Printf("%s SDK generation complete\n", buildSuccessStyle.Render("Success:"))
return nil
}
// --- PWA Build Logic ---
func runPwaBuild(pwaURL string) error {
fmt.Printf("Starting PWA build from URL: %s\n", pwaURL)
tempDir, err := os.MkdirTemp("", "core-pwa-build-*")
if err != nil {
return fmt.Errorf("failed to create temporary directory: %w", err)
}
// defer os.RemoveAll(tempDir) // Keep temp dir for debugging
fmt.Printf("Downloading PWA to temporary directory: %s\n", tempDir)
if err := downloadPWA(pwaURL, tempDir); err != nil {
return fmt.Errorf("failed to download PWA: %w", err)
}
return runBuild(tempDir)
}
func downloadPWA(baseURL, destDir string) error {
// Fetch the main HTML page
resp, err := http.Get(baseURL)
if err != nil {
return fmt.Errorf("failed to fetch URL %s: %w", baseURL, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Find the manifest URL from the HTML
manifestURL, err := findManifestURL(string(body), baseURL)
if err != nil {
// If no manifest, it's not a PWA, but we can still try to package it as a simple site.
fmt.Println("Warning: no manifest file found. Proceeding with basic site download.")
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
return fmt.Errorf("failed to write index.html: %w", err)
}
return nil
}
fmt.Printf("Found manifest: %s\n", manifestURL)
// Fetch and parse the manifest
manifest, err := fetchManifest(manifestURL)
if err != nil {
return fmt.Errorf("failed to fetch or parse manifest: %w", err)
}
// Download all assets listed in the manifest
assets := collectAssets(manifest, manifestURL)
for _, assetURL := range assets {
if err := downloadAsset(assetURL, destDir); err != nil {
fmt.Printf("Warning: failed to download asset %s: %v\n", assetURL, err)
}
}
// Also save the root index.html
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
return fmt.Errorf("failed to write index.html: %w", err)
}
fmt.Println("PWA download complete.")
return nil
}
func findManifestURL(htmlContent, baseURL string) (string, error) {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return "", err
}
var manifestPath string
var f func(*html.Node)
f = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "link" {
var rel, href string
for _, a := range n.Attr {
if a.Key == "rel" {
rel = a.Val
}
if a.Key == "href" {
href = a.Val
}
}
if rel == "manifest" && href != "" {
manifestPath = href
return
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
f(c)
}
}
f(doc)
if manifestPath == "" {
return "", fmt.Errorf("no <link rel=\"manifest\"> tag found")
}
base, err := url.Parse(baseURL)
if err != nil {
return "", err
}
manifestURL, err := base.Parse(manifestPath)
if err != nil {
return "", err
}
return manifestURL.String(), nil
}
func fetchManifest(manifestURL string) (map[string]interface{}, error) {
resp, err := http.Get(manifestURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var manifest map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
return nil, err
}
return manifest, nil
}
func collectAssets(manifest map[string]interface{}, manifestURL string) []string {
var assets []string
base, _ := url.Parse(manifestURL)
// Add start_url
if startURL, ok := manifest["start_url"].(string); ok {
if resolved, err := base.Parse(startURL); err == nil {
assets = append(assets, resolved.String())
}
}
// Add icons
if icons, ok := manifest["icons"].([]interface{}); ok {
for _, icon := range icons {
if iconMap, ok := icon.(map[string]interface{}); ok {
if src, ok := iconMap["src"].(string); ok {
if resolved, err := base.Parse(src); err == nil {
assets = append(assets, resolved.String())
}
}
}
}
}
return assets
}
func downloadAsset(assetURL, destDir string) error {
resp, err := http.Get(assetURL)
if err != nil {
return err
}
defer resp.Body.Close()
u, err := url.Parse(assetURL)
if err != nil {
return err
}
path := filepath.Join(destDir, filepath.FromSlash(u.Path))
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
return err
}
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// --- Standard Build Logic ---
func runBuild(fromPath string) error {
fmt.Printf("Starting build from path: %s\n", fromPath)
info, err := os.Stat(fromPath)
if err != nil {
return fmt.Errorf("invalid path specified: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("path specified must be a directory")
}
buildDir := ".core/build/app"
htmlDir := filepath.Join(buildDir, "html")
appName := filepath.Base(fromPath)
if strings.HasPrefix(appName, "core-pwa-build-") {
appName = "pwa-app"
}
outputExe := appName
if err := os.RemoveAll(buildDir); err != nil {
return fmt.Errorf("failed to clean build directory: %w", err)
}
// 1. Generate the project from the embedded template
fmt.Println("Generating application from template...")
templateFS, err := debme.FS(guiTemplate, "tmpl/gui")
if err != nil {
return fmt.Errorf("failed to anchor template filesystem: %w", err)
}
sod := gosod.New(templateFS)
if sod == nil {
return fmt.Errorf("failed to create new sod instance")
}
templateData := map[string]string{"AppName": appName}
if err := sod.Extract(buildDir, templateData); err != nil {
return fmt.Errorf("failed to extract template: %w", err)
}
// 2. Copy the user's web app files
fmt.Println("Copying application files...")
if err := copyDir(fromPath, htmlDir); err != nil {
return fmt.Errorf("failed to copy application files: %w", err)
}
// 3. Compile the application
fmt.Println("Compiling application...")
// Run go mod tidy
cmd := exec.Command("go", "mod", "tidy")
cmd.Dir = buildDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("go mod tidy failed: %w", err)
}
// Run go build
cmd = exec.Command("go", "build", "-o", outputExe)
cmd.Dir = buildDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("go build failed: %w", err)
}
fmt.Printf("\nBuild successful! Executable created at: %s/%s\n", buildDir, outputExe)
return nil
}
// copyDir recursively copies a directory from src to dst.
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
dstPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
}
srcFile, err := os.Open(path)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dstPath)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
})
}

369
cmd/build/build_project.go Normal file
View 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
View 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
View 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
}

View file

@ -8,6 +8,12 @@
// - Taskfile-based projects // - Taskfile-based projects
// //
// Configuration via .core/build.yaml or command-line flags. // Configuration via .core/build.yaml or command-line flags.
//
// Subcommands:
// - build: Auto-detect and build the current project
// - build from-path: Build from a local static web app directory
// - build pwa: Build from a live PWA URL
// - build sdk: Generate API SDKs from OpenAPI spec
package build package build
import "github.com/leaanthony/clir" import "github.com/leaanthony/clir"

31
cmd/ci/ci_changelog.go Normal file
View 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
View 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
View 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
}

View file

@ -2,37 +2,17 @@
package ci package ci
import ( import (
"bufio" "github.com/host-uk/core/cmd/shared"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/pkg/release"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// CIRelease command styles // Style aliases from shared
var ( var (
releaseHeaderStyle = lipgloss.NewStyle(). releaseHeaderStyle = shared.RepoNameStyle
Bold(true). releaseSuccessStyle = shared.SuccessStyle
Foreground(lipgloss.Color("#3b82f6")) // blue-500 releaseErrorStyle = shared.ErrorStyle
releaseDimStyle = shared.DimStyle
releaseSuccessStyle = lipgloss.NewStyle(). releaseValueStyle = shared.ValueStyle
Bold(true).
Foreground(lipgloss.Color("#22c55e")) // green-500
releaseErrorStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ef4444")) // red-500
releaseDimStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6b7280")) // gray-500
releaseValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#e2e8f0")) // gray-200
) )
// AddCIReleaseCommand adds the release command and its subcommands. // AddCIReleaseCommand adds the release command and its subcommands.
@ -84,172 +64,3 @@ func AddCIReleaseCommand(app *clir.Cli) {
return runCIReleaseVersion() return runCIReleaseVersion()
}) })
} }
// runCIPublish publishes pre-built artifacts from dist/.
// It does NOT build - use `core build` first.
func runCIPublish(dryRun bool, version string, draft, prerelease bool) error {
ctx := context.Background()
// Get current directory
projectDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
// Load configuration
cfg, err := release.LoadConfig(projectDir)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Apply CLI overrides
if version != "" {
cfg.SetVersion(version)
}
// Apply draft/prerelease overrides to all publishers
if draft || prerelease {
for i := range cfg.Publishers {
if draft {
cfg.Publishers[i].Draft = true
}
if prerelease {
cfg.Publishers[i].Prerelease = true
}
}
}
// Print header
fmt.Printf("%s Publishing release\n", releaseHeaderStyle.Render("CI:"))
if dryRun {
fmt.Printf(" %s\n", releaseDimStyle.Render("(dry-run) use --we-are-go-for-launch to publish"))
} else {
fmt.Printf(" %s\n", releaseSuccessStyle.Render("🚀 GO FOR LAUNCH"))
}
fmt.Println()
// Check for publishers
if len(cfg.Publishers) == 0 {
return fmt.Errorf("no publishers configured in .core/release.yaml")
}
// Publish pre-built artifacts
rel, err := release.Publish(ctx, cfg, dryRun)
if err != nil {
fmt.Printf("%s %v\n", releaseErrorStyle.Render("Error:"), err)
return err
}
// Print summary
fmt.Println()
fmt.Printf("%s Publish completed!\n", releaseSuccessStyle.Render("Success:"))
fmt.Printf(" Version: %s\n", releaseValueStyle.Render(rel.Version))
fmt.Printf(" Artifacts: %d\n", len(rel.Artifacts))
if !dryRun {
for _, pub := range cfg.Publishers {
fmt.Printf(" Published: %s\n", releaseValueStyle.Render(pub.Type))
}
}
return nil
}
// runCIReleaseInit creates a release configuration interactively.
func runCIReleaseInit() error {
projectDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
// Check if config already exists
if release.ConfigExists(projectDir) {
fmt.Printf("%s Configuration already exists at %s\n",
releaseDimStyle.Render("Note:"),
release.ConfigPath(projectDir))
reader := bufio.NewReader(os.Stdin)
fmt.Print("Overwrite? [y/N]: ")
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Aborted.")
return nil
}
}
fmt.Printf("%s Creating release configuration\n", releaseHeaderStyle.Render("Init:"))
fmt.Println()
reader := bufio.NewReader(os.Stdin)
// Project name
defaultName := filepath.Base(projectDir)
fmt.Printf("Project name [%s]: ", defaultName)
name, _ := reader.ReadString('\n')
name = strings.TrimSpace(name)
if name == "" {
name = defaultName
}
// Repository
fmt.Print("GitHub repository (owner/repo): ")
repo, _ := reader.ReadString('\n')
repo = strings.TrimSpace(repo)
// Create config
cfg := release.DefaultConfig()
cfg.Project.Name = name
cfg.Project.Repository = repo
// Write config
if err := release.WriteConfig(cfg, projectDir); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
fmt.Println()
fmt.Printf("%s Configuration written to %s\n",
releaseSuccessStyle.Render("Success:"),
release.ConfigPath(projectDir))
return nil
}
// runChangelog generates and prints a changelog.
func runChangelog(fromRef, toRef string) error {
projectDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
// Load config for changelog settings
cfg, err := release.LoadConfig(projectDir)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Generate changelog
changelog, err := release.GenerateWithConfig(projectDir, fromRef, toRef, &cfg.Changelog)
if err != nil {
return fmt.Errorf("failed to generate changelog: %w", err)
}
fmt.Println(changelog)
return nil
}
// runCIReleaseVersion shows the determined version.
func runCIReleaseVersion() error {
projectDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
version, err := release.DetermineVersion(projectDir)
if err != nil {
return fmt.Errorf("failed to determine version: %w", err)
}
fmt.Printf("Version: %s\n", releaseValueStyle.Render(version))
return nil
}

24
cmd/ci/ci_version.go Normal file
View 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
}

View file

@ -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)
}

View file

@ -1,529 +1,108 @@
// Package dev provides multi-repo development workflow commands.
//
// Git Operations:
// - work: Combined status, commit, and push workflow
// - health: Quick health check across all repos
// - commit: Claude-assisted commit message generation
// - push: Push repos with unpushed commits
// - pull: Pull repos that are behind remote
//
// GitHub Integration (requires gh CLI):
// - issues: List open issues across repos
// - reviews: List PRs needing review
// - ci: Check GitHub Actions CI status
// - impact: Analyse dependency impact of changes
//
// API Tools:
// - api sync: Synchronize public service APIs
//
// Dev Environment (VM management):
// - install: Download dev environment image
// - boot: Start dev environment VM
// - stop: Stop dev environment VM
// - status: Check dev VM status
// - shell: Open shell in dev VM
// - serve: Mount project and start dev server
// - test: Run tests in dev environment
// - claude: Start sandboxed Claude session
// - update: Check for and apply updates
package dev package dev
import ( import (
"context"
"fmt"
"os"
"time"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/pkg/devops" "github.com/host-uk/core/cmd/shared"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// Dev-specific styles // Style aliases from shared package
var ( var (
devHeaderStyle = lipgloss.NewStyle(). successStyle = shared.SuccessStyle
Bold(true). errorStyle = shared.ErrorStyle
Foreground(lipgloss.Color("#3b82f6")) // blue-500 warningStyle = shared.WarningStyle
dimStyle = shared.DimStyle
devSuccessStyle = lipgloss.NewStyle(). valueStyle = shared.ValueStyle
Foreground(lipgloss.Color("#22c55e")). // green-500 headerStyle = shared.HeaderStyle
Bold(true) repoNameStyle = shared.RepoNameStyle
devErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#ef4444")). // red-500
Bold(true)
devDimStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6b7280")) // gray-500
devValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#e2e8f0")) // gray-200
devWarningStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#f59e0b")) // amber-500
) )
// AddDevCommand adds the dev environment commands to the dev parent command. // Table styles for status display
// These are added as direct subcommands: core dev install, core dev boot, etc. var (
func AddDevCommand(parent *clir.Command) { cellStyle = lipgloss.NewStyle().
AddDevInstallCommand(parent) Padding(0, 1)
AddDevBootCommand(parent)
AddDevStopCommand(parent) dirtyStyle = lipgloss.NewStyle().
AddDevStatusCommand(parent) Foreground(lipgloss.Color("#ef4444")). // red-500
AddDevShellCommand(parent) Padding(0, 1)
AddDevServeCommand(parent)
AddDevTestCommand(parent) aheadStyle = lipgloss.NewStyle().
AddDevClaudeCommand(parent) Foreground(lipgloss.Color("#22c55e")). // green-500
AddDevUpdateCommand(parent) Padding(0, 1)
}
cleanStyle = lipgloss.NewStyle().
// AddDevInstallCommand adds the 'dev install' command. Foreground(lipgloss.Color("#6b7280")). // gray-500
func AddDevInstallCommand(parent *clir.Command) { Padding(0, 1)
installCmd := parent.NewSubCommand("install", "Download and install the dev environment image") )
installCmd.LongDescription("Downloads the platform-specific dev environment image.\n\n" +
"The image includes Go, PHP, Node.js, Python, Docker, and Claude CLI.\n" + // AddCommands registers the 'dev' command and all subcommands.
"Downloads are cached at ~/.core/images/\n\n" + func AddCommands(app *clir.Cli) {
"Examples:\n" + devCmd := app.NewSubCommand("dev", "Multi-repo development workflow")
" core dev install") devCmd.LongDescription("Manage multiple git repositories and GitHub integration.\n\n" +
"Uses repos.yaml to discover repositories. Falls back to scanning\n" +
installCmd.Action(func() error { "the current directory if no registry is found.\n\n" +
return runDevInstall() "Git Operations:\n" +
}) " work Combined status -> commit -> push workflow\n" +
} " health Quick repo health summary\n" +
" commit Claude-assisted commit messages\n" +
func runDevInstall() error { " push Push repos with unpushed commits\n" +
d, err := devops.New() " pull Pull repos behind remote\n\n" +
if err != nil { "GitHub Integration (requires gh CLI):\n" +
return err " issues List open issues across repos\n" +
} " reviews List PRs awaiting review\n" +
" ci Check GitHub Actions status\n" +
if d.IsInstalled() { " impact Analyse dependency impact\n\n" +
fmt.Println(devSuccessStyle.Render("Dev environment already installed")) "Dev Environment:\n" +
fmt.Println() " install Download dev environment image\n" +
fmt.Printf("Use %s to check for updates\n", devDimStyle.Render("core dev update")) " boot Start dev environment VM\n" +
return nil " stop Stop dev environment VM\n" +
} " shell Open shell in dev VM\n" +
" status Check dev VM status")
fmt.Printf("%s %s\n", devDimStyle.Render("Image:"), devops.ImageName())
fmt.Println() // Git operations
fmt.Println("Downloading dev environment...") addWorkCommand(devCmd)
fmt.Println() addHealthCommand(devCmd)
addCommitCommand(devCmd)
ctx := context.Background() addPushCommand(devCmd)
start := time.Now() addPullCommand(devCmd)
var lastProgress int64
// GitHub integration
err = d.Install(ctx, func(downloaded, total int64) { addIssuesCommand(devCmd)
if total > 0 { addReviewsCommand(devCmd)
pct := int(float64(downloaded) / float64(total) * 100) addCICommand(devCmd)
if pct != int(float64(lastProgress)/float64(total)*100) { addImpactCommand(devCmd)
fmt.Printf("\r%s %d%%", devDimStyle.Render("Progress:"), pct)
lastProgress = downloaded // API tools
} addAPICommands(devCmd)
}
}) // Dev environment
addVMCommands(devCmd)
fmt.Println() // Clear progress line
if err != nil {
return fmt.Errorf("install failed: %w", err)
}
elapsed := time.Since(start).Round(time.Second)
fmt.Println()
fmt.Printf("%s in %s\n", devSuccessStyle.Render("Installed"), elapsed)
fmt.Println()
fmt.Printf("Start with: %s\n", devDimStyle.Render("core dev boot"))
return nil
}
// AddDevBootCommand adds the 'devops boot' command.
func AddDevBootCommand(parent *clir.Command) {
var memory int
var cpus int
var fresh bool
bootCmd := parent.NewSubCommand("boot", "Start the dev environment")
bootCmd.LongDescription("Boots the dev environment VM.\n\n" +
"Examples:\n" +
" core dev boot\n" +
" core dev boot --memory 8192 --cpus 4\n" +
" core dev boot --fresh")
bootCmd.IntFlag("memory", "Memory in MB (default: 4096)", &memory)
bootCmd.IntFlag("cpus", "Number of CPUs (default: 2)", &cpus)
bootCmd.BoolFlag("fresh", "Stop existing and start fresh", &fresh)
bootCmd.Action(func() error {
return runDevBoot(memory, cpus, fresh)
})
}
func runDevBoot(memory, cpus int, fresh bool) error {
d, err := devops.New()
if err != nil {
return err
}
if !d.IsInstalled() {
return fmt.Errorf("dev environment not installed (run 'core dev install' first)")
}
opts := devops.DefaultBootOptions()
if memory > 0 {
opts.Memory = memory
}
if cpus > 0 {
opts.CPUs = cpus
}
opts.Fresh = fresh
fmt.Printf("%s %dMB, %d CPUs\n", devDimStyle.Render("Config:"), opts.Memory, opts.CPUs)
fmt.Println()
fmt.Println("Booting dev environment...")
ctx := context.Background()
if err := d.Boot(ctx, opts); err != nil {
return err
}
fmt.Println()
fmt.Println(devSuccessStyle.Render("Dev environment running"))
fmt.Println()
fmt.Printf("Connect with: %s\n", devDimStyle.Render("core dev shell"))
fmt.Printf("SSH port: %s\n", devDimStyle.Render("2222"))
return nil
}
// AddDevStopCommand adds the 'devops stop' command.
func AddDevStopCommand(parent *clir.Command) {
stopCmd := parent.NewSubCommand("stop", "Stop the dev environment")
stopCmd.LongDescription("Stops the running dev environment VM.\n\n" +
"Examples:\n" +
" core dev stop")
stopCmd.Action(func() error {
return runDevStop()
})
}
func runDevStop() error {
d, err := devops.New()
if err != nil {
return err
}
ctx := context.Background()
running, err := d.IsRunning(ctx)
if err != nil {
return err
}
if !running {
fmt.Println(devDimStyle.Render("Dev environment is not running"))
return nil
}
fmt.Println("Stopping dev environment...")
if err := d.Stop(ctx); err != nil {
return err
}
fmt.Println(devSuccessStyle.Render("Stopped"))
return nil
}
// AddDevStatusCommand adds the 'devops status' command.
func AddDevStatusCommand(parent *clir.Command) {
statusCmd := parent.NewSubCommand("status", "Show dev environment status")
statusCmd.LongDescription("Shows the current status of the dev environment.\n\n" +
"Examples:\n" +
" core dev status")
statusCmd.Action(func() error {
return runDevStatus()
})
}
func runDevStatus() error {
d, err := devops.New()
if err != nil {
return err
}
ctx := context.Background()
status, err := d.Status(ctx)
if err != nil {
return err
}
fmt.Println(devHeaderStyle.Render("Dev Environment Status"))
fmt.Println()
// Installation status
if status.Installed {
fmt.Printf("%s %s\n", devDimStyle.Render("Installed:"), devSuccessStyle.Render("Yes"))
if status.ImageVersion != "" {
fmt.Printf("%s %s\n", devDimStyle.Render("Version:"), status.ImageVersion)
}
} else {
fmt.Printf("%s %s\n", devDimStyle.Render("Installed:"), devErrorStyle.Render("No"))
fmt.Println()
fmt.Printf("Install with: %s\n", devDimStyle.Render("core dev install"))
return nil
}
fmt.Println()
// Running status
if status.Running {
fmt.Printf("%s %s\n", devDimStyle.Render("Status:"), devSuccessStyle.Render("Running"))
fmt.Printf("%s %s\n", devDimStyle.Render("Container:"), status.ContainerID[:8])
fmt.Printf("%s %dMB\n", devDimStyle.Render("Memory:"), status.Memory)
fmt.Printf("%s %d\n", devDimStyle.Render("CPUs:"), status.CPUs)
fmt.Printf("%s %d\n", devDimStyle.Render("SSH Port:"), status.SSHPort)
fmt.Printf("%s %s\n", devDimStyle.Render("Uptime:"), formatDevUptime(status.Uptime))
} else {
fmt.Printf("%s %s\n", devDimStyle.Render("Status:"), devDimStyle.Render("Stopped"))
fmt.Println()
fmt.Printf("Start with: %s\n", devDimStyle.Render("core dev boot"))
}
return nil
}
func formatDevUptime(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
}
return fmt.Sprintf("%dd %dh", int(d.Hours()/24), int(d.Hours())%24)
}
// AddDevShellCommand adds the 'devops shell' command.
func AddDevShellCommand(parent *clir.Command) {
var console bool
shellCmd := parent.NewSubCommand("shell", "Connect to the dev environment")
shellCmd.LongDescription("Opens an interactive shell in the dev environment.\n\n" +
"Uses SSH by default, or serial console with --console.\n\n" +
"Examples:\n" +
" core dev shell\n" +
" core dev shell --console\n" +
" core dev shell -- ls -la")
shellCmd.BoolFlag("console", "Use serial console instead of SSH", &console)
shellCmd.Action(func() error {
args := shellCmd.OtherArgs()
return runDevShell(console, args)
})
}
func runDevShell(console bool, command []string) error {
d, err := devops.New()
if err != nil {
return err
}
opts := devops.ShellOptions{
Console: console,
Command: command,
}
ctx := context.Background()
return d.Shell(ctx, opts)
}
// AddDevServeCommand adds the 'devops serve' command.
func AddDevServeCommand(parent *clir.Command) {
var port int
var path string
serveCmd := parent.NewSubCommand("serve", "Mount project and start dev server")
serveCmd.LongDescription("Mounts the current project into the dev environment and starts a dev server.\n\n" +
"Auto-detects the appropriate serve command based on project files.\n\n" +
"Examples:\n" +
" core dev serve\n" +
" core dev serve --port 3000\n" +
" core dev serve --path public")
serveCmd.IntFlag("port", "Port to serve on (default: 8000)", &port)
serveCmd.StringFlag("path", "Subdirectory to serve", &path)
serveCmd.Action(func() error {
return runDevServe(port, path)
})
}
func runDevServe(port int, path string) error {
d, err := devops.New()
if err != nil {
return err
}
projectDir, err := os.Getwd()
if err != nil {
return err
}
opts := devops.ServeOptions{
Port: port,
Path: path,
}
ctx := context.Background()
return d.Serve(ctx, projectDir, opts)
}
// AddDevTestCommand adds the 'devops test' command.
func AddDevTestCommand(parent *clir.Command) {
var name string
testCmd := parent.NewSubCommand("test", "Run tests in the dev environment")
testCmd.LongDescription("Runs tests in the dev environment.\n\n" +
"Auto-detects the test command based on project files, or uses .core/test.yaml.\n\n" +
"Examples:\n" +
" core dev test\n" +
" core dev test --name integration\n" +
" core dev test -- go test -v ./...")
testCmd.StringFlag("name", "Run named test command from .core/test.yaml", &name)
testCmd.Action(func() error {
args := testCmd.OtherArgs()
return runDevTest(name, args)
})
}
func runDevTest(name string, command []string) error {
d, err := devops.New()
if err != nil {
return err
}
projectDir, err := os.Getwd()
if err != nil {
return err
}
opts := devops.TestOptions{
Name: name,
Command: command,
}
ctx := context.Background()
return d.Test(ctx, projectDir, opts)
}
// AddDevClaudeCommand adds the 'devops claude' command.
func AddDevClaudeCommand(parent *clir.Command) {
var noAuth bool
var model string
var authFlags []string
claudeCmd := parent.NewSubCommand("claude", "Start sandboxed Claude session")
claudeCmd.LongDescription("Starts a Claude Code session inside the dev environment sandbox.\n\n" +
"Provides isolation while forwarding selected credentials.\n" +
"Auto-boots the dev environment if not running.\n\n" +
"Auth options (default: all):\n" +
" gh - GitHub CLI auth\n" +
" anthropic - Anthropic API key\n" +
" ssh - SSH agent forwarding\n" +
" git - Git config (name, email)\n\n" +
"Examples:\n" +
" core dev claude\n" +
" core dev claude --model opus\n" +
" core dev claude --auth gh,anthropic\n" +
" core dev claude --no-auth")
claudeCmd.BoolFlag("no-auth", "Don't forward any auth credentials", &noAuth)
claudeCmd.StringFlag("model", "Model to use (opus, sonnet)", &model)
claudeCmd.StringsFlag("auth", "Selective auth forwarding (gh,anthropic,ssh,git)", &authFlags)
claudeCmd.Action(func() error {
return runDevClaude(noAuth, model, authFlags)
})
}
func runDevClaude(noAuth bool, model string, authFlags []string) error {
d, err := devops.New()
if err != nil {
return err
}
projectDir, err := os.Getwd()
if err != nil {
return err
}
opts := devops.ClaudeOptions{
NoAuth: noAuth,
Model: model,
Auth: authFlags,
}
ctx := context.Background()
return d.Claude(ctx, projectDir, opts)
}
// AddDevUpdateCommand adds the 'devops update' command.
func AddDevUpdateCommand(parent *clir.Command) {
var apply bool
updateCmd := parent.NewSubCommand("update", "Check for and apply updates")
updateCmd.LongDescription("Checks for dev environment updates and optionally applies them.\n\n" +
"Examples:\n" +
" core dev update\n" +
" core dev update --apply")
updateCmd.BoolFlag("apply", "Download and apply the update", &apply)
updateCmd.Action(func() error {
return runDevUpdate(apply)
})
}
func runDevUpdate(apply bool) error {
d, err := devops.New()
if err != nil {
return err
}
ctx := context.Background()
fmt.Println("Checking for updates...")
fmt.Println()
current, latest, hasUpdate, err := d.CheckUpdate(ctx)
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}
fmt.Printf("%s %s\n", devDimStyle.Render("Current:"), devValueStyle.Render(current))
fmt.Printf("%s %s\n", devDimStyle.Render("Latest:"), devValueStyle.Render(latest))
fmt.Println()
if !hasUpdate {
fmt.Println(devSuccessStyle.Render("Already up to date"))
return nil
}
fmt.Println(devWarningStyle.Render("Update available"))
fmt.Println()
if !apply {
fmt.Printf("Run %s to update\n", devDimStyle.Render("core dev update --apply"))
return nil
}
// Stop if running
running, _ := d.IsRunning(ctx)
if running {
fmt.Println("Stopping current instance...")
_ = d.Stop(ctx)
}
fmt.Println("Downloading update...")
fmt.Println()
start := time.Now()
err = d.Install(ctx, func(downloaded, total int64) {
if total > 0 {
pct := int(float64(downloaded) / float64(total) * 100)
fmt.Printf("\r%s %d%%", devDimStyle.Render("Progress:"), pct)
}
})
fmt.Println()
if err != nil {
return fmt.Errorf("update failed: %w", err)
}
elapsed := time.Since(start).Round(time.Second)
fmt.Println()
fmt.Printf("%s in %s\n", devSuccessStyle.Render("Updated"), elapsed)
return nil
} }

View file

@ -4,14 +4,14 @@ import (
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// AddAPICommands adds the 'api' command and its subcommands to the given parent command. // addAPICommands adds the 'api' command and its subcommands to the given parent command.
func AddAPICommands(parent *clir.Command) { func addAPICommands(parent *clir.Command) {
// Create the 'api' command // Create the 'api' command
apiCmd := parent.NewSubCommand("api", "Tools for managing service APIs") apiCmd := parent.NewSubCommand("api", "Tools for managing service APIs")
// Add the 'sync' command to 'api' // Add the 'sync' command to 'api'
AddSyncCommand(apiCmd) addSyncCommand(apiCmd)
// TODO: Add the 'test-gen' command to 'api' // TODO: Add the 'test-gen' command to 'api'
// AddTestGenCommand(apiCmd) // addTestGenCommand(apiCmd)
} }

View file

@ -9,10 +9,12 @@ import (
"time" "time"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/repos" "github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// CI-specific styles
var ( var (
ciSuccessStyle = lipgloss.NewStyle(). ciSuccessStyle = lipgloss.NewStyle().
Bold(true). Bold(true).
@ -43,8 +45,8 @@ type WorkflowRun struct {
RepoName string `json:"-"` RepoName string `json:"-"`
} }
// AddCICommand adds the 'ci' command to the given parent command. // addCICommand adds the 'ci' command to the given parent command.
func AddCICommand(parent *clir.Command) { func addCICommand(parent *clir.Command) {
var registryPath string var registryPath string
var branch string var branch string
var failedOnly bool var failedOnly bool
@ -149,16 +151,16 @@ func runCI(registryPath string, branch string, failedOnly bool) error {
fmt.Println() fmt.Println()
fmt.Printf("%d repos checked", len(repoList)) fmt.Printf("%d repos checked", len(repoList))
if success > 0 { if success > 0 {
fmt.Printf(" · %s", ciSuccessStyle.Render(fmt.Sprintf("%d passing", success))) fmt.Printf(" * %s", ciSuccessStyle.Render(fmt.Sprintf("%d passing", success)))
} }
if failed > 0 { if failed > 0 {
fmt.Printf(" · %s", ciFailureStyle.Render(fmt.Sprintf("%d failing", failed))) fmt.Printf(" * %s", ciFailureStyle.Render(fmt.Sprintf("%d failing", failed)))
} }
if pending > 0 { if pending > 0 {
fmt.Printf(" · %s", ciPendingStyle.Render(fmt.Sprintf("%d pending", pending))) fmt.Printf(" * %s", ciPendingStyle.Render(fmt.Sprintf("%d pending", pending)))
} }
if len(noCI) > 0 { if len(noCI) > 0 {
fmt.Printf(" · %s", ciSkippedStyle.Render(fmt.Sprintf("%d no CI", len(noCI)))) fmt.Printf(" * %s", ciSkippedStyle.Render(fmt.Sprintf("%d no CI", len(noCI))))
} }
fmt.Println() fmt.Println()
fmt.Println() fmt.Println()
@ -227,30 +229,30 @@ func printWorkflowRun(run WorkflowRun) {
var status string var status string
switch run.Conclusion { switch run.Conclusion {
case "success": case "success":
status = ciSuccessStyle.Render("") status = ciSuccessStyle.Render("v")
case "failure": case "failure":
status = ciFailureStyle.Render("") status = ciFailureStyle.Render("x")
case "": case "":
if run.Status == "in_progress" { if run.Status == "in_progress" {
status = ciPendingStyle.Render("") status = ciPendingStyle.Render("*")
} else if run.Status == "queued" { } else if run.Status == "queued" {
status = ciPendingStyle.Render("") status = ciPendingStyle.Render("o")
} else { } else {
status = ciSkippedStyle.Render("") status = ciSkippedStyle.Render("-")
} }
case "skipped": case "skipped":
status = ciSkippedStyle.Render("") status = ciSkippedStyle.Render("-")
case "cancelled": case "cancelled":
status = ciSkippedStyle.Render("") status = ciSkippedStyle.Render("o")
default: default:
status = ciSkippedStyle.Render("?") status = ciSkippedStyle.Render("?")
} }
// Workflow name (truncated) // Workflow name (truncated)
workflowName := truncate(run.Name, 20) workflowName := shared.Truncate(run.Name, 20)
// Age // Age
age := formatAge(run.UpdatedAt) age := shared.FormatAge(run.UpdatedAt)
fmt.Printf(" %s %-18s %-22s %s\n", fmt.Printf(" %s %-18s %-22s %s\n",
status, status,

View file

@ -4,16 +4,15 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath"
"github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/git" "github.com/host-uk/core/pkg/git"
"github.com/host-uk/core/pkg/repos" "github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// AddCommitCommand adds the 'commit' command to the given parent command. // addCommitCommand adds the 'commit' command to the given parent command.
func AddCommitCommand(parent *clir.Command) { func addCommitCommand(parent *clir.Command) {
var registryPath string var registryPath string
var all bool var all bool
@ -116,7 +115,7 @@ func runCommit(registryPath string, all bool) error {
// Confirm unless --all // Confirm unless --all
if !all { if !all {
fmt.Println() fmt.Println()
if !confirm("Have Claude commit these repos?") { if !shared.Confirm("Have Claude commit these repos?") {
fmt.Println("Aborted.") fmt.Println("Aborted.")
return nil return nil
} }
@ -130,10 +129,10 @@ func runCommit(registryPath string, all bool) error {
fmt.Printf("%s %s\n", dimStyle.Render("Committing"), s.Name) fmt.Printf("%s %s\n", dimStyle.Render("Committing"), s.Name)
if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil { if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil {
fmt.Printf(" %s %s\n", errorStyle.Render(""), err) fmt.Printf(" %s %s\n", errorStyle.Render("x"), err)
failed++ failed++
} else { } else {
fmt.Printf(" %s committed\n", successStyle.Render("")) fmt.Printf(" %s committed\n", successStyle.Render("v"))
succeeded++ succeeded++
} }
fmt.Println() fmt.Println()
@ -148,25 +147,3 @@ func runCommit(registryPath string, all bool) error {
return nil return nil
} }
// claudeCommit is defined in work.go but we need it here too
// This version includes better output handling
func claudeCommitWithOutput(ctx context.Context, repoPath, repoName, registryPath string) error {
// Load AGENTS.md context if available
agentsPath := filepath.Join(filepath.Dir(registryPath), "AGENTS.md")
var agentContext string
if data, err := os.ReadFile(agentsPath); err == nil {
agentContext = string(data) + "\n\n"
}
prompt := agentContext + "Review the uncommitted changes and create an appropriate commit. " +
"Use Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>. Be concise."
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep")
cmd.Dir = repoPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}

View file

@ -6,35 +6,13 @@ import (
"os" "os"
"sort" "sort"
"github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/pkg/git" "github.com/host-uk/core/pkg/git"
"github.com/host-uk/core/pkg/repos" "github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
var ( // addHealthCommand adds the 'health' command to the given parent command.
healthLabelStyle = lipgloss.NewStyle(). func addHealthCommand(parent *clir.Command) {
Foreground(lipgloss.Color("#6b7280")) // gray-500
healthValueStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#e2e8f0")) // gray-200
healthGoodStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#22c55e")) // green-500
healthWarnStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#f59e0b")) // amber-500
healthBadStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ef4444")) // red-500
)
// AddHealthCommand adds the 'health' command to the given parent command.
func AddHealthCommand(parent *clir.Command) {
var registryPath string var registryPath string
var verbose bool var verbose bool
@ -108,11 +86,11 @@ func runHealth(registryPath string, verbose bool) error {
// Aggregate stats // Aggregate stats
var ( var (
totalRepos = len(statuses) totalRepos = len(statuses)
dirtyRepos []string dirtyRepos []string
aheadRepos []string aheadRepos []string
behindRepos []string behindRepos []string
errorRepos []string errorRepos []string
) )
for _, s := range statuses { for _, s := range statuses {
@ -139,16 +117,16 @@ func runHealth(registryPath string, verbose bool) error {
// Verbose output // Verbose output
if verbose { if verbose {
if len(dirtyRepos) > 0 { if len(dirtyRepos) > 0 {
fmt.Printf("%s %s\n", healthWarnStyle.Render("Dirty:"), formatRepoList(dirtyRepos)) fmt.Printf("%s %s\n", warningStyle.Render("Dirty:"), formatRepoList(dirtyRepos))
} }
if len(aheadRepos) > 0 { if len(aheadRepos) > 0 {
fmt.Printf("%s %s\n", healthGoodStyle.Render("Ahead:"), formatRepoList(aheadRepos)) fmt.Printf("%s %s\n", successStyle.Render("Ahead:"), formatRepoList(aheadRepos))
} }
if len(behindRepos) > 0 { if len(behindRepos) > 0 {
fmt.Printf("%s %s\n", healthWarnStyle.Render("Behind:"), formatRepoList(behindRepos)) fmt.Printf("%s %s\n", warningStyle.Render("Behind:"), formatRepoList(behindRepos))
} }
if len(errorRepos) > 0 { if len(errorRepos) > 0 {
fmt.Printf("%s %s\n", healthBadStyle.Render("Errors:"), formatRepoList(errorRepos)) fmt.Printf("%s %s\n", errorStyle.Render("Errors:"), formatRepoList(errorRepos))
} }
fmt.Println() fmt.Println()
} }
@ -158,62 +136,62 @@ func runHealth(registryPath string, verbose bool) error {
func printHealthSummary(total int, dirty, ahead, behind, errors []string) { func printHealthSummary(total int, dirty, ahead, behind, errors []string) {
// Total repos // Total repos
fmt.Print(healthValueStyle.Render(fmt.Sprintf("%d", total))) fmt.Print(valueStyle.Render(fmt.Sprintf("%d", total)))
fmt.Print(healthLabelStyle.Render(" repos")) fmt.Print(dimStyle.Render(" repos"))
// Separator // Separator
fmt.Print(healthLabelStyle.Render(" │ ")) fmt.Print(dimStyle.Render(" | "))
// Dirty // Dirty
if len(dirty) > 0 { if len(dirty) > 0 {
fmt.Print(healthWarnStyle.Render(fmt.Sprintf("%d", len(dirty)))) fmt.Print(warningStyle.Render(fmt.Sprintf("%d", len(dirty))))
fmt.Print(healthLabelStyle.Render(" dirty")) fmt.Print(dimStyle.Render(" dirty"))
} else { } else {
fmt.Print(healthGoodStyle.Render("clean")) fmt.Print(successStyle.Render("clean"))
} }
// Separator // Separator
fmt.Print(healthLabelStyle.Render(" │ ")) fmt.Print(dimStyle.Render(" | "))
// Ahead // Ahead
if len(ahead) > 0 { if len(ahead) > 0 {
fmt.Print(healthValueStyle.Render(fmt.Sprintf("%d", len(ahead)))) fmt.Print(valueStyle.Render(fmt.Sprintf("%d", len(ahead))))
fmt.Print(healthLabelStyle.Render(" to push")) fmt.Print(dimStyle.Render(" to push"))
} else { } else {
fmt.Print(healthGoodStyle.Render("synced")) fmt.Print(successStyle.Render("synced"))
} }
// Separator // Separator
fmt.Print(healthLabelStyle.Render(" │ ")) fmt.Print(dimStyle.Render(" | "))
// Behind // Behind
if len(behind) > 0 { if len(behind) > 0 {
fmt.Print(healthWarnStyle.Render(fmt.Sprintf("%d", len(behind)))) fmt.Print(warningStyle.Render(fmt.Sprintf("%d", len(behind))))
fmt.Print(healthLabelStyle.Render(" to pull")) fmt.Print(dimStyle.Render(" to pull"))
} else { } else {
fmt.Print(healthGoodStyle.Render("up to date")) fmt.Print(successStyle.Render("up to date"))
} }
// Errors (only if any) // Errors (only if any)
if len(errors) > 0 { if len(errors) > 0 {
fmt.Print(healthLabelStyle.Render(" │ ")) fmt.Print(dimStyle.Render(" | "))
fmt.Print(healthBadStyle.Render(fmt.Sprintf("%d", len(errors)))) fmt.Print(errorStyle.Render(fmt.Sprintf("%d", len(errors))))
fmt.Print(healthLabelStyle.Render(" errors")) fmt.Print(dimStyle.Render(" errors"))
} }
fmt.Println() fmt.Println()
} }
func formatRepoList(repos []string) string { func formatRepoList(reposList []string) string {
if len(repos) <= 5 { if len(reposList) <= 5 {
return joinRepos(repos) return joinRepos(reposList)
} }
return joinRepos(repos[:5]) + fmt.Sprintf(" +%d more", len(repos)-5) return joinRepos(reposList[:5]) + fmt.Sprintf(" +%d more", len(reposList)-5)
} }
func joinRepos(repos []string) string { func joinRepos(reposList []string) string {
result := "" result := ""
for i, r := range repos { for i, r := range reposList {
if i > 0 { if i > 0 {
result += ", " result += ", "
} }

View file

@ -6,10 +6,12 @@ import (
"sort" "sort"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/repos" "github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// Impact-specific styles
var ( var (
impactDirectStyle = lipgloss.NewStyle(). impactDirectStyle = lipgloss.NewStyle().
Bold(true). Bold(true).
@ -22,8 +24,8 @@ var (
Foreground(lipgloss.Color("#22c55e")) // green-500 Foreground(lipgloss.Color("#22c55e")) // green-500
) )
// AddImpactCommand adds the 'impact' command to the given parent command. // addImpactCommand adds the 'impact' command to the given parent command.
func AddImpactCommand(parent *clir.Command) { func addImpactCommand(parent *clir.Command) {
var registryPath string var registryPath string
impactCmd := parent.NewSubCommand("impact", "Show impact of changing a repo") impactCmd := parent.NewSubCommand("impact", "Show impact of changing a repo")
@ -110,21 +112,21 @@ func runImpact(registryPath string, repoName string) error {
fmt.Println() fmt.Println()
if len(allAffected) == 0 { if len(allAffected) == 0 {
fmt.Printf("%s No repos depend on %s\n", impactSafeStyle.Render(""), repoName) fmt.Printf("%s No repos depend on %s\n", impactSafeStyle.Render("v"), repoName)
return nil return nil
} }
// Direct dependents // Direct dependents
if len(direct) > 0 { if len(direct) > 0 {
fmt.Printf("%s %d direct dependent(s):\n", fmt.Printf("%s %d direct dependent(s):\n",
impactDirectStyle.Render(""), impactDirectStyle.Render("*"),
len(direct), len(direct),
) )
for _, d := range direct { for _, d := range direct {
r, _ := reg.Get(d) r, _ := reg.Get(d)
desc := "" desc := ""
if r != nil && r.Description != "" { if r != nil && r.Description != "" {
desc = dimStyle.Render(" - " + truncate(r.Description, 40)) desc = dimStyle.Render(" - " + shared.Truncate(r.Description, 40))
} }
fmt.Printf(" %s%s\n", d, desc) fmt.Printf(" %s%s\n", d, desc)
} }
@ -134,14 +136,14 @@ func runImpact(registryPath string, repoName string) error {
// Indirect dependents // Indirect dependents
if len(indirect) > 0 { if len(indirect) > 0 {
fmt.Printf("%s %d transitive dependent(s):\n", fmt.Printf("%s %d transitive dependent(s):\n",
impactIndirectStyle.Render(""), impactIndirectStyle.Render("o"),
len(indirect), len(indirect),
) )
for _, d := range indirect { for _, d := range indirect {
r, _ := reg.Get(d) r, _ := reg.Get(d)
desc := "" desc := ""
if r != nil && r.Description != "" { if r != nil && r.Description != "" {
desc = dimStyle.Render(" - " + truncate(r.Description, 40)) desc = dimStyle.Render(" - " + shared.Truncate(r.Description, 40))
} }
fmt.Printf(" %s%s\n", d, desc) fmt.Printf(" %s%s\n", d, desc)
} }

View file

@ -10,10 +10,12 @@ import (
"time" "time"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/repos" "github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// Issue-specific styles
var ( var (
issueRepoStyle = lipgloss.NewStyle(). issueRepoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6b7280")) // gray-500 Foreground(lipgloss.Color("#6b7280")) // gray-500
@ -60,8 +62,8 @@ type GitHubIssue struct {
RepoName string `json:"-"` RepoName string `json:"-"`
} }
// AddIssuesCommand adds the 'issues' command to the given parent command. // addIssuesCommand adds the 'issues' command to the given parent command.
func AddIssuesCommand(parent *clir.Command) { func addIssuesCommand(parent *clir.Command) {
var registryPath string var registryPath string
var limit int var limit int
var assignee string var assignee string
@ -204,7 +206,7 @@ func printIssue(issue GitHubIssue) {
// #42 [core-bio] Fix avatar upload // #42 [core-bio] Fix avatar upload
num := issueNumberStyle.Render(fmt.Sprintf("#%d", issue.Number)) num := issueNumberStyle.Render(fmt.Sprintf("#%d", issue.Number))
repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", issue.RepoName)) repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", issue.RepoName))
title := issueTitleStyle.Render(truncate(issue.Title, 60)) title := issueTitleStyle.Render(shared.Truncate(issue.Title, 60))
line := fmt.Sprintf(" %s %s %s", num, repo, title) line := fmt.Sprintf(" %s %s %s", num, repo, title)
@ -227,33 +229,8 @@ func printIssue(issue GitHubIssue) {
} }
// Add age // Add age
age := formatAge(issue.CreatedAt) age := shared.FormatAge(issue.CreatedAt)
line += " " + issueAgeStyle.Render(age) line += " " + issueAgeStyle.Render(age)
fmt.Println(line) fmt.Println(line)
} }
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
func formatAge(t time.Time) string {
d := time.Since(t)
if d < time.Hour {
return fmt.Sprintf("%dm ago", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh ago", int(d.Hours()))
}
if d < 7*24*time.Hour {
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
if d < 30*24*time.Hour {
return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7)))
}
return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30)))
}

View file

@ -11,8 +11,8 @@ import (
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// AddPullCommand adds the 'pull' command to the given parent command. // addPullCommand adds the 'pull' command to the given parent command.
func AddPullCommand(parent *clir.Command) { func addPullCommand(parent *clir.Command) {
var registryPath string var registryPath string
var all bool var all bool
@ -119,10 +119,10 @@ func runPull(registryPath string, all bool) error {
err := gitPull(ctx, s.Path) err := gitPull(ctx, s.Path)
if err != nil { if err != nil {
fmt.Printf("%s\n", errorStyle.Render(" "+err.Error())) fmt.Printf("%s\n", errorStyle.Render("x "+err.Error()))
failed++ failed++
} else { } else {
fmt.Printf("%s\n", successStyle.Render("")) fmt.Printf("%s\n", successStyle.Render("v"))
succeeded++ succeeded++
} }
} }

View file

@ -5,13 +5,14 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/git" "github.com/host-uk/core/pkg/git"
"github.com/host-uk/core/pkg/repos" "github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// AddPushCommand adds the 'push' command to the given parent command. // addPushCommand adds the 'push' command to the given parent command.
func AddPushCommand(parent *clir.Command) { func addPushCommand(parent *clir.Command) {
var registryPath string var registryPath string
var force bool var force bool
@ -108,7 +109,7 @@ func runPush(registryPath string, force bool) error {
// Confirm unless --force // Confirm unless --force
if !force { if !force {
fmt.Println() fmt.Println()
if !confirm(fmt.Sprintf("Push %d commit(s) to %d repo(s)?", totalCommits, len(aheadRepos))) { if !shared.Confirm(fmt.Sprintf("Push %d commit(s) to %d repo(s)?", totalCommits, len(aheadRepos))) {
fmt.Println("Aborted.") fmt.Println("Aborted.")
return nil return nil
} }
@ -127,10 +128,10 @@ func runPush(registryPath string, force bool) error {
var succeeded, failed int var succeeded, failed int
for _, r := range results { for _, r := range results {
if r.Success { if r.Success {
fmt.Printf(" %s %s\n", successStyle.Render(""), r.Name) fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name)
succeeded++ succeeded++
} else { } else {
fmt.Printf(" %s %s: %s\n", errorStyle.Render(""), r.Name, r.Error) fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, r.Error)
failed++ failed++
} }
} }

View file

@ -10,10 +10,12 @@ import (
"time" "time"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/repos" "github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// PR-specific styles
var ( var (
prNumberStyle = lipgloss.NewStyle(). prNumberStyle = lipgloss.NewStyle().
Bold(true). Bold(true).
@ -65,8 +67,8 @@ type GitHubPR struct {
RepoName string `json:"-"` RepoName string `json:"-"`
} }
// AddReviewsCommand adds the 'reviews' command to the given parent command. // addReviewsCommand adds the 'reviews' command to the given parent command.
func AddReviewsCommand(parent *clir.Command) { func addReviewsCommand(parent *clir.Command) {
var registryPath string var registryPath string
var author string var author string
var showAll bool var showAll bool
@ -175,13 +177,13 @@ func runReviews(registryPath string, author string, showAll bool) error {
fmt.Println() fmt.Println()
fmt.Printf("%d open PR(s)", len(allPRs)) fmt.Printf("%d open PR(s)", len(allPRs))
if pending > 0 { if pending > 0 {
fmt.Printf(" · %s", prPendingStyle.Render(fmt.Sprintf("%d pending", pending))) fmt.Printf(" * %s", prPendingStyle.Render(fmt.Sprintf("%d pending", pending)))
} }
if approved > 0 { if approved > 0 {
fmt.Printf(" · %s", prApprovedStyle.Render(fmt.Sprintf("%d approved", approved))) fmt.Printf(" * %s", prApprovedStyle.Render(fmt.Sprintf("%d approved", approved)))
} }
if changesRequested > 0 { if changesRequested > 0 {
fmt.Printf(" · %s", prChangesStyle.Render(fmt.Sprintf("%d changes requested", changesRequested))) fmt.Printf(" * %s", prChangesStyle.Render(fmt.Sprintf("%d changes requested", changesRequested)))
} }
fmt.Println() fmt.Println()
fmt.Println() fmt.Println()
@ -243,18 +245,18 @@ func printPR(pr GitHubPR) {
// #12 [core-php] Webhook validation // #12 [core-php] Webhook validation
num := prNumberStyle.Render(fmt.Sprintf("#%d", pr.Number)) num := prNumberStyle.Render(fmt.Sprintf("#%d", pr.Number))
repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", pr.RepoName)) repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", pr.RepoName))
title := prTitleStyle.Render(truncate(pr.Title, 50)) title := prTitleStyle.Render(shared.Truncate(pr.Title, 50))
author := prAuthorStyle.Render("@" + pr.Author.Login) author := prAuthorStyle.Render("@" + pr.Author.Login)
// Review status // Review status
var status string var status string
switch pr.ReviewDecision { switch pr.ReviewDecision {
case "APPROVED": case "APPROVED":
status = prApprovedStyle.Render(" approved") status = prApprovedStyle.Render("v approved")
case "CHANGES_REQUESTED": case "CHANGES_REQUESTED":
status = prChangesStyle.Render(" changes requested") status = prChangesStyle.Render("* changes requested")
default: default:
status = prPendingStyle.Render(" pending review") status = prPendingStyle.Render("o pending review")
} }
// Draft indicator // Draft indicator
@ -263,7 +265,7 @@ func printPR(pr GitHubPR) {
draft = prDraftStyle.Render(" [draft]") draft = prDraftStyle.Render(" [draft]")
} }
age := formatAge(pr.CreatedAt) age := shared.FormatAge(pr.CreatedAt)
fmt.Printf(" %s %s %s%s %s %s %s\n", num, repo, title, draft, author, status, issueAgeStyle.Render(age)) fmt.Printf(" %s %s %s%s %s %s %s\n", num, repo, title, draft, author, status, issueAgeStyle.Render(age))
} }

View file

@ -15,8 +15,8 @@ import (
"golang.org/x/text/language" "golang.org/x/text/language"
) )
// AddSyncCommand adds the 'sync' command to the given parent command. // addSyncCommand adds the 'sync' command to the given parent command.
func AddSyncCommand(parent *clir.Command) { func addSyncCommand(parent *clir.Command) {
syncCmd := parent.NewSubCommand("sync", "Synchronizes the public service APIs with their internal implementations.") syncCmd := parent.NewSubCommand("sync", "Synchronizes the public service APIs with their internal implementations.")
syncCmd.LongDescription("This command scans the 'pkg' directory for services and ensures that the\ntop-level public API for each service is in sync with its internal implementation.\nIt automatically generates the necessary Go files with type aliases.") syncCmd.LongDescription("This command scans the 'pkg' directory for services and ensures that the\ntop-level public API for each service is in sync with its internal implementation.\nIt automatically generates the necessary Go files with type aliases.")
syncCmd.Action(func() error { syncCmd.Action(func() error {

504
cmd/dev/dev_vm.go Normal file
View 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
}

View file

@ -1,4 +1,3 @@
// Package dev provides multi-repo development workflow commands.
package dev package dev
import ( import (
@ -11,52 +10,14 @@ import (
"strings" "strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/git" "github.com/host-uk/core/pkg/git"
"github.com/host-uk/core/pkg/repos" "github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
var ( // addWorkCommand adds the 'work' command to the given parent command.
// Table styles func addWorkCommand(parent *clir.Command) {
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#3b82f6")). // blue-500
Padding(0, 1)
cellStyle = lipgloss.NewStyle().
Padding(0, 1)
dirtyStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#ef4444")). // red-500
Padding(0, 1)
aheadStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#22c55e")). // green-500
Padding(0, 1)
cleanStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6b7280")). // gray-500
Padding(0, 1)
repoNameStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#e2e8f0")). // gray-200
Padding(0, 1)
successStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#22c55e")). // green-500
Bold(true)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#ef4444")). // red-500
Bold(true)
dimStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6b7280")) // gray-500
)
// AddWorkCommand adds the 'work' command to the given parent command.
func AddWorkCommand(parent *clir.Command) {
var statusOnly bool var statusOnly bool
var autoCommit bool var autoCommit bool
var registryPath string var registryPath string
@ -156,14 +117,15 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
// Auto-commit dirty repos if requested // Auto-commit dirty repos if requested
if autoCommit && len(dirtyRepos) > 0 { if autoCommit && len(dirtyRepos) > 0 {
fmt.Println() fmt.Println()
fmt.Printf("%s\n", headerStyle.Render("Committing dirty repos with Claude...")) hdrStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#3b82f6"))
fmt.Printf("%s\n", hdrStyle.Render("Committing dirty repos with Claude..."))
fmt.Println() fmt.Println()
for _, s := range dirtyRepos { for _, s := range dirtyRepos {
if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil { if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil {
fmt.Printf(" %s %s: %s\n", errorStyle.Render(""), s.Name, err) fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
} else { } else {
fmt.Printf(" %s %s\n", successStyle.Render(""), s.Name) fmt.Printf(" %s %s\n", successStyle.Render("v"), s.Name)
} }
} }
@ -205,7 +167,7 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
} }
fmt.Println() fmt.Println()
if !confirm("Push all?") { if !shared.Confirm("Push all?") {
fmt.Println("Aborted.") fmt.Println("Aborted.")
return nil return nil
} }
@ -222,9 +184,9 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
for _, r := range results { for _, r := range results {
if r.Success { if r.Success {
fmt.Printf(" %s %s\n", successStyle.Render(""), r.Name) fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name)
} else { } else {
fmt.Printf(" %s %s: %s\n", errorStyle.Render(""), r.Name, r.Error) fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, r.Error)
} }
} }
@ -252,7 +214,7 @@ func printStatusTable(statuses []git.RepoStatus) {
) )
// Print separator // Print separator
fmt.Println(strings.Repeat("", nameWidth+2+10+11+8+7)) fmt.Println(strings.Repeat("-", nameWidth+2+10+11+8+7))
// Print rows // Print rows
for _, s := range statuses { for _, s := range statuses {
@ -309,12 +271,12 @@ func printStatusTable(statuses []git.RepoStatus) {
func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error { func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
// Load AGENTS.md context if available // Load AGENTS.md context if available
agentsPath := filepath.Join(filepath.Dir(registryPath), "AGENTS.md") agentsPath := filepath.Join(filepath.Dir(registryPath), "AGENTS.md")
var context string var agentContext string
if data, err := os.ReadFile(agentsPath); err == nil { if data, err := os.ReadFile(agentsPath); err == nil {
context = string(data) + "\n\n" agentContext = string(data) + "\n\n"
} }
prompt := context + "Review the uncommitted changes and create an appropriate commit. " + prompt := agentContext + "Review the uncommitted changes and create an appropriate commit. " +
"Use Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>. Be concise." "Use Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>. Be concise."
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep") cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep")
@ -325,11 +287,3 @@ func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string)
return cmd.Run() return cmd.Run()
} }
func confirm(prompt string) bool {
fmt.Printf("%s [y/N] ", prompt)
var response string
fmt.Scanln(&response)
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
}

View file

@ -2,19 +2,12 @@
package docs package docs
import ( import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// Style and utility aliases // Style and utility aliases from shared
var ( var (
repoNameStyle = shared.RepoNameStyle repoNameStyle = shared.RepoNameStyle
successStyle = shared.SuccessStyle successStyle = shared.SuccessStyle
@ -24,6 +17,7 @@ var (
confirm = shared.Confirm confirm = shared.Confirm
) )
// Package-specific styles
var ( var (
docsFoundStyle = lipgloss.NewStyle(). docsFoundStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#22c55e")) // green-500 Foreground(lipgloss.Color("#22c55e")) // green-500
@ -35,17 +29,6 @@ var (
Foreground(lipgloss.Color("#3b82f6")) // blue-500 Foreground(lipgloss.Color("#3b82f6")) // blue-500
) )
// RepoDocInfo holds documentation info for a repo
type RepoDocInfo struct {
Name string
Path string
HasDocs bool
Readme string
ClaudeMd string
Changelog string
DocsFiles []string // All files in docs/ directory (recursive)
}
// AddDocsCommand adds the 'docs' command to the given parent command. // AddDocsCommand adds the 'docs' command to the given parent command.
func AddDocsCommand(parent *clir.Cli) { func AddDocsCommand(parent *clir.Cli) {
docsCmd := parent.NewSubCommand("docs", "Documentation management") docsCmd := parent.NewSubCommand("docs", "Documentation management")
@ -56,308 +39,3 @@ func AddDocsCommand(parent *clir.Cli) {
addDocsSyncCommand(docsCmd) addDocsSyncCommand(docsCmd)
addDocsListCommand(docsCmd) addDocsListCommand(docsCmd)
} }
func addDocsSyncCommand(parent *clir.Command) {
var registryPath string
var dryRun bool
var outputDir string
syncCmd := parent.NewSubCommand("sync", "Sync documentation to core-php/docs/packages/")
syncCmd.StringFlag("registry", "Path to repos.yaml", &registryPath)
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", &registryPath)
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
View 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", &registryPath)
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
View 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
View 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", &registryPath)
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
View 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, ""
}

View file

@ -3,18 +3,12 @@ package doctor
import ( import (
"fmt" "fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// Style aliases // Style aliases from shared
var ( var (
successStyle = shared.SuccessStyle successStyle = shared.SuccessStyle
errorStyle = shared.ErrorStyle errorStyle = shared.ErrorStyle
@ -36,95 +30,15 @@ func AddDoctorCommand(parent *clir.Cli) {
}) })
} }
type check struct {
name string
description string
command string
args []string
required bool
versionFlag string
}
func runDoctor(verbose bool) error { func runDoctor(verbose bool) error {
fmt.Println("Checking development environment...") fmt.Println("Checking development environment...")
fmt.Println() fmt.Println()
checks := []check{
// Required tools
{
name: "Git",
description: "Version control",
command: "git",
args: []string{"--version"},
required: true,
versionFlag: "--version",
},
{
name: "GitHub CLI",
description: "GitHub integration (issues, PRs, CI)",
command: "gh",
args: []string{"--version"},
required: true,
versionFlag: "--version",
},
{
name: "PHP",
description: "Laravel packages",
command: "php",
args: []string{"-v"},
required: true,
versionFlag: "-v",
},
{
name: "Composer",
description: "PHP dependencies",
command: "composer",
args: []string{"--version"},
required: true,
versionFlag: "--version",
},
{
name: "Node.js",
description: "Frontend builds",
command: "node",
args: []string{"--version"},
required: true,
versionFlag: "--version",
},
// Optional tools
{
name: "pnpm",
description: "Fast package manager",
command: "pnpm",
args: []string{"--version"},
required: false,
versionFlag: "--version",
},
{
name: "Claude Code",
description: "AI-assisted development",
command: "claude",
args: []string{"--version"},
required: false,
versionFlag: "--version",
},
{
name: "Docker",
description: "Container runtime",
command: "docker",
args: []string{"--version"},
required: false,
versionFlag: "--version",
},
}
var passed, failed, optional int var passed, failed, optional int
// Check required tools
fmt.Println("Required:") fmt.Println("Required:")
for _, c := range checks { for _, c := range requiredChecks {
if !c.required {
continue
}
ok, version := runCheck(c) ok, version := runCheck(c)
if ok { if ok {
if verbose && version != "" { if verbose && version != "" {
@ -139,11 +53,9 @@ func runDoctor(verbose bool) error {
} }
} }
// Check optional tools
fmt.Println("\nOptional:") fmt.Println("\nOptional:")
for _, c := range checks { for _, c := range optionalChecks {
if c.required {
continue
}
ok, version := runCheck(c) ok, version := runCheck(c)
if ok { if ok {
if verbose && version != "" { if verbose && version != "" {
@ -158,7 +70,7 @@ func runDoctor(verbose bool) error {
} }
} }
// Check SSH // Check GitHub access
fmt.Println("\nGitHub Access:") fmt.Println("\nGitHub Access:")
if checkGitHubSSH() { if checkGitHubSSH() {
fmt.Printf(" %s SSH key found\n", successStyle.Render("✓")) fmt.Printf(" %s SSH key found\n", successStyle.Render("✓"))
@ -176,38 +88,7 @@ func runDoctor(verbose bool) error {
// Check workspace // Check workspace
fmt.Println("\nWorkspace:") fmt.Println("\nWorkspace:")
registryPath, err := repos.FindRegistry() checkWorkspace()
if err == nil {
fmt.Printf(" %s Found repos.yaml at %s\n", successStyle.Render("✓"), registryPath)
reg, err := repos.LoadRegistry(registryPath)
if err == nil {
basePath := reg.BasePath
if basePath == "" {
basePath = "./packages"
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(registryPath), basePath)
}
if strings.HasPrefix(basePath, "~/") {
home, _ := os.UserHomeDir()
basePath = filepath.Join(home, basePath[2:])
}
// Count existing repos
allRepos := reg.List()
var cloned int
for _, repo := range allRepos {
repoPath := filepath.Join(basePath, repo.Name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
cloned++
}
}
fmt.Printf(" %s %d/%d repos cloned\n", successStyle.Render("✓"), cloned, len(allRepos))
}
} else {
fmt.Printf(" %s No repos.yaml found (run from workspace directory)\n", dimStyle.Render("○"))
}
// Summary // Summary
fmt.Println() fmt.Println()
@ -221,63 +102,3 @@ func runDoctor(verbose bool) error {
fmt.Printf("%s Environment ready\n", successStyle.Render("Doctor:")) fmt.Printf("%s Environment ready\n", successStyle.Render("Doctor:"))
return nil return nil
} }
func runCheck(c check) (bool, string) {
cmd := exec.Command(c.command, c.args...)
output, err := cmd.CombinedOutput()
if err != nil {
return false, ""
}
// Extract first line as version
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) > 0 {
return true, strings.TrimSpace(lines[0])
}
return true, ""
}
func checkGitHubSSH() bool {
// Just check if SSH keys exist - don't try to authenticate
// (key might be locked/passphrase protected)
home, err := os.UserHomeDir()
if err != nil {
return false
}
sshDir := filepath.Join(home, ".ssh")
keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"}
for _, key := range keyPatterns {
keyPath := filepath.Join(sshDir, key)
if _, err := os.Stat(keyPath); err == nil {
return true
}
}
return false
}
func checkGitHubCLI() bool {
cmd := exec.Command("gh", "auth", "status")
output, _ := cmd.CombinedOutput()
// Check for any successful login (even if there's also a failing token)
return strings.Contains(string(output), "Logged in to")
}
func printInstallInstructions() {
switch runtime.GOOS {
case "darwin":
fmt.Println(" brew install git gh php composer node pnpm docker")
fmt.Println(" brew install --cask claude")
case "linux":
fmt.Println(" # Install via your package manager or:")
fmt.Println(" # Git: apt install git")
fmt.Println(" # GitHub CLI: https://cli.github.com/")
fmt.Println(" # PHP: apt install php8.3-cli")
fmt.Println(" # Node: https://nodejs.org/")
fmt.Println(" # pnpm: npm install -g pnpm")
default:
fmt.Println(" See documentation for your OS")
}
}

77
cmd/doctor/environment.go Normal file
View 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
View 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")
}
}

View file

@ -4,14 +4,6 @@
package gocmd package gocmd
import ( import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/cmd/shared"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
@ -44,590 +36,3 @@ func AddGoCommands(parent *clir.Cli) {
addGoModCommand(goCmd) addGoModCommand(goCmd)
addGoWorkCommand(goCmd) addGoWorkCommand(goCmd)
} }
func addGoTestCommand(parent *clir.Command) {
var (
coverage bool
pkg string
run string
short bool
race bool
json bool
verbose bool
)
testCmd := parent.NewSubCommand("test", "Run tests with coverage")
testCmd.LongDescription("Run Go tests with coverage reporting.\n\n" +
"Sets MACOSX_DEPLOYMENT_TARGET=26.0 to suppress linker warnings.\n" +
"Filters noisy output and provides colour-coded coverage.\n\n" +
"Examples:\n" +
" core go test\n" +
" core go test --coverage\n" +
" core go test --pkg ./pkg/crypt\n" +
" core go test --run TestHash")
testCmd.BoolFlag("coverage", "Show detailed per-package coverage", &coverage)
testCmd.StringFlag("pkg", "Package to test (default: ./...)", &pkg)
testCmd.StringFlag("run", "Run only tests matching regexp", &run)
testCmd.BoolFlag("short", "Run only short tests", &short)
testCmd.BoolFlag("race", "Enable race detector", &race)
testCmd.BoolFlag("json", "Output JSON results", &json)
testCmd.BoolFlag("v", "Verbose output", &verbose)
testCmd.Action(func() error {
return runGoTest(coverage, pkg, run, short, race, json, verbose)
})
}
func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose bool) error {
if pkg == "" {
pkg = "./..."
}
args := []string{"test"}
if coverage {
args = append(args, "-cover")
} else {
args = append(args, "-cover")
}
if run != "" {
args = append(args, "-run", run)
}
if short {
args = append(args, "-short")
}
if race {
args = append(args, "-race")
}
if verbose {
args = append(args, "-v")
}
args = append(args, pkg)
if !jsonOut {
fmt.Printf("%s Running tests\n", dimStyle.Render("Test:"))
fmt.Printf(" %s %s\n", dimStyle.Render("Package:"), pkg)
fmt.Println()
}
cmd := exec.Command("go", args...)
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0")
cmd.Dir, _ = os.Getwd()
output, err := cmd.CombinedOutput()
outputStr := string(output)
// Filter linker warnings
lines := strings.Split(outputStr, "\n")
var filtered []string
for _, line := range lines {
if !strings.Contains(line, "ld: warning:") {
filtered = append(filtered, line)
}
}
outputStr = strings.Join(filtered, "\n")
// Parse results
passed, failed, skipped := parseTestResults(outputStr)
cov := parseOverallCoverage(outputStr)
if jsonOut {
fmt.Printf(`{"passed":%d,"failed":%d,"skipped":%d,"coverage":%.1f,"exit_code":%d}`,
passed, failed, skipped, cov, cmd.ProcessState.ExitCode())
fmt.Println()
return err
}
// Print filtered output if verbose or failed
if verbose || err != nil {
fmt.Println(outputStr)
}
// Summary
if err == nil {
fmt.Printf(" %s %d passed\n", successStyle.Render("✓"), passed)
} else {
fmt.Printf(" %s %d passed, %d failed\n", errorStyle.Render("✗"), passed, failed)
}
if cov > 0 {
covStyle := successStyle
if cov < 50 {
covStyle = errorStyle
} else if cov < 80 {
covStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b"))
}
fmt.Printf("\n %s %s\n", dimStyle.Render("Coverage:"), covStyle.Render(fmt.Sprintf("%.1f%%", cov)))
}
if err == nil {
fmt.Printf("\n%s\n", successStyle.Render("PASS All tests passed"))
} else {
fmt.Printf("\n%s\n", errorStyle.Render("FAIL Some tests failed"))
}
return err
}
func parseTestResults(output string) (passed, failed, skipped int) {
passRe := regexp.MustCompile(`(?m)^ok\s+`)
failRe := regexp.MustCompile(`(?m)^FAIL\s+`)
skipRe := regexp.MustCompile(`(?m)^\?\s+`)
passed = len(passRe.FindAllString(output, -1))
failed = len(failRe.FindAllString(output, -1))
skipped = len(skipRe.FindAllString(output, -1))
return
}
func parseOverallCoverage(output string) float64 {
re := regexp.MustCompile(`coverage:\s+([\d.]+)%`)
matches := re.FindAllStringSubmatch(output, -1)
if len(matches) == 0 {
return 0
}
var total float64
for _, m := range matches {
var cov float64
fmt.Sscanf(m[1], "%f", &cov)
total += cov
}
return total / float64(len(matches))
}
func addGoCovCommand(parent *clir.Command) {
var (
pkg string
html bool
open bool
threshold float64
)
covCmd := parent.NewSubCommand("cov", "Run tests with coverage report")
covCmd.LongDescription("Run tests and generate coverage report.\n\n" +
"Examples:\n" +
" core go cov # Run with coverage summary\n" +
" core go cov --html # Generate HTML report\n" +
" core go cov --open # Generate and open HTML report\n" +
" core go cov --threshold 80 # Fail if coverage < 80%")
covCmd.StringFlag("pkg", "Package to test (default: ./...)", &pkg)
covCmd.BoolFlag("html", "Generate HTML coverage report", &html)
covCmd.BoolFlag("open", "Generate and open HTML report in browser", &open)
covCmd.Float64Flag("threshold", "Minimum coverage percentage (exit 1 if below)", &threshold)
covCmd.Action(func() error {
if pkg == "" {
// Auto-discover packages with tests
pkgs, err := findTestPackages(".")
if err != nil {
return fmt.Errorf("failed to discover test packages: %w", err)
}
if len(pkgs) == 0 {
return fmt.Errorf("no test packages found")
}
pkg = strings.Join(pkgs, " ")
}
// Create temp file for coverage data
covFile, err := os.CreateTemp("", "coverage-*.out")
if err != nil {
return fmt.Errorf("failed to create coverage file: %w", err)
}
covPath := covFile.Name()
covFile.Close()
defer os.Remove(covPath)
fmt.Printf("%s Running tests with coverage\n", dimStyle.Render("Coverage:"))
// Truncate package list if too long for display
displayPkg := pkg
if len(displayPkg) > 60 {
displayPkg = displayPkg[:57] + "..."
}
fmt.Printf(" %s %s\n", dimStyle.Render("Package:"), displayPkg)
fmt.Println()
// Run tests with coverage
// We need to split pkg into individual arguments if it contains spaces
pkgArgs := strings.Fields(pkg)
args := append([]string{"test", "-coverprofile=" + covPath, "-covermode=atomic"}, pkgArgs...)
cmd := exec.Command("go", args...)
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
testErr := cmd.Run()
// Get coverage percentage
covCmd := exec.Command("go", "tool", "cover", "-func="+covPath)
covOutput, err := covCmd.Output()
if err != nil {
if testErr != nil {
return testErr
}
return fmt.Errorf("failed to get coverage: %w", err)
}
// Parse total coverage from last line
lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n")
var totalCov float64
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
// Format: "total: (statements) XX.X%"
if strings.Contains(lastLine, "total:") {
parts := strings.Fields(lastLine)
if len(parts) >= 3 {
covStr := strings.TrimSuffix(parts[len(parts)-1], "%")
fmt.Sscanf(covStr, "%f", &totalCov)
}
}
}
// Print coverage summary
fmt.Println()
covStyle := successStyle
if totalCov < 50 {
covStyle = errorStyle
} else if totalCov < 80 {
covStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b"))
}
fmt.Printf(" %s %s\n", dimStyle.Render("Total:"), covStyle.Render(fmt.Sprintf("%.1f%%", totalCov)))
// Generate HTML if requested
if html || open {
htmlPath := "coverage.html"
htmlCmd := exec.Command("go", "tool", "cover", "-html="+covPath, "-o="+htmlPath)
if err := htmlCmd.Run(); err != nil {
return fmt.Errorf("failed to generate HTML: %w", err)
}
fmt.Printf(" %s %s\n", dimStyle.Render("HTML:"), htmlPath)
if open {
// Open in browser
var openCmd *exec.Cmd
switch {
case exec.Command("which", "open").Run() == nil:
openCmd = exec.Command("open", htmlPath)
case exec.Command("which", "xdg-open").Run() == nil:
openCmd = exec.Command("xdg-open", htmlPath)
default:
fmt.Printf(" %s\n", dimStyle.Render("(open manually)"))
}
if openCmd != nil {
openCmd.Run()
}
}
}
// Check threshold
if threshold > 0 && totalCov < threshold {
fmt.Printf("\n%s Coverage %.1f%% is below threshold %.1f%%\n",
errorStyle.Render("FAIL"), totalCov, threshold)
return fmt.Errorf("coverage below threshold")
}
if testErr != nil {
return testErr
}
fmt.Printf("\n%s\n", successStyle.Render("OK"))
return nil
})
}
func findTestPackages(root string) ([]string, error) {
pkgMap := make(map[string]bool)
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") {
dir := filepath.Dir(path)
if !strings.HasPrefix(dir, ".") {
dir = "./" + dir
}
pkgMap[dir] = true
}
return nil
})
if err != nil {
return nil, err
}
var pkgs []string
for pkg := range pkgMap {
pkgs = append(pkgs, pkg)
}
return pkgs, nil
}
func addGoFmtCommand(parent *clir.Command) {
var (
fix bool
diff bool
check bool
)
fmtCmd := parent.NewSubCommand("fmt", "Format Go code")
fmtCmd.LongDescription("Format Go code using gofmt or goimports.\n\n" +
"Examples:\n" +
" core go fmt # Check formatting\n" +
" core go fmt --fix # Fix formatting\n" +
" core go fmt --diff # Show diff")
fmtCmd.BoolFlag("fix", "Fix formatting in place", &fix)
fmtCmd.BoolFlag("diff", "Show diff of changes", &diff)
fmtCmd.BoolFlag("check", "Check only, exit 1 if not formatted", &check)
fmtCmd.Action(func() error {
args := []string{}
if fix {
args = append(args, "-w")
}
if diff {
args = append(args, "-d")
}
if !fix && !diff {
args = append(args, "-l")
}
args = append(args, ".")
// Try goimports first, fall back to gofmt
var cmd *exec.Cmd
if _, err := exec.LookPath("goimports"); err == nil {
cmd = exec.Command("goimports", args...)
} else {
cmd = exec.Command("gofmt", args...)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
})
}
func addGoLintCommand(parent *clir.Command) {
var fix bool
lintCmd := parent.NewSubCommand("lint", "Run golangci-lint")
lintCmd.LongDescription("Run golangci-lint on the codebase.\n\n" +
"Examples:\n" +
" core go lint\n" +
" core go lint --fix")
lintCmd.BoolFlag("fix", "Fix issues automatically", &fix)
lintCmd.Action(func() error {
args := []string{"run"}
if fix {
args = append(args, "--fix")
}
cmd := exec.Command("golangci-lint", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
})
}
func addGoInstallCommand(parent *clir.Command) {
var verbose bool
var noCgo bool
installCmd := parent.NewSubCommand("install", "Install Go binary")
installCmd.LongDescription("Install Go binary to $GOPATH/bin.\n\n" +
"Examples:\n" +
" core go install # Install current module\n" +
" core go install ./cmd/core # Install specific path\n" +
" core go install --no-cgo # Pure Go (no C dependencies)\n" +
" core go install -v # Verbose output")
installCmd.BoolFlag("v", "Verbose output", &verbose)
installCmd.BoolFlag("no-cgo", "Disable CGO (CGO_ENABLED=0)", &noCgo)
installCmd.Action(func() error {
// Get install path from args or default to current dir
args := installCmd.OtherArgs()
installPath := "./..."
if len(args) > 0 {
installPath = args[0]
}
// Detect if we're in a module with cmd/ subdirectories or a root main.go
if installPath == "./..." {
if _, err := os.Stat("core.go"); err == nil {
installPath = "."
} else if entries, err := os.ReadDir("cmd"); err == nil && len(entries) > 0 {
installPath = "./cmd/..."
} else if _, err := os.Stat("main.go"); err == nil {
installPath = "."
}
}
fmt.Printf("%s Installing\n", dimStyle.Render("Install:"))
fmt.Printf(" %s %s\n", dimStyle.Render("Path:"), installPath)
if noCgo {
fmt.Printf(" %s %s\n", dimStyle.Render("CGO:"), "disabled")
}
cmdArgs := []string{"install"}
if verbose {
cmdArgs = append(cmdArgs, "-v")
}
cmdArgs = append(cmdArgs, installPath)
cmd := exec.Command("go", cmdArgs...)
if noCgo {
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("\n%s\n", errorStyle.Render("FAIL Install failed"))
return err
}
// Show where it was installed
gopath := os.Getenv("GOPATH")
if gopath == "" {
home, _ := os.UserHomeDir()
gopath = filepath.Join(home, "go")
}
binDir := filepath.Join(gopath, "bin")
fmt.Printf("\n%s Installed to %s\n", successStyle.Render("OK"), binDir)
return nil
})
}
func addGoModCommand(parent *clir.Command) {
modCmd := parent.NewSubCommand("mod", "Module management")
modCmd.LongDescription("Go module management commands.\n\n" +
"Commands:\n" +
" tidy Add missing and remove unused modules\n" +
" download Download modules to local cache\n" +
" verify Verify dependencies\n" +
" graph Print module dependency graph")
// tidy
tidyCmd := modCmd.NewSubCommand("tidy", "Tidy go.mod")
tidyCmd.Action(func() error {
cmd := exec.Command("go", "mod", "tidy")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
})
// download
downloadCmd := modCmd.NewSubCommand("download", "Download modules")
downloadCmd.Action(func() error {
cmd := exec.Command("go", "mod", "download")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
})
// verify
verifyCmd := modCmd.NewSubCommand("verify", "Verify dependencies")
verifyCmd.Action(func() error {
cmd := exec.Command("go", "mod", "verify")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
})
// graph
graphCmd := modCmd.NewSubCommand("graph", "Print dependency graph")
graphCmd.Action(func() error {
cmd := exec.Command("go", "mod", "graph")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
})
}
func addGoWorkCommand(parent *clir.Command) {
workCmd := parent.NewSubCommand("work", "Workspace management")
workCmd.LongDescription("Go workspace management commands.\n\n" +
"Commands:\n" +
" sync Sync go.work with modules\n" +
" init Initialize go.work\n" +
" use Add module to workspace")
// sync
syncCmd := workCmd.NewSubCommand("sync", "Sync workspace")
syncCmd.Action(func() error {
cmd := exec.Command("go", "work", "sync")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
})
// init
initCmd := workCmd.NewSubCommand("init", "Initialize workspace")
initCmd.Action(func() error {
cmd := exec.Command("go", "work", "init")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
// Auto-add current module if go.mod exists
if _, err := os.Stat("go.mod"); err == nil {
cmd = exec.Command("go", "work", "use", ".")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
return nil
})
// use
useCmd := workCmd.NewSubCommand("use", "Add module to workspace")
useCmd.Action(func() error {
args := useCmd.OtherArgs()
if len(args) == 0 {
// Auto-detect modules
modules := findGoModules(".")
if len(modules) == 0 {
return fmt.Errorf("no go.mod files found")
}
for _, mod := range modules {
cmd := exec.Command("go", "work", "use", mod)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
fmt.Printf("Added %s\n", mod)
}
return nil
}
cmdArgs := append([]string{"work", "use"}, args...)
cmd := exec.Command("go", cmdArgs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
})
}
func findGoModules(root string) []string {
var modules []string
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.Name() == "go.mod" && path != "go.mod" {
modules = append(modules, filepath.Dir(path))
}
return nil
})
return modules
}

77
cmd/go/go_format.go Normal file
View 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
View 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
View 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
}

View file

@ -2,19 +2,7 @@
package pkg package pkg
import ( import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/cache"
"github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
@ -45,571 +33,3 @@ func AddPkgCommands(parent *clir.Cli) {
addPkgUpdateCommand(pkgCmd) addPkgUpdateCommand(pkgCmd)
addPkgOutdatedCommand(pkgCmd) addPkgOutdatedCommand(pkgCmd)
} }
// addPkgSearchCommand adds the 'pkg search' command.
func addPkgSearchCommand(parent *clir.Command) {
var org string
var pattern string
var repoType string
var limit int
var refresh bool
searchCmd := parent.NewSubCommand("search", "Search GitHub for packages")
searchCmd.LongDescription("Searches GitHub for repositories matching a pattern.\n" +
"Uses gh CLI for authenticated search. Results are cached for 1 hour.\n\n" +
"Examples:\n" +
" core pkg search # List all host-uk repos\n" +
" core pkg search --pattern 'core-*' # Search for core-* repos\n" +
" core pkg search --org mycompany # Search different org\n" +
" core pkg search --refresh # Bypass cache")
searchCmd.StringFlag("org", "GitHub organization (default: host-uk)", &org)
searchCmd.StringFlag("pattern", "Repo name pattern (* for wildcard)", &pattern)
searchCmd.StringFlag("type", "Filter by type in name (mod, services, plug, website)", &repoType)
searchCmd.IntFlag("limit", "Max results (default 50)", &limit)
searchCmd.BoolFlag("refresh", "Bypass cache and fetch fresh data", &refresh)
searchCmd.Action(func() error {
if org == "" {
org = "host-uk"
}
if pattern == "" {
pattern = "*"
}
if limit == 0 {
limit = 50
}
return runPkgSearch(org, pattern, repoType, limit, refresh)
})
}
type ghRepo struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
UpdatedAt string `json:"updated_at"`
Language string `json:"language"`
}
func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error {
// Initialize cache in workspace .core/ directory
var cacheDir string
if regPath, err := repos.FindRegistry(); err == nil {
cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache")
}
c, err := cache.New(cacheDir, 0)
if err != nil {
c = nil
}
cacheKey := cache.GitHubReposKey(org)
var ghRepos []ghRepo
var fromCache bool
// Try cache first (unless refresh requested)
if c != nil && !refresh {
if found, err := c.Get(cacheKey, &ghRepos); found && err == nil {
fromCache = true
age := c.Age(cacheKey)
fmt.Printf("%s %s %s\n", dimStyle.Render("Cache:"), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second))))
}
}
// Fetch from GitHub if not cached
if !fromCache {
if !ghAuthenticated() {
return fmt.Errorf("gh CLI not authenticated. Run: gh auth login")
}
if os.Getenv("GH_TOKEN") != "" {
fmt.Printf("%s GH_TOKEN env var is set - this may cause auth issues\n", dimStyle.Render("Note:"))
fmt.Printf("%s Unset it with: unset GH_TOKEN\n\n", dimStyle.Render(""))
}
fmt.Printf("%s %s... ", dimStyle.Render("Fetching:"), org)
cmd := exec.Command("gh", "repo", "list", org,
"--json", "name,description,visibility,updatedAt,primaryLanguage",
"--limit", fmt.Sprintf("%d", limit))
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println()
errStr := strings.TrimSpace(string(output))
if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") {
return fmt.Errorf("authentication failed - try: unset GH_TOKEN && gh auth login")
}
return fmt.Errorf("search failed: %s", errStr)
}
if err := json.Unmarshal(output, &ghRepos); err != nil {
return fmt.Errorf("failed to parse results: %w", err)
}
if c != nil {
_ = c.Set(cacheKey, ghRepos)
}
fmt.Printf("%s\n", successStyle.Render("✓"))
}
// Filter by glob pattern and type
var filtered []ghRepo
for _, r := range ghRepos {
if !matchGlob(pattern, r.Name) {
continue
}
if repoType != "" && !strings.Contains(r.Name, repoType) {
continue
}
filtered = append(filtered, r)
}
if len(filtered) == 0 {
fmt.Println("No repositories found matching pattern.")
return nil
}
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].Name < filtered[j].Name
})
fmt.Printf("Found %d repositories:\n\n", len(filtered))
for _, r := range filtered {
visibility := ""
if r.Visibility == "private" {
visibility = dimStyle.Render(" [private]")
}
desc := r.Description
if len(desc) > 50 {
desc = desc[:47] + "..."
}
if desc == "" {
desc = dimStyle.Render("(no description)")
}
fmt.Printf(" %s%s\n", repoNameStyle.Render(r.Name), visibility)
fmt.Printf(" %s\n", desc)
}
fmt.Println()
fmt.Printf("Install with: %s\n", dimStyle.Render(fmt.Sprintf("core pkg install %s/<repo-name>", org)))
return nil
}
// matchGlob does simple glob matching with * wildcards
func matchGlob(pattern, name string) bool {
if pattern == "*" || pattern == "" {
return true
}
parts := strings.Split(pattern, "*")
pos := 0
for i, part := range parts {
if part == "" {
continue
}
idx := strings.Index(name[pos:], part)
if idx == -1 {
return false
}
if i == 0 && !strings.HasPrefix(pattern, "*") && idx != 0 {
return false
}
pos += idx + len(part)
}
if !strings.HasSuffix(pattern, "*") && pos != len(name) {
return false
}
return true
}
// addPkgInstallCommand adds the 'pkg install' command.
func addPkgInstallCommand(parent *clir.Command) {
var targetDir string
var addToRegistry bool
installCmd := parent.NewSubCommand("install", "Clone a package from GitHub")
installCmd.LongDescription("Clones a repository from GitHub.\n\n" +
"Examples:\n" +
" core pkg install host-uk/core-php\n" +
" core pkg install host-uk/core-tenant --dir ./packages\n" +
" core pkg install host-uk/core-admin --add")
installCmd.StringFlag("dir", "Target directory (default: ./packages or current dir)", &targetDir)
installCmd.BoolFlag("add", "Add to repos.yaml registry", &addToRegistry)
installCmd.Action(func() error {
args := installCmd.OtherArgs()
if len(args) == 0 {
return fmt.Errorf("repository is required (e.g., core pkg install host-uk/core-php)")
}
return runPkgInstall(args[0], targetDir, addToRegistry)
})
}
func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
ctx := context.Background()
// Parse org/repo
parts := strings.Split(repoArg, "/")
if len(parts) != 2 {
return fmt.Errorf("invalid repo format: use org/repo (e.g., host-uk/core-php)")
}
org, repoName := parts[0], parts[1]
// Determine target directory
if targetDir == "" {
if regPath, err := repos.FindRegistry(); err == nil {
if reg, err := repos.LoadRegistry(regPath); err == nil {
targetDir = reg.BasePath
if targetDir == "" {
targetDir = "./packages"
}
if !filepath.IsAbs(targetDir) {
targetDir = filepath.Join(filepath.Dir(regPath), targetDir)
}
}
}
if targetDir == "" {
targetDir = "."
}
}
if strings.HasPrefix(targetDir, "~/") {
home, _ := os.UserHomeDir()
targetDir = filepath.Join(home, targetDir[2:])
}
repoPath := filepath.Join(targetDir, repoName)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
fmt.Printf("%s %s already exists at %s\n", dimStyle.Render("Skip:"), repoName, repoPath)
return nil
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
fmt.Printf("%s %s/%s\n", dimStyle.Render("Installing:"), org, repoName)
fmt.Printf("%s %s\n", dimStyle.Render("Target:"), repoPath)
fmt.Println()
fmt.Printf(" %s... ", dimStyle.Render("Cloning"))
err := gitClone(ctx, org, repoName, repoPath)
if err != nil {
fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error()))
return err
}
fmt.Printf("%s\n", successStyle.Render("✓"))
if addToRegistry {
if err := addToRegistryFile(org, repoName); err != nil {
fmt.Printf(" %s add to registry: %s\n", errorStyle.Render("✗"), err)
} else {
fmt.Printf(" %s added to repos.yaml\n", successStyle.Render("✓"))
}
}
fmt.Println()
fmt.Printf("%s Installed %s\n", successStyle.Render("Done:"), repoName)
return nil
}
func addToRegistryFile(org, repoName string) error {
regPath, err := repos.FindRegistry()
if err != nil {
return fmt.Errorf("no repos.yaml found")
}
reg, err := repos.LoadRegistry(regPath)
if err != nil {
return err
}
if _, exists := reg.Get(repoName); exists {
return nil
}
f, err := os.OpenFile(regPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
repoType := detectRepoType(repoName)
entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
repoName, repoType)
_, err = f.WriteString(entry)
return err
}
func detectRepoType(name string) string {
lower := strings.ToLower(name)
if strings.Contains(lower, "-mod-") || strings.HasSuffix(lower, "-mod") {
return "module"
}
if strings.Contains(lower, "-plug-") || strings.HasSuffix(lower, "-plug") {
return "plugin"
}
if strings.Contains(lower, "-services-") || strings.HasSuffix(lower, "-services") {
return "service"
}
if strings.Contains(lower, "-website-") || strings.HasSuffix(lower, "-website") {
return "website"
}
if strings.HasPrefix(lower, "core-") {
return "package"
}
return "package"
}
// addPkgListCommand adds the 'pkg list' command.
func addPkgListCommand(parent *clir.Command) {
listCmd := parent.NewSubCommand("list", "List installed packages")
listCmd.LongDescription("Lists all packages in the current workspace.\n\n" +
"Reads from repos.yaml or scans for git repositories.\n\n" +
"Examples:\n" +
" core pkg list")
listCmd.Action(func() error {
return runPkgList()
})
}
func runPkgList() error {
regPath, err := repos.FindRegistry()
if err != nil {
return fmt.Errorf("no repos.yaml found - run from workspace directory")
}
reg, err := repos.LoadRegistry(regPath)
if err != nil {
return fmt.Errorf("failed to load registry: %w", err)
}
basePath := reg.BasePath
if basePath == "" {
basePath = "."
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
}
allRepos := reg.List()
if len(allRepos) == 0 {
fmt.Println("No packages in registry.")
return nil
}
fmt.Printf("%s\n\n", repoNameStyle.Render("Installed Packages"))
var installed, missing int
for _, r := range allRepos {
repoPath := filepath.Join(basePath, r.Name)
exists := false
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
exists = true
installed++
} else {
missing++
}
status := successStyle.Render("✓")
if !exists {
status = dimStyle.Render("○")
}
desc := r.Description
if len(desc) > 40 {
desc = desc[:37] + "..."
}
if desc == "" {
desc = dimStyle.Render("(no description)")
}
fmt.Printf(" %s %s\n", status, repoNameStyle.Render(r.Name))
fmt.Printf(" %s\n", desc)
}
fmt.Println()
fmt.Printf("%s %d installed, %d missing\n", dimStyle.Render("Total:"), installed, missing)
if missing > 0 {
fmt.Printf("\nInstall missing: %s\n", dimStyle.Render("core setup"))
}
return nil
}
// addPkgUpdateCommand adds the 'pkg update' command.
func addPkgUpdateCommand(parent *clir.Command) {
var all bool
updateCmd := parent.NewSubCommand("update", "Update installed packages")
updateCmd.LongDescription("Pulls latest changes for installed packages.\n\n" +
"Examples:\n" +
" core pkg update core-php # Update specific package\n" +
" core pkg update --all # Update all packages")
updateCmd.BoolFlag("all", "Update all packages", &all)
updateCmd.Action(func() error {
args := updateCmd.OtherArgs()
if !all && len(args) == 0 {
return fmt.Errorf("specify package name or use --all")
}
return runPkgUpdate(args, all)
})
}
func runPkgUpdate(packages []string, all bool) error {
regPath, err := repos.FindRegistry()
if err != nil {
return fmt.Errorf("no repos.yaml found")
}
reg, err := repos.LoadRegistry(regPath)
if err != nil {
return fmt.Errorf("failed to load registry: %w", err)
}
basePath := reg.BasePath
if basePath == "" {
basePath = "."
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
}
var toUpdate []string
if all {
for _, r := range reg.List() {
toUpdate = append(toUpdate, r.Name)
}
} else {
toUpdate = packages
}
fmt.Printf("%s Updating %d package(s)\n\n", dimStyle.Render("Update:"), len(toUpdate))
var updated, skipped, failed int
for _, name := range toUpdate {
repoPath := filepath.Join(basePath, name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) {
fmt.Printf(" %s %s (not installed)\n", dimStyle.Render("○"), name)
skipped++
continue
}
fmt.Printf(" %s %s... ", dimStyle.Render("↓"), name)
cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only")
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("%s\n", errorStyle.Render("✗"))
fmt.Printf(" %s\n", strings.TrimSpace(string(output)))
failed++
continue
}
if strings.Contains(string(output), "Already up to date") {
fmt.Printf("%s\n", dimStyle.Render("up to date"))
} else {
fmt.Printf("%s\n", successStyle.Render("✓"))
}
updated++
}
fmt.Println()
fmt.Printf("%s %d updated, %d skipped, %d failed\n",
dimStyle.Render("Done:"), updated, skipped, failed)
return nil
}
// addPkgOutdatedCommand adds the 'pkg outdated' command.
func addPkgOutdatedCommand(parent *clir.Command) {
outdatedCmd := parent.NewSubCommand("outdated", "Check for outdated packages")
outdatedCmd.LongDescription("Checks which packages have unpulled commits.\n\n" +
"Examples:\n" +
" core pkg outdated")
outdatedCmd.Action(func() error {
return runPkgOutdated()
})
}
func runPkgOutdated() error {
regPath, err := repos.FindRegistry()
if err != nil {
return fmt.Errorf("no repos.yaml found")
}
reg, err := repos.LoadRegistry(regPath)
if err != nil {
return fmt.Errorf("failed to load registry: %w", err)
}
basePath := reg.BasePath
if basePath == "" {
basePath = "."
}
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
}
fmt.Printf("%s Checking for updates...\n\n", dimStyle.Render("Outdated:"))
var outdated, upToDate, notInstalled int
var outdatedList []string
for _, r := range reg.List() {
repoPath := filepath.Join(basePath, r.Name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) {
notInstalled++
continue
}
// Fetch updates
exec.Command("git", "-C", repoPath, "fetch", "--quiet").Run()
// Check if behind
cmd := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}")
output, err := cmd.Output()
if err != nil {
continue
}
count := strings.TrimSpace(string(output))
if count != "0" {
fmt.Printf(" %s %s (%s commits behind)\n",
errorStyle.Render("↓"), repoNameStyle.Render(r.Name), count)
outdated++
outdatedList = append(outdatedList, r.Name)
} else {
upToDate++
}
}
fmt.Println()
if outdated == 0 {
fmt.Printf("%s All packages up to date\n", successStyle.Render("Done:"))
} else {
fmt.Printf("%s %d outdated, %d up to date\n",
dimStyle.Render("Summary:"), outdated, upToDate)
fmt.Printf("\nUpdate with: %s\n", dimStyle.Render("core pkg update --all"))
}
return nil
}

155
cmd/pkg/pkg_install.go Normal file
View 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
View 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
View 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
}

View file

@ -2,19 +2,11 @@
package setup package setup
import ( import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/cmd/shared" "github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/repos"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// Style aliases // Style aliases from shared package
var ( var (
repoNameStyle = shared.RepoNameStyle repoNameStyle = shared.RepoNameStyle
successStyle = shared.SuccessStyle successStyle = shared.SuccessStyle
@ -24,9 +16,9 @@ var (
// Default organization and devops repo for bootstrap // Default organization and devops repo for bootstrap
const ( const (
defaultOrg = "host-uk" defaultOrg = "host-uk"
devopsRepo = "core-devops" devopsRepo = "core-devops"
devopsReposYaml = "repos.yaml" devopsReposYaml = "repos.yaml"
) )
// AddSetupCommand adds the 'setup' command to the given parent command. // AddSetupCommand adds the 'setup' command to the given parent command.
@ -60,650 +52,3 @@ func AddSetupCommand(parent *clir.Cli) {
return runSetupOrchestrator(registryPath, only, dryRun, all, name, build) return runSetupOrchestrator(registryPath, only, dryRun, all, name, build)
}) })
} }
// runSetupOrchestrator decides between registry mode and bootstrap mode.
func runSetupOrchestrator(registryPath, only string, dryRun, all bool, projectName string, runBuild bool) error {
ctx := context.Background()
// Try to find an existing registry
var foundRegistry string
var err error
if registryPath != "" {
foundRegistry = registryPath
} else {
foundRegistry, err = repos.FindRegistry()
}
// If registry exists, use registry mode
if err == nil && foundRegistry != "" {
return runRegistrySetup(ctx, foundRegistry, only, dryRun, all, runBuild)
}
// No registry found - enter bootstrap mode
return runBootstrap(ctx, only, dryRun, all, projectName, runBuild)
}
// runBootstrap handles the case where no repos.yaml exists.
func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectName string, runBuild bool) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
fmt.Printf("%s Bootstrap mode (no repos.yaml found)\n", dimStyle.Render(">>"))
var targetDir string
// Check if current directory is empty
empty, err := isDirEmpty(cwd)
if err != nil {
return fmt.Errorf("failed to check directory: %w", err)
}
if empty {
// Clone into current directory
targetDir = cwd
fmt.Printf("%s Cloning into current directory\n", dimStyle.Render(">>"))
} else {
// Directory has content - check if it's a git repo root
isRepo := isGitRepoRoot(cwd)
if isRepo && isTerminal() && !all {
// Offer choice: setup working directory or create package
choice, err := promptSetupChoice()
if err != nil {
return fmt.Errorf("failed to get choice: %w", err)
}
if choice == "setup" {
// Setup this working directory with .core/ config
return runRepoSetup(cwd, dryRun)
}
// Otherwise continue to "create package" flow
}
// Create package flow - need a project name
if projectName == "" {
if !isTerminal() || all {
projectName = defaultOrg
} else {
projectName, err = promptProjectName(defaultOrg)
if err != nil {
return fmt.Errorf("failed to get project name: %w", err)
}
}
}
targetDir = filepath.Join(cwd, projectName)
fmt.Printf("%s Creating project directory: %s\n", dimStyle.Render(">>"), projectName)
if !dryRun {
if err := os.MkdirAll(targetDir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
}
}
// Clone core-devops first
devopsPath := filepath.Join(targetDir, devopsRepo)
if _, err := os.Stat(filepath.Join(devopsPath, ".git")); os.IsNotExist(err) {
fmt.Printf("%s Cloning %s...\n", dimStyle.Render(">>"), devopsRepo)
if !dryRun {
if err := gitClone(ctx, defaultOrg, devopsRepo, devopsPath); err != nil {
return fmt.Errorf("failed to clone %s: %w", devopsRepo, err)
}
fmt.Printf("%s %s cloned\n", successStyle.Render(">>"), devopsRepo)
} else {
fmt.Printf(" Would clone %s/%s to %s\n", defaultOrg, devopsRepo, devopsPath)
}
} else {
fmt.Printf("%s %s already exists\n", dimStyle.Render(">>"), devopsRepo)
}
// Load the repos.yaml from core-devops
registryPath := filepath.Join(devopsPath, devopsReposYaml)
if dryRun {
fmt.Printf("\n%s Would load registry from %s and present package wizard\n", dimStyle.Render(">>"), registryPath)
return nil
}
reg, err := repos.LoadRegistry(registryPath)
if err != nil {
return fmt.Errorf("failed to load registry from %s: %w", devopsRepo, err)
}
// Override base path to target directory
reg.BasePath = targetDir
// Now run the regular setup with the loaded registry
return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild)
}
// runRegistrySetup loads a registry from path and runs setup.
func runRegistrySetup(ctx context.Context, registryPath, only string, dryRun, all, runBuild bool) error {
reg, err := repos.LoadRegistry(registryPath)
if err != nil {
return fmt.Errorf("failed to load registry: %w", err)
}
return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild)
}
// runRegistrySetupWithReg runs setup with an already-loaded registry.
func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryPath, only string, dryRun, all, runBuild bool) error {
fmt.Printf("%s %s\n", dimStyle.Render("Registry:"), registryPath)
fmt.Printf("%s %s\n", dimStyle.Render("Org:"), reg.Org)
// Determine base path for cloning
basePath := reg.BasePath
if basePath == "" {
basePath = "./packages"
}
// Resolve relative to registry location
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(registryPath), basePath)
}
// Expand ~
if strings.HasPrefix(basePath, "~/") {
home, _ := os.UserHomeDir()
basePath = filepath.Join(home, basePath[2:])
}
fmt.Printf("%s %s\n", dimStyle.Render("Target:"), basePath)
// Parse type filter
var typeFilter []string
if only != "" {
for _, t := range strings.Split(only, ",") {
typeFilter = append(typeFilter, strings.TrimSpace(t))
}
fmt.Printf("%s %s\n", dimStyle.Render("Filter:"), only)
}
// Ensure base path exists
if !dryRun {
if err := os.MkdirAll(basePath, 0755); err != nil {
return fmt.Errorf("failed to create packages directory: %w", err)
}
}
// Get all available repos
allRepos := reg.List()
// Determine which repos to clone
var toClone []*repos.Repo
var skipped, exists int
// Use wizard in interactive mode, unless --all specified
useWizard := isTerminal() && !all && !dryRun
if useWizard {
selected, err := runPackageWizard(reg, typeFilter)
if err != nil {
return fmt.Errorf("wizard error: %w", err)
}
// Build set of selected repos
selectedSet := make(map[string]bool)
for _, name := range selected {
selectedSet[name] = true
}
// Filter repos based on selection
for _, repo := range allRepos {
if !selectedSet[repo.Name] {
skipped++
continue
}
// Check if already exists
repoPath := filepath.Join(basePath, repo.Name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
exists++
continue
}
toClone = append(toClone, repo)
}
} else {
// Non-interactive: filter by type
typeFilterSet := make(map[string]bool)
for _, t := range typeFilter {
typeFilterSet[t] = true
}
for _, repo := range allRepos {
// Skip if type filter doesn't match (when filter is specified)
if len(typeFilterSet) > 0 && !typeFilterSet[repo.Type] {
skipped++
continue
}
// Skip if clone: false
if repo.Clone != nil && !*repo.Clone {
skipped++
continue
}
// Check if already exists
repoPath := filepath.Join(basePath, repo.Name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
exists++
continue
}
toClone = append(toClone, repo)
}
}
// Summary
fmt.Println()
fmt.Printf("%d to clone, %d exist, %d skipped\n", len(toClone), exists, skipped)
if len(toClone) == 0 {
fmt.Println("\nNothing to clone.")
return nil
}
if dryRun {
fmt.Println("\nWould clone:")
for _, repo := range toClone {
fmt.Printf(" %s (%s)\n", repoNameStyle.Render(repo.Name), repo.Type)
}
return nil
}
// Confirm in interactive mode
if useWizard {
confirmed, err := confirmClone(len(toClone), basePath)
if err != nil {
return err
}
if !confirmed {
fmt.Println("Cancelled.")
return nil
}
}
// Clone repos
fmt.Println()
var succeeded, failed int
for _, repo := range toClone {
fmt.Printf(" %s %s... ", dimStyle.Render("Cloning"), repo.Name)
repoPath := filepath.Join(basePath, repo.Name)
err := gitClone(ctx, reg.Org, repo.Name, repoPath)
if err != nil {
fmt.Printf("%s\n", errorStyle.Render("x "+err.Error()))
failed++
} else {
fmt.Printf("%s\n", successStyle.Render("done"))
succeeded++
}
}
// Summary
fmt.Println()
fmt.Printf("%s %d cloned", successStyle.Render("Done:"), succeeded)
if failed > 0 {
fmt.Printf(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed)))
}
if exists > 0 {
fmt.Printf(", %d already exist", exists)
}
fmt.Println()
// Run build if requested
if runBuild && succeeded > 0 {
fmt.Println()
fmt.Printf("%s Running build...\n", dimStyle.Render(">>"))
buildCmd := exec.Command("core", "build")
buildCmd.Dir = basePath
buildCmd.Stdout = os.Stdout
buildCmd.Stderr = os.Stderr
if err := buildCmd.Run(); err != nil {
return fmt.Errorf("build failed: %w", err)
}
}
return nil
}
// isGitRepoRoot returns true if the directory is a git repository root.
func isGitRepoRoot(path string) bool {
_, err := os.Stat(filepath.Join(path, ".git"))
return err == nil
}
// runRepoSetup sets up the current repository with .core/ configuration.
func runRepoSetup(repoPath string, dryRun bool) error {
fmt.Printf("%s Setting up repository: %s\n", dimStyle.Render(">>"), repoPath)
// Detect project type
projectType := detectProjectType(repoPath)
fmt.Printf("%s Detected project type: %s\n", dimStyle.Render(">>"), projectType)
// Create .core directory
coreDir := filepath.Join(repoPath, ".core")
if !dryRun {
if err := os.MkdirAll(coreDir, 0755); err != nil {
return fmt.Errorf("failed to create .core directory: %w", err)
}
}
// Generate configs based on project type
name := filepath.Base(repoPath)
configs := map[string]string{
"build.yaml": generateBuildConfig(repoPath, projectType),
"release.yaml": generateReleaseConfig(name, projectType),
"test.yaml": generateTestConfig(projectType),
}
if dryRun {
fmt.Printf("\n%s Would create:\n", dimStyle.Render(">>"))
for filename, content := range configs {
fmt.Printf("\n %s:\n", filepath.Join(coreDir, filename))
// Indent content for display
for _, line := range strings.Split(content, "\n") {
fmt.Printf(" %s\n", line)
}
}
return nil
}
for filename, content := range configs {
configPath := filepath.Join(coreDir, filename)
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", filename, err)
}
fmt.Printf("%s Created %s\n", successStyle.Render(">>"), configPath)
}
return nil
}
// detectProjectType identifies the project type from files present.
func detectProjectType(path string) string {
// Check in priority order
if _, err := os.Stat(filepath.Join(path, "wails.json")); err == nil {
return "wails"
}
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
return "go"
}
if _, err := os.Stat(filepath.Join(path, "composer.json")); err == nil {
return "php"
}
if _, err := os.Stat(filepath.Join(path, "package.json")); err == nil {
return "node"
}
return "unknown"
}
// generateBuildConfig creates a build.yaml configuration based on project type.
func generateBuildConfig(path, projectType string) string {
name := filepath.Base(path)
switch projectType {
case "go", "wails":
return fmt.Sprintf(`version: 1
project:
name: %s
description: Go application
main: ./cmd/%s
binary: %s
build:
cgo: false
flags:
- -trimpath
ldflags:
- -s
- -w
targets:
- os: linux
arch: amd64
- os: linux
arch: arm64
- os: darwin
arch: amd64
- os: darwin
arch: arm64
- os: windows
arch: amd64
`, name, name, name)
case "php":
return fmt.Sprintf(`version: 1
project:
name: %s
description: PHP application
type: php
build:
dockerfile: Dockerfile
image: %s
`, name, name)
case "node":
return fmt.Sprintf(`version: 1
project:
name: %s
description: Node.js application
type: node
build:
script: npm run build
output: dist
`, name)
default:
return fmt.Sprintf(`version: 1
project:
name: %s
description: Application
`, name)
}
}
// generateReleaseConfig creates a release.yaml configuration.
func generateReleaseConfig(name, projectType string) string {
// Try to detect GitHub repo from git remote
repo := detectGitHubRepo()
if repo == "" {
repo = "owner/" + name
}
base := fmt.Sprintf(`version: 1
project:
name: %s
repository: %s
`, name, repo)
switch projectType {
case "go", "wails":
return base + `
changelog:
include:
- feat
- fix
- perf
- refactor
exclude:
- chore
- docs
- style
- test
publishers:
- type: github
draft: false
prerelease: false
`
case "php":
return base + `
changelog:
include:
- feat
- fix
- perf
publishers:
- type: github
draft: false
`
default:
return base + `
changelog:
include:
- feat
- fix
publishers:
- type: github
`
}
}
// generateTestConfig creates a test.yaml configuration.
func generateTestConfig(projectType string) string {
switch projectType {
case "go", "wails":
return `version: 1
commands:
- name: unit
run: go test ./...
- name: coverage
run: go test -coverprofile=coverage.out ./...
- name: race
run: go test -race ./...
env:
CGO_ENABLED: "0"
`
case "php":
return `version: 1
commands:
- name: unit
run: vendor/bin/pest --parallel
- name: types
run: vendor/bin/phpstan analyse
- name: lint
run: vendor/bin/pint --test
env:
APP_ENV: testing
DB_CONNECTION: sqlite
`
case "node":
return `version: 1
commands:
- name: unit
run: npm test
- name: lint
run: npm run lint
- name: typecheck
run: npm run typecheck
env:
NODE_ENV: test
`
default:
return `version: 1
commands:
- name: test
run: echo "No tests configured"
`
}
}
// detectGitHubRepo tries to extract owner/repo from git remote.
func detectGitHubRepo() string {
cmd := exec.Command("git", "remote", "get-url", "origin")
output, err := cmd.Output()
if err != nil {
return ""
}
url := strings.TrimSpace(string(output))
// Handle SSH format: git@github.com:owner/repo.git
if strings.HasPrefix(url, "git@github.com:") {
repo := strings.TrimPrefix(url, "git@github.com:")
repo = strings.TrimSuffix(repo, ".git")
return repo
}
// Handle HTTPS format: https://github.com/owner/repo.git
if strings.Contains(url, "github.com/") {
parts := strings.Split(url, "github.com/")
if len(parts) == 2 {
repo := strings.TrimSuffix(parts[1], ".git")
return repo
}
}
return ""
}
// isDirEmpty returns true if the directory is empty or contains only hidden files.
func isDirEmpty(path string) (bool, error) {
entries, err := os.ReadDir(path)
if err != nil {
return false, err
}
for _, e := range entries {
name := e.Name()
// Ignore common hidden/metadata files
if name == ".DS_Store" || name == ".git" || name == ".gitignore" {
continue
}
// Any other non-hidden file means directory is not empty
if !strings.HasPrefix(name, ".") {
return false, nil
}
}
return true, nil
}
func gitClone(ctx context.Context, org, repo, path string) error {
// Try gh clone first with HTTPS (works without SSH keys)
if ghAuthenticated() {
// Use HTTPS URL directly to bypass git_protocol config
httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo)
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path)
output, err := cmd.CombinedOutput()
if err == nil {
return nil
}
errStr := strings.TrimSpace(string(output))
// Only fall through to SSH if it's an auth error
if !strings.Contains(errStr, "Permission denied") &&
!strings.Contains(errStr, "could not read") {
return fmt.Errorf("%s", errStr)
}
}
// Fallback to git clone via SSH
url := fmt.Sprintf("git@github.com:%s/%s.git", org, repo)
cmd := exec.CommandContext(ctx, "git", "clone", url, path)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
}
return nil
}
func ghAuthenticated() bool {
cmd := exec.Command("gh", "auth", "status")
output, _ := cmd.CombinedOutput()
return strings.Contains(string(output), "Logged in")
}

View 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
View 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
View 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 ""
}

View file

@ -4,42 +4,22 @@
package testcmd package testcmd
import ( import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/cmd/shared"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// Test command styles // Style aliases from shared
var ( var (
testHeaderStyle = lipgloss.NewStyle(). testHeaderStyle = shared.RepoNameStyle
Bold(true). testPassStyle = shared.SuccessStyle
Foreground(lipgloss.Color("#3b82f6")) // blue-500 testFailStyle = shared.ErrorStyle
testSkipStyle = shared.WarningStyle
testPassStyle = lipgloss.NewStyle(). testDimStyle = shared.DimStyle
Foreground(lipgloss.Color("#22c55e")). // green-500 )
Bold(true)
testFailStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#ef4444")). // red-500
Bold(true)
testSkipStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#f59e0b")) // amber-500
testDimStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6b7280")) // gray-500
// Coverage-specific styles
var (
testCovHighStyle = lipgloss.NewStyle(). testCovHighStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#22c55e")) // green-500 Foreground(lipgloss.Color("#22c55e")) // green-500
@ -85,326 +65,3 @@ func AddTestCommand(parent *clir.Cli) {
return runTest(verbose, coverage, short, pkg, run, race, json) return runTest(verbose, coverage, short, pkg, run, race, json)
}) })
} }
type packageCoverage struct {
name string
coverage float64
hasCov bool
}
func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error {
// Detect if we're in a Go project
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
return fmt.Errorf("no go.mod found - run from a Go project directory")
}
// Build command arguments
args := []string{"test"}
// Default to ./... if no package specified
if pkg == "" {
pkg = "./..."
}
// Add flags
if verbose {
args = append(args, "-v")
}
if short {
args = append(args, "-short")
}
if run != "" {
args = append(args, "-run", run)
}
if race {
args = append(args, "-race")
}
// Always add coverage
args = append(args, "-cover")
// Add package pattern
args = append(args, pkg)
// Create command
cmd := exec.Command("go", args...)
cmd.Dir, _ = os.Getwd()
// Set environment to suppress macOS linker warnings
cmd.Env = append(os.Environ(), getMacOSDeploymentTarget())
if !jsonOutput {
fmt.Printf("%s Running tests\n", testHeaderStyle.Render("Test:"))
fmt.Printf(" Package: %s\n", testDimStyle.Render(pkg))
if run != "" {
fmt.Printf(" Filter: %s\n", testDimStyle.Render(run))
}
fmt.Println()
}
// Capture output for parsing
var stdout, stderr strings.Builder
if verbose && !jsonOutput {
// Stream output in verbose mode, but also capture for parsing
cmd.Stdout = io.MultiWriter(os.Stdout, &stdout)
cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
} else {
// Capture output for parsing
cmd.Stdout = &stdout
cmd.Stderr = &stderr
}
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
}
// Combine stdout and stderr for parsing, filtering linker warnings
combined := filterLinkerWarnings(stdout.String() + "\n" + stderr.String())
// Parse results
results := parseTestOutput(combined)
if jsonOutput {
// JSON output for CI/agents
printJSONResults(results, exitCode)
if exitCode != 0 {
return fmt.Errorf("tests failed")
}
return nil
}
// Print summary
if !verbose {
printTestSummary(results, coverage)
} else if coverage {
// In verbose mode, still show coverage summary at end
fmt.Println()
printCoverageSummary(results)
}
if exitCode != 0 {
fmt.Printf("\n%s Tests failed\n", testFailStyle.Render("FAIL"))
return fmt.Errorf("tests failed")
}
fmt.Printf("\n%s All tests passed\n", testPassStyle.Render("PASS"))
return nil
}
func getMacOSDeploymentTarget() string {
if runtime.GOOS == "darwin" {
// Use deployment target matching current macOS to suppress linker warnings
return "MACOSX_DEPLOYMENT_TARGET=26.0"
}
return ""
}
func filterLinkerWarnings(output string) string {
// Filter out ld: warning lines that pollute the output
var filtered []string
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
line := scanner.Text()
// Skip linker warnings
if strings.HasPrefix(line, "ld: warning:") {
continue
}
// Skip test binary build comments
if strings.HasPrefix(line, "# ") && strings.HasSuffix(line, ".test") {
continue
}
filtered = append(filtered, line)
}
return strings.Join(filtered, "\n")
}
type testResults struct {
packages []packageCoverage
passed int
failed int
skipped int
totalCov float64
covCount int
failedPkgs []string
}
func parseTestOutput(output string) testResults {
results := testResults{}
// Regex patterns - handle both timed and cached test results
// Example: ok github.com/host-uk/core/pkg/crypt 0.015s coverage: 91.2% of statements
// Example: ok github.com/host-uk/core/pkg/crypt (cached) coverage: 91.2% of statements
okPattern := regexp.MustCompile(`^ok\s+(\S+)\s+(?:[\d.]+s|\(cached\))(?:\s+coverage:\s+([\d.]+)%)?`)
failPattern := regexp.MustCompile(`^FAIL\s+(\S+)`)
skipPattern := regexp.MustCompile(`^\?\s+(\S+)\s+\[no test files\]`)
coverPattern := regexp.MustCompile(`coverage:\s+([\d.]+)%`)
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
line := scanner.Text()
if matches := okPattern.FindStringSubmatch(line); matches != nil {
pkg := packageCoverage{name: matches[1]}
if len(matches) > 2 && matches[2] != "" {
cov, _ := strconv.ParseFloat(matches[2], 64)
pkg.coverage = cov
pkg.hasCov = true
results.totalCov += cov
results.covCount++
}
results.packages = append(results.packages, pkg)
results.passed++
} else if matches := failPattern.FindStringSubmatch(line); matches != nil {
results.failed++
results.failedPkgs = append(results.failedPkgs, matches[1])
} else if matches := skipPattern.FindStringSubmatch(line); matches != nil {
results.skipped++
} else if matches := coverPattern.FindStringSubmatch(line); matches != nil {
// Catch any additional coverage lines
cov, _ := strconv.ParseFloat(matches[1], 64)
if cov > 0 {
// Find the last package without coverage and update it
for i := len(results.packages) - 1; i >= 0; i-- {
if !results.packages[i].hasCov {
results.packages[i].coverage = cov
results.packages[i].hasCov = true
results.totalCov += cov
results.covCount++
break
}
}
}
}
}
return results
}
func printTestSummary(results testResults, showCoverage bool) {
// Print pass/fail summary
total := results.passed + results.failed
if total > 0 {
fmt.Printf(" %s %d passed", testPassStyle.Render("✓"), results.passed)
if results.failed > 0 {
fmt.Printf(" %s %d failed", testFailStyle.Render("✗"), results.failed)
}
if results.skipped > 0 {
fmt.Printf(" %s %d skipped", testSkipStyle.Render("○"), results.skipped)
}
fmt.Println()
}
// Print failed packages
if len(results.failedPkgs) > 0 {
fmt.Printf("\n Failed packages:\n")
for _, pkg := range results.failedPkgs {
fmt.Printf(" %s %s\n", testFailStyle.Render("✗"), pkg)
}
}
// Print coverage
if showCoverage {
printCoverageSummary(results)
} else if results.covCount > 0 {
avgCov := results.totalCov / float64(results.covCount)
fmt.Printf("\n Coverage: %s\n", formatCoverage(avgCov))
}
}
func printCoverageSummary(results testResults) {
if len(results.packages) == 0 {
return
}
fmt.Printf("\n %s\n", testHeaderStyle.Render("Coverage by package:"))
// Sort packages by name
sort.Slice(results.packages, func(i, j int) bool {
return results.packages[i].name < results.packages[j].name
})
// Find max package name length for alignment
maxLen := 0
for _, pkg := range results.packages {
name := shortenPackageName(pkg.name)
if len(name) > maxLen {
maxLen = len(name)
}
}
// Print each package
for _, pkg := range results.packages {
if !pkg.hasCov {
continue
}
name := shortenPackageName(pkg.name)
padding := strings.Repeat(" ", maxLen-len(name)+2)
fmt.Printf(" %s%s%s\n", name, padding, formatCoverage(pkg.coverage))
}
// Print average
if results.covCount > 0 {
avgCov := results.totalCov / float64(results.covCount)
padding := strings.Repeat(" ", maxLen-7+2)
fmt.Printf("\n %s%s%s\n", testHeaderStyle.Render("Average"), padding, formatCoverage(avgCov))
}
}
func formatCoverage(cov float64) string {
var style lipgloss.Style
switch {
case cov >= 80:
style = testCovHighStyle
case cov >= 50:
style = testCovMedStyle
default:
style = testCovLowStyle
}
return style.Render(fmt.Sprintf("%.1f%%", cov))
}
func shortenPackageName(name string) string {
// Remove common prefixes
prefixes := []string{
"github.com/host-uk/core/",
"github.com/host-uk/",
}
for _, prefix := range prefixes {
if strings.HasPrefix(name, prefix) {
return strings.TrimPrefix(name, prefix)
}
}
return filepath.Base(name)
}
func printJSONResults(results testResults, exitCode int) {
// Simple JSON output for agents
fmt.Printf("{\n")
fmt.Printf(" \"passed\": %d,\n", results.passed)
fmt.Printf(" \"failed\": %d,\n", results.failed)
fmt.Printf(" \"skipped\": %d,\n", results.skipped)
if results.covCount > 0 {
avgCov := results.totalCov / float64(results.covCount)
fmt.Printf(" \"coverage\": %.1f,\n", avgCov)
}
fmt.Printf(" \"exit_code\": %d,\n", exitCode)
if len(results.failedPkgs) > 0 {
fmt.Printf(" \"failed_packages\": [\n")
for i, pkg := range results.failedPkgs {
comma := ","
if i == len(results.failedPkgs)-1 {
comma = ""
}
fmt.Printf(" %q%s\n", pkg, comma)
}
fmt.Printf(" ]\n")
} else {
fmt.Printf(" \"failed_packages\": []\n")
}
fmt.Printf("}\n")
}

205
cmd/test/test_output.go Normal file
View 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
View 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")
}

View file

@ -16,5 +16,5 @@ import "github.com/leaanthony/clir"
// AddCommands registers the 'vm' command and all subcommands. // AddCommands registers the 'vm' command and all subcommands.
func AddCommands(app *clir.Cli) { func AddCommands(app *clir.Cli) {
AddContainerCommands(app) AddVMCommands(app)
} }

View file

@ -1,4 +1,3 @@
// Package vm provides LinuxKit VM management commands.
package vm package vm
import ( import (
@ -14,28 +13,6 @@ import (
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// AddContainerCommands adds container-related commands under 'vm' to the CLI.
func AddContainerCommands(parent *clir.Cli) {
vmCmd := parent.NewSubCommand("vm", "LinuxKit VM management")
vmCmd.LongDescription("Manage LinuxKit virtual machines.\n\n" +
"LinuxKit VMs are lightweight, immutable VMs built from YAML templates.\n" +
"They run using qemu or hyperkit depending on your system.\n\n" +
"Commands:\n" +
" run Run a VM from image or template\n" +
" ps List running VMs\n" +
" stop Stop a running VM\n" +
" logs View VM logs\n" +
" exec Execute command in VM\n" +
" templates Manage LinuxKit templates")
addVMRunCommand(vmCmd)
addVMPsCommand(vmCmd)
addVMStopCommand(vmCmd)
addVMLogsCommand(vmCmd)
addVMExecCommand(vmCmd)
addVMTemplatesCommand(vmCmd)
}
// addVMRunCommand adds the 'run' command under vm. // addVMRunCommand adds the 'run' command under vm.
func addVMRunCommand(parent *clir.Command) { func addVMRunCommand(parent *clir.Command) {
var ( var (

View file

@ -9,25 +9,10 @@ import (
"strings" "strings"
"text/tabwriter" "text/tabwriter"
"github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/cmd/shared"
"github.com/host-uk/core/pkg/container" "github.com/host-uk/core/pkg/container"
"github.com/leaanthony/clir" "github.com/leaanthony/clir"
) )
// Style aliases
var (
repoNameStyle = shared.RepoNameStyle
successStyle = shared.SuccessStyle
errorStyle = shared.ErrorStyle
dimStyle = shared.DimStyle
)
var (
varStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b"))
defaultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Italic(true)
)
// addVMTemplatesCommand adds the 'templates' command under vm. // addVMTemplatesCommand adds the 'templates' command under vm.
func addVMTemplatesCommand(parent *clir.Command) { func addVMTemplatesCommand(parent *clir.Command) {
templatesCmd := parent.NewSubCommand("templates", "Manage LinuxKit templates") templatesCmd := parent.NewSubCommand("templates", "Manage LinuxKit templates")

44
cmd/vm/vm.go Normal file
View 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)
}