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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Package details view with version selection in interactive `spm add` — press Enter on a search result to see metadata (description, author, license, weekly downloads, GitHub stars) and pick a specific version to install.

## [0.6.2] - 2026-03-30

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Ever joined a project and had to check which package manager it uses before runn
- 🔒 **Security audit** — `spm audit` runs a dependency audit and normalizes output across npm/yarn/pnpm
- ⬆️ **Self-upgrade** — `spm upgrade` updates spm to the latest release from GitHub
- 🔎 **Interactive search** — `spm add` with no args lets you search and pick a package from the npm registry
- 📋 **Package details** — press Enter on a search result to view metadata (license, downloads, stars, repo) and pick a specific version to install
- ✨ **Progress TUI** — `spm install` shows a live spinner with elapsed time and scrolling logs (use `--raw` for raw output)

### Built With
Expand Down
4 changes: 2 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,8 @@ func run(command string, extraArgs []string) error {

args := resolver.Resolve(det.PM, command, extraArgs)

// Use progress TUI for install commands when stdout is a TTY and --raw is not set.
if command == "install" || command == "i" {
// Use progress TUI for install/add commands when stdout is a TTY and --raw is not set.
if command == "install" || command == "i" || command == "add" {
isTTY := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
if isTTY && !rawOutput {
return progress.Run(args, dryRun, vibes, notify)
Expand Down
255 changes: 255 additions & 0 deletions internal/search/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
)

Expand All @@ -16,6 +18,27 @@ type Result struct {
Version string
}

// VersionInfo holds a single npm package version with its publication date.
type VersionInfo struct {
Version string
PublishedAt time.Time
}

// PackageDetails holds detailed npm package information for display.
type PackageDetails struct {
Name string
Description string
Homepage string
Repository string // cleaned https URL
License string
Author string
Latest string
DistTags map[string]string // tag → version (e.g. "latest", "next", "beta")
Versions []VersionInfo // sorted by published date, newest first
WeeklyDownloads int64
Stars int
}

type searchResponse struct {
Objects []struct {
Package struct {
Expand All @@ -26,7 +49,28 @@ type searchResponse struct {
} `json:"objects"`
}

type packageDetailsResponse struct {
Name string `json:"name"`
Description string `json:"description"`
Homepage string `json:"homepage"`
License json.RawMessage `json:"license"`
Author json.RawMessage `json:"author"`
Repository json.RawMessage `json:"repository"`
DistTags map[string]string `json:"dist-tags"`
Time map[string]string `json:"time"`
}

type downloadsResponse struct {
Downloads int64 `json:"downloads"`
Package string `json:"package"`
}

type githubRepoResponse struct {
StargazersCount int `json:"stargazers_count"`
}

var httpClient = &http.Client{Timeout: 5 * time.Second}
var detailsClient = &http.Client{Timeout: 15 * time.Second}

// Query searches the npm registry for packages matching the given text.
func Query(ctx context.Context, text string, size int) ([]Result, error) {
Expand Down Expand Up @@ -66,3 +110,214 @@ func Query(ctx context.Context, text string, size int) ([]Result, error) {

return results, nil
}

// FetchDetails retrieves detailed package information from the npm registry,
// fetching the packument and weekly downloads concurrently, then stars.
func FetchDetails(ctx context.Context, name string) (*PackageDetails, error) {
type packResult struct {
data *packageDetailsResponse
err error
}

packCh := make(chan packResult, 1)
dlCh := make(chan int64, 1)

go func() {
data, err := fetchPackument(ctx, name)
packCh <- packResult{data, err}
}()

go func() {
count, _ := fetchWeeklyDownloads(ctx, name)
dlCh <- count
}()

pack := <-packCh
downloads := <-dlCh

if pack.err != nil {
return nil, pack.err
}

raw := pack.data
repoURL := parseRepoURL(raw.Repository)

// Fetch GitHub stars with a short timeout (best-effort).
var stars int
if ghRepo := extractGitHubRepo(repoURL); ghRepo != "" {
ghCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
stars, _ = fetchGitHubStars(ghCtx, ghRepo)
}

var versions []VersionInfo
for version, timeStr := range raw.Time {
if version == "created" || version == "modified" {
continue
}
t, err := time.Parse(time.RFC3339, timeStr)
if err != nil {
continue
}
versions = append(versions, VersionInfo{Version: version, PublishedAt: t})
}
sort.Slice(versions, func(i, j int) bool {
return versions[i].PublishedAt.After(versions[j].PublishedAt)
})

return &PackageDetails{
Name: raw.Name,
Description: raw.Description,
Homepage: raw.Homepage,
Repository: repoURL,
License: parseRawString(raw.License, "type"),
Author: parseRawString(raw.Author, "name"),
Latest: raw.DistTags["latest"],
DistTags: raw.DistTags,
Versions: versions,
WeeklyDownloads: downloads,
Stars: stars,
}, nil
}

func fetchPackument(ctx context.Context, name string) (*packageDetailsResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://registry.npmjs.org/"+name, nil)
if err != nil {
return nil, err
}
resp, err := detailsClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("npm registry returned %d", resp.StatusCode)
}
var raw packageDetailsResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
return &raw, nil
}

func fetchWeeklyDownloads(ctx context.Context, name string) (int64, error) {
u := "https://api.npmjs.org/downloads/point/last-week/" + url.PathEscape(name)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return 0, err
}
resp, err := httpClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("downloads API returned %d", resp.StatusCode)
}
var dl downloadsResponse
if err := json.NewDecoder(resp.Body).Decode(&dl); err != nil {
return 0, err
}
return dl.Downloads, nil
}

func fetchGitHubStars(ctx context.Context, repo string) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/"+repo, nil)
if err != nil {
return 0, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := httpClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("github API returned %d", resp.StatusCode)
}
var gh githubRepoResponse
if err := json.NewDecoder(resp.Body).Decode(&gh); err != nil {
return 0, err
}
return gh.StargazersCount, nil
}

// parseRepoURL extracts a clean https URL from npm's repository field.
func parseRepoURL(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return cleanRepoURL(s)
}
var obj struct {
URL string `json:"url"`
}
if err := json.Unmarshal(raw, &obj); err == nil {
return cleanRepoURL(obj.URL)
}
return ""
}

func cleanRepoURL(u string) string {
u = strings.TrimPrefix(u, "git+")
u = strings.TrimSuffix(u, ".git")
u = strings.NewReplacer(
"git://github.com/", "https://github.com/",
"ssh://git@github.com/", "https://github.com/",
).Replace(u)
return u
}

// extractGitHubRepo returns "owner/repo" from a GitHub URL, or "".
func extractGitHubRepo(repoURL string) string {
const prefix = "github.com/"
idx := strings.Index(repoURL, prefix)
if idx < 0 {
return ""
}
path := repoURL[idx+len(prefix):]
parts := strings.SplitN(path, "/", 3)
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return ""
}
return parts[0] + "/" + parts[1]
}

// FormatCount formats a large integer compactly (e.g. 47_800_000 → "47.8M").
func FormatCount(n int64) string {
switch {
case n >= 1_000_000:
f := float64(n) / 1_000_000
if f >= 10 {
return fmt.Sprintf("%.0fM", f)
}
return fmt.Sprintf("%.1fM", f)
case n >= 1_000:
f := float64(n) / 1_000
if f >= 10 {
return fmt.Sprintf("%.0fk", f)
}
return fmt.Sprintf("%.1fk", f)
default:
return fmt.Sprintf("%d", n)
}
}

// parseRawString tries to parse a JSON raw message as a plain string first,
// then as an object extracting the given key.
func parseRawString(raw json.RawMessage, objectKey string) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
var obj map[string]string
if err := json.Unmarshal(raw, &obj); err == nil {
return obj[objectKey]
}
return ""
}
Loading
Loading