Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- **landing.go**: Landing page management (show, update, disable) 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
Expand All @@ -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 landing page management (`/app/homepage` GET, `/app/config/homepage` POST/DELETE)
- **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
Expand All @@ -42,9 +43,10 @@ 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, landing page)
- `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 landing-page`: Landing page management (show/update/disable custom markdown landing page with 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
Expand Down Expand Up @@ -113,6 +115,15 @@ go run . admin token list --verbose
TZ=America/New_York go run . admin token list --verbose # With specific timezone
```

### Test landing page operations
```bash
go run . admin landing-page show
go run . admin landing-page update '# Welcome to JuliaHub'
go run . admin landing-page update --file landing.md
cat landing.md | go run . admin landing-page update
go run . admin landing-page disable
```

### Test Git operations
```bash
go run . clone john/my-project # Clone from another user
Expand Down Expand Up @@ -308,6 +319,9 @@ jh run setup
- Token list output is concise by default (Subject, Created By, and Expired status only); use `--verbose` flag for detailed information (signature, creation date, expiration date with estimate indicator)
- Token dates are formatted in human-readable format and converted to local timezone (respects system timezone or TZ environment variable)
- Token expiration estimate indicator only shown when `expires_at_is_estimate` is true in API response
- Landing page commands (`jh admin landing-page`) use REST API: GET `/app/homepage` (show), POST `/app/config/homepage` (update), DELETE `/app/config/homepage` (disable); require appropriate permissions
- Landing page `update` command accepts content inline as an argument, from a file via `--file`, or piped via stdin (priority: `--file` > arg > stdin)
- Landing page response uses custom JSON unmarshaling (`homepageResponse`) to handle `message` being either an object or a string

## Implementation Details

Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ 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)

#### Landing Page Management
- `jh admin landing-page show` - Show the current custom landing page content (markdown and last-modified date)
- `jh admin landing-page update <markdown-content>` - Set a custom markdown landing page
- `jh admin landing-page update --file landing.md` - Read content from a file
- `cat landing.md | jh admin landing-page update` - Read content from stdin
- `jh admin landing-page disable` - Remove the custom landing page and revert to the default

### Update (`jh update`)

- `jh update` - Check for updates and automatically install the latest version
Expand Down Expand Up @@ -282,6 +289,25 @@ jh admin token list -s yourinstall
TZ=America/New_York jh admin token list --verbose
```

### Landing Page Operations

```bash
# Show current custom landing page
jh admin landing-page show

# Set landing page from inline markdown
jh admin landing-page update '# Welcome to JuliaHub'

# Set landing page from a file
jh admin landing-page update --file landing.md

# Set landing page from stdin
cat landing.md | jh admin landing-page update

# Disable custom landing page (revert to default)
jh admin landing-page disable
```

### Git Workflow

```bash
Expand Down
217 changes: 217 additions & 0 deletions landing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)

type homepageConfig struct {
Md string `json:"md"`
UpdatedAt string `json:"updated_at"`
}

// homepageResponse handles `message` being either an object or a string.
type homepageResponse struct {
Success bool `json:"success"`
Message *homepageConfig
RawMsg json.RawMessage `json:"message"`
}

func (r *homepageResponse) UnmarshalJSON(data []byte) error {
type alias struct {
Success bool `json:"success"`
Message json.RawMessage `json:"message"`
}
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
r.Success = a.Success
r.RawMsg = a.Message
if len(a.Message) > 0 && a.Message[0] == '{' {
var cfg homepageConfig
if err := json.Unmarshal(a.Message, &cfg); err != nil {
return err
}
r.Message = &cfg
}
return nil
}

func showLandingPage(server string) error {
token, err := ensureValidToken()
if err != nil {
return fmt.Errorf("authentication required — run 'jh auth login' first")
}

url := fmt.Sprintf("https://%s/app/homepage", 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("could not reach the server")
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("you do not have permission to view the landing page configuration")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("could not retrieve landing page (server returned %d)", resp.StatusCode)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("could not read response from server")
}

var result homepageResponse
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("unexpected response from server")
}

if !result.Success {
var msg string
_ = json.Unmarshal(result.RawMsg, &msg)
return fmt.Errorf("%s", msg)
}

if result.Message == nil {
fmt.Println("Currently using default landing screen content.")
return nil
}

fmt.Printf("Last updated: %s\n\n", formatTokenDate(result.Message.UpdatedAt))
fmt.Println(result.Message.Md)
return nil
}

func setLandingPage(server, content string) error {
token, err := ensureValidToken()
if err != nil {
return fmt.Errorf("authentication required — run 'jh auth login' first")
}

payload, err := json.Marshal(map[string]string{"content": content})
if err != nil {
return fmt.Errorf("could not prepare request")
}

url := fmt.Sprintf("https://%s/app/config/homepage", server)
req, err := http.NewRequest("POST", url, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("could not prepare request")
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")

client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("could not reach the server")
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("you do not have permission to update the landing page")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("could not update landing page (server returned %d)", resp.StatusCode)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("could not read response from server")
}

var result struct {
Success bool `json:"success"`
Message json.RawMessage `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil || !result.Success {
var msg string
_ = json.Unmarshal(result.Message, &msg)
return fmt.Errorf("%s", msg)
}

fmt.Println("Successfully updated the landing page.")
return nil
}

func disableLandingPage(server string) error {
token, err := ensureValidToken()
if err != nil {
return fmt.Errorf("authentication required — run 'jh auth login' first")
}

url := fmt.Sprintf("https://%s/app/config/homepage", server)
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return fmt.Errorf("could not prepare request")
}
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("could not reach the server")
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("you do not have permission to disable the landing page")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("could not disable landing page (server returned %d)", resp.StatusCode)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("could not read response from server")
}

var result struct {
Success bool `json:"success"`
Message json.RawMessage `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil || !result.Success {
var msg string
_ = json.Unmarshal(result.Message, &msg)
return fmt.Errorf("%s", msg)
}

fmt.Println("Successfully disabled the custom landing page.")
return nil
}

func readContentFromFileOrArgOrStdin(filePath, contentArg string) (string, error) {
if filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file %q: %w", filePath, err)
}
return string(data), nil
}
if contentArg != "" {
return contentArg, nil
}
// Fall back to stdin
data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("failed to read from stdin: %w", err)
}
return string(data), nil
}
Loading