From cea0cb72b45c8ea1ff1603bb3c0164f876d47bda Mon Sep 17 00:00:00 2001 From: javanhut Date: Sat, 11 Oct 2025 07:48:02 -0400 Subject: [PATCH 1/6] feat: added oauth for github to flow new kw auth --- README.md | 9 + cli/auth.go | 151 ++++++++++ docs/commands/auth.md | 243 ++++++++++++++++ docs/commands/index.md | 2 + docs/guides/github-integration.md | 45 ++- internal/auth/oauth.go | 442 ++++++++++++++++++++++++++++++ internal/github/client.go | 19 +- 7 files changed, 900 insertions(+), 11 deletions(-) create mode 100644 cli/auth.go create mode 100644 docs/commands/auth.md create mode 100644 internal/auth/oauth.go diff --git a/README.md b/README.md index 96baa45..eb1888b 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,12 @@ ivaldi timeline switch main ### GitHub Integration ```bash +# Authenticate with GitHub (OAuth) +ivaldi auth login + +# Check authentication status +ivaldi auth status + # Connect to GitHub repository ivaldi portal add owner/repo @@ -252,6 +258,9 @@ ivaldi fuse --abort # Abort current merge ### Remote Operations ```bash +ivaldi auth login # Authenticate with GitHub (OAuth) +ivaldi auth status # Check authentication status +ivaldi auth logout # Log out ivaldi portal add # Add GitHub connection ivaldi portal list # List connections ivaldi download [dir] # Clone repository diff --git a/cli/auth.go b/cli/auth.go new file mode 100644 index 0000000..a58a8e5 --- /dev/null +++ b/cli/auth.go @@ -0,0 +1,151 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/javanhut/Ivaldi-vcs/internal/auth" + "github.com/spf13/cobra" +) + +// authCmd represents the auth command +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage GitHub authentication", + Long: `Authenticate with GitHub to access repositories and perform operations`, +} + +// authLoginCmd handles OAuth login +var authLoginCmd = &cobra.Command{ + Use: "login", + Short: "Authenticate with GitHub", + Long: `Start the OAuth device flow to authenticate with GitHub and obtain an access token`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + return auth.Login(ctx) + }, +} + +// authLogoutCmd handles logout +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Log out of GitHub", + Long: `Remove stored GitHub authentication credentials`, + RunE: func(cmd *cobra.Command, args []string) error { + return auth.Logout() + }, +} + +// authStatusCmd shows authentication status +var authStatusCmd = &cobra.Command{ + Use: "status", + Short: "View authentication status", + Long: `Display current GitHub authentication status and user information`, + RunE: func(cmd *cobra.Command, args []string) error { + // Check authentication method + authMethod := auth.GetAuthMethod() + + if authMethod == nil { + fmt.Println("Not authenticated with GitHub") + fmt.Println("\nTo authenticate, run:") + fmt.Println(" ivaldi auth login") + fmt.Println("\nAlternatively, you can:") + fmt.Println(" - Set GITHUB_TOKEN environment variable") + fmt.Println(" - Use 'gh auth login' (GitHub CLI)") + fmt.Println(" - Configure git credentials") + return nil + } + + // Display authentication method + fmt.Printf("%s\n", authMethod.Description) + + // Test the token by making a request to GitHub + user, err := getAuthenticatedUser(authMethod.Token) + if err != nil { + fmt.Println("\nAuthenticated, but token may be invalid") + fmt.Printf("Error: %v\n", err) + + if authMethod.Name == "ivaldi" { + fmt.Println("\nTry logging in again:") + fmt.Println(" ivaldi auth login") + } else if authMethod.Name == "gh-cli" { + fmt.Println("\nTry re-authenticating with GitHub CLI:") + fmt.Println(" gh auth login") + } else { + fmt.Println("\nCheck your authentication credentials or use:") + fmt.Println(" ivaldi auth login") + } + return nil + } + + fmt.Printf("\nLogged in to GitHub as: %s\n", user.Login) + if user.Name != "" { + fmt.Printf("Name: %s\n", user.Name) + } + if user.Email != "" { + fmt.Printf("Email: %s\n", user.Email) + } + fmt.Printf("Account type: %s\n", user.Type) + + // Show additional info based on auth method + if authMethod.Name != "ivaldi" { + fmt.Println("\nNote: You're using an external authentication method.") + fmt.Println("To use Ivaldi's built-in OAuth, run:") + fmt.Println(" ivaldi auth login") + } + + return nil + }, +} + +// GitHubUser represents a GitHub user +type GitHubUser struct { + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + Type string `json:"type"` +} + +// getAuthenticatedUser fetches the authenticated user's information +func getAuthenticatedUser(token string) (*GitHubUser, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user", nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var user GitHubUser + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, err + } + + return &user, nil +} + +func init() { + rootCmd.AddCommand(authCmd) + authCmd.AddCommand(authLoginCmd) + authCmd.AddCommand(authLogoutCmd) + authCmd.AddCommand(authStatusCmd) +} diff --git a/docs/commands/auth.md b/docs/commands/auth.md new file mode 100644 index 0000000..49c7a32 --- /dev/null +++ b/docs/commands/auth.md @@ -0,0 +1,243 @@ +# Auth Command + +Manage GitHub authentication for Ivaldi VCS using OAuth. + +## Overview + +The `auth` command provides a secure way to authenticate with GitHub using OAuth device flow. This eliminates the need to manually create and manage personal access tokens. + +## Subcommands + +### auth login + +Authenticate with GitHub using OAuth device flow. + +```bash +ivaldi auth login +``` + +This command will: +1. Generate a unique user code +2. Display the GitHub verification URL +3. Wait for you to authorize the application in your browser +4. Store the OAuth token securely in `~/.config/ivaldi/auth.json` + +**Example:** + +```bash +$ ivaldi auth login +Initiating GitHub authentication... + +First, copy your one-time code: ABCD-1234 +Then visit: https://github.com/login/device + +Waiting for authentication... + +Authentication successful! +``` + +Once authenticated, you can immediately use GitHub-related commands like `download`, `upload`, `scout`, and `harvest` without any additional configuration. + +### auth status + +Check your current authentication status and view user information. + +```bash +ivaldi auth status +``` + +This command will: +- Verify if you're authenticated +- Show which authentication method is being used +- Display your GitHub username +- Show your account information +- Validate that the token is still valid + +**Example when authenticated via Ivaldi OAuth:** + +```bash +$ ivaldi auth status +Authenticated via 'ivaldi auth login' + +Logged in to GitHub as: javanhut +Name: John Doe +Email: john@example.com +Account type: User +``` + +**Example when authenticated via GitHub CLI:** + +```bash +$ ivaldi auth status +Authenticated via 'gh auth login' (GitHub CLI) + +Logged in to GitHub as: javanhut +Name: John Doe +Email: john@example.com +Account type: User + +Note: You're using an external authentication method. +To use Ivaldi's built-in OAuth, run: + ivaldi auth login +``` + +**Example when authenticated via environment variable:** + +```bash +$ ivaldi auth status +Authenticated via GITHUB_TOKEN environment variable + +Logged in to GitHub as: javanhut +Name: John Doe +Email: john@example.com +Account type: User + +Note: You're using an external authentication method. +To use Ivaldi's built-in OAuth, run: + ivaldi auth login +``` + +**Example when not authenticated:** + +```bash +$ ivaldi auth status +Not authenticated with GitHub + +To authenticate, run: + ivaldi auth login + +Alternatively, you can: + - Set GITHUB_TOKEN environment variable + - Use 'gh auth login' (GitHub CLI) + - Configure git credentials +``` + +### auth logout + +Remove stored authentication credentials. + +```bash +ivaldi auth logout +``` + +This command will: +- Delete the OAuth token from `~/.config/ivaldi/auth.json` +- Require re-authentication for future GitHub operations + +**Example:** + +```bash +$ ivaldi auth logout +Logged out successfully +``` + +## Authentication Priority + +Ivaldi checks for GitHub credentials in the following order: + +1. **Ivaldi OAuth token** (from `ivaldi auth login`) - **Highest priority** +2. `GITHUB_TOKEN` environment variable +3. Git config (`github.token`) +4. Git credential helper +5. `.netrc` file +6. GitHub CLI (`gh`) config + +This means if you authenticate using `ivaldi auth login`, that token will be used even if other methods are configured. + +### Checking Your Authentication Source + +The `ivaldi auth status` command will tell you exactly which authentication method is currently active: + +| Auth Method | Status Message | +|-------------|----------------| +| Ivaldi OAuth | `Authenticated via 'ivaldi auth login'` | +| GitHub CLI | `Authenticated via 'gh auth login' (GitHub CLI)` | +| Environment Variable | `Authenticated via GITHUB_TOKEN environment variable` | +| Git Config | `Authenticated via git config (github.token)` | +| Git Credential Helper | `Authenticated via git credential helper` | +| .netrc File | `Authenticated via .netrc file` | + +This helps you understand which credentials Ivaldi is using and troubleshoot authentication issues. + +## Security + +- OAuth tokens are stored with restricted permissions (0600) in `~/.config/ivaldi/auth.json` +- Only you (the file owner) can read the token file +- Tokens are requested with minimal required scopes: `repo`, `read:user`, `user:email` +- You can revoke access at any time through GitHub settings or by running `ivaldi auth logout` + +## Token Scopes + +The OAuth token requests the following scopes: + +- **repo**: Full control of private repositories (required for clone, push, pull operations) +- **read:user**: Read user profile information +- **user:email**: Read user email addresses + +## Troubleshooting + +### Token expired or invalid + +If you see authentication errors: + +```bash +ivaldi auth status +``` + +If the token is invalid, re-authenticate: + +```bash +ivaldi auth logout +ivaldi auth login +``` + +### Permission denied errors + +If you get permission errors when accessing repositories: +1. Ensure the repository exists and you have access +2. Check your authentication: `ivaldi auth status` +3. Try re-authenticating: `ivaldi auth login` + +### Browser not available + +The OAuth device flow works well for: +- Headless servers +- Remote SSH sessions +- Containerized environments + +You can copy the verification URL and code to any device with a browser, authenticate there, and the CLI will automatically receive the token. + +## Comparison with Other Methods + +### vs. Personal Access Token (PAT) + +**OAuth (ivaldi auth login):** +- Automatic token management +- No manual token creation +- Easy revocation through logout +- Better security (shorter-lived tokens) + +**Personal Access Token:** +- Manual creation through GitHub settings +- Must be copied and stored manually +- Requires setting environment variable or git config +- Tokens don't expire automatically + +### vs. GitHub CLI (gh) + +Ivaldi's OAuth implementation is similar to GitHub CLI's authentication: +- Both use OAuth device flow +- Both store tokens securely +- Both provide easy login/logout + +**Difference:** +- Ivaldi stores tokens in `~/.config/ivaldi/auth.json` +- GitHub CLI stores tokens in `~/.config/gh/hosts.yml` +- Ivaldi can also read from GitHub CLI config as a fallback + +## See Also + +- [Portal Command](portal.md) - Managing repository connections +- [Download Command](download.md) - Cloning repositories +- [Upload Command](upload.md) - Pushing to GitHub +- [GitHub Integration Guide](../guides/github-integration.md) diff --git a/docs/commands/index.md b/docs/commands/index.md index c493357..a612b61 100644 --- a/docs/commands/index.md +++ b/docs/commands/index.md @@ -22,6 +22,7 @@ Complete reference for all Ivaldi commands. | [timeline](timeline.md) | Manage timelines | `git branch` / `git checkout` | | [travel](travel.md) | Interactive time travel | (interactive `git log` + checkout) | | [fuse](fuse.md) | Merge timelines | `git merge` | +| [auth](auth.md) | Authenticate with GitHub | (similar to `gh auth`) | | [portal](portal.md) | Manage GitHub connections | `git remote` | | [download](download.md) | Clone repository | `git clone` | | [upload](upload.md) | Push to GitHub | `git push` | @@ -54,6 +55,7 @@ Complete reference for all Ivaldi commands. - [fuse](fuse.md) - Merge timelines together ### Remote Operations +- [auth](auth.md) - Authenticate with GitHub using OAuth - [portal](portal.md) - Manage GitHub repository connections - [download](download.md) - Clone a repository from GitHub - [upload](upload.md) - Push commits to GitHub diff --git a/docs/guides/github-integration.md b/docs/guides/github-integration.md index 4c334ed..83b7bba 100644 --- a/docs/guides/github-integration.md +++ b/docs/guides/github-integration.md @@ -20,7 +20,33 @@ Ivaldi provides first-class GitHub support: ### GitHub Authentication -#### Option 1: Personal Access Token +#### Option 1: Ivaldi OAuth (Recommended) + +The easiest way to authenticate is using Ivaldi's built-in OAuth: + +```bash +ivaldi auth login +``` + +This will: +1. Generate a unique code +2. Open GitHub's device authentication page +3. Securely store your credentials +4. No manual token creation needed! + +**Verify authentication:** +```bash +ivaldi auth status +``` + +**Logout when needed:** +```bash +ivaldi auth logout +``` + +See the [Auth Command documentation](../commands/auth.md) for more details. + +#### Option 2: Personal Access Token 1. Create token on GitHub (Settings → Developer settings → Personal access tokens) 2. Required scopes: `repo`, `workflow` @@ -35,7 +61,7 @@ Add to `.bashrc` or `.zshrc` for persistence: echo 'export GITHUB_TOKEN="ghp_your_token"' >> ~/.bashrc ``` -#### Option 2: GitHub CLI +#### Option 3: GitHub CLI ```bash gh auth login @@ -46,7 +72,10 @@ This automatically configures authentication for Ivaldi. ### Verify Authentication ```bash -# Try listing a repo +# Check authentication status +ivaldi auth status + +# Or try listing a repo ivaldi scout # Should work without errors ``` @@ -517,10 +546,16 @@ Error: GitHub authentication failed Solutions: ```bash -# Refresh token +# Check authentication status +ivaldi auth status + +# Re-authenticate with Ivaldi +ivaldi auth login + +# Or refresh token export GITHUB_TOKEN="new_token" -# Or re-authenticate with CLI +# Or re-authenticate with gh CLI gh auth login gh auth status ``` diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go new file mode 100644 index 0000000..7adee64 --- /dev/null +++ b/internal/auth/oauth.go @@ -0,0 +1,442 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + // GitHub OAuth App credentials for Ivaldi VCS + // Note: These would need to be registered with GitHub + ClientID = "Iv1.b507a08c87ecfe98" // This is a placeholder - you'll need to register your app + DeviceCodeURL = "https://github.com/login/device/code" + AccessTokenURL = "https://github.com/login/oauth/access_token" + Scopes = "repo,read:user,user:email" +) + +// TokenStore manages OAuth tokens +type TokenStore struct { + configPath string +} + +// Token represents an OAuth token +type Token struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + CreatedAt time.Time `json:"created_at"` +} + +// DeviceCodeResponse represents the response from device code request +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// AccessTokenResponse represents the response from access token request +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` +} + +// NewTokenStore creates a new token store +func NewTokenStore() (*TokenStore, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + + configDir := filepath.Join(home, ".config", "ivaldi") + if err := os.MkdirAll(configDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create config directory: %w", err) + } + + return &TokenStore{ + configPath: filepath.Join(configDir, "auth.json"), + }, nil +} + +// LoadToken loads the stored token +func (ts *TokenStore) LoadToken() (*Token, error) { + data, err := os.ReadFile(ts.configPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read token: %w", err) + } + + var token Token + if err := json.Unmarshal(data, &token); err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + return &token, nil +} + +// SaveToken saves the token to disk +func (ts *TokenStore) SaveToken(token *Token) error { + token.CreatedAt = time.Now() + + data, err := json.MarshalIndent(token, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal token: %w", err) + } + + if err := os.WriteFile(ts.configPath, data, 0600); err != nil { + return fmt.Errorf("failed to write token: %w", err) + } + + return nil +} + +// DeleteToken removes the stored token +func (ts *TokenStore) DeleteToken() error { + if err := os.Remove(ts.configPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete token: %w", err) + } + return nil +} + +// RequestDeviceCode initiates the OAuth device flow +func RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) { + data := url.Values{} + data.Set("client_id", ClientID) + data.Set("scope", Scopes) + + req, err := http.NewRequestWithContext(ctx, "POST", DeviceCodeURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request device code: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("device code request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var deviceCode DeviceCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&deviceCode); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &deviceCode, nil +} + +// PollForAccessToken polls GitHub for the access token +func PollForAccessToken(ctx context.Context, deviceCode string, interval int) (*Token, error) { + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + token, err := checkAccessToken(ctx, deviceCode) + if err != nil { + // Check if it's a retriable error + if strings.Contains(err.Error(), "authorization_pending") { + continue + } + if strings.Contains(err.Error(), "slow_down") { + // Increase interval + ticker.Reset(time.Duration(interval+5) * time.Second) + continue + } + return nil, err + } + return token, nil + } + } +} + +// checkAccessToken checks if the access token is ready +func checkAccessToken(ctx context.Context, deviceCode string) (*Token, error) { + data := url.Values{} + data.Set("client_id", ClientID) + data.Set("device_code", deviceCode) + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + + req, err := http.NewRequestWithContext(ctx, "POST", AccessTokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request access token: %w", err) + } + defer resp.Body.Close() + + var tokenResp AccessTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if tokenResp.Error != "" { + return nil, fmt.Errorf("%s: %s", tokenResp.Error, tokenResp.ErrorDescription) + } + + if tokenResp.AccessToken == "" { + return nil, fmt.Errorf("no access token received") + } + + return &Token{ + AccessToken: tokenResp.AccessToken, + TokenType: tokenResp.TokenType, + Scope: tokenResp.Scope, + }, nil +} + +// GetToken returns the current token if available +func GetToken() (string, error) { + store, err := NewTokenStore() + if err != nil { + return "", err + } + + token, err := store.LoadToken() + if err != nil { + return "", err + } + + if token == nil { + return "", nil + } + + return token.AccessToken, nil +} + +// IsAuthenticated checks if the user is authenticated +func IsAuthenticated() bool { + token, err := GetToken() + return err == nil && token != "" +} + +// AuthMethod represents different authentication methods +type AuthMethod struct { + Name string + Description string + Token string +} + +// GetAuthMethod returns the active authentication method +func GetAuthMethod() *AuthMethod { + // 1. Check Ivaldi OAuth token + if token, err := GetToken(); err == nil && token != "" { + return &AuthMethod{ + Name: "ivaldi", + Description: "Authenticated via 'ivaldi auth login'", + Token: token, + } + } + + // 2. Check environment variable + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + return &AuthMethod{ + Name: "env", + Description: "Authenticated via GITHUB_TOKEN environment variable", + Token: token, + } + } + + // 3. Check git config for github token + if token := getGitConfig("github.token"); token != "" { + return &AuthMethod{ + Name: "git-config", + Description: "Authenticated via git config (github.token)", + Token: token, + } + } + + // 4. Try to read from git credential helper + if token := getGitCredential("github.com"); token != "" { + return &AuthMethod{ + Name: "git-credential", + Description: "Authenticated via git credential helper", + Token: token, + } + } + + // 5. Check .netrc file + if token := getNetrcToken("github.com"); token != "" { + return &AuthMethod{ + Name: "netrc", + Description: "Authenticated via .netrc file", + Token: token, + } + } + + // 6. Check gh CLI config + if token := getGHCLIToken(); token != "" { + return &AuthMethod{ + Name: "gh-cli", + Description: "Authenticated via 'gh auth login' (GitHub CLI)", + Token: token, + } + } + + return nil +} + +// Helper functions for GetAuthMethod +func getGitConfig(key string) string { + cmd := exec.Command("git", "config", "--get", key) + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +func getGitCredential(host string) string { + cmd := exec.Command("git", "credential", "fill") + cmd.Stdin = strings.NewReader(fmt.Sprintf("protocol=https\nhost=%s\n\n", host)) + + output, err := cmd.Output() + if err != nil { + return "" + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "password=") { + return strings.TrimPrefix(line, "password=") + } + } + + return "" +} + +func getNetrcToken(machine string) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + netrcPath := filepath.Join(home, ".netrc") + content, err := os.ReadFile(netrcPath) + if err != nil { + return "" + } + + lines := strings.Split(string(content), "\n") + inMachine := false + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "machine ") && strings.Contains(line, machine) { + inMachine = true + } else if inMachine && strings.HasPrefix(line, "password ") { + return strings.TrimPrefix(line, "password ") + } else if strings.HasPrefix(line, "machine ") { + inMachine = false + } + } + + return "" +} + +func getGHCLIToken() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + ghConfigPath := filepath.Join(home, ".config", "gh", "hosts.yml") + content, err := os.ReadFile(ghConfigPath) + if err != nil { + return "" + } + + lines := strings.Split(string(content), "\n") + for i, line := range lines { + if strings.Contains(line, "oauth_token:") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + return strings.TrimSpace(parts[1]) + } + } else if strings.Contains(line, "token:") && i > 0 && strings.Contains(lines[i-1], "github.com") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + return strings.TrimSpace(parts[1]) + } + } + } + + return "" +} + +// Login performs the OAuth device flow login +func Login(ctx context.Context) error { + fmt.Println("Initiating GitHub authentication...") + + deviceCode, err := RequestDeviceCode(ctx) + if err != nil { + return fmt.Errorf("failed to start authentication: %w", err) + } + + fmt.Printf("\nFirst, copy your one-time code: %s\n", deviceCode.UserCode) + fmt.Printf("Then visit: %s\n", deviceCode.VerificationURI) + fmt.Println("\nWaiting for authentication...") + + token, err := PollForAccessToken(ctx, deviceCode.DeviceCode, deviceCode.Interval) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + store, err := NewTokenStore() + if err != nil { + return err + } + + if err := store.SaveToken(token); err != nil { + return fmt.Errorf("failed to save token: %w", err) + } + + fmt.Println("\nAuthentication successful!") + return nil +} + +// Logout removes the stored authentication token +func Logout() error { + store, err := NewTokenStore() + if err != nil { + return err + } + + if err := store.DeleteToken(); err != nil { + return err + } + + fmt.Println("Logged out successfully") + return nil +} diff --git a/internal/github/client.go b/internal/github/client.go index cc28b25..0d9fdbd 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -15,6 +15,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/javanhut/Ivaldi-vcs/internal/auth" ) const ( @@ -167,7 +169,7 @@ func NewClient() (*Client, error) { username := getUsername() if token == "" { - return nil, fmt.Errorf("no GitHub authentication found. Please set GITHUB_TOKEN environment variable or configure git credentials") + return nil, fmt.Errorf("no GitHub authentication found. Run 'ivaldi auth login' to authenticate or set GITHUB_TOKEN environment variable") } return &Client{ @@ -183,27 +185,32 @@ func NewClient() (*Client, error) { // getAuthToken attempts to get GitHub auth token from various sources func getAuthToken() string { - // 1. Check environment variable (highest priority) + // 1. Check Ivaldi OAuth token (highest priority) + if token, err := auth.GetToken(); err == nil && token != "" { + return token + } + + // 2. Check environment variable if token := os.Getenv("GITHUB_TOKEN"); token != "" { return token } - // 2. Check git config for github token + // 3. Check git config for github token if token := getGitConfig("github.token"); token != "" { return token } - // 3. Try to read from git credential helper + // 4. Try to read from git credential helper if token := getGitCredential("github.com"); token != "" { return token } - // 4. Check .netrc file + // 5. Check .netrc file if token := getNetrcToken("github.com"); token != "" { return token } - // 5. Check gh CLI config + // 6. Check gh CLI config if token := getGHCLIToken(); token != "" { return token } From 775e1547cd514fee83ac88aa1eb8e9f6a2d047ff Mon Sep 17 00:00:00 2001 From: javanhut Date: Sat, 11 Oct 2025 07:51:23 -0400 Subject: [PATCH 2/6] package update --- go.mod | 10 +++++----- go.sum | 11 +++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 6b1f536..8887333 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,14 @@ go 1.24.5 require ( github.com/klauspost/compress v1.18.0 github.com/spf13/cobra v1.10.1 - go.etcd.io/bbolt v1.3.11 - golang.org/x/term v0.35.0 + go.etcd.io/bbolt v1.4.3 + golang.org/x/term v0.36.0 lukechampine.com/blake3 v1.4.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/cpuid/v2 v2.0.9 // indirect - github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.36.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 271ceea..23ec182 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -14,16 +16,25 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 3804eb2d0142e0972681196b2f37fa9fdf7d6457 Mon Sep 17 00:00:00 2001 From: javanhut Date: Sat, 11 Oct 2025 08:03:19 -0400 Subject: [PATCH 3/6] fix: fixed the migration issue --- internal/converter/converter_concurrent.go | 62 ++++++++++++++++++++-- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/internal/converter/converter_concurrent.go b/internal/converter/converter_concurrent.go index b408c1c..223c187 100644 --- a/internal/converter/converter_concurrent.go +++ b/internal/converter/converter_concurrent.go @@ -235,6 +235,27 @@ func ConvertGitObjectsToIvaldiConcurrent(gitDir, ivaldiDir string, workers int) // Create worker pool pool := NewConversionWorkerPool(workers, db, objectsDir) + // Start result collector goroutine to prevent deadlock + // This drains the results channel while jobs are being submitted + resultsChan := make(chan *ConversionResult, 1) + jobsSubmitted := len(objectPaths) + + go func() { + result := &ConversionResult{} + for i := 0; i < jobsSubmitted; i++ { + r := <-pool.results + if r.Success { + result.Converted++ + } else { + result.Skipped++ + if r.Error != nil { + result.Errors = append(result.Errors, r.Error) + } + } + } + resultsChan <- result + }() + // Submit all jobs fmt.Printf("Converting Git objects using %d workers...\n", workers) for i, objectPath := range objectPaths { @@ -245,8 +266,14 @@ func ConvertGitObjectsToIvaldiConcurrent(gitDir, ivaldiDir string, workers int) }) } - // Close pool and get results - return pool.Close() + // Close jobs channel and wait for workers to finish + close(pool.jobs) + pool.wg.Wait() + + // Get collected results from result collector goroutine + result := <-resultsChan + + return result, nil } // SnapshotCurrentFilesConcurrent creates blob objects for all files using concurrent workers @@ -308,6 +335,27 @@ func SnapshotCurrentFilesConcurrent(workDir, ivaldiDir string, workers int) (*Co // Create worker pool pool := NewConversionWorkerPool(workers, db, objectsDir) + // Start result collector goroutine to prevent deadlock + // This drains the results channel while jobs are being submitted + resultsChan := make(chan *ConversionResult, 1) + jobsSubmitted := len(files) + + go func() { + result := &ConversionResult{} + for i := 0; i < jobsSubmitted; i++ { + r := <-pool.results + if r.Success { + result.Converted++ + } else { + result.Skipped++ + if r.Error != nil { + result.Errors = append(result.Errors, r.Error) + } + } + } + resultsChan <- result + }() + // Submit all jobs fmt.Printf("Snapshotting files using %d workers...\n", workers) for _, file := range files { @@ -319,6 +367,12 @@ func SnapshotCurrentFilesConcurrent(workDir, ivaldiDir string, workers int) (*Co }) } - // Close pool and get results - return pool.Close() + // Close jobs channel and wait for workers to finish + close(pool.jobs) + pool.wg.Wait() + + // Get collected results from result collector goroutine + result := <-resultsChan + + return result, nil } From af49ce82f70561bc0cc0d7b40009f7a5e08b2265 Mon Sep 17 00:00:00 2001 From: javanhut Date: Sat, 11 Oct 2025 08:06:26 -0400 Subject: [PATCH 4/6] fix: for static check --- cli/management.go | 2 +- cli/resolver.go | 2 +- cli/timeline.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/management.go b/cli/management.go index 85c523c..aa05af9 100644 --- a/cli/management.go +++ b/cli/management.go @@ -594,7 +594,7 @@ var sealCmd = &cobra.Command{ // Check if there are staged files stageFile := filepath.Join(ivaldiDir, "stage", "files") if _, err := os.Stat(stageFile); os.IsNotExist(err) { - return fmt.Errorf("no files staged for commit. Use 'ivaldi gather' to stage files first.") + return fmt.Errorf("no files staged for commit. Use 'ivaldi gather' to stage files first") } // Read staged files diff --git a/cli/resolver.go b/cli/resolver.go index 8be1088..89a32ee 100644 --- a/cli/resolver.go +++ b/cli/resolver.go @@ -148,7 +148,7 @@ func (cr *ConflictResolver) showConflictAndGetChoice(conflict diffmerge.ChunkCon // showChunkPreview shows a preview of chunk content. func (cr *ConflictResolver) showChunkPreview(data []byte) { - if data == nil || len(data) == 0 { + if len(data) == 0 { fmt.Println(colors.Dim(" (empty)")) return } diff --git a/cli/timeline.go b/cli/timeline.go index 85fcdc6..d08d275 100644 --- a/cli/timeline.go +++ b/cli/timeline.go @@ -45,7 +45,7 @@ var createTimelineCmd = &cobra.Command{ defer refsManager.Close() // Get current timeline to branch from - currentTimeline, err := refsManager.GetCurrentTimeline() + currentTimeline, _ := refsManager.GetCurrentTimeline() var baseHashes [2][32]byte // blake3 and sha256 hashes var casStore cas.CAS @@ -307,7 +307,7 @@ var removeTimelineCmd = &cobra.Command{ // Check if trying to remove current timeline currentTimeline, err := refsManager.GetCurrentTimeline() if err == nil && currentTimeline == name { - return fmt.Errorf("cannot remove current timeline '%s'. Switch to another timeline first.", name) + return fmt.Errorf("cannot remove current timeline '%s'. Switch to another timeline first", name) } // Remove timeline file From 7f2644ac6603e9a1c03ecf8bbb7ea2f847b3dacb Mon Sep 17 00:00:00 2001 From: javanhut Date: Sat, 11 Oct 2025 08:10:10 -0400 Subject: [PATCH 5/6] fix: static check errors --- internal/diffmerge/diffmerge.go | 32 +++++++++++++++----------------- internal/fsmerkle/api.go | 13 ------------- internal/history/history_test.go | 6 ------ internal/pack/pack.go | 8 ++++---- internal/store/manager.go | 2 +- 5 files changed, 20 insertions(+), 41 deletions(-) diff --git a/internal/diffmerge/diffmerge.go b/internal/diffmerge/diffmerge.go index 4c84820..9b24fef 100644 --- a/internal/diffmerge/diffmerge.go +++ b/internal/diffmerge/diffmerge.go @@ -369,25 +369,23 @@ func (m *Merger) MergeWorkspacesWithStrategy(base, left, right wsindex.IndexRef, // If no merged chunks, file was deleted (intentionally left out) } else { // Conflicts remain - convert to legacy Conflict format - for range result.Conflicts { - conflict := Conflict{ - Type: FileFileConflict, - Path: path, - } - - if baseFile != nil { - conflict.BaseFile = baseFile - } - if leftFile != nil { - conflict.LeftFile = leftFile - } - if rightFile != nil { - conflict.RightFile = rightFile - } + // Only create one conflict per file + conflict := Conflict{ + Type: FileFileConflict, + Path: path, + } - conflicts = append(conflicts, conflict) - break // Only add one conflict per file + if baseFile != nil { + conflict.BaseFile = baseFile + } + if leftFile != nil { + conflict.LeftFile = leftFile } + if rightFile != nil { + conflict.RightFile = rightFile + } + + conflicts = append(conflicts, conflict) } } diff --git a/internal/fsmerkle/api.go b/internal/fsmerkle/api.go index 2cefcab..abb9f9a 100644 --- a/internal/fsmerkle/api.go +++ b/internal/fsmerkle/api.go @@ -67,12 +67,6 @@ func buildTreeFromMapRecursive(store *Store, files map[string][]byte, prefix str if subdirs[name] == nil { subdirs[name] = make(map[string][]byte) } - subPath := prefix - if subPath == "" { - subPath = name - } else { - subPath = subPath + "/" + name - } subdirs[name][filepath] = content } } @@ -269,10 +263,3 @@ func diffTreesRecursive(aHash, bHash Hash, pathPrefix string, ldr Loader, change return nil } -// Helper function to create a full path from components -func joinPath(prefix, name string) string { - if prefix == "" { - return name - } - return prefix + "/" + name -} \ No newline at end of file diff --git a/internal/history/history_test.go b/internal/history/history_test.go index fbfbe22..4a49efd 100644 --- a/internal/history/history_test.go +++ b/internal/history/history_test.go @@ -5,8 +5,6 @@ import ( "reflect" "testing" "time" - - "github.com/javanhut/Ivaldi-vcs/internal/fsmerkle" ) func TestLeafCanonicalEncoding(t *testing.T) { @@ -615,7 +613,3 @@ func TestAutoshelving(t *testing.T) { } } -// Helper function to create a test tree root -func createTestTreeRoot(content byte) fsmerkle.Hash { - return [32]byte{content} -} \ No newline at end of file diff --git a/internal/pack/pack.go b/internal/pack/pack.go index 7696e64..bfebd0f 100644 --- a/internal/pack/pack.go +++ b/internal/pack/pack.go @@ -19,10 +19,10 @@ var ( // Git object type codes (header nibble). We emit blobs here. const ( - objCommit = 1 - objTree = 2 - objBlob = 3 - objTag = 4 + _ = iota + 1 + _ + objBlob //nolint:unused // Used in Object.Type field and writeObjHeader + _ // 6 & 7 are reserved for OFS_DELTA/REF_DELTA (not used in this minimal writer). ) diff --git a/internal/store/manager.go b/internal/store/manager.go index f02d792..6099657 100644 --- a/internal/store/manager.go +++ b/internal/store/manager.go @@ -8,7 +8,7 @@ import ( // Manager provides shared database access to prevent locking conflicts. type Manager struct { - mu sync.RWMutex + mu sync.RWMutex //nolint:unused // Reserved for future synchronization needs db *DB dbPath string refs int // Reference count From 82aa76bba6fabc1f73a8b82c11bdb5247cd47638 Mon Sep 17 00:00:00 2001 From: javanhut Date: Sat, 11 Oct 2025 08:19:09 -0400 Subject: [PATCH 6/6] removed unused vars --- .github/workflows/ci.yml | 8 -------- internal/pack/pack.go | 2 +- internal/store/manager.go | 24 ++++++++++++------------ 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29f3cb8..3c4483d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,14 +35,6 @@ jobs: - name: Run staticcheck run: staticcheck ./... - - - name: Install golangci-lint - run: | - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.61.0 - - - name: Run golangci-lint - run: golangci-lint run --timeout=5m - test: name: Run Tests runs-on: ubuntu-latest diff --git a/internal/pack/pack.go b/internal/pack/pack.go index bfebd0f..11f9c4a 100644 --- a/internal/pack/pack.go +++ b/internal/pack/pack.go @@ -21,7 +21,7 @@ var ( const ( _ = iota + 1 _ - objBlob //nolint:unused // Used in Object.Type field and writeObjHeader + _ //nolint:unused // Used in Object.Type field and writeObjHeader _ // 6 & 7 are reserved for OFS_DELTA/REF_DELTA (not used in this minimal writer). ) diff --git a/internal/store/manager.go b/internal/store/manager.go index 6099657..55da6c4 100644 --- a/internal/store/manager.go +++ b/internal/store/manager.go @@ -8,7 +8,6 @@ import ( // Manager provides shared database access to prevent locking conflicts. type Manager struct { - mu sync.RWMutex //nolint:unused // Reserved for future synchronization needs db *DB dbPath string refs int // Reference count @@ -24,31 +23,31 @@ var managerMu sync.Mutex func GetSharedDB(ivaldiDir string) (*SharedDB, error) { managerMu.Lock() defer managerMu.Unlock() - + dbPath := filepath.Join(ivaldiDir, "objects.db") - + // If no manager exists or it's for a different database, create a new one if globalManager == nil || globalManager.dbPath != dbPath { // Close existing manager if it exists if globalManager != nil { globalManager.close() } - + db, err := Open(dbPath) if err != nil { return nil, fmt.Errorf("open database: %w", err) } - + globalManager = &Manager{ db: db, dbPath: dbPath, refs: 0, } } - + // Increment reference count globalManager.refs++ - + return &SharedDB{ manager: globalManager, DB: globalManager.db, @@ -67,19 +66,19 @@ func (sdb *SharedDB) Close() error { if sdb.manager == nil { return nil } - + managerMu.Lock() defer managerMu.Unlock() - + sdb.manager.refs-- - + // If no more references, close the underlying database if sdb.manager.refs <= 0 { err := sdb.manager.close() globalManager = nil return err } - + return nil } @@ -89,4 +88,5 @@ func (m *Manager) close() error { return m.db.Close() } return nil -} \ No newline at end of file +} +