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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ brew install shelltime/tap/shelltime
curl -sSL https://shelltime.xyz/i | bash
```

### Upgrading

For curl-installed users, upgrade in place:

```bash
shelltime update
```

Homebrew users should upgrade via brew:

```bash
brew upgrade shelltime/tap/shelltime
```

## Quick Start

The fastest setup path is:
Expand Down Expand Up @@ -58,6 +72,7 @@ shelltime codex install
|---------|-------------|
| `shelltime init` | Bootstrap auth, hooks, daemon, and AI-code integrations |
| `shelltime auth` | Authenticate with `shelltime.xyz` |
| `shelltime update` | Download and install the latest release in place |
| `shelltime doctor` | Check installation and environment health |
| `shelltime web` | Open the ShellTime dashboard in a browser |

Expand Down
1 change: 1 addition & 0 deletions cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func main() {
commands.GrepCommand,
commands.ConfigCommand,
commands.IosCommand,
commands.UpdateCommand,
}
err = app.Run(os.Args)
if err != nil {
Expand Down
194 changes: 194 additions & 0 deletions commands/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package commands

import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"

"github.com/gookit/color"
"github.com/malamtime/cli/model"
"github.com/urfave/cli/v2"
)

var UpdateCommand *cli.Command = &cli.Command{
Name: "update",
Usage: "Download and install the latest shelltime release in place",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "check",
Aliases: []string{"c"},
Usage: "Only report current vs latest version, do not install",
},
&cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
Usage: "Proceed even if already on the latest version or running a dev build",
},
&cli.BoolFlag{
Name: "skip-daemon-reinstall",
Usage: "Skip refreshing the daemon service after replacing binaries",
},
},
Action: commandUpdate,
}

func commandUpdate(c *cli.Context) error {
ctx, span := commandTracer.Start(c.Context, "update")
defer span.End()

check := c.Bool("check")
force := c.Bool("force")
skipDaemonReinstall := c.Bool("skip-daemon-reinstall")

color.Yellow.Println("🔍 Checking for updates...")

cliPath, err := model.ResolveCLIBinaryPath()
if err != nil {
return fmt.Errorf("resolve running binary path: %w", err)
}

switch model.DetectInstallKind(cliPath) {
case model.InstallKindHomebrew:
color.Yellow.Println("📦 Detected Homebrew installation.")
color.Yellow.Println(" Run: brew upgrade shelltime/tap/shelltime")
return nil
case model.InstallKindUnknown:
color.Yellow.Printf("⚠️ Binary at %s is not in a known auto-updatable location.\n", cliPath)
color.Yellow.Println(" Reinstall via the curl installer or Homebrew to enable in-place updates.")
return nil
}

latest, err := model.FetchLatestVersion(ctx)
if err != nil {
return fmt.Errorf("fetch latest release: %w", err)
}

current := commitID
if current == "" {
current = "dev"
}
normalizedLatest := model.NormalizeVersion(latest)
normalizedCurrent := model.NormalizeVersion(current)

color.Cyan.Printf(" Current: %s\n", current)
color.Cyan.Printf(" Latest: %s\n", latest)

if check {
if normalizedLatest == normalizedCurrent {
color.Green.Println("✅ Already on the latest version.")
} else {
color.Yellow.Println("⬆️ An update is available. Run `shelltime update` to install it.")
}
return nil
}

if current == "dev" && !force {
color.Yellow.Println("⚠️ Refusing to overwrite a dev build. Use --force to proceed anyway.")
return nil
}

if normalizedLatest == normalizedCurrent && !force {
color.Green.Println("✅ Already on the latest version. Use --force to reinstall.")
return nil
}

archiveName, err := model.BuildArchiveName(runtime.GOOS, runtime.GOARCH)
if err != nil {
return err
}
downloadURL := model.BuildDownloadURL(latest, archiveName)

expectedSum, ok, err := model.FetchChecksum(ctx, latest, archiveName)
if err != nil {
color.Yellow.Printf("⚠️ Could not fetch checksums.txt: %v (proceeding without verification)\n", err)
} else if !ok {
Comment on lines +104 to +107
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 Fail closed when checksum retrieval errors occur

If FetchChecksum returns an error (network failure, 5xx, throttling), the update continues with expectedSum empty, so DownloadAndVerify performs no integrity check. This creates a fail-open path where checksum verification is silently bypassed for transient or induced errors, which weakens the security guarantees of self-update.

Useful? React with 👍 / 👎.

color.Yellow.Println("⚠️ No checksum entry for this archive — proceeding without verification.")
}
Comment on lines +105 to +109
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.

security-medium medium

The update process currently proceeds even if an error occurs while fetching or parsing the checksum file. While skipping verification for missing checksums (older releases) is intended, failing open on network errors or malformed checksum data is a security risk. It is safer to return an error in these cases to prevent potentially installing a corrupted or tampered binary.

Suggested change
if err != nil {
color.Yellow.Printf("⚠️ Could not fetch checksums.txt: %v (proceeding without verification)\n", err)
} else if !ok {
color.Yellow.Println("⚠️ No checksum entry for this archive — proceeding without verification.")
}
if err != nil {
return fmt.Errorf("fetch checksum: %w", err)
}
if !ok {
color.Yellow.Println("⚠️ No checksum entry for this archive — proceeding without verification.")
}


tmpDir, err := os.MkdirTemp("", "shelltime-update-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)

archivePath := filepath.Join(tmpDir, archiveName)
color.Yellow.Printf("⬇️ Downloading %s ...\n", archiveName)
if err := model.DownloadAndVerify(ctx, downloadURL, expectedSum, archivePath); err != nil {
return fmt.Errorf("download release: %w", err)
}

extractDir := filepath.Join(tmpDir, "extracted")
if err := os.MkdirAll(extractDir, 0o755); err != nil {
return err
}
binaries, err := model.ExtractBinaries(archivePath, extractDir)
if err != nil {
return fmt.Errorf("extract archive: %w", err)
}
if _, ok := binaries["shelltime"]; !ok {
return fmt.Errorf("archive %s did not contain a shelltime binary", archiveName)
}

color.Yellow.Println("🔄 Replacing binaries...")

if err := model.ReplaceBinary(binaries["shelltime"], cliPath); err != nil {
return fmt.Errorf("replace shelltime binary: %w", err)
}
color.Green.Printf(" shelltime -> %s\n", cliPath)

if daemonSrc, ok := binaries["shelltime-daemon"]; ok {
daemonDest := resolveDaemonDest()
if err := model.ReplaceBinary(daemonSrc, daemonDest); err != nil {
return fmt.Errorf("replace shelltime-daemon binary: %w", err)
}
color.Green.Printf(" shelltime-daemon -> %s\n", daemonDest)
}

if shouldReinstallDaemon(ctx, skipDaemonReinstall) {
color.Yellow.Println("🔁 Refreshing daemon service...")
if err := commandDaemonReinstall(c); err != nil {
color.Yellow.Printf("⚠️ Daemon reinstall reported an error: %v\n", err)
Comment on lines +150 to +153
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 Skip daemon reinstall after creating .bak rollback state

Calling commandDaemonReinstall immediately after ReplaceBinary can undo the daemon update for curl installs. ReplaceBinary writes the new daemon to ~/.shelltime/bin/shelltime-daemon and leaves the old one at .bak, then commandDaemonReinstall invokes commandDaemonInstall, which restores .bak over the just-updated daemon. In the common case where the daemon service is running, shelltime update reports success but the daemon binary is reverted to the previous version.

Useful? React with 👍 / 👎.

color.Yellow.Println(" You can rerun `shelltime daemon reinstall` manually.")
}
} else {
color.Yellow.Println("ℹ️ Skipping daemon reinstall. Run `shelltime daemon reinstall` to pick up the new binary.")
}

color.Green.Printf("✅ Updated to %s. Restart your shell to use the new binary.\n", latest)
return nil
}

// resolveDaemonDest returns the path the daemon binary should be written to —
// the existing daemon location if installed, otherwise the curl-installer default.
func resolveDaemonDest() string {
if p, err := model.ResolveDaemonBinaryPath(); err == nil {
return p
}
return filepath.Join(model.GetBinFolderPath(), "shelltime-daemon")
}

// shouldReinstallDaemon decides whether to call commandDaemonReinstall after a
// binary swap.
func shouldReinstallDaemon(_ context.Context, skipFlag bool) bool {
if skipFlag {
return false
}
if runtime.GOOS == "windows" {
return false
}
if _, err := model.ResolveDaemonBinaryPath(); err != nil {
return false
}
installer, err := model.NewDaemonInstaller("", "", "")
if err != nil {
slog.Debug("skip daemon reinstall: installer factory failed", slog.Any("err", err))
return false
}
if err := installer.Check(); err != nil {
return false
}
return true
}
Loading
Loading