Documentation
¶
Overview ¶
Package profiles resolves which boid daemon a CLI invocation should talk to: the local UNIX socket (today's sole, and still default, behavior) or a remote daemon over HTTPS + Bearer auth, per a named "profile" in ~/.config/boid/config.yaml (docs/plans/cli-remote-connection.md, Phase 3 PR1 "profile 基盤").
Index ¶
- Constants
- func ConfigPath() (string, error)
- func DeleteToken(profileName string) error
- func LockConfigMutation(cfgPath string) (release func(), err error)
- func MutateConfig(cfgPath string, mutator func(*Config) (*Config, error)) error
- func TokenPath(profileName string) (string, error)
- func TokensDir() (string, error)
- func ValidateSlug(name string) error
- func WriteConfig(path string, cfg *Config) error
- func WriteToken(profileName string, tok *Token) error
- type Config
- type Profile
- type ResolvedProfile
- type Token
Constants ¶
const ( SourceFlag = "flag" SourceEnv = "env" SourceDefaultProfile = "default_profile" SourceUnixFallback = "unix-fallback" )
Source values explain, for diagnostics, which precedence tier a ResolvedProfile came from.
const BOIDProfileEnv = "BOID_PROFILE"
BOIDProfileEnv is the environment variable checked between --profile and default_profile (decision 1, docs/plans/cli-remote-connection.md).
const ProfileFlagName = "profile"
ProfileFlagName is the --profile persistent flag cmd/root.go registers on rootCmd. Exported so cmd/root.go and this package agree on the flag name without either hard-coding a string literal the other could drift from.
Variables ¶
This section is empty.
Functions ¶
func ConfigPath ¶
ConfigPath returns ~/.config/boid/config.yaml, honoring $XDG_CONFIG_HOME (os.UserConfigDir does this natively on Linux) — the same file `boid web set-url`/`boid web set-addr` (cmd/web.go) already read-merge- write, and the same resolution strategy internal/config.Load and internal/orchestrator.DefaultHostCommandsPath use for their own files under the same directory.
func DeleteToken ¶
DeleteToken removes the token file for profileName. A missing file is not an error — `boid logout` calls this unconditionally as part of an idempotent cleanup, so a second logout (or a logout of a profile that was hand-removed from config.yaml without ever running `boid logout`) must not fail merely because there was nothing left to delete.
func LockConfigMutation ¶
LockConfigMutation takes an exclusive advisory flock on the config.yaml mutation lock (a sibling config.lock file next to cfgPath) and returns a release function that MUST be called via defer. It is the shared serialization primitive for every writer that touches config.yaml — including profiles.MutateConfig (login/logout's profile mutations) and cmd/web.go's set-url/set-addr (web.public_url / web.addr mutations). All those writers hitting the same flock file guarantees a "read → modify → write" cycle in one process cannot interleave with another's and silently lose data (the whole reason MutateConfig existed to begin with — codex PR2 review round 2).
The lock file itself is created 0600 in the config directory (which is created 0755 if missing). A caller is responsible for releasing:
release, err := profiles.LockConfigMutation(cfgPath)
if err != nil { return err }
defer release()
// … read config.yaml, mutate, atomic write …
func MutateConfig ¶
MutateConfig serializes a read-modify-write cycle on cfgPath under LockConfigMutation, so two writers racing against the same config.yaml cannot lose each other's changes:
- acquire the shared config lock (LockConfigMutation)
- LoadConfig(cfgPath) — read the current on-disk state INSIDE the lock, so the mutator never runs against a value that was already stale by the time it was passed in
- call mutator(cfg) to compute the new Config value; the mutator may also perform matching side-effect writes to sibling profile-scoped files (token files — the mutator's `login` caller writes the token inside this lock so no concurrent login on the SAME profile can end up with a token file whose URL disagrees with the config entry written next; codex PR2 review round 2)
- WriteConfig(cfgPath, newCfg) atomically (temp+rename), still inside the lock, so no other process observes an intermediate state
- release the flock on return
mutator must not retain cfg after returning — the caller is expected to derive newCfg from it (SetProfile / RemoveProfile both return copies) and let cfg fall out of scope. A nil newCfg means "no write needed" (idempotent path, e.g. logout on a profile that was already absent by the time we grabbed the lock).
func TokenPath ¶
TokenPath returns the token file path for profileName. profileName is NOT re-validated here — callers (Resolve) are expected to have already run it through ValidateSlug before it ever reaches a filesystem path, so a rejected traversal-shaped name never gets this far in the first place.
func TokensDir ¶
TokensDir returns ~/.config/boid/tokens (decision 2), alongside ConfigPath's ~/.config/boid/config.yaml under the same XDG-resolved boid config directory.
func ValidateSlug ¶
ValidateSlug reports whether name is a valid profile name. Called wherever a profile name is actually about to be used to look something up (Resolve) or written to disk (a future PR2's `boid login --profile`), not by Config's own parsing (config.go's Config/UnmarshalYAML accept arbitrary map keys so a hand-edited config.yaml with a bad key still parses — Resolve is where an invalid name becomes a clear, contextual error instead of a low-level regex complaint).
func WriteConfig ¶
WriteConfig serializes cfg's default_profile/profiles fields into path (~/.config/boid/config.yaml) and writes it atomically (temp file in the same directory, fsynced, renamed into place — config.WriteFileAtomic), permission 0600. The parent directory is created (0755, matching the existing `boid web set-url`/`set-addr` convention in cmd/web.go) if it does not exist yet.
The single most important property of this function is what it does NOT touch: config.yaml is a file shared with internal/config.Config's own gc/web/notify/sandbox/task_ask/gateway sections (see Config's own doc comment in config.go), and a real dogfood config.yaml already has some of those. A naive "unmarshal whole file into a generic map, mutate default_profile/profiles, marshal the whole map back out" round-trip is exactly what this function must NOT do (map key/value order is not preserved by Go's map type, so every unrelated section would get silently reformatted — style-only churn on a first write, but map ordering makes even that non-deterministic across runs).
Instead this reads the file as a yaml.Node tree (loadConfigMappingNode), surgically replaces (or removes) only the "default_profile" and "profiles" entries within the top-level mapping via mapNodeSetOrRemove, and marshals that same tree back out — so every other top-level key (web, gc, gateway, ...) round-trips as the exact same node structure it was parsed from, byte-identical modulo yaml.v3's own re-indentation (which does not touch semantics — the values are unchanged Nodes, not re-derived from a lossy Go value).
func WriteToken ¶
WriteToken serializes tok as JSON and atomically writes it to ~/.config/boid/tokens/<profileName>.json (decision 2, docs/plans/ cli-remote-connection.md: "0600、親 dir 0700"), creating the tokens/ directory (0700) first if it does not exist yet. The write goes through config.WriteFileAtomic (temp file in the same directory, fsynced, renamed into place) so a reader — including this same process's own LoadToken, or a concurrent `boid` invocation — never observes a partially written file.
Types ¶
type Config ¶
type Config struct {
// DefaultProfile is used when neither --profile nor BOID_PROFILE is
// set. Empty means "no default" — Resolve then falls back to the
// pre-Phase-3 unix-socket default (docs/plans/cli-remote-connection.md's
// "現行互換" contract).
DefaultProfile string `yaml:"default_profile"`
// Profiles maps a profile name (validated by ValidateSlug wherever a
// name is actually selected — this type itself does not enforce it, so
// a hand-edited config.yaml with an invalid key still parses and
// Resolve surfaces the slug error with full context) to its connection
// target.
Profiles map[string]Profile `yaml:"profiles"`
}
Config is the profile-related subset of ~/.config/boid/config.yaml. config.yaml is a single shared file with several unrelated top-level sections (internal/config.Config's own gc/web/notify/sandbox/task_ask/ gateway) — see UnmarshalYAML's doc comment for why this type's decoding deliberately does NOT reject those siblings.
func LoadConfig ¶
LoadConfig reads and parses path. A missing file is not an error — it returns an empty Config, matching the documented "config.yaml が存在しない" branch of Resolve's 現行互換 fallback (docs/plans/cli-remote-connection.md).
func RemoveProfile ¶
RemoveProfile returns a copy of cfg with name's entry removed. Removing a name that is not present is a no-op (idempotent — cmd/login.go's `boid logout` relies on this to be safe to call twice).
func SetProfile ¶
SetProfile returns a copy of cfg with name's entry added or updated to prof. cfg itself is never mutated — callers (cmd/login.go) hold onto the value LoadConfig returned them for diagnostics (e.g. "already exists") and must not have it silently change underneath them.
func (*Config) UnmarshalYAML ¶
UnmarshalYAML implements yaml.Unmarshaler. It intentionally decodes ONLY the default_profile/profiles keys and silently ignores every other top-level key in the document — mirroring internal/config.Config's own UnmarshalYAML ("Unknown legacy fields... are silently ignored"), for the same reason: config.yaml is shared by several independent loaders (gc, web, gateway, sandbox, task_ask, and now profiles), and a plain dec.KnownFields(true) applied to the whole document would make this package reject any config.yaml that ALSO configures, say, gateway.forges or web.public_url — which is not "strict validation", it is a hard crash on a file every other loader in this codebase treats as perfectly normal.
What this DOES validate strictly is the shape *within* each profiles.<name> entry: a bare yaml.Node.Decode call always starts a fresh, loose decode regardless of the Decoder that produced the node (see internal/orchestrator/host_commands_config.go's hostCommandSpecStrict doc comment for the identical gopkg.in/yaml.v3 quirk, applied there one level down for the same reason) — so each profile's raw Node is re-marshaled to its own small YAML document and re-decoded through its own KnownFields(true) Decoder below, which does catch a typo'd field (e.g. "urll:") instead of silently dropping it.
type Profile ¶
type Profile struct {
URL string `yaml:"url"`
}
Profile is a single named connection target from config.yaml's `profiles:` map. Deliberately does not carry a token field: the token lives in ~/.config/boid/tokens/<profile>.json (token.go), never in config.yaml — see decision 2 (docs/plans/cli-remote-connection.md).
type ResolvedProfile ¶
type ResolvedProfile struct {
// Name is the profile's slug, or "" for the unix-fallback case (no
// profile was ever selected by name).
Name string
// URL is always a "unix://" or "https://" url.NewClient can consume
// directly.
URL string
// Token is the Bearer device token for an https-scheme profile, or ""
// for a unix-scheme one (unix never needs a token — decision 4).
Token string
// Source records which precedence tier decided Name (SourceFlag /
// SourceEnv / SourceDefaultProfile / SourceUnixFallback).
Source string
}
ResolvedProfile is the outcome of Resolve: everything client.NewClient needs to build the actual transport, plus Name/Source for diagnostics and error messages.
func Resolve ¶
func Resolve(cmd *cobra.Command) (*ResolvedProfile, error)
Resolve determines which daemon this CLI invocation should talk to, applying the precedence chain from decision 1 (docs/plans/ cli-remote-connection.md): --profile flag > BOID_PROFILE env > default_profile > 現行互換 (unix://DefaultSocketPath()). For https-scheme profiles it also loads and validates the Bearer device token (decision 9 origin-bind check).
cmd may be nil (unit tests exercising the env/default/fallback tiers without a real cobra invocation); a nil cmd is simply treated as "--profile was not passed".
Callers that need the profile identity BEFORE running the token-load step — root's PersistentPreRunE, in particular, so a scope=local rejection (decision 6) fires *before* an unrelated missing/corrupt-token error preempts it — should call ResolveWithoutToken instead and only reach for the token when they've decided the invocation is actually going to proceed.
func ResolveWithoutToken ¶
func ResolveWithoutToken(cmd *cobra.Command) (*ResolvedProfile, error)
ResolveWithoutToken performs the profile-selection half of Resolve (config load, precedence chain, slug validation, URL scheme validation) but deliberately stops SHORT of loading the Bearer device token. The returned ResolvedProfile has Token == "" even for an https-scheme profile that would normally need one.
This exists so root.PersistentPreRunE can enforce decision 6 (scope=local + non-unix profile → hard error) *before* a scope-independent failure — a missing/corrupt token file on the resolved https profile — has a chance to preempt it with a "run 'boid login' first" error that would mislead the caller into thinking token state is the actual problem.
func (*ResolvedProfile) IsUnix ¶
func (rp *ResolvedProfile) IsUnix() bool
IsUnix reports whether the resolved profile targets a UNIX socket (as opposed to an https-scheme remote daemon). Handy for callers that need this discrimination BEFORE a Token has been loaded — e.g. root's PersistentPreRunE checking scope=local rejection (decision 6) against a ResolveWithoutToken result, where a token-load failure on an unrelated https profile would otherwise pre-empt the intended "ローカル専用コマンドだよ" error.
type Token ¶
type Token struct {
DeviceID string `json:"device_id"`
Token string `json:"token"`
IssuedAt time.Time `json:"issued_at,omitempty"`
// URL is the canonical origin the token was issued against (the
// canonical_url field of POST /api/auth/device's response — see
// internal/api/device_auth.go's deviceAuthResponse). Resolve compares
// this byte-for-byte against the profile's own configured URL and hard-
// errors on any mismatch (decision 9: "token は canonical origin に強く
// bind") — never silently proceeds with a token that was issued for a
// different origin than the one config.yaml now points the profile at.
URL string `json:"url"`
}
Token is the on-disk shape of ~/.config/boid/tokens/<profile>.json (decision 2, docs/plans/cli-remote-connection.md). Writing this file is PR2's job (`boid login`); this package only reads and validates it.
func LoadToken ¶
LoadToken reads and parses the token file for profileName. A missing file is reported via the plain os.ErrNotExist-wrapping error LoadToken returns unchanged (callers use os.IsNotExist / errors.Is on it) — Resolve turns that specific case into the "run 'boid login <url>' first" message from the spec, so this function does not editorialize about *why* the file is missing.
A permission looser than 0600 is logged as a warning, not a hard error (decision 2: "起動時に権限緩ければ警告") — the token itself is still usable and refusing to read it outright would turn a merely-suspicious filesystem state into an outage; a human fixing `chmod 600` is a perfectly adequate remediation once warned.