From 30534be6626f280dec98fb4c554cb106af6af7d4 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Mon, 23 Feb 2026 07:20:57 +0000 Subject: [PATCH 1/9] registry add, update and config commands --- CLAUDE.md | 52 +++++++++- README.md | 31 +++++- main.go | 234 ++++++++++++++++++++++++++++++++++++++++++- registries.go | 270 +++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 544 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index df6956a..95fb4b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +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 +- **registries.go**: Registry operations (list, config, add, update) with REST API integration - **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,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}`), registry operations (`/api/v1/ui/registries/descriptions`), token management (`/app/token/activelist`) and user management (`/app/config/features/manage`) + - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`), token management (`/app/token/activelist`) and user management (`/app/config/features/manage`) - **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 @@ -39,7 +39,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 with REST API, supports verbose mode) + - `jh registry`: Registry operations (list, config, add, update — all via REST API; add/update accept JSON via stdin or `--file`) - `jh project`: Project management (list with GraphQL, supports user filtering) - `jh user`: User information (info with GraphQL) - `jh admin`: Administrative commands (user management, token management) @@ -94,6 +94,28 @@ go run . dataset upload --new ./file.tar.gz ```bash go run . registry list go run . registry list --verbose +go run . registry config JuliaSimRegistry +go run . registry config JuliaSimRegistry -s nightly.juliahub.dev + +# Add a registry (JSON via stdin or --file) +echo '{ + "name": "MyRegistry", + "license_detect": true, + "artifact": {"download": true}, + "docs": {"download": true, "docgen_check_installable": false, "html_size_threshold_bytes": null}, + "metadata": {"download": true}, + "pkg": {"download": true, "static_analysis_runs": []}, + "enabled": true, "display_apps": true, "owner": "", "sync_schedule": null, + "download_providers": [{ + "type": "cacheserver", "host": "https://pkg.juliahub.com", + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" + }] +}' | go run . registry add +go run . registry add --file registry.json + +# Update an existing registry (same JSON schema, same flags) +go run . registry update --file registry.json ``` ### Test project and user operations @@ -304,6 +326,11 @@ jh run setup - Admin user list command (`jh admin user list`) uses REST API endpoint `/app/config/features/manage` which requires appropriate permissions - User list output is concise by default (Name and Email only); use `--verbose` flag for detailed information (UUID, groups, features) - Registry list output is concise by default (UUID and Name only); use `--verbose` flag for detailed information (owner, creation date, package count, description) +- Registry config command (`jh registry config `) uses REST API endpoint `/api/v1/registry/config/registry/{name}` (GET) and prints the full JSON response +- Registry add/update commands (`jh registry add` / `jh registry update`) use REST API endpoint `/api/v1/registry/config/registry/{name}` (POST); the backend creates or updates based on whether the registry already exists +- Both commands accept the full registry JSON payload via `--file ` or stdin; the payload `name` field identifies the registry +- Registry add/update always poll `/api/v1/registry/config/registry/{name}/savestatus` every 3 seconds up to a 2-minute timeout +- Bundle provider type automatically sets `license_detect: false` in the payload - Admin token list command (`jh admin token list`) uses REST API endpoint `/app/token/activelist` which requires appropriate permissions - 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) @@ -311,6 +338,25 @@ jh run setup ## Implementation Details +### Registry Operations (`registries.go`) + +**Shared helpers:** + +- **`apiGet(url, idToken)`**: shared GET helper used by `listRegistries` and `getRegistryConfig`; retries up to 3 times on network errors or 500s, returns `[]byte` body on success +- **`readRegistryPayload(filePath)`**: reads JSON from `filePath` or stdin; validates `name` and `download_providers` are present and non-empty; returns raw `map[string]interface{}` for direct API forwarding + +**`jh registry list` / `jh registry config`:** + +- Both use `apiGet` for the HTTP call +- `listRegistries` unmarshals into `[]Registry` and formats output; `--verbose` adds owner, date, package count, and description +- `getRegistryConfig` pretty-prints the raw JSON response + +**`jh registry add` / `jh registry update`:** + +- Both call `submitRegistry(server, payload, operation)` with `operation` set to `"creation"` or `"update"` for status messages +- `submitRegistry` POSTs to `/api/v1/registry/config/registry/{name}` with retry on 500s, then calls `pollRegistrySaveStatus()` +- `pollRegistrySaveStatus` GETs `/api/v1/registry/config/registry/{name}/savestatus` every 3 seconds up to a 2-minute deadline + ### Julia Credentials Management (`run.go`) The Julia credentials system consists of three main functions: diff --git a/README.md b/README.md index f36e321..80574d5 100644 --- a/README.md +++ b/README.md @@ -6,7 +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 +- **Registry Management**: List, add, and update 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 @@ -155,6 +155,9 @@ go build -o jh . - `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 +- `jh registry config ` - Show the full JSON configuration for a registry +- `jh registry add` - Add a new registry (JSON payload via stdin or `--file`) +- `jh registry update` - Update an existing registry (same JSON schema as add, same flags) ### Project Management (`jh project`) @@ -245,6 +248,32 @@ jh registry list --verbose # List registries on custom server jh registry list -s yourinstall + +# Show full configuration for a registry +jh registry config JuliaSimRegistry +jh registry config JuliaSimRegistry -s nightly.juliahub.dev + +# Add a registry (JSON via stdin or --file) +echo '{ + "name": "MyRegistry", + "license_detect": true, + "artifact": {"download": true}, + "docs": {"download": true, "docgen_check_installable": false, "html_size_threshold_bytes": null}, + "metadata": {"download": true}, + "pkg": {"download": true, "static_analysis_runs": []}, + "enabled": true, "display_apps": true, "owner": "", "sync_schedule": null, + "download_providers": [{ + "type": "cacheserver", "host": "https://pkg.juliahub.com", + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" + }] +}' | jh registry add + +# Or use a file +jh registry add --file registry.json + +# Update an existing registry (same JSON schema, registry identified by "name" field) +jh registry update --file registry.json ``` ### Project Operations diff --git a/main.go b/main.go index 870b237..7087823 100644 --- a/main.go +++ b/main.go @@ -565,6 +565,233 @@ installed. JuliaHub supports multiple registries including the General registry, custom organizational registries, and test registries.`, } +var registryAddCmd = &cobra.Command{ + Use: "add", + Short: "Add a new registry", + Long: `Add a new Julia package registry on JuliaHub. + +Reads the registry configuration from a JSON file (--file) or stdin. The JSON must +match the registry API schema exactly — it is forwarded to the API as-is. + +Registry creation is always polled until completion (up to 2 minutes). + +REGISTRY JSON SCHEMA + + { + "name": "", // required + "license_detect": true, + "artifact": { "download": true }, + "docs": { "download": true, "docgen_check_installable": false, + "html_size_threshold_bytes": null }, + "metadata": { "download": true }, + "pkg": { "download": true, "static_analysis_runs": [] }, + "enabled": true, + "display_apps": true, + "owner": "", // optional; defaults to current user + "sync_schedule": null, // or: { "interval_sec": 420, + // "days": [1,2,3,4,5,6,7], + // "start_hour": 0, "end_hour": 24, + // "timezone": "UTC" } + "download_providers": [ , ... ] // required; one or more entries + } + + Provider object (gitserver): + { "type": "gitserver", "url": "", "server_type": "github|gitlab|bitbucket|bare-git", + "github_credential_type": "pat|app", "user_name": "", + "credential_key": "", "api_host": null, "host": "" } + + Provider object (cacheserver): + { "type": "cacheserver", "host": "", "credential_key": "", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" } + + Provider object (bundle): + { "type": "bundle", "credential_key": "", "server_type": "", + "github_credential_type": "", "api_host": "", "url": "", "user_name": "", "host": "" } + + Provider object (genericserver): + { "type": "genericserver", "auth": { "type": "basic", "user_name": "", + "credential_key": "" } }`, + Example: ` # Cache server — pipe JSON via stdin + echo '{ + "name": "MyRegistry", + "license_detect": true, + "artifact": {"download": true}, + "docs": {"download": true, "docgen_check_installable": false, "html_size_threshold_bytes": null}, + "metadata": {"download": true}, + "pkg": {"download": true, "static_analysis_runs": []}, + "enabled": true, "display_apps": true, "owner": "admin", "sync_schedule": null, + "download_providers": [{ + "type": "cacheserver", "host": "https://pkg.juliahub.com", + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" + }] + }' | jh registry add + + # Read from file + jh registry add --file registry.json + + # GitHub with Personal Access Token + echo '{ + "name": "MyRegistry", + "license_detect": true, + "artifact": {"download": true}, "docs": {"download": true, "docgen_check_installable": false, "html_size_threshold_bytes": null}, + "metadata": {"download": true}, "pkg": {"download": true, "static_analysis_runs": []}, + "enabled": true, "display_apps": true, "owner": "", "sync_schedule": null, + "download_providers": [{ + "type": "gitserver", + "url": "https://github.com/MyOrg/MyRegistry.git", + "server_type": "github", "github_credential_type": "pat", + "user_name": "myuser", "credential_key": "my-pat-token-id", + "api_host": null, "host": "" + }] + }' | jh registry add`, + Args: cobra.NoArgs, + 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) + } + + filePath, _ := cmd.Flags().GetString("file") + + payload, err := readRegistryPayload(filePath) + if err != nil { + fmt.Printf("Failed to read registry payload: %v\n", err) + os.Exit(1) + } + + if err := createRegistry(server, payload); err != nil { + fmt.Printf("Failed to add registry: %v\n", err) + os.Exit(1) + } + }, +} + +var registryUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update an existing registry", + Long: `Update an existing Julia package registry on JuliaHub. + +Reads the registry configuration from a JSON file (--file) or stdin. The JSON must +match the registry API schema exactly — it is forwarded to the API as-is. + +The registry is identified by the "name" field in the payload. All fields are +replaced with the values provided; omitted optional fields revert to defaults. + +Registry update is always polled until completion (up to 2 minutes). + +REGISTRY JSON SCHEMA + + { + "name": "", // required; identifies the registry to update + "license_detect": true, + "artifact": { "download": true }, + "docs": { "download": true, "docgen_check_installable": false, + "html_size_threshold_bytes": null }, + "metadata": { "download": true }, + "pkg": { "download": true, "static_analysis_runs": [] }, + "enabled": true, + "display_apps": true, + "owner": "", // optional; defaults to current user + "sync_schedule": null, // or: { "interval_sec": 420, + // "days": [1,2,3,4,5,6,7], + // "start_hour": 0, "end_hour": 24, + // "timezone": "UTC" } + "download_providers": [ , ... ] // required; one or more entries + } + + Provider object (gitserver): + { "type": "gitserver", "url": "", "server_type": "github|gitlab|bitbucket|bare-git", + "github_credential_type": "pat|app", "user_name": "", + "credential_key": "", "api_host": null, "host": "" } + + Provider object (cacheserver): + { "type": "cacheserver", "host": "", "credential_key": "", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" } + + Provider object (bundle): + { "type": "bundle", "credential_key": "", "server_type": "", + "github_credential_type": "", "api_host": "", "url": "", "user_name": "", "host": "" } + + Provider object (genericserver): + { "type": "genericserver", "auth": { "type": "basic", "user_name": "", + "credential_key": "" } }`, + Example: ` # Update cache server URL — pipe JSON via stdin + echo '{ + "name": "MyRegistry", + "license_detect": true, + "artifact": {"download": true}, + "docs": {"download": true, "docgen_check_installable": false, "html_size_threshold_bytes": null}, + "metadata": {"download": true}, + "pkg": {"download": true, "static_analysis_runs": []}, + "enabled": true, "display_apps": true, "owner": "admin", "sync_schedule": null, + "download_providers": [{ + "type": "cacheserver", "host": "https://pkg-new.juliahub.com", + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" + }] + }' | jh registry update + + # Read from file + jh registry update --file registry.json + + # Update GitHub registry to use a new credential + echo '{ + "name": "MyRegistry", + "license_detect": true, + "artifact": {"download": true}, "docs": {"download": true, "docgen_check_installable": false, "html_size_threshold_bytes": null}, + "metadata": {"download": true}, "pkg": {"download": true, "static_analysis_runs": []}, + "enabled": true, "display_apps": true, "owner": "", "sync_schedule": null, + "download_providers": [{ + "type": "gitserver", + "url": "https://github.com/MyOrg/MyRegistry.git", + "server_type": "github", "github_credential_type": "pat", + "user_name": "myuser", "credential_key": "new-pat-token-id", + "api_host": null, "host": "" + }] + }' | jh registry update`, + Args: cobra.NoArgs, + 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) + } + + filePath, _ := cmd.Flags().GetString("file") + + payload, err := readRegistryPayload(filePath) + if err != nil { + fmt.Printf("Failed to read registry payload: %v\n", err) + os.Exit(1) + } + + if err := updateRegistry(server, payload); err != nil { + fmt.Printf("Failed to update registry: %v\n", err) + os.Exit(1) + } + }, +} + +var registryConfigCmd = &cobra.Command{ + Use: "config ", + Short: "Show the configuration for a registry", + Example: " jh registry config JuliaSimRegistry\n jh registry config JuliaSimRegistry -s nightly.juliahub.dev", + 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) + } + if err := getRegistryConfig(server, args[0]); err != nil { + fmt.Printf("Failed to get registry config: %v\n", err) + os.Exit(1) + } + }, +} + var registryListCmd = &cobra.Command{ Use: "list", Short: "List registries", @@ -1121,6 +1348,10 @@ 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") + registryAddCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + registryAddCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") + registryUpdateCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + registryUpdateCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") 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") @@ -1139,7 +1370,8 @@ func init() { authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobCmd.AddCommand(jobListCmd, jobStartCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) - registryCmd.AddCommand(registryListCmd) + registryConfigCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryCmd.AddCommand(registryListCmd, registryAddCmd, registryUpdateCmd, registryConfigCmd) projectCmd.AddCommand(projectListCmd) userCmd.AddCommand(userInfoCmd) adminUserCmd.AddCommand(userListCmd) diff --git a/registries.go b/registries.go index 7012504..9e17f54 100644 --- a/registries.go +++ b/registries.go @@ -1,10 +1,12 @@ package main import ( + "bytes" "encoding/json" "fmt" "io" "net/http" + "os" "time" ) @@ -19,37 +21,60 @@ type Registry struct { Description string `json:"description"` } -func listRegistries(server string, verbose bool) error { - token, err := ensureValidToken() - if err != nil { - return fmt.Errorf("authentication required: %w", err) +func pluralize(count int, singular, plural string) string { + if count == 1 { + return singular } + return plural +} - url := fmt.Sprintf("https://%s/api/v1/registry/registries/descriptions", server) +// apiGet performs a GET request with up to 3 attempts, retrying on transient errors. +func apiGet(url, idToken string) ([]byte, error) { + client := &http.Client{Timeout: 30 * time.Second} + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + time.Sleep(2 * time.Second) + } + 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", idToken)) + req.Header.Set("Accept", "application/json") - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) + resp, err := client.Do(req) + if err != nil { + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + lastErr = fmt.Errorf("failed to read response: %w", readErr) + continue + } + if resp.StatusCode == http.StatusInternalServerError { + lastErr = fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) + continue + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) + } + return body, nil } + return nil, lastErr +} - 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) +func listRegistries(server string, verbose bool) error { + token, err := ensureValidToken() 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)) + return fmt.Errorf("authentication required: %w", err) } - body, err := io.ReadAll(resp.Body) + body, err := apiGet(fmt.Sprintf("https://%s/api/v1/registry/registries/descriptions", server), token.IDToken) if err != nil { - return fmt.Errorf("failed to read response: %w", err) + return err } var registries []Registry @@ -64,11 +89,10 @@ func listRegistries(server string, verbose bool) error { 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) + for _, registry := range registries { + fmt.Printf("UUID: %s\n", registry.UUID) + fmt.Printf("Name: %s\n", registry.Name) + if verbose { if registry.Owner != nil { fmt.Printf("Owner: %s\n", *registry.Owner) } else { @@ -80,23 +104,193 @@ func listRegistries(server string, verbose bool) error { if registry.Description != "" { fmt.Printf("Description: %s\n", registry.Description) } - fmt.Println() + } + fmt.Println() + } + + return nil +} + +func getRegistryConfig(server, name string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + body, err := apiGet(fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s", server, name), token.IDToken) + if err != nil { + return err + } + + var pretty bytes.Buffer + if err := json.Indent(&pretty, body, "", " "); err != nil { + fmt.Println(string(body)) + return nil + } + fmt.Println(pretty.String()) + return nil +} + +func readRegistryPayload(filePath string) (map[string]interface{}, error) { + var data []byte + var err error + + if filePath != "" { + data, err = os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file %q: %w", filePath, err) } } 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() + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + return nil, fmt.Errorf("no JSON payload provided — pipe JSON via stdin or use --file") + } + data, err = io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("failed to read stdin: %w", err) } } - return nil + var payload map[string]interface{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + nameVal, ok := payload["name"] + if !ok || nameVal == "" { + return nil, fmt.Errorf(`JSON payload must include a non-empty "name"`) + } + + providers, ok := payload["download_providers"] + if !ok { + return nil, fmt.Errorf(`JSON payload must include "download_providers"`) + } + if provList, ok := providers.([]interface{}); !ok || len(provList) == 0 { + return nil, fmt.Errorf(`"download_providers" must be a non-empty array`) + } + + return payload, nil } -func pluralize(count int, singular, plural string) string { - if count == 1 { - return singular +func submitRegistry(server string, payload map[string]interface{}, operation string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) } - return plural + + name, _ := payload["name"].(string) + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + apiURL := fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s", server, name) + client := &http.Client{Timeout: 30 * time.Second} + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + time.Sleep(2 * time.Second) + } + req, err := http.NewRequest("POST", apiURL, bytes.NewReader(payloadBytes)) + 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-JuliaHub-Ensure-Js", "true") + + resp, err := client.Do(req) + if err != nil { + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + bodyBytes, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode == http.StatusInternalServerError { + lastErr = fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(bodyBytes)) + continue + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(bodyBytes)) + } + lastErr = nil + break + } + if lastErr != nil { + return lastErr + } + + fmt.Printf("Registry '%s' %s submitted, waiting for completion...\n", name, operation) + return pollRegistrySaveStatus(server, token.IDToken, name, operation) +} + +func createRegistry(server string, payload map[string]interface{}) error { + return submitRegistry(server, payload, "creation") +} + +func updateRegistry(server string, payload map[string]interface{}) error { + return submitRegistry(server, payload, "update") +} + +type saveStatusResponse struct { + Status string `json:"status"` + Result *struct { + Success bool `json:"success"` + Message string `json:"message"` + } `json:"result"` +} + +func pollRegistrySaveStatus(server, idToken, registryName, operation string) error { + apiURL := fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s/savestatus", server, registryName) + client := &http.Client{Timeout: 30 * time.Second} + deadline := time.Now().Add(2 * time.Minute) + + for time.Now().Before(deadline) { + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return fmt.Errorf("failed to create status request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", idToken)) + req.Header.Set("Accept", "application/json") + req.Header.Set("X-JuliaHub-Ensure-Js", "true") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to check status: %w", err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return fmt.Errorf("failed to read status response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusInternalServerError { + time.Sleep(3 * time.Second) + continue + } + return fmt.Errorf("status check failed (status %d): %s", resp.StatusCode, string(body)) + } + + var status saveStatusResponse + if err := json.Unmarshal(body, &status); err != nil { + return fmt.Errorf("failed to parse status response: %w", err) + } + + if status.Status == "done" { + if status.Result != nil && status.Result.Success { + fmt.Printf("Registry '%s' %s completed successfully!\n", registryName, operation) + return nil + } else if status.Result != nil { + return fmt.Errorf("registry %s failed: %s", operation, status.Result.Message) + } + return fmt.Errorf("registry %s failed: unknown error", operation) + } + + time.Sleep(3 * time.Second) + } + + return fmt.Errorf("timed out waiting for registry %s to complete", operation) } From dae25ae1b4d66d363d0f4b15288e861381ce8c94 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Mon, 23 Feb 2026 07:43:43 +0000 Subject: [PATCH 2/9] updated add update help section --- main.go | 189 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 100 insertions(+), 89 deletions(-) diff --git a/main.go b/main.go index 7087823..ddde2dd 100644 --- a/main.go +++ b/main.go @@ -555,62 +555,118 @@ 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.`, -} +func registryMutateHelp(verb string) string { + var verbLine, nameNote, updateNote string + if verb == "add" { + verbLine = "Add a new Julia package registry on JuliaHub." + nameNote = "// required" + } else { + verbLine = "Update an existing Julia package registry on JuliaHub." + nameNote = "// required; identifies the registry to update" + updateNote = "\n\nThe registry is identified by the \"name\" field. All fields are replaced\nwith the provided values; omitted optional fields revert to defaults." + } -var registryAddCmd = &cobra.Command{ - Use: "add", - Short: "Add a new registry", - Long: `Add a new Julia package registry on JuliaHub. + return verbLine + ` -Reads the registry configuration from a JSON file (--file) or stdin. The JSON must -match the registry API schema exactly — it is forwarded to the API as-is. +Reads the registry configuration from a JSON file (--file) or stdin. +The JSON is validated and forwarded to the API as-is.` + updateNote + ` -Registry creation is always polled until completion (up to 2 minutes). +Registry ` + verb + ` is polled until completion (up to 2 minutes). +Use ` + "`jh registry config `" + ` to inspect the result afterward. REGISTRY JSON SCHEMA { - "name": "", // required - "license_detect": true, - "artifact": { "download": true }, - "docs": { "download": true, "docgen_check_installable": false, - "html_size_threshold_bytes": null }, - "metadata": { "download": true }, - "pkg": { "download": true, "static_analysis_runs": [] }, - "enabled": true, - "display_apps": true, - "owner": "", // optional; defaults to current user - "sync_schedule": null, // or: { "interval_sec": 420, - // "days": [1,2,3,4,5,6,7], - // "start_hour": 0, "end_hour": 24, - // "timezone": "UTC" } + "name": "", ` + nameNote + ` + "license_detect": true, + "artifact": { "download": true }, + "docs": { + "download": true, + "docgen_check_installable": false, + "html_size_threshold_bytes": null + }, + "metadata": { "download": true }, + "pkg": { "download": true, "static_analysis_runs": [] }, + "enabled": true, + "display_apps": true, + "owner": "", // optional; defaults to current user + "sync_schedule": null, // or: see SYNC SCHEDULE below "download_providers": [ , ... ] // required; one or more entries } - Provider object (gitserver): - { "type": "gitserver", "url": "", "server_type": "github|gitlab|bitbucket|bare-git", - "github_credential_type": "pat|app", "user_name": "", - "credential_key": "", "api_host": null, "host": "" } +SYNC SCHEDULE - Provider object (cacheserver): - { "type": "cacheserver", "host": "", "credential_key": "", - "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" } + { + "interval_sec": 420, + "days": [1, 2, 3, 4, 5, 6, 7], + "start_hour": 0, + "end_hour": 24, + "timezone": "UTC" + } + +PROVIDER TYPES + + gitserver — sync from a Git repository: + { + "type": "gitserver", + "url": "", + "server_type": "github|gitlab|bitbucket|bare-git", + "github_credential_type": "pat|app", + "user_name": "", + "credential_key": "", + "api_host": null, + "host": "" + } + + cacheserver — sync from a JuliaHub package cache: + { + "type": "cacheserver", + "host": "", + "credential_key": "", + "server_type": "", + "github_credential_type": "", + "api_host": "", + "url": "", + "user_name": "" + } - Provider object (bundle): - { "type": "bundle", "credential_key": "", "server_type": "", - "github_credential_type": "", "api_host": "", "url": "", "user_name": "", "host": "" } + bundle — local bundle (sets license_detect: false automatically): + { + "type": "bundle", + "credential_key": "", + "server_type": "", + "github_credential_type": "", + "api_host": "", + "url": "", + "user_name": "", + "host": "" + } + + genericserver — generic server with basic auth: + { + "type": "genericserver", + "auth": { + "type": "basic", + "user_name": "", + "credential_key": "" + } + }` +} - Provider object (genericserver): - { "type": "genericserver", "auth": { "type": "basic", "user_name": "", - "credential_key": "" } }`, +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 registryAddCmd = &cobra.Command{ + Use: "add", + Short: "Add a new registry", + Long: registryMutateHelp("add"), Example: ` # Cache server — pipe JSON via stdin echo '{ "name": "MyRegistry", @@ -671,52 +727,7 @@ REGISTRY JSON SCHEMA var registryUpdateCmd = &cobra.Command{ Use: "update", Short: "Update an existing registry", - Long: `Update an existing Julia package registry on JuliaHub. - -Reads the registry configuration from a JSON file (--file) or stdin. The JSON must -match the registry API schema exactly — it is forwarded to the API as-is. - -The registry is identified by the "name" field in the payload. All fields are -replaced with the values provided; omitted optional fields revert to defaults. - -Registry update is always polled until completion (up to 2 minutes). - -REGISTRY JSON SCHEMA - - { - "name": "", // required; identifies the registry to update - "license_detect": true, - "artifact": { "download": true }, - "docs": { "download": true, "docgen_check_installable": false, - "html_size_threshold_bytes": null }, - "metadata": { "download": true }, - "pkg": { "download": true, "static_analysis_runs": [] }, - "enabled": true, - "display_apps": true, - "owner": "", // optional; defaults to current user - "sync_schedule": null, // or: { "interval_sec": 420, - // "days": [1,2,3,4,5,6,7], - // "start_hour": 0, "end_hour": 24, - // "timezone": "UTC" } - "download_providers": [ , ... ] // required; one or more entries - } - - Provider object (gitserver): - { "type": "gitserver", "url": "", "server_type": "github|gitlab|bitbucket|bare-git", - "github_credential_type": "pat|app", "user_name": "", - "credential_key": "", "api_host": null, "host": "" } - - Provider object (cacheserver): - { "type": "cacheserver", "host": "", "credential_key": "", - "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" } - - Provider object (bundle): - { "type": "bundle", "credential_key": "", "server_type": "", - "github_credential_type": "", "api_host": "", "url": "", "user_name": "", "host": "" } - - Provider object (genericserver): - { "type": "genericserver", "auth": { "type": "basic", "user_name": "", - "credential_key": "" } }`, + Long: registryMutateHelp("update"), Example: ` # Update cache server URL — pipe JSON via stdin echo '{ "name": "MyRegistry", From ae1bfe30c8b7f71b142c75dcacd6246fd1cfbb70 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Mon, 23 Feb 2026 09:57:12 +0000 Subject: [PATCH 3/9] registry list cmd in one line, changed add and update to config add and update --- CLAUDE.md | 13 ++++++----- README.md | 10 ++++---- main.go | 63 ++++++++++++++++++++++++++------------------------- registries.go | 28 ++++++++++++----------- 4 files changed, 59 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 95fb4b2..f13e074 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +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, config, add, update — all via REST API; add/update accept JSON via stdin or `--file`) + - `jh registry`: Registry operations (list, config — all via REST API) + - `jh registry config`: Show registry JSON config by name; subcommands add/update accept JSON via stdin or `--file` - `jh project`: Project management (list with GraphQL, supports user filtering) - `jh user`: User information (info with GraphQL) - `jh admin`: Administrative commands (user management, token management) @@ -111,11 +112,11 @@ echo '{ "credential_key": "JC Auth Token", "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" }] -}' | go run . registry add -go run . registry add --file registry.json +}' | go run . registry config add +go run . registry config add --file registry.json # Update an existing registry (same JSON schema, same flags) -go run . registry update --file registry.json +go run . registry config update --file registry.json ``` ### Test project and user operations @@ -327,7 +328,7 @@ jh run setup - User list output is concise by default (Name and Email only); use `--verbose` flag for detailed information (UUID, groups, features) - Registry list output is concise by default (UUID and Name only); use `--verbose` flag for detailed information (owner, creation date, package count, description) - Registry config command (`jh registry config `) uses REST API endpoint `/api/v1/registry/config/registry/{name}` (GET) and prints the full JSON response -- Registry add/update commands (`jh registry add` / `jh registry update`) use REST API endpoint `/api/v1/registry/config/registry/{name}` (POST); the backend creates or updates based on whether the registry already exists +- Registry add/update commands (`jh registry config add` / `jh registry config update`) use REST API endpoint `/api/v1/registry/config/registry/{name}` (POST); the backend creates or updates based on whether the registry already exists - Both commands accept the full registry JSON payload via `--file ` or stdin; the payload `name` field identifies the registry - Registry add/update always poll `/api/v1/registry/config/registry/{name}/savestatus` every 3 seconds up to a 2-minute timeout - Bundle provider type automatically sets `license_detect: false` in the payload @@ -351,7 +352,7 @@ jh run setup - `listRegistries` unmarshals into `[]Registry` and formats output; `--verbose` adds owner, date, package count, and description - `getRegistryConfig` pretty-prints the raw JSON response -**`jh registry add` / `jh registry update`:** +**`jh registry config add` / `jh registry config update`:** - Both call `submitRegistry(server, payload, operation)` with `operation` set to `"creation"` or `"update"` for status messages - `submitRegistry` POSTs to `/api/v1/registry/config/registry/{name}` with retry on 500s, then calls `pollRegistrySaveStatus()` diff --git a/README.md b/README.md index 80574d5..b3e238e 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,8 @@ 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 - `jh registry config ` - Show the full JSON configuration for a registry -- `jh registry add` - Add a new registry (JSON payload via stdin or `--file`) -- `jh registry update` - Update an existing registry (same JSON schema as add, same flags) +- `jh registry config add` - Add a new registry (JSON payload via stdin or `--file`) +- `jh registry config update` - Update an existing registry (same JSON schema as add, same flags) ### Project Management (`jh project`) @@ -267,13 +267,13 @@ echo '{ "credential_key": "JC Auth Token", "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" }] -}' | jh registry add +}' | jh registry config add # Or use a file -jh registry add --file registry.json +jh registry config add --file registry.json # Update an existing registry (same JSON schema, registry identified by "name" field) -jh registry update --file registry.json +jh registry config update --file registry.json ``` ### Project Operations diff --git a/main.go b/main.go index ddde2dd..c800ff2 100644 --- a/main.go +++ b/main.go @@ -663,7 +663,25 @@ installed. JuliaHub supports multiple registries including the General registry, custom organizational registries, and test registries.`, } -var registryAddCmd = &cobra.Command{ +var registryConfigCmd = &cobra.Command{ + Use: "config ", + Short: "Show or modify the configuration for a registry", + Example: " jh registry config JuliaSimRegistry\n jh registry config JuliaSimRegistry -s nightly.juliahub.dev", + 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) + } + if err := getRegistryConfig(server, args[0]); err != nil { + fmt.Printf("Failed to get registry config: %v\n", err) + os.Exit(1) + } + }, +} + +var registryConfigAddCmd = &cobra.Command{ Use: "add", Short: "Add a new registry", Long: registryMutateHelp("add"), @@ -681,10 +699,10 @@ var registryAddCmd = &cobra.Command{ "credential_key": "JC Auth Token", "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" }] - }' | jh registry add + }' | jh registry config add # Read from file - jh registry add --file registry.json + jh registry config add --file registry.json # GitHub with Personal Access Token echo '{ @@ -700,7 +718,7 @@ var registryAddCmd = &cobra.Command{ "user_name": "myuser", "credential_key": "my-pat-token-id", "api_host": null, "host": "" }] - }' | jh registry add`, + }' | jh registry config add`, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) @@ -724,7 +742,7 @@ var registryAddCmd = &cobra.Command{ }, } -var registryUpdateCmd = &cobra.Command{ +var registryConfigUpdateCmd = &cobra.Command{ Use: "update", Short: "Update an existing registry", Long: registryMutateHelp("update"), @@ -742,10 +760,10 @@ var registryUpdateCmd = &cobra.Command{ "credential_key": "JC Auth Token", "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" }] - }' | jh registry update + }' | jh registry config update # Read from file - jh registry update --file registry.json + jh registry config update --file registry.json # Update GitHub registry to use a new credential echo '{ @@ -761,7 +779,7 @@ var registryUpdateCmd = &cobra.Command{ "user_name": "myuser", "credential_key": "new-pat-token-id", "api_host": null, "host": "" }] - }' | jh registry update`, + }' | jh registry config update`, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) @@ -785,24 +803,6 @@ var registryUpdateCmd = &cobra.Command{ }, } -var registryConfigCmd = &cobra.Command{ - Use: "config ", - Short: "Show the configuration for a registry", - Example: " jh registry config JuliaSimRegistry\n jh registry config JuliaSimRegistry -s nightly.juliahub.dev", - 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) - } - if err := getRegistryConfig(server, args[0]); err != nil { - fmt.Printf("Failed to get registry config: %v\n", err) - os.Exit(1) - } - }, -} - var registryListCmd = &cobra.Command{ Use: "list", Short: "List registries", @@ -1359,10 +1359,6 @@ 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") - registryAddCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") - registryAddCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") - registryUpdateCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") - registryUpdateCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") 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") @@ -1382,7 +1378,12 @@ func init() { jobCmd.AddCommand(jobListCmd, jobStartCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) registryConfigCmd.Flags().StringP("server", "s", "", "JuliaHub server") - registryCmd.AddCommand(registryListCmd, registryAddCmd, registryUpdateCmd, registryConfigCmd) + registryConfigAddCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + registryConfigAddCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") + registryConfigUpdateCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + registryConfigUpdateCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") + registryConfigCmd.AddCommand(registryConfigAddCmd, registryConfigUpdateCmd) + registryCmd.AddCommand(registryListCmd, registryConfigCmd) projectCmd.AddCommand(projectListCmd) userCmd.AddCommand(userInfoCmd) adminUserCmd.AddCommand(userListCmd) diff --git a/registries.go b/registries.go index 9e17f54..84889b3 100644 --- a/registries.go +++ b/registries.go @@ -90,20 +90,22 @@ func listRegistries(server string, verbose bool) error { fmt.Printf("Found %d registr%s:\n\n", len(registries), pluralize(len(registries), "y", "ies")) for _, registry := range registries { + if !verbose { + fmt.Printf("%s (%s)\n", registry.Name, registry.UUID) + continue + } fmt.Printf("UUID: %s\n", registry.UUID) fmt.Printf("Name: %s\n", registry.Name) - if verbose { - 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) - } + 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() } @@ -222,7 +224,7 @@ func submitRegistry(server string, payload map[string]interface{}, operation str return lastErr } - fmt.Printf("Registry '%s' %s submitted, waiting for completion...\n", name, operation) + fmt.Printf("Registry '%s' %s submitted, validating configuration...\n", name, operation) return pollRegistrySaveStatus(server, token.IDToken, name, operation) } From bd17d1f63d69e5ffe4b0923e8338ba9ae2f79db5 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Tue, 24 Feb 2026 10:03:21 +0000 Subject: [PATCH 4/9] updated print messages for add and update registry --- registries.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/registries.go b/registries.go index 84889b3..2701a7f 100644 --- a/registries.go +++ b/registries.go @@ -187,6 +187,8 @@ func submitRegistry(server string, payload map[string]interface{}, operation str return fmt.Errorf("failed to marshal request: %w", err) } + fmt.Println("validating configuration...") + apiURL := fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s", server, name) client := &http.Client{Timeout: 30 * time.Second} var lastErr error @@ -224,7 +226,7 @@ func submitRegistry(server string, payload map[string]interface{}, operation str return lastErr } - fmt.Printf("Registry '%s' %s submitted, validating configuration...\n", name, operation) + fmt.Println("configuration valid. validating registry content...") return pollRegistrySaveStatus(server, token.IDToken, name, operation) } @@ -283,7 +285,7 @@ func pollRegistrySaveStatus(server, idToken, registryName, operation string) err if status.Status == "done" { if status.Result != nil && status.Result.Success { - fmt.Printf("Registry '%s' %s completed successfully!\n", registryName, operation) + fmt.Println("success") return nil } else if status.Result != nil { return fmt.Errorf("registry %s failed: %s", operation, status.Result.Message) From a1290edf25c3b910688fabe262fb8ff81dc49950 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Fri, 6 Mar 2026 10:44:11 +0000 Subject: [PATCH 5/9] added registry permission list, set and delete commands --- groups.gql | 12 ++++ main.go | 139 +++++++++++++++++++++++++++++++++++- projects.go | 106 ++------------------------- registries.go | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++ user.go | 119 +++++++++++++++++++++---------- 5 files changed, 429 insertions(+), 141 deletions(-) create mode 100644 groups.gql diff --git a/groups.gql b/groups.gql new file mode 100644 index 0000000..02394aa --- /dev/null +++ b/groups.gql @@ -0,0 +1,12 @@ +query Groups($name: String = "%", $limit: Int = 100) { + groups(where: { name: { _ilike: $name } }, limit: $limit) { + name + group_id + } + products { + name + display_name + id + compute_type_name + } +} diff --git a/main.go b/main.go index c800ff2..c3e4a36 100644 --- a/main.go +++ b/main.go @@ -834,6 +834,105 @@ Use --verbose flag to display comprehensive information including: }, } +var registryPermissionCmd = &cobra.Command{ + Use: "permission", + Short: "Manage registry permissions", + Long: `Manage access permissions for a Julia package registry. + +Permissions control which users and groups can access the registry. +Supported privilege levels: + download - read-only access to download packages + register - download access plus ability to register packages + +The registry owner and admins can always manage permissions regardless of settings.`, +} + +var registryPermissionListCmd = &cobra.Command{ + Use: "list ", + Short: "List permissions for a registry", + Example: " jh registry permission list MyRegistry\n jh registry permission list MyRegistry -s custom.juliahub.com", + 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) + } + if err := listRegistryPermissions(server, args[0]); err != nil { + fmt.Printf("Failed to list permissions: %v\n", err) + os.Exit(1) + } + }, +} + +var registryPermissionSetCmd = &cobra.Command{ + Use: "set ", + Short: "Add or update a permission for a user or group", + Long: `Add or update access permission for a user or group on a registry. + +Exactly one of --user or --group must be provided. +Privilege must be 'download' or 'register'.`, + Example: " jh registry permission set MyRegistry --user alice --privilege download\n jh registry permission set MyRegistry --group devs --privilege register", + 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) + } + user, _ := cmd.Flags().GetString("user") + group, _ := cmd.Flags().GetString("group") + privilege, _ := cmd.Flags().GetString("privilege") + if user == "" && group == "" { + fmt.Println("Error: one of --user or --group is required") + os.Exit(1) + } + if user != "" && group != "" { + fmt.Println("Error: only one of --user or --group may be specified") + os.Exit(1) + } + if privilege == "" { + fmt.Println("Error: --privilege is required (download or register)") + os.Exit(1) + } + if err := setRegistryPermission(server, args[0], user, group, privilege); err != nil { + fmt.Printf("Failed to set permission: %v\n", err) + os.Exit(1) + } + }, +} + +var registryPermissionRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a permission for a user or group", + Long: `Remove access permission for a user or group from a registry. + +Exactly one of --user or --group must be provided.`, + Example: " jh registry permission remove MyRegistry --user alice\n jh registry permission remove MyRegistry --group devs", + 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) + } + user, _ := cmd.Flags().GetString("user") + group, _ := cmd.Flags().GetString("group") + if user == "" && group == "" { + fmt.Println("Error: one of --user or --group is required") + os.Exit(1) + } + if user != "" && group != "" { + fmt.Println("Error: only one of --user or --group may be specified") + os.Exit(1) + } + if err := removeRegistryPermission(server, args[0], user, group); err != nil { + fmt.Printf("Failed to remove permission: %v\n", err) + os.Exit(1) + } + }, +} + var projectCmd = &cobra.Command{ Use: "project", Short: "Project management commands", @@ -1299,6 +1398,31 @@ Provides commands to list and manage API tokens across the JuliaHub instance. Note: These commands require appropriate administrative permissions.`, } +var adminGroupCmd = &cobra.Command{ + Use: "group", + Short: "Group management commands", + Long: `Administrative commands for managing groups on JuliaHub. + +Provides commands to list and manage groups across the JuliaHub instance.`, +} + +var groupListCmd = &cobra.Command{ + Use: "list", + Short: "List all groups", + Example: " jh admin group list\n jh admin group list -s custom.juliahub.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 := listGroups(server); err != nil { + fmt.Printf("Failed to list groups: %v\n", err) + os.Exit(1) + } + }, +} + var tokenListCmd = &cobra.Command{ Use: "list", Short: "List all tokens", @@ -1383,12 +1507,23 @@ func init() { registryConfigUpdateCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") registryConfigUpdateCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") registryConfigCmd.AddCommand(registryConfigAddCmd, registryConfigUpdateCmd) - registryCmd.AddCommand(registryListCmd, registryConfigCmd) + registryPermissionListCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryPermissionSetCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryPermissionSetCmd.Flags().String("user", "", "Username to set permission for") + registryPermissionSetCmd.Flags().String("group", "", "Group name to set permission for") + registryPermissionSetCmd.Flags().String("privilege", "", "Privilege level: download or register") + registryPermissionRemoveCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryPermissionRemoveCmd.Flags().String("user", "", "Username to remove permission for") + registryPermissionRemoveCmd.Flags().String("group", "", "Group name to remove permission for") + registryPermissionCmd.AddCommand(registryPermissionListCmd, registryPermissionSetCmd, registryPermissionRemoveCmd) + registryCmd.AddCommand(registryListCmd, registryConfigCmd, registryPermissionCmd) projectCmd.AddCommand(projectListCmd) userCmd.AddCommand(userInfoCmd) + groupListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + adminGroupCmd.AddCommand(groupListCmd) adminUserCmd.AddCommand(userListCmd) adminTokenCmd.AddCommand(tokenListCmd) - adminCmd.AddCommand(adminUserCmd, adminTokenCmd) + adminCmd.AddCommand(adminUserCmd, adminTokenCmd, adminGroupCmd) juliaCmd.AddCommand(juliaInstallCmd) runCmd.AddCommand(runSetupCmd) gitCredentialCmd.AddCommand(gitCredentialHelperCmd, gitCredentialGetCmd, gitCredentialStoreCmd, gitCredentialEraseCmd, gitCredentialSetupCmd) diff --git a/projects.go b/projects.go index 13fbcd6..e73c6a0 100644 --- a/projects.go +++ b/projects.go @@ -2,6 +2,7 @@ package main import ( "bytes" + _ "embed" "encoding/json" "fmt" "io" @@ -10,6 +11,9 @@ import ( "time" ) +//go:embed projects.gql +var projectsQuery string + type Project struct { ID string `json:"id"` ProjectID string `json:"project_id"` @@ -128,107 +132,7 @@ func listProjects(server string, userFilter string, userFilterProvided bool) err return fmt.Errorf("failed to get user info: %w", err) } - // Read the GraphQL query from projects.gql - query := `query Projects( - $limit: Int - $offset: Int - $orderBy: [projects_order_by!] - $ownerId: bigint - $filter: projects_bool_exp - ) { - aggregate: projects_aggregate(where: $filter) { - aggregate { - count - } - } - projects(limit: $limit, offset: $offset, order_by: $orderBy, where: $filter) { - id: project_id - project_id - name - owner { - username - name - } - created_at - product_id - finished - is_archived - instance_default_role - deployable - project_deployments_aggregate { - aggregate { - count - } - } - running_deployments: project_deployments_aggregate( - where: { - status: { _eq: "JobQueued" } - job: { status: { _eq: "Running" } } - } - ) { - aggregate { - count - } - } - pending_deployments: project_deployments_aggregate( - where: { - status: { _eq: "JobQueued" } - job: { status: { _in: ["SubmitInitialized", "Submitted", "Pending"] } } - } - ) { - aggregate { - count - } - } - resources(order_by: [{ sorting_order: asc_nulls_last }]) { - sorting_order - instance_default_role - giturl - name - resource_id - resource_type - } - product { - id - displayName: display_name - name - } - visibility - description - users: groups(where: { group_id: { _is_null: true } }) { - user { - name - } - id - assigned_role - } - groups(where: { group_id: { _is_null: false } }) { - group { - name - group_id - } - id: group_id - group_id - project_id - assigned_role - } - tags - userRole: access_control_users_aggregate( - where: { user_id: { _eq: $ownerId } } - ) { - aggregate { - max { - assigned_role - } - } - } - is_simple_mode - projects_current_editor_user_id { - name - id - } - } - }` + query := projectsQuery // Create GraphQL request graphqlReq := GraphQLRequest{ diff --git a/registries.go b/registries.go index 2701a7f..279d85d 100644 --- a/registries.go +++ b/registries.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "strings" "time" ) @@ -246,6 +247,199 @@ type saveStatusResponse struct { } `json:"result"` } +// RegistryPermission represents a single user or group permission entry for a registry. +type RegistryPermission struct { + User *string `json:"user"` + Realm *string `json:"realm"` + Group *string `json:"group"` + Privilege string `json:"privilege"` +} + +func resolveRegistryUUID(server, idToken, nameOrUUID string) (string, error) { + body, err := apiGet(fmt.Sprintf("https://%s/api/v1/registry/registries/descriptions", server), idToken) + if err != nil { + return "", err + } + var registries []Registry + if err := json.Unmarshal(body, ®istries); err != nil { + return "", fmt.Errorf("failed to parse registries: %w", err) + } + for _, r := range registries { + if r.UUID == nameOrUUID || r.Name == nameOrUUID { + return r.UUID, nil + } + } + return "", fmt.Errorf("registry %q not found", nameOrUUID) +} + +func getRegistryPermissions(server, idToken, uuid string) ([]RegistryPermission, error) { + body, err := apiGet(fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s/sharing", server, uuid), idToken) + if err != nil { + return nil, err + } + var perms []RegistryPermission + if err := json.Unmarshal(body, &perms); err != nil { + return nil, fmt.Errorf("failed to parse permissions: %w", err) + } + return perms, nil +} + +func putRegistryPermissions(server, idToken, uuid string, perms []RegistryPermission) error { + data, err := json.Marshal(perms) + if err != nil { + return fmt.Errorf("failed to marshal permissions: %w", err) + } + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s/sharing", server, uuid), bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", idToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) + } + return nil +} + +func listRegistryPermissions(server, name string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + uuid, err := resolveRegistryUUID(server, token.IDToken, name) + if err != nil { + return err + } + perms, err := getRegistryPermissions(server, token.IDToken, uuid) + if err != nil { + return err + } + if len(perms) == 0 { + fmt.Println("No permissions set (registry is accessible to all users)") + return nil + } + fmt.Printf("%-30s %-8s %s\n", "User/Group", "Type", "Privilege") + fmt.Printf("%s\n", strings.Repeat("-", 52)) + for _, p := range perms { + subject, kind := "", "" + if p.User != nil { + subject, kind = *p.User, "user" + } else if p.Group != nil { + subject, kind = *p.Group, "group" + } + fmt.Printf("%-30s %-8s %s\n", subject, kind, p.Privilege) + } + return nil +} + +func setRegistryPermission(server, name, user, group, privilege string) error { + if privilege != "download" && privilege != "register" { + return fmt.Errorf("privilege must be 'download' or 'register'") + } + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + uuid, err := resolveRegistryUUID(server, token.IDToken, name) + if err != nil { + return err + } + perms, err := getRegistryPermissions(server, token.IDToken, uuid) + if err != nil { + return err + } + found := false + for i, p := range perms { + if user != "" && p.User != nil && *p.User == user { + perms[i].Privilege = privilege + found = true + break + } + if group != "" && p.Group != nil && *p.Group == group { + perms[i].Privilege = privilege + found = true + break + } + } + if !found { + newPerm := RegistryPermission{Privilege: privilege} + if user != "" { + newPerm.User = &user + } else { + realm := "site" + newPerm.Group = &group + newPerm.Realm = &realm + } + perms = append(perms, newPerm) + } + if err := putRegistryPermissions(server, token.IDToken, uuid, perms); err != nil { + return err + } + subject := user + if group != "" { + subject = group + } + action := "updated" + if !found { + action = "added" + } + fmt.Printf("Permission %s: %s now has '%s' access\n", action, subject, privilege) + return nil +} + +func removeRegistryPermission(server, name, user, group string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + uuid, err := resolveRegistryUUID(server, token.IDToken, name) + if err != nil { + return err + } + perms, err := getRegistryPermissions(server, token.IDToken, uuid) + if err != nil { + return err + } + original := len(perms) + filtered := perms[:0] + for _, p := range perms { + keep := true + if user != "" && p.User != nil && *p.User == user { + keep = false + } + if group != "" && p.Group != nil && *p.Group == group { + keep = false + } + if keep { + filtered = append(filtered, p) + } + } + if len(filtered) == original { + subject := user + if group != "" { + subject = group + } + return fmt.Errorf("%q has no permission on this registry", subject) + } + if err := putRegistryPermissions(server, token.IDToken, uuid, filtered); err != nil { + return err + } + subject := user + if group != "" { + subject = group + } + fmt.Printf("Permission removed: %s\n", subject) + return nil +} + func pollRegistrySaveStatus(server, idToken, registryName, operation string) error { apiURL := fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s/savestatus", server, registryName) client := &http.Client{Timeout: 30 * time.Second} diff --git a/user.go b/user.go index 03b2e23..cb1200a 100644 --- a/user.go +++ b/user.go @@ -2,6 +2,7 @@ package main import ( "bytes" + _ "embed" "encoding/json" "fmt" "io" @@ -9,6 +10,12 @@ import ( "time" ) +//go:embed userinfo.gql +var userinfoQuery string + +//go:embed groups.gql +var groupsQuery string + type UserInfo struct { ID int64 `json:"id"` Name string `json:"name"` @@ -66,34 +73,7 @@ func getUserInfo(server string) (*UserInfo, error) { return nil, fmt.Errorf("authentication required: %w", err) } - // GraphQL query from userinfo.gql - query := `query UserInfo { - users(limit: 1) { - id - name - firstname - emails { - email - } - groups: user_groups { - id: group_id - group { - name - group_id - } - } - username - roles { - role { - description - id - name - } - } - accepted_tos - survey_submitted_time - } -}` + query := userinfoQuery // Create GraphQL request graphqlReq := UserInfoRequest{ @@ -213,6 +193,74 @@ type ManageUsersResponse struct { Features json.RawMessage `json:"features"` } +type GroupsGQLResponse struct { + Data struct { + Groups []struct { + Name string `json:"name"` + GroupID int64 `json:"group_id"` + } `json:"groups"` + Products []struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + ID int64 `json:"id"` + ComputeTypeName string `json:"compute_type_name"` + } `json:"products"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +func listGroups(server string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + gqlReq := UserInfoRequest{ + OperationName: "Groups", + Query: groupsQuery, + Variables: map[string]interface{}{"limit": 500}, + } + jsonData, err := json.Marshal(gqlReq) + if err != nil { + return fmt.Errorf("failed to marshal groups request: %w", err) + } + req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/v1/graphql", server), 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") + req.Header.Set("X-Juliahub-Ensure-JS", "true") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch groups: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var groupsResp GroupsGQLResponse + if err := json.Unmarshal(body, &groupsResp); err != nil { + return fmt.Errorf("failed to parse groups: %w", err) + } + if len(groupsResp.Errors) > 0 { + return fmt.Errorf("GraphQL errors: %v", groupsResp.Errors) + } + if len(groupsResp.Data.Groups) == 0 { + fmt.Println("No groups found") + return nil + } + for _, g := range groupsResp.Data.Groups { + fmt.Println(g.Name) + } + return nil +} + func listUsers(server string, verbose bool) error { token, err := ensureValidToken() if err != nil { @@ -260,10 +308,8 @@ func listUsers(server string, verbose bool) error { } } - // Display users - fmt.Printf("Users (%d total):\n\n", len(response.Users)) - if verbose { + fmt.Printf("Users (%d total):\n\n", len(response.Users)) // Verbose mode: show all details for _, user := range response.Users { fmt.Printf("UUID: %s\n", user.UUID) @@ -283,15 +329,12 @@ func listUsers(server string, verbose bool) error { fmt.Println() } } else { - // Default mode: show only Name and Email for _, user := range response.Users { - if user.Name != nil { - fmt.Printf("Name: %s\n", *user.Name) - } else { - fmt.Printf("Name: (not set)\n") + name := user.Email + if user.Name != nil && *user.Name != "" { + name = *user.Name } - fmt.Printf("Email: %s\n", user.Email) - fmt.Println() + fmt.Printf("%s (%s)\n", name, user.Email) } } From 9076a9d2b454ff6804f6f00e626994a603d749c4 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Fri, 6 Mar 2026 11:13:21 +0000 Subject: [PATCH 6/9] separate commands for admin user and group list and public user and group list --- CLAUDE.md | 23 +++++++---- README.md | 32 +++++++++++++-- main.go | 46 +++++++++++++++++++++- user.go | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ users.gql | 12 ++++++ 5 files changed, 215 insertions(+), 13 deletions(-) create mode 100644 users.gql diff --git a/CLAUDE.md b/CLAUDE.md index f13e074..1fe18d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,9 +31,9 @@ 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`, `/api/v1/registry/config/registry/{name}`), token management (`/app/token/activelist`) and user management (`/app/config/features/manage`) - - **GraphQL API**: Used for projects and user info (`/v1/graphql`) - - **Headers**: All GraphQL requests require `X-Hasura-Role: jhuser` header + - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), and admin group management (`/app/config/groups`) + - **GraphQL API**: Used for projects, user info, user list (`public_users`), and group list (`/v1/graphql`) + - **Headers**: All GraphQL requests require `Authorization: Bearer `, `X-Hasura-Role: jhuser`, and `X-Juliahub-Ensure-JS: true` - **Authentication**: Uses ID tokens (`token.IDToken`) for API calls 3. **Command Structure**: @@ -41,11 +41,14 @@ 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, config — all via REST API) - `jh registry config`: Show registry JSON config by name; subcommands add/update accept JSON via stdin or `--file` + - `jh registry permission`: Registry permission management (list, set, remove) - `jh project`: Project management (list with GraphQL, supports user filtering) - - `jh user`: User information (info with GraphQL) - - `jh admin`: Administrative commands (user management, token management) + - `jh user`: User information (info, list via GraphQL `public_users`) + - `jh group`: Group information (list via GraphQL) + - `jh admin`: Administrative commands (user management, token management, group management) - `jh admin user`: User management (list all users with REST API, supports verbose mode) - `jh admin token`: Token management (list all tokens with REST API, supports verbose mode) + - `jh admin group`: Group management (list all groups via REST API) - `jh clone`: Git clone with JuliaHub authentication and project name resolution - `jh push/fetch/pull`: Git operations with JuliaHub authentication - `jh git-credential`: Git credential helper for seamless authentication @@ -125,8 +128,11 @@ go run . project list go run . project list --user go run . project list --user john go run . user info +go run . user list +go run . group list go run . admin user list go run . admin user list --verbose +go run . admin group list ``` ### Test token operations @@ -308,7 +314,7 @@ jh run setup ## Development Notes - All ID fields in GraphQL responses should be typed correctly (string for UUIDs, int64 for user IDs) -- GraphQL queries are embedded as strings (consider external .gql files for complex queries) +- GraphQL queries are embedded at compile time using `go:embed` from `.gql` files (`userinfo.gql`, `users.gql`, `groups.gql`, `projects.gql`) - Error handling includes both HTTP and GraphQL error responses - Token refresh is automatic via `ensureValidToken()` - File uploads use multipart form data with proper content types @@ -324,8 +330,11 @@ jh run setup - Clone command supports `project` (without username) and defaults to the logged-in user's username - 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 +- `jh user list` uses GraphQL `public_users` query (via `users.gql`) and displays ` ()` per line +- `jh group list` uses GraphQL groups query (via `groups.gql`) and displays one group name per line - Admin user list command (`jh admin user list`) uses REST API endpoint `/app/config/features/manage` which requires appropriate permissions -- User list output is concise by default (Name and Email only); use `--verbose` flag for detailed information (UUID, groups, features) +- Admin group list command (`jh admin group list`) uses REST API endpoint `/app/config/groups` which requires appropriate permissions +- Admin user list output is compact by default (` ()`); use `--verbose` flag for detailed information (UUID, groups, features) - Registry list output is concise by default (UUID and Name only); use `--verbose` flag for detailed information (owner, creation date, package count, description) - Registry config command (`jh registry config `) uses REST API endpoint `/api/v1/registry/config/registry/{name}` (GET) and prints the full JSON response - Registry add/update commands (`jh registry config add` / `jh registry config update`) use REST API endpoint `/api/v1/registry/config/registry/{name}` (POST); the backend creates or updates based on whether the registry already exists diff --git a/README.md b/README.md index b3e238e..1b0acb8 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ A command-line interface for interacting with JuliaHub, a platform for Julia com - **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 -- **User Management**: Display user information and view profile details -- **Administrative Commands**: Manage users, tokens, and system resources (requires admin permissions) +- **User Management**: Display user information, list users and groups +- **Administrative Commands**: Manage users, groups, tokens, and system resources (requires admin permissions) ## Installation @@ -158,6 +158,9 @@ go build -o jh . - `jh registry config ` - Show the full JSON configuration for a registry - `jh registry config add` - Add a new registry (JSON payload via stdin or `--file`) - `jh registry config update` - Update an existing registry (same JSON schema as add, same flags) +- `jh registry permission list ` - List permissions for a registry +- `jh registry permission set --user|--group --privilege download|register` - Add or update a permission +- `jh registry permission remove --user|--group ` - Remove a permission ### Project Management (`jh project`) @@ -185,15 +188,23 @@ go build -o jh . ### User Information (`jh user`) -- `jh user info` - Show detailed user information +- `jh user info` - Show detailed information about the logged-in user +- `jh user list` - List all users (` ()` format, via GraphQL) + +### Group Information (`jh group`) + +- `jh group list` - List all groups (one per line, via GraphQL) ### Administrative Commands (`jh admin`) #### User Management - `jh admin user list` - List all users (requires appropriate permissions) - - Default: Shows only Name and Email + - Default: Shows ` ()` per line - `jh admin user list --verbose` - Show detailed user information including UUID, groups, and features +#### Group Management +- `jh admin group list` - List all groups via REST API (requires appropriate permissions) + #### Token Management - `jh admin token list` - List all tokens (requires appropriate permissions) - Default: Shows only Subject, Created By, and Expired status @@ -289,6 +300,16 @@ jh project list --user jh project list --user alice ``` +### User and Group Operations + +```bash +# List users (GraphQL) +jh user list + +# List groups (GraphQL) +jh group list +``` + ### Administrative Operations ```bash @@ -298,6 +319,9 @@ jh admin user list # List users with detailed information jh admin user list --verbose +# List all groups via REST (requires admin permissions) +jh admin group list + # List all tokens (requires admin permissions) jh admin token list diff --git a/main.go b/main.go index c3e4a36..e00d628 100644 --- a/main.go +++ b/main.go @@ -1025,6 +1025,45 @@ Shows comprehensive user information including: Uses GraphQL API to fetch detailed user information.`, } +var userListGQLCmd = &cobra.Command{ + Use: "list", + Short: "List all users", + Example: " jh user list\n jh user list -s custom.juliahub.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 := listUsersGQL(server); err != nil { + fmt.Printf("Failed to list users: %v\n", err) + os.Exit(1) + } + }, +} + +var groupCmd = &cobra.Command{ + Use: "group", + Short: "Group information commands", +} + +var groupListGQLCmd = &cobra.Command{ + Use: "list", + Short: "List all groups", + Example: " jh group list\n jh group list -s custom.juliahub.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 := listGroupsGQL(server); err != nil { + fmt.Printf("Failed to list groups: %v\n", err) + os.Exit(1) + } + }, +} + var userInfoCmd = &cobra.Command{ Use: "info", Short: "Show user information", @@ -1518,7 +1557,10 @@ func init() { registryPermissionCmd.AddCommand(registryPermissionListCmd, registryPermissionSetCmd, registryPermissionRemoveCmd) registryCmd.AddCommand(registryListCmd, registryConfigCmd, registryPermissionCmd) projectCmd.AddCommand(projectListCmd) - userCmd.AddCommand(userInfoCmd) + userListGQLCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + userCmd.AddCommand(userInfoCmd, userListGQLCmd) + groupListGQLCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + groupCmd.AddCommand(groupListGQLCmd) groupListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") adminGroupCmd.AddCommand(groupListCmd) adminUserCmd.AddCommand(userListCmd) @@ -1528,7 +1570,7 @@ func init() { runCmd.AddCommand(runSetupCmd) gitCredentialCmd.AddCommand(gitCredentialHelperCmd, gitCredentialGetCmd, gitCredentialStoreCmd, gitCredentialEraseCmd, gitCredentialSetupCmd) - rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, projectCmd, registryCmd, userCmd, adminCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) + rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, projectCmd, registryCmd, userCmd, groupCmd, adminCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) } func main() { diff --git a/user.go b/user.go index cb1200a..8568bfb 100644 --- a/user.go +++ b/user.go @@ -13,6 +13,9 @@ import ( //go:embed userinfo.gql var userinfoQuery string +//go:embed users.gql +var usersQuery string + //go:embed groups.gql var groupsQuery string @@ -211,12 +214,57 @@ type GroupsGQLResponse struct { } `json:"errors"` } +type AdminGroup struct { + Name string `json:"name"` + ID int64 `json:"id"` +} + func listGroups(server string) error { token, err := ensureValidToken() if err != nil { return fmt.Errorf("authentication required: %w", err) } + url := fmt.Sprintf("https://%s/app/config/groups", 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 fetch groups: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("request failed (status %d): %s", resp.StatusCode, string(body)) + } + + var groups []AdminGroup + if err := json.Unmarshal(body, &groups); err != nil { + return fmt.Errorf("failed to parse groups: %w", err) + } + if len(groups) == 0 { + fmt.Println("No groups found") + return nil + } + for _, g := range groups { + fmt.Println(g.Name) + } + return nil +} + +func listGroupsGQL(server string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + gqlReq := UserInfoRequest{ OperationName: "Groups", Query: groupsQuery, @@ -261,6 +309,73 @@ func listGroups(server string) error { return nil } +type UsersGQLResponse struct { + Data struct { + Users []struct { + Username string `json:"username"` + ID int64 `json:"id"` + Name *string `json:"name"` + } `json:"users"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +func listUsersGQL(server string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + gqlReq := UserInfoRequest{ + OperationName: "Users", + Query: usersQuery, + Variables: map[string]interface{}{"limit": 500}, + } + jsonData, err := json.Marshal(gqlReq) + if err != nil { + return fmt.Errorf("failed to marshal users request: %w", err) + } + req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/v1/graphql", server), 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") + req.Header.Set("X-Juliahub-Ensure-JS", "true") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch users: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var usersResp UsersGQLResponse + if err := json.Unmarshal(body, &usersResp); err != nil { + return fmt.Errorf("failed to parse users: %w", err) + } + if len(usersResp.Errors) > 0 { + return fmt.Errorf("GraphQL errors: %v", usersResp.Errors) + } + if len(usersResp.Data.Users) == 0 { + fmt.Println("No users found") + return nil + } + for _, u := range usersResp.Data.Users { + name := u.Username + if u.Name != nil && *u.Name != "" { + name = *u.Name + } + fmt.Printf("%s (%s)\n", name, u.Username) + } + return nil +} + func listUsers(server string, verbose bool) error { token, err := ensureValidToken() if err != nil { diff --git a/users.gql b/users.gql new file mode 100644 index 0000000..f10dc6f --- /dev/null +++ b/users.gql @@ -0,0 +1,12 @@ +query Users($name: String = "%", $limit: Int = 100) { + users: public_users( + limit: $limit + where: { + _or: [{ username: { _ilike: $name } }, { name: { _ilike: $name } }] + } + ) { + username + id + name + } +} From 9b3b24a5bb852c0c352fb2133a9bb7658d0a94c0 Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Fri, 6 Mar 2026 17:23:49 +0000 Subject: [PATCH 7/9] updated helptext --- main.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index e00d628..6eef3f2 100644 --- a/main.go +++ b/main.go @@ -1103,8 +1103,7 @@ Use --verbose flag to display comprehensive information including: - JuliaHub groups and site groups - Feature flags -This command uses the /app/config/features/manage endpoint which requires -appropriate permissions to view all users.`, +This command requires appropriate administrator permissions to view all users (including staged).`, Example: " jh admin user list\n jh admin user list --verbose", Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) @@ -1474,8 +1473,7 @@ Use --verbose flag to display comprehensive information including: - Expiration date (with estimate indicator) - Expiration status -This command uses the /app/token/activelist endpoint which requires -appropriate permissions to view all tokens.`, +This command requires appropriate permissions to view all tokens.`, Example: " jh admin token list\n jh admin token list --verbose", Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) From beaca901eeebf3764e02b88409de46a72e4ba4fe Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Wed, 1 Apr 2026 10:36:42 +0000 Subject: [PATCH 8/9] removed incorrect json --- main.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/main.go b/main.go index e8bf085..7e9a3cb 100644 --- a/main.go +++ b/main.go @@ -904,8 +904,6 @@ var registryConfigAddCmd = &cobra.Command{ "enabled": true, "display_apps": true, "owner": "admin", "sync_schedule": null, "download_providers": [{ "type": "cacheserver", "host": "https://pkg.juliahub.com", - "credential_key": "JC Auth Token", - "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" "credential_key": "JC Auth Token" }] }' | jh registry config add @@ -966,8 +964,6 @@ var registryConfigUpdateCmd = &cobra.Command{ "enabled": true, "display_apps": true, "owner": "admin", "sync_schedule": null, "download_providers": [{ "type": "cacheserver", "host": "https://pkg-new.juliahub.com", - "credential_key": "JC Auth Token", - "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" "credential_key": "JC Auth Token" }] }' | jh registry config update From 4ebd1083a2eb38c99ba62a8f75ebf1ff1f90b17f Mon Sep 17 00:00:00 2001 From: Neeladri Das Date: Wed, 1 Apr 2026 10:38:54 +0000 Subject: [PATCH 9/9] removed incorrect json --- main.go | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/main.go b/main.go index 7e9a3cb..e86195e 100644 --- a/main.go +++ b/main.go @@ -621,26 +621,15 @@ PROVIDER TYPES cacheserver — sync from a JuliaHub package cache: { - "type": "cacheserver", - "host": "", - "credential_key": "", - "server_type": "", - "github_credential_type": "", - "api_host": "", - "url": "", - "user_name": "" + "type": "cacheserver", + "host": "", + "credential_key": "" } bundle — local bundle (sets license_detect: false automatically): { - "type": "bundle", - "credential_key": "", - "server_type": "", - "github_credential_type": "", - "api_host": "", - "url": "", - "user_name": "", - "host": "" + "type": "bundle", + "credential_key": "" } genericserver — generic server with basic auth: