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

369
cmd/build/build_project.go Normal file
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
//
// Configuration via .core/build.yaml or command-line flags.
//
// Subcommands:
// - build: Auto-detect and build the current project
// - build from-path: Build from a local static web app directory
// - build pwa: Build from a live PWA URL
// - build sdk: Generate API SDKs from OpenAPI spec
package build
import "github.com/leaanthony/clir"

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

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

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

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

205
cmd/test/test_output.go Normal file
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.
func AddCommands(app *clir.Cli) {
AddContainerCommands(app)
AddVMCommands(app)
}

View file

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

View file

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

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