package update
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"github.com/yetone/magpie/internal/proc"
)
// The `magpie` command in a terminal (PAMI on Discord: a way in the app to
// put it there, and to see whether it is). install.sh links it, Homebrew
// links it, but the app downloaded from the site — and every Windows one —
// leaves a terminal without it. Settings' Command line says, for each shell
// a terminal may open, which magpie its `magpie` runs, and puts this app's
// there when the user asks: a link in a folder already on PATH (the one
// whose magpie runs first, when that one is another copy, or ~/.local/bin
// and the like), on Windows the app's folder put first in the user's PATH.
// A shell profile is written only for the shell the user picks, and only
// with one line that puts ~/.local/bin on its PATH.
//
// Another magpie's version is read from its .app's Info.plist or its
// Homebrew Cellar folder, never by running it: `magpie version` migrates
// the settings first (see StaleCLI).
// CLIShell is one shell's `magpie`.
type CLIShell struct {
Name string `json:"name"` // zsh, bash, fish, PowerShell, cmd
Default bool `json:"default,omitempty"` // the one a terminal opens
Known bool `json:"known"` // its PATH could be read
Command string `json:"command,omitempty"` // the magpie it runs, "" none
Ours bool `json:"ours"` // and that is this app
Profile string `json:"profile,omitempty"` // the file a pick of it writes, ~ for the home; "" none
HasDir bool `json:"hasDir,omitempty"` // its PATH has CLI.Dir, so Add reaches it without its profile
}
// CLI is what `magpie` runs in a terminal opened now.
type CLI struct {
Exe string `json:"exe"` // this app's program, which it should run
Command string `json:"command,omitempty"` // the default shell's
Ours bool `json:"ours"`
Version string `json:"version,omitempty"` // the other one's, when it can be read without running it
Dir string `json:"dir,omitempty"` // where Add puts it; "" when no folder on PATH can take it
Stuck string `json:"stuck,omitempty"` // translocated, read-only: why it can't be linked from where it is
Shells []CLIShell `json:"shells"`
Windows bool `json:"windows,omitempty"`
Shim string `json:"shim,omitempty"` // Windows: the magpie.cmd Add writes beside an app not named magpie.exe, which runs it
}
// cliShell is a shell installed here.
type cliShell struct {
Name, Path string
Default bool
}
// What ReadCLI asks the system; tests set them.
var (
cliShellPath = proc.ShellPath
cliShells = installedShells
cliExe = cliProgram
)
var cliMu sync.Mutex
// ReadCLI says what `magpie` runs in each shell. It asks each shell for its
// PATH, a few seconds at most.
func ReadCLI() *CLI {
v := &CLI{Windows: runtime.GOOS == "windows", Shells: []CLIShell{}}
exe, err := cliExe()
if err != nil {
return v
}
v.Exe, v.Stuck = exe, cliStuck()
if v.Windows && needsShim(exe) {
v.Shim = filepath.Join(filepath.Dir(exe), shimName)
}
shells := cliShells()
paths := make([][]string, len(shells))
var wg sync.WaitGroup
for i, sh := range shells {
wg.Add(1)
go func() {
defer wg.Done()
paths[i] = cliShellPath(sh.Path)
}()
}
wg.Wait()
var def []string
for i, sh := range shells {
s := CLIShell{Name: sh.Name, Default: sh.Default, Known: paths[i] != nil, Command: findCLI(paths[i])}
s.Ours = s.Command != "" && (sameFile(s.Command, exe) || shimRuns(s.Command, exe))
if p := cliProfile(sh.Name); p != "" {
s.Profile = tildeHome(p)
}
if sh.Default {
v.Command, v.Ours, def = s.Command, s.Ours, paths[i]
}
v.Shells = append(v.Shells, s)
}
if v.Command != "" && !v.Ours {
v.Version = otherVersion(v.Command)
}
if !v.Ours {
v.Dir = cliDir(exe, def, v.Command)
for i := range v.Shells {
v.Shells[i].HasDir = v.Dir != "" && slices.Contains(paths[i], v.Dir)
}
}
return v
}
// AddCLI puts this app's magpie on the PATH: of every shell whose PATH has
// the folder ReadCLI names (shell ""), or of the one shell named, by
// linking it in ~/.local/bin and adding that folder to the shell's profile.
func AddCLI(shell string) (*CLI, error) {
cliMu.Lock()
defer cliMu.Unlock()
exe, err := cliExe()
if err != nil {
return nil, err
}
switch cliStuck() {
case "translocated":
return nil, errors.New("macOS runs magpie from a temporary copy until it is moved to Applications: move it there, open it again, then add the command")
case "read-only":
return nil, errors.New("magpie is running from its disk image: copy it to Applications, open it from there, then add the command")
}
if shell == "" {
v := ReadCLI()
if v.Ours {
return v, nil
}
if v.Dir == "" {
return nil, errors.New("no folder on your PATH can take the magpie command: add ~/.local/bin to a shell's profile below")
}
if err := cliPlace(exe, v.Dir); err != nil {
return nil, err
}
v = ReadCLI()
if !v.Ours && v.Command != "" {
return v, fmt.Errorf("magpie is in %s now, but a terminal still runs %s first", v.Dir, v.Command)
}
return v, nil
}
if runtime.GOOS == "windows" {
return nil, errors.New("on Windows the command is added to the user's PATH, for every shell at once")
}
var sh *cliShell
for _, s := range cliShells() {
if s.Name == shell {
sh = &s
break
}
}
prof := cliProfile(shell)
if sh == nil || prof == "" {
return nil, fmt.Errorf("%s isn't a shell magpie can add the command to", shell)
}
dir := filepath.Join(os.Getenv("HOME"), ".local", "bin")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
if err := cliPlace(exe, dir); err != nil {
return nil, err
}
if err := addToProfile(prof, profileLine(shell)); err != nil {
return nil, err
}
return ReadCLI(), nil
}
// Program is this magpie by a path that stays put: what a link, or an
// agent's config that starts magpie, should name (cliProgram).
func Program() (string, error) { return cliProgram() }
// cliProgram is the program a link should point at: the running one, or
// what stays put when it is replaced — Homebrew's opt link for a Cellar
// build, the AppImage for one run from its mount.
func cliProgram() (string, error) {
if a := os.Getenv("APPIMAGE"); a != "" && runtime.GOOS == "linux" {
return a, nil
}
exe, err := Executable()
if err != nil {
return "", err
}
if i := strings.Index(filepath.ToSlash(exe), "/Cellar/magpie/"); i > 0 {
opt := filepath.Join(exe[:i], "opt", "magpie", "bin", "magpie")
if sameFile(opt, exe) {
return opt, nil
}
}
return exe, nil
}
// cliStuck is why the app can't be linked to from where it runs (Stuck).
var cliStuck = func() string {
if b := Bundle(); b != "" {
return Stuck(b)
}
return ""
}
func cliNames() []string {
if runtime.GOOS == "windows" {
return []string{"magpie.exe", "magpie.cmd", "magpie.bat"}
}
return []string{"magpie"}
}
// findCLI is the magpie a shell with PATH dirs runs, "" none.
func findCLI(dirs []string) string {
for _, d := range dirs {
if d == "" || !filepath.IsAbs(d) {
continue
}
for _, n := range cliNames() {
p := filepath.Join(d, n)
if st, err := os.Stat(p); err == nil && !st.IsDir() && (runtime.GOOS == "windows" || st.Mode()&0o111 != 0) {
return p
}
}
}
return ""
}
func sameFile(a, b string) bool {
sa, err := os.Stat(a)
if err != nil {
return false
}
sb, err := os.Stat(b)
return err == nil && os.SameFile(sa, sb)
}
var (
plistVersion = regexp.MustCompile(`CFBundleShortVersionString\s*([^<]+)`)
cellarVer = regexp.MustCompile(`/Cellar/magpie/([^/]+)/`)
)
// otherVersion is the version of the magpie at cmd, read from its .app's
// Info.plist or its Cellar folder; "" when neither says.
func otherVersion(cmd string) string {
p, err := filepath.EvalSymlinks(cmd)
if err != nil {
return ""
}
if m := cellarVer.FindStringSubmatch(filepath.ToSlash(p)); m != nil {
return m[1]
}
if i := strings.Index(p, ".app/Contents/MacOS/"); i > 0 {
b, err := os.ReadFile(filepath.Join(p[:i+len(".app")], "Contents", "Info.plist"))
if m := plistVersion.FindSubmatch(b); err == nil && m != nil {
return strings.TrimSpace(string(m[1]))
}
}
return ""
}
// profileLine is what a shell's profile is given: ~/.local/bin first on its
// PATH.
func profileLine(shell string) string {
if shell == "fish" {
return "set -gx PATH $HOME/.local/bin $PATH"
}
return `export PATH="$HOME/.local/bin:$PATH"`
}
const profileMark = "# added by magpie (Settings › Command line)"
// addToProfile adds line to the profile at path, unless it has it.
func addToProfile(path, line string) error {
b, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
for _, l := range strings.Split(string(b), "\n") {
if strings.TrimSpace(l) == line {
return nil
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
if err != nil {
return err
}
add := "\n" + profileMark + "\n" + line + "\n"
if len(b) > 0 && b[len(b)-1] != '\n' {
add = "\n" + add
}
if _, err := f.WriteString(add); err != nil {
f.Close()
return err
}
return f.Close()
}
// cliProfile is the file a shell reads as a terminal opens it, which a pick
// of it writes; "" for a shell magpie doesn't write one for (Windows').
func cliProfile(shell string) string {
if runtime.GOOS == "windows" {
return ""
}
home := os.Getenv("HOME")
if home == "" {
return ""
}
switch shell {
case "zsh":
if z := os.Getenv("ZDOTDIR"); filepath.IsAbs(z) {
return filepath.Join(z, ".zshrc")
}
return filepath.Join(home, ".zshrc")
case "bash":
// Terminal on the Mac opens a login bash; a Linux terminal an
// interactive one
if runtime.GOOS == "darwin" {
return filepath.Join(home, ".bash_profile")
}
return filepath.Join(home, ".bashrc")
case "fish":
cfg := os.Getenv("XDG_CONFIG_HOME")
if !filepath.IsAbs(cfg) {
cfg = filepath.Join(home, ".config")
}
return filepath.Join(cfg, "fish", "config.fish")
}
return ""
}
func tildeHome(p string) string {
if h := os.Getenv("HOME"); h != "" && strings.HasPrefix(p, h+string(filepath.Separator)) {
return "~" + p[len(h):]
}
return p
}
// A Windows app not named magpie.exe — the site's download is
// magpie-windows-amd64.exe (#942) — is given a magpie.cmd beside it that
// runs it, since a terminal looks for `magpie` by that name only. It names
// the program by its folder (%~dp0) and file name, which an update keeps:
// it replaces the program where it is, under its name.
// shimName is the file a shim is written to.
const shimName = "magpie.cmd"
// shimText is the magpie.cmd that runs the program exe in its folder.
func shimText(exe string) string {
name := filepath.Base(strings.ReplaceAll(exe, `\`, "/"))
return "@echo off\r\nrem added by magpie (Settings > Command line): the magpie command runs " + name + " in this folder\r\n\"%~dp0" + name + "\" %*\r\n"
}
// needsShim says whether the program exe can't be run as `magpie` by its
// own name.
func needsShim(exe string) bool {
return !strings.EqualFold(filepath.Base(strings.ReplaceAll(exe, `\`, "/")), "magpie.exe")
}
// writeShim writes the magpie.cmd for exe beside it, when its name needs
// one; it is the file's path, "" when none was needed.
func writeShim(exe string) (string, error) {
if !needsShim(exe) {
return "", nil
}
p := filepath.Join(filepath.Dir(exe), shimName)
if b, err := os.ReadFile(p); err == nil && string(b) == shimText(exe) {
return p, nil
}
if err := os.WriteFile(p, []byte(shimText(exe)), 0o755); err != nil {
return "", fmt.Errorf("writing %s, which runs %s as magpie: %w", p, filepath.Base(exe), err)
}
return p, nil
}
// shimRuns says whether cmd is the magpie.cmd magpie wrote for exe.
func shimRuns(cmd, exe string) bool {
if !strings.EqualFold(filepath.Base(cmd), shimName) || !strings.EqualFold(filepath.Dir(cmd), filepath.Dir(exe)) {
return false
}
b, err := os.ReadFile(cmd)
return err == nil && string(b) == shimText(exe)
}
// pathFirst is the PATH value v (Windows', ;-separated) with dir first and
// nowhere else.
func pathFirst(v, dir string) string {
same := func(d string) bool {
return strings.EqualFold(strings.TrimRight(strings.TrimSpace(d), `\`), strings.TrimRight(dir, `\`))
}
out := []string{dir}
for _, d := range strings.Split(v, ";") {
if strings.TrimSpace(d) != "" && !same(d) {
out = append(out, d)
}
}
return strings.Join(out, ";")
}