From 074bfe8977574eac9368ef698fd0e21f6877753b Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 15:09:37 -0700 Subject: [PATCH 01/12] feat(config): add internal/config package with TOML load/save/migrate --- go.mod | 1 + go.sum | 2 + internal/config/config.go | 50 ++++++++++++++++++ internal/config/config_test.go | 59 ++++++++++++++++++++++ internal/config/load.go | 87 ++++++++++++++++++++++++++++++++ internal/config/load_test.go | 22 ++++++++ internal/config/migrate.go | 48 ++++++++++++++++++ internal/config/migrate_test.go | 43 ++++++++++++++++ internal/config/save.go | 49 ++++++++++++++++++ internal/config/save_test.go | 41 +++++++++++++++ internal/config/store.go | 24 +++++++++ internal/config/validate.go | 59 ++++++++++++++++++++++ internal/config/validate_test.go | 51 +++++++++++++++++++ 13 files changed, 536 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/load.go create mode 100644 internal/config/load_test.go create mode 100644 internal/config/migrate.go create mode 100644 internal/config/migrate_test.go create mode 100644 internal/config/save.go create mode 100644 internal/config/save_test.go create mode 100644 internal/config/store.go create mode 100644 internal/config/validate.go create mode 100644 internal/config/validate_test.go diff --git a/go.mod b/go.mod index e57b5b6..a12390c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen require ( + github.com/BurntSushi/toml v1.3.2 github.com/blevesearch/bleve/v2 v2.5.7 github.com/fatih/color v1.18.0 github.com/getkin/kin-openapi v0.133.0 diff --git a/go.sum b/go.sum index 9e9960a..4e14b3c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..17c94a9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,50 @@ +// Package config provides the single source of truth for arc CLI and server settings. +// Settings are stored in TOML format at ~/.arc/config.toml. +package config + +// Config is the full arc configuration document. +type Config struct { + CLI CLIConfig `toml:"cli" json:"cli"` + Server ServerConfig `toml:"server" json:"server"` + Share ShareConfig `toml:"share" json:"share"` + Updates UpdatesConfig `toml:"updates" json:"updates"` +} + +// CLIConfig holds settings the arc CLI uses to reach the server. +type CLIConfig struct { + Server string `toml:"server" json:"server"` +} + +// ServerConfig holds settings the arc server uses for its own runtime. +type ServerConfig struct { + Port int `toml:"port" json:"port"` + DBPath string `toml:"db_path" json:"db_path"` +} + +// ShareConfig holds defaults for `arc share` and the web share UI. +type ShareConfig struct { + Author string `toml:"author" json:"author"` + Server string `toml:"server" json:"server"` +} + +// UpdatesConfig holds update-channel settings for `arc self`. +type UpdatesConfig struct { + Channel string `toml:"channel" json:"channel"` +} + +// Default returns a Config populated with built-in defaults. +func Default() *Config { + return &Config{ + CLI: CLIConfig{Server: "http://localhost:7432"}, + Server: ServerConfig{Port: 7432, DBPath: "~/.arc/data.db"}, + Share: ShareConfig{Server: "https://arcplanner.sentiolabs.io"}, + Updates: UpdatesConfig{Channel: "stable"}, + } +} + +// RequiresRestart returns dotted keys whose changes don't take effect in a +// running arc-server until the server restarts. Used by the API + web UI to +// surface a "requires restart" warning. +func RequiresRestart() []string { + return []string{"server.port", "server.db_path"} +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..4c57349 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,59 @@ +package config + +import "testing" + +// --- Contract assertions --- +// These verify the design spec. Do NOT modify without updating the approved plan. +var ( + _ CLIConfig = Config{}.CLI + _ ServerConfig = Config{}.Server + _ ShareConfig = Config{}.Share + _ UpdatesConfig = Config{}.Updates +) + +var _ interface { + Load() *Config + Swap(*Config) *Config +} = (*Store)(nil) + +func TestDefaultIsUsable(t *testing.T) { + cfg := Default() + if cfg.CLI.Server == "" || cfg.Server.Port == 0 { + t.Fatal("Default() returned zero values for required fields") + } + if cfg.Updates.Channel != "stable" { + t.Fatalf("Default channel = %q, want stable", cfg.Updates.Channel) + } + if cfg.Share.Server == "" { + t.Fatal("Default share.server is empty") + } +} + +func TestRequiresRestartContainsServerKeys(t *testing.T) { + got := RequiresRestart() + want := map[string]bool{"server.port": true, "server.db_path": true} + if len(got) != len(want) { + t.Fatalf("RequiresRestart() = %v, want keys %v", got, want) + } + for _, k := range got { + if !want[k] { + t.Errorf("unexpected key %q in RequiresRestart()", k) + } + } +} + +func TestStoreSwap(t *testing.T) { + a := Default() + b := &Config{Updates: UpdatesConfig{Channel: "rc"}} + s := NewStore(a) + if got := s.Load(); got != a { + t.Fatalf("Load() before swap = %p, want %p", got, a) + } + prev := s.Swap(b) + if prev != a { + t.Fatalf("Swap returned %p, want %p", prev, a) + } + if got := s.Load(); got != b { + t.Fatalf("Load() after swap = %p, want %p", got, b) + } +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..2a287ad --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,87 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" +) + +// DefaultPath returns ~/.arc/config.toml. +func DefaultPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".arc", "config.toml") +} + +// LegacyJSONPath returns ~/.arc/cli-config.json (the pre-TOML location). +func LegacyJSONPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".arc", "cli-config.json") +} + +// expandHome expands a leading ~ to the user's home directory. +func expandHome(p string) string { + if !strings.HasPrefix(p, "~") { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return p + } + return filepath.Join(home, strings.TrimPrefix(p, "~")) +} + +// Load reads the TOML config from path. If path is missing but the legacy +// cli-config.json exists in the same directory, Load migrates JSON → TOML, +// renames the legacy file to *.bak, and prints a one-line stderr notice. +// If neither exists, Load writes Default() to path and returns it. +// Missing fields are filled from Default(). The returned Config is +// validated; an invalid file returns a non-nil error. +func Load(path string) (*Config, error) { + if path == "" { + path = DefaultPath() + } + + if _, err := os.Stat(path); err == nil { + cfg := Default() + if _, err := toml.DecodeFile(path, cfg); err != nil { + return nil, fmt.Errorf("decode toml %s: %w", path, err) + } + cfg.Server.DBPath = expandHome(cfg.Server.DBPath) + if err := Validate(cfg); err != nil { + return nil, err + } + return cfg, nil + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("stat %s: %w", path, err) + } + + // No TOML — try legacy JSON in the same directory. + legacy := filepath.Join(filepath.Dir(path), "cli-config.json") + if _, err := os.Stat(legacy); err == nil { + cfg, err := migrateLegacyJSON(legacy) + if err != nil { + return nil, err + } + cfg.Server.DBPath = expandHome(cfg.Server.DBPath) + if err := Save(path, cfg); err != nil { + return nil, err + } + backup := legacy + ".bak" + if err := os.Rename(legacy, backup); err != nil { + return nil, fmt.Errorf("backup legacy: %w", err) + } + fmt.Fprintf(os.Stderr, "migrated %s → %s (backup: %s)\n", legacy, path, filepath.Base(backup)) + return cfg, nil + } + + // No config anywhere — write defaults. + cfg := Default() + cfg.Server.DBPath = expandHome(cfg.Server.DBPath) + if err := Save(path, cfg); err != nil { + return nil, err + } + return cfg, nil +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 0000000..da96b50 --- /dev/null +++ b/internal/config/load_test.go @@ -0,0 +1,22 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadCreatesDefaultWhenMissing(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.CLI.Server == "" { + t.Error("default CLI.Server was empty") + } + if _, err := os.Stat(path); err != nil { + t.Errorf("Load did not write default file: %v", err) + } +} diff --git a/internal/config/migrate.go b/internal/config/migrate.go new file mode 100644 index 0000000..001b48c --- /dev/null +++ b/internal/config/migrate.go @@ -0,0 +1,48 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" +) + +type legacyJSON struct { + ServerURL string `json:"server_url"` + Channel string `json:"channel"` + ShareAuthor string `json:"share_author"` + ShareServer string `json:"share_server"` +} + +// migrateLegacyJSON reads the flat ~/.arc/cli-config.json shape and maps it +// onto the new Config. Old keys map as: +// +// server_url → cli.server +// channel → updates.channel +// share_author → share.author +// share_server → share.server +// +// Unknown keys are ignored. +func migrateLegacyJSON(jsonPath string) (*Config, error) { + data, err := os.ReadFile(jsonPath) + if err != nil { + return nil, fmt.Errorf("read legacy json: %w", err) + } + var legacy legacyJSON + if err := json.Unmarshal(data, &legacy); err != nil { + return nil, fmt.Errorf("parse legacy json: %w", err) + } + cfg := Default() + if legacy.ServerURL != "" { + cfg.CLI.Server = legacy.ServerURL + } + if legacy.Channel != "" { + cfg.Updates.Channel = legacy.Channel + } + if legacy.ShareAuthor != "" { + cfg.Share.Author = legacy.ShareAuthor + } + if legacy.ShareServer != "" { + cfg.Share.Server = legacy.ShareServer + } + return cfg, nil +} diff --git a/internal/config/migrate_test.go b/internal/config/migrate_test.go new file mode 100644 index 0000000..77414cc --- /dev/null +++ b/internal/config/migrate_test.go @@ -0,0 +1,43 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadMigratesLegacyJSON(t *testing.T) { + dir := t.TempDir() + legacy := filepath.Join(dir, "cli-config.json") + if err := os.WriteFile(legacy, []byte(`{ + "server_url": "http://example:1234", + "channel": "rc", + "share_author": "Grace", + "share_server": "https://share.example" + }`), 0o600); err != nil { + t.Fatalf("seed legacy: %v", err) + } + path := filepath.Join(dir, "config.toml") + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.CLI.Server != "http://example:1234" { + t.Errorf("cli.server = %q", cfg.CLI.Server) + } + if cfg.Updates.Channel != "rc" { + t.Errorf("updates.channel = %q", cfg.Updates.Channel) + } + if cfg.Share.Author != "Grace" { + t.Errorf("share.author = %q", cfg.Share.Author) + } + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Errorf("legacy file still present: %v", err) + } + if _, err := os.Stat(legacy + ".bak"); err != nil { + t.Errorf("legacy backup missing: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("toml file missing: %v", err) + } +} diff --git a/internal/config/save.go b/internal/config/save.go new file mode 100644 index 0000000..9a773c2 --- /dev/null +++ b/internal/config/save.go @@ -0,0 +1,49 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +const configFilePerm = 0o600 +const configDirPerm = 0o700 + +// Save atomically writes cfg to path as TOML with 0600 permissions. +// Writes to path.tmp, fsyncs, then renames over path. +func Save(path string, cfg *Config) error { + if err := Validate(cfg); err != nil { + return err + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, configDirPerm); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + tmp, err := os.CreateTemp(dir, ".config.toml.*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if err := toml.NewEncoder(tmp).Encode(cfg); err != nil { + _ = tmp.Close() + return fmt.Errorf("encode toml: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("fsync: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Chmod(tmpName, configFilePerm); err != nil { + return fmt.Errorf("chmod: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename: %w", err) + } + return nil +} diff --git a/internal/config/save_test.go b/internal/config/save_test.go new file mode 100644 index 0000000..c363ffc --- /dev/null +++ b/internal/config/save_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + cfg := Default() + cfg.Share.Author = "Ada" + if err := Save(path, cfg); err != nil { + t.Fatalf("Save: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("perm = %v, want 0600", info.Mode().Perm()) + } + got, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Share.Author != "Ada" { + t.Errorf("share.author = %q, want Ada", got.Share.Author) + } +} + +func TestSaveRejectsInvalid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + cfg := Default() + cfg.Server.Port = 0 + if err := Save(path, cfg); err == nil { + t.Fatal("Save accepted invalid config") + } +} diff --git a/internal/config/store.go b/internal/config/store.go new file mode 100644 index 0000000..bcc3624 --- /dev/null +++ b/internal/config/store.go @@ -0,0 +1,24 @@ +package config + +import "sync/atomic" + +// Store wraps an atomically-swappable *Config. The arc server holds one +// Store and reads through Load() so that a future Reload() can hot-swap +// without mutating consumers. Callers must treat the returned *Config +// as read-only. +type Store struct { + v atomic.Pointer[Config] +} + +// NewStore creates a Store pre-loaded with cfg. +func NewStore(cfg *Config) *Store { + s := &Store{} + s.v.Store(cfg) + return s +} + +// Load returns the current config snapshot. +func (s *Store) Load() *Config { return s.v.Load() } + +// Swap atomically replaces the active config and returns the previous one. +func (s *Store) Swap(cfg *Config) *Config { return s.v.Swap(cfg) } diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..66a0f18 --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,59 @@ +package config + +import ( + "fmt" + "net/url" + "sort" + "strings" +) + +// ValidChannels lists the allowed values for updates.channel. +var ValidChannels = []string{"stable", "rc", "nightly"} + +// ValidationErrors maps dotted-key to error message. +type ValidationErrors map[string]string + +func (v ValidationErrors) Error() string { + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s: %s", k, v[k])) + } + return "config validation failed: " + strings.Join(parts, "; ") +} + +// Validate returns a ValidationErrors if cfg has invalid values, or nil if OK. +func Validate(cfg *Config) error { + errs := ValidationErrors{} + if cfg.CLI.Server != "" { + if u, err := url.Parse(cfg.CLI.Server); err != nil || u.Scheme == "" || u.Host == "" { + errs["cli.server"] = "must be a valid URL with scheme and host" + } + } + if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { + errs["server.port"] = "must be between 1 and 65535" + } + if cfg.Share.Server != "" { + if u, err := url.Parse(cfg.Share.Server); err != nil || u.Scheme == "" || u.Host == "" { + errs["share.server"] = "must be a valid URL with scheme and host" + } + } + channelOK := false + for _, c := range ValidChannels { + if cfg.Updates.Channel == c { + channelOK = true + break + } + } + if !channelOK { + errs["updates.channel"] = fmt.Sprintf("must be one of: %s", strings.Join(ValidChannels, ", ")) + } + if len(errs) == 0 { + return nil + } + return errs +} diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go new file mode 100644 index 0000000..5409964 --- /dev/null +++ b/internal/config/validate_test.go @@ -0,0 +1,51 @@ +package config + +import ( + "errors" + "testing" +) + +func TestValidateAcceptsDefault(t *testing.T) { + if err := Validate(Default()); err != nil { + t.Fatalf("Validate(Default()) = %v, want nil", err) + } +} + +func TestValidateRejectsBadURL(t *testing.T) { + cfg := Default() + cfg.CLI.Server = "not a url" + err := Validate(cfg) + var ve ValidationErrors + if !errors.As(err, &ve) { + t.Fatalf("err type = %T, want ValidationErrors", err) + } + if _, ok := ve["cli.server"]; !ok { + t.Errorf("missing cli.server in errors: %v", ve) + } +} + +func TestValidateRejectsBadPort(t *testing.T) { + cfg := Default() + cfg.Server.Port = 0 + err := Validate(cfg) + var ve ValidationErrors + if !errors.As(err, &ve) { + t.Fatalf("err type = %T, want ValidationErrors", err) + } + if _, ok := ve["server.port"]; !ok { + t.Errorf("missing server.port in errors: %v", ve) + } +} + +func TestValidateRejectsBadChannel(t *testing.T) { + cfg := Default() + cfg.Updates.Channel = "weekly" + err := Validate(cfg) + var ve ValidationErrors + if !errors.As(err, &ve) { + t.Fatalf("err type = %T, want ValidationErrors", err) + } + if _, ok := ve["updates.channel"]; !ok { + t.Errorf("missing updates.channel in errors: %v", ve) + } +} From 5d05fab5e6b870b1cdf7ef2f6968108be3a41170 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 15:18:54 -0700 Subject: [PATCH 02/12] fix(config): address review findings (atomic migration, db_path round-trip, lint) --- internal/config/config.go | 8 ++++++++ internal/config/load.go | 25 ++++++++++++++++--------- internal/config/load_test.go | 27 +++++++++++++++++++++++++++ internal/config/save.go | 8 +++++++- internal/config/validate.go | 12 ++++-------- internal/config/validate_test.go | 26 ++++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 18 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 17c94a9..bc4f1dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,14 @@ type ServerConfig struct { DBPath string `toml:"db_path" json:"db_path"` } +// ResolvedDBPath returns DBPath with a leading ~ expanded to the user's home +// directory. Use this when you need a real filesystem path (e.g., opening the +// SQLite database). Do NOT call this before writing DBPath back to TOML — the +// raw value (which may contain ~) should be stored as-is for portability. +func (s ServerConfig) ResolvedDBPath() string { + return expandHome(s.DBPath) +} + // ShareConfig holds defaults for `arc share` and the web share UI. type ShareConfig struct { Author string `toml:"author" json:"author"` diff --git a/internal/config/load.go b/internal/config/load.go index 2a287ad..8e1f79b 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -1,7 +1,9 @@ package config import ( + "errors" "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -11,13 +13,19 @@ import ( // DefaultPath returns ~/.arc/config.toml. func DefaultPath() string { - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + panic(fmt.Errorf("cannot determine home directory: %w", err)) + } return filepath.Join(home, ".arc", "config.toml") } // LegacyJSONPath returns ~/.arc/cli-config.json (the pre-TOML location). func LegacyJSONPath() string { - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + panic(fmt.Errorf("cannot determine home directory: %w", err)) + } return filepath.Join(home, ".arc", "cli-config.json") } @@ -49,12 +57,11 @@ func Load(path string) (*Config, error) { if _, err := toml.DecodeFile(path, cfg); err != nil { return nil, fmt.Errorf("decode toml %s: %w", path, err) } - cfg.Server.DBPath = expandHome(cfg.Server.DBPath) if err := Validate(cfg); err != nil { return nil, err } return cfg, nil - } else if !os.IsNotExist(err) { + } else if !errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("stat %s: %w", path, err) } @@ -65,21 +72,21 @@ func Load(path string) (*Config, error) { if err != nil { return nil, err } - cfg.Server.DBPath = expandHome(cfg.Server.DBPath) - if err := Save(path, cfg); err != nil { - return nil, err - } + // Fix 1: rename legacy → .bak FIRST, then save TOML. + // If rename fails, return error before touching the TOML path. backup := legacy + ".bak" if err := os.Rename(legacy, backup); err != nil { return nil, fmt.Errorf("backup legacy: %w", err) } + if err := Save(path, cfg); err != nil { + return nil, err + } fmt.Fprintf(os.Stderr, "migrated %s → %s (backup: %s)\n", legacy, path, filepath.Base(backup)) return cfg, nil } // No config anywhere — write defaults. cfg := Default() - cfg.Server.DBPath = expandHome(cfg.Server.DBPath) if err := Save(path, cfg); err != nil { return nil, err } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index da96b50..477cb8c 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -20,3 +20,30 @@ func TestLoadCreatesDefaultWhenMissing(t *testing.T) { t.Errorf("Load did not write default file: %v", err) } } + +func TestLoadSavePreservesTildeInDBPath(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + cfg := Default() + cfg.Server.DBPath = "~/.arc/data.db" + if err := Save(path, cfg); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Server.DBPath != "~/.arc/data.db" { + t.Errorf("db_path = %q, want %q (tilde corrupted by load)", got.Server.DBPath, "~/.arc/data.db") + } + if err := Save(path, got); err != nil { + t.Fatalf("Save (round 2): %v", err) + } + got2, err := Load(path) + if err != nil { + t.Fatalf("Load (round 2): %v", err) + } + if got2.Server.DBPath != "~/.arc/data.db" { + t.Errorf("db_path after round-trip = %q, want preserved tilde", got2.Server.DBPath) + } +} diff --git a/internal/config/save.go b/internal/config/save.go index 9a773c2..6ebffe9 100644 --- a/internal/config/save.go +++ b/internal/config/save.go @@ -26,7 +26,12 @@ func Save(path string, cfg *Config) error { return fmt.Errorf("create temp file: %w", err) } tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() + success := false + defer func() { + if !success { + _ = os.Remove(tmpName) + } + }() if err := toml.NewEncoder(tmp).Encode(cfg); err != nil { _ = tmp.Close() @@ -45,5 +50,6 @@ func Save(path string, cfg *Config) error { if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("rename: %w", err) } + success = true return nil } diff --git a/internal/config/validate.go b/internal/config/validate.go index 66a0f18..46e081e 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -29,18 +29,14 @@ func (v ValidationErrors) Error() string { // Validate returns a ValidationErrors if cfg has invalid values, or nil if OK. func Validate(cfg *Config) error { errs := ValidationErrors{} - if cfg.CLI.Server != "" { - if u, err := url.Parse(cfg.CLI.Server); err != nil || u.Scheme == "" || u.Host == "" { - errs["cli.server"] = "must be a valid URL with scheme and host" - } + if u, err := url.Parse(cfg.CLI.Server); err != nil || u.Scheme == "" || u.Host == "" { + errs["cli.server"] = "must be a valid URL with scheme and host" } if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { errs["server.port"] = "must be between 1 and 65535" } - if cfg.Share.Server != "" { - if u, err := url.Parse(cfg.Share.Server); err != nil || u.Scheme == "" || u.Host == "" { - errs["share.server"] = "must be a valid URL with scheme and host" - } + if u, err := url.Parse(cfg.Share.Server); err != nil || u.Scheme == "" || u.Host == "" { + errs["share.server"] = "must be a valid URL with scheme and host" } channelOK := false for _, c := range ValidChannels { diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 5409964..cac8adf 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -49,3 +49,29 @@ func TestValidateRejectsBadChannel(t *testing.T) { t.Errorf("missing updates.channel in errors: %v", ve) } } + +func TestValidateRejectsEmptyCLIServer(t *testing.T) { + cfg := Default() + cfg.CLI.Server = "" + err := Validate(cfg) + var ve ValidationErrors + if !errors.As(err, &ve) { + t.Fatalf("err type = %T, want ValidationErrors", err) + } + if _, ok := ve["cli.server"]; !ok { + t.Errorf("missing cli.server in errors: %v", ve) + } +} + +func TestValidateRejectsEmptyShareServer(t *testing.T) { + cfg := Default() + cfg.Share.Server = "" + err := Validate(cfg) + var ve ValidationErrors + if !errors.As(err, &ve) { + t.Fatalf("err type = %T, want ValidationErrors", err) + } + if _, ok := ve["share.server"]; !ok { + t.Errorf("missing share.server in errors: %v", ve) + } +} From 7be355a7cd6ca0062509544850d8316e950bf055 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 15:25:59 -0700 Subject: [PATCH 03/12] feat(api): add GET/PUT /api/v1/config endpoints --- internal/api/config.go | 67 +++++++++++++++++++++++++ internal/api/config_test.go | 97 +++++++++++++++++++++++++++++++++++++ internal/api/server.go | 4 ++ 3 files changed, 168 insertions(+) create mode 100644 internal/api/config.go create mode 100644 internal/api/config_test.go diff --git a/internal/api/config.go b/internal/api/config.go new file mode 100644 index 0000000..a3c8080 --- /dev/null +++ b/internal/api/config.go @@ -0,0 +1,67 @@ +// Package api — config endpoints serve the arc config TOML file via REST. +// Used by the web Settings page; CLI talks to disk directly. +package api + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v4" + cfgpkg "github.com/sentiolabs/arc/internal/config" +) + +type configMeta struct { + Path string `json:"path"` + RequiresRestart []string `json:"requires_restart"` +} + +type configResponse struct { + *cfgpkg.Config + Meta configMeta `json:"meta"` +} + +type configValidationErrorBody struct { + Errors map[string]string `json:"errors"` +} + +// TODO(security): require auth before binding non-loopback. The config +// surface is currently safe only because the server is localhost-bound by +// default. + +func (s *Server) getConfig(c echo.Context) error { + path := cfgpkg.DefaultPath() + cfg, err := cfgpkg.Load(path) + if err != nil { + return errorJSON(c, http.StatusInternalServerError, err.Error()) + } + return successJSON(c, configResponse{ + Config: cfg, + Meta: configMeta{Path: path, RequiresRestart: cfgpkg.RequiresRestart()}, + }) +} + +func (s *Server) putConfig(c echo.Context) error { + var incoming cfgpkg.Config + if err := c.Bind(&incoming); err != nil { + return errorJSON(c, http.StatusBadRequest, "invalid request body") + } + if err := cfgpkg.Validate(&incoming); err != nil { + var ve cfgpkg.ValidationErrors + if errors.As(err, &ve) { + return c.JSON(http.StatusBadRequest, configValidationErrorBody{Errors: ve}) + } + return errorJSON(c, http.StatusBadRequest, err.Error()) + } + path := cfgpkg.DefaultPath() + if err := cfgpkg.Save(path, &incoming); err != nil { + return errorJSON(c, http.StatusInternalServerError, err.Error()) + } + cfg, err := cfgpkg.Load(path) + if err != nil { + return errorJSON(c, http.StatusInternalServerError, err.Error()) + } + return successJSON(c, configResponse{ + Config: cfg, + Meta: configMeta{Path: path, RequiresRestart: cfgpkg.RequiresRestart()}, + }) +} diff --git a/internal/api/config_test.go b/internal/api/config_test.go new file mode 100644 index 0000000..2fa08b7 --- /dev/null +++ b/internal/api/config_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/labstack/echo/v4" + cfgpkg "github.com/sentiolabs/arc/internal/config" +) + +func newTestServerWithTempHome(t *testing.T) *Server { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + // Pre-seed a default config so DefaultPath() resolves cleanly. + if err := os.MkdirAll(filepath.Join(home, ".arc"), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := cfgpkg.Save(cfgpkg.DefaultPath(), cfgpkg.Default()); err != nil { + t.Fatalf("seed config: %v", err) + } + return &Server{echo: echo.New()} +} + +func TestGetConfigReturnsDefaults(t *testing.T) { + s := newTestServerWithTempHome(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil) + rec := httptest.NewRecorder() + c := s.echo.NewContext(req, rec) + if err := s.getConfig(c); err != nil { + t.Fatalf("getConfig: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["cli"] == nil || got["meta"] == nil { + t.Errorf("response missing fields: %v", got) + } +} + +func TestPutConfigPersistsAndRevalidates(t *testing.T) { + s := newTestServerWithTempHome(t) + in := cfgpkg.Default() + in.Share.Author = "Grace" + body, _ := json.Marshal(in) + req := httptest.NewRequest(http.MethodPut, "/api/v1/config", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := s.echo.NewContext(req, rec) + if err := s.putConfig(c); err != nil { + t.Fatalf("putConfig: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body) + } + + reloaded, err := cfgpkg.Load(cfgpkg.DefaultPath()) + if err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.Share.Author != "Grace" { + t.Errorf("share.author = %q", reloaded.Share.Author) + } +} + +func TestPutConfigRejectsInvalid(t *testing.T) { + s := newTestServerWithTempHome(t) + in := cfgpkg.Default() + in.Server.Port = 0 + body, _ := json.Marshal(in) + req := httptest.NewRequest(http.MethodPut, "/api/v1/config", bytes.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := s.echo.NewContext(req, rec) + if err := s.putConfig(c); err != nil { + t.Fatalf("putConfig: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body=%s", rec.Code, rec.Body) + } + var resp configValidationErrorBody + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := resp.Errors["server.port"]; !ok { + t.Errorf("missing server.port in errors: %v", resp.Errors) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 3810c48..7d1747e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -159,6 +159,10 @@ func (s *Server) registerRoutes() { v1.PUT("/labels/:name", s.updateLabel) v1.DELETE("/labels/:name", s.deleteLabel) + // Config (singleton document) + v1.GET("/config", s.getConfig) + v1.PUT("/config", s.putConfig) + // Shares (author-side keyring of paste shares created on this machine) s.RegisterShareRoutes(v1) From 4a82a78290c5dc787a043dd9b3354c758c79dba5 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 15:30:00 -0700 Subject: [PATCH 04/12] feat(cli): adopt internal/config and dotted-path keys for arc config --- cmd/arc/config.go | 399 +++++++++++++++++++++++++++++------------ cmd/arc/config_test.go | 168 +++++++++++------ cmd/arc/main.go | 88 ++------- cmd/arc/self.go | 9 +- cmd/arc/self_test.go | 19 +- cmd/arc/server.go | 16 +- cmd/arc/share.go | 12 +- cmd/arc/share_test.go | 22 +-- 8 files changed, 451 insertions(+), 282 deletions(-) diff --git a/cmd/arc/config.go b/cmd/arc/config.go index 10fb2d3..8ee6f5c 100644 --- a/cmd/arc/config.go +++ b/cmd/arc/config.go @@ -1,163 +1,326 @@ -// Package main provides the config management commands for the arc CLI, -// allowing users to view and modify CLI configuration values such as the -// server URL. -// -// The config subcommands (list, get, set, path) read and write -// the JSON configuration stored at ~/.arc/cli-config.json. The only -// recognised key today is "server_url" which points at the arc server -// that the CLI talks to. +// Package main provides config management commands for the arc CLI, +// supporting dotted-path keys (cli.server, server.port, etc.) backed by +// ~/.arc/config.toml via the internal/config package. package main import ( "fmt" + "os" + "os/exec" + "sort" + "strconv" "strings" + cfgpkg "github.com/sentiolabs/arc/internal/config" "github.com/spf13/cobra" ) -// configSetArgCount is the number of arguments required for the config set command. -const configSetArgCount = 2 - -// validConfigKeys lists the allowed configuration keys. -var validConfigKeys = []string{"server_url", "channel"} +var recognizedKeys = []string{ + "cli.server", + "server.port", + "server.db_path", + "share.author", + "share.server", + "updates.channel", +} -// configCmd is the parent command for CLI configuration management. var configCmd = &cobra.Command{ Use: "config", - Short: "Manage CLI configuration", - Long: `View and modify arc CLI configuration. - -Configuration is stored in ~/.arc/cli-config.json`, + Short: "Manage arc configuration", + Long: `View and modify arc configuration. Stored at ~/.arc/config.toml.`, } -// configListCmd prints every config key and its current value. var configListCmd = &cobra.Command{ Use: "list", Short: "List all configuration values", - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - - if outputJSON { - outputResult(cfg) - return nil - } - - _, _ = fmt.Printf("server_url = %s\n", cfg.ServerURL) - channel := cfg.Channel - if channel == "" { - channel = channelStable - } - _, _ = fmt.Printf("channel = %s\n", channel) - _, _ = fmt.Printf("\nConfig file: %s\n", defaultConfigPath()) - return nil - }, + RunE: runConfigList, } -// configGetCmd retrieves a single configuration value by key. var configGetCmd = &cobra.Command{ Use: "get ", Short: "Get a configuration value", - Long: `Get a configuration value. + Args: cobra.ExactArgs(1), + RunE: runConfigGet, +} -Valid keys: server_url, channel`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - key := strings.ToLower(args[0]) +var configSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set a configuration value", + Args: cobra.ExactArgs(2), + RunE: runConfigSet, +} - cfg, err := loadConfig() - if err != nil { - return err - } +var configUnsetCmd = &cobra.Command{ + Use: "unset ", + Short: "Clear a configuration value back to its default", + Args: cobra.ExactArgs(1), + RunE: runConfigUnset, +} - var value string - switch key { - case "server_url", "server-url", "serverurl": - value = cfg.ServerURL - case "channel": - value = cfg.Channel - if value == "" { - value = "stable" - } - default: - return fmt.Errorf("unknown config key: %s\nValid keys: %s", key, strings.Join(validConfigKeys, ", ")) +var configPathCmd = &cobra.Command{ + Use: "path", + Short: "Show the config file path", + RunE: func(cmd *cobra.Command, args []string) error { + p := configPath + if p == "" { + p = cfgpkg.DefaultPath() } + fmt.Println(p) + return nil + }, +} - if outputJSON { - outputResult(map[string]string{key: value}) - return nil - } +var configEditCmd = &cobra.Command{ + Use: "edit", + Short: "Open the config file in $EDITOR", + RunE: runConfigEdit, +} - fmt.Println(value) +func init() { + configCmd.AddCommand(configListCmd, configGetCmd, configSetCmd, configUnsetCmd, configPathCmd, configEditCmd) + rootCmd.AddCommand(configCmd) +} + +func runConfigList(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + if outputJSON { + outputResult(cfg) return nil - }, + } + p := configPath + if p == "" { + p = cfgpkg.DefaultPath() + } + restartSet := map[string]bool{} + for _, k := range cfgpkg.RequiresRestart() { + restartSet[k] = true + } + printRow := func(key, value string) { + label := strings.SplitN(key, ".", 2)[1] + tag := "" + if restartSet[key] { + tag = " (requires restart)" + } + fmt.Printf(" %-10s = %s%s\n", label, value, tag) + } + fmt.Println("[cli]") + printRow("cli.server", cfg.CLI.Server) + fmt.Println() + fmt.Println("[server]") + printRow("server.port", strconv.Itoa(cfg.Server.Port)) + printRow("server.db_path", cfg.Server.DBPath) + fmt.Println() + fmt.Println("[share]") + printRow("share.author", cfg.Share.Author) + printRow("share.server", cfg.Share.Server) + fmt.Println() + fmt.Println("[updates]") + printRow("updates.channel", cfg.Updates.Channel) + fmt.Println() + fmt.Printf("Config: %s\n", p) + return nil } -// configSetCmd persists a new value for a configuration key. -var configSetCmd = &cobra.Command{ - Use: "set ", - Short: "Set a configuration value", - Long: `Set a configuration value. +func runConfigGet(cmd *cobra.Command, args []string) error { + key, err := normalizeKey(args[0]) + if err != nil { + return err + } + cfg, err := loadConfig() + if err != nil { + return err + } + value := getKey(cfg, key) + if outputJSON { + outputResult(map[string]string{key: value}) + return nil + } + fmt.Println(value) + return nil +} -Valid keys: server_url, channel +func runConfigSet(cmd *cobra.Command, args []string) error { + key, err := normalizeKey(args[0]) + if err != nil { + return err + } + cfg, err := loadConfig() + if err != nil { + return err + } + if err := setKey(cfg, key, args[1]); err != nil { + return err + } + if err := saveConfig(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + fmt.Printf("Set %s = %s\n", key, args[1]) + return nil +} -Examples: - arc config set server_url http://localhost:7432 - arc config set channel nightly`, - Args: cobra.ExactArgs(configSetArgCount), - RunE: func(cmd *cobra.Command, args []string) error { - key := strings.ToLower(args[0]) - value := args[1] +func runConfigUnset(cmd *cobra.Command, args []string) error { + key, err := normalizeKey(args[0]) + if err != nil { + return err + } + cfg, err := loadConfig() + if err != nil { + return err + } + def := cfgpkg.Default() + if err := setKey(cfg, key, getKey(def, key)); err != nil { + return err + } + if err := saveConfig(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + fmt.Printf("Unset %s (now %s)\n", key, getKey(cfg, key)) + return nil +} - cfg, err := loadConfig() - if err != nil { - return err - } +func runConfigEdit(cmd *cobra.Command, args []string) error { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + p := configPath + if p == "" { + p = cfgpkg.DefaultPath() + } + // Ensure the file exists by loading once. + if _, err := loadConfig(); err != nil { + return err + } + c := exec.Command(editor, p) + c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("editor exited: %w", err) + } + // Re-validate after edit. + if _, err := loadConfig(); err != nil { + return fmt.Errorf("config invalid after edit: %w", err) + } + return nil +} - switch key { - case "server_url", "server-url", "serverurl": - cfg.ServerURL = value - case "channel": - switch value { - case channelStable, channelRC, channelNightly: - cfg.Channel = value - default: - return fmt.Errorf("invalid channel %q: must be stable, rc, or nightly", value) - } - default: - return fmt.Errorf("unknown config key: %s\nValid keys: %s", key, strings.Join(validConfigKeys, ", ")) +// normalizeKey canonicalizes raw to a recognized dotted key. Dashes are +// converted to underscores; casing is normalized to lower. Returns an error +// with a "did you mean" hint when the key is unrecognized. +func normalizeKey(raw string) (string, error) { + k := strings.ToLower(strings.TrimSpace(raw)) + k = strings.ReplaceAll(k, "-", "_") + for _, valid := range recognizedKeys { + if k == valid { + return valid, nil } + } + return "", fmt.Errorf("unknown key: %s%s", raw, didYouMean(k)) +} - if err := saveConfig(cfg); err != nil { - return fmt.Errorf("save config: %w", err) +// didYouMean returns a " (did you mean ?)" hint if the closest +// recognized key is within edit distance 4, or a fallback list otherwise. +func didYouMean(input string) string { + best := "" + bestDist := 1_000_000 + for _, k := range recognizedKeys { + d := levenshtein(input, k) + if d < bestDist { + bestDist = d + best = k } - - _, _ = fmt.Printf("Set %s = %s\n", key, value) - return nil - }, + } + if best != "" && bestDist <= 4 { + return fmt.Sprintf(" (did you mean %s?)", best) + } + sorted := make([]string, len(recognizedKeys)) + copy(sorted, recognizedKeys) + sort.Strings(sorted) + return "\nValid keys: " + strings.Join(sorted, ", ") } -// configPathCmd displays the filesystem path to the active config file. -var configPathCmd = &cobra.Command{ - Use: "path", - Short: "Show the config file path", - RunE: func(cmd *cobra.Command, args []string) error { - path := configPath - if path == "" { - path = defaultConfigPath() +// levenshtein computes the edit distance between strings a and b. +func levenshtein(a, b string) int { + if a == b { + return 0 + } + la, lb := len(a), len(b) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + prev := make([]int, lb+1) + curr := make([]int, lb+1) + for j := 0; j <= lb; j++ { + prev[j] = j + } + for i := 1; i <= la; i++ { + curr[0] = i + for j := 1; j <= lb; j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + m := prev[j] + 1 + if curr[j-1]+1 < m { + m = curr[j-1] + 1 + } + if prev[j-1]+cost < m { + m = prev[j-1] + cost + } + curr[j] = m } - fmt.Println(path) - return nil - }, + prev, curr = curr, prev + } + return prev[lb] } -func init() { - configCmd.AddCommand(configListCmd) - configCmd.AddCommand(configGetCmd) - configCmd.AddCommand(configSetCmd) - configCmd.AddCommand(configPathCmd) +// getKey returns the string form of the config field for key. +func getKey(cfg *cfgpkg.Config, key string) string { + switch key { + case "cli.server": + return cfg.CLI.Server + case "server.port": + return strconv.Itoa(cfg.Server.Port) + case "server.db_path": + return cfg.Server.DBPath + case "share.author": + return cfg.Share.Author + case "share.server": + return cfg.Share.Server + case "updates.channel": + return cfg.Updates.Channel + } + return "" +} - rootCmd.AddCommand(configCmd) +// setKey parses value and assigns it to the field identified by key. +// It then validates the whole config and returns any error. +func setKey(cfg *cfgpkg.Config, key, value string) error { + switch key { + case "cli.server": + cfg.CLI.Server = value + case "server.port": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("server.port: must be an integer") + } + cfg.Server.Port = n + case "server.db_path": + cfg.Server.DBPath = value + case "share.author": + cfg.Share.Author = value + case "share.server": + cfg.Share.Server = value + case "updates.channel": + cfg.Updates.Channel = value + } + if err := cfgpkg.Validate(cfg); err != nil { + return err + } + return nil } diff --git a/cmd/arc/config_test.go b/cmd/arc/config_test.go index f1a97b9..40472b1 100644 --- a/cmd/arc/config_test.go +++ b/cmd/arc/config_test.go @@ -1,83 +1,135 @@ package main import ( - "encoding/json" - "fmt" "os" "path/filepath" + "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + cfgpkg "github.com/sentiolabs/arc/internal/config" ) -func TestConfigChannelRoundTrip(t *testing.T) { - tmp := t.TempDir() - cfgFile := filepath.Join(tmp, "cli-config.json") - - // Write config with channel - data := []byte(`{"server_url":"http://localhost:7432","channel":"nightly"}`) - require.NoError(t, os.WriteFile(cfgFile, data, 0o600)) - - // Point loadConfig at our temp file - origPath := configPath - configPath = cfgFile - t.Cleanup(func() { configPath = origPath }) +func TestConfigSetGetRoundTrip(t *testing.T) { + dir := t.TempDir() + configPath = filepath.Join(dir, "config.toml") + defer func() { configPath = "" }() cfg, err := loadConfig() - require.NoError(t, err) - assert.Equal(t, "nightly", cfg.Channel) - - // Save and re-read - require.NoError(t, saveConfig(cfg)) - raw, err := os.ReadFile(cfgFile) - require.NoError(t, err) + if err != nil { + t.Fatalf("loadConfig: %v", err) + } + if err := setKey(cfg, "share.author", "Ada"); err != nil { + t.Fatalf("setKey: %v", err) + } + if err := saveConfig(cfg); err != nil { + t.Fatalf("saveConfig: %v", err) + } - var reloaded Config - require.NoError(t, json.Unmarshal(raw, &reloaded)) - assert.Equal(t, "nightly", reloaded.Channel) + got, err := loadConfig() + if err != nil { + t.Fatalf("reload: %v", err) + } + if got.Share.Author != "Ada" { + t.Errorf("share.author = %q", got.Share.Author) + } } -func TestConfigChannelDefault(t *testing.T) { - tmp := t.TempDir() - cfgFile := filepath.Join(tmp, "cli-config.json") - - data := []byte(`{"server_url":"http://localhost:7432"}`) - require.NoError(t, os.WriteFile(cfgFile, data, 0o600)) +func TestNormalizeKeyUnknownReturnsHint(t *testing.T) { + _, err := normalizeKey("server_url") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "did you mean") { + t.Errorf("error missing hint: %v", err) + } +} - origPath := configPath - configPath = cfgFile - t.Cleanup(func() { configPath = origPath }) +func TestSetKeyRejectsBadPort(t *testing.T) { + cfg, _ := loadConfigForTest(t) + if err := setKey(cfg, "server.port", "abc"); err == nil { + t.Fatal("expected parse error") + } + if err := setKey(cfg, "server.port", "70000"); err == nil { + t.Fatal("expected validation error") + } +} +func loadConfigForTest(t *testing.T) (*cfgpkg.Config, string) { + t.Helper() + dir := t.TempDir() + configPath = filepath.Join(dir, "config.toml") + t.Cleanup(func() { configPath = ""; _ = os.RemoveAll(dir) }) cfg, err := loadConfig() - require.NoError(t, err) - assert.Empty(t, cfg.Channel) + if err != nil { + t.Fatalf("loadConfig: %v", err) + } + return cfg, configPath } -func TestConfigSetInvalidChannel(t *testing.T) { - tmp := t.TempDir() - cfgFile := filepath.Join(tmp, "cli-config.json") - - data := []byte(`{"server_url":"http://localhost:7432"}`) - require.NoError(t, os.WriteFile(cfgFile, data, 0o600)) +func TestNormalizeKeyValid(t *testing.T) { + validKeys := []string{ + "cli.server", + "server.port", + "server.db_path", + "share.author", + "share.server", + "updates.channel", + } + for _, k := range validKeys { + got, err := normalizeKey(k) + if err != nil { + t.Errorf("normalizeKey(%q) returned error: %v", k, err) + } + if got != k { + t.Errorf("normalizeKey(%q) = %q, want %q", k, got, k) + } + } +} - origPath := configPath - configPath = cfgFile - t.Cleanup(func() { configPath = origPath }) +func TestNormalizeKeyNormalizes(t *testing.T) { + // Should normalize dashes to underscores + got, err := normalizeKey("server.db-path") + if err != nil { + t.Errorf("normalizeKey(server.db-path): %v", err) + } + if got != "server.db_path" { + t.Errorf("got %q, want server.db_path", got) + } +} - // Simulate what configSetCmd does for an invalid channel - cfg, err := loadConfig() - require.NoError(t, err) +func TestGetKeyReturnsDefaults(t *testing.T) { + cfg := cfgpkg.Default() + val := getKey(cfg, "cli.server") + if val != "http://localhost:7432" { + t.Errorf("cli.server default = %q", val) + } + val = getKey(cfg, "server.port") + if val != "7432" { + t.Errorf("server.port default = %q", val) + } +} - value := "beta" - var setErr error - switch value { - case "stable", "rc", "nightly": - cfg.Channel = value - default: - setErr = fmt.Errorf("invalid channel %q: must be stable, rc, or nightly", value) +func TestSetKeyCliServer(t *testing.T) { + cfg, _ := loadConfigForTest(t) + if err := setKey(cfg, "cli.server", "http://example.com:9000"); err != nil { + t.Fatalf("setKey: %v", err) + } + if cfg.CLI.Server != "http://example.com:9000" { + t.Errorf("cli.server = %q", cfg.CLI.Server) } +} - require.Error(t, setErr) - assert.Contains(t, setErr.Error(), "invalid channel") +func TestLevenshtein(t *testing.T) { + // Exact match + if d := levenshtein("foo", "foo"); d != 0 { + t.Errorf("levenshtein(foo, foo) = %d, want 0", d) + } + // Empty + if d := levenshtein("", "abc"); d != 3 { + t.Errorf("levenshtein('', abc) = %d, want 3", d) + } + // One edit + if d := levenshtein("cat", "cut"); d != 1 { + t.Errorf("levenshtein(cat, cut) = %d, want 1", d) + } } diff --git a/cmd/arc/main.go b/cmd/arc/main.go index c2dcdf6..7baba5f 100644 --- a/cmd/arc/main.go +++ b/cmd/arc/main.go @@ -10,12 +10,12 @@ import ( "fmt" "io" "os" - "path/filepath" "strings" "text/tabwriter" "github.com/fatih/color" "github.com/sentiolabs/arc/internal/client" + cfgpkg "github.com/sentiolabs/arc/internal/config" "github.com/sentiolabs/arc/internal/project" "github.com/sentiolabs/arc/internal/sharesconfig" "github.com/sentiolabs/arc/internal/types" @@ -75,30 +75,6 @@ func main() { } } -// Config holds CLI configuration -type Config struct { - ServerURL string `json:"server_url"` - Channel string `json:"channel,omitempty"` - // ShareAuthor is the default author name embedded in `arc share create` - // plans. It's the canonical reviewer identity used by the share UI to - // gate Accept / Resolve / Reject controls — only visitors who type this - // exact name in the SPA's prompt are recognized as the plan owner. - // Resolution precedence in `arc share create`: - // 1. --author flag (highest) - // 2. this config field - // 3. $ARC_SHARE_AUTHOR - // 4. `git config user.name` - ShareAuthor string `json:"share_author,omitempty"` - // ShareServer is the default URL for the remote paste server used by - // `arc share create --remote`. Lets users persistently target a private - // arc-paste deployment instead of the public default. - // Resolution precedence in `arc share create --remote`: - // 1. --server flag (highest) - // 2. this config field - // 3. $ARC_SHARE_SERVER - // 4. https://arcplanner.sentiolabs.io (built-in default) - ShareServer string `json:"share_server,omitempty"` -} // ProjectSource indicates how the project was resolved type ProjectSource int @@ -123,62 +99,26 @@ func (s ProjectSource) String() string { } // defaultConfigPath returns the default config file path. -func defaultConfigPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".arc", "cli-config.json") -} - -// loadConfig reads CLI configuration from disk, creating a default on first use. -func loadConfig() (*Config, error) { - if configPath == "" { - configPath = defaultConfigPath() - } - - data, err := os.ReadFile(configPath) - if err != nil { - if os.IsNotExist(err) { - // Create default config on first use - cfg := &Config{ - ServerURL: "http://localhost:7432", - } - // Try to save, but don't fail if we can't - _ = saveConfig(cfg) - return cfg, nil - } - return nil, fmt.Errorf("read config: %w", err) - } - - var cfg Config - if err := json.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("parse config: %w", err) - } +func defaultConfigPath() string { return cfgpkg.DefaultPath() } - if cfg.ServerURL == "" { - cfg.ServerURL = "http://localhost:7432" +// loadConfig reads CLI configuration from disk, creating defaults on first use. +// It delegates to the internal/config package which handles TOML load, legacy +// JSON migration, and validation. +func loadConfig() (*cfgpkg.Config, error) { + path := configPath + if path == "" { + path = cfgpkg.DefaultPath() } - - return &cfg, nil + return cfgpkg.Load(path) } // saveConfig persists the CLI configuration to disk. -func saveConfig(cfg *Config) error { +func saveConfig(cfg *cfgpkg.Config) error { path := configPath if path == "" { - path = defaultConfigPath() - } - - // Create directory - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, defaultDirPerm); err != nil { - return fmt.Errorf("create config dir: %w", err) + path = cfgpkg.DefaultPath() } - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return fmt.Errorf("marshal config: %w", err) - } - - return os.WriteFile(path, data, defaultFilePerm) + return cfgpkg.Save(path, cfg) } // getClient returns an HTTP client configured for the current server URL. @@ -195,7 +135,7 @@ func getClient() (*client.Client, error) { } } if url == "" { - url = cfg.ServerURL + url = cfg.CLI.Server } return client.New(url), nil diff --git a/cmd/arc/self.go b/cmd/arc/self.go index 5059def..ecc9c7c 100644 --- a/cmd/arc/self.go +++ b/cmd/arc/self.go @@ -11,6 +11,7 @@ import ( "path/filepath" "regexp" + cfgpkg "github.com/sentiolabs/arc/internal/config" "github.com/sentiolabs/arc/internal/project" "github.com/sentiolabs/arc/internal/storage/sqlite" "github.com/sentiolabs/arc/internal/version" @@ -112,7 +113,7 @@ func runSelfUpdate(cmd *cobra.Command, args []string) error { return err } - channel := cfg.Channel + channel := cfg.Updates.Channel if channel == "" { channel = channelStable } @@ -280,7 +281,7 @@ func runSelfChannel(cmd *cobra.Command, args []string) error { // Show current channel if no args if len(args) == 0 { - channel := cfg.Channel + channel := cfg.Updates.Channel if channel == "" { channel = channelStable } @@ -322,7 +323,7 @@ func runSelfChannel(cmd *cobra.Command, args []string) error { } // setSelfChannel validates and persists the update channel. -func setSelfChannel(cfg *Config, channel string) error { +func setSelfChannel(cfg *cfgpkg.Config, channel string) error { switch channel { case channelStable, channelRC, channelNightly: // valid @@ -330,7 +331,7 @@ func setSelfChannel(cfg *Config, channel string) error { return fmt.Errorf("invalid channel %q: must be stable, rc, or nightly", channel) } - cfg.Channel = channel + cfg.Updates.Channel = channel if err := saveConfig(cfg); err != nil { return fmt.Errorf("save config: %w", err) } diff --git a/cmd/arc/self_test.go b/cmd/arc/self_test.go index 413e598..2677d85 100644 --- a/cmd/arc/self_test.go +++ b/cmd/arc/self_test.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os" "path/filepath" "testing" @@ -16,8 +15,7 @@ import ( func TestSelfChannelShowDefault(t *testing.T) { tmp := t.TempDir() - cfgFile := filepath.Join(tmp, "cli-config.json") - require.NoError(t, os.WriteFile(cfgFile, []byte(`{"server_url":"http://localhost:7432"}`), 0o600)) + cfgFile := filepath.Join(tmp, "config.toml") origPath := configPath configPath = cfgFile @@ -27,7 +25,7 @@ func TestSelfChannelShowDefault(t *testing.T) { cfg, err := loadConfig() require.NoError(t, err) - channel := cfg.Channel + channel := cfg.Updates.Channel if channel == "" { channel = channelStable } @@ -36,8 +34,7 @@ func TestSelfChannelShowDefault(t *testing.T) { func TestSelfChannelSwitchNightly(t *testing.T) { tmp := t.TempDir() - cfgFile := filepath.Join(tmp, "cli-config.json") - require.NoError(t, os.WriteFile(cfgFile, []byte(`{"server_url":"http://localhost:7432"}`), 0o600)) + cfgFile := filepath.Join(tmp, "config.toml") origPath := configPath configPath = cfgFile @@ -53,13 +50,12 @@ func TestSelfChannelSwitchNightly(t *testing.T) { // Re-read and verify cfg, err = loadConfig() require.NoError(t, err) - assert.Equal(t, channelNightly, cfg.Channel) + assert.Equal(t, channelNightly, cfg.Updates.Channel) } func TestSelfChannelSwitchRC(t *testing.T) { tmp := t.TempDir() - cfgFile := filepath.Join(tmp, "cli-config.json") - require.NoError(t, os.WriteFile(cfgFile, []byte(`{"server_url":"http://localhost:7432"}`), 0o600)) + cfgFile := filepath.Join(tmp, "config.toml") origPath := configPath configPath = cfgFile @@ -73,13 +69,12 @@ func TestSelfChannelSwitchRC(t *testing.T) { cfg, err = loadConfig() require.NoError(t, err) - assert.Equal(t, channelRC, cfg.Channel) + assert.Equal(t, channelRC, cfg.Updates.Channel) } func TestSelfChannelInvalid(t *testing.T) { tmp := t.TempDir() - cfgFile := filepath.Join(tmp, "cli-config.json") - require.NoError(t, os.WriteFile(cfgFile, []byte(`{"server_url":"http://localhost:7432"}`), 0o600)) + cfgFile := filepath.Join(tmp, "config.toml") origPath := configPath configPath = cfgFile diff --git a/cmd/arc/server.go b/cmd/arc/server.go index e2e86b0..1d34c34 100644 --- a/cmd/arc/server.go +++ b/cmd/arc/server.go @@ -82,8 +82,20 @@ func init() { func runServerStart(cmd *cobra.Command, args []string) error { foreground, _ := cmd.Flags().GetBool("foreground") - port, _ := cmd.Flags().GetInt("port") - dbPath, _ := cmd.Flags().GetString("db") + + cfg, err := loadConfig() + if err != nil { + return err + } + port := cfg.Server.Port + if cmd.Flags().Changed("port") { + p, _ := cmd.Flags().GetInt("port") + port = p + } + dbPath := cfg.Server.ResolvedDBPath() + if cmd.Flags().Changed("db") { + dbPath, _ = cmd.Flags().GetString("db") + } addr := fmt.Sprintf(":%d", port) diff --git a/cmd/arc/share.go b/cmd/arc/share.go index de7f570..f2b4696 100644 --- a/cmd/arc/share.go +++ b/cmd/arc/share.go @@ -475,7 +475,7 @@ func resolveAuthor(flag string) string { return s } if cfg, err := loadConfig(); err == nil { - if s := strings.TrimSpace(cfg.ShareAuthor); s != "" { + if s := strings.TrimSpace(cfg.Share.Author); s != "" { return s } } @@ -514,7 +514,11 @@ func resolveServer(remote bool, override string) (server, kind string) { // level up in resolveServer.) func resolveShareServer() string { if cfg, err := loadConfig(); err == nil { - if s := strings.TrimSpace(cfg.ShareServer); s != "" { + // Only treat config as an override when the user has explicitly set a + // non-default server URL. If it's still the built-in default, fall + // through to the env-var and built-in tiers so the precedence chain + // cli-config > $ARC_SHARE_SERVER > built-in is preserved. + if s := strings.TrimSpace(cfg.Share.Server); s != "" && s != defaultShareServer { return s } } @@ -528,10 +532,10 @@ func resolveShareServer() string { // to the default local URL. func cliConfigServerURL() string { cfg, err := loadConfig() - if err != nil || cfg.ServerURL == "" { + if err != nil || cfg.CLI.Server == "" { return "http://localhost:7432" } - return cfg.ServerURL + return cfg.CLI.Server } // postCreate sends a CreatePasteRequest to the server and returns the response. diff --git a/cmd/arc/share_test.go b/cmd/arc/share_test.go index 3a7efe4..a16ae87 100644 --- a/cmd/arc/share_test.go +++ b/cmd/arc/share_test.go @@ -772,12 +772,12 @@ func TestResolveAuthor(t *testing.T) { writeConfig := func(t *testing.T, author string) { t.Helper() dir := t.TempDir() - configPath = filepath.Join(dir, "cli-config.json") - body := `{"server_url":"http://localhost:7432"` + configPath = filepath.Join(dir, "config.toml") + body := "[cli]\nserver = \"http://localhost:7432\"\n[share]\n" if author != "" { - body += `,"share_author":"` + author + `"` + body += "author = \"" + author + "\"\n" } - body += `}` + body += "[server]\nport = 7432\ndb_path = \"~/.arc/data.db\"\n[updates]\nchannel = \"stable\"\n" if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil { t.Fatalf("write config: %v", err) } @@ -960,17 +960,19 @@ func TestShareShowAuthorURLMissingShare(t *testing.T) { } } -// writeShareServerConfig points the global configPath at a temp cli-config.json -// containing the given share_server (omitted entirely when empty). +// writeShareServerConfig points the global configPath at a temp config.toml +// containing the given share.server (omitted/default when empty). func writeShareServerConfig(t *testing.T, server string) { t.Helper() dir := t.TempDir() - configPath = filepath.Join(dir, "cli-config.json") - body := `{"server_url":"http://localhost:7432"` + configPath = filepath.Join(dir, "config.toml") + body := "[cli]\nserver = \"http://localhost:7432\"\n[share]\n" if server != "" { - body += `,"share_server":"` + server + `"` + body += "server = \"" + server + "\"\n" + } else { + body += "server = \"https://arcplanner.sentiolabs.io\"\n" } - body += `}` + body += "[server]\nport = 7432\ndb_path = \"~/.arc/data.db\"\n[updates]\nchannel = \"stable\"\n" if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil { t.Fatalf("write config: %v", err) } From 54ad4ac64d90400244629ee5043d031a5cdaf68f Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 15:40:33 -0700 Subject: [PATCH 05/12] fix(cli): repair share_test fixture and add legacy-alias hints Replace stale JSON config fixture in TestShareCreatePrintsPreviewURLForLocal with a valid TOML write so cfgpkg.Load() actually uses the configured test server URL instead of silently falling back to localhost defaults. Also add an explicit legacyAliases map in normalizeKey so well-known pre-TOML keys (server_url, share_author, share_server, channel) produce the correct "did you mean ?" hint instead of a Levenshtein false-positive. --- cmd/arc/config.go | 13 +++++++++++++ cmd/arc/config_test.go | 22 ++++++++++++++++++++++ cmd/arc/share_test.go | 11 +++++++++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/cmd/arc/config.go b/cmd/arc/config.go index 8ee6f5c..3c373ef 100644 --- a/cmd/arc/config.go +++ b/cmd/arc/config.go @@ -15,6 +15,16 @@ import ( "github.com/spf13/cobra" ) +// legacyAliases maps pre-TOML key names to their current dotted equivalents. +// These are checked before the Levenshtein fallback in normalizeKey so that +// well-known old names always produce the correct "did you mean" hint. +var legacyAliases = map[string]string{ + "server_url": "cli.server", + "share_author": "share.author", + "share_server": "share.server", + "channel": "updates.channel", +} + var recognizedKeys = []string{ "cli.server", "server.port", @@ -217,6 +227,9 @@ func normalizeKey(raw string) (string, error) { return valid, nil } } + if alias, ok := legacyAliases[k]; ok { + return "", fmt.Errorf("unknown key: %s (did you mean %s?)", raw, alias) + } return "", fmt.Errorf("unknown key: %s%s", raw, didYouMean(k)) } diff --git a/cmd/arc/config_test.go b/cmd/arc/config_test.go index 40472b1..14c9526 100644 --- a/cmd/arc/config_test.go +++ b/cmd/arc/config_test.go @@ -44,6 +44,28 @@ func TestNormalizeKeyUnknownReturnsHint(t *testing.T) { } } +func TestNormalizeKeyLegacyAliases(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"server_url", "cli.server"}, + {"share_author", "share.author"}, + {"share_server", "share.server"}, + {"channel", "updates.channel"}, + } + for _, tc := range cases { + _, err := normalizeKey(tc.input) + if err == nil { + t.Errorf("normalizeKey(%q): expected error, got nil", tc.input) + continue + } + if !strings.Contains(err.Error(), "did you mean "+tc.want) { + t.Errorf("normalizeKey(%q): expected hint %q in error, got: %v", tc.input, tc.want, err) + } + } +} + func TestSetKeyRejectsBadPort(t *testing.T) { cfg, _ := loadConfigForTest(t) if err := setKey(cfg, "server.port", "abc"); err == nil { diff --git a/cmd/arc/share_test.go b/cmd/arc/share_test.go index a16ae87..9da2ba7 100644 --- a/cmd/arc/share_test.go +++ b/cmd/arc/share_test.go @@ -703,8 +703,9 @@ func TestShareCreatePrintsPreviewURLForLocal(t *testing.T) { origConfigPath := configPath t.Cleanup(func() { configPath = origConfigPath }) dir := t.TempDir() - configPath = filepath.Join(dir, "cli-config.json") - if err := os.WriteFile(configPath, []byte(`{"server_url":"`+srv.URL+`"}`), 0o600); err != nil { + configPath = filepath.Join(dir, "config.toml") + tomlBody := "[cli]\nserver = \"" + srv.URL + "\"\n[share]\n[server]\nport = 7432\ndb_path = \"~/.arc/data.db\"\n[updates]\nchannel = \"stable\"\n" + if err := os.WriteFile(configPath, []byte(tomlBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } @@ -758,6 +759,12 @@ func TestShareCreatePrintsPreviewURLForLocal(t *testing.T) { if !hasToken { t.Errorf("expected the preview URL line to carry &t=, got: %s", output) } + // Verify the URL uses the configured test server — not the fallback + // localhost:7432. This catches a stale/invalid config fixture that causes + // loadConfig to fail and silently fall back to defaults. + if !strings.Contains(output, srv.URL) { + t.Errorf("expected output to contain configured server URL %s, got: %s", srv.URL, output) + } } func TestResolveAuthor(t *testing.T) { From 02308e7fed09846db597d25488af840b6ea7b709 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 15:43:07 -0700 Subject: [PATCH 06/12] fix(api): return saved config directly + tighten test assertions Eliminate the post-Save Load in putConfig so a transient disk-read failure cannot mask a successful write. Also tighten test assertions: all five top-level response keys, cli.server default value, non-empty requires_restart, response share.author after PUT, and explicit json.Marshal error handling. --- internal/api/config.go | 6 +---- internal/api/config_test.go | 45 +++++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/internal/api/config.go b/internal/api/config.go index a3c8080..6e91d74 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -56,12 +56,8 @@ func (s *Server) putConfig(c echo.Context) error { if err := cfgpkg.Save(path, &incoming); err != nil { return errorJSON(c, http.StatusInternalServerError, err.Error()) } - cfg, err := cfgpkg.Load(path) - if err != nil { - return errorJSON(c, http.StatusInternalServerError, err.Error()) - } return successJSON(c, configResponse{ - Config: cfg, + Config: &incoming, Meta: configMeta{Path: path, RequiresRestart: cfgpkg.RequiresRestart()}, }) } diff --git a/internal/api/config_test.go b/internal/api/config_test.go index 2fa08b7..11ceb55 100644 --- a/internal/api/config_test.go +++ b/internal/api/config_test.go @@ -42,8 +42,25 @@ func TestGetConfigReturnsDefaults(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal: %v", err) } - if got["cli"] == nil || got["meta"] == nil { - t.Errorf("response missing fields: %v", got) + for _, key := range []string{"cli", "server", "share", "updates", "meta"} { + if got[key] == nil { + t.Errorf("response missing key %q: %v", key, got) + } + } + cli, ok := got["cli"].(map[string]any) + if !ok { + t.Fatalf("cli is not an object: %T", got["cli"]) + } + if cli["server"] != "http://localhost:7432" { + t.Errorf("cli.server = %q, want %q", cli["server"], "http://localhost:7432") + } + meta, ok := got["meta"].(map[string]any) + if !ok { + t.Fatalf("meta is not an object: %T", got["meta"]) + } + requiresRestart, ok := meta["requires_restart"].([]any) + if !ok || len(requiresRestart) == 0 { + t.Errorf("meta.requires_restart is empty or missing: %v", meta["requires_restart"]) } } @@ -51,7 +68,10 @@ func TestPutConfigPersistsAndRevalidates(t *testing.T) { s := newTestServerWithTempHome(t) in := cfgpkg.Default() in.Share.Author = "Grace" - body, _ := json.Marshal(in) + body, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } req := httptest.NewRequest(http.MethodPut, "/api/v1/config", bytes.NewReader(body)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() @@ -63,12 +83,29 @@ func TestPutConfigPersistsAndRevalidates(t *testing.T) { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body) } + // Assert response body contains share.author and meta. + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + share, ok := got["share"].(map[string]any) + if !ok { + t.Fatalf("share is not an object: %T", got["share"]) + } + if share["author"] != "Grace" { + t.Errorf("response share.author = %q, want %q", share["author"], "Grace") + } + if got["meta"] == nil { + t.Errorf("response missing meta field") + } + + // Also verify disk state. reloaded, err := cfgpkg.Load(cfgpkg.DefaultPath()) if err != nil { t.Fatalf("reload: %v", err) } if reloaded.Share.Author != "Grace" { - t.Errorf("share.author = %q", reloaded.Share.Author) + t.Errorf("disk share.author = %q", reloaded.Share.Author) } } From 599d864b5c6c9c220a36b7a599b26497ba32d16d Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 15:46:56 -0700 Subject: [PATCH 07/12] feat(api): document /config endpoints in OpenAPI and regen types - Add /config GET and PUT paths to OpenAPI spec with config tag - Add Config, CLIConfig, ServerConfig, ShareConfig, UpdatesConfig, ConfigResponse, ConfigValidationError schemas - Regenerate internal/api/openapi.gen.go with config operation handlers - Regenerate web/src/lib/api/types.ts with Config types for T4 Settings page - Rename api.Config -> api.NewServerConfig to resolve naming collision with generated Config schema type - Update all server initialization call sites to use NewServerConfig --- api/openapi.yaml | 121 ++++++++ cmd/arc/share_test.go | 2 +- internal/api/openapi.gen.go | 411 ++++++++++++++++++++------- internal/api/paste_routes_test.go | 2 +- internal/api/plans_test.go | 2 +- internal/api/server.go | 6 +- internal/api/workspace_paths_test.go | 2 +- internal/server/server.go | 2 +- web/src/lib/api/types.ts | 109 +++++++ 9 files changed, 549 insertions(+), 108 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index a4b23db..239481a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -25,6 +25,8 @@ tags: description: Issue dependency management - name: labels description: Label management + - name: config + description: Arc configuration management - name: comments description: Comments and audit events - name: teams @@ -970,6 +972,50 @@ paths: "500": $ref: "#/components/responses/InternalError" + # ==================== + # Config (singleton) + # ==================== + /config: + get: + operationId: getConfig + tags: [config] + summary: Get the current arc configuration + responses: + "200": + description: Current configuration + content: + application/json: + schema: + $ref: "#/components/schemas/ConfigResponse" + "500": + $ref: "#/components/responses/InternalError" + + put: + operationId: putConfig + tags: [config] + summary: Replace the arc configuration + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Config" + responses: + "200": + description: Updated configuration + content: + application/json: + schema: + $ref: "#/components/schemas/ConfigResponse" + "400": + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ConfigValidationError" + "500": + $ref: "#/components/responses/InternalError" + # ==================== # Shares (author keyring) # ==================== @@ -2208,6 +2254,81 @@ components: additionalProperties: true description: Array of transcript entries + # ==================== + # Config Schemas + # ==================== + Config: + type: object + required: [cli, server, share, updates] + properties: + cli: + $ref: "#/components/schemas/CLIConfig" + server: + $ref: "#/components/schemas/ServerConfig" + share: + $ref: "#/components/schemas/ShareConfig" + updates: + $ref: "#/components/schemas/UpdatesConfig" + + CLIConfig: + type: object + properties: + server: + type: string + description: URL the CLI uses to talk to the arc server. + + ServerConfig: + type: object + properties: + port: + type: integer + minimum: 1 + maximum: 65535 + db_path: + type: string + + ShareConfig: + type: object + properties: + author: + type: string + server: + type: string + + UpdatesConfig: + type: object + properties: + channel: + type: string + enum: [stable, rc, nightly] + + ConfigResponse: + type: object + required: [cli, server, share, updates, meta] + allOf: + - $ref: "#/components/schemas/Config" + - type: object + properties: + meta: + type: object + required: [path, requires_restart] + properties: + path: + type: string + requires_restart: + type: array + items: + type: string + + ConfigValidationError: + type: object + required: [errors] + properties: + errors: + type: object + additionalProperties: + type: string + # ==================== # Plan Schemas # ==================== diff --git a/cmd/arc/share_test.go b/cmd/arc/share_test.go index 9da2ba7..4f3092d 100644 --- a/cmd/arc/share_test.go +++ b/cmd/arc/share_test.go @@ -41,7 +41,7 @@ func startTestPasteServer(t *testing.T) *httptest.Server { // Mount the share keyring routes on /api/v1 against the same store so // sharesconfig.{Load,Add,Find,Remove} hits a real handler chain. - apiSrv := api.New(api.Config{Store: store}) + apiSrv := api.New(api.NewServerConfig{Store: store}) apiSrv.RegisterShareRoutes(e.Group("/api/v1")) srv := httptest.NewServer(e) diff --git a/internal/api/openapi.gen.go b/internal/api/openapi.gen.go index 0b94647..0f4a2b1 100644 --- a/internal/api/openapi.gen.go +++ b/internal/api/openapi.gen.go @@ -76,6 +76,13 @@ const ( StatusOpen Status = "open" ) +// Defines values for UpdatesConfigChannel. +const ( + Nightly UpdatesConfigChannel = "nightly" + Rc UpdatesConfigChannel = "rc" + Stable UpdatesConfigChannel = "stable" +) + // Defines values for GetReadyWorkParamsSort. const ( Hybrid GetReadyWorkParamsSort = "hybrid" @@ -209,6 +216,12 @@ type BlockedIssue struct { UpdatedAt time.Time `json:"updated_at"` } +// CLIConfig defines model for CLIConfig. +type CLIConfig struct { + // Server URL the CLI uses to talk to the arc server. + Server *string `json:"server,omitempty"` +} + // CloseIssueRequest defines model for CloseIssueRequest. type CloseIssueRequest struct { // Reason Reason for closing @@ -225,6 +238,31 @@ type Comment struct { UpdatedAt *time.Time `json:"updated_at,omitempty"` } +// Config defines model for Config. +type Config struct { + Cli CLIConfig `json:"cli"` + Server ServerConfig `json:"server"` + Share ShareConfig `json:"share"` + Updates UpdatesConfig `json:"updates"` +} + +// ConfigResponse defines model for ConfigResponse. +type ConfigResponse struct { + Cli CLIConfig `json:"cli"` + Meta *struct { + Path string `json:"path"` + RequiresRestart []string `json:"requires_restart"` + } `json:"meta,omitempty"` + Server ServerConfig `json:"server"` + Share ShareConfig `json:"share"` + Updates UpdatesConfig `json:"updates"` +} + +// ConfigValidationError defines model for ConfigValidationError. +type ConfigValidationError struct { + Errors map[string]string `json:"errors"` +} + // CreateAIAgentRequest defines model for CreateAIAgentRequest. type CreateAIAgentRequest struct { // AgentType Type of agent @@ -499,6 +537,12 @@ type Project struct { UpdatedAt time.Time `json:"updated_at"` } +// ServerConfig defines model for ServerConfig. +type ServerConfig struct { + DBPath *string `json:"db_path,omitempty"` + Port *int `json:"port,omitempty"` +} + // Share defines model for Share. type Share struct { CreatedAt time.Time `json:"created_at"` @@ -510,6 +554,12 @@ type Share struct { URL string `json:"url"` } +// ShareConfig defines model for ShareConfig. +type ShareConfig struct { + Author *string `json:"author,omitempty"` + Server *string `json:"server,omitempty"` +} + // ShareKind defines model for ShareKind. type ShareKind string @@ -610,6 +660,14 @@ type UpdateProjectRequest struct { Path *string `json:"path,omitempty"` } +// UpdatesConfig defines model for UpdatesConfig. +type UpdatesConfig struct { + Channel *UpdatesConfigChannel `json:"channel,omitempty"` +} + +// UpdatesConfigChannel defines model for UpdatesConfig.Channel. +type UpdatesConfigChannel string + // UpsertShareRequest defines model for UpsertShareRequest. type UpsertShareRequest struct { EditToken string `json:"edit_token"` @@ -779,6 +837,9 @@ type GetTeamContextParams struct { EpicID *string `form:"epic_id,omitempty" json:"epic_id,omitempty"` } +// PutConfigJSONRequestBody defines body for PutConfig for application/json ContentType. +type PutConfigJSONRequestBody = Config + // CreateLabelJSONRequestBody defines body for CreateLabel for application/json ContentType. type CreateLabelJSONRequestBody = CreateLabelRequest @@ -838,6 +899,12 @@ type UpsertShareJSONRequestBody = UpsertShareRequest // ServerInterface represents all server handlers. type ServerInterface interface { + // Get the current arc configuration + // (GET /config) + GetConfig(ctx echo.Context) error + // Replace the arc configuration + // (PUT /config) + PutConfig(ctx echo.Context) error // Get issue by globally-unique ID // (GET /issues/{issueId}) GetIssueByID(ctx echo.Context, issueID IssueID, params GetIssueByIDParams) error @@ -1001,6 +1068,24 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } +// GetConfig converts echo context to params. +func (w *ServerInterfaceWrapper) GetConfig(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetConfig(ctx) + return err +} + +// PutConfig converts echo context to params. +func (w *ServerInterfaceWrapper) PutConfig(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.PutConfig(ctx) + return err +} + // GetIssueByID converts echo context to params. func (w *ServerInterfaceWrapper) GetIssueByID(ctx echo.Context) error { var err error @@ -2365,6 +2450,8 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL Handler: si, } + router.GET(baseURL+"/config", wrapper.GetConfig) + router.PUT(baseURL+"/config", wrapper.PutConfig) router.GET(baseURL+"/issues/:issueId", wrapper.GetIssueByID) router.GET(baseURL+"/labels", wrapper.ListLabels) router.POST(baseURL+"/labels", wrapper.CreateLabel) @@ -2426,6 +2513,66 @@ type InternalErrorJSONResponse Error type NotFoundJSONResponse Error +type GetConfigRequestObject struct { +} + +type GetConfigResponseObject interface { + VisitGetConfigResponse(w http.ResponseWriter) error +} + +type GetConfig200JSONResponse ConfigResponse + +func (response GetConfig200JSONResponse) VisitGetConfigResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetConfig500JSONResponse struct{ InternalErrorJSONResponse } + +func (response GetConfig500JSONResponse) VisitGetConfigResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type PutConfigRequestObject struct { + Body *PutConfigJSONRequestBody +} + +type PutConfigResponseObject interface { + VisitPutConfigResponse(w http.ResponseWriter) error +} + +type PutConfig200JSONResponse ConfigResponse + +func (response PutConfig200JSONResponse) VisitPutConfigResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PutConfig400JSONResponse ConfigValidationError + +func (response PutConfig400JSONResponse) VisitPutConfigResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type PutConfig500JSONResponse struct{ InternalErrorJSONResponse } + +func (response PutConfig500JSONResponse) VisitPutConfigResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type GetIssueByIDRequestObject struct { IssueID IssueID `json:"issueId"` Params GetIssueByIDParams @@ -4343,6 +4490,12 @@ func (response GetShare500JSONResponse) VisitGetShareResponse(w http.ResponseWri // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Get the current arc configuration + // (GET /config) + GetConfig(ctx context.Context, request GetConfigRequestObject) (GetConfigResponseObject, error) + // Replace the arc configuration + // (PUT /config) + PutConfig(ctx context.Context, request PutConfigRequestObject) (PutConfigResponseObject, error) // Get issue by globally-unique ID // (GET /issues/{issueId}) GetIssueByID(ctx context.Context, request GetIssueByIDRequestObject) (GetIssueByIDResponseObject, error) @@ -4513,6 +4666,58 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetConfig operation middleware +func (sh *strictHandler) GetConfig(ctx echo.Context) error { + var request GetConfigRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetConfig(ctx.Request().Context(), request.(GetConfigRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetConfig") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetConfigResponseObject); ok { + return validResponse.VisitGetConfigResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// PutConfig operation middleware +func (sh *strictHandler) PutConfig(ctx echo.Context) error { + var request PutConfigRequestObject + + var body PutConfigJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.PutConfig(ctx.Request().Context(), request.(PutConfigRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PutConfig") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(PutConfigResponseObject); ok { + return validResponse.VisitPutConfigResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // GetIssueByID operation middleware func (sh *strictHandler) GetIssueByID(ctx echo.Context, issueID IssueID, params GetIssueByIDParams) error { var request GetIssueByIDRequestObject @@ -5961,106 +6166,112 @@ func (sh *strictHandler) GetShare(ctx echo.Context, shareID string) error { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+w9224kN3a/QtQGiJSU1D3r8SIRsA+ake0VMmsP5hIH8Ax62VWnu2lVkzUkS5qGICBP", - "+YAgX7hfEvBWxepiXVrqm9b2izVNFi/nHJ77Ie+jhC1zRoFKEV3cRznmeAkSuP7XZSIZ/wvgFLj6Zwoi", - "4SSXhNHoIvoogKMc+IzxJaFzJBeAcKIa0UkKM1xkUiDJ0KcIU0ZXS1aIT9FpFEdEfb0wo8YRxUuILqL/", - "OtOTRXEkkgUssZpPrnLVJCQndB49PMTRtRAFXKfNxegGdH3lhs+xXFSDE/tZHHH4UhAOaXQheQHdk73l", - "7FdIZGg629Q6YV5+usmUD6qzyBkVoMH/Cqfv4EsBQqp/JYxKoPpPnOcZSbBazOhXoVZ07w37Txxm0UX0", - "h1GF2pFpFaPvOGfcTFXf0SucIm4nU4CmEjjFmem/89nddEgAvwWOwHSMox+Z/J4VNN39Et6BYAVPAFEm", - "0UzPqTrZ7/RpuL6cA5XvLIr0ceEsBy6JwRdWzROD1XWK+bDKAbEZ0n3QCZzPz2P0KZJY3HyK1F8JS8Gc", - "jzWyiKOEA5aQTrDeuzpv6q8oxRLOJFlC6Jva7OuL0ftAam7kN4SGKbiG8mQpmsNc2UZEKFqSLCMCEkZT", - "UQ1EqIQ5aEySwDH6SMmXAtDltQWLPk6NNSxZCllgE9dIt6BCQBr6Ludsmcu23ZtWJOGrDH0sQAi179Cy", - "32KuRrBdWlYtJJaFaJvdtKITXlBK6DxGilQzkJDGhviDhCAZyyaFgEnCChrY2Y/FcgpckZnqqQATxoVk", - "EmcTyW6ABlb4QbUi04oSRkWx9AFcjvPg87ZfFIJrYCtBUCPgz+U4bKpYpFrO5fV781nf0RLFcon5KgTU", - "OYe5msNSkoWvhpNAM8aRXBDhUBbFweFboGrgQUvY6s5CEf3amE1Al1jtx5gdVS6wrIgBiSJJQIhZkWWr", - "4AyaWDYbHWgKKbojcoEwtaw2NLSlzcGDJwVX5yJbIftlmGYa6E/uAmfsZ8ZvlFaREg5KMVhZJEIT3tX5", - "6OYx3efViuzwgS/FfQ3laAoZo3Ol5bRwAL4p25YcUzPzRGsUAd4jF0qr8iCBqo/QjGSBcUMn1dtvc9ra", - "4oNHNk1fs+VSi8NSQ6mfKc1Zg4qVvxbdq2WGK8gVrdJk1TpJqruISQunvr7SzHABSKuABnv2GxSmISe+", - "u5SIamFKrDf2VF+UHbJlj2/wFLIPTOuvrbvMVKd+WJpuoYleYZksrkDxlJLZitbpSBqQCm+IkAqYtYOk", - "FfxUj6sUYQlGTWgBaoQ5x6sAOYpNFt0mIswy0i5GZRcukOs7jEG9ylhyA6nGkZYWWfbTLLr4pZtITPeH", - "eH2dUzPaZKrl2FCYxd53FUfuEcmNT/xRAjD//BBHrzMmoJscOWARUivf6d81o04yJmoSwCPZBnwtJwlI", - "/UIujPmxFZ3YcIiyL6HyTy/DuqravmUoTbyE+VocFXm64ZJCfLmcO3bbt1P2qlGvdXNppbQgb7CRskOr", - "IsSqf8NGQN1bYpWnoC3Yo3J3UUWpYrfQxW61sF71q64s1T+2S0e6D1JHqQ4zq3jqFgW0Y1O11mdqR1M3", - "38Vk0kWRl9coYalCl1vtx49hYK8d40Y7fDXumIkWb4EOhkkNUZX0joyWpA4jYZxIa8BpBEYXf4yjJf5K", - "lsUyungZR0tCzd/jEGeuDlTXpO9NL4VkIjO9xiX++gboXOH52/G4D23ms3Y8aa2t/SixrEVq9QHeuA/7", - "9Dzdq31xbzNM+5Rzz5fWWERGKEyMuavaaZFleKqgaPyXPTqHG7l7fa0LU6eqPI/dYKi6dsxlrJwO+6FD", - "oDmzr0eaOaStOceIyDO8Qro19unvRYD+4ijMg37Sf+AMYSFYQrSHo2LG1lYLCD2Yka/tXnJkO6wtKx5C", - "d+XoIaBXhlGA5B6hsblvjK4cOE5r1l8Lo2rT5J5u6nnaWtDq61XaqvF/4DhftFm4QBNnnDmDYdiqQ5aE", - "G1JuZ8Cg9WsXXJuse/8fLDaAKu5vjRc1Qq41rbNkQTITT8mwsd1SIhJ2CxzSsxlnS2/8CsdlAKMOVXA/", - "1w+I7o2WIASe9wt3M0hoV9/dhu0ZHeIKmjOVCbQVUwdufS2/MyCiejoBvR0LicLd5BZnBQRbWZa2tvYY", - "RN6uYgvM3gNW7c+jLftNVBptpbd6kiwwnesfLE7M38oqNtTHcqCQepSdrCY4Tdd/4rBkt/pH7ZYpu5h/", - "udYQyZZ+hi1ofy7S9DrDRQroNUvBU8TD0Sa11Ull44c7bMjHDSiH8xvnEghwr8dFw3bARDfTodcYjW1F", - "HGbAgSZQxQTni7N/NzHBXwnHZ5evXrfEBTvc3aSKh29LedeEKzbzWdUVfn+dY3SScCJJgrNTdIZeopMp", - "Tm4yNj+NNrEH6l77xno4pjehuf+MCqraIEUngnEpUIaFPI3Ri39Ff0YZuwOOVDv6M7pj/AYximaECxnt", - "0ybZlkep7unXk3uxuRJFNcKoHbPaQkI8VtPJFUhMDIE8zUG6ffUk4OasSNtXOYp5FEczwLLgGrhY3Cix", - "k5NEQWTBOAQ59hvnn2+xA+vk9xf4inSTYtjeuf/DbDYej8cth33HpuNbPCdU4bhytQd0USzxYJw0o7oB", - "BpGRJQl6suOIzWYCWtp0AHuAA1wvuHO7mg6eulVHycezvQzT7VhgNXt83d2fYUluQZuhzlGWZ5iiJeY3", - "KbujLT6yTsmlB1CKi/rj/Kv6D5mlnnZ7WLsQpOBRMeDtsNUKMuFMh16u6flpNnPQPD720efrWQ/5UXB5", - "D5gq9odOaJFl2gmsDC+s/gZIlehW2OlzFcWRQmpYWAcll+0dl+DoVfc9THuMPeV4JnWq3oTDLYE7ZT/k", - "ObcaOgf1eYs6rkb8mcjFay/9bJB804ewKd48zNbB/Vd3amwPxAGnSFm21clqdToHRJx1X22HD2zDUdZ1", - "6qsMi1Ieqt/O8IvpH1sk4rP1u1VbnKZmb91uuG1xrLr/bjNm9X6BOWyHlCAl0iSfhV12YU51A6vJ9E8v", - "C56Fm4lJEu1UwdUe/kN1dJxIH6dgLJdnA3mUnth8UFtkbZ+9bKtamse1MpZgNZBQjWHmpFgdEZIkARUG", - "384nGeB0ohAxWbDCJHVXSGLF1OcmVgx4qQakVI4CqW3GC9DVJVW2Le/pROgk52zOQYjOfiwH2tmhzxQE", - "nK46BzBZke091rBfN6v8b+uLDW5xHX4NmDeht7aFIBU1BJ9aSH0F1VTeHJWHK0RkHwAvtfT7GhAm2jjq", - "OXjeAN+p7hW2elLrm1hkmSXuNCWGRb+tLWfgMt6xDKJmGroGLZpzVuSQoukKScDLJZaA1MROpqzBPUwX", - "kVtsCFHrAAmkYIX9K851MCTq3BrD9GZv8TWmkItQNp1AbGZ8SyKQSzfcL9SyPd9d9JiY8GB4VbGfwYCs", - "eUpKfb81tW+d1poYLvnMIBOzgbPerLp2NvGhzEfws+nW3MlqUJ08WWU8AJXcBHXKJYePYU37r+Zdp4KP", - "WuXYcR6pmeQ3lFhxFMkULWjYTd5E+3zGxNbW1CNSITbIbKimM+BqnW1zf8XaKuwAHYt4SsrDj3D3uHQH", - "/eFWTC810gZWVxj5ArjUOnUrGH6DVkiTZh60Aj5j7hxgo5HZKsb3QCVhb/BU2KEvooWUubgYjeZELorp", - "ecKWI6F7ZXgqRpgnTaXqtRIbOHMZ+BwnN4aV6kq/GePo8voMC0GEQrdltHeM38wydifOP9FLnqCcs1uS", - "gnCOgjORMKWimUGXmOI5KCliylqqMGw5X/yJmgCWLrXSwcgYYZoiXKREqm4kU5OV8l6JwAQZy/2DGgQ4", - "unx7HcXRLXBhtvbifHw+dsYJzkl0EX1zPj7/JjK0relsZATx6N4WoT6oH+cgQ/5UJV5vQSBM7camK0Sk", - "QPOMTXGWrc4K4y25vtL7ZIVEhhIUyJwLJTFawjn6KGzBA9A0Z0QBZwEUrViBFvgWqlmur9C0kChl9J8l", - "uqHsDjGOKEBq/E5mXAMbdYJ0zd91Gl1EP4BRRl6ttDj0i4d/aSiPNMmKFNCsyDKUmlgROvEDsz5mDK7K", - "auEvBfBVVV5rP68VC5f5hDOciUr7mDKWAabRw8PnteraP47HG1WVMgobBbV6e7mA2cPnUEWsxozb6EMc", - "vRy/bOMs5bZGZbnsQxx9a/bX/UG9zlcXvLoaO4XdigybJKjjY3Ph65oPDRoIzV51Gbl6bu2vHFURZns+", - "6tT2hgj5xnR5IioHadwmqNdUsxuocpUxdvnbAL0eEmeZBbsbuoK4/UFDnIkAsLw8VVuDDkK+Yulqa5XU", - "gUzYh7psUmbAQwNVL7a2AouhAEZUA3JJPvrwDECIV26/DRwa+CCMKNwZBIbwV9H96F7//0e8hIeqnqmJ", - "WVMRVWG2BtyXgSiOBoareDoIJzFLRrgdDD3Cw+zB6pWB+xZKyG1038LnOMqLwNnxbJUdnZ2ANTTo7Iz3", - "dXZcWtzjzs7+KcwAtIvC1EFT2rZxlHfwTB222yXL9PPf98wxTUiyiXT1+6H55TuYKxOA66L0fAFLUFZD", - "brDh0Gkw6GFzdK/+Z9XqbpZZIraPY2poHAPD1CFfZaQoI6DMoWxCIw4rTD+ADO95vFWC8oPjbbSlzTI3", - "48G0WZOcAhKnWGINWGV6oyq5oAnYNbEUuutH098WBY/ntNqp+Ak4x/YshAZQj216rgKpTNroILImLxv5", - "+dKttpCXP7Qfi8hPWBpgF7m1oYxsSURos0iD1GTxdPPEnR3dHuXBgWjXOsRa4OQAqkRJDK3If6JSsf9z", - "e5mmCK+R17AzW3n2d0Z4WCaLLqnx3oUldys06iGOA8iMEMGZRT1rOVFFlUPUZtywPSLBddqLOLCJDhu4", - "yMpNbNVJlle7LiHnfurzkL2tsjV2x6zrEbl9M2qHpoBq7kIGR+Qpq9JnArj0D8Lovrxlc4jx5+G51/4r", - "01mPwATsAkeH3de23fE+6erg4QsXE5uu6gGLOnvYKGRR3QrbZ8btlLMEY/37FsX9FPBcxfFjmNAIk5Hw", - "aqdapbRXYtXj9/6ryeVBHIS77IaDLDhtCYyaeqNgWFSnYJS5QS/G+p8uPehF6FqP5n04umaKMIps8VJ4", - "EWVjYBXj7pykJwdpO8k1UOIWIl3XTdut9Svvtqi4eIPqJAgcIDpMzsqJn8ypOpSgEiA7VYMad1DtWREK", - "FCQ2se9dVHVMWlG1rFbyGMKWRlNlQp5VqtL2KSp4a+OO6KrzWss9C8Pu2yqDN67LZGF1TMvgD0VqtaX4", - "3O5JtHZv/xqkn9dZUJ+G7p3SY1DSB5zOdlW9Y+fjg/G+g2vu3lrWlfetScW475bCgOeupOnNnHebHpiR", - "uVO7R428nO/N97/++MEGTh93s+eBqMlpW/aWcqNr9R/X50BVfVrd5XzXcYi122b3rtGtEWWQp5lrZZ9b", - "IGJd/Svv5N2KQLb8ZXSv/1/PSg5IqIqQdiafhmPyGGSTWck/gGSKu+9hDsxjKWYv8q+iz1FVh9ZJqqp7", - "Vd+2S5INVNGFqNZcil0V0R08CaZEsPTB9DsJb52Eh1Gs1cuPiWbfNy+7Pgaqbd7B/Y9qFLgC/Q6y8d+k", - "OGo38ud9WCe1Fzo2ME0soF1Ju85S1L8ROke6MG5b9FufySNcfanDE727rYRUFZm3WpHDKOh7kkngSuFx", - "jxaIIs/1zYnLIpMkzwDpS111zRZ8zTOWltdAhUiszHXYENNeNfJaNbmQK122N2N8GTjG1Q5sIeIqhyfv", - "wt6VuOEeaqXbj96Gq/lu30SMxmcvB+7Ev9yguZvhNeWP3k6Qza4tsl6nv5FO4MHNPNxRlj6eGFYokL5g", - "mgNFtwQj/9Jpr560rSLRdN98VUWWnUn4KpEAzJMFcsOG5viy2di/xw53ETu07HJQ3NBy3605sayUIjQQ", - "Knx0CebwKKGRrpuO778hbFC0K19U7Y6PPXuirObRVsd7TKFEYtHYoJweLaJewd4dzXGk0h/JcXXOhw/i", - "tMIl3qRc35bmE1oF9N1dBZwVEs7Rf+KMpFjqG5TcRd3VQ5LmtaEcEjIjkA4pvv+98H7Xhfff7P716Uv9", - "zCtKgSqsnxiqSBkI/Ry1IQ9FHZYeTo/gRoAApW9ZHsUb3B/QmYp3vJIrcDvVnvMWeiTXM03ge7qQG+kL", - "EZ+UI7Mh/YZVr/I5zuPUvBqvhT5Y+j0MudpbLJ9NkE8tt9QfnkivfTWDP4Dca7ngBqWCzh9X7uFg4s2t", - "wOQJNBFTrnCfki3IGaonuI+SMzRfCN+zSTagONG8yfS8ShMtASplsJs8N+cco3v71yAbzy9z7bPyHMCP", - "4xaaZlnn3k913AahthBTiZnOEFPvy209ivJuS5eD970O4godJPVsb6rppMLhh9dd1dwm8q/qzzHuTDFb", - "f8gywHb9pejbN7yXng5sz6YdS6sQVHtG7hgUAO/dqyPVAaoVHkgN8J8G6yDJ1bNUBvyLToP6wBrFbsZW", - "Rvf2evef+nL43+nnLLdKjX3838Oce0zzIIgwW6/jQr/Z04+NA6ka11f6XvcFrJGPAWNY/fAIYUs5Lg2K", - "04+7doqy724tPx6U7GLGe0rY81s/6vntcWS6mJeFNzCpLVgPJl/N/coWGUdjVA8ny+pS2kMLe30z4wd2", - "vM7AtTU+VcM3N1FqsaxOMXHJXc9IPmviaRHN9ZspN6DFoRfFGsmkofg9Z8tt0c0wtFmZbEShh7qDiWeD", - "ijbJ3H4R7X5kcnXBbZ8kfuRVt8OJzLxwfnCG904vYydEu7doRPlY/HPhWgboCNs4yiPCEiadtUOJe6c6", - "/Mz4zfCU0yphMxqYhjkw+3JYeqVNohycNTk0WfLIU/beMy5RzjKiDBrG9WukFhfi4hM9Q4vVlJMUndg5", - "Ty/QO0jK1EqBTj4V4/E3yct/W5wiwbg0L6o5kI04pjcxYlkK3H0xXSE8BzW263WBLrM7vBJ6gBpa/v4/", - "/2ueaFd/VK8rqo/VmEI2Pq06oRPTxTzqHuvt2efnUZIBVkzz9FMb1NV4YaBHBiRRXL6/V/7gUYqZO/DS", - "3n4siI2T5H3Eb9ftJuzYkpXP7J9QVmXg+/by6b5y5oXE3WaoHeW97rdD4eK97dlxw5Lweh36mi1vLVu/", - "a6sVXxLw8iypnqdsS9bTOd6k+cLjAghHf3MPPV78y99sDtw5+nmhZCFFkJNkQpQ0/ETtQ0RpjBjNVlXO", - "uPaoYKn7opOuDHKEOXyixGTlpefoZ/uSj50l1lcaUkbPfBlsa1PK5yjNEtUw/hs9amgrMSBtyRn0H/Ps", - "kcDlI8h6U9dX6pzqXEb1h79z7DIWE921hW/aDUY9Je27K37zdh44UKrZPZ5koN0kFm6fDT1gqKLjedLG", - "KzGqeRvnTj9A3F1G9N502YcAM09RbyDAzPLRCYW7UuifbvEK0kIuGIfUzVO+2K7fb0Y3sNLEXaHFgrP9", - "alLv/bqdhWIbL+TtOe/QYjFQfaoakJAKpIdKmL+mCjxIa715hhNQLE6vy2JTv4W6CuG0Oi+je/3/QckU", - "Fa773CsGPM69csI4wlOhtO6///f/IYGX+i4pPcbpdh0oZv8lcTtAnJAUljlTBHEapvHWyufwnvdGYhwS", - "xtMDesaRIHSewVDK6pHWZlPXV+jEPK14NgeqIF6qOSjHQgJaMCFPW+qYDb1u6FlSG9MzhlZ1+fYa3b4o", - "348c4ZyMbl9or4zdYNvtu9Wjjr69Xd4fHS4kef3u45WO32dkBskqyQCVdCeqcUpjpqknKntEGyNfCihA", - "j9Wo3bWjGDOkbSlecC20lVossM0hGPqwfGbuvu29hepZS3ARs3oWUehre1GFrwSpcbRioTUOI8Ocx0Vr", - "Fp3F8noVelA2VfSBpyQzBnBZzXnmXau5PtJ3tSeA3I38mEsyw4m/J3NJemApWiyfCZJWZ4vN7CGwotq6", - "BJThqV/KXOJkQSisnQhdKfL/AQAA//+2xGyGKq8AAA==", + "H4sIAAAAAAAC/+w9224cOXa/QtQGiJS01PKOvUgE7IMsze4K8c4Ysp0JMDZ62VWnuzmqJsskS3JDEJCn", + "fECQL9wvCXirYlWxLi31TTszL9NW8XrO4bnz8CGK2TJjFKgU0flDlGGOlyCB639dxJLxvwBOgKt/JiBi", + "TjJJGI3Oo08COMqAzxhfEjpHcgEIx+ojOkpghvNUCiQZ+hxhyuhqyXLxOTqORhFRvRdm1FFE8RKi8+i/", + "TvRk0SgS8QKWWM0nV5n6JCQndB49Po6iayFyuE6ai9Ef0PWVGz7DclEOTmy3UcTha044JNG55Dl0T/ae", + "s18glqHp7KfWCbOi6zpTPqrGImNUgAb/W5zcwNcchFT/ihmVQPVPnGUpibFazPgXoVb04A37Txxm0Xn0", + "u3GJ2rH5Ksbfc864maq6o7c4QdxOpgBNJXCKU9N+67O76ZAAfgccgWk4in5g8k8sp8n2l3ADguU8BkSZ", + "RDM9p2pk++nTcH0xBypvLIr0ceEsAy6JwRdWnycGq3WK+bjKALEZ0m3QEZzOT0focySxuP0cqV8xS8Cc", + "jxpZjKKYA5aQTLDeuzpv6leUYAknkiwh1Kcye30xeh9IzY38D6Fhcq6hPFmK5jBX9iMiFC1JmhIBMaOJ", + "KAciVMIcNCZJ4Bh9ouRrDuji2oJFH6fGGpYsgTSwiWukv6BcQBLql3G2zGTb7s1XJOGbDHUWIITad2jZ", + "7zFXI9gmLasWEstctM1uvqIjnlNK6HyEFKmmICEZGeIPEoJkLJ3kAiYxy2lgZz/kyylwRWaqpQJMGBeS", + "SZxOJLsFGljhR/UVma8oZlTkSx/AxTiPPm/7WSG4ArYCBBUC/lKMw6aKRarlXFx/MN36jpbIl0vMVyGg", + "zjnM1RyWkix8NZwEmjGO5IIIh7JoFBy+BaoGHrSArW4sFNHXxmwCusBqP8bsqHKBZUkMSORxDELM8jRd", + "BWfQxLLe6EATSNA9kQuEqWW1oaEtbQ4ePM65OhfpCtmeYZppoD++D5yxnxi/VVpFQjgoxWBlkQhNeJfn", + "o5vHdJ9XK7LDB74Q9xWUoymkjM6VltPCAfi6bFtyTM3ME61RBHiPXCityoMEKjuhGUkD44ZOqrff5rSV", + "xQePbJJcsuVSi8NCQ6meKc1Zg4qVvxbdqmWGK8gUrdJ41TpJopuISQunvr7SzHABSKuABnu2DwrTkBPf", + "XUpEuTAl1ht7qi7KDtmyx3d4CulHpvXX1l2mqlE/LE2z0ERvsYwXV6B4SsFsRet0JAlIhXdESAXMykHS", + "Cn6ix1WKsASjJrQANcKc41WAHMU6i24TEWYZSRejsgsXyLUdxqDepiy+hUTjSEuLNP1xFp3/3E0kpvnj", + "qL7OqRltMtVybCjMRl6/kiP3iORGF3+UAMy/PI6iy3fXl4zOyLwJYaOcB1jszTt9xi7fXWudQ/MnnN46", + "PoV5bBX70yBvagD8MmUCuo8EByxCqu2N/rsWFnHKREUKdU5puFlA88jlwphAG9HLDZcq2hIq//A6rC+r", + "7Vum1qSNMG8dRXmWrLmkkGwo5h657dspe1W5NtKJU9LHVEvC0+q3I7WuLh90K6/XAvNe5v1BNSr7GIiJ", + "vl6fTDPXrwY1tb1i0W4d5djtkPLZ2TC2Uqy8DuIlSNwEvNMiGpRily8mHLSsX4cZ1XZvVYbGiM1tN0/e", + "l7UAOTK7bIfnf+KUJNooLTwXVXhoddcc7CQhqiFO31f1lpa9e3vwV2wHDC5Jn5XCbdDCyQZ7DbZo5od0", + "p1+xVV51X1prJuic6bGBu6iisHlb6GK7ZlGvPVS1Xqqd7dKRboOUXKnCzFqC+osC2qHZPvWZ2tHUrYRg", + "MumiyItrFLNEocut9tOnMLBrx7jxHb4Z/+hEC4ZAAyOxh9guekfGbFGHkTBOpPWoaARG578fRUv8jSzz", + "ZXT+ehQtCTW/z0JqSnmgOmWuaaWQTGSq17jE394BnSs8vzk760Ob6daOJ21GtR8llraocH2AN/78PsNL", + "t2pf3PsU0z5r2XNuNxaREgoT439S32mepniqoGgCCj1GgBu5e32tC1OnatKiRdRmKpt2zGXcDh0GfYdA", + "c36YHmnmkFbzVhORpXiF9NeRT3+vAvQ3isI86MfM6AwIC8Fiol2OJTO2mlBA6MGMfGsPWyHboLas0RC6", + "K0YPAb30VARI7gnmi+tjjNfAcaq5Y1oYVZtZ83zfi2e6BN0wvRZMOf6fOc4WbS4noLHzljilediqQ6a9", + "G1JuZsCgO8ouuDJZ9/4/WmwAVdzfehPUCJnWtE7iBUlNgDPFxpmSEBGzO+CQnMw4W3rjlzju0subB0S3", + "RksQAs/7hbsZJLSr7+/Cxr2OOQdt+9IfsBG7H+58Lb8zQqlaOgG9GXcBhfvJHU5zCH5ladL6tcc74O1q", + "ZIHZe8DK/Xm0ZfsUpl4ZPprEC0zn+g8WJ+Z3yoyBwYFlQCHxKDteTXCS1P/EYcnu9B+1n7RoYv7lvoZI", + "tnD8bUD7c6HfyxTnCaBLloCniIfDv2qrk9LhFW6wJh83oBzOb5x/LMC9nhae3gITXU+HrjEa+xVxmAEH", + "GkMZpJ8vTv7dBOl/IRyfXLy9bAnUd8SfSJmgsinlXROuWM+JXFX4/XWeoaOYE0linB6jE/QaHU1xfJuy", + "+XG0jj1QDaM1vU6Y3obm/iPKqfoGCToSjEuBUizk8Qi9+lf0R5Sye+BIfUd/RPeM3yJG0YxwIaNd2iSb", + "cq9WQ296ci9YXqCoQhiVY1ZZSIjHajq5AomJIZDnRSw2r54E4g4lafsqRz6PRtEMsMy1H1BicavETkZi", + "BZEF4xDk2O9cwKzFDqyS31/gG9KfFMP2zv3vZrOzs7OzlsO+ZdPxPZ4TqnBcxr4Cuig2Pt9BOGmmWQQY", + "REqWJBhaGkVsNhPQ8k1nlAyISOkFd25X08Fzt+oo+XC2l2K6GQusYo/XY18pluQOtBnqHGVZiilaYn6b", + "sHva4iPrlFx6AKW4qB+n39R/yCz1uNvD2oUgBY+SAW+GrZaQCace9XJNz0+znoPm6YHAPl9PPQZPwSUi", + "YarYHzqieZpqJ7AyvLD6DZAo0a2w0+cqGkUKqWFhHZRctvWoAEevuu9h2mPsCcczqXNnJxzuCNwr+yHL", + "uNXQOajuLeq4GvEnIheXXj7oIPmmD2FTvHmYrYL7r+7U2BaIA06QsmzLk9XqdA6IOOu+2gwf2ISjrOvU", + "lylPhTxUfzvBr6a/b5GIL9bvVm5xmpi9dbvhNsWxqv679ZhVJfbdlJbTSWvkN2Mm2lso9X948+a7N55i", + "/2pYWswHF21/PjFDQqTJRw07DcO88hZWk+kfXuc8DX8mJm+8NxngP1RDxwv1gQ6mVvB0IJfUE5sOlUVW", + "9tnLOP08hXWyUsrUiQFpL+X+PeacshinLvoe5sGKoxMhSRzQ1PDdfJICTiYK25MFy028vaQElk99pmml", + "nZfiRAodMJBSa5wdXU0SZcLznkaETjLO5hyE6GzHMqCdDfosXsDJqnMAk43d3qKebVGxHv2+1cUGt1iH", + "XwPmTejVthAk1YZ8VwuprqCcypujdOSFiOwj4KUW8t8CMlPbgD2n2xvge9W8xFbPlZ4mFlkKnVkjA5dx", + "w1KImtdfNGjRnLM8gwRNV0gCXi6xBKQmdqKzOxHF7cwtNoSoOkACqZ9hN5LzkAwJrreGar3ZW1yqCWQi", + "lMUrEJsZF5oI5PAOd3+1bM/3ij0l9D0YXmWIazAgKw6hwqxpTSmu01oTwwWfGWRJN3DWm83bziY+FmkX", + "ftpbzWuuBtVJ22ViB1DJTeyqWHL4GFaMnHLeOhWYXL4t56+bSX5F+SMHkTPSgobtpIe0z2c8CdpofELG", + "xxoJHOV0Blyts63vlqmtwg7QsYjnZHb8APdPy+rQHTdiYaqR1jAu25EvWpOgF5hS45d2epKQ2OjCPFb2", + "IJkvZLoKKEPh2QRwqTX4VqD/Cg2rQNaxVvdnzJ06bPQ/e1f7A1BJ2Ds8FXbo82ghZSbOx+M5kYt8ehqz", + "5VjoVimeijHmcVOFu1RCCqfunhHH8a1h3Po+84xxdHF9goUgQhGXZev3jN/OUnYvTj/TCx6jjLM7koBw", + "3pcTETOlEJpBl5jiOSiZZS7vlbHtYr7RZ2qigvpCqY7wjhCmCcJ5QqRqRlI1WaFdKIEbI+MO+agGAY4u", + "3l9Ho+gOuDBbe3V6dnrmTCGckeg8+u707PS7yJwkTWfjuKD5uXGiKyrUidjXSXQe/RmkPRW1O+6/Pzvb", + "2N3uWkp94JL3pU1MNau115fV1t6YZYRGL5Y7rl6K17fD3YVUtUHtFHSpr5jHtVlGkcRzYRm6gsQXdTLy", + "ALDe5z6w9Ml+y5LVhuHkCg741Qke94odwz6TJnZeb3wZ9XsCgdWUTcpyBM8mkxvIUhxDcS2ql0QeR9HY", + "qNTjB1vG4tE7ZfUAkFKU70AgTC3TmK4QkQLNUzbFabo6yY179/pK8xCWS2QoQLEj5/ONjb5/ij4Je2US", + "aJIxohjPAihasRwt8B2Us1xfoWkuUcLoP0t0S9k9YhxRgMQ4ys24hu80+IJmPm9XWrH1y4/83DADaZzm", + "CaBZnqYoMcFtdORnkvhcz/DBot7I1xz4qizQYbtXyo0UCdAznIrSjpgylgKm0aO5q/KM08EorBWF723l", + "IvyPX0I1NTRm3Eb1KXrdT7xFwY1NMcWCDJsk6NG7sxofGzQQmr1sMnYVYXSAZVymxASl0Dsi5DvT5Jmo", + "HGQ7myyEpsHcQJW7W2uXvwnQ6yFxmlqwu6FLiNs/aIgzEQCWl1i/LTnUTN0fJJNebWwFFkMBjKgPyGUl", + "liKoGyFewZ5N4NDAB2FE4d4gMIS/ku7HD/r/P+AlPJY3opuYNXeqS8xWgPs6EHbWwHB3pvfCScySEW4H", + "Q4/wMHuwFmKgYlMBubUqNn1p0eA8r8OWzk7Ar7Fjfa7n7Lg83qednd1TmAFoF4Wpg6YsWRPy6uCZOs9g", + "myzTv7CzY45pciiaSFd/3ze/vIG5Mq+5LmuTLWAJyiLPDDYcOg0GPWyOH9T/rFrdzTILxPZxTA2NQ2CY", + "OkcF00QbAUXSdxMao1azPbzns40SlJ/N00Zb2uXhZtybNmuy6UDiBEusATsjKaAyG6oJ2JpYClUL1PS3", + "QcHjuZ+3Kn4Cbu4dC6EB1GM/vVSBVGSZdRBZk5eN/QserbaQl/C4G4vIz7AcYBe5taGUbEhEaLNIg9Sk", + "HXbzxK0d3R7lwYFo2zpELQS6B1WiIIZW5D9Tqdj9ub1IEoRr5DXszJYxuq0RHpbxoktqfHAJBtsVGtVg", + "5R5kRojgzKJetJwo80NC1GbcsD0iwTXaiTiwKUtruMiKTWzUSZaVuy4g5/7U5yF7X+ZdbY9ZV2Pru2bU", + "Dk0B1dyFDA7IU1YmwgVw6R+E8UNRp3uI8efhudf+K/LvD8AE7AJHh93Xtt2zXdLV3sMXLiY2XVUDFlX2", + "sFbIoqwr32fGbZWzBLN2di2K+yngpYrjpzChMSZj4V32bJXS3p3QHr/3X01WHuIgXHUuDjLntCUwai5I", + "BsOiOpmqyPJ7dab/2XlnpFnAS1/yJIwie9syvIjiY2AVZ93Zhc8O0naSa+BOboh0XTNtt1aL5m5QcfEG", + "1QlGOEB0mJwUEz+bU3UoQQVAtqoGNYrm7VgRCtygbmLfq6x3SFpRuaxW8hjClsZTZUKelKrS5ikqWPd5", + "S3TVWRh7x8Kwu9518M0WGS+sjmkZ/L5IrbIUn9s9i9Ye7K9B+nmVBfVp6N4pPQQlfcDpbFfVO3Z+tjfe", + "t3fN3VtLXXnfmFQc9ZVVDXjuCppez3m37oEZm1c5etTIi/nOfP/155PWcPq4UsR7oianbdl3Toyu1X9c", + "XwJV9Wl1F/NtxyFq5bF3rtHViDLI00wd7JcWiKirf0UR8Y0IZMtfxg/6/9Ws5ICEKglpa/JpOCYPQTaZ", + "lfwDSKZRd+H4wDyWYnYi/0r6HJc3SjtJVTUvb6puk2QD92FDVGuq+JfXYfeeBFMgWPpg+o2EN07CwyjW", + "6uWHRLMfmtX5D4Fqm48G/KMaBa7URgfZ+K9aHbQb+csurJPKG19rmCYW0K44hc5S1H8jdI70pdNN0W91", + "Jo9wdXmWZ3p3WwmpLBfRakUOo6A/kVQCVwqPe2VF5FmmS70u81SSLAWkq1DrO1vwLUtZUtStC5FYkeuw", + "Jqa9ugK1uhBCrvSV2Bnjy8AxLndgL/muMnj2Lmxx1zX3UCnC8ORtuOoN7ZsYobOT1wN34pcpae5meHWI", + "J28nyGZri6xW3FhLJ/DgZl4aKq4+HhlWKJCuiM+BojuCkV8l37ur3XYj0TRff1V5mp5I+CaRAMzjBXLD", + "hub4ut7Yv8UOtxE7tOxyUNzQct+NObGslCI0ECp88hXM4VFCI13XHf8iloz/BXCicPRlm76oSrWeHXui", + "rObRdo/3kEKJxKKxQTk9WkT1Bnt3NMeRSn8kx91z3n8QpxUuo3Wu69ur+YSWAX1XB4SzXMIpspUJdC00", + "97JA+RS1eR4tg5jMCCRDLt//dvF+2xfvv9sYm2gtV3GhH4pHCVCF9SNDFQkDgSiTljwUdVh6OD6AigAB", + "St+wPBqtUT+gMxXvcCVXoM7cjvMWeiTXC03ge76QG+vSps/KkVmTfsOqV/GY9mFqXo23vh8t/e6HXG09", + "2hcT5FPLLfSHZ9Jr351BXcVrh9cF17gq6PxxxR72Jt7cCkyeQBMxxQp3KdmCnOEiScqbhofHGcrl7ckk", + "G3A50Twi97KuJloCVMpgN3muzznGD/bXIBvPv+baZ+U5gB9GFZrmtc6dn+pRG4TaQkwFZjpDTL1PTfYo", + "ytu9uhys3DyIK3SQ1IutVNNJhcMPryu63ibyr6rvx25NMau/vBtgu/5SdPUN72m6PduzScfSSgRV3r08", + "BAXAe6jvQHWAcoV7UgP8tww7SHL1IpUBv4hwUB+oUex6bGX8YB9q+LEvh/9Gv7+7UWrs4/8e5tzrv3tB", + "hNl6FRf6kbF+bOxJ1bi+0i80LKBGPgaMYfXDI4QN5bg0KE6/Rt0pyr6/s/x4ULKLGe85Yc83ftTzzWFk", + "upin0NcwqS1Y9yZfTe1yi4yDMaqHk2VZlHbfwl5XZvzIDtcZWFvjczV8U4lSi2V1iolL7npB8lkTT4to", + "rlamXIMWhxaKNZJJQ/FPnC03RTfD0GZlshGFHur2Jp4NKtokc3sh2t3I5LLAbZ8kfmKp2+FExkE//7Zv", + "hnejl7EVot1ZNMKA8gVZFQboCNs4yhPCEiadtUOJu1ENfmL8dnjKaZmwGQ1MwxyYfTksvdImUQ7Omhya", + "LHngKXsfGJcoYylRBg3j+vlkiwtx/pmeoMVqykmCjuycx+foBuIitVKgo8/52dl38et/Wxwjwbg0byM6", + "kI05prcjxNIEuOsxXSE8BzW2a3WOLtJ7vBJ6gApa/v4//4vUEPpH+Rir6qzGFLLRtWyEjkwTNCNcyJHe", + "3hTHtymbozgFrJjm8ec2qKvxwkCPDEiiUfFCVPEHj1LM3KFnonZiQaydJO8jfrNuN2HHlkw/poQYRUeU", + "lRn4vr18vKuceSFxtxlqR/mg221RuHiv9HZUWBJeq32X2fLWsvFaW634koCXJ3H50Gxbsp7O8SbNt1oX", + "QDj6m3uy9fxf/mZz4E7RTwslCymCjMQToqThZ2of+UpGiNF0VeaMa48KlrotOurKIEeYw2dKTFZecop+", + "si/52FlGuqQhZfTEl8H2bkrxsKxZohrGf6NHDW0lBiQtOYP+s7w9Erh4tV1v6vpKnVOdy6h++DvHLmMx", + "1k1b+KbdYNRzpX17l9+8nQcOlPrsHk8y0G4SC7cPAO8xVNHx0HDjlRj1eRPnTj8l3n2N6INpsgsBZl6u", + "X0OAmeWjIwr3hdA/3mAJUv2aOyRuHm1jqlOpX2JHt7DSxF2ixYKzvTSp9zbk1kKxjdcnd5x3aLEYuH2q", + "PiAhFUj3lTB/TRV4kNZ6zZNv2GDXYVO/arwK4bQ8L+MH/f9ByRQlrvvcKwY8zr1yxDjCU6G07r//9/8h", + "gZe6lpQe43izDhSz/4K4HSCOSALLjCmCOA7TeOvN5/Ced0ZiHGLGkz16xpEgdJ7CUMrqkdZmU9dX6Mg8", + "W3oyB6ogXqg5KMNCAlowIY9b7jEbel3Ts6Q2pmcMreri/TW6e1W8zTrGGRnfvdJeGbvBtuq75YOpvr1d", + "1I8OXyS5vPl0peP3KZlBvIpTQAXdiXKcwphp6onKHtHGyNccctBjNe7u2lGMGdK2FC+4FtpKJRbY5hAM", + "dSyemWs+vl57kTLY3b5N2ZqBJLwXZ8EF3KpJSMHJTUkJX4dS42i9RCssRgQ6h41WTDrv2utV6EHZVJEX", + "npLU2M/FZdATrypnfaTvKy8IuYL+mEsyw7G/J1NjPbAULdVPBEnKo8lm9gxZSW89Cspu1Q9tLnG8IBRq", + "B0pfNPn/AAAA//+ibdChq7cAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/api/paste_routes_test.go b/internal/api/paste_routes_test.go index ebcfacf..7768dfa 100644 --- a/internal/api/paste_routes_test.go +++ b/internal/api/paste_routes_test.go @@ -24,7 +24,7 @@ func testServerWithDB(t *testing.T) (*Server, func()) { t.Fatalf("failed to create store: %v", err) } - server := New(Config{ + server := New(NewServerConfig{ Address: ":0", Store: store, DB: store.DB(), diff --git a/internal/api/plans_test.go b/internal/api/plans_test.go index 65b474a..f430d3f 100644 --- a/internal/api/plans_test.go +++ b/internal/api/plans_test.go @@ -25,7 +25,7 @@ func testServer(t *testing.T) (*Server, func()) { t.Fatalf("failed to create store: %v", err) } - server := New(Config{ + server := New(NewServerConfig{ Address: ":0", Store: store, }) diff --git a/internal/api/server.go b/internal/api/server.go index 7d1747e..f6ca4f7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -30,8 +30,8 @@ type Server struct { startTime time.Time } -// Config holds server configuration. -type Config struct { +// NewServerConfig holds the configuration needed to create a new API server. +type NewServerConfig struct { Address string // e.g., ":7432" or "localhost:7432" Store storage.Storage // DB is the underlying *sql.DB for the arc storage. When non-nil, paste @@ -40,7 +40,7 @@ type Config struct { } // New creates a new API server. -func New(cfg Config) *Server { +func New(cfg NewServerConfig) *Server { e := echo.New() e.HideBanner = true e.HidePort = true diff --git a/internal/api/workspace_paths_test.go b/internal/api/workspace_paths_test.go index 201cfa3..e548308 100644 --- a/internal/api/workspace_paths_test.go +++ b/internal/api/workspace_paths_test.go @@ -344,7 +344,7 @@ func (m *mockWPStore) Path() string { return "" } func setupWorkspaceTest(t *testing.T) (*echo.Echo, *mockWPStore) { t.Helper() store := newMockWPStore() - srv := New(Config{ + srv := New(NewServerConfig{ Address: ":0", Store: store, }) diff --git a/internal/server/server.go b/internal/server/server.go index b33e398..ddfd481 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -65,7 +65,7 @@ func Run(cfg Config) error { defer store.Close() // Create API server - server := api.New(api.Config{ + server := api.New(api.NewServerConfig{ Address: cfg.Address, Store: store, DB: store.DB(), diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 5fc81e0..fc7b5ec 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -488,6 +488,24 @@ export interface paths { patch?: never; trace?: never; }; + "/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the current arc configuration */ + get: operations["getConfig"]; + /** Replace the arc configuration */ + put: operations["putConfig"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/shares": { parameters: { query?: never; @@ -1050,6 +1068,39 @@ export interface components { TranscriptResponse: { [key: string]: unknown; }[]; + Config: { + cli: components["schemas"]["CLIConfig"]; + server: components["schemas"]["ServerConfig"]; + share: components["schemas"]["ShareConfig"]; + updates: components["schemas"]["UpdatesConfig"]; + }; + CLIConfig: { + /** @description URL the CLI uses to talk to the arc server. */ + server?: string; + }; + ServerConfig: { + port?: number; + db_path?: string; + }; + ShareConfig: { + author?: string; + server?: string; + }; + UpdatesConfig: { + /** @enum {string} */ + channel?: "stable" | "rc" | "nightly"; + }; + ConfigResponse: WithRequired & { + meta: { + path: string; + requires_restart: string[]; + }; + }; + ConfigValidationError: { + errors: { + [key: string]: string; + }; + }; /** @enum {string} */ PlanStatus: "draft" | "in_review" | "approved" | "rejected"; Plan: { @@ -2117,6 +2168,61 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + getConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current configuration */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigResponse"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + putConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Config"]; + }; + }; + responses: { + /** @description Updated configuration */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigResponse"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigValidationError"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; listShares: { parameters: { query?: never; @@ -2608,3 +2714,6 @@ export interface operations { }; }; } +type WithRequired = T & { + [P in K]-?: T[P]; +}; From cd5a12a1940a9e03c78644345b53b14d13b2b2a1 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 15:49:10 -0700 Subject: [PATCH 08/12] fix(api): rename NewServerConfig to ServerOptions; repair missed call site Rename api.NewServerConfig to api.ServerOptions everywhere for more idiomatic naming (echo, http, and other Go libs use Options for bootstrap structs). Also fixes the missed call site in internal/client/client_test.go:27 that was still referencing the old api.Config name introduced by the OpenAPI codegen collision. --- cmd/arc/share_test.go | 2 +- internal/api/paste_routes_test.go | 2 +- internal/api/plans_test.go | 2 +- internal/api/server.go | 6 +++--- internal/api/workspace_paths_test.go | 2 +- internal/client/client_test.go | 2 +- internal/server/server.go | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/arc/share_test.go b/cmd/arc/share_test.go index 4f3092d..5047bc4 100644 --- a/cmd/arc/share_test.go +++ b/cmd/arc/share_test.go @@ -41,7 +41,7 @@ func startTestPasteServer(t *testing.T) *httptest.Server { // Mount the share keyring routes on /api/v1 against the same store so // sharesconfig.{Load,Add,Find,Remove} hits a real handler chain. - apiSrv := api.New(api.NewServerConfig{Store: store}) + apiSrv := api.New(api.ServerOptions{Store: store}) apiSrv.RegisterShareRoutes(e.Group("/api/v1")) srv := httptest.NewServer(e) diff --git a/internal/api/paste_routes_test.go b/internal/api/paste_routes_test.go index 7768dfa..3dbb603 100644 --- a/internal/api/paste_routes_test.go +++ b/internal/api/paste_routes_test.go @@ -24,7 +24,7 @@ func testServerWithDB(t *testing.T) (*Server, func()) { t.Fatalf("failed to create store: %v", err) } - server := New(NewServerConfig{ + server := New(ServerOptions{ Address: ":0", Store: store, DB: store.DB(), diff --git a/internal/api/plans_test.go b/internal/api/plans_test.go index f430d3f..e4c0e11 100644 --- a/internal/api/plans_test.go +++ b/internal/api/plans_test.go @@ -25,7 +25,7 @@ func testServer(t *testing.T) (*Server, func()) { t.Fatalf("failed to create store: %v", err) } - server := New(NewServerConfig{ + server := New(ServerOptions{ Address: ":0", Store: store, }) diff --git a/internal/api/server.go b/internal/api/server.go index f6ca4f7..0e2f173 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -30,8 +30,8 @@ type Server struct { startTime time.Time } -// NewServerConfig holds the configuration needed to create a new API server. -type NewServerConfig struct { +// ServerOptions holds the configuration needed to create a new API server. +type ServerOptions struct { Address string // e.g., ":7432" or "localhost:7432" Store storage.Storage // DB is the underlying *sql.DB for the arc storage. When non-nil, paste @@ -40,7 +40,7 @@ type NewServerConfig struct { } // New creates a new API server. -func New(cfg NewServerConfig) *Server { +func New(cfg ServerOptions) *Server { e := echo.New() e.HideBanner = true e.HidePort = true diff --git a/internal/api/workspace_paths_test.go b/internal/api/workspace_paths_test.go index e548308..bb2de4b 100644 --- a/internal/api/workspace_paths_test.go +++ b/internal/api/workspace_paths_test.go @@ -344,7 +344,7 @@ func (m *mockWPStore) Path() string { return "" } func setupWorkspaceTest(t *testing.T) (*echo.Echo, *mockWPStore) { t.Helper() store := newMockWPStore() - srv := New(NewServerConfig{ + srv := New(ServerOptions{ Address: ":0", Store: store, }) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 333d7da..6de2c90 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -24,7 +24,7 @@ func testClientServer(t *testing.T) (*client.Client, func()) { t.Fatalf("failed to create store: %v", err) } - server := api.New(api.Config{ + server := api.New(api.ServerOptions{ Address: ":0", Store: store, }) diff --git a/internal/server/server.go b/internal/server/server.go index ddfd481..33bf5c5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -65,7 +65,7 @@ func Run(cfg Config) error { defer store.Close() // Create API server - server := api.New(api.NewServerConfig{ + server := api.New(api.ServerOptions{ Address: cfg.Address, Store: store, DB: store.DB(), From a8a533eb98bd0f61fb55ea00ab0542a9294dc0bf Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 15:56:28 -0700 Subject: [PATCH 09/12] feat(web): add /settings page for editing arc config --- web/src/lib/api/index.ts | 20 ++++ web/src/lib/components/Sidebar.svelte | 12 ++ .../components/settings/ChannelPicker.svelte | 25 ++++ .../components/settings/SettingsField.svelte | 26 ++++ .../settings/SettingsSection.svelte | 16 +++ web/src/routes/settings/+page.svelte | 111 ++++++++++++++++++ web/src/routes/settings/+page.ts | 11 ++ 7 files changed, 221 insertions(+) create mode 100644 web/src/lib/components/settings/ChannelPicker.svelte create mode 100644 web/src/lib/components/settings/SettingsField.svelte create mode 100644 web/src/lib/components/settings/SettingsSection.svelte create mode 100644 web/src/routes/settings/+page.svelte create mode 100644 web/src/routes/settings/+page.ts diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index 46dd5f9..16742fb 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -465,6 +465,26 @@ export async function createPlanComment( return data; } +// Config APIs +export type Config = components['schemas']['Config']; +export type ConfigResponse = components['schemas']['ConfigResponse']; + +export async function getConfig(): Promise { + const { data, error } = await api.GET('/config'); + if (error || !data) throw new Error(`getConfig failed: ${error ?? 'no data'}`); + return data as ConfigResponse; +} + +export async function updateConfig(cfg: Config): Promise { + const { data, error, response } = await api.PUT('/config', { body: cfg }); + if (response.status === 400 && error) { + const e = error as { errors?: Record }; + throw Object.assign(new Error('validation failed'), { fieldErrors: e.errors ?? {} }); + } + if (error || !data) throw new Error(`updateConfig failed: ${error ?? 'no data'}`); + return data as ConfigResponse; +} + // Team Context APIs export async function getTeamContext(projectId: string, epicId?: string): Promise { const { data, error } = await api.GET('/projects/{projectId}/team-context', { diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index f1acf37..18faae4 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -178,6 +178,12 @@ Labels + + + + + Settings + @@ -253,6 +259,12 @@ Labels + + + + + Settings + {/if} diff --git a/web/src/lib/components/settings/ChannelPicker.svelte b/web/src/lib/components/settings/ChannelPicker.svelte new file mode 100644 index 0000000..ac5166c --- /dev/null +++ b/web/src/lib/components/settings/ChannelPicker.svelte @@ -0,0 +1,25 @@ + + +
+ {#each options as opt} + + {/each} +
diff --git a/web/src/lib/components/settings/SettingsField.svelte b/web/src/lib/components/settings/SettingsField.svelte new file mode 100644 index 0000000..d74c918 --- /dev/null +++ b/web/src/lib/components/settings/SettingsField.svelte @@ -0,0 +1,26 @@ + + + diff --git a/web/src/lib/components/settings/SettingsSection.svelte b/web/src/lib/components/settings/SettingsSection.svelte new file mode 100644 index 0000000..7382de5 --- /dev/null +++ b/web/src/lib/components/settings/SettingsSection.svelte @@ -0,0 +1,16 @@ + + +
+
+

{title}

+ {#if warn} +

⚠ {warn}

+ {/if} +
+
+ {@render children()} +
+
diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte new file mode 100644 index 0000000..033c33b --- /dev/null +++ b/web/src/routes/settings/+page.svelte @@ -0,0 +1,111 @@ + + +Settings · Arc + +
+
+

Settings

+ {#if initial} + {initial.meta.path} + {/if} +
+ + {#if !data.available || !working} +
+

Settings are unavailable on this deployment.

+
+ {:else} + + + + + + + + + + + + + + + + + + + + + + + + + + + (working!.updates.channel = v)} + /> + + + +
+ + +
+ + {#if toast} +
{toast}
+ {/if} + {/if} +
diff --git a/web/src/routes/settings/+page.ts b/web/src/routes/settings/+page.ts new file mode 100644 index 0000000..a5925ef --- /dev/null +++ b/web/src/routes/settings/+page.ts @@ -0,0 +1,11 @@ +import type { PageLoad } from './$types'; +import { getConfig } from '$lib/api'; + +export const load: PageLoad = async () => { + try { + const data = await getConfig(); + return { config: data, available: true as const }; + } catch (err) { + return { config: null, available: false as const, error: String(err) }; + } +}; From 5e32b2474a559b03bf294a23be6612cca696bde8 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 16:03:24 -0700 Subject: [PATCH 10/12] fix(web): tighten settings type safety + a11y; surface load errors --- web/src/lib/api/index.ts | 4 +-- .../components/settings/ChannelPicker.svelte | 36 ++++++++++--------- web/src/routes/settings/+page.svelte | 8 +++-- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index 16742fb..812c3ad 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -472,7 +472,7 @@ export type ConfigResponse = components['schemas']['ConfigResponse']; export async function getConfig(): Promise { const { data, error } = await api.GET('/config'); if (error || !data) throw new Error(`getConfig failed: ${error ?? 'no data'}`); - return data as ConfigResponse; + return data; } export async function updateConfig(cfg: Config): Promise { @@ -482,7 +482,7 @@ export async function updateConfig(cfg: Config): Promise { throw Object.assign(new Error('validation failed'), { fieldErrors: e.errors ?? {} }); } if (error || !data) throw new Error(`updateConfig failed: ${error ?? 'no data'}`); - return data as ConfigResponse; + return data; } // Team Context APIs diff --git a/web/src/lib/components/settings/ChannelPicker.svelte b/web/src/lib/components/settings/ChannelPicker.svelte index ac5166c..9970c3c 100644 --- a/web/src/lib/components/settings/ChannelPicker.svelte +++ b/web/src/lib/components/settings/ChannelPicker.svelte @@ -1,25 +1,29 @@ -
- {#each options as opt} - - {/each} -
+
+ {label} +
+ {#each options as opt} + + {/each} +
+
diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 033c33b..f816678 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -7,9 +7,8 @@ let { data }: { data: { config: ConfigResponse | null; available: boolean; error?: string } } = $props(); - function stripMeta(r: ConfigResponse): Config { - const { meta: _meta, ...rest } = r as Config & { meta?: unknown }; - return rest as Config; + function stripMeta({ meta: _meta, ...rest }: ConfigResponse): Config { + return rest; } const initial = $derived(data.config); @@ -62,6 +61,9 @@ {#if !data.available || !working}

Settings are unavailable on this deployment.

+ {#if data.error} +

{data.error}

+ {/if}
{:else} From 7ccebff0d164ce66d5594d00d964db498734698a Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 16:35:15 -0700 Subject: [PATCH 11/12] chore: fix make lint issues across config/cli/api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - errname: rename ValidationErrors → ValidationError (singular) - goconst: extract dbPathKey, testAuthor, tildeDBPath constants in tests - gofmt/gofumpt: reformat main.go and save.go - gosec G702: nolint exec.Command in runConfigEdit ($EDITOR user-controlled) - mnd: extract setArgsCount, dottedKeyParts consts; add DefaultServerPort - perfsprint: errors.New in setKey; string concat in Validate - revive comments-density: add doc comments to config.go, save.go, validate.go - testpackage: move config/load/migrate/save/validate _test.go to config_test package; nolint api config_test - unused: delete defaultDirPerm, defaultFilePerm, defaultConfigPath from main.go - unhandled-error: discard fmt.Fprintf return in load.go migration notice - line-length-limit: break long tomlBody literal in share_test.go --- cmd/arc/config.go | 33 +++++++++++++++++++---- cmd/arc/config_test.go | 8 +++--- cmd/arc/main.go | 10 ------- cmd/arc/share_test.go | 3 ++- internal/api/config.go | 2 +- internal/api/config_test.go | 12 +++++---- internal/config/config.go | 5 +++- internal/config/config_test.go | 32 ++++++++++++---------- internal/config/load.go | 2 +- internal/config/load_test.go | 26 ++++++++++-------- internal/config/migrate_test.go | 6 +++-- internal/config/save.go | 8 +++++- internal/config/save_test.go | 14 +++++----- internal/config/validate.go | 15 ++++++----- internal/config/validate_test.go | 46 +++++++++++++++++--------------- 15 files changed, 133 insertions(+), 89 deletions(-) diff --git a/cmd/arc/config.go b/cmd/arc/config.go index 3c373ef..4102f1c 100644 --- a/cmd/arc/config.go +++ b/cmd/arc/config.go @@ -4,6 +4,7 @@ package main import ( + "errors" "fmt" "os" "os/exec" @@ -15,6 +16,12 @@ import ( "github.com/spf13/cobra" ) +// setArgsCount is the exact number of arguments required by "config set". +const setArgsCount = 2 + +// dottedKeyParts is the number of parts produced by splitting a dotted config key. +const dottedKeyParts = 2 + // legacyAliases maps pre-TOML key names to their current dotted equivalents. // These are checked before the Levenshtein fallback in normalizeKey so that // well-known old names always produce the correct "did you mean" hint. @@ -25,6 +32,7 @@ var legacyAliases = map[string]string{ "channel": "updates.channel", } +// recognizedKeys is the canonical list of all valid config key names. var recognizedKeys = []string{ "cli.server", "server.port", @@ -34,18 +42,21 @@ var recognizedKeys = []string{ "updates.channel", } +// configCmd is the parent command for all config sub-commands. var configCmd = &cobra.Command{ Use: "config", Short: "Manage arc configuration", Long: `View and modify arc configuration. Stored at ~/.arc/config.toml.`, } +// configListCmd prints all configuration key=value pairs. var configListCmd = &cobra.Command{ Use: "list", Short: "List all configuration values", RunE: runConfigList, } +// configGetCmd retrieves and prints a single config value by key. var configGetCmd = &cobra.Command{ Use: "get ", Short: "Get a configuration value", @@ -53,13 +64,15 @@ var configGetCmd = &cobra.Command{ RunE: runConfigGet, } +// configSetCmd writes a new value for a config key to disk. var configSetCmd = &cobra.Command{ Use: "set ", Short: "Set a configuration value", - Args: cobra.ExactArgs(2), + Args: cobra.ExactArgs(setArgsCount), RunE: runConfigSet, } +// configUnsetCmd resets a config key to its compiled-in default. var configUnsetCmd = &cobra.Command{ Use: "unset ", Short: "Clear a configuration value back to its default", @@ -67,6 +80,7 @@ var configUnsetCmd = &cobra.Command{ RunE: runConfigUnset, } +// configPathCmd prints the filesystem path of the active config file. var configPathCmd = &cobra.Command{ Use: "path", Short: "Show the config file path", @@ -80,17 +94,20 @@ var configPathCmd = &cobra.Command{ }, } +// configEditCmd opens the config file in $EDITOR for direct editing. var configEditCmd = &cobra.Command{ Use: "edit", Short: "Open the config file in $EDITOR", RunE: runConfigEdit, } +// init registers all config sub-commands with the root command. func init() { configCmd.AddCommand(configListCmd, configGetCmd, configSetCmd, configUnsetCmd, configPathCmd, configEditCmd) rootCmd.AddCommand(configCmd) } +// runConfigList prints all settings grouped by TOML section. func runConfigList(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -109,7 +126,7 @@ func runConfigList(cmd *cobra.Command, args []string) error { restartSet[k] = true } printRow := func(key, value string) { - label := strings.SplitN(key, ".", 2)[1] + label := strings.SplitN(key, ".", dottedKeyParts)[1] tag := "" if restartSet[key] { tag = " (requires restart)" @@ -134,6 +151,7 @@ func runConfigList(cmd *cobra.Command, args []string) error { return nil } +// runConfigGet prints the value of a single config key. func runConfigGet(cmd *cobra.Command, args []string) error { key, err := normalizeKey(args[0]) if err != nil { @@ -152,6 +170,7 @@ func runConfigGet(cmd *cobra.Command, args []string) error { return nil } +// runConfigSet validates and persists a new value for a config key. func runConfigSet(cmd *cobra.Command, args []string) error { key, err := normalizeKey(args[0]) if err != nil { @@ -171,6 +190,7 @@ func runConfigSet(cmd *cobra.Command, args []string) error { return nil } +// runConfigUnset resets a key to its default and persists the result. func runConfigUnset(cmd *cobra.Command, args []string) error { key, err := normalizeKey(args[0]) if err != nil { @@ -191,9 +211,12 @@ func runConfigUnset(cmd *cobra.Command, args []string) error { return nil } +// runConfigEdit opens the config file in $EDITOR and re-validates on save. +// Defaults to "vi" if $EDITOR is unset. func runConfigEdit(cmd *cobra.Command, args []string) error { editor := os.Getenv("EDITOR") if editor == "" { + // Fall back to vi if no EDITOR is configured. editor = "vi" } p := configPath @@ -204,12 +227,12 @@ func runConfigEdit(cmd *cobra.Command, args []string) error { if _, err := loadConfig(); err != nil { return err } - c := exec.Command(editor, p) + c := exec.Command(editor, p) //nolint:gosec // $EDITOR is intentionally user-controlled c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr if err := c.Run(); err != nil { return fmt.Errorf("editor exited: %w", err) } - // Re-validate after edit. + // Re-validate after edit to catch any syntax errors introduced by the user. if _, err := loadConfig(); err != nil { return fmt.Errorf("config invalid after edit: %w", err) } @@ -320,7 +343,7 @@ func setKey(cfg *cfgpkg.Config, key, value string) error { case "server.port": n, err := strconv.Atoi(value) if err != nil { - return fmt.Errorf("server.port: must be an integer") + return errors.New("server.port: must be an integer") } cfg.Server.Port = n case "server.db_path": diff --git a/cmd/arc/config_test.go b/cmd/arc/config_test.go index 14c9526..1ce8c8f 100644 --- a/cmd/arc/config_test.go +++ b/cmd/arc/config_test.go @@ -9,6 +9,8 @@ import ( cfgpkg "github.com/sentiolabs/arc/internal/config" ) +const dbPathKey = "server.db_path" + func TestConfigSetGetRoundTrip(t *testing.T) { dir := t.TempDir() configPath = filepath.Join(dir, "config.toml") @@ -92,7 +94,7 @@ func TestNormalizeKeyValid(t *testing.T) { validKeys := []string{ "cli.server", "server.port", - "server.db_path", + dbPathKey, "share.author", "share.server", "updates.channel", @@ -114,8 +116,8 @@ func TestNormalizeKeyNormalizes(t *testing.T) { if err != nil { t.Errorf("normalizeKey(server.db-path): %v", err) } - if got != "server.db_path" { - t.Errorf("got %q, want server.db_path", got) + if got != dbPathKey { + t.Errorf("got %q, want %s", got, dbPathKey) } } diff --git a/cmd/arc/main.go b/cmd/arc/main.go index 7baba5f..d727907 100644 --- a/cmd/arc/main.go +++ b/cmd/arc/main.go @@ -25,12 +25,6 @@ import ( // CLI constants for default values and formatting. const ( - // defaultDirPerm is the default permission for created directories. - defaultDirPerm = 0o755 - - // defaultFilePerm is the default permission for sensitive config files (owner read/write only). - defaultFilePerm = 0o600 - // tabwriterPadding is the minimum padding between columns in tabwriter output. tabwriterPadding = 2 @@ -75,7 +69,6 @@ func main() { } } - // ProjectSource indicates how the project was resolved type ProjectSource int @@ -98,9 +91,6 @@ func (s ProjectSource) String() string { } } -// defaultConfigPath returns the default config file path. -func defaultConfigPath() string { return cfgpkg.DefaultPath() } - // loadConfig reads CLI configuration from disk, creating defaults on first use. // It delegates to the internal/config package which handles TOML load, legacy // JSON migration, and validation. diff --git a/cmd/arc/share_test.go b/cmd/arc/share_test.go index 5047bc4..8381e3f 100644 --- a/cmd/arc/share_test.go +++ b/cmd/arc/share_test.go @@ -704,7 +704,8 @@ func TestShareCreatePrintsPreviewURLForLocal(t *testing.T) { t.Cleanup(func() { configPath = origConfigPath }) dir := t.TempDir() configPath = filepath.Join(dir, "config.toml") - tomlBody := "[cli]\nserver = \"" + srv.URL + "\"\n[share]\n[server]\nport = 7432\ndb_path = \"~/.arc/data.db\"\n[updates]\nchannel = \"stable\"\n" + tomlBody := "[cli]\nserver = \"" + srv.URL + "\"\n" + + "[share]\n[server]\nport = 7432\ndb_path = \"~/.arc/data.db\"\n[updates]\nchannel = \"stable\"\n" if err := os.WriteFile(configPath, []byte(tomlBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } diff --git a/internal/api/config.go b/internal/api/config.go index 6e91d74..16fec00 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -46,7 +46,7 @@ func (s *Server) putConfig(c echo.Context) error { return errorJSON(c, http.StatusBadRequest, "invalid request body") } if err := cfgpkg.Validate(&incoming); err != nil { - var ve cfgpkg.ValidationErrors + var ve cfgpkg.ValidationError if errors.As(err, &ve) { return c.JSON(http.StatusBadRequest, configValidationErrorBody{Errors: ve}) } diff --git a/internal/api/config_test.go b/internal/api/config_test.go index 11ceb55..e9a7c83 100644 --- a/internal/api/config_test.go +++ b/internal/api/config_test.go @@ -1,4 +1,4 @@ -package api +package api //nolint:testpackage // tests use internal helpers that access unexported fields import ( "bytes" @@ -13,6 +13,8 @@ import ( cfgpkg "github.com/sentiolabs/arc/internal/config" ) +const testAuthor = "Grace" + func newTestServerWithTempHome(t *testing.T) *Server { t.Helper() home := t.TempDir() @@ -67,7 +69,7 @@ func TestGetConfigReturnsDefaults(t *testing.T) { func TestPutConfigPersistsAndRevalidates(t *testing.T) { s := newTestServerWithTempHome(t) in := cfgpkg.Default() - in.Share.Author = "Grace" + in.Share.Author = testAuthor body, err := json.Marshal(in) if err != nil { t.Fatalf("marshal: %v", err) @@ -92,8 +94,8 @@ func TestPutConfigPersistsAndRevalidates(t *testing.T) { if !ok { t.Fatalf("share is not an object: %T", got["share"]) } - if share["author"] != "Grace" { - t.Errorf("response share.author = %q, want %q", share["author"], "Grace") + if share["author"] != testAuthor { + t.Errorf("response share.author = %q, want %q", share["author"], testAuthor) } if got["meta"] == nil { t.Errorf("response missing meta field") @@ -104,7 +106,7 @@ func TestPutConfigPersistsAndRevalidates(t *testing.T) { if err != nil { t.Fatalf("reload: %v", err) } - if reloaded.Share.Author != "Grace" { + if reloaded.Share.Author != testAuthor { t.Errorf("disk share.author = %q", reloaded.Share.Author) } } diff --git a/internal/config/config.go b/internal/config/config.go index bc4f1dd..c42d49c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,11 +40,14 @@ type UpdatesConfig struct { Channel string `toml:"channel" json:"channel"` } +// DefaultServerPort is the built-in default port for the arc server. +const DefaultServerPort = 7432 + // Default returns a Config populated with built-in defaults. func Default() *Config { return &Config{ CLI: CLIConfig{Server: "http://localhost:7432"}, - Server: ServerConfig{Port: 7432, DBPath: "~/.arc/data.db"}, + Server: ServerConfig{Port: DefaultServerPort, DBPath: "~/.arc/data.db"}, Share: ShareConfig{Server: "https://arcplanner.sentiolabs.io"}, Updates: UpdatesConfig{Channel: "stable"}, } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4c57349..0cb5079 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,23 +1,27 @@ -package config +package config_test -import "testing" +import ( + "testing" + + "github.com/sentiolabs/arc/internal/config" +) // --- Contract assertions --- // These verify the design spec. Do NOT modify without updating the approved plan. var ( - _ CLIConfig = Config{}.CLI - _ ServerConfig = Config{}.Server - _ ShareConfig = Config{}.Share - _ UpdatesConfig = Config{}.Updates + _ config.CLIConfig = config.Config{}.CLI + _ config.ServerConfig = config.Config{}.Server + _ config.ShareConfig = config.Config{}.Share + _ config.UpdatesConfig = config.Config{}.Updates ) var _ interface { - Load() *Config - Swap(*Config) *Config -} = (*Store)(nil) + Load() *config.Config + Swap(*config.Config) *config.Config +} = (*config.Store)(nil) func TestDefaultIsUsable(t *testing.T) { - cfg := Default() + cfg := config.Default() if cfg.CLI.Server == "" || cfg.Server.Port == 0 { t.Fatal("Default() returned zero values for required fields") } @@ -30,7 +34,7 @@ func TestDefaultIsUsable(t *testing.T) { } func TestRequiresRestartContainsServerKeys(t *testing.T) { - got := RequiresRestart() + got := config.RequiresRestart() want := map[string]bool{"server.port": true, "server.db_path": true} if len(got) != len(want) { t.Fatalf("RequiresRestart() = %v, want keys %v", got, want) @@ -43,9 +47,9 @@ func TestRequiresRestartContainsServerKeys(t *testing.T) { } func TestStoreSwap(t *testing.T) { - a := Default() - b := &Config{Updates: UpdatesConfig{Channel: "rc"}} - s := NewStore(a) + a := config.Default() + b := &config.Config{Updates: config.UpdatesConfig{Channel: "rc"}} + s := config.NewStore(a) if got := s.Load(); got != a { t.Fatalf("Load() before swap = %p, want %p", got, a) } diff --git a/internal/config/load.go b/internal/config/load.go index 8e1f79b..ecaf7b6 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -81,7 +81,7 @@ func Load(path string) (*Config, error) { if err := Save(path, cfg); err != nil { return nil, err } - fmt.Fprintf(os.Stderr, "migrated %s → %s (backup: %s)\n", legacy, path, filepath.Base(backup)) + _, _ = fmt.Fprintf(os.Stderr, "migrated %s → %s (backup: %s)\n", legacy, path, filepath.Base(backup)) return cfg, nil } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 477cb8c..143c9f9 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -1,15 +1,19 @@ -package config +package config_test import ( "os" "path/filepath" "testing" + + "github.com/sentiolabs/arc/internal/config" ) +const tildeDBPath = "~/.arc/data.db" + func TestLoadCreatesDefaultWhenMissing(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") - cfg, err := Load(path) + cfg, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } @@ -24,26 +28,26 @@ func TestLoadCreatesDefaultWhenMissing(t *testing.T) { func TestLoadSavePreservesTildeInDBPath(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") - cfg := Default() - cfg.Server.DBPath = "~/.arc/data.db" - if err := Save(path, cfg); err != nil { + cfg := config.Default() + cfg.Server.DBPath = tildeDBPath + if err := config.Save(path, cfg); err != nil { t.Fatalf("Save: %v", err) } - got, err := Load(path) + got, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } - if got.Server.DBPath != "~/.arc/data.db" { - t.Errorf("db_path = %q, want %q (tilde corrupted by load)", got.Server.DBPath, "~/.arc/data.db") + if got.Server.DBPath != tildeDBPath { + t.Errorf("db_path = %q, want %q (tilde corrupted by load)", got.Server.DBPath, tildeDBPath) } - if err := Save(path, got); err != nil { + if err := config.Save(path, got); err != nil { t.Fatalf("Save (round 2): %v", err) } - got2, err := Load(path) + got2, err := config.Load(path) if err != nil { t.Fatalf("Load (round 2): %v", err) } - if got2.Server.DBPath != "~/.arc/data.db" { + if got2.Server.DBPath != tildeDBPath { t.Errorf("db_path after round-trip = %q, want preserved tilde", got2.Server.DBPath) } } diff --git a/internal/config/migrate_test.go b/internal/config/migrate_test.go index 77414cc..17182c6 100644 --- a/internal/config/migrate_test.go +++ b/internal/config/migrate_test.go @@ -1,9 +1,11 @@ -package config +package config_test import ( "os" "path/filepath" "testing" + + "github.com/sentiolabs/arc/internal/config" ) func TestLoadMigratesLegacyJSON(t *testing.T) { @@ -18,7 +20,7 @@ func TestLoadMigratesLegacyJSON(t *testing.T) { t.Fatalf("seed legacy: %v", err) } path := filepath.Join(dir, "config.toml") - cfg, err := Load(path) + cfg, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } diff --git a/internal/config/save.go b/internal/config/save.go index 6ebffe9..96d99ec 100644 --- a/internal/config/save.go +++ b/internal/config/save.go @@ -8,11 +8,16 @@ import ( "github.com/BurntSushi/toml" ) +// configFilePerm is the permission for the written config file (owner read/write only). +// Using 0600 ensures only the owner can read sensitive settings. const configFilePerm = 0o600 + +// configDirPerm is the permission for the config directory. const configDirPerm = 0o700 // Save atomically writes cfg to path as TOML with 0600 permissions. -// Writes to path.tmp, fsyncs, then renames over path. +// The write is atomic: it creates a temp file, fsyncs, then renames over path. +// The config is validated before writing; invalid configs return an error. func Save(path string, cfg *Config) error { if err := Validate(cfg); err != nil { return err @@ -27,6 +32,7 @@ func Save(path string, cfg *Config) error { } tmpName := tmp.Name() success := false + // Clean up the temp file if we exit without renaming it into place. defer func() { if !success { _ = os.Remove(tmpName) diff --git a/internal/config/save_test.go b/internal/config/save_test.go index c363ffc..21bb15d 100644 --- a/internal/config/save_test.go +++ b/internal/config/save_test.go @@ -1,17 +1,19 @@ -package config +package config_test import ( "os" "path/filepath" "testing" + + "github.com/sentiolabs/arc/internal/config" ) func TestSaveLoadRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") - cfg := Default() + cfg := config.Default() cfg.Share.Author = "Ada" - if err := Save(path, cfg); err != nil { + if err := config.Save(path, cfg); err != nil { t.Fatalf("Save: %v", err) } info, err := os.Stat(path) @@ -21,7 +23,7 @@ func TestSaveLoadRoundTrip(t *testing.T) { if info.Mode().Perm() != 0o600 { t.Errorf("perm = %v, want 0600", info.Mode().Perm()) } - got, err := Load(path) + got, err := config.Load(path) if err != nil { t.Fatalf("Load: %v", err) } @@ -33,9 +35,9 @@ func TestSaveLoadRoundTrip(t *testing.T) { func TestSaveRejectsInvalid(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") - cfg := Default() + cfg := config.Default() cfg.Server.Port = 0 - if err := Save(path, cfg); err == nil { + if err := config.Save(path, cfg); err == nil { t.Fatal("Save accepted invalid config") } } diff --git a/internal/config/validate.go b/internal/config/validate.go index 46e081e..9a9e0fd 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -10,10 +10,11 @@ import ( // ValidChannels lists the allowed values for updates.channel. var ValidChannels = []string{"stable", "rc", "nightly"} -// ValidationErrors maps dotted-key to error message. -type ValidationErrors map[string]string +// ValidationError maps dotted-key to error message. +type ValidationError map[string]string -func (v ValidationErrors) Error() string { +// Error returns a sorted, semicolon-separated list of all validation failures. +func (v ValidationError) Error() string { keys := make([]string, 0, len(v)) for k := range v { keys = append(keys, k) @@ -26,9 +27,10 @@ func (v ValidationErrors) Error() string { return "config validation failed: " + strings.Join(parts, "; ") } -// Validate returns a ValidationErrors if cfg has invalid values, or nil if OK. +// Validate checks each field in cfg and returns a ValidationError describing all +// invalid fields, or nil if the config is fully valid. func Validate(cfg *Config) error { - errs := ValidationErrors{} + errs := ValidationError{} if u, err := url.Parse(cfg.CLI.Server); err != nil || u.Scheme == "" || u.Host == "" { errs["cli.server"] = "must be a valid URL with scheme and host" } @@ -38,6 +40,7 @@ func Validate(cfg *Config) error { if u, err := url.Parse(cfg.Share.Server); err != nil || u.Scheme == "" || u.Host == "" { errs["share.server"] = "must be a valid URL with scheme and host" } + // Check that updates.channel is one of the allowed values. channelOK := false for _, c := range ValidChannels { if cfg.Updates.Channel == c { @@ -46,7 +49,7 @@ func Validate(cfg *Config) error { } } if !channelOK { - errs["updates.channel"] = fmt.Sprintf("must be one of: %s", strings.Join(ValidChannels, ", ")) + errs["updates.channel"] = "must be one of: " + strings.Join(ValidChannels, ", ") } if len(errs) == 0 { return nil diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index cac8adf..757dafd 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -1,23 +1,25 @@ -package config +package config_test import ( "errors" "testing" + + "github.com/sentiolabs/arc/internal/config" ) func TestValidateAcceptsDefault(t *testing.T) { - if err := Validate(Default()); err != nil { + if err := config.Validate(config.Default()); err != nil { t.Fatalf("Validate(Default()) = %v, want nil", err) } } func TestValidateRejectsBadURL(t *testing.T) { - cfg := Default() + cfg := config.Default() cfg.CLI.Server = "not a url" - err := Validate(cfg) - var ve ValidationErrors + err := config.Validate(cfg) + var ve config.ValidationError if !errors.As(err, &ve) { - t.Fatalf("err type = %T, want ValidationErrors", err) + t.Fatalf("err type = %T, want ValidationError", err) } if _, ok := ve["cli.server"]; !ok { t.Errorf("missing cli.server in errors: %v", ve) @@ -25,12 +27,12 @@ func TestValidateRejectsBadURL(t *testing.T) { } func TestValidateRejectsBadPort(t *testing.T) { - cfg := Default() + cfg := config.Default() cfg.Server.Port = 0 - err := Validate(cfg) - var ve ValidationErrors + err := config.Validate(cfg) + var ve config.ValidationError if !errors.As(err, &ve) { - t.Fatalf("err type = %T, want ValidationErrors", err) + t.Fatalf("err type = %T, want ValidationError", err) } if _, ok := ve["server.port"]; !ok { t.Errorf("missing server.port in errors: %v", ve) @@ -38,12 +40,12 @@ func TestValidateRejectsBadPort(t *testing.T) { } func TestValidateRejectsBadChannel(t *testing.T) { - cfg := Default() + cfg := config.Default() cfg.Updates.Channel = "weekly" - err := Validate(cfg) - var ve ValidationErrors + err := config.Validate(cfg) + var ve config.ValidationError if !errors.As(err, &ve) { - t.Fatalf("err type = %T, want ValidationErrors", err) + t.Fatalf("err type = %T, want ValidationError", err) } if _, ok := ve["updates.channel"]; !ok { t.Errorf("missing updates.channel in errors: %v", ve) @@ -51,12 +53,12 @@ func TestValidateRejectsBadChannel(t *testing.T) { } func TestValidateRejectsEmptyCLIServer(t *testing.T) { - cfg := Default() + cfg := config.Default() cfg.CLI.Server = "" - err := Validate(cfg) - var ve ValidationErrors + err := config.Validate(cfg) + var ve config.ValidationError if !errors.As(err, &ve) { - t.Fatalf("err type = %T, want ValidationErrors", err) + t.Fatalf("err type = %T, want ValidationError", err) } if _, ok := ve["cli.server"]; !ok { t.Errorf("missing cli.server in errors: %v", ve) @@ -64,12 +66,12 @@ func TestValidateRejectsEmptyCLIServer(t *testing.T) { } func TestValidateRejectsEmptyShareServer(t *testing.T) { - cfg := Default() + cfg := config.Default() cfg.Share.Server = "" - err := Validate(cfg) - var ve ValidationErrors + err := config.Validate(cfg) + var ve config.ValidationError if !errors.As(err, &ve) { - t.Fatalf("err type = %T, want ValidationErrors", err) + t.Fatalf("err type = %T, want ValidationError", err) } if _, ok := ve["share.server"]; !ok { t.Errorf("missing share.server in errors: %v", ve) From aba1056ce2ea109c1f1aa71bd8d44aff514e1e72 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Fri, 1 May 2026 16:45:25 -0700 Subject: [PATCH 12/12] test(integration): seed TOML config directly to avoid migration notice The auto-migration stderr notice from internal/config.Load() polluted output in tests that asserted "no output" or parsed JSON, causing 7 failures. Switch the setupHome fixture to write config.toml directly, matching how cmd/arc/share_test.go and cmd/arc/self_test.go were updated in T1. --- tests/integration/helpers_test.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/integration/helpers_test.go b/tests/integration/helpers_test.go index 56d9a28..554f322 100644 --- a/tests/integration/helpers_test.go +++ b/tests/integration/helpers_test.go @@ -140,7 +140,7 @@ func arcCmdWithStdinSuccess(t *testing.T, homeDir string, stdin string, args ... } // setupHome creates an isolated temporary directory to serve as HOME for -// a single test. It writes a minimal arc CLI config pointing at the test +// a single test. It writes a minimal arc TOML config pointing at the test // server. The directory is cleaned up when the test finishes. func setupHome(t *testing.T) string { t.Helper() @@ -153,14 +153,15 @@ func setupHome(t *testing.T) string { t.Fatalf("create arc config dir: %v", err) } - cfg := map[string]string{"server_url": serverURL} - data, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal config: %v", err) - } - - cfgPath := filepath.Join(arcDir, "cli-config.json") - if err := os.WriteFile(cfgPath, data, 0o600); err != nil { + // Seed the modern TOML form directly — pre-seeding cli-config.json + // would trigger the auto-migration stderr notice and break tests + // that assert no output. + cfg := fmt.Sprintf( + "[cli]\nserver = %q\n[server]\nport = 7432\ndb_path = \"~/.arc/data.db\"\n[share]\nserver = \"https://arcplanner.sentiolabs.io\"\n[updates]\nchannel = \"stable\"\n", + serverURL, + ) + cfgPath := filepath.Join(arcDir, "config.toml") + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { t.Fatalf("write config: %v", err) }