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:
Zoey Bauer
2026-09-08 15:50:35 +00:00
committed by dms-ci[bot]
parent b2d1ec6690
commit 4015f634c4
11 changed files with 621 additions and 23 deletions
+1 -1
View File
@@ -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")
}
}
+9 -1
View File
@@ -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)
}
}
+2 -2
View File
@@ -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 {
+9 -6
View File
@@ -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",