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