diff --git a/src/cli/auth/auth.go b/src/cli/auth/auth.go index e8541de..cc9fb43 100644 --- a/src/cli/auth/auth.go +++ b/src/cli/auth/auth.go @@ -4,26 +4,40 @@ import ( "fmt" "os" - "github.com/devhindo/x/src/cli/lock" + "github.com/devhindo/x/src/cli/config" ) -// func check_authentication() {} - func Auth() { + cfg, err := config.LoadConfig() + if err != nil { + fmt.Println("Error loading config:", err) + os.Exit(1) + } - checkIfUserExists() + if len(cfg.Apps) == 0 { + fmt.Println("No apps found. Run 'x init add' first.") + os.Exit(1) + } - u := newUser() - u.add_user_to_db() - u.open_browser_to_auth_url() - fmt.Println("please authorize X CLI in your browser then run 'x auth --verify'") - fmt.Println("if the browser does not open, run 'x auth --url` to get the authorization url") -} + app := cfg.GetActiveApp() + if app == nil { + // Fallback to first app if active not set + if len(cfg.Apps) > 0 { + app = &cfg.Apps[0] + cfg.ActiveApp = app.Name + config.SaveConfig(cfg) + } else { + fmt.Println("No apps found. Run 'x init add' first.") + os.Exit(1) + } + } -func checkIfUserExists() { - _, err := lock.ReadLicenseKeyFromFile() - if err == nil { - fmt.Println("a user is already logged in | try 'x -h'") - os.Exit(0) + fmt.Printf("Authenticating app '%s'...\n", app.Name) + err = StartAuthFlow(app) + if err != nil { + fmt.Println("Authentication failed:", err) + os.Exit(1) } + + fmt.Println("Authentication successful! You can now use 'x tweet'.") } diff --git a/src/cli/auth/crud.go b/src/cli/auth/crud.go deleted file mode 100644 index 7bf1fef..0000000 --- a/src/cli/auth/crud.go +++ /dev/null @@ -1,59 +0,0 @@ -package auth - -import ( - "bytes" - "fmt" - "io" - "net/http" - "encoding/json" - "os" -) - -func Post(url string, u User) int { - - jsonBytes, err := json.Marshal(u) - if err != nil { - panic(err) - } - - resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) - - if err != nil { - fmt.Println("can't reach server to perform authentication") - os.Exit(0) - } - - defer resp.Body.Close() - - return resp.StatusCode -} - -func GET(url string) { - // Create a new HTTP request object. - req, err := http.NewRequest("GET", url, nil) - if err != nil { - fmt.Println(err) - return - } - - // Send the request and receive the response. - resp, err := http.DefaultClient.Do(req) - if err != nil { - fmt.Println(err) - return - } - - // Read the response body. - body, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Println(err) - return - } - - // Close the response body. - defer resp.Body.Close() - - // Print the response body. - fmt.Println(string(body)) - -} diff --git a/src/cli/auth/geturl.go b/src/cli/auth/geturl.go deleted file mode 100644 index 70d94c7..0000000 --- a/src/cli/auth/geturl.go +++ /dev/null @@ -1,21 +0,0 @@ -package auth - -import ( - "fmt" - "os" - - "github.com/devhindo/x/src/cli/lock" -) - -func Get_url_db() { - l, err := lock.ReadLicenseKeyFromFile() - if err != nil { - fmt.Println("you are not authenticated | try 'x auth'") - os.Exit(1) - } - - url := "https://x-blush.vercel.app/api/user/url" - k := License{License: l} - - postL(url, k) -} diff --git a/src/cli/auth/license.go b/src/cli/auth/license.go deleted file mode 100644 index 31640d8..0000000 --- a/src/cli/auth/license.go +++ /dev/null @@ -1,30 +0,0 @@ -package auth - -import ( - "os" - "fmt" - "io" - "path/filepath" -) - -func (u *User) RetrieveLicenseKey() (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("Error getting user home directory: %v", err) - } - - licenseFilePath := filepath.Join(homeDir, ".tempxcli") - licenseFile, err := os.Open(licenseFilePath) - if err != nil { - return "", fmt.Errorf("Error opening license file: %v", err) - } - defer licenseFile.Close() - - licenseFileBytes, err := io.ReadAll(licenseFile) - if err != nil { - return "", fmt.Errorf("Error reading license file: %v", err) - } - - licenseKey := string(licenseFileBytes) - return licenseKey, nil -} \ No newline at end of file diff --git a/src/cli/auth/lock.go b/src/cli/auth/lock.go deleted file mode 100644 index 55e62ab..0000000 --- a/src/cli/auth/lock.go +++ /dev/null @@ -1,18 +0,0 @@ -package auth - -import ( - "github.com/devhindo/x/src/cli/lock" -) - -func (u *User) Lock() { - licenseKey, err := lock.GenerateLicenseKey() - if err != nil { - panic(err) - } - - //err = lock.WriteLicenseKeyToFile(licenseKey) - //if err != nil { - // panic(err) - //} - u.License = licenseKey -} \ No newline at end of file diff --git a/src/cli/auth/oauth.go b/src/cli/auth/oauth.go index ed76b41..067331f 100644 --- a/src/cli/auth/oauth.go +++ b/src/cli/auth/oauth.go @@ -1,30 +1,217 @@ package auth import ( + "context" "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/json" + "fmt" + "io" "math/big" + "net/http" + "net/url" + "strings" + "time" + + "github.com/devhindo/x/src/cli/config" + "github.com/devhindo/x/src/cli/utils" +) + +const ( + RedirectURI = "http://localhost:3000/callback" + AuthURL = "https://twitter.com/i/oauth2/authorize" + TokenURL = "https://api.twitter.com/2/oauth2/token" + Scopes = "tweet.read tweet.write users.read follows.read follows.write offline.access" ) -/* -* code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) - */ +func StartAuthFlow(app *config.App) error { + state, err := generateRandomString(32) + if err != nil { + return err + } + codeVerifier, err := generateCodeVerifier() + if err != nil { + return err + } + codeChallenge := generateCodeChallenge(codeVerifier) + + // Start local server + codeChan := make(chan string) + errChan := make(chan error) + server := &http.Server{Addr: ":3000"} -func (u *User) generate_code_challenge() { - // Base64-URL-encoded string of the SHA256 hash of the code verifier - //u.Code_challenge = base64.RawURLEncoding.EncodeToString((hash_sha256(u.Code_verifier))) - u.Code_challenge = base64.RawURLEncoding.EncodeToString((hash_sha256(u.Code_verifier))) + go func() { + http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + queryState := r.URL.Query().Get("state") + if queryState != state { + http.Error(w, "Invalid state", http.StatusBadRequest) + errChan <- fmt.Errorf("invalid state") + return + } + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "Code not found", http.StatusBadRequest) + errChan <- fmt.Errorf("code not found") + return + } + fmt.Fprintf(w, "Authorization successful! You can close this window now.") + codeChan <- code + }) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errChan <- err + } + }() + + // Construct Auth URL + params := url.Values{} + params.Add("response_type", "code") + params.Add("client_id", app.ClientID) + params.Add("redirect_uri", RedirectURI) + params.Add("scope", Scopes) + params.Add("state", state) + params.Add("code_challenge", codeChallenge) + params.Add("code_challenge_method", "S256") + authURL := fmt.Sprintf("%s?%s", AuthURL, params.Encode()) + + fmt.Println("Opening browser to authenticate...") + utils.OpenBrowser(authURL) + + // Wait for code or error + var code string + select { + case code = <-codeChan: + case err := <-errChan: + return err + case <-time.After(5 * time.Minute): + return fmt.Errorf("timeout waiting for authentication") + } + + // Shutdown server + server.Shutdown(context.Background()) + // Exchange code for token + token, err := exchangeCodeForToken(app, code, codeVerifier) + if err != nil { + return err + } + + // Update app with user token + // We need to reload config to get the latest state (though we passed app pointer) + // Ideally we should update the config object. + app.User = token + + cfg, err := config.LoadConfig() + if err != nil { + return err + } + + // Find and update the app in the config + cfg.AddApp(*app) // AddApp updates if exists + + return config.SaveConfig(cfg) } -func hash_sha256(s string) []byte { - h := sha256.New() - h.Write([]byte(s)) - return h.Sum(nil) +func exchangeCodeForToken(app *config.App, code, codeVerifier string) (*config.User, error) { + data := url.Values{} + data.Set("code", code) + data.Set("grant_type", "authorization_code") + data.Set("client_id", app.ClientID) + data.Set("redirect_uri", RedirectURI) + data.Set("code_verifier", codeVerifier) + + req, err := http.NewRequest("POST", TokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(app.ClientID, app.ClientSecret) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to get token: %s", string(body)) + } + + var result struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &config.User{ + AccessToken: result.AccessToken, + RefreshToken: result.RefreshToken, + Expiry: time.Now().Add(time.Duration(result.ExpiresIn) * time.Second), + }, nil +} + +func RefreshToken(app *config.App) error { + data := url.Values{} + data.Set("refresh_token", app.User.RefreshToken) + data.Set("grant_type", "refresh_token") + data.Set("client_id", app.ClientID) + + req, err := http.NewRequest("POST", TokenURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(app.ClientID, app.ClientSecret) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to refresh token: %s", string(body)) + } + + var result struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return err + } + + app.User.AccessToken = result.AccessToken + if result.RefreshToken != "" { + app.User.RefreshToken = result.RefreshToken + } + app.User.Expiry = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) + + cfg, err := config.LoadConfig() + if err != nil { + return err + } + cfg.AddApp(*app) + return config.SaveConfig(cfg) } -func (u *User) generate_code_verifier() { +func generateRandomString(length int) (string, error) { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func generateCodeVerifier() (string, error) { const ( chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" min = 43 @@ -33,7 +220,7 @@ func (u *User) generate_code_verifier() { length, err := rand.Int(rand.Reader, big.NewInt(max-min+1)) if err != nil { - panic(err) + return "", err } length.Add(length, big.NewInt(min)) @@ -41,22 +228,16 @@ func (u *User) generate_code_verifier() { for i := range b { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) if err != nil { - panic(err) + return "", err } b[i] = chars[n.Int64()] } - u.Code_verifier = string(b) + return string(b), nil } -func (u *User) generate_state(stateLength int) { - b := make([]byte, stateLength) - _, err := rand.Read(b) - if err != nil { - panic(err) - } - - state := base64.RawURLEncoding.EncodeToString(b) - - u.State = state +func generateCodeChallenge(verifier string) string { + h := sha256.New() + h.Write([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) } diff --git a/src/cli/auth/oauth_test.go b/src/cli/auth/oauth_test.go deleted file mode 100644 index a13d0cd..0000000 --- a/src/cli/auth/oauth_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package auth - -import ( - "encoding/base64" - "testing" -) - -func TestHashSha256(t *testing.T) { - input := "hello world" - // echo -n "hello world" | openssl sha256 -binary | base64 - // uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek= - expectedBase64 := "uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=" - - got := hash_sha256(input) - gotBase64 := base64.StdEncoding.EncodeToString(got) - - if gotBase64 != expectedBase64 { - t.Errorf("hash_sha256(%q) = %q, want %q", input, gotBase64, expectedBase64) - } -} - -func TestGenerateCodeVerifier(t *testing.T) { - u := &User{} - u.generate_code_verifier() - - if len(u.Code_verifier) < 43 || len(u.Code_verifier) > 128 { - t.Errorf("generate_code_verifier() length = %d, want between 43 and 128", len(u.Code_verifier)) - } - - // Check for invalid characters - allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - for _, char := range u.Code_verifier { - isAllowed := false - for _, a := range allowed { - if char == a { - isAllowed = true - break - } - } - if !isAllowed { - t.Errorf("generate_code_verifier() contains invalid character: %c", char) - } - } -} - -func TestGenerateCodeChallenge(t *testing.T) { - u := &User{} - // Set a fixed verifier for reproducibility - u.Code_verifier = "hello_world_verifier_1234567890" - - // sha256("hello_world_verifier_1234567890") - // echo -n "hello_world_verifier_1234567890" | openssl sha256 -binary | base64 - // hash = 84c3c33379967676e828114f851080d859e3557451965b1285268c375531d041 - // Base64URL(hash) (without padding) - - u.generate_code_challenge() - - // Verify the result - // The implementation calls hash_sha256 then Base64 RawURL encoding - hash := hash_sha256(u.Code_verifier) - expected := base64.RawURLEncoding.EncodeToString(hash) - - if u.Code_challenge != expected { - t.Errorf("generate_code_challenge() = %q, want %q", u.Code_challenge, expected) - } -} - -func TestGenerateState(t *testing.T) { - u := &User{} - length := 127 - u.generate_state(length) - - // The implementation generates random bytes of 'length' then Base64 URL encodes them. - // So the resulting string length will be roughly length * 4/3. - - if u.State == "" { - t.Error("generate_state() produced empty state") - } - - // Decode back to check byte length - decoded, err := base64.RawURLEncoding.DecodeString(u.State) - if err != nil { - t.Errorf("generate_state() produced invalid base64: %v", err) - } - - if len(decoded) != length { - t.Errorf("generate_state() decoded length = %d, want %d", len(decoded), length) - } -} diff --git a/src/cli/auth/server.go b/src/cli/auth/server.go deleted file mode 100644 index f3b1e03..0000000 --- a/src/cli/auth/server.go +++ /dev/null @@ -1,11 +0,0 @@ -package auth - -import ( - -) - -// todo: send state to the server -// todo: actually send a one request containing everything need for requesting the access token - - - diff --git a/src/cli/auth/url.go b/src/cli/auth/url.go deleted file mode 100644 index 0674567..0000000 --- a/src/cli/auth/url.go +++ /dev/null @@ -1,13 +0,0 @@ -package auth - -func (u *User) generate_auth_url() { - auth_url := "" - auth_scopes := "tweet.read%20tweet.write%20users.read%20users.read%20follows.read%20follows.write%20offline.access" - auth_url += "https://twitter.com/i/oauth2/authorize?response_type=code&client_id=" - auth_url += "emJHZzZHMUdHMF9QRlRIdk45QjY6MTpjaQ" - redirect_url := "https://x-blush.vercel.app/api/auth" - auth_url += "&redirect_uri=" + redirect_url - auth_url += "&scope=" + auth_scopes - auth_url += "&state=" + u.State + "&code_challenge=" + u.Code_challenge + "&code_challenge_method=S256" - u.Auth_URL = auth_url -} diff --git a/src/cli/auth/url_test.go b/src/cli/auth/url_test.go deleted file mode 100644 index 4caa97d..0000000 --- a/src/cli/auth/url_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package auth - -import ( - "strings" - "testing" -) - -func TestGenerateAuthUrl(t *testing.T) { - u := &User{ - State: "test_state", - Code_challenge: "test_challenge", - } - - u.generate_auth_url() - - if u.Auth_URL == "" { - t.Error("generate_auth_url() resulted in empty Auth_URL") - } - - expectedParts := []string{ - "https://twitter.com/i/oauth2/authorize", - "response_type=code", - "client_id=emJHZzZHMUdHMF9QRlRIdk45QjY6MTpjaQ", - "redirect_uri=https://x-blush.vercel.app/api/auth", - "scope=tweet.read%20tweet.write%20users.read%20users.read%20follows.read%20follows.write%20offline.access", - "state=test_state", - "code_challenge=test_challenge", - "code_challenge_method=S256", - } - - for _, part := range expectedParts { - if !strings.Contains(u.Auth_URL, part) { - t.Errorf("Auth_URL missing part: %s", part) - } - } -} diff --git a/src/cli/auth/user.go b/src/cli/auth/user.go deleted file mode 100644 index 6ea3953..0000000 --- a/src/cli/auth/user.go +++ /dev/null @@ -1,46 +0,0 @@ -package auth - -import ( - "fmt" - - "github.com/devhindo/x/src/cli/lock" - "github.com/devhindo/x/src/cli/utils" -) - -type User struct { - State string `json:"state"` - Auth_URL string `json:"auth_url"` - Code_verifier string `json:"code_verifier"` - Code_challenge string `json:"code_challenge"` - License string `json:"license"` -} - -func newUser() *User { - u := new(User) - u.Lock() - u.generate_code_verifier() - u.generate_code_challenge() - u.generate_state(127) - u.generate_auth_url() - - return u -} - -func (u *User) add_user_to_db() { - status := Post("https://x-blush.vercel.app/api/auth/add", *u) - - if status != 200 { - fmt.Println("error adding user") - } else { - - err := lock.WriteLicenseKeyToFile(u.License) - if err != nil { - fmt.Println("coudln't write license key to file") - return - } - } -} - -func (u *User) open_browser_to_auth_url() { - utils.OpenBrowser(u.Auth_URL) -} diff --git a/src/cli/auth/validate.go b/src/cli/auth/validate.go deleted file mode 100644 index 7ccca22..0000000 --- a/src/cli/auth/validate.go +++ /dev/null @@ -1,7 +0,0 @@ -package auth - -import () - -func IsAuthenticated() bool { - return false -} \ No newline at end of file diff --git a/src/cli/auth/validate_test.go b/src/cli/auth/validate_test.go deleted file mode 100644 index 0cafcc8..0000000 --- a/src/cli/auth/validate_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package auth - -import "testing" - -func TestIsAuthenticated(t *testing.T) { - got := IsAuthenticated() - if got != false { - t.Errorf("IsAuthenticated() = %v, want false", got) - } -} diff --git a/src/cli/auth/verify.go b/src/cli/auth/verify.go deleted file mode 100644 index e760b5a..0000000 --- a/src/cli/auth/verify.go +++ /dev/null @@ -1,67 +0,0 @@ -package auth - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "os" - "io" - - "github.com/devhindo/x/src/cli/lock" -) - -type License struct { - License string `json:"license"` -} - -func Verify() bool { - l, err := lock.ReadLicenseKeyFromFile() - if err != nil { - fmt.Println("you are not authenticated | try 'x auth'") - os.Exit(1) - } - - k := License{License: l} - - url := "https://x-blush.vercel.app/api/auth/verify" - - postL(url, k) - - - return true -} - -type response struct { - Message string `json:"message"` -} - -func postL(url string, l License) { - - jsonBytes, err := json.Marshal(l) - if err != nil { - panic(err) - } - - resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) - - if err != nil { - fmt.Println("can't reach server to verify user") - os.Exit(0) - } - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - //Failed to read response. - panic(err) - } - - var r response - - err = json.Unmarshal(body, &r) - - //Convert bytes to String and print - fmt.Println(r.Message) -} \ No newline at end of file diff --git a/src/cli/clear/startover.go b/src/cli/clear/startover.go index a0522a7..d0646c5 100644 --- a/src/cli/clear/startover.go +++ b/src/cli/clear/startover.go @@ -1,67 +1,33 @@ package clear import ( - "bytes" - "encoding/json" "fmt" - "net/http" "os" - - "github.com/devhindo/x/src/cli/lock" + + "github.com/devhindo/x/src/cli/config" ) func StartOver() { - - license, err := lock.ReadLicenseKeyFromFile() - + cfg, err := config.LoadConfig() if err != nil { - fmt.Println("no user logged in") - return + fmt.Println("Error loading config:", err) + os.Exit(1) } - delete_user_from_db(license) - - lock.ClearLicenseFile() - - fmt.Println("user deleted successfully") - -} - -// is there anyway better to pass license? -type License struct { - License string `json:"license"` -} - -func delete_user_from_db(license string) { - - l := License{ - License: license, + app := cfg.GetActiveApp() + if app == nil { + fmt.Println("No active app found.") + return } - url := "https://x-blush.vercel.app/api/user/delete" - - status := post(url, l) + app.User = nil + cfg.AddApp(*app) // Update app - if status != 200 { - fmt.Println("error deleting user from db") + err = config.SaveConfig(cfg) + if err != nil { + fmt.Println("Error clearing user:", err) + os.Exit(1) } -} - -func post(url string, l License) int { - jsonBytes, err := json.Marshal(l) - if err != nil { - panic(err) - } - - resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) - - if err != nil { - fmt.Println("can't reach server to clear user") - os.Exit(0) - } - - defer resp.Body.Close() - - return resp.StatusCode -} \ No newline at end of file + fmt.Println("User session cleared successfully.") +} diff --git a/src/cli/cmd/auth.go b/src/cli/cmd/auth.go index 0eeb1e5..933b3a8 100644 --- a/src/cli/cmd/auth.go +++ b/src/cli/cmd/auth.go @@ -2,7 +2,6 @@ package cmd import ( "github.com/devhindo/x/src/cli/auth" - "github.com/devhindo/x/src/cli/clear" "github.com/spf13/cobra" ) @@ -10,25 +9,10 @@ var authCmd = &cobra.Command{ Use: "auth", Short: "Authenticate with X", Run: func(cmd *cobra.Command, args []string) { - verify, _ := cmd.Flags().GetBool("verify") - clearFlag, _ := cmd.Flags().GetBool("clear") - url, _ := cmd.Flags().GetBool("url") - - if verify { - auth.Verify() - } else if clearFlag { - clear.StartOver() - } else if url { - auth.Get_url_db() - } else { - auth.Auth() - } + auth.Auth() }, } func init() { rootCmd.AddCommand(authCmd) - authCmd.Flags().BoolP("verify", "v", false, "Verify authentication") - authCmd.Flags().BoolP("clear", "c", false, "Clear authentication") - authCmd.Flags().Bool("url", false, "Get authorization URL") } diff --git a/src/cli/cmd/init.go b/src/cli/cmd/init.go new file mode 100644 index 0000000..eb5ad22 --- /dev/null +++ b/src/cli/cmd/init.go @@ -0,0 +1,218 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/charmbracelet/huh" + "github.com/charmbracelet/huh/spinner" + "github.com/devhindo/x/src/cli/config" + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize and manage Twitter Apps", + Long: `Manage your Twitter Developer Apps locally. Add, remove, and switch between multiple apps.`, +} + +var initAddCmd = &cobra.Command{ + Use: "add", + Short: "Register a new Twitter App", + Run: func(cmd *cobra.Command, args []string) { + var ( + appName string + clientID string + clientSecret string + ) + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("App Name"). + Description("Give your app a unique nickname (e.g. MyBot)"). + Value(&appName). + Validate(func(s string) error { + if len(s) == 0 { + return fmt.Errorf("app name cannot be empty") + } + return nil + }), + huh.NewInput(). + Title("Client ID"). + Description("From Twitter Developer Portal"). + Value(&clientID). + Validate(func(s string) error { + if len(s) == 0 { + return fmt.Errorf("client ID cannot be empty") + } + return nil + }), + huh.NewInput(). + Title("Client Secret"). + Description("From Twitter Developer Portal"). + Value(&clientSecret). + Password(true). + Validate(func(s string) error { + if len(s) == 0 { + return fmt.Errorf("client secret cannot be empty") + } + return nil + }), + ), + ) + + err := form.Run() + if err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } + + action := func() { + cfg, err := config.LoadConfig() + if err != nil { + cfg = &config.Config{} + } + + cfg.AddApp(config.App{ + Name: appName, + ClientID: clientID, + ClientSecret: clientSecret, + }) + + if cfg.ActiveApp == "" { + cfg.ActiveApp = appName + } + + err = config.SaveConfig(cfg) + if err != nil { + fmt.Println("Error saving config:", err) + os.Exit(1) + } + } + + _ = spinner.New().Title("Saving app details...").Action(action).Run() + + fmt.Printf("App '%s' added successfully! 🎉\n", appName) + fmt.Println("Run 'x auth' to authenticate this app.") + }, +} + +var initUseCmd = &cobra.Command{ + Use: "use", + Short: "Select the active Twitter App", + Run: func(cmd *cobra.Command, args []string) { + cfg, err := config.LoadConfig() + if err != nil || len(cfg.Apps) == 0 { + fmt.Println("No apps found. Run 'x init add' to register an app.") + return + } + + var selectedApp string + var options []huh.Option[string] + + for _, app := range cfg.Apps { + label := app.Name + if app.Name == cfg.ActiveApp { + label += " (Active)" + } + options = append(options, huh.NewOption(label, app.Name)) + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select Active App"). + Options(options...). + Value(&selectedApp), + ), + ) + + err = form.Run() + if err != nil { + fmt.Println("Operation cancelled.") + return + } + + cfg.SetActiveApp(selectedApp) + err = config.SaveConfig(cfg) + if err != nil { + fmt.Println("Error saving config:", err) + os.Exit(1) + } + + fmt.Printf("Switched to '%s' successfully! 🚀\n", selectedApp) + }, +} + +var initDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a registered Twitter App", + Run: func(cmd *cobra.Command, args []string) { + cfg, err := config.LoadConfig() + if err != nil || len(cfg.Apps) == 0 { + fmt.Println("No apps found to delete.") + return + } + + var appToDelete string + var options []huh.Option[string] + + for _, app := range cfg.Apps { + options = append(options, huh.NewOption(app.Name, app.Name)) + } + + // Step 1: Select App + selectForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select App to Delete"). + Options(options...). + Value(&appToDelete), + ), + ) + + err = selectForm.Run() + if err != nil { + fmt.Println("Operation cancelled.") + return + } + + // Step 2: Confirm + var confirm bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Are you sure you want to delete '%s'?", appToDelete)). + Affirmative("Yes, delete it"). + Negative("No, keep it"). + Value(&confirm), + ), + ) + + err = confirmForm.Run() + if err != nil { + fmt.Println("Operation cancelled.") + return + } + + if confirm { + cfg.RemoveApp(appToDelete) + err = config.SaveConfig(cfg) + if err != nil { + fmt.Println("Error saving config:", err) + os.Exit(1) + } + fmt.Printf("App '%s' deleted. 🗑️\n", appToDelete) + } else { + fmt.Println("Deletion cancelled.") + } + }, +} + +func init() { + rootCmd.AddCommand(initCmd) + initCmd.AddCommand(initAddCmd) + initCmd.AddCommand(initUseCmd) + initCmd.AddCommand(initDeleteCmd) +} diff --git a/src/cli/cmd/version.go b/src/cli/cmd/version.go index e81c7d9..d2b607d 100644 --- a/src/cli/cmd/version.go +++ b/src/cli/cmd/version.go @@ -1,7 +1,7 @@ package cmd import ( - "github.com/devhindo/x/src/cli/x" + "fmt" "github.com/spf13/cobra" ) @@ -10,7 +10,7 @@ var versionCmd = &cobra.Command{ Short: "Print version", Aliases: []string{"v"}, Run: func(cmd *cobra.Command, args []string) { - x.Version() + fmt.Println("x CLI v1.0.0 (Local)") }, } diff --git a/src/cli/config/config.go b/src/cli/config/config.go new file mode 100644 index 0000000..592b616 --- /dev/null +++ b/src/cli/config/config.go @@ -0,0 +1,117 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +type User struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Expiry time.Time `json:"expiry"` +} + +type App struct { + Name string `json:"name"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + User *User `json:"user"` +} + +type Config struct { + ActiveApp string `json:"active_app"` + Apps []App `json:"apps"` +} + +func GetConfigPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + configDir := filepath.Join(homeDir, ".x-cli") + if err := os.MkdirAll(configDir, 0700); err != nil { + return "", err + } + return filepath.Join(configDir, "config.json"), nil +} + +func LoadConfig() (*Config, error) { + path, err := GetConfigPath() + if err != nil { + return nil, err + } + + file, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &Config{Apps: []App{}}, nil + } + return nil, err + } + + var cfg Config + if err := json.Unmarshal(file, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func SaveConfig(cfg *Config) error { + path, err := GetConfigPath() + if err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0600) +} + +func (c *Config) AddApp(app App) { + // Check if app with same name exists, update it if so + for i, a := range c.Apps { + if a.Name == app.Name { + c.Apps[i] = app + return + } + } + c.Apps = append(c.Apps, app) +} + +func (c *Config) RemoveApp(name string) { + var newApps []App + for _, app := range c.Apps { + if app.Name != name { + newApps = append(newApps, app) + } + } + c.Apps = newApps + if c.ActiveApp == name { + c.ActiveApp = "" + } +} + +func (c *Config) SetActiveApp(name string) error { + for _, app := range c.Apps { + if app.Name == name { + c.ActiveApp = name + return nil + } + } + return fmt.Errorf("app %s not found", name) +} + +func (c *Config) GetActiveApp() *App { + for i, app := range c.Apps { + if app.Name == c.ActiveApp { + return &c.Apps[i] + } + } + return nil +} diff --git a/src/cli/env/load.go b/src/cli/env/load.go deleted file mode 100644 index cd0d1e8..0000000 --- a/src/cli/env/load.go +++ /dev/null @@ -1,19 +0,0 @@ -package env -/* - -import ( - "github.com/joho/godotenv" - "os" -) - -func Load() { - // Set Twitter API Key - err := godotenv.Load(".env") - if err != nil { - panic(err) - } - - os.Getenv("TWITTER_API_KEY") - os.Getenv("TWITTER_API_SECRET_KEY") -} -*/ \ No newline at end of file diff --git a/src/cli/go.mod b/src/cli/go.mod index 957e3a3..9b6fb96 100644 --- a/src/cli/go.mod +++ b/src/cli/go.mod @@ -1,11 +1,39 @@ module github.com/devhindo/x/src/cli -go 1.22.2 +go 1.24.0 -require github.com/google/uuid v1.3.1 +require ( + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/huh/spinner v0.0.0-20260202112050-cf338358ac5c + github.com/spf13/cobra v1.10.2 +) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/cobra v1.10.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.23.0 // indirect ) diff --git a/src/cli/go.sum b/src/cli/go.sum index d5ced09..daa1ca0 100644 --- a/src/cli/go.sum +++ b/src/cli/go.sum @@ -1,13 +1,86 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/huh/spinner v0.0.0-20260202112050-cf338358ac5c h1:kaBoCcvsJlo2jkak04H7ObKjVSVA8bw3JKGlL5QdQDQ= +github.com/charmbracelet/huh/spinner v0.0.0-20260202112050-cf338358ac5c/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= -github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/src/cli/gotwi/post.go b/src/cli/gotwi/post.go deleted file mode 100644 index de084ef..0000000 --- a/src/cli/gotwi/post.go +++ /dev/null @@ -1,37 +0,0 @@ -package gotwi -/* -import ( - "github.com/michimani/gotwi" - "github.com/michimani/gotwi/tweet/managetweet" - "github.com/michimani/gotwi/tweet/managetweet/types" - "fmt" - "context" -) - -func PostTweet(t string) (string, error) { - - in := &gotwi.NewClientWithAccessTokenInput{ - AccessToken: "#", - } - - c, err := gotwi.NewClientWithAccessToken(in) - - if err != nil { - fmt.Println(err) - } else { - fmt.Println(c, "done") - } - - tweet := &types.CreateInput { - Text: gotwi.String(t), - } - - res, err := managetweet.Create(context.Background(), c, tweet) - - if err != nil { - return "", err - } - - return gotwi.StringValue(res.Data.ID), nil -} -*/ \ No newline at end of file diff --git a/src/cli/help/help.go b/src/cli/help/help.go deleted file mode 100644 index 33f6aa8..0000000 --- a/src/cli/help/help.go +++ /dev/null @@ -1,25 +0,0 @@ -package help - -import ( - "fmt" -) - -func Help() { - fmt.Println() - fmt.Println("interact with x (twitter) from terminal.") - fmt.Println() - fmt.Println("USAGE") - fmt.Println(" x ") - fmt.Println() - fmt.Println("Commands") - fmt.Println(" -h show this help") - fmt.Println(" auth start authorizing your X account") - fmt.Println(" auth --url get auth url if it didn't open browser after running 'x auth'") - fmt.Println(" auth -v verify authorization after running 'x auth'") - fmt.Println(" -t \"text\" post a tweet") - fmt.Println(" -v show version") - fmt.Println(" -c clear authorized account") - fmt.Println() - fmt.Println("LEARN MORE") - fmt.Println(" Cheack source code at: https://github.com/devhindo/x") -} \ No newline at end of file diff --git a/src/cli/lock/file_test.go b/src/cli/lock/file_test.go deleted file mode 100644 index d1d139a..0000000 --- a/src/cli/lock/file_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package lock - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLicenseFileOperations(t *testing.T) { - // Create a temporary directory to act as HOME - tempDir, err := os.MkdirTemp("", "locktest") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - // Set HOME environment variable to tempDir - originalHome := os.Getenv("HOME") - defer os.Setenv("HOME", originalHome) - os.Setenv("HOME", tempDir) - - licenseKey := "test-license-key-123" - - // Test WriteLicenseKeyToFile - t.Run("WriteLicenseKeyToFile", func(t *testing.T) { - err := WriteLicenseKeyToFile(licenseKey) - if err != nil { - t.Errorf("WriteLicenseKeyToFile() error = %v", err) - } - - // Verify file exists - expectedPath := filepath.Join(tempDir, ".tempxcli") - content, err := os.ReadFile(expectedPath) - if err != nil { - t.Errorf("Failed to read license file: %v", err) - } - if string(content) != licenseKey { - t.Errorf("File content = %q, want %q", string(content), licenseKey) - } - }) - - // Test ReadLicenseKeyFromFile - t.Run("ReadLicenseKeyFromFile", func(t *testing.T) { - readKey, err := ReadLicenseKeyFromFile() - if err != nil { - t.Errorf("ReadLicenseKeyFromFile() error = %v", err) - } - if readKey != licenseKey { - t.Errorf("ReadLicenseKeyFromFile() = %q, want %q", readKey, licenseKey) - } - }) - - // Test ClearLicenseFile - t.Run("ClearLicenseFile", func(t *testing.T) { - err := ClearLicenseFile() - if err != nil { - t.Errorf("ClearLicenseFile() error = %v", err) - } - - // Verify file is gone - _, err = ReadLicenseKeyFromFile() - if err == nil { - t.Error("ReadLicenseKeyFromFile() should fail after clear, but it succeeded") - } - - expectedPath := filepath.Join(tempDir, ".tempxcli") - if _, err := os.Stat(expectedPath); !os.IsNotExist(err) { - t.Error("License file should not exist after clear") - } - }) -} diff --git a/src/cli/lock/key.go b/src/cli/lock/key.go deleted file mode 100644 index a0415eb..0000000 --- a/src/cli/lock/key.go +++ /dev/null @@ -1,77 +0,0 @@ -package lock - -import ( - "encoding/base64" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/google/uuid" -) - -func GenerateLicenseKey() (string, error) { - uuid := uuid.New() - uuidBytes := uuid[:] - licenseKeyBytes := append(uuidBytes) - licenseKey := base64.StdEncoding.EncodeToString(licenseKeyBytes) - return licenseKey, nil -} - -func WriteLicenseKeyToFile(licenseKey string) error { - homeDir, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("Error getting user home directory: %v", err) - } - - licenseFilePath := filepath.Join(homeDir, ".tempxcli") - licenseFile, err := os.Create(licenseFilePath) - if err != nil { - return fmt.Errorf("Error creating license file: %v", err) - } - defer licenseFile.Close() - - _, err = licenseFile.WriteString(licenseKey) - if err != nil { - return fmt.Errorf("Error writing license key to file: %v", err) - } - - return nil -} - -func ReadLicenseKeyFromFile() (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("Error getting user home directory: %v", err) - } - - licenseFilePath := filepath.Join(homeDir, ".tempxcli") - licenseFile, err := os.Open(licenseFilePath) - if err != nil { - return "", fmt.Errorf("Error opening license file: %v", err) - } - defer licenseFile.Close() - - licenseFileBytes, err := io.ReadAll(licenseFile) - if err != nil { - return "", fmt.Errorf("Error reading license file: %v", err) - } - - licenseKey := string(licenseFileBytes) - return licenseKey, nil -} - -func ClearLicenseFile() error { - homeDir, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("Error getting user home directory: %v", err) - } - - licenseFilePath := filepath.Join(homeDir, ".tempxcli") - err = os.Remove(licenseFilePath) - if err != nil { - return fmt.Errorf("Error deleting license file: %v", err) - } - - return nil -} \ No newline at end of file diff --git a/src/cli/tweet/future.go b/src/cli/tweet/future.go index f39c27b..e329de7 100644 --- a/src/cli/tweet/future.go +++ b/src/cli/tweet/future.go @@ -1,170 +1,7 @@ package tweet -// x t "hi" 5h6m7s - -import ( - "fmt" - "log" - "strconv" - "strings" - "bytes" - "encoding/json" - "io" - "net/http" - - "github.com/devhindo/x/src/cli/lock" -) - -type FutureTweet struct { - License string `json:"license"` - Tweet string `json:"tweet"` - Hours int `json:"hours"` - Minutes int `json:"minutes"` -} +import "fmt" func PostFutureTweet(c []string) { - - url := "http://localhost:3000/api/tweets/future" - - // x t "hi" 5h6m7s - - tweetText, tweetTime, err := handleFutureTweetArgs(c) - if err != nil { - log.SetFlags(0) - log.Fatal(err) - } - - hrs, mins, err := handleTweetTime(tweetTime) - - if err != nil { - log.SetFlags(0) - log.Fatal(err) - } - - license, err := lock.ReadLicenseKeyFromFile() - - if err != nil { - fmt.Println("you are not authenticated | try 'x auth'") - return - } - - tweet := FutureTweet{ - License: license, - Tweet: tweetText, - Hours: hrs, - Minutes: mins, - } - - err = postFutureTweetToServer(url, tweet) - - if err != nil { - log.SetFlags(0) - log.Fatal(err) - } + fmt.Println("Future tweets are not supported in local mode yet.") } - - -func postFutureTweetToServer(url string, t FutureTweet) error { - fmt.Println("unmarchalling") - jsonBytes, err := json.Marshal(t) - if err != nil { - panic(err) - } - - resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) - fmt.Println("posting") - if err != nil { - return fmt.Errorf("can't reach server to post a tweet") - } - fmt.Println("before defer") - defer resp.Body.Close() - fmt.Println("after defer") - _, err = io.ReadAll(resp.Body) - if err != nil { - //Failed to read response. - return fmt.Errorf("can't read server response") - } - - var r response - - - //Convert bytes to String and print - fmt.Println(r.Message) - - return nil -} - -func handleFutureTweetArgs(c []string) (string, string, error) { - if len(c) < 3 { - return "", "", fmt.Errorf("no tweet given | try 'x f --help'") - } - - if c[2] == "-h" || c[2] == "--help" { - fmt.Println("post future tweets") - fmt.Println("using delayed times in this form") - fmt.Println("x f \"hi\" 2h3m") - fmt.Println("h -> hours") - fmt.Println("m -> minutes") - fmt.Println("this tweet would be scheduled to be posted after 2 hours and 3 minuets") - return c[2], c[3], nil - } - - if len(c) < 4 { - fmt.Println("No schedule time is given | try 'x f --help'") - } - return c[2], c[3], nil -} - -func handleTweetTime(t string) (int, int, error) { - hrs := 0 - mins := 0 - - // Check if the string is empty - if len(t) == 0 { - return hrs, mins, fmt.Errorf("empty time string") - } - - containsH := strings.Contains(t, "h") - containsM := strings.Contains(t, "m") - - if containsH && containsM { - // Split the string into hours and minutes - timeParts := strings.Split(t, "h") - if len(timeParts) != 2 { - return hrs, mins, fmt.Errorf("invalid time string") - } - - hours, err := strconv.Atoi(timeParts[0]) - if err != nil { - return hrs, mins, fmt.Errorf("invalid time string") - } - - minutes, err := strconv.Atoi(strings.TrimSuffix(timeParts[1], "m")) - if err != nil { - return hrs, mins, fmt.Errorf("invalid time string") - } - - hrs = hours - mins = minutes - } else if containsH { - // Extract the hours from the string - hours, err := strconv.Atoi(strings.TrimSuffix(t, "h")) - if err != nil { - return hrs, mins, fmt.Errorf("invalid time string") - } - - hrs = hours - } else if containsM { - // Extract the minutes from the string - minutes, err := strconv.Atoi(strings.TrimSuffix(t, "m")) - if err != nil { - return hrs, mins, fmt.Errorf("invalid time string") - } - - mins = minutes - } else { - return hrs, mins, fmt.Errorf("invalid time string") - } - - return hrs, mins, nil -} \ No newline at end of file diff --git a/src/cli/tweet/future_test.go b/src/cli/tweet/future_test.go deleted file mode 100644 index 280487a..0000000 --- a/src/cli/tweet/future_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package tweet - -import ( - "testing" -) - - -func TestHandleTweetTime(t *testing.T) { - tests := []struct { - name string - input string - wantHours int - wantMinutes int - wantErr bool - }{ - { - name: "hours and minutes", - input: "2h3m", - wantHours: 2, - wantMinutes: 3, - wantErr: false, - }, - { - name: "only hours", - input: "5h", - wantHours: 5, - wantMinutes: 0, - wantErr: false, - }, - { - name: "only minutes", - input: "30m", - wantHours: 0, - wantMinutes: 30, - wantErr: false, - }, - { - name: "empty string", - input: "", - wantHours: 0, - wantMinutes: 0, - wantErr: true, - }, - { - name: "invalid format garbage", - input: "abc", - wantHours: 0, - wantMinutes: 0, - wantErr: true, - }, - { - name: "missing suffix", - input: "2h3", - wantHours: 0, - wantMinutes: 0, - wantErr: true, - }, - { - name: "invalid number", - input: "xh", - wantHours: 0, - wantMinutes: 0, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotHours, gotMinutes, err := handleTweetTime(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("handleTweetTime() error = %v, wantErr %v", err, tt.wantErr) - return - } - if gotHours != tt.wantHours { - t.Errorf("handleTweetTime() gotHours = %v, want %v", gotHours, tt.wantHours) - } - if gotMinutes != tt.wantMinutes { - t.Errorf("handleTweetTime() gotMinutes = %v, want %v", gotMinutes, tt.wantMinutes) - } - }) - } -} - -func TestHandleFutureTweetArgs(t *testing.T) { - tests := []struct { - name string - args []string - wantTweet string - wantTime string - wantErr bool - }{ - { - name: "valid args", - args: []string{"x", "f", "hello world", "2h"}, - wantTweet: "hello world", - wantTime: "2h", - wantErr: false, - }, - { - name: "not enough args", - args: []string{"x", "f"}, - wantTweet: "", - wantTime: "", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotTweet, gotTime, err := handleFutureTweetArgs(tt.args) - if (err != nil) != tt.wantErr { - t.Errorf("handleFutureTweetArgs() error = %v, wantErr %v", err, tt.wantErr) - return - } - if gotTweet != tt.wantTweet { - t.Errorf("handleFutureTweetArgs() gotTweet = %v, want %v", gotTweet, tt.wantTweet) - } - if gotTime != tt.wantTime { - t.Errorf("handleFutureTweetArgs() gotTime = %v, want %v", gotTime, tt.wantTime) - } - }) - } -} diff --git a/src/cli/tweet/post.go b/src/cli/tweet/post.go index ff5a473..f0a23fd 100644 --- a/src/cli/tweet/post.go +++ b/src/cli/tweet/post.go @@ -7,68 +7,73 @@ import ( "io" "net/http" "os" + "time" - "github.com/devhindo/x/src/cli/lock" + "github.com/devhindo/x/src/cli/auth" + "github.com/devhindo/x/src/cli/config" ) type Tweet struct { - License string `json:"license"` - Tweet string `json:"tweet"` + Text string `json:"text"` } func POST_tweet(t string) { - - license, err := lock.ReadLicenseKeyFromFile() - + cfg, err := config.LoadConfig() if err != nil { - fmt.Println("you are not authenticated | try 'x auth'") + fmt.Println("Error loading config:", err) os.Exit(1) } - url := "https://x-blush.vercel.app/api/tweets/post" - tweet := Tweet{ - License: license, - Tweet: t, + app := cfg.GetActiveApp() + if app == nil || app.User == nil { + fmt.Println("No active app or user not authenticated. Run 'x init use' or 'x auth'.") + os.Exit(1) } - - postT(url, tweet) -} -type response struct { - Message string `json:"message"` -} + if time.Now().After(app.User.Expiry) { + fmt.Println("Token expired, refreshing...") + if err := auth.RefreshToken(app); err != nil { + fmt.Println("Error refreshing token:", err) + os.Exit(1) + } + } + + url := "https://api.twitter.com/2/tweets" + tweet := Tweet{ + Text: t, + } -func postT(url string, t Tweet) { + jsonBytes, err := json.Marshal(tweet) + if err != nil { + panic(err) + } - jsonBytes, err := json.Marshal(t) - if err != nil { - panic(err) - } + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBytes)) + if err != nil { + fmt.Println("Error creating request:", err) + os.Exit(1) + } - resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) - if err != nil { - fmt.Println("can't reach server to post a tweet") - os.Exit(0) - } + req.Header.Set("Authorization", "Bearer "+app.User.AccessToken) + req.Header.Set("Content-Type", "application/json") - defer resp.Body.Close() + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Println("Error posting tweet:", err) + os.Exit(1) + } + defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - //Failed to read response. panic(err) } + if resp.StatusCode != 201 { + fmt.Printf("Error posting tweet: %s\n", string(body)) + os.Exit(1) + } - var r response - err = json.Unmarshal(body, &r) - if err != nil { - fmt.Printf("rate limit reached thanks Elon! Try again tomorrow when it resets, sorry.") - //Failed to unmarshal response. - return - } - - - //Convert bytes to String and print - fmt.Println(r.Message) -} \ No newline at end of file + fmt.Println("Tweet posted successfully! 🐦") +} diff --git a/src/cli/x/args.go b/src/cli/x/args.go deleted file mode 100644 index 40254f1..0000000 --- a/src/cli/x/args.go +++ /dev/null @@ -1,103 +0,0 @@ -package x - -import ( - "os" - "fmt" - - "github.com/devhindo/x/src/cli/help" - "github.com/devhindo/x/src/cli/auth" - "github.com/devhindo/x/src/cli/tweet" - "github.com/devhindo/x/src/cli/clear" - -) - -func HandleArgs() { - checkArgs() - switch os.Args[1] { - case "help": - checkArgsequals2() - help.Help() - case "--help": - checkArgsequals2() - help.Help() - case "-h": - checkArgsequals2() - help.Help() - case "auth": - if len(os.Args) == 2 { - auth.Auth() - } else if len(os.Args) == 3 && (os.Args[2] == "--verify" || os.Args[2] == "-v") { - auth.Verify() - } else if len(os.Args) == 3 && (os.Args[2] == "--clear" || os.Args[2] == "-c") { - clear.StartOver() - } else if len(os.Args) == 3 && os.Args[2] == "--url" { - auth.Get_url_db() - } else { - fmt.Println("Unknown command | try 'x help'") - os.Exit(0) - } - case "t": - checkTweetArgs() - tweet.POST_tweet(os.Args[2]) - case "-t": - checkTweetArgs() - tweet.POST_tweet(os.Args[2]) - case "tweet": - checkTweetArgs() - tweet.POST_tweet(os.Args[2]) - case "version": - checkArgsequals2() - Version() - case "v": - checkArgsequals2() - Version() - case "-v": - checkArgsequals2() - Version() - case "f": // x -t "hi" 5h6m7s - checkArgsequals2() - tweet.PostFutureTweet(os.Args) - case "-f": - checkArgsequals2() - tweet.PostFutureTweet(os.Args) - default: - - if len(os.Args) != 2 { - fmt.Println("Unknown command | try 'x help'") - os.Exit(0) - } - - tweet.POST_tweet(os.Args[1]) - - } -} - -func checkTweetArgs() { - if len(os.Args) < 3 { - fmt.Println("No tweet given | try 'x help'") - os.Exit(0) - } -} - -func checkArgs() { - if len(os.Args) < 2 { - fmt.Println("No command given | try 'x help'") - os.Exit(0) - } -} - -func checkArgsequals2() { - if len(os.Args) != 2 { - fmt.Println("Unknown command | try 'x help'") - os.Exit(0) - } -} - -func checkFutureTweetArgs() { - if len(os.Args) < 4 { - fmt.Println("No tweet given | try 'x help'") - os.Exit(0) - } - -} - diff --git a/src/cli/x/run.go b/src/cli/x/run.go deleted file mode 100644 index a7f707f..0000000 --- a/src/cli/x/run.go +++ /dev/null @@ -1,5 +0,0 @@ -package x - -func Run() { - HandleArgs() -} \ No newline at end of file diff --git a/src/cli/x/version.go b/src/cli/x/version.go deleted file mode 100644 index ecf6690..0000000 --- a/src/cli/x/version.go +++ /dev/null @@ -1,7 +0,0 @@ -package x - -import "fmt" - -func Version() { - fmt.Println("x CLI v1.1.4") -}