From 02368fbe0b205637d48949dbc04a37a3c2cf906e Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Mon, 13 Apr 2026 01:36:50 +0800 Subject: [PATCH 1/2] feat(cli): support Homebrew binary paths for daemon and hooks Homebrew installs binaries to /opt/homebrew/bin (Apple Silicon) or /usr/local/bin (Intel), not ~/.shelltime/bin. This adds proper binary resolution across install methods and embeds hook scripts so Homebrew users don't need the curl installer for shell hooks. - Add ResolveDaemonBinaryPath() to search curl-installer, PATH, and Homebrew fallback locations - Update plist/systemd templates to use resolved DaemonBinPath - Embed bash/zsh/fish hook scripts via go:embed for self-contained Homebrew installs - Auto-download bash-preexec.sh dependency during hooks install - Update hooks install to accept shelltime on PATH (not just in ~/.shelltime/bin) - Add Homebrew install method to README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 8 +++ commands/daemon.install.go | 37 ++++++------ commands/daemon.status.go | 2 +- commands/daemon.uninstall.go | 2 +- commands/doctor.go | 2 +- commands/hooks.install.go | 10 +++- model/daemon-installer.darwin.go | 21 ++++--- model/daemon-installer.go | 6 +- model/daemon-installer.linux.go | 14 +++-- model/hooks/bash.bash | 71 +++++++++++++++++++++++ model/hooks/fish.fish | 33 +++++++++++ model/hooks/zsh.zsh | 34 +++++++++++ model/path.go | 34 +++++++++++ model/shell.bash.go | 43 ++++++++++++-- model/shell.fish.go | 5 ++ model/shell.go | 17 ++++++ model/shell.zsh.go | 5 ++ model/shell_hooks_embed.go | 12 ++++ model/sys-desc/shelltime.service | 2 +- model/sys-desc/xyz.shelltime.daemon.plist | 2 +- 20 files changed, 311 insertions(+), 49 deletions(-) create mode 100644 model/hooks/bash.bash create mode 100644 model/hooks/fish.fish create mode 100644 model/hooks/zsh.zsh create mode 100644 model/shell_hooks_embed.go diff --git a/README.md b/README.md index 6e5c42d..fb7b74c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ The Go module path is `github.com/malamtime/cli`. That naming mismatch is intent ## Install +### Homebrew (macOS and Linux) + +```bash +brew install shelltime/tap/shelltime +``` + +### curl installer + ```bash curl -sSL https://shelltime.xyz/i | bash ``` diff --git a/commands/daemon.install.go b/commands/daemon.install.go index 43a04af..35a3150 100644 --- a/commands/daemon.install.go +++ b/commands/daemon.install.go @@ -29,33 +29,32 @@ func commandDaemonInstall(c *cli.Context) error { baseFolder := filepath.Join(currentUser.HomeDir, ".shelltime") username := currentUser.Username - installer, err := model.NewDaemonInstaller(baseFolder, username) - if err != nil { - return err - } - - installer.CheckAndStopExistingService() - - // check latest file exist or not - if _, err := os.Stat(filepath.Join(baseFolder, "bin/shelltime-daemon.bak")); err == nil { + // Handle .bak upgrade for curl-installer users + bakPath := filepath.Join(baseFolder, "bin/shelltime-daemon.bak") + if _, err := os.Stat(bakPath); err == nil { color.Yellow.Println("🔄 Found latest daemon file, restoring...") - // try to remove old file _ = os.Remove(filepath.Join(baseFolder, "bin/shelltime-daemon")) - // rename .bak to original - if err := os.Rename( - filepath.Join(baseFolder, "bin/shelltime-daemon.bak"), - filepath.Join(baseFolder, "bin/shelltime-daemon"), - ); err != nil { + if err := os.Rename(bakPath, filepath.Join(baseFolder, "bin/shelltime-daemon")); err != nil { return fmt.Errorf("failed to restore latest daemon: %w", err) } } - // check shelltime-daemon - if _, err := os.Stat(filepath.Join(baseFolder, "bin/shelltime-daemon")); err != nil { - color.Yellow.Println("⚠️ shelltime-daemon not found, please reinstall the CLI first:") - color.Yellow.Println("curl -sSL https://raw.githubusercontent.com/malamtime/installation/master/install.bash | bash") + // Resolve daemon binary (curl-installer, Homebrew, or PATH) + daemonBinPath, err := model.ResolveDaemonBinaryPath() + if err != nil { + color.Yellow.Println("⚠️ shelltime-daemon not found.") + color.Yellow.Println("Install via Homebrew: brew install shelltime/tap/shelltime") + color.Yellow.Println("Or via curl installer: curl -sSL https://shelltime.xyz/i | bash") return nil } + color.Green.Printf("✅ Found daemon binary at: %s\n", daemonBinPath) + + installer, err := model.NewDaemonInstaller(baseFolder, username, daemonBinPath) + if err != nil { + return err + } + + installer.CheckAndStopExistingService() // User-level installation - no system-wide symlink needed color.Yellow.Println("🔍 Setting up user-level daemon installation...") diff --git a/commands/daemon.status.go b/commands/daemon.status.go index 9231017..e3572eb 100644 --- a/commands/daemon.status.go +++ b/commands/daemon.status.go @@ -53,7 +53,7 @@ func commandDaemonStatus(c *cli.Context) error { } // Check 3: Service manager status - installer, installerErr := model.NewDaemonInstaller("", "") + installer, installerErr := model.NewDaemonInstaller("", "", "") if installerErr == nil { if err := installer.Check(); err == nil { printSuccess("Service is registered and running") diff --git a/commands/daemon.uninstall.go b/commands/daemon.uninstall.go index 38caa45..21cb3a1 100644 --- a/commands/daemon.uninstall.go +++ b/commands/daemon.uninstall.go @@ -28,7 +28,7 @@ func commandDaemonUninstall(c *cli.Context) error { baseFolder := filepath.Join(currentUser.HomeDir, ".shelltime") username := currentUser.Username - installer, err := model.NewDaemonInstaller(baseFolder, username) + installer, err := model.NewDaemonInstaller(baseFolder, username, "") if err != nil { return err } diff --git a/commands/doctor.go b/commands/doctor.go index 2efb275..617d336 100644 --- a/commands/doctor.go +++ b/commands/doctor.go @@ -91,7 +91,7 @@ func commandDoctor(c *cli.Context) error { // 5. Check daemon process printSectionHeader("Daemon Process") - daemonInstaller, err := model.NewDaemonInstaller("", "") + daemonInstaller, err := model.NewDaemonInstaller("", "", "") if err != nil { printError(fmt.Sprintf("Error checking daemon installer: %v", err)) return err diff --git a/commands/hooks.install.go b/commands/hooks.install.go index 22dfea4..25441cb 100644 --- a/commands/hooks.install.go +++ b/commands/hooks.install.go @@ -3,6 +3,7 @@ package commands import ( "fmt" "os" + "os/exec" "github.com/gookit/color" "github.com/malamtime/cli/model" @@ -17,9 +18,12 @@ var HooksInstallCommand = &cli.Command{ func commandHooksInstall(c *cli.Context) error { binFolder := os.ExpandEnv(fmt.Sprintf("$HOME/%s/bin", model.COMMAND_BASE_STORAGE_FOLDER)) - if _, err := os.Stat(binFolder); os.IsNotExist(err) { - color.Red.Println("📁 cannot find bin folder at", binFolder) - color.Red.Println("Please run 'curl -sSL https://raw.githubusercontent.com/malamtime/installation/master/install.bash | bash' first") + _, binFolderErr := os.Stat(binFolder) + _, lookPathErr := exec.LookPath("shelltime") + if os.IsNotExist(binFolderErr) && lookPathErr != nil { + color.Red.Println("📁 shelltime binary not found.") + color.Red.Println("Install via Homebrew: brew install shelltime/tap/shelltime") + color.Red.Println("Or via curl installer: curl -sSL https://shelltime.xyz/i | bash") return nil } diff --git a/model/daemon-installer.darwin.go b/model/daemon-installer.darwin.go index 206d253..94aa7ba 100644 --- a/model/daemon-installer.darwin.go +++ b/model/daemon-installer.darwin.go @@ -18,16 +18,18 @@ var daemonMacServiceDesc []byte // MacDaemonInstaller implements DaemonInstaller for macOS systems type MacDaemonInstaller struct { - baseFolder string - serviceName string - user string + baseFolder string + serviceName string + user string + daemonBinPath string } -func NewMacDaemonInstaller(baseFolder, user string) *MacDaemonInstaller { +func NewMacDaemonInstaller(baseFolder, user, daemonBinPath string) *MacDaemonInstaller { return &MacDaemonInstaller{ - baseFolder: baseFolder, - user: user, - serviceName: "xyz.shelltime.daemon", + baseFolder: baseFolder, + user: user, + serviceName: "xyz.shelltime.daemon", + daemonBinPath: daemonBinPath, } } @@ -163,8 +165,9 @@ func (m *MacDaemonInstaller) GetDaemonServiceFile(username string) (buf bytes.Bu return } err = tmpl.Execute(&buf, map[string]string{ - "UserName": username, - "BaseFolder": m.baseFolder, + "UserName": username, + "BaseFolder": m.baseFolder, + "DaemonBinPath": m.daemonBinPath, }) return } diff --git a/model/daemon-installer.go b/model/daemon-installer.go index ae5d711..516bde2 100644 --- a/model/daemon-installer.go +++ b/model/daemon-installer.go @@ -18,12 +18,12 @@ type DaemonInstaller interface { } // Factory function to create appropriate installer based on OS -func NewDaemonInstaller(baseFolder, username string) (DaemonInstaller, error) { +func NewDaemonInstaller(baseFolder, username, daemonBinPath string) (DaemonInstaller, error) { switch runtime.GOOS { case "linux": - return NewLinuxDaemonInstaller(baseFolder, username), nil + return NewLinuxDaemonInstaller(baseFolder, username, daemonBinPath), nil case "darwin": - return NewMacDaemonInstaller(baseFolder, username), nil + return NewMacDaemonInstaller(baseFolder, username, daemonBinPath), nil default: return nil, fmt.Errorf("unsupported operating system: %s", runtime.GOOS) } diff --git a/model/daemon-installer.linux.go b/model/daemon-installer.linux.go index 1d468f8..db9f27c 100644 --- a/model/daemon-installer.linux.go +++ b/model/daemon-installer.linux.go @@ -19,12 +19,13 @@ var daemonLinuxServiceDesc []byte // LinuxDaemonInstaller implements DaemonInstaller for Linux systems type LinuxDaemonInstaller struct { - baseFolder string - user string + baseFolder string + user string + daemonBinPath string } -func NewLinuxDaemonInstaller(baseFolder, user string) *LinuxDaemonInstaller { - return &LinuxDaemonInstaller{baseFolder: baseFolder, user: user} +func NewLinuxDaemonInstaller(baseFolder, user, daemonBinPath string) *LinuxDaemonInstaller { + return &LinuxDaemonInstaller{baseFolder: baseFolder, user: user, daemonBinPath: daemonBinPath} } // getXDGRuntimeDir returns the XDG_RUNTIME_DIR path for the current user @@ -221,8 +222,9 @@ func (l *LinuxDaemonInstaller) GetDaemonServiceFile(username string) (buf bytes. return } err = tmpl.Execute(&buf, map[string]string{ - "UserName": username, - "BaseFolder": l.baseFolder, + "UserName": username, + "BaseFolder": l.baseFolder, + "DaemonBinPath": l.daemonBinPath, }) return } diff --git a/model/hooks/bash.bash b/model/hooks/bash.bash new file mode 100644 index 0000000..2f4e196 --- /dev/null +++ b/model/hooks/bash.bash @@ -0,0 +1,71 @@ +#!/bin/bash + +# Source bash-preexec.sh if it exists +if [ -f "bash-preexec.sh" ]; then + source "bash-preexec.sh" +else + # Attempt to find bash-preexec.sh in the same directory as this script + _SHELLTIME_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if [ -f "$_SHELLTIME_HOOK_DIR/bash-preexec.sh" ]; then + source "$_SHELLTIME_HOOK_DIR/bash-preexec.sh" + else + echo "Warning: bash-preexec.sh not found. Pre-execution hooks will not work." + return 1 + fi +fi + +# Check if shelltime CLI exists +if ! command -v shelltime &> /dev/null +then + echo "Warning: shelltime CLI not found. Please install it to enable time tracking." +else + shelltime gc +fi + +# Create a timestamp for the session when the shell starts +SESSION_ID=$(date +%Y%m%d%H%M%S) +LAST_COMMAND="" + +# Function to be executed before each command +preexec_invoke_cmd() { + local CMD="$1" + LAST_COMMAND="$CMD" + # Check if command starts with exit, logout, or reboot + if [[ "$CMD" =~ ^(exit|logout|reboot) ]]; then + return + fi + + # Avoid tracking shelltime commands themselves to prevent loops + if [[ "$CMD" =~ ^shelltime ]]; then + return + fi + + shelltime track -s=bash -id=$SESSION_ID -cmd="$CMD" -p=pre --ppid=$PPID &> /dev/null +} + +# Function to be executed after each command (before prompt) +precmd_invoke_cmd() { + local LAST_RESULT=$? + # BASH_COMMAND in precmd is the *previous* command + local CMD="$LAST_COMMAND" + # Check if command starts with exit, logout, or reboot + if [[ "$CMD" =~ ^(exit|logout|reboot) ]]; then + return + fi + + # Avoid tracking shelltime commands themselves to prevent loops + if [[ "$CMD" =~ ^shelltime ]]; then + return + fi + + # Ensure CMD is not empty or the precmd_invoke_cmd itself + if [ -z "$CMD" ] || [ "$CMD" == "precmd_invoke_cmd" ]; then + return + fi + + shelltime track -s=bash -id=$SESSION_ID -cmd="$CMD" -p=post -r=$LAST_RESULT --ppid=$PPID &> /dev/null +} + +# Set the functions for bash-preexec +preexec_functions+=(preexec_invoke_cmd) +precmd_functions+=(precmd_invoke_cmd) diff --git a/model/hooks/fish.fish b/model/hooks/fish.fish new file mode 100644 index 0000000..da25227 --- /dev/null +++ b/model/hooks/fish.fish @@ -0,0 +1,33 @@ +#!/usr/bin/env fish + +# Check if shelltime CLI exists +if not command -v shelltime > /dev/null + echo "Warning: shelltime CLI not found. Please install it to enable time tracking." +else + shelltime gc +end + +# Create a timestamp for the session when the shell starts +set -g SESSION_ID (date +%Y%m%d%H%M%S) + +# Capture parent process ID at shell startup (fish doesn't have native $PPID) +set -g FISH_PPID (ps -o ppid= -p %self | string trim) + +# Define the preexec function +function fish_preexec --on-event fish_preexec + if string match -q 'exit*' -- $argv; or string match -q 'logout*' -- $argv; or string match -q 'reboot*' -- $argv + return + end + + shelltime track -s=fish -id=$SESSION_ID -cmd="$argv" -p=pre --ppid=$FISH_PPID > /dev/null +end + +# Define the postexec function +function fish_postexec --on-event fish_postexec + set -g LAST_RESULT (echo $status) + if string match -q 'exit*' -- $argv; or string match -q 'logout*' -- $argv; or string match -q 'reboot*' -- $argv + return + end + # This event is triggered before each prompt, which is after each command + shelltime track -s=fish -id=$SESSION_ID -cmd="$argv" -p=post -r=$LAST_RESULT --ppid=$FISH_PPID > /dev/null +end diff --git a/model/hooks/zsh.zsh b/model/hooks/zsh.zsh new file mode 100644 index 0000000..fb89c05 --- /dev/null +++ b/model/hooks/zsh.zsh @@ -0,0 +1,34 @@ +#!/usr/bin/env zsh + +# Check if shelltime command exists +if ! command -v shelltime &> /dev/null +then + echo "Warning: shelltime command not found. Please install it to track shell usage." +else + shelltime gc +fi + +# Create a timestamp for the session when the shell starts +SESSION_ID=$(date +%Y%m%d%H%M%S) + +# Define the preexec function +preexec() { + local CMD=$1 + # Check if command starts with exit, logout, or reboot + if [[ $CMD =~ ^(exit|logout|reboot) ]]; then + return + fi + + shelltime track -s=zsh -id=$SESSION_ID -cmd="$CMD" -p=pre --ppid=$PPID &> /dev/null +} + +# Define the postexec function (in zsh, it's called precmd) +precmd() { + local LAST_RESULT=$? + local CMD=$(fc -ln -1) + # Check if command starts with exit, logout, or reboot + if [[ $CMD =~ ^(exit|logout|reboot) ]]; then + return + fi + shelltime track -s=zsh -id=$SESSION_ID -cmd="$CMD" -p=post -r=$LAST_RESULT --ppid=$PPID &> /dev/null +} diff --git a/model/path.go b/model/path.go index 938beaf..cc52929 100644 --- a/model/path.go +++ b/model/path.go @@ -1,7 +1,9 @@ package model import ( + "fmt" "os" + "os/exec" "path/filepath" ) @@ -111,3 +113,35 @@ func GetDaemonLogFilePath() string { func GetDaemonErrFilePath() string { return GetStoragePath("logs", "shelltime-daemon.err") } + +// ResolveDaemonBinaryPath finds the shelltime-daemon binary. +// It checks the curl-installer location first, then PATH (Homebrew), +// then known Homebrew prefix paths as fallback. +func ResolveDaemonBinaryPath() (string, error) { + const binaryName = "shelltime-daemon" + + // 1. Check curl-installer location + curlPath := filepath.Join(GetBaseStoragePath(), "bin", binaryName) + if info, err := os.Stat(curlPath); err == nil && !info.IsDir() { + return curlPath, nil + } + + // 2. Check PATH (covers Homebrew and other package managers) + if path, err := exec.LookPath(binaryName); err == nil { + return path, nil + } + + // 3. Explicit Homebrew fallback paths + homebrewPaths := []string{ + filepath.Join("/opt/homebrew/bin", binaryName), + filepath.Join("/usr/local/bin", binaryName), + filepath.Join("/home/linuxbrew/.linuxbrew/bin", binaryName), + } + for _, p := range homebrewPaths { + if info, err := os.Stat(p); err == nil && !info.IsDir() { + return p, nil + } + } + + return "", fmt.Errorf("%s not found at %s, on PATH, or in standard Homebrew locations", binaryName, curlPath) +} diff --git a/model/shell.bash.go b/model/shell.bash.go index 4410d9b..dece87d 100644 --- a/model/shell.bash.go +++ b/model/shell.bash.go @@ -2,12 +2,46 @@ package model import ( "fmt" + "io" + "net/http" "os" + "path/filepath" "strings" "github.com/gookit/color" ) +const bashPreexecURL = "https://raw.githubusercontent.com/rcaloras/bash-preexec/master/bash-preexec.sh" + +// ensureBashPreexec downloads bash-preexec.sh if it doesn't exist in the hooks directory. +func ensureBashPreexec(hooksDir string) error { + preexecPath := filepath.Join(hooksDir, "bash-preexec.sh") + if _, err := os.Stat(preexecPath); err == nil { + return nil // already exists + } + + resp, err := http.Get(bashPreexecURL) + if err != nil { + return fmt.Errorf("failed to download bash-preexec.sh: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download bash-preexec.sh: HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read bash-preexec.sh response: %w", err) + } + + if err := os.WriteFile(preexecPath, body, 0644); err != nil { + return fmt.Errorf("failed to write bash-preexec.sh: %w", err) + } + + return nil +} + type BashHookService struct { BaseHookService shellName string @@ -38,10 +72,11 @@ func (s *BashHookService) ShellName() string { func (s *BashHookService) Install() error { hookFilePath := os.ExpandEnv(fmt.Sprintf("$HOME/%s/hooks/bash.bash", COMMAND_BASE_STORAGE_FOLDER)) - if _, err := os.Stat(hookFilePath); os.IsNotExist(err) { - color.Red.Println("hook file not found at", hookFilePath) - color.Red.Println("Please run 'curl -sSL https://shelltime.xyz/i | bash' first") - return err + if err := ensureHookFile(hookFilePath, EmbeddedBashHook); err != nil { + return fmt.Errorf("failed to ensure bash hook file: %w", err) + } + if err := ensureBashPreexec(filepath.Dir(hookFilePath)); err != nil { + color.Yellow.Printf("Warning: failed to set up bash-preexec: %v\n", err) } if _, err := os.Stat(s.configPath); os.IsNotExist(err) { diff --git a/model/shell.fish.go b/model/shell.fish.go index abde4be..7d1ba0d 100644 --- a/model/shell.fish.go +++ b/model/shell.fish.go @@ -42,6 +42,11 @@ func (s *FishHookService) ShellName() string { } func (s *FishHookService) Install() error { + hookFilePath := os.ExpandEnv(fmt.Sprintf("$HOME/%s/hooks/fish.fish", COMMAND_BASE_STORAGE_FOLDER)) + if err := ensureHookFile(hookFilePath, EmbeddedFishHook); err != nil { + return fmt.Errorf("failed to ensure fish hook file: %w", err) + } + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { return fmt.Errorf("fish config file not found at %s. Please run 'shelltime hooks install'", s.configPath) } diff --git a/model/shell.go b/model/shell.go index ac7b3ce..68ff890 100644 --- a/model/shell.go +++ b/model/shell.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "time" ) @@ -18,6 +19,22 @@ type ShellHookService interface { ShellName() string } +// ensureHookFile writes the embedded hook script to disk if it doesn't already exist. +// This allows Homebrew-installed users to get hook files without the curl installer. +func ensureHookFile(hookFilePath string, embeddedContent []byte) error { + if _, err := os.Stat(hookFilePath); err == nil { + return nil // already exists + } + hookDir := filepath.Dir(hookFilePath) + if err := os.MkdirAll(hookDir, 0755); err != nil { + return fmt.Errorf("failed to create hooks directory: %w", err) + } + if err := os.WriteFile(hookFilePath, embeddedContent, 0644); err != nil { + return fmt.Errorf("failed to write hook file: %w", err) + } + return nil +} + // Common utilities for hook services type BaseHookService struct{} diff --git a/model/shell.zsh.go b/model/shell.zsh.go index c3c40f6..502d39c 100644 --- a/model/shell.zsh.go +++ b/model/shell.zsh.go @@ -38,6 +38,11 @@ func (s *ZshHookService) ShellName() string { } func (s *ZshHookService) Install() error { + hookFilePath := os.ExpandEnv(fmt.Sprintf("$HOME/%s/hooks/zsh.zsh", COMMAND_BASE_STORAGE_FOLDER)) + if err := ensureHookFile(hookFilePath, EmbeddedZshHook); err != nil { + return fmt.Errorf("failed to ensure zsh hook file: %w", err) + } + if _, err := os.Stat(s.configPath); os.IsNotExist(err) { return fmt.Errorf("zsh config file not found at %s. Please run 'shelltime hooks install'", s.configPath) } diff --git a/model/shell_hooks_embed.go b/model/shell_hooks_embed.go new file mode 100644 index 0000000..6988bba --- /dev/null +++ b/model/shell_hooks_embed.go @@ -0,0 +1,12 @@ +package model + +import _ "embed" + +//go:embed hooks/bash.bash +var EmbeddedBashHook []byte + +//go:embed hooks/zsh.zsh +var EmbeddedZshHook []byte + +//go:embed hooks/fish.fish +var EmbeddedFishHook []byte diff --git a/model/sys-desc/shelltime.service b/model/sys-desc/shelltime.service index 3a95f77..9493864 100644 --- a/model/sys-desc/shelltime.service +++ b/model/sys-desc/shelltime.service @@ -4,7 +4,7 @@ After=network.target [Service] Type=simple -ExecStart=/bin/sh -c 'exec $(getent passwd $(id -un) | cut -d: -f7) -l -c "{{.BaseFolder}}/bin/shelltime-daemon"' +ExecStart=/bin/sh -c 'exec $(getent passwd $(id -un) | cut -d: -f7) -l -c "{{.DaemonBinPath}}"' Restart=always # Resource limits diff --git a/model/sys-desc/xyz.shelltime.daemon.plist b/model/sys-desc/xyz.shelltime.daemon.plist index 3d9bbe6..a936882 100644 --- a/model/sys-desc/xyz.shelltime.daemon.plist +++ b/model/sys-desc/xyz.shelltime.daemon.plist @@ -8,7 +8,7 @@ /bin/sh -c - exec $SHELL -l -c '{{.BaseFolder}}/bin/shelltime-daemon' + exec $SHELL -l -c '{{.DaemonBinPath}}' RunAtLoad From 519ea35864300fc41f12b549d895920e51407143 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:53:11 +0000 Subject: [PATCH 2/2] fix(model): add 1-minute timeout and body size limit to bash-preexec download Use http.Client with 1-minute timeout instead of http.Get to prevent hanging on slow/broken networks. Also add io.LimitReader (1MB) to prevent excessive memory usage from unexpected large responses. Co-authored-by: Le He --- model/shell.bash.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/model/shell.bash.go b/model/shell.bash.go index dece87d..3ca7f8d 100644 --- a/model/shell.bash.go +++ b/model/shell.bash.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/gookit/color" ) @@ -20,7 +21,8 @@ func ensureBashPreexec(hooksDir string) error { return nil // already exists } - resp, err := http.Get(bashPreexecURL) + client := &http.Client{Timeout: 1 * time.Minute} + resp, err := client.Get(bashPreexecURL) if err != nil { return fmt.Errorf("failed to download bash-preexec.sh: %w", err) } @@ -30,7 +32,7 @@ func ensureBashPreexec(hooksDir string) error { return fmt.Errorf("failed to download bash-preexec.sh: HTTP %d", resp.StatusCode) } - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) if err != nil { return fmt.Errorf("failed to read bash-preexec.sh response: %w", err) }