package main import ( "context" "fmt" "os" "os/signal" "syscall" "time" "github.com/spf13/cobra" "github.com/MDietermann/ArduinoArchSetup/internal/config" "github.com/MDietermann/ArduinoArchSetup/internal/engine" "github.com/MDietermann/ArduinoArchSetup/internal/module" "github.com/MDietermann/ArduinoArchSetup/internal/modules/arduinocli" "github.com/MDietermann/ArduinoArchSetup/internal/modules/fonts" "github.com/MDietermann/ArduinoArchSetup/internal/modules/gittools" "github.com/MDietermann/ArduinoArchSetup/internal/modules/packages" "github.com/MDietermann/ArduinoArchSetup/internal/modules/shellconfig" "github.com/MDietermann/ArduinoArchSetup/internal/modules/starship" "github.com/MDietermann/ArduinoArchSetup/internal/modules/themes" "github.com/MDietermann/ArduinoArchSetup/internal/modules/yazi" "github.com/MDietermann/ArduinoArchSetup/internal/modules/zellij" "github.com/MDietermann/ArduinoArchSetup/internal/modules/zsh" "github.com/MDietermann/ArduinoArchSetup/internal/platform" "github.com/MDietermann/ArduinoArchSetup/internal/privilege" "github.com/MDietermann/ArduinoArchSetup/internal/state" "github.com/MDietermann/ArduinoArchSetup/internal/tui" "github.com/MDietermann/ArduinoArchSetup/internal/tui/dashboard" "github.com/MDietermann/ArduinoArchSetup/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) (*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), } 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()) // 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 ) 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 } // 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 } 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) }