Skip to content

feat(commands): add shelltime update self-update command#278

Merged
AnnatarHe merged 1 commit into
mainfrom
claude/add-cli-update-command-AeWpP
May 17, 2026
Merged

feat(commands): add shelltime update self-update command#278
AnnatarHe merged 1 commit into
mainfrom
claude/add-cli-update-command-AeWpP

Conversation

@AnnatarHe
Copy link
Copy Markdown
Contributor

Summary

Adds a top-level shelltime update command that downloads the latest release archive from GitHub, verifies its SHA256, extracts the bundled binaries, and replaces the running install in place — mirroring what install.bash does, but without leaving the shell.

  • New command lives at commands/update.go; pure logic (URL builder, archive name builder, checksum parser, archive extractor, replace-in-place) lives at model/updater.go with table-driven tests at model/updater_test.go.
  • Downloads the same release URL pattern the curl installer uses: https://github.com/shelltime/cli/releases/download/<tag>/cli_<OS>_<ARCH>.<ext>.
  • Verifies the archive's SHA256 against checksums.txt from the same release. Warns and proceeds if checksums.txt is missing (older releases).
  • Extracts only the known binary names (shelltime, shelltime-daemon, and their .exe variants); rejects anything else as defense-in-depth against zip-slip / tar path traversal. Caps extracted-entry size at 200 MB to defeat zip-bombs.
  • Replaces existing binaries using the .bak rename dance — same scheme commands/daemon.install.go already uses to recover on upgrades.
  • Detects Homebrew installs by resolving the executable through symlinks and matching /Cellar/, /opt/homebrew/, or /home/linuxbrew/.linuxbrew/ — those users get a brew upgrade shelltime/tap/shelltime hint and the command exits without touching the binary. Binaries at unrecognized locations (e.g. someone manually placed shelltime in /usr/bin/) are left alone with a warning.
  • After replacement, automatically runs commandDaemonReinstall so systemd/launchd reloads the new binary. Skipped on Windows, when the daemon isn't installed, or when --skip-daemon-reinstall is passed.

Flags

  • --check / -c — print current vs latest version, do not install.
  • --force / -f — proceed even on a dev build or when already on the latest version.
  • --skip-daemon-reinstall — don't run daemon reinstall after the binary swap.

Why

Today users can only upgrade by re-running the curl installer or brew upgrade. shelltime update makes upgrades a single in-shell command and keeps the daemon service in sync with the new binary automatically.

Test plan

  • go build ./cmd/cli/main.go builds cleanly.
  • go test ./model/... passes (all new TestUpdater* cases — URL builder matrix, checksum parser happy/missing/malformed, zip-slip guard, normalize-version, install-kind classifier, zip extraction, tar.gz extraction, replace-binary with and without existing target).
  • gofmt -l clean on all new / modified files.
  • shelltime update --help lists the three flags.
  • Smoke test: running the binary from an unrecognized path prints the "not auto-updatable" warning and exits 0.
  • Smoke test: running the binary from ~/.shelltime/bin/shelltime proceeds past install-kind detection and attempts the GitHub API call.
  • End-to-end (manual, in a real environment): install an older release via the curl installer, run shelltime update, confirm the binary is replaced (shelltime --version before/after), confirm ~/.shelltime/bin/shelltime.bak exists, confirm the daemon service restarts (systemctl --user status shelltime-daemon on Linux, launchctl list | grep shelltime on macOS).
  • Manual smoke test on macOS to confirm the replacement binary is notarized.

Generated by Claude Code

Adds a top-level `shelltime update` command that downloads the latest
release archive from GitHub, verifies its SHA256 against checksums.txt,
extracts the bundled shelltime / shelltime-daemon binaries, and replaces
the running install in place. After the swap it runs
`shelltime daemon reinstall` so systemd/launchd picks up the new binary
(opt-out via --skip-daemon-reinstall).

Homebrew installs are detected (via the resolved executable path
containing /Cellar/, /opt/homebrew/, or /home/linuxbrew/.linuxbrew/) and
redirected to `brew upgrade shelltime/tap/shelltime` instead of being
overwritten. Binaries at unrecognized locations are left untouched with a
warning.

Flags: --check (dry run), --force (override same-version / dev-build
guards), --skip-daemon-reinstall.

Pure logic (URL/archive-name builder, checksum parser, archive
extractor with zip-slip guard, .bak-rename replace) lives in
model/updater.go with table-driven tests in model/updater_test.go.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 17, 2026

Codecov Report

❌ Patch coverage is 31.90883% with 239 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
model/updater.go 45.16% 117 Missing and 19 partials ⚠️
commands/update.go 0.00% 102 Missing ⚠️
cmd/cli/main.go 0.00% 1 Missing ⚠️
Flag Coverage Δ
unittests 39.48% <31.90%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
cmd/cli/main.go 0.00% <0.00%> (ø)
commands/update.go 0.00% <0.00%> (ø)
model/updater.go 45.16% <45.16%> (ø)

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 865dca96e2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread commands/update.go
Comment on lines +150 to +153
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)
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 👍 / 👎.

Comment thread commands/update.go
Comment on lines +104 to +107
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 {
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 👍 / 👎.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements an in-place update mechanism for the ShellTime CLI, including a new update command and supporting logic in the model package to fetch, verify, and install releases from GitHub. Feedback from the review focuses on enhancing security and robustness: it is recommended to fail the update process on checksum retrieval errors rather than failing open, to use io.LimitReader when decoding API responses to mitigate potential denial-of-service risks, and to explicitly handle errors when managing backup files during binary replacement.

Comment thread commands/update.go
Comment on lines +105 to +109
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.")
}
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.")
}

Comment thread model/updater.go
}

var rel LatestRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != 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.

security-medium medium

When decoding responses from external APIs, it is a best practice to use an io.LimitReader to prevent potential denial-of-service attacks from excessively large response bodies.

Suggested change
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&rel); err != nil {

Comment thread model/updater.go
// old inode alive for the current process.
func ReplaceBinary(srcPath, destPath string) error {
bak := destPath + ".bak"
_ = os.Remove(bak)
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

Ignoring the error from os.Remove(bak) can lead to a failure in the subsequent os.Rename(destPath, bak) if the backup file exists but cannot be removed (e.g., due to permission issues or the file being in use). It is better to explicitly handle errors that are not 'file does not exist'.

	if err := os.Remove(bak); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("remove old backup: %w", err)
	}

@AnnatarHe AnnatarHe merged commit 74e4e86 into main May 17, 2026
9 of 10 checks passed
@AnnatarHe AnnatarHe deleted the claude/add-cli-update-command-AeWpP branch May 17, 2026 04:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants