Skip to content
Open
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
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
4 changes: 2 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 21 additions & 20 deletions packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shouldnt we access REST endpoints instead of Graphql?

}
if err := searchPackagesREST(params); err != nil {
return searchPackagesGraphQL(params)
}
Expand All @@ -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)
Expand Down
15 changes: 4 additions & 11 deletions packages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand Down
45 changes: 44 additions & 1 deletion registries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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)
Expand Down