32e18937bc
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>
161 lines
4.2 KiB
Go
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
|
|
}
|