Compare commits
2 Commits
feat/go-re
...
32ab9e03b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32ab9e03b2 | ||
|
|
edbbf47e9f |
@@ -1,52 +0,0 @@
|
|||||||
name: ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: archlinux:latest
|
|
||||||
steps:
|
|
||||||
- name: Install container prereqs
|
|
||||||
run: pacman -Sy --needed --noconfirm base-devel git shellcheck
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: go vet
|
|
||||||
run: go vet ./...
|
|
||||||
|
|
||||||
- name: gofmt check
|
|
||||||
run: |
|
|
||||||
diff=$(gofmt -l .)
|
|
||||||
if [ -n "$diff" ]; then
|
|
||||||
echo "files need gofmt:"
|
|
||||||
echo "$diff"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: go build
|
|
||||||
run: go build ./...
|
|
||||||
|
|
||||||
- name: go test -race
|
|
||||||
run: go test -race ./...
|
|
||||||
|
|
||||||
- name: shellcheck bootstrap.sh
|
|
||||||
run: shellcheck bootstrap.sh
|
|
||||||
|
|
||||||
- name: Install goreleaser
|
|
||||||
run: go install github.com/goreleaser/goreleaser/v2@latest
|
|
||||||
|
|
||||||
- name: goreleaser check
|
|
||||||
run: $(go env GOPATH)/bin/goreleaser check
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
name: release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
goreleaser:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Run goreleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v6
|
|
||||||
with:
|
|
||||||
distribution: goreleaser
|
|
||||||
version: '~> v2'
|
|
||||||
args: release --clean
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,16 +1,2 @@
|
|||||||
.idea
|
.idea
|
||||||
WokwiKey.txt
|
WokwiKey.txt
|
||||||
|
|
||||||
# Go
|
|
||||||
/archduino
|
|
||||||
/dist/
|
|
||||||
*.test
|
|
||||||
*.out
|
|
||||||
coverage.*
|
|
||||||
.worktrees/
|
|
||||||
|
|
||||||
# goreleaser output
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# generated shell completions
|
|
||||||
completions/
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
version: 2
|
|
||||||
|
|
||||||
project_name: archduino
|
|
||||||
|
|
||||||
before:
|
|
||||||
hooks:
|
|
||||||
- mkdir -p completions
|
|
||||||
- sh -c "go run ./cmd/archduino completion bash > completions/archduino.bash"
|
|
||||||
- sh -c "go run ./cmd/archduino completion zsh > completions/_archduino"
|
|
||||||
- sh -c "go run ./cmd/archduino completion fish > completions/archduino.fish"
|
|
||||||
|
|
||||||
builds:
|
|
||||||
- id: archduino
|
|
||||||
main: ./cmd/archduino
|
|
||||||
binary: archduino
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=0
|
|
||||||
goos:
|
|
||||||
- linux
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
- arm64
|
|
||||||
ldflags:
|
|
||||||
- -s -w
|
|
||||||
- -X main.version={{ .Version }}
|
|
||||||
- -X main.commit={{ .Commit }}
|
|
||||||
- -X main.date={{ .Date }}
|
|
||||||
|
|
||||||
archives:
|
|
||||||
- id: default
|
|
||||||
name_template: "archduino_{{ .Version }}_linux_{{ .Arch }}"
|
|
||||||
formats: [tar.gz]
|
|
||||||
files:
|
|
||||||
- README.md
|
|
||||||
- LICENSE
|
|
||||||
- src: completions/archduino.bash
|
|
||||||
dst: completions/archduino.bash
|
|
||||||
- src: completions/_archduino
|
|
||||||
dst: completions/_archduino
|
|
||||||
- src: completions/archduino.fish
|
|
||||||
dst: completions/archduino.fish
|
|
||||||
|
|
||||||
checksum:
|
|
||||||
name_template: "checksums.txt"
|
|
||||||
algorithm: sha256
|
|
||||||
|
|
||||||
changelog:
|
|
||||||
use: git
|
|
||||||
sort: asc
|
|
||||||
filters:
|
|
||||||
exclude:
|
|
||||||
- '^chore:'
|
|
||||||
- '^docs:'
|
|
||||||
- '^test:'
|
|
||||||
- '^ci:'
|
|
||||||
- '^style:'
|
|
||||||
- '^refactor:'
|
|
||||||
- 'Merge pull request'
|
|
||||||
- 'Merge branch'
|
|
||||||
groups:
|
|
||||||
- title: Features
|
|
||||||
regexp: '^feat(\(.*\))?:'
|
|
||||||
order: 0
|
|
||||||
- title: Bug fixes
|
|
||||||
regexp: '^(fix|perf)(\(.*\))?:'
|
|
||||||
order: 1
|
|
||||||
- title: Others
|
|
||||||
order: 999
|
|
||||||
|
|
||||||
gitea_urls:
|
|
||||||
api: https://gitlab.hive-brain.de/api/v1
|
|
||||||
download: https://gitlab.hive-brain.de
|
|
||||||
|
|
||||||
release:
|
|
||||||
draft: false
|
|
||||||
prerelease: auto
|
|
||||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 MDietermann
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
39
Makefile
39
Makefile
@@ -1,39 +0,0 @@
|
|||||||
.PHONY: build snapshot release completions clean help
|
|
||||||
|
|
||||||
GO ?= go
|
|
||||||
BIN := archduino
|
|
||||||
|
|
||||||
help: ## Show this help
|
|
||||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-14s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
|
||||||
|
|
||||||
build: ## Local dev build into ./archduino
|
|
||||||
$(GO) build -o $(BIN) ./cmd/archduino
|
|
||||||
|
|
||||||
completions: ## Regenerate shell completions into completions/
|
|
||||||
@mkdir -p completions
|
|
||||||
$(GO) run ./cmd/archduino completion bash > completions/archduino.bash
|
|
||||||
$(GO) run ./cmd/archduino completion zsh > completions/_archduino
|
|
||||||
$(GO) run ./cmd/archduino completion fish > completions/archduino.fish
|
|
||||||
|
|
||||||
snapshot: ## Local goreleaser dry run into dist/
|
|
||||||
goreleaser release --snapshot --clean
|
|
||||||
|
|
||||||
clean: ## Remove build artifacts
|
|
||||||
rm -rf dist/ completions/ $(BIN)
|
|
||||||
|
|
||||||
release: ## Compute next SemVer from commits, tag, and push
|
|
||||||
@command -v svu >/dev/null || { echo "svu not found. Run: go install github.com/caarlos0/svu@latest"; exit 1; }
|
|
||||||
@test -z "$$(git status --porcelain)" || { echo "working tree is dirty; commit or stash first"; exit 1; }
|
|
||||||
@branch=$$(git rev-parse --abbrev-ref HEAD); \
|
|
||||||
if [ "$$branch" != "master" ]; then echo "must be on master (currently on $$branch)"; exit 1; fi
|
|
||||||
@current=$$(svu current 2>/dev/null || echo "none"); \
|
|
||||||
next=$$(svu next); \
|
|
||||||
echo "Current: $$current"; \
|
|
||||||
echo "Next: $$next"; \
|
|
||||||
echo; \
|
|
||||||
echo "Commits since $$current:"; \
|
|
||||||
if [ "$$current" = "none" ]; then git log --oneline; else git log --oneline $$current..HEAD; fi; \
|
|
||||||
echo; \
|
|
||||||
printf "Tag %s and push? [y/N] " "$$next"; read ans; \
|
|
||||||
if [ "$$ans" != "y" ] && [ "$$ans" != "Y" ]; then echo "aborted"; exit 1; fi; \
|
|
||||||
git tag "$$next" && git push origin "$$next"
|
|
||||||
40
README.md
40
README.md
@@ -73,35 +73,22 @@ This updates Starship, Zellij, Lazygit, Yazi, and NvChad in one step.
|
|||||||
|
|
||||||
## 🚀 Quickstart
|
## 🚀 Quickstart
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
One-line install on Arch Linux:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://gitlab.hive-brain.de/MDietermann/Archduino/raw/branch/master/bootstrap.sh | bash
|
git clone --depth=1 https://github.com/<your-username>/ArduinoArch.git
|
||||||
|
cd ArduinoArch
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This fetches the latest release, verifies its checksum, installs `archduino`
|
The installer will:
|
||||||
to `/usr/local/bin`, installs shell completions, and launches
|
|
||||||
`archduino install`.
|
|
||||||
|
|
||||||
### Development
|
1. Show a banner with detected OS and package manager
|
||||||
|
2. Let you pick your **colorscheme** (applied to all tools)
|
||||||
|
3. Let you choose between **progress bar** or **verbose** output
|
||||||
|
4. Ask for your sudo password **once** (cached for the entire install)
|
||||||
|
5. Run through all 11 setup steps automatically
|
||||||
|
|
||||||
Local build:
|
After it finishes, start a new shell or run `source ~/.zshrc`.
|
||||||
|
|
||||||
```bash
|
|
||||||
make build
|
|
||||||
./archduino --help
|
|
||||||
```
|
|
||||||
|
|
||||||
Releasing (maintainers only):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go install github.com/caarlos0/svu@latest
|
|
||||||
make release # prompts with computed next version, tags, pushes
|
|
||||||
```
|
|
||||||
|
|
||||||
After install, start a new shell or run `source ~/.zshrc`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -109,10 +96,7 @@ After install, start a new shell or run `source ~/.zshrc`.
|
|||||||
|
|
||||||
```
|
```
|
||||||
ArduinoArch/
|
ArduinoArch/
|
||||||
├── bootstrap.sh # Curl-piped installer shim (fetches latest release)
|
├── install.sh # Main entry point
|
||||||
├── cmd/archduino/ # Go CLI entry point
|
|
||||||
├── internal/ # Go packages (install steps, theming, detect, etc.)
|
|
||||||
├── install.sh.legacy # Deprecated legacy Bash installer (kept for reference)
|
|
||||||
└── lib/
|
└── lib/
|
||||||
├── common.sh # Colors, helpers, environment detection, progress bar
|
├── common.sh # Colors, helpers, environment detection, progress bar
|
||||||
├── themes.sh # Colorscheme selection menu
|
├── themes.sh # Colorscheme selection menu
|
||||||
|
|||||||
65
bootstrap.sh
65
bootstrap.sh
@@ -1,65 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO="MDietermann/Archduino"
|
|
||||||
BASE_URL="${ARCHDUINO_RELEASE_BASE:-https://gitlab.hive-brain.de}"
|
|
||||||
API_URL="${BASE_URL}/api/v1"
|
|
||||||
|
|
||||||
err() { echo "bootstrap: error: $*" >&2; exit 1; }
|
|
||||||
msg() { echo "==> $*"; }
|
|
||||||
|
|
||||||
# Arch gate
|
|
||||||
[ -f /etc/arch-release ] || err "Archduino v1 supports Arch Linux only."
|
|
||||||
|
|
||||||
# Arch detect
|
|
||||||
case "$(uname -m)" in
|
|
||||||
x86_64) ARCH="amd64" ;;
|
|
||||||
aarch64) ARCH="arm64" ;;
|
|
||||||
*) err "unsupported architecture: $(uname -m)" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Prereqs
|
|
||||||
msg "Installing prerequisites (sudo, git, curl, ca-certificates)"
|
|
||||||
sudo pacman -Sy --needed --noconfirm sudo git curl ca-certificates
|
|
||||||
|
|
||||||
# Discover latest release
|
|
||||||
msg "Querying latest release from Gitea"
|
|
||||||
API_ENDPOINT="${API_URL}/repos/${REPO}/releases/latest"
|
|
||||||
RELEASE_JSON=$(curl -fsSL "$API_ENDPOINT") || err "failed to fetch ${API_ENDPOINT}"
|
|
||||||
|
|
||||||
# Extract tag_name without requiring jq
|
|
||||||
TAG=$(printf '%s' "$RELEASE_JSON" | grep -o '"tag_name":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
||||||
[ -n "$TAG" ] || err "could not parse tag_name from release metadata"
|
|
||||||
VERSION="${TAG#v}"
|
|
||||||
msg "Latest release: ${TAG}"
|
|
||||||
|
|
||||||
# Download
|
|
||||||
TMP=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
TARBALL="archduino_${VERSION}_linux_${ARCH}.tar.gz"
|
|
||||||
CHECKSUMS="checksums.txt"
|
|
||||||
DL_BASE="${BASE_URL}/${REPO}/releases/download/${TAG}"
|
|
||||||
|
|
||||||
msg "Downloading ${TARBALL}"
|
|
||||||
curl -fsSL -o "${TMP}/${TARBALL}" "${DL_BASE}/${TARBALL}" || err "tarball download failed"
|
|
||||||
curl -fsSL -o "${TMP}/${CHECKSUMS}" "${DL_BASE}/${CHECKSUMS}" || err "checksums download failed"
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
msg "Verifying checksum"
|
|
||||||
(cd "$TMP" && sha256sum --check --ignore-missing "$CHECKSUMS") || err "checksum verification failed"
|
|
||||||
|
|
||||||
# Extract
|
|
||||||
msg "Extracting"
|
|
||||||
tar -xzf "${TMP}/${TARBALL}" -C "$TMP"
|
|
||||||
|
|
||||||
# Install
|
|
||||||
msg "Installing to /usr/local/bin (sudo)"
|
|
||||||
sudo install -m 0755 "${TMP}/archduino" /usr/local/bin/archduino
|
|
||||||
|
|
||||||
msg "Installing shell completions"
|
|
||||||
sudo install -Dm 0644 "${TMP}/completions/archduino.bash" /usr/share/bash-completion/completions/archduino
|
|
||||||
sudo install -Dm 0644 "${TMP}/completions/_archduino" /usr/share/zsh/site-functions/_archduino
|
|
||||||
sudo install -Dm 0644 "${TMP}/completions/archduino.fish" /usr/share/fish/vendor_completions.d/archduino.fish
|
|
||||||
|
|
||||||
msg "Launching: archduino install $*"
|
|
||||||
exec /usr/local/bin/archduino install "$@"
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/engine"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/arduinocli"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/fonts"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/gittools"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/neovim"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/packages"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/renode"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/shellconfig"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/starship"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/themes"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/yazi"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/zellij"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/modules/zsh"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/privilege"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/state"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/subcommands/doctor"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/subcommands/newproject"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/theming"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/dashboard"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/wizard"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
version = "dev"
|
|
||||||
commit = "none"
|
|
||||||
date = "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
// buildRegistry constructs a module registry populated with all Plan 3a
|
|
||||||
// modules, wired against the provided config and platform dependencies.
|
|
||||||
// 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, themeID string) (*module.Registry, error) {
|
|
||||||
reg := module.NewRegistry()
|
|
||||||
runner := platform.ExecRunner{}
|
|
||||||
pm := platform.NewPacman(runner)
|
|
||||||
yay := platform.NewYay(runner)
|
|
||||||
git := platform.GitClient{Runner: runner}
|
|
||||||
|
|
||||||
mods := []module.Module{
|
|
||||||
packages.New(cfg.Packages.Base, pm),
|
|
||||||
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),
|
|
||||||
neovim.New(cfg.Neovim, themeID, pm, git),
|
|
||||||
renode.New(cfg.Renode, pm, yay),
|
|
||||||
}
|
|
||||||
for _, m := range mods {
|
|
||||||
if err := reg.Register(m); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return reg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
root := &cobra.Command{
|
|
||||||
Use: "archduino",
|
|
||||||
Short: "Archduino installer and toolbox",
|
|
||||||
SilenceUsage: true,
|
|
||||||
SilenceErrors: true,
|
|
||||||
Version: version,
|
|
||||||
}
|
|
||||||
root.AddCommand(newInstallCmd())
|
|
||||||
root.AddCommand(newThemeCmd())
|
|
||||||
root.AddCommand(newNewCmd())
|
|
||||||
root.AddCommand(newDoctorCmd())
|
|
||||||
root.AddCommand(newVersionCmd())
|
|
||||||
|
|
||||||
// Wire SIGINT/SIGTERM to a cancellable context so long-running phases
|
|
||||||
// (engine.Run, sudo keepalive) observe the cancellation promptly.
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
if err := root.ExecuteContext(ctx); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "error:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newInstallCmd() *cobra.Command {
|
|
||||||
var (
|
|
||||||
noTUI bool
|
|
||||||
dryRun bool
|
|
||||||
halt bool
|
|
||||||
noSudo bool
|
|
||||||
force bool
|
|
||||||
only []string
|
|
||||||
skip []string
|
|
||||||
overlayPath string
|
|
||||||
)
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "install",
|
|
||||||
Short: "Install or reconcile the Archduino toolset",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
ctx := cmd.Context()
|
|
||||||
if ctx == nil {
|
|
||||||
ctx = context.Background()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gate: v1 supports Arch only. Non-Arch hosts would silently run
|
|
||||||
// the hello module today but fail on Plan 3's pacman-dependent
|
|
||||||
// modules — fail fast with a clear message instead.
|
|
||||||
if !noSudo { // --no-sudo implies a test/container context; skip the gate
|
|
||||||
p, derr := platform.Detect()
|
|
||||||
if derr != nil {
|
|
||||||
return fmt.Errorf("platform detect: %w", derr)
|
|
||||||
}
|
|
||||||
if p.Distro != platform.DistroArch {
|
|
||||||
return fmt.Errorf("archduino requires Arch Linux (detected distro: %s)", p.Distro)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := config.Load(overlayPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
reg, err := buildRegistry(cfg, "")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build registry: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
selected, err := resolveSelection(reg, cfg, only, skip)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acquire sudo unless dry-run or explicitly skipped.
|
|
||||||
if !dryRun && !noSudo {
|
|
||||||
k, err := privilege.Acquire(ctx, privilege.ExecSudo{}, 50*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer k.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
started := time.Now().UTC()
|
|
||||||
var (
|
|
||||||
sum engine.Summary
|
|
||||||
runErr error
|
|
||||||
selectedTheme = cfg.ThemeOverrides["__active"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if noTUI || dryRun {
|
|
||||||
// Plain-output path: preserve the Plan 1 behaviour. Dry-run
|
|
||||||
// always uses plain output because the dashboard has nothing
|
|
||||||
// to animate when no Apply happens.
|
|
||||||
reporter := engine.NewPlainReporter(os.Stdout)
|
|
||||||
sum, runErr = engine.Run(ctx, reg, engine.RunOptions{
|
|
||||||
Selected: selected,
|
|
||||||
DryRun: dryRun,
|
|
||||||
HaltOnError: halt,
|
|
||||||
Force: force,
|
|
||||||
Reporter: reporter,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// TUI path: wizard → dashboard. If the user does not confirm,
|
|
||||||
// we exit cleanly with a zero-summary and no error.
|
|
||||||
initial := tui.NewWizardState(cfg, reg)
|
|
||||||
initial.Force = force
|
|
||||||
// Pre-filter the wizard's module list by --only/--skip so the
|
|
||||||
// user cannot accidentally widen selection in the TUI beyond
|
|
||||||
// what they asked for on the CLI.
|
|
||||||
if len(selected) < len(initial.Modules) {
|
|
||||||
wanted := map[string]bool{}
|
|
||||||
for _, id := range selected {
|
|
||||||
wanted[id] = true
|
|
||||||
}
|
|
||||||
for i := range initial.Modules {
|
|
||||||
initial.Modules[i].Selected = wanted[initial.Modules[i].ID]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
finalState, confirmed, werr := wizard.Run(ctx, reg, initial)
|
|
||||||
if werr != nil {
|
|
||||||
return werr
|
|
||||||
}
|
|
||||||
if !confirmed {
|
|
||||||
fmt.Fprintln(os.Stdout, "installation cancelled")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Rebuild the registry with the theme the user chose in the
|
|
||||||
// wizard so theme-aware modules (e.g. neovim in Task 7) pick
|
|
||||||
// up the selection instead of the empty pre-wizard default.
|
|
||||||
reg, err = buildRegistry(cfg, finalState.ThemeID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build registry: %w", err)
|
|
||||||
}
|
|
||||||
if finalState.ThemeID != "" {
|
|
||||||
selectedTheme = finalState.ThemeID
|
|
||||||
}
|
|
||||||
// Either the CLI flag or the wizard toggle can force halt-on-error.
|
|
||||||
sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError)
|
|
||||||
}
|
|
||||||
|
|
||||||
ended := time.Now().UTC()
|
|
||||||
if runErr != nil {
|
|
||||||
return runErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the wizard-selected theme (no-op if user chose a theme
|
|
||||||
// whose configs are already on disk). Skipped on dry-run.
|
|
||||||
if !dryRun && selectedTheme != "" {
|
|
||||||
if err := theming.Apply(cfg, selectedTheme); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: theme apply failed: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, fail, skipped := sum.Counts()
|
|
||||||
fmt.Fprintf(os.Stdout, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skipped)
|
|
||||||
for _, r := range sum.Results {
|
|
||||||
line := fmt.Sprintf(" %-16s %s", r.ID, r.Status)
|
|
||||||
if r.Err != nil {
|
|
||||||
line += " — " + r.Err.Error()
|
|
||||||
}
|
|
||||||
fmt.Fprintln(os.Stdout, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !dryRun {
|
|
||||||
if err := writeState(started, ended, sum); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "warning: state write failed:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fail > 0 {
|
|
||||||
return fmt.Errorf("%d module(s) failed", fail)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cmd.Flags().BoolVar(&noTUI, "no-tui", false, "plain-text output, no interactive UI")
|
|
||||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned actions without applying")
|
|
||||||
cmd.Flags().BoolVar(&halt, "halt-on-error", false, "stop at first failure instead of continuing siblings")
|
|
||||||
cmd.Flags().BoolVar(&noSudo, "no-sudo", false, "skip upfront sudo -v (for tests / containers with passwordless sudo)")
|
|
||||||
cmd.Flags().BoolVar(&force, "force", false, "reapply every selected module, ignoring current state")
|
|
||||||
cmd.Flags().StringSliceVar(&only, "only", nil, "comma-separated module IDs to run (exclusive with --skip)")
|
|
||||||
cmd.Flags().StringSliceVar(&skip, "skip", nil, "comma-separated module IDs to skip")
|
|
||||||
cmd.Flags().StringVar(&overlayPath, "config", "", "path to overlay.yaml (default: ~/.config/archduino/overlay.yaml)")
|
|
||||||
cmd.MarkFlagsMutuallyExclusive("only", "skip")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSelection(reg *module.Registry, cfg config.Config, only, skip []string) ([]string, error) {
|
|
||||||
all := reg.All()
|
|
||||||
known := map[string]bool{}
|
|
||||||
for _, m := range all {
|
|
||||||
known[m.Meta().ID] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate every explicit ID the user passed so a typo in --only does not
|
|
||||||
// silently fall through to "run all modules".
|
|
||||||
for _, id := range only {
|
|
||||||
if !known[id] {
|
|
||||||
return nil, fmt.Errorf("unknown module in --only: %q", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, id := range skip {
|
|
||||||
if !known[id] {
|
|
||||||
return nil, fmt.Errorf("unknown module in --skip: %q", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disabled := map[string]bool{}
|
|
||||||
for _, d := range cfg.DisabledModules {
|
|
||||||
disabled[d] = true
|
|
||||||
}
|
|
||||||
for _, d := range skip {
|
|
||||||
disabled[d] = true
|
|
||||||
}
|
|
||||||
onlySet := map[string]bool{}
|
|
||||||
for _, o := range only {
|
|
||||||
onlySet[o] = true
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for _, m := range all {
|
|
||||||
id := m.Meta().ID
|
|
||||||
if disabled[id] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(onlySet) > 0 && !onlySet[id] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, id)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeState(started, ended time.Time, sum engine.Summary) error {
|
|
||||||
s, err := state.DefaultStore()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rec := state.RunRecord{StartedAt: started, EndedAt: ended}
|
|
||||||
for _, r := range sum.Results {
|
|
||||||
mr := state.ModuleRecord{ID: r.ID, Status: string(r.Status), At: ended}
|
|
||||||
if r.Err != nil {
|
|
||||||
mr.Error = r.Err.Error()
|
|
||||||
}
|
|
||||||
rec.Modules = append(rec.Modules, mr)
|
|
||||||
}
|
|
||||||
return s.Save(rec, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newThemeCmd() *cobra.Command {
|
|
||||||
var overlayPath string
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "theme [id]",
|
|
||||||
Short: "Switch the active colorscheme across tools",
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, err := config.Load(overlayPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// "list" short-circuit.
|
|
||||||
if len(args) == 1 && args[0] == "list" {
|
|
||||||
for _, t := range theming.List() {
|
|
||||||
fmt.Printf("%s — %s\n", t.ID, t.DisplayName)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var id string
|
|
||||||
if len(args) == 1 {
|
|
||||||
id = args[0]
|
|
||||||
} else {
|
|
||||||
themes := theming.List()
|
|
||||||
opts := make([]huh.Option[string], 0, len(themes))
|
|
||||||
for _, t := range themes {
|
|
||||||
opts = append(opts, huh.NewOption(t.DisplayName, t.ID))
|
|
||||||
}
|
|
||||||
id = cfg.ThemeOverrides["__active"]
|
|
||||||
if id == "" && len(themes) > 0 {
|
|
||||||
id = themes[0].ID
|
|
||||||
}
|
|
||||||
form := huh.NewForm(huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().Title("Choose a theme").Options(opts...).Value(&id),
|
|
||||||
))
|
|
||||||
if err := form.RunWithContext(cmd.Context()); err != nil {
|
|
||||||
if errors.Is(err, huh.ErrUserAborted) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := theming.Apply(cfg, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Applied theme: %s\n", id)
|
|
||||||
fmt.Println("Restart zellij and neovim to see the new colors.")
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml (defaults to ~/.config/archduino/overlay.yaml)")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func newNewCmd() *cobra.Command {
|
|
||||||
var overlayPath string
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "new <project> <board>",
|
|
||||||
Short: "Scaffold a new Arduino sketch directory",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, err := config.Load(overlayPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
project, board := args[0], args[1]
|
|
||||||
if err := newproject.Run(cfg, project, board); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Created %s/ (board: %s)\n", project, board)
|
|
||||||
fmt.Printf("Next: cd %s && nvim %s.ino\n", project, project)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDoctorCmd() *cobra.Command {
|
|
||||||
var overlayPath string
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "doctor",
|
|
||||||
Short: "Report module convergence and pinned-tool version drift",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, err := config.Load(overlayPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reg, err := buildRegistry(cfg, cfg.ThemeOverrides["__active"])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build registry: %w", err)
|
|
||||||
}
|
|
||||||
code := doctor.Run(cmd.Context(), cfg, reg, platform.ExecRunner{}, os.Stdout)
|
|
||||||
if code != 0 {
|
|
||||||
os.Exit(code)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func newVersionCmd() *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "version",
|
|
||||||
Short: "Print version, commit, and build date",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
fmt.Printf("archduino %s\ncommit: %s\ndate: %s\n", version, commit, date)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
## Conventions used throughout this plan
|
## Conventions used throughout this plan
|
||||||
|
|
||||||
- **Module path:** `github.com/MDietermann/ArduinoArchSetup` (matches the existing GitHub repo name). All `import` statements in subsequent tasks must use this prefix. If a code snippet later in this document shows `github.com/MDietermann/archduino`, treat it as an editing artifact and substitute `github.com/MDietermann/ArduinoArchSetup`.
|
- **Module path:** `github.com/MDietermann/archduino`. If your GitHub owner/repo is different, adjust `go.mod` and all import paths in Task 1 and keep the rest of the plan consistent.
|
||||||
- **Go version:** 1.22 or newer. All code targets 1.22 features (e.g. `slices`, `range` over integers is not required but allowed).
|
- **Go version:** 1.22 or newer. All code targets 1.22 features (e.g. `slices`, `range` over integers is not required but allowed).
|
||||||
- **Test style:** table-driven with `t.Run(tc.name, ...)` subtests; no external assertion libraries.
|
- **Test style:** table-driven with `t.Run(tc.name, ...)` subtests; no external assertion libraries.
|
||||||
- **Commits:** one commit per completed task (after tests pass). Message format: `feat(scope): short summary` or `test(scope): ...`, `chore(scope): ...` as appropriate.
|
- **Commits:** one commit per completed task (after tests pass). Message format: `feat(scope): short summary` or `test(scope): ...`, `chore(scope): ...` as appropriate.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,205 +0,0 @@
|
|||||||
# CI Workflow Implementation Plan (Plan 4d)
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add a single Gitea Actions CI workflow that runs `go vet` + `gofmt` check + `go build` + `go test -race` + `shellcheck` + `goreleaser check` inside an `archlinux:latest` container on every push and PR.
|
|
||||||
|
|
||||||
**Architecture:** One workflow file (`.gitea/workflows/ci.yml`), one job with sequential steps. The Arch container gives environmental fidelity; `actions/setup-go@v5` pins Go via `go-version-file: go.mod` for reproducibility.
|
|
||||||
|
|
||||||
**Tech Stack:** Gitea Actions, archlinux:latest container, Go (version pinned in go.mod), shellcheck, goreleaser v2.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-04-14-ci-workflow-design.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
**New files:**
|
|
||||||
- `.gitea/workflows/ci.yml` — single-job CI workflow
|
|
||||||
|
|
||||||
**No other files change.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Pre-flight — verify all CI steps pass locally
|
|
||||||
|
|
||||||
Before writing the workflow, run every command it will execute locally to confirm the repo is in a CI-passing state. If any command fails, either the plan's step is wrong or the repo has a latent issue — either way, CI will fail without this check.
|
|
||||||
|
|
||||||
**Files:** none (validation only)
|
|
||||||
|
|
||||||
- [ ] **Step 1: `go vet ./...`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
go vet ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: exit 0, no output.
|
|
||||||
|
|
||||||
If warnings appear, STOP and report — fixing them is out of scope for this plan but must happen before CI can land.
|
|
||||||
|
|
||||||
- [ ] **Step 2: `gofmt` check**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
diff=$(gofmt -l .)
|
|
||||||
if [ -n "$diff" ]; then echo "FILES NEEDING FMT:"; echo "$diff"; exit 1; fi
|
|
||||||
echo "gofmt clean"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `gofmt clean`.
|
|
||||||
|
|
||||||
If any files are listed, STOP and report. Fixing formatting is out of scope — do NOT auto-fix in this plan. The user should decide whether to format in a separate commit first.
|
|
||||||
|
|
||||||
- [ ] **Step 3: `go build ./...`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: exit 0, no output.
|
|
||||||
|
|
||||||
- [ ] **Step 4: `go test -race ./...`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
go test -race ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all packages report `ok` or `no test files`. Failures = STOP and report. Race detector findings = STOP and report (they indicate real concurrency bugs).
|
|
||||||
|
|
||||||
- [ ] **Step 5: `shellcheck bootstrap.sh`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
command -v shellcheck || { echo "shellcheck not installed; install via: sudo pacman -S shellcheck"; exit 1; }
|
|
||||||
shellcheck /home/marvin/git-clones/ArduinoArchSetup/bootstrap.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: exit 0, no output.
|
|
||||||
|
|
||||||
If shellcheck isn't installed locally, install it (`sudo pacman -S shellcheck`) and retry. If findings appear, STOP and report — bootstrap.sh was validated by `bash -n` only, so this is the first shellcheck run.
|
|
||||||
|
|
||||||
- [ ] **Step 6: `goreleaser check`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
command -v goreleaser || command -v /home/marvin/go/bin/goreleaser || { echo "goreleaser missing"; exit 1; }
|
|
||||||
(command -v goreleaser && goreleaser check) || /home/marvin/go/bin/goreleaser check
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `• 1 configuration file(s) validated` or equivalent success message.
|
|
||||||
|
|
||||||
- [ ] **Step 7: No commit**
|
|
||||||
|
|
||||||
This task is informational. If all six steps passed, proceed to Task 2. If any failed, report findings to the controller and HALT — the CI workflow cannot land until the repo is in a passing state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Author `.gitea/workflows/ci.yml`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.gitea/workflows/ci.yml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the workflow file**
|
|
||||||
|
|
||||||
Create `/home/marvin/git-clones/ArduinoArchSetup/.gitea/workflows/ci.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: archlinux:latest
|
|
||||||
steps:
|
|
||||||
- name: Install container prereqs
|
|
||||||
run: pacman -Sy --needed --noconfirm base-devel git shellcheck
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: go vet
|
|
||||||
run: go vet ./...
|
|
||||||
|
|
||||||
- name: gofmt check
|
|
||||||
run: |
|
|
||||||
diff=$(gofmt -l .)
|
|
||||||
if [ -n "$diff" ]; then
|
|
||||||
echo "files need gofmt:"
|
|
||||||
echo "$diff"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: go build
|
|
||||||
run: go build ./...
|
|
||||||
|
|
||||||
- name: go test -race
|
|
||||||
run: go test -race ./...
|
|
||||||
|
|
||||||
- name: shellcheck bootstrap.sh
|
|
||||||
run: shellcheck bootstrap.sh
|
|
||||||
|
|
||||||
- name: Install goreleaser
|
|
||||||
run: go install github.com/goreleaser/goreleaser/v2@latest
|
|
||||||
|
|
||||||
- name: goreleaser check
|
|
||||||
run: $(go env GOPATH)/bin/goreleaser check
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Validate YAML syntax**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/ci.yml'))" && echo "yaml ok"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `yaml ok`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Sanity-check step ordering against spec**
|
|
||||||
|
|
||||||
Open the file and confirm the step order matches the spec's Section "Job steps":
|
|
||||||
1. Install container prereqs
|
|
||||||
2. Checkout
|
|
||||||
3. Setup Go
|
|
||||||
4. go vet
|
|
||||||
5. gofmt check
|
|
||||||
6. go build
|
|
||||||
7. go test -race
|
|
||||||
8. shellcheck bootstrap.sh
|
|
||||||
9. Install goreleaser
|
|
||||||
10. goreleaser check
|
|
||||||
|
|
||||||
No grouping or reordering. This is only a human-verified check (no command).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
git add .gitea/workflows/ci.yml
|
|
||||||
git commit -m "ci: add Gitea Actions workflow for test, vet, fmt, build, shellcheck, goreleaser check"
|
|
||||||
```
|
|
||||||
|
|
||||||
Do NOT add any Co-Authored-By trailer — repo convention forbids it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes for the executing agent
|
|
||||||
|
|
||||||
- **One file, one job.** Don't split into multiple jobs or multiple workflows. The spec is explicit.
|
|
||||||
- **Do not try to run the workflow itself** — that requires a Gitea Actions runner (Plan 4c Task 9, currently deferred). The workflow will queue when pushed until a runner is online; that's fine.
|
|
||||||
- **If Task 1 reveals failures** in `go vet`, `gofmt`, `go test -race`, or `shellcheck`, stop and escalate. Those are pre-existing issues the controller needs to address in a separate commit/plan; this plan only adds the workflow that detects them.
|
|
||||||
- **Why `$(go env GOPATH)/bin/goreleaser` explicitly** in step 10: `go install` puts binaries in `GOPATH/bin`, which isn't on `$PATH` inside the stock Arch container. Using the absolute path is simpler than mutating PATH.
|
|
||||||
- **YAML indentation is significant.** Use 2-space indents matching the example above. Do not substitute tabs.
|
|
||||||
@@ -1,726 +0,0 @@
|
|||||||
# Force Reapply Implementation Plan (Plan 4b)
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add `archduino install --force` that reapplies every selected module unconditionally — new `PlanForce` method on `module.Module`, new `Reinstall` helpers on `platform.Pacman`/`platform.Yay`, engine + wizard + CLI wiring.
|
|
||||||
|
|
||||||
**Architecture:** Interface-level `PlanForce(ctx) (Plan, error)` gives each module an explicit "redo everything" path. Engine branches on `RunOptions.Force` to skip `Detect` and call `PlanForce`. Wizard mirrors that branch in its preview cache. Package-only modules reinstall without `--needed`; config-writing modules rewrite managed files.
|
|
||||||
|
|
||||||
**Tech Stack:** Go, existing cobra + bubbletea stack. No new dependencies.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-04-14-force-reapply-design.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
**Modified (no new files):**
|
|
||||||
- `internal/platform/pacman.go` + `_test.go` — add `Reinstall`
|
|
||||||
- `internal/platform/aur.go` + `_test.go` — add `Reinstall`
|
|
||||||
- `internal/module/module.go` — add `PlanForce` to interface
|
|
||||||
- `internal/engine/engine.go` + `_test.go` — add `RunOptions.Force`
|
|
||||||
- `internal/tui/state.go` — add `WizardState.Force`
|
|
||||||
- `internal/tui/wizard/wizard.go` + `_test.go` — force-aware preview
|
|
||||||
- `cmd/archduino/main.go` — add `--force` flag
|
|
||||||
- 13 module files + their tests — implement `PlanForce`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Platform layer — `Reinstall` on Pacman and Yay
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/platform/pacman.go`
|
|
||||||
- Modify: `internal/platform/pacman_test.go`
|
|
||||||
- Modify: `internal/platform/aur.go`
|
|
||||||
- Modify: `internal/platform/aur_test.go`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Inspect existing Install methods**
|
|
||||||
|
|
||||||
Read the existing `Install` methods in `pacman.go` and `aur.go` to know their exact command construction. The new `Reinstall` must be identical except for the `--needed` flag being absent.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n -A 15 "func.*Install" internal/platform/pacman.go internal/platform/aur.go
|
|
||||||
```
|
|
||||||
|
|
||||||
Read the test files to understand how commands are asserted (likely via `FakeRunner` recording executed commands).
|
|
||||||
|
|
||||||
- [ ] **Step 2: Write failing test for `Pacman.Reinstall`**
|
|
||||||
|
|
||||||
Add to `internal/platform/pacman_test.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestPacmanReinstall(t *testing.T) {
|
|
||||||
fr := &FakeRunner{}
|
|
||||||
p := NewPacman(fr)
|
|
||||||
if err := p.Reinstall(context.Background(), []string{"git", "curl"}); err != nil {
|
|
||||||
t.Fatalf("Reinstall: %v", err)
|
|
||||||
}
|
|
||||||
if len(fr.Calls) != 1 {
|
|
||||||
t.Fatalf("expected 1 call, got %d", len(fr.Calls))
|
|
||||||
}
|
|
||||||
got := fr.Calls[0]
|
|
||||||
// Must include -S and --noconfirm but NOT --needed.
|
|
||||||
if !containsAll(got.Args, "-S", "--noconfirm") {
|
|
||||||
t.Errorf("expected -S and --noconfirm in args; got %v", got.Args)
|
|
||||||
}
|
|
||||||
for _, a := range got.Args {
|
|
||||||
if a == "--needed" {
|
|
||||||
t.Errorf("Reinstall must not pass --needed; got args %v", got.Args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !containsAll(got.Args, "git", "curl") {
|
|
||||||
t.Errorf("expected packages git, curl in args; got %v", got.Args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Adjust `containsAll` / `FakeRunner` / `Calls` field names to match what the existing tests already use. Read `pacman_test.go` first to confirm the exact helper names — do NOT invent names.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run test, verify it fails**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
go test ./internal/platform/ -run TestPacmanReinstall -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL with `undefined: Pacman.Reinstall` or similar.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Implement `Pacman.Reinstall`**
|
|
||||||
|
|
||||||
Add to `internal/platform/pacman.go`, mirroring the existing `Install`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Reinstall runs `pacman -S --noconfirm pkg...` without --needed so pacman
|
|
||||||
// unconditionally reinstalls each package. Used by force-reapply.
|
|
||||||
func (p Pacman) Reinstall(ctx context.Context, pkgs []string) error {
|
|
||||||
if len(pkgs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
args := append([]string{"pacman", "-S", "--noconfirm"}, pkgs...)
|
|
||||||
return p.runner.RunSudo(ctx, args[0], args[1:]...)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Adjust the runner method (`RunSudo` / `Run`) to whatever the existing `Install` uses. Read `Install` first.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run test, verify it passes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/platform/ -run TestPacmanReinstall -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Repeat Steps 2–5 for `Yay.Reinstall`**
|
|
||||||
|
|
||||||
Add `TestYayReinstall` to `internal/platform/aur_test.go` (parallel structure; `yay` command instead of `pacman`). Then implement `Yay.Reinstall` in `aur.go`. Run `go test ./internal/platform/ -run TestYayReinstall -v`, verify it passes.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/platform/pacman.go internal/platform/pacman_test.go \
|
|
||||||
internal/platform/aur.go internal/platform/aur_test.go
|
|
||||||
git commit -m "feat(platform): add Reinstall helpers to Pacman and Yay"
|
|
||||||
```
|
|
||||||
|
|
||||||
No Co-Authored-By trailer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Extend Module interface + stub all 13 modules
|
|
||||||
|
|
||||||
Adding `PlanForce` to the interface breaks compilation until every module implements it. This task adds the interface method and a *stub* implementation on every module that returns `module.Plan{}, errors.New("PlanForce not implemented")`. Compilation stays green, engine is not wired yet, and each module's real implementation lands in a later task with its own test.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/module/module.go`
|
|
||||||
- Modify: 13 module files listed below
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `PlanForce` to the interface**
|
|
||||||
|
|
||||||
Edit `internal/module/module.go`. In the `Module` interface block:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Module interface {
|
|
||||||
Meta() Meta
|
|
||||||
Detect(ctx context.Context) (State, error)
|
|
||||||
Plan(ctx context.Context, current State) (Plan, error)
|
|
||||||
PlanForce(ctx context.Context) (Plan, error)
|
|
||||||
Apply(ctx context.Context, plan Plan, r Reporter) error
|
|
||||||
Verify(ctx context.Context) error
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a doc comment above `PlanForce`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// PlanForce returns a plan that reapplies the module unconditionally,
|
|
||||||
// ignoring current detected state. Used by `archduino install --force`.
|
|
||||||
// Implementations should produce a plan equivalent to a from-scratch
|
|
||||||
// install — reinstall packages without pacman's --needed guard and
|
|
||||||
// rewrite managed config files.
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify compile fails**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
go build ./... 2>&1 | head -20
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: compile errors like `*starship.Module does not implement module.Module (missing method PlanForce)` for every module.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add stub `PlanForce` to every module**
|
|
||||||
|
|
||||||
For each of the 13 module files below, append this stub at the bottom of the file (replace `Module` receiver type if the file uses something different — read each file first to confirm):
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
return module.Plan{}, errors.New("PlanForce not implemented")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ensure `errors` is imported. The module files:
|
|
||||||
|
|
||||||
1. `internal/modules/packages/packages.go`
|
|
||||||
2. `internal/modules/gittools/gittools.go`
|
|
||||||
3. `internal/modules/fonts/fonts.go`
|
|
||||||
4. `internal/modules/renode/renode.go`
|
|
||||||
5. `internal/modules/hello/hello.go`
|
|
||||||
6. `internal/modules/starship/starship.go`
|
|
||||||
7. `internal/modules/yazi/yazi.go`
|
|
||||||
8. `internal/modules/zsh/zsh.go`
|
|
||||||
9. `internal/modules/zellij/zellij.go`
|
|
||||||
10. `internal/modules/shellconfig/shellconfig.go`
|
|
||||||
11. `internal/modules/themes/themes.go`
|
|
||||||
12. `internal/modules/neovim/neovim.go`
|
|
||||||
13. `internal/modules/arduinocli/arduinocli.go`
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify compile + existing tests pass**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go build ./...
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: compile clean, all existing tests green. The new stubs aren't exercised yet.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/module/module.go internal/modules/
|
|
||||||
git commit -m "feat(module): add PlanForce to Module interface with stubs"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Engine — `RunOptions.Force` + branch
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/engine/engine.go`
|
|
||||||
- Modify: `internal/engine/engine_test.go`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test**
|
|
||||||
|
|
||||||
In `internal/engine/engine_test.go`, add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestRunForceSkipsDetectAndCallsPlanForce(t *testing.T) {
|
|
||||||
fake := &fakeModule{
|
|
||||||
id: "fake",
|
|
||||||
planForce: module.Plan{Actions: []module.Action{{
|
|
||||||
Description: "forced action",
|
|
||||||
Exec: func(ctx context.Context) error { return nil },
|
|
||||||
}}},
|
|
||||||
}
|
|
||||||
reg := module.NewRegistry()
|
|
||||||
if err := reg.Register(fake); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
sum, err := engine.Run(context.Background(), reg, engine.RunOptions{
|
|
||||||
Selected: []string{"fake"},
|
|
||||||
Force: true,
|
|
||||||
Reporter: engine.NewPlainReporter(&buf),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run: %v", err)
|
|
||||||
}
|
|
||||||
if fake.detectCalls != 0 {
|
|
||||||
t.Errorf("Detect should not be called under Force; got %d calls", fake.detectCalls)
|
|
||||||
}
|
|
||||||
if fake.planForceCalls != 1 {
|
|
||||||
t.Errorf("PlanForce should be called once; got %d", fake.planForceCalls)
|
|
||||||
}
|
|
||||||
if fake.planCalls != 0 {
|
|
||||||
t.Errorf("Plan should not be called under Force; got %d", fake.planCalls)
|
|
||||||
}
|
|
||||||
if sum.Applied != 1 {
|
|
||||||
t.Errorf("expected Applied=1, got %d", sum.Applied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`fakeModule` already exists in `engine_test.go`; extend it with:
|
|
||||||
- `planForce module.Plan`
|
|
||||||
- `planForceCalls int`
|
|
||||||
- Implement `PlanForce(ctx) (module.Plan, error)` that increments the counter and returns `planForce`.
|
|
||||||
- If `detectCalls`/`planCalls` don't already exist on the fake, add them too (they may already be there — read the file first).
|
|
||||||
|
|
||||||
Read `engine_test.go` before editing to confirm the fake's field layout.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test, verify it fails**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/engine/ -run TestRunForceSkipsDetectAndCallsPlanForce -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL — likely `unknown field Force in RunOptions` at compile time.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `Force` to `RunOptions`**
|
|
||||||
|
|
||||||
In `internal/engine/engine.go`, find the `RunOptions` struct and add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Force, when true, skips Detect and calls PlanForce on every module
|
|
||||||
// so each one produces a from-scratch plan regardless of current state.
|
|
||||||
Force bool
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Branch `Run` on `Force`**
|
|
||||||
|
|
||||||
Find the per-module loop inside `Run`. The current shape is roughly:
|
|
||||||
|
|
||||||
```go
|
|
||||||
cur, err := mod.Detect(ctx)
|
|
||||||
if err != nil { /* handle */ }
|
|
||||||
plan, err := mod.Plan(ctx, cur)
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```go
|
|
||||||
var (
|
|
||||||
plan module.Plan
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if opts.Force {
|
|
||||||
plan, err = mod.PlanForce(ctx)
|
|
||||||
} else {
|
|
||||||
cur, derr := mod.Detect(ctx)
|
|
||||||
if derr != nil {
|
|
||||||
// preserve existing Detect error handling verbatim
|
|
||||||
err = derr
|
|
||||||
} else {
|
|
||||||
plan, err = mod.Plan(ctx, cur)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Read the current block first and preserve its error-handling shape (reporter calls, halt-on-error, summary bookkeeping). Do not inline a different handling style.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run test, verify it passes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/engine/ -run TestRunForceSkipsDetectAndCallsPlanForce -v
|
|
||||||
go test ./internal/engine/ -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: both green.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/engine/engine.go internal/engine/engine_test.go
|
|
||||||
git commit -m "feat(engine): add RunOptions.Force to skip Detect and call PlanForce"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Package-only modules — real `PlanForce`
|
|
||||||
|
|
||||||
Package-only modules' `PlanForce` produces a plan that calls `Reinstall` on the full package list.
|
|
||||||
|
|
||||||
**Modules covered:** `packages`, `gittools`, `fonts`, `renode`, `hello`.
|
|
||||||
|
|
||||||
**Files:** for each module, both `<name>.go` and `<name>_test.go`.
|
|
||||||
|
|
||||||
For the four real modules (packages, gittools, fonts, renode), follow the same pattern. Do this **one module at a time** (5 cycles of write-test → verify-fail → implement → verify-pass → next). For `hello`, since it's a placeholder, make `PlanForce` return a fixed non-empty plan (see below).
|
|
||||||
|
|
||||||
### Pattern (use for each of packages / gittools / fonts / renode)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing `TestPlanForce` in the module's `_test.go`**
|
|
||||||
|
|
||||||
Template (adapt field names to the module under test — read the existing `TestPlan` test for each module to see how the fake pacman/yay is wired):
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestPlanForceReinstallsAllPackages(t *testing.T) {
|
|
||||||
fp := &FakePacman{installed: map[string]bool{"git": true, "curl": true}}
|
|
||||||
m := New([]string{"git", "curl"}, fp)
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty even when packages are installed")
|
|
||||||
}
|
|
||||||
// Execute to confirm it calls Reinstall, not Install.
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fp.ReinstallCalls != 1 {
|
|
||||||
t.Errorf("expected 1 Reinstall call, got %d", fp.ReinstallCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If `FakePacman` doesn't already have a `ReinstallCalls` counter and a `Reinstall` method, add them to the fake first (read the fake in the same test file — most modules share a local fake or use a common one in `internal/platform`).
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test, verify it fails**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/modules/<name>/ -run TestPlanForce -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL — the stub returns an error.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Replace stub with real `PlanForce`**
|
|
||||||
|
|
||||||
In `<name>.go`, replace the stub with (adjust receiver/field names):
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
if len(m.pkgs) == 0 {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
pkgs := append([]string{}, m.pkgs...)
|
|
||||||
return module.Plan{Actions: []module.Action{{
|
|
||||||
Description: "pacman -S (force) " + strings.Join(pkgs, ", "),
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, pkgs) },
|
|
||||||
}}}, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ensure the module's stored `pm` field (or equivalent) has the `Reinstall` method available — it will, because Task 1 added it to both `Pacman` and `Yay`, and the fake was extended in Step 1.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test, verify it passes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/modules/<name>/ -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/modules/<name>/
|
|
||||||
git commit -m "feat(<name>): implement PlanForce to reinstall all packages"
|
|
||||||
```
|
|
||||||
|
|
||||||
### `hello` module
|
|
||||||
|
|
||||||
The `hello` module is a placeholder. Implement `PlanForce` as:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
return module.Plan{Actions: []module.Action{{
|
|
||||||
Description: "hello force reapply",
|
|
||||||
Exec: func(ctx context.Context) error { return nil },
|
|
||||||
}}}, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a test that just asserts the plan is non-empty. Same commit cadence as above.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Complete all 5 modules before moving on**
|
|
||||||
|
|
||||||
After each of packages / gittools / fonts / renode / hello has its own commit, move to Task 5. Do NOT batch them into one commit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Hybrid modules — `starship`, `yazi`
|
|
||||||
|
|
||||||
These modules install packages AND (optionally) write configs. Their existing `Plan` already produces a plan that may include both; `PlanForce` should produce both unconditionally.
|
|
||||||
|
|
||||||
For each of `starship` and `yazi`:
|
|
||||||
|
|
||||||
- [ ] **Step 1: Read the existing `Plan` to see what it does**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n -A 30 "func.*Module.*Plan\b" internal/modules/starship/starship.go
|
|
||||||
grep -n -A 30 "func.*Module.*Plan\b" internal/modules/yazi/yazi.go
|
|
||||||
```
|
|
||||||
|
|
||||||
Identify the branches: package install vs. config write (if any).
|
|
||||||
|
|
||||||
- [ ] **Step 2: Write failing `TestPlanForce`**
|
|
||||||
|
|
||||||
The test must arrange a "fully satisfied" state (packages installed, any config files already present) and assert the force plan is non-empty AND its actions reinstall the packages (via Reinstall call counter) AND rewrite the config files (via a writefile call counter or file presence assertion).
|
|
||||||
|
|
||||||
Use the same pattern as Task 4 for the package side. For the config side, use whatever the module's existing tests use to assert config writes (likely `WriteFile` mock or a tempdir).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run test, verify it fails**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/modules/<name>/ -run TestPlanForce -v
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Replace stub with real `PlanForce`**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
var actions []module.Action
|
|
||||||
if len(m.pkgs) > 0 {
|
|
||||||
pkgs := append([]string{}, m.pkgs...)
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "pacman -S (force) " + strings.Join(pkgs, ", "),
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, pkgs) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Any config-write actions the module normally conditions on Detect:
|
|
||||||
actions = append(actions, /* unconditional config-write actions */ ...)
|
|
||||||
return module.Plan{Actions: actions}, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Fill in the config-write actions by lifting them from `Plan` but stripping the "already exists" short-circuit. Reuse the same helper functions (e.g. `writeConfig`) that `Plan` uses.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run test, verify it passes, commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/modules/<name>/ -v
|
|
||||||
git add internal/modules/<name>/
|
|
||||||
git commit -m "feat(<name>): implement PlanForce (reinstall + rewrite config)"
|
|
||||||
```
|
|
||||||
|
|
||||||
Repeat for both modules, one commit each.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Config-writing simple modules — `zsh`, `zellij`, `shellconfig`, `themes`
|
|
||||||
|
|
||||||
Same pattern as Task 5 — read existing `Plan`, lift its actions, strip conditionals. Do one module per commit.
|
|
||||||
|
|
||||||
For each of `zsh`, `zellij`, `shellconfig`, `themes`:
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing `TestPlanForce`** — satisfied-system fixture → assert non-empty plan with all the module's write/install actions.
|
|
||||||
- [ ] **Step 2: Run test, verify it fails.**
|
|
||||||
- [ ] **Step 3: Replace stub with real implementation.**
|
|
||||||
- [ ] **Step 4: Run test, verify it passes.**
|
|
||||||
- [ ] **Step 5: Commit** with message `feat(<name>): implement PlanForce`.
|
|
||||||
|
|
||||||
Module-specific notes:
|
|
||||||
|
|
||||||
- **`shellconfig`** has no packages; `PlanForce` only produces file-write actions.
|
|
||||||
- **`themes`** writes under `~/.config/archduino/overlay.yaml` — force rewrites the overlay's theme fields unconditionally.
|
|
||||||
- **`zsh`** clones plugins (oh-my-zsh, etc.) via `git`; `PlanForce` should clone even if the target already exists — add a pre-step that removes the directory, or use the `git` helper's `ForceClone` if one exists (grep `internal/platform/git.go`; if missing, note it as a concern and fall back to rm-then-clone via an action that uses `os.RemoveAll` before the clone).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: Config-writing complex modules — `neovim`, `arduinocli`
|
|
||||||
|
|
||||||
Same pattern as Task 6, but these modules have multiple sub-steps (neovim: clone NvChad, write chadrc, run post-install; arduinocli: install binary, update index, install cores, install libraries).
|
|
||||||
|
|
||||||
For each module:
|
|
||||||
|
|
||||||
- [ ] **Step 1: Enumerate the existing `Plan`'s sub-actions.** Every conditional "already done" branch in `Plan` becomes an unconditional branch in `PlanForce`.
|
|
||||||
- [ ] **Step 2: Write failing `TestPlanForce`** — satisfied-system fixture → assert ALL sub-actions are present (count-based is fine if exact equality is fragile).
|
|
||||||
- [ ] **Step 3: Run test, verify it fails.**
|
|
||||||
- [ ] **Step 4: Replace stub with real implementation.**
|
|
||||||
- [ ] **Step 5: Run test, verify it passes.**
|
|
||||||
- [ ] **Step 6: Commit** with message `feat(<name>): implement PlanForce`.
|
|
||||||
|
|
||||||
**Neovim specifics:**
|
|
||||||
- If `NvChad` is already cloned, `PlanForce` should either `git pull` + rewrite `chadrc.lua` OR `rm -rf && git clone`. Pick the simpler: rewrite `chadrc.lua` unconditionally, do NOT delete the existing clone. Users' local NvChad modifications shouldn't be wiped by `--force`. Document this in a code comment.
|
|
||||||
|
|
||||||
**arduinocli specifics:**
|
|
||||||
- `PlanForce` should re-download the binary tarball, re-run `arduino-cli core update-index`, and re-install every configured core/library regardless of current installation. Cores install is idempotent at the arduino-cli layer; no issue.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 8: TUI wizard — force-aware preview
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/tui/state.go`
|
|
||||||
- Modify: `internal/tui/wizard/wizard.go`
|
|
||||||
- Modify: `internal/tui/wizard/wizard_test.go`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `Force bool` to `WizardState`**
|
|
||||||
|
|
||||||
In `internal/tui/state.go`, find the `WizardState` struct (or wherever the wizard's global state lives) and add a `Force bool` field with a doc comment:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Force mirrors the --force CLI flag: when true, the wizard's per-module
|
|
||||||
// plan preview uses PlanForce instead of Detect + Plan, and the header
|
|
||||||
// shows a "Force mode" banner.
|
|
||||||
Force bool
|
|
||||||
```
|
|
||||||
|
|
||||||
If `WizardState` is constructed in a factory (e.g., `NewWizardState(cfg, reg)`), leave the constructor signature alone — the flag is set by the CLI after construction.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Write failing test for wizard preview under force**
|
|
||||||
|
|
||||||
In `internal/tui/wizard/wizard_test.go`, add a test that dispatches the wizard's plan-preview code path with `Force: true` on a fake module whose `Detect` panics (proving it's not called) and whose `PlanForce` returns a non-empty plan.
|
|
||||||
|
|
||||||
The exact test shape depends on how the wizard currently tests previews — read the existing preview tests before writing. Bare minimum:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestWizardPreviewUsesPlanForceWhenForceSet(t *testing.T) {
|
|
||||||
mod := &fakeModule{
|
|
||||||
planForce: module.Plan{Actions: []module.Action{{Description: "forced"}}},
|
|
||||||
detect: func() (module.State, error) { t.Fatal("Detect should not be called"); return nil, nil },
|
|
||||||
}
|
|
||||||
// ... drive the preview path and assert the displayed plan is the PlanForce output
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run test, verify it fails**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/tui/wizard/ -run TestWizardPreviewUsesPlanForceWhenForceSet -v
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Branch the preview cache on `Force`**
|
|
||||||
|
|
||||||
In `internal/tui/wizard/wizard.go`, find the `planCache` / preview logic (Plan 3a Task 1 added this — search for `planCache` or `Detect` call sites inside the wizard). Add a branch:
|
|
||||||
|
|
||||||
```go
|
|
||||||
if state.Force {
|
|
||||||
plan, err = mod.PlanForce(ctx)
|
|
||||||
} else {
|
|
||||||
cur, derr := mod.Detect(ctx)
|
|
||||||
if derr != nil { /* existing error path */ }
|
|
||||||
plan, err = mod.Plan(ctx, cur)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also: the cache key must include `force` so a force run doesn't reuse a non-force cached plan. If the cache is keyed by module ID only today, extend it to `(moduleID, force)`.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Add a header banner**
|
|
||||||
|
|
||||||
Find the wizard's view/render code (likely a `View()` method). When `state.Force`, prepend a banner line to the rendered output:
|
|
||||||
|
|
||||||
```go
|
|
||||||
if state.Force {
|
|
||||||
sb.WriteString(styles.Warning.Render("⚠ Force mode: all selected modules will be reapplied"))
|
|
||||||
sb.WriteString("\n\n")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use whatever Lipgloss style the codebase already has for warnings (grep for `Warning`, `warnStyle`, or similar). If none exists, use a plain bold-red or bold string — do NOT create a new style module just for this.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run test, verify it passes + rest of wizard tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/tui/wizard/ -v
|
|
||||||
go test ./internal/tui/ -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all green.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/tui/state.go internal/tui/wizard/
|
|
||||||
git commit -m "feat(tui): wizard uses PlanForce preview when Force is set"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 9: CLI — `--force` flag
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `cmd/archduino/main.go`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the flag**
|
|
||||||
|
|
||||||
In `newInstallCmd`, alongside the other flag declarations (`noTUI`, `dryRun`, `halt`, `noSudo`, `only`, `skip`, `overlayPath`), add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
var force bool
|
|
||||||
// ...
|
|
||||||
cmd.Flags().BoolVar(&force, "force", false,
|
|
||||||
"Reapply every selected module, ignoring current state")
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Propagate to engine and wizard**
|
|
||||||
|
|
||||||
In the `RunE` body:
|
|
||||||
- Where `engine.RunOptions{…}` is constructed (there are currently two call sites: no-TUI path and TUI path — both must set `Force: force`).
|
|
||||||
- Where `tui.NewWizardState(cfg, reg)` is called: after construction, set `initial.Force = force`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Smoke-test**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
go build ./cmd/archduino
|
|
||||||
./archduino install --help | grep -A 2 force
|
|
||||||
./archduino install --force --dry-run --no-tui --no-sudo 2>&1 | head -20
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `--help` shows the new flag; a dry-run with `--force` completes without error and prints per-module force plans.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add cmd/archduino/main.go
|
|
||||||
git commit -m "feat(cmd): add --force flag to install command"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 10: End-to-end validation
|
|
||||||
|
|
||||||
**Files:** none (validation only)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Full test suite with race detector**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
go test -race ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: every package `ok`. Failures here = STOP and fix.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Full build**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: exit 0.
|
|
||||||
|
|
||||||
- [ ] **Step 3: gofmt + vet**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
diff=$(gofmt -l .); [ -z "$diff" ] && echo "gofmt clean" || { echo "$diff"; exit 1; }
|
|
||||||
go vet ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: both clean.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Dry-run smoke (without and with force)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./archduino install --dry-run --no-tui --no-sudo 2>&1 | tail -10
|
|
||||||
./archduino install --dry-run --no-tui --no-sudo --force 2>&1 | tail -10
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: second output's per-module plan is non-empty for every selected module (even ones the first output reports as satisfied).
|
|
||||||
|
|
||||||
- [ ] **Step 5: No commit — validation is informational**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes for the executing agent
|
|
||||||
|
|
||||||
- **Interface change order matters.** Task 2's stubs MUST land before any per-module `PlanForce` test runs (Task 3 onward), otherwise the whole package is red.
|
|
||||||
- **Read each module's existing `Plan` before writing `PlanForce`.** The force version is almost always "strip the conditionals from Plan". Don't invent new actions.
|
|
||||||
- **Do not delete users' cloned repos during force.** Especially neovim's NvChad clone. Rewriting `chadrc.lua` is enough; use `git pull` if the user wants updates, not `rm -rf && git clone`.
|
|
||||||
- **If a module's test file doesn't already have a fake pacman/yay with a `Reinstall` method,** extend the fake in that file (or, if it lives in `internal/platform/testing.go` or similar, extend it once there). Do NOT duplicate fakes — reuse.
|
|
||||||
- **Conventional Commits:** every module's force implementation gets its own `feat(<name>):` commit. This keeps the changelog readable and svu happy.
|
|
||||||
- **CI is now live** (Plan 4d merged into this branch). Expect the workflow to run on every push of this plan — if it goes red, the task isn't done.
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,789 +0,0 @@
|
|||||||
# Release Pipeline Implementation Plan (Plan 4c)
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Ship versioned Archduino releases to self-hosted Gitea with one-command `make release`, and let users install via `curl | bash`.
|
|
||||||
|
|
||||||
**Architecture:** `bootstrap.sh` (curl-piped installer) + `.goreleaser.yaml` (cross-compile/archive/publish) + `Makefile` (svu-driven bump) + `.gitea/workflows/release.yml` (tag-triggered CI). Binary version injected via `-ldflags` into existing `main.version` var.
|
|
||||||
|
|
||||||
**Tech Stack:** Go 1.x, goreleaser v2, svu, Gitea Actions, cobra (already in tree).
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-04-14-release-pipeline-design.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
**New files:**
|
|
||||||
- `bootstrap.sh` — curl-piped installer
|
|
||||||
- `.goreleaser.yaml` — goreleaser config
|
|
||||||
- `Makefile` — release/snapshot/completions/build targets
|
|
||||||
- `.gitea/workflows/release.yml` — tag-triggered release workflow
|
|
||||||
- `LICENSE` — repo license (MIT; confirm with user during Task 7)
|
|
||||||
- `.gitignore` additions: `dist/`, `completions/`
|
|
||||||
|
|
||||||
**Modified files:**
|
|
||||||
- `cmd/archduino/main.go` — add `commit`/`date` ldflag-injected vars; ensure `version` cobra subcommand prints them; ensure `completion` subcommand registered
|
|
||||||
- `README.md` — install-via-bootstrap section, `svu` dev note
|
|
||||||
|
|
||||||
**Removed/archived files:**
|
|
||||||
- `install.sh` → `install.sh.legacy` (kept for reference, renamed so it's not confused with entry point)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Build-info ldflag wiring + version subcommand
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `cmd/archduino/main.go`
|
|
||||||
- Test: manual smoke (no Go test — version output is a trivial glue)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add commit/date vars and `version` subcommand**
|
|
||||||
|
|
||||||
Edit `cmd/archduino/main.go`. Change the `version` declaration and add two new package-level vars just below it:
|
|
||||||
|
|
||||||
```go
|
|
||||||
var (
|
|
||||||
version = "dev"
|
|
||||||
commit = "none"
|
|
||||||
date = "unknown"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a `newVersionCmd` function at the bottom of the file:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func newVersionCmd() *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "version",
|
|
||||||
Short: "Print version, commit, and build date",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
fmt.Printf("archduino %s\ncommit: %s\ndate: %s\n", version, commit, date)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Register it in `main()` alongside the other `root.AddCommand` calls:
|
|
||||||
|
|
||||||
```go
|
|
||||||
root.AddCommand(newVersionCmd())
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify `completion` subcommand is available**
|
|
||||||
|
|
||||||
Cobra registers a `completion` command automatically unless disabled. Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
go run ./cmd/archduino completion bash | head -5
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: bash completion script output (starts with `# bash completion V2 for archduino`).
|
|
||||||
|
|
||||||
If the command is missing, it means `CompletionOptions.DisableDefaultCmd` was set somewhere — grep for it and remove.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Smoke-test the version command**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go run ./cmd/archduino version
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output:
|
|
||||||
```
|
|
||||||
archduino dev
|
|
||||||
commit: none
|
|
||||||
date: unknown
|
|
||||||
```
|
|
||||||
|
|
||||||
Then verify ldflag injection works:
|
|
||||||
```bash
|
|
||||||
go run -ldflags "-X main.version=v0.9.9 -X main.commit=abc123 -X main.date=2026-04-14" ./cmd/archduino version
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
```
|
|
||||||
archduino v0.9.9
|
|
||||||
commit: abc123
|
|
||||||
date: 2026-04-14
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add cmd/archduino/main.go
|
|
||||||
git commit -m "feat(cmd): add version subcommand with ldflag-injected build info"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: .gitignore + LICENSE + completions dir
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `.gitignore`
|
|
||||||
- Create: `LICENSE`
|
|
||||||
- Create: `completions/.gitkeep`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update .gitignore**
|
|
||||||
|
|
||||||
Append to `.gitignore` (create the file if missing):
|
|
||||||
|
|
||||||
```
|
|
||||||
# goreleaser output
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# generated shell completions
|
|
||||||
completions/
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Ask user for LICENSE choice**
|
|
||||||
|
|
||||||
Before writing `LICENSE`, pause and ask the user which license (MIT, Apache-2.0, GPL-3.0, etc.). Default suggestion: MIT. Do not proceed with a guess.
|
|
||||||
|
|
||||||
Once confirmed, write `LICENSE` with the standard text for the chosen license. For MIT:
|
|
||||||
|
|
||||||
```
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 MDietermann
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .gitignore LICENSE
|
|
||||||
git commit -m "chore: add LICENSE and ignore dist/ and completions/"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: .goreleaser.yaml
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.goreleaser.yaml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the goreleaser config**
|
|
||||||
|
|
||||||
Create `.goreleaser.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: 2
|
|
||||||
|
|
||||||
project_name: archduino
|
|
||||||
|
|
||||||
before:
|
|
||||||
hooks:
|
|
||||||
- mkdir -p completions
|
|
||||||
- sh -c "go run ./cmd/archduino completion bash > completions/archduino.bash"
|
|
||||||
- sh -c "go run ./cmd/archduino completion zsh > completions/_archduino"
|
|
||||||
- sh -c "go run ./cmd/archduino completion fish > completions/archduino.fish"
|
|
||||||
|
|
||||||
builds:
|
|
||||||
- id: archduino
|
|
||||||
main: ./cmd/archduino
|
|
||||||
binary: archduino
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=0
|
|
||||||
goos:
|
|
||||||
- linux
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
- arm64
|
|
||||||
ldflags:
|
|
||||||
- -s -w
|
|
||||||
- -X main.version={{ .Version }}
|
|
||||||
- -X main.commit={{ .Commit }}
|
|
||||||
- -X main.date={{ .Date }}
|
|
||||||
|
|
||||||
archives:
|
|
||||||
- id: default
|
|
||||||
name_template: "archduino_{{ .Version }}_linux_{{ .Arch }}"
|
|
||||||
formats: [tar.gz]
|
|
||||||
files:
|
|
||||||
- README.md
|
|
||||||
- LICENSE
|
|
||||||
- src: completions/archduino.bash
|
|
||||||
dst: completions/archduino.bash
|
|
||||||
- src: completions/_archduino
|
|
||||||
dst: completions/_archduino
|
|
||||||
- src: completions/archduino.fish
|
|
||||||
dst: completions/archduino.fish
|
|
||||||
|
|
||||||
checksum:
|
|
||||||
name_template: "checksums.txt"
|
|
||||||
algorithm: sha256
|
|
||||||
|
|
||||||
changelog:
|
|
||||||
use: git
|
|
||||||
sort: asc
|
|
||||||
filters:
|
|
||||||
exclude:
|
|
||||||
- '^chore:'
|
|
||||||
- '^docs:'
|
|
||||||
- '^test:'
|
|
||||||
- '^ci:'
|
|
||||||
- '^style:'
|
|
||||||
- '^refactor:'
|
|
||||||
- 'Merge pull request'
|
|
||||||
- 'Merge branch'
|
|
||||||
groups:
|
|
||||||
- title: Features
|
|
||||||
regexp: '^feat(\(.*\))?:'
|
|
||||||
order: 0
|
|
||||||
- title: Bug fixes
|
|
||||||
regexp: '^(fix|perf)(\(.*\))?:'
|
|
||||||
order: 1
|
|
||||||
- title: Others
|
|
||||||
order: 999
|
|
||||||
|
|
||||||
release:
|
|
||||||
gitea_urls:
|
|
||||||
api: https://gitlab.hive-brain.de/api/v1
|
|
||||||
download: https://gitlab.hive-brain.de
|
|
||||||
draft: false
|
|
||||||
prerelease: auto
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Install goreleaser locally if not present**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
command -v goreleaser || go install github.com/goreleaser/goreleaser/v2@latest
|
|
||||||
goreleaser --version
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: prints a version ≥ v2.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Validate the config**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/marvin/git-clones/ArduinoArchSetup
|
|
||||||
goreleaser check
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `• config is valid`. If errors, fix them before proceeding.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run a snapshot build**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
goreleaser release --snapshot --clean
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `dist/` populated with two tar.gz archives (`archduino_*_linux_amd64.tar.gz`, `archduino_*_linux_arm64.tar.gz`), `checksums.txt`, and extracted binary subdirs. No errors.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Inspect archive contents**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tar -tzf dist/archduino_*_linux_amd64.tar.gz
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected file list: `archduino`, `README.md`, `LICENSE`, `completions/archduino.bash`, `completions/_archduino`, `completions/archduino.fish`.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Verify binary has injected version**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tar -xzf dist/archduino_*_linux_amd64.tar.gz -C /tmp/archduino-smoke --one-top-level
|
|
||||||
/tmp/archduino-smoke/archduino_*_linux_amd64/archduino version
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: prints a snapshot version (looks like `0.0.0-next-<commit>` or similar) and a real commit + date.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .goreleaser.yaml
|
|
||||||
git commit -m "build: add goreleaser config for Gitea releases"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Makefile
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `Makefile`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Install svu locally**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
command -v svu || go install github.com/caarlos0/svu@latest
|
|
||||||
svu --version
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: prints a version.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Write the Makefile**
|
|
||||||
|
|
||||||
Create `Makefile`:
|
|
||||||
|
|
||||||
```makefile
|
|
||||||
.PHONY: build snapshot release completions clean help
|
|
||||||
|
|
||||||
GO ?= go
|
|
||||||
BIN := archduino
|
|
||||||
|
|
||||||
help: ## Show this help
|
|
||||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-14s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
|
||||||
|
|
||||||
build: ## Local dev build into ./archduino
|
|
||||||
$(GO) build -o $(BIN) ./cmd/archduino
|
|
||||||
|
|
||||||
completions: ## Regenerate shell completions into completions/
|
|
||||||
@mkdir -p completions
|
|
||||||
$(GO) run ./cmd/archduino completion bash > completions/archduino.bash
|
|
||||||
$(GO) run ./cmd/archduino completion zsh > completions/_archduino
|
|
||||||
$(GO) run ./cmd/archduino completion fish > completions/archduino.fish
|
|
||||||
|
|
||||||
snapshot: ## Local goreleaser dry run into dist/
|
|
||||||
goreleaser release --snapshot --clean
|
|
||||||
|
|
||||||
clean: ## Remove build artifacts
|
|
||||||
rm -rf dist/ completions/ $(BIN)
|
|
||||||
|
|
||||||
release: ## Compute next SemVer from commits, tag, and push
|
|
||||||
@command -v svu >/dev/null || { echo "svu not found. Run: go install github.com/caarlos0/svu@latest"; exit 1; }
|
|
||||||
@test -z "$$(git status --porcelain)" || { echo "working tree is dirty; commit or stash first"; exit 1; }
|
|
||||||
@branch=$$(git rev-parse --abbrev-ref HEAD); \
|
|
||||||
if [ "$$branch" != "master" ]; then echo "must be on master (currently on $$branch)"; exit 1; fi
|
|
||||||
@current=$$(svu current 2>/dev/null || echo "none"); \
|
|
||||||
next=$$(svu next); \
|
|
||||||
echo "Current: $$current"; \
|
|
||||||
echo "Next: $$next"; \
|
|
||||||
echo; \
|
|
||||||
echo "Commits since $$current:"; \
|
|
||||||
if [ "$$current" = "none" ]; then git log --oneline; else git log --oneline $$current..HEAD; fi; \
|
|
||||||
echo; \
|
|
||||||
printf "Tag %s and push? [y/N] " "$$next"; read ans; \
|
|
||||||
if [ "$$ans" != "y" ] && [ "$$ans" != "Y" ]; then echo "aborted"; exit 1; fi; \
|
|
||||||
git tag "$$next" && git push origin "$$next"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Smoke-test non-destructive targets**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make help
|
|
||||||
make build && ./archduino version
|
|
||||||
make completions && ls completions/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `help` lists targets; `build` produces `./archduino`; `completions` generates three files.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Dry-run the release guard**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# With dirty tree (touch a file first if tree is clean)
|
|
||||||
touch /tmp/dirt && make release || echo "guard worked as expected"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: aborts with "working tree is dirty". Do NOT actually run `make release` to completion yet — that creates a real tag.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add Makefile
|
|
||||||
git commit -m "build: add Makefile with release, snapshot, completions targets"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: bootstrap.sh
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `bootstrap.sh`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write bootstrap.sh**
|
|
||||||
|
|
||||||
Create `bootstrap.sh`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO="MDietermann/Archduino"
|
|
||||||
BASE_URL="${ARCHDUINO_RELEASE_BASE:-https://gitlab.hive-brain.de}"
|
|
||||||
API_URL="${BASE_URL}/api/v1"
|
|
||||||
|
|
||||||
err() { echo "bootstrap: error: $*" >&2; exit 1; }
|
|
||||||
msg() { echo "==> $*"; }
|
|
||||||
|
|
||||||
# Arch gate
|
|
||||||
[ -f /etc/arch-release ] || err "Archduino v1 supports Arch Linux only."
|
|
||||||
|
|
||||||
# Arch detect
|
|
||||||
case "$(uname -m)" in
|
|
||||||
x86_64) ARCH="amd64" ;;
|
|
||||||
aarch64) ARCH="arm64" ;;
|
|
||||||
*) err "unsupported architecture: $(uname -m)" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Prereqs
|
|
||||||
msg "Installing prerequisites (sudo, git, curl, ca-certificates)"
|
|
||||||
sudo pacman -Sy --needed --noconfirm sudo git curl ca-certificates
|
|
||||||
|
|
||||||
# Discover latest release
|
|
||||||
msg "Querying latest release from Gitea"
|
|
||||||
API_ENDPOINT="${API_URL}/repos/${REPO}/releases/latest"
|
|
||||||
RELEASE_JSON=$(curl -fsSL "$API_ENDPOINT") || err "failed to fetch ${API_ENDPOINT}"
|
|
||||||
|
|
||||||
# Extract tag_name without requiring jq
|
|
||||||
TAG=$(printf '%s' "$RELEASE_JSON" | grep -o '"tag_name":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
||||||
[ -n "$TAG" ] || err "could not parse tag_name from release metadata"
|
|
||||||
VERSION="${TAG#v}"
|
|
||||||
msg "Latest release: ${TAG}"
|
|
||||||
|
|
||||||
# Download
|
|
||||||
TMP=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
TARBALL="archduino_${VERSION}_linux_${ARCH}.tar.gz"
|
|
||||||
CHECKSUMS="checksums.txt"
|
|
||||||
DL_BASE="${BASE_URL}/${REPO}/releases/download/${TAG}"
|
|
||||||
|
|
||||||
msg "Downloading ${TARBALL}"
|
|
||||||
curl -fsSL -o "${TMP}/${TARBALL}" "${DL_BASE}/${TARBALL}" || err "tarball download failed"
|
|
||||||
curl -fsSL -o "${TMP}/${CHECKSUMS}" "${DL_BASE}/${CHECKSUMS}" || err "checksums download failed"
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
msg "Verifying checksum"
|
|
||||||
(cd "$TMP" && sha256sum --check --ignore-missing "$CHECKSUMS") || err "checksum verification failed"
|
|
||||||
|
|
||||||
# Extract
|
|
||||||
msg "Extracting"
|
|
||||||
tar -xzf "${TMP}/${TARBALL}" -C "$TMP"
|
|
||||||
|
|
||||||
# Install
|
|
||||||
msg "Installing to /usr/local/bin (sudo)"
|
|
||||||
sudo install -m 0755 "${TMP}/archduino" /usr/local/bin/archduino
|
|
||||||
|
|
||||||
msg "Installing shell completions"
|
|
||||||
sudo install -Dm 0644 "${TMP}/completions/archduino.bash" /usr/share/bash-completion/completions/archduino
|
|
||||||
sudo install -Dm 0644 "${TMP}/completions/_archduino" /usr/share/zsh/site-functions/_archduino
|
|
||||||
sudo install -Dm 0644 "${TMP}/completions/archduino.fish" /usr/share/fish/vendor_completions.d/archduino.fish
|
|
||||||
|
|
||||||
msg "Launching: archduino install $*"
|
|
||||||
exec /usr/local/bin/archduino install "$@"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Make it executable and shellcheck it**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x bootstrap.sh
|
|
||||||
command -v shellcheck && shellcheck bootstrap.sh || echo "(shellcheck not installed — skipping)"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `shellcheck` passes with no warnings. If warnings appear, fix them (common ones: quote expansions, etc.).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Smoke-test the non-root path in a container**
|
|
||||||
|
|
||||||
This step requires a snapshot tarball. Skip if Docker is unavailable — the workflow will validate end-to-end.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make snapshot
|
|
||||||
# Serve dist/ locally
|
|
||||||
(cd dist && python3 -m http.server 8000 &) ; SERVER_PID=$!
|
|
||||||
sleep 1
|
|
||||||
# In a throwaway Arch container, verify the prereq + download logic.
|
|
||||||
# Since Gitea API isn't reachable in the container, we only eyeball the
|
|
||||||
# script behavior up to the API call:
|
|
||||||
docker run --rm -v "$PWD/bootstrap.sh:/bootstrap.sh:ro" archlinux bash -c \
|
|
||||||
'head -40 /bootstrap.sh && echo "--- script is syntactically valid shell ---" && bash -n /bootstrap.sh'
|
|
||||||
kill $SERVER_PID 2>/dev/null || true
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `bash -n` reports no syntax errors. (Full e2e test is gated on Task 8.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add bootstrap.sh
|
|
||||||
git commit -m "feat: add bootstrap.sh curl-piped installer"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: .gitea/workflows/release.yml
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.gitea/workflows/release.yml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the workflow**
|
|
||||||
|
|
||||||
Create `.gitea/workflows/release.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
goreleaser:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Run goreleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v6
|
|
||||||
with:
|
|
||||||
distribution: goreleaser
|
|
||||||
version: '~> v2'
|
|
||||||
args: release --clean
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Lint the YAML**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
command -v yamllint && yamllint .gitea/workflows/release.yml || python3 -c "import yaml,sys; yaml.safe_load(open('.gitea/workflows/release.yml'))" && echo "yaml ok"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .gitea/workflows/release.yml
|
|
||||||
git commit -m "ci: add Gitea Actions release workflow"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: Retire install.sh and update README
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Rename: `install.sh` → `install.sh.legacy`
|
|
||||||
- Modify: `README.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Rename install.sh**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git mv install.sh install.sh.legacy
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a one-line header to the top of `install.sh.legacy` explaining the rename. Use Edit with these exact lines (the first original line is `#!/bin/bash` or similar — verify and preserve):
|
|
||||||
|
|
||||||
Read the first 3 lines first to know exact content:
|
|
||||||
```bash
|
|
||||||
head -3 install.sh.legacy
|
|
||||||
```
|
|
||||||
|
|
||||||
Then use Edit to prepend:
|
|
||||||
```bash
|
|
||||||
# DEPRECATED: This is the legacy Bash installer. The active installer is
|
|
||||||
# bootstrap.sh (curl-piped shim) + the archduino Go binary. Kept for reference.
|
|
||||||
```
|
|
||||||
|
|
||||||
Inserted as the second line (below the shebang).
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update README install section**
|
|
||||||
|
|
||||||
Read the current install section:
|
|
||||||
```bash
|
|
||||||
grep -n -A 20 -i "install" README.md | head -40
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace whatever install instructions currently exist with a block that contains:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
One-line install on Arch Linux:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://gitlab.hive-brain.de/MDietermann/Archduino/raw/branch/master/bootstrap.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
This fetches the latest release, verifies its checksum, installs `archduino`
|
|
||||||
to `/usr/local/bin`, installs shell completions, and launches
|
|
||||||
`archduino install`.
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
Local build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make build
|
|
||||||
./archduino --help
|
|
||||||
```
|
|
||||||
|
|
||||||
Releasing (maintainers only):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go install github.com/caarlos0/svu@latest
|
|
||||||
make release # prompts with computed next version, tags, pushes
|
|
||||||
```
|
|
||||||
```
|
|
||||||
|
|
||||||
Use Edit to splice this in at the appropriate location. If the README has no install section, append it after the title/intro.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add install.sh.legacy README.md
|
|
||||||
git rm install.sh 2>/dev/null || true # git mv already handles this
|
|
||||||
git commit -m "docs: update README for bootstrap.sh install; retire install.sh"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 8: End-to-end snapshot verification
|
|
||||||
|
|
||||||
**Files:** none (validation only)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Clean snapshot**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make clean
|
|
||||||
make snapshot
|
|
||||||
ls dist/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `checksums.txt`, two `.tar.gz` archives, build dirs.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify tarball contents match bootstrap.sh expectations**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tar -tzf dist/archduino_*_linux_amd64.tar.gz | sort
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected (order may vary):
|
|
||||||
```
|
|
||||||
LICENSE
|
|
||||||
README.md
|
|
||||||
archduino
|
|
||||||
completions/_archduino
|
|
||||||
completions/archduino.bash
|
|
||||||
completions/archduino.fish
|
|
||||||
```
|
|
||||||
|
|
||||||
The paths must match what `bootstrap.sh` extracts and installs. If the archive layout differs (e.g. everything under a top-level dir), adjust `.goreleaser.yaml` `archives.wrap_in_directory: false` or update `bootstrap.sh`'s install paths.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify checksums file format**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat dist/checksums.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: one line per archive, `<sha256> archduino_<version>_linux_<arch>.tar.gz`. This is what `sha256sum --check` consumes.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Bash-syntax-check bootstrap.sh against a snapshot in a container**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm -v "$PWD:/work:ro" archlinux bash -c '
|
|
||||||
set -e
|
|
||||||
pacman -Sy --needed --noconfirm bash curl >/dev/null
|
|
||||||
bash -n /work/bootstrap.sh
|
|
||||||
echo "syntax ok"
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `syntax ok`. (Full network-dependent e2e is gated on the first real release.)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit nothing; verification is informational**
|
|
||||||
|
|
||||||
No commit. If any step failed, go back to the relevant task and fix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 9: Register Gitea Actions runner and first release
|
|
||||||
|
|
||||||
**Files:** none (operational)
|
|
||||||
|
|
||||||
This task is operational, performed once. Coordinate with the user before executing.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Confirm with the user**
|
|
||||||
|
|
||||||
Before running any of the steps below, confirm with the user that they want to proceed with a real release. Gitea Actions runner registration and the first tag are both irreversible-ish actions that affect the remote repo.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Register a Gitea Actions runner**
|
|
||||||
|
|
||||||
Runner registration is a one-time step and is performed on a host the user controls (likely the Gitea server or a persistent machine). High-level steps (user runs these — agent does not execute on remote hosts):
|
|
||||||
|
|
||||||
1. In Gitea UI → Repo Settings → Actions → Runners → generate a registration token.
|
|
||||||
2. On the runner host, install `act_runner` (https://gitea.com/gitea/act_runner).
|
|
||||||
3. `act_runner register --instance https://gitlab.hive-brain.de --token <TOKEN>`.
|
|
||||||
4. `act_runner daemon &` (or set up a systemd unit).
|
|
||||||
|
|
||||||
Verify in the Gitea UI that the runner shows as online.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create a repo-scoped release token**
|
|
||||||
|
|
||||||
1. Gitea UI → User Settings → Applications → Generate Token.
|
|
||||||
2. Scope: `write:repository` (and `write:package` if future-proofing).
|
|
||||||
3. Copy the token.
|
|
||||||
4. Repo → Settings → Actions → Secrets → New secret named `RELEASE_TOKEN`, paste the token value.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Seed the first tag**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Confirm branch/working-tree
|
|
||||||
git checkout master
|
|
||||||
git pull
|
|
||||||
git status
|
|
||||||
# Seed v0.1.0 manually so svu has a baseline
|
|
||||||
git tag v0.1.0
|
|
||||||
git push origin v0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Watch the Gitea Actions tab — the `release` workflow should pick up the tag and run goreleaser. Expected outcome: a new Release page at `https://gitlab.hive-brain.de/MDietermann/Archduino/releases/tag/v0.1.0` with two tarballs and `checksums.txt` attached.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Smoke-test bootstrap.sh against the real release**
|
|
||||||
|
|
||||||
In a throwaway Arch container:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm -it archlinux bash -c '
|
|
||||||
pacman -Sy --noconfirm curl >/dev/null
|
|
||||||
curl -fsSL https://gitlab.hive-brain.de/MDietermann/Archduino/raw/branch/master/bootstrap.sh | bash -s -- --dry-run
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: prereqs install, release detected, tarball downloaded, checksum verified, `archduino install --dry-run` runs and exits cleanly.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Merge to master**
|
|
||||||
|
|
||||||
Once the first real release is verified, merge the worktree branch back to `master`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout master
|
|
||||||
git merge --no-ff feat/plan4c-release
|
|
||||||
git push origin master
|
|
||||||
```
|
|
||||||
|
|
||||||
(Branch name may differ — use whichever worktree was created.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes for the executing agent
|
|
||||||
|
|
||||||
- **No Go-side tests** are created in this plan. The deliverables are configs, scripts, and a CI workflow — validation is via `goreleaser check`, `make snapshot`, `bash -n`, and `shellcheck`.
|
|
||||||
- **Conventional Commits are required from here on.** `svu` and the goreleaser changelog both depend on them. Commit messages in this plan use that format as a model.
|
|
||||||
- **Do not run `make release` during implementation** — it creates real git tags that would pollute the release history. The first real release happens in Task 9 after user confirmation.
|
|
||||||
- **If Task 3 snapshot fails** because `completion` isn't registered, revisit Task 1 Step 2 — cobra may have had its default subcommand disabled.
|
|
||||||
- **If Task 8 reveals archive layout mismatches**, the fix is in `.goreleaser.yaml` (files stanza or `wrap_in_directory`) rather than `bootstrap.sh`, because goreleaser's layout is the source of truth.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# CI Workflow Design (Plan 4d)
|
|
||||||
|
|
||||||
**Date:** 2026-04-14
|
|
||||||
**Scope:** Plan 4d of the Archduino Go rewrite — validate Go code quality, build health, and release config on every change.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
A single Gitea Actions workflow that gives fast, reliable feedback on every push and pull request. It runs unit tests with the race detector, static checks, a build sanity pass, shell-script linting, and validates the goreleaser config — all inside an Arch Linux container for environmental fidelity with the target platform.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- No integration tests that invoke a real `archduino install` against live pacman/AUR mirrors. That requires a privileged container and mirror access; it lives in a future plan if ever needed.
|
|
||||||
- No `golangci-lint`. The `go vet` + `gofmt` baseline is enough for this codebase; revisit only if linting gaps surface.
|
|
||||||
- No coverage reporting or codecov upload.
|
|
||||||
- No scheduled/nightly runs. Trigger is push + PR only.
|
|
||||||
|
|
||||||
## Trigger
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
```
|
|
||||||
|
|
||||||
Fires on every push to every branch and every pull request. Matches a single-developer workflow where noisy branches aren't a real concern; immediate feedback is worth the runner minutes.
|
|
||||||
|
|
||||||
## Runner
|
|
||||||
|
|
||||||
- `runs-on: ubuntu-latest` — same runner label used by the Plan 4c release workflow.
|
|
||||||
- `container: archlinux:latest` — tests execute inside a fresh Arch userspace each run, matching Archduino's sole supported OS.
|
|
||||||
|
|
||||||
**Runner infrastructure:** depends on the same Gitea Actions runner registered for Plan 4c. No new infra introduced by this plan. If the runner is offline, CI queues rather than failing; this is acceptable behavior.
|
|
||||||
|
|
||||||
## Job steps (single job, sequential)
|
|
||||||
|
|
||||||
1. **Install container prereqs.** Before `actions/checkout` can run, the Arch image needs git; the later steps also need `base-devel` (for the C toolchain Go occasionally pulls in for cgo-adjacent deps) and `shellcheck`. Run once up front:
|
|
||||||
```
|
|
||||||
pacman -Sy --needed --noconfirm base-devel git shellcheck
|
|
||||||
```
|
|
||||||
Note: `go` is intentionally NOT installed here — `actions/setup-go` installs the pinned version.
|
|
||||||
|
|
||||||
2. **Checkout code.** `actions/checkout@v4` with `fetch-depth: 1` (CI doesn't need history).
|
|
||||||
|
|
||||||
3. **Set up Go.** `actions/setup-go@v5` with:
|
|
||||||
- `go-version-file: go.mod` (pinned, deterministic)
|
|
||||||
- `cache: true` (built-in cache of `$GOMODCACHE` + `$GOCACHE`, keyed on `go.sum`)
|
|
||||||
|
|
||||||
4. **`go vet ./...`** — catches obvious correctness issues the compiler misses.
|
|
||||||
|
|
||||||
5. **`gofmt` check:**
|
|
||||||
```
|
|
||||||
diff=$(gofmt -l .)
|
|
||||||
if [ -n "$diff" ]; then
|
|
||||||
echo "files need gofmt:"; echo "$diff"; exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
Fails the build if any Go file isn't canonically formatted.
|
|
||||||
|
|
||||||
6. **`go build ./...`** — compiles every package. Catches deadcode paths and untested packages that `go test` would otherwise skip.
|
|
||||||
|
|
||||||
7. **`go test -race ./...`** — full unit test suite with the race detector. The engine (DAG scheduler) and `privilege.Acquire` (sudo keepalive goroutine) are the concurrency hotspots this covers.
|
|
||||||
|
|
||||||
8. **`shellcheck bootstrap.sh`** — lints the user-facing shell installer. `install.sh.legacy` is NOT linted; it's archived code.
|
|
||||||
|
|
||||||
9. **Install goreleaser + `goreleaser check`:**
|
|
||||||
```
|
|
||||||
go install github.com/goreleaser/goreleaser/v2@latest
|
|
||||||
"$(go env GOPATH)/bin/goreleaser" check
|
|
||||||
```
|
|
||||||
Validates `.goreleaser.yaml` on every change. Cheap insurance against config drift.
|
|
||||||
|
|
||||||
## Runtime expectations
|
|
||||||
|
|
||||||
- Arch image pull: ~15–30s (cached on the runner after first run).
|
|
||||||
- `pacman -Sy … base-devel git shellcheck`: ~10–20s.
|
|
||||||
- `actions/setup-go` + cache restore: ~5–10s warm, ~30s cold.
|
|
||||||
- `go build ./...` + `go test -race ./...`: ~30–60s given 37 test files with mostly mocked I/O.
|
|
||||||
- `go vet`, `gofmt`, `shellcheck`, `goreleaser check`: <5s combined.
|
|
||||||
- **Total per run:** ~1–2 min warm cache, ~3 min cold.
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
- Sequential steps; the first failure fails the workflow. No `continue-on-error` anywhere.
|
|
||||||
- `gofmt` check uses an explicit guard (not just `-l`) so a non-empty output is a clear failure.
|
|
||||||
- `setup-go` cache misses silently fall back to a clean install — no workflow-breaking behavior.
|
|
||||||
|
|
||||||
## Caching
|
|
||||||
|
|
||||||
`actions/setup-go@v5` handles Go build/module caching natively via `cache: true`. No extra cache step. The `pacman` packages are re-installed every run; caching them is not worth the complexity for ~15s of savings on a 90s run.
|
|
||||||
|
|
||||||
## Files touched
|
|
||||||
|
|
||||||
- Create: `.gitea/workflows/ci.yml`
|
|
||||||
|
|
||||||
No other repo changes; the existing tests, `go.mod`, `.goreleaser.yaml`, and `bootstrap.sh` all stay as-is.
|
|
||||||
|
|
||||||
## Interaction with Plan 4c
|
|
||||||
|
|
||||||
The release workflow (`.gitea/workflows/release.yml`) is unchanged. Both workflows share the Gitea Actions runner but never run together: CI fires on push/PR, release fires on tag push. Independent files, independent triggers, zero coupling.
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
# Force Reapply Design (Plan 4b)
|
|
||||||
|
|
||||||
**Date:** 2026-04-14
|
|
||||||
**Scope:** Plan 4b of the Archduino Go rewrite — add `archduino install --force` that re-runs every selected module's Apply unconditionally.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Let the user bypass the four-phase contract's idempotency guard when they explicitly want to reapply everything — typically after a manual edit, corruption, or "I'm not sure what's broken, redo it" scenario. Scope is narrow: force a full reapply of currently-selected modules. No uninstall, no revert, no selective force-by-category.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- No uninstall / reset / revert behavior. Modules have no `Revert` method today; adding one is a separate, much larger plan.
|
|
||||||
- No selective force (`--force-configs`, `--force-packages`). The user picked "full re-everything" during brainstorming; single flag only.
|
|
||||||
- No new subcommand (`archduino reinstall`). A flag on `install` is enough.
|
|
||||||
- No changes to `Verify`; force affects only what `Apply` does, not how we confirm it.
|
|
||||||
|
|
||||||
## Interface change
|
|
||||||
|
|
||||||
Add one method to `module.Module`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// PlanForce returns a plan that reapplies the module unconditionally,
|
|
||||||
// ignoring current detected state. Used by `archduino install --force`.
|
|
||||||
// Implementations should produce a plan equivalent to "install from
|
|
||||||
// scratch" — reinstall all packages (without pacman's --needed guard)
|
|
||||||
// and rewrite all managed config files.
|
|
||||||
PlanForce(ctx context.Context) (Plan, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
Every existing module implements this. Two categories:
|
|
||||||
|
|
||||||
### Config-writing modules
|
|
||||||
`zsh`, `neovim`, `themes`, `starship`, `shellconfig`, `zellij`, `yazi` (config bits), `arduinocli` (sketch dirs, indexes).
|
|
||||||
|
|
||||||
`PlanForce` produces a plan that:
|
|
||||||
1. Reinstalls packages via `platform.Pacman.Reinstall` / `platform.Yay.Reinstall` (no `--needed`).
|
|
||||||
2. Rewrites every managed config file, overwriting existing content.
|
|
||||||
3. Re-runs any post-install steps (e.g., arduino-cli core update, zsh plugin clone).
|
|
||||||
|
|
||||||
### Package-only modules
|
|
||||||
`packages`, `gittools`, `fonts`, `renode`, and the package-only portions of `starship` / `yazi`.
|
|
||||||
|
|
||||||
`PlanForce` produces a plan that calls `platform.Pacman.Reinstall(ctx, pkgs)` — identical to the install step but without `--needed`, so pacman physically re-fetches and reinstalls the package even if already present.
|
|
||||||
|
|
||||||
## Platform layer additions
|
|
||||||
|
|
||||||
`internal/platform/pacman.go`:
|
|
||||||
```go
|
|
||||||
// Reinstall runs `pacman -S --noconfirm pkg...` without the --needed flag
|
|
||||||
// so pacman unconditionally reinstalls each package.
|
|
||||||
func (p Pacman) Reinstall(ctx context.Context, pkgs []string) error
|
|
||||||
```
|
|
||||||
|
|
||||||
`internal/platform/aur.go`:
|
|
||||||
```go
|
|
||||||
// Reinstall runs `yay -S --noconfirm pkg...` without the --needed flag.
|
|
||||||
func (y Yay) Reinstall(ctx context.Context, pkgs []string) error
|
|
||||||
```
|
|
||||||
|
|
||||||
Both are straight copies of the existing `Install` methods with `--needed` omitted from the command-line args.
|
|
||||||
|
|
||||||
## Engine changes
|
|
||||||
|
|
||||||
`internal/engine/engine.go`:
|
|
||||||
|
|
||||||
- `RunOptions` gains a field:
|
|
||||||
```go
|
|
||||||
Force bool
|
|
||||||
```
|
|
||||||
- `Run` per-module path becomes:
|
|
||||||
```go
|
|
||||||
var plan module.Plan
|
|
||||||
if opts.Force {
|
|
||||||
plan, err = mod.PlanForce(ctx)
|
|
||||||
} else {
|
|
||||||
cur, derr := mod.Detect(ctx)
|
|
||||||
if derr != nil { /* existing error handling */ }
|
|
||||||
plan, err = mod.Plan(ctx, cur)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
When `Force` is true, `Detect` is not called.
|
|
||||||
|
|
||||||
The Apply and Verify phases are unchanged. Force does not affect DAG ordering, reporter output, or halt-on-error behavior.
|
|
||||||
|
|
||||||
## CLI changes
|
|
||||||
|
|
||||||
`cmd/archduino/main.go::newInstallCmd()`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
var force bool
|
|
||||||
cmd.Flags().BoolVar(&force, "force", false,
|
|
||||||
"Reapply every selected module, ignoring current state")
|
|
||||||
```
|
|
||||||
|
|
||||||
In `RunE`, propagate to both engine and wizard:
|
|
||||||
```go
|
|
||||||
runOpts.Force = force
|
|
||||||
initialState.Force = force
|
|
||||||
```
|
|
||||||
|
|
||||||
## Wizard / TUI integration
|
|
||||||
|
|
||||||
`internal/tui/state.go` — `WizardState` gains:
|
|
||||||
```go
|
|
||||||
Force bool
|
|
||||||
```
|
|
||||||
|
|
||||||
`internal/tui/wizard/wizard.go` — the `planCache` path branches:
|
|
||||||
- When `state.Force` is true, call `PlanForce(ctx)` instead of `Detect` + `Plan`.
|
|
||||||
- When a user toggles a module's selection, the cache key includes `force` so caches from non-force runs are not reused.
|
|
||||||
- Header gains a persistent banner: `⚠ Force mode: all selected modules will be reapplied`.
|
|
||||||
|
|
||||||
## Data flow
|
|
||||||
|
|
||||||
```
|
|
||||||
user: archduino install --force
|
|
||||||
→ main.go: force=true → RunOptions.Force = true, WizardState.Force = true
|
|
||||||
→ wizard.Run: per-module preview uses PlanForce (skips Detect)
|
|
||||||
→ user confirms
|
|
||||||
→ engine.Run: per-module PlanForce → Apply → Verify
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
No new failure modes. `PlanForce` errors are handled identically to `Plan` errors: reported via the reporter, counted in the engine summary, respected by `--halt-on-error`.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**Per-module tests** — each existing `*_test.go` gains a `TestPlanForce` case. The test arranges a "satisfied" system state (mocked platform says everything is installed, configs exist), calls `PlanForce(ctx)`, and asserts the returned plan is non-empty and contains the expected reinstall/rewrite actions. This is the core behavioral contract — without this test, a module could silently "forget" to implement force.
|
|
||||||
|
|
||||||
**Engine test** — `engine_test.go` gets a case using a fake module that records whether `Detect`, `Plan`, and `PlanForce` were called. Runs with `RunOptions.Force: true` and asserts `Detect` was NOT called and `PlanForce` WAS.
|
|
||||||
|
|
||||||
**Platform tests** — `pacman_test.go` and `aur_test.go` each gain a `TestReinstall` that verifies the command-line args include `-S` and `--noconfirm` but do NOT include `--needed`.
|
|
||||||
|
|
||||||
**No TUI test for the banner** — visual-only, covered by the existing wizard tests for preview-cache behavior which will get a new "force preview" case.
|
|
||||||
|
|
||||||
## Interaction with existing features
|
|
||||||
|
|
||||||
- `--dry-run --force` prints the force plan and exits. Existing dry-run code path is unchanged.
|
|
||||||
- `--only`/`--skip` respect force: force applies only to the selected set.
|
|
||||||
- `--halt-on-error` respects force identically.
|
|
||||||
- Theme application at end of install (Plan 4a feature) runs unchanged; force doesn't skip it.
|
|
||||||
|
|
||||||
## File touch list
|
|
||||||
|
|
||||||
**Modified:**
|
|
||||||
- `internal/module/module.go` — add `PlanForce` to `Module` interface
|
|
||||||
- `internal/engine/engine.go` — add `RunOptions.Force`, branch in `Run`
|
|
||||||
- `internal/engine/engine_test.go` — new force test
|
|
||||||
- `internal/tui/state.go` — add `WizardState.Force`
|
|
||||||
- `internal/tui/wizard/wizard.go` — force-aware preview cache + banner
|
|
||||||
- `internal/tui/wizard/wizard_test.go` — new preview test with force
|
|
||||||
- `internal/platform/pacman.go` — add `Reinstall` method
|
|
||||||
- `internal/platform/pacman_test.go` — new test
|
|
||||||
- `internal/platform/aur.go` — add `Reinstall` method
|
|
||||||
- `internal/platform/aur_test.go` — new test
|
|
||||||
- `cmd/archduino/main.go` — add `--force` flag, wire through
|
|
||||||
- 13 module files (`internal/modules/*/*.go`) — implement `PlanForce`
|
|
||||||
- 13 module test files — `TestPlanForce` each
|
|
||||||
|
|
||||||
**New:** none.
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# Plan 3a — Install Modules (Simple/Medium Tier) Design
|
|
||||||
|
|
||||||
**Status:** Approved 2026-04-14
|
|
||||||
**Scope:** Port 10 of the 13 Bash install modules to Go. Plan 3b (neovim, renode, scaffold) is a separate brainstorm.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Port the 10 simple/medium `lib/*.sh` installer modules into Go packages under `internal/modules/`, each implementing the `module.Module` contract (Detect / Plan / Apply / Verify). Every module is unit-tested against an injected fake `platform.Runner`. Along the way, add the platform helpers these modules need (AUR, download, extract, git-clone, text-block writer) and fix the wizard `renderPlan` UI-blocking watchpoint flagged at the end of Plan 2.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Foundation tasks land first, then modules in simplest-first order:
|
|
||||||
|
|
||||||
1. **Wizard cache fix** — cache Detect+Plan results per module; invalidate on selection toggle. One small change, but it unblocks every downstream module from stalling the UI.
|
|
||||||
2. **Platform helpers** — `AURHelper` (yay), `Download`, `Extract`, `gitclient`, `textblock`. Each gets its own file, each gets TDD against a fake Runner (except `extract` which uses real tempdirs with fixture archives).
|
|
||||||
3. **Ten modules** in this order: packages → gittools(lazygit) → starship → yazi → zsh → fonts → zellij → themes → arduinocli → shellconfig.
|
|
||||||
|
|
||||||
All module inputs (package lists, versions, URLs, plugin repos, shell-config blocks) are typed fields on `config.Config` populated from the embedded `defaults.yaml`. Overlay override plumbing already exists from Plan 1. Modules take their inputs as struct fields at construction time — no globals.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
Go, existing `internal/platform` + `internal/module` + `internal/engine` + `internal/tui` packages. No new external dependencies expected beyond what Plan 1/2 pulled in.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
### New — platform helpers
|
|
||||||
|
|
||||||
- `internal/platform/aur.go` + `aur_test.go`
|
|
||||||
- `AURHelper` interface, `Yay` implementation.
|
|
||||||
- `EnsureInstalled(ctx) error` — bootstraps yay from AUR via `git clone https://aur.archlinux.org/yay.git /tmp/... && makepkg -si --noconfirm` when missing.
|
|
||||||
- `Install(ctx, pkgs []string) error` — `yay -S --needed --noconfirm <pkgs...>`.
|
|
||||||
- Takes a `Runner` and a `PackageManager` (to check if `yay` itself is installed).
|
|
||||||
|
|
||||||
- `internal/platform/download.go` + `download_test.go`
|
|
||||||
- `Download(ctx, url, destPath string, mode os.FileMode) error`.
|
|
||||||
- Atomic-ish: write to `destPath+".tmp"` then rename. Clean up on error.
|
|
||||||
- Tested with `httptest.Server`.
|
|
||||||
|
|
||||||
- `internal/platform/extract.go` + `extract_test.go`
|
|
||||||
- `ExtractTarGz(ctx, archivePath, destDir string) error`.
|
|
||||||
- `ExtractZip(ctx, archivePath, destDir string) error`.
|
|
||||||
- Tested against real fixture archives in `testdata/`.
|
|
||||||
|
|
||||||
- `internal/platform/git.go` + `git_test.go`
|
|
||||||
- `GitClient` struct taking a `Runner`.
|
|
||||||
- `Clone(ctx, url, dest string) error`.
|
|
||||||
- `EnsureRepo(ctx, url, dest string) error` — clone if `dest/.git` missing, otherwise noop.
|
|
||||||
|
|
||||||
- `internal/platform/textblock.go` + `textblock_test.go`
|
|
||||||
- `EnsureBlock(path, marker, content string) error` — writes or replaces a block delimited by `# >>> marker >>>` / `# <<< marker <<<` markers. Creates the file if missing.
|
|
||||||
- `RemoveBlock(path, marker string) error`.
|
|
||||||
- `HasBlock(path, marker string) (bool, error)`.
|
|
||||||
- Table-driven tests: insert, replace, remove, preserve surrounding content, missing-file, empty-file.
|
|
||||||
|
|
||||||
### New — modules
|
|
||||||
|
|
||||||
Each lives in its own package under `internal/modules/<name>/` with `<name>.go` + `<name>_test.go`.
|
|
||||||
|
|
||||||
- `packages` — installs base pacman package list from `cfg.Packages.Base`.
|
|
||||||
- `gittools` — installs lazygit (pacman). Named `gittools` to avoid collision with Go stdlib / pacman `git`.
|
|
||||||
- `starship` — installs starship (pacman).
|
|
||||||
- `yazi` — installs yazi (pacman).
|
|
||||||
- `zsh` — installs zsh (pacman) + clones oh-my-zsh + configured plugins via `gitclient`.
|
|
||||||
- `fonts` — installs AUR fonts (e.g. `ttf-jetbrains-mono-nerd`) via `AURHelper`. First action is `EnsureYayInstalled` if needed.
|
|
||||||
- `zellij` — installs zellij (pacman) + downloads config.
|
|
||||||
- `themes` — copies theme files from source directory (bundled under `lib/themes/` or overridable via `cfg.Themes.Source`).
|
|
||||||
- `arduinocli` — downloads arduino-cli tarball, extracts to `~/.local/bin`, runs `arduino-cli core install <cores...>` from `cfg.ArduinoCLI.Cores`.
|
|
||||||
- `shellconfig` — writes marker-delimited blocks into `~/.zshrc`, `~/.bashrc`, etc., using `textblock.EnsureBlock`. Blocks defined in `cfg.ShellConfig.Blocks`.
|
|
||||||
|
|
||||||
### Modified
|
|
||||||
|
|
||||||
- `internal/config/config.go` + `internal/config/defaults.yaml` — add typed fields:
|
|
||||||
- `Packages.Base []string`
|
|
||||||
- `Fonts []string`
|
|
||||||
- `ArduinoCLI{Version string, URL string, Cores []string}`
|
|
||||||
- `Starship{Version string}` (reserved; pacman install doesn't need it but overlay may)
|
|
||||||
- `Zsh{OhMyZshRepo string, Plugins []struct{Name, Repo string}}`
|
|
||||||
- `Zellij{ConfigURL string}`
|
|
||||||
- `Themes{Source string}` (defaults to embedded `lib/themes/`)
|
|
||||||
- `ShellConfig{Blocks []struct{Name, File, Content string}}`
|
|
||||||
- `internal/tui/wizard/wizard.go` — cache Detect+Plan per module; invalidate on selection toggle.
|
|
||||||
- Module registry (wherever `cmd/archduino/install.go` currently registers `hello`) — register the 10 new modules instead of / alongside `hello`.
|
|
||||||
|
|
||||||
## Module Contract Details
|
|
||||||
|
|
||||||
All modules follow the same shape:
|
|
||||||
|
|
||||||
- **Detect** — pure read. `PackageManager.IsInstalled` per pkg, `os.Stat` per file, `textblock.HasBlock` per marker. Returns a module-private `state` struct. Must be fast (wizard caches results, but Detect still shouldn't shell out more than necessary).
|
|
||||||
- **Plan** — given current state, returns `Plan{Actions: []Action}`. Empty actions = already converged (idempotent no-op). No side effects.
|
|
||||||
- **Apply** — executes each action sequentially, reporting progress via `Reporter.Progress(id, i, total, description)`.
|
|
||||||
- **Verify** — re-runs Detect and asserts convergence. Returns a descriptive error on mismatch.
|
|
||||||
|
|
||||||
Error semantics from the engine (`--halt-on-error` flag, sibling-on-failure continuation) apply unchanged.
|
|
||||||
|
|
||||||
### Module archetypes
|
|
||||||
|
|
||||||
1. **Pacman-only** (packages, gittools, starship, yazi, zellij base, themes deps if any): Detect = `IsInstalled` per pkg; Plan = install missing; Apply = `Pacman.Install`; Verify = all installed.
|
|
||||||
2. **AUR** (fonts): same as pacman-only but via `AURHelper`. Plan prepends an `EnsureYay` action if yay is not installed.
|
|
||||||
3. **Download + extract** (arduinocli): Detect = probe binary + `--version`; Plan = download + extract + symlink + `arduino-cli core install`; Verify = version string matches.
|
|
||||||
4. **Git-clone configs** (zsh oh-my-zsh + plugins, zellij config fetch if URL-based, themes if sourced from repo): Detect = check `.git` or file presence; Plan = `gitclient.EnsureRepo` or `Download`; Verify = path exists.
|
|
||||||
5. **Text-block writers** (shellconfig): Detect = `textblock.HasBlock` per block; Plan = `textblock.EnsureBlock`; Verify = re-scan.
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
**Per-module tests (all unit, all fake-Runner-based):**
|
|
||||||
|
|
||||||
1. Detect with nothing installed → absent state.
|
|
||||||
2. Detect with everything installed → present state.
|
|
||||||
3. Plan(absent) → non-empty actions matching config inputs.
|
|
||||||
4. Plan(present) → empty actions (idempotent no-op).
|
|
||||||
5. Apply → records correct shell commands against fake Runner.
|
|
||||||
6. Verify after Apply → passes when fake reports success.
|
|
||||||
|
|
||||||
**Platform helper tests:**
|
|
||||||
|
|
||||||
- `aur_test.go` — yay bootstrap path (fake Runner sees `git clone` + `makepkg -si`), install path, already-installed short-circuit.
|
|
||||||
- `download_test.go` — `httptest.Server` for happy path + 404 + mid-download error cleanup.
|
|
||||||
- `extract_test.go` — real tar/zip fixtures in `testdata/` extracted into `t.TempDir()`.
|
|
||||||
- `git_test.go` — fake Runner for clone-if-missing, skip-if-present.
|
|
||||||
- `textblock_test.go` — table-driven: insert new, replace existing, remove, preserve surrounding content, missing-file, empty-file.
|
|
||||||
|
|
||||||
**Wizard caching test:** assert `Detect` is called once per module regardless of redraw count.
|
|
||||||
|
|
||||||
**No integration tests** that touch real pacman, real AUR, or the real network. That's Plan 4 (CI with Arch container). Manual smoke after plan completes.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Plan 3b modules (neovim, renode, scaffold) — separate brainstorm.
|
|
||||||
- Subcommands beyond `install` (theme/new/doctor) — Plan 4.
|
|
||||||
- `bootstrap.sh`, goreleaser, CI — Plan 4.
|
|
||||||
- Debian/Fedora PackageManager implementations — Plan 4+.
|
|
||||||
- Integration tests against real package managers — Plan 4.
|
|
||||||
|
|
||||||
## Open Risks / Watchpoints
|
|
||||||
|
|
||||||
- **AUR bootstrap fragility** — if `makepkg -si --noconfirm` prompts despite `--noconfirm` (sudo password mid-build), the fake-Runner tests won't catch it. Manual smoke on fonts module is essential.
|
|
||||||
- **arduino-cli URL drift** — hardcoded version in `defaults.yaml` will go stale. Acceptable for v1; doctor subcommand in Plan 4 can warn.
|
|
||||||
- **Theme file source** — if themes ship bundled via Go `embed`, we need to decide that at module time. Default assumption: copy from `lib/themes/` on disk (same behavior as bash); switch to `embed` only if it's a clear win.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# Plan 3b — Install Modules (Heavy Tier) Design
|
|
||||||
|
|
||||||
**Status:** Approved 2026-04-14
|
|
||||||
**Scope:** Port 2 of the 3 remaining Bash install modules (`neovim`, `renode`). The third (`scaffold`) is deferred to Plan 4, where its functionality is absorbed natively into the `archduino new` and `archduino theme` subcommands rather than shipped as bash helper scripts.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Port the `neovim` and `renode` Bash modules to Go, each implementing the `module.Module` four-phase contract, unit-tested against a fake `platform.Runner`. Add one small platform helper (`WriteUserFile`), extend `config.Config` with the new modules' inputs, extend `tui.Theme` so the wizard's theme selection flows into NvChad's `chadrc.lua`, and register the two modules in the install command.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **neovim**: pacman-install neovim (Arch ships 0.11.x, no upstream-tarball fallback); clone the NvChad starter to `~/.config/nvim` if no existing config; if a non-NvChad config is detected, back it up to `~/.config/nvim.bak.<RFC3339>` and install fresh (no LazyVim-specific salvage — deliberate scope cut); strip the starter's `nvim-lspconfig` block (we use native `vim.lsp`); render `chadrc.lua` from an embedded template with the selected theme's `NvChadTheme`; write five static Lua asset files (`arduino.lua`, `clangd.lua`, `lspconfig.lua`, `dap.lua`, `mappings.lua`); clear NvChad's base46 theme cache.
|
|
||||||
- **renode**: AUR-install `renode-bin` (via `AURHelper`); pacman-install `arm-none-eabi-gdb`; render and write `~/.local/bin/arduino-sim` from an embedded bash template driven by a platform map in config; download `wokwi-cli` from a version-pinned URL into `~/.local/bin/wokwi-cli` (mode 0755).
|
|
||||||
- Both modules' artifacts (Lua files, bash helper) live as real files under `internal/modules/<name>/assets/` and are bundled via `go:embed`. No sudo required at runtime; everything lands in user-owned paths.
|
|
||||||
- One new platform helper, `platform.WriteUserFile(path, data, mode)`, consolidates the repeated `os.MkdirAll + os.WriteFile` pattern and does an atomic tmp+rename write. Plan 3a modules are not retrofitted — they stay as-is.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
Go stdlib only for the new helper and modules (`embed`, `text/template`, `os`, `path/filepath`). Relies on all Plan 3a platform helpers (pacman, AUR, download, extract, git) and on `internal/module` / `internal/engine` / `internal/tui` from Plans 1-2.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
### New — platform helper
|
|
||||||
|
|
||||||
- `internal/platform/writefile.go` + `writefile_test.go`
|
|
||||||
- `WriteUserFile(path string, data []byte, mode os.FileMode) error`
|
|
||||||
- Creates parent directory (0755) if missing, writes to `path+".tmp"`, renames to `path`.
|
|
||||||
- Tests: happy path (content + mode), missing parent dir (creates it), overwrite preserves atomicity (crash-proof against partial writes is out of scope; atomic rename is sufficient).
|
|
||||||
|
|
||||||
### New — modules
|
|
||||||
|
|
||||||
- `internal/modules/neovim/neovim.go` + `neovim_test.go` + `assets/`
|
|
||||||
- `assets/chadrc.lua.tmpl` — `text/template` with `{{.NvChadTheme}}`.
|
|
||||||
- `assets/arduino.lua`, `assets/clangd.lua`, `assets/lspconfig.lua`, `assets/dap.lua`, `assets/mappings.lua` — static, copied verbatim from the bash heredocs in `lib/neovim.sh`.
|
|
||||||
- Constructor: `New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module`.
|
|
||||||
- State: `NvimInstalled, StarterCloned, ChadrcPresent, BackupNeeded bool`, `MissingAssets []string`, `ThemeName string` (resolved at Detect time).
|
|
||||||
|
|
||||||
- `internal/modules/renode/renode.go` + `renode_test.go` + `assets/`
|
|
||||||
- `assets/arduino-sim.tmpl` — `text/template` with `{{range .Platforms}}...{{end}}` rendering the case-statement platform map from config.
|
|
||||||
- Constructor: `New(cfg config.RenodeConfig, pm platform.PackageManager, aur platform.AURHelper) Module`.
|
|
||||||
- State: `RenodeInstalled, GdbInstalled, SimScriptCurrent, WokwiInstalled bool`.
|
|
||||||
|
|
||||||
### Modified
|
|
||||||
|
|
||||||
- `internal/config/config.go` + `internal/config/defaults.yaml`
|
|
||||||
- `type NeovimConfig struct { NvChadStarterRepo string }`
|
|
||||||
- `type RenodePlatform struct { Name, ReplPath, Machine string }`
|
|
||||||
- `type RenodeConfig struct { WokwiVersion, WokwiURL string; Platforms []RenodePlatform }`
|
|
||||||
- `Config` gains `Neovim NeovimConfig` and `Renode RenodeConfig`.
|
|
||||||
- `merge` gains overlay-replace clauses for both (matching Plan 3a's pattern).
|
|
||||||
- `defaults.yaml` populated with NvChad starter URL, pinned wokwi version/URL, and the three platform entries from `lib/renode.sh`.
|
|
||||||
|
|
||||||
- `internal/tui` (wherever `BuiltinThemes` is declared — likely `internal/tui/state.go` or a themes file)
|
|
||||||
- `Theme` gains `NvChadTheme string`. Populate for each builtin entry (one-line edits per theme).
|
|
||||||
- Helper `Theme(themes []Theme, id string) (Theme, bool)` for the neovim module to resolve its themeID. (If an equivalent helper already exists, reuse it.)
|
|
||||||
|
|
||||||
- `cmd/archduino/main.go` — `buildRegistry` gains a `selectedThemeID string` parameter; registers neovim and renode. TUI path: call `buildRegistry(cfg, tui.DefaultThemeID())` before the wizard, then after the wizard returns with a theme selection, call it again with the selected ID before invoking the dashboard. Plain-output path: pass `cfg`'s default theme directly.
|
|
||||||
|
|
||||||
## Module Contract Details
|
|
||||||
|
|
||||||
Both modules use the Plan 3a archetype pattern:
|
|
||||||
|
|
||||||
- **Detect** — pure reads: `PackageManager.IsInstalled`, `os.Stat`, byte-compare of rendered/static asset files against their destination paths. For neovim, `~/.config/nvim/lua/chadrc.lua` acts as the NvChad-marker; any `lua/` dir without chadrc triggers `BackupNeeded`.
|
|
||||||
- **Plan** — emit only missing actions; empty plan = already converged (idempotent).
|
|
||||||
- **Apply** — sequential actions with `Reporter.Progress`; no action is retried.
|
|
||||||
- **Verify** — re-run Detect; every boolean must be true and `MissingAssets`/related slices must be empty.
|
|
||||||
|
|
||||||
Error semantics unchanged from Plan 3a — engine's `--halt-on-error` and sibling-continuation rules apply.
|
|
||||||
|
|
||||||
## Theme Propagation
|
|
||||||
|
|
||||||
The wizard's `ThemeID` is captured into `tui.WizardState.ThemeID` as before. `buildRegistry` takes the themeID as a second parameter; for the TUI install path, the registry is rebuilt after the wizard confirms selection and before the dashboard runs. For the plain-output path (`--no-tui` / `--dry-run`), the default theme ID is used. The `neovim` module resolves the ID to a `tui.Theme` at construction and stores the `NvChadTheme` for Detect/Plan. If an unknown ID is passed, neovim falls back to the first builtin theme's NvChad name (no hard error — wizards shouldn't crash the installer on a config bug).
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### neovim (all unit, tempdir-based)
|
|
||||||
|
|
||||||
- `TestDetectFreshSystem` — no nvim, no config → plans pacman install + clone starter + strip lspconfig + write assets.
|
|
||||||
- `TestDetectExistingNvChad` — chadrc.lua present → no backup planned; missing-assets test still exercises Plan.
|
|
||||||
- `TestDetectNonNvChadTriggersBackup` — `lua/` with no chadrc → `BackupNeeded=true`; Plan includes a rename-to-bak action.
|
|
||||||
- `TestPlanApplyInstallsEverything` — fake Runner + fake GitClient + tempdir HOME; asserts pacman call, git clone call, backup done, assets written with expected content, chadrc contains the theme name.
|
|
||||||
- `TestPlanAfterApplyIsEmpty` — idempotency.
|
|
||||||
- `TestUnknownThemeFallsBackToFirst` — construction with a bogus ID still produces a valid chadrc.
|
|
||||||
|
|
||||||
### renode (all unit)
|
|
||||||
|
|
||||||
- `TestDetectNothingInstalled` — plans AUR install + pacman install + sim-script write + wokwi download.
|
|
||||||
- `TestDetectAllInstalled` — empty plan.
|
|
||||||
- `TestApplyWritesSimScript` — rendered script contains each configured platform's case-branch.
|
|
||||||
- `TestApplyDownloadsWokwi` — httptest server serves a short payload; verify file written to `~/.local/bin/wokwi-cli` with mode 0755.
|
|
||||||
|
|
||||||
### platform.WriteUserFile
|
|
||||||
|
|
||||||
- `TestWriteUserFileCreatesParent` — target under a non-existent dir → parent created.
|
|
||||||
- `TestWriteUserFileAtomicity` — two sequential writes of different content → final content matches the second; no `.tmp` leaked after success.
|
|
||||||
- `TestWriteUserFileErrorCleanup` — a failed write leaves no `.tmp` turd (simulate via read-only parent or unwritable tmp; accept either form if OS makes it awkward).
|
|
||||||
|
|
||||||
No integration tests against real pacman, yay, neovim, renode, or GitHub; that's Plan 4's CI.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- `scaffold` module — deferred to Plan 4 as native Go subcommands (`archduino new`, `archduino theme`).
|
|
||||||
- Upstream Neovim tarball fallback (`/opt/nvim-linux-x86_64`) — pacman is sufficient; version-drift concerns go to Plan 4's `doctor`.
|
|
||||||
- LazyVim → NvChad plugin salvage — existing non-NvChad configs get a full backup; user re-adds plugins manually.
|
|
||||||
- Wokwi GitHub-API "latest" lookup — version is pinned in config, overrideable via overlay.
|
|
||||||
- Retrofitting Plan 3a modules to use `WriteUserFile` — opportunistic cleanup only, not a Plan 3b deliverable.
|
|
||||||
- Retroactive theme-sync across every tool (starship, zellij, yazi, lazygit) — Plan 4's `archduino theme` subcommand.
|
|
||||||
|
|
||||||
## Open Risks / Watchpoints
|
|
||||||
|
|
||||||
- **NvChad starter drift** — if upstream changes `lua/plugins/init.lua`'s `nvim-lspconfig` block format, our sed-equivalent strip-logic in Go will miss it. Mitigation: implement as "find the `{` / `},` block containing the string `neovim/nvim-lspconfig` and delete it", unit-test against a fixture copy of the current starter's `init.lua`.
|
|
||||||
- **arduino-sim pinned to Renode's REPL paths** — if Renode renames `platforms/cpus/rp2040.repl`, the sim script breaks. Acceptable for v1; surface via Plan 4 doctor.
|
|
||||||
- **Theme propagation race** — rebuilding the registry twice around the wizard is ugly. Alternative considered (module-level `SetTheme` after construction) was rejected as un-idiomatic. Acceptable for 2 modules; revisit if more theme-aware modules appear.
|
|
||||||
- **Asset byte-equality idempotency** — if a user edits their `mappings.lua` by hand, every Apply will overwrite it. Documented behavior; `--force-assets` flag is a Plan 4 concern.
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# Plan 4a — User-Facing Subcommands Design
|
|
||||||
|
|
||||||
**Status:** Approved 2026-04-14
|
|
||||||
**Scope:** Ship three new subcommands — `archduino theme`, `archduino new`, `archduino doctor` — plus the shared `theming` package they all build on. This plan absorbs the functionality of `lib/scaffold.sh` (originally deferred from Plan 3b) and `lib/themes/*.sh` natively into Go, without shipping bash wrappers.
|
|
||||||
|
|
||||||
Plan 4b (reinstall/reset mode), 4c (bootstrap + goreleaser), and 4d (CI) are separate plans with independent specs.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Complete user-facing feature parity with the Bash installer by adding three subcommands: a theme switcher that updates starship/zellij/yazi/lazygit/NvChad configs in place; a project scaffolder for Arduino sketches; and a doctor command that reports module convergence and pinned-tool version drift.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
One new shared package and two subcommand packages:
|
|
||||||
|
|
||||||
- **`internal/theming/`** — reusable package that embeds per-theme config assets (5 themes × 4 tool configs + a manifest each) and exposes `List() []Theme`, `Get(id) (Theme, bool)`, and `Apply(cfg, themeID) error`. Called from both the `install` command (applies the wizard's selected theme at the end of an install run) and the new `theme` subcommand.
|
|
||||||
- **`internal/subcommands/newproject/`** — owns `archduino new <project> <board>`. Board list lives in `config.Config.Scaffolding.Boards`; `.ino` starter templates embedded.
|
|
||||||
- **`internal/subcommands/doctor/`** — owns `archduino doctor`. Iterates the module registry, calls `Detect` + `Plan` on each, reports per-module status and pinned-tool version drift.
|
|
||||||
|
|
||||||
`tui.BuiltinThemes` and the `tui.Theme.NvChadTheme` field retire: the canonical theme list is now discovered from `theming`'s embedded manifests. The wizard calls `theming.List()`. `tui.Theme` stays as a thin display value (`ID`, `Name`) used by the wizard's dropdown.
|
|
||||||
|
|
||||||
The existing `install` command gains a final step: after `engine.Run` succeeds, call `theming.Apply(cfg, finalState.ThemeID)` so the initial install leaves tools themed, not just installed. This is an improvement over the Bash behavior, where the theme was never applied during install.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
Go stdlib + existing deps. `go:embed` for all assets. `gopkg.in/yaml.v3` for overlay round-trip updates. `huh` (already a dep) for the interactive theme picker. `text/template` used only where needed (neovim chadrc already uses it; theming's tool configs are static and don't).
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
### New — `internal/theming/`
|
|
||||||
|
|
||||||
- `theming.go`
|
|
||||||
- Exported: `type Theme struct { ID, DisplayName, NvChadTheme string }`
|
|
||||||
- `func List() []Theme` — returns themes in manifest-discovery order (sort by ID for determinism).
|
|
||||||
- `func Get(id string) (Theme, bool)`
|
|
||||||
- `func Apply(cfg config.Config, themeID string) error`
|
|
||||||
- Private: `discoverThemes()` run once at package init; reads `assets/*/theme.yaml`.
|
|
||||||
- `theming_test.go`
|
|
||||||
- `assets/<theme>/theme.yaml` — five files (`gruvbox`, `catppuccin`, `tokyonight`, `nord`, `dracula`), each containing:
|
|
||||||
```yaml
|
|
||||||
display_name: "Gruvbox Dark"
|
|
||||||
nvchad: "gruvbox"
|
|
||||||
```
|
|
||||||
- `assets/<theme>/starship.toml`, `zellij.kdl`, `yazi.toml`, `lazygit.yml` — 20 static config files lifted verbatim from the corresponding `write_*_config` heredocs in `lib/themes/<theme>.sh`. Tool config paths on disk are:
|
|
||||||
- starship.toml → `~/.config/starship.toml`
|
|
||||||
- zellij.kdl → `~/.config/zellij/config.kdl`
|
|
||||||
- yazi.toml → `~/.config/yazi/theme.toml`
|
|
||||||
- lazygit.yml → `~/.config/lazygit/config.yml`
|
|
||||||
|
|
||||||
### New — `internal/subcommands/newproject/`
|
|
||||||
|
|
||||||
- `newproject.go`
|
|
||||||
- `func Run(cfg config.Config, project, board string) error`.
|
|
||||||
- `newproject_test.go`
|
|
||||||
- `assets/<board>.ino` — six starter sketches (`uno`, `mega`, `nano`, `esp32`, `esp32s3`, `esp8266`) plus `default.ino`. Content lifted from `lib/scaffold.sh` heredocs.
|
|
||||||
|
|
||||||
### New — `internal/subcommands/doctor/`
|
|
||||||
|
|
||||||
- `doctor.go`
|
|
||||||
- `func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) (exitCode int)`
|
|
||||||
- `doctor_test.go`
|
|
||||||
|
|
||||||
### Modified
|
|
||||||
|
|
||||||
- `internal/config/config.go` + `internal/config/defaults.yaml`
|
|
||||||
- Add:
|
|
||||||
```go
|
|
||||||
type Board struct { Name, FQBN, Template string `yaml:"name,fqbn,template"` }
|
|
||||||
type Scaffolding struct { Boards []Board `yaml:"boards"` }
|
|
||||||
```
|
|
||||||
(adjust tag syntax — yaml tags per field as with existing types).
|
|
||||||
- `Config.Scaffolding Scaffolding`
|
|
||||||
- `merge` clause: `if len(over.Scaffolding.Boards) > 0 { out.Scaffolding = over.Scaffolding }`.
|
|
||||||
- Defaults: the 6 boards from `lib/scaffold.sh` FQBN_MAP with `template` set to the board name (so `template: "uno"` maps to `internal/subcommands/newproject/assets/uno.ino`).
|
|
||||||
|
|
||||||
- `internal/modules/neovim/neovim.go`
|
|
||||||
- Extract the chadrc render+write logic into an exported helper the theming package can call. Suggested signature: `func RewriteChadrc(themeID string, themes []Theme) error` (takes themes so the module doesn't depend on the theming package — avoid import cycle). Or simpler: `func WriteChadrc(nvChadThemeName string) error` — takes the already-resolved NvChad name, no theme-registry knowledge needed. The helper no-ops if `~/.config/nvim/lua/chadrc.lua` doesn't exist.
|
|
||||||
|
|
||||||
- `internal/tui/state.go`
|
|
||||||
- Remove `BuiltinThemes`, remove `NvChadTheme` field from `Theme`, remove `ThemeByID`. Wizard takes a `[]Theme` parameter or reads from `theming.List()` directly.
|
|
||||||
- Option: delete `tui.Theme` entirely and have the wizard use `theming.Theme` directly. Weigh the import coupling: `internal/tui` already imports `internal/config`, and `theming` imports `config` too, so `tui → theming` is fine.
|
|
||||||
|
|
||||||
- `internal/tui/wizard/wizard.go`
|
|
||||||
- Populate the theme dropdown from `theming.List()`.
|
|
||||||
|
|
||||||
- `cmd/archduino/main.go`
|
|
||||||
- Add three `cobra.Command` constructors: `newThemeCmd()`, `newProjectCmd()`, `newDoctorCmd()`.
|
|
||||||
- Register them on the root command alongside `install`.
|
|
||||||
- In the `install` RunE, after `engine.Run` returns success, call `theming.Apply(cfg, finalState.ThemeID)` and surface any error.
|
|
||||||
- `buildRegistry` no longer needs to receive `themeID` for neovim's chadrc — neovim's install path writes chadrc based on the ID threaded through `neovim.New`, and `theming.Apply` overwrites it afterward. Keeping the parameter is fine; it remains useful for the initial chadrc write.
|
|
||||||
|
|
||||||
## Command surfaces
|
|
||||||
|
|
||||||
- `archduino theme` → interactive huh-based picker listing themes; pre-selects `cfg.ThemeOverrides["__active"]` (or the first theme if unset). On confirm, calls `theming.Apply`.
|
|
||||||
- `archduino theme <id>` → non-interactive; calls `theming.Apply(cfg, id)`. Errors if unknown.
|
|
||||||
- `archduino theme list` → prints `<id> — <display_name>` one per line to stdout.
|
|
||||||
- `archduino new <project> <board>` → see data-flow below.
|
|
||||||
- `archduino doctor` → see data-flow below.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### theming.Apply
|
|
||||||
|
|
||||||
1. Look up theme by ID; return error if unknown (message lists valid IDs).
|
|
||||||
2. For each of the four tool configs under `assets/<theme>/`, read via `embed.FS` and write to its target path via `platform.WriteUserFile` with mode 0o644.
|
|
||||||
3. If `~/.config/nvim/lua/chadrc.lua` exists, call `neovim.WriteChadrc(theme.NvChadTheme)` to re-render it. If it doesn't exist, skip silently (user hasn't run install yet, or removed nvim config).
|
|
||||||
4. Idempotently clear NvChad's theme cache: `os.RemoveAll(~/.cache/nvim/base46)` and `os.RemoveAll(~/.local/share/nvim/lazy/base46/lua/base46/themes)`. Errors ignored — cache may not exist.
|
|
||||||
5. Update the user's overlay file at `~/.config/archduino/overlay.yaml`: load existing YAML (or empty), set `theme_overrides.__active: <id>`, write back via a YAML round-trip using `gopkg.in/yaml.v3` decode-into-map-then-encode. First-run creates the file with the single key.
|
|
||||||
|
|
||||||
### newproject.Run
|
|
||||||
|
|
||||||
1. Validate `project`: non-empty, no `/` or `\\`, doesn't start with `.`.
|
|
||||||
2. Look up board in `cfg.Scaffolding.Boards`. If absent, return an error whose message lists all valid board names, one per line.
|
|
||||||
3. `os.Stat(project)`; if it exists, return "project directory already exists". Do not overwrite.
|
|
||||||
4. `os.MkdirAll(project, 0o755)`.
|
|
||||||
5. Determine `.ino` asset path: `<board.Template>.ino`, falling back to `default.ino` if the board-specific asset is missing. Write to `<project>/<project>.ino` via `platform.WriteUserFile` mode 0o644.
|
|
||||||
6. Write `<project>/sketch.yaml`:
|
|
||||||
```yaml
|
|
||||||
default_fqbn: <FQBN>
|
|
||||||
default_port: auto
|
|
||||||
```
|
|
||||||
7. Write `<project>/.clangd`:
|
|
||||||
```yaml
|
|
||||||
CompileFlags:
|
|
||||||
Add:
|
|
||||||
- -xc++
|
|
||||||
- -std=c++17
|
|
||||||
```
|
|
||||||
8. Print a success line to stdout: `Created <project>/ (fqbn: <FQBN>). Next: cd <project> && nvim <project>.ino`.
|
|
||||||
|
|
||||||
### doctor.Run
|
|
||||||
|
|
||||||
1. For each module in `reg.All()`: call `Detect(ctx)`, then `Plan(ctx, state)`. Format output one row per module:
|
|
||||||
- `[✓] <id>` — empty plan (converged).
|
|
||||||
- `[✗] <id> (N actions pending)` — non-empty plan.
|
|
||||||
- `[!] <id> — detect failed: <err>` — Detect/Plan returned error. Never propagate the error up; record and continue.
|
|
||||||
2. Version drift checks (only if `arduino-cli` / `wokwi-cli` are found on PATH via the injected `Runner`):
|
|
||||||
- `arduino-cli version --format json` → parse `VersionString`, compare to `cfg.ArduinoCLI.Version`. Print `[✓] arduino-cli <ver>` or `[!] arduino-cli drift: got <X>, want <Y>`.
|
|
||||||
- `wokwi-cli --version` → parse first line's version-like token, compare to `cfg.Renode.WokwiVersion`. Same output format.
|
|
||||||
3. Summary line: `<converged>/<total> converged, <pending> need-install, <drift> drift, <errors> errors`.
|
|
||||||
4. Exit code: 0 if all modules converged AND no drift AND no errors; else 1. Return value from `Run` is the exit code; the cobra RunE wrapper calls `os.Exit(code)`.
|
|
||||||
|
|
||||||
No network calls. No writes. Fast enough to run interactively (<5s on a typical install).
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- `theming.Apply`: first write error aborts. No rollback. User re-runs after fixing (partial state is a benign overlap of two themes' configs on disk until re-run). Overlay update happens last; if a config write fails, the overlay is not touched, so `__active` still reflects the previously-applied theme.
|
|
||||||
- `newproject.Run`: refuses to overwrite an existing project directory. All other failures (mkdir, write) bubble up to the cobra layer and print via `RunE`'s default error handling.
|
|
||||||
- `doctor.Run`: never returns a Go error. Module failures are reported inline; only a programmer error (e.g., nil registry) would panic.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### theming_test.go (all unit, tempdir-based $HOME)
|
|
||||||
|
|
||||||
- `TestListReadsManifests` — asserts 5 themes discovered, each with non-empty DisplayName and NvChadTheme.
|
|
||||||
- `TestGetKnownAndUnknown` — happy path + false return for bogus ID.
|
|
||||||
- `TestApplyWritesAllFourConfigs` — `t.Setenv("HOME", …)`; `Apply(cfg, "dracula")`; assert each of the 4 target files exists with non-empty content and the first line matches the asset's first line (robust to copy-paste drift in tests).
|
|
||||||
- `TestApplyUpdatesOverlayActive` — after Apply, `~/.config/archduino/overlay.yaml` contains `theme_overrides.__active: dracula` (round-trip through `gopkg.in/yaml.v3`).
|
|
||||||
- `TestApplyPreservesOtherOverlayKeys` — seed overlay with `extra_packages: [foo]`, run Apply, assert `extra_packages` still present after write.
|
|
||||||
- `TestApplyRewritesChadrcIfPresent` — seed `~/.config/nvim/lua/chadrc.lua` with gruvbox; Apply dracula; assert file now contains `theme = "dracula"`.
|
|
||||||
- `TestApplyNoChadrcIsFine` — no chadrc on disk; Apply succeeds.
|
|
||||||
- `TestApplyUnknownThemeErrors`.
|
|
||||||
|
|
||||||
### newproject_test.go
|
|
||||||
|
|
||||||
- `TestNewCreatesProject` — config with `uno` board; project dir doesn't exist; `Run(cfg, "blinky", "uno")` in a tempdir (chdir); assert `blinky/blinky.ino` + `sketch.yaml` (contains `arduino:avr:uno`) + `.clangd` exist.
|
|
||||||
- `TestNewRefusesExistingDir` — pre-create the target dir; `Run` returns error; dir untouched.
|
|
||||||
- `TestNewFallsBackToDefaultTemplate` — config board entry whose `.template` points at an asset that doesn't exist (use a made-up board with `template: "xyz"`); Run succeeds and the generated `.ino` matches `default.ino` content.
|
|
||||||
- `TestNewUnknownBoardErrors` — error message lists all valid board names.
|
|
||||||
- `TestNewRejectsSlashInProjectName`.
|
|
||||||
|
|
||||||
### doctor_test.go
|
|
||||||
|
|
||||||
- `TestDoctorAllConvergedExitsZero` — single stub module returning empty Plan; runner that makes version commands not-found (`exec: "arduino-cli": executable file not found in $PATH`-style error — skip version checks cleanly). Exit code 0.
|
|
||||||
- `TestDoctorDivergedExitsOne` — single stub module returning a Plan with actions; exit code 1.
|
|
||||||
- `TestDoctorDetectErrorIsReported` — module whose Detect returns an error; doctor prints `[!]` and exits 1.
|
|
||||||
- `TestDoctorArduinoCLIDriftIsReported` — injected Runner returns a different version for `arduino-cli version --format json`; exit code 1; output contains "drift".
|
|
||||||
- `TestDoctorMissingBinariesSkipVersionCheck` — runner returns "not found"; no false drift report; exit reflects module status only.
|
|
||||||
|
|
||||||
### install-command integration (smoke-level)
|
|
||||||
|
|
||||||
- Existing `install` tests continue to pass; add a unit test that stubs `theming.Apply` and asserts `install` calls it after a successful `engine.Run` with the wizard's ThemeID.
|
|
||||||
|
|
||||||
No integration tests against real starship/zellij/arduino-cli/wokwi binaries — Plan 4d (CI) introduces those.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Per-user theme creation / custom palettes — pinned to the five built-in themes.
|
|
||||||
- Live reload: users must restart their shell, zellij session, etc. manually (bash also required this).
|
|
||||||
- `archduino doctor --fix` mode — reserved for a future plan, possibly merged with Plan 4b's reinstall/reset.
|
|
||||||
- Version-drift remediation — doctor only reports.
|
|
||||||
- `archduino new` support for non-Arduino-CLI ecosystems (PlatformIO, etc.).
|
|
||||||
|
|
||||||
## Open Risks / Watchpoints
|
|
||||||
|
|
||||||
- **NvChad chadrc extraction:** pulling the render-and-write out of `neovim.Module.Plan`'s closures into a free function requires care — currently that closure is built inside `Plan`. Acceptable fix: factor a `writeChadrc(cfg string, nvChadTheme string) error` free function in the neovim package, have both the Plan action and the new `WriteChadrc` export use it.
|
|
||||||
- **tui.Theme retirement:** wizard test coverage is decent but `BuiltinThemes` is referenced in `internal/tui/state_test.go`; removing the symbol requires updating those tests. Grep before deleting.
|
|
||||||
- **Overlay YAML round-trip:** `gopkg.in/yaml.v3`'s node-based decoder preserves comments and order; using `map[string]any` decode/encode will lose both. Acceptable for v1 (the user's overlay is config, not hand-commented prose); document the behavior in the `theming.Apply` godoc.
|
|
||||||
- **Doctor version parsing:** `arduino-cli version --format json` shape is stable, but `wokwi-cli --version`'s output format is upstream-controlled. A tiny regex `v?\d+\.\d+\.\d+` covers the common case; if it grows fragile, fall back to byte-for-byte compare with `cfg.Renode.WokwiVersion`.
|
|
||||||
- **Theme-applied-during-install is a behavior change** vs. Bash. It means a fresh install writes tool configs. If the user had pre-existing starship/zellij configs they liked, they get overwritten. Mitigation: `install` preserves a `.bak.<timestamp>` copy before the theme apply step. Document clearly in the install command's help text.
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# Release Pipeline Design (Plan 4c)
|
|
||||||
|
|
||||||
**Date:** 2026-04-14
|
|
||||||
**Scope:** Plan 4c of the Archduino Go rewrite — ship versioned releases and enable one-line install.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Let a user install Archduino with a single `curl | bash`, and let the maintainer ship versioned releases with a single `make release`, hosted entirely on the self-hosted Gitea instance at `gitlab.hive-brain.de:2222`.
|
|
||||||
|
|
||||||
## Deliverables
|
|
||||||
|
|
||||||
1. `bootstrap.sh` — thin curl-piped shim that installs prereqs, downloads the latest release, and execs `archduino install`.
|
|
||||||
2. `.goreleaser.yaml` — cross-compile + archive + checksum + changelog + publish to Gitea Releases.
|
|
||||||
3. `Makefile` with `release` and `snapshot` targets — driven by `svu` for SemVer bumps.
|
|
||||||
4. `.gitea/workflows/release.yml` — tag-triggered workflow that runs goreleaser.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### 1. `bootstrap.sh` (repo root)
|
|
||||||
|
|
||||||
Replaces the legacy `install.sh` (which is Bash-era and will be removed/archived).
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- `set -euo pipefail` throughout.
|
|
||||||
- Detect Arch via `/etc/arch-release`; exit 1 with clear message otherwise.
|
|
||||||
- `sudo pacman -Sy --needed --noconfirm sudo git curl ca-certificates`.
|
|
||||||
- Detect arch: `uname -m` → `x86_64` → `amd64`, `aarch64` → `arm64`. Unknown arch → exit 1.
|
|
||||||
- Query Gitea API for latest release:
|
|
||||||
`GET https://gitlab.hive-brain.de/api/v1/repos/MDietermann/Archduino/releases/latest`
|
|
||||||
Extract `tag_name` (e.g. `v0.1.0`) and use it to locate assets.
|
|
||||||
- Download `archduino_<version-without-v>_linux_<arch>.tar.gz` and `checksums.txt` into a `mktemp -d`.
|
|
||||||
- Verify SHA256 via `sha256sum --check --ignore-missing checksums.txt`; mismatch → exit 1.
|
|
||||||
- Extract tarball.
|
|
||||||
- Install with sudo:
|
|
||||||
- Binary → `/usr/local/bin/archduino` (0755), overwriting.
|
|
||||||
- `completions/archduino.bash` → `/usr/share/bash-completion/completions/archduino`.
|
|
||||||
- `completions/_archduino` → `/usr/share/zsh/site-functions/_archduino`.
|
|
||||||
- `completions/archduino.fish` → `/usr/share/fish/vendor_completions.d/archduino.fish`.
|
|
||||||
- `exec /usr/local/bin/archduino install "$@"` so flags pass through (e.g. `--halt-on-error`).
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
```
|
|
||||||
curl -fsSL https://gitlab.hive-brain.de/MDietermann/Archduino/raw/branch/master/bootstrap.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `.goreleaser.yaml` (repo root)
|
|
||||||
|
|
||||||
- `project_name: archduino`
|
|
||||||
- `before.hooks:` regenerate shell completions via the cobra `completion` subcommand:
|
|
||||||
- `go run ./cmd/archduino completion bash > completions/archduino.bash`
|
|
||||||
- `go run ./cmd/archduino completion zsh > completions/_archduino`
|
|
||||||
- `go run ./cmd/archduino completion fish > completions/archduino.fish`
|
|
||||||
- Prerequisite: the `completion` subcommand must be registered on the root cobra command. If not already present, add it as part of the implementation.
|
|
||||||
- `builds:`
|
|
||||||
- One entry, `main: ./cmd/archduino`, `binary: archduino`.
|
|
||||||
- `env: [CGO_ENABLED=0]`.
|
|
||||||
- `goos: [linux]`, `goarch: [amd64, arm64]`.
|
|
||||||
- `ldflags: -s -w -X gitlab.hive-brain.de/MDietermann/Archduino/internal/buildinfo.Version={{.Version}} -X ...Commit={{.Commit}} -X ...Date={{.Date}}` (add a small `internal/buildinfo` package to hold these; surface via `archduino version`).
|
|
||||||
- `archives:`
|
|
||||||
- Format tar.gz.
|
|
||||||
- Name template `archduino_{{.Version}}_linux_{{.Arch}}`.
|
|
||||||
- Files: binary + `completions/*` + `README.md` + `LICENSE` (add if missing).
|
|
||||||
- `checksum: name_template: 'checksums.txt'` (SHA256).
|
|
||||||
- `changelog:`
|
|
||||||
- `use: git`
|
|
||||||
- Filters: exclude `^chore:`, `^docs:`, `^test:`, `^ci:`, `^style:`, `Merge pull request`, `Merge branch`.
|
|
||||||
- Groups: `Features` (`^feat`), `Bug fixes` (`^fix|^perf`), `Others` (everything else).
|
|
||||||
- `release:`
|
|
||||||
- `gitea_urls.api: https://gitlab.hive-brain.de/api/v1`
|
|
||||||
- `gitea_urls.download: https://gitlab.hive-brain.de`
|
|
||||||
|
|
||||||
### 3. `Makefile` (repo root)
|
|
||||||
|
|
||||||
Targets:
|
|
||||||
- `snapshot` — `goreleaser release --snapshot --clean`. No tagging, no publish. For local build-matrix validation.
|
|
||||||
- `release` — SemVer bump flow:
|
|
||||||
1. Assert working tree clean and current branch is `master`; abort otherwise.
|
|
||||||
2. `CURRENT=$$(svu current)` and `NEXT=$$(svu next)`. Display both plus the commit log since `CURRENT`.
|
|
||||||
3. Prompt `Tag $NEXT and push? [y/N]`. Abort on anything but `y`.
|
|
||||||
4. `git tag $$NEXT && git push origin $$NEXT`.
|
|
||||||
The actual release is then produced by CI.
|
|
||||||
- `completions` — regenerate completions into `completions/` locally (useful for dev).
|
|
||||||
- `build` — local `go build ./cmd/archduino` for quick smoke.
|
|
||||||
|
|
||||||
`svu` is a dev dependency; README gains a one-line note: `go install github.com/caarlos0/svu@latest`.
|
|
||||||
|
|
||||||
### 4. `.gitea/workflows/release.yml`
|
|
||||||
|
|
||||||
- Trigger: `on: push: tags: ['v*']`.
|
|
||||||
- `runs-on: ubuntu-latest` (Gitea Actions convention; matches runner labels).
|
|
||||||
- Steps:
|
|
||||||
1. `actions/checkout@v4` with `fetch-depth: 0` (goreleaser needs full history for changelog).
|
|
||||||
2. `actions/setup-go@v5` with the Go version pinned in `go.mod`.
|
|
||||||
3. `goreleaser/goreleaser-action@v6` with `args: release --clean`.
|
|
||||||
- Env: `GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}`. Token is created manually in Gitea UI (repo-scoped, `write:package`/`write:repository` as needed) and stored as a repo secret before first release.
|
|
||||||
- Prerequisite (documented, not executed by the plan): a Gitea Actions runner must be registered on the instance. One-time setup outside the scope of this spec.
|
|
||||||
|
|
||||||
## Data flow
|
|
||||||
|
|
||||||
```
|
|
||||||
dev: git commit -m "feat: ..." (conventional commits)
|
|
||||||
dev: make release → svu computes vX.Y.Z → git tag + push
|
|
||||||
Gitea Actions: tag push → goreleaser --clean → Gitea Release published
|
|
||||||
(tarballs + checksums.txt + changelog)
|
|
||||||
user: curl … | bash → bootstrap.sh → Gitea API (latest release) → tarball →
|
|
||||||
sha256 verify → /usr/local/bin + completions → exec archduino install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
SemVer driven by Conventional Commits via `svu`:
|
|
||||||
- `feat:` → minor
|
|
||||||
- `fix:` / `perf:` → patch
|
|
||||||
- `feat!:` or `BREAKING CHANGE:` footer → major
|
|
||||||
- `chore:` / `docs:` / `refactor:` / `test:` / `ci:` / `style:` → no release
|
|
||||||
|
|
||||||
First release: `v0.1.0` (seed tag so `svu` has a baseline; manual one-time `git tag v0.1.0`).
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
- `bootstrap.sh`: checksum mismatch, non-Arch host, arch detection failure, non-200 from API → exit 1 with a specific message; `set -euo pipefail` covers the rest.
|
|
||||||
- goreleaser is transactional — the Gitea Release is created after artifacts upload successfully, so a mid-run failure leaves no partial release.
|
|
||||||
- `make release` aborts before tagging if the tree is dirty or not on `master`.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `make snapshot` locally verifies the build matrix and archive contents without publishing.
|
|
||||||
- `bootstrap.sh` tested against a snapshot tarball in a throwaway Arch container:
|
|
||||||
`docker run --rm -it archlinux bash` → serve the tarball locally → run bootstrap with an overridden base URL (env var hook, e.g. `ARCHDUINO_RELEASE_BASE`) for test runs.
|
|
||||||
- No Go test changes; this is all out-of-band release machinery.
|
|
||||||
|
|
||||||
## Out of scope (deferred)
|
|
||||||
|
|
||||||
- GPG-signed checksums (revisit if/when distribution widens).
|
|
||||||
- Debian/Fedora/macOS builds (waiting on multi-distro support elsewhere in the roadmap).
|
|
||||||
- Homebrew tap and AUR PKGBUILD (AUR is a natural follow-up but tracked separately).
|
|
||||||
- Automated "release PR" bots (release-please / semantic-release) — `svu` + manual `make release` is the chosen trigger.
|
|
||||||
|
|
||||||
## File touch list
|
|
||||||
|
|
||||||
New:
|
|
||||||
- `bootstrap.sh`
|
|
||||||
- `.goreleaser.yaml`
|
|
||||||
- `Makefile`
|
|
||||||
- `.gitea/workflows/release.yml`
|
|
||||||
- `internal/buildinfo/buildinfo.go` (tiny — holds `Version`, `Commit`, `Date` vars)
|
|
||||||
- `completions/` (gitignored; generated)
|
|
||||||
- `LICENSE` (if missing)
|
|
||||||
|
|
||||||
Modified:
|
|
||||||
- `cmd/archduino/main.go` (register cobra `completion` subcommand if absent; wire a `version` subcommand that prints `buildinfo`).
|
|
||||||
- `README.md` (install-via-bootstrap instructions, `svu` note).
|
|
||||||
- `.gitignore` (add `dist/`, `completions/`).
|
|
||||||
|
|
||||||
Removed/archived:
|
|
||||||
- `install.sh` (legacy Bash installer — superseded by `bootstrap.sh` + Go binary).
|
|
||||||
42
go.mod
42
go.mod
@@ -1,42 +0,0 @@
|
|||||||
module gitlab.hive-brain.de/MDietermann/Archduino
|
|
||||||
|
|
||||||
go 1.25.0
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
|
||||||
github.com/charmbracelet/huh v1.0.0
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
|
||||||
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913
|
|
||||||
github.com/spf13/cobra v1.10.2
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
|
|
||||||
github.com/catppuccin/go v0.3.0 // indirect
|
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
|
||||||
github.com/charmbracelet/colorprofile v0.3.2 // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect
|
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
|
||||||
golang.org/x/text v0.28.0 // indirect
|
|
||||||
)
|
|
||||||
88
go.sum
88
go.sum
@@ -1,88 +0,0 @@
|
|||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
|
||||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
|
||||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
|
||||||
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
|
|
||||||
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
|
|
||||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
|
||||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
|
||||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
|
||||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
|
||||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
|
||||||
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913 h1:MvJqk9htSLLxNlNQRqfRMhObcytCT4+xfO4830kNlVs=
|
|
||||||
github.com/charmbracelet/x/exp/teatest v0.0.0-20260413165052-6921c759c913/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
|
||||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
|
||||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
|
||||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
|
||||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# DEPRECATED: This is the legacy Bash installer. The active installer is
|
|
||||||
# bootstrap.sh (curl-piped shim) + the archduino Go binary. Kept for reference.
|
|
||||||
#
|
#
|
||||||
# Archduino Install — TUI Arduino Development Environment
|
# Archduino Install — TUI Arduino Development Environment
|
||||||
# Works on both native Arch Linux and Arch/Ubuntu WSL2
|
# Works on both native Arch Linux and Arch/Ubuntu WSL2
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
// Package config loads embedded defaults and optionally merges a user
|
|
||||||
// overlay from ~/.config/archduino/overlay.yaml.
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed defaults.yaml
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NeovimConfig struct {
|
|
||||||
NvChadStarterRepo string `yaml:"nvchad_starter_repo"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RenodePlatform struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
ReplPath string `yaml:"repl_path"`
|
|
||||||
Machine string `yaml:"machine"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RenodeConfig struct {
|
|
||||||
WokwiVersion string `yaml:"wokwi_version"`
|
|
||||||
WokwiURL string `yaml:"wokwi_url"`
|
|
||||||
Platforms []RenodePlatform `yaml:"platforms"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Board struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
FQBN string `yaml:"fqbn"`
|
|
||||||
Template string `yaml:"template"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Scaffolding struct {
|
|
||||||
Boards []Board `yaml:"boards"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config is the fully-resolved runtime configuration.
|
|
||||||
type Config struct {
|
|
||||||
Version int `yaml:"version"`
|
|
||||||
ExtraPackages []string `yaml:"extra_packages"`
|
|
||||||
DisabledModules []string `yaml:"disabled_modules"`
|
|
||||||
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"`
|
|
||||||
Neovim NeovimConfig `yaml:"neovim"`
|
|
||||||
Renode RenodeConfig `yaml:"renode"`
|
|
||||||
Scaffolding Scaffolding `yaml:"scaffolding"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentVersion = 1
|
|
||||||
|
|
||||||
// Load returns defaults merged with the overlay at overlayPath (if present).
|
|
||||||
// If overlayPath is "", the default location (~/.config/archduino/overlay.yaml)
|
|
||||||
// is used. A missing overlay is not an error.
|
|
||||||
func Load(overlayPath string) (Config, error) {
|
|
||||||
var cfg Config
|
|
||||||
if err := yaml.Unmarshal(defaultsYAML, &cfg); err != nil {
|
|
||||||
return Config{}, fmt.Errorf("parse embedded defaults: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if overlayPath == "" {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
overlayPath = filepath.Join(home, ".config", "archduino", "overlay.yaml")
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(overlayPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
return Config{}, fmt.Errorf("read overlay %s: %w", overlayPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var overlay Config
|
|
||||||
if err := yaml.Unmarshal(data, &overlay); err != nil {
|
|
||||||
return Config{}, fmt.Errorf("parse overlay %s: %w", overlayPath, err)
|
|
||||||
}
|
|
||||||
if overlay.Version != 0 && overlay.Version != currentVersion {
|
|
||||||
return Config{}, fmt.Errorf("overlay %s: unsupported version %d (want %d)", overlayPath, overlay.Version, currentVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
return merge(cfg, overlay), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func merge(base, over Config) Config {
|
|
||||||
out := base
|
|
||||||
if len(over.ExtraPackages) > 0 {
|
|
||||||
out.ExtraPackages = append(append([]string{}, base.ExtraPackages...), over.ExtraPackages...)
|
|
||||||
}
|
|
||||||
if len(over.DisabledModules) > 0 {
|
|
||||||
out.DisabledModules = append(append([]string{}, base.DisabledModules...), over.DisabledModules...)
|
|
||||||
}
|
|
||||||
if len(over.ThemeOverrides) > 0 {
|
|
||||||
merged := make(map[string]string, len(base.ThemeOverrides)+len(over.ThemeOverrides))
|
|
||||||
for k, v := range base.ThemeOverrides {
|
|
||||||
merged[k] = v
|
|
||||||
}
|
|
||||||
for k, v := range over.ThemeOverrides {
|
|
||||||
merged[k] = v
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
if over.Neovim.NvChadStarterRepo != "" {
|
|
||||||
out.Neovim = over.Neovim
|
|
||||||
}
|
|
||||||
if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 {
|
|
||||||
out.Renode = over.Renode
|
|
||||||
}
|
|
||||||
if len(over.Scaffolding.Boards) > 0 {
|
|
||||||
out.Scaffolding = over.Scaffolding
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoadDefaultsWhenNoOverlay(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
cfg, err := Load(filepath.Join(dir, "missing.yaml"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if cfg.Version != 1 {
|
|
||||||
t.Errorf("version = %d", cfg.Version)
|
|
||||||
}
|
|
||||||
if len(cfg.ExtraPackages) != 0 {
|
|
||||||
t.Errorf("extra_packages = %v", cfg.ExtraPackages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadMergesOverlay(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "overlay.yaml")
|
|
||||||
content := []byte(`
|
|
||||||
version: 1
|
|
||||||
extra_packages: [htop, ncdu]
|
|
||||||
disabled_modules: [renode]
|
|
||||||
theme_overrides:
|
|
||||||
starship: /tmp/mine.toml
|
|
||||||
`)
|
|
||||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
cfg, err := Load(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := cfg.ExtraPackages; len(got) != 2 || got[0] != "htop" || got[1] != "ncdu" {
|
|
||||||
t.Errorf("extra_packages = %v", got)
|
|
||||||
}
|
|
||||||
if len(cfg.DisabledModules) != 1 || cfg.DisabledModules[0] != "renode" {
|
|
||||||
t.Errorf("disabled_modules = %v", cfg.DisabledModules)
|
|
||||||
}
|
|
||||||
if cfg.ThemeOverrides["starship"] != "/tmp/mine.toml" {
|
|
||||||
t.Errorf("theme_overrides = %v", cfg.ThemeOverrides)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 TestDefaultsCarryPlan3bInputs(t *testing.T) {
|
|
||||||
cfg, err := Load("/nonexistent/overlay.yaml")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if cfg.Neovim.NvChadStarterRepo == "" {
|
|
||||||
t.Error("Neovim.NvChadStarterRepo empty")
|
|
||||||
}
|
|
||||||
if cfg.Renode.WokwiVersion == "" {
|
|
||||||
t.Error("Renode.WokwiVersion empty")
|
|
||||||
}
|
|
||||||
if cfg.Renode.WokwiURL == "" {
|
|
||||||
t.Error("Renode.WokwiURL empty")
|
|
||||||
}
|
|
||||||
if len(cfg.Renode.Platforms) == 0 {
|
|
||||||
t.Error("Renode.Platforms empty")
|
|
||||||
}
|
|
||||||
want := map[string]string{"stm32f4": "STM32F4_Discovery", "rp2040": "RP2040", "nrf52840": "nRF52840"}
|
|
||||||
for _, p := range cfg.Renode.Platforms {
|
|
||||||
if want[p.Name] != p.Machine {
|
|
||||||
t.Errorf("platform %q: machine=%q want %q", p.Name, p.Machine, want[p.Name])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadRejectsUnknownVersion(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "overlay.yaml")
|
|
||||||
if err := os.WriteFile(path, []byte("version: 99\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := Load(path); err == nil {
|
|
||||||
t.Fatal("expected version error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultsCarryScaffoldingBoards(t *testing.T) {
|
|
||||||
cfg, err := Load("/nonexistent/overlay.yaml")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(cfg.Scaffolding.Boards) < 6 {
|
|
||||||
t.Fatalf("want >= 6 boards, got %d", len(cfg.Scaffolding.Boards))
|
|
||||||
}
|
|
||||||
want := map[string]string{
|
|
||||||
"uno": "arduino:avr:uno", "mega": "arduino:avr:mega", "nano": "arduino:avr:nano",
|
|
||||||
"esp32": "esp32:esp32:esp32", "esp32s3": "esp32:esp32:esp32s3", "esp8266": "esp8266:esp8266:nodemcuv2",
|
|
||||||
}
|
|
||||||
got := map[string]string{}
|
|
||||||
for _, b := range cfg.Scaffolding.Boards {
|
|
||||||
got[b.Name] = b.FQBN
|
|
||||||
}
|
|
||||||
for name, fqbn := range want {
|
|
||||||
if got[name] != fqbn {
|
|
||||||
t.Errorf("board %q: got fqbn %q, want %q", name, got[name], fqbn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
version: 1
|
|
||||||
extra_packages: []
|
|
||||||
disabled_modules: []
|
|
||||||
theme_overrides: {}
|
|
||||||
|
|
||||||
packages:
|
|
||||||
base:
|
|
||||||
- base-devel
|
|
||||||
- git
|
|
||||||
- curl
|
|
||||||
- wget
|
|
||||||
- unzip
|
|
||||||
- zip
|
|
||||||
- tree
|
|
||||||
- htop
|
|
||||||
- fastfetch
|
|
||||||
- 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"
|
|
||||||
|
|
||||||
neovim:
|
|
||||||
nvchad_starter_repo: "https://github.com/NvChad/starter"
|
|
||||||
|
|
||||||
renode:
|
|
||||||
wokwi_version: "0.26.1"
|
|
||||||
wokwi_url: "https://github.com/wokwi/wokwi-cli/releases/download/v0.26.1/wokwi-cli-linuxstatic-x64"
|
|
||||||
platforms:
|
|
||||||
- name: stm32f4
|
|
||||||
repl_path: "platforms/boards/stm32f4_discovery.repl"
|
|
||||||
machine: "STM32F4_Discovery"
|
|
||||||
- name: rp2040
|
|
||||||
repl_path: "platforms/cpus/rp2040.repl"
|
|
||||||
machine: "RP2040"
|
|
||||||
- name: nrf52840
|
|
||||||
repl_path: "platforms/cpus/nrf52840.repl"
|
|
||||||
machine: "nRF52840"
|
|
||||||
|
|
||||||
scaffolding:
|
|
||||||
boards:
|
|
||||||
- {name: uno, fqbn: "arduino:avr:uno", template: uno}
|
|
||||||
- {name: mega, fqbn: "arduino:avr:mega", template: mega}
|
|
||||||
- {name: nano, fqbn: "arduino:avr:nano", template: nano}
|
|
||||||
- {name: esp32, fqbn: "esp32:esp32:esp32", template: esp32}
|
|
||||||
- {name: esp32s3, fqbn: "esp32:esp32:esp32s3", template: esp32s3}
|
|
||||||
- {name: esp8266, fqbn: "esp8266:esp8266:nodemcuv2", template: esp8266}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package engine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TopoSort returns module IDs in a valid dependency order.
|
|
||||||
// Ties are broken alphabetically for deterministic output.
|
|
||||||
// Returns an error on unknown deps or cycles.
|
|
||||||
func TopoSort(metas []module.Meta) ([]string, error) {
|
|
||||||
known := make(map[string]bool, len(metas))
|
|
||||||
for _, m := range metas {
|
|
||||||
known[m.ID] = true
|
|
||||||
}
|
|
||||||
for _, m := range metas {
|
|
||||||
for _, d := range m.Deps {
|
|
||||||
if !known[d] {
|
|
||||||
return nil, fmt.Errorf("module %q depends on unknown %q", m.ID, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kahn's algorithm with alphabetical tie-breaking.
|
|
||||||
indeg := make(map[string]int, len(metas))
|
|
||||||
children := make(map[string][]string, len(metas))
|
|
||||||
for _, m := range metas {
|
|
||||||
if _, ok := indeg[m.ID]; !ok {
|
|
||||||
indeg[m.ID] = 0
|
|
||||||
}
|
|
||||||
for _, d := range m.Deps {
|
|
||||||
indeg[m.ID]++
|
|
||||||
children[d] = append(children[d], m.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var ready []string
|
|
||||||
for id, d := range indeg {
|
|
||||||
if d == 0 {
|
|
||||||
ready = append(ready, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(ready)
|
|
||||||
|
|
||||||
var order []string
|
|
||||||
for len(ready) > 0 {
|
|
||||||
next := ready[0]
|
|
||||||
ready = ready[1:]
|
|
||||||
order = append(order, next)
|
|
||||||
kids := append([]string(nil), children[next]...)
|
|
||||||
sort.Strings(kids)
|
|
||||||
for _, c := range kids {
|
|
||||||
indeg[c]--
|
|
||||||
if indeg[c] == 0 {
|
|
||||||
ready = append(ready, c)
|
|
||||||
sort.Strings(ready)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(order) != len(metas) {
|
|
||||||
return nil, fmt.Errorf("dependency cycle detected among modules")
|
|
||||||
}
|
|
||||||
return order, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Descendants returns the set of module IDs transitively downstream of root.
|
|
||||||
// Root itself is NOT included.
|
|
||||||
func Descendants(metas []module.Meta, root string) map[string]bool {
|
|
||||||
children := make(map[string][]string)
|
|
||||||
for _, m := range metas {
|
|
||||||
for _, d := range m.Deps {
|
|
||||||
children[d] = append(children[d], m.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make(map[string]bool)
|
|
||||||
var walk func(string)
|
|
||||||
walk = func(id string) {
|
|
||||||
for _, c := range children[id] {
|
|
||||||
if out[c] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[c] = true
|
|
||||||
walk(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(root)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package engine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTopoSort(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
metas []module.Meta
|
|
||||||
want []string
|
|
||||||
err bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "linear chain",
|
|
||||||
metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"b"}}},
|
|
||||||
want: []string{"a", "b", "c"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "independent modules sorted alphabetically",
|
|
||||||
metas: []module.Meta{{ID: "c"}, {ID: "a"}, {ID: "b"}},
|
|
||||||
want: []string{"a", "b", "c"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "diamond",
|
|
||||||
metas: []module.Meta{{ID: "a"}, {ID: "b", Deps: []string{"a"}}, {ID: "c", Deps: []string{"a"}}, {ID: "d", Deps: []string{"b", "c"}}},
|
|
||||||
want: []string{"a", "b", "c", "d"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown dep",
|
|
||||||
metas: []module.Meta{{ID: "a", Deps: []string{"missing"}}},
|
|
||||||
err: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cycle",
|
|
||||||
metas: []module.Meta{{ID: "a", Deps: []string{"b"}}, {ID: "b", Deps: []string{"a"}}},
|
|
||||||
err: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got, err := TopoSort(tc.metas)
|
|
||||||
if tc.err {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("want error, got nil (order=%v)", got)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, tc.want) {
|
|
||||||
t.Fatalf("order = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescendants(t *testing.T) {
|
|
||||||
metas := []module.Meta{
|
|
||||||
{ID: "a"},
|
|
||||||
{ID: "b", Deps: []string{"a"}},
|
|
||||||
{ID: "c", Deps: []string{"a"}},
|
|
||||||
{ID: "d", Deps: []string{"b"}},
|
|
||||||
{ID: "e"}, // independent
|
|
||||||
}
|
|
||||||
got := Descendants(metas, "a")
|
|
||||||
want := map[string]bool{"b": true, "c": true, "d": true}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("descendants(a) = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(Descendants(metas, "e")) != 0 {
|
|
||||||
t.Fatalf("descendants(e) should be empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package engine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RunOptions controls a single engine run.
|
|
||||||
type RunOptions struct {
|
|
||||||
Selected []string // module IDs to run; empty = all
|
|
||||||
DryRun bool // Detect + Plan only, no Apply/Verify
|
|
||||||
HaltOnError bool // stop at first failure instead of continuing siblings
|
|
||||||
// Force, when true, skips Detect and calls PlanForce on every module
|
|
||||||
// so each one produces a from-scratch plan regardless of current state.
|
|
||||||
Force bool
|
|
||||||
Reporter module.Reporter
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModuleStatus is the terminal state of a module within a run.
|
|
||||||
type ModuleStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusSucceeded ModuleStatus = "succeeded"
|
|
||||||
StatusFailed ModuleStatus = "failed"
|
|
||||||
StatusSkipped ModuleStatus = "skipped"
|
|
||||||
StatusPlanned ModuleStatus = "planned" // dry-run only
|
|
||||||
)
|
|
||||||
|
|
||||||
// ModuleResult captures what happened for one module.
|
|
||||||
type ModuleResult struct {
|
|
||||||
ID string
|
|
||||||
Status ModuleStatus
|
|
||||||
Plan module.Plan
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary aggregates results.
|
|
||||||
type Summary struct {
|
|
||||||
Results []ModuleResult
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counts returns (succeeded, failed, skipped).
|
|
||||||
func (s Summary) Counts() (int, int, int) {
|
|
||||||
var ok, fail, skip int
|
|
||||||
for _, r := range s.Results {
|
|
||||||
switch r.Status {
|
|
||||||
case StatusSucceeded, StatusPlanned:
|
|
||||||
ok++
|
|
||||||
case StatusFailed:
|
|
||||||
fail++
|
|
||||||
case StatusSkipped:
|
|
||||||
skip++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ok, fail, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run drives the selected modules through the four phases in DAG order.
|
|
||||||
// Parallelism is intentionally omitted in this foundation; a later plan
|
|
||||||
// will introduce it. Sequential execution is a correct baseline.
|
|
||||||
func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, error) {
|
|
||||||
mods, err := resolveSelection(reg, opts.Selected)
|
|
||||||
if err != nil {
|
|
||||||
return Summary{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
metas := make([]module.Meta, 0, len(mods))
|
|
||||||
for _, m := range mods {
|
|
||||||
metas = append(metas, m.Meta())
|
|
||||||
}
|
|
||||||
order, err := TopoSort(metas)
|
|
||||||
if err != nil {
|
|
||||||
return Summary{}, fmt.Errorf("module graph: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
byID := make(map[string]module.Module, len(mods))
|
|
||||||
for _, m := range mods {
|
|
||||||
byID[m.Meta().ID] = m
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make(map[string]*ModuleResult, len(mods))
|
|
||||||
skipped := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, id := range order {
|
|
||||||
if skipped[id] {
|
|
||||||
results[id] = &ModuleResult{ID: id, Status: StatusSkipped, Err: errors.New("skipped: upstream failed")}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m := byID[id]
|
|
||||||
res := &ModuleResult{ID: id}
|
|
||||||
results[id] = res
|
|
||||||
|
|
||||||
var (
|
|
||||||
plan module.Plan
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if opts.Force {
|
|
||||||
plan, err = m.PlanForce(ctx)
|
|
||||||
if err != nil {
|
|
||||||
res.Status = StatusFailed
|
|
||||||
res.Err = fmt.Errorf("plan-force: %w", err)
|
|
||||||
markFailed(metas, id, skipped)
|
|
||||||
if opts.HaltOnError {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state, derr := m.Detect(ctx)
|
|
||||||
if derr != nil {
|
|
||||||
res.Status = StatusFailed
|
|
||||||
res.Err = fmt.Errorf("detect: %w", derr)
|
|
||||||
markFailed(metas, id, skipped)
|
|
||||||
if opts.HaltOnError {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
plan, err = m.Plan(ctx, state)
|
|
||||||
if err != nil {
|
|
||||||
res.Status = StatusFailed
|
|
||||||
res.Err = fmt.Errorf("plan: %w", err)
|
|
||||||
markFailed(metas, id, skipped)
|
|
||||||
if opts.HaltOnError {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.Plan = plan
|
|
||||||
|
|
||||||
if opts.DryRun {
|
|
||||||
res.Status = StatusPlanned
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.Apply(ctx, plan, opts.Reporter); err != nil {
|
|
||||||
res.Status = StatusFailed
|
|
||||||
res.Err = fmt.Errorf("apply: %w", err)
|
|
||||||
markFailed(metas, id, skipped)
|
|
||||||
if opts.HaltOnError {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := m.Verify(ctx); err != nil {
|
|
||||||
res.Status = StatusFailed
|
|
||||||
res.Err = fmt.Errorf("verify: %w", err)
|
|
||||||
markFailed(metas, id, skipped)
|
|
||||||
if opts.HaltOnError {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
res.Status = StatusSucceeded
|
|
||||||
}
|
|
||||||
|
|
||||||
summary := Summary{}
|
|
||||||
// Emit results in topo order for stable output.
|
|
||||||
ids := make([]string, 0, len(results))
|
|
||||||
for id := range results {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
sort.SliceStable(ids, func(i, j int) bool {
|
|
||||||
return indexOf(order, ids[i]) < indexOf(order, ids[j])
|
|
||||||
})
|
|
||||||
for _, id := range ids {
|
|
||||||
summary.Results = append(summary.Results, *results[id])
|
|
||||||
}
|
|
||||||
return summary, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSelection(reg *module.Registry, selected []string) ([]module.Module, error) {
|
|
||||||
all := reg.All()
|
|
||||||
if len(selected) == 0 {
|
|
||||||
return all, nil
|
|
||||||
}
|
|
||||||
out := make([]module.Module, 0, len(selected))
|
|
||||||
for _, id := range selected {
|
|
||||||
m, ok := reg.Get(id)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unknown module %q", id)
|
|
||||||
}
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func markFailed(metas []module.Meta, id string, skipped map[string]bool) {
|
|
||||||
for d := range Descendants(metas, id) {
|
|
||||||
skipped[d] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func indexOf(s []string, v string) int {
|
|
||||||
for i, x := range s {
|
|
||||||
if x == v {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
package engine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
)
|
|
||||||
|
|
||||||
// scriptedModule lets tests control what each phase returns.
|
|
||||||
type scriptedModule struct {
|
|
||||||
id string
|
|
||||||
deps []string
|
|
||||||
detect func() (module.State, error)
|
|
||||||
plan func() (module.Plan, error)
|
|
||||||
planForce func() (module.Plan, error)
|
|
||||||
apply func() error
|
|
||||||
verify func() error
|
|
||||||
|
|
||||||
detectCalls int
|
|
||||||
planCalls int
|
|
||||||
planForceCalls int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *scriptedModule) Meta() module.Meta { return module.Meta{ID: s.id, Name: s.id, Deps: s.deps} }
|
|
||||||
func (s *scriptedModule) Detect(ctx context.Context) (module.State, error) {
|
|
||||||
s.detectCalls++
|
|
||||||
if s.detect != nil {
|
|
||||||
return s.detect()
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (s *scriptedModule) Plan(ctx context.Context, _ module.State) (module.Plan, error) {
|
|
||||||
s.planCalls++
|
|
||||||
if s.plan != nil {
|
|
||||||
return s.plan()
|
|
||||||
}
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
func (s *scriptedModule) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
s.planForceCalls++
|
|
||||||
if s.planForce != nil {
|
|
||||||
return s.planForce()
|
|
||||||
}
|
|
||||||
if s.plan != nil {
|
|
||||||
return s.plan()
|
|
||||||
}
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
func (s *scriptedModule) Apply(ctx context.Context, _ module.Plan, _ module.Reporter) error {
|
|
||||||
if s.apply != nil {
|
|
||||||
return s.apply()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (s *scriptedModule) Verify(ctx context.Context) error {
|
|
||||||
if s.verify != nil {
|
|
||||||
return s.verify()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newReg(t *testing.T, mods ...module.Module) *module.Registry {
|
|
||||||
t.Helper()
|
|
||||||
r := module.NewRegistry()
|
|
||||||
for _, m := range mods {
|
|
||||||
if err := r.Register(m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunAllSucceed(t *testing.T) {
|
|
||||||
reg := newReg(t,
|
|
||||||
&scriptedModule{id: "a"},
|
|
||||||
&scriptedModule{id: "b", deps: []string{"a"}},
|
|
||||||
)
|
|
||||||
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ok, fail, skip := sum.Counts()
|
|
||||||
if ok != 2 || fail != 0 || skip != 0 {
|
|
||||||
t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip)
|
|
||||||
}
|
|
||||||
if sum.Results[0].ID != "a" || sum.Results[1].ID != "b" {
|
|
||||||
t.Fatalf("order = %v", sum.Results)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunDryRunDoesNotApply(t *testing.T) {
|
|
||||||
applied := false
|
|
||||||
reg := newReg(t, &scriptedModule{
|
|
||||||
id: "a",
|
|
||||||
apply: func() error { applied = true; return nil },
|
|
||||||
})
|
|
||||||
sum, err := Run(context.Background(), reg, RunOptions{DryRun: true, Reporter: NewBufferedReporter()})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if applied {
|
|
||||||
t.Fatal("Apply was called during dry-run")
|
|
||||||
}
|
|
||||||
if sum.Results[0].Status != StatusPlanned {
|
|
||||||
t.Fatalf("status = %v", sum.Results[0].Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) {
|
|
||||||
reg := newReg(t,
|
|
||||||
&scriptedModule{id: "root"},
|
|
||||||
&scriptedModule{id: "fails", deps: []string{"root"}, apply: func() error { return errors.New("boom") }},
|
|
||||||
&scriptedModule{id: "child", deps: []string{"fails"}},
|
|
||||||
&scriptedModule{id: "sibling", deps: []string{"root"}},
|
|
||||||
)
|
|
||||||
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
status := map[string]ModuleStatus{}
|
|
||||||
for _, r := range sum.Results {
|
|
||||||
status[r.ID] = r.Status
|
|
||||||
}
|
|
||||||
if status["root"] != StatusSucceeded {
|
|
||||||
t.Errorf("root = %v", status["root"])
|
|
||||||
}
|
|
||||||
if status["fails"] != StatusFailed {
|
|
||||||
t.Errorf("fails = %v", status["fails"])
|
|
||||||
}
|
|
||||||
if status["child"] != StatusSkipped {
|
|
||||||
t.Errorf("child = %v", status["child"])
|
|
||||||
}
|
|
||||||
if status["sibling"] != StatusSucceeded {
|
|
||||||
t.Errorf("sibling = %v (should have run)", status["sibling"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunHaltOnError(t *testing.T) {
|
|
||||||
reg := newReg(t,
|
|
||||||
&scriptedModule{id: "a", apply: func() error { return errors.New("nope") }},
|
|
||||||
&scriptedModule{id: "b"}, // independent
|
|
||||||
)
|
|
||||||
sum, err := Run(context.Background(), reg, RunOptions{HaltOnError: true, Reporter: NewBufferedReporter()})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// "b" should not appear in results because we halted before reaching it.
|
|
||||||
for _, r := range sum.Results {
|
|
||||||
if r.ID == "b" {
|
|
||||||
t.Fatalf("b was run despite HaltOnError")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunUnknownModule(t *testing.T) {
|
|
||||||
reg := module.NewRegistry()
|
|
||||||
_, err := Run(context.Background(), reg, RunOptions{Selected: []string{"ghost"}})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for unknown module")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunForceSkipsDetectAndCallsPlanForce(t *testing.T) {
|
|
||||||
mod := &scriptedModule{
|
|
||||||
id: "a",
|
|
||||||
detect: func() (module.State, error) {
|
|
||||||
panic("Detect must not be called when Force=true")
|
|
||||||
},
|
|
||||||
planForce: func() (module.Plan, error) {
|
|
||||||
return module.Plan{Actions: []module.Action{{Description: "forced"}}}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
reg := newReg(t, mod)
|
|
||||||
sum, err := Run(context.Background(), reg, RunOptions{Force: true, Reporter: NewBufferedReporter()})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if mod.planForceCalls != 1 {
|
|
||||||
t.Errorf("planForceCalls = %d, want 1", mod.planForceCalls)
|
|
||||||
}
|
|
||||||
if mod.detectCalls != 0 {
|
|
||||||
t.Errorf("detectCalls = %d, want 0", mod.detectCalls)
|
|
||||||
}
|
|
||||||
if mod.planCalls != 0 {
|
|
||||||
t.Errorf("planCalls = %d, want 0", mod.planCalls)
|
|
||||||
}
|
|
||||||
ok, fail, skip := sum.Counts()
|
|
||||||
if ok != 1 || fail != 0 || skip != 0 {
|
|
||||||
t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip)
|
|
||||||
}
|
|
||||||
if len(sum.Results[0].Plan.Actions) != 1 {
|
|
||||||
t.Fatalf("expected 1 action in plan, got %d", len(sum.Results[0].Plan.Actions))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
// Package engine runs modules through the Detect/Plan/Apply/Verify phases.
|
|
||||||
package engine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Event is a single item streamed from a module during Apply.
|
|
||||||
type Event struct {
|
|
||||||
ModuleID string
|
|
||||||
Kind EventKind
|
|
||||||
Current int
|
|
||||||
Total int
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
type EventKind int
|
|
||||||
|
|
||||||
const (
|
|
||||||
EventProgress EventKind = iota
|
|
||||||
EventLog
|
|
||||||
)
|
|
||||||
|
|
||||||
// BufferedReporter stores every event in memory. Used in tests.
|
|
||||||
type BufferedReporter struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
events []Event
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewBufferedReporter() *BufferedReporter { return &BufferedReporter{} }
|
|
||||||
|
|
||||||
func (b *BufferedReporter) Progress(id string, current, total int, msg string) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
b.events = append(b.events, Event{ModuleID: id, Kind: EventProgress, Current: current, Total: total, Message: msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BufferedReporter) Log(id, line string) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
b.events = append(b.events, Event{ModuleID: id, Kind: EventLog, Message: line})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BufferedReporter) Events() []Event {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
out := make([]Event, len(b.events))
|
|
||||||
copy(out, b.events)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlainReporter writes human-readable lines to w. Concurrency-safe.
|
|
||||||
type PlainReporter struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
w io.Writer
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPlainReporter(w io.Writer) *PlainReporter { return &PlainReporter{w: w} }
|
|
||||||
|
|
||||||
func (p *PlainReporter) Progress(id string, current, total int, msg string) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
fmt.Fprintf(p.w, "[%s] (%d/%d) %s\n", id, current, total, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PlainReporter) Log(id, line string) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
fmt.Fprintf(p.w, "[%s] %s\n", id, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compile-time guarantee both types satisfy module.Reporter.
|
|
||||||
var (
|
|
||||||
_ module.Reporter = (*BufferedReporter)(nil)
|
|
||||||
_ module.Reporter = (*PlainReporter)(nil)
|
|
||||||
)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package engine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBufferedReporterRecordsEventsInOrder(t *testing.T) {
|
|
||||||
r := NewBufferedReporter()
|
|
||||||
r.Progress("m", 1, 3, "step 1")
|
|
||||||
r.Log("m", "hello")
|
|
||||||
r.Progress("m", 2, 3, "step 2")
|
|
||||||
|
|
||||||
ev := r.Events()
|
|
||||||
if len(ev) != 3 {
|
|
||||||
t.Fatalf("want 3 events, got %d", len(ev))
|
|
||||||
}
|
|
||||||
if ev[0].Kind != EventProgress || ev[0].Current != 1 {
|
|
||||||
t.Errorf("event[0] = %+v", ev[0])
|
|
||||||
}
|
|
||||||
if ev[1].Kind != EventLog || ev[1].Message != "hello" {
|
|
||||||
t.Errorf("event[1] = %+v", ev[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBufferedReporterIsConcurrencySafe(t *testing.T) {
|
|
||||||
r := NewBufferedReporter()
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i := 0; i < 50; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int) {
|
|
||||||
defer wg.Done()
|
|
||||||
r.Progress("m", i, 50, "x")
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
if got := len(r.Events()); got != 50 {
|
|
||||||
t.Fatalf("want 50 events, got %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlainReporterFormat(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
r := NewPlainReporter(&buf)
|
|
||||||
r.Progress("packages", 2, 10, "installing tio")
|
|
||||||
r.Log("packages", "pacman: done")
|
|
||||||
|
|
||||||
out := buf.String()
|
|
||||||
for _, want := range []string{"[packages] (2/10) installing tio", "[packages] pacman: done"} {
|
|
||||||
if !strings.Contains(out, want) {
|
|
||||||
t.Errorf("output missing %q; got:\n%s", want, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
// Package module defines the four-phase contract every installable unit
|
|
||||||
// implements, along with the value types exchanged with the engine.
|
|
||||||
package module
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// Meta is static, declarative information about a module.
|
|
||||||
type Meta struct {
|
|
||||||
ID string // stable machine identifier, e.g. "packages"
|
|
||||||
Name string // human label, e.g. "Base packages"
|
|
||||||
Description string // one-line summary for TUI
|
|
||||||
Deps []string // IDs of modules that must complete before this one
|
|
||||||
}
|
|
||||||
|
|
||||||
// State is an opaque, module-defined snapshot of current system state.
|
|
||||||
// The engine never inspects it; it is threaded from Detect → Plan.
|
|
||||||
type State any
|
|
||||||
|
|
||||||
// ActionKind categorizes what an Action does, for UI grouping only.
|
|
||||||
type ActionKind int
|
|
||||||
|
|
||||||
const (
|
|
||||||
ActionUnknown ActionKind = iota
|
|
||||||
ActionPackageInstall
|
|
||||||
ActionFileWrite
|
|
||||||
ActionSymlink
|
|
||||||
ActionCommand
|
|
||||||
ActionGitClone
|
|
||||||
ActionGroupAdd
|
|
||||||
)
|
|
||||||
|
|
||||||
// Action is a single planned change with a human-readable description.
|
|
||||||
type Action struct {
|
|
||||||
Description string
|
|
||||||
Kind ActionKind
|
|
||||||
Exec func(ctx context.Context) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan is an ordered list of actions returned by Module.Plan.
|
|
||||||
type Plan struct {
|
|
||||||
Actions []Action
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty reports whether the plan would make no changes.
|
|
||||||
func (p Plan) IsEmpty() bool { return len(p.Actions) == 0 }
|
|
||||||
|
|
||||||
// Module is the four-phase contract.
|
|
||||||
type Module interface {
|
|
||||||
Meta() Meta
|
|
||||||
Detect(ctx context.Context) (State, error)
|
|
||||||
Plan(ctx context.Context, current State) (Plan, error)
|
|
||||||
// PlanForce returns a plan that reapplies the module unconditionally,
|
|
||||||
// ignoring current detected state. Used by `archduino install --force`.
|
|
||||||
// Implementations should produce a plan equivalent to a from-scratch
|
|
||||||
// install — reinstall packages without pacman's --needed guard and
|
|
||||||
// rewrite managed config files.
|
|
||||||
PlanForce(ctx context.Context) (Plan, error)
|
|
||||||
Apply(ctx context.Context, plan Plan, r Reporter) error
|
|
||||||
Verify(ctx context.Context) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reporter is how modules stream progress events. Defined here as an
|
|
||||||
// interface to avoid an import cycle with the engine package.
|
|
||||||
type Reporter interface {
|
|
||||||
Progress(moduleID string, current, total int, message string)
|
|
||||||
Log(moduleID, line string)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package module
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Registry holds all known modules keyed by Meta.ID.
|
|
||||||
type Registry struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
modules map[string]Module
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRegistry returns an empty registry.
|
|
||||||
func NewRegistry() *Registry {
|
|
||||||
return &Registry{modules: make(map[string]Module)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register adds a module. Returns an error if the ID is already present
|
|
||||||
// or empty. Safe for concurrent use.
|
|
||||||
func (r *Registry) Register(m Module) error {
|
|
||||||
id := m.Meta().ID
|
|
||||||
if id == "" {
|
|
||||||
return fmt.Errorf("module has empty ID")
|
|
||||||
}
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if _, dup := r.modules[id]; dup {
|
|
||||||
return fmt.Errorf("module %q already registered", id)
|
|
||||||
}
|
|
||||||
r.modules[id] = m
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns the module with the given ID, or (nil, false) if absent.
|
|
||||||
func (r *Registry) Get(id string) (Module, bool) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
m, ok := r.modules[id]
|
|
||||||
return m, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// All returns all registered modules sorted by ID for determinism.
|
|
||||||
func (r *Registry) All() []Module {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
out := make([]Module, 0, len(r.modules))
|
|
||||||
for _, m := range r.modules {
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
return out[i].Meta().ID < out[j].Meta().ID
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package module
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeModule struct {
|
|
||||||
id string
|
|
||||||
deps []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fakeModule) Meta() Meta { return Meta{ID: f.id, Name: f.id, Deps: f.deps} }
|
|
||||||
func (f fakeModule) Detect(context.Context) (State, error) { return nil, nil }
|
|
||||||
func (f fakeModule) Plan(context.Context, State) (Plan, error) { return Plan{}, nil }
|
|
||||||
func (f fakeModule) PlanForce(context.Context) (Plan, error) { return Plan{}, nil }
|
|
||||||
func (f fakeModule) Apply(context.Context, Plan, Reporter) error { return nil }
|
|
||||||
func (f fakeModule) Verify(context.Context) error { return nil }
|
|
||||||
|
|
||||||
func TestRegistry(t *testing.T) {
|
|
||||||
t.Run("register then get", func(t *testing.T) {
|
|
||||||
r := NewRegistry()
|
|
||||||
if err := r.Register(fakeModule{id: "a"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m, ok := r.Get("a")
|
|
||||||
if !ok || m.Meta().ID != "a" {
|
|
||||||
t.Fatalf("want module a, got %v ok=%v", m, ok)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("duplicate register errors", func(t *testing.T) {
|
|
||||||
r := NewRegistry()
|
|
||||||
_ = r.Register(fakeModule{id: "a"})
|
|
||||||
if err := r.Register(fakeModule{id: "a"}); err == nil {
|
|
||||||
t.Fatal("expected duplicate error")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("empty id errors", func(t *testing.T) {
|
|
||||||
r := NewRegistry()
|
|
||||||
if err := r.Register(fakeModule{id: ""}); err == nil {
|
|
||||||
t.Fatal("expected empty-id error")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("all is sorted by id", func(t *testing.T) {
|
|
||||||
r := NewRegistry()
|
|
||||||
_ = r.Register(fakeModule{id: "b"})
|
|
||||||
_ = r.Register(fakeModule{id: "a"})
|
|
||||||
_ = r.Register(fakeModule{id: "c"})
|
|
||||||
all := r.All()
|
|
||||||
got := []string{all[0].Meta().ID, all[1].Meta().ID, all[2].Meta().ID}
|
|
||||||
want := []string{"a", "b", "c"}
|
|
||||||
for i := range got {
|
|
||||||
if got[i] != want[i] {
|
|
||||||
t.Fatalf("All() = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
// Package arduinocli installs arduino-cli and its configured cores.
|
|
||||||
package arduinocli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadBinary fetches, extracts, and installs arduino-cli to bp.
|
|
||||||
func (m Module) downloadBinary(ctx context.Context, bp string) 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { return m.downloadBinary(ctx, bp) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanForce redownloads the arduino-cli binary, refreshes the core index,
|
|
||||||
// and reinstalls every configured core unconditionally. arduino-cli's
|
|
||||||
// `core install` is idempotent, so reissuing it is safe.
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
bp, err := binaryPath()
|
|
||||||
if err != nil {
|
|
||||||
return module.Plan{}, err
|
|
||||||
}
|
|
||||||
actions := []module.Action{
|
|
||||||
{
|
|
||||||
Description: "redownload + reinstall arduino-cli " + m.cfg.Version + " (force)",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error { return m.downloadBinary(ctx, bp) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "arduino-cli core update-index (force)",
|
|
||||||
Kind: module.ActionCommand,
|
|
||||||
Exec: func(ctx context.Context) error { return m.runner.Run(ctx, bp, "core", "update-index") },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, core := range m.cfg.Cores {
|
|
||||||
core := core
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "arduino-cli core install " + core + " (force)",
|
|
||||||
Kind: module.ActionCommand,
|
|
||||||
Exec: func(ctx context.Context) error { return m.runner.Run(ctx, bp, "core", "install", core) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return module.Plan{Actions: actions}, nil
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
package arduinocli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceRedownloadsAndReinstallsCores(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
// Pre-populate a stale binary to prove force overwrites.
|
|
||||||
os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755)
|
|
||||||
bp := filepath.Join(home, ".local", "bin", "arduino-cli")
|
|
||||||
os.WriteFile(bp, []byte("stale"), 0o755)
|
|
||||||
|
|
||||||
archive := makeTarGz(t, map[string][]byte{"arduino-cli": []byte("#!/bin/sh\nfresh\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("arduino:avr\nesp32:esp32\n")}
|
|
||||||
m := New(config.ArduinoCLIConfig{
|
|
||||||
Version: "1.0.0",
|
|
||||||
URL: srv.URL + "/arduino-cli.tgz",
|
|
||||||
Cores: []string{"arduino:avr", "esp32:esp32"},
|
|
||||||
}, r)
|
|
||||||
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec %q: %v", a.Description, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Binary got overwritten with fresh payload.
|
|
||||||
got, _ := os.ReadFile(bp)
|
|
||||||
if string(got) == "stale" {
|
|
||||||
t.Fatalf("binary not overwritten by force")
|
|
||||||
}
|
|
||||||
|
|
||||||
// update-index and both `core install` calls fired.
|
|
||||||
sawUpdate := false
|
|
||||||
installed := map[string]bool{}
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 3 && c[1] == "core" && c[2] == "update-index" {
|
|
||||||
sawUpdate = true
|
|
||||||
}
|
|
||||||
if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 4 && c[1] == "core" && c[2] == "install" {
|
|
||||||
installed[c[3]] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawUpdate {
|
|
||||||
t.Errorf("no core update-index call; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
if !installed["arduino:avr"] || !installed["esp32:esp32"] {
|
|
||||||
t.Errorf("expected both cores force-installed; got=%v calls=%v", installed, r.runCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
// Package fonts installs AUR fonts via the yay helper.
|
|
||||||
package fonts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
if len(m.fonts) == 0 {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
fonts := append([]string{}, m.fonts...)
|
|
||||||
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 -S (force) " + strings.Join(fonts, ", "),
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.aur.Reinstall(ctx, fonts) },
|
|
||||||
},
|
|
||||||
}}, nil
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package fonts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceReinstallsAllFonts(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))
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sawReinstall := false
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
// yay -S --noconfirm ... (no --needed)
|
|
||||||
if len(c) >= 3 && c[0] == "yay" && c[1] == "-S" && c[2] == "--noconfirm" {
|
|
||||||
sawReinstall = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawReinstall {
|
|
||||||
t.Fatalf("no yay reinstall (-S --noconfirm, no --needed) call; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// Package gittools installs a declarative list of pacman packages.
|
|
||||||
package gittools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
if len(m.pkgs) == 0 {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
pkgs := append([]string{}, m.pkgs...)
|
|
||||||
return module.Plan{Actions: []module.Action{{
|
|
||||||
Description: "pacman -S (force) " + strings.Join(pkgs, ", "),
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, pkgs) },
|
|
||||||
}}}, nil
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package gittools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceReinstallsAllPackages(t *testing.T) {
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"lazygit": true}}
|
|
||||||
m := New([]string{"lazygit"}, platform.NewPacman(r))
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty even when packages are installed")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sawReinstall := false
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
|
|
||||||
sawReinstall = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawReinstall {
|
|
||||||
t.Fatalf("no pacman reinstall (-S --noconfirm, no --needed) call; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
// Package hello is a toy module that writes a marker file. It exists to
|
|
||||||
// validate the engine end-to-end before real modules arrive in Plan 3.
|
|
||||||
package hello
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultRelPath = ".cache/archduino/hello.ok"
|
|
||||||
const markerContent = "archduino-hello\n"
|
|
||||||
|
|
||||||
// Module is the exported module value. Path is overridable for tests.
|
|
||||||
type Module struct {
|
|
||||||
Path string // absolute path; if empty, ~/.cache/archduino/hello.ok is used
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) resolve() (string, error) {
|
|
||||||
if m.Path != "" {
|
|
||||||
return m.Path, nil
|
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(home, defaultRelPath), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (Module) Meta() module.Meta {
|
|
||||||
return module.Meta{
|
|
||||||
ID: "hello",
|
|
||||||
Name: "Hello",
|
|
||||||
Description: "Toy module that writes a marker file (engine smoke test).",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type state struct{ Present bool }
|
|
||||||
|
|
||||||
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
|
||||||
path, err := m.resolve()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return state{Present: false}, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return state{Present: string(data) == markerContent}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
|
||||||
s, _ := cur.(state)
|
|
||||||
if s.Present {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
path, err := m.resolve()
|
|
||||||
if err != nil {
|
|
||||||
return module.Plan{}, err
|
|
||||||
}
|
|
||||||
return module.Plan{Actions: []module.Action{{
|
|
||||||
Description: "write marker " + path,
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(path, []byte(markerContent), 0o644)
|
|
||||||
},
|
|
||||||
}}}, 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 !st.(state).Present {
|
|
||||||
return fmt.Errorf("hello: marker not present after apply")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
path, err := m.resolve()
|
|
||||||
if err != nil {
|
|
||||||
return module.Plan{}, err
|
|
||||||
}
|
|
||||||
return module.Plan{Actions: []module.Action{{
|
|
||||||
Description: "hello force reapply (rewrite marker " + path + ")",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(path, []byte(markerContent), 0o644)
|
|
||||||
},
|
|
||||||
}}}, nil
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package hello
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/engine"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHelloFullLifecycle(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "hello.ok")
|
|
||||||
m := Module{Path: path}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
st, err := m.Detect(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if st.(state).Present {
|
|
||||||
t.Fatal("marker should be absent on fresh tmpdir")
|
|
||||||
}
|
|
||||||
|
|
||||||
plan, err := m.Plan(ctx, st)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("plan should have one action")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.Apply(ctx, plan, engine.NewBufferedReporter()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.Verify(ctx); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
st2, _ := m.Detect(ctx)
|
|
||||||
if !st2.(state).Present {
|
|
||||||
t.Fatal("marker should be present after apply")
|
|
||||||
}
|
|
||||||
|
|
||||||
plan2, _ := m.Plan(ctx, st2)
|
|
||||||
if !plan2.IsEmpty() {
|
|
||||||
t.Fatal("second plan should be empty (idempotent)")
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if string(data) != markerContent {
|
|
||||||
t.Fatalf("content = %q", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHelloPlanForceNonEmpty(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "hello.ok")
|
|
||||||
m := Module{Path: path}
|
|
||||||
// Even after marker is present, force plan must run.
|
|
||||||
if err := os.WriteFile(path, []byte(markerContent), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if string(data) != markerContent {
|
|
||||||
t.Fatalf("content after force = %q", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
return {
|
|
||||||
{
|
|
||||||
"stevearc/vim-arduino",
|
|
||||||
ft = { "arduino", "cpp", "c" },
|
|
||||||
config = function()
|
|
||||||
vim.g.arduino_use_cli = 1
|
|
||||||
vim.g.arduino_cli_path = "arduino-cli"
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---@type ChadrcConfig
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
M.base46 = {
|
|
||||||
theme = "{{.NvChadTheme}}",
|
|
||||||
}
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
return {
|
|
||||||
cmd = {
|
|
||||||
"clangd",
|
|
||||||
"--background-index",
|
|
||||||
"--clang-tidy",
|
|
||||||
"--header-insertion=iwyu",
|
|
||||||
"--completion-style=detailed",
|
|
||||||
},
|
|
||||||
filetypes = { "c", "cpp", "objc", "objcpp", "cuda", "arduino" },
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
return {
|
|
||||||
{
|
|
||||||
"mfussenegger/nvim-dap",
|
|
||||||
keys = { "<leader>d" },
|
|
||||||
config = function()
|
|
||||||
local dap = require("dap")
|
|
||||||
|
|
||||||
-- GDB remote adapter (connects to Renode's GDB server)
|
|
||||||
dap.adapters.gdb_remote = {
|
|
||||||
type = "executable",
|
|
||||||
command = vim.fn.executable("arm-none-eabi-gdb") == 1
|
|
||||||
and "arm-none-eabi-gdb"
|
|
||||||
or "gdb-multiarch",
|
|
||||||
args = { "-i", "dap" },
|
|
||||||
}
|
|
||||||
|
|
||||||
-- Default debug configurations for Arduino/embedded
|
|
||||||
dap.configurations.c = {
|
|
||||||
{
|
|
||||||
name = "Attach to Renode (GDB :3333)",
|
|
||||||
type = "gdb_remote",
|
|
||||||
request = "attach",
|
|
||||||
target = "localhost:3333",
|
|
||||||
cwd = "${workspaceFolder}",
|
|
||||||
program = function()
|
|
||||||
return vim.fn.input("ELF path: ", vim.fn.getcwd() .. "/build/", "file")
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
dap.configurations.cpp = dap.configurations.c
|
|
||||||
dap.configurations.arduino = dap.configurations.c
|
|
||||||
|
|
||||||
-- Breakpoint signs
|
|
||||||
vim.fn.sign_define("DapBreakpoint", { text = "●", texthl = "DiagnosticError" })
|
|
||||||
vim.fn.sign_define("DapStopped", { text = "▶", texthl = "DiagnosticOk", linehl = "DiffAdd" })
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rcarriga/nvim-dap-ui",
|
|
||||||
dependencies = { "mfussenegger/nvim-dap", "nvim-neotest/nvim-nio" },
|
|
||||||
keys = { "<leader>du" },
|
|
||||||
config = function()
|
|
||||||
local dapui = require("dapui")
|
|
||||||
dapui.setup()
|
|
||||||
|
|
||||||
-- Auto open/close UI on debug sessions
|
|
||||||
local dap = require("dap")
|
|
||||||
dap.listeners.after.event_initialized["dapui_config"] = function() dapui.open() end
|
|
||||||
dap.listeners.before.event_terminated["dapui_config"] = function() dapui.close() end
|
|
||||||
dap.listeners.before.event_exited["dapui_config"] = function() dapui.close() end
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nvim-neotest/nvim-nio",
|
|
||||||
lazy = true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
vim.lsp.enable("clangd")
|
|
||||||
return {}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
require "nvchad.mappings"
|
|
||||||
|
|
||||||
local map = vim.keymap.set
|
|
||||||
|
|
||||||
map("n", "<leader>ac", "<cmd>ArduinoChooseBoard<cr>", { desc = "Arduino Choose Board" })
|
|
||||||
map("n", "<leader>ap", "<cmd>ArduinoChoosePort<cr>", { desc = "Arduino Choose Port" })
|
|
||||||
map("n", "<leader>av", "<cmd>ArduinoVerify<cr>", { desc = "Arduino Verify (compile)" })
|
|
||||||
map("n", "<leader>au", "<cmd>ArduinoUpload<cr>", { desc = "Arduino Upload" })
|
|
||||||
map("n", "<leader>as", "<cmd>ArduinoSerial<cr>", { desc = "Arduino Serial Monitor" })
|
|
||||||
map("n", "<leader>ai", "<cmd>ArduinoInfo<cr>", { desc = "Arduino Board Info" })
|
|
||||||
map("n", "<leader>aw", "<cmd>!wokwi-cli .<cr>", { desc = "Arduino Wokwi Simulate" })
|
|
||||||
|
|
||||||
-- Debug (DAP)
|
|
||||||
map("n", "<leader>ds", function() require("dap").continue() end, { desc = "Debug Start/Continue" })
|
|
||||||
map("n", "<leader>db", function() require("dap").toggle_breakpoint() end, { desc = "Debug Toggle Breakpoint" })
|
|
||||||
map("n", "<leader>dn", function() require("dap").step_over() end, { desc = "Debug Step Over" })
|
|
||||||
map("n", "<leader>di", function() require("dap").step_into() end, { desc = "Debug Step Into" })
|
|
||||||
map("n", "<leader>do", function() require("dap").step_out() end, { desc = "Debug Step Out" })
|
|
||||||
map("n", "<leader>dx", function() require("dap").terminate() end, { desc = "Debug Terminate" })
|
|
||||||
map("n", "<leader>du", function() require("dapui").toggle() end, { desc = "Debug UI Toggle" })
|
|
||||||
@@ -1,575 +0,0 @@
|
|||||||
// Package neovim installs Neovim via pacman, clones the NvChad starter,
|
|
||||||
// backs up any non-NvChad config, strips the starter's nvim-lspconfig
|
|
||||||
// block, and writes Archduino's Lua asset files.
|
|
||||||
package neovim
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"embed"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"text/template"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/theming"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed assets/*
|
|
||||||
var assets embed.FS
|
|
||||||
|
|
||||||
// Register WriteChadrc with the theming package so theming.Apply can
|
|
||||||
// rewrite chadrc.lua on theme changes without importing this package
|
|
||||||
// (which would be a cycle: neovim imports theming).
|
|
||||||
func init() {
|
|
||||||
theming.ChadrcWriter = WriteChadrc
|
|
||||||
}
|
|
||||||
|
|
||||||
// assetMap lists each asset and its destination under ~/.config/nvim.
|
|
||||||
// Order matters: Detect iterates in this order and Apply writes in this order.
|
|
||||||
var assetMap = []struct {
|
|
||||||
SrcPath string // under assets/
|
|
||||||
DstRel string // under ~/.config/nvim/
|
|
||||||
}{
|
|
||||||
{"assets/arduino.lua", "lua/plugins/arduino.lua"},
|
|
||||||
{"assets/dap.lua", "lua/plugins/dap.lua"},
|
|
||||||
{"assets/lspconfig.lua", "lua/plugins/lspconfig.lua"},
|
|
||||||
{"assets/mappings.lua", "lua/mappings.lua"},
|
|
||||||
{"assets/clangd.lua", "lsp/clangd.lua"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module installs and configures Neovim + NvChad for Archduino.
|
|
||||||
type Module struct {
|
|
||||||
cfg config.NeovimConfig
|
|
||||||
themeID string
|
|
||||||
pm platform.PackageManager
|
|
||||||
git platform.GitClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// New constructs a Neovim Module.
|
|
||||||
func New(cfg config.NeovimConfig, themeID string, pm platform.PackageManager, git platform.GitClient) Module {
|
|
||||||
return Module{cfg: cfg, themeID: themeID, pm: pm, git: git}
|
|
||||||
}
|
|
||||||
|
|
||||||
type state struct {
|
|
||||||
NvimInstalled bool
|
|
||||||
ConfigDirExists bool
|
|
||||||
ChadrcPresent bool
|
|
||||||
BackupNeeded bool
|
|
||||||
BackupSource string
|
|
||||||
StarterCloned bool
|
|
||||||
MissingAssets []string
|
|
||||||
ChadrcDrift bool
|
|
||||||
LspconfigBlock bool
|
|
||||||
StarterLspFile bool
|
|
||||||
ThemeName string
|
|
||||||
RenderedChadrc []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// Meta returns the module metadata.
|
|
||||||
func (Module) Meta() module.Meta {
|
|
||||||
return module.Meta{ID: "neovim", Name: "Neovim + NvChad", Description: "Install Neovim and configure NvChad with Archduino plugins."}
|
|
||||||
}
|
|
||||||
|
|
||||||
func nvimCfg() (string, error) {
|
|
||||||
h, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(h, ".config", "nvim"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) resolvedTheme() string {
|
|
||||||
if t, ok := theming.Get(m.themeID); ok {
|
|
||||||
return t.NvChadTheme
|
|
||||||
}
|
|
||||||
return theming.Default().NvChadTheme
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteChadrc renders chadrc.lua for the given NvChad theme name and writes
|
|
||||||
// it to ~/.config/nvim/lua/chadrc.lua. If the file does not already exist
|
|
||||||
// and the surrounding nvim config dir does not exist, the function returns
|
|
||||||
// nil without writing anything (the user hasn't installed neovim yet).
|
|
||||||
//
|
|
||||||
// Exported so the theming package can re-render chadrc on theme switches
|
|
||||||
// without re-running the full Module pipeline.
|
|
||||||
func WriteChadrc(nvChadThemeName string) error {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cfg := filepath.Join(home, ".config", "nvim")
|
|
||||||
chadrcPath := filepath.Join(cfg, "lua", "chadrc.lua")
|
|
||||||
|
|
||||||
// If chadrc doesn't exist, only write when the parent dir exists. A
|
|
||||||
// fresh system where nvim was never installed should be a no-op.
|
|
||||||
if _, err := os.Stat(chadrcPath); err != nil {
|
|
||||||
if _, err := os.Stat(cfg); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tmpl, err := template.New("chadrc").Parse(string(tmplBytes))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: nvChadThemeName}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return platform.WriteUserFile(chadrcPath, buf.Bytes(), 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) renderChadrc() ([]byte, error) {
|
|
||||||
tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tmpl, err := template.New("chadrc").Parse(string(tmplBytes))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: m.resolvedTheme()}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect inspects the filesystem and returns the current state.
|
|
||||||
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
|
||||||
cfg, err := nvimCfg()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var s state
|
|
||||||
s.ThemeName = m.resolvedTheme()
|
|
||||||
if m.pm != nil {
|
|
||||||
ok, err := m.pm.IsInstalled(ctx, "neovim")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.NvimInstalled = ok
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered, err := m.renderChadrc()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.RenderedChadrc = rendered
|
|
||||||
|
|
||||||
if fi, err := os.Stat(cfg); err == nil && fi.IsDir() {
|
|
||||||
s.ConfigDirExists = true
|
|
||||||
if _, err := os.Stat(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil {
|
|
||||||
s.ChadrcPresent = true
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(cfg, "lua")); err == nil && !s.ChadrcPresent {
|
|
||||||
s.BackupNeeded = true
|
|
||||||
s.BackupSource = cfg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", "init.lua")); err == nil {
|
|
||||||
s.StarterCloned = true
|
|
||||||
data, _ := os.ReadFile(filepath.Join(cfg, "lua", "plugins", "init.lua"))
|
|
||||||
if bytes.Contains(data, []byte("nvim-lspconfig")) {
|
|
||||||
s.LspconfigBlock = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(cfg, "lua", "configs", "lspconfig.lua")); err == nil {
|
|
||||||
s.StarterLspFile = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if cur, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil {
|
|
||||||
if !bytes.Equal(cur, rendered) {
|
|
||||||
s.ChadrcDrift = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, a := range assetMap {
|
|
||||||
want, _ := assets.ReadFile(a.SrcPath)
|
|
||||||
got, err := os.ReadFile(filepath.Join(cfg, a.DstRel))
|
|
||||||
if err != nil || !bytes.Equal(got, want) {
|
|
||||||
s.MissingAssets = append(s.MissingAssets, a.DstRel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripLspconfigBlock removes the plugin-table entry that references
|
|
||||||
// "neovim/nvim-lspconfig" from the NvChad starter's init.lua. It walks the
|
|
||||||
// source line-by-line: find the marker line, scan backward for the opening
|
|
||||||
// `{` of that entry, then scan forward counting braces until the matching
|
|
||||||
// `}` (plus trailing `,` if present). Robust against nested tables inside
|
|
||||||
// the entry (e.g. `opts = {}`) that defeat single-level regexes.
|
|
||||||
func stripLspconfigBlock(src []byte) []byte {
|
|
||||||
lines := strings.Split(string(src), "\n")
|
|
||||||
markerIdx := -1
|
|
||||||
for i, l := range lines {
|
|
||||||
if strings.Contains(l, "neovim/nvim-lspconfig") {
|
|
||||||
markerIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if markerIdx == -1 {
|
|
||||||
return src
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan backward for the opening `{` that begins the plugin entry.
|
|
||||||
// We track braces relative to the marker line so nested tables on
|
|
||||||
// earlier lines don't confuse the search: we want the first `{`
|
|
||||||
// whose pair would enclose the marker.
|
|
||||||
startLine := -1
|
|
||||||
depth := 0
|
|
||||||
for i := markerIdx; i >= 0; i-- {
|
|
||||||
// Count braces on this line from right to left.
|
|
||||||
for j := len(lines[i]) - 1; j >= 0; j-- {
|
|
||||||
c := lines[i][j]
|
|
||||||
if c == '}' {
|
|
||||||
depth++
|
|
||||||
} else if c == '{' {
|
|
||||||
if depth == 0 {
|
|
||||||
startLine = i
|
|
||||||
// break out of both loops
|
|
||||||
i = -1
|
|
||||||
break
|
|
||||||
}
|
|
||||||
depth--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if startLine == -1 {
|
|
||||||
return src
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan forward from markerIdx for the matching `}`.
|
|
||||||
endLine := -1
|
|
||||||
depth = 1 // we're already inside the `{` at startLine
|
|
||||||
// Start just after the opening `{` on startLine.
|
|
||||||
for i := startLine; i < len(lines); i++ {
|
|
||||||
start := 0
|
|
||||||
if i == startLine {
|
|
||||||
// skip past the first `{` on this line
|
|
||||||
idx := strings.Index(lines[i], "{")
|
|
||||||
if idx >= 0 {
|
|
||||||
start = idx + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for j := start; j < len(lines[i]); j++ {
|
|
||||||
c := lines[i][j]
|
|
||||||
if c == '{' {
|
|
||||||
depth++
|
|
||||||
} else if c == '}' {
|
|
||||||
depth--
|
|
||||||
if depth == 0 {
|
|
||||||
endLine = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if endLine != -1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if endLine == -1 {
|
|
||||||
return src
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include an optional trailing comma and whitespace-only lines after endLine.
|
|
||||||
cutEnd := endLine
|
|
||||||
// If the `}` line also has a trailing `,`, that's fine; keep the line as-is
|
|
||||||
// but drop it. If the endLine text after the closing `}` is just `,` or
|
|
||||||
// `,` + whitespace, keep cutEnd = endLine. Otherwise also keep cutEnd.
|
|
||||||
// Skip a single blank line after the block to avoid leaving a stray gap.
|
|
||||||
if cutEnd+1 < len(lines) && strings.TrimSpace(lines[cutEnd+1]) == "" {
|
|
||||||
cutEnd++
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]string, 0, len(lines)-(cutEnd-startLine+1))
|
|
||||||
out = append(out, lines[:startLine]...)
|
|
||||||
out = append(out, lines[cutEnd+1:]...)
|
|
||||||
return []byte(strings.Join(out, "\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan computes the idempotent set of actions to converge to desired state.
|
|
||||||
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
|
||||||
s, _ := cur.(state)
|
|
||||||
cfg, err := nvimCfg()
|
|
||||||
if err != nil {
|
|
||||||
return module.Plan{}, err
|
|
||||||
}
|
|
||||||
var actions []module.Action
|
|
||||||
|
|
||||||
if !s.NvimInstalled && m.pm != nil {
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "pacman install neovim",
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"neovim"}) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.BackupNeeded {
|
|
||||||
src := s.BackupSource
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "back up existing non-NvChad config",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
ts := time.Now().UTC().Format("20060102T150405Z")
|
|
||||||
return os.Rename(src, src+".bak."+ts)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.StarterCloned || s.BackupNeeded {
|
|
||||||
repo := m.cfg.NvChadStarterRepo
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "clone NvChad starter",
|
|
||||||
Kind: module.ActionGitClone,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := m.git.Clone(ctx, repo, cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.RemoveAll(filepath.Join(cfg, ".git"))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.LspconfigBlock || !s.StarterCloned || s.BackupNeeded {
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "strip nvim-lspconfig block from starter init.lua",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
p := filepath.Join(cfg, "lua", "plugins", "init.lua")
|
|
||||||
data, err := os.ReadFile(p)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return platform.WriteUserFile(p, stripLspconfigBlock(data), 0o644)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.StarterLspFile || !s.StarterCloned || s.BackupNeeded {
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "remove starter lspconfig.lua",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
err := os.Remove(filepath.Join(cfg, "lua", "configs", "lspconfig.lua"))
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.ChadrcDrift || !s.ChadrcPresent || s.BackupNeeded {
|
|
||||||
rendered := append([]byte{}, s.RenderedChadrc...)
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "write chadrc.lua with theme " + s.ThemeName,
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
return platform.WriteUserFile(filepath.Join(cfg, "lua", "chadrc.lua"), rendered, 0o644)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, dst := range s.MissingAssets {
|
|
||||||
dst := dst
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "write asset " + dst,
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
var src string
|
|
||||||
for _, a := range assetMap {
|
|
||||||
if a.DstRel == dst {
|
|
||||||
src = a.SrcPath
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data, err := assets.ReadFile(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return platform.WriteUserFile(filepath.Join(cfg, dst), data, 0o644)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(actions) > 0 {
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "clear NvChad base46 theme cache",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
_ = os.RemoveAll(filepath.Join(home, ".local", "share", "nvim", "lazy", "base46", "lua", "base46", "themes"))
|
|
||||||
_ = os.RemoveAll(filepath.Join(home, ".cache", "nvim", "base46"))
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return module.Plan{Actions: actions}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply executes the planned actions in order.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify re-Detects and returns an error if state is not converged.
|
|
||||||
func (m Module) Verify(ctx context.Context) error {
|
|
||||||
st, err := m.Detect(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s := st.(state)
|
|
||||||
if !s.NvimInstalled {
|
|
||||||
return fmt.Errorf("neovim not installed")
|
|
||||||
}
|
|
||||||
if !s.ChadrcPresent {
|
|
||||||
return fmt.Errorf("chadrc.lua missing")
|
|
||||||
}
|
|
||||||
if s.ChadrcDrift {
|
|
||||||
return fmt.Errorf("chadrc.lua content drifted")
|
|
||||||
}
|
|
||||||
if len(s.MissingAssets) > 0 {
|
|
||||||
return fmt.Errorf("missing assets: %v", s.MissingAssets)
|
|
||||||
}
|
|
||||||
if s.LspconfigBlock {
|
|
||||||
return fmt.Errorf("nvim-lspconfig block not stripped")
|
|
||||||
}
|
|
||||||
if s.StarterLspFile {
|
|
||||||
return fmt.Errorf("starter lspconfig.lua still present")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanForce rewrites chadrc.lua and reinstalls packages but does NOT
|
|
||||||
// delete an existing NvChad clone, to preserve any user modifications.
|
|
||||||
// If NvChad is missing, it's cloned fresh.
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
cfg, err := nvimCfg()
|
|
||||||
if err != nil {
|
|
||||||
return module.Plan{}, err
|
|
||||||
}
|
|
||||||
rendered, err := m.renderChadrc()
|
|
||||||
if err != nil {
|
|
||||||
return module.Plan{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var actions []module.Action
|
|
||||||
|
|
||||||
if m.pm != nil {
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "pacman -S (force) neovim",
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, []string{"neovim"}) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone NvChad only if absent; preserve any user modifications otherwise.
|
|
||||||
repo := m.cfg.NvChadStarterRepo
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "ensure NvChad starter cloned (skip if present)",
|
|
||||||
Kind: module.ActionGitClone,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", "init.lua")); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := m.git.Clone(ctx, repo, cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.RemoveAll(filepath.Join(cfg, ".git"))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "strip nvim-lspconfig block from starter init.lua (force)",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
p := filepath.Join(cfg, "lua", "plugins", "init.lua")
|
|
||||||
data, err := os.ReadFile(p)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return platform.WriteUserFile(p, stripLspconfigBlock(data), 0o644)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "remove starter lspconfig.lua (force)",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
err := os.Remove(filepath.Join(cfg, "lua", "configs", "lspconfig.lua"))
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
renderedCopy := append([]byte{}, rendered...)
|
|
||||||
themeName := m.resolvedTheme()
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "rewrite chadrc.lua with theme " + themeName + " (force)",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
if err := os.MkdirAll(filepath.Join(cfg, "lua"), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return platform.WriteUserFile(filepath.Join(cfg, "lua", "chadrc.lua"), renderedCopy, 0o644)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, a := range assetMap {
|
|
||||||
a := a
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "rewrite asset " + a.DstRel + " (force)",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
data, err := assets.ReadFile(a.SrcPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return platform.WriteUserFile(filepath.Join(cfg, a.DstRel), data, 0o644)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "clear NvChad base46 theme cache (force)",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
_ = os.RemoveAll(filepath.Join(home, ".local", "share", "nvim", "lazy", "base46", "lua", "base46", "themes"))
|
|
||||||
_ = os.RemoveAll(filepath.Join(home, ".cache", "nvim", "base46"))
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return module.Plan{Actions: actions}, nil
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
package neovim
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeRunner struct {
|
|
||||||
runCalls, 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...))
|
|
||||||
if name == "git" && len(args) >= 1 && args[0] == "clone" {
|
|
||||||
dest := args[len(args)-1]
|
|
||||||
os.MkdirAll(filepath.Join(dest, "lua", "plugins"), 0o755)
|
|
||||||
os.WriteFile(filepath.Join(dest, "lua", "plugins", "init.lua"),
|
|
||||||
[]byte("return {\n \"neovim/nvim-lspconfig\",\n opts = {},\n},\n{\n \"other\",\n},\n"),
|
|
||||||
0o644)
|
|
||||||
os.MkdirAll(filepath.Join(dest, "lua", "configs"), 0o755)
|
|
||||||
os.WriteFile(filepath.Join(dest, "lua", "configs", "lspconfig.lua"), []byte("stub"), 0o644)
|
|
||||||
}
|
|
||||||
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, errExit1
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type exitOne struct{}
|
|
||||||
|
|
||||||
func (exitOne) Error() string { return "exit 1" }
|
|
||||||
|
|
||||||
var errExit1 = exitOne{}
|
|
||||||
|
|
||||||
type nopReporter struct{}
|
|
||||||
|
|
||||||
func (nopReporter) Progress(string, int, int, string) {}
|
|
||||||
func (nopReporter) Log(string, string) {}
|
|
||||||
|
|
||||||
func TestDetectFreshSystem(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
r := &fakeRunner{installed: map[string]bool{}}
|
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r})
|
|
||||||
st, err := m.Detect(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
s := st.(state)
|
|
||||||
if s.NvimInstalled {
|
|
||||||
t.Error("nvim should be absent")
|
|
||||||
}
|
|
||||||
if s.ChadrcPresent {
|
|
||||||
t.Error("chadrc should be absent")
|
|
||||||
}
|
|
||||||
if s.BackupNeeded {
|
|
||||||
t.Error("no backup on empty system")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectNonNvChadTriggersBackup(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755)
|
|
||||||
os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644)
|
|
||||||
r := &fakeRunner{installed: map[string]bool{}}
|
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r})
|
|
||||||
st, _ := m.Detect(context.Background())
|
|
||||||
if !st.(state).BackupNeeded {
|
|
||||||
t.Fatal("expected BackupNeeded=true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanApplyInstallsEverything(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
r := &fakeRunner{installed: map[string]bool{}}
|
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", 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("empty plan")
|
|
||||||
}
|
|
||||||
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sawPacman, sawClone := false, false
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" {
|
|
||||||
sawPacman = true
|
|
||||||
}
|
|
||||||
if c[0] == "git" && len(c) >= 2 && c[1] == "clone" {
|
|
||||||
sawClone = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawPacman || !sawClone {
|
|
||||||
t.Fatalf("missing steps; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
|
|
||||||
nvimCfg := filepath.Join(home, ".config", "nvim")
|
|
||||||
chadrc, err := os.ReadFile(filepath.Join(nvimCfg, "lua", "chadrc.lua"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("chadrc missing: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(chadrc), `theme = "dracula"`) {
|
|
||||||
t.Fatalf("chadrc theme not applied: %s", chadrc)
|
|
||||||
}
|
|
||||||
|
|
||||||
initLua, _ := os.ReadFile(filepath.Join(nvimCfg, "lua", "plugins", "init.lua"))
|
|
||||||
if strings.Contains(string(initLua), "nvim-lspconfig") {
|
|
||||||
t.Fatalf("nvim-lspconfig not stripped: %s", initLua)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "configs", "lspconfig.lua")); !os.IsNotExist(err) {
|
|
||||||
t.Fatal("starter lspconfig.lua should be removed")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range []string{"arduino.lua", "dap.lua"} {
|
|
||||||
if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "plugins", name)); err != nil {
|
|
||||||
t.Errorf("asset %s missing: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "mappings.lua")); err != nil {
|
|
||||||
t.Errorf("mappings.lua missing: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(nvimCfg, "lsp", "clangd.lua")); err != nil {
|
|
||||||
t.Errorf("clangd.lua missing: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanAfterApplyIsEmpty(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
|
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r})
|
|
||||||
st, _ := m.Detect(context.Background())
|
|
||||||
p, _ := m.Plan(context.Background(), st)
|
|
||||||
_ = m.Apply(context.Background(), p, nopReporter{})
|
|
||||||
st2, _ := m.Detect(context.Background())
|
|
||||||
p2, _ := m.Plan(context.Background(), st2)
|
|
||||||
if !p2.IsEmpty() {
|
|
||||||
t.Fatalf("second plan not empty: %+v", p2.Actions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnknownThemeFallsBackToDefault(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
|
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "does-not-exist", platform.NewPacman(r), platform.GitClient{Runner: r})
|
|
||||||
st, _ := m.Detect(context.Background())
|
|
||||||
p, _ := m.Plan(context.Background(), st)
|
|
||||||
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
chadrc, _ := os.ReadFile(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua"))
|
|
||||||
// Unknown theme IDs fall back to theming.Default(), which is gruvbox.
|
|
||||||
if !strings.Contains(string(chadrc), `theme = "gruvbox"`) {
|
|
||||||
t.Fatalf("fallback theme not applied: %s", chadrc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteChadrcWritesFile(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
cfg := filepath.Join(home, ".config", "nvim")
|
|
||||||
if err := os.MkdirAll(cfg, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := WriteChadrc("dracula"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), `theme = "dracula"`) {
|
|
||||||
t.Fatalf("theme not applied: %s", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteChadrcNoopWithoutConfigDir(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
if err := WriteChadrc("gruvbox"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")); !os.IsNotExist(err) {
|
|
||||||
t.Fatal("chadrc should not be created on a bare system")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceReinstallsAndRewrites(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
// Pre-populate an "installed" NvChad clone with a local user tweak so we
|
|
||||||
// can verify PlanForce does NOT wipe the clone.
|
|
||||||
cfg := filepath.Join(home, ".config", "nvim")
|
|
||||||
os.MkdirAll(filepath.Join(cfg, "lua", "plugins"), 0o755)
|
|
||||||
os.WriteFile(filepath.Join(cfg, "lua", "plugins", "init.lua"),
|
|
||||||
[]byte("return {\n \"other\",\n},\n"), 0o644)
|
|
||||||
userTweak := filepath.Join(cfg, "lua", "user_tweak.lua")
|
|
||||||
os.WriteFile(userTweak, []byte("-- my custom stuff"), 0o644)
|
|
||||||
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
|
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", platform.NewPacman(r), platform.GitClient{Runner: r})
|
|
||||||
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec %q: %v", a.Description, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User tweak must survive.
|
|
||||||
if _, err := os.Stat(userTweak); err != nil {
|
|
||||||
t.Fatalf("PlanForce wiped user NvChad modification: %v", err)
|
|
||||||
}
|
|
||||||
// No `git clone` call because starter init.lua was already present.
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if c[0] == "git" && len(c) >= 2 && c[1] == "clone" {
|
|
||||||
t.Fatalf("PlanForce re-cloned existing NvChad: %v", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// pacman reinstall must have fired.
|
|
||||||
sawReinstall := false
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
|
|
||||||
sawReinstall = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawReinstall {
|
|
||||||
t.Fatalf("no pacman reinstall call; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
// chadrc.lua was rewritten with the requested theme.
|
|
||||||
chadrc, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("chadrc missing after force: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(chadrc), `theme = "dracula"`) {
|
|
||||||
t.Fatalf("chadrc theme not applied: %s", chadrc)
|
|
||||||
}
|
|
||||||
// assets rewritten
|
|
||||||
for _, name := range []string{"arduino.lua", "dap.lua"} {
|
|
||||||
if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", name)); err != nil {
|
|
||||||
t.Errorf("asset %s missing after force: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceClonesIfMissing(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
|
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r})
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec %q: %v", a.Description, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sawClone := false
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if c[0] == "git" && len(c) >= 2 && c[1] == "clone" {
|
|
||||||
sawClone = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawClone {
|
|
||||||
t.Fatalf("expected git clone when NvChad absent; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// Package packages installs a declarative list of pacman packages.
|
|
||||||
package packages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
if len(m.pkgs) == 0 {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
pkgs := append([]string{}, m.pkgs...)
|
|
||||||
return module.Plan{Actions: []module.Action{{
|
|
||||||
Description: "pacman -S (force) " + strings.Join(pkgs, ", "),
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, pkgs) },
|
|
||||||
}}}, nil
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package packages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceReinstallsAllPackages(t *testing.T) {
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"curl": true, "wget": true}}
|
|
||||||
m := New([]string{"curl", "wget"}, platform.NewPacman(r))
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty even when packages are installed")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sawReinstall := false
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
|
|
||||||
sawReinstall = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawReinstall {
|
|
||||||
t.Fatalf("no pacman reinstall (-S --noconfirm, no --needed) call; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "Usage: arduino-sim <firmware.elf> [platform]"
|
|
||||||
echo ""
|
|
||||||
echo "Launch Renode with a GDB server for debugging firmware."
|
|
||||||
echo "Connect nvim-dap or gdb to localhost:3333"
|
|
||||||
echo ""
|
|
||||||
echo "Platforms:"
|
|
||||||
{{range .Platforms}} echo " {{.Name}} {{.Machine}}"
|
|
||||||
{{end}} exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
[[ $# -lt 1 ]] && usage
|
|
||||||
|
|
||||||
ELF="$1"
|
|
||||||
PLATFORM="${2:-{{(index .Platforms 0).Name}}}"
|
|
||||||
|
|
||||||
[[ ! -f "$ELF" ]] && echo "Error: $ELF not found" && exit 1
|
|
||||||
|
|
||||||
case "$PLATFORM" in
|
|
||||||
{{range .Platforms}} {{.Name}})
|
|
||||||
RENODE_PLATFORM="{{.ReplPath}}"
|
|
||||||
MACHINE="{{.Machine}}"
|
|
||||||
;;
|
|
||||||
{{end}} *)
|
|
||||||
echo "Unknown platform: $PLATFORM"
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
RESC=$(mktemp /tmp/renode-XXXXXX.resc)
|
|
||||||
cat >"$RESC" <<EOF
|
|
||||||
using sysbus
|
|
||||||
|
|
||||||
mach create "$MACHINE"
|
|
||||||
machine LoadPlatformDescription @$RENODE_PLATFORM
|
|
||||||
|
|
||||||
sysbus LoadELF @$(realpath "$ELF")
|
|
||||||
|
|
||||||
machine StartGdbServer 3333
|
|
||||||
|
|
||||||
showAnalyzer sysbus.uart0 Antmicro.Renode.Analyzers.LoggingUartAnalyzer
|
|
||||||
|
|
||||||
start
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "Starting Renode ($MACHINE) with GDB server on :3333"
|
|
||||||
echo "Connect debugger: nvim → <leader>ds or gdb → target remote :3333"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
renode --disable-xwt "$RESC"
|
|
||||||
rm -f "$RESC"
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
// Package renode installs the Renode emulator (AUR), arm-none-eabi-gdb,
|
|
||||||
// the arduino-sim helper script, and wokwi-cli.
|
|
||||||
package renode
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"embed"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"text/template"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed assets/arduino-sim.tmpl
|
|
||||||
var assetsFS embed.FS
|
|
||||||
|
|
||||||
// Module installs Renode and adjacent emulator tooling.
|
|
||||||
type Module struct {
|
|
||||||
cfg config.RenodeConfig
|
|
||||||
pm platform.PackageManager
|
|
||||||
aur platform.AURHelper
|
|
||||||
}
|
|
||||||
|
|
||||||
// New constructs a renode Module.
|
|
||||||
func New(cfg config.RenodeConfig, pm platform.PackageManager, aur platform.AURHelper) Module {
|
|
||||||
return Module{cfg: cfg, pm: pm, aur: aur}
|
|
||||||
}
|
|
||||||
|
|
||||||
type state struct {
|
|
||||||
RenodeInstalled bool
|
|
||||||
GdbInstalled bool
|
|
||||||
SimScriptCurrent bool
|
|
||||||
WokwiInstalled bool
|
|
||||||
RenderedSim []byte
|
|
||||||
SimPath string
|
|
||||||
WokwiPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Meta returns the module metadata.
|
|
||||||
func (Module) Meta() module.Meta {
|
|
||||||
return module.Meta{ID: "renode", Name: "Renode emulator", Description: "Install Renode, ARM GDB, arduino-sim helper, and Wokwi CLI."}
|
|
||||||
}
|
|
||||||
|
|
||||||
func binPath() (string, error) {
|
|
||||||
h, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(h, ".local", "bin"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) renderSim() ([]byte, error) {
|
|
||||||
tmplBytes, err := assetsFS.ReadFile("assets/arduino-sim.tmpl")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tmpl, err := template.New("sim").Parse(string(tmplBytes))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := tmpl.Execute(&buf, m.cfg); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect inspects the system and returns current state.
|
|
||||||
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
|
||||||
bin, err := binPath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s := state{
|
|
||||||
SimPath: filepath.Join(bin, "arduino-sim"),
|
|
||||||
WokwiPath: filepath.Join(bin, "wokwi-cli"),
|
|
||||||
}
|
|
||||||
if ok, err := m.pm.IsInstalled(ctx, "renode-bin"); err == nil && ok {
|
|
||||||
s.RenodeInstalled = true
|
|
||||||
}
|
|
||||||
if ok, err := m.pm.IsInstalled(ctx, "arm-none-eabi-gdb"); err == nil && ok {
|
|
||||||
s.GdbInstalled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered, err := m.renderSim()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.RenderedSim = rendered
|
|
||||||
if cur, err := os.ReadFile(s.SimPath); err == nil && bytes.Equal(cur, rendered) {
|
|
||||||
s.SimScriptCurrent = true
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(s.WokwiPath); err == nil {
|
|
||||||
s.WokwiInstalled = true
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan computes the set of actions needed to converge to desired state.
|
|
||||||
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
|
||||||
s, _ := cur.(state)
|
|
||||||
var actions []module.Action
|
|
||||||
|
|
||||||
if !s.RenodeInstalled {
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "ensure yay installed",
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) },
|
|
||||||
})
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "yay install renode-bin",
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.aur.Install(ctx, []string{"renode-bin"}) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if !s.GdbInstalled {
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "pacman install arm-none-eabi-gdb",
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"arm-none-eabi-gdb"}) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if !s.SimScriptCurrent {
|
|
||||||
rendered := append([]byte{}, s.RenderedSim...)
|
|
||||||
path := s.SimPath
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "write arduino-sim helper",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
return platform.WriteUserFile(path, rendered, 0o755)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if !s.WokwiInstalled {
|
|
||||||
url := m.cfg.WokwiURL
|
|
||||||
dst := s.WokwiPath
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "download wokwi-cli " + m.cfg.WokwiVersion,
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return platform.Download(ctx, url, dst, 0o755)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return module.Plan{Actions: actions}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply executes the planned actions in order.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify re-Detects and errors if state hasn't converged.
|
|
||||||
func (m Module) Verify(ctx context.Context) error {
|
|
||||||
st, err := m.Detect(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s := st.(state)
|
|
||||||
if !s.RenodeInstalled {
|
|
||||||
return fmt.Errorf("renode-bin not installed")
|
|
||||||
}
|
|
||||||
if !s.GdbInstalled {
|
|
||||||
return fmt.Errorf("arm-none-eabi-gdb not installed")
|
|
||||||
}
|
|
||||||
if !s.SimScriptCurrent {
|
|
||||||
return fmt.Errorf("arduino-sim script drift")
|
|
||||||
}
|
|
||||||
if !s.WokwiInstalled {
|
|
||||||
return fmt.Errorf("wokwi-cli missing")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
bin, err := binPath()
|
|
||||||
if err != nil {
|
|
||||||
return module.Plan{}, err
|
|
||||||
}
|
|
||||||
rendered, err := m.renderSim()
|
|
||||||
if err != nil {
|
|
||||||
return module.Plan{}, err
|
|
||||||
}
|
|
||||||
simPath := filepath.Join(bin, "arduino-sim")
|
|
||||||
wokwiPath := filepath.Join(bin, "wokwi-cli")
|
|
||||||
|
|
||||||
actions := []module.Action{
|
|
||||||
{
|
|
||||||
Description: "ensure yay installed",
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "yay -S (force) renode-bin",
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.aur.Reinstall(ctx, []string{"renode-bin"}) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "pacman -S (force) arm-none-eabi-gdb",
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, []string{"arm-none-eabi-gdb"}) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "rewrite arduino-sim helper (force)",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
return platform.WriteUserFile(simPath, rendered, 0o755)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "redownload wokwi-cli " + m.cfg.WokwiVersion + " (force)",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(wokwiPath), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return platform.Download(ctx, m.cfg.WokwiURL, wokwiPath, 0o755)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return module.Plan{Actions: actions}, nil
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package renode
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeRunner struct {
|
|
||||||
runCalls, 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, errExit1
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type exitOne struct{}
|
|
||||||
|
|
||||||
func (exitOne) Error() string { return "exit 1" }
|
|
||||||
|
|
||||||
var errExit1 = exitOne{}
|
|
||||||
|
|
||||||
type nopReporter struct{}
|
|
||||||
|
|
||||||
func (nopReporter) Progress(string, int, int, string) {}
|
|
||||||
func (nopReporter) Log(string, string) {}
|
|
||||||
|
|
||||||
func sampleCfg(url string) config.RenodeConfig {
|
|
||||||
return config.RenodeConfig{
|
|
||||||
WokwiVersion: "0.28.1",
|
|
||||||
WokwiURL: url,
|
|
||||||
Platforms: []config.RenodePlatform{
|
|
||||||
{Name: "stm32f4", ReplPath: "platforms/boards/stm32f4_discovery.repl", Machine: "STM32F4_Discovery"},
|
|
||||||
{Name: "rp2040", ReplPath: "platforms/cpus/rp2040.repl", Machine: "RP2040"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectNothingInstalled(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
r := &fakeRunner{installed: map[string]bool{}}
|
|
||||||
m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r))
|
|
||||||
st, err := m.Detect(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
s := st.(state)
|
|
||||||
if s.RenodeInstalled || s.GdbInstalled || s.SimScriptCurrent || s.WokwiInstalled {
|
|
||||||
t.Fatalf("want all absent, got %+v", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectAllInstalled(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755)
|
|
||||||
os.WriteFile(filepath.Join(home, ".local", "bin", "wokwi-cli"), []byte("x"), 0o755)
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"renode-bin": true, "arm-none-eabi-gdb": true}}
|
|
||||||
m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r))
|
|
||||||
// Apply once to seed the sim script, then re-detect.
|
|
||||||
st, _ := m.Detect(context.Background())
|
|
||||||
p, _ := m.Plan(context.Background(), st)
|
|
||||||
_ = m.Apply(context.Background(), p, nopReporter{})
|
|
||||||
st2, _ := m.Detect(context.Background())
|
|
||||||
p2, _ := m.Plan(context.Background(), st2)
|
|
||||||
if !p2.IsEmpty() {
|
|
||||||
t.Fatalf("second plan not empty: %+v", p2.Actions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyRendersSimScriptAndDownloadsWokwi(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Write([]byte("wokwi-binary"))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"yay": true}}
|
|
||||||
m := New(sampleCfg(srv.URL), platform.NewPacman(r), platform.NewYay(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// arduino-sim rendered with both platforms
|
|
||||||
sim, err := os.ReadFile(filepath.Join(home, ".local", "bin", "arduino-sim"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("arduino-sim not written: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(sim), "stm32f4)") {
|
|
||||||
t.Errorf("stm32f4 branch missing: %s", sim)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(sim), "rp2040)") {
|
|
||||||
t.Errorf("rp2040 branch missing: %s", sim)
|
|
||||||
}
|
|
||||||
info, _ := os.Stat(filepath.Join(home, ".local", "bin", "arduino-sim"))
|
|
||||||
if info.Mode().Perm() != 0o755 {
|
|
||||||
t.Errorf("arduino-sim mode %v", info.Mode().Perm())
|
|
||||||
}
|
|
||||||
|
|
||||||
// wokwi-cli downloaded
|
|
||||||
wokwi, err := os.ReadFile(filepath.Join(home, ".local", "bin", "wokwi-cli"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("wokwi-cli missing: %v", err)
|
|
||||||
}
|
|
||||||
if string(wokwi) != "wokwi-binary" {
|
|
||||||
t.Errorf("wokwi-cli content %q", wokwi)
|
|
||||||
}
|
|
||||||
|
|
||||||
// yay install renode-bin called
|
|
||||||
sawYay := false
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if c[0] == "yay" && len(c) >= 2 && c[1] == "-S" {
|
|
||||||
sawYay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawYay {
|
|
||||||
t.Fatalf("yay install missing; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceReinstallsEverything(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Write([]byte("wokwi-forced"))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
// Even with everything already "installed", force plan should be non-empty.
|
|
||||||
os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755)
|
|
||||||
os.WriteFile(filepath.Join(home, ".local", "bin", "wokwi-cli"), []byte("stale"), 0o755)
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"yay": true, "renode-bin": true, "arm-none-eabi-gdb": true}}
|
|
||||||
m := New(sampleCfg(srv.URL), platform.NewPacman(r), platform.NewYay(r))
|
|
||||||
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec %q: %v", a.Description, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var sawYayReinstall, sawPacmanReinstall bool
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if len(c) >= 3 && c[0] == "yay" && c[1] == "-S" && c[2] == "--noconfirm" {
|
|
||||||
sawYayReinstall = true
|
|
||||||
}
|
|
||||||
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
|
|
||||||
sawPacmanReinstall = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawYayReinstall {
|
|
||||||
t.Errorf("no yay reinstall call; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
if !sawPacmanReinstall {
|
|
||||||
t.Errorf("no pacman reinstall call; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
// wokwi-cli should have been overwritten with the fresh payload
|
|
||||||
wokwi, _ := os.ReadFile(filepath.Join(home, ".local", "bin", "wokwi-cli"))
|
|
||||||
if string(wokwi) != "wokwi-forced" {
|
|
||||||
t.Errorf("wokwi-cli not overwritten: %q", wokwi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// Package shellconfig writes marker-delimited blocks into rc files.
|
|
||||||
package shellconfig
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
var actions []module.Action
|
|
||||||
for _, b := range m.cfg.Blocks {
|
|
||||||
b := b
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: fmt.Sprintf("rewrite block %s into %s (force)", 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
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package shellconfig
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceRewritesBlocks(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
cfg := config.ShellConfigSection{Blocks: []config.ShellConfigBlock{
|
|
||||||
{Name: "path", File: ".zshrc", Content: `export PATH="$HOME/.local/bin:$PATH"`},
|
|
||||||
}}
|
|
||||||
m := New(cfg)
|
|
||||||
st, _ := m.Detect(context.Background())
|
|
||||||
p, _ := m.Plan(context.Background(), st)
|
|
||||||
if err := m.Apply(context.Background(), p, nop{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
st2, _ := m.Detect(context.Background())
|
|
||||||
p2, _ := m.Plan(context.Background(), st2)
|
|
||||||
if !p2.IsEmpty() {
|
|
||||||
t.Fatalf("precondition: regular plan should be empty")
|
|
||||||
}
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data, _ := os.ReadFile(filepath.Join(home, ".zshrc"))
|
|
||||||
if !strings.Contains(string(data), `export PATH="$HOME/.local/bin:$PATH"`) {
|
|
||||||
t.Fatalf("content missing after force: %q", data)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), ">>> path >>>") {
|
|
||||||
t.Fatalf("block marker missing: %q", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// Package starship installs a declarative list of pacman packages.
|
|
||||||
package starship
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
if len(m.pkgs) == 0 {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
pkgs := append([]string{}, m.pkgs...)
|
|
||||||
return module.Plan{Actions: []module.Action{{
|
|
||||||
Description: "pacman -S (force) " + strings.Join(pkgs, ", "),
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, pkgs) },
|
|
||||||
}}}, nil
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package starship
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceReinstallsAllPackages(t *testing.T) {
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"starship": true}}
|
|
||||||
m := New([]string{"starship"}, platform.NewPacman(r))
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty even when packages are installed")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sawReinstall := false
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
|
|
||||||
sawReinstall = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawReinstall {
|
|
||||||
t.Fatalf("no pacman reinstall call; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
// Package themes installs archduino theme files into ~/.config/archduino/themes.
|
|
||||||
package themes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
dst, err := destRoot()
|
|
||||||
if err != nil {
|
|
||||||
return module.Plan{}, err
|
|
||||||
}
|
|
||||||
var pairs []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)
|
|
||||||
pairs = append(pairs, filePair{src: p, dst: filepath.Join(dst, rel)})
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
return module.Plan{}, err
|
|
||||||
}
|
|
||||||
if len(pairs) == 0 {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
return module.Plan{Actions: []module.Action{{
|
|
||||||
Description: fmt.Sprintf("rewrite %d theme files (force)", 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
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package themes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceRewritesAllThemes(t *testing.T) {
|
|
||||||
src := t.TempDir()
|
|
||||||
os.WriteFile(filepath.Join(src, "one.toml"), []byte("FRESH-A"), 0o644)
|
|
||||||
os.MkdirAll(filepath.Join(src, "sub"), 0o755)
|
|
||||||
os.WriteFile(filepath.Join(src, "sub", "two.toml"), []byte("FRESH-B"), 0o644)
|
|
||||||
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
m := New(config.ThemesConfig{Source: src})
|
|
||||||
dst := filepath.Join(home, ".config", "archduino", "themes")
|
|
||||||
os.MkdirAll(filepath.Join(dst, "sub"), 0o755)
|
|
||||||
os.WriteFile(filepath.Join(dst, "one.toml"), []byte("FRESH-A"), 0o644)
|
|
||||||
os.WriteFile(filepath.Join(dst, "sub", "two.toml"), []byte("FRESH-B"), 0o644)
|
|
||||||
|
|
||||||
st, _ := m.Detect(context.Background())
|
|
||||||
p, _ := m.Plan(context.Background(), st)
|
|
||||||
if !p.IsEmpty() {
|
|
||||||
t.Fatalf("precondition: plan should be empty, got %+v", p.Actions)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.WriteFile(filepath.Join(dst, "one.toml"), []byte("STALE"), 0o644)
|
|
||||||
os.WriteFile(filepath.Join(dst, "sub", "two.toml"), []byte("STALE"), 0o644)
|
|
||||||
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dst, "one.toml"))
|
|
||||||
if string(got) != "FRESH-A" {
|
|
||||||
t.Fatalf("one.toml not rewritten: %q", got)
|
|
||||||
}
|
|
||||||
got, _ = os.ReadFile(filepath.Join(dst, "sub", "two.toml"))
|
|
||||||
if string(got) != "FRESH-B" {
|
|
||||||
t.Fatalf("two.toml not rewritten: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// Package yazi installs a declarative list of pacman packages.
|
|
||||||
package yazi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
if len(m.pkgs) == 0 {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
pkgs := append([]string{}, m.pkgs...)
|
|
||||||
return module.Plan{Actions: []module.Action{{
|
|
||||||
Description: "pacman -S (force) " + strings.Join(pkgs, ", "),
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, pkgs) },
|
|
||||||
}}}, nil
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package yazi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceReinstallsAllPackages(t *testing.T) {
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"yazi": true}}
|
|
||||||
m := New([]string{"yazi"}, platform.NewPacman(r))
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty even when packages are installed")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sawReinstall := false
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
|
|
||||||
sawReinstall = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawReinstall {
|
|
||||||
t.Fatalf("no pacman reinstall call; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
// Package zellij installs zellij and optionally writes a remote config.
|
|
||||||
package zellij
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
actions := []module.Action{{
|
|
||||||
Description: "pacman -S (force) zellij",
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, []string{"zellij"}) },
|
|
||||||
}}
|
|
||||||
if m.cfg.ConfigURL != "" {
|
|
||||||
p, err := configPath()
|
|
||||||
if err != nil {
|
|
||||||
return module.Plan{}, err
|
|
||||||
}
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "redownload zellij config (force)",
|
|
||||||
Kind: module.ActionFileWrite,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return platform.Download(ctx, m.cfg.ConfigURL, p, 0o644)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return module.Plan{Actions: actions}, nil
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
package zellij
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceReinstallsAndRewritesConfig(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
os.MkdirAll(filepath.Join(home, ".config", "zellij"), 0o755)
|
|
||||||
os.WriteFile(filepath.Join(home, ".config", "zellij", "config.kdl"), []byte("old\n"), 0o644)
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Write([]byte("fresh {}\n"))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
r := &fakeRunner{installed: map[string]bool{"zellij": true}}
|
|
||||||
m := New(config.ZellijConfig{ConfigURL: srv.URL}, platform.NewPacman(r))
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sawReinstall := false
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
|
|
||||||
sawReinstall = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawReinstall {
|
|
||||||
t.Fatalf("no pacman reinstall call; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
got, err := os.ReadFile(filepath.Join(home, ".config", "zellij", "config.kdl"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("config not written: %v", err)
|
|
||||||
}
|
|
||||||
if string(got) != "fresh {}\n" {
|
|
||||||
t.Fatalf("config not overwritten: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
// Package zsh installs zsh, oh-my-zsh, and configured plugins.
|
|
||||||
package zsh
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Module) PlanForce(ctx context.Context) (module.Plan, error) {
|
|
||||||
actions := []module.Action{{
|
|
||||||
Description: "pacman -S (force) zsh",
|
|
||||||
Kind: module.ActionPackageInstall,
|
|
||||||
Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, []string{"zsh"}) },
|
|
||||||
}}
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "reclone oh-my-zsh from " + m.cfg.OhMyZshRepo + " (force)",
|
|
||||||
Kind: module.ActionGitClone,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
omz, err := omzHome()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.RemoveAll(omz); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return m.git.Clone(ctx, m.cfg.OhMyZshRepo, omz)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
for _, p := range m.cfg.Plugins {
|
|
||||||
p := p
|
|
||||||
actions = append(actions, module.Action{
|
|
||||||
Description: "reclone plugin " + p.Name + " (force)",
|
|
||||||
Kind: module.ActionGitClone,
|
|
||||||
Exec: func(ctx context.Context) error {
|
|
||||||
omz, err := omzHome()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
dest := filepath.Join(omz, "custom", "plugins", p.Name)
|
|
||||||
if err := os.RemoveAll(dest); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return m.git.Clone(ctx, p.Repo, dest)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return module.Plan{Actions: actions}, nil
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
package zsh
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanForceReinstallsEverything(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
os.MkdirAll(filepath.Join(home, ".oh-my-zsh", ".git"), 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})
|
|
||||||
plan, err := m.PlanForce(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanForce: %v", err)
|
|
||||||
}
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
t.Fatal("force plan must not be empty")
|
|
||||||
}
|
|
||||||
for _, a := range plan.Actions {
|
|
||||||
if err := a.Exec(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Exec: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sawReinstall, sawOMZ, sawPlugin := false, false, false
|
|
||||||
for _, c := range r.runCalls {
|
|
||||||
if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" {
|
|
||||||
sawReinstall = 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 !sawReinstall || !sawOMZ || !sawPlugin {
|
|
||||||
t.Fatalf("missing force action; calls=%v", r.runCalls)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(home, ".oh-my-zsh")); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("oh-my-zsh dir should have been removed; stat err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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
|
|
||||||
Reinstall(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...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reinstall runs `yay -S --noconfirm <pkgs...>` WITHOUT `--needed`, forcing
|
|
||||||
// yay/pacman to re-fetch and reinstall the packages even if already present.
|
|
||||||
// Used by the `--force` flag to bypass idempotency.
|
|
||||||
func (y *Yay) Reinstall(ctx context.Context, pkgs []string) error {
|
|
||||||
if len(pkgs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
args := append([]string{"-S", "--noconfirm"}, pkgs...)
|
|
||||||
return y.Runner.Run(ctx, "yay", args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func shellQuote(s string) string {
|
|
||||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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 TestYayReinstall(t *testing.T) {
|
|
||||||
r := &aurFakeRunner{yayInstalled: true}
|
|
||||||
y := NewYay(r)
|
|
||||||
if err := y.Reinstall(context.Background(), []string{"git", "curl"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(r.runCalls) == 0 {
|
|
||||||
t.Fatal("expected yay reinstall call")
|
|
||||||
}
|
|
||||||
c := r.runCalls[len(r.runCalls)-1]
|
|
||||||
want := []string{"yay", "-S", "--noconfirm", "git", "curl"}
|
|
||||||
if len(c) != len(want) {
|
|
||||||
t.Fatalf("call = %v, want %v", c, want)
|
|
||||||
}
|
|
||||||
for i, w := range want {
|
|
||||||
if c[i] != w {
|
|
||||||
t.Fatalf("call = %v, want %v", c, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, a := range c {
|
|
||||||
if a == "--needed" {
|
|
||||||
t.Fatalf("Reinstall must not pass --needed; got %v", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package platform
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExecRunner runs commands via os/exec. This is the production Runner.
|
|
||||||
type ExecRunner struct{}
|
|
||||||
|
|
||||||
func (ExecRunner) Run(ctx context.Context, name string, args ...string) error {
|
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stdout = nil
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
msg := strings.TrimSpace(stderr.String())
|
|
||||||
if msg == "" {
|
|
||||||
return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, msg)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ExecRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, stderr.String())
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pacman implements PackageManager via `pacman` on Arch.
|
|
||||||
type Pacman struct {
|
|
||||||
Runner Runner
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPacman(r Runner) *Pacman {
|
|
||||||
if r == nil {
|
|
||||||
r = ExecRunner{}
|
|
||||||
}
|
|
||||||
return &Pacman{Runner: r}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (Pacman) Name() string { return "pacman" }
|
|
||||||
|
|
||||||
// IsInstalled returns true when `pacman -Q <pkg>` succeeds.
|
|
||||||
func (p Pacman) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
|
||||||
if pkg == "" {
|
|
||||||
return false, errors.New("empty package name")
|
|
||||||
}
|
|
||||||
_, err := p.Runner.Output(ctx, "pacman", "-Q", pkg)
|
|
||||||
if err == nil {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
// pacman -Q exits 1 when not installed; we treat any error as "not installed"
|
|
||||||
// because false-positives on transient errors here are harmless (Install
|
|
||||||
// is idempotent via --needed).
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install runs `sudo pacman -S --needed --noconfirm <pkgs...>`.
|
|
||||||
func (p Pacman) Install(ctx context.Context, pkgs []string) error {
|
|
||||||
if len(pkgs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
args := append([]string{"pacman", "-S", "--needed", "--noconfirm"}, pkgs...)
|
|
||||||
return p.Runner.Run(ctx, "sudo", args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reinstall runs `sudo pacman -S --noconfirm <pkgs...>` WITHOUT `--needed`,
|
|
||||||
// so pacman physically re-fetches and reinstalls the packages even if they
|
|
||||||
// are already present. Used by the `--force` flag to bypass idempotency.
|
|
||||||
func (p Pacman) Reinstall(ctx context.Context, pkgs []string) error {
|
|
||||||
if len(pkgs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
args := append([]string{"pacman", "-S", "--noconfirm"}, pkgs...)
|
|
||||||
return p.Runner.Run(ctx, "sudo", args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update runs `sudo pacman -Syu --noconfirm` (full sync + system upgrade).
|
|
||||||
// On Arch, a bare `pacman -Sy` followed by an install is the partial-upgrade
|
|
||||||
// anti-pattern that can break the system when a dep's ABI changes, so we
|
|
||||||
// always pair the sync with the upgrade.
|
|
||||||
func (p Pacman) Update(ctx context.Context) error {
|
|
||||||
return p.Runner.Run(ctx, "sudo", "pacman", "-Syu", "--noconfirm")
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package platform
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeRunner struct {
|
|
||||||
runCalls [][]string
|
|
||||||
outputCalls [][]string
|
|
||||||
outputErr error
|
|
||||||
output []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.outputCalls = append(f.outputCalls, append([]string{name}, args...))
|
|
||||||
return f.output, f.outputErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPacmanInstallShellsOutCorrectly(t *testing.T) {
|
|
||||||
r := &fakeRunner{}
|
|
||||||
p := NewPacman(r)
|
|
||||||
if err := p.Install(context.Background(), []string{"tio", "git"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(r.runCalls) != 1 {
|
|
||||||
t.Fatalf("run calls = %d", len(r.runCalls))
|
|
||||||
}
|
|
||||||
want := []string{"sudo", "pacman", "-S", "--needed", "--noconfirm", "tio", "git"}
|
|
||||||
if !reflect.DeepEqual(r.runCalls[0], want) {
|
|
||||||
t.Fatalf("args = %v, want %v", r.runCalls[0], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPacmanReinstall(t *testing.T) {
|
|
||||||
r := &fakeRunner{}
|
|
||||||
p := NewPacman(r)
|
|
||||||
if err := p.Reinstall(context.Background(), []string{"git", "curl"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(r.runCalls) != 1 {
|
|
||||||
t.Fatalf("run calls = %d", len(r.runCalls))
|
|
||||||
}
|
|
||||||
got := r.runCalls[0]
|
|
||||||
want := []string{"sudo", "pacman", "-S", "--noconfirm", "git", "curl"}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("args = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
for _, a := range got {
|
|
||||||
if a == "--needed" {
|
|
||||||
t.Fatalf("Reinstall must not pass --needed; got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPacmanInstallEmptyIsNoop(t *testing.T) {
|
|
||||||
r := &fakeRunner{}
|
|
||||||
p := NewPacman(r)
|
|
||||||
if err := p.Install(context.Background(), nil); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(r.runCalls) != 0 {
|
|
||||||
t.Fatalf("expected no calls, got %v", r.runCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPacmanIsInstalledTrue(t *testing.T) {
|
|
||||||
r := &fakeRunner{output: []byte("tio 3.2-1")}
|
|
||||||
p := NewPacman(r)
|
|
||||||
ok, err := p.IsInstalled(context.Background(), "tio")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("want installed=true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPacmanIsInstalledFalseOnError(t *testing.T) {
|
|
||||||
r := &fakeRunner{outputErr: errors.New("not found")}
|
|
||||||
p := NewPacman(r)
|
|
||||||
ok, err := p.IsInstalled(context.Background(), "ghost")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
t.Fatal("want installed=false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPacmanUpdate(t *testing.T) {
|
|
||||||
r := &fakeRunner{}
|
|
||||||
p := NewPacman(r)
|
|
||||||
if err := p.Update(context.Background()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := []string{"sudo", "pacman", "-Syu", "--noconfirm"}
|
|
||||||
if !reflect.DeepEqual(r.runCalls[0], want) {
|
|
||||||
t.Fatalf("args = %v, want %v", r.runCalls[0], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package platform
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// PackageManager abstracts OS-level package operations so later plans
|
|
||||||
// can add Debian/Fedora without touching modules.
|
|
||||||
type PackageManager interface {
|
|
||||||
Name() string
|
|
||||||
IsInstalled(ctx context.Context, pkg string) (bool, error)
|
|
||||||
Install(ctx context.Context, pkgs []string) error
|
|
||||||
Reinstall(ctx context.Context, pkgs []string) error
|
|
||||||
Update(ctx context.Context) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runner executes a command with arguments. Injected into package managers
|
|
||||||
// so tests can stub shell-outs.
|
|
||||||
type Runner interface {
|
|
||||||
Run(ctx context.Context, name string, args ...string) error
|
|
||||||
Output(ctx context.Context, name string, args ...string) ([]byte, error)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// Package platform detects the host OS/distro/WSL status.
|
|
||||||
package platform
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Distro is a narrow enum of distros we care about.
|
|
||||||
type Distro string
|
|
||||||
|
|
||||||
const (
|
|
||||||
DistroArch Distro = "arch"
|
|
||||||
DistroUnknown Distro = "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Platform holds detected host information.
|
|
||||||
type Platform struct {
|
|
||||||
Distro Distro
|
|
||||||
IsWSL bool
|
|
||||||
Arch string // e.g. "amd64", "arm64"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect reads the live system. Returns DistroUnknown rather than an error
|
|
||||||
// when /etc/os-release is missing or unrecognized; callers decide what to do.
|
|
||||||
func Detect() (Platform, error) {
|
|
||||||
f, err := os.Open("/etc/os-release")
|
|
||||||
distro := DistroUnknown
|
|
||||||
if err == nil {
|
|
||||||
distro = parseDistro(f)
|
|
||||||
_ = f.Close()
|
|
||||||
} else if !os.IsNotExist(err) {
|
|
||||||
return Platform{}, fmt.Errorf("open /etc/os-release: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wsl := false
|
|
||||||
if data, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil {
|
|
||||||
lower := strings.ToLower(string(data))
|
|
||||||
wsl = strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl")
|
|
||||||
}
|
|
||||||
|
|
||||||
return Platform{Distro: distro, IsWSL: wsl, Arch: runtime.GOARCH}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDistro(r io.Reader) Distro {
|
|
||||||
sc := bufio.NewScanner(r)
|
|
||||||
for sc.Scan() {
|
|
||||||
line := sc.Text()
|
|
||||||
if !strings.HasPrefix(line, "ID=") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := strings.Trim(strings.TrimPrefix(line, "ID="), `"`)
|
|
||||||
if id == "arch" {
|
|
||||||
return DistroArch
|
|
||||||
}
|
|
||||||
return DistroUnknown
|
|
||||||
}
|
|
||||||
return DistroUnknown
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package platform
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseDistroArch(t *testing.T) {
|
|
||||||
osRelease := `NAME="Arch Linux"
|
|
||||||
ID=arch
|
|
||||||
ID_LIKE=archlinux
|
|
||||||
`
|
|
||||||
if got := parseDistro(strings.NewReader(osRelease)); got != DistroArch {
|
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDistroUbuntu(t *testing.T) {
|
|
||||||
osRelease := `NAME="Ubuntu"
|
|
||||||
ID=ubuntu
|
|
||||||
`
|
|
||||||
if got := parseDistro(strings.NewReader(osRelease)); got != DistroUnknown {
|
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDistroQuoted(t *testing.T) {
|
|
||||||
osRelease := `ID="arch"
|
|
||||||
`
|
|
||||||
if got := parseDistro(strings.NewReader(osRelease)); got != DistroArch {
|
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectReturnsArch(t *testing.T) {
|
|
||||||
p, err := Detect()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if p.Arch == "" {
|
|
||||||
t.Fatal("arch is empty")
|
|
||||||
}
|
|
||||||
// We don't assert Distro here because the test host may not be Arch.
|
|
||||||
// The parse-level tests above cover that logic.
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package platform
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WriteUserFile writes data to path atomically (tmp + rename) with the
|
|
||||||
// given mode, creating any missing parent directories (mode 0755).
|
|
||||||
// Intended for user-owned destinations under $HOME — no sudo.
|
|
||||||
func WriteUserFile(path string, data []byte, mode os.FileMode) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tmp := path + ".tmp"
|
|
||||||
if err := os.WriteFile(tmp, data, mode); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmp, path); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package platform
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWriteUserFileCreatesParent(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dest := filepath.Join(dir, "a", "b", "c.txt")
|
|
||||||
if err := WriteUserFile(dest, []byte("hi"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, _ := os.ReadFile(dest)
|
|
||||||
if string(got) != "hi" {
|
|
||||||
t.Fatalf("content %q", got)
|
|
||||||
}
|
|
||||||
info, _ := os.Stat(dest)
|
|
||||||
if info.Mode().Perm() != 0o644 {
|
|
||||||
t.Fatalf("mode %v", info.Mode().Perm())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteUserFileOverwritesAtomically(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dest := filepath.Join(dir, "x")
|
|
||||||
if err := WriteUserFile(dest, []byte("one"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := WriteUserFile(dest, []byte("two"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, _ := os.ReadFile(dest)
|
|
||||||
if string(got) != "two" {
|
|
||||||
t.Fatalf("got %q want two", got)
|
|
||||||
}
|
|
||||||
// No .tmp sibling should be left behind.
|
|
||||||
if _, err := os.Stat(dest + ".tmp"); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf(".tmp left behind: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteUserFileExecMode(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dest := filepath.Join(dir, "bin")
|
|
||||||
if err := WriteUserFile(dest, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
info, _ := os.Stat(dest)
|
|
||||||
if info.Mode().Perm() != 0o755 {
|
|
||||||
t.Fatalf("mode %v want 0755", info.Mode().Perm())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// Package privilege manages sudo timestamp acquisition and keepalive so
|
|
||||||
// a long-running installer only prompts once.
|
|
||||||
package privilege
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Keepalive holds the lifetime of a background sudo-refresh goroutine.
|
|
||||||
type Keepalive struct {
|
|
||||||
cancel context.CancelFunc
|
|
||||||
done chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop terminates the keepalive goroutine. Safe to call multiple times.
|
|
||||||
func (k *Keepalive) Stop() {
|
|
||||||
if k == nil || k.cancel == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
k.cancel()
|
|
||||||
<-k.done
|
|
||||||
k.cancel = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acquirer abstracts the `sudo` binary so tests can inject a fake.
|
|
||||||
type Acquirer interface {
|
|
||||||
Validate(ctx context.Context) error // sudo -v (may prompt)
|
|
||||||
ValidateNonInteractive(ctx context.Context) error // sudo -n -v
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecSudo is the production Acquirer.
|
|
||||||
type ExecSudo struct{}
|
|
||||||
|
|
||||||
func (ExecSudo) Validate(ctx context.Context) error {
|
|
||||||
cmd := exec.CommandContext(ctx, "sudo", "-v")
|
|
||||||
// Connect to the real terminal so sudo can prompt for the password.
|
|
||||||
// exec.Cmd.Stdin defaults to /dev/null, not the parent process's stdin.
|
|
||||||
cmd.Stdin = os.Stdin
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ExecSudo) ValidateNonInteractive(ctx context.Context) error {
|
|
||||||
return exec.CommandContext(ctx, "sudo", "-n", "-v").Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acquire runs `sudo -v` once (prompting if needed) and returns a Keepalive
|
|
||||||
// that refreshes the timestamp every `interval` using ValidateNonInteractive.
|
|
||||||
// If the initial Validate fails, no goroutine is started and the error is returned.
|
|
||||||
func Acquire(ctx context.Context, a Acquirer, interval time.Duration) (*Keepalive, error) {
|
|
||||||
if a == nil {
|
|
||||||
a = ExecSudo{}
|
|
||||||
}
|
|
||||||
if interval <= 0 {
|
|
||||||
interval = 50 * time.Second
|
|
||||||
}
|
|
||||||
if err := a.Validate(ctx); err != nil {
|
|
||||||
return nil, fmt.Errorf("sudo -v failed: %w", err)
|
|
||||||
}
|
|
||||||
kCtx, cancel := context.WithCancel(ctx)
|
|
||||||
k := &Keepalive{cancel: cancel, done: make(chan struct{})}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(k.done)
|
|
||||||
t := time.NewTicker(interval)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-kCtx.Done():
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
_ = a.ValidateNonInteractive(kCtx) // best-effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return k, nil
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package privilege
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeAcquirer struct {
|
|
||||||
validateErr error
|
|
||||||
validates int32
|
|
||||||
refreshes int32
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeAcquirer) Validate(ctx context.Context) error {
|
|
||||||
atomic.AddInt32(&f.validates, 1)
|
|
||||||
return f.validateErr
|
|
||||||
}
|
|
||||||
func (f *fakeAcquirer) ValidateNonInteractive(ctx context.Context) error {
|
|
||||||
atomic.AddInt32(&f.refreshes, 1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAcquireFailsIfValidateFails(t *testing.T) {
|
|
||||||
a := &fakeAcquirer{validateErr: errors.New("wrong password")}
|
|
||||||
_, err := Acquire(context.Background(), a, 10*time.Millisecond)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
if atomic.LoadInt32(&a.refreshes) != 0 {
|
|
||||||
t.Fatal("keepalive ran despite Validate failure")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAcquireStartsKeepaliveAndStops(t *testing.T) {
|
|
||||||
a := &fakeAcquirer{}
|
|
||||||
k, err := Acquire(context.Background(), a, 5*time.Millisecond)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
time.Sleep(25 * time.Millisecond)
|
|
||||||
k.Stop()
|
|
||||||
got := atomic.LoadInt32(&a.refreshes)
|
|
||||||
if got < 2 {
|
|
||||||
t.Fatalf("refreshes = %d, want >= 2", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// After Stop, no additional refreshes should occur.
|
|
||||||
snap := atomic.LoadInt32(&a.refreshes)
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
if after := atomic.LoadInt32(&a.refreshes); after != snap {
|
|
||||||
t.Fatalf("refreshes continued after Stop: %d → %d", snap, after)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStopIsIdempotent(t *testing.T) {
|
|
||||||
k, err := Acquire(context.Background(), &fakeAcquirer{}, 5*time.Millisecond)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
k.Stop()
|
|
||||||
k.Stop() // must not panic or hang
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// Package state persists a per-run log used only for resume hints in the UI.
|
|
||||||
// Correctness always comes from live Detect() calls.
|
|
||||||
package state
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RunRecord is the JSON document written to runs/<ts>.json.
|
|
||||||
type RunRecord struct {
|
|
||||||
StartedAt time.Time `json:"started_at"`
|
|
||||||
EndedAt time.Time `json:"ended_at"`
|
|
||||||
Modules []ModuleRecord `json:"modules"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModuleRecord captures how one module fared.
|
|
||||||
type ModuleRecord struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Status string `json:"status"` // succeeded|failed|skipped|planned
|
|
||||||
Duration string `json:"duration"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store is a file-backed run log.
|
|
||||||
type Store struct {
|
|
||||||
Root string // e.g. ~/.local/state/archduino
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultStore returns a Store rooted at $XDG_STATE_HOME/archduino or
|
|
||||||
// ~/.local/state/archduino.
|
|
||||||
func DefaultStore() (*Store, error) {
|
|
||||||
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
|
|
||||||
return &Store{Root: filepath.Join(xdg, "archduino")}, nil
|
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &Store{Root: filepath.Join(home, ".local", "state", "archduino")}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save writes record as runs/<ts>.json and updates last.json. Both paths are
|
|
||||||
// created if missing. Old runs beyond `keep` are deleted.
|
|
||||||
func (s *Store) Save(rec RunRecord, keep int) error {
|
|
||||||
if keep <= 0 {
|
|
||||||
keep = 10
|
|
||||||
}
|
|
||||||
runsDir := filepath.Join(s.Root, "runs")
|
|
||||||
if err := os.MkdirAll(runsDir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
name := rec.StartedAt.UTC().Format("20060102T150405Z") + ".json"
|
|
||||||
path := filepath.Join(runsDir, name)
|
|
||||||
|
|
||||||
data, err := json.MarshalIndent(rec, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(s.Root, "last.json"), data, 0o644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return rotate(runsDir, keep)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LastRun reads last.json if present.
|
|
||||||
func (s *Store) LastRun() (RunRecord, bool, error) {
|
|
||||||
data, err := os.ReadFile(filepath.Join(s.Root, "last.json"))
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return RunRecord{}, false, nil
|
|
||||||
}
|
|
||||||
return RunRecord{}, false, err
|
|
||||||
}
|
|
||||||
var rec RunRecord
|
|
||||||
if err := json.Unmarshal(data, &rec); err != nil {
|
|
||||||
return RunRecord{}, false, fmt.Errorf("parse last.json: %w", err)
|
|
||||||
}
|
|
||||||
return rec, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rotate(dir string, keep int) error {
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
names := make([]string, 0, len(entries))
|
|
||||||
for _, e := range entries {
|
|
||||||
if !e.IsDir() {
|
|
||||||
names = append(names, e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(names) <= keep {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sort.Strings(names) // lexicographic == chronological (UTC stamp)
|
|
||||||
for _, n := range names[:len(names)-keep] {
|
|
||||||
_ = os.Remove(filepath.Join(dir, n))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package state
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newStore(t *testing.T) *Store {
|
|
||||||
t.Helper()
|
|
||||||
return &Store{Root: t.TempDir()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSaveAndLoadLast(t *testing.T) {
|
|
||||||
s := newStore(t)
|
|
||||||
rec := RunRecord{
|
|
||||||
StartedAt: time.Date(2026, 4, 13, 10, 0, 0, 0, time.UTC),
|
|
||||||
EndedAt: time.Date(2026, 4, 13, 10, 1, 0, 0, time.UTC),
|
|
||||||
Modules: []ModuleRecord{{ID: "hello", Status: "succeeded", Duration: "1s"}},
|
|
||||||
}
|
|
||||||
if err := s.Save(rec, 10); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, ok, err := s.LastRun()
|
|
||||||
if err != nil || !ok {
|
|
||||||
t.Fatalf("LastRun err=%v ok=%v", err, ok)
|
|
||||||
}
|
|
||||||
if len(got.Modules) != 1 || got.Modules[0].ID != "hello" {
|
|
||||||
t.Fatalf("modules = %+v", got.Modules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLastRunMissingReturnsFalse(t *testing.T) {
|
|
||||||
s := newStore(t)
|
|
||||||
_, ok, err := s.LastRun()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
t.Fatal("expected ok=false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRotateKeepsNewest(t *testing.T) {
|
|
||||||
s := newStore(t)
|
|
||||||
for i := 0; i < 12; i++ {
|
|
||||||
rec := RunRecord{
|
|
||||||
StartedAt: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC),
|
|
||||||
EndedAt: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC),
|
|
||||||
}
|
|
||||||
if err := s.Save(rec, 5); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(filepath.Join(s.Root, "runs"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(entries) != 5 {
|
|
||||||
t.Fatalf("runs count = %d, want 5", len(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// Package doctor reports module convergence and pinned-tool version drift.
|
|
||||||
package doctor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/platform"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Run inspects every module in the registry plus pinned tool versions and
|
|
||||||
// writes a human-readable report to w. Returns 0 when everything is
|
|
||||||
// converged with no drift; 1 otherwise. Never panics on module errors.
|
|
||||||
func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) int {
|
|
||||||
var converged, pending, errs, drift int
|
|
||||||
|
|
||||||
for _, m := range reg.All() {
|
|
||||||
id := m.Meta().ID
|
|
||||||
st, err := m.Detect(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(w, "[!] %s — detect failed: %v\n", id, err)
|
|
||||||
errs++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pl, err := m.Plan(ctx, st)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(w, "[!] %s — plan failed: %v\n", id, err)
|
|
||||||
errs++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if pl.IsEmpty() {
|
|
||||||
fmt.Fprintf(w, "[✓] %s\n", id)
|
|
||||||
converged++
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(w, "[✗] %s (%d actions pending)\n", id, len(pl.Actions))
|
|
||||||
pending++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drift += checkArduinoCLI(ctx, runner, cfg.ArduinoCLI.Version, w)
|
|
||||||
drift += checkWokwiCLI(ctx, runner, cfg.Renode.WokwiVersion, w)
|
|
||||||
|
|
||||||
fmt.Fprintf(w, "\n%d converged, %d need-install, %d drift, %d errors\n", converged, pending, drift, errs)
|
|
||||||
|
|
||||||
if pending > 0 || drift > 0 || errs > 0 {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
type arduinoCLIVersion struct {
|
|
||||||
VersionString string `json:"VersionString"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkArduinoCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
|
|
||||||
if want == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
out, err := runner.Output(ctx, "arduino-cli", "version", "--format", "json")
|
|
||||||
if err != nil {
|
|
||||||
return 0 // not installed → silent skip
|
|
||||||
}
|
|
||||||
var v arduinoCLIVersion
|
|
||||||
if err := json.Unmarshal(out, &v); err != nil || v.VersionString == "" {
|
|
||||||
fmt.Fprintf(w, "[!] arduino-cli — version parse failed\n")
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if v.VersionString != want {
|
|
||||||
fmt.Fprintf(w, "[!] arduino-cli drift: got %s, want %s\n", v.VersionString, want)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "[✓] arduino-cli %s\n", v.VersionString)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var wokwiVersionRE = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
|
||||||
|
|
||||||
func checkWokwiCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
|
|
||||||
if want == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
out, err := runner.Output(ctx, "wokwi-cli", "--version")
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
m := wokwiVersionRE.FindStringSubmatch(strings.TrimSpace(string(out)))
|
|
||||||
if len(m) < 2 {
|
|
||||||
fmt.Fprintf(w, "[!] wokwi-cli — version parse failed\n")
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
got := m[1]
|
|
||||||
if got != want {
|
|
||||||
fmt.Fprintf(w, "[!] wokwi-cli drift: got %s, want %s\n", got, want)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "[✓] wokwi-cli %s\n", got)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
package doctor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/config"
|
|
||||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/module"
|
|
||||||
)
|
|
||||||
|
|
||||||
// convergedModule returns an empty Plan — always converged.
|
|
||||||
type convergedModule struct{ id string }
|
|
||||||
|
|
||||||
func (c convergedModule) Meta() module.Meta { return module.Meta{ID: c.id, Name: c.id} }
|
|
||||||
func (c convergedModule) Detect(context.Context) (module.State, error) { return nil, nil }
|
|
||||||
func (c convergedModule) Plan(context.Context, module.State) (module.Plan, error) {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
func (c convergedModule) PlanForce(context.Context) (module.Plan, error) {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
func (c convergedModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
|
||||||
func (c convergedModule) Verify(context.Context) error { return nil }
|
|
||||||
|
|
||||||
type pendingModule struct{ id string }
|
|
||||||
|
|
||||||
func (p pendingModule) Meta() module.Meta { return module.Meta{ID: p.id} }
|
|
||||||
func (p pendingModule) Detect(context.Context) (module.State, error) { return nil, nil }
|
|
||||||
func (p pendingModule) Plan(context.Context, module.State) (module.Plan, error) {
|
|
||||||
return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil
|
|
||||||
}
|
|
||||||
func (p pendingModule) PlanForce(context.Context) (module.Plan, error) {
|
|
||||||
return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil
|
|
||||||
}
|
|
||||||
func (p pendingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
|
||||||
func (p pendingModule) Verify(context.Context) error { return nil }
|
|
||||||
|
|
||||||
type detectFailModule struct{ id string }
|
|
||||||
|
|
||||||
func (d detectFailModule) Meta() module.Meta { return module.Meta{ID: d.id} }
|
|
||||||
func (d detectFailModule) Detect(context.Context) (module.State, error) {
|
|
||||||
return nil, errors.New("boom")
|
|
||||||
}
|
|
||||||
func (d detectFailModule) Plan(context.Context, module.State) (module.Plan, error) {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
func (d detectFailModule) PlanForce(context.Context) (module.Plan, error) {
|
|
||||||
return module.Plan{}, nil
|
|
||||||
}
|
|
||||||
func (d detectFailModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
|
||||||
func (d detectFailModule) Verify(context.Context) error { return nil }
|
|
||||||
|
|
||||||
// notFoundRunner simulates "command not found" for version checks.
|
|
||||||
type notFoundRunner struct{}
|
|
||||||
|
|
||||||
func (notFoundRunner) Run(context.Context, string, ...string) error { return errors.New("not found") }
|
|
||||||
func (notFoundRunner) Output(context.Context, string, ...string) ([]byte, error) {
|
|
||||||
return nil, errors.New("not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixedRunner returns a preset version string for a matching command.
|
|
||||||
type fixedRunner struct {
|
|
||||||
arduinoOut []byte
|
|
||||||
arduinoErr error
|
|
||||||
wokwiOut []byte
|
|
||||||
wokwiErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fixedRunner) Run(context.Context, string, ...string) error { return nil }
|
|
||||||
func (f fixedRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
|
||||||
switch name {
|
|
||||||
case "arduino-cli":
|
|
||||||
return f.arduinoOut, f.arduinoErr
|
|
||||||
case "wokwi-cli":
|
|
||||||
return f.wokwiOut, f.wokwiErr
|
|
||||||
}
|
|
||||||
return nil, errors.New("no match")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDoctorAllConvergedExitsZero(t *testing.T) {
|
|
||||||
reg := module.NewRegistry()
|
|
||||||
_ = reg.Register(convergedModule{id: "foo"})
|
|
||||||
var buf bytes.Buffer
|
|
||||||
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
|
|
||||||
if code != 0 {
|
|
||||||
t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(buf.String(), "[✓] foo") {
|
|
||||||
t.Errorf("missing converged mark: %s", buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDoctorDivergedExitsOne(t *testing.T) {
|
|
||||||
reg := module.NewRegistry()
|
|
||||||
_ = reg.Register(pendingModule{id: "bar"})
|
|
||||||
var buf bytes.Buffer
|
|
||||||
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
|
|
||||||
if code != 1 {
|
|
||||||
t.Fatalf("exit %d, want 1", code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(buf.String(), "[✗] bar") {
|
|
||||||
t.Errorf("missing diverged mark: %s", buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDoctorDetectErrorIsReported(t *testing.T) {
|
|
||||||
reg := module.NewRegistry()
|
|
||||||
_ = reg.Register(detectFailModule{id: "baz"})
|
|
||||||
var buf bytes.Buffer
|
|
||||||
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
|
|
||||||
if code != 1 {
|
|
||||||
t.Fatalf("exit %d, want 1", code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(buf.String(), "[!] baz") {
|
|
||||||
t.Errorf("missing error mark: %s", buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDoctorArduinoCLIDriftIsReported(t *testing.T) {
|
|
||||||
reg := module.NewRegistry()
|
|
||||||
_ = reg.Register(convergedModule{id: "foo"})
|
|
||||||
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
|
|
||||||
runner := fixedRunner{arduinoOut: []byte(`{"VersionString":"0.36.0"}`)}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
code := Run(context.Background(), cfg, reg, runner, &buf)
|
|
||||||
if code != 1 {
|
|
||||||
t.Fatalf("exit %d, want 1 (drift)", code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(buf.String(), "drift") {
|
|
||||||
t.Errorf("missing drift report: %s", buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDoctorMissingBinariesSkipVersionCheck(t *testing.T) {
|
|
||||||
reg := module.NewRegistry()
|
|
||||||
_ = reg.Register(convergedModule{id: "foo"})
|
|
||||||
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
code := Run(context.Background(), cfg, reg, notFoundRunner{}, &buf)
|
|
||||||
if code != 0 {
|
|
||||||
t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
while (!Serial) { ; }
|
|
||||||
Serial.println("Hello from Arduino!");
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
// Your code here
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#include <WiFi.h>
|
|
||||||
|
|
||||||
const char* ssid = "YOUR_SSID";
|
|
||||||
const char* password = "YOUR_PASS";
|
|
||||||
|
|
||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
delay(1000);
|
|
||||||
WiFi.begin(ssid, password);
|
|
||||||
Serial.print("Connecting to WiFi");
|
|
||||||
while (WiFi.status() != WL_CONNECTED) {
|
|
||||||
delay(500);
|
|
||||||
Serial.print(".");
|
|
||||||
}
|
|
||||||
Serial.println();
|
|
||||||
Serial.print("Connected! IP: ");
|
|
||||||
Serial.println(WiFi.localIP());
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
delay(1000);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
while (!Serial) { ; }
|
|
||||||
Serial.println("Hello from Arduino!");
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
// Your code here
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#include <ESP8266WiFi.h>
|
|
||||||
|
|
||||||
const char* ssid = "YOUR_SSID";
|
|
||||||
const char* password = "YOUR_PASS";
|
|
||||||
|
|
||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
delay(1000);
|
|
||||||
WiFi.begin(ssid, password);
|
|
||||||
Serial.print("Connecting to WiFi");
|
|
||||||
while (WiFi.status() != WL_CONNECTED) {
|
|
||||||
delay(500);
|
|
||||||
Serial.print(".");
|
|
||||||
}
|
|
||||||
Serial.println();
|
|
||||||
Serial.print("Connected! IP: ");
|
|
||||||
Serial.println(WiFi.localIP());
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
delay(1000);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
while (!Serial) { ; }
|
|
||||||
Serial.println("Hello from Arduino!");
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
// Your code here
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
while (!Serial) { ; }
|
|
||||||
Serial.println("Hello from Arduino!");
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
// Your code here
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user