This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
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 "…"
}