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:
+143
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func runLogs(args []string) error {
|
||||
fs := flag.NewFlagSet("logs", flag.ExitOnError)
|
||||
g := addGlobalFlags(fs)
|
||||
follow := fs.Bool("f", false, "follow the log stream (Ctrl-C to stop)")
|
||||
tail := fs.Int("tail", 200, "number of trailing lines to show (max 5000)")
|
||||
container := fs.String("container", "", "container row id/prefix or role (when an app has several)")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprint(os.Stderr, "Usage: tinyforge logs <app> [-f] [--tail N] [--container CID]\n\nPrint or follow a container's logs.\n")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
fs.Usage()
|
||||
return fmt.Errorf("expected exactly one app (name or id)")
|
||||
}
|
||||
|
||||
sess, err := newSession(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resolveCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
app, err := resolveApp(resolveCtx, sess.client, fs.Arg(0))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var containers []Container
|
||||
if err := sess.client.doJSON(resolveCtx, "GET", "/api/workloads/"+app.ID+"/containers", nil, &containers); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := chooseContainer(containers, *container)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("tail", fmt.Sprintf("%d", *tail))
|
||||
base := "/api/workloads/" + app.ID + "/containers/" + target.ID + "/logs"
|
||||
|
||||
if !*follow {
|
||||
var lines []string
|
||||
if err := sess.client.doJSON(resolveCtx, "GET", base+"?"+q.Encode(), nil, &lines); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range lines {
|
||||
fmt.Println(line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Follow: stream until EOF or Ctrl-C.
|
||||
q.Set("follow", "true")
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
err = sess.client.streamSSE(ctx, base+"?"+q.Encode(), func(payload []byte) error {
|
||||
var frame struct {
|
||||
Line string `json:"line"`
|
||||
}
|
||||
if json.Unmarshal(payload, &frame) != nil {
|
||||
return nil // ignore frames we can't parse
|
||||
}
|
||||
fmt.Println(frame.Line)
|
||||
return nil
|
||||
})
|
||||
if ctx.Err() != nil { // user interrupted — clean exit
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// chooseContainer selects which container to read. With an explicit selector,
|
||||
// it matches the row id (exact or prefix) or the role. Otherwise it uses the
|
||||
// sole container, or the sole running one, and errors with a list when the
|
||||
// choice is ambiguous.
|
||||
func chooseContainer(cs []Container, selector string) (Container, error) {
|
||||
if len(cs) == 0 {
|
||||
return Container{}, fmt.Errorf("app has no containers yet — deploy it first")
|
||||
}
|
||||
|
||||
if selector != "" {
|
||||
var matches []Container
|
||||
for _, c := range cs {
|
||||
if c.ID == selector || strings.EqualFold(c.Role, selector) ||
|
||||
(len(selector) >= 6 && strings.HasPrefix(c.ID, selector)) {
|
||||
matches = append(matches, c)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 1:
|
||||
return matches[0], nil
|
||||
case 0:
|
||||
return Container{}, fmt.Errorf("no container matching %q\n%s", selector, containerList(cs))
|
||||
default:
|
||||
return Container{}, fmt.Errorf("%q matches multiple containers\n%s", selector, containerList(cs))
|
||||
}
|
||||
}
|
||||
|
||||
if len(cs) == 1 {
|
||||
return cs[0], nil
|
||||
}
|
||||
var running []Container
|
||||
for _, c := range cs {
|
||||
if c.State == "running" {
|
||||
running = append(running, c)
|
||||
}
|
||||
}
|
||||
if len(running) == 1 {
|
||||
return running[0], nil
|
||||
}
|
||||
return Container{}, fmt.Errorf("app has %d containers; pick one with --container:\n%s", len(cs), containerList(cs))
|
||||
}
|
||||
|
||||
func containerList(cs []Container) string {
|
||||
var b strings.Builder
|
||||
for _, c := range cs {
|
||||
role := c.Role
|
||||
if role == "" {
|
||||
role = "(default)"
|
||||
}
|
||||
fmt.Fprintf(&b, " %s %-12s %s\n", idShort(c.ID), role, c.State)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
Reference in New Issue
Block a user