00503b4c0a
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.
123 lines
3.1 KiB
Go
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
|
|
}
|