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.
233 lines
6.9 KiB
Go
233 lines
6.9 KiB
Go
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
|
|
}
|