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
24 changes: 18 additions & 6 deletions commands/daemon.install.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,27 @@ func commandDaemonInstall(c *cli.Context) error {
}
}

// Resolve daemon binary (Homebrew/PATH preferred, curl-installer fallback)
// Resolve daemon binary (Homebrew/PATH preferred, curl-installer fallback).
// On miss, try to auto-download a matching daemon archive from GitHub
// releases so curl-installer users from before the daemon-bundling fix
// don't have to rerun the installer.
daemonBinPath, err := model.ResolveDaemonBinaryPath()
if err != nil {
color.Red.Println("❌ shelltime-daemon binary 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 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)
		}

color.Yellow.Println("⬇️ Attempting to download matching daemon from GitHub releases...")
daemonBinPath, err = model.EnsureDaemonBinary(c.Context, cliPath, commitID)
if err != nil {
color.Red.Println("❌ shelltime-daemon binary not found and auto-download failed.")
color.Yellow.Printf(" reason: %v\n", err)
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 fmt.Errorf("shelltime-daemon binary not found: %w", err)
}
color.Green.Printf("✅ Downloaded daemon binary to: %s\n", daemonBinPath)
} else {
color.Green.Printf("✅ Found daemon binary at: %s\n", daemonBinPath)
}
color.Green.Printf("✅ Found daemon binary at: %s\n", daemonBinPath)

// If we picked a system-managed binary but a stale curl-installer copy
// still lives under ~/.shelltime/bin, remove it so future resolution
Expand Down
129 changes: 129 additions & 0 deletions model/daemon_fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package model

import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)

// daemonFetchGOOS is a swappable indirection over runtime.GOOS so tests can
// exercise the Windows-aborts branch on Linux/Darwin runners.
var daemonFetchGOOS = runtime.GOOS

// EnsureDaemonBinary returns the path to a usable shelltime-daemon binary,
// downloading it from GitHub releases into the curl-installer location
// (~/.shelltime/bin/shelltime-daemon) when no existing binary is found.
//
// cliBinPath is the resolved path of the running CLI; it is consulted only
// to refuse auto-download for Homebrew installs (those should use
// `brew reinstall`). cliVersion is the CLI's own version (typically
// commands.commitID); when empty or "dev" we fall back to the latest
// GitHub release tag.
func EnsureDaemonBinary(ctx context.Context, cliBinPath, cliVersion string) (string, error) {
if p, err := ResolveDaemonBinaryPath(); err == nil {
return p, nil
}

if daemonFetchGOOS == "windows" {
return "", fmt.Errorf("shelltime-daemon is not built for Windows; please use WSL or run the CLI without the daemon")
}

if cliBinPath != "" && DetectInstallKind(cliBinPath) == InstallKindHomebrew {
return "", fmt.Errorf("shelltime-daemon missing for Homebrew install; run: brew reinstall shelltime/tap/shelltime")
}

tag := normalizeDaemonTag(cliVersion)
if tag == "" {
latest, err := FetchLatestVersion(ctx)
if err != nil {
return "", fmt.Errorf("cannot determine release tag (no CLI version, latest lookup failed): %w", err)
}
tag = latest
}

archiveName, err := BuildArchiveName(runtime.GOOS, runtime.GOARCH)
if err != nil {
return "", err
}

destPath, err := fetchDaemonToCurlPath(ctx, tag, archiveName)
if err == nil {
return destPath, nil
}

// 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.

latest, lerr := FetchLatestVersion(ctx)
if lerr == nil && latest != "" && latest != tag {
if destPath, ferr := fetchDaemonToCurlPath(ctx, latest, archiveName); ferr == nil {
return destPath, nil
}
}
}
return "", err
}

// fetchDaemonToCurlPath downloads, verifies, extracts, and installs the
// shelltime-daemon binary from release `tag` to GetCurlInstallerDaemonPath().
func fetchDaemonToCurlPath(ctx context.Context, tag, archiveName string) (string, error) {
downloadURL := BuildDownloadURL(tag, archiveName)

sum, _, err := FetchChecksum(ctx, tag, archiveName)
if err != nil {
return "", fmt.Errorf("fetch checksum: %w", err)
}
// Empty sum (404 / no entry for this archive) is legitimately absent —
// proceed without verification. Non-nil errors (5xx, network, MITM) MUST
// propagate so an attacker can't silently downgrade us to an unverified
// download.

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

archivePath := filepath.Join(tmpDir, archiveName)
if err := DownloadAndVerify(ctx, downloadURL, sum, archivePath); err != nil {
return "", fmt.Errorf("download daemon archive: %w", err)
}

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

destPath := GetCurlInstallerDaemonPath()
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return "", fmt.Errorf("create bin dir: %w", err)
}
if err := ReplaceBinary(daemonSrc, destPath); err != nil {
return "", fmt.Errorf("install daemon binary: %w", err)
}
return destPath, nil
}

// normalizeDaemonTag returns "" for empty/"dev" inputs (signaling the caller
// to fetch the latest tag), otherwise ensures the result has a leading "v"
// to match goreleaser's tag template.
func normalizeDaemonTag(v string) string {
v = strings.TrimSpace(v)
if v == "" || v == "dev" {
return ""
}
if !strings.HasPrefix(v, "v") {
v = "v" + v
}
return v
}
Loading
Loading