diff --git a/internal/modules/arduinocli/arduinocli.go b/internal/modules/arduinocli/arduinocli.go index d098be8..6e115ef 100644 --- a/internal/modules/arduinocli/arduinocli.go +++ b/internal/modules/arduinocli/arduinocli.go @@ -4,7 +4,6 @@ package arduinocli import ( "bytes" "context" - "errors" "fmt" "os" "path/filepath" @@ -61,6 +60,30 @@ func (m Module) Detect(ctx context.Context) (module.State, error) { return s, nil } +// downloadBinary fetches, extracts, and installs arduino-cli to bp. +func (m Module) downloadBinary(ctx context.Context, bp string) error { + tmpDir, err := os.MkdirTemp("", "arduinocli-") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + archive := filepath.Join(tmpDir, "arduino-cli.tar.gz") + if err := platform.Download(ctx, m.cfg.URL, archive, 0o644); err != nil { + return err + } + if err := platform.ExtractTarGz(ctx, archive, tmpDir); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(bp), 0o755); err != nil { + return err + } + data, err := os.ReadFile(filepath.Join(tmpDir, "arduino-cli")) + if err != nil { + return err + } + return os.WriteFile(bp, data, 0o755) +} + func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { s, _ := cur.(state) var actions []module.Action @@ -69,28 +92,7 @@ func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) actions = append(actions, module.Action{ Description: "download + install arduino-cli " + m.cfg.Version, Kind: module.ActionFileWrite, - Exec: func(ctx context.Context) error { - tmpDir, err := os.MkdirTemp("", "arduinocli-") - if err != nil { - return err - } - defer os.RemoveAll(tmpDir) - archive := filepath.Join(tmpDir, "arduino-cli.tar.gz") - if err := platform.Download(ctx, m.cfg.URL, archive, 0o644); err != nil { - return err - } - if err := platform.ExtractTarGz(ctx, archive, tmpDir); err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(bp), 0o755); err != nil { - return err - } - data, err := os.ReadFile(filepath.Join(tmpDir, "arduino-cli")) - if err != nil { - return err - } - return os.WriteFile(bp, data, 0o755) - }, + Exec: func(ctx context.Context) error { return m.downloadBinary(ctx, bp) }, }) } for _, core := range s.MissingCores { @@ -138,6 +140,33 @@ func (m Module) Verify(ctx context.Context) error { return nil } +// PlanForce redownloads the arduino-cli binary, refreshes the core index, +// and reinstalls every configured core unconditionally. arduino-cli's +// `core install` is idempotent, so reissuing it is safe. func (m Module) PlanForce(ctx context.Context) (module.Plan, error) { - return module.Plan{}, errors.New("PlanForce not implemented") + bp, err := binaryPath() + if err != nil { + return module.Plan{}, err + } + actions := []module.Action{ + { + Description: "redownload + reinstall arduino-cli " + m.cfg.Version + " (force)", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { return m.downloadBinary(ctx, bp) }, + }, + { + Description: "arduino-cli core update-index (force)", + Kind: module.ActionCommand, + Exec: func(ctx context.Context) error { return m.runner.Run(ctx, bp, "core", "update-index") }, + }, + } + for _, core := range m.cfg.Cores { + core := core + actions = append(actions, module.Action{ + Description: "arduino-cli core install " + core + " (force)", + Kind: module.ActionCommand, + Exec: func(ctx context.Context) error { return m.runner.Run(ctx, bp, "core", "install", core) }, + }) + } + return module.Plan{Actions: actions}, nil } diff --git a/internal/modules/arduinocli/arduinocli_test.go b/internal/modules/arduinocli/arduinocli_test.go index 90dbec3..c293f6e 100644 --- a/internal/modules/arduinocli/arduinocli_test.go +++ b/internal/modules/arduinocli/arduinocli_test.go @@ -91,4 +91,64 @@ func TestArduinoCLIDownloadsAndInstallsCore(t *testing.T) { } } +func TestPlanForceRedownloadsAndReinstallsCores(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + // Pre-populate a stale binary to prove force overwrites. + os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755) + bp := filepath.Join(home, ".local", "bin", "arduino-cli") + os.WriteFile(bp, []byte("stale"), 0o755) + + archive := makeTarGz(t, map[string][]byte{"arduino-cli": []byte("#!/bin/sh\nfresh\n")}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := os.ReadFile(archive) + w.Write(data) + })) + defer srv.Close() + + r := &fakeRunner{coreListOut: []byte("arduino:avr\nesp32:esp32\n")} + m := New(config.ArduinoCLIConfig{ + Version: "1.0.0", + URL: srv.URL + "/arduino-cli.tgz", + Cores: []string{"arduino:avr", "esp32:esp32"}, + }, r) + + plan, err := m.PlanForce(context.Background()) + if err != nil { + t.Fatalf("PlanForce: %v", err) + } + if plan.IsEmpty() { + t.Fatal("force plan must not be empty") + } + for _, a := range plan.Actions { + if err := a.Exec(context.Background()); err != nil { + t.Fatalf("Exec %q: %v", a.Description, err) + } + } + + // Binary got overwritten with fresh payload. + got, _ := os.ReadFile(bp) + if string(got) == "stale" { + t.Fatalf("binary not overwritten by force") + } + + // update-index and both `core install` calls fired. + sawUpdate := false + installed := map[string]bool{} + for _, c := range r.runCalls { + if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 3 && c[1] == "core" && c[2] == "update-index" { + sawUpdate = true + } + if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 4 && c[1] == "core" && c[2] == "install" { + installed[c[3]] = true + } + } + if !sawUpdate { + t.Errorf("no core update-index call; calls=%v", r.runCalls) + } + if !installed["arduino:avr"] || !installed["esp32:esp32"] { + t.Errorf("expected both cores force-installed; got=%v calls=%v", installed, r.runCalls) + } +} + var _ module.Module = Module{}