//go:build !windows package tui import ( "errors" "fmt" "os" "path/filepath" "strconv" "strings" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/pkg/constants" ) const ( managedPATHStart = "# >>> mpv-manager PATH >>>" managedPATHEnd = "# <<< mpv-manager PATH <<<" ) func currentManagerPATHInfo() (managerPATHInfo, error) { homeDir, err := os.UserHomeDir() if err != nil { return managerPATHInfo{}, fmt.Errorf("resolve home directory: %w", err) } binDir := filepath.Join(homeDir, ".local", "bin") shellName := filepath.Base(os.Getenv("SHELL")) return managerPATHInfo{ BinDir: binDir, BinaryPath: filepath.Join(binDir, constants.AppName), AliasPath: filepath.Join(binDir, "mpv-install"), ShellConfig: activeShellConfig(homeDir, shellName), ShellName: shellName, }, nil } func activeShellConfig(homeDir, shellName string) string { switch shellName { case "bash": bashrc := filepath.Join(homeDir, ".bashrc") if _, err := os.Stat(bashrc); err == nil { return bashrc } return filepath.Join(homeDir, ".bash_profile") case "zsh": return filepath.Join(homeDir, ".zshrc") case "fish": return filepath.Join(homeDir, ".config", "fish", "config.fish") case "nu": return filepath.Join(homeDir, ".config", "nu", "config.nu") default: return "" } } func prepareManagerPATHInstall(info managerPATHInfo, source string, emit func(string)) (reversiblePATHMutation, error) { if info.ShellConfig == "" { return reversiblePATHMutation{}, fmt.Errorf("unsupported or unavailable login shell %q; cannot register %s in PATH", info.ShellName, info.BinDir) } sourceInfo, err := os.Stat(source) if err != nil { return reversiblePATHMutation{}, fmt.Errorf("inspect source executable: %w", err) } if !sourceInfo.Mode().IsRegular() { return reversiblePATHMutation{}, fmt.Errorf("source executable is not a regular file: %s", source) } binaryData, err := os.ReadFile(source) if err != nil { return reversiblePATHMutation{}, fmt.Errorf("read source executable: %w", err) } snapshots, err := capturePaths(info.BinaryPath, info.AliasPath, info.ShellConfig) if err != nil { return reversiblePATHMutation{}, err } fail := func(operationErr error) (reversiblePATHMutation, error) { return reversiblePATHMutation{}, errors.Join(operationErr, wrapRollbackError(rollbackPaths(snapshots...))) } if err := fileops.AtomicWrite(info.BinaryPath, binaryData, 0o755); err != nil { return fail(fmt.Errorf("install command binary: %w", err)) } if err := os.Chmod(info.BinaryPath, 0o755); err != nil { return fail(fmt.Errorf("make command binary executable: %w", err)) } if err := os.Remove(info.AliasPath); err != nil && !os.IsNotExist(err) { return fail(fmt.Errorf("replace command alias: %w", err)) } if err := os.Symlink(info.BinaryPath, info.AliasPath); err != nil { return fail(fmt.Errorf("create mpv-install command alias: %w", err)) } if err := writeManagedShellPATH(info); err != nil { return fail(err) } emit(fmt.Sprintf("Installed executable: %s", info.BinaryPath)) emit(fmt.Sprintf("Installed command alias: %s", info.AliasPath)) emit(fmt.Sprintf("Updated %s PATH block: %s", info.ShellName, info.ShellConfig)) return reversiblePATHMutation{info: info, rollback: func() error { return rollbackPaths(snapshots...) }}, nil } func prepareManagerPATHRemoval(info managerPATHInfo, emit func(string)) (reversiblePATHMutation, error) { configs, err := shellConfigsForRemoval() if err != nil { return reversiblePATHMutation{}, err } paths := []string{info.BinaryPath, info.AliasPath} paths = append(paths, configs...) snapshots, err := capturePaths(paths...) if err != nil { return reversiblePATHMutation{}, err } fail := func(operationErr error) (reversiblePATHMutation, error) { return reversiblePATHMutation{}, errors.Join(operationErr, wrapRollbackError(rollbackPaths(snapshots...))) } for _, path := range []string{info.BinaryPath, info.AliasPath} { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fail(fmt.Errorf("remove %s: %w", path, err)) } } for _, configPath := range configs { if err := removeManagedShellPATH(configPath, info.BinaryPath); err != nil { return fail(err) } } emit(fmt.Sprintf("Removed executable: %s", info.BinaryPath)) emit(fmt.Sprintf("Removed command alias: %s", info.AliasPath)) if len(configs) > 0 { emit("Removed managed PATH blocks from shell configuration") } return reversiblePATHMutation{info: info, rollback: func() error { return rollbackPaths(snapshots...) }}, nil } func managerPATHIsRegistered(info managerPATHInfo) bool { binaryInfo, err := os.Lstat(info.BinaryPath) return err == nil && binaryInfo.Mode().IsRegular() } func writeManagedShellPATH(info managerPATHInfo) error { if err := os.MkdirAll(filepath.Dir(info.ShellConfig), 0o755); err != nil { return fmt.Errorf("create shell configuration directory: %w", err) } content, err := os.ReadFile(info.ShellConfig) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("read shell configuration: %w", err) } newline := detectTextNewline(content) clean := removeManagedPATHText(string(content), info.BinaryPath) clean = strings.TrimRight(clean, "\r\n") if clean != "" { clean += newline } clean += managedPATHBlock(info, newline) + newline _, statErr := os.Lstat(info.ShellConfig) if statErr == nil { err = fileops.AtomicWritePreserve(info.ShellConfig, []byte(clean), 0o644) } else if os.IsNotExist(statErr) { err = fileops.AtomicWrite(info.ShellConfig, []byte(clean), 0o644) } else { err = statErr } if err != nil { return fmt.Errorf("write shell PATH registration: %w", err) } return nil } func removeManagedShellPATH(path, binaryPath string) error { content, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("read shell configuration %s: %w", path, err) } clean := removeManagedPATHText(string(content), binaryPath) if clean == string(content) { return nil } if err := fileops.AtomicWritePreserve(path, []byte(clean), 0o644); err != nil { return fmt.Errorf("remove managed PATH registration from %s: %w", path, err) } return nil } func managedPATHBlock(info managerPATHInfo, newline string) string { var registration string switch info.ShellName { case "fish": registration = "fish_add_path -- " + quotePOSIXShell(info.BinDir) case "nu": registration = "$env.PATH = ($env.PATH | prepend " + strconv.Quote(info.BinDir) + ")" default: registration = "export PATH=" + quotePOSIXShell(info.BinDir) + ":\"$PATH\"" } return strings.Join([]string{managedPATHStart, registration, managedPATHEnd}, newline) } func removeManagedPATHText(content, binaryPath string) string { for { start := strings.Index(content, managedPATHStart) if start < 0 { break } endRelative := strings.Index(content[start+len(managedPATHStart):], managedPATHEnd) if endRelative < 0 { break } end := start + len(managedPATHStart) + endRelative + len(managedPATHEnd) if end < len(content) && content[end] == '\r' { end++ } if end < len(content) && content[end] == '\n' { end++ } content = content[:start] + content[end:] } newline := detectTextNewline([]byte(content)) lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") kept := lines[:0] legacySingle := "alias mpv-install='" + binaryPath + "'" legacyDouble := `alias mpv-install="` + binaryPath + `"` for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == legacySingle || trimmed == legacyDouble { continue } kept = append(kept, line) } return strings.Join(kept, newline) } func shellConfigsForRemoval() ([]string, error) { homeDir, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("resolve home directory: %w", err) } candidates := []string{ filepath.Join(homeDir, ".bashrc"), filepath.Join(homeDir, ".bash_profile"), filepath.Join(homeDir, ".zshrc"), filepath.Join(homeDir, ".config", "fish", "config.fish"), filepath.Join(homeDir, ".config", "nu", "config.nu"), } configs := make([]string, 0, len(candidates)) for _, candidate := range candidates { if info, statErr := os.Lstat(candidate); statErr == nil && info.Mode().IsRegular() { configs = append(configs, candidate) } else if statErr != nil && !os.IsNotExist(statErr) { return nil, fmt.Errorf("inspect shell configuration %s: %w", candidate, statErr) } } return configs, nil } func detectTextNewline(content []byte) string { if strings.Contains(string(content), "\r\n") { return "\r\n" } return "\n" } func quotePOSIXShell(value string) string { return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" }