config

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 13, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package config holds fft's project model and its YAML persistence.

A project is one fulfillmenttools tenant plus the Firebase project that authenticates against it. Users configure a project once and switch between them with `fft project use`.

Why not viper

Viper is the natural reach for a CLI config file, and it is the wrong tool here: it lower-cases every key on both read and write. `activeProject` comes back as `activeproject`, `baseUrl` as `baseurl`, and a load-then-save cycle silently rewrites the user's file into keys nothing reads. So the file is persisted with gopkg.in/yaml.v3 into the typed structs below, and viper is used only for what it is good at: the flag → env → default precedence chain on the global flags.

Index

Constants

View Source
const (
	OutputTable = "table"
	OutputJSON  = "json"
	OutputYAML  = "yaml"
)

Output formats a project may default to.

View Source
const (
	EnvBaseURL        = "FFT_BASE_URL"
	EnvFirebaseAPIKey = "FFT_FIREBASE_API_KEY"
	EnvEmail          = "FFT_EMAIL"
	EnvPassword       = "FFT_PASSWORD"
	EnvIDToken        = "FFT_ID_TOKEN"
	EnvUsername       = "FFT_USERNAME"
	EnvProjectID      = "FFT_PROJECT_ID"
	EnvEnvironment    = "FFT_ENVIRONMENT"
	EnvTenant         = "FFT_TENANT"

	// EnvEnv is an alias for EnvEnvironment, matching the --env flag of
	// `fft project add`. A user who wrote --env and then exported the same value
	// should not have to discover that the variable is spelled differently.
	EnvEnv = "FFT_ENV"
)

Environment variables that describe a headless project.

View Source
const EphemeralName = "env"

EphemeralName is the name given to the project synthesized from the environment. It shows up in `fft project list` so that a confused CI log is still readable.

View Source
const Version = 1

Version is the schema version written to new config files. It exists so that a future breaking change to the file layout can be detected and migrated rather than misread.

Variables

View Source
var (
	// ErrNoActiveProject means nothing told fft which project to use.
	ErrNoActiveProject = errors.New("no active project")
	// ErrProjectNotFound means a project was named but is not configured.
	ErrProjectNotFound = errors.New("project not found")
)

Sentinels callers branch on. They are always wrapped in an Error, so they carry exit code 3 with them.

Functions

func CandidateEmail

func CandidateEmail(username, projectID, environment string) string

CandidateEmail builds the synthetic address fulfillmenttools issues for a username: {username}@ocff-{projectId}-{env}.com.

It is a *candidate*, not the truth. Some tenants authenticate with a plain corporate address instead, so this only seeds the sign-in attempt; whatever actually works is what gets stored in Project.Email. It returns "" when it has too little to work with, rather than a plausible-looking wrong answer.

func DefaultPath

func DefaultPath() (string, error)

DefaultPath is $XDG_CONFIG_HOME/fft/config.yaml, falling back to ~/.config/fft/config.yaml.

func NormalizeBaseURL

func NormalizeBaseURL(raw string) (string, error)

NormalizeBaseURL validates and canonicalises a project's API root.

A bare host is assumed to be https, and a trailing slash is dropped so that joining a path onto the result never produces a double slash. Plain http is refused unless the host is a loopback address: a bearer token sent in the clear to a real fulfillmenttools tenant is a credential leak, and the only legitimate reason to point fft at http is a mock server on localhost.

Types

type Config

type Config struct {
	Version       int       `yaml:"version"`
	ActiveProject string    `yaml:"activeProject,omitempty"`
	Projects      []Project `yaml:"projects,omitempty"`
	Settings      Settings  `yaml:"settings"`
}

Config is the whole of ~/.config/fft/config.yaml.

func New

func New() *Config

New returns a Config with the defaults a fresh install should have.

func (*Config) Find

func (c *Config) Find(name string) (Project, bool)

Find returns the project with the given name.

func (*Config) Remove

func (c *Config) Remove(name string) bool

Remove deletes the named project and, if it was the active one, clears the active selection. It reports whether the project existed.

func (*Config) Resolve

func (c *Config) Resolve(name string) (Project, error)

Resolve picks the project a command should act on.

The order is: an explicit name (--project, or FFT_PROJECT — viper has already collapsed those two by the time we get here), then the config file's activeProject. Anything else is an Error at exit code 3.

func (*Config) Upsert

func (c *Config) Upsert(p Project)

Upsert adds the project, replacing any existing one with the same name.

type Error

type Error struct {
	Err error
	// contains filtered or unexported fields
}

Error is a configuration problem: no active project, an unknown project name, an unreadable file. It exits 3 and carries a hint telling the user what to run next, which is the difference between an error a user can act on and one they have to go and read the manual about.

func NewError

func NewError(err error, hint string) *Error

NewError wraps err as a configuration problem, exiting 3, with a hint telling the user which command fixes it. hint may be empty.

func (*Error) Error

func (e *Error) Error() string

func (*Error) ExitCode

func (e *Error) ExitCode() int

ExitCode implements the interface exitcode.FromError looks for.

func (*Error) Hint

func (e *Error) Hint() string

Hint returns the suggested next command, or "" if there is none.

func (*Error) Unwrap

func (e *Error) Unwrap() error

type Project

type Project struct {
	// Name identifies the project to the user and namespaces its secrets.
	Name string `yaml:"name"`

	// BaseURL is the fully-qualified API root, for example
	// "https://acme.api.fulfillmenttools.com". It is stored, never derived.
	//
	// The official docs contradict themselves on whether the host is
	// "{projectId}.api…" or "ocff-{projectId}.api…", so fft refuses to guess:
	// ProjectID and Environment below exist for display and for building a
	// candidate email, and are never used to construct a URL.
	BaseURL string `yaml:"baseUrl"`

	// FirebaseAPIKey is the Firebase *Web* API key. It identifies the Firebase
	// project and confers no authorization by itself; it is sent only as the
	// ?key= parameter on Google's identity endpoints, never to fulfillmenttools.
	FirebaseAPIKey string `yaml:"firebaseApiKey"`

	// Email is the address that actually authenticates. fulfillmenttools users
	// often have a synthetic address ({username}@ocff-{projectId}-{env}.com) but
	// not always, so whatever value signs in successfully is persisted verbatim
	// rather than recomputed.
	Email string `yaml:"email"`

	// Username is the short login name the email was derived from, kept so that
	// `fft project list` can show something a human recognises.
	Username string `yaml:"username,omitempty"`

	// Tenant, ProjectID and Environment are descriptive only.
	Tenant      string `yaml:"tenant,omitempty"`
	ProjectID   string `yaml:"projectId,omitempty"`
	Environment string `yaml:"environment,omitempty"`

	// Ephemeral marks a project synthesized from FFT_* environment variables in
	// headless mode. It is never written to disk — hence the yaml:"-".
	Ephemeral bool `yaml:"-"`
}

Project is one configured fulfillmenttools tenant. It holds no secrets: the password and tokens live in a [secrets.Store].

func FromEnv

func FromEnv(lookup func(string) (string, bool)) (Project, bool)

FromEnv synthesizes an ephemeral project from the environment, reporting whether one was found.

This is how fft works in CI, and it is not a convenience. A GitHub Linux runner has no Secret Service, so the OS keychain is simply unavailable there; a headless project therefore touches neither the keychain nor the config file — everything it needs comes from the environment and dies with the process.

A project is synthesized only when the base URL, the Firebase Web API key, an email and *some* credential (a password to sign in with, or an id token to use directly) are all present. A partial set is ignored rather than half-honoured: silently falling back to the config file when one variable is missing is how a CI job ends up running against the wrong tenant.

FFT_EMAIL may be left out when FFT_USERNAME, FFT_PROJECT_ID and FFT_ENV/FFT_ENVIRONMENT are given: the synthetic address is then built from them exactly as `fft project add --username` does. It is only a candidate — the sign-in is what settles it — but it means a CI job configures the same four values a human types, rather than having to know how the address is spelled.

lookup may be nil, in which case os.LookupEnv is used.

type Settings

type Settings struct {
	// Output is the default output format when -o is not given.
	Output string `yaml:"output"`
	// UpdateCheck enables the once-a-day check for a newer fft release.
	UpdateCheck bool `yaml:"updateCheck"`
}

Settings are the preferences that apply across all projects.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store persists a Config as YAML.

func NewStore

func NewStore(path string) *Store

NewStore returns a Store reading and writing the file at path.

func (*Store) Load

func (s *Store) Load() (*Config, error)

Load reads the config file. A missing file is not an error: a first run has no config yet, and every command that needs a project will fail with a useful hint soon enough.

func (*Store) Path

func (s *Store) Path() string

Path is the file the store reads and writes.

func (*Store) Save

func (s *Store) Save(cfg *Config) error

Save writes the config atomically, mode 0600.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL