244 lines
6.8 KiB
Go
244 lines
6.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"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/hello"
|
|
"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"
|
|
|
|
// Registry is the global module registry. Tasks in Plan 3 will register
|
|
// real modules into it via init(). Task 12 registers the hello toy module.
|
|
var Registry = module.NewRegistry()
|
|
|
|
func init() {
|
|
if err := Registry.Register(hello.Module{}); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
root := &cobra.Command{
|
|
Use: "archduino",
|
|
Short: "Archduino installer and toolbox",
|
|
SilenceUsage: true,
|
|
SilenceErrors: true,
|
|
Version: version,
|
|
}
|
|
root.AddCommand(newInstallCmd())
|
|
if err := root.Execute(); 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
|
|
}
|
|
|
|
selected, err := resolveSelection(Registry, 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, Registry, 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, Registry)
|
|
// 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, Registry, initial)
|
|
if werr != nil {
|
|
return werr
|
|
}
|
|
if !confirmed {
|
|
fmt.Fprintln(os.Stdout, "installation cancelled")
|
|
return nil
|
|
}
|
|
sum, runErr = dashboard.Run(ctx, Registry, finalState, halt)
|
|
}
|
|
|
|
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)
|
|
}
|