diff --git a/CLAUDE.md b/CLAUDE.md index bd32ad4..b0ce20e 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,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 +- **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 @@ -29,7 +31,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 +39,8 @@ 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 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 @@ -84,6 +88,26 @@ go run . dataset download go run . dataset upload --new ./file.tar.gz ``` +### Test registry operations +```bash +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 @@ -273,6 +297,10 @@ 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) +- 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 e896a05..1ac17e3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ 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 @@ -148,6 +150,26 @@ 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 + - 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 @@ -214,6 +236,38 @@ jh dataset upload --new ./my-data.tar.gz jh dataset upload my-dataset ./updated-data.tar.gz ``` +### Registry Operations + +```bash +# 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 +``` + +### 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 71b31c2..8a3b481 100644 --- a/main.go +++ b/main.go @@ -161,11 +161,13 @@ 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 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 +552,235 @@ 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 tags, 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 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 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", + 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. + +By default, displays, UUID, and Name for each registry. +Use --verbose flag to display comprehensive 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 --verbose\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) + } + + verbose, _ := cmd.Flags().GetBool("verbose") + + if err := listRegistries(server, verbose); 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 +1213,18 @@ 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") + 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") 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 +1237,15 @@ func init() { authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobCmd.AddCommand(jobListCmd, jobStartCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) + packageCmd.AddCommand(packageSearchCmd, packageInfoCmd) + 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, 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..33c7d80 --- /dev/null +++ b/package_search.gql @@ -0,0 +1,62 @@ +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 + __typename + } + metadata { + docshosteduri + versions + description + docslink + repo + owner + tags + starcount + __typename + } + uuid + installed + failures { + package_version + __typename + } + __typename + } +} diff --git a/packages.go b/packages.go new file mode 100644 index 0000000..fd99591 --- /dev/null +++ b/packages.go @@ -0,0 +1,329 @@ +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"` +} + +// 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 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 nil, fmt.Errorf("failed to read GraphQL query: %w", err) + } + query := string(queryBytes) + + graphqlReq := GraphQLRequest{ + OperationName: "FilteredPackages", + Query: query, + Variables: variables, + } + + jsonData, err := json.Marshal(graphqlReq) + 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 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("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.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 nil, fmt.Errorf("failed to read response: %w", err) + } + + var response PackageSearchResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Check for GraphQL errors + if len(response.Errors) > 0 { + 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 + } + + 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") + return nil + } + + 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 + displayPackageDetails(&pkg) + } 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[:50] + "..." + } + fmt.Printf("%s", desc) + } + + fmt.Printf("\n") + } + + fmt.Println() + } + + 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 +} diff --git a/registries.go b/registries.go new file mode 100644 index 0000000..e7cbde8 --- /dev/null +++ b/registries.go @@ -0,0 +1,112 @@ +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"` +} + +// fetchRegistries retrieves all registries from the API and returns them +func fetchRegistries(server string) ([]Registry, error) { + token, err := ensureValidToken() + if err != nil { + 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 nil, 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 nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.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 nil, fmt.Errorf("failed to read response: %w", err) + } + + var registries []Registry + if err := json.Unmarshal(body, ®istries); err != nil { + 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 { + fmt.Println("No registries found") + return nil + } + + fmt.Printf("Found %d registr%s:\n\n", len(registries), pluralize(len(registries), "y", "ies")) + + 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() + } + } else { + // Default mode: show UUID, Name, and Registry ID (needed for package search filtering) + for _, registry := range registries { + fmt.Printf("UUID: %s\n", registry.UUID) + fmt.Printf("Name: %s\n", registry.Name) + fmt.Println() + } + } + + return nil +} + +func pluralize(count int, singular, plural string) string { + if count == 1 { + return singular + } + return plural +}