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.
149 lines
4.2 KiB
Go
149 lines
4.2 KiB
Go
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
|
|
}
|