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:
2026-06-02 13:34:42 +03:00
parent 97f338fba3
commit 00503b4c0a
12 changed files with 1224 additions and 0 deletions
+3
View File
@@ -6,7 +6,10 @@ data/
.env
tinyforge
tinyforge.exe
/cli
/cli.exe
server.exe
tinyforge-server.exe
docker-watcher
docker-watcher.exe
docker-watcher.exe~
+40
View File
@@ -115,6 +115,46 @@ curl -X POST https://your-domain/api/webhook/<secret> \
3. Enter your provider's Issuer URL, Client ID, and Client Secret
4. Set the Redirect URL to `https://your-domain/api/auth/oidc/callback`
## CLI
`tinyforge` is a terminal client for driving a server from the shell, built on the same HTTP API as the web UI.
### Build
```bash
go build -o tinyforge ./cmd/cli # ./tinyforge (tinyforge.exe on Windows)
```
### Usage
```bash
# Log in once — caches a 24h token in ~/.tinyforge/config.json (mode 0600)
tinyforge login --base-url http://localhost:8090
# ...or non-interactively (no password echo / shell-history leak):
TINYFORGE_PASSWORD=… tinyforge login --base-url http://localhost:8090 --user admin
tinyforge apps # list apps + container state
tinyforge deploy my-app # deploy and wait for completion
tinyforge deploy my-app --ref v1.2.3 --note "hotfix"
tinyforge logs my-app -f # follow logs (Ctrl-C to stop)
tinyforge status # server health + current user
tinyforge status my-app # one app's containers
tinyforge logout # revoke + clear the cached token
```
### Server & token resolution
| Setting | Flag | Env | Default |
| -------- | ------------ | ----------------- | ------------------------ |
| Base URL | `--base-url` | `TINYFORGE_URL` | `http://localhost:8080` |
| Token | `--token` | `TINYFORGE_TOKEN` | cached by `login` |
| Config | `--config` | `TINYFORGE_CONFIG`| `~/.tinyforge/config.json` |
### Notes
- Login returns a **24h JWT** — there is no long-lived API token yet, so unattended use re-logs in when the token expires. `deploy` / `stop` / `start` require an **admin** account.
- The token is sent as an `Authorization: Bearer` header (never placed in the URL) and the config file is written with `0600` permissions.
## Development
```bash
+149
View File
@@ -0,0 +1,149 @@
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
}
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
// apiError carries the server's error message plus the HTTP status, so callers
// can distinguish auth failures (401) from other errors without losing the
// server's message (e.g. "invalid credentials" vs "invalid or expired token").
type apiError struct {
status int
msg string
}
func (e *apiError) Error() string { return e.msg }
// isAuthError reports whether err is a 401 from the API.
func isAuthError(err error) bool {
var ae *apiError
return errors.As(err, &ae) && ae.status == http.StatusUnauthorized
}
// Client talks to the Tinyforge HTTP API. It has no global timeout so that
// long synchronous deploys and follow streams work; callers pass a context
// with the appropriate deadline.
type Client struct {
baseURL string
token string
http *http.Client
}
func newClient(baseURL, token string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
http: &http.Client{},
}
}
// apiEnvelope mirrors the server's response wrapper. The server's struct is
// unexported, so the CLI defines its own matching shape. Data is deferred so a
// single decode path serves every endpoint.
type apiEnvelope struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
Error string `json:"error"`
}
// SessionToken is the data payload of POST /api/auth/login.
type SessionToken struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
// User is the data payload of GET /api/auth/me.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
}
// Workload is the subset of the workload row the CLI needs. An "app" is a
// workload with a non-empty SourceKind.
type Workload struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
AppID string `json:"app_id"`
SourceKind string `json:"source_kind"`
CreatedAt string `json:"created_at"`
}
func (w Workload) isApp() bool { return w.SourceKind != "" }
// Container is the subset of a container row the CLI needs. State is one of
// running|stopped|failed|missing|starting|created|restarting|paused|...
type Container struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
Role string `json:"role"`
ContainerID string `json:"container_id"`
ImageRef string `json:"image_ref"`
State string `json:"state"`
Port int `json:"port"`
Subdomain string `json:"subdomain"`
CreatedAt string `json:"created_at"`
}
// DeployResult is the data payload of POST /api/workloads/{id}/deploy.
type DeployResult struct {
WorkloadID string `json:"workload_id"`
Reference string `json:"reference"`
TriggeredBy string `json:"triggered_by"`
}
// doJSON performs a JSON request and unwraps the response envelope. body may be
// nil. out may be nil when the caller does not need the data payload. A 401
// maps to errNotAuthenticated; any other non-success surfaces the server's
// error message.
func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) error {
var reqBody io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
reqBody = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
c.authorize(req)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return fmt.Errorf("read response: %w", err)
}
var env apiEnvelope
if jsonErr := json.Unmarshal(raw, &env); jsonErr != nil {
// Non-JSON body (e.g. a proxy error page). Surface status + a snippet,
// preserving auth-error typing for 401s with an unparseable body.
if resp.StatusCode >= 400 {
return &apiError{status: resp.StatusCode, msg: fmt.Sprintf(
"%s %s: unexpected response (status %d): %s", method, path, resp.StatusCode, snippet(raw))}
}
return fmt.Errorf("%s %s: decode response: %w", method, path, jsonErr)
}
if resp.StatusCode >= 400 || !env.Success {
msg := env.Error
if msg == "" {
msg = fmt.Sprintf("%s %s: request failed (status %d)", method, path, resp.StatusCode)
}
return &apiError{status: resp.StatusCode, msg: msg}
}
if out != nil && len(env.Data) > 0 {
if err := json.Unmarshal(env.Data, out); err != nil {
return fmt.Errorf("decode response data: %w", err)
}
}
return nil
}
// authorize attaches the bearer token. Using the Authorization header (rather
// than a ?token= query param) keeps the JWT out of server and proxy logs.
func (c *Client) authorize(req *http.Request) {
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
}
// streamSSE opens an SSE stream and invokes onData for each `data:` payload.
// Comment lines (heartbeats, beginning with ':') and blanks are skipped. The
// stream ends on EOF, context cancellation, or when onData returns an error.
func (c *Client) streamSSE(ctx context.Context, path string, onData func(payload []byte) error) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "text/event-stream")
c.authorize(req)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("GET %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var env apiEnvelope
msg := fmt.Sprintf("GET %s: stream failed (status %d)", path, resp.StatusCode)
if json.Unmarshal(raw, &env) == nil && env.Error != "" {
msg = env.Error
}
return &apiError{status: resp.StatusCode, msg: msg}
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64<<10), 2<<20) // tolerate long log lines
for scanner.Scan() {
line := scanner.Text()
if line == "" || strings.HasPrefix(line, ":") {
continue // blank separator or SSE comment/heartbeat
}
data, ok := strings.CutPrefix(line, "data:")
if !ok {
continue // ignore event:/id: fields — the API uses default events
}
if err := onData([]byte(strings.TrimPrefix(data, " "))); err != nil {
return err
}
}
if err := scanner.Err(); err != nil && !errors.Is(err, context.Canceled) {
return fmt.Errorf("read stream: %w", err)
}
return nil
}
// snippet returns a short, single-line view of an unexpected response body.
func snippet(b []byte) string {
const max = 200
s := strings.TrimSpace(string(b))
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > max {
s = s[:max] + "…"
}
if s == "" {
s = "(empty body)"
}
return s
}
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
)
// defaultBaseURL matches the server's default LISTEN_ADDR (:8080). The dev
// server runs on :8090; point at it with --base-url or $TINYFORGE_URL.
const defaultBaseURL = "http://localhost:8080"
// Config is the persisted CLI state at ~/.tinyforge/config.json.
type Config struct {
BaseURL string `json:"base_url"`
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
// globals holds the cross-cutting flags every command accepts.
type globals struct {
baseURL *string
token *string
configPath *string
}
// addGlobalFlags registers the shared flags on a command's flag set.
func addGlobalFlags(fs *flag.FlagSet) *globals {
return &globals{
baseURL: fs.String("base-url", "", "Tinyforge server URL (default $TINYFORGE_URL or "+defaultBaseURL+")"),
token: fs.String("token", "", "auth token (default $TINYFORGE_TOKEN or cached config)"),
configPath: fs.String("config", "", "config file path (default $TINYFORGE_CONFIG or ~/.tinyforge/config.json)"),
}
}
// configFilePath resolves the config file location with precedence:
// --config flag > $TINYFORGE_CONFIG > ~/.tinyforge/config.json.
func configFilePath(g *globals) (string, error) {
if g != nil && *g.configPath != "" {
return *g.configPath, nil
}
if env := os.Getenv("TINYFORGE_CONFIG"); env != "" {
return env, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("locate home directory: %w", err)
}
return filepath.Join(home, ".tinyforge", "config.json"), nil
}
// loadConfig reads the config file. A missing file yields a zero Config and no
// error — first run is not a failure.
func loadConfig(path string) (Config, error) {
var cfg Config
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, fmt.Errorf("read config %s: %w", path, err)
}
// An empty or whitespace-only file (e.g. freshly touched) is treated as
// "no config yet" rather than a parse error.
if len(bytes.TrimSpace(data)) == 0 {
return cfg, nil
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse config %s: %w", path, err)
}
return cfg, nil
}
// saveConfig writes the config file with 0600 permissions, since it holds a
// bearer token. The parent directory is created if absent.
func saveConfig(path string, cfg Config) error {
if dir := filepath.Dir(path); dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config: %w", err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("write config %s: %w", path, err)
}
// os.WriteFile only applies the mode when creating the file; Chmod ensures
// 0600 even when overwriting a pre-existing, looser-permissioned config.
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("secure config %s: %w", path, err)
}
return nil
}
// resolveBaseURL applies precedence: --base-url > $TINYFORGE_URL > config > default.
func resolveBaseURL(g *globals, cfg Config) string {
if g != nil && *g.baseURL != "" {
return *g.baseURL
}
if env := os.Getenv("TINYFORGE_URL"); env != "" {
return env
}
if cfg.BaseURL != "" {
return cfg.BaseURL
}
return defaultBaseURL
}
// resolveToken applies precedence: --token > $TINYFORGE_TOKEN > config.
func resolveToken(g *globals, cfg Config) string {
if g != nil && *g.token != "" {
return *g.token
}
if env := os.Getenv("TINYFORGE_TOKEN"); env != "" {
return env
}
return cfg.Token
}
// session bundles the resolved client with the loaded config and its path, so
// commands can both make requests and persist updates (e.g. login).
type session struct {
client *Client
cfg Config
configPath string
}
// newSession loads config and builds a client with resolved base URL + token.
func newSession(g *globals) (*session, error) {
path, err := configFilePath(g)
if err != nil {
return nil, err
}
cfg, err := loadConfig(path)
if err != nil {
return nil, err
}
return &session{
client: newClient(resolveBaseURL(g, cfg), resolveToken(g, cfg)),
cfg: cfg,
configPath: path,
}, nil
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
)
func runDeploy(args []string) error {
fs := flag.NewFlagSet("deploy", flag.ExitOnError)
g := addGlobalFlags(fs)
ref := fs.String("ref", "", "image tag / git ref / source-specific deploy target")
note := fs.String("note", "", "free-text note recorded with the deploy")
timeout := fs.Duration("timeout", 15*time.Minute, "max time to wait for the deploy to finish")
fs.Usage = func() {
fmt.Fprint(os.Stderr, "Usage: tinyforge deploy <app> [--ref TAG] [--note TEXT] [--timeout DUR]\n\n"+
"Trigger a deploy and wait for it to finish. Requires an admin token.\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
}
// Resolve the app on a short deadline; the deploy itself gets the full one.
resolveCtx, cancelResolve := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelResolve()
app, err := resolveApp(resolveCtx, sess.client, fs.Arg(0))
if err != nil {
return err
}
body := map[string]string{}
if *ref != "" {
body["reference"] = *ref
}
if *note != "" {
body["note"] = *note
}
fmt.Printf("Deploying %s%s…\n", app.Name, refSuffix(*ref))
// The endpoint returns 202 but blocks until the deploy completes, so a
// success here means it finished; allow plenty of time for pull/build.
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
var result DeployResult
if err := sess.client.doJSON(ctx, "POST", "/api/workloads/"+app.ID+"/deploy", body, &result); err != nil {
return err
}
fmt.Printf("Deploy of %s completed (triggered by %s).\n", app.Name, result.TriggeredBy)
fmt.Printf("Follow with: tinyforge logs %s -f\n", app.Name)
return nil
}
func refSuffix(ref string) string {
if ref == "" {
return ""
}
return fmt.Sprintf(" @ %s", ref)
}
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"bufio"
"context"
"flag"
"fmt"
"os"
"strings"
"time"
)
func runLogin(args []string) error {
fs := flag.NewFlagSet("login", flag.ExitOnError)
g := addGlobalFlags(fs)
user := fs.String("user", "", "username (prompted if omitted)")
pass := fs.String("password", "", "password (insecure; prefer $TINYFORGE_PASSWORD or the prompt)")
fs.Usage = func() {
fmt.Fprint(os.Stderr, "Usage: tinyforge login [--user U] [--password P] [--base-url URL]\n\n"+
"Authenticate against the server and cache the token.\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return err
}
sess, err := newSession(g)
if err != nil {
return err
}
username := *user
if username == "" {
username, err = promptLine("Username: ")
if err != nil {
return err
}
}
password := *pass
if password == "" {
password = os.Getenv("TINYFORGE_PASSWORD")
}
if password == "" {
password, err = promptPassword("Password: ")
if err != nil {
return err
}
}
if username == "" || password == "" {
return fmt.Errorf("username and password are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var tok SessionToken
body := map[string]string{"username": username, "password": password}
if err := sess.client.doJSON(ctx, "POST", "/api/auth/login", body, &tok); err != nil {
return err
}
// Persist the resolved base URL alongside the token so later commands need
// no flags. The token file is written 0600 by saveConfig.
sess.cfg.BaseURL = sess.client.baseURL
sess.cfg.Token = tok.Token
sess.cfg.ExpiresAt = tok.ExpiresAt
if err := saveConfig(sess.configPath, sess.cfg); err != nil {
return err
}
fmt.Printf("Logged in to %s as %s.\n", sess.client.baseURL, username)
if exp := friendlyExpiry(tok.ExpiresAt); exp != "" {
fmt.Printf("Token valid until %s.\n", exp)
}
return nil
}
func runLogout(args []string) error {
fs := flag.NewFlagSet("logout", flag.ExitOnError)
g := addGlobalFlags(fs)
if err := fs.Parse(args); err != nil {
return err
}
sess, err := newSession(g)
if err != nil {
return err
}
if sess.client.token == "" {
fmt.Println("Not logged in.")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Best-effort server-side revocation; clear the local token regardless.
revokeErr := sess.client.doJSON(ctx, "POST", "/api/auth/logout", nil, nil)
sess.cfg.Token = ""
sess.cfg.ExpiresAt = ""
if err := saveConfig(sess.configPath, sess.cfg); err != nil {
return err
}
if revokeErr != nil {
fmt.Printf("Cleared local token (server revocation skipped: %v).\n", revokeErr)
return nil
}
fmt.Println("Logged out.")
return nil
}
// promptLine reads a single trimmed line from stdin.
func promptLine(label string) (string, error) {
fmt.Fprint(os.Stderr, label)
r := bufio.NewReader(os.Stdin)
line, err := r.ReadString('\n')
if err != nil && line == "" {
return "", fmt.Errorf("read input: %w", err)
}
return strings.TrimSpace(line), nil
}
// friendlyExpiry formats an RFC3339 expiry as a local time, best-effort.
func friendlyExpiry(s string) string {
if s == "" {
return ""
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return s
}
return t.Local().Format("2006-01-02 15:04 MST")
}
+143
View File
@@ -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")
}
+95
View File
@@ -0,0 +1,95 @@
// Command tinyforge is a terminal client for a Tinyforge server.
//
// It drives the existing HTTP API: log in to obtain a 24h JWT, then list
// apps, trigger deploys, stream logs, and check status. The token is cached
// in ~/.tinyforge/config.json (mode 0600) so subsequent commands reuse it.
//
// Usage:
//
// tinyforge login [--user U] [--password P]
// tinyforge apps [list]
// tinyforge deploy <app> [--ref TAG] [--note TEXT]
// tinyforge logs <app> [-f] [--tail N] [--container CID]
// tinyforge status [<app>]
// tinyforge logout
// tinyforge version
//
// The target server is resolved from --base-url, then $TINYFORGE_URL, then the
// saved config, then http://localhost:8080.
package main
import (
"fmt"
"os"
)
// version is the CLI build version. Overridable at build time via
// -ldflags "-X main.version=...".
var version = "dev"
func main() {
if len(os.Args) < 2 {
usage(os.Stderr)
os.Exit(2)
}
cmd, args := os.Args[1], os.Args[2:]
var err error
switch cmd {
case "login":
err = runLogin(args)
case "logout":
err = runLogout(args)
case "apps":
err = runApps(args)
case "deploy":
err = runDeploy(args)
case "logs":
err = runLogs(args)
case "status":
err = runStatus(args)
case "version", "--version", "-v":
fmt.Printf("tinyforge %s\n", version)
case "help", "-h", "--help":
usage(os.Stdout)
default:
fmt.Fprintf(os.Stderr, "tinyforge: unknown command %q\n\n", cmd)
usage(os.Stderr)
os.Exit(2)
}
if err != nil {
// Authenticated commands that hit a 401 get a re-login hint; the login
// command itself surfaces the server message ("invalid credentials").
if cmd != "login" && isAuthError(err) {
err = fmt.Errorf("%w — run 'tinyforge login'", err)
}
fmt.Fprintf(os.Stderr, "tinyforge: %v\n", err)
os.Exit(1)
}
}
func usage(w *os.File) {
fmt.Fprint(w, `tinyforge — terminal client for a Tinyforge server
Usage:
tinyforge <command> [flags]
Commands:
login Authenticate and cache a token
logout Revoke the cached token and clear it
apps [list] List your apps (workloads with a source)
deploy <app> Trigger a deploy (waits for completion)
logs <app> Print container logs (use -f to follow)
status [<app>] Show server health, or one app's containers
version Print the CLI version
Global flags (accepted by any command):
--base-url URL Server URL (default $TINYFORGE_URL or http://localhost:8080)
--token TOKEN Auth token (default $TINYFORGE_TOKEN or cached config)
--config PATH Config file (default $TINYFORGE_CONFIG or ~/.tinyforge/config.json)
Run "tinyforge <command> -h" for command-specific flags.
`)
}
+38
View File
@@ -0,0 +1,38 @@
//go:build !windows
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"strings"
)
// promptPassword reads a password from stdin with echo disabled via stty. If
// stty is unavailable (no tty, missing binary), it falls back to an echoed
// read so the command still works in pipes/CI.
func promptPassword(label string) (string, error) {
fmt.Fprint(os.Stderr, label)
echoDisabled := stty("-echo") == nil
if echoDisabled {
defer func() {
_ = stty("echo")
fmt.Fprintln(os.Stderr) // the Enter keystroke was not echoed
}()
}
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", fmt.Errorf("read password: %w", err)
}
return strings.TrimRight(line, "\r\n"), nil
}
func stty(arg string) error {
cmd := exec.Command("stty", arg)
cmd.Stdin = os.Stdin
return cmd.Run()
}
+45
View File
@@ -0,0 +1,45 @@
//go:build windows
package main
import (
"bufio"
"fmt"
"os"
"strings"
"syscall"
"unsafe"
)
// enableEchoInput is the Windows console mode bit that echoes typed input.
const enableEchoInput = 0x0004
// promptPassword reads a password from the console with echo disabled, using
// kernel32 directly so no third-party dependency is needed. If the console
// mode cannot be changed (e.g. piped stdin), it falls back to an echoed read.
func promptPassword(label string) (string, error) {
fmt.Fprint(os.Stderr, label)
kernel32 := syscall.NewLazyDLL("kernel32.dll")
getConsoleMode := kernel32.NewProc("GetConsoleMode")
setConsoleMode := kernel32.NewProc("SetConsoleMode")
handle := syscall.Handle(os.Stdin.Fd())
var mode uint32
echoDisabled := false
if r, _, _ := getConsoleMode.Call(uintptr(handle), uintptr(unsafe.Pointer(&mode))); r != 0 {
if ret, _, _ := setConsoleMode.Call(uintptr(handle), uintptr(mode&^enableEchoInput)); ret != 0 {
echoDisabled = true
defer setConsoleMode.Call(uintptr(handle), uintptr(mode))
}
}
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if echoDisabled {
fmt.Fprintln(os.Stderr) // the Enter keystroke was not echoed
}
if err != nil && line == "" {
return "", fmt.Errorf("read password: %w", err)
}
return strings.TrimRight(line, "\r\n"), nil
}
+122
View File
@@ -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
}