Skip to content

Commit 6732169

Browse files
committed
gateway: install as a background service (launchd/systemd)
`memcode gateway install` writes an OS-native service unit that runs the gateway in the current project — a launchd LaunchAgent on macOS, a systemd --user unit on Linux — and prints the one command to start it. `gateway uninstall` removes it. It writes the unit but doesn't run launchctl/systemctl itself, since activating a system service is the operator's call. Unit generation is a pure, tested function.
1 parent ac617a7 commit 6732169

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

‎cmd/gateway_install.go‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/memcode-ai/memcode/internal/atomicfile"
12+
)
13+
14+
// gatewayInstallCmd installs the gateway as a managed background service so it
15+
// survives logout/reboot instead of living in a foreground terminal. It writes an
16+
// OS-native unit (launchd on macOS, systemd --user on Linux) that runs
17+
// `memcode gateway` in the current project, then prints how to start it.
18+
var gatewayInstallCmd = &cobra.Command{
19+
Use: "install",
20+
Short: "Install the gateway as a background service (launchd/systemd)",
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
bin, err := os.Executable()
23+
if err != nil {
24+
return fmt.Errorf("locating memcode binary: %w", err)
25+
}
26+
workDir, err := os.Getwd()
27+
if err != nil {
28+
return err
29+
}
30+
home, err := os.UserHomeDir()
31+
if err != nil {
32+
return err
33+
}
34+
path, content, start, err := gatewayUnit(runtime.GOOS, home, bin, workDir)
35+
if err != nil {
36+
return err
37+
}
38+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
39+
return err
40+
}
41+
if err := atomicfile.WriteFile(path, []byte(content), 0o644); err != nil {
42+
return err
43+
}
44+
cmd.Printf("Installed gateway service for %s\n", workDir)
45+
cmd.Printf("Unit: %s\n", path)
46+
cmd.Printf("Start it with:\n %s\n", start)
47+
return nil
48+
},
49+
}
50+
51+
// gatewayUninstallCmd removes the service unit. It doesn't stop a running service
52+
// (the user unloads it with the printed command); it just deletes the unit.
53+
var gatewayUninstallCmd = &cobra.Command{
54+
Use: "uninstall",
55+
Short: "Remove the installed gateway service unit",
56+
RunE: func(cmd *cobra.Command, args []string) error {
57+
home, err := os.UserHomeDir()
58+
if err != nil {
59+
return err
60+
}
61+
path, _, _, err := gatewayUnit(runtime.GOOS, home, "", "")
62+
if err != nil {
63+
return err
64+
}
65+
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
66+
return err
67+
}
68+
cmd.Printf("Removed %s\n", path)
69+
switch runtime.GOOS {
70+
case "darwin":
71+
cmd.Printf("Stop it (if running) with:\n launchctl unload %s\n", path)
72+
case "linux":
73+
cmd.Printf("Stop it (if running) with:\n systemctl --user disable --now memcode-gateway\n")
74+
}
75+
return nil
76+
},
77+
}
78+
79+
// gatewayUnit builds the service unit for goos: its file path, contents, and the
80+
// command to start it. bin/workDir may be empty when only the path is needed
81+
// (uninstall). Returns an error for an unsupported OS.
82+
func gatewayUnit(goos, home, bin, workDir string) (path, content, start string, err error) {
83+
switch goos {
84+
case "darwin":
85+
path = filepath.Join(home, "Library", "LaunchAgents", "ai.memcode.gateway.plist")
86+
content = fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
87+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
88+
<plist version="1.0">
89+
<dict>
90+
<key>Label</key><string>ai.memcode.gateway</string>
91+
<key>ProgramArguments</key>
92+
<array>
93+
<string>%s</string>
94+
<string>gateway</string>
95+
</array>
96+
<key>WorkingDirectory</key><string>%s</string>
97+
<key>RunAtLoad</key><true/>
98+
<key>KeepAlive</key><true/>
99+
<key>StandardOutPath</key><string>%s/.memcode/gateway.log</string>
100+
<key>StandardErrorPath</key><string>%s/.memcode/gateway.log</string>
101+
</dict>
102+
</plist>
103+
`, bin, workDir, workDir, workDir)
104+
start = "launchctl load " + path
105+
return path, content, start, nil
106+
case "linux":
107+
path = filepath.Join(home, ".config", "systemd", "user", "memcode-gateway.service")
108+
content = fmt.Sprintf(`[Unit]
109+
Description=memcode gateway
110+
After=network-online.target
111+
112+
[Service]
113+
ExecStart=%s gateway
114+
WorkingDirectory=%s
115+
Restart=always
116+
RestartSec=5
117+
118+
[Install]
119+
WantedBy=default.target
120+
`, bin, workDir)
121+
start = "systemctl --user daemon-reload && systemctl --user enable --now memcode-gateway"
122+
return path, content, start, nil
123+
default:
124+
return "", "", "", fmt.Errorf("gateway install is not supported on %s (run `memcode gateway` directly)", goos)
125+
}
126+
}
127+
128+
func init() {
129+
gatewayCmd.AddCommand(gatewayInstallCmd)
130+
gatewayCmd.AddCommand(gatewayUninstallCmd)
131+
}

‎cmd/gateway_install_test.go‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestGatewayUnitDarwin(t *testing.T) {
9+
path, content, start, err := gatewayUnit("darwin", "/Users/tim", "/usr/local/bin/memcode", "/work/proj")
10+
if err != nil {
11+
t.Fatal(err)
12+
}
13+
if !strings.HasSuffix(path, "Library/LaunchAgents/ai.memcode.gateway.plist") {
14+
t.Errorf("path = %q", path)
15+
}
16+
for _, want := range []string{"/usr/local/bin/memcode", "<string>gateway</string>", "/work/proj", "KeepAlive"} {
17+
if !strings.Contains(content, want) {
18+
t.Errorf("plist missing %q:\n%s", want, content)
19+
}
20+
}
21+
if !strings.Contains(start, "launchctl load") {
22+
t.Errorf("start cmd = %q", start)
23+
}
24+
}
25+
26+
func TestGatewayUnitLinux(t *testing.T) {
27+
path, content, start, err := gatewayUnit("linux", "/home/tim", "/usr/bin/memcode", "/work/proj")
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
if !strings.HasSuffix(path, ".config/systemd/user/memcode-gateway.service") {
32+
t.Errorf("path = %q", path)
33+
}
34+
for _, want := range []string{"ExecStart=/usr/bin/memcode gateway", "WorkingDirectory=/work/proj", "Restart=always"} {
35+
if !strings.Contains(content, want) {
36+
t.Errorf("unit missing %q:\n%s", want, content)
37+
}
38+
}
39+
if !strings.Contains(start, "systemctl --user") {
40+
t.Errorf("start cmd = %q", start)
41+
}
42+
}
43+
44+
func TestGatewayUnitUnsupported(t *testing.T) {
45+
if _, _, _, err := gatewayUnit("plan9", "/home/tim", "/bin/memcode", "/work"); err == nil {
46+
t.Error("an unsupported OS must return an error")
47+
}
48+
}

0 commit comments

Comments
 (0)