Merge Plan 4a: theme/new/doctor subcommands + theming package

This commit is contained in:
MDietermann
2026-04-14 14:25:48 +02:00
47 changed files with 1716 additions and 76 deletions

View File

@@ -2,12 +2,14 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/MDietermann/ArduinoArchSetup/internal/config" "github.com/MDietermann/ArduinoArchSetup/internal/config"
@@ -28,6 +30,9 @@ import (
"github.com/MDietermann/ArduinoArchSetup/internal/platform" "github.com/MDietermann/ArduinoArchSetup/internal/platform"
"github.com/MDietermann/ArduinoArchSetup/internal/privilege" "github.com/MDietermann/ArduinoArchSetup/internal/privilege"
"github.com/MDietermann/ArduinoArchSetup/internal/state" "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"
"github.com/MDietermann/ArduinoArchSetup/internal/tui/dashboard" "github.com/MDietermann/ArduinoArchSetup/internal/tui/dashboard"
"github.com/MDietermann/ArduinoArchSetup/internal/tui/wizard" "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), themes.New(cfg.Themes),
arduinocli.New(cfg.ArduinoCLI, runner), arduinocli.New(cfg.ArduinoCLI, runner),
shellconfig.New(cfg.ShellConfig), 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), renode.New(cfg.Renode, pm, yay),
} }
for _, m := range mods { for _, m := range mods {
@@ -77,6 +82,9 @@ func main() {
Version: version, Version: version,
} }
root.AddCommand(newInstallCmd()) root.AddCommand(newInstallCmd())
root.AddCommand(newThemeCmd())
root.AddCommand(newNewCmd())
root.AddCommand(newDoctorCmd())
// Wire SIGINT/SIGTERM to a cancellable context so long-running phases // Wire SIGINT/SIGTERM to a cancellable context so long-running phases
// (engine.Run, sudo keepalive) observe the cancellation promptly. // (engine.Run, sudo keepalive) observe the cancellation promptly.
@@ -147,8 +155,9 @@ func newInstallCmd() *cobra.Command {
started := time.Now().UTC() started := time.Now().UTC()
var ( var (
sum engine.Summary sum engine.Summary
runErr error runErr error
selectedTheme = cfg.ThemeOverrides["__active"]
) )
if noTUI || dryRun { if noTUI || dryRun {
@@ -194,6 +203,9 @@ func newInstallCmd() *cobra.Command {
if err != nil { if err != nil {
return fmt.Errorf("build registry: %w", err) 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. // Either the CLI flag or the wizard toggle can force halt-on-error.
sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError) sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError)
} }
@@ -203,6 +215,14 @@ func newInstallCmd() *cobra.Command {
return runErr 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() ok, fail, skipped := sum.Counts()
fmt.Fprintf(os.Stdout, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skipped) fmt.Fprintf(os.Stdout, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skipped)
for _, r := range sum.Results { for _, r := range sum.Results {
@@ -296,3 +316,109 @@ func writeState(started, ended time.Time, sum engine.Summary) error {
} }
return s.Save(rec, 10) 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 <project> <board>",
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
}

View File

@@ -61,6 +61,16 @@ type RenodeConfig struct {
Platforms []RenodePlatform `yaml:"platforms"` 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. // Config is the fully-resolved runtime configuration.
type Config struct { type Config struct {
Version int `yaml:"version"` Version int `yaml:"version"`
@@ -76,6 +86,7 @@ type Config struct {
ShellConfig ShellConfigSection `yaml:"shell_config"` ShellConfig ShellConfigSection `yaml:"shell_config"`
Neovim NeovimConfig `yaml:"neovim"` Neovim NeovimConfig `yaml:"neovim"`
Renode RenodeConfig `yaml:"renode"` Renode RenodeConfig `yaml:"renode"`
Scaffolding Scaffolding `yaml:"scaffolding"`
} }
const currentVersion = 1 const currentVersion = 1
@@ -161,5 +172,8 @@ func merge(base, over Config) Config {
if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 { if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 {
out.Renode = over.Renode out.Renode = over.Renode
} }
if len(over.Scaffolding.Boards) > 0 {
out.Scaffolding = over.Scaffolding
}
return out return out
} }

View File

@@ -108,3 +108,26 @@ func TestLoadRejectsUnknownVersion(t *testing.T) {
t.Fatal("expected version error") 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)
}
}
}

View File

@@ -97,3 +97,12 @@ renode:
- name: nrf52840 - name: nrf52840
repl_path: "platforms/cpus/nrf52840.repl" repl_path: "platforms/cpus/nrf52840.repl"
machine: "nRF52840" 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}

View File

@@ -17,12 +17,19 @@ import (
"github.com/MDietermann/ArduinoArchSetup/internal/config" "github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/module" "github.com/MDietermann/ArduinoArchSetup/internal/module"
"github.com/MDietermann/ArduinoArchSetup/internal/platform" "github.com/MDietermann/ArduinoArchSetup/internal/platform"
"github.com/MDietermann/ArduinoArchSetup/internal/tui" "github.com/MDietermann/ArduinoArchSetup/internal/theming"
) )
//go:embed assets/* //go:embed assets/*
var assets embed.FS 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. // assetMap lists each asset and its destination under ~/.config/nvim.
// Order matters: Detect iterates in this order and Apply writes in this order. // Order matters: Detect iterates in this order and Apply writes in this order.
var assetMap = []struct { var assetMap = []struct {
@@ -40,14 +47,13 @@ var assetMap = []struct {
type Module struct { type Module struct {
cfg config.NeovimConfig cfg config.NeovimConfig
themeID string themeID string
themes []tui.Theme
pm platform.PackageManager pm platform.PackageManager
git platform.GitClient git platform.GitClient
} }
// New constructs a Neovim Module. // New constructs a Neovim Module.
func New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module { func New(cfg config.NeovimConfig, themeID string, pm platform.PackageManager, git platform.GitClient) Module {
return Module{cfg: cfg, themeID: themeID, themes: themes, pm: pm, git: git} return Module{cfg: cfg, themeID: themeID, pm: pm, git: git}
} }
type state struct { type state struct {
@@ -79,13 +85,48 @@ func nvimCfg() (string, error) {
} }
func (m Module) resolvedTheme() string { 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 return t.NvChadTheme
} }
if len(m.themes) > 0 { return theming.Default().NvChadTheme
return m.themes[0].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) { func (m Module) renderChadrc() ([]byte, error) {

View File

@@ -10,7 +10,6 @@ import (
"github.com/MDietermann/ArduinoArchSetup/internal/config" "github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/module" "github.com/MDietermann/ArduinoArchSetup/internal/module"
"github.com/MDietermann/ArduinoArchSetup/internal/platform" "github.com/MDietermann/ArduinoArchSetup/internal/platform"
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
) )
type fakeRunner struct { type fakeRunner struct {
@@ -58,7 +57,7 @@ func TestDetectFreshSystem(t *testing.T) {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}} 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()) st, err := m.Detect(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -81,7 +80,7 @@ func TestDetectNonNvChadTriggersBackup(t *testing.T) {
os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755) os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755)
os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644) os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644)
r := &fakeRunner{installed: map[string]bool{}} 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()) st, _ := m.Detect(context.Background())
if !st.(state).BackupNeeded { if !st.(state).BackupNeeded {
t.Fatal("expected BackupNeeded=true") t.Fatal("expected BackupNeeded=true")
@@ -92,7 +91,7 @@ func TestPlanApplyInstallsEverything(t *testing.T) {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{}} 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()) st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st) p, _ := m.Plan(context.Background(), st)
if len(p.Actions) == 0 { if len(p.Actions) == 0 {
@@ -150,7 +149,7 @@ func TestPlanAfterApplyIsEmpty(t *testing.T) {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{"neovim": true}} 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()) st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st) p, _ := m.Plan(context.Background(), st)
_ = m.Apply(context.Background(), p, nopReporter{}) _ = 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() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
r := &fakeRunner{installed: map[string]bool{"neovim": true}} 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()) st, _ := m.Detect(context.Background())
p, _ := m.Plan(context.Background(), st) p, _ := m.Plan(context.Background(), st)
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
chadrc, _ := os.ReadFile(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")) 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"`) { if !strings.Contains(string(chadrc), `theme = "gruvbox"`) {
t.Fatalf("fallback theme not applied: %s", chadrc) 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{} var _ module.Module = Module{}

View File

@@ -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
}

View File

@@ -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())
}
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -0,0 +1,22 @@
#include <WiFi.h>
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);
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -0,0 +1,22 @@
#include <ESP8266WiFi.h>
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);
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -0,0 +1,9 @@
void setup() {
Serial.begin(115200);
while (!Serial) { ; }
Serial.println("Hello from Arduino!");
}
void loop() {
// Your code here
}

View File

@@ -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 <template>.ino, else default.ino.
inoData, err := assetsFS.ReadFile(filepath.Join("assets", b.Template+".ino"))
if err != nil {
inoData, err = assetsFS.ReadFile("assets/default.ino")
if err != nil {
return err
}
}
if err := platform.WriteUserFile(filepath.Join(project, project+".ino"), inoData, 0o644); err != nil {
return err
}
sketch := fmt.Sprintf("default_fqbn: %s\ndefault_port: auto\n", b.FQBN)
if err := platform.WriteUserFile(filepath.Join(project, "sketch.yaml"), []byte(sketch), 0o644); err != nil {
return err
}
if err := platform.WriteUserFile(filepath.Join(project, ".clangd"), []byte(clangdContent), 0o644); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,84 @@
package newproject
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
)
func sampleCfg() config.Config {
return config.Config{
Scaffolding: config.Scaffolding{Boards: []config.Board{
{Name: "uno", FQBN: "arduino:avr:uno", Template: "uno"},
{Name: "xyz", FQBN: "fake:fqbn:xyz", Template: "does-not-exist"},
}},
}
}
func TestRunCreatesProject(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := Run(sampleCfg(), "blinky", "uno"); err != nil {
t.Fatal(err)
}
ino, err := os.ReadFile(filepath.Join("blinky", "blinky.ino"))
if err != nil {
t.Fatalf("blinky.ino missing: %v", err)
}
if len(ino) == 0 {
t.Error("blinky.ino empty")
}
sketch, err := os.ReadFile(filepath.Join("blinky", "sketch.yaml"))
if err != nil {
t.Fatalf("sketch.yaml missing: %v", err)
}
if !strings.Contains(string(sketch), "arduino:avr:uno") {
t.Errorf("sketch.yaml missing fqbn: %s", sketch)
}
if _, err := os.Stat(filepath.Join("blinky", ".clangd")); err != nil {
t.Errorf(".clangd missing: %v", err)
}
}
func TestRunRefusesExistingDir(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
os.Mkdir("blinky", 0o755)
if err := Run(sampleCfg(), "blinky", "uno"); err == nil {
t.Fatal("want error on existing dir")
}
}
func TestRunFallsBackToDefaultTemplate(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := Run(sampleCfg(), "thing", "xyz"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join("thing", "thing.ino")); err != nil {
t.Fatalf("thing.ino missing: %v", err)
}
}
func TestRunUnknownBoardErrors(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
err := Run(sampleCfg(), "thing", "bogus")
if err == nil {
t.Fatal("want error")
}
if !strings.Contains(err.Error(), "uno") {
t.Errorf("error should list valid boards: %v", err)
}
}
func TestRunRejectsSlashInProjectName(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := Run(sampleCfg(), "bad/name", "uno"); err == nil {
t.Fatal("want error on slashes")
}
}

View File

@@ -0,0 +1,13 @@
gui:
theme:
activeBorderColor:
- "#f9e2af"
- bold
inactiveBorderColor:
- "#585b70"
selectedLineBgColor:
- "#313244"
cherryPickedCommitFgColor:
- "#a6e3a1"
defaultFgColor:
- "#cdd6f4"

View File

@@ -0,0 +1,26 @@
palette = "catppuccin_mocha"
format = """$directory$git_branch$git_status$character"""
[directory]
style = "bold fg:bright-yellow"
truncation_length = 3
[git_branch]
style = "bold fg:bright-green"
format = " [$branch]($style) "
[git_status]
style = "bold fg:bright-red"
[character]
success_symbol = "[❯](bold fg:bright-green)"
error_symbol = "[❯](bold fg:bright-red)"
[palettes.catppuccin_mocha]
bright-yellow = "#f9e2af"
bright-green = "#a6e3a1"
bright-red = "#f38ba8"
bright-blue = "#89b4fa"
bright-purple = "#cba6f7"
bright-aqua = "#94e2d5"
bright-orange = "#fab387"

View File

@@ -0,0 +1,2 @@
display_name: "Catppuccin Mocha"
nvchad: "catppuccin"

View File

@@ -0,0 +1,30 @@
[manager]
cwd = { fg = "#f9e2af" }
tab_active = { fg = "#1e1e2e", bg = "#f9e2af" }
tab_inactive = { fg = "#bac2de", bg = "#313244" }
border_symbol = "│"
border_style = { fg = "#585b70" }
count_copied = { fg = "#1e1e2e", bg = "#a6e3a1" }
count_cut = { fg = "#1e1e2e", bg = "#f38ba8" }
count_selected = { fg = "#1e1e2e", bg = "#f9e2af" }
[status]
separator_open = ""
separator_close = ""
mode_normal = { fg = "#1e1e2e", bg = "#a6e3a1", bold = true }
mode_select = { fg = "#1e1e2e", bg = "#f9e2af", bold = true }
mode_unset = { fg = "#1e1e2e", bg = "#f38ba8", bold = true }
progress_label = { fg = "#cdd6f4", bold = true }
progress_normal = { fg = "#89b4fa", bg = "#313244" }
progress_error = { fg = "#f38ba8", bg = "#313244" }
[input]
border = { fg = "#f9e2af" }
title = {}
value = {}
selected = { reversed = true }
[select]
border = { fg = "#f9e2af" }
active = { fg = "#a6e3a1", bold = true }
inactive = {}

View File

@@ -0,0 +1,35 @@
theme "catppuccin-mocha"
default_layout "default"
default_mode "normal"
pane_frames false
simplified_ui true
mouse_mode false
keybinds {
shared {
bind "Alt h" { MoveFocusOrTab "Left"; }
bind "Alt l" { MoveFocusOrTab "Right"; }
bind "Alt j" { MoveFocus "Down"; }
bind "Alt k" { MoveFocus "Up"; }
bind "Alt n" { NewPane "Down"; }
bind "Alt v" { NewPane "Right"; }
bind "Alt f" { ToggleFloatingPanes; }
bind "Alt t" { NewTab; }
}
}
themes {
catppuccin-mocha {
fg "#cdd6f4"
bg "#1e1e2e"
black "#181825"
red "#f38ba8"
green "#a6e3a1"
yellow "#f9e2af"
blue "#89b4fa"
magenta "#cba6f7"
cyan "#94e2d5"
white "#bac2de"
orange "#fab387"
}
}

View File

@@ -0,0 +1,13 @@
gui:
theme:
activeBorderColor:
- "#f1fa8c"
- bold
inactiveBorderColor:
- "#6272a4"
selectedLineBgColor:
- "#44475a"
cherryPickedCommitFgColor:
- "#50fa7b"
defaultFgColor:
- "#f8f8f2"

View File

@@ -0,0 +1,26 @@
palette = "dracula"
format = """$directory$git_branch$git_status$character"""
[directory]
style = "bold fg:bright-yellow"
truncation_length = 3
[git_branch]
style = "bold fg:bright-green"
format = " [$branch]($style) "
[git_status]
style = "bold fg:bright-red"
[character]
success_symbol = "[❯](bold fg:bright-green)"
error_symbol = "[❯](bold fg:bright-red)"
[palettes.dracula]
bright-yellow = "#f1fa8c"
bright-green = "#50fa7b"
bright-red = "#ff5555"
bright-blue = "#bd93f9"
bright-purple = "#ff79c6"
bright-aqua = "#8be9fd"
bright-orange = "#ffb86c"

View File

@@ -0,0 +1,2 @@
display_name: "Dracula"
nvchad: "dracula"

View File

@@ -0,0 +1,30 @@
[manager]
cwd = { fg = "#f1fa8c" }
tab_active = { fg = "#282a36", bg = "#f1fa8c" }
tab_inactive = { fg = "#bfbfbf", bg = "#44475a" }
border_symbol = "│"
border_style = { fg = "#6272a4" }
count_copied = { fg = "#282a36", bg = "#50fa7b" }
count_cut = { fg = "#282a36", bg = "#ff5555" }
count_selected = { fg = "#282a36", bg = "#f1fa8c" }
[status]
separator_open = ""
separator_close = ""
mode_normal = { fg = "#282a36", bg = "#50fa7b", bold = true }
mode_select = { fg = "#282a36", bg = "#f1fa8c", bold = true }
mode_unset = { fg = "#282a36", bg = "#ff5555", bold = true }
progress_label = { fg = "#f8f8f2", bold = true }
progress_normal = { fg = "#bd93f9", bg = "#44475a" }
progress_error = { fg = "#ff5555", bg = "#44475a" }
[input]
border = { fg = "#f1fa8c" }
title = {}
value = {}
selected = { reversed = true }
[select]
border = { fg = "#f1fa8c" }
active = { fg = "#50fa7b", bold = true }
inactive = {}

View File

@@ -0,0 +1,35 @@
theme "dracula"
default_layout "default"
default_mode "normal"
pane_frames false
simplified_ui true
mouse_mode false
keybinds {
shared {
bind "Alt h" { MoveFocusOrTab "Left"; }
bind "Alt l" { MoveFocusOrTab "Right"; }
bind "Alt j" { MoveFocus "Down"; }
bind "Alt k" { MoveFocus "Up"; }
bind "Alt n" { NewPane "Down"; }
bind "Alt v" { NewPane "Right"; }
bind "Alt f" { ToggleFloatingPanes; }
bind "Alt t" { NewTab; }
}
}
themes {
dracula {
fg "#f8f8f2"
bg "#282a36"
black "#21222c"
red "#ff5555"
green "#50fa7b"
yellow "#f1fa8c"
blue "#bd93f9"
magenta "#ff79c6"
cyan "#8be9fd"
white "#bfbfbf"
orange "#ffb86c"
}
}

View File

@@ -0,0 +1,13 @@
gui:
theme:
activeBorderColor:
- "#fabd2f"
- bold
inactiveBorderColor:
- "#665c54"
selectedLineBgColor:
- "#3c3836"
cherryPickedCommitFgColor:
- "#b8bb26"
defaultFgColor:
- "#ebdbb2"

View File

@@ -0,0 +1,26 @@
palette = "gruvbox_dark"
format = """$directory$git_branch$git_status$character"""
[directory]
style = "bold fg:bright-yellow"
truncation_length = 3
[git_branch]
style = "bold fg:bright-green"
format = " [$branch]($style) "
[git_status]
style = "bold fg:bright-red"
[character]
success_symbol = "[❯](bold fg:bright-green)"
error_symbol = "[❯](bold fg:bright-red)"
[palettes.gruvbox_dark]
bright-yellow = "#fabd2f"
bright-green = "#b8bb26"
bright-red = "#fb4934"
bright-blue = "#83a598"
bright-purple = "#d3869b"
bright-aqua = "#8ec07c"
bright-orange = "#fe8019"

View File

@@ -0,0 +1,2 @@
display_name: "Gruvbox Dark"
nvchad: "gruvbox"

View File

@@ -0,0 +1,30 @@
[manager]
cwd = { fg = "#fabd2f" }
tab_active = { fg = "#282828", bg = "#fabd2f" }
tab_inactive = { fg = "#a89984", bg = "#3c3836" }
border_symbol = "│"
border_style = { fg = "#665c54" }
count_copied = { fg = "#282828", bg = "#b8bb26" }
count_cut = { fg = "#282828", bg = "#fb4934" }
count_selected = { fg = "#282828", bg = "#fabd2f" }
[status]
separator_open = ""
separator_close = ""
mode_normal = { fg = "#282828", bg = "#b8bb26", bold = true }
mode_select = { fg = "#282828", bg = "#fabd2f", bold = true }
mode_unset = { fg = "#282828", bg = "#fb4934", bold = true }
progress_label = { fg = "#ebdbb2", bold = true }
progress_normal = { fg = "#458588", bg = "#3c3836" }
progress_error = { fg = "#fb4934", bg = "#3c3836" }
[input]
border = { fg = "#fabd2f" }
title = {}
value = {}
selected = { reversed = true }
[select]
border = { fg = "#fabd2f" }
active = { fg = "#b8bb26", bold = true }
inactive = {}

View File

@@ -0,0 +1,35 @@
theme "gruvbox-dark"
default_layout "default"
default_mode "normal"
pane_frames false
simplified_ui true
mouse_mode false
keybinds {
shared {
bind "Alt h" { MoveFocusOrTab "Left"; }
bind "Alt l" { MoveFocusOrTab "Right"; }
bind "Alt j" { MoveFocus "Down"; }
bind "Alt k" { MoveFocus "Up"; }
bind "Alt n" { NewPane "Down"; }
bind "Alt v" { NewPane "Right"; }
bind "Alt f" { ToggleFloatingPanes; }
bind "Alt t" { NewTab; }
}
}
themes {
gruvbox-dark {
fg "#ebdbb2"
bg "#282828"
black "#1d2021"
red "#cc241d"
green "#98971a"
yellow "#d79921"
blue "#458588"
magenta "#b16286"
cyan "#689d6a"
white "#a89984"
orange "#d65d0e"
}
}

View File

@@ -0,0 +1,13 @@
gui:
theme:
activeBorderColor:
- "#ebcb8b"
- bold
inactiveBorderColor:
- "#4c566a"
selectedLineBgColor:
- "#3b4252"
cherryPickedCommitFgColor:
- "#a3be8c"
defaultFgColor:
- "#d8dee9"

View File

@@ -0,0 +1,26 @@
palette = "nord"
format = """$directory$git_branch$git_status$character"""
[directory]
style = "bold fg:bright-yellow"
truncation_length = 3
[git_branch]
style = "bold fg:bright-green"
format = " [$branch]($style) "
[git_status]
style = "bold fg:bright-red"
[character]
success_symbol = "[❯](bold fg:bright-green)"
error_symbol = "[❯](bold fg:bright-red)"
[palettes.nord]
bright-yellow = "#ebcb8b"
bright-green = "#a3be8c"
bright-red = "#bf616a"
bright-blue = "#81a1c1"
bright-purple = "#b48ead"
bright-aqua = "#88c0d0"
bright-orange = "#d08770"

View File

@@ -0,0 +1,2 @@
display_name: "Nord"
nvchad: "nord"

View File

@@ -0,0 +1,30 @@
[manager]
cwd = { fg = "#ebcb8b" }
tab_active = { fg = "#2e3440", bg = "#ebcb8b" }
tab_inactive = { fg = "#e5e9f0", bg = "#3b4252" }
border_symbol = "│"
border_style = { fg = "#4c566a" }
count_copied = { fg = "#2e3440", bg = "#a3be8c" }
count_cut = { fg = "#2e3440", bg = "#bf616a" }
count_selected = { fg = "#2e3440", bg = "#ebcb8b" }
[status]
separator_open = ""
separator_close = ""
mode_normal = { fg = "#2e3440", bg = "#a3be8c", bold = true }
mode_select = { fg = "#2e3440", bg = "#ebcb8b", bold = true }
mode_unset = { fg = "#2e3440", bg = "#bf616a", bold = true }
progress_label = { fg = "#d8dee9", bold = true }
progress_normal = { fg = "#81a1c1", bg = "#3b4252" }
progress_error = { fg = "#bf616a", bg = "#3b4252" }
[input]
border = { fg = "#ebcb8b" }
title = {}
value = {}
selected = { reversed = true }
[select]
border = { fg = "#ebcb8b" }
active = { fg = "#a3be8c", bold = true }
inactive = {}

View File

@@ -0,0 +1,35 @@
theme "nord"
default_layout "default"
default_mode "normal"
pane_frames false
simplified_ui true
mouse_mode false
keybinds {
shared {
bind "Alt h" { MoveFocusOrTab "Left"; }
bind "Alt l" { MoveFocusOrTab "Right"; }
bind "Alt j" { MoveFocus "Down"; }
bind "Alt k" { MoveFocus "Up"; }
bind "Alt n" { NewPane "Down"; }
bind "Alt v" { NewPane "Right"; }
bind "Alt f" { ToggleFloatingPanes; }
bind "Alt t" { NewTab; }
}
}
themes {
nord {
fg "#d8dee9"
bg "#2e3440"
black "#3b4252"
red "#bf616a"
green "#a3be8c"
yellow "#ebcb8b"
blue "#81a1c1"
magenta "#b48ead"
cyan "#88c0d0"
white "#e5e9f0"
orange "#d08770"
}
}

View File

@@ -0,0 +1,13 @@
gui:
theme:
activeBorderColor:
- "#e0af68"
- bold
inactiveBorderColor:
- "#3b4261"
selectedLineBgColor:
- "#283457"
cherryPickedCommitFgColor:
- "#9ece6a"
defaultFgColor:
- "#c0caf5"

View File

@@ -0,0 +1,26 @@
palette = "tokyonight"
format = """$directory$git_branch$git_status$character"""
[directory]
style = "bold fg:bright-yellow"
truncation_length = 3
[git_branch]
style = "bold fg:bright-green"
format = " [$branch]($style) "
[git_status]
style = "bold fg:bright-red"
[character]
success_symbol = "[❯](bold fg:bright-green)"
error_symbol = "[❯](bold fg:bright-red)"
[palettes.tokyonight]
bright-yellow = "#e0af68"
bright-green = "#9ece6a"
bright-red = "#f7768e"
bright-blue = "#7aa2f7"
bright-purple = "#bb9af7"
bright-aqua = "#7dcfff"
bright-orange = "#ff9e64"

View File

@@ -0,0 +1,2 @@
display_name: "Tokyo Night"
nvchad: "tokyonight"

View File

@@ -0,0 +1,30 @@
[manager]
cwd = { fg = "#e0af68" }
tab_active = { fg = "#1a1b26", bg = "#e0af68" }
tab_inactive = { fg = "#a9b1d6", bg = "#283457" }
border_symbol = "│"
border_style = { fg = "#3b4261" }
count_copied = { fg = "#1a1b26", bg = "#9ece6a" }
count_cut = { fg = "#1a1b26", bg = "#f7768e" }
count_selected = { fg = "#1a1b26", bg = "#e0af68" }
[status]
separator_open = ""
separator_close = ""
mode_normal = { fg = "#1a1b26", bg = "#9ece6a", bold = true }
mode_select = { fg = "#1a1b26", bg = "#e0af68", bold = true }
mode_unset = { fg = "#1a1b26", bg = "#f7768e", bold = true }
progress_label = { fg = "#c0caf5", bold = true }
progress_normal = { fg = "#7aa2f7", bg = "#283457" }
progress_error = { fg = "#f7768e", bg = "#283457" }
[input]
border = { fg = "#e0af68" }
title = {}
value = {}
selected = { reversed = true }
[select]
border = { fg = "#e0af68" }
active = { fg = "#9ece6a", bold = true }
inactive = {}

View File

@@ -0,0 +1,35 @@
theme "tokyonight"
default_layout "default"
default_mode "normal"
pane_frames false
simplified_ui true
mouse_mode false
keybinds {
shared {
bind "Alt h" { MoveFocusOrTab "Left"; }
bind "Alt l" { MoveFocusOrTab "Right"; }
bind "Alt j" { MoveFocus "Down"; }
bind "Alt k" { MoveFocus "Up"; }
bind "Alt n" { NewPane "Down"; }
bind "Alt v" { NewPane "Right"; }
bind "Alt f" { ToggleFloatingPanes; }
bind "Alt t" { NewTab; }
}
}
themes {
tokyonight {
fg "#c0caf5"
bg "#1a1b26"
black "#15161e"
red "#f7768e"
green "#9ece6a"
yellow "#e0af68"
blue "#7aa2f7"
magenta "#bb9af7"
cyan "#7dcfff"
white "#a9b1d6"
orange "#ff9e64"
}
}

224
internal/theming/theming.go Normal file
View File

@@ -0,0 +1,224 @@
// Package theming applies a selected colorscheme across starship, zellij,
// yazi, lazygit, and NvChad. Themes are discovered from embedded manifest
// files under assets/<id>/theme.yaml at package init time.
package theming
import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
"gopkg.in/yaml.v3"
)
// ChadrcWriter is the hook used by Apply to rewrite NvChad's chadrc.lua
// after a theme change. The neovim module registers its WriteChadrc here
// via an init() function to avoid a theming→neovim import cycle (neovim
// now imports theming to look up the NvChad theme name). If nil, Apply
// skips the chadrc update silently.
var ChadrcWriter func(nvChadThemeName string) error
//go:embed assets
var assetsFS embed.FS
// Theme describes a colorscheme discovered under assets/<id>/theme.yaml.
type Theme struct {
ID string
DisplayName string
NvChadTheme string
}
type manifest struct {
DisplayName string `yaml:"display_name"`
NvChad string `yaml:"nvchad"`
}
var discovered []Theme
func init() {
themes, err := discover()
if err != nil {
// Package is embed-only; discovery failure is a programmer error,
// not runtime input. Panic so tests surface the mistake early.
panic(fmt.Errorf("theming: discover themes: %w", err))
}
discovered = themes
}
func discover() ([]Theme, error) {
entries, err := fs.ReadDir(assetsFS, "assets")
if err != nil {
return nil, err
}
var out []Theme
for _, e := range entries {
if !e.IsDir() {
continue
}
id := e.Name()
m, err := readManifest(id)
if err != nil {
return nil, fmt.Errorf("theme %q: %w", id, err)
}
out = append(out, Theme{ID: id, DisplayName: m.DisplayName, NvChadTheme: m.NvChad})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
func readManifest(id string) (manifest, error) {
data, err := assetsFS.ReadFile(filepath.Join("assets", id, "theme.yaml"))
if err != nil {
return manifest{}, err
}
var m manifest
if err := yaml.Unmarshal(data, &m); err != nil {
return manifest{}, err
}
if m.DisplayName == "" {
return manifest{}, fmt.Errorf("empty display_name")
}
if m.NvChad == "" {
return manifest{}, fmt.Errorf("empty nvchad")
}
return m, nil
}
// List returns the themes sorted by ID.
func List() []Theme {
out := make([]Theme, len(discovered))
copy(out, discovered)
return out
}
// Default returns the archduino default theme. Used by the wizard's fresh-
// install default and by callers that resolve an unknown theme ID.
// The identity here is stable policy: gruvbox is the project's long-standing
// default (carried over from the pre-Go installer). Changing this changes
// the out-of-box experience on fresh systems.
func Default() Theme {
if t, ok := Get("gruvbox"); ok {
return t
}
if len(discovered) > 0 {
return discovered[0]
}
return Theme{ID: "gruvbox", DisplayName: "Gruvbox Dark", NvChadTheme: "gruvbox"}
}
// Get returns the theme with the given ID, if present.
func Get(id string) (Theme, bool) {
for _, t := range discovered {
if t.ID == id {
return t, true
}
}
return Theme{}, false
}
// toolConfig describes one tool's config file: its asset name under
// assets/<theme>/ and its user-config destination relative to $HOME.
type toolConfig struct {
Asset string // e.g. "starship.toml"
DestRel string // e.g. ".config/starship.toml"
}
var toolConfigs = []toolConfig{
{"starship.toml", ".config/starship.toml"},
{"zellij.kdl", ".config/zellij/config.kdl"},
{"yazi.toml", ".config/yazi/theme.toml"},
{"lazygit.yml", ".config/lazygit/config.yml"},
}
// Apply writes each tool's theme config to the user's home directory.
// Task 3 adds overlay-file update; Task 4 adds NvChad chadrc rewrite.
func Apply(cfg config.Config, themeID string) error {
th, ok := Get(themeID)
if !ok {
ids := make([]string, 0, len(discovered))
for _, t := range discovered {
ids = append(ids, t.ID)
}
return fmt.Errorf("unknown theme %q; valid: %s", themeID, strings.Join(ids, ", "))
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
for _, tc := range toolConfigs {
data, err := assetsFS.ReadFile(filepath.Join("assets", th.ID, tc.Asset))
if err != nil {
return fmt.Errorf("read asset %s: %w", tc.Asset, err)
}
dst := filepath.Join(home, tc.DestRel)
if err := platform.WriteUserFile(dst, data, 0o644); err != nil {
return fmt.Errorf("write %s: %w", tc.DestRel, err)
}
}
_ = cfg // overlay carries config state; overlay file update below
if err := updateOverlayActive(th.ID); err != nil {
return fmt.Errorf("update overlay: %w", err)
}
if ChadrcWriter != nil {
if err := ChadrcWriter(th.NvChadTheme); err != nil {
return fmt.Errorf("update chadrc: %w", err)
}
}
// Clear NvChad's base46 theme cache so the new theme activates on next
// nvim launch. Errors ignored — the cache may not exist.
_ = os.RemoveAll(filepath.Join(home, ".cache", "nvim", "base46"))
_ = os.RemoveAll(filepath.Join(home, ".local", "share", "nvim", "lazy", "base46", "lua", "base46", "themes"))
return nil
}
// updateOverlayActive rewrites ~/.config/archduino/overlay.yaml so that
// theme_overrides.__active reflects the selected theme, preserving all
// other keys via a generic map round-trip.
func updateOverlayActive(themeID string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
path := filepath.Join(home, ".config", "archduino", "overlay.yaml")
var root map[string]any
if data, err := os.ReadFile(path); err == nil {
if err := yaml.Unmarshal(data, &root); err != nil {
return fmt.Errorf("parse overlay: %w", err)
}
}
if root == nil {
root = map[string]any{}
}
if _, ok := root["version"]; !ok {
root["version"] = 1
}
var overrides map[string]any
if raw, ok := root["theme_overrides"]; ok {
if m, ok := raw.(map[string]any); ok {
overrides = m
}
}
if overrides == nil {
overrides = map[string]any{}
}
overrides["__active"] = themeID
root["theme_overrides"] = overrides
out, err := yaml.Marshal(root)
if err != nil {
return err
}
return platform.WriteUserFile(path, out, 0o644)
}

View File

@@ -0,0 +1,200 @@
package theming
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/MDietermann/ArduinoArchSetup/internal/config"
)
func TestListDiscoversThemes(t *testing.T) {
themes := List()
if len(themes) == 0 {
t.Fatal("no themes discovered")
}
for _, th := range themes {
if th.ID == "" || th.DisplayName == "" || th.NvChadTheme == "" {
t.Errorf("incomplete theme: %+v", th)
}
}
}
func TestGetKnownThemeReturnsMatch(t *testing.T) {
got, ok := Get("gruvbox")
if !ok {
t.Fatal("gruvbox should exist")
}
if got.ID != "gruvbox" || got.DisplayName == "" || got.NvChadTheme != "gruvbox" {
t.Fatalf("got %+v", got)
}
}
func TestGetUnknownThemeReturnsFalse(t *testing.T) {
if _, ok := Get("does-not-exist"); ok {
t.Fatal("bogus id should return ok=false")
}
}
func TestListIsSortedByID(t *testing.T) {
themes := List()
for i := 1; i < len(themes); i++ {
if themes[i].ID < themes[i-1].ID {
t.Fatalf("not sorted: %s before %s", themes[i-1].ID, themes[i].ID)
}
}
}
func TestApplyWritesFourToolConfigs(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := Apply(config.Config{}, "gruvbox"); err != nil {
t.Fatal(err)
}
checks := []struct{ rel, wantPrefix string }{
{".config/starship.toml", "palette"},
{".config/zellij/config.kdl", "theme"},
{".config/yazi/theme.toml", ""},
{".config/lazygit/config.yml", "gui"},
}
for _, c := range checks {
data, err := os.ReadFile(filepath.Join(home, c.rel))
if err != nil {
t.Errorf("%s missing: %v", c.rel, err)
continue
}
if len(data) == 0 {
t.Errorf("%s empty", c.rel)
}
if c.wantPrefix != "" && !strings.HasPrefix(strings.TrimSpace(string(data)), c.wantPrefix) {
t.Errorf("%s: want prefix %q, got %q", c.rel, c.wantPrefix, string(data[:min(len(data), 40)]))
}
}
}
func TestApplyUnknownThemeErrors(t *testing.T) {
if err := Apply(config.Config{}, "does-not-exist"); err == nil {
t.Fatal("want error on unknown theme")
}
}
func TestApplyUpdatesOverlayActive(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := Apply(config.Config{}, "gruvbox"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(home, ".config", "archduino", "overlay.yaml"))
if err != nil {
t.Fatalf("overlay missing: %v", err)
}
if !strings.Contains(string(data), "__active: gruvbox") {
t.Fatalf("overlay missing active theme: %s", data)
}
}
func TestApplyPreservesOtherOverlayKeys(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
op := filepath.Join(home, ".config", "archduino", "overlay.yaml")
if err := os.MkdirAll(filepath.Dir(op), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(op, []byte("version: 1\nextra_packages:\n - foo\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := Apply(config.Config{}, "gruvbox"); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(op)
if !strings.Contains(string(data), "- foo") {
t.Fatalf("extra_packages lost: %s", data)
}
if !strings.Contains(string(data), "__active: gruvbox") {
t.Fatalf("active theme missing after preserve: %s", data)
}
}
func TestApplyRewritesChadrcIfPresent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
nvimCfg := filepath.Join(home, ".config", "nvim", "lua")
if err := os.MkdirAll(nvimCfg, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nvimCfg, "chadrc.lua"),
[]byte(`M.base46 = { theme = "onedark" }`), 0o644); err != nil {
t.Fatal(err)
}
// The real ChadrcWriter lives in the neovim package, which cannot be
// imported here without creating an import cycle (neovim→theming).
// Stub it with a minimal writer that covers what the test asserts.
prev := ChadrcWriter
t.Cleanup(func() { ChadrcWriter = prev })
ChadrcWriter = func(name string) error {
return os.WriteFile(filepath.Join(nvimCfg, "chadrc.lua"),
[]byte(`theme = "`+name+`"`), 0o644)
}
if err := Apply(config.Config{}, "gruvbox"); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(filepath.Join(nvimCfg, "chadrc.lua"))
if !strings.Contains(string(data), `theme = "gruvbox"`) {
t.Fatalf("chadrc not rewritten: %s", data)
}
if strings.Contains(string(data), `theme = "onedark"`) {
t.Fatalf("old theme still present: %s", data)
}
}
func TestApplyNoChadrcIsFine(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := Apply(config.Config{}, "gruvbox"); err != nil {
t.Fatal(err)
}
}
func TestAllFiveThemesPresent(t *testing.T) {
want := []string{"catppuccin", "dracula", "gruvbox", "nord", "tokyonight"}
themes := List()
if len(themes) != len(want) {
t.Fatalf("want %d themes, got %d", len(want), len(themes))
}
for i, id := range want {
if themes[i].ID != id {
t.Fatalf("themes[%d].ID = %q, want %q", i, themes[i].ID, id)
}
}
}
func TestApplyEachThemeWritesStarship(t *testing.T) {
for _, id := range []string{"catppuccin", "dracula", "nord", "tokyonight"} {
id := id
t.Run(id, func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := Apply(config.Config{}, id); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(home, ".config", "starship.toml"))
if err != nil {
t.Fatal(err)
}
if len(data) == 0 {
t.Fatal("empty starship.toml")
}
})
}
}
func TestDefaultIsGruvbox(t *testing.T) {
if got := Default(); got.ID != "gruvbox" {
t.Fatalf("Default().ID = %q, want gruvbox", got.ID)
}
}

View File

@@ -5,35 +5,15 @@ import (
"github.com/MDietermann/ArduinoArchSetup/internal/config" "github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/module" "github.com/MDietermann/ArduinoArchSetup/internal/module"
"github.com/MDietermann/ArduinoArchSetup/internal/theming"
) )
// Theme is a named colorscheme presented in the wizard. Each entry carries // Theme is a named colorscheme presented in the wizard. It is a thin
// per-tool theme names; Plan 3b uses NvChadTheme, later plans add more. // display value for the dropdown; the authoritative theme catalogue lives
// in the theming package (theming.List / theming.Get).
type Theme struct { type Theme struct {
ID string ID string
Name string Name string
NvChadTheme string
}
// BuiltinThemes is the fixed list the wizard shows. Keep in sync with the
// themes shipped by Plan 3's theme module.
var BuiltinThemes = []Theme{
{ID: "gruvbox", Name: "Gruvbox Dark", NvChadTheme: "gruvbox"},
{ID: "catppuccin", Name: "Catppuccin Mocha", NvChadTheme: "catppuccin"},
{ID: "tokyonight", Name: "Tokyo Night", NvChadTheme: "tokyonight"},
{ID: "nord", Name: "Nord", NvChadTheme: "nord"},
{ID: "dracula", Name: "Dracula", NvChadTheme: "dracula"},
}
// ThemeByID returns the Theme with the given ID. The bool is false when no
// match is found; callers decide whether to fall back or error.
func ThemeByID(themes []Theme, id string) (Theme, bool) {
for _, t := range themes {
if t.ID == id {
return t, true
}
}
return Theme{}, false
} }
// ModuleChoice is one row in the module selector. // ModuleChoice is one row in the module selector.
@@ -74,7 +54,7 @@ func NewWizardState(cfg config.Config, reg *module.Registry) WizardState {
} }
sort.Slice(choices, func(i, j int) bool { return choices[i].ID < choices[j].ID }) sort.Slice(choices, func(i, j int) bool { return choices[i].ID < choices[j].ID })
theme := BuiltinThemes[0].ID theme := theming.Default().ID
if override, ok := cfg.ThemeOverrides["__active"]; ok && override != "" { if override, ok := cfg.ThemeOverrides["__active"]; ok && override != "" {
theme = override theme = override
} }

View File

@@ -6,6 +6,7 @@ import (
"github.com/MDietermann/ArduinoArchSetup/internal/config" "github.com/MDietermann/ArduinoArchSetup/internal/config"
"github.com/MDietermann/ArduinoArchSetup/internal/module" "github.com/MDietermann/ArduinoArchSetup/internal/module"
"github.com/MDietermann/ArduinoArchSetup/internal/theming"
) )
type fakeModule struct{ id, name, desc string } type fakeModule struct{ id, name, desc string }
@@ -34,7 +35,7 @@ func TestNewWizardStatePreTicksAllByDefault(t *testing.T) {
t.Errorf("%s not selected", m.ID) t.Errorf("%s not selected", m.ID)
} }
} }
if s.ThemeID != BuiltinThemes[0].ID { if s.ThemeID != theming.Default().ID {
t.Errorf("theme = %q", s.ThemeID) t.Errorf("theme = %q", s.ThemeID)
} }
} }
@@ -71,30 +72,3 @@ func TestSelectedIDsOnlyReturnsChecked(t *testing.T) {
} }
} }
func TestBuiltinThemesCarryNvChadName(t *testing.T) {
for _, th := range BuiltinThemes {
if th.NvChadTheme == "" {
t.Errorf("theme %q has empty NvChadTheme", th.ID)
}
}
}
func TestThemeByIDReturnsMatch(t *testing.T) {
got, ok := ThemeByID(BuiltinThemes, "dracula")
if !ok {
t.Fatal("dracula should exist")
}
if got.ID != "dracula" {
t.Fatalf("got id=%q", got.ID)
}
if got.NvChadTheme == "" {
t.Fatal("NvChadTheme empty")
}
}
func TestThemeByIDFallsBackWhenUnknown(t *testing.T) {
_, ok := ThemeByID(BuiltinThemes, "does-not-exist")
if ok {
t.Fatal("unknown id should report ok=false")
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/MDietermann/ArduinoArchSetup/internal/module" "github.com/MDietermann/ArduinoArchSetup/internal/module"
"github.com/MDietermann/ArduinoArchSetup/internal/theming"
"github.com/MDietermann/ArduinoArchSetup/internal/tui" "github.com/MDietermann/ArduinoArchSetup/internal/tui"
) )
@@ -23,9 +24,10 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu
return s, false, nil return s, false, nil
} }
themeOptions := make([]huh.Option[string], 0, len(tui.BuiltinThemes)) themes := theming.List()
for _, t := range tui.BuiltinThemes { themeOptions := make([]huh.Option[string], 0, len(themes))
themeOptions = append(themeOptions, huh.NewOption(t.Name, t.ID)) for _, t := range themes {
themeOptions = append(themeOptions, huh.NewOption(t.DisplayName, t.ID))
} }
moduleOptions := make([]huh.Option[string], 0, len(s.Modules)) moduleOptions := make([]huh.Option[string], 0, len(s.Modules))