Skip to content

Commit 74e4e86

Browse files
authored
Merge pull request #278 from shelltime/claude/add-cli-update-command-AeWpP
feat(commands): add `shelltime update` self-update command
2 parents 5351071 + 865dca9 commit 74e4e86

5 files changed

Lines changed: 934 additions & 0 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ brew install shelltime/tap/shelltime
2121
curl -sSL https://shelltime.xyz/i | bash
2222
```
2323

24+
### Upgrading
25+
26+
For curl-installed users, upgrade in place:
27+
28+
```bash
29+
shelltime update
30+
```
31+
32+
Homebrew users should upgrade via brew:
33+
34+
```bash
35+
brew upgrade shelltime/tap/shelltime
36+
```
37+
2438
## Quick Start
2539

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

cmd/cli/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ func main() {
104104
commands.GrepCommand,
105105
commands.ConfigCommand,
106106
commands.IosCommand,
107+
commands.UpdateCommand,
107108
}
108109
err = app.Run(os.Args)
109110
if err != nil {

commands/update.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package commands
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"os"
8+
"path/filepath"
9+
"runtime"
10+
11+
"github.com/gookit/color"
12+
"github.com/malamtime/cli/model"
13+
"github.com/urfave/cli/v2"
14+
)
15+
16+
var UpdateCommand *cli.Command = &cli.Command{
17+
Name: "update",
18+
Usage: "Download and install the latest shelltime release in place",
19+
Flags: []cli.Flag{
20+
&cli.BoolFlag{
21+
Name: "check",
22+
Aliases: []string{"c"},
23+
Usage: "Only report current vs latest version, do not install",
24+
},
25+
&cli.BoolFlag{
26+
Name: "force",
27+
Aliases: []string{"f"},
28+
Usage: "Proceed even if already on the latest version or running a dev build",
29+
},
30+
&cli.BoolFlag{
31+
Name: "skip-daemon-reinstall",
32+
Usage: "Skip refreshing the daemon service after replacing binaries",
33+
},
34+
},
35+
Action: commandUpdate,
36+
}
37+
38+
func commandUpdate(c *cli.Context) error {
39+
ctx, span := commandTracer.Start(c.Context, "update")
40+
defer span.End()
41+
42+
check := c.Bool("check")
43+
force := c.Bool("force")
44+
skipDaemonReinstall := c.Bool("skip-daemon-reinstall")
45+
46+
color.Yellow.Println("🔍 Checking for updates...")
47+
48+
cliPath, err := model.ResolveCLIBinaryPath()
49+
if err != nil {
50+
return fmt.Errorf("resolve running binary path: %w", err)
51+
}
52+
53+
switch model.DetectInstallKind(cliPath) {
54+
case model.InstallKindHomebrew:
55+
color.Yellow.Println("📦 Detected Homebrew installation.")
56+
color.Yellow.Println(" Run: brew upgrade shelltime/tap/shelltime")
57+
return nil
58+
case model.InstallKindUnknown:
59+
color.Yellow.Printf("⚠️ Binary at %s is not in a known auto-updatable location.\n", cliPath)
60+
color.Yellow.Println(" Reinstall via the curl installer or Homebrew to enable in-place updates.")
61+
return nil
62+
}
63+
64+
latest, err := model.FetchLatestVersion(ctx)
65+
if err != nil {
66+
return fmt.Errorf("fetch latest release: %w", err)
67+
}
68+
69+
current := commitID
70+
if current == "" {
71+
current = "dev"
72+
}
73+
normalizedLatest := model.NormalizeVersion(latest)
74+
normalizedCurrent := model.NormalizeVersion(current)
75+
76+
color.Cyan.Printf(" Current: %s\n", current)
77+
color.Cyan.Printf(" Latest: %s\n", latest)
78+
79+
if check {
80+
if normalizedLatest == normalizedCurrent {
81+
color.Green.Println("✅ Already on the latest version.")
82+
} else {
83+
color.Yellow.Println("⬆️ An update is available. Run `shelltime update` to install it.")
84+
}
85+
return nil
86+
}
87+
88+
if current == "dev" && !force {
89+
color.Yellow.Println("⚠️ Refusing to overwrite a dev build. Use --force to proceed anyway.")
90+
return nil
91+
}
92+
93+
if normalizedLatest == normalizedCurrent && !force {
94+
color.Green.Println("✅ Already on the latest version. Use --force to reinstall.")
95+
return nil
96+
}
97+
98+
archiveName, err := model.BuildArchiveName(runtime.GOOS, runtime.GOARCH)
99+
if err != nil {
100+
return err
101+
}
102+
downloadURL := model.BuildDownloadURL(latest, archiveName)
103+
104+
expectedSum, ok, err := model.FetchChecksum(ctx, latest, archiveName)
105+
if err != nil {
106+
color.Yellow.Printf("⚠️ Could not fetch checksums.txt: %v (proceeding without verification)\n", err)
107+
} else if !ok {
108+
color.Yellow.Println("⚠️ No checksum entry for this archive — proceeding without verification.")
109+
}
110+
111+
tmpDir, err := os.MkdirTemp("", "shelltime-update-*")
112+
if err != nil {
113+
return fmt.Errorf("create temp dir: %w", err)
114+
}
115+
defer os.RemoveAll(tmpDir)
116+
117+
archivePath := filepath.Join(tmpDir, archiveName)
118+
color.Yellow.Printf("⬇️ Downloading %s ...\n", archiveName)
119+
if err := model.DownloadAndVerify(ctx, downloadURL, expectedSum, archivePath); err != nil {
120+
return fmt.Errorf("download release: %w", err)
121+
}
122+
123+
extractDir := filepath.Join(tmpDir, "extracted")
124+
if err := os.MkdirAll(extractDir, 0o755); err != nil {
125+
return err
126+
}
127+
binaries, err := model.ExtractBinaries(archivePath, extractDir)
128+
if err != nil {
129+
return fmt.Errorf("extract archive: %w", err)
130+
}
131+
if _, ok := binaries["shelltime"]; !ok {
132+
return fmt.Errorf("archive %s did not contain a shelltime binary", archiveName)
133+
}
134+
135+
color.Yellow.Println("🔄 Replacing binaries...")
136+
137+
if err := model.ReplaceBinary(binaries["shelltime"], cliPath); err != nil {
138+
return fmt.Errorf("replace shelltime binary: %w", err)
139+
}
140+
color.Green.Printf(" shelltime -> %s\n", cliPath)
141+
142+
if daemonSrc, ok := binaries["shelltime-daemon"]; ok {
143+
daemonDest := resolveDaemonDest()
144+
if err := model.ReplaceBinary(daemonSrc, daemonDest); err != nil {
145+
return fmt.Errorf("replace shelltime-daemon binary: %w", err)
146+
}
147+
color.Green.Printf(" shelltime-daemon -> %s\n", daemonDest)
148+
}
149+
150+
if shouldReinstallDaemon(ctx, skipDaemonReinstall) {
151+
color.Yellow.Println("🔁 Refreshing daemon service...")
152+
if err := commandDaemonReinstall(c); err != nil {
153+
color.Yellow.Printf("⚠️ Daemon reinstall reported an error: %v\n", err)
154+
color.Yellow.Println(" You can rerun `shelltime daemon reinstall` manually.")
155+
}
156+
} else {
157+
color.Yellow.Println("ℹ️ Skipping daemon reinstall. Run `shelltime daemon reinstall` to pick up the new binary.")
158+
}
159+
160+
color.Green.Printf("✅ Updated to %s. Restart your shell to use the new binary.\n", latest)
161+
return nil
162+
}
163+
164+
// resolveDaemonDest returns the path the daemon binary should be written to —
165+
// the existing daemon location if installed, otherwise the curl-installer default.
166+
func resolveDaemonDest() string {
167+
if p, err := model.ResolveDaemonBinaryPath(); err == nil {
168+
return p
169+
}
170+
return filepath.Join(model.GetBinFolderPath(), "shelltime-daemon")
171+
}
172+
173+
// shouldReinstallDaemon decides whether to call commandDaemonReinstall after a
174+
// binary swap.
175+
func shouldReinstallDaemon(_ context.Context, skipFlag bool) bool {
176+
if skipFlag {
177+
return false
178+
}
179+
if runtime.GOOS == "windows" {
180+
return false
181+
}
182+
if _, err := model.ResolveDaemonBinaryPath(); err != nil {
183+
return false
184+
}
185+
installer, err := model.NewDaemonInstaller("", "", "")
186+
if err != nil {
187+
slog.Debug("skip daemon reinstall: installer factory failed", slog.Any("err", err))
188+
return false
189+
}
190+
if err := installer.Check(); err != nil {
191+
return false
192+
}
193+
return true
194+
}

0 commit comments

Comments
 (0)