Skip to content

fix(daemon): auto-download shelltime-daemon when missing on install#281

Merged
AnnatarHe merged 2 commits into
mainfrom
claude/fix-linux-daemon-binary-ZX5pQ
May 20, 2026
Merged

fix(daemon): auto-download shelltime-daemon when missing on install#281
AnnatarHe merged 2 commits into
mainfrom
claude/fix-linux-daemon-binary-ZX5pQ

Conversation

@AnnatarHe
Copy link
Copy Markdown
Contributor

Summary

Fixes Linux users hitting shelltime-daemon binary not found on shelltime daemon reinstall.

Root cause: users who ran the curl installer before shelltime/installation@36a09e9 (Apr 17 2026) ended up with no daemon binary — the old script downloaded from the legacy malamtime/cli URL (404) and even when extraction worked it renamed shelltime-daemon to .bak. Today, those users have to know to rerun the installer to recover. On macOS, the Homebrew formula installs both binaries, so the issue doesn't manifest.

This PR makes shelltime daemon install/reinstall self-heal: if the daemon binary can't be resolved locally, the CLI now downloads the matching release archive from GitHub and extracts shelltime-daemon to ~/.shelltime/bin/shelltime-daemon before continuing with service setup.

  • New model.EnsureDaemonBinary(ctx, cliBinPath, cliVersion):
    • Returns immediately when ResolveDaemonBinaryPath() already finds a binary (fast path).
    • Refuses to auto-download for Homebrew installs (suggests brew reinstall shelltime/tap/shelltime) and for Windows (no daemon binary built).
    • Downloads the archive matching the CLI's own version; falls back to FetchLatestVersion for dev builds or 404'd tags.
    • Reuses existing DownloadAndVerify / ExtractBinaries / ReplaceBinary plumbing from update.go.
    • Creates ~/.shelltime/bin on demand (covers Linuxbrew users who never ran the curl installer).
  • commands/daemon.install.go now calls EnsureDaemonBinary after the existing .bak restore logic and the original ResolveDaemonBinaryPath lookup — the stale-curl cleanup block stays correct because a freshly-downloaded binary lives at GetCurlInstallerDaemonPath().
  • model/updater.go exposes githubAPIBaseURL / githubReleaseBaseURL as package vars so tests can point them at an httptest.Server. No production behavior change.

Paired with the install.bash hardening in shelltime/installation (warn when the daemon binary is missing from the extracted archive).

Test plan

  • go build ./... clean
  • gofmt -d clean on all touched files
  • go test ./model/... — all model tests pass, including 11 new TestEnsureDaemon_* cases (existing binary short-circuit, download-when-missing, Homebrew aborts, Windows aborts, version selection, dev → latest fallback, network error, archive missing daemon, creates bin dir, 404 → latest fallback, tag normalization)
  • Built shelltime smoke-tested locally (shelltime daemon --help lists reinstall etc.)
  • Manual on Linux: delete ~/.shelltime/bin/shelltime-daemon*, run shelltime daemon reinstall, confirm download happens and service starts under systemctl --user
  • Manual on Homebrew (Mac or Linuxbrew): delete the daemon under $(brew --prefix)/bin/, run shelltime daemon reinstall, confirm we point at brew reinstall and do not drop a binary into ~/.shelltime/bin/

https://claude.ai/code/session_01ThKSJA9sGXQm8jMeCZZdYY


Generated by Claude Code

When `shelltime daemon install/reinstall` cannot find the daemon binary
locally, fetch the matching release archive from GitHub and extract
shelltime-daemon to ~/.shelltime/bin/. This recovers curl-installer users
who installed before commit shelltime/installation@36a09e9 (where the
daemon was renamed to .bak and download URLs 404'd) without forcing them
to rerun the installer.

- Add model.EnsureDaemonBinary: resolves locally first; on miss downloads
  the archive for the CLI's own version (falling back to latest for dev
  builds or 404'd tags), extracts shelltime-daemon, places it at
  ~/.shelltime/bin/shelltime-daemon via existing ReplaceBinary.
- Refuse auto-download for Homebrew installs (point users at
  `brew reinstall`) and for Windows (no daemon binary built).
- Make github base URLs swappable for tests in updater.go.
- Wire the new helper into commands/daemon.install.go after the existing
  ResolveDaemonBinaryPath() call so the .bak restore path still runs.

https://claude.ai/code/session_01ThKSJA9sGXQm8jMeCZZdYY
@codecov
Copy link
Copy Markdown

codecov Bot commented May 19, 2026

Codecov Report

❌ Patch coverage is 64.93506% with 27 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
model/daemon_fetch.go 76.27% 7 Missing and 7 partials ⚠️
commands/daemon.install.go 0.00% 13 Missing ⚠️
Flag Coverage Δ
unittests 40.37% <64.93%> (?)

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

Files with missing lines Coverage Δ
model/updater.go 64.51% <100.00%> (+19.35%) ⬆️
commands/daemon.install.go 0.00% <0.00%> (ø)
model/daemon_fetch.go 76.27% <76.27%> (ø)

... and 1 file 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
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 introduces an automatic download mechanism for the shelltime-daemon binary when it is missing during installation. It adds logic to fetch the appropriate version from GitHub releases, handles platform-specific restrictions (e.g., Windows and Homebrew), and includes comprehensive tests for the new functionality. Feedback was provided regarding the brittle nature of error string matching for 404 responses, the security implications of ignoring checksum fetch errors, and the lack of error handling when resolving the CLI binary path.

color.Yellow.Println("Or via curl installer: curl -sSL https://shelltime.xyz/i | bash")
return fmt.Errorf("shelltime-daemon binary not found: %w", err)
color.Yellow.Println("⚠️ shelltime-daemon binary not found locally.")
cliPath, _ := model.ResolveCLIBinaryPath()
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 error from ResolveCLIBinaryPath is ignored. While this function is generally reliable, an error here would result in an empty cliPath, which causes EnsureDaemonBinary to skip the Homebrew installation check. It's safer to handle the error or at least log a warning.

		cliPath, err := model.ResolveCLIBinaryPath()
		if err != nil {
			color.Yellow.Printf("⚠️  Could not resolve CLI path: %v\n", err)
		}

Comment thread model/daemon_fetch.go
}

// Fall back once to latest if the tagged release 404'd (deleted/yanked).
if strings.Contains(err.Error(), "status 404") {
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

Checking for "status 404" in the error string is brittle as it depends on the exact error message format returned by DownloadAndVerify in updater.go. If that message changes, the fallback logic will break. Consider having DownloadAndVerify return a structured error or a specific error type to make this check more robust.

Comment thread model/daemon_fetch.go Outdated
func fetchDaemonToCurlPath(ctx context.Context, tag, archiveName string) (string, error) {
downloadURL := BuildDownloadURL(tag, archiveName)

sum, _, _ := FetchChecksum(ctx, tag, archiveName)
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

Ignoring the error from FetchChecksum means that any network failure or API error (other than a 404) while retrieving the checksum will result in the download proceeding without integrity verification. This creates a potential security risk where a compromised or intercepted download could go undetected. It is recommended to handle non-404 errors explicitly rather than treating all errors as "checksum not found".

@AnnatarHe
Copy link
Copy Markdown
Contributor Author

@claude review it

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 20, 2026

Claude finished @AnnatarHe's task in 2m 35s —— View job


Claude Code Review

  • Gather context from PR description and review comments
  • Read changed files (commands/daemon.install.go, model/daemon_fetch.go, model/daemon_fetch_test.go, model/updater.go)
  • Analyze the implementation and identify issues
  • Post review feedback

Overall: the self-heal flow is the right shape — fast-path on ResolveDaemonBinaryPath, refuse to clobber Homebrew installs, normalize tags, and reuse the existing update.go plumbing. Tests are thorough for the new model/daemon_fetch.go. The biggest concerns are real but small: a security trade-off on checksum failures, a brittle error-string match, and zero coverage on the commands/ orchestration. Nothing is a blocker.

Findings

1. FetchChecksum errors are silently swallowed — security regression vs. shelltime update  blocking-ish

model/daemon_fetch.go:74:

sum, _, _ := FetchChecksum(ctx, tag, archiveName)
// FetchChecksum errors and absent checksums are both non-fatal; we proceed
// without verification, matching the `shelltime update` behavior.

The comment isn't quite right. FetchChecksum distinguishes three cases:

  • 404 → ("", false, nil) (checksum doesn't exist for this release — expected for old releases)
  • 200 with parse → (sha, true, nil)
  • network error / 5xx / malformed body → ("", false, err)

By throwing away the error, you collapse case 3 into case 1. A MITM (or a transient 502 from GitHub) silently downgrades you to unverified download. The narrow fix is to treat only (_, false, nil) as "skip verification" and surface the other errors:

sum, found, err := FetchChecksum(ctx, tag, archiveName)
if err != nil {
    return "", fmt.Errorf("fetch checksum: %w", err)
}
if !found {
    // legitimately absent (old release) — proceed unverified, but warn
}

Fix this →


2. strings.Contains(err.Error(), "status 404") is brittle  nit, agree with gemini

model/daemon_fetch.go:58 and the producer at model/updater.go:228 ("download %s returned status %d"). One typo in a future cleanup of the error string and the fallback silently stops working. Easy hardening:

// updater.go
var ErrAssetNotFound = errors.New("release asset not found")

if resp.StatusCode == http.StatusNotFound {
    return fmt.Errorf("%w: %s", ErrAssetNotFound, url)
}

Then errors.Is(err, ErrAssetNotFound) at the callsite. Same change is worth applying to FetchLatestVersion if you want to surface "no releases yet" cleanly.

Fix this →


3. ResolveCLIBinaryPath() error swallowed  agree with gemini, minor

commands/daemon.install.go:49: if os.Executable() errors, cliPath == "" and the Homebrew guard in EnsureDaemonBinary is bypassed — we could end up dropping a binary into ~/.shelltime/bin for a user whose real binary lives under /opt/homebrew/. Unlikely in practice but cheap to log:

cliPath, err := model.ResolveCLIBinaryPath()
if err != nil {
    color.Yellow.Printf("⚠️  could not resolve CLI path (%v) — Homebrew detection may be inaccurate\n", err)
}

4. The latest-tag fallback discards the second error  nit

model/daemon_fetch.go:58-66:

if destPath, ferr := fetchDaemonToCurlPath(ctx, latest, archiveName); ferr == nil {
    return destPath, nil
}
// ferr lost; we return the original 404 err

If the fallback also fails (say the latest archive is missing the daemon binary), the user only sees "status 404 for v0.1.83" with no hint that we tried v9.9.9 too. Prefer errors.Join(err, ferr) or at least include the latest tag in the surface message.


5. commands/daemon.install.go has 0% patch coverage  follow-up

Codecov calls this out. The orchestration (.bak restore → resolve → ensure → stale-curl cleanup) is the exact code path the bug originally hit, and none of it is exercised. A function with the filesystem-touching bits extracted (e.g. prepareDaemonBinary(ctx, baseFolder, commitID) (string, error)) would be table-testable. Not blocking this PR but worth a follow-up issue.


6. A commit SHA passed as version goes through silently  edge case

normalizeDaemonTag("99d7f48") returns "v99d7f48", the download 404s, then the fallback grabs latest. Functionally fine, but the user sees a misleading first attempt. If anywhere in the build pipeline version is set to the commit SHA by mistake (it currently isn't, but the existing variable is misleadingly named commitID in the commands package), this would mask the misconfiguration. Consider rejecting non-semver-looking values up-front so they take the dev → latest path instead of the failed-tag path.


7. Tests mutate package-level globals  nit

pointURLsAt swaps githubAPIBaseURL / githubReleaseBaseURL, and isolateDaemonFetchEnv swaps daemonHomebrewSearchPaths / daemonFetchGOOS. None of these tests use t.Parallel() today so it's fine, but it's an easy footgun if anyone adds t.Parallel() later. A short comment on each global noting "guarded by test serialization" would help.


Things done well

  • The fast-path ResolveDaemonBinaryPath short-circuit means existing Homebrew/curl users pay zero network cost on reinstall.
  • Cross-FS-safe moveFile in ReplaceBinary (handles the /tmp$HOME case on Linux with separate mounts).
  • safeExtractPath defends against zip-slip — important when extracting attacker-influenced archive entry names.
  • The daemonFetchGOOS indirection is a clean way to exercise the Windows-aborts branch on Linux CI.
  • Test fixture is well-scoped: real httptest.Server, real archive bytes, real DownloadAndVerify/ExtractBinaries path — not over-mocked.
  • The 404-fallback-to-latest is exactly right for the deleted-release case.

Suggested commit message for follow-up fix

fix(model): propagate FetchChecksum non-404 errors and use sentinel for 404 fallback
· Branch

Previously fetchDaemonToCurlPath discarded the FetchChecksum error,
treating any failure the same as a legitimately-absent checksum (404 /
no entry for this archive). That meant a transient 5xx from the GitHub
CDN — or an attacker blocking/tampering with checksums.txt — could
silently downgrade the install to an unverified download.

Now only ("", false, nil) is treated as absent. Real errors propagate
out of EnsureDaemonBinary, the temp dir is cleaned via defer, and no
daemon binary is written to ~/.shelltime/bin/.

https://claude.ai/code/session_01ThKSJA9sGXQm8jMeCZZdYY
@AnnatarHe AnnatarHe merged commit 7b71e01 into main May 20, 2026
2 checks passed
@AnnatarHe AnnatarHe deleted the claude/fix-linux-daemon-binary-ZX5pQ branch May 20, 2026 03:11
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