From dc759c82a2d4b811a6a2e2033ad1d615018621a6 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 06:03:48 +0000 Subject: [PATCH 1/5] feat(daemon): move macOS daemon install from root to user permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove root permission checks from daemon install/uninstall commands - Update MacDaemonInstaller to use ~/Library/LaunchAgents instead of /Library/LaunchDaemons - Modify plist template to use user directories for binary and logs - Update documentation to remove sudo requirements - Change from system-level to user-level service for better security Fixes #108 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Le He --- README.md | 20 +++---- commands/daemon.install.go | 26 +++------ commands/daemon.uninstall.go | 26 ++++----- model/daemon-installer.darwin.go | 68 ++++++++++++++++++----- model/sys-desc/xyz.shelltime.daemon.plist | 6 +- 5 files changed, 85 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 0b04361..4905c0b 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ curl -sSL https://shelltime.xyz/i | bash 3. **Optional: Enable daemon mode** (recommended for optimal performance): ```bash - sudo shelltime daemon install + shelltime daemon install ``` ## Configuration @@ -274,20 +274,20 @@ shelltime daemon ``` **Subcommands:** -- `install`: Install the daemon service (requires sudo) -- `uninstall`: Remove the daemon service (requires sudo) -- `reinstall`: Reinstall the daemon service (requires sudo) +- `install`: Install the daemon service +- `uninstall`: Remove the daemon service +- `reinstall`: Reinstall the daemon service **Examples:** ```bash # Install daemon for better performance -sudo shelltime daemon install +shelltime daemon install # Remove daemon service -sudo shelltime daemon uninstall +shelltime daemon uninstall # Reinstall (useful for updates) -sudo shelltime daemon reinstall +shelltime daemon reinstall ``` #### `shelltime hooks` @@ -413,7 +413,7 @@ Default synchronization behavior and expected latencies: For optimal performance and minimal shell latency, enable daemon mode: ```bash -sudo ~/.shelltime/bin/shelltime daemon install +~/.shelltime/bin/shelltime daemon install ``` **Key Benefits:** @@ -423,7 +423,7 @@ sudo ~/.shelltime/bin/shelltime daemon install - **Resilient Delivery**: Automatic retry and buffering during network issues **Technical Implementation:** -- Operates as a system-level service +- Operates as a user-level service - Manages all network synchronization operations - Implements intelligent command buffering - Provides automatic retry mechanisms for failed synchronizations @@ -500,7 +500,7 @@ ShellTime implements a hybrid RSA/AES-GCM encryption scheme: Remove the daemon service when no longer needed: ```bash -sudo ~/.shelltime/bin/shelltime daemon uninstall +~/.shelltime/bin/shelltime daemon uninstall ``` **Uninstallation Process:** diff --git a/commands/daemon.install.go b/commands/daemon.install.go index 58e7782..dc4301a 100644 --- a/commands/daemon.install.go +++ b/commands/daemon.install.go @@ -3,6 +3,7 @@ package commands import ( "fmt" "os" + "os/user" "path/filepath" "github.com/gookit/color" @@ -18,18 +19,16 @@ var DaemonInstallCommand *cli.Command = &cli.Command{ func commandDaemonInstall(c *cli.Context) error { color.Yellow.Println("âš ī¸ Warning: This daemon service is currently not ready for use. Please proceed with caution.") - - // Check if running as root - if os.Geteuid() != 0 { - return fmt.Errorf("this command must be run as root (sudo shelltime daemon install)") - } color.Yellow.Println("🔍 Detecting system architecture...") - // TODO: the username is not stable in multiple user system - baseFolder, username, err := model.SudoGetBaseFolder() + // Get current user's home directory and username + currentUser, err := user.Current() if err != nil { - return err + return fmt.Errorf("failed to get current user: %w", err) } + + baseFolder := filepath.Join(currentUser.HomeDir, ".shelltime") + username := currentUser.Username installer, err := model.NewDaemonInstaller(baseFolder, username) if err != nil { @@ -59,15 +58,8 @@ func commandDaemonInstall(c *cli.Context) error { return nil } - // Copy to final location - binaryPath := "/usr/local/bin/shelltime-daemon" - - if _, err := os.Stat(binaryPath); err != nil { - color.Yellow.Println("🔍 Creating daemon symlink...") - if err := os.Symlink(filepath.Join(baseFolder, "bin/shelltime-daemon"), binaryPath); err != nil { - return fmt.Errorf("failed to create daemon symlink: %w", err) - } - } + // User-level installation - no system-wide symlink needed + color.Yellow.Println("🔍 Setting up user-level daemon installation...") if err := installer.InstallService(username); err != nil { return err diff --git a/commands/daemon.uninstall.go b/commands/daemon.uninstall.go index be034e9..d071584 100644 --- a/commands/daemon.uninstall.go +++ b/commands/daemon.uninstall.go @@ -3,6 +3,8 @@ package commands import ( "fmt" "os" + "os/user" + "path/filepath" "github.com/gookit/color" "github.com/malamtime/cli/model" @@ -16,18 +18,16 @@ var DaemonUninstallCommand = &cli.Command{ } func commandDaemonUninstall(c *cli.Context) error { - // Check if running as root - if os.Geteuid() != 0 { - return fmt.Errorf("this command must be run as root (sudo shelltime daemon uninstall)") - } - color.Yellow.Println("🔍 Starting daemon service uninstallation...") - // TODO: the username is not stable in multiple user system - baseFolder, username, err := model.SudoGetBaseFolder() + // Get current user's home directory and username + currentUser, err := user.Current() if err != nil { - return err + return fmt.Errorf("failed to get current user: %w", err) } + + baseFolder := filepath.Join(currentUser.HomeDir, ".shelltime") + username := currentUser.Username installer, err := model.NewDaemonInstaller(baseFolder, username) if err != nil { @@ -39,14 +39,8 @@ func commandDaemonUninstall(c *cli.Context) error { return fmt.Errorf("failed to unregister service: %w", err) } - // Remove symlink from /usr/local/bin - binaryPath := "/usr/local/bin/shelltime-daemon" - if _, err := os.Stat(binaryPath); err == nil { - color.Yellow.Println("🗑 Removing daemon symlink...") - if err := os.Remove(binaryPath); err != nil { - return fmt.Errorf("failed to remove daemon symlink: %w", err) - } - } + // No need to remove system-wide symlink for user-level installation + color.Yellow.Println("🗑 User-level daemon service cleanup completed...") color.Green.Println("✅ Daemon service has been successfully uninstalled!") // color.Yellow.Println("â„šī¸ Note: Your commands will now be synced to shelltime.xyz on the next login") diff --git a/model/daemon-installer.darwin.go b/model/daemon-installer.darwin.go index e2ab7a3..4ce7e8e 100644 --- a/model/daemon-installer.darwin.go +++ b/model/daemon-installer.darwin.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "os/user" "path/filepath" "text/template" @@ -31,7 +32,7 @@ func NewMacDaemonInstaller(baseFolder, user string) *MacDaemonInstaller { } func (m *MacDaemonInstaller) Check() error { - cmd := exec.Command("launchctl", "print", "system/"+m.serviceName) + cmd := exec.Command("launchctl", "print", "gui/"+fmt.Sprintf("%d", os.Getuid())+"/"+m.serviceName) if err := cmd.Run(); err == nil { return nil } @@ -41,13 +42,16 @@ func (m *MacDaemonInstaller) Check() error { func (m *MacDaemonInstaller) CheckAndStopExistingService() error { color.Yellow.Println("🔍 Checking if service is running...") - if err := m.Check(); err != nil { - return err - } - - color.Yellow.Println("🛑 Stopping existing service...") - if err := exec.Command("launchctl", "unload", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", m.serviceName)).Run(); err != nil { - return fmt.Errorf("failed to stop existing service: %w", err) + if err := m.Check(); err == nil { + color.Yellow.Println("🛑 Stopping existing service...") + currentUser, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + agentPath := filepath.Join(currentUser.HomeDir, "Library/LaunchAgents", fmt.Sprintf("%s.plist", m.serviceName)) + if err := exec.Command("launchctl", "unload", agentPath).Run(); err != nil { + return fmt.Errorf("failed to stop existing service: %w", err) + } } return nil } @@ -61,6 +65,12 @@ func (m *MacDaemonInstaller) InstallService(username string) error { if err := os.MkdirAll(daemonPath, 0755); err != nil { return fmt.Errorf("failed to create daemon directory: %w", err) } + + // Create logs directory if not exists + logsPath := filepath.Join(m.baseFolder, "logs") + if err := os.MkdirAll(logsPath, 0755); err != nil { + return fmt.Errorf("failed to create logs directory: %w", err) + } plistPath := filepath.Join(daemonPath, fmt.Sprintf("%s.plist", m.serviceName)) if _, err := os.Stat(plistPath); err == nil { @@ -84,7 +94,19 @@ func (m *MacDaemonInstaller) RegisterService() error { if m.baseFolder == "" { return fmt.Errorf("base folder is not set") } - plistPath := fmt.Sprintf("/Library/LaunchDaemons/%s.plist", m.serviceName) + + currentUser, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + + // Create LaunchAgents directory if it doesn't exist + launchAgentsDir := filepath.Join(currentUser.HomeDir, "Library/LaunchAgents") + if err := os.MkdirAll(launchAgentsDir, 0755); err != nil { + return fmt.Errorf("failed to create LaunchAgents directory: %w", err) + } + + plistPath := filepath.Join(launchAgentsDir, fmt.Sprintf("%s.plist", m.serviceName)) if _, err := os.Stat(plistPath); err != nil { sourceFile := filepath.Join(m.baseFolder, fmt.Sprintf("daemon/%s.plist", m.serviceName)) if err := os.Symlink(sourceFile, plistPath); err != nil { @@ -96,7 +118,14 @@ func (m *MacDaemonInstaller) RegisterService() error { func (m *MacDaemonInstaller) StartService() error { color.Yellow.Println("🚀 Starting service...") - if err := exec.Command("launchctl", "load", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", m.serviceName)).Run(); err != nil { + + currentUser, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + + agentPath := filepath.Join(currentUser.HomeDir, "Library/LaunchAgents", fmt.Sprintf("%s.plist", m.serviceName)) + if err := exec.Command("launchctl", "load", agentPath).Run(); err != nil { return fmt.Errorf("failed to start service: %w", err) } return nil @@ -106,14 +135,22 @@ func (m *MacDaemonInstaller) UnregisterService() error { if m.baseFolder == "" { return fmt.Errorf("base folder is not set") } + + currentUser, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + + agentPath := filepath.Join(currentUser.HomeDir, "Library/LaunchAgents", fmt.Sprintf("%s.plist", m.serviceName)) + color.Yellow.Println("🛑 Stopping service if running...") // Try to stop the service first - _ = exec.Command("launchctl", "unload", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", m.serviceName)).Run() + _ = exec.Command("launchctl", "unload", agentPath).Run() color.Yellow.Println("🗑 Removing service files...") - // Remove symlink from LaunchDaemons - if err := os.Remove(fmt.Sprintf("/Library/LaunchDaemons/%s.plist", m.serviceName)); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove launch daemon plist: %w", err) + // Remove symlink from LaunchAgents + if err := os.Remove(agentPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove launch agent plist: %w", err) } color.Green.Println("✅ Service unregistered successfully") @@ -126,7 +163,8 @@ func (m *MacDaemonInstaller) GetDaemonServiceFile(username string) (buf bytes.Bu return } err = tmpl.Execute(&buf, map[string]string{ - "UserName": username, + "UserName": username, + "BaseFolder": m.baseFolder, }) return } diff --git a/model/sys-desc/xyz.shelltime.daemon.plist b/model/sys-desc/xyz.shelltime.daemon.plist index 9ed44af..d30eefc 100644 --- a/model/sys-desc/xyz.shelltime.daemon.plist +++ b/model/sys-desc/xyz.shelltime.daemon.plist @@ -6,16 +6,16 @@ xyz.shelltime.daemon ProgramArguments - /usr/local/bin/shelltime-daemon + {{.BaseFolder}}/bin/shelltime-daemon RunAtLoad KeepAlive StandardErrorPath - /var/log/shelltime-daemon.err + {{.BaseFolder}}/logs/shelltime-daemon.err StandardOutPath - /var/log/shelltime-daemon.log + {{.BaseFolder}}/logs/shelltime-daemon.log EnvironmentVariables HOSTING_USER From a0279d9a42dcdc5e19ec3b620ba0897fe43f09a1 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Mon, 29 Sep 2025 22:41:11 +0800 Subject: [PATCH 2/5] fix(daemon): remove unused import and fix formatting in uninstall command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unused os import and fix trailing whitespace in daemon.uninstall.go 🤖 Generated with Claude Code Co-Authored-By: Claude --- commands/daemon.uninstall.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/commands/daemon.uninstall.go b/commands/daemon.uninstall.go index d071584..44649eb 100644 --- a/commands/daemon.uninstall.go +++ b/commands/daemon.uninstall.go @@ -2,7 +2,6 @@ package commands import ( "fmt" - "os" "os/user" "path/filepath" @@ -25,7 +24,7 @@ func commandDaemonUninstall(c *cli.Context) error { if err != nil { return fmt.Errorf("failed to get current user: %w", err) } - + baseFolder := filepath.Join(currentUser.HomeDir, ".shelltime") username := currentUser.Username From 892ea8b3263c05b3a00c5327cd28b996fe2ad6aa Mon Sep 17 00:00:00 2001 From: Le He Date: Tue, 30 Sep 2025 02:20:05 +0800 Subject: [PATCH 3/5] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- model/daemon-installer.darwin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model/daemon-installer.darwin.go b/model/daemon-installer.darwin.go index 4ce7e8e..ed84d61 100644 --- a/model/daemon-installer.darwin.go +++ b/model/daemon-installer.darwin.go @@ -32,7 +32,7 @@ func NewMacDaemonInstaller(baseFolder, user string) *MacDaemonInstaller { } func (m *MacDaemonInstaller) Check() error { - cmd := exec.Command("launchctl", "print", "gui/"+fmt.Sprintf("%d", os.Getuid())+"/"+m.serviceName) +cmd := exec.Command("launchctl", "print", "user/"+fmt.Sprintf("%d", os.Getuid())+"/"+m.serviceName) if err := cmd.Run(); err == nil { return nil } From ffc7086d6780fa6168ccbd79f0b31c4f8985921f Mon Sep 17 00:00:00 2001 From: Le He Date: Tue, 30 Sep 2025 02:20:23 +0800 Subject: [PATCH 4/5] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4905c0b..41a5cd6 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ shelltime daemon **Subcommands:** - `install`: Install the daemon service -- `uninstall`: Remove the daemon service +- `uninstall`: Remove the daemon service - `reinstall`: Reinstall the daemon service **Examples:** From e95b6ee34b330cf8246038ca9a090b9069faf8c7 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Thu, 2 Oct 2025 00:16:35 +0800 Subject: [PATCH 5/5] refactor(daemon): remove warning and fix formatting in daemon commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove "not ready for use" warning from daemon install - Fix indentation consistency across daemon installer files - Standardize emoji spacing in user messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- commands/daemon.install.go | 3 +-- commands/daemon.reinstall.go | 4 ++-- commands/daemon.uninstall.go | 2 +- model/daemon-installer.darwin.go | 22 +++++++++++----------- model/daemon-installer.linux.go | 2 +- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/commands/daemon.install.go b/commands/daemon.install.go index dc4301a..43a04af 100644 --- a/commands/daemon.install.go +++ b/commands/daemon.install.go @@ -18,7 +18,6 @@ var DaemonInstallCommand *cli.Command = &cli.Command{ } func commandDaemonInstall(c *cli.Context) error { - color.Yellow.Println("âš ī¸ Warning: This daemon service is currently not ready for use. Please proceed with caution.") color.Yellow.Println("🔍 Detecting system architecture...") // Get current user's home directory and username @@ -26,7 +25,7 @@ func commandDaemonInstall(c *cli.Context) error { if err != nil { return fmt.Errorf("failed to get current user: %w", err) } - + baseFolder := filepath.Join(currentUser.HomeDir, ".shelltime") username := currentUser.Username diff --git a/commands/daemon.reinstall.go b/commands/daemon.reinstall.go index 3a5cbc7..60b84fb 100644 --- a/commands/daemon.reinstall.go +++ b/commands/daemon.reinstall.go @@ -15,7 +15,7 @@ func commandDaemonReinstall(c *cli.Context) error { color.Yellow.Println("🔄 Starting daemon service reinstallation...") // First, uninstall the existing service - color.Yellow.Println("🗑 Uninstalling existing daemon service...") + color.Yellow.Println("🗑 Uninstalling existing daemon service...") if err := commandDaemonUninstall(c); err != nil { return err } @@ -28,4 +28,4 @@ func commandDaemonReinstall(c *cli.Context) error { color.Green.Println("✅ Daemon service has been successfully reinstalled!") return nil -} \ No newline at end of file +} diff --git a/commands/daemon.uninstall.go b/commands/daemon.uninstall.go index 44649eb..38caa45 100644 --- a/commands/daemon.uninstall.go +++ b/commands/daemon.uninstall.go @@ -39,7 +39,7 @@ func commandDaemonUninstall(c *cli.Context) error { } // No need to remove system-wide symlink for user-level installation - color.Yellow.Println("🗑 User-level daemon service cleanup completed...") + color.Yellow.Println("🗑 User-level daemon service cleanup completed...") color.Green.Println("✅ Daemon service has been successfully uninstalled!") // color.Yellow.Println("â„šī¸ Note: Your commands will now be synced to shelltime.xyz on the next login") diff --git a/model/daemon-installer.darwin.go b/model/daemon-installer.darwin.go index ed84d61..2e50366 100644 --- a/model/daemon-installer.darwin.go +++ b/model/daemon-installer.darwin.go @@ -32,7 +32,7 @@ func NewMacDaemonInstaller(baseFolder, user string) *MacDaemonInstaller { } func (m *MacDaemonInstaller) Check() error { -cmd := exec.Command("launchctl", "print", "user/"+fmt.Sprintf("%d", os.Getuid())+"/"+m.serviceName) + cmd := exec.Command("launchctl", "print", "user/"+fmt.Sprintf("%d", os.Getuid())+"/"+m.serviceName) if err := cmd.Run(); err == nil { return nil } @@ -65,7 +65,7 @@ func (m *MacDaemonInstaller) InstallService(username string) error { if err := os.MkdirAll(daemonPath, 0755); err != nil { return fmt.Errorf("failed to create daemon directory: %w", err) } - + // Create logs directory if not exists logsPath := filepath.Join(m.baseFolder, "logs") if err := os.MkdirAll(logsPath, 0755); err != nil { @@ -94,18 +94,18 @@ func (m *MacDaemonInstaller) RegisterService() error { if m.baseFolder == "" { return fmt.Errorf("base folder is not set") } - + currentUser, err := user.Current() if err != nil { return fmt.Errorf("failed to get current user: %w", err) } - + // Create LaunchAgents directory if it doesn't exist launchAgentsDir := filepath.Join(currentUser.HomeDir, "Library/LaunchAgents") if err := os.MkdirAll(launchAgentsDir, 0755); err != nil { return fmt.Errorf("failed to create LaunchAgents directory: %w", err) } - + plistPath := filepath.Join(launchAgentsDir, fmt.Sprintf("%s.plist", m.serviceName)) if _, err := os.Stat(plistPath); err != nil { sourceFile := filepath.Join(m.baseFolder, fmt.Sprintf("daemon/%s.plist", m.serviceName)) @@ -118,12 +118,12 @@ func (m *MacDaemonInstaller) RegisterService() error { func (m *MacDaemonInstaller) StartService() error { color.Yellow.Println("🚀 Starting service...") - + currentUser, err := user.Current() if err != nil { return fmt.Errorf("failed to get current user: %w", err) } - + agentPath := filepath.Join(currentUser.HomeDir, "Library/LaunchAgents", fmt.Sprintf("%s.plist", m.serviceName)) if err := exec.Command("launchctl", "load", agentPath).Run(); err != nil { return fmt.Errorf("failed to start service: %w", err) @@ -135,19 +135,19 @@ func (m *MacDaemonInstaller) UnregisterService() error { if m.baseFolder == "" { return fmt.Errorf("base folder is not set") } - + currentUser, err := user.Current() if err != nil { return fmt.Errorf("failed to get current user: %w", err) } - + agentPath := filepath.Join(currentUser.HomeDir, "Library/LaunchAgents", fmt.Sprintf("%s.plist", m.serviceName)) - + color.Yellow.Println("🛑 Stopping service if running...") // Try to stop the service first _ = exec.Command("launchctl", "unload", agentPath).Run() - color.Yellow.Println("🗑 Removing service files...") + color.Yellow.Println("🗑 Removing service files...") // Remove symlink from LaunchAgents if err := os.Remove(agentPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove launch agent plist: %w", err) diff --git a/model/daemon-installer.linux.go b/model/daemon-installer.linux.go index 185188c..d55dd80 100644 --- a/model/daemon-installer.linux.go +++ b/model/daemon-installer.linux.go @@ -117,7 +117,7 @@ func (l *LinuxDaemonInstaller) UnregisterService() error { _ = exec.Command("systemctl", "stop", "shelltime").Run() _ = exec.Command("systemctl", "disable", "shelltime").Run() - color.Yellow.Println("🗑 Removing service files...") + color.Yellow.Println("🗑 Removing service files...") // Remove symlink from systemd if err := os.Remove("/etc/systemd/system/shelltime.service"); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove systemd service symlink: %w", err)