init
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user