mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-18 17:24:01 -04:00
add support for shelly (#3367)
* add support for shelly
* fixed terminal failure
* updated to have it not allow adding ignore aur
* updated error strings
* Fixed label
port 1.6
(cherry picked from commit 8fe918ff24)
This commit is contained in:
@@ -58,7 +58,7 @@ func init() {
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates")
|
||||
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
|
||||
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
func init() {
|
||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
|
||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
|
||||
RegisterSystemBackend(func() Backend { return &shellyBackend{} })
|
||||
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
|
||||
}
|
||||
|
||||
@@ -53,9 +54,7 @@ func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
||||
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
||||
}
|
||||
|
||||
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
|
||||
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
|
||||
// AUR packages are exempt — holding those cannot break the repo dependency graph.
|
||||
// Arch repository packages must upgrade together; AUR packages may be held.
|
||||
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
|
||||
if len(ignored) == 0 {
|
||||
return ignored
|
||||
@@ -81,7 +80,7 @@ func isPacmanFamily(b Backend) bool {
|
||||
return false
|
||||
}
|
||||
switch b.ID() {
|
||||
case "pacman", "paru", "yay":
|
||||
case "pacman", "paru", "yay", "shelly":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type shellyBackend struct{}
|
||||
|
||||
func (shellyBackend) ID() string { return "shelly" }
|
||||
func (shellyBackend) DisplayName() string { return "Shelly (AUR)" }
|
||||
func (shellyBackend) Repo() RepoKind { return RepoSystem }
|
||||
func (shellyBackend) NeedsAuth() bool { return true }
|
||||
func (shellyBackend) RunsInTerminal() bool {
|
||||
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
|
||||
}
|
||||
|
||||
func (shellyBackend) IsAvailable(ctx context.Context) bool {
|
||||
if !commandExists("shelly") {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
out, err := Capture(ctx, []string{"shelly", "--version", "--json"})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var version struct {
|
||||
Name string `json:"name"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
}
|
||||
// Shelly 3.1.3 introduced this CLI identity response.
|
||||
return json.Unmarshal([]byte(out), &version) == nil && version.Name == "shelly" && version.SchemaVersion == 1
|
||||
}
|
||||
|
||||
func (b shellyBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
pkgs, err := b.checkUpdates(ctx, RepoSystem)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aur, err := b.checkUpdates(ctx, RepoAUR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(pkgs, aur...), nil
|
||||
}
|
||||
|
||||
func (b shellyBackend) checkUpdates(ctx context.Context, repo RepoKind) ([]Package, error) {
|
||||
kind := "standard"
|
||||
if repo == RepoAUR {
|
||||
kind = "aur"
|
||||
}
|
||||
out, err := Capture(ctx, []string{"shelly", "list-updates", kind, "--json"})
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
return nil, fmt.Errorf("shelly %s update check: %w: %s", kind, err, strings.TrimSpace(string(exitErr.Stderr)))
|
||||
}
|
||||
return nil, fmt.Errorf("shelly %s update check: %w", kind, err)
|
||||
}
|
||||
return parseShellyUpdates(out, repo)
|
||||
}
|
||||
|
||||
func parseShellyUpdates(out string, repo RepoKind) ([]Package, error) {
|
||||
var updates []struct {
|
||||
Name string
|
||||
CurrentVersion string
|
||||
Version string
|
||||
NewVersion string
|
||||
DownloadSize int64
|
||||
PackageBase string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &updates); err != nil {
|
||||
return nil, fmt.Errorf("shelly %s updates: invalid JSON: %w", repo, err)
|
||||
}
|
||||
if updates == nil {
|
||||
return nil, fmt.Errorf("shelly %s updates: expected a JSON array", repo)
|
||||
}
|
||||
var pkgs []Package
|
||||
for _, update := range updates {
|
||||
fromVersion := update.CurrentVersion
|
||||
if repo == RepoAUR {
|
||||
fromVersion = update.Version
|
||||
}
|
||||
if update.Name == "" || fromVersion == "" || update.NewVersion == "" {
|
||||
return nil, fmt.Errorf("shelly %s updates: missing package name or version", repo)
|
||||
}
|
||||
pkg := Package{
|
||||
Name: update.Name,
|
||||
Backend: "shelly",
|
||||
Repo: repo,
|
||||
FromVersion: fromVersion,
|
||||
ToVersion: update.NewVersion,
|
||||
SizeBytes: max(0, update.DownloadSize),
|
||||
}
|
||||
if repo == RepoAUR {
|
||||
base := update.PackageBase
|
||||
if base == "" {
|
||||
base = update.Name
|
||||
}
|
||||
pkg.ChangelogURL = "https://aur.archlinux.org/packages/" + url.PathEscape(base)
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
func (b shellyBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||
return nil
|
||||
}
|
||||
var aur []Package
|
||||
if opts.IncludeAUR && (opts.DryRun || len(opts.Ignored) > 0) {
|
||||
// The manager has already removed held packages from opts.Targets.
|
||||
var err error
|
||||
aur, err = b.checkUpdates(ctx, RepoAUR)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, pkg := range aur {
|
||||
if slices.Contains(opts.Ignored, pkg.Name) {
|
||||
return fmt.Errorf("shelly cannot exclude held AUR package %q; disable AUR updates or remove its DMS hold before updating", pkg.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if opts.DryRun {
|
||||
pkgs, err := b.checkUpdates(ctx, RepoSystem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkgs = append(pkgs, aur...)
|
||||
for _, pkg := range pkgs {
|
||||
if onLine != nil {
|
||||
onLine(fmt.Sprintf("%s %s -> %s", pkg.Name, pkg.FromVersion, pkg.ToVersion))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
argv := shellyUpgradeArgv(opts.IncludeAUR)
|
||||
if opts.AttachStdio {
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: true})
|
||||
}
|
||||
if !b.RunsInTerminal() {
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine, Env: []string{"SHELLY_ELEVATOR=pkexec"}})
|
||||
}
|
||||
term := findTerminal(opts.Terminal)
|
||||
if term == "" {
|
||||
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||
}
|
||||
return Run(ctx, wrapInTerminal(term, "DMS — System Update (shelly)", strings.Join(argv, " "), opts.TerminalArgs), RunOptions{OnLine: onLine})
|
||||
}
|
||||
|
||||
func shellyUpgradeArgv(includeAUR bool) []string {
|
||||
if !includeAUR {
|
||||
return []string{"shelly", "upgrade", "standard", "--no-confirm"}
|
||||
}
|
||||
return []string{"shelly", "upgrade", "all", "--no-flatpak", "--no-appimage", "--no-confirm"}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const shellyRepoFixture = `[{"Name":"linux","CurrentVersion":"2:6.18-1","NewVersion":"2:6.19-1","DownloadSize":1024,"Repository":"core"}]`
|
||||
const shellyAURFixture = `[{"Name":"example-git","Version":"r1-1","NewVersion":"r2-1","DownloadSize":-1,"PackageBase":"example"}]`
|
||||
|
||||
func TestParseShellyUpdates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
out string
|
||||
repo RepoKind
|
||||
want []Package
|
||||
}{
|
||||
{"repository", shellyRepoFixture, RepoSystem, []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem, FromVersion: "2:6.18-1", ToVersion: "2:6.19-1", SizeBytes: 1024}}},
|
||||
{"aur package base", shellyAURFixture, RepoAUR, []Package{{Name: "example-git", Backend: "shelly", Repo: RepoAUR, FromVersion: "r1-1", ToVersion: "r2-1", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
|
||||
{"aur name fallback", `[{"Name":"example","Version":"1","NewVersion":"2"}]`, RepoAUR, []Package{{Name: "example", Backend: "shelly", Repo: RepoAUR, FromVersion: "1", ToVersion: "2", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
|
||||
{"empty", "[]\n", RepoSystem, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseShellyUpdates(tt.out, tt.repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("packages = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, out := range []string{"", "null", "{}", "[", "[] trailing", "[null]", `[{"Name":"linux"}]`, `[{"Name":"linux","CurrentVersion":"1","NewVersion":"2","DownloadSize":"large"}]`} {
|
||||
t.Run(out, func(t *testing.T) {
|
||||
if _, err := parseShellyUpdates(out, RepoSystem); err == nil {
|
||||
t.Fatalf("expected error for %q", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeUpdateExecutable(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellySelection(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
bins []string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{"shelly only", []string{"shelly"}, `{"schemaVersion":1,"name":"shelly","version":"3.1.3"}`, "shelly"},
|
||||
{"shelly before pacman", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly"}`, "shelly"},
|
||||
{"paru preferred", []string{"paru", "yay", "shelly", "pacman"}, "", "paru"},
|
||||
{"yay preferred", []string{"yay", "shelly", "pacman"}, "", "yay"},
|
||||
{"legacy shelly", []string{"shelly", "pacman"}, "2.4.1", "pacman"},
|
||||
{"wrong binary", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly-ui"}`, "pacman"},
|
||||
{"unknown schema", []string{"shelly", "pacman"}, `{"schemaVersion":2,"name":"shelly"}`, "pacman"},
|
||||
{"pacman only", []string{"pacman"}, "", "pacman"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
for _, bin := range tt.bins {
|
||||
writeUpdateExecutable(t, dir, bin, "printf '%s\\n' '"+tt.version+"'")
|
||||
}
|
||||
selection := Select(t.Context())
|
||||
if selection.System == nil || selection.System.ID() != tt.want {
|
||||
t.Fatalf("selection = %#v, want %s", selection.System, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fakeShelly(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
log := filepath.Join(dir, "calls")
|
||||
t.Setenv("PATH", dir)
|
||||
t.Setenv("TERMINAL", "")
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "")
|
||||
t.Setenv("DMS_TEST_SHELLY_LOG", log)
|
||||
t.Setenv("DMS_TEST_SHELLY_EXIT", "0")
|
||||
t.Setenv("SHELLY_ELEVATOR", "")
|
||||
writeUpdateExecutable(t, dir, "shelly", `printf '%s|%s\n' "$*" "$SHELLY_ELEVATOR" >> "$DMS_TEST_SHELLY_LOG"
|
||||
case "$*" in
|
||||
'list-updates standard --json') printf '%s\n' '`+shellyRepoFixture+`' ;;
|
||||
'list-updates aur --json') printf '%s\n' '`+shellyAURFixture+`' ;;
|
||||
'upgrade standard --no-confirm'|'upgrade all --no-flatpak --no-appimage --no-confirm') exit "$DMS_TEST_SHELLY_EXIT" ;;
|
||||
*) echo "unexpected arguments: $*" >&2; exit 99 ;;
|
||||
esac`)
|
||||
return dir, log
|
||||
}
|
||||
|
||||
func readUpdateCalls(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestShellyCheckUpdates(t *testing.T) {
|
||||
_, log := fakeShelly(t)
|
||||
pkgs, err := (shellyBackend{}).CheckUpdates(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pkgs) != 2 || pkgs[0].Repo != RepoSystem || pkgs[1].Repo != RepoAUR {
|
||||
t.Fatalf("unexpected packages: %#v", pkgs)
|
||||
}
|
||||
if got, want := readUpdateCalls(t, log), "list-updates standard --json|\nlist-updates aur --json|\n"; got != want {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyCheckFailures(t *testing.T) {
|
||||
for _, code := range []string{"1", "2"} {
|
||||
t.Run(code, func(t *testing.T) {
|
||||
dir, _ := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", "echo 'database unavailable' >&2; exit "+code)
|
||||
if _, err := (shellyBackend{}).CheckUpdates(t.Context()); err == nil || !strings.Contains(err.Error(), "database unavailable") {
|
||||
t.Fatalf("expected query failure with stderr, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("cancellation", func(t *testing.T) {
|
||||
dir, _ := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", "exec /bin/sleep 30")
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := (shellyBackend{}).CheckUpdates(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected deadline error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShellyUpgrade(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
includeAUR bool
|
||||
attached bool
|
||||
terminal bool
|
||||
}{
|
||||
{"pkexec repository", false, false, false},
|
||||
{"pkexec aur", true, false, false},
|
||||
{"attached repository", false, true, false},
|
||||
{"attached aur", true, true, false},
|
||||
{"terminal repository", false, false, true},
|
||||
{"terminal aur", true, false, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir, log := fakeShelly(t)
|
||||
opts := UpgradeOptions{IncludeAUR: tt.includeAUR, AttachStdio: tt.attached, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
elevator := ""
|
||||
if tt.terminal {
|
||||
opts.Terminal = "fake-terminal"
|
||||
writeUpdateExecutable(t, dir, opts.Terminal, `while [ "$1" != sh ] && [ "$#" -gt 0 ]; do shift; done
|
||||
shift
|
||||
exec /bin/sh "$@"`)
|
||||
} else if !tt.attached {
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
elevator = "pkexec"
|
||||
}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := "upgrade standard --no-confirm"
|
||||
if tt.includeAUR {
|
||||
command = "upgrade all --no-flatpak --no-appimage --no-confirm"
|
||||
}
|
||||
if got, want := readUpdateCalls(t, log), command+"|"+elevator+"\n"; got != want {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyUpgradeHolds(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
includeAUR bool
|
||||
ignored []string
|
||||
wantError bool
|
||||
dryRun bool
|
||||
}{
|
||||
{"held aur", true, []string{"example-git"}, true, false},
|
||||
{"repository only", false, []string{"example-git"}, false, false},
|
||||
{"repository hold cannot exclude system upgrade", true, []string{"linux"}, false, false},
|
||||
{"unrelated hold", true, []string{"org.example.Flatpak"}, false, false},
|
||||
{"dry held aur", true, []string{"example-git"}, true, true},
|
||||
{"dry repository only", false, []string{"example-git"}, false, true},
|
||||
{"dry repository hold", true, []string{"linux"}, false, true},
|
||||
{"dry unrelated hold", true, []string{"org.example.Flatpak"}, false, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, log := fakeShelly(t)
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
opts := UpgradeOptions{DryRun: tt.dryRun, IncludeAUR: tt.includeAUR, Ignored: tt.ignored, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
|
||||
calls := readUpdateCalls(t, log)
|
||||
if strings.Count(calls, "list-updates aur --json") > 1 {
|
||||
t.Fatalf("duplicate AUR check: %q", calls)
|
||||
}
|
||||
if tt.wantError {
|
||||
if err == nil || err.Error() != `shelly cannot exclude held AUR package "example-git"; disable AUR updates or remove its DMS hold before updating` || strings.Contains(calls, "upgrade ") {
|
||||
t.Fatalf("held AUR package must block upgrade, got %v, calls %q", err, readUpdateCalls(t, log))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || strings.Contains(calls, "upgrade ") == tt.dryRun {
|
||||
t.Fatalf("unexpected result for dryRun=%v: %v, calls %q", tt.dryRun, err, calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !isPacmanFamily(shellyBackend{}) {
|
||||
t.Fatal("Shelly must enforce repository hold restrictions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyHoldCheckFailure(t *testing.T) {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
dir, log := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", `printf '%s\n' "$*" >> "$DMS_TEST_SHELLY_LOG"
|
||||
echo 'AUR unavailable' >&2
|
||||
exit 1`)
|
||||
opts := UpgradeOptions{DryRun: dryRun, IncludeAUR: true, Ignored: []string{"example-git"}, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "AUR unavailable") {
|
||||
t.Fatalf("dryRun=%v: expected AUR query error, got %v", dryRun, err)
|
||||
}
|
||||
if calls := readUpdateCalls(t, log); calls != "list-updates aur --json\n" {
|
||||
t.Fatalf("query failure must stop the operation: %q", calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyDryRun(t *testing.T) {
|
||||
for _, includeAUR := range []bool{false, true} {
|
||||
_, log := fakeShelly(t)
|
||||
var lines []string
|
||||
opts := UpgradeOptions{DryRun: true, IncludeAUR: includeAUR, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, func(line string) { lines = append(lines, line) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantCalls := "list-updates standard --json|\n"
|
||||
wantLines := []string{"linux 2:6.18-1 -> 2:6.19-1"}
|
||||
if includeAUR {
|
||||
wantCalls = "list-updates aur --json|\n" + wantCalls
|
||||
wantLines = append(wantLines, "example-git r1-1 -> r2-1")
|
||||
}
|
||||
if got := readUpdateCalls(t, log); got != wantCalls || !reflect.DeepEqual(lines, wantLines) {
|
||||
t.Fatalf("calls = %q, lines = %q", got, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellySkipsExcludedTargets(t *testing.T) {
|
||||
for _, targets := range [][]Package{
|
||||
nil,
|
||||
{{Name: "example-git", Backend: "shelly", Repo: RepoAUR}},
|
||||
{{Name: "org.example.App", Backend: "flatpak", Repo: RepoFlatpak}},
|
||||
{{Name: "linux", Backend: "pacman", Repo: RepoSystem}},
|
||||
} {
|
||||
_, log := fakeShelly(t)
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), UpgradeOptions{Targets: targets, IncludeFlatpak: true}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readUpdateCalls(t, log); got != "" {
|
||||
t.Fatalf("excluded targets executed commands: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyUpgradeFailure(t *testing.T) {
|
||||
_, _ = fakeShelly(t)
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
t.Setenv("DMS_TEST_SHELLY_EXIT", "126")
|
||||
opts := UpgradeOptions{Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err == nil {
|
||||
t.Fatal("authentication or upgrade failure must propagate")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ func Run(ctx context.Context, argv []string, opts RunOptions) error {
|
||||
}
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
@@ -125,7 +126,14 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
||||
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
||||
title, shellCmd,
|
||||
)
|
||||
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
|
||||
closer := `dms_update_status=$?
|
||||
if [ "$dms_update_status" -eq 0 ]; then
|
||||
printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'
|
||||
else
|
||||
printf '\n\033[1;31m=== Update failed (exit %s). Press Enter to close. ===\033[0m\n' "$dms_update_status"
|
||||
fi
|
||||
read -r dms_update_reply
|
||||
exit "$dms_update_status"`
|
||||
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||
full := export + banner + "; " + shellCmd + "; " + closer
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTerminalWrapperPreservesExitStatus(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
command string
|
||||
status int
|
||||
message string
|
||||
}{
|
||||
{"true", 0, "Done."},
|
||||
{"false", 1, "Update failed (exit 1)"},
|
||||
{"sh -c 'exit 42'", 42, "Update failed (exit 42)"},
|
||||
} {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
argv := wrapInTerminal("xterm", "Test", tt.command, nil)
|
||||
cmd := exec.Command("sh", "-c", argv[len(argv)-1])
|
||||
cmd.Stdin = strings.NewReader("\n")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if tt.status == 0 && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.status != 0 {
|
||||
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||
if !ok || exitErr.ExitCode() != tt.status {
|
||||
t.Fatalf("expected exit %d, got %v (%s)", tt.status, err, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(out), tt.message) {
|
||||
t.Fatalf("missing status message %q: %s", tt.message, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAttachesStdin(t *testing.T) {
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
if _, err := writer.WriteString("confirmation\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer.Close()
|
||||
previous := os.Stdin
|
||||
os.Stdin = reader
|
||||
defer func() { os.Stdin = previous }()
|
||||
if err := Run(t.Context(), []string{"sh", "-c", `read -r answer && [ "$answer" = confirmation ]`}, RunOptions{AttachStdio: true}); err != nil {
|
||||
t.Fatalf("attached command did not receive stdin: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -420,8 +420,8 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
m.failCustomUpgrade(ErrCodeCancelled, err)
|
||||
return
|
||||
}
|
||||
// exit status reflects the trailing `read`, not the update command
|
||||
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
|
||||
m.failCustomUpgrade(ErrCodeBackendFailed, err)
|
||||
return
|
||||
}
|
||||
|
||||
m.finishSuccessfulUpgrade(false)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type customUpgradeTestBackend struct {
|
||||
pacmanBackend
|
||||
checks int
|
||||
}
|
||||
|
||||
func (b *customUpgradeTestBackend) CheckUpdates(context.Context) ([]Package, error) {
|
||||
b.checks++
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestCustomUpgradeFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
writeUpdateExecutable(t, dir, "fake-terminal", `while [ "$#" -gt 0 ] && [ "$1" != sh ]; do shift; done
|
||||
[ "$#" -gt 0 ] || exit 99
|
||||
shift
|
||||
exec /bin/sh "$@"`)
|
||||
|
||||
backend := &customUpgradeTestBackend{}
|
||||
m := &Manager{
|
||||
selection: Selection{System: backend},
|
||||
notifyDirty: make(chan struct{}, 1),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
m.runCustomUpgrade(t.Context(), UpgradeOptions{
|
||||
Terminal: "fake-terminal",
|
||||
CustomCommand: "false",
|
||||
})
|
||||
|
||||
state := m.GetState()
|
||||
if state.Phase != PhaseError {
|
||||
t.Errorf("phase = %s, want %s", state.Phase, PhaseError)
|
||||
}
|
||||
if state.Error == nil || state.Error.Code != ErrCodeBackendFailed || !strings.Contains(state.Error.Message, "exit status 1") {
|
||||
t.Errorf("error = %#v, want backend-failed with exit status 1", state.Error)
|
||||
}
|
||||
if slices.Contains(state.RecentLog, "Upgrade complete.") {
|
||||
t.Error("failed custom upgrade logged success")
|
||||
}
|
||||
if backend.checks != 0 {
|
||||
t.Errorf("failed custom upgrade refreshed %d times", backend.checks)
|
||||
}
|
||||
}
|
||||
@@ -179,10 +179,10 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "systemUpdaterAUR"
|
||||
tags: ["aur", "paru", "yay"]
|
||||
tags: ["aur", "paru", "yay", "shelly"]
|
||||
text: I18n.tr("Include AUR updates")
|
||||
description: I18n.tr("Run paru/yay with AUR enabled when 'Update All' is clicked.")
|
||||
visible: (SystemUpdateService.backends || []).some(b => b.id === "paru" || b.id === "yay")
|
||||
description: I18n.tr("Apply AUR updates alongside system updates when running 'Update All'.")
|
||||
visible: (SystemUpdateService.backends || []).some(b => ["paru", "yay", "shelly"].includes(b.id))
|
||||
checked: SettingsData.updaterAllowAUR
|
||||
onToggled: checked => SettingsData.set("updaterAllowAUR", checked)
|
||||
}
|
||||
@@ -198,17 +198,21 @@ Item {
|
||||
settingKey: "systemUpdaterIgnoredPackages"
|
||||
tags: ["system", "update", "package", "ignore"]
|
||||
|
||||
property bool errorIsInvalidName: false
|
||||
|
||||
function addIgnoredPackage() {
|
||||
const name = newIgnoredPackageField.text.trim();
|
||||
if (name === "") {
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z0-9@._+:-]+$/.test(name)) {
|
||||
errorIsInvalidName = !/^[A-Za-z0-9@._+:-]+$/.test(name);
|
||||
if (errorIsInvalidName) {
|
||||
ignoredPackageError.visible = true;
|
||||
return;
|
||||
}
|
||||
ignoredPackageError.visible = false;
|
||||
SystemUpdateService.ignorePackage(name);
|
||||
ignoredPackageError.visible = !SystemUpdateService.ignorePackage(name);
|
||||
if (ignoredPackageError.visible)
|
||||
return;
|
||||
newIgnoredPackageField.text = "";
|
||||
}
|
||||
|
||||
@@ -222,6 +226,9 @@ Item {
|
||||
if (SettingsData.updaterUseCustomCommand) {
|
||||
return I18n.tr("Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions.");
|
||||
}
|
||||
if (SystemUpdateService.pkgManager === "shelly") {
|
||||
return I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.");
|
||||
}
|
||||
return (SettingsData.updaterIgnoredPackages || []).length > 0 ? I18n.tr("Ignored packages are hidden from the updater and skipped by 'Update All'.") : I18n.tr("No packages ignored. Add one here or hover an update in the popout and click the hide button.");
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
@@ -258,10 +265,12 @@ Item {
|
||||
|
||||
StyledText {
|
||||
id: ignoredPackageError
|
||||
width: parent.width
|
||||
visible: false
|
||||
text: I18n.tr("Invalid package name — letters, digits and @._+:- only.")
|
||||
text: ignoredPackagesCard.errorIsInvalidName ? I18n.tr("Invalid package name — letters, digits and @._+:- only.") : I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.error
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -35,14 +35,14 @@ Singleton {
|
||||
readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0
|
||||
readonly property bool useCustomCommand: SettingsData.updaterUseCustomCommand && (SettingsData.updaterCustomCommand || "").trim().length > 0
|
||||
|
||||
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
|
||||
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
|
||||
// AUR/Flatpak packages stay ignorable — holding those cannot break the repo dependency graph.
|
||||
readonly property bool systemHoldsAllowed: !["pacman", "paru", "yay"].includes(pkgManager)
|
||||
// Arch repository packages must upgrade together.
|
||||
readonly property bool systemHoldsAllowed: !["pacman", "paru", "yay", "shelly"].includes(pkgManager)
|
||||
|
||||
function canIgnorePackage(pkg) {
|
||||
if (!pkg)
|
||||
return false;
|
||||
if (pkgManager === "shelly")
|
||||
return pkg.repo === "flatpak";
|
||||
return systemHoldsAllowed || pkg.repo !== "system";
|
||||
}
|
||||
|
||||
@@ -181,12 +181,15 @@ Singleton {
|
||||
|
||||
function ignorePackage(name) {
|
||||
if (!name)
|
||||
return;
|
||||
return false;
|
||||
if (pkgManager === "shelly" && !_rawUpdates.some(p => p.name === name && canIgnorePackage(p)))
|
||||
return false;
|
||||
const list = (SettingsData.updaterIgnoredPackages || []).slice();
|
||||
if (list.indexOf(name) !== -1)
|
||||
return;
|
||||
return true;
|
||||
list.push(name);
|
||||
SettingsData.set("updaterIgnoredPackages", list);
|
||||
return true;
|
||||
}
|
||||
|
||||
function unignorePackage(name) {
|
||||
|
||||
@@ -8225,18 +8225,21 @@
|
||||
"tabIndex": 20,
|
||||
"category": "System Updater",
|
||||
"keywords": [
|
||||
"alongside",
|
||||
"apply",
|
||||
"aur",
|
||||
"enabled",
|
||||
"include",
|
||||
"packages",
|
||||
"paru",
|
||||
"running",
|
||||
"shelly",
|
||||
"system",
|
||||
"updater",
|
||||
"updates",
|
||||
"upgrade",
|
||||
"yay"
|
||||
],
|
||||
"description": "Run paru/yay with AUR enabled when "
|
||||
"description": "Apply AUR updates alongside system updates when running "
|
||||
},
|
||||
{
|
||||
"section": "systemUpdaterFlatpak",
|
||||
|
||||
Reference in New Issue
Block a user