Files
ozan d76c6178d2
Build tstudio CLI / build (push) Failing after 3s
feat: tstudio login auto-creates Cursor token + fix OAuth2 Bearer auth
Login flow now:
1. Browser OAuth2 → JWT access token
2. SSH key gen + git credentials
3. Auto-creates a PAT named cursor-<hostname> for Cursor/DeepSeek
4. Displays the PAT with Cursor setup instructions (shown once)

Fixed: API client now sends Bearer prefix for OAuth2 JWT tokens
(was sending "token" prefix which Gitea rejects for JWTs).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 11:53:32 +01:00

96 lines
2.8 KiB
Go

// Copyright 2026 Tinqs Ltd. All rights reserved.
// SPDX-License-Identifier: MIT
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
var httpClient = &http.Client{Timeout: 30 * time.Second}
// apiRequest makes an authenticated request to the Gitea API.
func apiRequest(method, url, token string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", UserAgent)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
// OAuth2 JWT tokens start with "ey", PATs start with "gta_" or are short hex
if len(token) > 100 || strings.HasPrefix(token, "ey") {
req.Header.Set("Authorization", "Bearer "+token)
} else {
req.Header.Set("Authorization", "token "+token)
}
}
return httpClient.Do(req)
}
// apiGet is a shorthand for authenticated GET.
func apiGet(instance, path, token string) (*http.Response, error) {
url := strings.TrimRight(instance, "/") + "/api/v1" + path
return apiRequest("GET", url, token, nil)
}
// apiPost is a shorthand for authenticated POST with JSON body.
func apiPost(instance, path, token string, body io.Reader) (*http.Response, error) {
url := strings.TrimRight(instance, "/") + "/api/v1" + path
return apiRequest("POST", url, token, body)
}
// apiDelete is a shorthand for authenticated DELETE.
func apiDelete(instance, path, token string) (*http.Response, error) {
url := strings.TrimRight(instance, "/") + "/api/v1" + path
return apiRequest("DELETE", url, token, nil)
}
// decodeJSON reads the response body and decodes JSON into v.
func decodeJSON(resp *http.Response, v any) error {
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
return json.NewDecoder(resp.Body).Decode(v)
}
// GiteaUser represents a Gitea user response.
type GiteaUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
}
// GiteaRepo represents a Gitea repository response.
type GiteaRepo struct {
ID int64 `json:"id"`
FullName string `json:"full_name"`
Description string `json:"description"`
Private bool `json:"private"`
Fork bool `json:"fork"`
HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
Stars int `json:"stars_count"`
Size int64 `json:"size"`
}
// GiteaToken represents a Gitea access token.
type GiteaToken struct {
ID int64 `json:"id"`
Name string `json:"name"`
SHA1 string `json:"sha1"`
}