diff --git a/cmd/appherder/cli.go b/cmd/appherder/cli.go index 77ce808..c1f87a1 100644 --- a/cmd/appherder/cli.go +++ b/cmd/appherder/cli.go @@ -27,6 +27,8 @@ func newRootCommand(a appherder.App, stdout io.Writer, stderr io.Writer) *cobra. newInstallCommand(a), newUninstallCommand(a), newListCommand(a), + newLinkCommand(a), + newUnlinkCommand(a), newSyncCommand(a), newMigrateCommand(a), newUpgradeCommand(a), @@ -215,6 +217,39 @@ func newAutoCommand(use, short, long, offHelp string, enable, disable func() err return cmd } +func newLinkCommand(a appherder.App) *cobra.Command { + return &cobra.Command{ + Use: "link APP", + Short: "Add an installed AppImage to PATH", + Long: "Creates a symlink in ~/.local/bin so the AppImage can be invoked by its name\n" + + "from a terminal. The app must already be installed in ~/AppImages.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := a.Link(args[0]); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "linked %s\n", appherder.NormalizeAppName(args[0])) + return nil + }, + } +} + +func newUnlinkCommand(a appherder.App) *cobra.Command { + return &cobra.Command{ + Use: "unlink APP", + Short: "Remove an AppImage symlink from PATH", + Long: "Removes the ~/.local/bin symlink created by `appherder link`.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := a.Unlink(args[0]); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "unlinked %s\n", appherder.NormalizeAppName(args[0])) + return nil + }, + } +} + func newAutosyncCommand() *cobra.Command { return newAutoCommand( "autosync", diff --git a/internal/appherder/link.go b/internal/appherder/link.go new file mode 100644 index 0000000..04c34b5 --- /dev/null +++ b/internal/appherder/link.go @@ -0,0 +1,48 @@ +package appherder + +import ( + "fmt" + "os" + "path/filepath" +) + +func (a App) localBinDir() string { + return filepath.Join(filepath.Dir(filepath.Dir(a.applicationsDir)), "bin") +} + +func (a App) linkPath(appName string) string { + return filepath.Join(a.localBinDir(), appName) +} + +func (a App) appImagePath(appName string) string { + return filepath.Join(a.appimagesDir, appName+".appimage") +} + +func (a App) Link(appName string) error { + appName = NormalizeAppName(appName) + src := a.appImagePath(appName) + if _, err := os.Stat(src); err != nil { + return fmt.Errorf("%s is not installed", appName) + } + binDir := a.localBinDir() + if err := os.MkdirAll(binDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", binDir, err) + } + dst := a.linkPath(appName) + if err := os.Remove(dst); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove existing link %s: %w", dst, err) + } + if err := os.Symlink(src, dst); err != nil { + return fmt.Errorf("link %s -> %s: %w", dst, src, err) + } + return nil +} + +func (a App) Unlink(appName string) error { + appName = NormalizeAppName(appName) + dst := a.linkPath(appName) + if err := os.Remove(dst); err != nil { + return fmt.Errorf("remove link %s: %w", dst, err) + } + return nil +} diff --git a/internal/appherder/link_test.go b/internal/appherder/link_test.go new file mode 100644 index 0000000..3729be9 --- /dev/null +++ b/internal/appherder/link_test.go @@ -0,0 +1,114 @@ +package appherder + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLink(t *testing.T) { + app, home := newTestApp(t) + mustWrite(t, filepath.Join(home, "AppImages", "foo.appimage"), []byte("fake appimage")) + + if err := app.Link("foo"); err != nil { + t.Fatalf("Link: %v", err) + } + + link := filepath.Join(home, ".local", "bin", "foo") + target, err := os.Readlink(link) + if err != nil { + t.Fatalf("readlink: %v", err) + } + want := filepath.Join(home, "AppImages", "foo.appimage") + if target != want { + t.Errorf("link target = %q, want %q", target, want) + } +} + +func TestLinkUninstalled(t *testing.T) { + app, _ := newTestApp(t) + err := app.Link("nope") + if err == nil { + t.Fatal("expected error linking uninstalled app") + } +} + +func TestLinkNormalizesName(t *testing.T) { + app, home := newTestApp(t) + mustWrite(t, filepath.Join(home, "AppImages", "foo.appimage"), []byte("x")) + + if err := app.Link("foo.appimage"); err != nil { + t.Fatalf("Link: %v", err) + } + + link := filepath.Join(home, ".local", "bin", "foo") + target, err := os.Readlink(link) + if err != nil { + t.Fatalf("readlink: %v", err) + } + if target != filepath.Join(home, "AppImages", "foo.appimage") { + t.Errorf("unexpected target: %s", target) + } +} + +func TestUnlink(t *testing.T) { + app, home := newTestApp(t) + mustWrite(t, filepath.Join(home, "AppImages", "foo.appimage"), []byte("x")) + binDir := filepath.Join(home, ".local", "bin") + mustMkdir(t, binDir) + mustSymlink(t, filepath.Join(home, "AppImages", "foo.appimage"), filepath.Join(binDir, "foo")) + + if err := app.Unlink("foo"); err != nil { + t.Fatalf("Unlink: %v", err) + } + if _, err := os.Readlink(filepath.Join(binDir, "foo")); !os.IsNotExist(err) { + t.Fatal("link still exists after unlink") + } +} + +func TestUnlinkNotLinked(t *testing.T) { + app, _ := newTestApp(t) + err := app.Unlink("nope") + if err == nil { + t.Fatal("expected error unlinking nonexistent link") + } +} + +func TestUnlinkNormalizesName(t *testing.T) { + app, home := newTestApp(t) + mustWrite(t, filepath.Join(home, "AppImages", "foo.appimage"), []byte("x")) + binDir := filepath.Join(home, ".local", "bin") + mustMkdir(t, binDir) + mustSymlink(t, filepath.Join(home, "AppImages", "foo.appimage"), filepath.Join(binDir, "foo")) + + if err := app.Unlink("foo.appimage"); err != nil { + t.Fatalf("Unlink: %v", err) + } + if _, err := os.Readlink(filepath.Join(binDir, "foo")); !os.IsNotExist(err) { + t.Fatal("link still exists after unlink") + } +} + +func mustWrite(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} + +func mustMkdir(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } +} + +func mustSymlink(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } +}