A shepherd for your Appimages.
0

Configure Feed

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

cleanup CLI

Aly Raffauf (Jun 17, 2026, 10:55 PM EDT) 63e9c7bc e6e08603

+92 -53
+53 -29
cmd/appherder/cli.go
··· 1 1 package main 2 2 3 3 import ( 4 + "fmt" 4 5 "io" 5 6 6 7 "github.com/spf13/cobra" ··· 8 9 9 10 func newRootCommand(a app, stdout io.Writer, stderr io.Writer) *cobra.Command { 10 11 cmd := &cobra.Command{ 11 - Use: "appherder", 12 - Short: "Manage AppImages with desktop integration", 12 + Use: "appherder", 13 + Short: "A herder for your AppImages", 14 + Long: "appherder installs AppImages so they show up in your application menu with their\n" + 15 + "real name and icon, instead of sitting in a folder doing nothing. Drop AppImages in\n" + 16 + "~/AppImages and appherder keeps everything in sync.", 13 17 SilenceUsage: true, 14 18 SilenceErrors: true, 15 19 } 16 20 cmd.SetOut(stdout) 17 21 cmd.SetErr(stderr) 18 - cmd.AddCommand(newInstallCommand(a), newUninstallCommand(a), newSyncCommand(a), newMigrateCommand(a), newUpgradeCommand(a), newListCommand(a)) 19 - return cmd 20 - } 21 - 22 - func newUpgradeCommand(a app) *cobra.Command { 23 - var check bool 24 - cmd := &cobra.Command{ 25 - Use: "upgrade", 26 - Short: "Download and install updates for your AppImages", 27 - Args: cobra.NoArgs, 28 - RunE: func(cmd *cobra.Command, args []string) error { 29 - return a.upgrade(cmd.Context(), cmd.OutOrStdout(), check) 30 - }, 31 - } 32 - cmd.Flags().BoolVar(&check, "check", false, "Report available updates without installing them") 22 + cmd.AddCommand(newInstallCommand(a), newUninstallCommand(a), newListCommand(a), newSyncCommand(a), newMigrateCommand(a), newUpgradeCommand(a)) 33 23 return cmd 34 24 } 35 25 ··· 37 27 return &cobra.Command{ 38 28 Use: "install APPIMAGE", 39 29 Short: "Install an AppImage", 30 + Long: "Copies an AppImage into ~/AppImages and creates a launcher for it with the app's\nreal name and icon. You can delete the original download afterward.", 40 31 Args: cobra.ExactArgs(1), 41 32 RunE: func(cmd *cobra.Command, args []string) error { 42 - return a.install(args[0]) 33 + name, err := a.install(args[0]) 34 + if err != nil { 35 + return err 36 + } 37 + fmt.Fprintf(cmd.OutOrStdout(), "installed %s\n", name) 38 + return nil 43 39 }, 44 40 } 45 41 } ··· 49 45 cmd := &cobra.Command{ 50 46 Use: "uninstall APP|APPIMAGE", 51 47 Short: "Uninstall an AppImage", 48 + Long: "Removes an AppImage and its launcher and icon. Give the name as it appears in\n~/AppImages (without .appimage), or the full path.", 52 49 Args: cobra.ExactArgs(1), 53 50 RunE: func(cmd *cobra.Command, args []string) error { 54 - return a.uninstall(args[0], force) 51 + if err := a.uninstall(args[0], force); err != nil { 52 + return err 53 + } 54 + fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", normalizeAppName(args[0])) 55 + return nil 55 56 }, 56 57 } 57 58 cmd.Flags().BoolVarP(&force, "force", "f", false, ··· 59 60 return cmd 60 61 } 61 62 63 + func newListCommand(a app) *cobra.Command { 64 + return &cobra.Command{ 65 + Use: "list", 66 + Short: "Show managed AppImages", 67 + Long: "Shows every app appherder is managing: its display name, version, file size,\nand where it checks for updates.", 68 + Args: cobra.NoArgs, 69 + RunE: func(cmd *cobra.Command, args []string) error { 70 + return a.list(cmd.OutOrStdout()) 71 + }, 72 + } 73 + } 74 + 62 75 func newSyncCommand(a app) *cobra.Command { 63 76 var force bool 64 77 cmd := &cobra.Command{ 65 78 Use: "sync", 66 - Short: "Reconcile ~/AppImages with installed applications", 67 - Args: cobra.NoArgs, 79 + Short: "Match launchers to what's in ~/AppImages", 80 + Long: "Installs any AppImages in ~/AppImages that don't have a launcher yet, and removes\n" + 81 + "launchers whose AppImage is gone. Run this after adding or removing files from\n" + 82 + "~/AppImages.", 83 + Args: cobra.NoArgs, 68 84 RunE: func(cmd *cobra.Command, args []string) error { 69 85 return a.sync(cmd.Context(), cmd.OutOrStdout(), force) 70 86 }, ··· 77 93 func newMigrateCommand(a app) *cobra.Command { 78 94 return &cobra.Command{ 79 95 Use: "migrate", 80 - Short: "Adopt launchers from another tool and remove their broken orphans", 81 - Args: cobra.NoArgs, 96 + Short: "Adopt apps from another tool", 97 + Long: "Like sync --force: adopts launchers another tool created and removes the ones\n" + 98 + "whose AppImage is missing. Safe to run anytime.", 99 + Args: cobra.NoArgs, 82 100 RunE: func(cmd *cobra.Command, args []string) error { 83 101 return a.sync(cmd.Context(), cmd.OutOrStdout(), true) 84 102 }, 85 103 } 86 104 } 87 105 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, 106 + func newUpgradeCommand(a app) *cobra.Command { 107 + var check bool 108 + cmd := &cobra.Command{ 109 + Use: "upgrade", 110 + Short: "Download and install updates", 111 + Long: "Checks for and installs updates. appherder reads the update info baked into each\n" + 112 + "AppImage and fetches the latest from GitHub, GitLab, zsync, or a static URL.\n" + 113 + "Apps with no update info or already current are skipped silently.", 114 + Args: cobra.NoArgs, 93 115 RunE: func(cmd *cobra.Command, args []string) error { 94 - return a.list(cmd.OutOrStdout()) 116 + return a.upgrade(cmd.Context(), cmd.OutOrStdout(), check) 95 117 }, 96 118 } 119 + cmd.Flags().BoolVar(&check, "check", false, "Report available updates without installing them") 120 + return cmd 97 121 }
+10 -10
cmd/appherder/install.go
··· 7 7 "strings" 8 8 ) 9 9 10 - func (a app) install(appimage string) (err error) { 10 + func (a app) install(appimage string) (appName string, err error) { 11 11 appimage, err = filepath.Abs(appimage) 12 12 if err != nil { 13 - return fmt.Errorf("resolve AppImage path %q: %w", appimage, err) 13 + return "", fmt.Errorf("resolve AppImage path %q: %w", appimage, err) 14 14 } 15 15 16 16 fsys, closeAppImage, err := openAppImage(appimage) 17 17 if err != nil { 18 - return err 18 + return "", err 19 19 } 20 20 defer closeAppImage() 21 21 ··· 28 28 29 29 desktop, desktopName, err := findDesktopFile(fsys) 30 30 if err != nil { 31 - return fmt.Errorf("find desktop file: %w", err) 31 + return "", fmt.Errorf("find desktop file: %w", err) 32 32 } 33 33 34 34 icon := resolveIcon(fsys) 35 - appName := deriveAppName(desktop, desktopName, appimage) 35 + appName = deriveAppName(desktop, desktopName, appimage) 36 36 37 37 // Patch in memory before any filesystem writes so a failure here installs nothing. 38 38 if desktop != nil { 39 39 if err := a.patchDesktopFile(desktop, appName, icon != ""); err != nil { 40 - return err 40 + return "", err 41 41 } 42 42 } 43 43 ··· 53 53 dest, err := a.installIcon(fsys, icon, appName) 54 54 if err != nil { 55 55 rollback() 56 - return err 56 + return "", err 57 57 } 58 58 installed = append(installed, dest) 59 59 } ··· 62 62 dest, err := a.installDesktopFile(desktop, appName) 63 63 if err != nil { 64 64 rollback() 65 - return err 65 + return "", err 66 66 } 67 67 installed = append(installed, dest) 68 68 } ··· 72 72 // file. 73 73 if _, err := a.installAppImage(appimage, appName); err != nil { 74 74 rollback() 75 - return err 75 + return "", err 76 76 } 77 77 78 - return nil 78 + return appName, nil 79 79 } 80 80 81 81 // deriveAppName picks the canonical install name, matching GearLever so the
+24 -10
cmd/appherder/sync.go
··· 15 15 const installConcurrency = 4 16 16 17 17 type syncResult struct { 18 - file string 19 - err error 18 + file string 19 + appName string 20 + err error 20 21 } 21 22 22 23 // sync reconciles ~/AppImages with installed state: install every AppImage in ··· 31 32 } 32 33 appimagesDir := filepath.Join(home, "AppImages") 33 34 35 + // Snapshot managed apps before installing, so we can report only newly 36 + // installed ones instead of every already-present app. 37 + existing, err := managedApps(home) 38 + if err != nil { 39 + return err 40 + } 41 + managed := make(map[string]bool, len(existing)) 42 + for _, appid := range existing { 43 + managed[appid] = true 44 + } 45 + 34 46 files, err := listAppImages(appimagesDir) 35 47 if err != nil { 36 48 return err 37 49 } 38 50 39 - // parallelMap preserves input order, so skip output stays deterministic. 51 + // parallelMap preserves input order, so output stays deterministic. 40 52 results := parallelMap(ctx, files, installConcurrency, func(_ context.Context, f string) syncResult { 41 - return syncResult{file: f, err: a.install(f)} 53 + name, err := a.install(f) 54 + return syncResult{file: f, appName: name, err: err} 42 55 }) 43 56 for _, result := range results { 44 57 if result.err != nil { 45 - fmt.Fprintf(out, "skip %s: %v\n", filepath.Base(result.file), result.err) 58 + fmt.Fprintf(out, "skipped %s: %v\n", filepath.Base(result.file), result.err) 59 + continue 60 + } 61 + if !managed[result.appName] { 62 + fmt.Fprintf(out, "installed %s\n", result.appName) 46 63 } 47 64 } 48 65 49 - candidates, err := managedApps(home) 50 - if err != nil { 51 - return err 52 - } 66 + candidates := existing 53 67 if force { 54 68 extra, err := appImageBackedOrphans(home, appimagesDir) 55 69 if err != nil { ··· 66 80 continue 67 81 } 68 82 if err := a.uninstall(appid, force); err != nil { 69 - fmt.Fprintf(out, "skip remove %s: %v\n", appid, err) 83 + fmt.Fprintf(out, "skipped removing %s: %v\n", appid, err) 70 84 continue 71 85 } 72 86 fmt.Fprintf(out, "removed %s\n", appid)
+1 -1
cmd/appherder/sync_test.go
··· 65 65 if _, err := os.Stat(filepath.Join(home, ".local", "share", "applications", "present.desktop")); err != nil { 66 66 t.Fatalf("launcher for a present (if unparseable) AppImage should be kept: %v", err) 67 67 } 68 - if !bytes.Contains(out.Bytes(), []byte("skip present.appimage:")) { 68 + if !bytes.Contains(out.Bytes(), []byte("skipped present.appimage:")) { 69 69 t.Fatalf("sync output = %q, want a skip report for the bad file", out.String()) 70 70 } 71 71 }
+4 -3
cmd/appherder/upgrade.go
··· 47 47 for _, check := range checks { 48 48 switch { 49 49 case check.err != nil: 50 - fmt.Fprintf(out, "skip %s: %v\n", check.name, check.err) 50 + fmt.Fprintf(out, "skipped %s: %v\n", check.name, check.err) 51 51 continue 52 52 case check.noSource || !check.available: 53 53 continue ··· 60 60 } 61 61 62 62 if err := a.applyUpgrade(ctx, check.release); err != nil { 63 - fmt.Fprintf(out, "skip %s: %v\n", check.name, err) 63 + fmt.Fprintf(out, "skipped %s: %v\n", check.name, err) 64 64 continue 65 65 } 66 66 fmt.Fprintf(out, "upgraded %s to %s\n", check.name, check.release.version) ··· 138 138 return err 139 139 } 140 140 141 - return a.install(tmpName) 141 + _, err = a.install(tmpName) 142 + return err 142 143 } 143 144 144 145 func download(ctx context.Context, url string, writer io.Writer) error {