A shepherd for your Appimages.
0

Configure Feed

Select the types of activity you want to include in your feed.

add list argument

Aly Raffauf (Jun 17, 2026, 10:45 PM EDT) e6e08603 ced51215

+251 -5
+2
README.md
··· 15 15 - **Upgrades replace instead of piling up.** appherder names an app by what's inside it, not the download's filename, so a newer version of `Foo` just replaces the old one. 16 16 - **Checks for updates.** Reads the update info baked into each AppImage and fetches the latest from GitHub, GitLab, zsync, or a static URL. `appherder upgrade --check` shows what's new; `appherder upgrade` downloads and installs it. 17 17 - **Won't touch your other apps.** It only removes launchers it made itself, so your Flatpaks, Snaps, and hand-made shortcuts are safe. 18 + - **Tells you what it manages.** `appherder list` shows every app it installed, where it checks for updates, and which ones are missing their AppImage. 18 19 - **Quiet when nothing changed.** Re-installing an unchanged app does nothing. Drop your AppImages in one folder and `appherder sync` lines everything up. 19 20 20 21 ## Installation ··· 51 52 ```bash 52 53 appherder install ~/Downloads/Foo-x86_64.AppImage # install one 53 54 appherder uninstall foo # remove one 55 + appherder list # see what's installed 54 56 appherder sync # match your apps to what's in ~/AppImages 55 57 appherder upgrade --check # see what's out of date 56 58 appherder upgrade # download and install updates
+12 -1
cmd/appherder/cli.go
··· 15 15 } 16 16 cmd.SetOut(stdout) 17 17 cmd.SetErr(stderr) 18 - cmd.AddCommand(newInstallCommand(a), newUninstallCommand(a), newSyncCommand(a), newMigrateCommand(a), newUpgradeCommand(a)) 18 + cmd.AddCommand(newInstallCommand(a), newUninstallCommand(a), newSyncCommand(a), newMigrateCommand(a), newUpgradeCommand(a), newListCommand(a)) 19 19 return cmd 20 20 } 21 21 ··· 84 84 }, 85 85 } 86 86 } 87 + 88 + func newListCommand(a app) *cobra.Command { 89 + return &cobra.Command{ 90 + Use: "list", 91 + Short: "Show managed AppImages and their update sources", 92 + Args: cobra.NoArgs, 93 + RunE: func(cmd *cobra.Command, args []string) error { 94 + return a.list(cmd.OutOrStdout()) 95 + }, 96 + } 97 + }
+112
cmd/appherder/list.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "os" 7 + "path/filepath" 8 + "sort" 9 + "text/tabwriter" 10 + ) 11 + 12 + // appInfo is one row in the `list` output. 13 + type appInfo struct { 14 + appid string 15 + name string // desktop Name= field, falls back to filename 16 + filename string // basename of the AppImage in ~/AppImages 17 + version string // desktop X-AppImage-Version= 18 + size int64 19 + source string 20 + } 21 + 22 + // list prints appherder's managed apps: display name, version, file size, and 23 + // update source. Offline and instant; use `upgrade --check` to see what's 24 + // stale. A missing AppImage shows as empty filename and size. 25 + func (a app) list(out io.Writer) error { 26 + home, err := a.homeDir() 27 + if err != nil { 28 + return fmt.Errorf("resolve home directory: %w", err) 29 + } 30 + 31 + appids, err := managedApps(home) 32 + if err != nil { 33 + return err 34 + } 35 + 36 + appimagesDir := filepath.Join(home, "AppImages") 37 + appsDir := filepath.Join(home, ".local", "share", "applications") 38 + 39 + infos := make([]appInfo, 0, len(appids)) 40 + for _, appid := range appids { 41 + infos = append(infos, gatherAppInfo(appsDir, appimagesDir, appid)) 42 + } 43 + sort.Slice(infos, func(i, j int) bool { return infos[i].name < infos[j].name }) 44 + 45 + tabWriter := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) 46 + fmt.Fprintln(tabWriter, "NAME\tFILENAME\tVERSION\tSIZE\tSOURCE") 47 + for _, info := range infos { 48 + size := "-" 49 + if info.size > 0 { 50 + size = humanSize(info.size) 51 + } 52 + fmt.Fprintf(tabWriter, "%s\t%s\t%s\t%s\t%s\n", 53 + info.name, orDash(info.filename), orDash(info.version), size, orDash(info.source)) 54 + } 55 + return tabWriter.Flush() 56 + } 57 + 58 + // orDash returns s, or "-" when empty, for table cells. 59 + func orDash(s string) string { 60 + if s == "" { 61 + return "-" 62 + } 63 + return s 64 + } 65 + 66 + // gatherAppInfo collects display metadata for appid from its installed desktop 67 + // file and AppImage. 68 + func gatherAppInfo(appsDir, appimagesDir, appid string) appInfo { 69 + info := appInfo{appid: appid, source: "none"} 70 + 71 + if desktop, err := readDesktopFile(filepath.Join(appsDir, appid+".desktop")); err == nil { 72 + if name, ok := desktop.get("Name", desktopEntrySection); ok && name != "" { 73 + info.name = name 74 + } 75 + if version, ok := desktop.get("X-AppImage-Version", desktopEntrySection); ok { 76 + info.version = version 77 + } 78 + } 79 + 80 + if path, err := findAppImagePath(appimagesDir, appid); err == nil && path != "" { 81 + info.filename = filepath.Base(path) 82 + if stat, err := os.Stat(path); err == nil { 83 + info.size = stat.Size() 84 + } 85 + if src, err := sourceForAppImage(path); err == nil && src != nil { 86 + info.source = src.kind() 87 + } 88 + } 89 + 90 + if info.name == "" { 91 + if info.filename != "" { 92 + info.name = info.filename 93 + } else { 94 + info.name = appid 95 + } 96 + } 97 + return info 98 + } 99 + 100 + // humanSize formats bytes in binary units with one decimal place (e.g. "354 MB"). 101 + func humanSize(bytes int64) string { 102 + const unit = 1024 103 + if bytes < unit { 104 + return fmt.Sprintf("%d B", bytes) 105 + } 106 + div, exp := int64(unit), 0 107 + for scaled := bytes / unit; scaled >= unit; scaled /= unit { 108 + div *= unit 109 + exp++ 110 + } 111 + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 112 + }
+102
cmd/appherder/list_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + ) 10 + 11 + func TestListShowsInstalledAndOrphaned(t *testing.T) { 12 + home := t.TempDir() 13 + appimages := filepath.Join(home, "AppImages") 14 + appsDir := filepath.Join(home, ".local", "share", "applications") 15 + if err := os.MkdirAll(appsDir, 0o755); err != nil { 16 + t.Fatal(err) 17 + } 18 + if err := os.MkdirAll(appimages, 0o755); err != nil { 19 + t.Fatal(err) 20 + } 21 + 22 + // Present AppImage with a named desktop entry. 23 + if err := os.WriteFile(filepath.Join(appimages, "present.appimage"), []byte("x"), 0o644); err != nil { 24 + t.Fatal(err) 25 + } 26 + if err := os.WriteFile(filepath.Join(appsDir, "present.desktop"), []byte( 27 + "[Desktop Entry]\nName=Present App\nX-AppImage-Version=1.2.3\n"+desktopOwnerKey+"=true\n", 28 + ), 0o644); err != nil { 29 + t.Fatal(err) 30 + } 31 + 32 + // Missing AppImage: orphaned launcher only. 33 + writeManagedDesktop(t, home, "gone") 34 + 35 + // Unmanaged: must not appear. 36 + if err := os.WriteFile( 37 + filepath.Join(appsDir, "other.desktop"), 38 + []byte("[Desktop Entry]\nName=Other\n"), 0o644, 39 + ); err != nil { 40 + t.Fatal(err) 41 + } 42 + 43 + var out bytes.Buffer 44 + app := app{homeDir: func() (string, error) { return home, nil }} 45 + if err := app.list(&out); err != nil { 46 + t.Fatal(err) 47 + } 48 + 49 + got := out.String() 50 + for _, want := range []string{"Present App", "present.appimage", "1.2.3", "gone"} { 51 + if !strings.Contains(got, want) { 52 + t.Fatalf("list output missing %q:\n%s", want, got) 53 + } 54 + } 55 + if strings.Contains(got, "other") { 56 + t.Fatalf("unmanaged app appeared in list:\n%s", got) 57 + } 58 + } 59 + 60 + func TestListEmptyWhenNothingManaged(t *testing.T) { 61 + home := t.TempDir() 62 + var out bytes.Buffer 63 + app := app{homeDir: func() (string, error) { return home, nil }} 64 + if err := app.list(&out); err != nil { 65 + t.Fatal(err) 66 + } 67 + lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") 68 + if len(lines) != 1 || !strings.HasPrefix(lines[0], "NAME") { 69 + t.Fatalf("expected header only, got:\n%s", out.String()) 70 + } 71 + } 72 + 73 + func TestListFallsBackToFilenameForName(t *testing.T) { 74 + home := t.TempDir() 75 + appimages := filepath.Join(home, "AppImages") 76 + appsDir := filepath.Join(home, ".local", "share", "applications") 77 + if err := os.MkdirAll(appimages, 0o755); err != nil { 78 + t.Fatal(err) 79 + } 80 + if err := os.MkdirAll(appsDir, 0o755); err != nil { 81 + t.Fatal(err) 82 + } 83 + 84 + // Desktop file with no Name= field — list should fall back to the filename. 85 + if err := os.WriteFile(filepath.Join(appimages, "noname.appimage"), []byte("x"), 0o644); err != nil { 86 + t.Fatal(err) 87 + } 88 + if err := os.WriteFile(filepath.Join(appsDir, "noname.desktop"), []byte( 89 + "[Desktop Entry]\n"+desktopOwnerKey+"=true\n", 90 + ), 0o644); err != nil { 91 + t.Fatal(err) 92 + } 93 + 94 + var out bytes.Buffer 95 + app := app{homeDir: func() (string, error) { return home, nil }} 96 + if err := app.list(&out); err != nil { 97 + t.Fatal(err) 98 + } 99 + if !strings.Contains(out.String(), "noname.appimage") { 100 + t.Fatalf("expected filename fallback in output:\n%s", out.String()) 101 + } 102 + }
+1
cmd/appherder/source.go
··· 81 81 // source resolves the latest available build of an installed app. 82 82 type source interface { 83 83 latest(ctx context.Context) (release, error) 84 + kind() string 84 85 } 85 86 86 87 // readUpdateInfo returns the AppImage's embedded update-information string from
+2
cmd/appherder/source_github.go
··· 28 28 Assets []ghAsset `json:"assets"` 29 29 } 30 30 31 + func (githubReleaseSource) kind() string { return "github" } 32 + 31 33 func (s githubReleaseSource) latest(ctx context.Context) (release, error) { 32 34 base := s.api 33 35 if base == "" {
+2
cmd/appherder/source_gitlab.go
··· 33 33 } `json:"assets"` 34 34 } 35 35 36 + func (gitlabReleaseSource) kind() string { return "gitlab" } 37 + 36 38 func (s gitlabReleaseSource) latest(ctx context.Context) (release, error) { 37 39 base := s.api 38 40 if base == "" {
+2
cmd/appherder/source_static.go
··· 14 14 url string 15 15 } 16 16 17 + func (staticURLSource) kind() string { return "static" } 18 + 17 19 func (s staticURLSource) latest(ctx context.Context) (release, error) { 18 20 ctx, cancel := context.WithTimeout(ctx, apiTimeout) 19 21 defer cancel()
+2
cmd/appherder/source_zsync.go
··· 19 19 url string 20 20 } 21 21 22 + func (zsyncURLSource) kind() string { return "zsync" } 23 + 22 24 func (s zsyncURLSource) latest(ctx context.Context) (release, error) { 23 25 header, err := fetchZsyncHeader(ctx, s.url) 24 26 if err != nil {
+14 -4
cmd/appherder/sync.go
··· 101 101 // appImagePresent reports whether <appid>.appimage exists in dir, matching the 102 102 // extension case-insensitively. 103 103 func appImagePresent(dir, appid string) (bool, error) { 104 + path, err := findAppImagePath(dir, appid) 105 + if err != nil { 106 + return false, err 107 + } 108 + return path != "", nil 109 + } 110 + 111 + // findAppImagePath returns the full path of <appid>.appimage in dir, matching 112 + // the extension case-insensitively, or "" when absent. 113 + func findAppImagePath(dir, appid string) (string, error) { 104 114 entries, err := os.ReadDir(dir) 105 115 if err != nil { 106 116 if os.IsNotExist(err) { 107 - return false, nil 117 + return "", nil 108 118 } 109 - return false, err 119 + return "", err 110 120 } 111 121 for _, entry := range entries { 112 122 if entry.IsDir() { 113 123 continue 114 124 } 115 125 if strings.EqualFold(entry.Name(), appid+".appimage") { 116 - return true, nil 126 + return filepath.Join(dir, entry.Name()), nil 117 127 } 118 128 } 119 - return false, nil 129 + return "", nil 120 130 } 121 131 122 132 // appImageBackedOrphans returns appids of unmanaged desktop entries whose