From 99db34d70fb91073962e8863a093724d8e461245 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 09:07:44 +0200 Subject: [PATCH] feat(modules): port arduino-cli module to Go --- internal/modules/arduinocli/arduinocli.go | 138 ++++++++++++++++++ .../modules/arduinocli/arduinocli_test.go | 94 ++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 internal/modules/arduinocli/arduinocli.go create mode 100644 internal/modules/arduinocli/arduinocli_test.go diff --git a/internal/modules/arduinocli/arduinocli.go b/internal/modules/arduinocli/arduinocli.go new file mode 100644 index 0000000..ac78f96 --- /dev/null +++ b/internal/modules/arduinocli/arduinocli.go @@ -0,0 +1,138 @@ +// Package arduinocli installs arduino-cli and its configured cores. +package arduinocli + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + cfg config.ArduinoCLIConfig + runner platform.Runner +} + +func New(cfg config.ArduinoCLIConfig, r platform.Runner) Module { + return Module{cfg: cfg, runner: r} +} + +type state struct { + BinaryPath string + BinaryInstalled bool + MissingCores []string +} + +func (Module) Meta() module.Meta { + return module.Meta{ID: "arduinocli", Name: "arduino-cli", Description: "Install arduino-cli and cores."} +} + +func binaryPath() (string, error) { + h, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(h, ".local", "bin", "arduino-cli"), nil +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + bp, err := binaryPath() + if err != nil { + return nil, err + } + s := state{BinaryPath: bp} + if _, err := os.Stat(bp); err == nil { + s.BinaryInstalled = true + out, _ := m.runner.Output(ctx, bp, "core", "list") + for _, core := range m.cfg.Cores { + if !bytes.Contains(out, []byte(core)) { + s.MissingCores = append(s.MissingCores, core) + } + } + } else { + s.MissingCores = append([]string{}, m.cfg.Cores...) + } + return s, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + var actions []module.Action + if !s.BinaryInstalled { + bp := s.BinaryPath + 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) + }, + }) + } + for _, core := range s.MissingCores { + core := core + actions = append(actions, module.Action{ + Description: "arduino-cli core update-index (for " + core + ")", + Kind: module.ActionCommand, + Exec: func(ctx context.Context) error { + return m.runner.Run(ctx, s.BinaryPath, "core", "update-index") + }, + }) + actions = append(actions, module.Action{ + Description: "arduino-cli core install " + core, + Kind: module.ActionCommand, + Exec: func(ctx context.Context) error { + return m.runner.Run(ctx, s.BinaryPath, "core", "install", core) + }, + }) + } + return module.Plan{Actions: actions}, nil +} + +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { + return err + } + } + return nil +} + +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx) + if err != nil { + return err + } + s := st.(state) + if !s.BinaryInstalled { + return fmt.Errorf("arduino-cli binary missing") + } + if len(s.MissingCores) > 0 { + return fmt.Errorf("cores missing: %v", s.MissingCores) + } + return nil +} diff --git a/internal/modules/arduinocli/arduinocli_test.go b/internal/modules/arduinocli/arduinocli_test.go new file mode 100644 index 0000000..f43ae9a --- /dev/null +++ b/internal/modules/arduinocli/arduinocli_test.go @@ -0,0 +1,94 @@ +package arduinocli + +import ( + "archive/tar" + "compress/gzip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +type fakeRunner struct { + runCalls [][]string + outCalls [][]string + coreListOut []byte +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + return nil +} + +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if filepath.Base(name) == "arduino-cli" && len(args) >= 2 && args[0] == "core" && args[1] == "list" { + return f.coreListOut, nil + } + return nil, nil +} + +type nop struct{} + +func (nop) Progress(string, int, int, string) {} +func (nop) Log(string, string) {} + +func makeTarGz(t *testing.T, entries map[string][]byte) string { + t.Helper() + p := filepath.Join(t.TempDir(), "a.tgz") + f, _ := os.Create(p) + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + for name, data := range entries { + _ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(data))}) + _, _ = tw.Write(data) + } + tw.Close() + gz.Close() + f.Close() + return p +} + +func TestArduinoCLIDownloadsAndInstallsCore(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + archive := makeTarGz(t, map[string][]byte{"arduino-cli": []byte("#!/bin/sh\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("ID Installed\n")} + m := New(config.ArduinoCLIConfig{ + Version: "1.0.0", + URL: srv.URL + "/arduino-cli.tgz", + Cores: []string{"arduino:avr"}, + }, r) + + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nop{}); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(filepath.Join(home, ".local", "bin", "arduino-cli")); err != nil { + t.Fatalf("binary not installed: %v", err) + } + sawCore := false + for _, c := range r.runCalls { + if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 4 && c[1] == "core" && c[2] == "install" && c[3] == "arduino:avr" { + sawCore = true + } + } + if !sawCore { + t.Fatalf("core install not run; calls=%v", r.runCalls) + } +} + +var _ module.Module = Module{}