Compare commits
34 Commits
929cee1643
...
feat/go-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26d5ec8367 | ||
|
|
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 |
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 }}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,3 +8,9 @@ WokwiKey.txt
|
||||
*.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
|
||||
|
||||
## Installation
|
||||
|
||||
One-line install on Arch Linux:
|
||||
|
||||
```bash
|
||||
git clone --depth=1 https://github.com/<your-username>/ArduinoArch.git
|
||||
cd ArduinoArch
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
curl -fsSL https://gitlab.hive-brain.de/MDietermann/Archduino/raw/branch/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
### Development
|
||||
|
||||
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/
|
||||
├── 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/
|
||||
├── common.sh # Colors, helpers, environment detection, progress bar
|
||||
├── 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 "$@"
|
||||
@@ -38,7 +38,11 @@ import (
|
||||
"gitlab.hive-brain.de/MDietermann/Archduino/internal/tui/wizard"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
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.
|
||||
@@ -85,6 +89,7 @@ func main() {
|
||||
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.
|
||||
@@ -103,6 +108,7 @@ func newInstallCmd() *cobra.Command {
|
||||
dryRun bool
|
||||
halt bool
|
||||
noSudo bool
|
||||
force bool
|
||||
only []string
|
||||
skip []string
|
||||
overlayPath string
|
||||
@@ -169,12 +175,14 @@ func newInstallCmd() *cobra.Command {
|
||||
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.
|
||||
@@ -249,6 +257,7 @@ func newInstallCmd() *cobra.Command {
|
||||
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)")
|
||||
@@ -422,3 +431,13 @@ func newDoctorCmd() *cobra.Command {
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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).
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/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
|
||||
# Works on both native Arch Linux and Arch/Ubuntu WSL2
|
||||
@@ -14,7 +14,10 @@ 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
|
||||
Reporter module.Reporter
|
||||
// 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.
|
||||
@@ -91,26 +94,43 @@ func Run(ctx context.Context, reg *module.Registry, opts RunOptions) (Summary, e
|
||||
res := &ModuleResult{ID: id}
|
||||
results[id] = res
|
||||
|
||||
state, err := m.Detect(ctx)
|
||||
if err != nil {
|
||||
res.Status = StatusFailed
|
||||
res.Err = fmt.Errorf("detect: %w", err)
|
||||
markFailed(metas, id, skipped)
|
||||
if opts.HaltOnError {
|
||||
break
|
||||
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
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
continue
|
||||
}
|
||||
res.Plan = plan
|
||||
|
||||
|
||||
@@ -10,34 +10,51 @@ import (
|
||||
|
||||
// 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)
|
||||
apply func() error
|
||||
verify func() error
|
||||
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) {
|
||||
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) {
|
||||
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) Apply(ctx context.Context, _ module.Plan, _ module.Reporter) error {
|
||||
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 {
|
||||
func (s *scriptedModule) Verify(ctx context.Context) error {
|
||||
if s.verify != nil {
|
||||
return s.verify()
|
||||
}
|
||||
@@ -57,8 +74,8 @@ func newReg(t *testing.T, mods ...module.Module) *module.Registry {
|
||||
|
||||
func TestRunAllSucceed(t *testing.T) {
|
||||
reg := newReg(t,
|
||||
scriptedModule{id: "a"},
|
||||
scriptedModule{id: "b", deps: []string{"a"}},
|
||||
&scriptedModule{id: "a"},
|
||||
&scriptedModule{id: "b", deps: []string{"a"}},
|
||||
)
|
||||
sum, err := Run(context.Background(), reg, RunOptions{Reporter: NewBufferedReporter()})
|
||||
if err != nil {
|
||||
@@ -75,7 +92,7 @@ func TestRunAllSucceed(t *testing.T) {
|
||||
|
||||
func TestRunDryRunDoesNotApply(t *testing.T) {
|
||||
applied := false
|
||||
reg := newReg(t, scriptedModule{
|
||||
reg := newReg(t, &scriptedModule{
|
||||
id: "a",
|
||||
apply: func() error { applied = true; return nil },
|
||||
})
|
||||
@@ -93,10 +110,10 @@ func TestRunDryRunDoesNotApply(t *testing.T) {
|
||||
|
||||
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"}},
|
||||
&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 {
|
||||
@@ -122,8 +139,8 @@ func TestRunFailureSkipsDescendantsContinuesSiblings(t *testing.T) {
|
||||
|
||||
func TestRunHaltOnError(t *testing.T) {
|
||||
reg := newReg(t,
|
||||
scriptedModule{id: "a", apply: func() error { return errors.New("nope") }},
|
||||
scriptedModule{id: "b"}, // independent
|
||||
&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 {
|
||||
@@ -144,3 +161,36 @@ func TestRunUnknownModule(t *testing.T) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeModule struct{ id string; deps []string }
|
||||
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) Apply(context.Context, Plan, Reporter) error { return nil }
|
||||
func (f fakeModule) Verify(context.Context) error { return nil }
|
||||
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) {
|
||||
|
||||
@@ -60,6 +60,30 @@ func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||
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
|
||||
@@ -68,28 +92,7 @@ func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error)
|
||||
actions = append(actions, module.Action{
|
||||
Description: "download + install arduino-cli " + m.cfg.Version,
|
||||
Kind: module.ActionFileWrite,
|
||||
Exec: func(ctx context.Context) error {
|
||||
tmpDir, err := os.MkdirTemp("", "arduinocli-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
archive := filepath.Join(tmpDir, "arduino-cli.tar.gz")
|
||||
if err := platform.Download(ctx, m.cfg.URL, archive, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := platform.ExtractTarGz(ctx, archive, tmpDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(bp), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "arduino-cli"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(bp, data, 0o755)
|
||||
},
|
||||
Exec: func(ctx context.Context) error { return m.downloadBinary(ctx, bp) },
|
||||
})
|
||||
}
|
||||
for _, core := range s.MissingCores {
|
||||
@@ -136,3 +139,34 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]b
|
||||
type nop struct{}
|
||||
|
||||
func (nop) Progress(string, int, int, string) {}
|
||||
func (nop) Log(string, string) {}
|
||||
func (nop) Log(string, string) {}
|
||||
|
||||
func makeTarGz(t *testing.T, entries map[string][]byte) string {
|
||||
t.Helper()
|
||||
@@ -91,4 +91,64 @@ func TestArduinoCLIDownloadsAndInstallsCore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -80,3 +80,22 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ var errOne = eo{}
|
||||
type nop struct{}
|
||||
|
||||
func (nop) Progress(string, int, int, string) {}
|
||||
func (nop) Log(string, string) {}
|
||||
func (nop) Log(string, string) {}
|
||||
|
||||
func TestFontsPlanApplyUsesYay(t *testing.T) {
|
||||
r := &fakeRunner{installed: map[string]bool{"yay": true}}
|
||||
@@ -69,4 +69,31 @@ func TestFontsDetectPresentNoActions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -72,3 +72,15 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -110,4 +110,30 @@ func TestVerifyAfterApply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -96,3 +96,20 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -55,3 +55,31 @@ func TestHelloFullLifecycle(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,3 +461,115 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -207,4 +207,93 @@ func TestWriteChadrcNoopWithoutConfigDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -72,3 +72,15 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -110,4 +110,30 @@ func TestVerifyAfterApply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -185,3 +185,52 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -142,4 +142,52 @@ func TestApplyRendersSimScriptAndDownloadsWokwi(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -88,3 +88,22 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -55,4 +55,42 @@ func TestShellConfigIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -72,3 +72,15 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -110,4 +110,30 @@ func TestVerifyAfterApply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -113,3 +113,47 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
type nop struct{}
|
||||
|
||||
func (nop) Progress(string, int, int, string) {}
|
||||
func (nop) Log(string, string) {}
|
||||
func (nop) Log(string, string) {}
|
||||
|
||||
func TestThemesCopiesFiles(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
@@ -63,4 +63,49 @@ func applyOnce(t *testing.T, m Module) error {
|
||||
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{}
|
||||
|
||||
@@ -72,3 +72,15 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -110,4 +110,30 @@ func TestVerifyAfterApply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -108,3 +108,28 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -81,4 +81,45 @@ func TestZellijInstalledPresentEmptyPlan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -130,3 +130,44 @@ func (m Module) Verify(ctx context.Context) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -114,4 +114,46 @@ func TestZshPlanApplyInstallsEverything(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
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 {
|
||||
@@ -52,6 +53,17 @@ func (y *Yay) Install(ctx context.Context, pkgs []string) error {
|
||||
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, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
@@ -70,6 +70,32 @@ func TestAUREnsureInstalledBootstrapsYay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -76,6 +76,17 @@ func (p Pacman) Install(ctx context.Context, pkgs []string) error {
|
||||
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
|
||||
|
||||
@@ -39,6 +39,27 @@ func TestPacmanInstallShellsOutCorrectly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -14,33 +14,44 @@ import (
|
||||
// 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) 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 }
|
||||
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) 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 }
|
||||
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) 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 }
|
||||
func (d detectFailModule) Verify(context.Context) error { return nil }
|
||||
|
||||
// notFoundRunner simulates "command not found" for version checks.
|
||||
type notFoundRunner struct{}
|
||||
|
||||
@@ -31,6 +31,10 @@ type WizardState struct {
|
||||
Parallelism int // advanced; reserved for a future concurrent engine
|
||||
HaltOnError bool // advanced
|
||||
Confirmed bool // true once the user hits Confirm on the last screen
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewWizardState builds an initial state from the running config and the
|
||||
|
||||
@@ -18,8 +18,11 @@ func (f fakeModule) Detect(context.Context) (module.State, error) { return nil,
|
||||
func (f fakeModule) Plan(context.Context, module.State) (module.Plan, error) {
|
||||
return module.Plan{}, nil
|
||||
}
|
||||
func (f fakeModule) PlanForce(context.Context) (module.Plan, error) {
|
||||
return module.Plan{}, nil
|
||||
}
|
||||
func (f fakeModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
||||
func (f fakeModule) Verify(context.Context) error { return nil }
|
||||
func (f fakeModule) Verify(context.Context) error { return nil }
|
||||
|
||||
func TestNewWizardStatePreTicksAllByDefault(t *testing.T) {
|
||||
reg := module.NewRegistry()
|
||||
@@ -71,4 +74,3 @@ func TestSelectedIDsOnlyReturnsChecked(t *testing.T) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu
|
||||
Title("Review plan").
|
||||
DescriptionFunc(func() string {
|
||||
s.Modules = syncSelected(s.Modules, selectedModules)
|
||||
return renderPlanCached(ctx, reg, s.SelectedIDs(), cache)
|
||||
return renderPlanCached(ctx, reg, s.SelectedIDs(), cache, s.Force)
|
||||
}, &selectedModules)),
|
||||
|
||||
huh.NewGroup(huh.NewConfirm().
|
||||
@@ -118,24 +118,35 @@ func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice
|
||||
return out
|
||||
}
|
||||
|
||||
// planCache memoises rendered Detect+Plan output per module ID so
|
||||
// DescriptionFunc (which fires on every redraw) does not re-run
|
||||
// potentially expensive Detect/Plan calls. Entries are evicted when a
|
||||
// module leaves the current selection.
|
||||
// planCache memoises rendered Detect+Plan (or PlanForce) output per
|
||||
// (module ID, force) tuple so DescriptionFunc (which fires on every
|
||||
// redraw) does not re-run potentially expensive module calls. Including
|
||||
// force in the key ensures a force-mode preview never reuses a non-force
|
||||
// cached plan. Entries are evicted when a module leaves the current
|
||||
// selection.
|
||||
type planCacheEntry struct{ out string }
|
||||
type planCache struct{ entries map[string]planCacheEntry }
|
||||
|
||||
func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} }
|
||||
|
||||
func (c *planCache) get(id string) (string, bool) {
|
||||
e, ok := c.entries[id]
|
||||
func cacheKey(id string, force bool) string {
|
||||
if force {
|
||||
return "force=1|" + id
|
||||
}
|
||||
return "force=0|" + id
|
||||
}
|
||||
|
||||
func (c *planCache) get(id string, force bool) (string, bool) {
|
||||
e, ok := c.entries[cacheKey(id, force)]
|
||||
return e.out, ok
|
||||
}
|
||||
func (c *planCache) put(id, out string) { c.entries[id] = planCacheEntry{out: out} }
|
||||
func (c *planCache) prune(keepIDs []string) {
|
||||
func (c *planCache) put(id string, force bool, out string) {
|
||||
c.entries[cacheKey(id, force)] = planCacheEntry{out: out}
|
||||
}
|
||||
func (c *planCache) prune(keepIDs []string, force bool) {
|
||||
keep := map[string]bool{}
|
||||
for _, id := range keepIDs {
|
||||
keep[id] = true
|
||||
keep[cacheKey(id, force)] = true
|
||||
}
|
||||
for k := range c.entries {
|
||||
if !keep[k] {
|
||||
@@ -147,38 +158,51 @@ func (c *planCache) prune(keepIDs []string) {
|
||||
// renderPlanCached is the cached equivalent of running Detect+Plan for
|
||||
// every selected module and joining the rendered lines. IDs no longer in
|
||||
// the selection are evicted so a deselect+reselect re-runs Detect.
|
||||
func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache) string {
|
||||
cache.prune(selectedIDs)
|
||||
func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache, force bool) string {
|
||||
cache.prune(selectedIDs, force)
|
||||
if len(selectedIDs) == 0 {
|
||||
return "No modules selected."
|
||||
}
|
||||
var b strings.Builder
|
||||
if force {
|
||||
b.WriteString("⚠ Force mode: all selected modules will be reapplied\n\n")
|
||||
}
|
||||
for _, id := range selectedIDs {
|
||||
if cached, ok := cache.get(id); ok {
|
||||
if cached, ok := cache.get(id, force); ok {
|
||||
b.WriteString(cached)
|
||||
continue
|
||||
}
|
||||
line := renderOne(ctx, reg, id)
|
||||
cache.put(id, line)
|
||||
line := renderOne(ctx, reg, id, force)
|
||||
cache.put(id, force, line)
|
||||
b.WriteString(line)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderOne runs Detect+Plan for a single module and returns a rendered
|
||||
// line. A failing Detect/Plan is shown inline but does not abort.
|
||||
func renderOne(ctx context.Context, reg *module.Registry, id string) string {
|
||||
// renderOne runs Detect+Plan (or PlanForce when force=true) for a single
|
||||
// module and returns a rendered line. A failing Detect/Plan/PlanForce is
|
||||
// shown inline but does not abort.
|
||||
func renderOne(ctx context.Context, reg *module.Registry, id string, force bool) string {
|
||||
m, ok := reg.Get(id)
|
||||
if !ok {
|
||||
return fmt.Sprintf("• %s — (unknown)\n", id)
|
||||
}
|
||||
state, err := m.Detect(ctx)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("• %s — detect failed: %v\n", id, err)
|
||||
}
|
||||
plan, err := m.Plan(ctx, state)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("• %s — plan failed: %v\n", id, err)
|
||||
var plan module.Plan
|
||||
var err error
|
||||
if force {
|
||||
plan, err = m.PlanForce(ctx)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("• %s — plan failed: %v\n", id, err)
|
||||
}
|
||||
} else {
|
||||
state, derr := m.Detect(ctx)
|
||||
if derr != nil {
|
||||
return fmt.Sprintf("• %s — detect failed: %v\n", id, derr)
|
||||
}
|
||||
plan, err = m.Plan(ctx, state)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("• %s — plan failed: %v\n", id, err)
|
||||
}
|
||||
}
|
||||
if plan.IsEmpty() {
|
||||
return fmt.Sprintf("• %s — already up to date\n", id)
|
||||
|
||||
@@ -31,28 +31,28 @@ func TestSyncSelected(t *testing.T) {
|
||||
func TestRenderPlanWithHello(t *testing.T) {
|
||||
reg := module.NewRegistry()
|
||||
_ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"})
|
||||
out := renderPlanCached(context.Background(), reg, []string{"hello"}, newPlanCache())
|
||||
out := renderPlanCached(context.Background(), reg, []string{"hello"}, newPlanCache(), false)
|
||||
if !strings.Contains(out, "hello") {
|
||||
t.Fatalf("output missing hello: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPlanEmpty(t *testing.T) {
|
||||
if got := renderPlanCached(context.Background(), module.NewRegistry(), nil, newPlanCache()); got != "No modules selected." {
|
||||
if got := renderPlanCached(context.Background(), module.NewRegistry(), nil, newPlanCache(), false); got != "No modules selected." {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntClamp(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
"": 1,
|
||||
" ": 1,
|
||||
"abc": 1,
|
||||
"3abc": 1, // partial match is rejected (strconv.Atoi, not Sscanf)
|
||||
"0": 1,
|
||||
"-5": 1,
|
||||
"3": 3,
|
||||
"12": 12,
|
||||
"": 1,
|
||||
" ": 1,
|
||||
"abc": 1,
|
||||
"3abc": 1, // partial match is rejected (strconv.Atoi, not Sscanf)
|
||||
"0": 1,
|
||||
"-5": 1,
|
||||
"3": 3,
|
||||
"12": 12,
|
||||
"99999999999999999999": 1, // overflow → fallback
|
||||
}
|
||||
for in, want := range cases {
|
||||
@@ -75,9 +75,9 @@ func TestRenderPlanCachesDetectAndPlan(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cache := newPlanCache()
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
|
||||
if detects != 1 || plans != 1 {
|
||||
t.Fatalf("want 1 detect and 1 plan, got detects=%d plans=%d", detects, plans)
|
||||
}
|
||||
@@ -92,18 +92,20 @@ func TestRenderPlanInvalidatesOnSelectionChange(t *testing.T) {
|
||||
reg := module.NewRegistry()
|
||||
_ = reg.Register(m)
|
||||
cache := newPlanCache()
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||
_ = renderPlanCached(context.Background(), reg, []string{}, cache)
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache)
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
|
||||
_ = renderPlanCached(context.Background(), reg, []string{}, cache, false)
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
|
||||
if detects != 2 {
|
||||
t.Fatalf("deselect+reselect should re-detect; detects=%d (want 2)", detects)
|
||||
}
|
||||
}
|
||||
|
||||
type countingModule struct {
|
||||
meta module.Meta
|
||||
onDetect func()
|
||||
onPlan func()
|
||||
meta module.Meta
|
||||
onDetect func()
|
||||
onPlan func()
|
||||
onPlanForce func()
|
||||
planForceOut module.Plan
|
||||
}
|
||||
|
||||
func (c countingModule) Meta() module.Meta { return c.meta }
|
||||
@@ -119,9 +121,63 @@ func (c countingModule) Plan(context.Context, module.State) (module.Plan, error)
|
||||
}
|
||||
return module.Plan{}, nil
|
||||
}
|
||||
func (c countingModule) PlanForce(context.Context) (module.Plan, error) {
|
||||
if c.onPlanForce != nil {
|
||||
c.onPlanForce()
|
||||
}
|
||||
return c.planForceOut, nil
|
||||
}
|
||||
func (c countingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
||||
func (c countingModule) Verify(context.Context) error { return nil }
|
||||
|
||||
func TestWizardPreviewUsesPlanForce(t *testing.T) {
|
||||
var planForceCalls int
|
||||
m := countingModule{
|
||||
meta: module.Meta{ID: "count", Name: "Count"},
|
||||
onDetect: func() {
|
||||
t.Fatal("Detect must not be called in force mode")
|
||||
},
|
||||
onPlan: func() {
|
||||
t.Fatal("Plan must not be called in force mode")
|
||||
},
|
||||
onPlanForce: func() { planForceCalls++ },
|
||||
planForceOut: module.Plan{
|
||||
Actions: []module.Action{{Description: "forced-reapply"}},
|
||||
},
|
||||
}
|
||||
reg := module.NewRegistry()
|
||||
if err := reg.Register(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := renderPlanCached(context.Background(), reg, []string{"count"}, newPlanCache(), true)
|
||||
if planForceCalls != 1 {
|
||||
t.Fatalf("want 1 PlanForce call, got %d", planForceCalls)
|
||||
}
|
||||
if !strings.Contains(out, "forced-reapply") {
|
||||
t.Fatalf("output missing PlanForce action: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWizardPreviewCacheSeparatesForceAndNonForce(t *testing.T) {
|
||||
var detects, planForceCalls int
|
||||
m := countingModule{
|
||||
meta: module.Meta{ID: "count", Name: "Count"},
|
||||
onDetect: func() { detects++ },
|
||||
onPlanForce: func() { planForceCalls++ },
|
||||
}
|
||||
reg := module.NewRegistry()
|
||||
_ = reg.Register(m)
|
||||
cache := newPlanCache()
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, false)
|
||||
_ = renderPlanCached(context.Background(), reg, []string{"count"}, cache, true)
|
||||
if detects != 1 {
|
||||
t.Fatalf("want 1 Detect (non-force path), got %d", detects)
|
||||
}
|
||||
if planForceCalls != 1 {
|
||||
t.Fatalf("want 1 PlanForce (force path), got %d", planForceCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
@@ -17,26 +17,38 @@ write_zellij_config ~/.config/zellij/config.kdl
|
||||
|
||||
cat >~/.config/zellij/layouts/arduino.kdl <<'LAYOUT'
|
||||
layout {
|
||||
tab name="code" focus=true {
|
||||
pane split_direction="vertical" {
|
||||
pane size="65%" {
|
||||
command "nvim"
|
||||
args "."
|
||||
}
|
||||
pane split_direction="horizontal" {
|
||||
pane size="60%" name="terminal"
|
||||
pane size="40%" name="serial" {
|
||||
command "bash"
|
||||
args "-c" "echo '⚡ Serial — run: tio /dev/ttyUSB0 -b 115200'"
|
||||
}
|
||||
tab name="code" focus=true {
|
||||
pane split_direction="vertical" {
|
||||
pane size=1 borderless=true {
|
||||
plugin location="tab-bar"
|
||||
}
|
||||
pane size="65%" {
|
||||
command "nvim"
|
||||
args "."
|
||||
}
|
||||
pane split_direction="horizontal" {
|
||||
pane size="60%" name="terminal"
|
||||
pane size="40%" name="serial" {
|
||||
command "bash"
|
||||
args "-c" "echo '⚡ Serial — run: tio /dev/ttyUSB0 -b 115200'"
|
||||
}
|
||||
}
|
||||
}
|
||||
tab name="git" {
|
||||
pane {
|
||||
command "lazygit"
|
||||
}
|
||||
pane size=1 borderless=true {
|
||||
plugin location="status-bar"
|
||||
}
|
||||
}
|
||||
tab name="git" {
|
||||
pane size=1 borderless=true {
|
||||
plugin location="tab-bar"
|
||||
}
|
||||
pane {
|
||||
command "lazygit"
|
||||
}
|
||||
pane size=1 borderless=true {
|
||||
plugin location="status-bar"
|
||||
}
|
||||
}
|
||||
}
|
||||
LAYOUT
|
||||
info "Zellij configured with $THEME_DISPLAY_NAME + Arduino layout"
|
||||
|
||||
Reference in New Issue
Block a user