Files
Archduino/cmd/archduino/main.go

425 lines
13 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/engine"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/arduinocli"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/fonts"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/gittools"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/neovim"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/packages"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/renode"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/shellconfig"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/starship"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/themes"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/yazi"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/zellij"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/zsh"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/privilege"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/state"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/subcommands/doctor"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/subcommands/newproject"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/theming"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/dashboard"
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/wizard"
)
var version = "dev"
// buildRegistry constructs a module registry populated with all Plan 3a
// modules, wired against the provided config and platform dependencies.
// It replaces the earlier init()-time hello-module registration now that
// the real modules need runtime config to be instantiated.
func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error) {
reg := module.NewRegistry()
runner := platform.ExecRunner{}
pm := platform.NewPacman(runner)
yay := platform.NewYay(runner)
git := platform.GitClient{Runner: runner}
mods := []module.Module{
packages.New(cfg.Packages.Base, pm),
gittools.New([]string{"lazygit"}, pm),
starship.New([]string{"starship"}, pm),
yazi.New([]string{"yazi"}, pm),
zsh.New(cfg.Zsh, pm, git),
fonts.New(cfg.Fonts, pm, yay),
zellij.New(cfg.Zellij, pm),
themes.New(cfg.Themes),
arduinocli.New(cfg.ArduinoCLI, runner),
shellconfig.New(cfg.ShellConfig),
neovim.New(cfg.Neovim, themeID, pm, git),
renode.New(cfg.Renode, pm, yay),
}
for _, m := range mods {
if err := reg.Register(m); err != nil {
return nil, err
}
}
return reg, nil
}
func main() {
root := &cobra.Command{
Use: "archduino",
Short: "Archduino installer and toolbox",
SilenceUsage: true,
SilenceErrors: true,
Version: version,
}
root.AddCommand(newInstallCmd())
root.AddCommand(newThemeCmd())
root.AddCommand(newNewCmd())
root.AddCommand(newDoctorCmd())
// Wire SIGINT/SIGTERM to a cancellable context so long-running phases
// (engine.Run, sudo keepalive) observe the cancellation promptly.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := root.ExecuteContext(ctx); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func newInstallCmd() *cobra.Command {
var (
noTUI bool
dryRun bool
halt bool
noSudo bool
only []string
skip []string
overlayPath string
)
cmd := &cobra.Command{
Use: "install",
Short: "Install or reconcile the Archduino toolset",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
// Gate: v1 supports Arch only. Non-Arch hosts would silently run
// the hello module today but fail on Plan 3's pacman-dependent
// modules — fail fast with a clear message instead.
if !noSudo { // --no-sudo implies a test/container context; skip the gate
p, derr := platform.Detect()
if derr != nil {
return fmt.Errorf("platform detect: %w", derr)
}
if p.Distro != platform.DistroArch {
return fmt.Errorf("archduino requires Arch Linux (detected distro: %s)", p.Distro)
}
}
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
reg, err := buildRegistry(cfg, "")
if err != nil {
return fmt.Errorf("build registry: %w", err)
}
selected, err := resolveSelection(reg, cfg, only, skip)
if err != nil {
return err
}
// Acquire sudo unless dry-run or explicitly skipped.
if !dryRun && !noSudo {
k, err := privilege.Acquire(ctx, privilege.ExecSudo{}, 50*time.Second)
if err != nil {
return err
}
defer k.Stop()
}
started := time.Now().UTC()
var (
sum engine.Summary
runErr error
selectedTheme = cfg.ThemeOverrides["__active"]
)
if noTUI || dryRun {
// Plain-output path: preserve the Plan 1 behaviour. Dry-run
// always uses plain output because the dashboard has nothing
// to animate when no Apply happens.
reporter := engine.NewPlainReporter(os.Stdout)
sum, runErr = engine.Run(ctx, reg, engine.RunOptions{
Selected: selected,
DryRun: dryRun,
HaltOnError: halt,
Reporter: reporter,
})
} else {
// TUI path: wizard → dashboard. If the user does not confirm,
// we exit cleanly with a zero-summary and no error.
initial := tui.NewWizardState(cfg, reg)
// Pre-filter the wizard's module list by --only/--skip so the
// user cannot accidentally widen selection in the TUI beyond
// what they asked for on the CLI.
if len(selected) < len(initial.Modules) {
wanted := map[string]bool{}
for _, id := range selected {
wanted[id] = true
}
for i := range initial.Modules {
initial.Modules[i].Selected = wanted[initial.Modules[i].ID]
}
}
finalState, confirmed, werr := wizard.Run(ctx, reg, initial)
if werr != nil {
return werr
}
if !confirmed {
fmt.Fprintln(os.Stdout, "installation cancelled")
return nil
}
// Rebuild the registry with the theme the user chose in the
// wizard so theme-aware modules (e.g. neovim in Task 7) pick
// up the selection instead of the empty pre-wizard default.
reg, err = buildRegistry(cfg, finalState.ThemeID)
if err != nil {
return fmt.Errorf("build registry: %w", err)
}
if finalState.ThemeID != "" {
selectedTheme = finalState.ThemeID
}
// Either the CLI flag or the wizard toggle can force halt-on-error.
sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError)
}
ended := time.Now().UTC()
if runErr != nil {
return runErr
}
// Apply the wizard-selected theme (no-op if user chose a theme
// whose configs are already on disk). Skipped on dry-run.
if !dryRun && selectedTheme != "" {
if err := theming.Apply(cfg, selectedTheme); err != nil {
fmt.Fprintf(os.Stderr, "warning: theme apply failed: %v\n", err)
}
}
ok, fail, skipped := sum.Counts()
fmt.Fprintf(os.Stdout, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skipped)
for _, r := range sum.Results {
line := fmt.Sprintf(" %-16s %s", r.ID, r.Status)
if r.Err != nil {
line += " — " + r.Err.Error()
}
fmt.Fprintln(os.Stdout, line)
}
if !dryRun {
if err := writeState(started, ended, sum); err != nil {
fmt.Fprintln(os.Stderr, "warning: state write failed:", err)
}
}
if fail > 0 {
return fmt.Errorf("%d module(s) failed", fail)
}
return nil
},
}
cmd.Flags().BoolVar(&noTUI, "no-tui", false, "plain-text output, no interactive UI")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned actions without applying")
cmd.Flags().BoolVar(&halt, "halt-on-error", false, "stop at first failure instead of continuing siblings")
cmd.Flags().BoolVar(&noSudo, "no-sudo", false, "skip upfront sudo -v (for tests / containers with passwordless sudo)")
cmd.Flags().StringSliceVar(&only, "only", nil, "comma-separated module IDs to run (exclusive with --skip)")
cmd.Flags().StringSliceVar(&skip, "skip", nil, "comma-separated module IDs to skip")
cmd.Flags().StringVar(&overlayPath, "config", "", "path to overlay.yaml (default: ~/.config/archduino/overlay.yaml)")
cmd.MarkFlagsMutuallyExclusive("only", "skip")
return cmd
}
func resolveSelection(reg *module.Registry, cfg config.Config, only, skip []string) ([]string, error) {
all := reg.All()
known := map[string]bool{}
for _, m := range all {
known[m.Meta().ID] = true
}
// Validate every explicit ID the user passed so a typo in --only does not
// silently fall through to "run all modules".
for _, id := range only {
if !known[id] {
return nil, fmt.Errorf("unknown module in --only: %q", id)
}
}
for _, id := range skip {
if !known[id] {
return nil, fmt.Errorf("unknown module in --skip: %q", id)
}
}
disabled := map[string]bool{}
for _, d := range cfg.DisabledModules {
disabled[d] = true
}
for _, d := range skip {
disabled[d] = true
}
onlySet := map[string]bool{}
for _, o := range only {
onlySet[o] = true
}
var out []string
for _, m := range all {
id := m.Meta().ID
if disabled[id] {
continue
}
if len(onlySet) > 0 && !onlySet[id] {
continue
}
out = append(out, id)
}
return out, nil
}
func writeState(started, ended time.Time, sum engine.Summary) error {
s, err := state.DefaultStore()
if err != nil {
return err
}
rec := state.RunRecord{StartedAt: started, EndedAt: ended}
for _, r := range sum.Results {
mr := state.ModuleRecord{ID: r.ID, Status: string(r.Status), At: ended}
if r.Err != nil {
mr.Error = r.Err.Error()
}
rec.Modules = append(rec.Modules, mr)
}
return s.Save(rec, 10)
}
func newThemeCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "theme [id]",
Short: "Switch the active colorscheme across tools",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
// "list" short-circuit.
if len(args) == 1 && args[0] == "list" {
for _, t := range theming.List() {
fmt.Printf("%s — %s\n", t.ID, t.DisplayName)
}
return nil
}
var id string
if len(args) == 1 {
id = args[0]
} else {
themes := theming.List()
opts := make([]huh.Option[string], 0, len(themes))
for _, t := range themes {
opts = append(opts, huh.NewOption(t.DisplayName, t.ID))
}
id = cfg.ThemeOverrides["__active"]
if id == "" && len(themes) > 0 {
id = themes[0].ID
}
form := huh.NewForm(huh.NewGroup(
huh.NewSelect[string]().Title("Choose a theme").Options(opts...).Value(&id),
))
if err := form.RunWithContext(cmd.Context()); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return nil
}
return err
}
}
if err := theming.Apply(cfg, id); err != nil {
return err
}
fmt.Printf("Applied theme: %s\n", id)
fmt.Println("Restart zellij and neovim to see the new colors.")
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml (defaults to ~/.config/archduino/overlay.yaml)")
return cmd
}
func newNewCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "new <project> <board>",
Short: "Scaffold a new Arduino sketch directory",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
project, board := args[0], args[1]
if err := newproject.Run(cfg, project, board); err != nil {
return err
}
fmt.Printf("Created %s/ (board: %s)\n", project, board)
fmt.Printf("Next: cd %s && nvim %s.ino\n", project, project)
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
return cmd
}
func newDoctorCmd() *cobra.Command {
var overlayPath string
cmd := &cobra.Command{
Use: "doctor",
Short: "Report module convergence and pinned-tool version drift",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(overlayPath)
if err != nil {
return err
}
reg, err := buildRegistry(cfg, cfg.ThemeOverrides["__active"])
if err != nil {
return fmt.Errorf("build registry: %w", err)
}
code := doctor.Run(cmd.Context(), cfg, reg, platform.ExecRunner{}, os.Stdout)
if code != 0 {
os.Exit(code)
}
return nil
},
}
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
return cmd
}