-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.go
More file actions
179 lines (164 loc) · 5.3 KB
/
Copy pathinstall.go
File metadata and controls
179 lines (164 loc) · 5.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
package main
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
const launchLabel = "dev.mmux"
// launchd hands a service a bare PATH, and MCP servers are usually launched through
// `npx`, which commonly lives in a version-manager shim directory. Baking the PATH of
// the shell that ran `install` into the plist is what keeps the backends findable —
// without it mmux starts fine and every backend dies with "executable file not found".
func buildPlist(binary, cfgPath, pathEnv, home, logPath string) []byte {
var b bytes.Buffer
esc := func(s string) string {
var e bytes.Buffer
_ = xml.EscapeText(&e, []byte(s))
return e.String()
}
fmt.Fprint(&b, xml.Header)
fmt.Fprint(&b, "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n")
fmt.Fprint(&b, "<plist version=\"1.0\">\n<dict>\n")
fmt.Fprintf(&b, " <key>Label</key><string>%s</string>\n", esc(launchLabel))
fmt.Fprint(&b, " <key>ProgramArguments</key>\n <array>\n")
for _, a := range []string{binary, "-config", cfgPath} {
fmt.Fprintf(&b, " <string>%s</string>\n", esc(a))
}
fmt.Fprint(&b, " </array>\n")
fmt.Fprint(&b, " <key>RunAtLoad</key><true/>\n")
fmt.Fprint(&b, " <key>KeepAlive</key><true/>\n")
fmt.Fprint(&b, " <key>ProcessType</key><string>Background</string>\n")
fmt.Fprint(&b, " <key>EnvironmentVariables</key>\n <dict>\n")
fmt.Fprintf(&b, " <key>PATH</key><string>%s</string>\n", esc(pathEnv))
fmt.Fprintf(&b, " <key>HOME</key><string>%s</string>\n", esc(home))
fmt.Fprint(&b, " </dict>\n")
fmt.Fprintf(&b, " <key>StandardOutPath</key><string>%s</string>\n", esc(logPath))
fmt.Fprintf(&b, " <key>StandardErrorPath</key><string>%s</string>\n", esc(logPath))
fmt.Fprint(&b, "</dict>\n</plist>\n")
return b.Bytes()
}
func plistDest(home string) string {
return filepath.Join(home, "Library", "LaunchAgents", launchLabel+".plist")
}
func requireDarwin(action string) error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("%s manages a launchd agent and only works on macOS (this is %s); "+
"on Linux write a systemd --user unit that runs %q", action, runtime.GOOS, "mmux -config <path>")
}
return nil
}
func runInstall(cfgPath string) error {
if err := requireDarwin("install"); err != nil {
return err
}
// Fail before touching launchd if the config is missing or unparseable — a service
// that crash-loops on a bad config is worse than a refused install.
cfg, err := loadConfig(cfgPath)
if err != nil {
return fmt.Errorf("config: %w", err)
}
absCfg, err := filepath.Abs(cfgPath)
if err != nil {
return err
}
exe, err := os.Executable()
if err != nil {
return err
}
// Resolve symlinks so the agent keeps working if a PATH symlink is later removed.
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
logPath := filepath.Join(home, "Library", "Logs", "mmux.log")
dest := plistDest(home)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(dest, buildPlist(exe, absCfg, os.Getenv("PATH"), home, logPath), 0o644); err != nil {
return err
}
// Idempotent: a stale agent must go before the new plist can take effect.
_ = exec.Command("launchctl", "unload", dest).Run()
if out, err := exec.Command("launchctl", "load", dest).CombinedOutput(); err != nil {
return fmt.Errorf("launchctl load: %w: %s", err, bytes.TrimSpace(out))
}
fmt.Println("installed:", dest)
fmt.Println("binary: ", exe)
fmt.Println("config: ", absCfg)
fmt.Println("log: ", logPath)
return waitHealthy(cfg)
}
func runUninstall() error {
if err := requireDarwin("uninstall"); err != nil {
return err
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
dest := plistDest(home)
if _, err := os.Stat(dest); os.IsNotExist(err) {
fmt.Println("not installed:", dest)
return nil
}
if out, err := exec.Command("launchctl", "unload", dest).CombinedOutput(); err != nil {
return fmt.Errorf("launchctl unload: %w: %s", err, bytes.TrimSpace(out))
}
if err := os.Remove(dest); err != nil {
return err
}
fmt.Println("uninstalled:", dest)
return nil
}
// waitHealthy polls /status until every backend reports up, so `install` fails loudly
// rather than leaving a half-working agent behind.
func waitHealthy(cfg *config) error {
url := "http://" + cfg.Listen + "/status"
deadline := time.Now().Add(60 * time.Second)
client := &http.Client{Timeout: 3 * time.Second}
for time.Now().Before(deadline) {
time.Sleep(time.Second)
res, err := client.Get(url)
if err != nil {
continue
}
var st struct {
Servers map[string]struct {
Up bool `json:"up"`
PID int `json:"pid"`
} `json:"servers"`
}
err = json.NewDecoder(res.Body).Decode(&st)
res.Body.Close()
if err != nil || len(st.Servers) == 0 {
continue
}
allUp := true
for _, s := range st.Servers {
if !s.Up {
allUp = false
}
}
if allUp {
for name, s := range st.Servers {
fmt.Printf(" %-16s up pid=%d http://%s/%s/mcp\n", name, s.PID, cfg.Listen, name)
}
return nil
}
}
return fmt.Errorf("agent loaded but backends did not come up within 60s; check the log")
}