// 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//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 }