feat: tstudio v0.4.0 — browser OAuth2 login, getting-started page
Login rewritten to use OAuth2 PKCE browser flow: - Opens browser → user authenticates on tinqs.com → callback to localhost - No passwords in terminal, same UX as gcloud/gh auth login - PKCE (RFC 7636) with S256 code challenge - Stores refresh_token for silent token renewal - Logout clears git credentials Server-side: - Registered tstudio-cli as built-in OAuth2 app (models/auth/oauth2.go) - Added to default applications list (modules/setting/oauth2.go) - New /cli/getting-started route + public onboarding page - Teaches Cursor setup, agent workflow, git operations First login redirects to getting-started page automatically. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-5
@@ -10,14 +10,20 @@ import (
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const DefaultInstance = "https://git.tinqs.com"
|
||||
const DefaultInstance = "https://tinqs.com"
|
||||
|
||||
// OAuthClientID is the built-in OAuth2 application for tstudio CLI.
|
||||
// Registered in models/auth/oauth2.go BuiltinApplications() — public client, no secret.
|
||||
const OAuthClientID = "b8f4e9a1-7c3d-4f2e-a1d5-6e8b9c0f3a2d"
|
||||
|
||||
// Config holds all stored state for tstudio.
|
||||
type Config struct {
|
||||
Instance string `json:"instance"` // e.g. "https://git.tinqs.com"
|
||||
Username string `json:"username"` // e.g. "ozan"
|
||||
Token string `json:"token"` // Gitea API token
|
||||
TokenID int64 `json:"token_id,omitempty"` // Gitea token ID (for revocation)
|
||||
Instance string `json:"instance"` // e.g. "https://tinqs.com"
|
||||
Username string `json:"username"` // e.g. "ozan"
|
||||
Token string `json:"token"` // OAuth2 access token
|
||||
RefreshToken string `json:"refresh_token,omitempty"` // OAuth2 refresh token
|
||||
TokenID int64 `json:"token_id,omitempty"` // legacy: Gitea token ID
|
||||
FirstLogin bool `json:"first_login,omitempty"` // true on very first login on this machine
|
||||
}
|
||||
|
||||
func configDir() string {
|
||||
|
||||
+233
-77
@@ -4,18 +4,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
"golang.org/x/term"
|
||||
const (
|
||||
oauthScopes = "all"
|
||||
codeVerifierLength = 64
|
||||
authTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
func cmdLogin(args []string) {
|
||||
@@ -23,107 +32,219 @@ func cmdLogin(args []string) {
|
||||
instance := fs.String("instance", DefaultInstance, "Tinqs Studio instance URL")
|
||||
fs.Parse(args)
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
base := strings.TrimRight(*instance, "/")
|
||||
|
||||
fmt.Printf("Logging in to %s\n\n", *instance)
|
||||
// Check if this is a first-ever login on this machine
|
||||
existing, _ := loadConfig()
|
||||
isFirstLogin := existing == nil
|
||||
|
||||
// Username
|
||||
fmt.Print("Username: ")
|
||||
username, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
fatal("username cannot be empty")
|
||||
}
|
||||
|
||||
// Password (hidden)
|
||||
fmt.Print("Password: ")
|
||||
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println() // newline after hidden input
|
||||
// 1. Start local server on random port
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
fatal("failed to read password: %v", err)
|
||||
}
|
||||
password := string(pwBytes)
|
||||
if password == "" {
|
||||
fatal("password cannot be empty")
|
||||
fatal("cannot start local server: %v", err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
|
||||
// Create API token via basic auth
|
||||
tokenName := fmt.Sprintf("tstudio-%s", hostname())
|
||||
body := fmt.Sprintf(`{"name":%q,"scopes":["all"]}`, tokenName)
|
||||
|
||||
url := strings.TrimRight(*instance, "/") + "/api/v1/users/" + username + "/tokens"
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(body))
|
||||
// 2. Generate PKCE code verifier + challenge (RFC 7636)
|
||||
codeVerifier, err := generateCodeVerifier(codeVerifierLength)
|
||||
if err != nil {
|
||||
fatal("failed to create request: %v", err)
|
||||
fatal("cannot generate code verifier: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
codeChallenge := generateCodeChallenge(codeVerifier)
|
||||
|
||||
// 3. Generate CSRF state
|
||||
stateBytes := make([]byte, 16)
|
||||
rand.Read(stateBytes)
|
||||
state := base64.RawURLEncoding.EncodeToString(stateBytes)
|
||||
|
||||
// 4. Build authorization URL
|
||||
authorizeURL := fmt.Sprintf("%s/login/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code&state=%s&scope=%s&code_challenge=%s&code_challenge_method=S256",
|
||||
base,
|
||||
url.QueryEscape(OAuthClientID),
|
||||
url.QueryEscape(redirectURI),
|
||||
url.QueryEscape(state),
|
||||
url.QueryEscape(oauthScopes),
|
||||
url.QueryEscape(codeChallenge),
|
||||
)
|
||||
|
||||
// 5. Open browser
|
||||
fmt.Printf("Opening %s ...\n", base)
|
||||
fmt.Println("Waiting for you to authorize in the browser...\n")
|
||||
fmt.Printf("If the browser doesn't open, visit:\n%s\n\n", authorizeURL)
|
||||
openBrowser(authorizeURL)
|
||||
|
||||
// 6. Wait for callback
|
||||
var authCode string
|
||||
var authErr error
|
||||
|
||||
srv := &http.Server{}
|
||||
srv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("state") != state {
|
||||
authErr = fmt.Errorf("invalid state — possible CSRF attack")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, authPage("Error", "Invalid state parameter. Try again.", true))
|
||||
go srv.Shutdown(context.Background())
|
||||
return
|
||||
}
|
||||
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
|
||||
authErr = fmt.Errorf("authorization denied: %s", errMsg)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, authPage("Denied", "Authorization was denied. You can close this tab.", true))
|
||||
go srv.Shutdown(context.Background())
|
||||
return
|
||||
}
|
||||
authCode = r.URL.Query().Get("code")
|
||||
if authCode == "" {
|
||||
authErr = fmt.Errorf("no authorization code received")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, authPage("Error", "No authorization code received.", true))
|
||||
go srv.Shutdown(context.Background())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, authPage("Logged In", "You can close this tab and return to the terminal.", false))
|
||||
go srv.Shutdown(context.Background())
|
||||
})
|
||||
|
||||
// Timeout
|
||||
go func() {
|
||||
time.Sleep(authTimeout)
|
||||
if authCode == "" && authErr == nil {
|
||||
authErr = fmt.Errorf("timed out waiting for authorization (5 minutes)")
|
||||
srv.Shutdown(context.Background())
|
||||
}
|
||||
}()
|
||||
|
||||
srv.Serve(listener)
|
||||
|
||||
if authErr != nil {
|
||||
fatal("%v", authErr)
|
||||
}
|
||||
|
||||
// 7. Exchange code for token (with PKCE code_verifier)
|
||||
fmt.Println("Exchanging token...")
|
||||
tokenURL := base + "/login/oauth/access_token"
|
||||
data := url.Values{
|
||||
"client_id": {OAuthClientID},
|
||||
"code": {authCode},
|
||||
"grant_type": {"authorization_code"},
|
||||
"redirect_uri": {redirectURI},
|
||||
"code_verifier": {codeVerifier},
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
req.SetBasicAuth(username, password)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
fatal("failed to connect to %s: %v", *instance, err)
|
||||
fatal("token exchange failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
fatal("invalid username or password")
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnprocessableEntity {
|
||||
// Token with this name may already exist — try with a suffix
|
||||
tokenName = fmt.Sprintf("tstudio-%s-%d", hostname(), os.Getpid())
|
||||
body = fmt.Sprintf(`{"name":%q,"scopes":["all"]}`, tokenName)
|
||||
req, _ = http.NewRequest("POST", url, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
req.SetBasicAuth(username, password)
|
||||
resp, err = httpClient.Do(req)
|
||||
if err != nil {
|
||||
fatal("retry failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
fatal("cannot parse token response: %v", err)
|
||||
}
|
||||
if tokenResp.Error != "" {
|
||||
fatal("token exchange failed: %s", tokenResp.Error)
|
||||
}
|
||||
if tokenResp.AccessToken == "" {
|
||||
fatal("server returned empty access token")
|
||||
}
|
||||
|
||||
var token GiteaToken
|
||||
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
|
||||
fatal("failed to parse response: %v", err)
|
||||
// 8. Verify token and get user info
|
||||
userResp, err := apiGet(base, "/user", tokenResp.AccessToken)
|
||||
if err != nil {
|
||||
fatal("cannot verify token: %v", err)
|
||||
}
|
||||
if token.SHA1 == "" {
|
||||
fatal("server returned empty token (HTTP %d)", resp.StatusCode)
|
||||
var user GiteaUser
|
||||
if err := decodeJSON(userResp, &user); err != nil {
|
||||
fatal("cannot get user info: %v", err)
|
||||
}
|
||||
|
||||
// 9. Save config
|
||||
cfg := &Config{
|
||||
Instance: *instance,
|
||||
Username: username,
|
||||
Token: token.SHA1,
|
||||
TokenID: token.ID,
|
||||
Instance: base,
|
||||
Username: user.Login,
|
||||
Token: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
FirstLogin: isFirstLogin,
|
||||
}
|
||||
if err := saveConfig(cfg); err != nil {
|
||||
fatal("failed to save credentials: %v", err)
|
||||
fatal("cannot save credentials: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\nLogged in as %s on %s\n", username, *instance)
|
||||
fmt.Printf("Token stored in %s\n", configPath())
|
||||
fmt.Printf("\nLogged in as %s on %s\n", user.Login, base)
|
||||
|
||||
// Full machine setup: SSH key + git credentials
|
||||
// 10. Full machine setup
|
||||
fmt.Println("\nSetting up SSH key...")
|
||||
if err := setupSSHKey(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: SSH key setup failed: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "You can still use HTTPS. Run 'tstudio setup-git' to retry.\n")
|
||||
}
|
||||
|
||||
fmt.Println("\nConfiguring git credentials...")
|
||||
if err := setupGitCredentials(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not configure git: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Run 'tstudio setup-git' manually to fix.\n")
|
||||
fmt.Fprintf(os.Stderr, "Warning: git credential setup failed: %v\n", err)
|
||||
}
|
||||
|
||||
// Open welcome page in browser
|
||||
welcomeURL := strings.TrimRight(*instance, "/") + "/cli/welcome"
|
||||
fmt.Printf("\nOpening %s ...\n", welcomeURL)
|
||||
openBrowser(welcomeURL)
|
||||
// 11. First login → open getting started guide
|
||||
if isFirstLogin {
|
||||
guideURL := base + "/cli/getting-started"
|
||||
fmt.Printf("\nOpening getting started guide: %s\n", guideURL)
|
||||
openBrowser(guideURL)
|
||||
}
|
||||
|
||||
fmt.Println("\nDone. You're all set.")
|
||||
}
|
||||
|
||||
// refreshToken silently refreshes the access token using the refresh token.
|
||||
func refreshToken(cfg *Config) error {
|
||||
if cfg.RefreshToken == "" {
|
||||
return fmt.Errorf("no refresh token — run 'tstudio login'")
|
||||
}
|
||||
|
||||
tokenURL := cfg.Instance + "/login/oauth/access_token"
|
||||
data := url.Values{
|
||||
"client_id": {OAuthClientID},
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {cfg.RefreshToken},
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return err
|
||||
}
|
||||
if tokenResp.Error != "" {
|
||||
return fmt.Errorf("%s", tokenResp.Error)
|
||||
}
|
||||
|
||||
cfg.Token = tokenResp.AccessToken
|
||||
if tokenResp.RefreshToken != "" {
|
||||
cfg.RefreshToken = tokenResp.RefreshToken
|
||||
}
|
||||
return saveConfig(cfg)
|
||||
}
|
||||
|
||||
func openBrowser(url string) {
|
||||
@@ -141,17 +262,18 @@ func openBrowser(url string) {
|
||||
|
||||
func cmdLogout(args []string) {
|
||||
cfg, _ := loadConfig()
|
||||
if cfg != nil && cfg.Token != "" && cfg.TokenID > 0 {
|
||||
// Revoke the token on the server
|
||||
path := fmt.Sprintf("/users/%s/tokens/%d", cfg.Username, cfg.TokenID)
|
||||
resp, err := apiDelete(cfg.Instance, path, cfg.Token)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if cfg != nil && cfg.Token != "" {
|
||||
// Clear git credentials for this host
|
||||
host := strings.TrimPrefix(cfg.Instance, "https://")
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
credInput := fmt.Sprintf("protocol=https\nhost=%s\nusername=%s\npassword=%s\n\n", host, cfg.Username, cfg.Token)
|
||||
credCmd := exec.Command("git", "credential", "reject")
|
||||
credCmd.Stdin = strings.NewReader(credInput)
|
||||
credCmd.Run() // best-effort
|
||||
}
|
||||
|
||||
if err := deleteConfig(); err != nil {
|
||||
fatal("failed to remove credentials: %v", err)
|
||||
fatal("cannot remove credentials: %v", err)
|
||||
}
|
||||
fmt.Println("Logged out. Credentials removed.")
|
||||
}
|
||||
@@ -163,3 +285,37 @@ func hostname() string {
|
||||
}
|
||||
return strings.ToLower(h)
|
||||
}
|
||||
|
||||
// PKCE helpers (RFC 7636)
|
||||
|
||||
func generateCodeVerifier(length int) (string, error) {
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)[:length], nil
|
||||
}
|
||||
|
||||
func generateCodeChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// HTML pages shown in browser during auth flow
|
||||
|
||||
func authPage(title, message string, isError bool) string {
|
||||
color := "#c9935a"
|
||||
if isError {
|
||||
color = "#e84855"
|
||||
}
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html><head><title>tstudio — %s</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0f;color:#e8e4df;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
||||
.box{text-align:center;padding:48px}
|
||||
h1{color:%s;font-size:2rem;margin-bottom:8px}
|
||||
p{color:#9e9890;font-size:1.1rem}
|
||||
</style></head><body>
|
||||
<div class="box"><h1>%s</h1><p>%s</p></div>
|
||||
</body></html>`, title, color, title, message)
|
||||
}
|
||||
|
||||
@@ -80,6 +80,11 @@ func BuiltinApplications() map[string]*BuiltinOAuth2Application {
|
||||
DisplayName: "tea",
|
||||
RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"},
|
||||
}
|
||||
m["b8f4e9a1-7c3d-4f2e-a1d5-6e8b9c0f3a2d"] = &BuiltinOAuth2Application{
|
||||
ConfigName: "tstudio-cli",
|
||||
DisplayName: "Tinqs Studio CLI",
|
||||
RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ var OAuth2 = struct {
|
||||
JWTSigningAlgorithm: "RS256",
|
||||
JWTSigningPrivateKeyFile: "jwt/private.pem",
|
||||
MaxTokenLength: math.MaxInt16,
|
||||
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
|
||||
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea", "tstudio-cli"},
|
||||
}
|
||||
|
||||
func loadOAuth2From(rootCfg ConfigProvider) {
|
||||
|
||||
@@ -11,9 +11,16 @@ import (
|
||||
)
|
||||
|
||||
const tplCLIWelcome templates.TplName = "tstudio/welcome"
|
||||
const tplCLIGettingStarted templates.TplName = "tstudio/getting-started"
|
||||
|
||||
// CLIWelcome renders the post-login success page for tstudio CLI
|
||||
func CLIWelcome(ctx *context.Context) {
|
||||
ctx.Data["Title"] = "Authenticated with tstudio"
|
||||
ctx.HTML(http.StatusOK, tplCLIWelcome)
|
||||
}
|
||||
|
||||
// CLIGettingStarted renders the first-time setup guide (public, no auth required)
|
||||
func CLIGettingStarted(ctx *context.Context) {
|
||||
ctx.Data["Title"] = "Getting Started — Tinqs Studio CLI"
|
||||
ctx.HTML(http.StatusOK, tplCLIGettingStarted)
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
|
||||
// tstudio CLI pages
|
||||
m.Get("/cli/welcome", CLIWelcome)
|
||||
m.Get("/cli/getting-started", CLIGettingStarted)
|
||||
|
||||
m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup)
|
||||
m.Post("/-/web-banner/dismiss", misc.WebBannerDismiss)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Getting Started — Tinqs Studio CLI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, 'SF Pro Text', system-ui, sans-serif;
|
||||
background: #0a0a0f;
|
||||
color: #e8e4df;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.container { max-width: 720px; margin: 0 auto; padding: 60px 24px 80px; }
|
||||
h1 { font-size: 2.4rem; font-weight: 300; margin-bottom: 8px; }
|
||||
h1 span { color: #c9935a; }
|
||||
.subtitle { color: #9e9890; font-size: 1.1rem; margin-bottom: 48px; }
|
||||
h2 {
|
||||
color: #c9935a;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 40px 0 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #1e1c24;
|
||||
}
|
||||
h2 .step {
|
||||
display: inline-block;
|
||||
background: #c9935a;
|
||||
color: #0a0a0f;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 8px;
|
||||
}
|
||||
p { color: #b8b3ad; margin-bottom: 12px; }
|
||||
code {
|
||||
background: #14131a;
|
||||
border: 1px solid #1e1c24;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #e0b87a;
|
||||
}
|
||||
pre {
|
||||
background: #14131a;
|
||||
border: 1px solid #1e1c24;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
margin: 12px 0 20px;
|
||||
overflow-x: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
color: #e8e4df;
|
||||
}
|
||||
pre .comment { color: #6b6660; }
|
||||
.card {
|
||||
background: #14131a;
|
||||
border: 1px solid #1e1c24;
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.card h3 { color: #e0b87a; font-size: 1rem; margin-bottom: 8px; }
|
||||
.card p { color: #9e9890; margin-bottom: 0; }
|
||||
.done {
|
||||
margin-top: 48px;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #1a1510 0%, #14131a 100%);
|
||||
border: 1px solid #c9935a33;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.done h3 { color: #c9935a; margin-bottom: 8px; }
|
||||
.done p { color: #9e9890; }
|
||||
a { color: #c9935a; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.check { color: #4ade80; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Welcome to <span>Tinqs Studio</span></h1>
|
||||
<p class="subtitle">Your machine is connected. Here's how to work with the platform.</p>
|
||||
|
||||
<h2><span class="step">1</span> What just happened</h2>
|
||||
<p>When you ran <code>tstudio login</code>, the CLI:</p>
|
||||
<div class="card">
|
||||
<p><span class="check">✓</span> Authenticated via your browser (OAuth2)</p>
|
||||
<p><span class="check">✓</span> Generated an SSH key and registered it with your account</p>
|
||||
<p><span class="check">✓</span> Configured git so push/pull works over both SSH and HTTPS</p>
|
||||
<p><span class="check">✓</span> Installed itself to your PATH</p>
|
||||
</div>
|
||||
<p>Both <code>git clone git@ssh.tinqs.com:tinqs/repo.git</code> and HTTPS clones work now.</p>
|
||||
|
||||
<h2><span class="step">2</span> Clone a project</h2>
|
||||
<pre>tstudio clone tinqs/studio</pre>
|
||||
<p>Or use regular git — credentials are already configured:</p>
|
||||
<pre>git clone git@ssh.tinqs.com:tinqs/studio.git</pre>
|
||||
|
||||
<h2><span class="step">3</span> Set up Cursor</h2>
|
||||
<p>Cursor (and Claude Code, Windsurf, etc.) work automatically once your machine is connected. The agents use your machine's git credentials — no extra tokens needed.</p>
|
||||
|
||||
<p><strong>For a new project in Cursor:</strong></p>
|
||||
<pre><span class="comment"># Clone the repo</span>
|
||||
tstudio clone tinqs/your-project
|
||||
<span class="comment"># Open in Cursor</span>
|
||||
cursor tinqs/your-project</pre>
|
||||
|
||||
<p><strong>For an existing project:</strong> If you have repos from the old <code>git.arikigame.com</code>, fix the remotes first:</p>
|
||||
<pre>tstudio migrate --dir ~/projects</pre>
|
||||
|
||||
<p><strong>Agent configuration:</strong> If the repo has a <code>.cursor/</code> directory, it contains agent rules that Cursor picks up automatically. The platform provides these for each project — you don't need to write your own.</p>
|
||||
|
||||
<h2><span class="step">4</span> Verify everything works</h2>
|
||||
<pre>tstudio doctor</pre>
|
||||
<p>This checks git, SSH, your auth token, and connectivity. All checks should pass.</p>
|
||||
|
||||
<h2><span class="step">5</span> Useful commands</h2>
|
||||
<div class="card">
|
||||
<h3>Day-to-day</h3>
|
||||
<p><code>tstudio repos</code> — list your repositories</p>
|
||||
<p><code>tstudio clone owner/repo</code> — clone with credentials</p>
|
||||
<p><code>tstudio whoami</code> — show your identity</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Management</h3>
|
||||
<p><code>tstudio token create --name ci-bot</code> — create API tokens for agents</p>
|
||||
<p><code>tstudio update</code> — update the CLI to latest version</p>
|
||||
<p><code>tstudio migrate --dir path</code> — fix old remotes from arikigame.com</p>
|
||||
</div>
|
||||
|
||||
<div class="done">
|
||||
<h3>You're ready</h3>
|
||||
<p>Clone a project, open it in your editor, and start building. The platform handles the rest.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user