Files
ozan 32e18937bc
Build tinqs-git / build (push) Failing after 13m1s
Build tstudio CLI / build (push) Failing after 13m1s
feat: tstudio CLI v0.3.0 — SSH key gen on login, origin migration, SETUP.md
Login now does full machine setup in one command:
- Creates API token
- Generates ed25519 SSH key + registers it with Gitea
- Configures ~/.ssh/config for ssh.tinqs.com
- Sets up HTTPS credential helper + SSH→HTTPS rewrite

New commands:
- tstudio migrate: rewrites old git.arikigame.com remotes to tinqs.com
- SETUP.md: agent-executable setup guide for any machine

No more separate tokens for bot/cursor/agents. One login, everything works.

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

161 lines
4.2 KiB
Go

// Copyright 2026 Tinqs Ltd. All rights reserved.
// SPDX-License-Identifier: MIT
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"golang.org/x/crypto/ssh"
)
const sshKeyName = "tstudio_ed25519"
// setupSSHKey generates an SSH key pair, registers the public key with Gitea,
// and configures SSH to use it for the instance. One command, full setup.
func setupSSHKey(cfg *Config) error {
keyPath := sshKeyPath()
pubPath := keyPath + ".pub"
// Generate key pair if it doesn't exist
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
fmt.Printf(" Generating SSH key: %s\n", keyPath)
if err := generateED25519Key(keyPath); err != nil {
return fmt.Errorf("key generation failed: %v", err)
}
} else {
fmt.Printf(" SSH key exists: %s\n", keyPath)
}
// Read public key
pubBytes, err := os.ReadFile(pubPath)
if err != nil {
return fmt.Errorf("cannot read public key: %v", err)
}
pubKey := strings.TrimSpace(string(pubBytes))
// Register with Gitea (idempotent — if key exists, Gitea returns 422)
title := fmt.Sprintf("tstudio-%s", hostname())
body := fmt.Sprintf(`{"title":%q,"key":%q}`, title, pubKey)
resp, err := apiPost(cfg.Instance, "/user/keys", cfg.Token, strings.NewReader(body))
if err != nil {
return fmt.Errorf("cannot register SSH key: %v", err)
}
resp.Body.Close()
if resp.StatusCode == 201 {
fmt.Printf(" SSH key registered: %s\n", title)
} else if resp.StatusCode == 422 {
fmt.Printf(" SSH key already registered\n")
} else {
fmt.Printf(" SSH key registration: HTTP %d (may already exist)\n", resp.StatusCode)
}
// Configure SSH to use this key for the instance host
if err := configureSSH(cfg.Instance); err != nil {
return fmt.Errorf("SSH config failed: %v", err)
}
return nil
}
func sshKeyPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".ssh", sshKeyName)
}
func generateED25519Key(path string) error {
// Ensure .ssh directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
// Generate key pair
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
// Marshal private key to OpenSSH format
sshPub, err := ssh.NewPublicKey(pubKey)
if err != nil {
return err
}
privPEM, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
return err
}
// Write private key
if err := os.WriteFile(path, pem.EncodeToMemory(privPEM), 0o600); err != nil {
return err
}
// Write public key
pubLine := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))) + " tstudio@" + hostname()
if err := os.WriteFile(path+".pub", []byte(pubLine+"\n"), 0o644); err != nil {
return err
}
return nil
}
// configureSSH adds a Host entry to ~/.ssh/config for the instance.
func configureSSH(instance string) error {
host := strings.TrimPrefix(instance, "https://")
host = strings.TrimPrefix(host, "http://")
host = strings.TrimRight(host, "/")
// SSH host for tinqs.com is ssh.tinqs.com
sshHost := "ssh." + host
if strings.HasPrefix(host, "git.") {
sshHost = strings.Replace(host, "git.", "ssh.", 1)
}
home, _ := os.UserHomeDir()
configPath := filepath.Join(home, ".ssh", "config")
keyPath := sshKeyPath()
// Check if entry already exists
existing, _ := os.ReadFile(configPath)
marker := fmt.Sprintf("# tstudio:%s", host)
if strings.Contains(string(existing), marker) {
fmt.Printf(" SSH config already set for %s\n", sshHost)
return nil
}
// Build the SSH config block
var block string
if runtime.GOOS == "windows" {
// Windows uses backslashes in paths
block = fmt.Sprintf("\n%s\nHost %s\n HostName %s\n User git\n IdentityFile %s\n IdentitiesOnly yes\n",
marker, sshHost, sshHost, keyPath)
} else {
block = fmt.Sprintf("\n%s\nHost %s\n HostName %s\n User git\n IdentityFile %s\n IdentitiesOnly yes\n",
marker, sshHost, sshHost, keyPath)
}
// Append to SSH config
f, err := os.OpenFile(configPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("cannot write SSH config: %v", err)
}
defer f.Close()
if _, err := f.WriteString(block); err != nil {
return err
}
fmt.Printf(" SSH config updated: %s → %s\n", sshHost, keyPath)
return nil
}