1515 lines
50 KiB
Markdown
1515 lines
50 KiB
Markdown
# Plan 4a — User-Facing Subcommands Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Ship `archduino theme`, `archduino new`, and `archduino doctor` subcommands plus a shared `internal/theming/` package that both the new `theme` subcommand and the existing `install` command use to write starship / zellij / yazi / lazygit / NvChad configs.
|
|
|
|
**Architecture:** One new package (`theming`) owns per-theme embedded assets + the `Apply` function. Two subcommand packages (`newproject`, `doctor`) implement the remaining two subcommands. The `neovim` module exports a `WriteChadrc` helper that `theming.Apply` reuses to re-render chadrc when the theme changes. `tui.BuiltinThemes` retires; the wizard reads `theming.List()` instead. The `install` command calls `theming.Apply` after a successful `engine.Run` so the selected theme is applied on first run.
|
|
|
|
**Tech Stack:** Go stdlib + `embed`, `gopkg.in/yaml.v3` (already a dep) for overlay round-trip, `huh` for the interactive theme picker, `text/template` for existing chadrc rendering.
|
|
|
|
---
|
|
|
|
## Reference: relevant existing surfaces
|
|
|
|
```go
|
|
// internal/module/module.go — unchanged
|
|
type Module interface { Meta() Meta; Detect(ctx) (State, error); Plan(ctx, State) (Plan, error); Apply(ctx, Plan, Reporter) error; Verify(ctx) error }
|
|
|
|
// internal/platform — unchanged
|
|
func WriteUserFile(path string, data []byte, mode os.FileMode) error
|
|
type Runner interface { Run(ctx, name string, args ...string) error; Output(ctx, name string, args ...string) ([]byte, error) }
|
|
|
|
// internal/config/config.go — has ThemeOverrides map[string]string already
|
|
type Config struct {
|
|
// ... existing fields ...
|
|
ThemeOverrides map[string]string
|
|
}
|
|
const currentVersion = 1
|
|
|
|
// internal/modules/neovim/neovim.go — private helpers
|
|
func (m Module) resolvedTheme() string // returns NvChad theme name
|
|
func (m Module) renderChadrc() ([]byte, error) // renders chadrc.lua.tmpl
|
|
|
|
// internal/tui/state.go — to be retired
|
|
type Theme struct { ID, Name, NvChadTheme string }
|
|
var BuiltinThemes = []Theme{ ... 5 entries ... }
|
|
func ThemeByID(themes []Theme, id string) (Theme, bool)
|
|
type WizardState struct { ThemeID string; ... }
|
|
|
|
// cmd/archduino/main.go
|
|
root := &cobra.Command{Use: "archduino"}
|
|
root.AddCommand(newInstallCmd())
|
|
func newInstallCmd() *cobra.Command
|
|
func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error)
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: `theming` package skeleton — Theme type, manifest discovery, gruvbox assets
|
|
|
|
**Files:**
|
|
- Create: `internal/theming/theming.go`
|
|
- Create: `internal/theming/theming_test.go`
|
|
- Create: `internal/theming/assets/gruvbox/theme.yaml`
|
|
- Create: `internal/theming/assets/gruvbox/starship.toml`
|
|
- Create: `internal/theming/assets/gruvbox/zellij.kdl`
|
|
- Create: `internal/theming/assets/gruvbox/yazi.toml`
|
|
- Create: `internal/theming/assets/gruvbox/lazygit.yml`
|
|
|
|
- [ ] **Step 1: Create gruvbox assets**
|
|
|
|
Extract contents from `/home/marvin/git-clones/ArduinoArchSetup/lib/themes/gruvbox.sh`'s `write_starship_config`, `write_zellij_config`, `write_lazygit_config`, `write_yazi_config` heredocs. Write each heredoc body verbatim to the corresponding asset file (drop the `TOML` / `KDL` / `YAML` end-tokens and the surrounding bash). `theme.yaml`:
|
|
|
|
```yaml
|
|
display_name: "Gruvbox Dark"
|
|
nvchad: "gruvbox"
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing test**
|
|
|
|
```go
|
|
package theming
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run test, confirm fail**
|
|
|
|
```
|
|
go test ./internal/theming/... -v
|
|
```
|
|
Expected: `undefined: List` / `undefined: Get`.
|
|
|
|
- [ ] **Step 4: Implement `theming.go` skeleton**
|
|
|
|
```go
|
|
// 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"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
//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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// stringsPrefix is a trivial helper reserved for Task 2's tool-config step.
|
|
// It exists to keep the strings import live if Task 1's code doesn't use it.
|
|
var _ = strings.Split
|
|
```
|
|
|
|
Delete the `_ = strings.Split` line if the build complains; Task 2 will reintroduce `strings` usage.
|
|
|
|
- [ ] **Step 5: Run tests, confirm pass**
|
|
|
|
```
|
|
go test ./internal/theming/... -v
|
|
go test ./...
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/theming/
|
|
git commit -m "feat(theming): add theme discovery with gruvbox assets"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: `theming.Apply` — write the four tool configs
|
|
|
|
**Files:**
|
|
- Modify: `internal/theming/theming.go` — add `Apply(cfg config.Config, themeID string) error`.
|
|
- Modify: `internal/theming/theming_test.go` — add tests.
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Append to `theming_test.go`:
|
|
|
|
```go
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
|
)
|
|
|
|
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")
|
|
}
|
|
}
|
|
```
|
|
|
|
(The `min` helper is stdlib in Go 1.21+; if the repo's go.mod is older, replace `min(len(data),40)` with a manual min.)
|
|
|
|
- [ ] **Step 2: Run test, confirm fail**
|
|
|
|
```
|
|
go test ./internal/theming/... -v
|
|
```
|
|
Expected: `undefined: Apply`.
|
|
|
|
- [ ] **Step 3: Implement `Apply` (initial version — writes tool configs only, no overlay, no chadrc)**
|
|
|
|
Add to `theming.go`:
|
|
|
|
```go
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
|
)
|
|
|
|
// 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 update lands in Task 3
|
|
return nil
|
|
}
|
|
```
|
|
|
|
Remove the `_ = strings.Split` line from Task 1 now that `strings` is used genuinely.
|
|
|
|
- [ ] **Step 4: Run tests, confirm pass**
|
|
|
|
```
|
|
go test ./internal/theming/... -v
|
|
go test ./...
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/theming/
|
|
git commit -m "feat(theming): apply writes four tool configs to $HOME"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: `theming.Apply` — overlay round-trip
|
|
|
|
**Files:**
|
|
- Modify: `internal/theming/theming.go` — extend `Apply`.
|
|
- Modify: `internal/theming/theming_test.go` — add overlay tests.
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Append:
|
|
|
|
```go
|
|
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)
|
|
// Seed overlay with a non-theme key.
|
|
op := filepath.Join(home, ".config", "archduino", "overlay.yaml")
|
|
os.MkdirAll(filepath.Dir(op), 0o755)
|
|
os.WriteFile(op, []byte("version: 1\nextra_packages:\n - foo\n"), 0o644)
|
|
|
|
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)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests, confirm fail**
|
|
|
|
```
|
|
go test ./internal/theming/... -run TestApplyUpdates -v
|
|
go test ./internal/theming/... -run TestApplyPreserves -v
|
|
```
|
|
|
|
- [ ] **Step 3: Implement the overlay update**
|
|
|
|
Add a helper and call it at the end of `Apply`:
|
|
|
|
```go
|
|
func updateOverlayActive(themeID string) error {
|
|
home, err := os.UserHomeDir(); if err != nil { return err }
|
|
path := filepath.Join(home, ".config", "archduino", "overlay.yaml")
|
|
|
|
// Load existing overlay into a generic map so we preserve sibling keys.
|
|
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{} }
|
|
|
|
// Ensure the version stamp is present so Load doesn't complain.
|
|
if _, ok := root["version"]; !ok { root["version"] = 1 }
|
|
|
|
// theme_overrides may be absent, nil, or a map under various yaml decode
|
|
// shapes. Normalise to map[string]any.
|
|
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)
|
|
}
|
|
```
|
|
|
|
In `Apply`, after the tool-config loop, add:
|
|
|
|
```go
|
|
if err := updateOverlayActive(themeID); err != nil {
|
|
return fmt.Errorf("update overlay: %w", err)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, confirm pass**
|
|
|
|
```
|
|
go test ./internal/theming/... -v
|
|
go test ./...
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/theming/
|
|
git commit -m "feat(theming): apply updates overlay theme_overrides.__active"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Extract neovim chadrc writer, wire into theming.Apply
|
|
|
|
**Files:**
|
|
- Modify: `internal/modules/neovim/neovim.go` — export `WriteChadrc`.
|
|
- Modify: `internal/modules/neovim/neovim_test.go` — add targeted test.
|
|
- Modify: `internal/theming/theming.go` — call `neovim.WriteChadrc` from `Apply`.
|
|
- Modify: `internal/theming/theming_test.go` — add chadrc tests.
|
|
|
|
- [ ] **Step 1: Add the exported helper in the neovim package**
|
|
|
|
In `internal/modules/neovim/neovim.go`, add at file scope (near the other render helpers around line 91):
|
|
|
|
```go
|
|
// 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 }
|
|
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)
|
|
}
|
|
```
|
|
|
|
(This duplicates a few lines from `Module.renderChadrc`; the duplication is acceptable since the renderer is small and the Module-scoped version carries the `Module`'s theme-ID fallback logic that doesn't belong in the standalone helper.)
|
|
|
|
- [ ] **Step 2: Add a test for the new helper**
|
|
|
|
In `internal/modules/neovim/neovim_test.go`, add:
|
|
|
|
```go
|
|
func TestWriteChadrcWritesFile(t *testing.T) {
|
|
home := t.TempDir(); t.Setenv("HOME", home)
|
|
cfg := filepath.Join(home, ".config", "nvim")
|
|
os.MkdirAll(cfg, 0o755)
|
|
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")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add theming integration**
|
|
|
|
In `internal/theming/theming.go`, import the neovim package:
|
|
|
|
```go
|
|
import (
|
|
// ... existing imports ...
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/neovim"
|
|
)
|
|
```
|
|
|
|
After the overlay update in `Apply`:
|
|
|
|
```go
|
|
if err := neovim.WriteChadrc(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"))
|
|
```
|
|
|
|
Note: `home` must still be in scope at this point; if the existing code doesn't keep it, hoist `home, _ := os.UserHomeDir()` near the top of `Apply`.
|
|
|
|
- [ ] **Step 4: Add theming tests**
|
|
|
|
Append to `theming_test.go`:
|
|
|
|
```go
|
|
func TestApplyRewritesChadrcIfPresent(t *testing.T) {
|
|
home := t.TempDir(); t.Setenv("HOME", home)
|
|
nvimCfg := filepath.Join(home, ".config", "nvim", "lua")
|
|
os.MkdirAll(nvimCfg, 0o755)
|
|
os.WriteFile(filepath.Join(nvimCfg, "chadrc.lua"),
|
|
[]byte(`M.base46 = { theme = "gruvbox" }`), 0o644)
|
|
if err := Apply(config.Config{}, "dracula"); err != nil { t.Fatal(err) }
|
|
data, _ := os.ReadFile(filepath.Join(nvimCfg, "chadrc.lua"))
|
|
if !strings.Contains(string(data), `theme = "dracula"`) {
|
|
t.Fatalf("chadrc not rewritten: %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) }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests, confirm pass**
|
|
|
|
```
|
|
go test ./internal/theming/... -v
|
|
go test ./internal/modules/neovim/... -v
|
|
go test ./...
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/modules/neovim/ internal/theming/
|
|
git commit -m "feat(theming): integrate NvChad chadrc rewrite into Apply"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Add the remaining four themes (catppuccin, tokyonight, nord, dracula)
|
|
|
|
**Files:**
|
|
- Create: `internal/theming/assets/<theme>/{theme.yaml,starship.toml,zellij.kdl,yazi.toml,lazygit.yml}` for four themes.
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Append to `theming_test.go`:
|
|
|
|
```go
|
|
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") }
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Confirm fail**
|
|
|
|
```
|
|
go test ./internal/theming/... -run TestAllFiveThemesPresent -v
|
|
```
|
|
Expected: `want 5 themes, got 1`.
|
|
|
|
- [ ] **Step 3: Copy the four themes' assets**
|
|
|
|
For each of `catppuccin`, `tokyonight`, `nord`, `dracula`:
|
|
- Read `/home/marvin/git-clones/ArduinoArchSetup/lib/themes/<theme>.sh`.
|
|
- Create directory `internal/theming/assets/<theme>/`.
|
|
- Extract heredoc bodies: `write_starship_config` → `starship.toml`, `write_zellij_config` → `zellij.kdl`, `write_yazi_config` → `yazi.toml`, `write_lazygit_config` → `lazygit.yml`. Drop the heredoc tokens; keep content verbatim.
|
|
- Extract the top-of-file variables for the manifest:
|
|
```yaml
|
|
display_name: "<THEME_DISPLAY_NAME from bash>"
|
|
nvchad: "<NVCHAD_THEME from bash>"
|
|
```
|
|
The bash variables are `THEME_DISPLAY_NAME="..."` and `NVCHAD_THEME="..."` near lines 2-3 of each file.
|
|
|
|
- [ ] **Step 4: Run tests, confirm pass**
|
|
|
|
```
|
|
go test ./internal/theming/... -v
|
|
go test ./...
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/theming/assets/
|
|
git commit -m "feat(theming): add catppuccin, dracula, nord, tokyonight themes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Retire `tui.BuiltinThemes`; wizard reads `theming.List()`
|
|
|
|
**Files:**
|
|
- Modify: `internal/tui/state.go` — remove `BuiltinThemes`, `NvChadTheme`, `ThemeByID`; simplify `Theme`.
|
|
- Modify: `internal/tui/state_test.go` — adjust or remove affected tests.
|
|
- Modify: `internal/tui/wizard/wizard.go` — read from `theming.List()`.
|
|
- Modify: `internal/modules/neovim/neovim.go` — drop the `tui.Theme`-based fallback in `resolvedTheme`; switch to `theming.Get`.
|
|
- Modify: `cmd/archduino/main.go` — adjust any `tui.BuiltinThemes` references (Plan 3b added one for the pre-wizard `buildRegistry` call — replace with `""` or the first theme from `theming.List()`).
|
|
|
|
- [ ] **Step 1: Grep current usages**
|
|
|
|
```
|
|
grep -rn "BuiltinThemes\|ThemeByID\|tui.Theme\b" --include="*.go"
|
|
```
|
|
Expected hits: `internal/tui/state.go`, `internal/tui/state_test.go`, `internal/tui/wizard/wizard.go`, `internal/modules/neovim/neovim.go`, `internal/modules/neovim/neovim_test.go`, `cmd/archduino/main.go`. Note every file before editing.
|
|
|
|
- [ ] **Step 2: Simplify `tui.Theme`**
|
|
|
|
In `internal/tui/state.go`:
|
|
|
|
```go
|
|
// Theme is the wizard's display-only view of a colorscheme option.
|
|
// Canonical theme metadata lives in internal/theming.
|
|
type Theme struct {
|
|
ID string
|
|
Name string
|
|
}
|
|
```
|
|
|
|
Remove `BuiltinThemes`, `ThemeByID`. In `NewWizardState`:
|
|
|
|
```go
|
|
import "github.com/MDietermann/ArduinoArchSetup/internal/theming"
|
|
|
|
func NewWizardState(cfg config.Config, reg *module.Registry) WizardState {
|
|
// ... existing module-choice logic unchanged ...
|
|
|
|
themes := theming.List()
|
|
theme := ""
|
|
if len(themes) > 0 { theme = themes[0].ID }
|
|
if override, ok := cfg.ThemeOverrides["__active"]; ok && override != "" {
|
|
theme = override
|
|
}
|
|
|
|
return WizardState{
|
|
ThemeID: theme,
|
|
Modules: choices,
|
|
Parallelism: 1,
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Rewrite affected tests**
|
|
|
|
In `internal/tui/state_test.go`, replace any test referencing `BuiltinThemes` or `NvChadTheme` with equivalents against `theming.List()`. Specifically, the `TestBuiltinThemesCarryNvChadName` and `TestThemeByID*` tests (added in Plan 3b Task 2) should be **deleted** — the symbols they test no longer exist in `tui`. Their logic is already covered by `internal/theming`'s own tests (Task 1, Task 5).
|
|
|
|
A `NewWizardState` test that expected `s.ThemeID == BuiltinThemes[0].ID` should be updated to `s.ThemeID == theming.List()[0].ID`.
|
|
|
|
- [ ] **Step 4: Update wizard**
|
|
|
|
In `internal/tui/wizard/wizard.go`:
|
|
|
|
```go
|
|
import "github.com/MDietermann/ArduinoArchSetup/internal/theming"
|
|
|
|
// ... in Run, replace the themeOptions construction (lines ~26-29):
|
|
themes := theming.List()
|
|
themeOptions := make([]huh.Option[string], 0, len(themes))
|
|
for _, t := range themes {
|
|
themeOptions = append(themeOptions, huh.NewOption(t.DisplayName, t.ID))
|
|
}
|
|
```
|
|
|
|
Drop `import "github.com/MDietermann/ArduinoArchSetup/internal/tui"` if it's no longer used (the wizard still references `tui.WizardState` and `tui.ModuleChoice`, so keep it).
|
|
|
|
- [ ] **Step 5: Update neovim module**
|
|
|
|
In `internal/modules/neovim/neovim.go`, `resolvedTheme` currently takes `[]tui.Theme`. Simplify:
|
|
|
|
```go
|
|
import "github.com/MDietermann/ArduinoArchSetup/internal/theming"
|
|
|
|
func (m Module) resolvedTheme() string {
|
|
if t, ok := theming.Get(m.themeID); ok { return t.NvChadTheme }
|
|
if themes := theming.List(); len(themes) > 0 { return themes[0].NvChadTheme }
|
|
return "gruvbox"
|
|
}
|
|
```
|
|
|
|
Change `New` signature to drop the `themes []tui.Theme` parameter:
|
|
|
|
```go
|
|
func New(cfg config.NeovimConfig, themeID string, pm platform.PackageManager, git platform.GitClient) Module {
|
|
return Module{cfg: cfg, themeID: themeID, pm: pm, git: git}
|
|
}
|
|
```
|
|
|
|
Remove the `themes []tui.Theme` field from `Module` struct.
|
|
|
|
Update `internal/modules/neovim/neovim_test.go`: replace every `New(cfg, id, tui.BuiltinThemes, pm, git)` call with `New(cfg, id, pm, git)`. Drop the `tui` import if unused.
|
|
|
|
- [ ] **Step 6: Update cmd/archduino/main.go**
|
|
|
|
In `buildRegistry`, update the neovim registration:
|
|
|
|
```go
|
|
neovim.New(cfg.Neovim, themeID, pm, git),
|
|
```
|
|
|
|
Replace any `tui.BuiltinThemes[0].ID` reference with `""` (neovim's `resolvedTheme` already falls back to `theming.List()[0].NvChadTheme`).
|
|
|
|
- [ ] **Step 7: Run the full test suite**
|
|
|
|
```
|
|
go build ./...
|
|
go test ./...
|
|
```
|
|
|
|
If tests fail due to leftover `BuiltinThemes` references the grep missed, fix them and rerun.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add internal/tui/ internal/modules/neovim/ cmd/archduino/main.go
|
|
git commit -m "refactor: retire tui.BuiltinThemes in favor of theming.List"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Extend config with `Scaffolding.Boards`
|
|
|
|
**Files:**
|
|
- Modify: `internal/config/config.go`
|
|
- Modify: `internal/config/defaults.yaml`
|
|
- Modify: `internal/config/config_test.go`
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Append to `config_test.go`:
|
|
|
|
```go
|
|
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) }
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test, confirm fail**
|
|
|
|
```
|
|
go test ./internal/config/... -run TestDefaultsCarryScaffoldingBoards -v
|
|
```
|
|
|
|
- [ ] **Step 3: Extend `config.go`**
|
|
|
|
Add types above `Config`:
|
|
|
|
```go
|
|
type Board struct {
|
|
Name string `yaml:"name"`
|
|
FQBN string `yaml:"fqbn"`
|
|
Template string `yaml:"template"`
|
|
}
|
|
|
|
type Scaffolding struct {
|
|
Boards []Board `yaml:"boards"`
|
|
}
|
|
```
|
|
|
|
Add field to `Config`:
|
|
|
|
```go
|
|
Scaffolding Scaffolding `yaml:"scaffolding"`
|
|
```
|
|
|
|
Extend `merge` with:
|
|
|
|
```go
|
|
if len(over.Scaffolding.Boards) > 0 { out.Scaffolding = over.Scaffolding }
|
|
```
|
|
|
|
- [ ] **Step 4: Extend `defaults.yaml`**
|
|
|
|
Append:
|
|
|
|
```yaml
|
|
|
|
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}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests, confirm pass**
|
|
|
|
```
|
|
go test ./internal/config/... -v
|
|
go test ./...
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/config/
|
|
git commit -m "feat(config): add Scaffolding.Boards typed input with 6 defaults"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: `newproject` subcommand package
|
|
|
|
**Files:**
|
|
- Create: `internal/subcommands/newproject/newproject.go`
|
|
- Create: `internal/subcommands/newproject/newproject_test.go`
|
|
- Create: `internal/subcommands/newproject/assets/default.ino`
|
|
- Create: `internal/subcommands/newproject/assets/uno.ino`
|
|
- Create: `internal/subcommands/newproject/assets/mega.ino`
|
|
- Create: `internal/subcommands/newproject/assets/nano.ino`
|
|
- Create: `internal/subcommands/newproject/assets/esp32.ino`
|
|
- Create: `internal/subcommands/newproject/assets/esp32s3.ino`
|
|
- Create: `internal/subcommands/newproject/assets/esp8266.ino`
|
|
|
|
- [ ] **Step 1: Create .ino assets**
|
|
|
|
Read `/home/marvin/git-clones/ArduinoArchSetup/lib/scaffold.sh` and extract the `cat >...` heredoc bodies for each per-board `.ino` template. If any board doesn't have its own heredoc in the bash file, copy the `default.ino` content for it. Keep each file's content verbatim.
|
|
|
|
For `default.ino`, if the bash has no explicit default heredoc, use this minimal fallback:
|
|
|
|
```
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
}
|
|
|
|
void loop() {
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing test**
|
|
|
|
```go
|
|
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")
|
|
}
|
|
}
|
|
```
|
|
|
|
`t.Chdir` is Go 1.24+. If the repo's Go is older, replace with an explicit pushd pattern (record `os.Getwd`, `os.Chdir(dir)`, and a `t.Cleanup(func() { os.Chdir(old) })`).
|
|
|
|
- [ ] **Step 3: Run tests, confirm fail**
|
|
|
|
```
|
|
go test ./internal/subcommands/newproject/... -v
|
|
```
|
|
Expected: `undefined: Run`.
|
|
|
|
- [ ] **Step 4: Implement `newproject.go`**
|
|
|
|
```go
|
|
// 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
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests, confirm pass**
|
|
|
|
```
|
|
go test ./internal/subcommands/newproject/... -v
|
|
go test ./...
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/subcommands/newproject/
|
|
git commit -m "feat(newproject): add archduino new project scaffolder"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: `doctor` subcommand package
|
|
|
|
**Files:**
|
|
- Create: `internal/subcommands/doctor/doctor.go`
|
|
- Create: `internal/subcommands/doctor/doctor_test.go`
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
```go
|
|
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()) }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests, confirm fail**
|
|
|
|
```
|
|
go test ./internal/subcommands/doctor/... -v
|
|
```
|
|
Expected: `undefined: Run`.
|
|
|
|
- [ ] **Step 3: Implement `doctor.go`**
|
|
|
|
```go
|
|
// 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
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, confirm pass**
|
|
|
|
```
|
|
go test ./internal/subcommands/doctor/... -v
|
|
go test ./...
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/subcommands/doctor/
|
|
git commit -m "feat(doctor): add archduino doctor subcommand"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Wire all three subcommands into cobra + integrate theming into install
|
|
|
|
**Files:**
|
|
- Modify: `cmd/archduino/main.go`
|
|
|
|
- [ ] **Step 1: Add the three cobra constructors**
|
|
|
|
Append near the existing `newInstallCmd`:
|
|
|
|
```go
|
|
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 {
|
|
// Interactive picker.
|
|
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
|
|
}
|
|
```
|
|
|
|
Add these registrations near `root.AddCommand(newInstallCmd())`:
|
|
|
|
```go
|
|
root.AddCommand(newThemeCmd())
|
|
root.AddCommand(newNewCmd())
|
|
root.AddCommand(newDoctorCmd())
|
|
```
|
|
|
|
Add imports:
|
|
|
|
```go
|
|
"errors"
|
|
"github.com/charmbracelet/huh"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/subcommands/doctor"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/subcommands/newproject"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/theming"
|
|
```
|
|
|
|
- [ ] **Step 2: Integrate theming into install**
|
|
|
|
In `newInstallCmd`'s RunE, after the block that invokes `engine.Run` and assigns `sum, runErr` (the existing plain-path or TUI-path block), add — after any error handling that returns early and after the TUI post-wizard logic that rebuilds the registry:
|
|
|
|
```go
|
|
// Apply the wizard-selected theme (no-op if user chose a theme
|
|
// whose configs are already on disk).
|
|
if runErr == nil && !dryRun {
|
|
selectedTheme := finalState.ThemeID
|
|
if selectedTheme == "" { selectedTheme = cfg.ThemeOverrides["__active"] }
|
|
if selectedTheme != "" {
|
|
if err := theming.Apply(cfg, selectedTheme); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: theme apply failed: %v\n", err)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
The exact variable names (`finalState`, `runErr`, `dryRun`) come from the existing install command code. Match them to what's currently in `newInstallCmd`. If the plain-output path doesn't have access to a chosen theme ID (because no wizard ran), fall through with just `cfg.ThemeOverrides["__active"]`.
|
|
|
|
- [ ] **Step 3: Run build + tests**
|
|
|
|
```
|
|
go build ./...
|
|
go test ./...
|
|
```
|
|
Both green.
|
|
|
|
- [ ] **Step 4: Manual smoke (optional, non-blocking)**
|
|
|
|
```
|
|
go run ./cmd/archduino --help
|
|
go run ./cmd/archduino theme list
|
|
go run ./cmd/archduino doctor
|
|
```
|
|
Expected: help lists all four subcommands; `theme list` prints 5 themes; `doctor` reports per-module status.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add cmd/archduino/main.go
|
|
git commit -m "feat(cmd): add theme/new/doctor subcommands; install applies theme"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-review notes
|
|
|
|
- Spec coverage: theming package (Tasks 1-5), tui retirement (Task 6), config extension (Task 7), newproject (Task 8), doctor (Task 9), cobra wiring + install integration (Task 10). All spec sections mapped.
|
|
- Type consistency: `theming.Theme{ID, DisplayName, NvChadTheme}`, `theming.List`, `theming.Get`, `theming.Apply(cfg, id)`, `config.Board{Name, FQBN, Template}`, `config.Scaffolding.Boards`, `newproject.Run(cfg, project, board) error`, `doctor.Run(ctx, cfg, reg, runner, w) int`, `neovim.WriteChadrc(nvChadThemeName) error`. Neovim `New` signature change in Task 6 consistently applied to `main.go` and tests.
|
|
- Known approximations:
|
|
- Task 1's `_ = strings.Split` is a placeholder to keep the import live for Task 2; delete if the build complains.
|
|
- Task 5 Step 3 requires copy-paste from 4 bash files. Diff carefully — bash `$variable` interpolations are rare in the theme files but not impossible; any `$foo` in the heredoc bodies must be preserved as literal text (the heredocs use quoted delimiters `'TOML'` / `'KDL'` / `'YAML'` which disable expansion in bash).
|
|
- Task 6 is the highest-risk task because it touches six files. The grep in Step 1 is authoritative; do not skip it.
|
|
- Task 10 Step 2's `finalState.ThemeID` depends on the current install command's variable name. If it's a different name (e.g., `state`), use that. Read the current code before inserting.
|