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