Compare commits
123 Commits
master
...
712f09a824
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
712f09a824 | ||
|
|
ee3bb1d67c | ||
|
|
48d50efe19 | ||
|
|
25b4eefecd | ||
|
|
77b767aa0e | ||
|
|
b2cbd4f640 | ||
|
|
4ed257bbd1 | ||
|
|
47064338c8 | ||
|
|
abd0a617e2 | ||
|
|
edfff22bac | ||
|
|
bd5c34f293 | ||
|
|
372a6598fe | ||
|
|
c056697721 | ||
|
|
c75afade8c | ||
|
|
58bd532dba | ||
|
|
613363e993 | ||
|
|
d525646f3a | ||
|
|
e6e12ab5fd | ||
|
|
1cd3886d56 | ||
|
|
592d332968 | ||
|
|
f6b39fbdbc | ||
|
|
c579375c83 | ||
|
|
137a9b62a5 | ||
|
|
b30471bacb | ||
|
|
9d08c635ce | ||
|
|
e0a1628ca5 | ||
|
|
5b3ab9154f | ||
|
|
947533a279 | ||
|
|
702638b29c | ||
|
|
d346ca4241 | ||
|
|
ebae3fe8a4 | ||
|
|
5fe8cb2767 | ||
|
|
79f2bcd230 | ||
|
|
929cee1643 | ||
|
|
fd87135f62 | ||
|
|
a203cdfe89 | ||
|
|
c710ed81df | ||
|
|
2464e70cd2 | ||
|
|
bbaa21a6bf | ||
|
|
698271a235 | ||
|
|
4f7411ca95 | ||
|
|
12c502d48a | ||
|
|
770bdcf3a2 | ||
|
|
4fa580f226 | ||
|
|
b422402ffc | ||
|
|
b628898c6d | ||
|
|
465d10a5ef | ||
|
|
e000e8d22c | ||
|
|
fcbd2201b3 | ||
|
|
f177d86255 | ||
|
|
bf2d8de82c | ||
|
|
66f02edfb2 | ||
|
|
daa20b1dca | ||
|
|
8677a1b950 | ||
|
|
e0c1fc3c5d | ||
|
|
e2637bc093 | ||
|
|
e830e4dfda | ||
|
|
adfc9232ac | ||
|
|
b9f9f0306d | ||
|
|
027b7a88bc | ||
|
|
7914b7ef51 | ||
|
|
5587d793df | ||
|
|
fa946e9403 | ||
|
|
7de9d6c72c | ||
|
|
99db34d70f | ||
|
|
9650cd0c82 | ||
|
|
78ad784c7e | ||
|
|
4b3ce22491 | ||
|
|
1735f66bf0 | ||
|
|
6dbfc41ce2 | ||
|
|
f5a45c4be6 | ||
|
|
2b30b371e4 | ||
|
|
cba883376a | ||
|
|
ffd18c5382 | ||
|
|
fcc24b99b2 | ||
|
|
1903217b5f | ||
|
|
3f4223879b | ||
|
|
2d73114b5f | ||
|
|
afed8ab600 | ||
|
|
83bb622232 | ||
|
|
05dc8aa7f5 | ||
|
|
42dabf85a5 | ||
|
|
6318362e99 | ||
|
|
2ebaa6b5c6 | ||
|
|
ea5667e040 | ||
|
|
4f3d736bd7 | ||
|
|
341250d8f4 | ||
|
|
7baf0fceaa | ||
|
|
5c5076f1f1 | ||
|
|
feb01242f2 | ||
|
|
e7aa75735d | ||
|
|
394fb33d0c | ||
|
|
d21ab3fd30 | ||
|
|
c0983284b3 | ||
|
|
258b53b6a0 | ||
|
|
777a3fe49b | ||
|
|
07c13551ce | ||
|
|
8b43a0f273 | ||
|
|
40ac730719 | ||
|
|
3a4855b92a | ||
|
|
7a3ebbb146 | ||
|
|
7a382c9bdd | ||
|
|
e4f945146b | ||
|
|
8f9e34174c | ||
|
|
83cf7f0e72 | ||
|
|
3b666615d7 | ||
|
|
ea0d793461 | ||
|
|
0f0ea7e925 | ||
|
|
94c7084ec3 | ||
|
|
37761e017e | ||
|
|
30643e385d | ||
|
|
eb54225ad3 | ||
|
|
5b5e677749 | ||
|
|
94dbea001c | ||
|
|
b952704be5 | ||
|
|
5b8de40b4c | ||
|
|
897dc424dc | ||
|
|
9c3cdbc90f | ||
|
|
f53663298e | ||
|
|
33f1112bf8 | ||
|
|
9bec4660f6 | ||
|
|
9c38a8414e | ||
|
|
b512d41dc8 |
52
.gitea/workflows/ci.yml
Normal file
52
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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
|
||||||
30
.gitea/workflows/release.yml
Normal file
30
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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 }}
|
||||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -1 +1,16 @@
|
|||||||
.idea
|
.idea
|
||||||
|
WokwiKey.txt
|
||||||
|
|
||||||
|
# Go
|
||||||
|
/archduino
|
||||||
|
/dist/
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.*
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
# goreleaser output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# generated shell completions
|
||||||
|
completions/
|
||||||
|
|||||||
76
.goreleaser.yaml
Normal file
76
.goreleaser.yaml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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
Normal file
39
Makefile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
.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,22 +73,35 @@ This updates Starship, Zellij, Lazygit, Yazi, and NvChad in one step.
|
|||||||
|
|
||||||
## 🚀 Quickstart
|
## 🚀 Quickstart
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
One-line install on Arch Linux:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone --depth=1 https://github.com/<your-username>/ArduinoArch.git
|
curl -fsSL https://gitlab.hive-brain.de/MDietermann/Archduino/raw/branch/master/bootstrap.sh | bash
|
||||||
cd ArduinoArch
|
|
||||||
chmod +x install.sh
|
|
||||||
./install.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer will:
|
This fetches the latest release, verifies its checksum, installs `archduino`
|
||||||
|
to `/usr/local/bin`, installs shell completions, and launches
|
||||||
|
`archduino install`.
|
||||||
|
|
||||||
1. Show a banner with detected OS and package manager
|
### Development
|
||||||
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
|
|
||||||
|
|
||||||
After it finishes, start a new shell or run `source ~/.zshrc`.
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
After install, start a new shell or run `source ~/.zshrc`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -96,7 +109,10 @@ After it finishes, start a new shell or run `source ~/.zshrc`.
|
|||||||
|
|
||||||
```
|
```
|
||||||
ArduinoArch/
|
ArduinoArch/
|
||||||
├── install.sh # Main entry point
|
├── bootstrap.sh # Curl-piped installer shim (fetches latest release)
|
||||||
|
├── 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
Executable file
65
bootstrap.sh
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/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 "$@"
|
||||||
443
cmd/archduino/main.go
Normal file
443
cmd/archduino/main.go
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
2615
docs/superpowers/plans/2026-04-13-installer-foundation.md
Normal file
2615
docs/superpowers/plans/2026-04-13-installer-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
1489
docs/superpowers/plans/2026-04-13-installer-tui.md
Normal file
1489
docs/superpowers/plans/2026-04-13-installer-tui.md
Normal file
File diff suppressed because it is too large
Load Diff
205
docs/superpowers/plans/2026-04-14-ci-workflow.md
Normal file
205
docs/superpowers/plans/2026-04-14-ci-workflow.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# 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.
|
||||||
726
docs/superpowers/plans/2026-04-14-force-reapply.md
Normal file
726
docs/superpowers/plans/2026-04-14-force-reapply.md
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
# 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.
|
||||||
2559
docs/superpowers/plans/2026-04-14-plan3a-install-modules.md
Normal file
2559
docs/superpowers/plans/2026-04-14-plan3a-install-modules.md
Normal file
File diff suppressed because it is too large
Load Diff
1370
docs/superpowers/plans/2026-04-14-plan3b-install-modules.md
Normal file
1370
docs/superpowers/plans/2026-04-14-plan3b-install-modules.md
Normal file
File diff suppressed because it is too large
Load Diff
1514
docs/superpowers/plans/2026-04-14-plan4a-subcommands.md
Normal file
1514
docs/superpowers/plans/2026-04-14-plan4a-subcommands.md
Normal file
File diff suppressed because it is too large
Load Diff
789
docs/superpowers/plans/2026-04-14-release-pipeline.md
Normal file
789
docs/superpowers/plans/2026-04-14-release-pipeline.md
Normal file
@@ -0,0 +1,789 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# Installer Architecture Redesign
|
||||||
|
|
||||||
|
**Status:** Design approved, pending implementation plan
|
||||||
|
**Date:** 2026-04-13
|
||||||
|
**Scope:** Installer architecture only. Toolset overhaul, CLI browser, and TTY-font story are queued as follow-up specs.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current Archduino installer is a Bash entry point (`install.sh`) that sources 13 files under `lib/`. It has grown beyond what Bash handles comfortably:
|
||||||
|
|
||||||
|
- **Monolithic / ordering-fragile.** `install.sh` sources lib files in a fixed order at top level; hard to run a single step and hard to re-run safely.
|
||||||
|
- **Not idempotent / no resume.** Re-running either duplicates work or silently skips things.
|
||||||
|
- **Mixed concerns per file.** Detection, templating, package install, and theme application are intertwined in every lib.
|
||||||
|
- **Thin UX.** Only choices are theme + verbose; no dry-run, no module selection, no preview.
|
||||||
|
- **Platform coupling.** Arch vs Ubuntu-WSL branches are scattered throughout.
|
||||||
|
|
||||||
|
The goal is a principled rebuild across all five axes.
|
||||||
|
|
||||||
|
## Decision summary
|
||||||
|
|
||||||
|
| Area | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Language / runtime | Go, single static binary |
|
||||||
|
| UI | Bubble Tea + Huh (wizard for input, dashboard for execution) |
|
||||||
|
| Pre-install shim | Minimal Bash script: ensures `sudo`, `git`, `curl`, `ca-certificates`, fetches binary |
|
||||||
|
| Module contract | Four-phase: `Detect → Plan → Apply → Verify` |
|
||||||
|
| Configuration | Embedded defaults + optional `~/.config/archduino/overlay.yaml` |
|
||||||
|
| Privilege model | User-mode binary; `sudo` per privileged command; upfront `sudo -v` + keepalive goroutine |
|
||||||
|
| State / logging | `Detect()` is authoritative; run log is advisory UX only |
|
||||||
|
| Failure policy | DAG execution; failed node fails its descendants; siblings proceed; `--halt-on-error` flag available |
|
||||||
|
| Subcommands | One binary: `install`, `theme`, `new`, `doctor` |
|
||||||
|
| Platform scope (v1) | Arch-only native + WSL2; `PackageManager` interface ready for Debian/Fedora later |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Module contract
|
||||||
|
|
||||||
|
Every installable unit implements:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Module interface {
|
||||||
|
Meta() ModuleMeta // id, human name, description, deps []string
|
||||||
|
Detect(ctx) (State, error) // read current system
|
||||||
|
Plan(ctx, State) (Plan, error) // diff vs desired; []Action with descriptions
|
||||||
|
Apply(ctx, Plan, *Reporter) error // execute; stream progress to Reporter
|
||||||
|
Verify(ctx) error // confirm desired state reached
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `State` is module-defined.
|
||||||
|
- `Plan` is a flat list of `Action{Description, Kind, Exec}` so the engine can render plans without knowing modules.
|
||||||
|
- `Reporter` decouples modules from the TUI.
|
||||||
|
- **`Detect()` is authoritative.** No cached state overrides what the system reports.
|
||||||
|
|
||||||
|
Initial module set (1:1 with today's `lib/*.sh`):
|
||||||
|
`packages`, `fonts`, `zsh`, `starship`, `zellij`, `neovim`, `yazi`, `arduino-cli`, `renode`, `git` (lazygit), `scaffold`, `shell-config`, `themes`.
|
||||||
|
|
||||||
|
### Execution engine
|
||||||
|
|
||||||
|
- **DAG-scheduled.** Modules declare `deps []string`. Engine topologically sorts and runs independent branches concurrently, default parallelism 4.
|
||||||
|
- **Failure policy.** Failed node marks its descendants `skipped-due-to-dep`; siblings proceed. End-of-run summary: `X succeeded, Y failed, Z skipped`.
|
||||||
|
- **Dry-run** (`--dry-run`, or wizard "Review plan" step): runs Detect + Plan for every selected module, renders the combined plan, exits.
|
||||||
|
- **Halt mode** (`--halt-on-error`): opts into fail-fast for scripted use.
|
||||||
|
- **Resume hint.** If a prior run log exists, TUI offers "last run: `starship` failed — resume? / start over? / pick modules?" Hint only; Detect decides what actually runs.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- **Embedded defaults** via `//go:embed`: package lists, theme files, Zellij layouts, config templates.
|
||||||
|
- **Optional overlay** at `~/.config/archduino/overlay.yaml`:
|
||||||
|
```yaml
|
||||||
|
version: 1
|
||||||
|
extra_packages: [htop, ncdu]
|
||||||
|
disabled_modules: [renode]
|
||||||
|
theme_overrides:
|
||||||
|
starship: ~/my-starship.toml
|
||||||
|
```
|
||||||
|
- Overlay is merged at load time. Schema is versioned; a migration path exists when it grows.
|
||||||
|
|
||||||
|
### Privilege model
|
||||||
|
|
||||||
|
- Binary runs as the invoking user.
|
||||||
|
- On `install` start, one `sudo -v` call; a keepalive goroutine refreshes the timestamp every 50 seconds while the installer is live.
|
||||||
|
- Privileged operations shell out to `sudo pacman …`, `sudo usermod …`. If sudo needs to re-prompt (timestamp expired), the TUI is suspended via `tea.ExecProcess`.
|
||||||
|
- Config files, git clones, and anything under `$HOME` run as the user — no root-owned files in home.
|
||||||
|
|
||||||
|
### Platform abstraction
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PackageManager interface {
|
||||||
|
Install(pkgs []string) error
|
||||||
|
IsInstalled(pkg string) (bool, error)
|
||||||
|
Update() error
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- V1 ships `Pacman` only. Debian/Ubuntu/Fedora impls are stubbed; wiring them is a later spec.
|
||||||
|
- `Platform{Distro, IsWSL, Arch}` detection is centralized. WSL path sets `/etc/wsl.conf` hints and adds the user to `dialout`/`uucp`.
|
||||||
|
|
||||||
|
### State and logs
|
||||||
|
|
||||||
|
- `~/.local/state/archduino/runs/<timestamp>.json` — per-run structured log: `{module, phase, status, duration, error}`.
|
||||||
|
- `~/.local/state/archduino/last.json` — most recent run; read by TUI for the resume hint.
|
||||||
|
- Log rotation: keep the last 10 runs.
|
||||||
|
- Safe to delete at any time; never read for correctness decisions.
|
||||||
|
|
||||||
|
## User-facing surface
|
||||||
|
|
||||||
|
### Pre-install shim (`scripts/bootstrap.sh`)
|
||||||
|
|
||||||
|
~30 lines of Bash, served from a stable URL. Invocation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://archduino.dev/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Responsibilities in order:
|
||||||
|
|
||||||
|
1. Refuse to run as root.
|
||||||
|
2. Detect distro; abort with a clear message if not Arch (v1 scope).
|
||||||
|
3. Ensure `sudo`, `git`, `curl`, `ca-certificates` are installed (`pacman -S --needed`).
|
||||||
|
4. Detect architecture, fetch the matching release binary from GitHub Releases, verify its SHA256 against the published checksum file, install to `~/.local/bin/archduino`, `chmod +x`.
|
||||||
|
5. `exec archduino install`.
|
||||||
|
|
||||||
|
Everything further (base-devel, multilib, user groups, toolset) is handled by modules inside the binary.
|
||||||
|
|
||||||
|
### Subcommands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `archduino install` | Default; wizard + dashboard flow |
|
||||||
|
| `archduino theme [name]` | Switch colorscheme across all tools; no arg = TUI picker |
|
||||||
|
| `archduino new <name> <board>` | Scaffold a project (current `arduino-new` behavior) |
|
||||||
|
| `archduino doctor` | Run Detect across all modules; report drift in a read-only dashboard |
|
||||||
|
| `archduino --version` / `completion <shell>` | Standard |
|
||||||
|
|
||||||
|
Global flags: `--dry-run`, `--halt-on-error`, `--verbose`, `--no-tui`, `--only <mods>`, `--skip <mods>`.
|
||||||
|
|
||||||
|
Shell aliases `theme` and `arduino-new` are preserved via thin wrappers dropped in `~/.local/bin` by the `shell-config` module.
|
||||||
|
|
||||||
|
### TUI flow (`install`)
|
||||||
|
|
||||||
|
**Wizard phase (Huh forms):**
|
||||||
|
|
||||||
|
1. Welcome — detected platform, WSL status, binary version.
|
||||||
|
2. Theme picker — 5 options, palette preview.
|
||||||
|
3. Module selector — checkbox list; pre-ticked from overlay or "all" by default; disabled modules grayed with reason.
|
||||||
|
4. Advanced (collapsible) — parallelism, halt-on-error toggle, overlay path override.
|
||||||
|
5. Review plan — rendered `Plan()` grouped by module, expandable. Back-navigable.
|
||||||
|
6. Confirm — `[y/N]` gate into Apply.
|
||||||
|
|
||||||
|
**Apply phase (Bubble Tea dashboard):**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ archduino install ──────────────────────────────────┐
|
||||||
|
│ ✓ packages 12/12 2.4s │
|
||||||
|
│ ✓ fonts 1/1 0.8s │
|
||||||
|
│ ⠋ neovim 3/7 running: :MasonInstall… │
|
||||||
|
│ ⠋ arduino-cli 2/4 running: core install… │
|
||||||
|
│ · zellij pending (waits: packages) │
|
||||||
|
│ ✗ starship failed — config template parse │
|
||||||
|
│ ⊘ shell-config skipped (dep: starship) │
|
||||||
|
├──────────────────────────────────────────────────────┤
|
||||||
|
│ [tab] focus module [enter] expand log [q] abort │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Concurrent modules visible live.
|
||||||
|
- `enter` on a row expands a scrollable log tail.
|
||||||
|
- On completion: summary view; footer offers `[r]etry failed / [v]iew full log / [q]uit`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
archduino/
|
||||||
|
├── cmd/archduino/ # main.go, cobra wiring
|
||||||
|
├── internal/
|
||||||
|
│ ├── engine/ # DAG scheduler, Reporter, dry-run, resume
|
||||||
|
│ ├── module/ # Module interface, Plan/Action types, registry
|
||||||
|
│ ├── modules/ # one package per module
|
||||||
|
│ │ ├── packages/
|
||||||
|
│ │ ├── zsh/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── platform/ # detection; PackageManager interface + pacman impl
|
||||||
|
│ ├── privilege/ # sudo -v + keepalive
|
||||||
|
│ ├── config/ # embedded defaults + overlay merge
|
||||||
|
│ ├── state/ # run log, last-run tracking
|
||||||
|
│ ├── theme/ # theme model + switcher
|
||||||
|
│ └── tui/
|
||||||
|
│ ├── wizard/ # Huh forms
|
||||||
|
│ └── dashboard/ # Bubble Tea Apply view
|
||||||
|
├── assets/ # embedded configs, themes, zellij layout, templates
|
||||||
|
│ ├── themes/
|
||||||
|
│ ├── templates/
|
||||||
|
│ └── layouts/
|
||||||
|
├── scripts/bootstrap.sh
|
||||||
|
├── .github/workflows/
|
||||||
|
│ ├── ci.yml
|
||||||
|
│ └── release.yml # goreleaser
|
||||||
|
└── docs/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Unit** — every module's `Detect` and `Plan` against an injected `System` fake; table-driven cases for clean / already-installed / partial / drifted.
|
||||||
|
- **Engine** — DAG scheduling, failure propagation, dry-run, resume; pure Go, fully covered.
|
||||||
|
- **Integration** — Dockerfile on `archlinux:base` runs `archduino install --no-tui --yes` end-to-end in CI; asserts key files/packages/services. One matrix entry for native Arch. WSL smoke test remains manual for v1.
|
||||||
|
- **TUI** — `teatest` for wizard navigation and dashboard state transitions. No screenshot tests.
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
|
||||||
|
- **GitHub Releases** via `goreleaser`: `linux/amd64`, `linux/arm64` static binaries; `SHA256SUMS` file.
|
||||||
|
- No AUR package for v1; the shim is the distribution channel. AUR can follow later.
|
||||||
|
- Semver; breaking changes bump major.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
- Existing `install.sh` + `lib/*.sh` stay on `master` at a tagged `v0-bash-final` so users can still run the old installer if they want.
|
||||||
|
- `feat/renode-dap` merges first; a new `feat/go-rewrite` branch hosts the rewrite.
|
||||||
|
- First Go release (`v1.0.0`) lands when feature parity with the current Bash installer is verified on a clean Arch VM.
|
||||||
|
|
||||||
|
## Out of scope (queued for follow-up specs)
|
||||||
|
|
||||||
|
- Toolset redesign (re-evaluating which CLI tools are curated).
|
||||||
|
- CLI browser integration.
|
||||||
|
- Nerd Font rendering on bare TTY (constrained by the Linux console's 512-glyph PSF limit; needs a separate design covering `fbterm`/`kmscon` or the "SSH-in-from-a-real-terminal" fallback).
|
||||||
|
- Non-Arch platform support (Debian/Ubuntu/Fedora).
|
||||||
|
- AUR packaging.
|
||||||
99
docs/superpowers/specs/2026-04-14-ci-workflow-design.md
Normal file
99
docs/superpowers/specs/2026-04-14-ci-workflow-design.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# 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.
|
||||||
161
docs/superpowers/specs/2026-04-14-force-reapply-design.md
Normal file
161
docs/superpowers/specs/2026-04-14-force-reapply-design.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# 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.
|
||||||
200
docs/superpowers/specs/2026-04-14-plan4a-subcommands-design.md
Normal file
200
docs/superpowers/specs/2026-04-14-plan4a-subcommands-design.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# 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.
|
||||||
156
docs/superpowers/specs/2026-04-14-release-pipeline-design.md
Normal file
156
docs/superpowers/specs/2026-04-14-release-pipeline-design.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# 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
Normal file
42
go.mod
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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
Normal file
88
go.sum
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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,4 +1,6 @@
|
|||||||
#!/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
|
||||||
@@ -82,6 +84,7 @@ source "$SCRIPT_DIR/lib/zellij.sh"
|
|||||||
source "$SCRIPT_DIR/lib/neovim.sh"
|
source "$SCRIPT_DIR/lib/neovim.sh"
|
||||||
source "$SCRIPT_DIR/lib/yazi.sh"
|
source "$SCRIPT_DIR/lib/yazi.sh"
|
||||||
source "$SCRIPT_DIR/lib/arduino.sh"
|
source "$SCRIPT_DIR/lib/arduino.sh"
|
||||||
|
source "$SCRIPT_DIR/lib/renode.sh"
|
||||||
source "$SCRIPT_DIR/lib/git.sh"
|
source "$SCRIPT_DIR/lib/git.sh"
|
||||||
source "$SCRIPT_DIR/lib/scaffold.sh"
|
source "$SCRIPT_DIR/lib/scaffold.sh"
|
||||||
source "$SCRIPT_DIR/lib/shell-config.sh"
|
source "$SCRIPT_DIR/lib/shell-config.sh"
|
||||||
@@ -104,6 +107,9 @@ echo " arduino-new my-project uno # scaffold a project"
|
|||||||
echo " adev # launch Arduino workspace"
|
echo " adev # launch Arduino workspace"
|
||||||
echo " <leader>av # compile in Neovim"
|
echo " <leader>av # compile in Neovim"
|
||||||
echo " <leader>au # upload in Neovim"
|
echo " <leader>au # upload in Neovim"
|
||||||
|
echo " wokwi-cli . # simulate with Wokwi (visual)"
|
||||||
|
echo " arduino-sim build/fw.elf # simulate with Renode (GDB)"
|
||||||
|
echo " <leader>ds # attach debugger to Renode"
|
||||||
echo " theme # switch colorscheme"
|
echo " theme # switch colorscheme"
|
||||||
echo ""
|
echo ""
|
||||||
warn "A reboot is recommended to apply all changes (shell, groups, PATH)."
|
warn "A reboot is recommended to apply all changes (shell, groups, PATH)."
|
||||||
179
internal/config/config.go
Normal file
179
internal/config/config.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
133
internal/config/config_test.go
Normal file
133
internal/config/config_test.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
internal/config/defaults.yaml
Normal file
108
internal/config/defaults.yaml
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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}
|
||||||
91
internal/engine/dag.go
Normal file
91
internal/engine/dag.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
79
internal/engine/dag_test.go
Normal file
79
internal/engine/dag_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
207
internal/engine/engine.go
Normal file
207
internal/engine/engine.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
196
internal/engine/engine_test.go
Normal file
196
internal/engine/engine_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
80
internal/engine/reporter.go
Normal file
80
internal/engine/reporter.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// 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)
|
||||||
|
)
|
||||||
56
internal/engine/reporter_test.go
Normal file
56
internal/engine/reporter_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/module/module.go
Normal file
67
internal/module/module.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
56
internal/module/registry.go
Normal file
56
internal/module/registry.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
61
internal/module/registry_test.go
Normal file
61
internal/module/registry_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
172
internal/modules/arduinocli/arduinocli.go
Normal file
172
internal/modules/arduinocli/arduinocli.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
154
internal/modules/arduinocli/arduinocli_test.go
Normal file
154
internal/modules/arduinocli/arduinocli_test.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
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{}
|
||||||
101
internal/modules/fonts/fonts.go
Normal file
101
internal/modules/fonts/fonts.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
99
internal/modules/fonts/fonts_test.go
Normal file
99
internal/modules/fonts/fonts_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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{}
|
||||||
86
internal/modules/gittools/gittools.go
Normal file
86
internal/modules/gittools/gittools.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
139
internal/modules/gittools/gittools_test.go
Normal file
139
internal/modules/gittools/gittools_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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{}
|
||||||
115
internal/modules/hello/hello.go
Normal file
115
internal/modules/hello/hello.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
85
internal/modules/hello/hello_test.go
Normal file
85
internal/modules/hello/hello_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
internal/modules/neovim/assets/arduino.lua
Normal file
10
internal/modules/neovim/assets/arduino.lua
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
return {
|
||||||
|
{
|
||||||
|
"stevearc/vim-arduino",
|
||||||
|
ft = { "arduino", "cpp", "c" },
|
||||||
|
config = function()
|
||||||
|
vim.g.arduino_use_cli = 1
|
||||||
|
vim.g.arduino_cli_path = "arduino-cli"
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
8
internal/modules/neovim/assets/chadrc.lua.tmpl
Normal file
8
internal/modules/neovim/assets/chadrc.lua.tmpl
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---@type ChadrcConfig
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
M.base46 = {
|
||||||
|
theme = "{{.NvChadTheme}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
return M
|
||||||
10
internal/modules/neovim/assets/clangd.lua
Normal file
10
internal/modules/neovim/assets/clangd.lua
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
return {
|
||||||
|
cmd = {
|
||||||
|
"clangd",
|
||||||
|
"--background-index",
|
||||||
|
"--clang-tidy",
|
||||||
|
"--header-insertion=iwyu",
|
||||||
|
"--completion-style=detailed",
|
||||||
|
},
|
||||||
|
filetypes = { "c", "cpp", "objc", "objcpp", "cuda", "arduino" },
|
||||||
|
}
|
||||||
57
internal/modules/neovim/assets/dap.lua
Normal file
57
internal/modules/neovim/assets/dap.lua
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
2
internal/modules/neovim/assets/lspconfig.lua
Normal file
2
internal/modules/neovim/assets/lspconfig.lua
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
vim.lsp.enable("clangd")
|
||||||
|
return {}
|
||||||
20
internal/modules/neovim/assets/mappings.lua
Normal file
20
internal/modules/neovim/assets/mappings.lua
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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" })
|
||||||
575
internal/modules/neovim/neovim.go
Normal file
575
internal/modules/neovim/neovim.go
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
299
internal/modules/neovim/neovim_test.go
Normal file
299
internal/modules/neovim/neovim_test.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
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{}
|
||||||
86
internal/modules/packages/packages.go
Normal file
86
internal/modules/packages/packages.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
139
internal/modules/packages/packages_test.go
Normal file
139
internal/modules/packages/packages_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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{}
|
||||||
54
internal/modules/renode/assets/arduino-sim.tmpl
Normal file
54
internal/modules/renode/assets/arduino-sim.tmpl
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/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"
|
||||||
236
internal/modules/renode/renode.go
Normal file
236
internal/modules/renode/renode.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
193
internal/modules/renode/renode_test.go
Normal file
193
internal/modules/renode/renode_test.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
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{}
|
||||||
109
internal/modules/shellconfig/shellconfig.go
Normal file
109
internal/modules/shellconfig/shellconfig.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
96
internal/modules/shellconfig/shellconfig_test.go
Normal file
96
internal/modules/shellconfig/shellconfig_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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{}
|
||||||
86
internal/modules/starship/starship.go
Normal file
86
internal/modules/starship/starship.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
139
internal/modules/starship/starship_test.go
Normal file
139
internal/modules/starship/starship_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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{}
|
||||||
159
internal/modules/themes/themes.go
Normal file
159
internal/modules/themes/themes.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
111
internal/modules/themes/themes_test.go
Normal file
111
internal/modules/themes/themes_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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{}
|
||||||
86
internal/modules/yazi/yazi.go
Normal file
86
internal/modules/yazi/yazi.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
139
internal/modules/yazi/yazi_test.go
Normal file
139
internal/modules/yazi/yazi_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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{}
|
||||||
135
internal/modules/zellij/zellij.go
Normal file
135
internal/modules/zellij/zellij.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
125
internal/modules/zellij/zellij_test.go
Normal file
125
internal/modules/zellij/zellij_test.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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{}
|
||||||
173
internal/modules/zsh/zsh.go
Normal file
173
internal/modules/zsh/zsh.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
159
internal/modules/zsh/zsh_test.go
Normal file
159
internal/modules/zsh/zsh_test.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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{}
|
||||||
69
internal/platform/aur.go
Normal file
69
internal/platform/aur.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
115
internal/platform/aur_test.go
Normal file
115
internal/platform/aur_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
internal/platform/download.go
Normal file
42
internal/platform/download.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Download fetches url into destPath atomically (temp file + rename) with
|
||||||
|
// the given file mode. Any non-2xx response returns an error and leaves
|
||||||
|
// no file behind.
|
||||||
|
func Download(ctx context.Context, url, destPath string, mode os.FileMode) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("download %s: http %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
tmp := destPath + ".tmp"
|
||||||
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, destPath)
|
||||||
|
}
|
||||||
45
internal/platform/download_test.go
Normal file
45
internal/platform/download_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDownloadHappyPath(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("payload"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "out.bin")
|
||||||
|
if err := Download(context.Background(), srv.URL, dest, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(dest)
|
||||||
|
if string(got) != "payload" {
|
||||||
|
t.Fatalf("got %q want payload", got)
|
||||||
|
}
|
||||||
|
info, _ := os.Stat(dest)
|
||||||
|
if info.Mode().Perm() != 0o755 {
|
||||||
|
t.Fatalf("mode=%v want 0755", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownload404Errors(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "nope", http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "out.bin")
|
||||||
|
if err := Download(context.Background(), srv.URL, dest, 0o644); err == nil {
|
||||||
|
t.Fatal("want error on 404")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("dest should not exist after failed download")
|
||||||
|
}
|
||||||
|
}
|
||||||
114
internal/platform/extract.go
Normal file
114
internal/platform/extract.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func safeJoin(dest, name string) (string, error) {
|
||||||
|
p := filepath.Join(dest, name)
|
||||||
|
cleanDest := filepath.Clean(dest) + string(os.PathSeparator)
|
||||||
|
if !strings.HasPrefix(filepath.Clean(p)+string(os.PathSeparator), cleanDest) {
|
||||||
|
return "", fmt.Errorf("unsafe path %q escapes %q", name, dest)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractTarGz(ctx context.Context, archivePath, destDir string) error {
|
||||||
|
f, err := os.Open(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
gz, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target, err := safeJoin(destDir, h.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch h.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(target, os.FileMode(h.Mode)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(w, tr); err != nil {
|
||||||
|
w.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractZip(ctx context.Context, archivePath, destDir string) error {
|
||||||
|
zr, err := zip.OpenReader(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
for _, zf := range zr.File {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target, err := safeJoin(destDir, zf.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if zf.FileInfo().IsDir() {
|
||||||
|
if err := os.MkdirAll(target, zf.Mode()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, zf.Mode())
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, rc)
|
||||||
|
rc.Close()
|
||||||
|
w.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
80
internal/platform/extract_test.go
Normal file
80
internal/platform/extract_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeTarGz(t *testing.T, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "a.tar.gz")
|
||||||
|
f, err := os.Create(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gz := gzip.NewWriter(f)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
for name, body := range entries {
|
||||||
|
tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))})
|
||||||
|
tw.Write([]byte(body))
|
||||||
|
}
|
||||||
|
tw.Close()
|
||||||
|
gz.Close()
|
||||||
|
f.Close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeZip(t *testing.T, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "a.zip")
|
||||||
|
f, _ := os.Create(p)
|
||||||
|
zw := zip.NewWriter(f)
|
||||||
|
for name, body := range entries {
|
||||||
|
w, _ := zw.Create(name)
|
||||||
|
w.Write([]byte(body))
|
||||||
|
}
|
||||||
|
zw.Close()
|
||||||
|
f.Close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTarGz(t *testing.T) {
|
||||||
|
src := makeTarGz(t, map[string]string{"hello.txt": "world", "sub/x.txt": "y"})
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := ExtractTarGz(context.Background(), src, dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dest, "hello.txt"))
|
||||||
|
if string(got) != "world" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
got, _ = os.ReadFile(filepath.Join(dest, "sub", "x.txt"))
|
||||||
|
if string(got) != "y" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractZip(t *testing.T) {
|
||||||
|
src := makeZip(t, map[string]string{"a.txt": "A"})
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := ExtractZip(context.Background(), src, dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dest, "a.txt"))
|
||||||
|
if string(got) != "A" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsPathTraversal(t *testing.T) {
|
||||||
|
src := makeTarGz(t, map[string]string{"../evil.txt": "x"})
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := ExtractTarGz(context.Background(), src, dest); err == nil {
|
||||||
|
t.Fatal("want error on path traversal")
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/platform/git.go
Normal file
23
internal/platform/git.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitClient wraps `git` via a Runner.
|
||||||
|
type GitClient struct{ Runner Runner }
|
||||||
|
|
||||||
|
// Clone always runs `git clone --depth=1 url dest`.
|
||||||
|
func (g GitClient) Clone(ctx context.Context, url, dest string) error {
|
||||||
|
return g.Runner.Run(ctx, "git", "clone", "--depth=1", url, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRepo clones url into dest if dest/.git is missing; otherwise noop.
|
||||||
|
func (g GitClient) EnsureRepo(ctx context.Context, url, dest string) error {
|
||||||
|
if _, err := os.Stat(filepath.Join(dest, ".git")); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return g.Clone(ctx, url, dest)
|
||||||
|
}
|
||||||
55
internal/platform/git_test.go
Normal file
55
internal/platform/git_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gitFakeRunner struct {
|
||||||
|
calls [][]string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *gitFakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.calls = append(f.calls, append([]string{name}, args...))
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
func (f *gitFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitEnsureRepoClonesWhenMissing(t *testing.T) {
|
||||||
|
r := &gitFakeRunner{}
|
||||||
|
g := GitClient{Runner: r}
|
||||||
|
dest := filepath.Join(t.TempDir(), "repo")
|
||||||
|
if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.calls) != 1 {
|
||||||
|
t.Fatalf("want 1 call, got %d: %v", len(r.calls), r.calls)
|
||||||
|
}
|
||||||
|
got := r.calls[0]
|
||||||
|
want := []string{"git", "clone", "--depth=1", "https://x/y.git", dest}
|
||||||
|
for i := range want {
|
||||||
|
if i >= len(got) || got[i] != want[i] {
|
||||||
|
t.Fatalf("call = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitEnsureRepoSkipsWhenPresent(t *testing.T) {
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dest, ".git"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := &gitFakeRunner{}
|
||||||
|
g := GitClient{Runner: r}
|
||||||
|
if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(r.calls) != 0 {
|
||||||
|
t.Fatalf("expected no calls, got %v", r.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
96
internal/platform/pacman.go
Normal file
96
internal/platform/pacman.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
108
internal/platform/pacman_test.go
Normal file
108
internal/platform/pacman_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
internal/platform/pkgmgr.go
Normal file
20
internal/platform/pkgmgr.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
63
internal/platform/platform.go
Normal file
63
internal/platform/platform.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
45
internal/platform/platform_test.go
Normal file
45
internal/platform/platform_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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.
|
||||||
|
}
|
||||||
78
internal/platform/textblock.go
Normal file
78
internal/platform/textblock.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func blockMarkers(marker string) (string, string) {
|
||||||
|
return fmt.Sprintf("# >>> %s >>>", marker), fmt.Sprintf("# <<< %s <<<", marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureBlock writes a marker-delimited block into path. If the file does
|
||||||
|
// not exist it is created. If a block with the same marker already exists
|
||||||
|
// it is replaced in place. Surrounding content is preserved.
|
||||||
|
func EnsureBlock(path, marker, content string) error {
|
||||||
|
start, end := blockMarkers(marker)
|
||||||
|
existing, err := os.ReadFile(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
block := start + "\n" + strings.TrimRight(content, "\n") + "\n" + end + "\n"
|
||||||
|
|
||||||
|
text := string(existing)
|
||||||
|
si := strings.Index(text, start)
|
||||||
|
ei := strings.Index(text, end)
|
||||||
|
if si >= 0 && ei > si {
|
||||||
|
endPos := ei + len(end)
|
||||||
|
if endPos < len(text) && text[endPos] == '\n' {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
text = text[:si] + block + text[endPos:]
|
||||||
|
} else {
|
||||||
|
if text != "" && !strings.HasSuffix(text, "\n") {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += block
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(text), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasBlock reports whether path contains a block with the given marker.
|
||||||
|
// A missing file yields (false, nil).
|
||||||
|
func HasBlock(path, marker string) (bool, error) {
|
||||||
|
start, _ := blockMarkers(marker)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return strings.Contains(string(data), start), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveBlock deletes a marker-delimited block if present.
|
||||||
|
func RemoveBlock(path, marker string) error {
|
||||||
|
start, end := blockMarkers(marker)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
si := strings.Index(text, start)
|
||||||
|
ei := strings.Index(text, end)
|
||||||
|
if si < 0 || ei <= si {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
endPos := ei + len(end)
|
||||||
|
if endPos < len(text) && text[endPos] == '\n' {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
text = text[:si] + text[endPos:]
|
||||||
|
return os.WriteFile(path, []byte(text), 0o644)
|
||||||
|
}
|
||||||
88
internal/platform/textblock_test.go
Normal file
88
internal/platform/textblock_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureBlockInsertsIntoMissingFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
if err := EnsureBlock(p, "archduino", "export FOO=1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(p)
|
||||||
|
want := "# >>> archduino >>>\nexport FOO=1\n# <<< archduino <<<\n"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureBlockReplacesExisting(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
initial := "alias ll='ls -l'\n# >>> archduino >>>\nold content\n# <<< archduino <<<\necho tail\n"
|
||||||
|
if err := os.WriteFile(p, []byte(initial), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := EnsureBlock(p, "archduino", "new content"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(p)
|
||||||
|
want := "alias ll='ls -l'\n# >>> archduino >>>\nnew content\n# <<< archduino <<<\necho tail\n"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureBlockIdempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
_ = EnsureBlock(p, "a", "x")
|
||||||
|
before, _ := os.ReadFile(p)
|
||||||
|
_ = EnsureBlock(p, "a", "x")
|
||||||
|
after, _ := os.ReadFile(p)
|
||||||
|
if string(before) != string(after) {
|
||||||
|
t.Fatalf("not idempotent: %q vs %q", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasBlockTrueFalse(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
has, err := HasBlock(p, "a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
t.Fatal("missing file should report has=false")
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(p, []byte("no blocks here\n"), 0o644)
|
||||||
|
has, _ = HasBlock(p, "a")
|
||||||
|
if has {
|
||||||
|
t.Fatal("unblocked file should report has=false")
|
||||||
|
}
|
||||||
|
_ = EnsureBlock(p, "a", "x")
|
||||||
|
has, _ = HasBlock(p, "a")
|
||||||
|
if !has {
|
||||||
|
t.Fatal("blocked file should report has=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveBlock(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "rc")
|
||||||
|
_ = os.WriteFile(p, []byte("head\n# >>> a >>>\nmid\n# <<< a <<<\ntail\n"), 0o644)
|
||||||
|
if err := RemoveBlock(p, "a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(p)
|
||||||
|
if strings.Contains(string(got), ">>> a >>>") {
|
||||||
|
t.Fatalf("block not removed: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(got), "head") || !strings.Contains(string(got), "tail") {
|
||||||
|
t.Fatalf("surrounding content lost: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/platform/writefile.go
Normal file
25
internal/platform/writefile.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
54
internal/platform/writefile_test.go
Normal file
54
internal/platform/writefile_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
82
internal/privilege/sudo.go
Normal file
82
internal/privilege/sudo.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
65
internal/privilege/sudo_test.go
Normal file
65
internal/privilege/sudo_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
109
internal/state/state.go
Normal file
109
internal/state/state.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
63
internal/state/state_test.go
Normal file
63
internal/state/state_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
104
internal/subcommands/doctor/doctor.go
Normal file
104
internal/subcommands/doctor/doctor.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Package doctor reports module convergence and pinned-tool version drift.
|
||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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
|
||||||
|
}
|
||||||
146
internal/subcommands/doctor/doctor_test.go
Normal file
146
internal/subcommands/doctor/doctor_test.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
9
internal/subcommands/newproject/assets/default.ino
Normal file
9
internal/subcommands/newproject/assets/default.ino
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
while (!Serial) { ; }
|
||||||
|
Serial.println("Hello from Arduino!");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Your code here
|
||||||
|
}
|
||||||
22
internal/subcommands/newproject/assets/esp32.ino
Normal file
22
internal/subcommands/newproject/assets/esp32.ino
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#include <WiFi.h>
|
||||||
|
|
||||||
|
const char* ssid = "YOUR_SSID";
|
||||||
|
const char* password = "YOUR_PASS";
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(1000);
|
||||||
|
WiFi.begin(ssid, password);
|
||||||
|
Serial.print("Connecting to WiFi");
|
||||||
|
while (WiFi.status() != WL_CONNECTED) {
|
||||||
|
delay(500);
|
||||||
|
Serial.print(".");
|
||||||
|
}
|
||||||
|
Serial.println();
|
||||||
|
Serial.print("Connected! IP: ");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
delay(1000);
|
||||||
|
}
|
||||||
9
internal/subcommands/newproject/assets/esp32s3.ino
Normal file
9
internal/subcommands/newproject/assets/esp32s3.ino
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
while (!Serial) { ; }
|
||||||
|
Serial.println("Hello from Arduino!");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Your code here
|
||||||
|
}
|
||||||
22
internal/subcommands/newproject/assets/esp8266.ino
Normal file
22
internal/subcommands/newproject/assets/esp8266.ino
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#include <ESP8266WiFi.h>
|
||||||
|
|
||||||
|
const char* ssid = "YOUR_SSID";
|
||||||
|
const char* password = "YOUR_PASS";
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(1000);
|
||||||
|
WiFi.begin(ssid, password);
|
||||||
|
Serial.print("Connecting to WiFi");
|
||||||
|
while (WiFi.status() != WL_CONNECTED) {
|
||||||
|
delay(500);
|
||||||
|
Serial.print(".");
|
||||||
|
}
|
||||||
|
Serial.println();
|
||||||
|
Serial.print("Connected! IP: ");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
delay(1000);
|
||||||
|
}
|
||||||
9
internal/subcommands/newproject/assets/mega.ino
Normal file
9
internal/subcommands/newproject/assets/mega.ino
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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