diff --git a/CLAUDE.md b/CLAUDE.md index df6956a..6d7ff64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a Go-based CLI tool for interacting with JuliaHub, a platform for Julia computing. The CLI provides commands for authentication, dataset management, registry management, project management, user information, token management, Git integration, and Julia integration. +This is a Go-based CLI tool for interacting with JuliaHub, a platform for Julia computing. The CLI provides commands for authentication, dataset management, registry management, project management, user information, token management, registry credential management, Git integration, and Julia integration. ## Architecture @@ -17,6 +17,7 @@ The application follows a command-line interface pattern using the Cobra library - **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 +- **credentials.go**: Registry credential management (list, add, update, delete) with REST API integration - **git.go**: Git integration (clone, push, fetch, pull) with JuliaHub authentication - **julia.go**: Julia installation and management - **run.go**: Julia execution with JuliaHub configuration @@ -31,7 +32,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/ui/registries/descriptions`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), and registry credential management (old API tried first, new API fallback) - **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 @@ -42,9 +43,14 @@ The application follows a command-line interface pattern using the Cobra library - `jh registry`: Registry operations (list with REST API, supports verbose mode) - `jh project`: Project management (list with GraphQL, supports user filtering) - `jh user`: User information (info with GraphQL) - - `jh admin`: Administrative commands (user management, token management) + - `jh admin`: Administrative commands (user management, token management, credential 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 credential`: Registry credential management (list, add, update, delete via REST API) + - `jh admin credential list`: List all registry credentials (tokens, SSH keys, GitHub Apps); supports verbose mode + - `jh admin credential add`: Add a credential — subcommands: `token`, `ssh`, `github-app`; accepts JSON argument or stdin + - `jh admin credential update`: Update a credential — subcommands: `token`, `ssh`, `github-app`; accepts JSON argument or stdin + - `jh admin credential delete`: Delete a credential — subcommands: `token`, `ssh`, `github-app`; takes positional identifier - `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 @@ -113,6 +119,27 @@ go run . admin token list --verbose TZ=America/New_York go run . admin token list --verbose # With specific timezone ``` +### Test credential operations +```bash +go run . admin credential list +go run . admin credential list --verbose + +# Add credentials (JSON as argument or piped via stdin) +go run . admin credential add token '{"name":"MyToken","url":"https://github.com","value":"ghp_xxxx"}' +go run . admin credential add ssh '{"host_key":"github.com ssh-ed25519 AAAA...","private_key_file":"/home/user/.ssh/id_ed25519"}' +go run . admin credential add github-app '{"app_id":"12345","url":"https://github.com/my-org","private_key_file":"app.pem"}' + +# Update credentials (partial update: only supply fields to change) +go run . admin credential update token '{"name":"MyToken","url":"https://github.com/new-org"}' +go run . admin credential update ssh '{"index":1,"private_key_file":"/home/user/.ssh/new_key"}' +go run . admin credential update github-app '{"app_id":"12345","private_key_file":"new_app.pem"}' + +# Delete credentials +go run . admin credential delete token MyToken +go run . admin credential delete ssh 1 +go run . admin credential delete github-app 12345 +``` + ### Test Git operations ```bash go run . clone john/my-project # Clone from another user @@ -187,8 +214,12 @@ The application uses OAuth2 device flow: - **Dataset operations**: Use presigned URLs for upload/download - **User management**: `/app/config/features/manage` endpoint for listing all users - **Token management**: `/app/token/activelist` endpoint for listing all API tokens +- **Registry credentials**: Old API tried first, falls back to new API on failure: + - **Old API**: `GET /app/config/credentials/info` to fetch (returns `{"success":true,"creds":{...}}`), `POST /app/config/credentials/store` to write full payload + - **New API** (fallback): `GET /app/config/credentials` to fetch (returns credentials object directly); `POST` to add tokens/apps; `PUT` to update tokens/apps or replace all SSH credentials; `DELETE /app/config/credentials` with `{tokens:[...], githubApps:[...]}` to delete - **Authentication**: Bearer token with ID token - **Upload workflow**: 3-step process (request presigned URL, upload to URL, close upload) +- **Credential write pattern**: For the old API — read-modify-write (fetch full state, apply change, post full payload). For the new API — targeted mutations; SSH operations still require read-modify-write since `sshcreds` in PUT is a full replacement ### Data Type Handling - Project/dataset IDs are UUID strings, not integers @@ -308,6 +339,13 @@ jh run setup - Token list output is concise by default (Subject, Created By, and Expired status only); use `--verbose` flag for detailed information (signature, creation date, expiration date with estimate indicator) - Token dates are formatted in human-readable format and converted to local timezone (respects system timezone or TZ environment variable) - Token expiration estimate indicator only shown when `expires_at_is_estimate` is true in API response +- Registry credential commands do not accept a `--server` flag; server is always read from `~/.juliahub` config +- Credential add/update commands accept JSON as a positional argument or from stdin (pass `-` or omit argument to read stdin) +- SSH and GitHub App private keys can be supplied inline (`private_key`, raw PEM) or via file path (`private_key_file`); both are base64-encoded into a `data:application/octet-stream;base64,...` data URL before sending +- Credential list output is concise by default; use `--verbose` to show token metadata (account login, expiry, scopes, rate limit) and SSH host keys +- SSH credentials are identified by 1-based index (from `list` output) for update and delete operations +- Existing sensitive values (token values, private keys) are omitted when re-posting unchanged credentials to avoid re-sending masked server-side values +- `rate_limit_reset` in token metadata is a Unix timestamp (int64), displayed as local time in verbose mode ## Implementation Details diff --git a/README.md b/README.md index f36e321..ff4d734 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A command-line interface for interacting with JuliaHub, a platform for Julia com - **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) +- **Administrative Commands**: Manage users, tokens, credentials, and system resources (requires admin permissions) ## Installation @@ -196,6 +196,25 @@ go build -o jh . - Default: Shows only Subject, Created By, and Expired status - `jh admin token list --verbose` - Show detailed token information including signature, creation date, expiration date (with estimate indicator) +#### Credential Management +- `jh admin credential list` - List all credentials (tokens, SSH keys, GitHub Apps) + - `jh admin credential list --verbose` - Show detailed info including token metadata (account, expiry, scopes, rate limit) and SSH host keys +- `jh admin credential add token ` - Add a token credential +- `jh admin credential add ssh ` - Add SSH key credentials +- `jh admin credential add github-app ` - Add a GitHub App credential +- `jh admin credential update token ` - Update an existing token credential +- `jh admin credential update ssh ` - Update an SSH credential by 1-based index +- `jh admin credential update github-app ` - Update an existing GitHub App credential +- `jh admin credential delete token ` - Delete a token credential +- `jh admin credential delete ssh ` - Delete an SSH credential by 1-based index +- `jh admin credential delete github-app ` - Delete a GitHub App credential + +All `add` and `update` commands accept JSON as a positional argument or from stdin: +```bash +jh admin credential add token '{"name":"MyToken","url":"https://github.com","value":"ghp_xxxx"}' +echo '{"name":"MyToken","url":"https://github.com","value":"ghp_xxxx"}' | jh admin credential add token +``` + ### Update (`jh update`) - `jh update` - Check for updates and automatically install the latest version @@ -280,6 +299,30 @@ jh admin token list -s yourinstall # Use specific timezone for date display TZ=America/New_York jh admin token list --verbose + +# List credentials +jh admin credential list +jh admin credential list --verbose + +# Add a token credential (JSON as argument or via stdin) +jh admin credential add token '{"name":"MyGHToken","url":"https://github.com","value":"ghp_xxxx"}' + +# Add SSH credentials (private key from file) +jh admin credential add ssh '{"host_key":"github.com ssh-ed25519 AAAA...","private_key_file":"/home/user/.ssh/id_ed25519"}' + +# Add a GitHub App credential +jh admin credential add github-app '{"app_id":"12345","url":"https://github.com/my-org","private_key_file":"app.pem"}' + +# Update a token's URL (partial update — only supply fields to change) +jh admin credential update token '{"name":"MyGHToken","url":"https://github.com/new-org"}' + +# Update an SSH key by index (use list to find the index) +jh admin credential update ssh '{"index":1,"private_key_file":"/home/user/.ssh/new_key"}' + +# Delete credentials +jh admin credential delete token MyGHToken +jh admin credential delete ssh 1 +jh admin credential delete github-app 12345 ``` ### Git Workflow diff --git a/credentials.go b/credentials.go new file mode 100644 index 0000000..fedc3cc --- /dev/null +++ b/credentials.go @@ -0,0 +1,831 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "text/tabwriter" + "time" +) + +// TokenMeta holds metadata about a token fetched. +type TokenMeta struct { + Login string `json:"login"` + Expires string `json:"expires"` + Scopes string `json:"scopes"` + RateLimitRemaining int `json:"rate_limit_remaining"` + RateLimitMax int `json:"rate_limit_max"` + RateLimitReset int64 `json:"rate_limit_reset"` +} + +// TokenMetadata wraps the metadata response for a single token. +type TokenMetadata struct { + Success bool `json:"success"` + Data *TokenMeta `json:"data,omitempty"` + Message string `json:"message,omitempty"` +} + +// CredToken represents a token credential +type CredToken struct { + ID string `json:"id"` + URLPrefix string `json:"urlprefix"` + Value string `json:"value,omitempty"` + Metadata *TokenMetadata `json:"metadata,omitempty"` +} + +// CredSSH represents an SSH credential. +type CredSSH struct { + KnownHost string `json:"known_host"` + PrivateKey string `json:"private_key,omitempty"` +} + +// CredGitHubApp represents a GitHub App credential +type CredGitHubApp struct { + ID string `json:"id"` + URLPrefix string `json:"urlprefix"` +} + +// Credentials is the full credentials payload returned by the GET endpoint. +type Credentials struct { + Tokens map[string]CredToken `json:"tokens"` + SSHCreds []CredSSH `json:"sshcreds"` + GitHubApps map[string]CredGitHubApp `json:"githubApps"` +} + +// CredentialsInfoResponse is the top-level response from the GET credentials endpoint. +type CredentialsInfoResponse struct { + Success bool `json:"success"` + Creds Credentials `json:"creds"` + Message string `json:"message,omitempty"` +} + +// StoreToken is the token format expected by POST /app/config/credentials/store (old API). +type StoreToken struct { + ID string `json:"id"` + URLPrefix string `json:"urlprefix"` + Value string `json:"value,omitempty"` +} + +// StoreGitHubApp is the GitHub App format expected by the old store endpoint. +type StoreGitHubApp struct { + ID string `json:"id"` + URLPrefix string `json:"urlprefix"` + PrivateKey string `json:"privateKey,omitempty"` +} + +// StoreCredentials is the payload sent to POST /app/config/credentials/store (old API). +type StoreCredentials struct { + Tokens map[string]StoreToken `json:"tokens"` + SSHCreds []CredSSH `json:"sshcreds"` + GitHubApps map[string]StoreGitHubApp `json:"githubApps"` +} + +// CredentialsStoreResponse is the response from credential write endpoints. +type CredentialsStoreResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +// tryOldThenNew tries the old API first; if it fails, tries the new API. +func tryOldThenNew(oldFn, newFn func() error) error { + if err := oldFn(); err != nil { + return newFn() + } + return nil +} + +// newTokenUpsert is the per-token value in POST/PUT /app/config/credentials. +type newTokenUpsert struct { + ID string `json:"id"` + URLPrefix string `json:"urlprefix"` + Value string `json:"value,omitempty"` +} + +// newGitHubAppUpsert is the per-app value in POST/PUT /app/config/credentials. +type newGitHubAppUpsert struct { + ID string `json:"id"` + URLPrefix string `json:"urlprefix"` + PrivateKey string `json:"privateKey,omitempty"` +} + +// newAddCredentialsRequest is the body for POST /app/config/credentials. +type newAddCredentialsRequest struct { + Tokens map[string]newTokenUpsert `json:"tokens,omitempty"` + GitHubApps map[string]newGitHubAppUpsert `json:"githubApps,omitempty"` +} + +// newUpdateCredentialsRequest is the body for PUT /app/config/credentials. +// sshcreds performs a full replacement of all SSH credentials. +type newUpdateCredentialsRequest struct { + Tokens map[string]newTokenUpsert `json:"tokens,omitempty"` + GitHubApps map[string]newGitHubAppUpsert `json:"githubApps,omitempty"` + SSHCreds []CredSSH `json:"sshcreds,omitempty"` +} + +// newDeleteCredentialsRequest is the body for DELETE /app/config/credentials. +type newDeleteCredentialsRequest struct { + Tokens []string `json:"tokens,omitempty"` + GitHubApps []string `json:"githubApps,omitempty"` +} + +// API paths for credentials endpoints. +const ( + credentialsPath = "/app/config/credentials" // new API (v26.2.0+) + credentialsInfoPath = "/app/config/credentials/info" // old API GET + credentialsStorePath = "/app/config/credentials/store" // old API POST +) + +// doFetchCredentials performs a GET against path and decodes the credentials response. +func doFetchCredentials(server, path string) (*Credentials, error) { + token, err := ensureValidToken() + if err != nil { + return nil, fmt.Errorf("authentication required: %w", err) + } + req, err := http.NewRequest("GET", "https://"+server+path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token.IDToken) + req.Header.Set("Accept", "application/json") + + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request failed (status %d): %s", resp.StatusCode, string(body)) + } + var response CredentialsInfoResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + if !response.Success { + return nil, fmt.Errorf("API request failed: %s", response.Message) + } + return &response.Creds, nil +} + +// doFetchCredentialsDirect fetches credentials from the new API, which returns +// the Credentials object directly without a success/creds wrapper. +func doFetchCredentialsDirect(server string) (*Credentials, error) { + token, err := ensureValidToken() + if err != nil { + return nil, fmt.Errorf("authentication required: %w", err) + } + req, err := http.NewRequest("GET", "https://"+server+credentialsPath, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token.IDToken) + req.Header.Set("Accept", "application/json") + + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request failed (status %d): %s", resp.StatusCode, string(body)) + } + var creds Credentials + if err := json.Unmarshal(body, &creds); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &creds, nil +} + +// doWriteCredentials marshals payload and sends it with the given method to path. +func doWriteCredentials(server, method, path string, payload interface{}) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + reqBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + req, err := http.NewRequest(method, "https://"+server+path, bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token.IDToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("request failed (status %d): %s", resp.StatusCode, string(respBody)) + } + var response CredentialsStoreResponse + if err := json.Unmarshal(respBody, &response); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + if !response.Success { + return fmt.Errorf("API request failed: %s", response.Message) + } + return nil +} + +// fetchCredentials fetches credentials, trying the old API first and falling +// back to the new API on failure. +func fetchCredentials(server string) (*Credentials, error) { + creds, err := doFetchCredentials(server, credentialsInfoPath) + if err != nil { + return doFetchCredentialsDirect(server) + } + return creds, nil +} + +// buildStoreCredentials converts a Credentials (GET response) into a +// StoreCredentials payload for the old API, omitting existing token/key values +// so masked server-side values are not re-sent. +func buildStoreCredentials(creds *Credentials) *StoreCredentials { + store := &StoreCredentials{ + Tokens: make(map[string]StoreToken), + SSHCreds: make([]CredSSH, 0), + GitHubApps: make(map[string]StoreGitHubApp), + } + for name, tok := range creds.Tokens { + store.Tokens[name] = StoreToken{ID: name, URLPrefix: tok.URLPrefix} + } + for _, ssh := range creds.SSHCreds { + store.SSHCreds = append(store.SSHCreds, CredSSH{KnownHost: ssh.KnownHost}) + } + for id, app := range creds.GitHubApps { + store.GitHubApps[id] = StoreGitHubApp{ID: id, URLPrefix: app.URLPrefix} + } + return store +} + +// sshHostList returns the current SSH credentials with private keys stripped. +// Used as the starting point for full-replacement PUT requests in the new API. +func sshHostList(creds *Credentials) []CredSSH { + list := make([]CredSSH, len(creds.SSHCreds)) + for i, s := range creds.SSHCreds { + list[i] = CredSSH{KnownHost: s.KnownHost} + } + return list +} + +// toDataURL encodes raw bytes as a data URL. +func toDataURL(data []byte) string { + return "data:application/octet-stream;base64," + base64.StdEncoding.EncodeToString(data) +} + +// readFileAsDataURL reads a file and returns it as a data URL (base64-encoded). +func readFileAsDataURL(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read file %s: %w", path, err) + } + return toDataURL(data), nil +} + +// readJSONInput returns raw JSON from the first positional arg or from stdin. +func readJSONInput(args []string) ([]byte, error) { + if len(args) > 0 && args[0] != "-" { + return []byte(args[0]), nil + } + return io.ReadAll(os.Stdin) +} + +// resolvePrivateKey returns a data-URL encoded private key from either an +// inline PEM string or a file path. Returns "" when neither is provided. +func resolvePrivateKey(privateKey, privateKeyFile string) (string, error) { + switch { + case privateKeyFile != "": + return readFileAsDataURL(privateKeyFile) + case privateKey != "": + return toDataURL([]byte(privateKey)), nil + default: + return "", nil + } +} + +// AddTokenInput is the JSON schema accepted by "jh admin credential add token". +type AddTokenInput struct { + Name string `json:"name"` + URL string `json:"url"` + Value string `json:"value"` +} + +// AddSSHInput is the JSON schema accepted by "jh admin credential add ssh". +type AddSSHInput struct { + HostKey string `json:"host_key"` + PrivateKey string `json:"private_key"` + PrivateKeyFile string `json:"private_key_file"` +} + +// AddGitHubAppInput is the JSON schema accepted by "jh admin credential add github-app". +type AddGitHubAppInput struct { + AppID string `json:"app_id"` + URL string `json:"url"` + PrivateKey string `json:"private_key"` + PrivateKeyFile string `json:"private_key_file"` +} + +// UpdateTokenInput is the JSON schema accepted by "jh admin credential update token". +type UpdateTokenInput struct { + Name string `json:"name"` + URL string `json:"url"` + Value string `json:"value"` +} + +// UpdateSSHInput is the JSON schema accepted by "jh admin credential update ssh". +type UpdateSSHInput struct { + Index int `json:"index"` + HostKey string `json:"host_key"` + PrivateKey string `json:"private_key"` + PrivateKeyFile string `json:"private_key_file"` +} + +// UpdateGitHubAppInput is the JSON schema accepted by "jh admin credential update github-app". +type UpdateGitHubAppInput struct { + AppID string `json:"app_id"` + URL string `json:"url"` + PrivateKey string `json:"private_key"` + PrivateKeyFile string `json:"private_key_file"` +} + +func listCredentials(server string, verbose bool) error { + creds, err := fetchCredentials(server) + if err != nil { + return err + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + // Tokens + fmt.Printf("Tokens (%d total):\n\n", len(creds.Tokens)) + const indent = " " + if len(creds.Tokens) > 0 { + if verbose { + fmt.Fprintln(w, indent+"NAME\tURL\tVALUE\tACCOUNT\tEXPIRES\tSCOPES\tRATE LIMIT") + } else { + fmt.Fprintln(w, indent+"NAME\tURL\tVALUE") + } + for name, tok := range creds.Tokens { + if verbose { + account, expires, scopes, rateLimit := "", "", "", "" + if tok.Metadata != nil { + meta := tok.Metadata + if meta.Success && meta.Data != nil { + account = meta.Data.Login + expires = meta.Data.Expires + scopes = meta.Data.Scopes + resetTime := time.Unix(meta.Data.RateLimitReset, 0).In(time.Local) + rateLimit = fmt.Sprintf("%d/%d (resets %s)", meta.Data.RateLimitRemaining, meta.Data.RateLimitMax, resetTime.Format("2006-01-02 15:04:05 MST")) + } else if meta.Message != "" { + account = "error: " + meta.Message + } + } + fmt.Fprintf(w, indent+"%s\t%s\t%s\t%s\t%s\t%s\t%s\n", name, tok.URLPrefix, tok.Value, account, expires, scopes, rateLimit) + } else { + fmt.Fprintf(w, indent+"%s\t%s\t%s\n", name, tok.URLPrefix, tok.Value) + } + } + w.Flush() + } + + // SSH Keys + fmt.Printf("\nSSH Keys (%d total):\n\n", len(creds.SSHCreds)) + if len(creds.SSHCreds) > 0 { + if verbose { + fmt.Fprintln(w, indent+"#\tHOST KEY") + } else { + fmt.Fprintln(w, indent+"#\tHOST") + } + for i, ssh := range creds.SSHCreds { + host := ssh.KnownHost + if !verbose { + if fields := strings.Fields(host); len(fields) > 0 { + host = fields[0] + } + } + fmt.Fprintf(w, indent+"%d\t%s\n", i+1, host) + } + w.Flush() + } + + // GitHub Apps + fmt.Printf("\nGitHub Apps (%d total):\n\n", len(creds.GitHubApps)) + if len(creds.GitHubApps) > 0 { + fmt.Fprintln(w, indent+"APP ID\tURL") + for id, app := range creds.GitHubApps { + fmt.Fprintf(w, indent+"%s\t%s\n", id, app.URLPrefix) + } + w.Flush() + } + + return nil +} + +func addCredentialToken(server string, jsonData []byte) error { + var input AddTokenInput + if err := json.Unmarshal(jsonData, &input); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + if input.Name == "" { + return fmt.Errorf("missing required field: name") + } + if input.URL == "" { + return fmt.Errorf("missing required field: url") + } + if input.Value == "" { + return fmt.Errorf("missing required field: value") + } + + creds, err := fetchCredentials(server) + if err != nil { + return err + } + if _, exists := creds.Tokens[input.Name]; exists { + return fmt.Errorf("token with name %q already exists; remove it first before re-adding", input.Name) + } + + err = tryOldThenNew( + func() error { + store := buildStoreCredentials(creds) + store.Tokens[input.Name] = StoreToken{ID: input.Name, URLPrefix: input.URL, Value: input.Value} + return doWriteCredentials(server, "POST", credentialsStorePath, store) + }, + func() error { + return doWriteCredentials(server, "POST", credentialsPath, &newAddCredentialsRequest{ + Tokens: map[string]newTokenUpsert{ + input.Name: {ID: input.Name, URLPrefix: input.URL, Value: input.Value}, + }, + }) + }, + ) + if err != nil { + return err + } + fmt.Printf("Token %q added successfully\n", input.Name) + return nil +} + +func addCredentialSSH(server string, jsonData []byte) error { + var input AddSSHInput + if err := json.Unmarshal(jsonData, &input); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + if input.HostKey == "" { + return fmt.Errorf("missing required field: host_key") + } + if input.PrivateKey != "" && input.PrivateKeyFile != "" { + return fmt.Errorf("specify either private_key or private_key_file, not both") + } + + creds, err := fetchCredentials(server) + if err != nil { + return err + } + encoded, err := resolvePrivateKey(input.PrivateKey, input.PrivateKeyFile) + if err != nil { + return err + } + newSSH := CredSSH{KnownHost: input.HostKey, PrivateKey: encoded} + + err = tryOldThenNew( + func() error { + store := buildStoreCredentials(creds) + store.SSHCreds = append(store.SSHCreds, newSSH) + return doWriteCredentials(server, "POST", credentialsStorePath, store) + }, + func() error { + // SSH uses full-replacement via PUT; append new entry to existing host list. + return doWriteCredentials(server, "PUT", credentialsPath, &newUpdateCredentialsRequest{ + SSHCreds: append(sshHostList(creds), newSSH), + }) + }, + ) + if err != nil { + return err + } + fmt.Println("SSH credential added successfully") + return nil +} + +func addCredentialGitHubApp(server string, jsonData []byte) error { + var input AddGitHubAppInput + if err := json.Unmarshal(jsonData, &input); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + if input.AppID == "" { + return fmt.Errorf("missing required field: app_id") + } + if input.URL == "" { + return fmt.Errorf("missing required field: url") + } + if input.PrivateKey != "" && input.PrivateKeyFile != "" { + return fmt.Errorf("specify either private_key or private_key_file, not both") + } + + creds, err := fetchCredentials(server) + if err != nil { + return err + } + if _, exists := creds.GitHubApps[input.AppID]; exists { + return fmt.Errorf("GitHub App with ID %q already exists; remove it first before re-adding", input.AppID) + } + encoded, err := resolvePrivateKey(input.PrivateKey, input.PrivateKeyFile) + if err != nil { + return err + } + + err = tryOldThenNew( + func() error { + store := buildStoreCredentials(creds) + store.GitHubApps[input.AppID] = StoreGitHubApp{ID: input.AppID, URLPrefix: input.URL, PrivateKey: encoded} + return doWriteCredentials(server, "POST", credentialsStorePath, store) + }, + func() error { + return doWriteCredentials(server, "POST", credentialsPath, &newAddCredentialsRequest{ + GitHubApps: map[string]newGitHubAppUpsert{ + input.AppID: {ID: input.AppID, URLPrefix: input.URL, PrivateKey: encoded}, + }, + }) + }, + ) + if err != nil { + return err + } + fmt.Printf("GitHub App %q added successfully\n", input.AppID) + return nil +} + +func updateCredentialToken(server string, jsonData []byte) error { + var input UpdateTokenInput + if err := json.Unmarshal(jsonData, &input); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + if input.Name == "" { + return fmt.Errorf("missing required field: name") + } + if input.URL == "" && input.Value == "" { + return fmt.Errorf("nothing to update: provide at least one of url or value") + } + + creds, err := fetchCredentials(server) + if err != nil { + return err + } + existing, ok := creds.Tokens[input.Name] + if !ok { + return fmt.Errorf("token %q not found", input.Name) + } + + err = tryOldThenNew( + func() error { + store := buildStoreCredentials(creds) + entry := store.Tokens[input.Name] + if input.URL != "" { + entry.URLPrefix = input.URL + } + if input.Value != "" { + entry.Value = input.Value + } + store.Tokens[input.Name] = entry + return doWriteCredentials(server, "POST", credentialsStorePath, store) + }, + func() error { + urlPrefix := existing.URLPrefix + if input.URL != "" { + urlPrefix = input.URL + } + upsert := newTokenUpsert{ID: input.Name, URLPrefix: urlPrefix, Value: input.Value} + return doWriteCredentials(server, "PUT", credentialsPath, &newUpdateCredentialsRequest{ + Tokens: map[string]newTokenUpsert{input.Name: upsert}, + }) + }, + ) + if err != nil { + return err + } + fmt.Printf("Token %q updated successfully\n", input.Name) + return nil +} + +func updateCredentialSSH(server string, jsonData []byte) error { + var input UpdateSSHInput + if err := json.Unmarshal(jsonData, &input); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + if input.Index <= 0 { + return fmt.Errorf("missing or invalid required field: index (must be >= 1)") + } + if input.HostKey == "" && input.PrivateKey == "" && input.PrivateKeyFile == "" { + return fmt.Errorf("nothing to update: provide at least one of host_key, private_key, or private_key_file") + } + if input.PrivateKey != "" && input.PrivateKeyFile != "" { + return fmt.Errorf("specify either private_key or private_key_file, not both") + } + + creds, err := fetchCredentials(server) + if err != nil { + return err + } + idx := input.Index - 1 + if idx >= len(creds.SSHCreds) { + return fmt.Errorf("SSH key #%d not found (only %d exist)", input.Index, len(creds.SSHCreds)) + } + encoded, err := resolvePrivateKey(input.PrivateKey, input.PrivateKeyFile) + if err != nil { + return err + } + + err = tryOldThenNew( + func() error { + store := buildStoreCredentials(creds) + entry := store.SSHCreds[idx] + if input.HostKey != "" { + entry.KnownHost = input.HostKey + } + if encoded != "" { + entry.PrivateKey = encoded + } + store.SSHCreds[idx] = entry + return doWriteCredentials(server, "POST", credentialsStorePath, store) + }, + func() error { + updatedSSH := sshHostList(creds) + if input.HostKey != "" { + updatedSSH[idx].KnownHost = input.HostKey + } + if encoded != "" { + updatedSSH[idx].PrivateKey = encoded + } + return doWriteCredentials(server, "PUT", credentialsPath, &newUpdateCredentialsRequest{ + SSHCreds: updatedSSH, + }) + }, + ) + if err != nil { + return err + } + fmt.Printf("SSH key #%d updated successfully\n", input.Index) + return nil +} + +func updateCredentialGitHubApp(server string, jsonData []byte) error { + var input UpdateGitHubAppInput + if err := json.Unmarshal(jsonData, &input); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + if input.AppID == "" { + return fmt.Errorf("missing required field: app_id") + } + if input.URL == "" && input.PrivateKey == "" && input.PrivateKeyFile == "" { + return fmt.Errorf("nothing to update: provide at least one of url, private_key, or private_key_file") + } + if input.PrivateKey != "" && input.PrivateKeyFile != "" { + return fmt.Errorf("specify either private_key or private_key_file, not both") + } + + creds, err := fetchCredentials(server) + if err != nil { + return err + } + existing, ok := creds.GitHubApps[input.AppID] + if !ok { + return fmt.Errorf("GitHub App %q not found", input.AppID) + } + encoded, err := resolvePrivateKey(input.PrivateKey, input.PrivateKeyFile) + if err != nil { + return err + } + + err = tryOldThenNew( + func() error { + store := buildStoreCredentials(creds) + entry := store.GitHubApps[input.AppID] + if input.URL != "" { + entry.URLPrefix = input.URL + } + if encoded != "" { + entry.PrivateKey = encoded + } + store.GitHubApps[input.AppID] = entry + return doWriteCredentials(server, "POST", credentialsStorePath, store) + }, + func() error { + urlPrefix := existing.URLPrefix + if input.URL != "" { + urlPrefix = input.URL + } + upsert := newGitHubAppUpsert{ID: input.AppID, URLPrefix: urlPrefix, PrivateKey: encoded} + return doWriteCredentials(server, "PUT", credentialsPath, &newUpdateCredentialsRequest{ + GitHubApps: map[string]newGitHubAppUpsert{input.AppID: upsert}, + }) + }, + ) + if err != nil { + return err + } + fmt.Printf("GitHub App %q updated successfully\n", input.AppID) + return nil +} + +func deleteCredentialToken(server, name string) error { + creds, err := fetchCredentials(server) + if err != nil { + return err + } + if _, ok := creds.Tokens[name]; !ok { + return fmt.Errorf("token %q not found", name) + } + + err = tryOldThenNew( + func() error { + store := buildStoreCredentials(creds) + delete(store.Tokens, name) + return doWriteCredentials(server, "POST", credentialsStorePath, store) + }, + func() error { + return doWriteCredentials(server, "DELETE", credentialsPath, &newDeleteCredentialsRequest{ + Tokens: []string{name}, + }) + }, + ) + if err != nil { + return err + } + fmt.Printf("Token %q deleted successfully\n", name) + return nil +} + +func deleteCredentialSSH(server string, index int) error { + creds, err := fetchCredentials(server) + if err != nil { + return err + } + idx := index - 1 + if idx < 0 || idx >= len(creds.SSHCreds) { + return fmt.Errorf("SSH key #%d not found (only %d exist)", index, len(creds.SSHCreds)) + } + + hosts := sshHostList(creds) + updatedSSH := append(hosts[:idx], hosts[idx+1:]...) + + err = tryOldThenNew( + func() error { + store := buildStoreCredentials(creds) + store.SSHCreds = updatedSSH + return doWriteCredentials(server, "POST", credentialsStorePath, store) + }, + func() error { + return doWriteCredentials(server, "PUT", credentialsPath, &newUpdateCredentialsRequest{ + SSHCreds: updatedSSH, + }) + }, + ) + if err != nil { + return err + } + fmt.Printf("SSH key #%d deleted successfully\n", index) + return nil +} + +func deleteCredentialGitHubApp(server, appID string) error { + creds, err := fetchCredentials(server) + if err != nil { + return err + } + if _, ok := creds.GitHubApps[appID]; !ok { + return fmt.Errorf("GitHub App %q not found", appID) + } + + err = tryOldThenNew( + func() error { + store := buildStoreCredentials(creds) + delete(store.GitHubApps, appID) + return doWriteCredentials(server, "POST", credentialsStorePath, store) + }, + func() error { + return doWriteCredentials(server, "DELETE", credentialsPath, &newDeleteCredentialsRequest{ + GitHubApps: []string{appID}, + }) + }, + ) + if err != nil { + return err + } + fmt.Printf("GitHub App %q deleted successfully\n", appID) + return nil +} diff --git a/main.go b/main.go index 870b237..5ff0c9a 100644 --- a/main.go +++ b/main.go @@ -1061,6 +1061,353 @@ Provides commands to list and manage API tokens across the JuliaHub instance. Note: These commands require appropriate administrative permissions.`, } +var adminCredentialCmd = &cobra.Command{ + Use: "credential", + Short: "Credential management commands", + Long: `Administrative commands for managing credentials on JuliaHub. + +Provides commands to list and add credentials including tokens, +SSH keys, and GitHub Apps used for private package registry access. + +Note: These commands require appropriate administrative permissions.`, +} + +var credentialListCmd = &cobra.Command{ + Use: "list", + Short: "List credentials", + Long: `List all credentials configured on JuliaHub. + +Displays credentials grouped by type: Tokens, SSH Keys, and GitHub Apps. + +By default, shows Name and URL for tokens, and index number and hostname for SSH keys. +Use --verbose flag to display additional details including: +- Token account login, expiry, scopes, and rate limit info +- SSH host key strings`, + Example: " jh admin credential list\n jh admin credential 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 := listCredentials(server, verbose); err != nil { + fmt.Printf("Failed to list credentials: %v\n", err) + os.Exit(1) + } + }, +} + +var credentialAddCmd = &cobra.Command{ + Use: "add", + Short: "Add a credential", + Long: `Add a new credential to JuliaHub. + +Use one of the subcommands to add a specific credential type: + token - Add a token-based credential (e.g. GitHub PAT) + ssh - Add SSH key credentials (host key + private key) + github-app - Add a GitHub App credential`, +} + +var credentialAddTokenCmd = &cobra.Command{ + Use: "token [JSON]", + Short: "Add a token credential", + Long: `Add a token-based registry credential (e.g. a GitHub personal access token). + +Accepts a JSON object as a positional argument or from stdin (use "-" or omit +the argument to read from stdin). + +JSON fields: + name string Token name (required) + url string URL prefix this token applies to (required) + value string Token value (required)`, + Example: ` jh admin credential add token '{"name":"MyGHToken","url":"https://github.com","value":"ghp_xxxx"}' + echo '{"name":"MyGHToken","url":"https://github.com","value":"ghp_xxxx"}' | jh admin credential add token`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + + jsonData, err := readJSONInput(args) + if err != nil { + fmt.Printf("Failed to read input: %v\n", err) + os.Exit(1) + } + + if err := addCredentialToken(server, jsonData); err != nil { + fmt.Printf("Failed to add token credential: %v\n", err) + os.Exit(1) + } + }, +} + +var credentialAddSSHCmd = &cobra.Command{ + Use: "ssh [JSON]", + Short: "Add an SSH credential", + Long: `Add SSH key credential. + +Accepts a JSON object as a positional argument or from stdin (use "-" or omit +the argument to read from stdin). + +JSON fields: + host_key string SSH host key string, e.g. from ssh-keyscan (required) + private_key string Raw SSH private key content (PEM) + private_key_file string Path to SSH private key file + +Provide either private_key or private_key_file, not both.`, + Example: ` jh admin credential add ssh '{"host_key":"github.com ssh-ed25519 AAAA...","private_key_file":"/home/user/.ssh/id_ed25519"}' + jh admin credential add ssh '{"host_key":"github.com ssh-ed25519 AAAA...","private_key":"-----BEGIN OPENSSH PRIVATE KEY-----\n..."}'`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + + jsonData, err := readJSONInput(args) + if err != nil { + fmt.Printf("Failed to read input: %v\n", err) + os.Exit(1) + } + + if err := addCredentialSSH(server, jsonData); err != nil { + fmt.Printf("Failed to add SSH credential: %v\n", err) + os.Exit(1) + } + }, +} + +var credentialAddGitHubAppCmd = &cobra.Command{ + Use: "github-app [JSON]", + Short: "Add a GitHub App credential", + Long: `Add a GitHub App credential. + +Accepts a JSON object as a positional argument or from stdin (use "-" or omit +the argument to read from stdin). + +JSON fields: + app_id string GitHub App numeric ID (required) + url string URL prefix this App applies to (required) + private_key string Raw App private key content (PEM) + private_key_file string Path to GitHub App private key (.pem) file + +Provide either private_key or private_key_file, not both.`, + Example: ` jh admin credential add github-app '{"app_id":"12345","url":"https://github.com/my-org","private_key_file":"app.pem"}' + jh admin credential add github-app '{"app_id":"12345","url":"https://github.com/my-org","private_key":"-----BEGIN RSA PRIVATE KEY-----\n..."}'`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + + jsonData, err := readJSONInput(args) + if err != nil { + fmt.Printf("Failed to read input: %v\n", err) + os.Exit(1) + } + + if err := addCredentialGitHubApp(server, jsonData); err != nil { + fmt.Printf("Failed to add GitHub App credential: %v\n", err) + os.Exit(1) + } + }, +} + +var credentialUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update an existing credential", + Long: `Update an existing credential on JuliaHub. + +Use one of the subcommands to update a specific credential type: + token - Update a token credential (url and/or value) + ssh - Update an SSH credential (host key and/or private key) + github-app - Update a GitHub App credential (url and/or private key)`, +} + +var credentialUpdateTokenCmd = &cobra.Command{ + Use: "token [JSON]", + Short: "Update a token credential", + Long: `Update an existing token credential. + +Accepts a JSON object as a positional argument or from stdin. + +JSON fields: + name string Token name — identifies the token to update (required) + url string New URL prefix + value string New token value + +At least one of url or value must be provided.`, + Example: ` jh admin credential update token '{"name":"MyGHToken","url":"https://github.com/new-org"}' + jh admin credential update token '{"name":"MyGHToken","value":"ghp_newvalue"}'`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + jsonData, err := readJSONInput(args) + if err != nil { + fmt.Printf("Failed to read input: %v\n", err) + os.Exit(1) + } + if err := updateCredentialToken(server, jsonData); err != nil { + fmt.Printf("Failed to update token credential: %v\n", err) + os.Exit(1) + } + }, +} + +var credentialUpdateSSHCmd = &cobra.Command{ + Use: "ssh [JSON]", + Short: "Update an SSH credential", + Long: `Update an existing SSH credential by its 1-based index. + +Accepts a JSON object as a positional argument or from stdin. + +JSON fields: + index int 1-based position in the SSH key list (required) + host_key string New SSH host key string + private_key string New raw SSH private key content (PEM) + private_key_file string Path to new SSH private key file + +At least one of host_key, private_key, or private_key_file must be provided. +Provide either private_key or private_key_file, not both.`, + Example: ` jh admin credential update ssh '{"index":1,"host_key":"github.com ssh-ed25519 AAAA..."}' + jh admin credential update ssh '{"index":1,"private_key_file":"/home/user/.ssh/new_key"}'`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + jsonData, err := readJSONInput(args) + if err != nil { + fmt.Printf("Failed to read input: %v\n", err) + os.Exit(1) + } + if err := updateCredentialSSH(server, jsonData); err != nil { + fmt.Printf("Failed to update SSH credential: %v\n", err) + os.Exit(1) + } + }, +} + +var credentialUpdateGitHubAppCmd = &cobra.Command{ + Use: "github-app [JSON]", + Short: "Update a GitHub App credential", + Long: `Update an existing GitHub App credential. + +Accepts a JSON object as a positional argument or from stdin. + +JSON fields: + app_id string GitHub App ID — identifies the App to update (required) + url string New URL prefix + private_key string New raw App private key content (PEM) + private_key_file string Path to new GitHub App private key (.pem) file + +At least one of url, private_key, or private_key_file must be provided. +Provide either private_key or private_key_file, not both.`, + Example: ` jh admin credential update github-app '{"app_id":"12345","url":"https://github.com/new-org"}' + jh admin credential update github-app '{"app_id":"12345","private_key_file":"new_app.pem"}'`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + jsonData, err := readJSONInput(args) + if err != nil { + fmt.Printf("Failed to read input: %v\n", err) + os.Exit(1) + } + if err := updateCredentialGitHubApp(server, jsonData); err != nil { + fmt.Printf("Failed to update GitHub App credential: %v\n", err) + os.Exit(1) + } + }, +} + +var credentialDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a credential", + Long: `Delete a credential from JuliaHub. + +Use one of the subcommands to delete a specific credential type: + token - Delete a token by name + ssh - Delete an SSH key by 1-based index + github-app - Delete a GitHub App by App ID`, +} + +var credentialDeleteTokenCmd = &cobra.Command{ + Use: "token ", + Short: "Delete a token credential", + Example: " jh admin credential delete token MyGHToken", + 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 := deleteCredentialToken(server, args[0]); err != nil { + fmt.Printf("Failed to delete token credential: %v\n", err) + os.Exit(1) + } + }, +} + +var credentialDeleteSSHCmd = &cobra.Command{ + Use: "ssh ", + Short: "Delete an SSH credential by 1-based index", + Example: " jh admin credential delete ssh 1", + 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) + } + var index int + if _, err := fmt.Sscan(args[0], &index); err != nil || index <= 0 { + fmt.Printf("Invalid index %q: must be a positive integer\n", args[0]) + os.Exit(1) + } + if err := deleteCredentialSSH(server, index); err != nil { + fmt.Printf("Failed to delete SSH credential: %v\n", err) + os.Exit(1) + } + }, +} + +var credentialDeleteGitHubAppCmd = &cobra.Command{ + Use: "github-app ", + Short: "Delete a GitHub App credential by App ID", + Example: " jh admin credential delete github-app 12345", + 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 := deleteCredentialGitHubApp(server, args[0]); err != nil { + fmt.Printf("Failed to delete GitHub App credential: %v\n", err) + os.Exit(1) + } + }, +} + var tokenListCmd = &cobra.Command{ Use: "list", Short: "List all tokens", @@ -1130,6 +1477,7 @@ func init() { userListCmd.Flags().Bool("verbose", false, "Show detailed user information") tokenListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") tokenListCmd.Flags().Bool("verbose", false, "Show detailed token information") + credentialListCmd.Flags().Bool("verbose", false, "Show detailed credential 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") @@ -1144,7 +1492,11 @@ func init() { userCmd.AddCommand(userInfoCmd) adminUserCmd.AddCommand(userListCmd) adminTokenCmd.AddCommand(tokenListCmd) - adminCmd.AddCommand(adminUserCmd, adminTokenCmd) + credentialAddCmd.AddCommand(credentialAddTokenCmd, credentialAddSSHCmd, credentialAddGitHubAppCmd) + credentialUpdateCmd.AddCommand(credentialUpdateTokenCmd, credentialUpdateSSHCmd, credentialUpdateGitHubAppCmd) + credentialDeleteCmd.AddCommand(credentialDeleteTokenCmd, credentialDeleteSSHCmd, credentialDeleteGitHubAppCmd) + adminCredentialCmd.AddCommand(credentialListCmd, credentialAddCmd, credentialUpdateCmd, credentialDeleteCmd) + adminCmd.AddCommand(adminUserCmd, adminTokenCmd, adminCredentialCmd) juliaCmd.AddCommand(juliaInstallCmd) runCmd.AddCommand(runSetupCmd) gitCredentialCmd.AddCommand(gitCredentialHelperCmd, gitCredentialGetCmd, gitCredentialStoreCmd, gitCredentialEraseCmd, gitCredentialSetupCmd)