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,122 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user