From 27b3bc58edde3d26087b14354b12dd1eded50bb9 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Thu, 13 Aug 2026 10:39:31 +0000 Subject: [PATCH 1/2] Drop Status and Score from package output `jh package search --verbose` printed a `Status: Active/Inactive` line derived from the registry map and a `Score:` line carrying the GraphQL ranking score. Both are internal details with no meaning to users, so remove them from the `packageInfo` display struct, the verbose printer, and the `gqlToInfo` mapping. The wire-level `Package.Score` and `PackageRegistryMap.Status` fields stay, since they still mirror the GraphQL response shape. Co-Authored-By: Claude Opus 5 (1M context) --- packages.go | 14 -------------- packages_test.go | 15 ++++----------- 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/packages.go b/packages.go index f005350..d998b84 100644 --- a/packages.go +++ b/packages.go @@ -127,8 +127,6 @@ type packageInfo struct { DocsURL string License string IsApp bool - Score float64 - Status string } func printPackages(pkgs []packageInfo, total int, verbose bool) { @@ -179,15 +177,9 @@ func printPackages(pkgs []packageInfo, total int, verbose bool) { if pkg.Version != "" { fmt.Printf("Latest Version: %s\n", pkg.Version) } - if pkg.Status != "" { - fmt.Printf("Status: %s\n", pkg.Status) - } if pkg.IsApp { fmt.Printf("Type: Application\n") } - if pkg.Score != 0 { - fmt.Printf("Score: %.2f\n", pkg.Score) - } } else { fmt.Printf("%-30s %-20s %-20s", pkg.Name, pkg.Registry, pkg.Owner) if pkg.Version != "" { @@ -232,7 +224,6 @@ func gqlToInfo(p Package, registryIDToName map[int]string) packageInfo { Owner: p.Owner, License: p.License, IsApp: p.IsApp, - Score: p.Score, } if p.Metadata != nil { info.Description = p.Metadata.Description @@ -244,11 +235,6 @@ func gqlToInfo(p Package, registryIDToName map[int]string) packageInfo { if p.RegistryMap != nil { info.Registry = registryIDToName[p.RegistryMap.RegistryID] info.Version = p.RegistryMap.Version - if p.RegistryMap.Status { - info.Status = "Active" - } else { - info.Status = "Inactive" - } } return info } diff --git a/packages_test.go b/packages_test.go index c75b1ef..25b9817 100644 --- a/packages_test.go +++ b/packages_test.go @@ -40,29 +40,22 @@ func TestGqlToInfo(t *testing.T) { t.Run("full package", func(t *testing.T) { p := Package{ Name: "Plots", UUID: "u-1", Owner: " JuliaPlots", License: "MIT", - IsApp: true, Score: 9.5, + IsApp: true, Metadata: &PackageMetadata{Description: "viz", Repo: "r", Tags: []string{"plot"}, StarCount: 42, DocsLink: "d"}, - RegistryMap: &PackageRegistryMap{Version: "1.0.0", RegistryID: 7, Status: true}, + RegistryMap: &PackageRegistryMap{Version: "1.0.0", RegistryID: 7}, } got := gqlToInfo(p, idToName) if got.Registry != "General" { t.Errorf("Registry = %q, want General (resolved from id)", got.Registry) } - if got.Version != "1.0.0" || got.Status != "Active" || got.Stars != 42 || !got.IsApp { + if got.Version != "1.0.0" || got.Stars != 42 || !got.IsApp { t.Errorf("fields not mapped: %+v", got) } }) - t.Run("inactive status", func(t *testing.T) { - p := Package{Name: "X", RegistryMap: &PackageRegistryMap{RegistryID: 7, Status: false}} - if got := gqlToInfo(p, idToName); got.Status != "Inactive" { - t.Errorf("Status = %q, want Inactive", got.Status) - } - }) - t.Run("nil metadata and registrymap are safe", func(t *testing.T) { got := gqlToInfo(Package{Name: "Bare", UUID: "u"}, idToName) - if got.Name != "Bare" || got.Registry != "" || got.Description != "" || got.Status != "" { + if got.Name != "Bare" || got.Registry != "" || got.Description != "" || got.Version != "" { t.Errorf("nil sub-structs should leave fields empty: %+v", got) } }) From cc68f355f1600aeb06ffd11f460e9f96ca15a34f Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Thu, 13 Aug 2026 10:39:40 +0000 Subject: [PATCH 2/2] Let package search fall back to public juliahub.com data Searching without a stored token failed with "authentication required". On juliahub.com the package data is public, so an anonymous search now works there. Every other server (juliahub.dev, private deployments) still requires authentication. The authenticated endpoints are unusable logged out, so the anonymous path is GraphQL-only: - optionalToken(server) returns the stored token when there is one, and (nil, nil) for juliahub.com when there is not, so callers can fall back to public endpoints. Any other server gets an error, as before. - executeGraphQL omits the Authorization header and sends X-Hasura-Role: anonymous when the token is nil. - searchPackages skips the REST attempt when unauthenticated, since /packages/info is always authenticated and would only 401 before falling back to GraphQL anyway. - fetchPackageRegistries resolves registry IDs from the public /app/packages/registries endpoint (the one the logged-out web UI uses). This is required, not an optimisation: the anonymous Hasura role returns no rows unless the registries variable is non-empty. - apiGet skips the Authorization header when given an empty token. Row-level permissions scope anonymous results to public registries, so --registries naming a private registry returns "No packages found" rather than an error. Authenticated behaviour is unchanged: REST first, GraphQL fallback. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 9 ++++++++- auth.go | 20 ++++++++++++++++++++ auth_test.go | 27 +++++++++++++++++++++++++++ main.go | 4 ++-- packages.go | 27 +++++++++++++++++++++------ registries.go | 45 ++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 122 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6126b51..5b12b1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -509,7 +509,14 @@ jh run setup - 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 - 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) +- `fetchRegistries` in `registries.go` is used by `listRegistries`, `packageInfoCmd`, and `packageDependencyCmd` to resolve registry names to IDs (for GraphQL) and names (for REST); `packageSearchCmd` uses `fetchPackageRegistries`, which adds the anonymous fallback +- `jh package search` works without logging in, but **only against juliahub.com** — every other server still requires authentication + - `optionalToken(server)` in `auth.go` returns the stored token when available; when there is none it returns `(nil, nil)` for juliahub.com (via `allowsAnonymousReads`) and an error for any other server + - Anonymous searches skip REST entirely (`/packages/info` is always authenticated) and go straight to GraphQL; `executeGraphQL` omits the `Authorization` header and sends `X-Hasura-Role: anonymous` when the token is nil + - The anonymous Hasura role only returns rows when the `registries` variable is non-empty, so registry IDs must always be resolved first + - `fetchPackageRegistries` resolves those IDs: authenticated users hit `/api/v1/registry/registries/descriptions`; anonymous users hit the public `/app/packages/registries` (`fetchPublicRegistries`), which only carries name, UUID, and ID + - Row-level permissions restrict anonymous results to public registries (General on juliahub.com), so `--registries` naming a private registry returns "No packages found" rather than an error + - `apiGet` skips the `Authorization` header when passed an empty token, which is how the public registry listing is fetched - 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 - A package in multiple registries appears as multiple rows (one per registry) in both REST and GraphQL paths, since the GraphQL view (`package_rank_vw`) is already flattened per package-registry combination - GraphQL fallback uses `package_search.gql` (`FilteredPackages`) for the package list and `package_search_count.gql` (`FilteredPackagesCount`) for the aggregate count as separate requests diff --git a/auth.go b/auth.go index 3430860..ac68ba0 100644 --- a/auth.go +++ b/auth.go @@ -336,6 +336,26 @@ func ensureValidToken() (*StoredToken, error) { return updatedToken, nil } +// allowsAnonymousReads reports whether a server exposes package data publicly. +// Only juliahub.com does; private deployments always require authentication. +func allowsAnonymousReads(server string) bool { + return strings.EqualFold(server, "juliahub.com") +} + +// optionalToken returns a valid token when the user is logged in. When there is +// no usable token it returns (nil, nil) for servers that allow anonymous reads, +// so callers can fall back to public endpoints, and an error otherwise. +func optionalToken(server string) (*StoredToken, error) { + token, err := ensureValidToken() + if err == nil { + return token, nil + } + if allowsAnonymousReads(server) { + return nil, nil + } + return nil, fmt.Errorf("authentication required: %w", err) +} + // updateJuliaCredentialsIfNeeded updates Julia credentials if the auth file exists // This is called after token refresh to keep credentials in sync func updateJuliaCredentialsIfNeeded(server string, token *StoredToken) error { diff --git a/auth_test.go b/auth_test.go index 5558a56..019363c 100644 --- a/auth_test.go +++ b/auth_test.go @@ -132,3 +132,30 @@ func TestReadStoredToken(t *testing.T) { t.Errorf("parsed token mismatch: %+v", tok) } } + +func TestAllowsAnonymousReads(t *testing.T) { + for server, want := range map[string]bool{ + "juliahub.com": true, + "JuliaHub.com": true, + "nightly.juliahub.dev": false, + "internal.juliahub.com": false, + } { + if got := allowsAnonymousReads(server); got != want { + t.Errorf("allowsAnonymousReads(%q) = %t, want %t", server, got, want) + } + } +} + +func TestOptionalTokenWithoutStoredToken(t *testing.T) { + // An empty home means there is no stored token to load. + t.Setenv("HOME", t.TempDir()) + + tok, err := optionalToken("juliahub.com") + if err != nil || tok != nil { + t.Errorf("juliahub.com should fall back to anonymous, got token=%v err=%v", tok, err) + } + + if _, err := optionalToken("nightly.juliahub.dev"); err == nil { + t.Error("private servers should still require authentication") + } +} diff --git a/main.go b/main.go index 0171561..5de90a8 100644 --- a/main.go +++ b/main.go @@ -934,8 +934,8 @@ Use --verbose flag for comprehensive output, or get a concise summary by default verbose, _ := cmd.Flags().GetBool("verbose") registryNamesStr, _ := cmd.Flags().GetString("registries") - // Fetch all registries from the API - allRegistries, err := fetchRegistries(server) + // Fetch all registries from the API (works logged out on juliahub.com) + allRegistries, err := fetchPackageRegistries(server) if err != nil { fmt.Printf("Failed to fetch registries: %v\n", err) os.Exit(1) diff --git a/packages.go b/packages.go index d998b84..410f3d0 100644 --- a/packages.go +++ b/packages.go @@ -332,9 +332,9 @@ func buildGraphQLPackageVariables(search string, limit, offset int, registryIDs } func fetchGraphQLPackages(server, search string, limit, offset int, registryIDs []int) ([]Package, error) { - token, err := ensureValidToken() + token, err := optionalToken(server) if err != nil { - return nil, fmt.Errorf("authentication required: %w", err) + return nil, err } queryBytes, err := packageSearchFS.ReadFile("package_search.gql") @@ -370,9 +370,9 @@ func fetchGraphQLPackages(server, search string, limit, offset int, registryIDs } func fetchGraphQLPackageCount(server, search string, registryIDs []int) (int, error) { - token, err := ensureValidToken() + token, err := optionalToken(server) if err != nil { - return 0, fmt.Errorf("authentication required: %w", err) + return 0, err } queryBytes, err := packageSearchFS.ReadFile("package_search_count.gql") @@ -460,6 +460,15 @@ func searchPackagesGraphQL(params PackageSearchParams) error { } func searchPackages(params PackageSearchParams) error { + token, err := optionalToken(params.Server) + if err != nil { + return err + } + // /packages/info always requires authentication, so anonymous searches go + // straight to GraphQL, which serves public registries under the anonymous role. + if token == nil { + return searchPackagesGraphQL(params) + } if err := searchPackagesREST(params); err != nil { return searchPackagesGraphQL(params) } @@ -478,10 +487,16 @@ func executeGraphQL(server string, token *StoredToken, gqlReq GraphQLRequest) ([ return nil, fmt.Errorf("failed to create GraphQL request: %w", err) } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + // A nil token means an anonymous query: Hasura's anonymous role serves the + // publicly readable registries without an Authorization header. + if token != nil { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + req.Header.Set("X-Hasura-Role", "jhuser") + } else { + req.Header.Set("X-Hasura-Role", "anonymous") + } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - req.Header.Set("X-Hasura-Role", "jhuser") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) diff --git a/registries.go b/registries.go index c0eef88..52f5fbb 100644 --- a/registries.go +++ b/registries.go @@ -49,6 +49,46 @@ func fetchRegistries(server string) ([]Registry, error) { return registries, nil } +// fetchPackageRegistries resolves the registries used to scope package queries. +// Logged-in users get the full descriptions; anonymous users on juliahub.com get +// the public listing, which only carries name, UUID and ID. +func fetchPackageRegistries(server string) ([]Registry, error) { + token, err := optionalToken(server) + if err != nil { + return nil, err + } + if token != nil { + return fetchRegistries(server) + } + return fetchPublicRegistries(server) +} + +// fetchPublicRegistries lists registries via the unauthenticated endpoint used by +// the logged-out web UI. +func fetchPublicRegistries(server string) ([]Registry, error) { + body, err := apiGet(fmt.Sprintf("https://%s/app/packages/registries", server), "") + if err != nil { + return nil, err + } + + var response struct { + Registries []struct { + Name string `json:"name"` + UUID string `json:"uuid"` + ID int `json:"id"` + } `json:"registries"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + registries := make([]Registry, len(response.Registries)) + for i, r := range response.Registries { + registries[i] = Registry{Name: r.Name, UUID: r.UUID, RegistryID: r.ID} + } + return registries, nil +} + // apiGet performs a GET request with up to 3 attempts, retrying on transient errors. func apiGet(url, idToken string) ([]byte, error) { client := &http.Client{Timeout: 30 * time.Second} @@ -61,7 +101,10 @@ func apiGet(url, idToken string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", idToken)) + // An empty token means an anonymous request against a public endpoint. + if idToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", idToken)) + } req.Header.Set("Accept", "application/json") resp, err := client.Do(req)