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/config.go b/cmd/arc/config.go index 10fb2d3..4102f1c 100644 --- a/cmd/arc/config.go +++ b/cmd/arc/config.go @@ -1,163 +1,362 @@ -// 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 ( + "errors" "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 +// setArgsCount is the exact number of arguments required by "config set". +const setArgsCount = 2 -// validConfigKeys lists the allowed configuration keys. -var validConfigKeys = []string{"server_url", "channel"} +// dottedKeyParts is the number of parts produced by splitting a dotted config key. +const dottedKeyParts = 2 -// configCmd is the parent command for CLI configuration management. +// 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", +} + +// recognizedKeys is the canonical list of all valid config key names. +var recognizedKeys = []string{ + "cli.server", + "server.port", + "server.db_path", + "share.author", + "share.server", + "updates.channel", +} + +// configCmd is the parent command for all config sub-commands. 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. +// configListCmd prints all configuration key=value pairs. 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. +// configGetCmd retrieves and prints a single config 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]) +// 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(setArgsCount), + RunE: runConfigSet, +} - cfg, err := loadConfig() - if err != nil { - return err - } +// 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", + 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, ", ")) +// configPathCmd prints the filesystem path of the active config file. +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 - } +// 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, +} - fmt.Println(value) +// 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 { + 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, ".", dottedKeyParts)[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. +// 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 { + 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 +// 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 { + 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] +// 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 { + 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 - } +// 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 + if p == "" { + p = cfgpkg.DefaultPath() + } + // Ensure the file exists by loading once. + if _, err := loadConfig(); err != nil { + return err + } + 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 to catch any syntax errors introduced by the user. + 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 } + } + 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)) +} - 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 errors.New("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..1ce8c8f 100644 --- a/cmd/arc/config_test.go +++ b/cmd/arc/config_test.go @@ -1,83 +1,159 @@ 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") +const dbPathKey = "server.db_path" - // 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") +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) + } +} - data := []byte(`{"server_url":"http://localhost:7432"}`) - require.NoError(t, os.WriteFile(cfgFile, data, 0o600)) +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) + } + } +} - 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", + dbPathKey, + "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 != dbPathKey { + t.Errorf("got %q, want %s", got, dbPathKey) + } +} - // 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..d727907 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" @@ -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,31 +69,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 @@ -122,63 +91,24 @@ 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) - } - - 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() + path = cfgpkg.DefaultPath() } - - // Create directory - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, defaultDirPerm); err != nil { - return fmt.Errorf("create config dir: %w", err) - } - - 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 +125,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..8381e3f 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.ServerOptions{Store: store}) apiSrv.RegisterShareRoutes(e.Group("/api/v1")) srv := httptest.NewServer(e) @@ -703,8 +703,10 @@ 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 +760,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) { @@ -772,12 +780,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 +968,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) } 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/api/config.go b/internal/api/config.go new file mode 100644 index 0000000..16fec00 --- /dev/null +++ b/internal/api/config.go @@ -0,0 +1,63 @@ +// 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.ValidationError + 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()) + } + return successJSON(c, configResponse{ + Config: &incoming, + 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..e9a7c83 --- /dev/null +++ b/internal/api/config_test.go @@ -0,0 +1,136 @@ +package api //nolint:testpackage // tests use internal helpers that access unexported fields + +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" +) + +const testAuthor = "Grace" + +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) + } + 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"]) + } +} + +func TestPutConfigPersistsAndRevalidates(t *testing.T) { + s := newTestServerWithTempHome(t) + in := cfgpkg.Default() + in.Share.Author = testAuthor + 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() + 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) + } + + // 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"] != testAuthor { + t.Errorf("response share.author = %q, want %q", share["author"], testAuthor) + } + 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 != testAuthor { + t.Errorf("disk 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/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{ - "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", + "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..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(Config{ + 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 65b474a..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(Config{ + server := New(ServerOptions{ Address: ":0", Store: store, }) diff --git a/internal/api/server.go b/internal/api/server.go index 3810c48..0e2f173 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 { +// 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 Config struct { } // New creates a new API server. -func New(cfg Config) *Server { +func New(cfg ServerOptions) *Server { e := echo.New() e.HideBanner = true e.HidePort = true @@ -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) diff --git a/internal/api/workspace_paths_test.go b/internal/api/workspace_paths_test.go index 201cfa3..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(Config{ + 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/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c42d49c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,61 @@ +// 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"` +} + +// 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"` + Server string `toml:"server" json:"server"` +} + +// UpdatesConfig holds update-channel settings for `arc self`. +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: DefaultServerPort, 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..0cb5079 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,63 @@ +package config_test + +import ( + "testing" + + "github.com/sentiolabs/arc/internal/config" +) + +// --- Contract assertions --- +// These verify the design spec. Do NOT modify without updating the approved plan. +var ( + _ config.CLIConfig = config.Config{}.CLI + _ config.ServerConfig = config.Config{}.Server + _ config.ShareConfig = config.Config{}.Share + _ config.UpdatesConfig = config.Config{}.Updates +) + +var _ interface { + Load() *config.Config + Swap(*config.Config) *config.Config +} = (*config.Store)(nil) + +func TestDefaultIsUsable(t *testing.T) { + cfg := config.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 := 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) + } + for _, k := range got { + if !want[k] { + t.Errorf("unexpected key %q in RequiresRestart()", k) + } + } +} + +func TestStoreSwap(t *testing.T) { + 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) + } + 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..ecaf7b6 --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,94 @@ +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" +) + +// DefaultPath returns ~/.arc/config.toml. +func DefaultPath() string { + 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, err := os.UserHomeDir() + if err != nil { + panic(fmt.Errorf("cannot determine home directory: %w", err)) + } + 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) + } + if err := Validate(cfg); err != nil { + return nil, err + } + return cfg, nil + } else if !errors.Is(err, fs.ErrNotExist) { + 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 + } + // 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() + 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..143c9f9 --- /dev/null +++ b/internal/config/load_test.go @@ -0,0 +1,53 @@ +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 := config.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) + } +} + +func TestLoadSavePreservesTildeInDBPath(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + cfg := config.Default() + cfg.Server.DBPath = tildeDBPath + if err := config.Save(path, cfg); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Server.DBPath != tildeDBPath { + t.Errorf("db_path = %q, want %q (tilde corrupted by load)", got.Server.DBPath, tildeDBPath) + } + if err := config.Save(path, got); err != nil { + t.Fatalf("Save (round 2): %v", err) + } + got2, err := config.Load(path) + if err != nil { + t.Fatalf("Load (round 2): %v", err) + } + 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.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..17182c6 --- /dev/null +++ b/internal/config/migrate_test.go @@ -0,0 +1,45 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sentiolabs/arc/internal/config" +) + +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 := config.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..96d99ec --- /dev/null +++ b/internal/config/save.go @@ -0,0 +1,61 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "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. +// 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 + } + 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() + success := false + // Clean up the temp file if we exit without renaming it into place. + defer func() { + if !success { + _ = 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) + } + success = true + return nil +} diff --git a/internal/config/save_test.go b/internal/config/save_test.go new file mode 100644 index 0000000..21bb15d --- /dev/null +++ b/internal/config/save_test.go @@ -0,0 +1,43 @@ +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 := config.Default() + cfg.Share.Author = "Ada" + if err := config.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 := config.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 := config.Default() + cfg.Server.Port = 0 + if err := config.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..9a9e0fd --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,58 @@ +package config + +import ( + "fmt" + "net/url" + "sort" + "strings" +) + +// ValidChannels lists the allowed values for updates.channel. +var ValidChannels = []string{"stable", "rc", "nightly"} + +// ValidationError maps dotted-key to error message. +type ValidationError map[string]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) + } + 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 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 := 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" + } + if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { + errs["server.port"] = "must be between 1 and 65535" + } + 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 { + channelOK = true + break + } + } + if !channelOK { + errs["updates.channel"] = "must be one of: " + 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..757dafd --- /dev/null +++ b/internal/config/validate_test.go @@ -0,0 +1,79 @@ +package config_test + +import ( + "errors" + "testing" + + "github.com/sentiolabs/arc/internal/config" +) + +func TestValidateAcceptsDefault(t *testing.T) { + if err := config.Validate(config.Default()); err != nil { + t.Fatalf("Validate(Default()) = %v, want nil", err) + } +} + +func TestValidateRejectsBadURL(t *testing.T) { + cfg := config.Default() + cfg.CLI.Server = "not a url" + err := config.Validate(cfg) + var ve config.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err type = %T, want ValidationError", err) + } + if _, ok := ve["cli.server"]; !ok { + t.Errorf("missing cli.server in errors: %v", ve) + } +} + +func TestValidateRejectsBadPort(t *testing.T) { + cfg := config.Default() + cfg.Server.Port = 0 + err := config.Validate(cfg) + var ve config.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err type = %T, want ValidationError", err) + } + if _, ok := ve["server.port"]; !ok { + t.Errorf("missing server.port in errors: %v", ve) + } +} + +func TestValidateRejectsBadChannel(t *testing.T) { + cfg := config.Default() + cfg.Updates.Channel = "weekly" + err := config.Validate(cfg) + var ve config.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err type = %T, want ValidationError", err) + } + if _, ok := ve["updates.channel"]; !ok { + t.Errorf("missing updates.channel in errors: %v", ve) + } +} + +func TestValidateRejectsEmptyCLIServer(t *testing.T) { + cfg := config.Default() + cfg.CLI.Server = "" + err := config.Validate(cfg) + var ve config.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err type = %T, want ValidationError", err) + } + if _, ok := ve["cli.server"]; !ok { + t.Errorf("missing cli.server in errors: %v", ve) + } +} + +func TestValidateRejectsEmptyShareServer(t *testing.T) { + cfg := config.Default() + cfg.Share.Server = "" + err := config.Validate(cfg) + var ve config.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err type = %T, want ValidationError", err) + } + if _, ok := ve["share.server"]; !ok { + t.Errorf("missing share.server in errors: %v", ve) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b33e398..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.Config{ + server := api.New(api.ServerOptions{ Address: cfg.Address, Store: store, DB: store.DB(), 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) } diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index 46dd5f9..812c3ad 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; +} + +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; +} + // 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/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]; +}; 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..9970c3c --- /dev/null +++ b/web/src/lib/components/settings/ChannelPicker.svelte @@ -0,0 +1,29 @@ + + +
+ {label} +
+ {#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..f816678 --- /dev/null +++ b/web/src/routes/settings/+page.svelte @@ -0,0 +1,113 @@ + + +Settings · Arc + +
+
+

Settings

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

Settings are unavailable on this deployment.

+ {#if data.error} +

{data.error}

+ {/if} +
+ {: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) }; + } +};