Skip to content

Commit b9d7625

Browse files
authored
Merge pull request #109 from shelltime/claude/issue-108-20250929-0557
feat(daemon): move macOS daemon install from root to user permissions
2 parents b104b40 + e95b6ee commit b9d7625

7 files changed

Lines changed: 89 additions & 67 deletions

File tree

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ curl -sSL https://shelltime.xyz/i | bash
3131

3232
3. **Optional: Enable daemon mode** (recommended for optimal performance):
3333
```bash
34-
sudo shelltime daemon install
34+
shelltime daemon install
3535
```
3636

3737
## Configuration
@@ -274,20 +274,20 @@ shelltime daemon <subcommand>
274274
```
275275

276276
**Subcommands:**
277-
- `install`: Install the daemon service (requires sudo)
278-
- `uninstall`: Remove the daemon service (requires sudo)
279-
- `reinstall`: Reinstall the daemon service (requires sudo)
277+
- `install`: Install the daemon service
278+
- `uninstall`: Remove the daemon service
279+
- `reinstall`: Reinstall the daemon service
280280

281281
**Examples:**
282282
```bash
283283
# Install daemon for better performance
284-
sudo shelltime daemon install
284+
shelltime daemon install
285285

286286
# Remove daemon service
287-
sudo shelltime daemon uninstall
287+
shelltime daemon uninstall
288288

289289
# Reinstall (useful for updates)
290-
sudo shelltime daemon reinstall
290+
shelltime daemon reinstall
291291
```
292292

293293
#### `shelltime hooks`
@@ -413,7 +413,7 @@ Default synchronization behavior and expected latencies:
413413
For optimal performance and minimal shell latency, enable daemon mode:
414414

415415
```bash
416-
sudo ~/.shelltime/bin/shelltime daemon install
416+
~/.shelltime/bin/shelltime daemon install
417417
```
418418

419419
**Key Benefits:**
@@ -423,7 +423,7 @@ sudo ~/.shelltime/bin/shelltime daemon install
423423
- **Resilient Delivery**: Automatic retry and buffering during network issues
424424

425425
**Technical Implementation:**
426-
- Operates as a system-level service
426+
- Operates as a user-level service
427427
- Manages all network synchronization operations
428428
- Implements intelligent command buffering
429429
- Provides automatic retry mechanisms for failed synchronizations
@@ -500,7 +500,7 @@ ShellTime implements a hybrid RSA/AES-GCM encryption scheme:
500500
Remove the daemon service when no longer needed:
501501

502502
```bash
503-
sudo ~/.shelltime/bin/shelltime daemon uninstall
503+
~/.shelltime/bin/shelltime daemon uninstall
504504
```
505505

506506
**Uninstallation Process:**

commands/daemon.install.go

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package commands
33
import (
44
"fmt"
55
"os"
6+
"os/user"
67
"path/filepath"
78

89
"github.com/gookit/color"
@@ -17,20 +18,17 @@ var DaemonInstallCommand *cli.Command = &cli.Command{
1718
}
1819

1920
func commandDaemonInstall(c *cli.Context) error {
20-
color.Yellow.Println("⚠️ Warning: This daemon service is currently not ready for use. Please proceed with caution.")
21-
22-
// Check if running as root
23-
if os.Geteuid() != 0 {
24-
return fmt.Errorf("this command must be run as root (sudo shelltime daemon install)")
25-
}
2621
color.Yellow.Println("🔍 Detecting system architecture...")
2722

28-
// TODO: the username is not stable in multiple user system
29-
baseFolder, username, err := model.SudoGetBaseFolder()
23+
// Get current user's home directory and username
24+
currentUser, err := user.Current()
3025
if err != nil {
31-
return err
26+
return fmt.Errorf("failed to get current user: %w", err)
3227
}
3328

29+
baseFolder := filepath.Join(currentUser.HomeDir, ".shelltime")
30+
username := currentUser.Username
31+
3432
installer, err := model.NewDaemonInstaller(baseFolder, username)
3533
if err != nil {
3634
return err
@@ -59,15 +57,8 @@ func commandDaemonInstall(c *cli.Context) error {
5957
return nil
6058
}
6159

62-
// Copy to final location
63-
binaryPath := "/usr/local/bin/shelltime-daemon"
64-
65-
if _, err := os.Stat(binaryPath); err != nil {
66-
color.Yellow.Println("🔍 Creating daemon symlink...")
67-
if err := os.Symlink(filepath.Join(baseFolder, "bin/shelltime-daemon"), binaryPath); err != nil {
68-
return fmt.Errorf("failed to create daemon symlink: %w", err)
69-
}
70-
}
60+
// User-level installation - no system-wide symlink needed
61+
color.Yellow.Println("🔍 Setting up user-level daemon installation...")
7162

7263
if err := installer.InstallService(username); err != nil {
7364
return err

commands/daemon.reinstall.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func commandDaemonReinstall(c *cli.Context) error {
1515
color.Yellow.Println("🔄 Starting daemon service reinstallation...")
1616

1717
// First, uninstall the existing service
18-
color.Yellow.Println("🗑 Uninstalling existing daemon service...")
18+
color.Yellow.Println("🗑 Uninstalling existing daemon service...")
1919
if err := commandDaemonUninstall(c); err != nil {
2020
return err
2121
}
@@ -28,4 +28,4 @@ func commandDaemonReinstall(c *cli.Context) error {
2828

2929
color.Green.Println("✅ Daemon service has been successfully reinstalled!")
3030
return nil
31-
}
31+
}

commands/daemon.uninstall.go

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ package commands
22

33
import (
44
"fmt"
5-
"os"
5+
"os/user"
6+
"path/filepath"
67

78
"github.com/gookit/color"
89
"github.com/malamtime/cli/model"
@@ -16,19 +17,17 @@ var DaemonUninstallCommand = &cli.Command{
1617
}
1718

1819
func commandDaemonUninstall(c *cli.Context) error {
19-
// Check if running as root
20-
if os.Geteuid() != 0 {
21-
return fmt.Errorf("this command must be run as root (sudo shelltime daemon uninstall)")
22-
}
23-
2420
color.Yellow.Println("🔍 Starting daemon service uninstallation...")
2521

26-
// TODO: the username is not stable in multiple user system
27-
baseFolder, username, err := model.SudoGetBaseFolder()
22+
// Get current user's home directory and username
23+
currentUser, err := user.Current()
2824
if err != nil {
29-
return err
25+
return fmt.Errorf("failed to get current user: %w", err)
3026
}
3127

28+
baseFolder := filepath.Join(currentUser.HomeDir, ".shelltime")
29+
username := currentUser.Username
30+
3231
installer, err := model.NewDaemonInstaller(baseFolder, username)
3332
if err != nil {
3433
return err
@@ -39,14 +38,8 @@ func commandDaemonUninstall(c *cli.Context) error {
3938
return fmt.Errorf("failed to unregister service: %w", err)
4039
}
4140

42-
// Remove symlink from /usr/local/bin
43-
binaryPath := "/usr/local/bin/shelltime-daemon"
44-
if _, err := os.Stat(binaryPath); err == nil {
45-
color.Yellow.Println("🗑 Removing daemon symlink...")
46-
if err := os.Remove(binaryPath); err != nil {
47-
return fmt.Errorf("failed to remove daemon symlink: %w", err)
48-
}
49-
}
41+
// No need to remove system-wide symlink for user-level installation
42+
color.Yellow.Println("🗑 User-level daemon service cleanup completed...")
5043

5144
color.Green.Println("✅ Daemon service has been successfully uninstalled!")
5245
// color.Yellow.Println("ℹ️ Note: Your commands will now be synced to shelltime.xyz on the next login")

model/daemon-installer.darwin.go

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"os/exec"
9+
"os/user"
910
"path/filepath"
1011
"text/template"
1112

@@ -31,7 +32,7 @@ func NewMacDaemonInstaller(baseFolder, user string) *MacDaemonInstaller {
3132
}
3233

3334
func (m *MacDaemonInstaller) Check() error {
34-
cmd := exec.Command("launchctl", "print", "system/"+m.serviceName)
35+
cmd := exec.Command("launchctl", "print", "user/"+fmt.Sprintf("%d", os.Getuid())+"/"+m.serviceName)
3536
if err := cmd.Run(); err == nil {
3637
return nil
3738
}
@@ -41,13 +42,16 @@ func (m *MacDaemonInstaller) Check() error {
4142
func (m *MacDaemonInstaller) CheckAndStopExistingService() error {
4243
color.Yellow.Println("🔍 Checking if service is running...")
4344

44-
if err := m.Check(); err != nil {
45-
return err
46-
}
47-
48-
color.Yellow.Println("🛑 Stopping existing service...")
49-
if err := exec.Command("launchctl", "unload", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", m.serviceName)).Run(); err != nil {
50-
return fmt.Errorf("failed to stop existing service: %w", err)
45+
if err := m.Check(); err == nil {
46+
color.Yellow.Println("🛑 Stopping existing service...")
47+
currentUser, err := user.Current()
48+
if err != nil {
49+
return fmt.Errorf("failed to get current user: %w", err)
50+
}
51+
agentPath := filepath.Join(currentUser.HomeDir, "Library/LaunchAgents", fmt.Sprintf("%s.plist", m.serviceName))
52+
if err := exec.Command("launchctl", "unload", agentPath).Run(); err != nil {
53+
return fmt.Errorf("failed to stop existing service: %w", err)
54+
}
5155
}
5256
return nil
5357
}
@@ -62,6 +66,12 @@ func (m *MacDaemonInstaller) InstallService(username string) error {
6266
return fmt.Errorf("failed to create daemon directory: %w", err)
6367
}
6468

69+
// Create logs directory if not exists
70+
logsPath := filepath.Join(m.baseFolder, "logs")
71+
if err := os.MkdirAll(logsPath, 0755); err != nil {
72+
return fmt.Errorf("failed to create logs directory: %w", err)
73+
}
74+
6575
plistPath := filepath.Join(daemonPath, fmt.Sprintf("%s.plist", m.serviceName))
6676
if _, err := os.Stat(plistPath); err == nil {
6777
if err := os.Remove(plistPath); err != nil {
@@ -84,7 +94,19 @@ func (m *MacDaemonInstaller) RegisterService() error {
8494
if m.baseFolder == "" {
8595
return fmt.Errorf("base folder is not set")
8696
}
87-
plistPath := fmt.Sprintf("/Library/LaunchDaemons/%s.plist", m.serviceName)
97+
98+
currentUser, err := user.Current()
99+
if err != nil {
100+
return fmt.Errorf("failed to get current user: %w", err)
101+
}
102+
103+
// Create LaunchAgents directory if it doesn't exist
104+
launchAgentsDir := filepath.Join(currentUser.HomeDir, "Library/LaunchAgents")
105+
if err := os.MkdirAll(launchAgentsDir, 0755); err != nil {
106+
return fmt.Errorf("failed to create LaunchAgents directory: %w", err)
107+
}
108+
109+
plistPath := filepath.Join(launchAgentsDir, fmt.Sprintf("%s.plist", m.serviceName))
88110
if _, err := os.Stat(plistPath); err != nil {
89111
sourceFile := filepath.Join(m.baseFolder, fmt.Sprintf("daemon/%s.plist", m.serviceName))
90112
if err := os.Symlink(sourceFile, plistPath); err != nil {
@@ -96,7 +118,14 @@ func (m *MacDaemonInstaller) RegisterService() error {
96118

97119
func (m *MacDaemonInstaller) StartService() error {
98120
color.Yellow.Println("🚀 Starting service...")
99-
if err := exec.Command("launchctl", "load", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", m.serviceName)).Run(); err != nil {
121+
122+
currentUser, err := user.Current()
123+
if err != nil {
124+
return fmt.Errorf("failed to get current user: %w", err)
125+
}
126+
127+
agentPath := filepath.Join(currentUser.HomeDir, "Library/LaunchAgents", fmt.Sprintf("%s.plist", m.serviceName))
128+
if err := exec.Command("launchctl", "load", agentPath).Run(); err != nil {
100129
return fmt.Errorf("failed to start service: %w", err)
101130
}
102131
return nil
@@ -106,14 +135,22 @@ func (m *MacDaemonInstaller) UnregisterService() error {
106135
if m.baseFolder == "" {
107136
return fmt.Errorf("base folder is not set")
108137
}
138+
139+
currentUser, err := user.Current()
140+
if err != nil {
141+
return fmt.Errorf("failed to get current user: %w", err)
142+
}
143+
144+
agentPath := filepath.Join(currentUser.HomeDir, "Library/LaunchAgents", fmt.Sprintf("%s.plist", m.serviceName))
145+
109146
color.Yellow.Println("🛑 Stopping service if running...")
110147
// Try to stop the service first
111-
_ = exec.Command("launchctl", "unload", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", m.serviceName)).Run()
148+
_ = exec.Command("launchctl", "unload", agentPath).Run()
112149

113-
color.Yellow.Println("🗑 Removing service files...")
114-
// Remove symlink from LaunchDaemons
115-
if err := os.Remove(fmt.Sprintf("/Library/LaunchDaemons/%s.plist", m.serviceName)); err != nil && !os.IsNotExist(err) {
116-
return fmt.Errorf("failed to remove launch daemon plist: %w", err)
150+
color.Yellow.Println("🗑 Removing service files...")
151+
// Remove symlink from LaunchAgents
152+
if err := os.Remove(agentPath); err != nil && !os.IsNotExist(err) {
153+
return fmt.Errorf("failed to remove launch agent plist: %w", err)
117154
}
118155

119156
color.Green.Println("✅ Service unregistered successfully")
@@ -126,7 +163,8 @@ func (m *MacDaemonInstaller) GetDaemonServiceFile(username string) (buf bytes.Bu
126163
return
127164
}
128165
err = tmpl.Execute(&buf, map[string]string{
129-
"UserName": username,
166+
"UserName": username,
167+
"BaseFolder": m.baseFolder,
130168
})
131169
return
132170
}

model/daemon-installer.linux.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ func (l *LinuxDaemonInstaller) UnregisterService() error {
117117
_ = exec.Command("systemctl", "stop", "shelltime").Run()
118118
_ = exec.Command("systemctl", "disable", "shelltime").Run()
119119

120-
color.Yellow.Println("🗑 Removing service files...")
120+
color.Yellow.Println("🗑 Removing service files...")
121121
// Remove symlink from systemd
122122
if err := os.Remove("/etc/systemd/system/shelltime.service"); err != nil && !os.IsNotExist(err) {
123123
return fmt.Errorf("failed to remove systemd service symlink: %w", err)

model/sys-desc/xyz.shelltime.daemon.plist

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@
66
<string>xyz.shelltime.daemon</string>
77
<key>ProgramArguments</key>
88
<array>
9-
<string>/usr/local/bin/shelltime-daemon</string>
9+
<string>{{.BaseFolder}}/bin/shelltime-daemon</string>
1010
</array>
1111
<key>RunAtLoad</key>
1212
<true/>
1313
<key>KeepAlive</key>
1414
<true/>
1515
<key>StandardErrorPath</key>
16-
<string>/var/log/shelltime-daemon.err</string>
16+
<string>{{.BaseFolder}}/logs/shelltime-daemon.err</string>
1717
<key>StandardOutPath</key>
18-
<string>/var/log/shelltime-daemon.log</string>
18+
<string>{{.BaseFolder}}/logs/shelltime-daemon.log</string>
1919
<key>EnvironmentVariables</key>
2020
<dict>
2121
<key>HOSTING_USER</key>

0 commit comments

Comments
 (0)