diff --git a/cmd/archduino/main.go b/cmd/archduino/main.go index bbcc835..aa77686 100644 --- a/cmd/archduino/main.go +++ b/cmd/archduino/main.go @@ -2,12 +2,14 @@ package main import ( "context" + "errors" "fmt" "os" "os/signal" "syscall" "time" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" "github.com/MDietermann/ArduinoArchSetup/internal/config" @@ -28,6 +30,9 @@ import ( "github.com/MDietermann/ArduinoArchSetup/internal/platform" "github.com/MDietermann/ArduinoArchSetup/internal/privilege" "github.com/MDietermann/ArduinoArchSetup/internal/state" + "github.com/MDietermann/ArduinoArchSetup/internal/subcommands/doctor" + "github.com/MDietermann/ArduinoArchSetup/internal/subcommands/newproject" + "github.com/MDietermann/ArduinoArchSetup/internal/theming" "github.com/MDietermann/ArduinoArchSetup/internal/tui" "github.com/MDietermann/ArduinoArchSetup/internal/tui/dashboard" "github.com/MDietermann/ArduinoArchSetup/internal/tui/wizard" @@ -57,7 +62,7 @@ func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error) themes.New(cfg.Themes), arduinocli.New(cfg.ArduinoCLI, runner), shellconfig.New(cfg.ShellConfig), - neovim.New(cfg.Neovim, themeID, tui.BuiltinThemes, pm, git), + neovim.New(cfg.Neovim, themeID, pm, git), renode.New(cfg.Renode, pm, yay), } for _, m := range mods { @@ -77,6 +82,9 @@ func main() { 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. @@ -147,8 +155,9 @@ func newInstallCmd() *cobra.Command { started := time.Now().UTC() var ( - sum engine.Summary - runErr error + sum engine.Summary + runErr error + selectedTheme = cfg.ThemeOverrides["__active"] ) if noTUI || dryRun { @@ -194,6 +203,9 @@ func newInstallCmd() *cobra.Command { 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) } @@ -203,6 +215,14 @@ func newInstallCmd() *cobra.Command { 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 { @@ -296,3 +316,109 @@ func writeState(started, ended time.Time, sum engine.Summary) error { } 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 ", + 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 +} diff --git a/internal/config/config.go b/internal/config/config.go index 42d2274..f23c512 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,6 +61,16 @@ type RenodeConfig struct { Platforms []RenodePlatform `yaml:"platforms"` } +type Board struct { + Name string `yaml:"name"` + FQBN string `yaml:"fqbn"` + Template string `yaml:"template"` +} + +type Scaffolding struct { + Boards []Board `yaml:"boards"` +} + // Config is the fully-resolved runtime configuration. type Config struct { Version int `yaml:"version"` @@ -76,6 +86,7 @@ type Config struct { ShellConfig ShellConfigSection `yaml:"shell_config"` Neovim NeovimConfig `yaml:"neovim"` Renode RenodeConfig `yaml:"renode"` + Scaffolding Scaffolding `yaml:"scaffolding"` } const currentVersion = 1 @@ -161,5 +172,8 @@ func merge(base, over Config) Config { if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 { out.Renode = over.Renode } + if len(over.Scaffolding.Boards) > 0 { + out.Scaffolding = over.Scaffolding + } return out } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4becd3c..d2b31cd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -108,3 +108,26 @@ func TestLoadRejectsUnknownVersion(t *testing.T) { t.Fatal("expected version error") } } + +func TestDefaultsCarryScaffoldingBoards(t *testing.T) { + cfg, err := Load("/nonexistent/overlay.yaml") + if err != nil { + t.Fatal(err) + } + if len(cfg.Scaffolding.Boards) < 6 { + t.Fatalf("want >= 6 boards, got %d", len(cfg.Scaffolding.Boards)) + } + want := map[string]string{ + "uno": "arduino:avr:uno", "mega": "arduino:avr:mega", "nano": "arduino:avr:nano", + "esp32": "esp32:esp32:esp32", "esp32s3": "esp32:esp32:esp32s3", "esp8266": "esp8266:esp8266:nodemcuv2", + } + got := map[string]string{} + for _, b := range cfg.Scaffolding.Boards { + got[b.Name] = b.FQBN + } + for name, fqbn := range want { + if got[name] != fqbn { + t.Errorf("board %q: got fqbn %q, want %q", name, got[name], fqbn) + } + } +} diff --git a/internal/config/defaults.yaml b/internal/config/defaults.yaml index 51da5bf..b84c562 100644 --- a/internal/config/defaults.yaml +++ b/internal/config/defaults.yaml @@ -97,3 +97,12 @@ renode: - name: nrf52840 repl_path: "platforms/cpus/nrf52840.repl" machine: "nRF52840" + +scaffolding: + boards: + - {name: uno, fqbn: "arduino:avr:uno", template: uno} + - {name: mega, fqbn: "arduino:avr:mega", template: mega} + - {name: nano, fqbn: "arduino:avr:nano", template: nano} + - {name: esp32, fqbn: "esp32:esp32:esp32", template: esp32} + - {name: esp32s3, fqbn: "esp32:esp32:esp32s3", template: esp32s3} + - {name: esp8266, fqbn: "esp8266:esp8266:nodemcuv2", template: esp8266} diff --git a/internal/modules/neovim/neovim.go b/internal/modules/neovim/neovim.go index e61c51f..fc1dbd4 100644 --- a/internal/modules/neovim/neovim.go +++ b/internal/modules/neovim/neovim.go @@ -17,12 +17,19 @@ import ( "github.com/MDietermann/ArduinoArchSetup/internal/config" "github.com/MDietermann/ArduinoArchSetup/internal/module" "github.com/MDietermann/ArduinoArchSetup/internal/platform" - "github.com/MDietermann/ArduinoArchSetup/internal/tui" + "github.com/MDietermann/ArduinoArchSetup/internal/theming" ) //go:embed assets/* var assets embed.FS +// Register WriteChadrc with the theming package so theming.Apply can +// rewrite chadrc.lua on theme changes without importing this package +// (which would be a cycle: neovim imports theming). +func init() { + theming.ChadrcWriter = WriteChadrc +} + // assetMap lists each asset and its destination under ~/.config/nvim. // Order matters: Detect iterates in this order and Apply writes in this order. var assetMap = []struct { @@ -40,14 +47,13 @@ var assetMap = []struct { type Module struct { cfg config.NeovimConfig themeID string - themes []tui.Theme pm platform.PackageManager git platform.GitClient } // New constructs a Neovim Module. -func New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module { - return Module{cfg: cfg, themeID: themeID, themes: themes, pm: pm, git: git} +func New(cfg config.NeovimConfig, themeID string, pm platform.PackageManager, git platform.GitClient) Module { + return Module{cfg: cfg, themeID: themeID, pm: pm, git: git} } type state struct { @@ -79,13 +85,48 @@ func nvimCfg() (string, error) { } func (m Module) resolvedTheme() string { - if t, ok := tui.ThemeByID(m.themes, m.themeID); ok && t.NvChadTheme != "" { + if t, ok := theming.Get(m.themeID); ok { return t.NvChadTheme } - if len(m.themes) > 0 { - return m.themes[0].NvChadTheme + return theming.Default().NvChadTheme +} + +// WriteChadrc renders chadrc.lua for the given NvChad theme name and writes +// it to ~/.config/nvim/lua/chadrc.lua. If the file does not already exist +// and the surrounding nvim config dir does not exist, the function returns +// nil without writing anything (the user hasn't installed neovim yet). +// +// Exported so the theming package can re-render chadrc on theme switches +// without re-running the full Module pipeline. +func WriteChadrc(nvChadThemeName string) error { + home, err := os.UserHomeDir() + if err != nil { + return err } - return "gruvbox" + cfg := filepath.Join(home, ".config", "nvim") + chadrcPath := filepath.Join(cfg, "lua", "chadrc.lua") + + // If chadrc doesn't exist, only write when the parent dir exists. A + // fresh system where nvim was never installed should be a no-op. + if _, err := os.Stat(chadrcPath); err != nil { + if _, err := os.Stat(cfg); err != nil { + return nil + } + } + + tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl") + if err != nil { + return err + } + tmpl, err := template.New("chadrc").Parse(string(tmplBytes)) + if err != nil { + return err + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: nvChadThemeName}); err != nil { + return err + } + return platform.WriteUserFile(chadrcPath, buf.Bytes(), 0o644) } func (m Module) renderChadrc() ([]byte, error) { diff --git a/internal/modules/neovim/neovim_test.go b/internal/modules/neovim/neovim_test.go index 6ad7510..7c91180 100644 --- a/internal/modules/neovim/neovim_test.go +++ b/internal/modules/neovim/neovim_test.go @@ -10,7 +10,6 @@ import ( "github.com/MDietermann/ArduinoArchSetup/internal/config" "github.com/MDietermann/ArduinoArchSetup/internal/module" "github.com/MDietermann/ArduinoArchSetup/internal/platform" - "github.com/MDietermann/ArduinoArchSetup/internal/tui" ) type fakeRunner struct { @@ -58,7 +57,7 @@ func TestDetectFreshSystem(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) r := &fakeRunner{installed: map[string]bool{}} - m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r}) st, err := m.Detect(context.Background()) if err != nil { t.Fatal(err) @@ -81,7 +80,7 @@ func TestDetectNonNvChadTriggersBackup(t *testing.T) { os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755) os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644) r := &fakeRunner{installed: map[string]bool{}} - m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r}) st, _ := m.Detect(context.Background()) if !st.(state).BackupNeeded { t.Fatal("expected BackupNeeded=true") @@ -92,7 +91,7 @@ func TestPlanApplyInstallsEverything(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) r := &fakeRunner{installed: map[string]bool{}} - m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", platform.NewPacman(r), platform.GitClient{Runner: r}) st, _ := m.Detect(context.Background()) p, _ := m.Plan(context.Background(), st) if len(p.Actions) == 0 { @@ -150,7 +149,7 @@ func TestPlanAfterApplyIsEmpty(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) r := &fakeRunner{installed: map[string]bool{"neovim": true}} - m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r}) st, _ := m.Detect(context.Background()) p, _ := m.Plan(context.Background(), st) _ = m.Apply(context.Background(), p, nopReporter{}) @@ -161,20 +160,51 @@ func TestPlanAfterApplyIsEmpty(t *testing.T) { } } -func TestUnknownThemeFallsBackToFirst(t *testing.T) { +func TestUnknownThemeFallsBackToDefault(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) r := &fakeRunner{installed: map[string]bool{"neovim": true}} - m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "does-not-exist", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "does-not-exist", platform.NewPacman(r), platform.GitClient{Runner: r}) st, _ := m.Detect(context.Background()) p, _ := m.Plan(context.Background(), st) if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { t.Fatal(err) } chadrc, _ := os.ReadFile(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")) + // Unknown theme IDs fall back to theming.Default(), which is gruvbox. if !strings.Contains(string(chadrc), `theme = "gruvbox"`) { t.Fatalf("fallback theme not applied: %s", chadrc) } } +func TestWriteChadrcWritesFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cfg := filepath.Join(home, ".config", "nvim") + if err := os.MkdirAll(cfg, 0o755); err != nil { + t.Fatal(err) + } + if err := WriteChadrc("dracula"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `theme = "dracula"`) { + t.Fatalf("theme not applied: %s", data) + } +} + +func TestWriteChadrcNoopWithoutConfigDir(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := WriteChadrc("gruvbox"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")); !os.IsNotExist(err) { + t.Fatal("chadrc should not be created on a bare system") + } +} + var _ module.Module = Module{} diff --git a/internal/subcommands/doctor/doctor.go b/internal/subcommands/doctor/doctor.go new file mode 100644 index 0000000..1776978 --- /dev/null +++ b/internal/subcommands/doctor/doctor.go @@ -0,0 +1,104 @@ +// Package doctor reports module convergence and pinned-tool version drift. +package doctor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "regexp" + "strings" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +// Run inspects every module in the registry plus pinned tool versions and +// writes a human-readable report to w. Returns 0 when everything is +// converged with no drift; 1 otherwise. Never panics on module errors. +func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) int { + var converged, pending, errs, drift int + + for _, m := range reg.All() { + id := m.Meta().ID + st, err := m.Detect(ctx) + if err != nil { + fmt.Fprintf(w, "[!] %s — detect failed: %v\n", id, err) + errs++ + continue + } + pl, err := m.Plan(ctx, st) + if err != nil { + fmt.Fprintf(w, "[!] %s — plan failed: %v\n", id, err) + errs++ + continue + } + if pl.IsEmpty() { + fmt.Fprintf(w, "[✓] %s\n", id) + converged++ + } else { + fmt.Fprintf(w, "[✗] %s (%d actions pending)\n", id, len(pl.Actions)) + pending++ + } + } + + drift += checkArduinoCLI(ctx, runner, cfg.ArduinoCLI.Version, w) + drift += checkWokwiCLI(ctx, runner, cfg.Renode.WokwiVersion, w) + + fmt.Fprintf(w, "\n%d converged, %d need-install, %d drift, %d errors\n", converged, pending, drift, errs) + + if pending > 0 || drift > 0 || errs > 0 { + return 1 + } + return 0 +} + +type arduinoCLIVersion struct { + VersionString string `json:"VersionString"` +} + +func checkArduinoCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int { + if want == "" { + return 0 + } + out, err := runner.Output(ctx, "arduino-cli", "version", "--format", "json") + if err != nil { + return 0 // not installed → silent skip + } + var v arduinoCLIVersion + if err := json.Unmarshal(out, &v); err != nil || v.VersionString == "" { + fmt.Fprintf(w, "[!] arduino-cli — version parse failed\n") + return 1 + } + if v.VersionString != want { + fmt.Fprintf(w, "[!] arduino-cli drift: got %s, want %s\n", v.VersionString, want) + return 1 + } + fmt.Fprintf(w, "[✓] arduino-cli %s\n", v.VersionString) + return 0 +} + +var wokwiVersionRE = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`) + +func checkWokwiCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int { + if want == "" { + return 0 + } + out, err := runner.Output(ctx, "wokwi-cli", "--version") + if err != nil { + return 0 + } + m := wokwiVersionRE.FindStringSubmatch(strings.TrimSpace(string(out))) + if len(m) < 2 { + fmt.Fprintf(w, "[!] wokwi-cli — version parse failed\n") + return 1 + } + got := m[1] + if got != want { + fmt.Fprintf(w, "[!] wokwi-cli drift: got %s, want %s\n", got, want) + return 1 + } + fmt.Fprintf(w, "[✓] wokwi-cli %s\n", got) + return 0 +} diff --git a/internal/subcommands/doctor/doctor_test.go b/internal/subcommands/doctor/doctor_test.go new file mode 100644 index 0000000..ad3f5e9 --- /dev/null +++ b/internal/subcommands/doctor/doctor_test.go @@ -0,0 +1,135 @@ +package doctor + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +// convergedModule returns an empty Plan — always converged. +type convergedModule struct{ id string } + +func (c convergedModule) Meta() module.Meta { return module.Meta{ID: c.id, Name: c.id} } +func (c convergedModule) Detect(context.Context) (module.State, error) { return nil, nil } +func (c convergedModule) Plan(context.Context, module.State) (module.Plan, error) { + return module.Plan{}, nil +} +func (c convergedModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } +func (c convergedModule) Verify(context.Context) error { return nil } + +type pendingModule struct{ id string } + +func (p pendingModule) Meta() module.Meta { return module.Meta{ID: p.id} } +func (p pendingModule) Detect(context.Context) (module.State, error) { return nil, nil } +func (p pendingModule) Plan(context.Context, module.State) (module.Plan, error) { + return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil +} +func (p pendingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } +func (p pendingModule) Verify(context.Context) error { return nil } + +type detectFailModule struct{ id string } + +func (d detectFailModule) Meta() module.Meta { return module.Meta{ID: d.id} } +func (d detectFailModule) Detect(context.Context) (module.State, error) { return nil, errors.New("boom") } +func (d detectFailModule) Plan(context.Context, module.State) (module.Plan, error) { + return module.Plan{}, nil +} +func (d detectFailModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } +func (d detectFailModule) Verify(context.Context) error { return nil } + +// notFoundRunner simulates "command not found" for version checks. +type notFoundRunner struct{} + +func (notFoundRunner) Run(context.Context, string, ...string) error { return errors.New("not found") } +func (notFoundRunner) Output(context.Context, string, ...string) ([]byte, error) { + return nil, errors.New("not found") +} + +// fixedRunner returns a preset version string for a matching command. +type fixedRunner struct { + arduinoOut []byte + arduinoErr error + wokwiOut []byte + wokwiErr error +} + +func (f fixedRunner) Run(context.Context, string, ...string) error { return nil } +func (f fixedRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + switch name { + case "arduino-cli": + return f.arduinoOut, f.arduinoErr + case "wokwi-cli": + return f.wokwiOut, f.wokwiErr + } + return nil, errors.New("no match") +} + +func TestDoctorAllConvergedExitsZero(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(convergedModule{id: "foo"}) + var buf bytes.Buffer + code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf) + if code != 0 { + t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String()) + } + if !strings.Contains(buf.String(), "[✓] foo") { + t.Errorf("missing converged mark: %s", buf.String()) + } +} + +func TestDoctorDivergedExitsOne(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(pendingModule{id: "bar"}) + var buf bytes.Buffer + code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf) + if code != 1 { + t.Fatalf("exit %d, want 1", code) + } + if !strings.Contains(buf.String(), "[✗] bar") { + t.Errorf("missing diverged mark: %s", buf.String()) + } +} + +func TestDoctorDetectErrorIsReported(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(detectFailModule{id: "baz"}) + var buf bytes.Buffer + code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf) + if code != 1 { + t.Fatalf("exit %d, want 1", code) + } + if !strings.Contains(buf.String(), "[!] baz") { + t.Errorf("missing error mark: %s", buf.String()) + } +} + +func TestDoctorArduinoCLIDriftIsReported(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(convergedModule{id: "foo"}) + cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}} + runner := fixedRunner{arduinoOut: []byte(`{"VersionString":"0.36.0"}`)} + var buf bytes.Buffer + code := Run(context.Background(), cfg, reg, runner, &buf) + if code != 1 { + t.Fatalf("exit %d, want 1 (drift)", code) + } + if !strings.Contains(buf.String(), "drift") { + t.Errorf("missing drift report: %s", buf.String()) + } +} + +func TestDoctorMissingBinariesSkipVersionCheck(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(convergedModule{id: "foo"}) + cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}} + var buf bytes.Buffer + code := Run(context.Background(), cfg, reg, notFoundRunner{}, &buf) + if code != 0 { + t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String()) + } +} diff --git a/internal/subcommands/newproject/assets/default.ino b/internal/subcommands/newproject/assets/default.ino new file mode 100644 index 0000000..2d0cebb --- /dev/null +++ b/internal/subcommands/newproject/assets/default.ino @@ -0,0 +1,9 @@ +void setup() { + Serial.begin(115200); + while (!Serial) { ; } + Serial.println("Hello from Arduino!"); +} + +void loop() { + // Your code here +} diff --git a/internal/subcommands/newproject/assets/esp32.ino b/internal/subcommands/newproject/assets/esp32.ino new file mode 100644 index 0000000..12bc573 --- /dev/null +++ b/internal/subcommands/newproject/assets/esp32.ino @@ -0,0 +1,22 @@ +#include + +const char* ssid = "YOUR_SSID"; +const char* password = "YOUR_PASS"; + +void setup() { + Serial.begin(115200); + delay(1000); + WiFi.begin(ssid, password); + Serial.print("Connecting to WiFi"); + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print("."); + } + Serial.println(); + Serial.print("Connected! IP: "); + Serial.println(WiFi.localIP()); +} + +void loop() { + delay(1000); +} diff --git a/internal/subcommands/newproject/assets/esp32s3.ino b/internal/subcommands/newproject/assets/esp32s3.ino new file mode 100644 index 0000000..2d0cebb --- /dev/null +++ b/internal/subcommands/newproject/assets/esp32s3.ino @@ -0,0 +1,9 @@ +void setup() { + Serial.begin(115200); + while (!Serial) { ; } + Serial.println("Hello from Arduino!"); +} + +void loop() { + // Your code here +} diff --git a/internal/subcommands/newproject/assets/esp8266.ino b/internal/subcommands/newproject/assets/esp8266.ino new file mode 100644 index 0000000..fb8694c --- /dev/null +++ b/internal/subcommands/newproject/assets/esp8266.ino @@ -0,0 +1,22 @@ +#include + +const char* ssid = "YOUR_SSID"; +const char* password = "YOUR_PASS"; + +void setup() { + Serial.begin(115200); + delay(1000); + WiFi.begin(ssid, password); + Serial.print("Connecting to WiFi"); + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print("."); + } + Serial.println(); + Serial.print("Connected! IP: "); + Serial.println(WiFi.localIP()); +} + +void loop() { + delay(1000); +} diff --git a/internal/subcommands/newproject/assets/mega.ino b/internal/subcommands/newproject/assets/mega.ino new file mode 100644 index 0000000..2d0cebb --- /dev/null +++ b/internal/subcommands/newproject/assets/mega.ino @@ -0,0 +1,9 @@ +void setup() { + Serial.begin(115200); + while (!Serial) { ; } + Serial.println("Hello from Arduino!"); +} + +void loop() { + // Your code here +} diff --git a/internal/subcommands/newproject/assets/nano.ino b/internal/subcommands/newproject/assets/nano.ino new file mode 100644 index 0000000..2d0cebb --- /dev/null +++ b/internal/subcommands/newproject/assets/nano.ino @@ -0,0 +1,9 @@ +void setup() { + Serial.begin(115200); + while (!Serial) { ; } + Serial.println("Hello from Arduino!"); +} + +void loop() { + // Your code here +} diff --git a/internal/subcommands/newproject/assets/uno.ino b/internal/subcommands/newproject/assets/uno.ino new file mode 100644 index 0000000..2d0cebb --- /dev/null +++ b/internal/subcommands/newproject/assets/uno.ino @@ -0,0 +1,9 @@ +void setup() { + Serial.begin(115200); + while (!Serial) { ; } + Serial.println("Hello from Arduino!"); +} + +void loop() { + // Your code here +} diff --git a/internal/subcommands/newproject/newproject.go b/internal/subcommands/newproject/newproject.go new file mode 100644 index 0000000..4f44faa --- /dev/null +++ b/internal/subcommands/newproject/newproject.go @@ -0,0 +1,75 @@ +// Package newproject scaffolds a new Arduino sketch directory. +package newproject + +import ( + "embed" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +//go:embed assets +var assetsFS embed.FS + +const clangdContent = `CompileFlags: + Add: + - -xc++ + - -std=c++17 +` + +// Run creates a new Arduino project directory. It errors if the project +// directory already exists, if the board is unknown, or if the project name +// contains a path separator. +func Run(cfg config.Config, project, board string) error { + if project == "" { + return fmt.Errorf("project name required") + } + if strings.ContainsAny(project, "/\\") || strings.HasPrefix(project, ".") { + return fmt.Errorf("invalid project name %q", project) + } + + var b *config.Board + var valid []string + for i := range cfg.Scaffolding.Boards { + bp := &cfg.Scaffolding.Boards[i] + valid = append(valid, bp.Name) + if bp.Name == board { + b = bp + } + } + if b == nil { + return fmt.Errorf("unknown board %q; valid: %s", board, strings.Join(valid, ", ")) + } + + if _, err := os.Stat(project); err == nil { + return fmt.Errorf("project directory %q already exists", project) + } + if err := os.MkdirAll(project, 0o755); err != nil { + return err + } + + // Pick .ino template: try