From bd9307dce49c54014c2c232f8ef0d659569c3dd7 Mon Sep 17 00:00:00 2001 From: tan Date: Tue, 6 Oct 2026 15:10:23 +0530 Subject: [PATCH 1/4] fix(packages): use /api/v1/ui/packages/info for package search and name lookup `jh package search`, `jh search packages`, `jh package info` and every `--package ` lookup called `/packages/info`, which current JuliaHub does not route: the request falls through to the git `/packages/` handler and returns 404. The GraphQL fallback is then refused ("query is not allowed"), so all of these failed. Call the web UI's package listing, `/api/v1/ui/packages/info`, instead. It pages with `pagination[page]`/`pagination[per_page]` (at most 100 per page) rather than limit/offset, so map the CLI's `--limit`/`--offset` window onto the covering pages (`planRESTPages`). Order by relevance (`-score`) when a search term is given, by stars otherwise, and always break ties by name: packages from one repository share a score, and without a tiebreak their order changes between requests, so consecutive pages could repeat or skip rows. --- CLAUDE.md | 10 ++-- packages.go | 124 +++++++++++++++++++++++++++++++++++++---------- packages_test.go | 89 ++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6126b51..32338c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ The application follows a command-line interface pattern using the Cobra library - **auth.go**: OAuth2 device flow authentication with JWT token handling - **datasets.go**: Dataset operations (list, download, upload, status) with REST API integration - **registries.go**: Registry operations (list, config, add, update, registrator) with REST API integration -- **packages.go**: Package operations (search, dependency) with REST API primary path (`/packages/info`), GraphQL fallback, and documentation API (`/docs/{registry}/{package}/stable/pkg.json`) +- **packages.go**: Package operations (search, dependency) with REST API primary path (`/api/v1/ui/packages/info`), GraphQL fallback, and documentation API (`/docs/{registry}/{package}/stable/pkg.json`) - **projects.go**: Project management using GraphQL API with user filtering - **user.go**: User information retrieval using GraphQL API and REST API for listing users - **tokens.go**: Token management operations (list) with REST API integration @@ -36,7 +36,7 @@ The application follows a command-line interface pattern using the Cobra library - Stores tokens securely in `~/.juliahub` with 0600 permissions 2. **API Integration**: - - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`, `/api/v1/registry/config/registrator/{name}`), package search/info primary path (`/packages/info`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), admin group management (`/app/config/groups`), registry credential management (old API tried first, new API fallback), landing page management (`/app/homepage` GET, `/app/config/homepage` POST/DELETE), and vulnerability scanning (`/api/v1/ui/vulnerabilities/packages/{name}`) + - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`, `/api/v1/registry/config/registrator/{name}`), package search/info primary path (`/api/v1/ui/packages/info`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), admin group management (`/app/config/groups`), registry credential management (old API tried first, new API fallback), landing page management (`/app/homepage` GET, `/app/config/homepage` POST/DELETE), and vulnerability scanning (`/api/v1/ui/vulnerabilities/packages/{name}`) - **GraphQL API**: Used for projects, user info, user list (`public_users`), group list, and package search/info fallback (`/v1/graphql`) - **Headers**: All GraphQL requests require `Authorization: Bearer `, `X-Hasura-Role: jhuser`, and `X-Juliahub-Ensure-JS: true` - **Authentication**: Uses ID tokens (`token.IDToken`) for API calls @@ -48,7 +48,7 @@ The application follows a command-line interface pattern using the Cobra library - `jh registry config`: Show registry JSON config by name; subcommands add/update accept JSON via stdin or `--file` - `jh registry permission`: Registry permission management (list, set, remove) - `jh registry registrator`: Show registrator config by name; subcommand update accepts JSON via stdin or `--file` - - `jh package`: Package search and dependency (REST primary via `/packages/info`, GraphQL fallback; dependency data from `/docs/{registry}/{package}/stable/pkg.json`) + - `jh package`: Package search and dependency (REST primary via `/api/v1/ui/packages/info`, GraphQL fallback; dependency data from `/docs/{registry}/{package}/stable/pkg.json`) - `jh project`: Project management (list with GraphQL, supports user filtering) - `jh user`: User information (info, list via GraphQL `public_users`) - `jh group`: Group information (list via GraphQL) @@ -327,7 +327,7 @@ The application uses OAuth2 device flow: - **User management**: `/app/config/features/manage` endpoint for listing all users - **Token management**: `/app/token/activelist` endpoint for listing all API tokens - **Registry credentials**: `GET /api/v1/sysconfig/credentials` to fetch (returns `CredentialsInfo` object directly); `POST` to add tokens/apps; `PUT` to update tokens/apps or replace all SSH credentials; `DELETE` with `{tokens:[...], githubApps:[...]}` to delete -- **Package search/info primary**: `/packages/info` endpoint with `name`, `registries`, `tags`, `licenses`, `limit`, `offset` query params; returns `{packages: [...], meta: {total: N}}` +- **Package search/info primary**: `/api/v1/ui/packages/info` (the web UI's package listing) with `name`, `registries` (comma-separated names), `tags`, `licenses`, `sorts[0]` (`-score` when searching by name) and page-based pagination `pagination[type]=offset&pagination[page]=N&pagination[per_page]=M` (M ≤ 100); returns `{packages: [...], meta: {total: N}}`. The CLI's `--limit`/`--offset` window is mapped onto pages by `planRESTPages`. (The older `/packages/info` path is not routed by current JuliaHub: it falls through to the git `/packages/` handler and 404s.) - **Package dependencies**: `/docs/{registry}/{package}/stable/pkg.json` for dependency information - **Authentication**: Bearer token with ID token - **Upload workflow**: 3-step process (request presigned URL, upload to URL, close upload) @@ -507,7 +507,7 @@ jh run setup - Landing page commands (`jh admin landing-page`) use REST API: GET `/app/homepage` (show), POST `/app/config/homepage` (update), DELETE `/app/config/homepage` (remove); require appropriate permissions - Landing page `update` command accepts content inline as an argument, from a file via `--file`, or piped via stdin (priority: `--file` > arg > stdin) - Landing page response uses custom JSON unmarshaling (`homepageResponse`) to handle `message` being either an object or a string -- Package search (`jh package search`) and info (`jh package info`) both try REST API (`/packages/info`) first, then fall back to GraphQL (`FilteredPackages` / `FilteredPackagesCount` via `/v1/graphql`) on failure; a warning is printed to stderr when the fallback is used +- Package search (`jh package search`) and info (`jh package info`) both try REST API (`/api/v1/ui/packages/info`) first, then fall back to GraphQL (`FilteredPackages` / `FilteredPackagesCount` via `/v1/graphql`) on failure; a warning is printed to stderr when the fallback is used - REST API passes `--registries` as comma-separated registry names to the `registries` query param; GraphQL fallback passes registry IDs to the `registries` variable - `fetchRegistries` in `registries.go` is used by `listRegistries`, `packageSearchCmd`, `packageInfoCmd`, and `packageDependencyCmd` to resolve registry names to IDs (for GraphQL) and names (for REST) - Both REST and GraphQL package search/info paths produce identical output columns (Registry and Owner); GraphQL resolves registry names from the `registryIDs`/`registryNames` already in `PackageSearchParams` — no extra API call needed diff --git a/packages.go b/packages.go index f005350..05ffd9d 100644 --- a/packages.go +++ b/packages.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) @@ -264,59 +265,132 @@ func buildRegistryIDToName(ids []int, names []string) map[int]string { return m } -// fetchRESTPackages calls the /packages/info endpoint and returns raw results and total count. +// packagesInfoPath is the package listing endpoint behind the web UI's package +// pages. It pages with pagination[page]/[per_page] (1-based pages, at most +// packagesInfoMaxPerPage rows each) rather than limit/offset. +const ( + packagesInfoPath = "/api/v1/ui/packages/info" + packagesInfoMaxPerPage = 100 +) + +// restPage is one page request against packagesInfoPath. +type restPage struct { + Page int + PerPage int +} + +// planRESTPages maps the CLI's limit/offset window onto the pages that cover +// it, all of one size: min(limit, packagesInfoMaxPerPage). The window starts +// skip rows into the first page; an offset that is not a multiple of the page +// size costs one extra page. +func planRESTPages(limit, offset int) (pages []restPage, skip int) { + if limit <= 0 { + limit = 10 + } + if offset < 0 { + offset = 0 + } + per := min(limit, packagesInfoMaxPerPage) + first := offset / per + last := (offset + limit - 1) / per + for p := first; p <= last; p++ { + pages = append(pages, restPage{Page: p + 1, PerPage: per}) + } + return pages, offset - first*per +} + +// buildPackagesInfoQuery returns the query string for one page. With a search +// term the results are ordered by the server's relevance score (exact name > +// prefix > substring, then stars), like the GraphQL search; without one, by +// stars (the server's default). Name breaks ties: packages of one repository +// share a score, and without a tiebreak their order changes between requests, +// so consecutive pages could repeat or skip rows. +func buildPackagesInfoQuery(search string, registryNames []string, page restPage) url.Values { + q := url.Values{} + q.Set("sorts[0]", "-stargazers_count") + if search != "" { + q.Set("name", search) + q.Set("sorts[0]", "-score") + } + q.Set("sorts[1]", "name") + if len(registryNames) > 0 { + q.Set("registries", strings.Join(registryNames, ",")) + } + q.Set("pagination[type]", "offset") + q.Set("pagination[page]", fmt.Sprintf("%d", page.Page)) + q.Set("pagination[per_page]", fmt.Sprintf("%d", page.PerPage)) + return q +} + +// fetchRESTPackages lists packages matching search via packagesInfoPath and +// returns the limit/offset window and the total match count. func fetchRESTPackages(server, search string, limit, offset int, registryNames []string) ([]RESTPackage, int, error) { token, err := ensureValidToken() if err != nil { return nil, 0, fmt.Errorf("authentication required: %w", err) } + client := &http.Client{Timeout: 30 * time.Second} + return fetchRESTPackagesFrom(client, "https://"+server, token.IDToken, search, limit, offset, registryNames) +} - url := fmt.Sprintf("https://%s/packages/info", server) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, 0, fmt.Errorf("failed to create request: %w", err) +// fetchRESTPackagesFrom is the testable core of fetchRESTPackages (client, +// base URL and token are injected; an empty token sends no Authorization). +func fetchRESTPackagesFrom(client *http.Client, baseURL, token, search string, limit, offset int, registryNames []string) ([]RESTPackage, int, error) { + if limit <= 0 { + limit = 10 + } + pages, skip := planRESTPages(limit, offset) + var all []RESTPackage + total := 0 + for _, page := range pages { + pkgs, t, err := fetchPackagesInfoPage(client, baseURL, token, buildPackagesInfoQuery(search, registryNames, page)) + if err != nil { + return nil, 0, err + } + total = t + all = append(all, pkgs...) + if len(pkgs) < page.PerPage { + break // last page + } } - - q := req.URL.Query() - if search != "" { - q.Add("name", search) + if skip >= len(all) { + return []RESTPackage{}, total, nil } - if limit > 0 { - q.Add("limit", fmt.Sprintf("%d", limit)) + all = all[skip:] + if len(all) > limit { + all = all[:limit] } - if offset > 0 { - q.Add("offset", fmt.Sprintf("%d", offset)) + return all, total, nil +} + +func fetchPackagesInfoPage(client *http.Client, baseURL, token string, q url.Values) ([]RESTPackage, int, error) { + req, err := http.NewRequest("GET", baseURL+packagesInfoPath+"?"+q.Encode(), nil) + if err != nil { + return nil, 0, fmt.Errorf("failed to create request: %w", err) } - if len(registryNames) > 0 { - q.Add("registries", strings.Join(registryNames, ",")) + if token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) } - req.URL.RawQuery = q.Encode() - - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) req.Header.Set("Accept", "application/json") - client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, 0, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, 0, fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) - } - body, err := io.ReadAll(resp.Body) if err != nil { return nil, 0, fmt.Errorf("failed to read response: %w", err) } + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } var response PackageRESTListResponse if err := json.Unmarshal(body, &response); err != nil { return nil, 0, fmt.Errorf("failed to parse response: %w", err) } - return response.Packages, response.Meta.Total, nil } diff --git a/packages_test.go b/packages_test.go index c75b1ef..0edb99e 100644 --- a/packages_test.go +++ b/packages_test.go @@ -1,7 +1,13 @@ package main import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "reflect" + "strconv" + "strings" "testing" ) @@ -101,3 +107,86 @@ func TestBuildGraphQLPackageVariables(t *testing.T) { } }) } + +func TestPlanRESTPages(t *testing.T) { + cases := []struct { + limit, offset int + pages []restPage + skip int + }{ + {10, 0, []restPage{{1, 10}}, 0}, + {10, 20, []restPage{{3, 10}}, 0}, + {10, 15, []restPage{{2, 10}, {3, 10}}, 5}, + {250, 0, []restPage{{1, 100}, {2, 100}, {3, 100}}, 0}, + {0, 0, []restPage{{1, 10}}, 0}, + } + for _, c := range cases { + pages, skip := planRESTPages(c.limit, c.offset) + if !reflect.DeepEqual(pages, c.pages) || skip != c.skip { + t.Errorf("planRESTPages(%d, %d) = %v, %d; want %v, %d", c.limit, c.offset, pages, skip, c.pages, c.skip) + } + } +} + +func TestBuildPackagesInfoQuery(t *testing.T) { + q := buildPackagesInfoQuery("plots", []string{"General", "MyReg"}, restPage{Page: 2, PerPage: 25}) + want := "name=plots&pagination%5Bpage%5D=2&pagination%5Bper_page%5D=25&pagination%5Btype%5D=offset®istries=General%2CMyReg&sorts%5B0%5D=-score&sorts%5B1%5D=name" + if got := q.Encode(); got != want { + t.Errorf("query = %s\nwant %s", got, want) + } + // Without a search term: most-starred first, still with the name tiebreak. + q = buildPackagesInfoQuery("", nil, restPage{1, 10}) + if q.Has("name") || q.Get("sorts[0]") != "-stargazers_count" || q.Get("sorts[1]") != "name" { + t.Errorf("unexpected params without a search term: %s", q.Encode()) + } +} + +func TestFetchRESTPackagesFromWindow(t *testing.T) { + // 23 packages P00..P22 served in pages; ask for 10 starting at offset 15. + var gotAuth []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != packagesInfoPath { + http.NotFound(w, r) + return + } + gotAuth = append(gotAuth, r.Header.Get("Authorization")) + page, _ := strconv.Atoi(r.URL.Query().Get("pagination[page]")) + per, _ := strconv.Atoi(r.URL.Query().Get("pagination[per_page]")) + var pkgs []RESTPackage + for i := (page - 1) * per; i < page*per && i < 23; i++ { + pkgs = append(pkgs, RESTPackage{Name: fmt.Sprintf("P%02d", i)}) + } + json.NewEncoder(w).Encode(PackageRESTListResponse{Packages: pkgs, Meta: struct { + Total int `json:"total"` + }{23}}) + })) + defer srv.Close() + + pkgs, total, err := fetchRESTPackagesFrom(srv.Client(), srv.URL, "", "", 10, 15, nil) + if err != nil { + t.Fatal(err) + } + var names []string + for _, p := range pkgs { + names = append(names, p.Name) + } + want := []string{"P15", "P16", "P17", "P18", "P19", "P20", "P21", "P22"} + if !reflect.DeepEqual(names, want) || total != 23 { + t.Errorf("got %v (total %d), want %v (total 23)", names, total, want) + } + for _, a := range gotAuth { + if a != "" { + t.Errorf("empty token should send no Authorization header, got %q", a) + } + } +} + +func TestFetchRESTPackagesFromHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusUnauthorized) + })) + defer srv.Close() + if _, _, err := fetchRESTPackagesFrom(srv.Client(), srv.URL, "t", "x", 10, 0, nil); err == nil || !strings.Contains(err.Error(), "status 401") { + t.Errorf("want a status 401 error, got %v", err) + } +} From c95e26e48d577867f74d3d0a8e8965124afe8f3a Mon Sep 17 00:00:00 2001 From: tan Date: Tue, 6 Oct 2026 15:25:28 +0530 Subject: [PATCH 2/4] fix(packages): find a package's registry via the REST listing in `jh package dependency` Without `--registry`, `jh package dependency ` looked the package's registry up through the GraphQL package search only, which current JuliaHub refuses ("query is not allowed"), so the command failed. Look it up in the REST package listing first (`findPackageRegistry`) and keep GraphQL as the fallback, reporting both errors if both fail. --- packages.go | 68 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/packages.go b/packages.go index 05ffd9d..fa8b824 100644 --- a/packages.go +++ b/packages.go @@ -636,38 +636,50 @@ func getPackageInfoGraphQL(server, packageName string, registryIDs []int, regist return nil } -func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error { - var targetRegistry string - if registryName != "" { - targetRegistry = registryName - } else { - allRegistries, err := fetchRegistries(server) - if err != nil { - return fmt.Errorf("failed to fetch registries: %w", err) - } - var registryIDs []int - for _, reg := range allRegistries { - registryIDs = append(registryIDs, reg.RegistryID) - } - gqlPkgs, err := fetchGraphQLPackages(server, packageName, 100, 0, registryIDs) - if err != nil { - return fmt.Errorf("failed to search for package %q: %w", packageName, err) +// findPackageRegistry returns the registry of the package named packageName +// (exact, case-insensitive): from the REST package listing, or, when that is +// unavailable, from the GraphQL package search. +func findPackageRegistry(server, packageName string) (string, error) { + pkgs, _, restErr := fetchRESTPackages(server, packageName, 100, 0, nil) + if restErr == nil { + for _, p := range pkgs { + if strings.EqualFold(p.Name, packageName) && p.Registry != "" { + return p.Registry, nil + } } - for i := range gqlPkgs { - if strings.EqualFold(gqlPkgs[i].Name, packageName) { - if gqlPkgs[i].RegistryMap != nil { - for _, reg := range allRegistries { - if reg.RegistryID == gqlPkgs[i].RegistryMap.RegistryID { - targetRegistry = reg.Name - break - } - } + return "", fmt.Errorf("package not found: %s", packageName) + } + + allRegistries, err := fetchRegistries(server) + if err != nil { + return "", fmt.Errorf("failed to search for package %q: %v; failed to fetch registries: %w", packageName, restErr, err) + } + var registryIDs []int + for _, reg := range allRegistries { + registryIDs = append(registryIDs, reg.RegistryID) + } + gqlPkgs, err := fetchGraphQLPackages(server, packageName, 100, 0, registryIDs) + if err != nil { + return "", fmt.Errorf("failed to search for package %q: %v; GraphQL fallback: %w", packageName, restErr, err) + } + for i := range gqlPkgs { + if strings.EqualFold(gqlPkgs[i].Name, packageName) && gqlPkgs[i].RegistryMap != nil { + for _, reg := range allRegistries { + if reg.RegistryID == gqlPkgs[i].RegistryMap.RegistryID { + return reg.Name, nil } - break } } - if targetRegistry == "" { - return fmt.Errorf("package not found: %s", packageName) + } + return "", fmt.Errorf("package not found: %s", packageName) +} + +func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error { + targetRegistry := registryName + if targetRegistry == "" { + var err error + if targetRegistry, err = findPackageRegistry(server, packageName); err != nil { + return err } } From 473817aadee6ca730468d3029badb795af101dea Mon Sep 17 00:00:00 2001 From: Panagiotis Georgakopoulos Date: Wed, 14 Oct 2026 19:10:15 +0300 Subject: [PATCH 3/4] fix(packages): send package-search sorts the API accepts The packages API's `Sorts` schema requires every key to carry a direction sign (`^[+-].+$`), so the unsigned `sorts[1]=name` tiebreak was rejected with a 400. Send `-name` instead. Package changes of JuliaComputing/jh#65 (7ad14d9), without its e2e changes. Co-Authored-By: Claude Opus 5.5 --- packages.go | 6 +++++- packages_test.go | 20 ++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages.go b/packages.go index fa8b824..4a96c8e 100644 --- a/packages.go +++ b/packages.go @@ -305,6 +305,10 @@ func planRESTPages(limit, offset int) (pages []restPage, skip int) { // stars (the server's default). Name breaks ties: packages of one repository // share a score, and without a tiebreak their order changes between requests, // so consecutive pages could repeat or skip rows. +// +// Every sort key carries a direction sign: the API rejects unsigned keys +// (`^[+-].+$`), and servers only honour the `-` form, so the tiebreak is +// descending. Any fixed direction keeps the paging stable. func buildPackagesInfoQuery(search string, registryNames []string, page restPage) url.Values { q := url.Values{} q.Set("sorts[0]", "-stargazers_count") @@ -312,7 +316,7 @@ func buildPackagesInfoQuery(search string, registryNames []string, page restPage q.Set("name", search) q.Set("sorts[0]", "-score") } - q.Set("sorts[1]", "name") + q.Set("sorts[1]", "-name") if len(registryNames) > 0 { q.Set("registries", strings.Join(registryNames, ",")) } diff --git a/packages_test.go b/packages_test.go index 0edb99e..9c50b97 100644 --- a/packages_test.go +++ b/packages_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "regexp" "strconv" "strings" "testing" @@ -130,15 +131,30 @@ func TestPlanRESTPages(t *testing.T) { func TestBuildPackagesInfoQuery(t *testing.T) { q := buildPackagesInfoQuery("plots", []string{"General", "MyReg"}, restPage{Page: 2, PerPage: 25}) - want := "name=plots&pagination%5Bpage%5D=2&pagination%5Bper_page%5D=25&pagination%5Btype%5D=offset®istries=General%2CMyReg&sorts%5B0%5D=-score&sorts%5B1%5D=name" + want := "name=plots&pagination%5Bpage%5D=2&pagination%5Bper_page%5D=25&pagination%5Btype%5D=offset®istries=General%2CMyReg&sorts%5B0%5D=-score&sorts%5B1%5D=-name" if got := q.Encode(); got != want { t.Errorf("query = %s\nwant %s", got, want) } // Without a search term: most-starred first, still with the name tiebreak. q = buildPackagesInfoQuery("", nil, restPage{1, 10}) - if q.Has("name") || q.Get("sorts[0]") != "-stargazers_count" || q.Get("sorts[1]") != "name" { + if q.Has("name") || q.Get("sorts[0]") != "-stargazers_count" || q.Get("sorts[1]") != "-name" { t.Errorf("unexpected params without a search term: %s", q.Encode()) } + // The API's Sorts schema requires a direction sign on every key. + signed := regexp.MustCompile(`^[+-].+$`) + for _, search := range []string{"plots", ""} { + q = buildPackagesInfoQuery(search, nil, restPage{1, 10}) + for key, vals := range q { + if !strings.HasPrefix(key, "sorts[") { + continue + } + for _, v := range vals { + if !signed.MatchString(v) { + t.Errorf("search %q: %s=%q does not match the API's sort pattern ^[+-].+$", search, key, v) + } + } + } + } } func TestFetchRESTPackagesFromWindow(t *testing.T) { From b8d756bd27771607e71a8ca8890a534a9eb70b5d Mon Sep 17 00:00:00 2001 From: Panagiotis Georgakopoulos Date: Wed, 14 Oct 2026 18:54:24 +0300 Subject: [PATCH 4/4] feat: `jh package dependents ` lists reverse dependencies The docs pkg.json that `jh package dependency` already reads also carries `reversedeps`. `dependents` prints the direct, indirect and total counts and lists the direct dependents; `--indirect` lists the indirect ones too. `--registry` and `--server` work as for `dependency`. A dependent can appear in `reversedeps` once as direct and once as indirect, so entries are collapsed to one per UUID (direct wins), which matches the count on JuliaHub's "Dependents" badge. The pkg.json fetch moves into `fetchPackageDocs`, shared by both commands. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 7 +++- README.md | 8 ++++ main.go | 38 ++++++++++++++++- packages.go | 105 +++++++++++++++++++++++++++++++++++++++++++---- packages_test.go | 40 ++++++++++++++++++ 5 files changed, 188 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 32338c6..eb0d0f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,10 @@ go run . package info Plots --registries General go run . package dependency DataFrames go run . package dependency DataFrames --indirect go run . package dependency CSV --registry General + +# Get package dependents (reverse dependencies) +go run . package dependents JuMP +go run . package dependents JuMP --indirect ``` ### Test registry operations @@ -515,7 +519,8 @@ jh run setup - GraphQL fallback uses `package_search.gql` (`FilteredPackages`) for the package list and `package_search_count.gql` (`FilteredPackagesCount`) for the aggregate count as separate requests - `executeGraphQL(server, token, req)` in `packages.go` is a shared helper for GraphQL POST requests (sets Authorization, Content-Type, Accept, X-Hasura-Role headers) - `getPackageInfo` in `packages.go` implements exact name-match lookup using REST-first (`getPackageInfoREST`), GraphQL fallback (`getPackageInfoGraphQL`); `packageInfoCmd` in `main.go` resolves registries via `fetchRegistries` -- `getPackageDependencies` uses GraphQL (`fetchGraphQLPackages`) to locate the package, then fetches `/docs/{registry}/{package}/stable/pkg.json` for dependency data; no REST fallback (docs endpoint is authoritative) +- `jh package dependents` reads `reversedeps` from the same `pkg.json` (`fetchPackageDocs`). Entries carry `version` (singular), and a dependent can appear once as direct and once as indirect; `splitDependents` collapses them to one per UUID (direct wins), sorted by name +- `getPackageDependencies` locates the package's registry with `findPackageRegistry` (REST listing, GraphQL fallback), then fetches `/docs/{registry}/{package}/stable/pkg.json` for dependency data; no REST fallback (docs endpoint is authoritative) - `jh vuln` uses two REST endpoints: vulnerabilities at `/api/v1/ui/vulnerabilities/packages/{name}?version=` and latest version at `/docs///versions.json` (first entry is latest); no GraphQL fallback - When no `--version` is given, `fetchLatestVersion` calls the versions.json endpoint (registry defaults to `General`, overridable with `--registry`) - By default only advisories where `is_affected == true` are shown; `--all` overrides this diff --git a/README.md b/README.md index d7a9912..3cf929a 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,10 @@ go build -o jh . - Default: Shows up to 10 direct dependencies (NAME, REGISTRY, UUID, VERSIONS) - `jh package dependency --indirect` - Include both direct and indirect dependencies - `jh package dependency --registry General` - Specify registry to use +- `jh package dependents ` - List packages that depend on a package (reverse dependencies) + - Default: Prints direct/indirect/total counts and lists the direct dependents (NAME, REGISTRY, UUID) + - `jh package dependents --indirect` - Also list indirect dependents + - `jh package dependents --registry General` - Specify registry to use ### Registry Management (`jh registry`) @@ -347,6 +351,10 @@ jh package info Plots --registries General jh package dependency DataFrames # Shows up to 10 direct dependencies jh package dependency DataFrames --indirect # Includes indirect dependencies jh package dependency CSV --registry General # Use specific registry + +# List reverse dependencies (dependents) +jh package dependents JuMP # Counts, plus the direct dependents +jh package dependents JuMP --indirect # Also lists indirect dependents ``` ### Registry Operations diff --git a/main.go b/main.go index 0171561..f4f7bd7 100644 --- a/main.go +++ b/main.go @@ -1096,6 +1096,39 @@ the --registry flag.`, }, } +var packageDependentsCmd = &cobra.Command{ + Use: "dependents ", + Short: "List packages that depend on a package", + Long: `List the reverse dependencies of a Julia package: the packages that +depend on it. + +Prints the number of direct and indirect dependents and lists the direct +ones. Use --indirect to list the indirect dependents as well. + +The data comes from the same package documentation JSON endpoint as +jh package dependency, for the package's stable version. If a package +exists in multiple registries, it uses the first registry by default. +You can specify a different registry using the --registry flag.`, + Example: " jh package dependents JuMP\n jh package dependents --indirect DataFrames\n jh package dependents --registry General CSV", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + + packageName := args[0] + registryName, _ := cmd.Flags().GetString("registry") + showIndirect, _ := cmd.Flags().GetBool("indirect") + + if err := getPackageDependents(server, packageName, registryName, showIndirect); err != nil { + fmt.Printf("Failed to get package dependents: %v\n", err) + os.Exit(1) + } + }, +} + var registryCmd = &cobra.Command{ Use: "registry", Short: "Registry management commands", @@ -2458,6 +2491,9 @@ func init() { packageDependencyCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") packageDependencyCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)") packageDependencyCmd.Flags().Bool("indirect", false, "Include indirect dependencies") + packageDependentsCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + packageDependentsCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)") + packageDependentsCmd.Flags().Bool("indirect", false, "Also list indirect dependents") registryListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") registryListCmd.Flags().Bool("verbose", false, "Show detailed registry information") projectListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") @@ -2500,7 +2536,7 @@ func init() { authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobCmd.AddCommand(jobListCmd, jobStartCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) - packageCmd.AddCommand(packageSearchCmd, packageInfoCmd, packageDependencyCmd) + packageCmd.AddCommand(packageSearchCmd, packageInfoCmd, packageDependencyCmd, packageDependentsCmd) registryConfigCmd.Flags().StringP("server", "s", "", "JuliaHub server") registryConfigAddCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") registryConfigAddCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") diff --git a/packages.go b/packages.go index 4a96c8e..cdc70e3 100644 --- a/packages.go +++ b/packages.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "sort" "strings" "time" ) @@ -61,10 +62,21 @@ type PackageDependency struct { Slug string `json:"slug"` } +// PackageDependent is one entry of pkg.json's `reversedeps`. A dependent can +// appear twice, once with direct=true and once with direct=false. +type PackageDependent struct { + Direct bool `json:"direct"` + Name string `json:"name"` + UUID string `json:"uuid"` + Registry string `json:"registry"` + Slug string `json:"slug"` +} + type PackageDocsResponse struct { Name string `json:"name"` Version string `json:"version"` Dependencies []PackageDependency `json:"deps"` + Dependents []PackageDependent `json:"reversedeps"` } type Package struct { @@ -678,12 +690,14 @@ func findPackageRegistry(server, packageName string) (string, error) { return "", fmt.Errorf("package not found: %s", packageName) } -func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error { +// fetchPackageDocs returns the stable version's pkg.json of packageName in +// registryName, or in the registry findPackageRegistry picks when it is empty. +func fetchPackageDocs(server, packageName, registryName string) (*PackageDocsResponse, string, error) { targetRegistry := registryName if targetRegistry == "" { var err error if targetRegistry, err = findPackageRegistry(server, packageName); err != nil { - return err + return nil, "", err } } @@ -691,12 +705,12 @@ func getPackageDependencies(server string, packageName string, registryName stri token, err := ensureValidToken() if err != nil { - return fmt.Errorf("authentication required: %w", err) + return nil, "", fmt.Errorf("authentication required: %w", err) } req, err := http.NewRequest("GET", docsURL, nil) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return nil, "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) @@ -705,23 +719,31 @@ func getPackageDependencies(server string, packageName string, registryName stri client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to fetch package documentation: %w", err) + return nil, "", fmt.Errorf("failed to fetch package documentation: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) + return nil, "", fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) } body, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("failed to read response: %w", err) + return nil, "", fmt.Errorf("failed to read response: %w", err) } var docsResp PackageDocsResponse if err := json.Unmarshal(body, &docsResp); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + return nil, "", fmt.Errorf("failed to parse response: %w", err) + } + return &docsResp, targetRegistry, nil +} + +func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error { + docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName) + if err != nil { + return err } var deps []PackageDependency @@ -784,3 +806,70 @@ func getPackageDependencies(server string, packageName string, registryName stri return nil } + +// splitDependents collapses entries to one per UUID, sorted by name. A +// dependent listed as direct in any entry is direct; the rest are indirect. +func splitDependents(entries []PackageDependent) (direct, indirect []PackageDependent) { + byUUID := make(map[string]PackageDependent) + for _, e := range entries { + if prev, ok := byUUID[e.UUID]; ok { + e.Direct = e.Direct || prev.Direct + } + byUUID[e.UUID] = e + } + for _, e := range byUUID { + if e.Direct { + direct = append(direct, e) + } else { + indirect = append(indirect, e) + } + } + byName := func(s []PackageDependent) { + sort.Slice(s, func(i, j int) bool { + if a, b := strings.ToLower(s[i].Name), strings.ToLower(s[j].Name); a != b { + return a < b + } + return s[i].UUID < s[j].UUID + }) + } + byName(direct) + byName(indirect) + return direct, indirect +} + +func printDependentsTable(title string, deps []PackageDependent) { + fmt.Printf("%s (%d):\n", title, len(deps)) + fmt.Printf("%-35s %-15s %s\n", "NAME", "REGISTRY", "UUID") + fmt.Printf("%-35s %-15s %s\n", strings.Repeat("-", 35), strings.Repeat("-", 15), strings.Repeat("-", 36)) + for _, d := range deps { + fmt.Printf("%-35s %-15s %s\n", d.Name, d.Registry, d.UUID) + } +} + +func getPackageDependents(server, packageName, registryName string, showIndirect bool) error { + docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName) + if err != nil { + return err + } + + direct, indirect := splitDependents(docsResp.Dependents) + if len(direct)+len(indirect) == 0 { + fmt.Printf("Package %s (v%s) has no dependents\n", docsResp.Name, docsResp.Version) + return nil + } + + fmt.Printf("Dependents of %s (v%s) from registry '%s':\n\n", docsResp.Name, docsResp.Version, targetRegistry) + fmt.Printf("%d direct, %d indirect, %d total\n\n", len(direct), len(indirect), len(direct)+len(indirect)) + if len(direct) > 0 { + printDependentsTable("Direct dependents", direct) + } + if showIndirect && len(indirect) > 0 { + if len(direct) > 0 { + fmt.Println() + } + printDependentsTable("Indirect dependents", indirect) + } else if len(indirect) > 0 { + fmt.Printf("\nUse --indirect to list the %d indirect dependents\n", len(indirect)) + } + return nil +} diff --git a/packages_test.go b/packages_test.go index 9c50b97..7dce95d 100644 --- a/packages_test.go +++ b/packages_test.go @@ -206,3 +206,43 @@ func TestFetchRESTPackagesFromHTTPError(t *testing.T) { t.Errorf("want a status 401 error, got %v", err) } } + +func TestSplitDependents(t *testing.T) { + var docs PackageDocsResponse + body := `{"name":"JuMP","version":"1.32.1","reversedeps":[ + {"direct":false,"name":"zeta","uuid":"u3","registry":"General","slug":"c","version":["1.0.0"]}, + {"direct":true,"name":"Beta","uuid":"u2","registry":"General","slug":"b","version":["0.1.0"]}, + {"direct":false,"name":"Beta","uuid":"u2","registry":"General","slug":"b","version":["0.1.0"]}, + {"direct":false,"name":"alpha","uuid":"u1","registry":"General","slug":"a","version":["2.0.0"]}, + {"direct":true,"name":"Alpha","uuid":"u0","registry":"DyadRegistry","slug":"d","version":["3.0.0"]} + ]}` + if err := json.Unmarshal([]byte(body), &docs); err != nil { + t.Fatal(err) + } + + direct, indirect := splitDependents(docs.Dependents) + + names := func(ds []PackageDependent) []string { + var out []string + for _, d := range ds { + out = append(out, d.Name+"/"+d.UUID) + } + return out + } + if got, want := names(direct), []string{"Alpha/u0", "Beta/u2"}; !reflect.DeepEqual(got, want) { + t.Errorf("direct = %v, want %v", got, want) + } + if got, want := names(indirect), []string{"alpha/u1", "zeta/u3"}; !reflect.DeepEqual(got, want) { + t.Errorf("indirect = %v, want %v", got, want) + } + if direct[0].Registry != "DyadRegistry" { + t.Errorf("direct[0].Registry = %q, want DyadRegistry", direct[0].Registry) + } +} + +func TestSplitDependentsEmpty(t *testing.T) { + direct, indirect := splitDependents(nil) + if len(direct) != 0 || len(indirect) != 0 { + t.Errorf("splitDependents(nil) = %v, %v; want empty", direct, indirect) + } +}