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
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,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
Expand Down Expand Up @@ -538,7 +542,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=<ver>` and latest version at `/docs/<registry>/<package>/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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,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 <package-name>` - 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`)

Expand Down Expand Up @@ -374,6 +378,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
Expand Down
38 changes: 37 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,39 @@ the --registry flag.`,
},
}

var packageDependentsCmd = &cobra.Command{
Use: "dependents <package-name>",
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 searchCmd = &cobra.Command{
Use: "search",
Short: "Search code, symbols, documentation and packages",
Expand Down Expand Up @@ -2619,6 +2652,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")
Expand Down Expand Up @@ -2681,7 +2717,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)")
Expand Down
105 changes: 97 additions & 8 deletions packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
)
Expand Down Expand Up @@ -62,10 +63,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 {
Expand Down Expand Up @@ -733,25 +745,27 @@ 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
}
}

docsURL := fmt.Sprintf("https://%s/docs/%s/%s/stable/pkg.json", server, targetRegistry, packageName)

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))
Expand All @@ -760,23 +774,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
Expand Down Expand Up @@ -839,3 +861,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
}
40 changes: 40 additions & 0 deletions packages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,43 @@ func TestFetchRESTPackagesFromHTTPError(t *testing.T) {
t.Errorf("want a one-line 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)
}
}
Loading