Files
tiny-forge/cmd/cli/status.go
alexei.dolgolyov 00503b4c0a 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.
2026-06-02 13:34:42 +03:00

123 lines
3.1 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"os"
"text/tabwriter"
"time"
)
func runStatus(args []string) error {
fs := flag.NewFlagSet("status", flag.ExitOnError)
g := addGlobalFlags(fs)
fs.Usage = func() {
fmt.Fprint(os.Stderr, "Usage: tinyforge status [<app>]\n\nWith no app: server health and the logged-in user.\nWith an app: that app's containers.\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return err
}
sess, err := newSession(g)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if fs.NArg() == 0 {
return serverStatus(ctx, sess)
}
return appStatus(ctx, sess.client, fs.Arg(0))
}
func serverStatus(ctx context.Context, sess *session) error {
fmt.Printf("Server: %s\n", sess.client.baseURL)
var me User
if err := sess.client.doJSON(ctx, "GET", "/api/auth/me", nil, &me); err != nil {
fmt.Printf("User: not logged in (%v)\n", err)
} else {
fmt.Printf("User: %s (%s)\n", me.Username, me.Role)
}
if exp := friendlyExpiry(sess.cfg.ExpiresAt); exp != "" {
fmt.Printf("Token: valid until %s\n", exp)
}
var health map[string]any
if err := sess.client.doJSON(ctx, "GET", "/api/health", nil, &health); err != nil {
return err
}
fmt.Printf("DB: %s\n", connState(health, "database"))
docker := connState(health, "docker")
if v := nestedString(health, "docker", "version"); v != "" {
docker += " (v" + v + ")"
}
fmt.Printf("Docker: %s\n", docker)
if _, ok := health["proxy"]; ok {
fmt.Printf("Proxy: %s\n", connState(health, "proxy"))
}
return nil
}
func appStatus(ctx context.Context, c *Client, ref string) error {
app, err := resolveApp(ctx, c, ref)
if err != nil {
return err
}
var containers []Container
if err := c.doJSON(ctx, "GET", "/api/workloads/"+app.ID+"/containers", nil, &containers); err != nil {
return err
}
fmt.Printf("%s (%s, %s)\n", app.Name, app.SourceKind, idShort(app.ID))
if len(containers) == 0 {
fmt.Println("No containers — not deployed yet.")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(tw, "ROLE\tSTATE\tIMAGE\tPORT\tSUBDOMAIN\tCONTAINER")
for _, c := range containers {
role := c.Role
if role == "" {
role = "(default)"
}
port := ""
if c.Port != 0 {
port = fmt.Sprintf("%d", c.Port)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
role, c.State, c.ImageRef, port, c.Subdomain, idShort(c.ID))
}
return tw.Flush()
}
// connState reads health[section].connected and renders connected/disconnected,
// appending the section's error string when present.
func connState(health map[string]any, section string) string {
m, ok := health[section].(map[string]any)
if !ok {
return "unknown"
}
connected, _ := m["connected"].(bool)
if connected {
return "connected"
}
if msg, ok := m["error"].(string); ok && msg != "" {
return "disconnected (" + msg + ")"
}
return "disconnected"
}
func nestedString(m map[string]any, section, key string) string {
sub, ok := m[section].(map[string]any)
if !ok {
return ""
}
s, _ := sub[key].(string)
return s
}