From 848095908d4b306867b1a454753700526db72026 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Thu, 29 Jan 2026 11:15:28 +0000 Subject: [PATCH 01/14] registry list cmd --- CLAUDE.md | 11 ++++-- README.md | 15 +++++++++ main.go | 45 +++++++++++++++++++++++-- registries.go | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 registries.go diff --git a/CLAUDE.md b/CLAUDE.md index bd32ad4..0442344 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a Go-based CLI tool for interacting with JuliaHub, a platform for Julia computing. The CLI provides commands for authentication, dataset management, project management, user information, Git integration, and Julia integration. +This is a Go-based CLI tool for interacting with JuliaHub, a platform for Julia computing. The CLI provides commands for authentication, dataset management, registry management, project management, user information, Git integration, and Julia integration. ## Architecture @@ -13,6 +13,7 @@ The application follows a command-line interface pattern using the Cobra library - **main.go**: Core CLI structure with command definitions and configuration management - **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) with REST API integration - **projects.go**: Project management using GraphQL API with user filtering - **user.go**: User information retrieval using GraphQL API - **git.go**: Git integration (clone, push, fetch, pull) with JuliaHub authentication @@ -29,7 +30,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}`) + - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`) and registry operations (`/api/v1/ui/registries/descriptions`) - **GraphQL API**: Used for projects and user info (`/v1/graphql`) - **Headers**: All GraphQL requests require `X-Hasura-Role: jhuser` header - **Authentication**: Uses ID tokens (`token.IDToken`) for API calls @@ -37,6 +38,7 @@ The application follows a command-line interface pattern using the Cobra library 3. **Command Structure**: - `jh auth`: Authentication commands (login, refresh, status, env) - `jh dataset`: Dataset operations (list, download, upload, status) + - `jh registry`: Registry operations (list) - `jh project`: Project management (list with GraphQL, supports user filtering) - `jh user`: User information (info with GraphQL) - `jh clone`: Git clone with JuliaHub authentication and project name resolution @@ -84,6 +86,11 @@ go run . dataset download go run . dataset upload --new ./file.tar.gz ``` +### Test registry operations +```bash +go run . registry list +``` + ### Test project and user operations ```bash go run . project list diff --git a/README.md b/README.md index e896a05..6766022 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A command-line interface for interacting with JuliaHub, a platform for Julia com - **Authentication**: OAuth2 device flow authentication with JWT token handling - **Dataset Management**: List, download, upload, and check status of datasets +- **Registry Management**: List and manage Julia package registries - **Project Management**: List and filter projects using GraphQL API - **Git Integration**: Clone, push, fetch, and pull with automatic JuliaHub authentication - **Julia Integration**: Install Julia and run with JuliaHub package server configuration @@ -148,6 +149,10 @@ go build -o jh . - `jh dataset upload [dataset-id] ` - Upload a dataset - `jh dataset status [version]` - Show dataset status +### Registry Management (`jh registry`) + +- `jh registry list` - List all package registries on JuliaHub + ### Project Management (`jh project`) - `jh project list` - List all accessible projects @@ -214,6 +219,16 @@ jh dataset upload --new ./my-data.tar.gz jh dataset upload my-dataset ./updated-data.tar.gz ``` +### Registry Operations + +```bash +# List all registries +jh registry list + +# List registries on custom server +jh registry list -s yourinstall +``` + ### Project Operations ```bash diff --git a/main.go b/main.go index 71b31c2..e8d46ff 100644 --- a/main.go +++ b/main.go @@ -161,11 +161,12 @@ job execution, project management, Git integration, and package hosting capabili Available command categories: auth - Authentication and token management dataset - Dataset operations (list, download, upload, status) + registry - Registry management (list registries) project - Project management (list, filter by user) user - User information and profile clone - Clone projects with automatic authentication push - Push changes with authentication - fetch - Fetch updates with authentication + fetch - Fetch updates with authentication pull - Pull changes with authentication julia - Julia installation and management run - Run Julia with JuliaHub configuration @@ -550,6 +551,44 @@ Displays: }, } +var registryCmd = &cobra.Command{ + Use: "registry", + Short: "Registry management commands", + Long: `Manage Julia package registries on JuliaHub. + +Registries are collections of Julia packages that can be registered and +installed. JuliaHub supports multiple registries including the General +registry, custom organizational registries, and test registries.`, +} + +var registryListCmd = &cobra.Command{ + Use: "list", + Short: "List registries", + Long: `List all package registries on JuliaHub. + +Displays information including: +- Registry UUID +- Registry name and ID +- Owner information +- Creation date +- Package count +- Description +- Registration status`, + Example: " jh registry list\n jh registry list -s custom-server.com", + 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) + } + + if err := listRegistries(server); err != nil { + fmt.Printf("Failed to list registries: %v\n", err) + os.Exit(1) + } + }, +} + var projectCmd = &cobra.Command{ Use: "project", Short: "Project management commands", @@ -982,6 +1021,7 @@ func init() { datasetUploadCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") datasetUploadCmd.Flags().Bool("new", false, "Create a new dataset") datasetStatusCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + registryListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") projectListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") projectListCmd.Flags().String("user", "", "Filter projects by user (leave empty to show only your own projects)") userInfoCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") @@ -994,13 +1034,14 @@ func init() { authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobCmd.AddCommand(jobListCmd, jobStartCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) + registryCmd.AddCommand(registryListCmd) projectCmd.AddCommand(projectListCmd) userCmd.AddCommand(userInfoCmd) juliaCmd.AddCommand(juliaInstallCmd) runCmd.AddCommand(runSetupCmd) gitCredentialCmd.AddCommand(gitCredentialHelperCmd, gitCredentialGetCmd, gitCredentialStoreCmd, gitCredentialEraseCmd, gitCredentialSetupCmd) - rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, projectCmd, userCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) + rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, registryCmd, projectCmd, userCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) } func main() { diff --git a/registries.go b/registries.go new file mode 100644 index 0000000..6d46180 --- /dev/null +++ b/registries.go @@ -0,0 +1,92 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type Registry struct { + UUID string `json:"uuid"` + Name string `json:"name"` + RegistryID int `json:"registry_id"` + Owner *string `json:"owner"` + Register bool `json:"register"` + CreationDate CustomTime `json:"creation_date"` + PackageCount int `json:"package_count"` + Description string `json:"description"` +} + +func listRegistries(server string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + url := fmt.Sprintf("https://%s/api/v1/ui/registries/descriptions", server) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + 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 fmt.Errorf("failed to make request: %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)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var registries []Registry + if err := json.Unmarshal(body, ®istries); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if len(registries) == 0 { + fmt.Println("No registries found") + return nil + } + + fmt.Printf("Found %d registr%s:\n\n", len(registries), pluralize(len(registries), "y", "ies")) + for _, registry := range registries { + fmt.Printf("UUID: %s\n", registry.UUID) + fmt.Printf("Name: %s\n", registry.Name) + fmt.Printf("Registry ID: %d\n", registry.RegistryID) + if registry.Owner != nil { + fmt.Printf("Owner: %s\n", *registry.Owner) + } else { + fmt.Printf("Owner: (none)\n") + } + fmt.Printf("Register: %t\n", registry.Register) + fmt.Printf("Creation Date: %s\n", registry.CreationDate.Time.Format(time.RFC3339)) + fmt.Printf("Package Count: %d\n", registry.PackageCount) + if registry.Description != "" { + fmt.Printf("Description: %s\n", registry.Description) + } + fmt.Println() + } + + return nil +} + +func pluralize(count int, singular, plural string) string { + if count == 1 { + return singular + } + return plural +} From dd52750c171255014804e64cc91740dcb8c57f9c Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Mon, 2 Feb 2026 07:58:33 +0000 Subject: [PATCH 02/14] added verbose for detailed view --- CLAUDE.md | 4 +++- README.md | 7 ++++++- main.go | 10 +++++++--- registries.go | 40 +++++++++++++++++++++++++--------------- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0442344..b3819e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ The application follows a command-line interface pattern using the Cobra library 3. **Command Structure**: - `jh auth`: Authentication commands (login, refresh, status, env) - `jh dataset`: Dataset operations (list, download, upload, status) - - `jh registry`: Registry operations (list) + - `jh registry`: Registry operations (list with REST API, supports verbose mode) - `jh project`: Project management (list with GraphQL, supports user filtering) - `jh user`: User information (info with GraphQL) - `jh clone`: Git clone with JuliaHub authentication and project name resolution @@ -89,6 +89,7 @@ go run . dataset upload --new ./file.tar.gz ### Test registry operations ```bash go run . registry list +go run . registry list --verbose ``` ### Test project and user operations @@ -280,6 +281,7 @@ jh run setup - Clone command automatically resolves `username/project` format to project UUIDs - Folder naming conflicts are resolved with automatic numbering (project-1, project-2, etc.) - Credential helper follows Git protocol: responds only to JuliaHub URLs, ignores others +- Registry list output is concise by default (UUID and Name only); use `--verbose` flag for detailed information (owner, creation date, package count, description) ## Implementation Details diff --git a/README.md b/README.md index 6766022..2de4c8d 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ go build -o jh . ### Registry Management (`jh registry`) - `jh registry list` - List all package registries on JuliaHub + - Default: Shows only UUID and Name + - `jh registry list --verbose` - Show detailed registry information including owner, creation date, package count, and description ### Project Management (`jh project`) @@ -222,9 +224,12 @@ jh dataset upload my-dataset ./updated-data.tar.gz ### Registry Operations ```bash -# List all registries +# List all registries (UUID and Name only) jh registry list +# List registries with detailed information +jh registry list --verbose + # List registries on custom server jh registry list -s yourinstall ``` diff --git a/main.go b/main.go index e8d46ff..77af961 100644 --- a/main.go +++ b/main.go @@ -566,7 +566,8 @@ var registryListCmd = &cobra.Command{ Short: "List registries", Long: `List all package registries on JuliaHub. -Displays information including: +By default, displays only UUID and Name for each registry. +Use --verbose flag to display comprehensive information including: - Registry UUID - Registry name and ID - Owner information @@ -574,7 +575,7 @@ Displays information including: - Package count - Description - Registration status`, - Example: " jh registry list\n jh registry list -s custom-server.com", + Example: " jh registry list\n jh registry list --verbose\n jh registry list -s custom-server.com", Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) if err != nil { @@ -582,7 +583,9 @@ Displays information including: os.Exit(1) } - if err := listRegistries(server); err != nil { + verbose, _ := cmd.Flags().GetBool("verbose") + + if err := listRegistries(server, verbose); err != nil { fmt.Printf("Failed to list registries: %v\n", err) os.Exit(1) } @@ -1022,6 +1025,7 @@ func init() { datasetUploadCmd.Flags().Bool("new", false, "Create a new dataset") datasetStatusCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") 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") projectListCmd.Flags().String("user", "", "Filter projects by user (leave empty to show only your own projects)") userInfoCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") diff --git a/registries.go b/registries.go index 6d46180..6bc8e9e 100644 --- a/registries.go +++ b/registries.go @@ -19,7 +19,7 @@ type Registry struct { Description string `json:"description"` } -func listRegistries(server string) error { +func listRegistries(server string, verbose bool) error { token, err := ensureValidToken() if err != nil { return fmt.Errorf("authentication required: %w", err) @@ -63,22 +63,32 @@ func listRegistries(server string) error { } fmt.Printf("Found %d registr%s:\n\n", len(registries), pluralize(len(registries), "y", "ies")) - for _, registry := range registries { - fmt.Printf("UUID: %s\n", registry.UUID) - fmt.Printf("Name: %s\n", registry.Name) - fmt.Printf("Registry ID: %d\n", registry.RegistryID) - if registry.Owner != nil { - fmt.Printf("Owner: %s\n", *registry.Owner) - } else { - fmt.Printf("Owner: (none)\n") + + if verbose { + // Verbose mode: show all details + for _, registry := range registries { + fmt.Printf("UUID: %s\n", registry.UUID) + fmt.Printf("Name: %s\n", registry.Name) + if registry.Owner != nil { + fmt.Printf("Owner: %s\n", *registry.Owner) + } else { + fmt.Printf("Owner: (none)\n") + } + fmt.Printf("Register: %t\n", registry.Register) + fmt.Printf("Creation Date: %s\n", registry.CreationDate.Time.Format(time.RFC3339)) + fmt.Printf("Package Count: %d\n", registry.PackageCount) + if registry.Description != "" { + fmt.Printf("Description: %s\n", registry.Description) + } + fmt.Println() } - fmt.Printf("Register: %t\n", registry.Register) - fmt.Printf("Creation Date: %s\n", registry.CreationDate.Time.Format(time.RFC3339)) - fmt.Printf("Package Count: %d\n", registry.PackageCount) - if registry.Description != "" { - fmt.Printf("Description: %s\n", registry.Description) + } else { + // Default mode: show only UUID and Name + for _, registry := range registries { + fmt.Printf("UUID: %s\n", registry.UUID) + fmt.Printf("Name: %s\n", registry.Name) + fmt.Println() } - fmt.Println() } return nil From abcd7e9d7b8a8e10e028f82966b92b7ec56c98c2 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Tue, 3 Feb 2026 08:20:57 +0000 Subject: [PATCH 03/14] package search cmd with flags --- main.go | 129 ++++++++++++++++++++++- package_search.gql | 58 +++++++++++ packages.go | 252 +++++++++++++++++++++++++++++++++++++++++++++ registries.go | 29 ++++-- 4 files changed, 457 insertions(+), 11 deletions(-) create mode 100644 package_search.gql create mode 100644 packages.go diff --git a/main.go b/main.go index 77af961..b1652c6 100644 --- a/main.go +++ b/main.go @@ -161,6 +161,7 @@ job execution, project management, Git integration, and package hosting capabili Available command categories: auth - Authentication and token management dataset - Dataset operations (list, download, upload, status) + package - Package search and exploration registry - Registry management (list registries) project - Project management (list, filter by user) user - User information and profile @@ -551,6 +552,121 @@ Displays: }, } +var packageCmd = &cobra.Command{ + Use: "package", + Short: "Package search commands", + Long: `Search and explore Julia packages on JuliaHub. + +Packages are Julia libraries that provide reusable functionality. JuliaHub +hosts packages from multiple registries and provides comprehensive search +capabilities including filtering by installation status, failures, and more.`, +} + +var packageSearchCmd = &cobra.Command{ + Use: "search [search-term]", + Short: "Search for packages", + Long: `Search for Julia packages on JuliaHub. + +Displays package information including: +- Package name, owner, and UUID +- Version and registry information +- Description and repository +- Tags and star count +- Installation status +- License information + +Filtering options: +- Filter by registry using --registries flag (searches all registries by default) +- Filter by installation status (--installed, --not-installed) +- Filter by packages with download failures (--has-failures) + +Use --verbose flag for comprehensive output, or get a concise summary by default.`, + Example: " jh package search dataframes\n jh package search --installed\n jh package search --verbose plots\n jh package search --limit 20 ml\n jh package search --registries General optimization", + Args: cobra.MaximumNArgs(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) + } + + search := "" + if len(args) > 0 { + search = args[0] + } + + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + verbose, _ := cmd.Flags().GetBool("verbose") + registryNamesStr, _ := cmd.Flags().GetString("registries") + + // Handle boolean flags - only set if explicitly provided + var installed *bool + var notInstalled *bool + var hasFailures *bool + + if cmd.Flags().Changed("installed") { + val, _ := cmd.Flags().GetBool("installed") + installed = &val + } + + if cmd.Flags().Changed("not-installed") { + val, _ := cmd.Flags().GetBool("not-installed") + notInstalled = &val + } + + if cmd.Flags().Changed("has-failures") { + val, _ := cmd.Flags().GetBool("has-failures") + hasFailures = &val + } + + // Fetch all registries from the API + allRegistries, err := fetchRegistries(server) + if err != nil { + fmt.Printf("Failed to fetch registries: %v\n", err) + os.Exit(1) + } + + // Determine which registry IDs to use + var registryIDs []int + if registryNamesStr != "" { + // Use only specified registries + requestedNames := strings.Split(registryNamesStr, ",") + for _, requestedName := range requestedNames { + requestedName = strings.TrimSpace(requestedName) + if requestedName == "" { + continue + } + + // Find matching registry (case-insensitive) + found := false + for _, reg := range allRegistries { + if strings.EqualFold(reg.Name, requestedName) { + registryIDs = append(registryIDs, reg.RegistryID) + found = true + break + } + } + + if !found { + fmt.Printf("Registry not found: '%s'\n", requestedName) + os.Exit(1) + } + } + } else { + // Use all registries + for _, reg := range allRegistries { + registryIDs = append(registryIDs, reg.RegistryID) + } + } + + if err := searchPackages(server, search, limit, offset, installed, notInstalled, hasFailures, registryIDs, verbose); err != nil { + fmt.Printf("Failed to search packages: %v\n", err) + os.Exit(1) + } + }, +} + var registryCmd = &cobra.Command{ Use: "registry", Short: "Registry management commands", @@ -566,7 +682,7 @@ var registryListCmd = &cobra.Command{ Short: "List registries", Long: `List all package registries on JuliaHub. -By default, displays only UUID and Name for each registry. +By default, displays Registry ID, UUID, and Name for each registry. Use --verbose flag to display comprehensive information including: - Registry UUID - Registry name and ID @@ -1024,6 +1140,14 @@ func init() { datasetUploadCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") datasetUploadCmd.Flags().Bool("new", false, "Create a new dataset") datasetStatusCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + packageSearchCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + packageSearchCmd.Flags().Int("limit", 10, "Maximum number of results to return") + packageSearchCmd.Flags().Int("offset", 0, "Number of results to skip") + packageSearchCmd.Flags().Bool("installed", false, "Filter by installed packages") + packageSearchCmd.Flags().Bool("not-installed", false, "Filter by not installed packages") + packageSearchCmd.Flags().Bool("has-failures", false, "Filter by packages with download failures") + packageSearchCmd.Flags().String("registries", "", "Filter by registry names (comma-separated, e.g., 'General,CustomRegistry')") + packageSearchCmd.Flags().Bool("verbose", false, "Show detailed package information") 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") @@ -1038,6 +1162,7 @@ func init() { authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobCmd.AddCommand(jobListCmd, jobStartCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) + packageCmd.AddCommand(packageSearchCmd) registryCmd.AddCommand(registryListCmd) projectCmd.AddCommand(projectListCmd) userCmd.AddCommand(userInfoCmd) @@ -1045,7 +1170,7 @@ func init() { runCmd.AddCommand(runSetupCmd) gitCredentialCmd.AddCommand(gitCredentialHelperCmd, gitCredentialGetCmd, gitCredentialStoreCmd, gitCredentialEraseCmd, gitCredentialSetupCmd) - rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, registryCmd, projectCmd, userCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) + rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, packageCmd, registryCmd, projectCmd, userCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) } func main() { diff --git a/package_search.gql b/package_search.gql new file mode 100644 index 0000000..b0310c9 --- /dev/null +++ b/package_search.gql @@ -0,0 +1,58 @@ +query FilteredPackages( + $search: String + $limit: Int + $offset: Int + $matchtags: _text + $registries: _int8 + $hasfailures: Boolean + $installed: Boolean + $notinstalled: Boolean + $licenses: _text + $order: [package_rank_vw_order_by!] + $filter: package_rank_vw_bool_exp = {} +) { + package_search( + args: { + search: $search + matchtags: $matchtags + licenses: $licenses + isinstalled: $installed + notinstalled: $notinstalled + hasfailures: $hasfailures + registrylist: $registries + } + order_by: $order + limit: $limit + offset: $offset + where: { _and: [{ fit: { _gte: 1 } }, $filter] } + ) { + name + owner + slug + license + isapp + score + registrymap { + version + registryid + status + isapp + isjsml + } + metadata { + docshosteduri + versions + description + docslink + repo + owner + tags + starcount + } + uuid + installed + failures { + package_version + } + } +} diff --git a/packages.go b/packages.go new file mode 100644 index 0000000..b06b9e0 --- /dev/null +++ b/packages.go @@ -0,0 +1,252 @@ +package main + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +//go:embed package_search.gql +var packageSearchFS embed.FS + +type PackageMetadata struct { + DocsHostedURI string `json:"docshosteduri"` + Versions *string `json:"versions"` + Description string `json:"description"` + DocsLink string `json:"docslink"` + Repo string `json:"repo"` + Owner string `json:"owner"` + Tags []string `json:"tags"` + StarCount int `json:"starcount"` +} + +type PackageRegistryMap struct { + Version string `json:"version"` + RegistryID int `json:"registryid"` + Status bool `json:"status"` + IsApp bool `json:"isapp"` + IsJSML *bool `json:"isjsml"` +} + +type PackageFailure struct { + PackageVersion string `json:"package_version"` +} + +type Package struct { + Name string `json:"name"` + Owner string `json:"owner"` + Slug *string `json:"slug"` + License string `json:"license"` + IsApp bool `json:"isapp"` + Score float64 `json:"score"` + RegistryMap *PackageRegistryMap `json:"registrymap"` + Metadata *PackageMetadata `json:"metadata"` + UUID string `json:"uuid"` + Installed bool `json:"installed"` + Failures []PackageFailure `json:"failures"` +} + +type PackageSearchResponse struct { + Data struct { + PackageSearch []Package `json:"package_search"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +func searchPackages(server string, search string, limit int, offset int, installed *bool, notInstalled *bool, hasFailures *bool, registries []int, verbose bool) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + // Read the GraphQL query from package_search.gql + queryBytes, err := packageSearchFS.ReadFile("package_search.gql") + if err != nil { + return fmt.Errorf("failed to read GraphQL query: %w", err) + } + query := string(queryBytes) + + // Build variables for the GraphQL query + variables := map[string]interface{}{} + + if search != "" { + variables["search"] = search + } + + if limit > 0 { + variables["limit"] = limit + } + + if offset > 0 { + variables["offset"] = offset + } + + if installed != nil { + variables["installed"] = *installed + } + + if notInstalled != nil { + variables["notinstalled"] = *notInstalled + } + + if hasFailures != nil { + variables["hasfailures"] = *hasFailures + } + + if len(registries) > 0 { + variables["registries"] = fmt.Sprintf("{%s}", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(registries)), ","), "[]")) + } + + // Create GraphQL request + graphqlReq := GraphQLRequest{ + OperationName: "FilteredPackages", + Query: query, + Variables: variables, + } + + jsonData, err := json.Marshal(graphqlReq) + if err != nil { + return fmt.Errorf("failed to marshal GraphQL request: %w", err) + } + + url := fmt.Sprintf("https://%s/v1/graphql", server) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + 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) + if err != nil { + return fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GraphQL 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) + } + + var response PackageSearchResponse + if err := json.Unmarshal(body, &response); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + // Check for GraphQL errors + if len(response.Errors) > 0 { + return fmt.Errorf("GraphQL errors: %v", response.Errors) + } + + packages := response.Data.PackageSearch + + if len(packages) == 0 { + fmt.Println("No packages found") + return nil + } + + fmt.Printf("Found %d package(s):\n\n", len(packages)) + + for _, pkg := range packages { + if verbose { + // Verbose output with all details + fmt.Printf("Name: %s\n", pkg.Name) + fmt.Printf("UUID: %s\n", pkg.UUID) + fmt.Printf("Owner: %s\n", pkg.Owner) + + if pkg.Metadata != nil { + if pkg.Metadata.Description != "" { + fmt.Printf("Description: %s\n", pkg.Metadata.Description) + } + if pkg.Metadata.Repo != "" { + fmt.Printf("Repository: %s\n", pkg.Metadata.Repo) + } + if len(pkg.Metadata.Tags) > 0 { + fmt.Printf("Tags: %s\n", strings.Join(pkg.Metadata.Tags, ", ")) + } + if pkg.Metadata.StarCount > 0 { + fmt.Printf("Stars: %d\n", pkg.Metadata.StarCount) + } + if pkg.Metadata.DocsLink != "" { + fmt.Printf("Documentation: %s\n", pkg.Metadata.DocsLink) + } + } + + if pkg.License != "" { + fmt.Printf("License: %s\n", pkg.License) + } + + if pkg.RegistryMap != nil { + fmt.Printf("Latest Version: %s\n", pkg.RegistryMap.Version) + fmt.Printf("Registry ID: %d\n", pkg.RegistryMap.RegistryID) + fmt.Printf("Status: ") + if pkg.RegistryMap.Status { + fmt.Printf("Active\n") + } else { + fmt.Printf("Inactive\n") + } + } + + fmt.Printf("Installed: %t\n", pkg.Installed) + + if pkg.IsApp { + fmt.Printf("Type: Application\n") + } + + if len(pkg.Failures) > 0 { + fmt.Printf("Failed Versions: ") + versions := make([]string, len(pkg.Failures)) + for i, failure := range pkg.Failures { + versions[i] = failure.PackageVersion + } + fmt.Printf("%s\n", strings.Join(versions, ", ")) + } + + fmt.Printf("Score: %.2f\n", pkg.Score) + } else { + // Concise output + fmt.Printf("%-30s %-20s", pkg.Name, pkg.Owner) + + if pkg.RegistryMap != nil { + fmt.Printf(" v%-10s", pkg.RegistryMap.Version) + } else { + fmt.Printf(" %-12s", "N/A") + } + + if pkg.Installed { + fmt.Printf(" [Installed]") + } + + if pkg.Metadata != nil && pkg.Metadata.Description != "" { + // Truncate description for concise view + desc := pkg.Metadata.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + fmt.Printf("\n %s", desc) + } + + fmt.Printf("\n") + } + + fmt.Println() + } + + return nil +} diff --git a/registries.go b/registries.go index 6bc8e9e..1f66447 100644 --- a/registries.go +++ b/registries.go @@ -19,17 +19,18 @@ type Registry struct { Description string `json:"description"` } -func listRegistries(server string, verbose bool) error { +// fetchRegistries retrieves all registries from the API and returns them +func fetchRegistries(server string) ([]Registry, error) { token, err := ensureValidToken() if err != nil { - return fmt.Errorf("authentication required: %w", err) + return nil, fmt.Errorf("authentication required: %w", err) } - url := fmt.Sprintf("https://%s/api/v1/ui/registries/descriptions", server) + url := fmt.Sprintf("https://%s/api/v1/registry/registries/descriptions", server) req, err := http.NewRequest("GET", url, 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)) @@ -38,23 +39,32 @@ func listRegistries(server string, verbose bool) error { client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to make request: %w", err) + return nil, fmt.Errorf("failed to make request: %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 registries []Registry if err := json.Unmarshal(body, ®istries); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return registries, nil +} + +func listRegistries(server string, verbose bool) error { + registries, err := fetchRegistries(server) + if err != nil { + return err } if len(registries) == 0 { @@ -83,8 +93,9 @@ func listRegistries(server string, verbose bool) error { fmt.Println() } } else { - // Default mode: show only UUID and Name + // Default mode: show UUID, Name, and Registry ID (needed for package search filtering) for _, registry := range registries { + fmt.Printf("Registry ID: %d\n", registry.RegistryID) fmt.Printf("UUID: %s\n", registry.UUID) fmt.Printf("Name: %s\n", registry.Name) fmt.Println() From 872e4f8a90f173f0d803f4ab16868ca46c2ef232 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Tue, 3 Feb 2026 10:12:04 +0000 Subject: [PATCH 04/14] fixed gql query issues --- package_search.gql | 4 ++++ packages.go | 21 ++++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/package_search.gql b/package_search.gql index b0310c9..33c7d80 100644 --- a/package_search.gql +++ b/package_search.gql @@ -38,6 +38,7 @@ query FilteredPackages( status isapp isjsml + __typename } metadata { docshosteduri @@ -48,11 +49,14 @@ query FilteredPackages( owner tags starcount + __typename } uuid installed failures { package_version + __typename } + __typename } } diff --git a/packages.go b/packages.go index b06b9e0..600c99e 100644 --- a/packages.go +++ b/packages.go @@ -74,7 +74,17 @@ func searchPackages(server string, search string, limit int, offset int, install query := string(queryBytes) // Build variables for the GraphQL query - variables := map[string]interface{}{} + variables := map[string]interface{}{ + "filter": map[string]interface{}{}, + "order": map[string]string{"score": "desc"}, + "matchtags": "{}", + "licenses": "{}", + "search": "", + "offset": 0, + "hasfailures": false, + "installed": true, + "notinstalled": true, + } if search != "" { variables["search"] = search @@ -101,10 +111,15 @@ func searchPackages(server string, search string, limit int, offset int, install } if len(registries) > 0 { - variables["registries"] = fmt.Sprintf("{%s}", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(registries)), ","), "[]")) + // Convert registry IDs to strings + registryStrs := make([]string, len(registries)) + for i, id := range registries { + registryStrs[i] = fmt.Sprintf("%d", id) + } + // Format as PostgreSQL array: "{1,2,3}" + variables["registries"] = fmt.Sprintf("{%s}", strings.Join(registryStrs, ",")) } - // Create GraphQL request graphqlReq := GraphQLRequest{ OperationName: "FilteredPackages", Query: query, From 01fb0e8e3aff8a2307473a069e1066aa6abcc140 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Tue, 3 Feb 2026 10:45:34 +0000 Subject: [PATCH 05/14] refactoring --- main.go | 6 +++--- packages.go | 9 ++++----- registries.go | 1 - 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/main.go b/main.go index b1652c6..28f1de0 100644 --- a/main.go +++ b/main.go @@ -559,7 +559,7 @@ var packageCmd = &cobra.Command{ Packages are Julia libraries that provide reusable functionality. JuliaHub hosts packages from multiple registries and provides comprehensive search -capabilities including filtering by installation status, failures, and more.`, +capabilities including filtering by tags, installation status, failures, and more.`, } var packageSearchCmd = &cobra.Command{ @@ -569,7 +569,7 @@ var packageSearchCmd = &cobra.Command{ Displays package information including: - Package name, owner, and UUID -- Version and registry information +- Version information - Description and repository - Tags and star count - Installation status @@ -682,7 +682,7 @@ var registryListCmd = &cobra.Command{ Short: "List registries", Long: `List all package registries on JuliaHub. -By default, displays Registry ID, UUID, and Name for each registry. +By default, displays, UUID, and Name for each registry. Use --verbose flag to display comprehensive information including: - Registry UUID - Registry name and ID diff --git a/packages.go b/packages.go index 600c99e..9dd6e3e 100644 --- a/packages.go +++ b/packages.go @@ -16,7 +16,7 @@ var packageSearchFS embed.FS type PackageMetadata struct { DocsHostedURI string `json:"docshosteduri"` - Versions *string `json:"versions"` + Versions []string `json:"versions"` Description string `json:"description"` DocsLink string `json:"docslink"` Repo string `json:"repo"` @@ -209,7 +209,6 @@ func searchPackages(server string, search string, limit int, offset int, install if pkg.RegistryMap != nil { fmt.Printf("Latest Version: %s\n", pkg.RegistryMap.Version) - fmt.Printf("Registry ID: %d\n", pkg.RegistryMap.RegistryID) fmt.Printf("Status: ") if pkg.RegistryMap.Status { fmt.Printf("Active\n") @@ -251,10 +250,10 @@ func searchPackages(server string, search string, limit int, offset int, install if pkg.Metadata != nil && pkg.Metadata.Description != "" { // Truncate description for concise view desc := pkg.Metadata.Description - if len(desc) > 50 { - desc = desc[:47] + "..." + if len(desc) > 100 { + desc = desc[:100] + "..." } - fmt.Printf("\n %s", desc) + fmt.Printf("%s", desc) } fmt.Printf("\n") diff --git a/registries.go b/registries.go index 1f66447..e7cbde8 100644 --- a/registries.go +++ b/registries.go @@ -95,7 +95,6 @@ func listRegistries(server string, verbose bool) error { } else { // Default mode: show UUID, Name, and Registry ID (needed for package search filtering) for _, registry := range registries { - fmt.Printf("Registry ID: %d\n", registry.RegistryID) fmt.Printf("UUID: %s\n", registry.UUID) fmt.Printf("Name: %s\n", registry.Name) fmt.Println() From f53aeb8c69bf682d93dd259d20c5f8dd26e95a28 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Wed, 4 Feb 2026 12:12:43 +0000 Subject: [PATCH 06/14] display refactoring --- packages.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages.go b/packages.go index 9dd6e3e..d87c3c7 100644 --- a/packages.go +++ b/packages.go @@ -178,6 +178,12 @@ func searchPackages(server string, search string, limit int, offset int, install fmt.Printf("Found %d package(s):\n\n", len(packages)) + // Print column headers for concise output + if !verbose { + fmt.Printf("%-30s %-20s %-12s %s\n", "NAME", "OWNER", "VERSION", "DESCRIPTION") + fmt.Printf("%-30s %-20s %-12s %s\n", strings.Repeat("-", 30), strings.Repeat("-", 20), strings.Repeat("-", 12), strings.Repeat("-", 50)) + } + for _, pkg := range packages { if verbose { // Verbose output with all details @@ -250,8 +256,8 @@ func searchPackages(server string, search string, limit int, offset int, install if pkg.Metadata != nil && pkg.Metadata.Description != "" { // Truncate description for concise view desc := pkg.Metadata.Description - if len(desc) > 100 { - desc = desc[:100] + "..." + if len(desc) > 50 { + desc = desc[:50] + "..." } fmt.Printf("%s", desc) } From 485388780337c18821b25794f7563b2d08608ad0 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Fri, 6 Feb 2026 07:44:58 +0000 Subject: [PATCH 07/14] refactoring --- CLAUDE.md | 19 ++++ README.md | 34 +++++++ main.go | 77 ++++++++++++++- packages.go | 277 +++++++++++++++++++++++++++++++--------------------- 4 files changed, 296 insertions(+), 111 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b3819e0..b0ce20e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +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) with REST API integration +- **packages.go**: Package operations (search, info) with GraphQL API integration - **projects.go**: Project management using GraphQL API with user filtering - **user.go**: User information retrieval using GraphQL API - **git.go**: Git integration (clone, push, fetch, pull) with JuliaHub authentication @@ -39,6 +40,7 @@ The application follows a command-line interface pattern using the Cobra library - `jh auth`: Authentication commands (login, refresh, status, env) - `jh dataset`: Dataset operations (list, download, upload, status) - `jh registry`: Registry operations (list with REST API, supports verbose mode) + - `jh package`: Package operations (search, info with GraphQL API, supports filtering by registry, installation status, and failures) - `jh project`: Project management (list with GraphQL, supports user filtering) - `jh user`: User information (info with GraphQL) - `jh clone`: Git clone with JuliaHub authentication and project name resolution @@ -92,6 +94,20 @@ go run . registry list go run . registry list --verbose ``` +### Test package operations +```bash +# Search for packages +go run . package search dataframes +go run . package search --verbose plots +go run . package search --limit 20 ml +go run . package search --registries General optimization +go run . package search --installed + +# Get package info +go run . package info DataFrames +go run . package info Plots --registries General +``` + ### Test project and user operations ```bash go run . project list @@ -282,6 +298,9 @@ jh run setup - Folder naming conflicts are resolved with automatic numbering (project-1, project-2, etc.) - Credential helper follows Git protocol: responds only to JuliaHub URLs, ignores others - Registry list output is concise by default (UUID and Name only); use `--verbose` flag for detailed information (owner, creation date, package count, description) +- Package search output shows column headers (NAME, OWNER, VERSION, DESCRIPTION) by default; use `--verbose` flag for detailed key-value format +- Package info command performs exact name match (case-insensitive) and displays detailed package information +- Package commands support registry filtering via `--registries` flag (comma-separated list) ## Implementation Details diff --git a/README.md b/README.md index 2de4c8d..1ac17e3 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A command-line interface for interacting with JuliaHub, a platform for Julia com - **Authentication**: OAuth2 device flow authentication with JWT token handling - **Dataset Management**: List, download, upload, and check status of datasets - **Registry Management**: List and manage Julia package registries +- **Package Management**: Search and explore Julia packages with filtering and detailed information - **Project Management**: List and filter projects using GraphQL API - **Git Integration**: Clone, push, fetch, and pull with automatic JuliaHub authentication - **Julia Integration**: Install Julia and run with JuliaHub package server configuration @@ -155,6 +156,20 @@ go build -o jh . - Default: Shows only UUID and Name - `jh registry list --verbose` - Show detailed registry information including owner, creation date, package count, and description +### Package Management (`jh package`) + +- `jh package search [search-term]` - Search for Julia packages + - Default: Shows concise output with NAME, OWNER, VERSION, and DESCRIPTION columns + - `jh package search --verbose` - Show detailed package information + - `jh package search --limit 20` - Limit number of results + - `jh package search --offset 10` - Skip first N results + - `jh package search --registries General` - Filter by specific registries + - `jh package search --installed` - Show only installed packages + - `jh package search --not-installed` - Show only packages not installed + - `jh package search --has-failures` - Show only packages with download failures +- `jh package info ` - Get detailed information about a specific package by exact name match + - `jh package info --registries General` - Search in specific registries only + ### Project Management (`jh project`) - `jh project list` - List all accessible projects @@ -234,6 +249,25 @@ jh registry list --verbose jh registry list -s yourinstall ``` +### Package Operations + +```bash +# Search for packages (shows concise output with columns) +jh package search dataframes + +# Search with detailed information +jh package search --verbose plots + +# Search with filters +jh package search --installed +jh package search --limit 20 ml +jh package search --registries General optimization + +# Get detailed info about a specific package +jh package info DataFrames +jh package info Plots --registries General +``` + ### Project Operations ```bash diff --git a/main.go b/main.go index 28f1de0..8a3b481 100644 --- a/main.go +++ b/main.go @@ -667,6 +667,79 @@ Use --verbose flag for comprehensive output, or get a concise summary by default }, } +var packageInfoCmd = &cobra.Command{ + Use: "info ", + Short: "Get detailed information about a package", + Long: `Display detailed information about a specific Julia package by exact name match. + +Shows comprehensive package information including: +- Package name, UUID, and owner +- Version information and status +- Description and repository +- Tags and star count +- Installation status +- License information +- Documentation links +- Failed versions (if any) + +The package name must match exactly (case-insensitive).`, + Example: " jh package info DataFrames\n jh package info Plots\n jh package info 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] + registryNamesStr, _ := cmd.Flags().GetString("registries") + + // Fetch all registries from the API + allRegistries, err := fetchRegistries(server) + if err != nil { + fmt.Printf("Failed to fetch registries: %v\n", err) + os.Exit(1) + } + + var registryIDs []int + if registryNamesStr != "" { + requestedNames := strings.Split(registryNamesStr, ",") + for _, requestedName := range requestedNames { + requestedName = strings.TrimSpace(requestedName) + if requestedName == "" { + continue + } + + // Find matching registry (case-insensitive) + found := false + for _, reg := range allRegistries { + if strings.EqualFold(reg.Name, requestedName) { + registryIDs = append(registryIDs, reg.RegistryID) + found = true + break + } + } + + if !found { + fmt.Printf("Registry not found: '%s'\n", requestedName) + os.Exit(1) + } + } + } else { + // Use all registries + for _, reg := range allRegistries { + registryIDs = append(registryIDs, reg.RegistryID) + } + } + + if err := getPackageInfo(server, packageName, registryIDs); err != nil { + fmt.Printf("Failed to get package info: %v\n", err) + os.Exit(1) + } + }, +} + var registryCmd = &cobra.Command{ Use: "registry", Short: "Registry management commands", @@ -1148,6 +1221,8 @@ func init() { packageSearchCmd.Flags().Bool("has-failures", false, "Filter by packages with download failures") packageSearchCmd.Flags().String("registries", "", "Filter by registry names (comma-separated, e.g., 'General,CustomRegistry')") packageSearchCmd.Flags().Bool("verbose", false, "Show detailed package information") + packageInfoCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + packageInfoCmd.Flags().String("registries", "", "Filter by registry names (comma-separated, e.g., 'General,CustomRegistry')") 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") @@ -1162,7 +1237,7 @@ func init() { authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobCmd.AddCommand(jobListCmd, jobStartCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) - packageCmd.AddCommand(packageSearchCmd) + packageCmd.AddCommand(packageSearchCmd, packageInfoCmd) registryCmd.AddCommand(registryListCmd) projectCmd.AddCommand(projectListCmd) userCmd.AddCommand(userInfoCmd) diff --git a/packages.go b/packages.go index d87c3c7..fd99591 100644 --- a/packages.go +++ b/packages.go @@ -60,66 +60,20 @@ type PackageSearchResponse struct { } `json:"errors"` } -func searchPackages(server string, search string, limit int, offset int, installed *bool, notInstalled *bool, hasFailures *bool, registries []int, verbose bool) error { +// executePackageQuery executes a GraphQL package search query and returns the results +func executePackageQuery(server string, variables map[string]interface{}) ([]Package, error) { token, err := ensureValidToken() if err != nil { - return fmt.Errorf("authentication required: %w", err) + return nil, fmt.Errorf("authentication required: %w", err) } // Read the GraphQL query from package_search.gql queryBytes, err := packageSearchFS.ReadFile("package_search.gql") if err != nil { - return fmt.Errorf("failed to read GraphQL query: %w", err) + return nil, fmt.Errorf("failed to read GraphQL query: %w", err) } query := string(queryBytes) - // Build variables for the GraphQL query - variables := map[string]interface{}{ - "filter": map[string]interface{}{}, - "order": map[string]string{"score": "desc"}, - "matchtags": "{}", - "licenses": "{}", - "search": "", - "offset": 0, - "hasfailures": false, - "installed": true, - "notinstalled": true, - } - - if search != "" { - variables["search"] = search - } - - if limit > 0 { - variables["limit"] = limit - } - - if offset > 0 { - variables["offset"] = offset - } - - if installed != nil { - variables["installed"] = *installed - } - - if notInstalled != nil { - variables["notinstalled"] = *notInstalled - } - - if hasFailures != nil { - variables["hasfailures"] = *hasFailures - } - - if len(registries) > 0 { - // Convert registry IDs to strings - registryStrs := make([]string, len(registries)) - for i, id := range registries { - registryStrs[i] = fmt.Sprintf("%d", id) - } - // Format as PostgreSQL array: "{1,2,3}" - variables["registries"] = fmt.Sprintf("{%s}", strings.Join(registryStrs, ",")) - } - graphqlReq := GraphQLRequest{ OperationName: "FilteredPackages", Query: query, @@ -128,13 +82,13 @@ func searchPackages(server string, search string, limit int, offset int, install jsonData, err := json.Marshal(graphqlReq) if err != nil { - return fmt.Errorf("failed to marshal GraphQL request: %w", err) + return nil, fmt.Errorf("failed to marshal GraphQL request: %w", err) } url := fmt.Sprintf("https://%s/v1/graphql", server) req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) 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)) @@ -145,31 +99,144 @@ func searchPackages(server string, search string, limit int, offset int, install client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to make request: %w", err) + return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("GraphQL request failed (status %d): %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("GraphQL 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 response PackageSearchResponse if err := json.Unmarshal(body, &response); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + return nil, fmt.Errorf("failed to parse response: %w", err) } // Check for GraphQL errors if len(response.Errors) > 0 { - return fmt.Errorf("GraphQL errors: %v", response.Errors) + return nil, fmt.Errorf("GraphQL errors: %v", response.Errors) + } + + return response.Data.PackageSearch, nil +} + +// displayPackageDetails displays detailed information about a package +func displayPackageDetails(pkg *Package) { + fmt.Printf("Name: %s\n", pkg.Name) + fmt.Printf("UUID: %s\n", pkg.UUID) + fmt.Printf("Owner: %s\n", pkg.Owner) + + if pkg.Metadata != nil { + if pkg.Metadata.Description != "" { + fmt.Printf("Description: %s\n", pkg.Metadata.Description) + } + if pkg.Metadata.Repo != "" { + fmt.Printf("Repository: %s\n", pkg.Metadata.Repo) + } + if len(pkg.Metadata.Tags) > 0 { + fmt.Printf("Tags: %s\n", strings.Join(pkg.Metadata.Tags, ", ")) + } + if pkg.Metadata.StarCount > 0 { + fmt.Printf("Stars: %d\n", pkg.Metadata.StarCount) + } + if pkg.Metadata.DocsLink != "" { + fmt.Printf("Documentation: %s\n", pkg.Metadata.DocsLink) + } + } + + if pkg.License != "" { + fmt.Printf("License: %s\n", pkg.License) + } + + if pkg.RegistryMap != nil { + fmt.Printf("Latest Version: %s\n", pkg.RegistryMap.Version) + fmt.Printf("Status: ") + if pkg.RegistryMap.Status { + fmt.Printf("Active\n") + } else { + fmt.Printf("Inactive\n") + } + } + + fmt.Printf("Installed: %t\n", pkg.Installed) + + if pkg.IsApp { + fmt.Printf("Type: Application\n") + } + + if len(pkg.Failures) > 0 { + fmt.Printf("Failed Versions: ") + versions := make([]string, len(pkg.Failures)) + for i, failure := range pkg.Failures { + versions[i] = failure.PackageVersion + } + fmt.Printf("%s\n", strings.Join(versions, ", ")) + } + + fmt.Printf("Score: %.2f\n", pkg.Score) +} + +// buildRegistriesParam converts registry IDs to PostgreSQL array format +func buildRegistriesParam(registries []int) string { + registryStrs := make([]string, len(registries)) + for i, id := range registries { + registryStrs[i] = fmt.Sprintf("%d", id) + } + return fmt.Sprintf("{%s}", strings.Join(registryStrs, ",")) +} + +func searchPackages(server string, search string, limit int, offset int, installed *bool, notInstalled *bool, hasFailures *bool, registries []int, verbose bool) error { + // Build variables for the GraphQL query + variables := map[string]interface{}{ + "filter": map[string]interface{}{}, + "order": map[string]string{"score": "desc"}, + "matchtags": "{}", + "licenses": "{}", + "search": "", + "offset": 0, + "hasfailures": false, + "installed": true, + "notinstalled": true, + } + + if search != "" { + variables["search"] = search + } + + if limit > 0 { + variables["limit"] = limit + } + + if offset > 0 { + variables["offset"] = offset + } + + if installed != nil { + variables["installed"] = *installed } - packages := response.Data.PackageSearch + if notInstalled != nil { + variables["notinstalled"] = *notInstalled + } + + if hasFailures != nil { + variables["hasfailures"] = *hasFailures + } + + if len(registries) > 0 { + variables["registries"] = buildRegistriesParam(registries) + } + + packages, err := executePackageQuery(server, variables) + if err != nil { + return err + } if len(packages) == 0 { fmt.Println("No packages found") @@ -187,58 +254,7 @@ func searchPackages(server string, search string, limit int, offset int, install for _, pkg := range packages { if verbose { // Verbose output with all details - fmt.Printf("Name: %s\n", pkg.Name) - fmt.Printf("UUID: %s\n", pkg.UUID) - fmt.Printf("Owner: %s\n", pkg.Owner) - - if pkg.Metadata != nil { - if pkg.Metadata.Description != "" { - fmt.Printf("Description: %s\n", pkg.Metadata.Description) - } - if pkg.Metadata.Repo != "" { - fmt.Printf("Repository: %s\n", pkg.Metadata.Repo) - } - if len(pkg.Metadata.Tags) > 0 { - fmt.Printf("Tags: %s\n", strings.Join(pkg.Metadata.Tags, ", ")) - } - if pkg.Metadata.StarCount > 0 { - fmt.Printf("Stars: %d\n", pkg.Metadata.StarCount) - } - if pkg.Metadata.DocsLink != "" { - fmt.Printf("Documentation: %s\n", pkg.Metadata.DocsLink) - } - } - - if pkg.License != "" { - fmt.Printf("License: %s\n", pkg.License) - } - - if pkg.RegistryMap != nil { - fmt.Printf("Latest Version: %s\n", pkg.RegistryMap.Version) - fmt.Printf("Status: ") - if pkg.RegistryMap.Status { - fmt.Printf("Active\n") - } else { - fmt.Printf("Inactive\n") - } - } - - fmt.Printf("Installed: %t\n", pkg.Installed) - - if pkg.IsApp { - fmt.Printf("Type: Application\n") - } - - if len(pkg.Failures) > 0 { - fmt.Printf("Failed Versions: ") - versions := make([]string, len(pkg.Failures)) - for i, failure := range pkg.Failures { - versions[i] = failure.PackageVersion - } - fmt.Printf("%s\n", strings.Join(versions, ", ")) - } - - fmt.Printf("Score: %.2f\n", pkg.Score) + displayPackageDetails(&pkg) } else { // Concise output fmt.Printf("%-30s %-20s", pkg.Name, pkg.Owner) @@ -270,3 +286,44 @@ func searchPackages(server string, search string, limit int, offset int, install return nil } + +func getPackageInfo(server string, packageName string, registries []int) error { + variables := map[string]interface{}{ + "filter": map[string]interface{}{}, + "order": map[string]string{"score": "desc"}, + "matchtags": "{}", + "licenses": "{}", + "search": packageName, + "offset": 0, + "hasfailures": false, + "installed": true, + "notinstalled": true, + "limit": 100, // Get more results to find exact match + } + + if len(registries) > 0 { + variables["registries"] = buildRegistriesParam(registries) + } + + packages, err := executePackageQuery(server, variables) + if err != nil { + return err + } + + // Find exact match (case-insensitive) + var pkg *Package + for i := range packages { + if strings.EqualFold(packages[i].Name, packageName) { + pkg = &packages[i] + break + } + } + + if pkg == nil { + fmt.Println("Package not found") + return nil + } + + displayPackageDetails(pkg) + return nil +} From 653a1019f5b9e81ef1044ccbec914c607c1af70d Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Wed, 11 Feb 2026 06:56:46 +0000 Subject: [PATCH 08/14] display registries the package belongs to --- packages.go | 144 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 120 insertions(+), 24 deletions(-) diff --git a/packages.go b/packages.go index fd99591..6705464 100644 --- a/packages.go +++ b/packages.go @@ -38,17 +38,50 @@ type PackageFailure struct { } type Package struct { - Name string `json:"name"` - Owner string `json:"owner"` - Slug *string `json:"slug"` - License string `json:"license"` - IsApp bool `json:"isapp"` - Score float64 `json:"score"` - RegistryMap *PackageRegistryMap `json:"registrymap"` - Metadata *PackageMetadata `json:"metadata"` - UUID string `json:"uuid"` - Installed bool `json:"installed"` - Failures []PackageFailure `json:"failures"` + Name string `json:"name"` + Owner string `json:"owner"` + Slug *string `json:"slug"` + License string `json:"license"` + IsApp bool `json:"isapp"` + Score float64 `json:"score"` + RegistryMap []PackageRegistryMap `json:"-"` // Custom unmarshaling + Metadata *PackageMetadata `json:"metadata"` + UUID string `json:"uuid"` + Installed bool `json:"installed"` + Failures []PackageFailure `json:"failures"` +} + +// UnmarshalJSON custom unmarshaler to handle registrymap as both object and array +func (p *Package) UnmarshalJSON(data []byte) error { + // Create an alias to avoid infinite recursion + type Alias Package + aux := &struct { + RegistryMapRaw json.RawMessage `json:"registrymap"` + *Alias + }{ + Alias: (*Alias)(p), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Handle registrymap - can be object, array, or null + if len(aux.RegistryMapRaw) > 0 && string(aux.RegistryMapRaw) != "null" { + // Try to unmarshal as array first + var registryMapArray []PackageRegistryMap + if err := json.Unmarshal(aux.RegistryMapRaw, ®istryMapArray); err == nil { + p.RegistryMap = registryMapArray + } else { + // If that fails, try as single object + var registryMapObj PackageRegistryMap + if err := json.Unmarshal(aux.RegistryMapRaw, ®istryMapObj); err == nil { + p.RegistryMap = []PackageRegistryMap{registryMapObj} + } + } + } + + return nil } type PackageSearchResponse struct { @@ -127,7 +160,7 @@ func executePackageQuery(server string, variables map[string]interface{}) ([]Pac } // displayPackageDetails displays detailed information about a package -func displayPackageDetails(pkg *Package) { +func displayPackageDetails(pkg *Package, registryLookup map[int]string) { fmt.Printf("Name: %s\n", pkg.Name) fmt.Printf("UUID: %s\n", pkg.UUID) fmt.Printf("Owner: %s\n", pkg.Owner) @@ -154,14 +187,32 @@ func displayPackageDetails(pkg *Package) { fmt.Printf("License: %s\n", pkg.License) } - if pkg.RegistryMap != nil { - fmt.Printf("Latest Version: %s\n", pkg.RegistryMap.Version) + // Display registry information + if len(pkg.RegistryMap) > 0 { + // Find the latest version (first entry is typically latest) + latestEntry := pkg.RegistryMap[0] + fmt.Printf("Latest Version: %s\n", latestEntry.Version) fmt.Printf("Status: ") - if pkg.RegistryMap.Status { + if latestEntry.Status { fmt.Printf("Active\n") } else { fmt.Printf("Inactive\n") } + + // Display all registries this package belongs to + if registryLookup != nil { + registryNames := []string{} + for _, entry := range pkg.RegistryMap { + if registryName, ok := registryLookup[entry.RegistryID]; ok { + registryNames = append(registryNames, registryName) + } else { + registryNames = append(registryNames, fmt.Sprintf("Registry-%d", entry.RegistryID)) + } + } + if len(registryNames) > 0 { + fmt.Printf("Registries: %s\n", strings.Join(registryNames, ", ")) + } + } } fmt.Printf("Installed: %t\n", pkg.Installed) @@ -191,6 +242,21 @@ func buildRegistriesParam(registries []int) string { return fmt.Sprintf("{%s}", strings.Join(registryStrs, ",")) } +// buildRegistryLookup creates a map from registry ID to registry name +func buildRegistryLookup(server string) map[int]string { + registries, err := fetchRegistries(server) + if err != nil { + // Return empty map if we can't fetch registries + return map[int]string{} + } + + lookup := make(map[int]string) + for _, registry := range registries { + lookup[registry.RegistryID] = registry.Name + } + return lookup +} + func searchPackages(server string, search string, limit int, offset int, installed *bool, notInstalled *bool, hasFailures *bool, registries []int, verbose bool) error { // Build variables for the GraphQL query variables := map[string]interface{}{ @@ -243,37 +309,64 @@ func searchPackages(server string, search string, limit int, offset int, install return nil } + // Build registry lookup for displaying registry names + registryLookup := buildRegistryLookup(server) + fmt.Printf("Found %d package(s):\n\n", len(packages)) // Print column headers for concise output if !verbose { - fmt.Printf("%-30s %-20s %-12s %s\n", "NAME", "OWNER", "VERSION", "DESCRIPTION") - fmt.Printf("%-30s %-20s %-12s %s\n", strings.Repeat("-", 30), strings.Repeat("-", 20), strings.Repeat("-", 12), strings.Repeat("-", 50)) + fmt.Printf("%-30s %-20s %-12s %-25s %s\n", "NAME", "OWNER", "VERSION", "REGISTRIES", "DESCRIPTION") + fmt.Printf("%-30s %-20s %-12s %-25s %s\n", strings.Repeat("-", 30), strings.Repeat("-", 20), strings.Repeat("-", 12), strings.Repeat("-", 25), strings.Repeat("-", 40)) } for _, pkg := range packages { if verbose { // Verbose output with all details - displayPackageDetails(&pkg) + displayPackageDetails(&pkg, registryLookup) } else { // Concise output fmt.Printf("%-30s %-20s", pkg.Name, pkg.Owner) - if pkg.RegistryMap != nil { - fmt.Printf(" v%-10s", pkg.RegistryMap.Version) + // Display version from first registry entry + if len(pkg.RegistryMap) > 0 { + fmt.Printf(" v%-10s", pkg.RegistryMap[0].Version) } else { fmt.Printf(" %-12s", "N/A") } + // Display registry names + if len(pkg.RegistryMap) > 0 { + registryNames := []string{} + for _, entry := range pkg.RegistryMap { + if name, ok := registryLookup[entry.RegistryID]; ok { + registryNames = append(registryNames, name) + } + } + if len(registryNames) > 0 { + registryStr := strings.Join(registryNames, ", ") + if len(registryStr) > 25 { + registryStr = registryStr[:22] + "..." + } + fmt.Printf(" %-25s", registryStr) + } else { + fmt.Printf(" %-25s", "") + } + } else { + fmt.Printf(" %-25s", "") + } + if pkg.Installed { - fmt.Printf(" [Installed]") + fmt.Printf(" [Installed] ") + } else { + fmt.Printf(" ") } if pkg.Metadata != nil && pkg.Metadata.Description != "" { // Truncate description for concise view desc := pkg.Metadata.Description - if len(desc) > 50 { - desc = desc[:50] + "..." + if len(desc) > 40 { + desc = desc[:40] + "..." } fmt.Printf("%s", desc) } @@ -324,6 +417,9 @@ func getPackageInfo(server string, packageName string, registries []int) error { return nil } - displayPackageDetails(pkg) + // Build registry lookup for displaying registry names + registryLookup := buildRegistryLookup(server) + + displayPackageDetails(pkg, registryLookup) return nil } From 531fe816492c31351a76a7229d532d1081f60360 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Mon, 9 Mar 2026 09:21:47 +0000 Subject: [PATCH 09/14] added openapi endpoints for package search, with fallback logic --- CLAUDE.md | 20 +++++- README.md | 28 +++++++- main.go | 40 ++---------- packages.go | 177 ++++++++++++++++++++++++++++++++++++++++++-------- registries.go | 23 +++++-- 5 files changed, 216 insertions(+), 72 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d011e3e..1a0185a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,8 @@ The application follows a command-line interface pattern using the Cobra library - **main.go**: Core CLI structure with command definitions and configuration management - **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) with REST API integration +- **registries.go**: Registry operations (list, fetch) with REST API integration +- **packages.go**: Package search with GraphQL primary path and REST API fallback (`/packages/info`) - **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 @@ -30,8 +31,8 @@ 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/ui/registries/descriptions`), token management (`/app/token/activelist`) and user management (`/app/config/features/manage`) - - **GraphQL API**: Used for projects and user info (`/v1/graphql`) + - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`), package search fallback (`/packages/info`), token management (`/app/token/activelist`) and user management (`/app/config/features/manage`) + - **GraphQL API**: Used for projects, user info, and package search primary path (`/v1/graphql`) - **Headers**: All GraphQL requests require `X-Hasura-Role: jhuser` header - **Authentication**: Uses ID tokens (`token.IDToken`) for API calls @@ -40,6 +41,7 @@ The application follows a command-line interface pattern using the Cobra library - `jh dataset`: Dataset operations (list, download, upload, status) - `jh registry`: Registry operations (list with REST API, supports verbose mode) - `jh project`: Project management (list with GraphQL, supports user filtering) + - `jh package`: Package search (GraphQL primary, REST fallback via `/packages/info`) - `jh user`: User information (info with GraphQL) - `jh admin`: Administrative commands (user management, token management) - `jh admin user`: User management (list all users with REST API, supports verbose mode) @@ -89,6 +91,14 @@ go run . dataset download go run . dataset upload --new ./file.tar.gz ``` +### Test package search operations +```bash +go run . package search dataframes +go run . package search --verbose plots +go run . package search --limit 20 ml +go run . package search --registries General optimization +``` + ### Test registry operations ```bash go run . registry list @@ -186,6 +196,7 @@ The application uses OAuth2 device flow: - **Dataset operations**: Use presigned URLs for upload/download - **User management**: `/app/config/features/manage` endpoint for listing all users - **Token management**: `/app/token/activelist` endpoint for listing all API tokens +- **Package search fallback**: `/packages/info` endpoint with `name`, `registries`, `tags`, `licenses`, `limit`, `offset` query params; returns `{packages: [...], meta: {total: N}}` - **Authentication**: Bearer token with ID token - **Upload workflow**: 3-step process (request presigned URL, upload to URL, close upload) @@ -307,6 +318,9 @@ jh run setup - Token list output is concise by default (Subject, Created By, and Expired status only); use `--verbose` flag for detailed information (signature, creation date, expiration date with estimate indicator) - Token dates are formatted in human-readable format and converted to local timezone (respects system timezone or TZ environment variable) - Token expiration estimate indicator only shown when `expires_at_is_estimate` is true in API response +- Package search (`jh package search`) tries GraphQL (`FilteredPackages` via `/v1/graphql`) first, then falls back to REST API (`/packages/info`) on failure; a warning is printed to stderr when the fallback is used +- Package search REST fallback passes `--registries` as comma-separated registry names to the `registries` query param +- `fetchRegistries` in `registries.go` is used by both `listRegistries` (for display) and `packageSearchCmd` (to resolve registry names to IDs for GraphQL and names for REST fallback) ## Implementation Details diff --git a/README.md b/README.md index f36e321..0d61802 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A command-line interface for interacting with JuliaHub, a platform for Julia com - **Authentication**: OAuth2 device flow authentication with JWT token handling - **Dataset Management**: List, download, upload, and check status of datasets +- **Package Search**: Search Julia packages across registries with GraphQL (REST fallback) - **Registry Management**: List and manage Julia package registries - **Project Management**: List and filter projects using GraphQL API - **Git Integration**: Clone, push, fetch, and pull with automatic JuliaHub authentication @@ -150,6 +151,15 @@ go build -o jh . - `jh dataset upload [dataset-id] ` - Upload a dataset - `jh dataset status [version]` - Show dataset status +### Package Search (`jh package`) + +- `jh package search [search-term]` - Search for Julia packages + - Default: Shows Name, Owner/Registry, Version, and Description + - `jh package search --verbose` - Show detailed package information including UUID, repository, tags, stars, docs, and license + - `--registries ` - Filter by registry names (comma-separated, e.g. `General,MyRegistry`) + - `--limit ` - Maximum results to return (default: 10) + - `--offset ` - Number of results to skip + ### Registry Management (`jh registry`) - `jh registry list` - List all package registries on JuliaHub @@ -234,6 +244,22 @@ jh dataset upload --new ./my-data.tar.gz jh dataset upload my-dataset ./updated-data.tar.gz ``` +### Package Search + +```bash +# Search for packages by name +jh package search dataframes + +# Search with verbose output +jh package search --verbose plots + +# Filter by registry +jh package search --registries General optimization + +# Limit and paginate results +jh package search --limit 20 --offset 0 ml +``` + ### Registry Operations ```bash @@ -333,7 +359,7 @@ Note: Arguments after `--` are passed directly to Julia. The `jh run` command: - **Built with Go** using the Cobra CLI framework - **Authentication**: OAuth2 device flow with JWT token management -- **APIs**: REST API for datasets, GraphQL API for projects and user info +- **APIs**: REST API for datasets; GraphQL API for projects, user info, and package search (with REST fallback) - **Git Integration**: Seamless authentication via HTTP headers or credential helper - **Cross-platform**: Supports Windows, macOS, and Linux diff --git a/main.go b/main.go index 9c4e949..e17409b 100644 --- a/main.go +++ b/main.go @@ -563,7 +563,7 @@ var packageCmd = &cobra.Command{ Packages are Julia libraries that provide reusable functionality. JuliaHub hosts packages from multiple registries and provides comprehensive search -capabilities including filtering by tags, installation status, failures, and more.`, +capabilities including filtering by tags, registries, and more.`, } var packageSearchCmd = &cobra.Command{ @@ -576,16 +576,13 @@ Displays package information including: - Version information - Description and repository - Tags and star count -- Installation status - License information Filtering options: - Filter by registry using --registries flag (searches all registries by default) -- Filter by installation status (--installed, --not-installed) -- Filter by packages with download failures (--has-failures) Use --verbose flag for comprehensive output, or get a concise summary by default.`, - Example: " jh package search dataframes\n jh package search --installed\n jh package search --verbose plots\n jh package search --limit 20 ml\n jh package search --registries General optimization", + Example: " jh package search dataframes\n jh package search --verbose plots\n jh package search --limit 20 ml\n jh package search --registries General optimization", Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) @@ -604,26 +601,6 @@ Use --verbose flag for comprehensive output, or get a concise summary by default verbose, _ := cmd.Flags().GetBool("verbose") registryNamesStr, _ := cmd.Flags().GetString("registries") - // Handle boolean flags - only set if explicitly provided - var installed *bool - var notInstalled *bool - var hasFailures *bool - - if cmd.Flags().Changed("installed") { - val, _ := cmd.Flags().GetBool("installed") - installed = &val - } - - if cmd.Flags().Changed("not-installed") { - val, _ := cmd.Flags().GetBool("not-installed") - notInstalled = &val - } - - if cmd.Flags().Changed("has-failures") { - val, _ := cmd.Flags().GetBool("has-failures") - hasFailures = &val - } - // Fetch all registries from the API allRegistries, err := fetchRegistries(server) if err != nil { @@ -631,8 +608,9 @@ Use --verbose flag for comprehensive output, or get a concise summary by default os.Exit(1) } - // Determine which registry IDs to use + // Determine which registry IDs and names to use var registryIDs []int + var registryNames []string if registryNamesStr != "" { // Use only specified registries requestedNames := strings.Split(registryNamesStr, ",") @@ -647,6 +625,7 @@ Use --verbose flag for comprehensive output, or get a concise summary by default for _, reg := range allRegistries { if strings.EqualFold(reg.Name, requestedName) { registryIDs = append(registryIDs, reg.RegistryID) + registryNames = append(registryNames, reg.Name) found = true break } @@ -661,10 +640,11 @@ Use --verbose flag for comprehensive output, or get a concise summary by default // Use all registries for _, reg := range allRegistries { registryIDs = append(registryIDs, reg.RegistryID) + registryNames = append(registryNames, reg.Name) } } - if err := searchPackages(server, search, limit, offset, installed, notInstalled, hasFailures, registryIDs, verbose); err != nil { + if err := searchPackages(server, search, limit, offset, registryIDs, registryNames, verbose); err != nil { fmt.Printf("Failed to search packages: %v\n", err) os.Exit(1) } @@ -1237,17 +1217,11 @@ func init() { datasetUploadCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") datasetUploadCmd.Flags().Bool("new", false, "Create a new dataset") datasetStatusCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") -<<<<<<< HEAD packageSearchCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") packageSearchCmd.Flags().Int("limit", 10, "Maximum number of results to return") packageSearchCmd.Flags().Int("offset", 0, "Number of results to skip") - packageSearchCmd.Flags().Bool("installed", false, "Filter by installed packages") - packageSearchCmd.Flags().Bool("not-installed", false, "Filter by not installed packages") - packageSearchCmd.Flags().Bool("has-failures", false, "Filter by packages with download failures") packageSearchCmd.Flags().String("registries", "", "Filter by registry names (comma-separated, e.g., 'General,CustomRegistry')") packageSearchCmd.Flags().Bool("verbose", false, "Show detailed package information") -======= ->>>>>>> 598769ba8b5d9204ddfa0992fd62b572c17226b4 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") diff --git a/packages.go b/packages.go index d87c3c7..1217031 100644 --- a/packages.go +++ b/packages.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "os" "strings" "time" ) @@ -60,7 +61,154 @@ type PackageSearchResponse struct { } `json:"errors"` } -func searchPackages(server string, search string, limit int, offset int, installed *bool, notInstalled *bool, hasFailures *bool, registries []int, verbose bool) error { +type RESTPackage struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Registry string `json:"registry"` + Description string `json:"description"` + StargazersCount int `json:"stargazers_count"` + SourceURL string `json:"source_url"` + JHubDocsURL string `json:"jhub_docs_url"` + LatestStableVersion string `json:"latest_stable_version"` + DetectedSourceLicenses []string `json:"detected_source_licenses"` + Downloads struct { + Count int `json:"count"` + } `json:"downloads"` + Tags []string `json:"tags"` +} + +type PackageRESTListResponse struct { + Packages []RESTPackage `json:"packages"` + Meta struct { + Total int `json:"total"` + } `json:"meta"` +} + +func searchPackagesREST(server string, search string, limit int, offset int, registryNames []string, verbose bool) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + url := fmt.Sprintf("https://%s/packages/info", server) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + q := req.URL.Query() + if search != "" { + q.Add("name", search) + } + if limit > 0 { + q.Add("limit", fmt.Sprintf("%d", limit)) + } + if offset > 0 { + q.Add("offset", fmt.Sprintf("%d", offset)) + } + if len(registryNames) > 0 { + q.Add("registries", strings.Join(registryNames, ",")) + } + 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 fmt.Errorf("failed to make request: %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)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var response PackageRESTListResponse + if err := json.Unmarshal(body, &response); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + packages := response.Packages + + if len(packages) == 0 { + fmt.Println("No packages found") + return nil + } + + fmt.Printf("Found %d package(s):\n\n", len(packages)) + + if !verbose { + fmt.Printf("%-30s %-20s %-12s %s\n", "NAME", "REGISTRY", "VERSION", "DESCRIPTION") + fmt.Printf("%-30s %-20s %-12s %s\n", strings.Repeat("-", 30), strings.Repeat("-", 20), strings.Repeat("-", 12), strings.Repeat("-", 50)) + } + + for _, pkg := range packages { + if verbose { + fmt.Printf("Name: %s\n", pkg.Name) + fmt.Printf("UUID: %s\n", pkg.UUID) + fmt.Printf("Registry: %s\n", pkg.Registry) + if pkg.Description != "" { + fmt.Printf("Description: %s\n", pkg.Description) + } + if pkg.SourceURL != "" { + fmt.Printf("Repository: %s\n", pkg.SourceURL) + } + if len(pkg.Tags) > 0 { + fmt.Printf("Tags: %s\n", strings.Join(pkg.Tags, ", ")) + } + if pkg.StargazersCount > 0 { + fmt.Printf("Stars: %d\n", pkg.StargazersCount) + } + if pkg.JHubDocsURL != "" { + fmt.Printf("Documentation: %s\n", pkg.JHubDocsURL) + } + if len(pkg.DetectedSourceLicenses) > 0 { + fmt.Printf("License: %s\n", strings.Join(pkg.DetectedSourceLicenses, ", ")) + } + if pkg.LatestStableVersion != "" { + fmt.Printf("Latest Version: %s\n", pkg.LatestStableVersion) + } + } else { + fmt.Printf("%-30s %-20s", pkg.Name, pkg.Registry) + if pkg.LatestStableVersion != "" { + fmt.Printf(" v%-10s", pkg.LatestStableVersion) + } else { + fmt.Printf(" %-12s", "N/A") + } + if pkg.Description != "" { + desc := pkg.Description + if len(desc) > 50 { + desc = desc[:50] + "..." + } + fmt.Printf("%s", desc) + } + fmt.Printf("\n") + } + + fmt.Println() + } + + return nil +} + +func searchPackages(server string, search string, limit int, offset int, registryIDs []int, registryNames []string, verbose bool) error { + err := searchPackagesGraphQL(server, search, limit, offset, registryIDs, verbose) + if err != nil { + fmt.Fprintf(os.Stderr, "GraphQL search failed (%v), falling back to REST API...\n", err) + return searchPackagesREST(server, search, limit, offset, registryNames, verbose) + } + return nil +} + +func searchPackagesGraphQL(server string, search string, limit int, offset int, registries []int, verbose bool) error { token, err := ensureValidToken() if err != nil { return fmt.Errorf("authentication required: %w", err) @@ -98,18 +246,6 @@ func searchPackages(server string, search string, limit int, offset int, install variables["offset"] = offset } - if installed != nil { - variables["installed"] = *installed - } - - if notInstalled != nil { - variables["notinstalled"] = *notInstalled - } - - if hasFailures != nil { - variables["hasfailures"] = *hasFailures - } - if len(registries) > 0 { // Convert registry IDs to strings registryStrs := make([]string, len(registries)) @@ -223,21 +359,10 @@ func searchPackages(server string, search string, limit int, offset int, install } } - fmt.Printf("Installed: %t\n", pkg.Installed) - if pkg.IsApp { fmt.Printf("Type: Application\n") } - if len(pkg.Failures) > 0 { - fmt.Printf("Failed Versions: ") - versions := make([]string, len(pkg.Failures)) - for i, failure := range pkg.Failures { - versions[i] = failure.PackageVersion - } - fmt.Printf("%s\n", strings.Join(versions, ", ")) - } - fmt.Printf("Score: %.2f\n", pkg.Score) } else { // Concise output @@ -249,10 +374,6 @@ func searchPackages(server string, search string, limit int, offset int, install fmt.Printf(" %-12s", "N/A") } - if pkg.Installed { - fmt.Printf(" [Installed]") - } - if pkg.Metadata != nil && pkg.Metadata.Description != "" { // Truncate description for concise view desc := pkg.Metadata.Description diff --git a/registries.go b/registries.go index 7012504..33931a4 100644 --- a/registries.go +++ b/registries.go @@ -19,17 +19,17 @@ type Registry struct { Description string `json:"description"` } -func listRegistries(server string, verbose bool) error { +func fetchRegistries(server string) ([]Registry, error) { token, err := ensureValidToken() if err != nil { - return fmt.Errorf("authentication required: %w", err) + return nil, fmt.Errorf("authentication required: %w", err) } url := fmt.Sprintf("https://%s/api/v1/registry/registries/descriptions", server) req, err := http.NewRequest("GET", url, 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)) @@ -38,23 +38,32 @@ func listRegistries(server string, verbose bool) error { client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to make request: %w", err) + return nil, fmt.Errorf("failed to make request: %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 registries []Registry if err := json.Unmarshal(body, ®istries); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return registries, nil +} + +func listRegistries(server string, verbose bool) error { + registries, err := fetchRegistries(server) + if err != nil { + return err } if len(registries) == 0 { From af3e8b20c4a1b0f50404e4616b8d576d2678822c Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Mon, 9 Mar 2026 09:34:34 +0000 Subject: [PATCH 10/14] refactorings --- packages.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages.go b/packages.go index 1217031..51ca778 100644 --- a/packages.go +++ b/packages.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/http" - "os" "strings" "time" ) @@ -202,7 +201,6 @@ func searchPackagesREST(server string, search string, limit int, offset int, reg func searchPackages(server string, search string, limit int, offset int, registryIDs []int, registryNames []string, verbose bool) error { err := searchPackagesGraphQL(server, search, limit, offset, registryIDs, verbose) if err != nil { - fmt.Fprintf(os.Stderr, "GraphQL search failed (%v), falling back to REST API...\n", err) return searchPackagesREST(server, search, limit, offset, registryNames, verbose) } return nil From d61cba15a09f33fa3a1bf73e3ca01507d02684ef Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Tue, 10 Mar 2026 15:12:15 +0000 Subject: [PATCH 11/14] refactorings --- CLAUDE.md | 14 +++++++------- main.go | 10 +++++++++- packages.go | 32 +++++++++++++++++++++++++++----- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1a0185a..7bd7d70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,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, fetch) with REST API integration -- **packages.go**: Package search with GraphQL primary path and REST API fallback (`/packages/info`) +- **packages.go**: Package search with REST API primary path (`/packages/info`) and GraphQL fallback - **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 @@ -31,8 +31,8 @@ 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`), package search fallback (`/packages/info`), token management (`/app/token/activelist`) and user management (`/app/config/features/manage`) - - **GraphQL API**: Used for projects, user info, and package search primary path (`/v1/graphql`) + - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`), package search primary path (`/packages/info`), token management (`/app/token/activelist`) and user management (`/app/config/features/manage`) + - **GraphQL API**: Used for projects, user info, and package search fallback (`/v1/graphql`) - **Headers**: All GraphQL requests require `X-Hasura-Role: jhuser` header - **Authentication**: Uses ID tokens (`token.IDToken`) for API calls @@ -41,7 +41,7 @@ The application follows a command-line interface pattern using the Cobra library - `jh dataset`: Dataset operations (list, download, upload, status) - `jh registry`: Registry operations (list with REST API, supports verbose mode) - `jh project`: Project management (list with GraphQL, supports user filtering) - - `jh package`: Package search (GraphQL primary, REST fallback via `/packages/info`) + - `jh package`: Package search (REST primary via `/packages/info`, GraphQL fallback) - `jh user`: User information (info with GraphQL) - `jh admin`: Administrative commands (user management, token management) - `jh admin user`: User management (list all users with REST API, supports verbose mode) @@ -196,7 +196,7 @@ The application uses OAuth2 device flow: - **Dataset operations**: Use presigned URLs for upload/download - **User management**: `/app/config/features/manage` endpoint for listing all users - **Token management**: `/app/token/activelist` endpoint for listing all API tokens -- **Package search fallback**: `/packages/info` endpoint with `name`, `registries`, `tags`, `licenses`, `limit`, `offset` query params; returns `{packages: [...], meta: {total: N}}` +- **Package search primary**: `/packages/info` endpoint with `name`, `registries`, `tags`, `licenses`, `limit`, `offset` query params; returns `{packages: [...], meta: {total: N}}` - **Authentication**: Bearer token with ID token - **Upload workflow**: 3-step process (request presigned URL, upload to URL, close upload) @@ -318,8 +318,8 @@ jh run setup - Token list output is concise by default (Subject, Created By, and Expired status only); use `--verbose` flag for detailed information (signature, creation date, expiration date with estimate indicator) - Token dates are formatted in human-readable format and converted to local timezone (respects system timezone or TZ environment variable) - Token expiration estimate indicator only shown when `expires_at_is_estimate` is true in API response -- Package search (`jh package search`) tries GraphQL (`FilteredPackages` via `/v1/graphql`) first, then falls back to REST API (`/packages/info`) on failure; a warning is printed to stderr when the fallback is used -- Package search REST fallback passes `--registries` as comma-separated registry names to the `registries` query param +- Package search (`jh package search`) tries REST API (`/packages/info`) first, then falls back to GraphQL (`FilteredPackages` via `/v1/graphql`) on failure; a warning is printed to stderr when the fallback is used +- Package search GraphQL fallback passes `--registries` as registry IDs to the `registries` variable - `fetchRegistries` in `registries.go` is used by both `listRegistries` (for display) and `packageSearchCmd` (to resolve registry names to IDs for GraphQL and names for REST fallback) ## Implementation Details diff --git a/main.go b/main.go index e17409b..0cac6a6 100644 --- a/main.go +++ b/main.go @@ -644,7 +644,15 @@ Use --verbose flag for comprehensive output, or get a concise summary by default } } - if err := searchPackages(server, search, limit, offset, registryIDs, registryNames, verbose); err != nil { + if err := searchPackages(PackageSearchParams{ + Server: server, + Search: search, + Limit: limit, + Offset: offset, + RegistryIDs: registryIDs, + RegistryNames: registryNames, + Verbose: verbose, + }); err != nil { fmt.Printf("Failed to search packages: %v\n", err) os.Exit(1) } diff --git a/packages.go b/packages.go index 51ca778..566b5b5 100644 --- a/packages.go +++ b/packages.go @@ -83,7 +83,23 @@ type PackageRESTListResponse struct { } `json:"meta"` } -func searchPackagesREST(server string, search string, limit int, offset int, registryNames []string, verbose bool) error { +type PackageSearchParams struct { + Server string + Search string + Limit int + Offset int + RegistryIDs []int + RegistryNames []string + Verbose bool +} + +func searchPackagesREST(params PackageSearchParams) error { + server := params.Server + search := params.Search + limit := params.Limit + offset := params.Offset + registryNames := params.RegistryNames + verbose := params.Verbose token, err := ensureValidToken() if err != nil { return fmt.Errorf("authentication required: %w", err) @@ -198,15 +214,21 @@ func searchPackagesREST(server string, search string, limit int, offset int, reg return nil } -func searchPackages(server string, search string, limit int, offset int, registryIDs []int, registryNames []string, verbose bool) error { - err := searchPackagesGraphQL(server, search, limit, offset, registryIDs, verbose) +func searchPackages(params PackageSearchParams) error { + err := searchPackagesREST(params) if err != nil { - return searchPackagesREST(server, search, limit, offset, registryNames, verbose) + return searchPackagesGraphQL(params) } return nil } -func searchPackagesGraphQL(server string, search string, limit int, offset int, registries []int, verbose bool) error { +func searchPackagesGraphQL(params PackageSearchParams) error { + server := params.Server + search := params.Search + limit := params.Limit + offset := params.Offset + registries := params.RegistryIDs + verbose := params.Verbose token, err := ensureValidToken() if err != nil { return fmt.Errorf("authentication required: %w", err) From f81d35512c2bf3050043de931ed0d9efd9a84c7f Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Thu, 12 Mar 2026 13:11:32 +0000 Subject: [PATCH 12/14] fixed error --- packages.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages.go b/packages.go index cc932f0..c93cd5b 100644 --- a/packages.go +++ b/packages.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/http" - "os" "strings" "time" ) @@ -407,7 +406,6 @@ func searchPackagesGraphQL(params PackageSearchParams) error { func getPackageInfo(server, packageName string, registryIDs []int, registryNames []string) error { if err := getPackageInfoREST(server, packageName, registryNames); err != nil { - fmt.Fprintf(os.Stderr, "Warning: REST API unavailable (%v), falling back to GraphQL\n", err) return getPackageInfoGraphQL(server, packageName, registryIDs, registryNames) } return nil From c187309ebec300afde92f43014ec89784674f6f2 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Thu, 12 Mar 2026 14:04:04 +0000 Subject: [PATCH 13/14] fixed error --- main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/main.go b/main.go index 2ea8527..9405ae3 100644 --- a/main.go +++ b/main.go @@ -731,7 +731,6 @@ The package name must match exactly (case-insensitive).`, }, } - var registryCmd = &cobra.Command{ Use: "registry", Short: "Registry management commands", From 9945e2aab014a6b03a26294bd95e7a64945bb027 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Thu, 12 Mar 2026 14:15:08 +0000 Subject: [PATCH 14/14] refactorings --- packages.go | 401 +++++++++++++++++++--------------------------------- 1 file changed, 149 insertions(+), 252 deletions(-) diff --git a/packages.go b/packages.go index c93cd5b..22ee908 100644 --- a/packages.go +++ b/packages.go @@ -193,30 +193,87 @@ func printPackages(pkgs []packageInfo, total int, verbose bool) { } } -func searchPackagesREST(params PackageSearchParams) error { +// restToInfo converts a RESTPackage to the common packageInfo display struct. +func restToInfo(p RESTPackage) packageInfo { + return packageInfo{ + Name: p.Name, + UUID: p.UUID, + Registry: p.Registry, + Version: p.LatestStableVersion, + Description: p.Description, + SourceURL: p.SourceURL, + Tags: p.Tags, + Stars: p.StargazersCount, + DocsURL: p.JHubDocsURL, + License: strings.Join(p.DetectedSourceLicenses, ", "), + } +} + +// gqlToInfo converts a GraphQL Package to the common packageInfo display struct. +func gqlToInfo(p Package, registryIDToName map[int]string) packageInfo { + info := packageInfo{ + Name: p.Name, + UUID: p.UUID, + Owner: p.Owner, + License: p.License, + IsApp: p.IsApp, + Score: p.Score, + } + if p.Metadata != nil { + info.Description = p.Metadata.Description + info.SourceURL = p.Metadata.Repo + info.Tags = p.Metadata.Tags + info.Stars = p.Metadata.StarCount + info.DocsURL = p.Metadata.DocsLink + } + 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 +} + +// buildRegistryIDToName creates a registry ID → name lookup from parallel slices. +func buildRegistryIDToName(ids []int, names []string) map[int]string { + m := make(map[int]string, len(ids)) + for i, id := range ids { + if i < len(names) { + m[id] = names[i] + } + } + return m +} + +// fetchRESTPackages calls the /packages/info endpoint and returns raw results and total count. +func fetchRESTPackages(server, search string, limit, offset int, registryNames []string) ([]RESTPackage, int, error) { token, err := ensureValidToken() if err != nil { - return fmt.Errorf("authentication required: %w", err) + return nil, 0, fmt.Errorf("authentication required: %w", err) } - url := fmt.Sprintf("https://%s/packages/info", params.Server) + url := fmt.Sprintf("https://%s/packages/info", server) req, err := http.NewRequest("GET", url, nil) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return nil, 0, fmt.Errorf("failed to create request: %w", err) } q := req.URL.Query() - if params.Search != "" { - q.Add("name", params.Search) + if search != "" { + q.Add("name", search) } - if params.Limit > 0 { - q.Add("limit", fmt.Sprintf("%d", params.Limit)) + if limit > 0 { + q.Add("limit", fmt.Sprintf("%d", limit)) } - if params.Offset > 0 { - q.Add("offset", fmt.Sprintf("%d", params.Offset)) + if offset > 0 { + q.Add("offset", fmt.Sprintf("%d", offset)) } - if len(params.RegistryNames) > 0 { - q.Add("registries", strings.Join(params.RegistryNames, ",")) + if len(registryNames) > 0 { + q.Add("registries", strings.Join(registryNames, ",")) } req.URL.RawQuery = q.Encode() @@ -226,98 +283,38 @@ func searchPackagesREST(params PackageSearchParams) error { client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to make request: %w", err) + 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 fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(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 fmt.Errorf("failed to read response: %w", err) + return nil, 0, fmt.Errorf("failed to read response: %w", err) } var response PackageRESTListResponse if err := json.Unmarshal(body, &response); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - pkgs := make([]packageInfo, len(response.Packages)) - for i, p := range response.Packages { - pkgs[i] = packageInfo{ - Name: p.Name, - UUID: p.UUID, - Registry: p.Registry, - Version: p.LatestStableVersion, - Description: p.Description, - SourceURL: p.SourceURL, - Tags: p.Tags, - Stars: p.StargazersCount, - DocsURL: p.JHubDocsURL, - License: strings.Join(p.DetectedSourceLicenses, ", "), - } + return nil, 0, fmt.Errorf("failed to parse response: %w", err) } - printPackages(pkgs, response.Meta.Total, params.Verbose) - return nil -} - -func searchPackages(params PackageSearchParams) error { - err := searchPackagesREST(params) - if err != nil { - return searchPackagesGraphQL(params) - } - return nil + return response.Packages, response.Meta.Total, nil } -func executeGraphQL(server string, token *StoredToken, gqlReq GraphQLRequest) ([]byte, error) { - jsonData, err := json.Marshal(gqlReq) - if err != nil { - return nil, fmt.Errorf("failed to marshal GraphQL request: %w", err) - } - - url := fmt.Sprintf("https://%s/v1/graphql", server) - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create GraphQL request: %w", err) - } - - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) - 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) - if err != nil { - return nil, fmt.Errorf("GraphQL request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read GraphQL response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GraphQL request failed (status %d): %s", resp.StatusCode, string(body)) - } - - return body, nil -} - -func searchPackagesGraphQL(params PackageSearchParams) error { +// fetchGraphQLPackages executes the package search GraphQL query and returns raw results and total count. +func fetchGraphQLPackages(server, search string, limit, offset int, registryIDs []int) ([]Package, int, error) { token, err := ensureValidToken() if err != nil { - return fmt.Errorf("authentication required: %w", err) + return nil, 0, fmt.Errorf("authentication required: %w", err) } queryBytes, err := packageSearchFS.ReadFile("package_search_with_count.gql") if err != nil { - return fmt.Errorf("failed to read GraphQL query: %w", err) + return nil, 0, fmt.Errorf("failed to read GraphQL query: %w", err) } variables := map[string]interface{}{ @@ -325,255 +322,155 @@ func searchPackagesGraphQL(params PackageSearchParams) error { "order": map[string]string{"score": "desc"}, "matchtags": "{}", "licenses": "{}", - "search": "", - "offset": 0, + "search": search, + "offset": offset, "hasfailures": false, "installed": true, "notinstalled": true, } - if params.Search != "" { - variables["search"] = params.Search - } - if params.Limit > 0 { - variables["limit"] = params.Limit - } - if params.Offset > 0 { - variables["offset"] = params.Offset + if limit > 0 { + variables["limit"] = limit } - if len(params.RegistryIDs) > 0 { - registryStrs := make([]string, len(params.RegistryIDs)) - for i, id := range params.RegistryIDs { + if len(registryIDs) > 0 { + registryStrs := make([]string, len(registryIDs)) + for i, id := range registryIDs { registryStrs[i] = fmt.Sprintf("%d", id) } variables["registries"] = fmt.Sprintf("{%s}", strings.Join(registryStrs, ",")) } - body, err := executeGraphQL(params.Server, token, GraphQLRequest{ + body, err := executeGraphQL(server, token, GraphQLRequest{ OperationName: "FilteredPackagesWithCount", Query: string(queryBytes), Variables: variables, }) if err != nil { - return err + return nil, 0, err } var response PackageSearchResponse if err := json.Unmarshal(body, &response); err != nil { - return fmt.Errorf("failed to parse GraphQL response: %w", err) + return nil, 0, fmt.Errorf("failed to parse GraphQL response: %w", err) } if len(response.Errors) > 0 { - return fmt.Errorf("GraphQL errors: %v", response.Errors) + return nil, 0, fmt.Errorf("GraphQL errors: %v", response.Errors) } - registryIDToName := make(map[int]string, len(params.RegistryIDs)) - for i, id := range params.RegistryIDs { - registryIDToName[id] = params.RegistryNames[i] - } + return response.Data.PackageSearch, response.Data.PackageAggregate.Aggregate.Count, nil +} - gqlPkgs := response.Data.PackageSearch - pkgs := make([]packageInfo, len(gqlPkgs)) - for i, p := range gqlPkgs { - info := packageInfo{ - Name: p.Name, - UUID: p.UUID, - Owner: p.Owner, - License: p.License, - IsApp: p.IsApp, - Score: p.Score, - } - if p.Metadata != nil { - info.Description = p.Metadata.Description - info.SourceURL = p.Metadata.Repo - info.Tags = p.Metadata.Tags - info.Stars = p.Metadata.StarCount - info.DocsURL = p.Metadata.DocsLink - } - 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" - } - } - pkgs[i] = info +func searchPackagesREST(params PackageSearchParams) error { + pkgs, total, err := fetchRESTPackages(params.Server, params.Search, params.Limit, params.Offset, params.RegistryNames) + if err != nil { + return err } - - printPackages(pkgs, response.Data.PackageAggregate.Aggregate.Count, params.Verbose) + infos := make([]packageInfo, len(pkgs)) + for i, p := range pkgs { + infos[i] = restToInfo(p) + } + printPackages(infos, total, params.Verbose) return nil } -func getPackageInfo(server, packageName string, registryIDs []int, registryNames []string) error { - if err := getPackageInfoREST(server, packageName, registryNames); err != nil { - return getPackageInfoGraphQL(server, packageName, registryIDs, registryNames) +func searchPackagesGraphQL(params PackageSearchParams) error { + pkgs, total, err := fetchGraphQLPackages(params.Server, params.Search, params.Limit, params.Offset, params.RegistryIDs) + if err != nil { + return err + } + registryIDToName := buildRegistryIDToName(params.RegistryIDs, params.RegistryNames) + infos := make([]packageInfo, len(pkgs)) + for i, p := range pkgs { + infos[i] = gqlToInfo(p, registryIDToName) } + printPackages(infos, total, params.Verbose) return nil } -func getPackageInfoREST(server, packageName string, registryNames []string) error { - token, err := ensureValidToken() - if err != nil { - return fmt.Errorf("authentication required: %w", err) +func searchPackages(params PackageSearchParams) error { + if err := searchPackagesREST(params); err != nil { + return searchPackagesGraphQL(params) } + return nil +} - url := fmt.Sprintf("https://%s/packages/info", server) - req, err := http.NewRequest("GET", url, nil) +func executeGraphQL(server string, token *StoredToken, gqlReq GraphQLRequest) ([]byte, error) { + jsonData, err := json.Marshal(gqlReq) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return nil, fmt.Errorf("failed to marshal GraphQL request: %w", err) } - q := req.URL.Query() - q.Add("name", packageName) - q.Add("limit", "100") - if len(registryNames) > 0 { - q.Add("registries", strings.Join(registryNames, ",")) + url := fmt.Sprintf("https://%s/v1/graphql", server) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create GraphQL request: %w", err) } - req.URL.RawQuery = q.Encode() req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + 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) if err != nil { - return fmt.Errorf("failed to make request: %w", err) + return nil, fmt.Errorf("GraphQL request failed: %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)) - } - 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 GraphQL response: %w", err) } - var response PackageRESTListResponse - if err := json.Unmarshal(body, &response); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GraphQL request failed (status %d): %s", resp.StatusCode, string(body)) } + return body, nil +} + +func getPackageInfo(server, packageName string, registryIDs []int, registryNames []string) error { + if err := getPackageInfoREST(server, packageName, registryNames); err != nil { + return getPackageInfoGraphQL(server, packageName, registryIDs, registryNames) + } + return nil +} + +func getPackageInfoREST(server, packageName string, registryNames []string) error { + pkgs, _, err := fetchRESTPackages(server, packageName, 100, 0, registryNames) + if err != nil { + return err + } var matches []packageInfo - for _, p := range response.Packages { + for _, p := range pkgs { if strings.EqualFold(p.Name, packageName) { - matches = append(matches, packageInfo{ - Name: p.Name, - UUID: p.UUID, - Registry: p.Registry, - Version: p.LatestStableVersion, - Description: p.Description, - SourceURL: p.SourceURL, - Tags: p.Tags, - Stars: p.StargazersCount, - DocsURL: p.JHubDocsURL, - License: strings.Join(p.DetectedSourceLicenses, ", "), - }) + matches = append(matches, restToInfo(p)) } } - if len(matches) == 0 { fmt.Println("Package not found") return nil } - printPackages(matches, len(matches), true) return nil } func getPackageInfoGraphQL(server, packageName string, registryIDs []int, registryNames []string) error { - token, err := ensureValidToken() - if err != nil { - return fmt.Errorf("authentication required: %w", err) - } - - queryBytes, err := packageSearchFS.ReadFile("package_search_with_count.gql") - if err != nil { - return fmt.Errorf("failed to read GraphQL query: %w", err) - } - - variables := map[string]interface{}{ - "filter": map[string]interface{}{}, - "order": map[string]string{"score": "desc"}, - "matchtags": "{}", - "licenses": "{}", - "search": packageName, - "offset": 0, - "limit": 100, - "hasfailures": false, - "installed": true, - "notinstalled": true, - } - if len(registryIDs) > 0 { - registryStrs := make([]string, len(registryIDs)) - for i, id := range registryIDs { - registryStrs[i] = fmt.Sprintf("%d", id) - } - variables["registries"] = fmt.Sprintf("{%s}", strings.Join(registryStrs, ",")) - } - - body, err := executeGraphQL(server, token, GraphQLRequest{ - OperationName: "FilteredPackagesWithCount", - Query: string(queryBytes), - Variables: variables, - }) + pkgs, _, err := fetchGraphQLPackages(server, packageName, 100, 0, registryIDs) if err != nil { return err } - - var response PackageSearchResponse - if err := json.Unmarshal(body, &response); err != nil { - return fmt.Errorf("failed to parse GraphQL response: %w", err) - } - if len(response.Errors) > 0 { - return fmt.Errorf("GraphQL errors: %v", response.Errors) - } - - registryIDToName := make(map[int]string, len(registryIDs)) - for i, id := range registryIDs { - if i < len(registryNames) { - registryIDToName[id] = registryNames[i] - } - } - + registryIDToName := buildRegistryIDToName(registryIDs, registryNames) var matches []packageInfo - for _, p := range response.Data.PackageSearch { + for _, p := range pkgs { if strings.EqualFold(p.Name, packageName) { - info := packageInfo{ - Name: p.Name, - UUID: p.UUID, - Owner: p.Owner, - License: p.License, - IsApp: p.IsApp, - Score: p.Score, - } - if p.Metadata != nil { - info.Description = p.Metadata.Description - info.SourceURL = p.Metadata.Repo - info.Tags = p.Metadata.Tags - info.Stars = p.Metadata.StarCount - info.DocsURL = p.Metadata.DocsLink - } - 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" - } - } - matches = append(matches, info) + matches = append(matches, gqlToInfo(p, registryIDToName)) } } - if len(matches) == 0 { fmt.Println("Package not found") return nil } - printPackages(matches, len(matches), true) return nil }