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:
@@ -6,7 +6,10 @@ data/
|
|||||||
.env
|
.env
|
||||||
tinyforge
|
tinyforge
|
||||||
tinyforge.exe
|
tinyforge.exe
|
||||||
|
/cli
|
||||||
|
/cli.exe
|
||||||
server.exe
|
server.exe
|
||||||
|
tinyforge-server.exe
|
||||||
docker-watcher
|
docker-watcher
|
||||||
docker-watcher.exe
|
docker-watcher.exe
|
||||||
docker-watcher.exe~
|
docker-watcher.exe~
|
||||||
|
|||||||
@@ -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
|
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`
|
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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+149
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user