Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
37 changes: 18 additions & 19 deletions commands/daemon.install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The command returns nil when the daemon binary is not found. It should return an error to ensure the CLI exits with a non-zero status code, which is essential for scripts and automation to detect failures.

Suggested change
return nil
return fmt.Errorf("shelltime-daemon binary not found")

}
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...")
Expand Down
2 changes: 1 addition & 1 deletion commands/daemon.status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion commands/daemon.uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion commands/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions commands/hooks.install.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"fmt"
"os"
"os/exec"

"github.com/gookit/color"
"github.com/malamtime/cli/model"
Expand All @@ -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
}

Expand Down
21 changes: 12 additions & 9 deletions model/daemon-installer.darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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
}
6 changes: 3 additions & 3 deletions model/daemon-installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
14 changes: 8 additions & 6 deletions model/daemon-installer.linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
71 changes: 71 additions & 0 deletions model/hooks/bash.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/bin/bash

# Source bash-preexec.sh if it exists
if [ -f "bash-preexec.sh" ]; then
source "bash-preexec.sh"
Comment on lines +4 to +5
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid sourcing bash-preexec from current directory

The new Bash hook will source bash-preexec.sh from the caller’s current working directory before checking the ShellTime hook directory, which allows arbitrary local files to be executed whenever a user starts a Bash subshell in an untrusted folder. This is a security regression for any user who installs embedded hooks, because a project containing a malicious bash-preexec.sh would be executed implicitly.

Useful? React with 👍 / 👎.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Running shelltime gc synchronously on every shell startup can slow down terminal opening. Backgrounding this task improves shell responsiveness.

Suggested change
shelltime gc
shelltime gc &> /dev/null &

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Executing shelltime track synchronously before every command adds latency to the shell. Backgrounding the process prevents blocking the user's workflow.

Suggested change
shelltime track -s=bash -id=$SESSION_ID -cmd="$CMD" -p=pre --ppid=$PPID &> /dev/null
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The post-command tracking should also be backgrounded to ensure the prompt returns immediately after a command finishes.

Suggested change
shelltime track -s=bash -id=$SESSION_ID -cmd="$CMD" -p=post -r=$LAST_RESULT --ppid=$PPID &> /dev/null
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)
33 changes: 33 additions & 0 deletions model/hooks/fish.fish
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Fish shell, status ppid is a more efficient and built-in way to retrieve the parent process ID compared to spawning an external ps process.

set -g FISH_PPID (status ppid)


# 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Backgrounding the tracking call in Fish prevents latency between command completion and the next prompt. Also, redirecting stderr ensures that any errors from the tracking command don't clutter the terminal.

    shelltime track -s=fish -id=$SESSION_ID -cmd="$argv" -p=post -r=$LAST_RESULT --ppid=$FISH_PPID > /dev/null 2>&1 &

end
34 changes: 34 additions & 0 deletions model/hooks/zsh.zsh
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Backgrounding the tracking call in Zsh ensures that the prompt returns immediately after a command finishes, avoiding any perceived lag.

    shelltime track -s=zsh -id=$SESSION_ID -cmd="$CMD" -p=post -r=$LAST_RESULT --ppid=$PPID &> /dev/null &

}
34 changes: 34 additions & 0 deletions model/path.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package model

import (
"fmt"
"os"
"os/exec"
"path/filepath"
)

Expand Down Expand Up @@ -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)
}
Loading
Loading