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__), 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 "…" }