feat(cli): add tinyforge terminal client
New zero-dependency Go CLI (cmd/cli) that drives the existing HTTP API: login/logout, apps list, deploy (synchronous, --timeout), logs (one-shot + -f SSE follow), and status. Caches a 24h JWT in ~/.tinyforge/config.json (0600, Chmod-enforced on overwrite); Bearer-header auth keeps the token out of server/proxy logs; no-echo password prompt (kernel32 on Windows, stty elsewhere). Server/token resolved via flags, TINYFORGE_URL/TINYFORGE_TOKEN env, or config. README CLI section + root-anchored .gitignore entries for the build output.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func runLogin(args []string) error {
|
||||
fs := flag.NewFlagSet("login", flag.ExitOnError)
|
||||
g := addGlobalFlags(fs)
|
||||
user := fs.String("user", "", "username (prompted if omitted)")
|
||||
pass := fs.String("password", "", "password (insecure; prefer $TINYFORGE_PASSWORD or the prompt)")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprint(os.Stderr, "Usage: tinyforge login [--user U] [--password P] [--base-url URL]\n\n"+
|
||||
"Authenticate against the server and cache the token.\n")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sess, err := newSession(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := *user
|
||||
if username == "" {
|
||||
username, err = promptLine("Username: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
password := *pass
|
||||
if password == "" {
|
||||
password = os.Getenv("TINYFORGE_PASSWORD")
|
||||
}
|
||||
if password == "" {
|
||||
password, err = promptPassword("Password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if username == "" || password == "" {
|
||||
return fmt.Errorf("username and password are required")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var tok SessionToken
|
||||
body := map[string]string{"username": username, "password": password}
|
||||
if err := sess.client.doJSON(ctx, "POST", "/api/auth/login", body, &tok); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Persist the resolved base URL alongside the token so later commands need
|
||||
// no flags. The token file is written 0600 by saveConfig.
|
||||
sess.cfg.BaseURL = sess.client.baseURL
|
||||
sess.cfg.Token = tok.Token
|
||||
sess.cfg.ExpiresAt = tok.ExpiresAt
|
||||
if err := saveConfig(sess.configPath, sess.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Logged in to %s as %s.\n", sess.client.baseURL, username)
|
||||
if exp := friendlyExpiry(tok.ExpiresAt); exp != "" {
|
||||
fmt.Printf("Token valid until %s.\n", exp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runLogout(args []string) error {
|
||||
fs := flag.NewFlagSet("logout", flag.ExitOnError)
|
||||
g := addGlobalFlags(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sess, err := newSession(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sess.client.token == "" {
|
||||
fmt.Println("Not logged in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Best-effort server-side revocation; clear the local token regardless.
|
||||
revokeErr := sess.client.doJSON(ctx, "POST", "/api/auth/logout", nil, nil)
|
||||
|
||||
sess.cfg.Token = ""
|
||||
sess.cfg.ExpiresAt = ""
|
||||
if err := saveConfig(sess.configPath, sess.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if revokeErr != nil {
|
||||
fmt.Printf("Cleared local token (server revocation skipped: %v).\n", revokeErr)
|
||||
return nil
|
||||
}
|
||||
fmt.Println("Logged out.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// promptLine reads a single trimmed line from stdin.
|
||||
func promptLine(label string) (string, error) {
|
||||
fmt.Fprint(os.Stderr, label)
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return "", fmt.Errorf("read input: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
|
||||
// friendlyExpiry formats an RFC3339 expiry as a local time, best-effort.
|
||||
func friendlyExpiry(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return t.Local().Format("2006-01-02 15:04 MST")
|
||||
}
|
||||
Reference in New Issue
Block a user