diff --git a/CLAUDE.md b/CLAUDE.md index bd32ad4..6156799 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ The application follows a command-line interface pattern using the Cobra library - **auth.go**: OAuth2 device flow authentication with JWT token handling - **datasets.go**: Dataset operations (list, download, upload, status) with REST API integration - **projects.go**: Project management using GraphQL API with user filtering -- **user.go**: User information retrieval using GraphQL API +- **user.go**: User information retrieval using GraphQL API and REST API for listing users - **git.go**: Git integration (clone, push, fetch, pull) with JuliaHub authentication - **julia.go**: Julia installation and management - **run.go**: Julia execution with JuliaHub configuration @@ -29,7 +29,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 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,6 +39,8 @@ The application follows a command-line interface pattern using the Cobra library - `jh dataset`: Dataset operations (list, download, upload, status) - `jh project`: Project management (list with GraphQL, supports user filtering) - `jh user`: User information (info with GraphQL) + - `jh admin`: Administrative commands (user management) + - `jh admin user`: User management (list all users with REST API, supports verbose mode) - `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 @@ -90,6 +92,8 @@ go run . project list go run . project list --user go run . project list --user john go run . user info +go run . admin user list +go run . admin user list --verbose ``` ### Test Git operations @@ -163,6 +167,7 @@ The application uses OAuth2 device flow: ### REST API Integration - **Dataset operations**: Use presigned URLs for upload/download +- **User management**: `/app/config/features/manage` endpoint for listing all users - **Authentication**: Bearer token with ID token - **Upload workflow**: 3-step process (request presigned URL, upload to URL, close upload) @@ -273,6 +278,8 @@ 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 +- 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) ## Implementation Details diff --git a/README.md b/README.md index e896a05..9716a62 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,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 profile details +- **User Management**: Display user information and view profile details +- **Administrative Commands**: Manage users and system resources (requires admin permissions) ## Installation @@ -176,6 +177,12 @@ go build -o jh . - `jh user info` - Show detailed user information +### Administrative Commands (`jh admin`) + +- `jh admin user list` - List all users (requires appropriate permissions) + - Default: Shows only Name and Email + - `jh admin user list --verbose` - Show detailed user information including UUID, groups, and features + ### Update (`jh update`) - `jh update` - Check for updates and automatically install the latest version diff --git a/main.go b/main.go index 71b31c2..0a3413d 100644 --- a/main.go +++ b/main.go @@ -163,9 +163,10 @@ Available command categories: dataset - Dataset operations (list, download, upload, status) project - Project management (list, filter by user) user - User information and profile + admin - Administrative commands (user management) 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 @@ -669,6 +670,37 @@ Displays comprehensive information about the current user including: }, } +var userListCmd = &cobra.Command{ + Use: "list", + Short: "List all users", + Long: `List all users from JuliaHub. + +By default, displays only Name and Email for each user. +Use --verbose flag to display comprehensive information including: +- UUID and email addresses +- Names +- JuliaHub groups and site groups +- Feature flags + +This command uses the /app/config/features/manage endpoint which requires +appropriate permissions to view all users.`, + Example: " jh admin user list\n jh admin user list --verbose", + 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 := listUsers(server, verbose); err != nil { + fmt.Printf("Failed to list users: %v\n", err) + os.Exit(1) + } + }, +} + var cloneCmd = &cobra.Command{ Use: "clone [local-path]", Short: "Clone a project from JuliaHub", @@ -953,6 +985,27 @@ without needing to use the 'jh' wrapper commands.`, }, } +var adminCmd = &cobra.Command{ + Use: "admin", + Short: "Administrative commands", + Long: `Administrative commands for JuliaHub. + +These commands provide administrative functionality for managing JuliaHub +resources such as users, groups, and system configuration. + +Note: Some commands may require administrative permissions.`, +} + +var adminUserCmd = &cobra.Command{ + Use: "user", + Short: "User management commands", + Long: `Administrative commands for managing users on JuliaHub. + +Provides commands to list and manage users across the JuliaHub instance. + +Note: These commands require appropriate administrative permissions.`, +} + var updateCmd = &cobra.Command{ Use: "update", Short: "Update jh to the latest version", @@ -985,6 +1038,8 @@ func init() { 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") + userListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + userListCmd.Flags().Bool("verbose", false, "Show detailed user information") cloneCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") pushCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") fetchCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") @@ -996,11 +1051,13 @@ func init() { datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) projectCmd.AddCommand(projectListCmd) userCmd.AddCommand(userInfoCmd) + adminUserCmd.AddCommand(userListCmd) + adminCmd.AddCommand(adminUserCmd) 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, projectCmd, userCmd, adminCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) } func main() { diff --git a/user.go b/user.go index e13244f..03b2e23 100644 --- a/user.go +++ b/user.go @@ -195,3 +195,105 @@ func showUserInfo(server string) error { return nil } + +// ManageUser represents a user from the /app/config/features/manage endpoint +type ManageUser struct { + Email string `json:"email"` + Name *string `json:"name"` + UUID string `json:"uuid"` + Features json.RawMessage `json:"features"` // Will be parsed from JSON string + JuliaHubGroups string `json:"juliahub_groups"` + SiteGroups string `json:"site_groups"` + ParsedFeatures map[string]interface{} `json:"-"` // Parsed features +} + +// ManageUsersResponse represents the response from /app/config/features/manage +type ManageUsersResponse struct { + Users []ManageUser `json:"users"` + Features json.RawMessage `json:"features"` +} + +func listUsers(server string, verbose bool) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + url := fmt.Sprintf("https://%s/app/config/features/manage", server) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("request failed (status %d): %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var response ManageUsersResponse + if err := json.Unmarshal(body, &response); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + // Parse features JSON string for each user (only needed in verbose mode) + if verbose { + for i := range response.Users { + var features map[string]interface{} + if err := json.Unmarshal(response.Users[i].Features, &features); err == nil { + response.Users[i].ParsedFeatures = features + } + } + } + + // Display users + fmt.Printf("Users (%d total):\n\n", len(response.Users)) + + if verbose { + // Verbose mode: show all details + for _, user := range response.Users { + fmt.Printf("UUID: %s\n", user.UUID) + fmt.Printf("Email: %s\n", user.Email) + if user.Name != nil { + fmt.Printf("Name: %s\n", *user.Name) + } + if user.JuliaHubGroups != "" { + fmt.Printf("JuliaHub Groups: %s\n", user.JuliaHubGroups) + } + if user.SiteGroups != "" { + fmt.Printf("Site Groups: %s\n", user.SiteGroups) + } + if len(user.ParsedFeatures) > 0 { + fmt.Printf("Features: %v\n", user.ParsedFeatures) + } + 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") + } + fmt.Printf("Email: %s\n", user.Email) + fmt.Println() + } + } + + return nil +}