Files
pages/internal/clicmd/globals.go
T
2026-08-15 07:13:00 +00:00

217 lines
6.3 KiB
Go

// Package clicmd implements the pages subcommands.
package clicmd
import (
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/iceBear67/simplepages/internal/client"
"github.com/iceBear67/simplepages/internal/cliutil"
)
// Globals are the settings every command shares.
//
// Precedence is flag > environment > config file > default. Flags are parsed
// into these fields first, so Resolve fills in only what is still empty — which
// is why a flag given as the empty string counts as absent.
type Globals struct {
Server string
Token string
TokenFile string
Project string
Config string
Output string
Timeout time.Duration
Verbose bool
In io.Reader
Out io.Writer
Err io.Writer
file ConfigFile
resolved bool
}
// NewGlobals returns Globals wired to the process's standard streams.
func NewGlobals() *Globals {
return &Globals{In: os.Stdin, Out: os.Stdout, Err: os.Stderr}
}
// Register adds the global flags to fs.
//
// Every flag's default is the field's current value, so registering on a
// subcommand's FlagSet preserves what an outer parse already set. Passing a
// fixed default here would make "pages --server X project list" lose --server
// the moment the subcommand registered its own copy.
func (g *Globals) Register(fs *flag.FlagSet) {
fs.StringVar(&g.Server, "server", g.Server, "management API base `URL` (env PAGES_SERVER)")
fs.StringVar(&g.Token, "token", g.Token,
"API `token`; avoid it — argv is world-readable via /proc on shared runners, so prefer --token-file or PAGES_TOKEN")
fs.StringVar(&g.TokenFile, "token-file", g.TokenFile, "read the API token from `path` (env PAGES_TOKEN_FILE)")
fs.StringVar(&g.Project, "project", g.Project, "project `name` to operate on (env PAGES_PROJECT)")
fs.StringVar(&g.Config, "config", g.Config, "config file `path` (env PAGES_CONFIG)")
fs.StringVar(&g.Output, "o", g.Output, "output `format`: table or json (env PAGES_OUTPUT)")
fs.StringVar(&g.Output, "output", g.Output, "output `format`: table or json")
fs.DurationVar(&g.Timeout, "timeout", g.Timeout, "per-request `timeout`")
fs.BoolVar(&g.Verbose, "v", g.Verbose, "verbose progress on stderr")
fs.BoolVar(&g.Verbose, "verbose", g.Verbose, "verbose progress on stderr")
}
// Resolve applies the environment, then the config file, to anything the flags
// did not set. It runs once; later calls are no-ops.
func (g *Globals) Resolve() error {
if g.resolved {
return nil
}
g.resolved = true
if g.Config == "" {
g.Config = DefaultConfigPath()
}
f, err := LoadConfig(g.Config, g.Err)
if err != nil {
return err
}
g.file = f
g.Server = firstNonEmpty(g.Server, os.Getenv("PAGES_SERVER"), f.Server)
g.Project = firstNonEmpty(g.Project, os.Getenv("PAGES_PROJECT"), f.Project)
if err := g.resolveToken(); err != nil {
return err
}
g.Output = firstNonEmpty(g.Output, os.Getenv("PAGES_OUTPUT"), f.Output, cliutil.FormatTable)
if !cliutil.ValidFormat(g.Output) {
return fmt.Errorf("unknown output format %q: use table or json", g.Output)
}
if g.Timeout == 0 {
if s := os.Getenv("PAGES_TIMEOUT"); s != "" {
d, err := cliutil.ParseDuration(s)
if err != nil {
return fmt.Errorf("PAGES_TIMEOUT: %w", err)
}
g.Timeout = d
}
}
if g.Timeout <= 0 {
g.Timeout = client.DefaultTimeout
}
return nil
}
// resolveToken picks the token from the most specific source that has one:
// --token, --token-file, $PAGES_TOKEN, $PAGES_TOKEN_FILE, config file.
func (g *Globals) resolveToken() error {
if g.Token != "" {
// Only a flag can have set this: the environment and the config file are
// read below. Say so once — on a shared runner every other user can read
// this token out of /proc/<pid>/cmdline for as long as the process lives.
fmt.Fprintln(g.Err, "warning: --token puts the token in the process list; prefer --token-file or PAGES_TOKEN")
return nil
}
if g.TokenFile != "" {
t, err := readTokenFile(g.TokenFile, g.Err)
if err != nil {
return err
}
g.Token = t
return nil
}
if t := os.Getenv("PAGES_TOKEN"); t != "" {
g.Token = strings.TrimSpace(t)
return nil
}
if p := os.Getenv("PAGES_TOKEN_FILE"); p != "" {
t, err := readTokenFile(p, g.Err)
if err != nil {
return err
}
g.Token = t
return nil
}
g.Token = g.file.Token
return nil
}
// readTokenFile reads a token, tolerating the trailing newline that every text
// editor and `pages key create ... > token` adds.
func readTokenFile(path string, warn io.Writer) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read token file: %w", err)
}
if fi, err := os.Stat(path); err == nil && fi.Mode().Perm()&0o077 != 0 && warn != nil {
fmt.Fprintf(warn, "warning: token file %s is mode %#o; run: chmod 600 %s\n",
path, fi.Mode().Perm(), path)
}
t := strings.TrimSpace(string(raw))
if t == "" {
return "", fmt.Errorf("token file %s is empty", path)
}
return t, nil
}
// Client builds an API client from the resolved settings.
func (g *Globals) Client() (*client.Client, error) {
if err := g.Resolve(); err != nil {
return nil, err
}
return client.New(client.Config{
BaseURL: g.Server,
Token: g.Token,
Timeout: g.Timeout,
})
}
// Printer renders command output in the requested format.
func (g *Globals) Printer() (*cliutil.Printer, error) {
if err := g.Resolve(); err != nil {
return nil, err
}
return &cliutil.Printer{Out: g.Out, Format: g.Output}, nil
}
// ProjectName returns the project to operate on, or an explanation of how to
// name one.
func (g *Globals) ProjectName() (string, error) {
if err := g.Resolve(); err != nil {
return "", err
}
if g.Project == "" {
return "", errors.New("no project: pass --project or set PAGES_PROJECT")
}
return g.Project, nil
}
// logf writes a progress note to stderr under --verbose.
func (g *Globals) logf(format string, args ...any) {
if g.Verbose {
fmt.Fprintf(g.Err, format+"\n", args...)
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// exactArgs is the argument-count check every leaf command starts with. It
// reports a usage error, so the command's own usage text follows the message.
func exactArgs(args []string, n int, want string) error {
if len(args) != n {
return cliutil.UsageErrorf("expected %s, got %d argument(s)", want, len(args))
}
return nil
}