425 lines
13 KiB
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
|
|
}
|