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 f005350..410f3d0 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 } @@ -346,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") @@ -384,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") @@ -474,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) } @@ -492,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/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) } }) 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)