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.
150 lines
3.7 KiB
Go
150 lines
3.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
)
|
|
|
|
func runApps(args []string) error {
|
|
// Accept an optional "list" subcommand: `tinyforge apps` == `tinyforge apps list`.
|
|
if len(args) > 0 && args[0] == "list" {
|
|
args = args[1:]
|
|
}
|
|
fs := flag.NewFlagSet("apps", flag.ExitOnError)
|
|
g := addGlobalFlags(fs)
|
|
fs.Usage = func() {
|
|
fmt.Fprint(os.Stderr, "Usage: tinyforge apps [list] [--base-url URL]\n\nList apps (workloads with a source) and their container state.\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()
|
|
|
|
var workloads []Workload
|
|
if err := sess.client.doJSON(ctx, "GET", "/api/workloads", nil, &workloads); err != nil {
|
|
return err
|
|
}
|
|
|
|
// One extra call fetches every container so state can be shown without an
|
|
// N+1 per-app request.
|
|
var containers []Container
|
|
if err := sess.client.doJSON(ctx, "GET", "/api/containers", nil, &containers); err != nil {
|
|
return err
|
|
}
|
|
byWorkload := map[string][]Container{}
|
|
for _, c := range containers {
|
|
byWorkload[c.WorkloadID] = append(byWorkload[c.WorkloadID], c)
|
|
}
|
|
|
|
apps := make([]Workload, 0, len(workloads))
|
|
for _, w := range workloads {
|
|
if w.isApp() {
|
|
apps = append(apps, w)
|
|
}
|
|
}
|
|
sort.Slice(apps, func(i, j int) bool { return apps[i].Name < apps[j].Name })
|
|
|
|
if len(apps) == 0 {
|
|
fmt.Println("No apps yet. Create one in the web UI, then deploy with 'tinyforge deploy <app>'.")
|
|
return nil
|
|
}
|
|
|
|
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
|
fmt.Fprintln(tw, "NAME\tSOURCE\tSTATE\tID")
|
|
for _, w := range apps {
|
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", w.Name, w.SourceKind, stateSummary(byWorkload[w.ID]), idShort(w.ID))
|
|
}
|
|
return tw.Flush()
|
|
}
|
|
|
|
// stateSummary condenses a workload's containers into one status word.
|
|
func stateSummary(cs []Container) string {
|
|
if len(cs) == 0 {
|
|
return "—"
|
|
}
|
|
running := 0
|
|
for _, c := range cs {
|
|
if c.State == "running" {
|
|
running++
|
|
}
|
|
}
|
|
switch {
|
|
case running == len(cs):
|
|
return "running"
|
|
case running == 0:
|
|
return cs[0].State // e.g. stopped / failed / missing
|
|
default:
|
|
return fmt.Sprintf("%d/%d running", running, len(cs))
|
|
}
|
|
}
|
|
|
|
// resolveApp maps a user-supplied reference (name, full id, or id prefix) to a
|
|
// single app workload. Exact id wins, then exact name, then a unique id prefix.
|
|
func resolveApp(ctx context.Context, c *Client, ref string) (Workload, error) {
|
|
var workloads []Workload
|
|
if err := c.doJSON(ctx, "GET", "/api/workloads", nil, &workloads); err != nil {
|
|
return Workload{}, err
|
|
}
|
|
|
|
var byID, byName, byPrefix []Workload
|
|
for _, w := range workloads {
|
|
if !w.isApp() {
|
|
continue
|
|
}
|
|
switch {
|
|
case w.ID == ref:
|
|
byID = append(byID, w)
|
|
case strings.EqualFold(w.Name, ref):
|
|
byName = append(byName, w)
|
|
case len(ref) >= 6 && strings.HasPrefix(w.ID, ref):
|
|
byPrefix = append(byPrefix, w)
|
|
}
|
|
}
|
|
|
|
if len(byID) == 1 {
|
|
return byID[0], nil
|
|
}
|
|
if len(byName) == 1 {
|
|
return byName[0], nil
|
|
}
|
|
if len(byName) > 1 {
|
|
return Workload{}, ambiguousErr(ref, byName)
|
|
}
|
|
if len(byPrefix) == 1 {
|
|
return byPrefix[0], nil
|
|
}
|
|
if len(byPrefix) > 1 {
|
|
return Workload{}, ambiguousErr(ref, byPrefix)
|
|
}
|
|
return Workload{}, fmt.Errorf("no app matching %q (try 'tinyforge apps list')", ref)
|
|
}
|
|
|
|
func ambiguousErr(ref string, matches []Workload) error {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%q matches multiple apps; use the id:\n", ref)
|
|
for _, w := range matches {
|
|
fmt.Fprintf(&b, " %s %s\n", idShort(w.ID), w.Name)
|
|
}
|
|
return fmt.Errorf("%s", strings.TrimRight(b.String(), "\n"))
|
|
}
|
|
|
|
func idShort(id string) string {
|
|
if len(id) > 8 {
|
|
return id[:8]
|
|
}
|
|
return id
|
|
}
|