119 lines
3.4 KiB
Go
119 lines
3.4 KiB
Go
package clicmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// ConfigFile is the CLI's on-disk configuration.
|
|
//
|
|
// JSON rather than TOML: encoding/json is already linked into the binary for
|
|
// the API, and adding a TOML parser to read four fields would cost more than
|
|
// the file saves. CI never reads it at all — there, everything comes from
|
|
// PAGES_* environment variables.
|
|
type ConfigFile struct {
|
|
Server string `json:"server,omitempty"`
|
|
Token string `json:"token,omitempty"`
|
|
Project string `json:"project,omitempty"`
|
|
Output string `json:"output,omitempty"`
|
|
}
|
|
|
|
// DefaultConfigPath is $PAGES_CONFIG, else $XDG_CONFIG_HOME/pages/config.json,
|
|
// else ~/.config/pages/config.json. It returns "" when no home directory can be
|
|
// determined, which simply means there is no config file.
|
|
func DefaultConfigPath() string {
|
|
if p := os.Getenv("PAGES_CONFIG"); p != "" {
|
|
return p
|
|
}
|
|
dir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.Join(dir, "pages", "config.json")
|
|
}
|
|
|
|
// LoadConfig reads path. A missing file is not an error — it is the normal
|
|
// state on a CI runner. warn receives a note if the file is readable by anyone
|
|
// but its owner, because it may hold a token.
|
|
func LoadConfig(path string, warn io.Writer) (ConfigFile, error) {
|
|
var f ConfigFile
|
|
if path == "" {
|
|
return f, nil
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return f, nil
|
|
}
|
|
if err != nil {
|
|
return f, fmt.Errorf("read config %s: %w", path, err)
|
|
}
|
|
if fi, err := os.Stat(path); err == nil && fi.Mode().Perm()&0o077 != 0 && warn != nil {
|
|
fmt.Fprintf(warn, "warning: %s is mode %#o and may contain a token; run: chmod 600 %s\n",
|
|
path, fi.Mode().Perm(), path)
|
|
}
|
|
if err := json.Unmarshal(raw, &f); err != nil {
|
|
return ConfigFile{}, fmt.Errorf("parse config %s: %w", path, err)
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// SaveConfig writes f to path with mode 0600, replacing any existing file
|
|
// atomically so a crash cannot leave a truncated config behind.
|
|
func SaveConfig(path string, f ConfigFile) error {
|
|
if path == "" {
|
|
return errors.New("no config path: set PAGES_CONFIG or pass --config")
|
|
}
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return fmt.Errorf("create config directory: %w", err)
|
|
}
|
|
raw, err := json.MarshalIndent(f, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw = append(raw, '\n')
|
|
|
|
// Written in the destination directory so the rename stays within one
|
|
// filesystem, and created 0600 from the start so the token is never briefly
|
|
// world-readable.
|
|
tmp, err := os.CreateTemp(dir, ".config-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("create temporary config: %w", err)
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if _, err := tmp.Write(raw); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp.Name(), path); err != nil {
|
|
return fmt.Errorf("install config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// redactToken shows enough of a token to recognise which one it is and nothing
|
|
// that could be used with it. The key id is the public half by construction
|
|
// (pgs_<keyid>_<secret>), so it is safe to print in full.
|
|
func redactToken(token string) string {
|
|
if token == "" {
|
|
return ""
|
|
}
|
|
parts := strings.SplitN(token, "_", 3)
|
|
if len(parts) == 3 {
|
|
return parts[0] + "_" + parts[1] + "_…"
|
|
}
|
|
return "…"
|
|
}
|