Merge Plan 3a: install modules + platform helpers + wizard cache fix
This commit is contained in:
@@ -13,7 +13,16 @@ import (
|
|||||||
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/modules/hello"
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/arduinocli"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/fonts"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/gittools"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/packages"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/shellconfig"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/starship"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/themes"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/yazi"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/zellij"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/zsh"
|
||||||
"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"
|
||||||
@@ -24,14 +33,35 @@ import (
|
|||||||
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
// Registry is the global module registry. Tasks in Plan 3 will register
|
// buildRegistry constructs a module registry populated with all Plan 3a
|
||||||
// real modules into it via init(). Task 12 registers the hello toy module.
|
// modules, wired against the provided config and platform dependencies.
|
||||||
var Registry = module.NewRegistry()
|
// It replaces the earlier init()-time hello-module registration now that
|
||||||
|
// the real modules need runtime config to be instantiated.
|
||||||
|
func buildRegistry(cfg config.Config) (*module.Registry, error) {
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
runner := platform.ExecRunner{}
|
||||||
|
pm := platform.NewPacman(runner)
|
||||||
|
yay := platform.NewYay(runner)
|
||||||
|
git := platform.GitClient{Runner: runner}
|
||||||
|
|
||||||
func init() {
|
mods := []module.Module{
|
||||||
if err := Registry.Register(hello.Module{}); err != nil {
|
packages.New(cfg.Packages.Base, pm),
|
||||||
panic(err)
|
gittools.New([]string{"lazygit"}, pm),
|
||||||
|
starship.New([]string{"starship"}, pm),
|
||||||
|
yazi.New([]string{"yazi"}, pm),
|
||||||
|
zsh.New(cfg.Zsh, pm, git),
|
||||||
|
fonts.New(cfg.Fonts, pm, yay),
|
||||||
|
zellij.New(cfg.Zellij, pm),
|
||||||
|
themes.New(cfg.Themes),
|
||||||
|
arduinocli.New(cfg.ArduinoCLI, runner),
|
||||||
|
shellconfig.New(cfg.ShellConfig),
|
||||||
}
|
}
|
||||||
|
for _, m := range mods {
|
||||||
|
if err := reg.Register(m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -92,7 +122,12 @@ func newInstallCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
selected, err := resolveSelection(Registry, cfg, only, skip)
|
reg, err := buildRegistry(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
selected, err := resolveSelection(reg, cfg, only, skip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -117,7 +152,7 @@ func newInstallCmd() *cobra.Command {
|
|||||||
// always uses plain output because the dashboard has nothing
|
// always uses plain output because the dashboard has nothing
|
||||||
// to animate when no Apply happens.
|
// to animate when no Apply happens.
|
||||||
reporter := engine.NewPlainReporter(os.Stdout)
|
reporter := engine.NewPlainReporter(os.Stdout)
|
||||||
sum, runErr = engine.Run(ctx, Registry, engine.RunOptions{
|
sum, runErr = engine.Run(ctx, reg, engine.RunOptions{
|
||||||
Selected: selected,
|
Selected: selected,
|
||||||
DryRun: dryRun,
|
DryRun: dryRun,
|
||||||
HaltOnError: halt,
|
HaltOnError: halt,
|
||||||
@@ -126,7 +161,7 @@ func newInstallCmd() *cobra.Command {
|
|||||||
} else {
|
} else {
|
||||||
// TUI path: wizard → dashboard. If the user does not confirm,
|
// TUI path: wizard → dashboard. If the user does not confirm,
|
||||||
// we exit cleanly with a zero-summary and no error.
|
// we exit cleanly with a zero-summary and no error.
|
||||||
initial := tui.NewWizardState(cfg, Registry)
|
initial := tui.NewWizardState(cfg, reg)
|
||||||
// Pre-filter the wizard's module list by --only/--skip so the
|
// Pre-filter the wizard's module list by --only/--skip so the
|
||||||
// user cannot accidentally widen selection in the TUI beyond
|
// user cannot accidentally widen selection in the TUI beyond
|
||||||
// what they asked for on the CLI.
|
// what they asked for on the CLI.
|
||||||
@@ -140,7 +175,7 @@ func newInstallCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finalState, confirmed, werr := wizard.Run(ctx, Registry, initial)
|
finalState, confirmed, werr := wizard.Run(ctx, reg, initial)
|
||||||
if werr != nil {
|
if werr != nil {
|
||||||
return werr
|
return werr
|
||||||
}
|
}
|
||||||
@@ -149,7 +184,7 @@ func newInstallCmd() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// 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, Registry, finalState, halt || finalState.HaltOnError)
|
sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError)
|
||||||
}
|
}
|
||||||
|
|
||||||
ended := time.Now().UTC()
|
ended := time.Now().UTC()
|
||||||
|
|||||||
@@ -14,12 +14,50 @@ import (
|
|||||||
//go:embed defaults.yaml
|
//go:embed defaults.yaml
|
||||||
var defaultsYAML []byte
|
var defaultsYAML []byte
|
||||||
|
|
||||||
|
type Packages struct {
|
||||||
|
Base []string `yaml:"base"`
|
||||||
|
}
|
||||||
|
type ArduinoCLIConfig struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Cores []string `yaml:"cores"`
|
||||||
|
}
|
||||||
|
type ZshPlugin struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Repo string `yaml:"repo"`
|
||||||
|
}
|
||||||
|
type ZshConfig struct {
|
||||||
|
OhMyZshRepo string `yaml:"oh_my_zsh_repo"`
|
||||||
|
Plugins []ZshPlugin `yaml:"plugins"`
|
||||||
|
}
|
||||||
|
type ZellijConfig struct {
|
||||||
|
ConfigURL string `yaml:"config_url"`
|
||||||
|
}
|
||||||
|
type ThemesConfig struct {
|
||||||
|
Source string `yaml:"source"`
|
||||||
|
}
|
||||||
|
type ShellConfigBlock struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
File string `yaml:"file"`
|
||||||
|
Content string `yaml:"content"`
|
||||||
|
}
|
||||||
|
type ShellConfigSection struct {
|
||||||
|
Blocks []ShellConfigBlock `yaml:"blocks"`
|
||||||
|
}
|
||||||
|
|
||||||
// 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"`
|
||||||
ExtraPackages []string `yaml:"extra_packages"`
|
ExtraPackages []string `yaml:"extra_packages"`
|
||||||
DisabledModules []string `yaml:"disabled_modules"`
|
DisabledModules []string `yaml:"disabled_modules"`
|
||||||
ThemeOverrides map[string]string `yaml:"theme_overrides"`
|
ThemeOverrides map[string]string `yaml:"theme_overrides"`
|
||||||
|
Packages Packages `yaml:"packages"`
|
||||||
|
Fonts []string `yaml:"fonts"`
|
||||||
|
ArduinoCLI ArduinoCLIConfig `yaml:"arduino_cli"`
|
||||||
|
Zsh ZshConfig `yaml:"zsh"`
|
||||||
|
Zellij ZellijConfig `yaml:"zellij"`
|
||||||
|
Themes ThemesConfig `yaml:"themes"`
|
||||||
|
ShellConfig ShellConfigSection `yaml:"shell_config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVersion = 1
|
const currentVersion = 1
|
||||||
@@ -78,5 +116,26 @@ func merge(base, over Config) Config {
|
|||||||
}
|
}
|
||||||
out.ThemeOverrides = merged
|
out.ThemeOverrides = merged
|
||||||
}
|
}
|
||||||
|
if len(over.Packages.Base) > 0 {
|
||||||
|
out.Packages = over.Packages
|
||||||
|
}
|
||||||
|
if len(over.Fonts) > 0 {
|
||||||
|
out.Fonts = over.Fonts
|
||||||
|
}
|
||||||
|
if over.ArduinoCLI.Version != "" {
|
||||||
|
out.ArduinoCLI = over.ArduinoCLI
|
||||||
|
}
|
||||||
|
if over.Zsh.OhMyZshRepo != "" {
|
||||||
|
out.Zsh = over.Zsh
|
||||||
|
}
|
||||||
|
if over.Zellij.ConfigURL != "" {
|
||||||
|
out.Zellij = over.Zellij
|
||||||
|
}
|
||||||
|
if over.Themes.Source != "" {
|
||||||
|
out.Themes = over.Themes
|
||||||
|
}
|
||||||
|
if len(over.ShellConfig.Blocks) > 0 {
|
||||||
|
out.ShellConfig = over.ShellConfig
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,31 @@ theme_overrides:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultsCarryModuleInputs(t *testing.T) {
|
||||||
|
cfg, err := Load("/nonexistent/overlay.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cfg.Packages.Base) == 0 {
|
||||||
|
t.Error("Packages.Base empty")
|
||||||
|
}
|
||||||
|
if len(cfg.Fonts) == 0 {
|
||||||
|
t.Error("Fonts empty")
|
||||||
|
}
|
||||||
|
if cfg.ArduinoCLI.Version == "" {
|
||||||
|
t.Error("ArduinoCLI.Version empty")
|
||||||
|
}
|
||||||
|
if cfg.ArduinoCLI.URL == "" {
|
||||||
|
t.Error("ArduinoCLI.URL empty")
|
||||||
|
}
|
||||||
|
if cfg.Zsh.OhMyZshRepo == "" {
|
||||||
|
t.Error("Zsh.OhMyZshRepo empty")
|
||||||
|
}
|
||||||
|
if len(cfg.ShellConfig.Blocks) == 0 {
|
||||||
|
t.Error("ShellConfig.Blocks empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadRejectsUnknownVersion(t *testing.T) {
|
func TestLoadRejectsUnknownVersion(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "overlay.yaml")
|
path := filepath.Join(dir, "overlay.yaml")
|
||||||
|
|||||||
@@ -2,3 +2,81 @@ version: 1
|
|||||||
extra_packages: []
|
extra_packages: []
|
||||||
disabled_modules: []
|
disabled_modules: []
|
||||||
theme_overrides: {}
|
theme_overrides: {}
|
||||||
|
|
||||||
|
packages:
|
||||||
|
base:
|
||||||
|
- base-devel
|
||||||
|
- git
|
||||||
|
- curl
|
||||||
|
- wget
|
||||||
|
- unzip
|
||||||
|
- zip
|
||||||
|
- tree
|
||||||
|
- htop
|
||||||
|
- neofetch
|
||||||
|
- ripgrep
|
||||||
|
- fd
|
||||||
|
- bat
|
||||||
|
- eza
|
||||||
|
- jq
|
||||||
|
- zsh
|
||||||
|
- clang
|
||||||
|
- ca-certificates
|
||||||
|
|
||||||
|
fonts:
|
||||||
|
- ttf-jetbrains-mono-nerd
|
||||||
|
|
||||||
|
arduino_cli:
|
||||||
|
version: "0.35.3"
|
||||||
|
url: "https://github.com/arduino/arduino-cli/releases/download/v0.35.3/arduino-cli_0.35.3_Linux_64bit.tar.gz"
|
||||||
|
cores:
|
||||||
|
- arduino:avr
|
||||||
|
|
||||||
|
zsh:
|
||||||
|
oh_my_zsh_repo: "https://github.com/ohmyzsh/ohmyzsh.git"
|
||||||
|
plugins:
|
||||||
|
- name: zsh-autosuggestions
|
||||||
|
repo: https://github.com/zsh-users/zsh-autosuggestions
|
||||||
|
- name: zsh-syntax-highlighting
|
||||||
|
repo: https://github.com/zsh-users/zsh-syntax-highlighting.git
|
||||||
|
|
||||||
|
zellij:
|
||||||
|
config_url: ""
|
||||||
|
|
||||||
|
themes:
|
||||||
|
source: "lib/themes"
|
||||||
|
|
||||||
|
shell_config:
|
||||||
|
blocks:
|
||||||
|
- name: archduino-path
|
||||||
|
file: ".zshrc"
|
||||||
|
content: |
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
- name: archduino-editor
|
||||||
|
file: ".zshrc"
|
||||||
|
content: |
|
||||||
|
export EDITOR="nvim"
|
||||||
|
export VISUAL="nvim"
|
||||||
|
- name: archduino-starship
|
||||||
|
file: ".zshrc"
|
||||||
|
content: |
|
||||||
|
eval "$(starship init zsh)"
|
||||||
|
- name: archduino-zellij
|
||||||
|
file: ".zshrc"
|
||||||
|
content: |
|
||||||
|
if [[ -z "$ZELLIJ" ]]; then
|
||||||
|
zellij attach --create archduino
|
||||||
|
fi
|
||||||
|
- name: archduino-aliases
|
||||||
|
file: ".zshrc"
|
||||||
|
content: |
|
||||||
|
alias adev="zellij --layout arduino"
|
||||||
|
alias ac="arduino-cli"
|
||||||
|
alias acc="arduino-cli compile"
|
||||||
|
alias acu="arduino-cli upload"
|
||||||
|
alias acm="arduino-cli monitor"
|
||||||
|
alias acb="arduino-cli board list"
|
||||||
|
alias lg="lazygit"
|
||||||
|
alias v="nvim"
|
||||||
|
alias y="yazi"
|
||||||
|
alias theme="archduino-theme"
|
||||||
|
|||||||
138
internal/modules/arduinocli/arduinocli.go
Normal file
138
internal/modules/arduinocli/arduinocli.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// Package arduinocli installs arduino-cli and its configured cores.
|
||||||
|
package arduinocli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
cfg config.ArduinoCLIConfig
|
||||||
|
runner platform.Runner
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.ArduinoCLIConfig, r platform.Runner) Module {
|
||||||
|
return Module{cfg: cfg, runner: r}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
BinaryPath string
|
||||||
|
BinaryInstalled bool
|
||||||
|
MissingCores []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "arduinocli", Name: "arduino-cli", Description: "Install arduino-cli and cores."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func binaryPath() (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, ".local", "bin", "arduino-cli"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
bp, err := binaryPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := state{BinaryPath: bp}
|
||||||
|
if _, err := os.Stat(bp); err == nil {
|
||||||
|
s.BinaryInstalled = true
|
||||||
|
out, _ := m.runner.Output(ctx, bp, "core", "list")
|
||||||
|
for _, core := range m.cfg.Cores {
|
||||||
|
if !bytes.Contains(out, []byte(core)) {
|
||||||
|
s.MissingCores = append(s.MissingCores, core)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.MissingCores = append([]string{}, m.cfg.Cores...)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
var actions []module.Action
|
||||||
|
if !s.BinaryInstalled {
|
||||||
|
bp := s.BinaryPath
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "download + install arduino-cli " + m.cfg.Version,
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "arduinocli-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
archive := filepath.Join(tmpDir, "arduino-cli.tar.gz")
|
||||||
|
if err := platform.Download(ctx, m.cfg.URL, archive, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := platform.ExtractTarGz(ctx, archive, tmpDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(bp), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(tmpDir, "arduino-cli"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(bp, data, 0o755)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, core := range s.MissingCores {
|
||||||
|
core := core
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "arduino-cli core update-index (for " + core + ")",
|
||||||
|
Kind: module.ActionCommand,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
return m.runner.Run(ctx, s.BinaryPath, "core", "update-index")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "arduino-cli core install " + core,
|
||||||
|
Kind: module.ActionCommand,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
return m.runner.Run(ctx, s.BinaryPath, "core", "install", core)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return module.Plan{Actions: actions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||||
|
for i, a := range p.Actions {
|
||||||
|
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||||
|
if err := a.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if !s.BinaryInstalled {
|
||||||
|
return fmt.Errorf("arduino-cli binary missing")
|
||||||
|
}
|
||||||
|
if len(s.MissingCores) > 0 {
|
||||||
|
return fmt.Errorf("cores missing: %v", s.MissingCores)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
94
internal/modules/arduinocli/arduinocli_test.go
Normal file
94
internal/modules/arduinocli/arduinocli_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package arduinocli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
coreListOut []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if filepath.Base(name) == "arduino-cli" && len(args) >= 2 && args[0] == "core" && args[1] == "list" {
|
||||||
|
return f.coreListOut, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type nop struct{}
|
||||||
|
|
||||||
|
func (nop) Progress(string, int, int, string) {}
|
||||||
|
func (nop) Log(string, string) {}
|
||||||
|
|
||||||
|
func makeTarGz(t *testing.T, entries map[string][]byte) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "a.tgz")
|
||||||
|
f, _ := os.Create(p)
|
||||||
|
gz := gzip.NewWriter(f)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
for name, data := range entries {
|
||||||
|
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(data))})
|
||||||
|
_, _ = tw.Write(data)
|
||||||
|
}
|
||||||
|
tw.Close()
|
||||||
|
gz.Close()
|
||||||
|
f.Close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArduinoCLIDownloadsAndInstallsCore(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
archive := makeTarGz(t, map[string][]byte{"arduino-cli": []byte("#!/bin/sh\n")})
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, _ := os.ReadFile(archive)
|
||||||
|
w.Write(data)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
r := &fakeRunner{coreListOut: []byte("ID Installed\n")}
|
||||||
|
m := New(config.ArduinoCLIConfig{
|
||||||
|
Version: "1.0.0",
|
||||||
|
URL: srv.URL + "/arduino-cli.tgz",
|
||||||
|
Cores: []string{"arduino:avr"},
|
||||||
|
}, r)
|
||||||
|
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nop{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(home, ".local", "bin", "arduino-cli")); err != nil {
|
||||||
|
t.Fatalf("binary not installed: %v", err)
|
||||||
|
}
|
||||||
|
sawCore := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 4 && c[1] == "core" && c[2] == "install" && c[3] == "arduino:avr" {
|
||||||
|
sawCore = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawCore {
|
||||||
|
t.Fatalf("core install not run; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
82
internal/modules/fonts/fonts.go
Normal file
82
internal/modules/fonts/fonts.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Package fonts installs AUR fonts via the yay helper.
|
||||||
|
package fonts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
fonts []string
|
||||||
|
pm platform.PackageManager
|
||||||
|
aur platform.AURHelper
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(fonts []string, pm platform.PackageManager, aur platform.AURHelper) Module {
|
||||||
|
return Module{fonts: fonts, pm: pm, aur: aur}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct{ Missing []string }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "fonts", Name: "Nerd fonts", Description: "Install AUR nerd fonts."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []string
|
||||||
|
for _, f := range m.fonts {
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
missing := append([]string{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{
|
||||||
|
{
|
||||||
|
Description: "ensure yay installed",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Description: "yay install " + strings.Join(missing, ", "),
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.aur.Install(ctx, missing) },
|
||||||
|
},
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||||
|
for i, a := range p.Actions {
|
||||||
|
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||||
|
if err := a.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if miss := st.(state).Missing; len(miss) > 0 {
|
||||||
|
return fmt.Errorf("fonts still missing: %v", miss)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
72
internal/modules/fonts/fonts_test.go
Normal file
72
internal/modules/fonts/fonts_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package fonts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte("ok"), nil
|
||||||
|
}
|
||||||
|
return nil, errOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type eo struct{}
|
||||||
|
|
||||||
|
func (eo) Error() string { return "exit 1" }
|
||||||
|
|
||||||
|
var errOne = eo{}
|
||||||
|
|
||||||
|
type nop struct{}
|
||||||
|
|
||||||
|
func (nop) Progress(string, int, int, string) {}
|
||||||
|
func (nop) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestFontsPlanApplyUsesYay(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"yay": true}}
|
||||||
|
m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nop{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawYay := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 2 && c[0] == "yay" && c[1] == "-S" {
|
||||||
|
sawYay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawYay {
|
||||||
|
t.Fatalf("no yay install; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFontsDetectPresentNoActions(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"yay": true, "ttf-jetbrains-mono-nerd": true}}
|
||||||
|
m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatal("want empty plan when already installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
74
internal/modules/gittools/gittools.go
Normal file
74
internal/modules/gittools/gittools.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Package gittools installs a declarative list of pacman packages.
|
||||||
|
package gittools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
pkgs []string
|
||||||
|
pm platform.PackageManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pkgs []string, pm platform.PackageManager) Module {
|
||||||
|
return Module{pkgs: pkgs, pm: pm}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct{ Missing []string }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "gittools", Name: "Git tools", Description: "Install lazygit."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []string
|
||||||
|
for _, p := range m.pkgs {
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
missing := append([]string{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{{
|
||||||
|
Description: "pacman install " + strings.Join(missing, ", "),
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
|
||||||
|
}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||||
|
for i, a := range p.Actions {
|
||||||
|
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||||
|
if err := a.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if missing := st.(state).Missing; len(missing) > 0 {
|
||||||
|
return fmt.Errorf("packages not installed after apply: %v", missing)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
113
internal/modules/gittools/gittools_test.go
Normal file
113
internal/modules/gittools/gittools_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package gittools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool // key: pkg name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte(args[1] + " 1.0-1\n"), nil
|
||||||
|
}
|
||||||
|
return nil, errExitOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitOneErr struct{}
|
||||||
|
|
||||||
|
func (exitOneErr) Error() string { return "exit status 1" }
|
||||||
|
|
||||||
|
var errExitOne = exitOneErr{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestDetectAbsent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"lazygit"}, platform.NewPacman(r))
|
||||||
|
st, err := m.Detect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if len(s.Missing) != 1 {
|
||||||
|
t.Fatalf("want 1 missing, got %v", s.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPresent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"lazygit": true}}
|
||||||
|
m := New([]string{"lazygit"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
if len(st.(state).Missing) != 0 {
|
||||||
|
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
|
||||||
|
m := New([]string{"lazygit"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: []string{"lazygit"}})
|
||||||
|
if len(p.Actions) != 1 {
|
||||||
|
t.Fatalf("want 1 action, got %d", len(p.Actions))
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Actions[0].Description, "lazygit") {
|
||||||
|
t.Fatalf("desc: %s", p.Actions[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanPresentEmpty(t *testing.T) {
|
||||||
|
m := New([]string{"lazygit"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: nil})
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatal("want empty plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyShellsOut(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"lazygit"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawInstall := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
|
||||||
|
sawInstall = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawInstall {
|
||||||
|
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyAfterApply(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"lazygit": true}}
|
||||||
|
m := New([]string{"lazygit"}, platform.NewPacman(r))
|
||||||
|
if err := m.Verify(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
74
internal/modules/packages/packages.go
Normal file
74
internal/modules/packages/packages.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Package packages installs a declarative list of pacman packages.
|
||||||
|
package packages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
pkgs []string
|
||||||
|
pm platform.PackageManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pkgs []string, pm platform.PackageManager) Module {
|
||||||
|
return Module{pkgs: pkgs, pm: pm}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct{ Missing []string }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "packages", Name: "Base packages", Description: "Install base pacman package list."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []string
|
||||||
|
for _, p := range m.pkgs {
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
missing := append([]string{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{{
|
||||||
|
Description: "pacman install " + strings.Join(missing, ", "),
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
|
||||||
|
}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||||
|
for i, a := range p.Actions {
|
||||||
|
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||||
|
if err := a.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if missing := st.(state).Missing; len(missing) > 0 {
|
||||||
|
return fmt.Errorf("packages not installed after apply: %v", missing)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
113
internal/modules/packages/packages_test.go
Normal file
113
internal/modules/packages/packages_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package packages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool // key: pkg name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte(args[1] + " 1.0-1\n"), nil
|
||||||
|
}
|
||||||
|
return nil, errExitOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitOneErr struct{}
|
||||||
|
|
||||||
|
func (exitOneErr) Error() string { return "exit status 1" }
|
||||||
|
|
||||||
|
var errExitOne = exitOneErr{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestDetectAbsent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"curl", "wget"}, platform.NewPacman(r))
|
||||||
|
st, err := m.Detect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if len(s.Missing) != 2 {
|
||||||
|
t.Fatalf("want 2 missing, got %v", s.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPresent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"curl": true, "wget": true}}
|
||||||
|
m := New([]string{"curl", "wget"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
if len(st.(state).Missing) != 0 {
|
||||||
|
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
|
||||||
|
m := New([]string{"curl", "wget"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: []string{"curl", "wget"}})
|
||||||
|
if len(p.Actions) != 1 {
|
||||||
|
t.Fatalf("want 1 action, got %d", len(p.Actions))
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Actions[0].Description, "curl") {
|
||||||
|
t.Fatalf("desc: %s", p.Actions[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanPresentEmpty(t *testing.T) {
|
||||||
|
m := New([]string{"curl"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: nil})
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatal("want empty plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyShellsOut(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"curl"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawInstall := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
|
||||||
|
sawInstall = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawInstall {
|
||||||
|
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyAfterApply(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"curl": true}}
|
||||||
|
m := New([]string{"curl"}, platform.NewPacman(r))
|
||||||
|
if err := m.Verify(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
90
internal/modules/shellconfig/shellconfig.go
Normal file
90
internal/modules/shellconfig/shellconfig.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Package shellconfig writes marker-delimited blocks into rc files.
|
||||||
|
package shellconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct{ cfg config.ShellConfigSection }
|
||||||
|
|
||||||
|
func New(cfg config.ShellConfigSection) Module { return Module{cfg: cfg} }
|
||||||
|
|
||||||
|
type state struct{ Missing []config.ShellConfigBlock }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "shellconfig", Name: "Shell config blocks", Description: "Write archduino blocks into rc files."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFile(name string) (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []config.ShellConfigBlock
|
||||||
|
for _, b := range m.cfg.Blocks {
|
||||||
|
p, err := resolveFile(b.File)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
has, err := platform.HasBlock(p, b.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
missing = append(missing, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
var actions []module.Action
|
||||||
|
for _, b := range s.Missing {
|
||||||
|
b := b
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: fmt.Sprintf("write block %s into %s", b.Name, b.File),
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
p, err := resolveFile(b.File)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return platform.EnsureBlock(p, b.Name, b.Content)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return module.Plan{Actions: actions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||||
|
for i, a := range p.Actions {
|
||||||
|
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||||
|
if err := a.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if miss := st.(state).Missing; len(miss) > 0 {
|
||||||
|
return fmt.Errorf("shell blocks still missing: %d", len(miss))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
58
internal/modules/shellconfig/shellconfig_test.go
Normal file
58
internal/modules/shellconfig/shellconfig_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package shellconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type nop struct{}
|
||||||
|
|
||||||
|
func (nop) Progress(string, int, int, string) {}
|
||||||
|
func (nop) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestShellConfigWritesBlocks(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
m := New(config.ShellConfigSection{
|
||||||
|
Blocks: []config.ShellConfigBlock{
|
||||||
|
{Name: "path", File: ".zshrc", Content: `export PATH="$HOME/.local/bin:$PATH"`},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nop{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(filepath.Join(home, ".zshrc"))
|
||||||
|
if !strings.Contains(string(data), ">>> path >>>") {
|
||||||
|
t.Fatalf("block not present: %q", data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `export PATH="$HOME/.local/bin:$PATH"`) {
|
||||||
|
t.Fatalf("content missing: %q", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellConfigIdempotent(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
cfg := config.ShellConfigSection{Blocks: []config.ShellConfigBlock{
|
||||||
|
{Name: "x", File: ".zshrc", Content: "echo x"},
|
||||||
|
}}
|
||||||
|
m := New(cfg)
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
_ = m.Apply(context.Background(), p, nop{})
|
||||||
|
st2, _ := m.Detect(context.Background())
|
||||||
|
p2, _ := m.Plan(context.Background(), st2)
|
||||||
|
if !p2.IsEmpty() {
|
||||||
|
t.Fatalf("second plan not empty: %+v", p2.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
74
internal/modules/starship/starship.go
Normal file
74
internal/modules/starship/starship.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Package starship installs a declarative list of pacman packages.
|
||||||
|
package starship
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
pkgs []string
|
||||||
|
pm platform.PackageManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pkgs []string, pm platform.PackageManager) Module {
|
||||||
|
return Module{pkgs: pkgs, pm: pm}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct{ Missing []string }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "starship", Name: "Starship prompt", Description: "Install starship prompt."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []string
|
||||||
|
for _, p := range m.pkgs {
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
missing := append([]string{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{{
|
||||||
|
Description: "pacman install " + strings.Join(missing, ", "),
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
|
||||||
|
}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||||
|
for i, a := range p.Actions {
|
||||||
|
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||||
|
if err := a.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if missing := st.(state).Missing; len(missing) > 0 {
|
||||||
|
return fmt.Errorf("packages not installed after apply: %v", missing)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
113
internal/modules/starship/starship_test.go
Normal file
113
internal/modules/starship/starship_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package starship
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool // key: pkg name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte(args[1] + " 1.0-1\n"), nil
|
||||||
|
}
|
||||||
|
return nil, errExitOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitOneErr struct{}
|
||||||
|
|
||||||
|
func (exitOneErr) Error() string { return "exit status 1" }
|
||||||
|
|
||||||
|
var errExitOne = exitOneErr{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestDetectAbsent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"starship"}, platform.NewPacman(r))
|
||||||
|
st, err := m.Detect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if len(s.Missing) != 1 {
|
||||||
|
t.Fatalf("want 1 missing, got %v", s.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPresent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"starship": true}}
|
||||||
|
m := New([]string{"starship"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
if len(st.(state).Missing) != 0 {
|
||||||
|
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
|
||||||
|
m := New([]string{"starship"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: []string{"starship"}})
|
||||||
|
if len(p.Actions) != 1 {
|
||||||
|
t.Fatalf("want 1 action, got %d", len(p.Actions))
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Actions[0].Description, "starship") {
|
||||||
|
t.Fatalf("desc: %s", p.Actions[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanPresentEmpty(t *testing.T) {
|
||||||
|
m := New([]string{"starship"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: nil})
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatal("want empty plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyShellsOut(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"starship"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawInstall := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
|
||||||
|
sawInstall = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawInstall {
|
||||||
|
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyAfterApply(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"starship": true}}
|
||||||
|
m := New([]string{"starship"}, platform.NewPacman(r))
|
||||||
|
if err := m.Verify(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
115
internal/modules/themes/themes.go
Normal file
115
internal/modules/themes/themes.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Package themes installs archduino theme files into ~/.config/archduino/themes.
|
||||||
|
package themes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct{ cfg config.ThemesConfig }
|
||||||
|
|
||||||
|
func New(cfg config.ThemesConfig) Module { return Module{cfg: cfg} }
|
||||||
|
|
||||||
|
type filePair struct{ src, dst string }
|
||||||
|
type state struct{ Missing []filePair }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "themes", Name: "Colorscheme themes", Description: "Install archduino themes."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func destRoot() (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, ".config", "archduino", "themes"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
dst, err := destRoot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var missing []filePair
|
||||||
|
err = filepath.WalkDir(m.cfg.Source, func(p string, d fs.DirEntry, werr error) error {
|
||||||
|
if werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(m.cfg.Source, p)
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
a, errA := os.ReadFile(p)
|
||||||
|
if errA != nil {
|
||||||
|
return errA
|
||||||
|
}
|
||||||
|
b, errB := os.ReadFile(target)
|
||||||
|
if errB != nil || !bytes.Equal(a, b) {
|
||||||
|
missing = append(missing, filePair{src: p, dst: target})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return state{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
pairs := append([]filePair{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{{
|
||||||
|
Description: fmt.Sprintf("copy %d theme files", len(pairs)),
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
for _, fp := range pairs {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fp.dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(fp.src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fp.dst, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||||
|
for i, a := range p.Actions {
|
||||||
|
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||||
|
if err := a.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if miss := st.(state).Missing; len(miss) > 0 {
|
||||||
|
return fmt.Errorf("themes still missing: %d files", len(miss))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
66
internal/modules/themes/themes_test.go
Normal file
66
internal/modules/themes/themes_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package themes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type nop struct{}
|
||||||
|
|
||||||
|
func (nop) Progress(string, int, int, string) {}
|
||||||
|
func (nop) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestThemesCopiesFiles(t *testing.T) {
|
||||||
|
src := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "one.toml"), []byte("A"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.MkdirAll(filepath.Join(src, "sub"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(src, "sub", "two.toml"), []byte("B"), 0o644)
|
||||||
|
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
m := New(config.ThemesConfig{Source: src})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nop{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "one.toml"))
|
||||||
|
if string(got) != "A" {
|
||||||
|
t.Fatalf("one.toml: %q", got)
|
||||||
|
}
|
||||||
|
got, _ = os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "sub", "two.toml"))
|
||||||
|
if string(got) != "B" {
|
||||||
|
t.Fatalf("two.toml: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThemesIdempotent(t *testing.T) {
|
||||||
|
src := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(src, "x.toml"), []byte("X"), 0o644)
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
m := New(config.ThemesConfig{Source: src})
|
||||||
|
_ = applyOnce(t, m)
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatalf("second plan not empty: %+v", p.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyOnce(t *testing.T, m Module) error {
|
||||||
|
t.Helper()
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
return m.Apply(context.Background(), p, nop{})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
74
internal/modules/yazi/yazi.go
Normal file
74
internal/modules/yazi/yazi.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Package yazi installs a declarative list of pacman packages.
|
||||||
|
package yazi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
pkgs []string
|
||||||
|
pm platform.PackageManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pkgs []string, pm platform.PackageManager) Module {
|
||||||
|
return Module{pkgs: pkgs, pm: pm}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct{ Missing []string }
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "yazi", Name: "Yazi file manager", Description: "Install yazi."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var missing []string
|
||||||
|
for _, p := range m.pkgs {
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state{Missing: missing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
if len(s.Missing) == 0 {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
missing := append([]string{}, s.Missing...)
|
||||||
|
return module.Plan{Actions: []module.Action{{
|
||||||
|
Description: "pacman install " + strings.Join(missing, ", "),
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) },
|
||||||
|
}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||||
|
for i, a := range p.Actions {
|
||||||
|
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||||
|
if err := a.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if missing := st.(state).Missing; len(missing) > 0 {
|
||||||
|
return fmt.Errorf("packages not installed after apply: %v", missing)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
113
internal/modules/yazi/yazi_test.go
Normal file
113
internal/modules/yazi/yazi_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package yazi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool // key: pkg name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte(args[1] + " 1.0-1\n"), nil
|
||||||
|
}
|
||||||
|
return nil, errExitOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitOneErr struct{}
|
||||||
|
|
||||||
|
func (exitOneErr) Error() string { return "exit status 1" }
|
||||||
|
|
||||||
|
var errExitOne = exitOneErr{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestDetectAbsent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"yazi"}, platform.NewPacman(r))
|
||||||
|
st, err := m.Detect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if len(s.Missing) != 1 {
|
||||||
|
t.Fatalf("want 1 missing, got %v", s.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPresent(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"yazi": true}}
|
||||||
|
m := New([]string{"yazi"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
if len(st.(state).Missing) != 0 {
|
||||||
|
t.Fatalf("want 0 missing, got %v", st.(state).Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanAbsentEmitsInstallAction(t *testing.T) {
|
||||||
|
m := New([]string{"yazi"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: []string{"yazi"}})
|
||||||
|
if len(p.Actions) != 1 {
|
||||||
|
t.Fatalf("want 1 action, got %d", len(p.Actions))
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Actions[0].Description, "yazi") {
|
||||||
|
t.Fatalf("desc: %s", p.Actions[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanPresentEmpty(t *testing.T) {
|
||||||
|
m := New([]string{"yazi"}, nil)
|
||||||
|
p, _ := m.Plan(context.Background(), state{Missing: nil})
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatal("want empty plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyShellsOut(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New([]string{"yazi"}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawInstall := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" {
|
||||||
|
sawInstall = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawInstall {
|
||||||
|
t.Fatalf("no pacman install call; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyAfterApply(t *testing.T) {
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"yazi": true}}
|
||||||
|
m := New([]string{"yazi"}, platform.NewPacman(r))
|
||||||
|
if err := m.Verify(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
110
internal/modules/zellij/zellij.go
Normal file
110
internal/modules/zellij/zellij.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// Package zellij installs zellij and optionally writes a remote config.
|
||||||
|
package zellij
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
cfg config.ZellijConfig
|
||||||
|
pm platform.PackageManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.ZellijConfig, pm platform.PackageManager) Module {
|
||||||
|
return Module{cfg: cfg, pm: pm}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
Installed bool
|
||||||
|
ConfigOK bool
|
||||||
|
ConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "zellij", Name: "Zellij terminal multiplexer", Description: "Install zellij and config."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configPath() (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, ".config", "zellij", "config.kdl"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var s state
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, "zellij")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.Installed = ok
|
||||||
|
p, err := configPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.ConfigPath = p
|
||||||
|
if m.cfg.ConfigURL == "" {
|
||||||
|
s.ConfigOK = true
|
||||||
|
} else if _, err := os.Stat(p); err == nil {
|
||||||
|
s.ConfigOK = true
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
var actions []module.Action
|
||||||
|
if !s.Installed {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "pacman install zellij",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zellij"}) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !s.ConfigOK {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "download zellij config",
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(s.ConfigPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return platform.Download(ctx, m.cfg.ConfigURL, s.ConfigPath, 0o644)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return module.Plan{Actions: actions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||||
|
for i, a := range p.Actions {
|
||||||
|
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||||
|
if err := a.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if !s.Installed {
|
||||||
|
return fmt.Errorf("zellij not installed")
|
||||||
|
}
|
||||||
|
if !s.ConfigOK {
|
||||||
|
return fmt.Errorf("zellij config missing")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
84
internal/modules/zellij/zellij_test.go
Normal file
84
internal/modules/zellij/zellij_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package zellij
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte("ok"), nil
|
||||||
|
}
|
||||||
|
return nil, errOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type eo struct{}
|
||||||
|
|
||||||
|
func (eo) Error() string { return "exit 1" }
|
||||||
|
|
||||||
|
var errOne = eo{}
|
||||||
|
|
||||||
|
type nop struct{}
|
||||||
|
|
||||||
|
func (nop) Progress(string, int, int, string) {}
|
||||||
|
func (nop) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestZellijPlanAppliesInstallAndConfig(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Write([]byte("keybinds {}\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New(config.ZellijConfig{ConfigURL: srv.URL}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nop{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(filepath.Join(home, ".config", "zellij", "config.kdl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config not written: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "keybinds {}\n" {
|
||||||
|
t.Fatalf("unexpected content %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZellijInstalledPresentEmptyPlan(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"zellij": true}}
|
||||||
|
m := New(config.ZellijConfig{ConfigURL: ""}, platform.NewPacman(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if !p.IsEmpty() {
|
||||||
|
t.Fatalf("want empty plan, got %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
132
internal/modules/zsh/zsh.go
Normal file
132
internal/modules/zsh/zsh.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// Package zsh installs zsh, oh-my-zsh, and configured plugins.
|
||||||
|
package zsh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
cfg config.ZshConfig
|
||||||
|
pm platform.PackageManager
|
||||||
|
git platform.GitClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.ZshConfig, pm platform.PackageManager, git platform.GitClient) Module {
|
||||||
|
return Module{cfg: cfg, pm: pm, git: git}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
ZshInstalled bool
|
||||||
|
OMZInstalled bool
|
||||||
|
MissingPlugins []config.ZshPlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
func omzHome() (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, ".oh-my-zsh"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "zsh", Name: "Zsh + oh-my-zsh", Description: "Install zsh, oh-my-zsh, and plugins."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
var s state
|
||||||
|
ok, err := m.pm.IsInstalled(ctx, "zsh")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.ZshInstalled = ok
|
||||||
|
omz, err := omzHome()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(omz); err == nil {
|
||||||
|
s.OMZInstalled = true
|
||||||
|
}
|
||||||
|
for _, p := range m.cfg.Plugins {
|
||||||
|
path := filepath.Join(omz, "custom", "plugins", p.Name, ".git")
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
s.MissingPlugins = append(s.MissingPlugins, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
var actions []module.Action
|
||||||
|
if !s.ZshInstalled {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "pacman install zsh",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zsh"}) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !s.OMZInstalled {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "clone oh-my-zsh from " + m.cfg.OhMyZshRepo,
|
||||||
|
Kind: module.ActionGitClone,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
omz, err := omzHome()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.git.EnsureRepo(ctx, m.cfg.OhMyZshRepo, omz)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, p := range s.MissingPlugins {
|
||||||
|
p := p // capture
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "clone plugin " + p.Name,
|
||||||
|
Kind: module.ActionGitClone,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
omz, err := omzHome()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.git.EnsureRepo(ctx, p.Repo, filepath.Join(omz, "custom", "plugins", p.Name))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return module.Plan{Actions: actions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||||
|
for i, a := range p.Actions {
|
||||||
|
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||||
|
if err := a.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if !s.ZshInstalled {
|
||||||
|
return fmt.Errorf("zsh not installed")
|
||||||
|
}
|
||||||
|
if !s.OMZInstalled {
|
||||||
|
return fmt.Errorf("oh-my-zsh not installed")
|
||||||
|
}
|
||||||
|
if len(s.MissingPlugins) > 0 {
|
||||||
|
return fmt.Errorf("plugins missing: %v", s.MissingPlugins)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
117
internal/modules/zsh/zsh_test.go
Normal file
117
internal/modules/zsh/zsh_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package zsh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
installed map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte("ok"), nil
|
||||||
|
}
|
||||||
|
return nil, errOne
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type eo struct{}
|
||||||
|
|
||||||
|
func (eo) Error() string { return "exit 1" }
|
||||||
|
|
||||||
|
var errOne = eo{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func TestZshDetectAbsent(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New(config.ZshConfig{
|
||||||
|
OhMyZshRepo: "https://x/omz.git",
|
||||||
|
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
|
||||||
|
}, platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
s := st.(state)
|
||||||
|
if s.ZshInstalled {
|
||||||
|
t.Error("want zsh absent")
|
||||||
|
}
|
||||||
|
if s.OMZInstalled {
|
||||||
|
t.Error("want omz absent")
|
||||||
|
}
|
||||||
|
if len(s.MissingPlugins) != 1 {
|
||||||
|
t.Errorf("want 1 plugin missing, got %v", s.MissingPlugins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZshDetectAllPresent(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0o755)
|
||||||
|
os.MkdirAll(filepath.Join(home, ".oh-my-zsh", "custom", "plugins", "zsh-autosuggestions", ".git"), 0o755)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"zsh": true}}
|
||||||
|
m := New(config.ZshConfig{
|
||||||
|
OhMyZshRepo: "https://x/omz.git",
|
||||||
|
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
|
||||||
|
}, platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
s := st.(state)
|
||||||
|
if !s.ZshInstalled || !s.OMZInstalled || len(s.MissingPlugins) != 0 {
|
||||||
|
t.Fatalf("want all present, got %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZshPlanApplyInstallsEverything(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New(config.ZshConfig{
|
||||||
|
OhMyZshRepo: "https://x/omz.git",
|
||||||
|
Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}},
|
||||||
|
}, platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if len(p.Actions) == 0 {
|
||||||
|
t.Fatal("want non-empty plan")
|
||||||
|
}
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawPacman, sawOMZ, sawPlugin := false, false, false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" {
|
||||||
|
sawPacman = true
|
||||||
|
}
|
||||||
|
if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/omz.git" {
|
||||||
|
sawOMZ = true
|
||||||
|
}
|
||||||
|
if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/a.git" {
|
||||||
|
sawPlugin = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawPacman || !sawOMZ || !sawPlugin {
|
||||||
|
t.Fatalf("missing step; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
57
internal/platform/aur.go
Normal file
57
internal/platform/aur.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AURHelper abstracts an AUR front-end (currently only yay).
|
||||||
|
type AURHelper interface {
|
||||||
|
EnsureInstalled(ctx context.Context) error
|
||||||
|
Install(ctx context.Context, pkgs []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Yay struct {
|
||||||
|
Runner Runner
|
||||||
|
pm *Pacman
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewYay(r Runner) *Yay { return &Yay{Runner: r, pm: NewPacman(r)} }
|
||||||
|
|
||||||
|
func (y *Yay) EnsureInstalled(ctx context.Context) error {
|
||||||
|
ok, _ := y.pm.IsInstalled(ctx, "yay")
|
||||||
|
if ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Bootstrap: install build deps, clone AUR yay, makepkg -si.
|
||||||
|
if err := y.pm.Install(ctx, []string{"base-devel", "git"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp, err := os.MkdirTemp("", "archduino-yay-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmp)
|
||||||
|
clone := filepath.Join(tmp, "yay")
|
||||||
|
if err := y.Runner.Run(ctx, "git", "clone", "https://aur.archlinux.org/yay.git", clone); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// `makepkg -si --noconfirm` needs to cd into the cloned dir. os/exec does
|
||||||
|
// not have a per-command cwd without exec.Cmd; our Runner interface does
|
||||||
|
// not expose that, so we invoke via sh -c with a pushd-style prefix.
|
||||||
|
return y.Runner.Run(ctx, "sh", "-c", "cd "+shellQuote(clone)+" && makepkg -si --noconfirm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *Yay) Install(ctx context.Context, pkgs []string) error {
|
||||||
|
if len(pkgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args := append([]string{"-S", "--needed", "--noconfirm"}, pkgs...)
|
||||||
|
return y.Runner.Run(ctx, "yay", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
89
internal/platform/aur_test.go
Normal file
89
internal/platform/aur_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type aurFakeRunner struct {
|
||||||
|
runCalls [][]string
|
||||||
|
outCalls [][]string
|
||||||
|
// yayInstalled toggles what `pacman -Q yay` returns.
|
||||||
|
yayInstalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *aurFakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *aurFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" && args[1] == "yay" {
|
||||||
|
if f.yayInstalled {
|
||||||
|
return []byte("yay 12.0-1\n"), nil
|
||||||
|
}
|
||||||
|
return nil, &fakeExitErr{}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeExitErr struct{}
|
||||||
|
|
||||||
|
func (fakeExitErr) Error() string { return "exit 1" }
|
||||||
|
|
||||||
|
func TestAUREnsureInstalledSkipsWhenYayPresent(t *testing.T) {
|
||||||
|
r := &aurFakeRunner{yayInstalled: true}
|
||||||
|
y := NewYay(r)
|
||||||
|
if err := y.EnsureInstalled(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.runCalls) != 0 {
|
||||||
|
t.Fatalf("no installs expected, got %v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAUREnsureInstalledBootstrapsYay(t *testing.T) {
|
||||||
|
r := &aurFakeRunner{yayInstalled: false}
|
||||||
|
y := NewYay(r)
|
||||||
|
if err := y.EnsureInstalled(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Expect: pacman install base-devel+git, git clone yay, makepkg -si in that dir.
|
||||||
|
sawPacman := false
|
||||||
|
sawClone := false
|
||||||
|
sawMakepkg := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if len(c) >= 2 && c[0] == "sudo" && c[1] == "pacman" {
|
||||||
|
sawPacman = true
|
||||||
|
}
|
||||||
|
if len(c) >= 2 && c[0] == "git" && c[1] == "clone" {
|
||||||
|
sawClone = true
|
||||||
|
}
|
||||||
|
if len(c) >= 3 && c[0] == "sh" && c[1] == "-c" && strings.Contains(c[2], "makepkg") {
|
||||||
|
sawMakepkg = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawPacman || !sawClone || !sawMakepkg {
|
||||||
|
t.Fatalf("missing bootstrap step; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAURInstall(t *testing.T) {
|
||||||
|
r := &aurFakeRunner{yayInstalled: true}
|
||||||
|
y := NewYay(r)
|
||||||
|
if err := y.Install(context.Background(), []string{"ttf-jetbrains-mono-nerd"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.runCalls) == 0 {
|
||||||
|
t.Fatal("expected yay install call")
|
||||||
|
}
|
||||||
|
c := r.runCalls[len(r.runCalls)-1]
|
||||||
|
want := []string{"yay", "-S", "--needed", "--noconfirm", "ttf-jetbrains-mono-nerd"}
|
||||||
|
for i, w := range want {
|
||||||
|
if i >= len(c) || c[i] != w {
|
||||||
|
t.Fatalf("call = %v, want %v", c, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
internal/platform/download.go
Normal file
42
internal/platform/download.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Download fetches url into destPath atomically (temp file + rename) with
|
||||||
|
// the given file mode. Any non-2xx response returns an error and leaves
|
||||||
|
// no file behind.
|
||||||
|
func Download(ctx context.Context, url, destPath string, mode os.FileMode) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("download %s: http %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
tmp := destPath + ".tmp"
|
||||||
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, destPath)
|
||||||
|
}
|
||||||
45
internal/platform/download_test.go
Normal file
45
internal/platform/download_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDownloadHappyPath(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("payload"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "out.bin")
|
||||||
|
if err := Download(context.Background(), srv.URL, dest, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(dest)
|
||||||
|
if string(got) != "payload" {
|
||||||
|
t.Fatalf("got %q want payload", got)
|
||||||
|
}
|
||||||
|
info, _ := os.Stat(dest)
|
||||||
|
if info.Mode().Perm() != 0o755 {
|
||||||
|
t.Fatalf("mode=%v want 0755", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownload404Errors(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "nope", http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "out.bin")
|
||||||
|
if err := Download(context.Background(), srv.URL, dest, 0o644); err == nil {
|
||||||
|
t.Fatal("want error on 404")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("dest should not exist after failed download")
|
||||||
|
}
|
||||||
|
}
|
||||||
114
internal/platform/extract.go
Normal file
114
internal/platform/extract.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func safeJoin(dest, name string) (string, error) {
|
||||||
|
p := filepath.Join(dest, name)
|
||||||
|
cleanDest := filepath.Clean(dest) + string(os.PathSeparator)
|
||||||
|
if !strings.HasPrefix(filepath.Clean(p)+string(os.PathSeparator), cleanDest) {
|
||||||
|
return "", fmt.Errorf("unsafe path %q escapes %q", name, dest)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractTarGz(ctx context.Context, archivePath, destDir string) error {
|
||||||
|
f, err := os.Open(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
gz, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target, err := safeJoin(destDir, h.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch h.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(target, os.FileMode(h.Mode)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(w, tr); err != nil {
|
||||||
|
w.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractZip(ctx context.Context, archivePath, destDir string) error {
|
||||||
|
zr, err := zip.OpenReader(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
for _, zf := range zr.File {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target, err := safeJoin(destDir, zf.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if zf.FileInfo().IsDir() {
|
||||||
|
if err := os.MkdirAll(target, zf.Mode()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, zf.Mode())
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, rc)
|
||||||
|
rc.Close()
|
||||||
|
w.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
80
internal/platform/extract_test.go
Normal file
80
internal/platform/extract_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeTarGz(t *testing.T, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "a.tar.gz")
|
||||||
|
f, err := os.Create(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gz := gzip.NewWriter(f)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
for name, body := range entries {
|
||||||
|
tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))})
|
||||||
|
tw.Write([]byte(body))
|
||||||
|
}
|
||||||
|
tw.Close()
|
||||||
|
gz.Close()
|
||||||
|
f.Close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeZip(t *testing.T, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "a.zip")
|
||||||
|
f, _ := os.Create(p)
|
||||||
|
zw := zip.NewWriter(f)
|
||||||
|
for name, body := range entries {
|
||||||
|
w, _ := zw.Create(name)
|
||||||
|
w.Write([]byte(body))
|
||||||
|
}
|
||||||
|
zw.Close()
|
||||||
|
f.Close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTarGz(t *testing.T) {
|
||||||
|
src := makeTarGz(t, map[string]string{"hello.txt": "world", "sub/x.txt": "y"})
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := ExtractTarGz(context.Background(), src, dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dest, "hello.txt"))
|
||||||
|
if string(got) != "world" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
got, _ = os.ReadFile(filepath.Join(dest, "sub", "x.txt"))
|
||||||
|
if string(got) != "y" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractZip(t *testing.T) {
|
||||||
|
src := makeZip(t, map[string]string{"a.txt": "A"})
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := ExtractZip(context.Background(), src, dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dest, "a.txt"))
|
||||||
|
if string(got) != "A" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsPathTraversal(t *testing.T) {
|
||||||
|
src := makeTarGz(t, map[string]string{"../evil.txt": "x"})
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := ExtractTarGz(context.Background(), src, dest); err == nil {
|
||||||
|
t.Fatal("want error on path traversal")
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/platform/git.go
Normal file
23
internal/platform/git.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitClient wraps `git` via a Runner.
|
||||||
|
type GitClient struct{ Runner Runner }
|
||||||
|
|
||||||
|
// Clone always runs `git clone --depth=1 url dest`.
|
||||||
|
func (g GitClient) Clone(ctx context.Context, url, dest string) error {
|
||||||
|
return g.Runner.Run(ctx, "git", "clone", "--depth=1", url, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRepo clones url into dest if dest/.git is missing; otherwise noop.
|
||||||
|
func (g GitClient) EnsureRepo(ctx context.Context, url, dest string) error {
|
||||||
|
if _, err := os.Stat(filepath.Join(dest, ".git")); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return g.Clone(ctx, url, dest)
|
||||||
|
}
|
||||||
55
internal/platform/git_test.go
Normal file
55
internal/platform/git_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gitFakeRunner struct {
|
||||||
|
calls [][]string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *gitFakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.calls = append(f.calls, append([]string{name}, args...))
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
func (f *gitFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitEnsureRepoClonesWhenMissing(t *testing.T) {
|
||||||
|
r := &gitFakeRunner{}
|
||||||
|
g := GitClient{Runner: r}
|
||||||
|
dest := filepath.Join(t.TempDir(), "repo")
|
||||||
|
if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.calls) != 1 {
|
||||||
|
t.Fatalf("want 1 call, got %d: %v", len(r.calls), r.calls)
|
||||||
|
}
|
||||||
|
got := r.calls[0]
|
||||||
|
want := []string{"git", "clone", "--depth=1", "https://x/y.git", dest}
|
||||||
|
for i := range want {
|
||||||
|
if i >= len(got) || got[i] != want[i] {
|
||||||
|
t.Fatalf("call = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitEnsureRepoSkipsWhenPresent(t *testing.T) {
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dest, ".git"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := &gitFakeRunner{}
|
||||||
|
g := GitClient{Runner: r}
|
||||||
|
if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.calls) != 0 {
|
||||||
|
t.Fatalf("expected no calls, got %v", r.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/platform/textblock.go
Normal file
78
internal/platform/textblock.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func blockMarkers(marker string) (string, string) {
|
||||||
|
return fmt.Sprintf("# >>> %s >>>", marker), fmt.Sprintf("# <<< %s <<<", marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureBlock writes a marker-delimited block into path. If the file does
|
||||||
|
// not exist it is created. If a block with the same marker already exists
|
||||||
|
// it is replaced in place. Surrounding content is preserved.
|
||||||
|
func EnsureBlock(path, marker, content string) error {
|
||||||
|
start, end := blockMarkers(marker)
|
||||||
|
existing, err := os.ReadFile(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
block := start + "\n" + strings.TrimRight(content, "\n") + "\n" + end + "\n"
|
||||||
|
|
||||||
|
text := string(existing)
|
||||||
|
si := strings.Index(text, start)
|
||||||
|
ei := strings.Index(text, end)
|
||||||
|
if si >= 0 && ei > si {
|
||||||
|
endPos := ei + len(end)
|
||||||
|
if endPos < len(text) && text[endPos] == '\n' {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
text = text[:si] + block + text[endPos:]
|
||||||
|
} else {
|
||||||
|
if text != "" && !strings.HasSuffix(text, "\n") {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += block
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(text), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasBlock reports whether path contains a block with the given marker.
|
||||||
|
// A missing file yields (false, nil).
|
||||||
|
func HasBlock(path, marker string) (bool, error) {
|
||||||
|
start, _ := blockMarkers(marker)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return strings.Contains(string(data), start), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveBlock deletes a marker-delimited block if present.
|
||||||
|
func RemoveBlock(path, marker string) error {
|
||||||
|
start, end := blockMarkers(marker)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
si := strings.Index(text, start)
|
||||||
|
ei := strings.Index(text, end)
|
||||||
|
if si < 0 || ei <= si {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
endPos := ei + len(end)
|
||||||
|
if endPos < len(text) && text[endPos] == '\n' {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
text = text[:si] + text[endPos:]
|
||||||
|
return os.WriteFile(path, []byte(text), 0o644)
|
||||||
|
}
|
||||||
88
internal/platform/textblock_test.go
Normal file
88
internal/platform/textblock_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureBlockInsertsIntoMissingFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
if err := EnsureBlock(p, "archduino", "export FOO=1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(p)
|
||||||
|
want := "# >>> archduino >>>\nexport FOO=1\n# <<< archduino <<<\n"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureBlockReplacesExisting(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
initial := "alias ll='ls -l'\n# >>> archduino >>>\nold content\n# <<< archduino <<<\necho tail\n"
|
||||||
|
if err := os.WriteFile(p, []byte(initial), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := EnsureBlock(p, "archduino", "new content"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(p)
|
||||||
|
want := "alias ll='ls -l'\n# >>> archduino >>>\nnew content\n# <<< archduino <<<\necho tail\n"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureBlockIdempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
_ = EnsureBlock(p, "a", "x")
|
||||||
|
before, _ := os.ReadFile(p)
|
||||||
|
_ = EnsureBlock(p, "a", "x")
|
||||||
|
after, _ := os.ReadFile(p)
|
||||||
|
if string(before) != string(after) {
|
||||||
|
t.Fatalf("not idempotent: %q vs %q", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasBlockTrueFalse(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
has, err := HasBlock(p, "a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
t.Fatal("missing file should report has=false")
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(p, []byte("no blocks here\n"), 0o644)
|
||||||
|
has, _ = HasBlock(p, "a")
|
||||||
|
if has {
|
||||||
|
t.Fatal("unblocked file should report has=false")
|
||||||
|
}
|
||||||
|
_ = EnsureBlock(p, "a", "x")
|
||||||
|
has, _ = HasBlock(p, "a")
|
||||||
|
if !has {
|
||||||
|
t.Fatal("blocked file should report has=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveBlock(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
_ = os.WriteFile(p, []byte("head\n# >>> a >>>\nmid\n# <<< a <<<\ntail\n"), 0o644)
|
||||||
|
if err := RemoveBlock(p, "a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(p)
|
||||||
|
if strings.Contains(string(got), ">>> a >>>") {
|
||||||
|
t.Fatalf("block not removed: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(got), "head") || !strings.Contains(string(got), "tail") {
|
||||||
|
t.Fatalf("surrounding content lost: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu
|
|||||||
selectedModules := defaults
|
selectedModules := defaults
|
||||||
parallelismStr := fmt.Sprintf("%d", s.Parallelism)
|
parallelismStr := fmt.Sprintf("%d", s.Parallelism)
|
||||||
var proceed bool
|
var proceed bool
|
||||||
|
cache := newPlanCache()
|
||||||
|
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
huh.NewGroup(huh.NewNote().
|
huh.NewGroup(huh.NewNote().
|
||||||
@@ -74,7 +75,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu
|
|||||||
Title("Review plan").
|
Title("Review plan").
|
||||||
DescriptionFunc(func() string {
|
DescriptionFunc(func() string {
|
||||||
s.Modules = syncSelected(s.Modules, selectedModules)
|
s.Modules = syncSelected(s.Modules, selectedModules)
|
||||||
return renderPlan(ctx, reg, s.SelectedIDs())
|
return renderPlanCached(ctx, reg, s.SelectedIDs(), cache)
|
||||||
}, &selectedModules)),
|
}, &selectedModules)),
|
||||||
|
|
||||||
huh.NewGroup(huh.NewConfirm().
|
huh.NewGroup(huh.NewConfirm().
|
||||||
@@ -115,38 +116,75 @@ func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderPlan runs Detect + Plan for every selected module and renders a
|
// planCache memoises rendered Detect+Plan output per module ID so
|
||||||
// human-readable summary. A failing Detect/Plan is shown inline but does
|
// DescriptionFunc (which fires on every redraw) does not re-run
|
||||||
// not abort the wizard.
|
// potentially expensive Detect/Plan calls. Entries are evicted when a
|
||||||
func renderPlan(ctx context.Context, reg *module.Registry, selectedIDs []string) string {
|
// module leaves the current selection.
|
||||||
|
type planCacheEntry struct{ out string }
|
||||||
|
type planCache struct{ entries map[string]planCacheEntry }
|
||||||
|
|
||||||
|
func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} }
|
||||||
|
|
||||||
|
func (c *planCache) get(id string) (string, bool) {
|
||||||
|
e, ok := c.entries[id]
|
||||||
|
return e.out, ok
|
||||||
|
}
|
||||||
|
func (c *planCache) put(id, out string) { c.entries[id] = planCacheEntry{out: out} }
|
||||||
|
func (c *planCache) prune(keepIDs []string) {
|
||||||
|
keep := map[string]bool{}
|
||||||
|
for _, id := range keepIDs {
|
||||||
|
keep[id] = true
|
||||||
|
}
|
||||||
|
for k := range c.entries {
|
||||||
|
if !keep[k] {
|
||||||
|
delete(c.entries, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPlanCached is the cached equivalent of running Detect+Plan for
|
||||||
|
// every selected module and joining the rendered lines. IDs no longer in
|
||||||
|
// the selection are evicted so a deselect+reselect re-runs Detect.
|
||||||
|
func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache) string {
|
||||||
|
cache.prune(selectedIDs)
|
||||||
if len(selectedIDs) == 0 {
|
if len(selectedIDs) == 0 {
|
||||||
return "No modules selected."
|
return "No modules selected."
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, id := range selectedIDs {
|
for _, id := range selectedIDs {
|
||||||
m, ok := reg.Get(id)
|
if cached, ok := cache.get(id); ok {
|
||||||
if !ok {
|
b.WriteString(cached)
|
||||||
fmt.Fprintf(&b, "• %s — (unknown)\n", id)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
state, err := m.Detect(ctx)
|
line := renderOne(ctx, reg, id)
|
||||||
if err != nil {
|
cache.put(id, line)
|
||||||
fmt.Fprintf(&b, "• %s — detect failed: %v\n", id, err)
|
b.WriteString(line)
|
||||||
continue
|
}
|
||||||
}
|
return b.String()
|
||||||
plan, err := m.Plan(ctx, state)
|
}
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(&b, "• %s — plan failed: %v\n", id, err)
|
// renderOne runs Detect+Plan for a single module and returns a rendered
|
||||||
continue
|
// line. A failing Detect/Plan is shown inline but does not abort.
|
||||||
}
|
func renderOne(ctx context.Context, reg *module.Registry, id string) string {
|
||||||
if plan.IsEmpty() {
|
m, ok := reg.Get(id)
|
||||||
fmt.Fprintf(&b, "• %s — already up to date\n", id)
|
if !ok {
|
||||||
continue
|
return fmt.Sprintf("• %s — (unknown)\n", id)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "• %s\n", id)
|
state, err := m.Detect(ctx)
|
||||||
for _, a := range plan.Actions {
|
if err != nil {
|
||||||
fmt.Fprintf(&b, " - %s\n", a.Description)
|
return fmt.Sprintf("• %s — detect failed: %v\n", id, err)
|
||||||
}
|
}
|
||||||
|
plan, err := m.Plan(ctx, state)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("• %s — plan failed: %v\n", id, err)
|
||||||
|
}
|
||||||
|
if plan.IsEmpty() {
|
||||||
|
return fmt.Sprintf("• %s — already up to date\n", id)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "• %s\n", id)
|
||||||
|
for _, a := range plan.Actions {
|
||||||
|
fmt.Fprintf(&b, " - %s\n", a.Description)
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ func TestSyncSelected(t *testing.T) {
|
|||||||
func TestRenderPlanWithHello(t *testing.T) {
|
func TestRenderPlanWithHello(t *testing.T) {
|
||||||
reg := module.NewRegistry()
|
reg := module.NewRegistry()
|
||||||
_ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"})
|
_ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"})
|
||||||
out := renderPlan(context.Background(), reg, []string{"hello"})
|
out := renderPlanCached(context.Background(), reg, []string{"hello"}, newPlanCache())
|
||||||
if !strings.Contains(out, "hello") {
|
if !strings.Contains(out, "hello") {
|
||||||
t.Fatalf("output missing hello: %q", out)
|
t.Fatalf("output missing hello: %q", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderPlanEmpty(t *testing.T) {
|
func TestRenderPlanEmpty(t *testing.T) {
|
||||||
if got := renderPlan(context.Background(), module.NewRegistry(), nil); got != "No modules selected." {
|
if got := renderPlanCached(context.Background(), module.NewRegistry(), nil, newPlanCache()); got != "No modules selected." {
|
||||||
t.Fatalf("got %q", got)
|
t.Fatalf("got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,65 @@ func TestParseIntClamp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderPlanCachesDetectAndPlan(t *testing.T) {
|
||||||
|
var detects, plans int
|
||||||
|
m := countingModule{
|
||||||
|
meta: module.Meta{ID: "count", Name: "Count"},
|
||||||
|
onDetect: func() { detects++ },
|
||||||
|
onPlan: func() { plans++ },
|
||||||
|
}
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
if err := reg.Register(m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cache := newPlanCache()
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||||
|
if detects != 1 || plans != 1 {
|
||||||
|
t.Fatalf("want 1 detect and 1 plan, got detects=%d plans=%d", detects, plans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPlanInvalidatesOnSelectionChange(t *testing.T) {
|
||||||
|
var detects int
|
||||||
|
m := countingModule{
|
||||||
|
meta: module.Meta{ID: "count", Name: "Count"},
|
||||||
|
onDetect: func() { detects++ },
|
||||||
|
}
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
_ = reg.Register(m)
|
||||||
|
cache := newPlanCache()
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{}, cache)
|
||||||
|
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||||
|
if detects != 2 {
|
||||||
|
t.Fatalf("deselect+reselect should re-detect; detects=%d (want 2)", detects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingModule struct {
|
||||||
|
meta module.Meta
|
||||||
|
onDetect func()
|
||||||
|
onPlan func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingModule) Meta() module.Meta { return c.meta }
|
||||||
|
func (c countingModule) Detect(context.Context) (module.State, error) {
|
||||||
|
if c.onDetect != nil {
|
||||||
|
c.onDetect()
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (c countingModule) Plan(context.Context, module.State) (module.Plan, error) {
|
||||||
|
if c.onPlan != nil {
|
||||||
|
c.onPlan()
|
||||||
|
}
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
func (c countingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
||||||
|
func (c countingModule) Verify(context.Context) error { return nil }
|
||||||
|
|
||||||
func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) {
|
func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|||||||
Reference in New Issue
Block a user