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
110 changes: 85 additions & 25 deletions SEARCH_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ truth for the response schemas.
| ----------------- | ----------------------------------------------------------------------------------------------------- |
| `GET /v2/resolve` | Writing `devbox.lock` (`devbox add`, `devbox update`, `devbox install`), `devbox init --auto` |
| `GET /v1/resolve` | `devbox search <name>@<version>`, `devbox info`, and `devbox.lock` when `DEVBOX_FEATURE_RESOLVE_V2=0` |
| `GET /v1/search` | `devbox search <query>` |
| `GET /v2/search` | `devbox search <query>` |
| `GET /v2/pkg` | `devbox search <query>`, once for each search result without `releases` |

## Conventions

Expand Down Expand Up @@ -175,47 +176,106 @@ When `/v1/resolve` is used to write a lock file, `resolved` is
`github:NixOS/nixpkgs/<commit_hash>#<attr_paths[0]>` from the same system
selection described for `/v2/resolve`.

## `GET /v1/search`
## `GET /v2/search`

Free-text package search, used by `devbox search <query>`.
Free-text package search, used by `devbox search <query>`. Each result
carries its newest releases and the systems each runs on, so one request is
enough.

### Parameters

| Name | Required | Description |
| ---- | -------- | -------------------------------------------------------------- |
| `q` | yes | Search query. Devbox never sends an empty query. |
| Name | Required | Description |
| ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `q` | yes | Search query. Devbox never sends an empty query. |
| `releases` | yes | How many of each result's newest releases to return, as a positive integer, or `all`. Devbox sends `11` to show 10 and know there are more, and `all` with `--show-all`. |

### Response

```json
{
"num_results": 14,
"packages": [
"query": "hello",
"total_results": 15,
"results": [
{
"name": "hello",
"num_versions": 5,
"versions": [
{ "name": "hello", "version": "2.12.3", "summary": "Program that produces a familiar, friendly greeting" },
{ "name": "hello", "version": "2.12.2" }
"summary": "Program that produces a familiar, friendly greeting",
"last_updated": "2026-10-17T03:39:59Z",
"releases": [
{
"version": "2.12.3",
"systems": ["aarch64-darwin", "aarch64-linux", "x86_64-darwin", "x86_64-linux"]
}
]
}
]
}
```

| Field | Required | Description |
| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------ |
| `num_results` | yes | Result count, displayed as `Found <n>+ results`. |
| `packages` | yes | Matching packages, most relevant first. An empty list prints `No results found`. |
| `packages[].name` | yes | Package name, as a user would pass it to `devbox add`. |
| `packages[].num_versions` | yes | Total versions available. Used to decide whether to show a `...` after truncated version lists. |
| `packages[].versions` | yes | Available versions, newest first. Entries use the same shape as the `/v1/resolve` response. |
| `packages[].versions[].version` | yes | Version string to display. Empty versions are skipped. |
| Field | Required | Description |
| ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
| `total_results` | yes | Total number of matches, displayed as `Found <n>+ results`. |
| `results` | yes | Matching packages, most relevant first. An empty list prints `No results found`. |
| `results[].name` | yes | Package name, as a user would pass it to `devbox add`. Passed to `/v2/pkg` as `name`. |
| `results[].releases` | no | The package's newest releases, newest first, at most as many as `releases` asked for. Without it, Devbox calls `/v2/pkg` for the package. |
| `results[].releases[].version` | yes | Version string to display. Releases with an empty version are skipped. |
| `results[].releases[].systems` | yes | Nix systems the release is available on, such as `aarch64-darwin`. May be empty. |
| `query`, `results[].summary`, `results[].last_updated`, and other result fields | no | Informational. Not read by Devbox. |

## `GET /v2/pkg`

Returns every release of a single package, with the platforms each release is
available on. Devbox calls it, several at a time, only for `/v2/search` results
without `releases`, so a service that returns `releases` doesn't need it.

### Parameters

| Name | Required | Description |
| ------ | -------- | ------------------------------------------------ |
| `name` | yes | Exact package name, from `/v2/search` `results[].name`. |

### Response

```json
{
"name": "hello",
"summary": "Program that produces a familiar, friendly greeting",
"homepage_url": "https://www.gnu.org/software/hello/manual/",
"license": "GPL-3.0-or-later",
"releases": [
{
"version": "2.12.3",
"last_updated": "2026-10-17T03:39:59Z",
"platforms": [
{ "arch": "arm64", "os": "macOS" },
{ "arch": "arm64", "os": "Linux" },
{ "arch": "x86-64", "os": "macOS" },
{ "arch": "x86-64", "os": "Linux" }
]
}
]
}
```

Devbox displays packages and versions in the order returned and doesn't
re-sort them, so ranking is entirely up to the server. By default it shows the
first 10 packages and the first 10 versions of each. `--show-all` shows
everything returned.
| Field | Required | Description |
| ------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `name` | yes | Package name to display. |
| `releases` | yes | Available versions, newest first. |
| `releases[].version` | yes | Version string to display. Releases with an empty version are skipped. |
| `releases[].platforms` | yes | Platforms the release is available on. May be empty. |
| `releases[].platforms[].arch` | yes | CPU architecture, such as `arm64` or `x86-64`. Devbox displays `arm64` as `aarch64` and `-` as `_`. |
| `releases[].platforms[].os` | yes | Operating system, such as `Linux` or `macOS`. Devbox lowercases it and displays `macOS` as `darwin`. |
| `summary`, `homepage_url`, `license`, `releases[].last_updated`, and other platform fields | no | Informational. Not read by Devbox. |

A package whose lookup fails, including with `404 Not Found`, is dropped from
the search results. The search fails only when every lookup fails with
something other than a `404`.

Devbox shows each package's versions next to the Nix systems they are available
on (for example `aarch64-darwin`), grouping consecutive releases that have the
same platforms. It displays packages and releases in the order returned and
doesn't re-sort them, so ranking is entirely up to the server. By default it
shows the first 10 packages and the first 10 versions of each. `--show-all`
shows everything returned.

## Testing a search service

Expand All @@ -224,7 +284,7 @@ Point Devbox at the server and exercise each endpoint:
```sh
export DEVBOX_SEARCH_HOST=http://localhost:8080

devbox search ripgrep # /v1/search
devbox search ripgrep # /v2/search, and /v2/pkg without releases
devbox search go@1.21 # /v1/resolve
devbox info hello # /v1/resolve
devbox init && devbox add hello # /v2/resolve, check devbox.lock
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/hashicorp/go-envparse v0.1.0
github.com/jedib0t/go-pretty/v6 v6.8.3
github.com/joho/godotenv v1.5.1
github.com/mattn/go-isatty v0.0.24
github.com/mholt/archives v0.1.5
Expand Down
2 changes: 2 additions & 0 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

99 changes: 77 additions & 22 deletions internal/boxcli/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
package boxcli

import (
"bytes"
"fmt"
"io"
"math"
"slices"
"strings"
"text/tabwriter"

"github.com/samber/lo"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/spf13/cobra"

"go.jetify.com/devbox/internal/boxcli/usererr"
Expand All @@ -33,12 +36,17 @@ func searchCmd() *cobra.Command {
query := args[0]
name, version, isVersioned := searcher.ParseVersionedPackage(query)
if !isVersioned {
results, err := searcher.Client().Search(cmd.Context(), query)
client := searcher.Client()
limit := trimmedVersionsLength + 1
if flags.showAll {
limit = 0
}
results, err := client.Search(cmd.Context(), query, limit)
if err != nil {
return err
}
return printSearchResults(
cmd.OutOrStdout(), query, results, flags.showAll,
cmd.OutOrStdout(), query, results, flags.showAll, client.Host(),
)
}
packageVersion, err := searcher.Client().Resolve(name, version)
Expand Down Expand Up @@ -71,6 +79,7 @@ func printSearchResults(
query string,
results *searcher.SearchResults,
showAll bool,
host string,
) error {
if len(results.Packages) == 0 {
fmt.Fprintf(w, "No results found for %q\n", query)
Expand All @@ -87,41 +96,87 @@ func printSearchResults(
pkgs := results.Packages
if !showAll && len(pkgs) > trimmedVersionsLength {
resultsAreTrimmed = true
pkgs = results.Packages[:int(math.Min(10, float64(len(results.Packages))))]
pkgs = pkgs[:trimmedVersionsLength]
}

tableWriter := table.NewWriter()
tableWriter.AppendHeader(table.Row{"Package", "Versions", "Platforms"})
appendRow := func(name string, versions, systems []string) {
versionStr := columnize(versions, 2)
systemColumns := 1
if strings.Count(versionStr, "\n")+1 < len(systems) {
systemColumns = 2
}
tableWriter.AppendRow(table.Row{name, versionStr, columnize(systems, systemColumns)})
}

for _, pkg := range pkgs {
nonEmptyVersions := []string{}
for i, v := range pkg.Versions {
if !showAll && i >= trimmedVersionsLength {
var versions, systems []string
shown := 0
for _, pkgVersion := range pkg.Versions {
if pkgVersion.Version == "" {
continue
}
if !showAll && shown >= trimmedVersionsLength {
resultsAreTrimmed = true
break
}
if v.Version != "" {
nonEmptyVersions = append(nonEmptyVersions, v.Version)
}
}
shown++

versionString := ""
if len(nonEmptyVersions) > 0 {
ellipses := lo.Ternary(resultsAreTrimmed && pkg.NumVersions > trimmedVersionsLength, " ...", "")
if showAll {
versionString = fmt.Sprintf("\n > %s \n", strings.Join(nonEmptyVersions, "\n > "))
} else {
versionString = fmt.Sprintf(" (%s%s)", strings.Join(nonEmptyVersions, ", "), ellipses)
if len(versions) > 0 && !slices.Equal(systems, pkgVersion.Systems) {
appendRow(pkg.Name, versions, systems)
versions = nil
}
versions = append(versions, pkgVersion.Version)
systems = pkgVersion.Systems
}
if len(versions) > 0 || shown == 0 {
appendRow(pkg.Name, versions, systems)
}
fmt.Fprintf(w, "* %s %s\n", pkg.Name, versionString)
}

tableWriter.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, AutoMerge: true, VAlign: text.VAlignMiddle},
{Number: 2, AlignHeader: text.AlignCenter},
{Number: 3, AlignHeader: text.AlignCenter},
})
tableWriter.SetStyle(table.StyleLight)
tableWriter.Style().Options.SeparateRows = true
fmt.Fprintln(w, tableWriter.Render())

if resultsAreTrimmed {
fmt.Println()
fmt.Fprintln(w)
ux.Fwarningf(
w,
"Showing top 10 results and truncated versions. Use --show-all to "+
"Showing top %d results and truncated versions. Use --show-all to "+
"show all.\n\n",
trimmedVersionsLength,
)
}
fmt.Fprintf(w, "For more information go to: %s/\n", strings.TrimSuffix(host, "/"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 security Footer exposes URL credentials

If DEVBOX_SEARCH_HOST contains URL credentials, such as https://user:secret@example.com, this footer copies the password into stdout. Captured command output and CI logs will then contain it after a successful search. Remove the URL's user info before printing the link.

How this was verified: Client() reads the environment value unchanged, and Host() passes it directly to the footer's fmt.Fprintf call.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/boxcli/search.go
Line: 156

Comment:
**Footer exposes URL credentials**

If `DEVBOX_SEARCH_HOST` contains URL credentials, such as `https://user:secret@example.com`, this footer copies the password into stdout. Captured command output and CI logs will then contain it after a successful search. Remove the URL's user info before printing the link.

**How this was verified:** `Client()` reads the environment value unchanged, and `Host()` passes it directly to the footer's `fmt.Fprintf` call.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code


return nil
}

func columnize(data []string, maxColumns int) string {
columns := maxColumns
if len(data) <= columns {
columns = 1
}

buf := bytes.NewBufferString("")
var versionsGroup []string
writer := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0)
for _, version := range data {
if len(versionsGroup) == columns {
_, _ = fmt.Fprintf(writer, "%s\n", strings.Join(versionsGroup, "\t"))
versionsGroup = nil
}
versionsGroup = append(versionsGroup, version)
}
if len(versionsGroup) > 0 {
_, _ = fmt.Fprintf(writer, "%s\n", strings.Join(versionsGroup, "\t"))
}
_ = writer.Flush()
return strings.TrimSuffix(buf.String(), "\n")
}
Loading
Loading