diff --git a/CLAUDE.md b/CLAUDE.md index d933677..544d292 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ The CLI embeds the anytype-heart gRPC server directly, creating a self-contained - Each command group has its own directory: - `auth/`: Authentication commands (login, logout, status) - `apikey/`: API key management (create, list, revoke) + - `config/`: User settings (list, get, set, unset); keys are registered in `core/config/settings.go` - `serve/`: Run the embedded Anytype server in foreground - `service/`: System service management (install, uninstall, start, stop, restart, status) - `space/`: Space management operations @@ -108,7 +109,7 @@ The CLI embeds the anytype-heart gRPC server directly, creating a self-contained - `stream.go`: Event streaming with EventReceiver and message batching (cheggaaa/mb) - `keyring.go`: Secure credential storage using system keyring - `apikey.go`: API key generation and management -- `config/`: Configuration management with constants +- `config/`: Configuration management with constants. `Config.Settings` holds user settings that survive logout; account state and fallback credentials sit beside it - `serviceprogram/`: Cross-platform service implementation (Windows Service, macOS launchd, Linux systemd) - `grpcserver/`: Embedded gRPC server implementation diff --git a/README.md b/README.md index 52f63ff..596e8af 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A command-line interface for interacting with [Anytype](https://github.com/anypr - [Authentication](#authentication) - [API Keys](#api-keys) - [Space Management](#space-management) + - [Settings](#settings) - [Development](#development) - [Project Structure](#project-structure) - [Building from Source](#building-from-source) @@ -207,6 +208,38 @@ anytype space join anytype space leave ``` +### Settings + +Settings are saved in `~/.anytype/config.json` and survive logout: + +```bash +# Show every setting, its value and where the value comes from +anytype config list + +# Print one value +anytype config get prefer-tcp + +# Change a setting, or return it to the default +anytype config set prefer-tcp true +anytype config unset prefer-tcp +``` + +| Key | Default | Description | +| --- | --- | --- | +| `listen-address` | `127.0.0.1:31012` | JSON API listen address. Change it with `--listen-address` (see [Network Configuration](#network-configuration)). | +| `prefer-tcp` | `false` | Connect to sync nodes and peers over TCP before QUIC (UDP). QUIC is still used when TCP fails. Override with `ANYTYPE_PREFER_TCP=true` or `false`. | + +#### Sync stalls on networks that block UDP + +Anytype connects over QUIC, which runs on UDP, and falls back to TCP when QUIC fails. Some firewalls, VPNs and corporate networks block or throttle UDP, so every connection waits for QUIC first. If sync is slow or stalls on such a network, try TCP first: + +```bash +anytype config set prefer-tcp true +anytype service restart # or stop and start `anytype serve` +``` + +The setting applies the next time the account starts. In Docker, set `ANYTYPE_PREFER_TCP=true` in the container's environment instead. `anytype auth status` shows the setting and where its value comes from. + ## Development ### Project Structure @@ -215,6 +248,7 @@ anytype space leave anytype-cli/ ├── cmd/ # CLI commands │ ├── auth/ # Authentication commands +│ ├── config/ # Settings │ ├── serve/ # Server command │ ├── service/ # Service management │ ├── space/ # Space management diff --git a/SELF-HOSTED.md b/SELF-HOSTED.md index 05ccbe3..7a7317d 100644 --- a/SELF-HOSTED.md +++ b/SELF-HOSTED.md @@ -78,6 +78,9 @@ Re-create your account with `--network-config` pointing to the correct YAML file **`DeadlineExceeded`** Check network connectivity to your self-hosted nodes. +**Slow sync when only TCP ports are reachable** +If your nodes' QUIC (UDP) ports are blocked or not forwarded, every connection waits for QUIC to fail before falling back to TCP. Connect over TCP first with `anytype config set prefer-tcp true`, then `anytype service restart` (see [Settings](README.md#settings)). + **`no ns peers configured`** / **`membership status`** These warnings are expected on self-hosted networks (naming service and membership are only available on the Anytype Network). diff --git a/cmd/auth/status/status.go b/cmd/auth/status/status.go index 73cb604..884e6ea 100644 --- a/cmd/auth/status/status.go +++ b/cmd/auth/status/status.go @@ -7,6 +7,7 @@ import ( "github.com/anyproto/anytype-heart/pb/service" "github.com/spf13/cobra" + "github.com/anyproto/anytype-cli/cmd/cmdutil" "github.com/anyproto/anytype-cli/core" "github.com/anyproto/anytype-cli/core/config" "github.com/anyproto/anytype-cli/core/output" @@ -94,6 +95,12 @@ func NewStatusCmd() *cobra.Command { output.Print(" - JSON API: \033[1m%s\033[0m", config.APIURL(apiAddr)) } + preferTcp, source, err := config.PreferTcp() + if err != nil { + output.Warning("prefer-tcp: %v", err) + } + output.Print(" - Prefer TCP: \033[1m%v\033[0m (%s)", preferTcp, cmdutil.SourceLabel(source, config.PreferTcpEnv)) + if hasAccountKey { if len(accountKey) > 8 { output.Print(" - Account Key: \033[1m%s****\033[0m", accountKey[:8]) diff --git a/cmd/cmdutil/settings.go b/cmd/cmdutil/settings.go new file mode 100644 index 0000000..0429a95 --- /dev/null +++ b/cmd/cmdutil/settings.go @@ -0,0 +1,48 @@ +package cmdutil + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/output" +) + +// SettingKeyCompletion completes the first argument with config keys. +func SettingKeyCompletion(settableOnly bool) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var keys []cobra.Completion + for _, s := range config.SettingKeys() { + if !settableOnly || s.Settable() { + keys = append(keys, s.Key) + } + } + return keys, cobra.ShellCompDirectiveNoFileComp + } +} + +// SourceLabel names where a setting's value comes from, including the +// environment variable when it's an override. +func SourceLabel(source config.Source, env string) string { + if source == config.SourceEnv { + return "env " + env + } + return string(source) +} + +// ExplainSettingChange tells the user when a saved change takes effect. +func ExplainSettingChange(s config.Setting) { + if s.Env != "" { + if value := os.Getenv(s.Env); value != "" { + output.Warning("%s=%s is set here and overrides the saved value for commands run from this environment.", s.Env, value) + } + } + if s.RestartToApply { + output.Print("Applies the next time the account starts. To apply it now, restart the server:") + output.Print(" anytype service restart (or stop and start 'anytype serve')") + } +} diff --git a/cmd/config/config.go b/cmd/config/config.go index 4f7705c..0e277d9 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -4,21 +4,34 @@ import ( "github.com/spf13/cobra" configGetCmd "github.com/anyproto/anytype-cli/cmd/config/get" - configResetCmd "github.com/anyproto/anytype-cli/cmd/config/reset" + configListCmd "github.com/anyproto/anytype-cli/cmd/config/list" configSetCmd "github.com/anyproto/anytype-cli/cmd/config/set" + configUnsetCmd "github.com/anyproto/anytype-cli/cmd/config/unset" ) func NewConfigCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "config ", - Short: "Manage configuration", - Long: `Manage Anytype CLI configuration settings`, - Hidden: true, + Use: "config ", + Short: "View and change settings", + Long: `View and change settings saved in ~/.anytype/config.json. Settings survive logout. + +Keys: + listen-address JSON API listen address (host:port). Change it with + --listen-address on 'service install', 'serve', 'auth login' + or 'auth create'. + prefer-tcp Connect to sync nodes and peers over TCP before QUIC (UDP). + Turn it on if sync stalls on a network that blocks or + throttles UDP. QUIC is still used when TCP fails. + Override with ANYTYPE_PREFER_TCP=true|false.`, + Example: ` anytype config list + anytype config set prefer-tcp true + anytype service restart`, } + cmd.AddCommand(configListCmd.NewListCmd()) cmd.AddCommand(configGetCmd.NewGetCmd()) cmd.AddCommand(configSetCmd.NewSetCmd()) - cmd.AddCommand(configResetCmd.NewResetCmd()) + cmd.AddCommand(configUnsetCmd.NewUnsetCmd()) return cmd } diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go new file mode 100644 index 0000000..7eedc63 --- /dev/null +++ b/cmd/config/config_test.go @@ -0,0 +1,222 @@ +package config + +import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + + coreconfig "github.com/anyproto/anytype-cli/core/config" +) + +// TestMain points HOME at a temp dir before the config manager first reads +// it, so the commands work on a throwaway config file. +func TestMain(m *testing.M) { + home, err := os.MkdirTemp("", "anytype-config-cmd") + if err != nil { + panic(err) + } + os.Setenv("HOME", home) + code := m.Run() + os.RemoveAll(home) + os.Exit(code) +} + +func configPath(t *testing.T) string { + t.Helper() + return coreconfig.GetConfigManager().GetFilePath() +} + +// writeConfig replaces the config file; empty content removes it. +func writeConfig(t *testing.T, content string) { + t.Helper() + path := configPath(t) + if content == "" { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + return + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } +} + +func readConfig(t *testing.T) map[string]any { + t.Helper() + data, err := os.ReadFile(configPath(t)) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + return raw +} + +// runConfig runs `anytype config args...` and returns what it printed. +func runConfig(t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + capture := func(target **os.File) func() string { + r, w, pipeErr := os.Pipe() + if pipeErr != nil { + t.Fatal(pipeErr) + } + orig := *target + *target = w + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + return func() string { + *target = orig + w.Close() + <-done + return buf.String() + } + } + stopOut := capture(&os.Stdout) + stopErr := capture(&os.Stderr) + + cmd := NewConfigCmd() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs(args) + err = cmd.Execute() + + return stopOut(), stopErr(), err +} + +func TestConfigListDefaults(t *testing.T) { + t.Setenv(coreconfig.PreferTcpEnv, "") + writeConfig(t, "") + + stdout, _, err := runConfig(t, "list") + if err != nil { + t.Fatalf("list: %v", err) + } + for _, want := range []string{ + "listen-address " + coreconfig.DefaultAPIAddress, + "prefer-tcp false", + } { + if !strings.Contains(stdout, want) { + t.Errorf("list output missing %q:\n%s", want, stdout) + } + } + if strings.Count(stdout, "default") != 2 { + t.Errorf("want both keys from default:\n%s", stdout) + } +} + +func TestConfigSetGetUnsetPreferTcp(t *testing.T) { + t.Setenv(coreconfig.PreferTcpEnv, "") + writeConfig(t, `{"accountId":"acc","sessionToken":"token"}`) + + stdout, _, err := runConfig(t, "set", "prefer-tcp", "1") + if err != nil { + t.Fatalf("set: %v", err) + } + if !strings.Contains(stdout, "prefer-tcp = true") || !strings.Contains(stdout, "anytype service restart") { + t.Errorf("set output = %q, want the saved value and a restart hint", stdout) + } + raw := readConfig(t) + if raw["preferTcp"] != true || raw["accountId"] != "acc" || raw["sessionToken"] != "token" { + t.Errorf("config after set = %v, want preferTcp added and account fields kept", raw) + } + + stdout, _, err = runConfig(t, "get", "prefer-tcp") + if err != nil || stdout != "true\n" { + t.Errorf("get = (%q, %v), want \"true\\n\"", stdout, err) + } + + stdout, _, err = runConfig(t, "list") + if err != nil || !strings.Contains(stdout, "true config") { + t.Errorf("list = (%q, %v), want prefer-tcp true from config", stdout, err) + } + + stdout, _, err = runConfig(t, "unset", "prefer-tcp") + if err != nil || !strings.Contains(stdout, "prefer-tcp = false (default)") { + t.Errorf("unset = (%q, %v)", stdout, err) + } + raw = readConfig(t) + if _, ok := raw["preferTcp"]; ok || raw["accountId"] != "acc" { + t.Errorf("config after unset = %v, want preferTcp removed and account fields kept", raw) + } +} + +func TestConfigSetRejectsInvalidValue(t *testing.T) { + writeConfig(t, `{"preferTcp":true}`) + + if _, _, err := runConfig(t, "set", "prefer-tcp", "maybe"); err == nil { + t.Fatal("set prefer-tcp maybe succeeded, want an error") + } + if raw := readConfig(t); raw["preferTcp"] != true { + t.Errorf("config after a rejected set = %v, want it unchanged", raw) + } +} + +func TestConfigListenAddressIsReadOnly(t *testing.T) { + writeConfig(t, `{"apiListenAddr":"0.0.0.0:4000"}`) + + for _, args := range [][]string{{"set", "listen-address", "0.0.0.0:5000"}, {"unset", "listen-address"}} { + _, _, err := runConfig(t, args...) + if err == nil || !strings.Contains(err.Error(), "--listen-address") { + t.Errorf("%v error = %v, want a hint about --listen-address", args, err) + } + } + if raw := readConfig(t); raw["apiListenAddr"] != "0.0.0.0:4000" { + t.Errorf("config = %v, want apiListenAddr unchanged", raw) + } + + stdout, _, err := runConfig(t, "get", "listen-address") + if err != nil || stdout != "0.0.0.0:4000\n" { + t.Errorf("get listen-address = (%q, %v)", stdout, err) + } +} + +func TestConfigEnvOverride(t *testing.T) { + t.Setenv(coreconfig.PreferTcpEnv, "true") + writeConfig(t, "") + + stdout, _, err := runConfig(t, "get", "prefer-tcp") + if err != nil || stdout != "true\n" { + t.Errorf("get = (%q, %v), want the environment value", stdout, err) + } + + stdout, _, err = runConfig(t, "list") + if err != nil || !strings.Contains(stdout, "env "+coreconfig.PreferTcpEnv) { + t.Errorf("list = (%q, %v), want the environment as the source", stdout, err) + } + + _, stderr, err := runConfig(t, "unset", "prefer-tcp") + if err != nil || !strings.Contains(stderr, coreconfig.PreferTcpEnv+"=true") { + t.Errorf("unset = (stderr %q, %v), want a warning about the override", stderr, err) + } +} + +func TestConfigInvalidEnvWarns(t *testing.T) { + t.Setenv(coreconfig.PreferTcpEnv, "maybe") + writeConfig(t, `{"preferTcp":true}`) + + stdout, stderr, err := runConfig(t, "get", "prefer-tcp") + if err != nil || stdout != "true\n" || !strings.Contains(stderr, "ignoring "+coreconfig.PreferTcpEnv) { + t.Errorf("get = (%q, stderr %q, %v), want the saved value and a warning", stdout, stderr, err) + } +} + +func TestConfigUnknownKey(t *testing.T) { + for _, args := range [][]string{{"get", "accountId"}, {"set", "accountId", "x"}, {"unset", "accountId"}} { + if _, _, err := runConfig(t, args...); err == nil || !strings.Contains(err.Error(), "unknown config key") { + t.Errorf("%v error = %v, want unknown config key", args, err) + } + } +} diff --git a/cmd/config/get/get.go b/cmd/config/get/get.go index af47749..d13867b 100644 --- a/cmd/config/get/get.go +++ b/cmd/config/get/get.go @@ -1,46 +1,35 @@ package get import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/cmd/cmdutil" "github.com/anyproto/anytype-cli/core/config" "github.com/anyproto/anytype-cli/core/output" - "github.com/spf13/cobra" ) func NewGetCmd() *cobra.Command { return &cobra.Command{ - Use: "get [key]", - Short: "Get a configuration value", - Long: `Get a specific configuration value or all values if no key is specified`, + Use: "get ", + Short: "Print a setting's effective value", + Long: "Print a setting's effective value, including any environment override. Run 'anytype config list' to see every key.", + Args: cmdutil.ExactArgs(1, "cannot get config: key argument required"), + ValidArgsFunction: cmdutil.SettingKeyCompletion(false), RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - accountId, _ := config.GetAccountIdFromConfig() - techSpaceId, _ := config.GetTechSpaceIdFromConfig() - - if accountId != "" { - output.Info("accountId: %s", accountId) - } - if techSpaceId != "" { - output.Info("techSpaceId: %s", techSpaceId) - } - return nil + s, err := config.LookupSetting(args[0]) + if err != nil { + return output.Error("%w", err) } - - key := args[0] - switch key { - case "accountId": - accountId, _ := config.GetAccountIdFromConfig() - if accountId != "" { - output.Info(accountId) - } - case "techSpaceId": - techSpaceId, _ := config.GetTechSpaceIdFromConfig() - if techSpaceId != "" { - output.Info(techSpaceId) - } - default: - return output.Error("unknown config key: %s", key) + stored, err := config.LoadStoredConfig() + if err != nil { + return output.Error("Failed to read settings: %w", err) } + value, _, warning := s.Resolve(stored.Settings) + if warning != nil { + output.Warning("%s: %v", s.Key, warning) + } + output.Print("%s", value) return nil }, } diff --git a/cmd/config/list/list.go b/cmd/config/list/list.go new file mode 100644 index 0000000..be35c7d --- /dev/null +++ b/cmd/config/list/list.go @@ -0,0 +1,33 @@ +package list + +import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/cmd/cmdutil" + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/output" +) + +func NewListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List settings with their effective values", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + stored, err := config.LoadStoredConfig() + if err != nil { + return output.Error("Failed to read settings: %w", err) + } + + output.Print("%-16s %-24s %s", "KEY", "VALUE", "SOURCE") + for _, s := range config.SettingKeys() { + value, source, warning := s.Resolve(stored.Settings) + if warning != nil { + output.Warning("%s: %v", s.Key, warning) + } + output.Print("%-16s %-24s %s", s.Key, value, cmdutil.SourceLabel(source, s.Env)) + } + return nil + }, + } +} diff --git a/cmd/config/reset/reset.go b/cmd/config/reset/reset.go deleted file mode 100644 index 0abd678..0000000 --- a/cmd/config/reset/reset.go +++ /dev/null @@ -1,24 +0,0 @@ -package reset - -import ( - "github.com/anyproto/anytype-cli/core/config" - "github.com/anyproto/anytype-cli/core/output" - "github.com/spf13/cobra" -) - -func NewResetCmd() *cobra.Command { - return &cobra.Command{ - Use: "reset", - Short: "Reset configuration to defaults", - Long: `Reset all configuration values to their default state`, - RunE: func(cmd *cobra.Command, args []string) error { - configMgr := config.GetConfigManager() - if err := configMgr.Reset(); err != nil { - return output.Error("Failed to reset config: %w", err) - } - - output.Success("Configuration reset to defaults") - return nil - }, - } -} diff --git a/cmd/config/set/set.go b/cmd/config/set/set.go index a5fb12b..4a5e85c 100644 --- a/cmd/config/set/set.go +++ b/cmd/config/set/set.go @@ -10,28 +10,28 @@ import ( func NewSetCmd() *cobra.Command { return &cobra.Command{ - Use: "set ", - Short: "Set a configuration value", - Long: `Set a specific configuration value`, - Args: cmdutil.ExactArgs(2, "cannot set config: key and value arguments required"), + Use: "set ", + Short: "Save a setting", + Args: cmdutil.ExactArgs(2, "cannot set config: key and value arguments required"), + ValidArgsFunction: cmdutil.SettingKeyCompletion(true), RunE: func(cmd *cobra.Command, args []string) error { - key := args[0] - value := args[1] + s, err := config.LookupSetting(args[0]) + if err != nil { + return output.Error("%w", err) + } - switch key { - case "accountId": - if err := config.SetAccountIdToConfig(value); err != nil { - return output.Error("Failed to set account Id: %w", err) - } - case "techSpaceId": - if err := config.SetTechSpaceIdToConfig(value); err != nil { - return output.Error("Failed to set tech space Id: %w", err) - } - default: - return output.Error("unknown config key: %s", key) + var saved string + err = config.UpdateSettingsInConfig(func(settings *config.Settings) error { + var err error + saved, err = s.Set(settings, args[1]) + return err + }) + if err != nil { + return output.Error("Failed to set %s: %w", s.Key, err) } - output.Success("Set %s = %s", key, value) + output.Success("%s = %s", s.Key, saved) + cmdutil.ExplainSettingChange(s) return nil }, } diff --git a/cmd/config/unset/unset.go b/cmd/config/unset/unset.go new file mode 100644 index 0000000..65b7f28 --- /dev/null +++ b/cmd/config/unset/unset.go @@ -0,0 +1,38 @@ +package unset + +import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/cmd/cmdutil" + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/output" +) + +func NewUnsetCmd() *cobra.Command { + return &cobra.Command{ + Use: "unset ", + Short: "Return a setting to its default", + Args: cmdutil.ExactArgs(1, "cannot unset config: key argument required"), + ValidArgsFunction: cmdutil.SettingKeyCompletion(true), + RunE: func(cmd *cobra.Command, args []string) error { + s, err := config.LookupSetting(args[0]) + if err != nil { + return output.Error("%w", err) + } + + var saved string + err = config.UpdateSettingsInConfig(func(settings *config.Settings) error { + var err error + saved, err = s.Unset(settings) + return err + }) + if err != nil { + return output.Error("Failed to unset %s: %w", s.Key, err) + } + + output.Success("%s = %s (default)", s.Key, saved) + cmdutil.ExplainSettingChange(s) + return nil + }, + } +} diff --git a/core/auth.go b/core/auth.go index 38af0c1..2a49a8e 100644 --- a/core/auth.go +++ b/core/auth.go @@ -24,6 +24,16 @@ func determineNetworkMode(networkConfigPath string) pb.RpcAccountNetworkMode { return pb.RpcAccount_DefaultConfig } +// resolvePreferTcp reads the transport preference sent when the account +// starts. A bad override only warns: service auto-login runs through here too. +func resolvePreferTcp() bool { + preferTcp, _, err := config.PreferTcp() + if err != nil { + output.Warning("prefer-tcp: %v", err) + } + return preferTcp +} + // Authenticate performs the full authentication flow for a bot account using an account key. // This includes wallet recovery, session creation, account recovery, account selection, and config persistence. // If networkConfigPath is provided, connects to that custom network. @@ -36,6 +46,7 @@ func Authenticate(accountKey, rootPath, apiAddr, networkConfigPath string) error } networkMode := determineNetworkMode(networkConfigPath) + preferTcp := resolvePreferTcp() var sessionToken string err := GRPCCallNoAuth(func(ctx context.Context, client service.ClientCommandsClient) error { @@ -119,6 +130,7 @@ func Authenticate(accountKey, rootPath, apiAddr, networkConfigPath string) error RootPath: rootPath, NetworkMode: networkMode, NetworkCustomConfigFilePath: networkConfigPath, + PreferYamuxTransport: preferTcp, }) if err != nil { return fmt.Errorf("failed to select account: %w", err) @@ -273,6 +285,7 @@ func CreateWallet(name, rootPath, apiAddr, networkConfigPath string) (string, st } networkMode := determineNetworkMode(networkConfigPath) + preferTcp := resolvePreferTcp() var sessionToken string var accountKey string @@ -333,6 +346,7 @@ func CreateWallet(name, rootPath, apiAddr, networkConfigPath string) (string, st JsonApiListenAddr: apiAddr, NetworkMode: networkMode, NetworkCustomConfigFilePath: networkConfigPath, + PreferYamuxTransport: preferTcp, }) if err != nil { return fmt.Errorf("account creation failed: %w", err) @@ -352,6 +366,7 @@ func CreateWallet(name, rootPath, apiAddr, networkConfigPath string) (string, st RootPath: rootPath, NetworkMode: networkMode, NetworkCustomConfigFilePath: networkConfigPath, + PreferYamuxTransport: preferTcp, }) if err != nil { return fmt.Errorf("failed to select account: %w", err) diff --git a/core/automation/account.go b/core/automation/account.go index d70ee0b..daec35f 100644 --- a/core/automation/account.go +++ b/core/automation/account.go @@ -50,6 +50,10 @@ func (e *Engine) authMutation(ctx context.Context, r Request, op *operation, ret if err != nil { return fail(err) } + preferTcp, _, envErr := config.ResolvePreferTcp(c.Settings) + if envErr != nil { + return fail(problem("invalid_environment", config.PreferTcpEnv+" must be true or false.", 2)) + } s, err := e.live(ctx) if err != nil { return fail(err) @@ -314,7 +318,7 @@ func (e *Engine) authMutation(ctx context.Context, r Request, op *operation, ret if err = e.saveOp(op, "account_sent"); err != nil { return fail(err) } - result, rpcErr := e.Client.AccountCreate(authCtx, &pb.RpcAccountCreateRequest{Name: r.Name, StorePath: e.DataPath, JsonApiListenAddr: addr, NetworkMode: mode, NetworkCustomConfigFilePath: network}) + result, rpcErr := e.Client.AccountCreate(authCtx, &pb.RpcAccountCreateRequest{Name: r.Name, StorePath: e.DataPath, JsonApiListenAddr: addr, NetworkMode: mode, NetworkCustomConfigFilePath: network, PreferYamuxTransport: preferTcp}) if rpcErr != nil { return fail(rpcError(rpcErr)) } @@ -328,7 +332,7 @@ func (e *Engine) authMutation(ctx context.Context, r Request, op *operation, ret if err = e.saveOp(op, "select_sent"); err != nil { return fail(err) } - result, rpcErr := e.Client.AccountSelect(authCtx, &pb.RpcAccountSelectRequest{Id: op.AccountId, RootPath: e.DataPath, JsonApiListenAddr: addr, NetworkMode: mode, NetworkCustomConfigFilePath: network}) + result, rpcErr := e.Client.AccountSelect(authCtx, &pb.RpcAccountSelectRequest{Id: op.AccountId, RootPath: e.DataPath, JsonApiListenAddr: addr, NetworkMode: mode, NetworkCustomConfigFilePath: network, PreferYamuxTransport: preferTcp}) if rpcErr != nil { return fail(rpcError(rpcErr)) } diff --git a/core/automation/account_test.go b/core/automation/account_test.go index 5c831af..29ea5db 100644 --- a/core/automation/account_test.go +++ b/core/automation/account_test.go @@ -1,11 +1,13 @@ package automation import ( + "encoding/json" "errors" "os" "path/filepath" "testing" + "github.com/anyproto/anytype-cli/core/config" "github.com/anyproto/anytype-cli/core/credentials" "github.com/anyproto/anytype-cli/core/localstate" "github.com/anyproto/anytype-cli/core/runtimeinfo" @@ -231,3 +233,105 @@ func TestWildcardListenerAliasPreservesAccountSettingsOnReplay(t *testing.T) { } } } + +func TestAccountStartSendsTransportPreference(t *testing.T) { + tests := []struct { + name string + env string + saved bool + want bool + }{ + {"default", "", false, false}, + {"saved", "", true, true}, + {"env on", "true", false, true}, + {"env off overrides saved", "false", true, false}, + } + for _, command := range []string{"auth create", "auth login"} { + for _, tt := range tests { + t.Run(command+"/"+tt.name, func(t *testing.T) { + t.Setenv(config.PreferTcpEnv, tt.env) + e, rpc, _ := fixture(t, false) + if tt.saved { + if err := localstate.WriteJSON(e.ConfigPath, config.Config{Settings: config.Settings{PreferTcp: true}}); err != nil { + t.Fatal(err) + } + } + r := createRequest() + if command == "auth login" { + r = Request{Command: "auth login", OperationId: "login-0001", ExpectedAccount: "none", Secret: "account-private-fixture"} + } + if out := run(e, r); out.ExitCode() != 0 { + t.Fatalf("%s: %+v", command, out) + } + if len(rpc.preferTcp) == 0 { + t.Fatal("no account start was sent") + } + for _, got := range rpc.preferTcp { + if got != tt.want { + t.Errorf("PreferYamuxTransport = %v, want %v", got, tt.want) + } + } + }) + } + } +} + +func TestInvalidTransportOverrideStopsBeforeRPC(t *testing.T) { + t.Setenv(config.PreferTcpEnv, "maybe") + e, rpc, _ := fixture(t, false) + r := Request{Command: "auth login", OperationId: "login-0001", ExpectedAccount: "none", Secret: "account-private-fixture"} + assertCode(t, run(e, r), "invalid_environment") + if rpc.initialCalls != 0 || rpc.recoverCalls != 0 || rpc.selectCalls != 0 { + t.Fatal("sent RPCs despite an invalid transport override") + } +} + +func TestInspectionReportsTransportPreference(t *testing.T) { + tests := []struct { + name string + env string + saved bool + want bool + wantSource string + wantAction bool + }{ + {"default", "", false, false, "default", false}, + {"saved", "", true, true, "config", false}, + {"env", "true", false, true, "env", false}, + {"invalid env", "maybe", true, true, "config", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(config.PreferTcpEnv, tt.env) + e, _, _ := fixture(t, false) + if tt.saved { + if err := localstate.WriteJSON(e.ConfigPath, config.Config{Settings: config.Settings{PreferTcp: true}}); err != nil { + t.Fatal(err) + } + } + out := run(e, Request{Command: "inspect"}) + if out.ExitCode() != 0 { + t.Fatal(out) + } + b, err := json.Marshal(out.Result) + if err != nil { + t.Fatal(err) + } + var result struct { + Transport struct { + PreferTcp bool `json:"prefer_tcp"` + Source string `json:"source"` + Observation string `json:"observation"` + NextAction string `json:"next_action"` + } `json:"transport"` + } + if err := json.Unmarshal(b, &result); err != nil { + t.Fatal(err) + } + got := result.Transport + if got.PreferTcp != tt.want || got.Source != tt.wantSource || got.Observation != "configured" || (got.NextAction != "") != tt.wantAction { + t.Errorf("transport = %+v, want prefer_tcp %v from %s (next_action %v)", got, tt.want, tt.wantSource, tt.wantAction) + } + }) + } +} diff --git a/core/automation/automation_test.go b/core/automation/automation_test.go index 8255743..da9177e 100644 --- a/core/automation/automation_test.go +++ b/core/automation/automation_test.go @@ -63,6 +63,7 @@ type fakeRPC struct { badGrant bool afterKey func() afterCreate func() + preferTcp []bool } func (f *fakeRPC) InitialSetParameters(context.Context, *pb.RpcInitialSetParametersRequest, ...grpc.CallOption) (*pb.RpcInitialSetParametersResponse, error) { @@ -109,6 +110,7 @@ func (f *fakeRPC) AccountCreate(_ context.Context, r *pb.RpcAccountCreateRequest panic("AccountCreate before recovery key persisted") } f.names = r.Name + f.preferTcp = append(f.preferTcp, r.PreferYamuxTransport) f.settings(r.NetworkCustomConfigFilePath, r.JsonApiListenAddr) a := f.account() if f.afterCreate != nil { @@ -118,6 +120,7 @@ func (f *fakeRPC) AccountCreate(_ context.Context, r *pb.RpcAccountCreateRequest } func (f *fakeRPC) AccountSelect(_ context.Context, r *pb.RpcAccountSelectRequest, _ ...grpc.CallOption) (*pb.RpcAccountSelectResponse, error) { f.selectCalls++ + f.preferTcp = append(f.preferTcp, r.PreferYamuxTransport) f.settings(r.NetworkCustomConfigFilePath, r.JsonApiListenAddr) return &pb.RpcAccountSelectResponse{Account: f.account()}, nil } diff --git a/core/automation/engine.go b/core/automation/engine.go index c810f77..3b49bb7 100644 --- a/core/automation/engine.go +++ b/core/automation/engine.go @@ -206,6 +206,7 @@ func (e *Engine) inspect(ctx context.Context, r Request) Envelope { } svc, svcErr := e.runtimeServiceStatus(ctx) result := map[string]any{"cli_version": core.GetVersion(), "instance": map[string]any{"id": "default", "config_path": e.ConfigPath, "data_path": e.DataPath, "work_path": e.WorkPath, "grpc_address": config.DefaultGRPCAddress, "grpc_web_address": config.DefaultGRPCWebAddress, "isolated": false}, "service": svc, "capabilities": capabilities(), "account": Account{Id: c.AccountId, Observation: "configured"}, "network": map[string]any{"id": c.NetworkId, "config_path": c.NetworkConfigPath, "observation": "configured"}, "api": map[string]any{"configured_origin": apiOrigin(config.ResolveAPIListenAddr("", false, c.ApiListenAddr)), "ready": false, "observation": "configured"}} + result["transport"] = transportJSON(c.Settings) s, err := e.live(ctx) if err != nil { out := Failure(err) diff --git a/core/automation/service_test.go b/core/automation/service_test.go index 8a9d540..1bf41d8 100644 --- a/core/automation/service_test.go +++ b/core/automation/service_test.go @@ -68,7 +68,7 @@ func TestServiceDefinitionInheritsSavedListenAddress(t *testing.T) { if err := os.WriteFile(exe, []byte("fixture"), 0700); err != nil { t.Fatal(err) } - if err := localstate.WriteJSON(filepath.Join(dir, config.AnytypeDirName, config.ConfigFileName), config.Config{ApiListenAddr: "127.0.0.1:4500"}); err != nil { + if err := localstate.WriteJSON(filepath.Join(dir, config.AnytypeDirName, config.ConfigFileName), config.Config{Settings: config.Settings{ApiListenAddr: "127.0.0.1:4500"}}); err != nil { t.Fatal(err) } definition := filepath.Join(dir, "Library/LaunchAgents/anytype.plist") diff --git a/core/automation/types.go b/core/automation/types.go index d8bdd69..7571492 100644 --- a/core/automation/types.go +++ b/core/automation/types.go @@ -178,6 +178,17 @@ func capabilities() map[string]any { } } +// transportJSON reports the transport preference that an account start from +// this process would send. Heart doesn't expose the one in use. +func transportJSON(s config.Settings) map[string]any { + preferTcp, source, err := config.ResolvePreferTcp(s) + out := map[string]any{"prefer_tcp": preferTcp, "source": string(source), "observation": "configured"} + if err != nil { + out["next_action"] = "Set " + config.PreferTcpEnv + " to true or false, or unset it." + } + return out +} + func apiOrigin(addr string) string { host, port, err := net.SplitHostPort(addr) if err != nil { diff --git a/core/config/config.go b/core/config/config.go index bf23fc5..797aac7 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -17,9 +17,17 @@ type Config struct { // WARNING: This is insecure and should only be used on headless servers AccountKey string `json:"accountKey,omitempty"` SessionToken string `json:"sessionToken,omitempty"` + // Settings are embedded so their keys stay at the top level of the file. + Settings +} + +// Settings are the user's choices about how this instance runs. Unlike the +// account fields in Config, they survive logout. +type Settings struct { // ApiListenAddr is the JSON API address last chosen with --listen-address. - // It is a server setting, so it survives logout. ApiListenAddr string `json:"apiListenAddr,omitempty"` + // PreferTcp makes the account dial peers over TCP before QUIC. + PreferTcp bool `json:"preferTcp,omitempty"` } var ( @@ -55,14 +63,19 @@ func (cm *ConfigManager) Load() error { if err != nil { if os.IsNotExist(err) { // Config doesn't exist yet, that's okay + cm.config = &Config{} return nil } return fmt.Errorf("failed to read config file: %w", err) } - if err := json.Unmarshal(data, cm.config); err != nil { + // Decode into a fresh value: keys another process removed must not + // survive from an earlier load. + loaded := &Config{} + if err := json.Unmarshal(data, loaded); err != nil { return fmt.Errorf("failed to parse config file: %w", err) } + cm.config = loaded return nil } @@ -163,14 +176,29 @@ func (cm *ConfigManager) SetApiListenAddr(addr string) error { return cm.Save() } +// UpdateSettings applies fn to a copy of the settings and saves the result. +// Nothing changes when fn returns an error. +func (cm *ConfigManager) UpdateSettings(fn func(*Settings) error) error { + cm.mu.Lock() + settings := cm.config.Settings + if err := fn(&settings); err != nil { + cm.mu.Unlock() + return err + } + cm.config.Settings = settings + cm.mu.Unlock() + + return cm.Save() +} + // ClearAccount removes everything tied to the logged-in account and keeps -// server settings. The file is deleted when no settings remain. +// settings. The file is deleted when no settings remain. func (cm *ConfigManager) ClearAccount() error { cm.mu.Lock() - keep := &Config{ApiListenAddr: cm.config.ApiListenAddr} + keep := &Config{Settings: cm.config.Settings} cm.mu.Unlock() - if keep.ApiListenAddr == "" { + if keep.Settings == (Settings{}) { return cm.Delete() } @@ -180,14 +208,6 @@ func (cm *ConfigManager) ClearAccount() error { return cm.Save() } -func (cm *ConfigManager) Reset() error { - cm.mu.Lock() - cm.config = &Config{} - cm.mu.Unlock() - - return cm.Save() -} - func (cm *ConfigManager) Delete() error { cm.mu.Lock() defer cm.mu.Unlock() diff --git a/core/config/config_helper.go b/core/config/config_helper.go index 6c3758a..0e5394e 100644 --- a/core/config/config_helper.go +++ b/core/config/config_helper.go @@ -180,3 +180,12 @@ func SetApiListenAddrToConfig(addr string) error { return configMgr.SetApiListenAddr(addr) } + +func UpdateSettingsInConfig(fn func(*Settings) error) error { + configMgr := GetConfigManager() + if err := configMgr.Load(); err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + return configMgr.UpdateSettings(fn) +} diff --git a/core/config/config_test.go b/core/config/config_test.go index 3223091..3f6d536 100644 --- a/core/config/config_test.go +++ b/core/config/config_test.go @@ -1,8 +1,11 @@ package config import ( + "encoding/json" + "errors" "os" "path/filepath" + "reflect" "testing" ) @@ -116,13 +119,14 @@ func TestApiListenAddrPersists(t *testing.T) { } } -func TestClearAccountKeepsApiListenAddr(t *testing.T) { +func TestClearAccountKeepsSettings(t *testing.T) { + settings := Settings{ApiListenAddr: "0.0.0.0:4000", PreferTcp: true} cm := newTestConfigManager(t, &Config{ - AccountId: "acc", - TechSpaceId: "tech", - AccountKey: "key", - SessionToken: "token", - ApiListenAddr: "0.0.0.0:4000", + AccountId: "acc", + TechSpaceId: "tech", + AccountKey: "key", + SessionToken: "token", + Settings: settings, }) if err := cm.Save(); err != nil { t.Fatalf("Save: %v", err) @@ -136,12 +140,90 @@ func TestClearAccountKeepsApiListenAddr(t *testing.T) { if err := cm2.Load(); err != nil { t.Fatalf("Load: %v", err) } - want := Config{ApiListenAddr: "0.0.0.0:4000"} + want := Config{Settings: settings} if got := *cm2.Get(); got != want { t.Errorf("config after ClearAccount = %+v, want %+v", got, want) } } +func TestSettingsStayAtTopLevelOfFile(t *testing.T) { + cm := newTestConfigManager(t, &Config{ + AccountId: "acc", + Settings: Settings{ApiListenAddr: "0.0.0.0:4000", PreferTcp: true}, + }) + if err := cm.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + data, err := os.ReadFile(cm.filePath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + want := map[string]any{"accountId": "acc", "apiListenAddr": "0.0.0.0:4000", "preferTcp": true} + if !reflect.DeepEqual(raw, want) { + t.Errorf("config file = %v, want %v", raw, want) + } +} + +func TestUpdateSettings(t *testing.T) { + cm := newTestConfigManager(t, &Config{AccountId: "acc"}) + + if err := cm.UpdateSettings(func(s *Settings) error { + s.PreferTcp = true + return nil + }); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + cm2 := &ConfigManager{config: &Config{}, filePath: cm.filePath} + if err := cm2.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if got := *cm2.Get(); got != (Config{AccountId: "acc", Settings: Settings{PreferTcp: true}}) { + t.Errorf("config after UpdateSettings = %+v", got) + } + + failed := errors.New("rejected") + err := cm.UpdateSettings(func(s *Settings) error { + s.PreferTcp = false + return failed + }) + if !errors.Is(err, failed) { + t.Fatalf("UpdateSettings error = %v, want %v", err, failed) + } + if !cm.Get().PreferTcp { + t.Error("UpdateSettings changed the settings although fn failed") + } +} + +func TestLoadDropsKeysRemovedFromFile(t *testing.T) { + cm := newTestConfigManager(t, &Config{AccountId: "acc", Settings: Settings{PreferTcp: true}}) + if err := os.WriteFile(cm.filePath, []byte(`{"accountId":"acc"}`), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := cm.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if cm.Get().PreferTcp { + t.Error("PreferTcp survived Load although the file no longer has it") + } + + if err := os.Remove(cm.filePath); err != nil { + t.Fatalf("Remove: %v", err) + } + if err := cm.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if got := *cm.Get(); got != (Config{}) { + t.Errorf("config after loading a missing file = %+v, want empty", got) + } +} + func TestClearAccountRemovesFileWithoutSettings(t *testing.T) { cm := newTestConfigManager(t, &Config{AccountId: "acc", SessionToken: "token"}) if err := cm.Save(); err != nil { diff --git a/core/config/settings.go b/core/config/settings.go new file mode 100644 index 0000000..24d2883 --- /dev/null +++ b/core/config/settings.go @@ -0,0 +1,151 @@ +package config + +import ( + "errors" + "fmt" + "os" + "slices" + "strconv" + "strings" +) + +// PreferTcpEnv overrides the saved prefer-tcp setting in the process that +// starts the account, such as a Docker container or a foreground serve. +const PreferTcpEnv = "ANYTYPE_PREFER_TCP" + +// Source says where a setting's effective value comes from. +type Source string + +const ( + SourceDefault Source = "default" + SourceConfig Source = "config" + SourceEnv Source = "env" +) + +// ResolvePreferTcp decides whether the account dials peers over TCP before +// QUIC: a non-empty ANYTYPE_PREFER_TCP wins, then the saved setting, then the +// default (QUIC first). An invalid environment value is skipped and returned +// as the error, so callers can warn without failing a login. +func ResolvePreferTcp(s Settings) (bool, Source, error) { + var envErr error + if env := os.Getenv(PreferTcpEnv); env != "" { + value, err := strconv.ParseBool(env) + if err == nil { + return value, SourceEnv, nil + } + envErr = fmt.Errorf("ignoring %s=%q: use true or false", PreferTcpEnv, env) + } + if s.PreferTcp { + return true, SourceConfig, envErr + } + return false, SourceDefault, envErr +} + +// PreferTcp resolves the transport preference against the saved config. When +// the config can't be read, it resolves as if nothing were saved. +func PreferTcp() (bool, Source, error) { + cfg, err := LoadStoredConfig() + if err != nil { + value, source, envErr := ResolvePreferTcp(Settings{}) + return value, source, errors.Join(err, envErr) + } + return ResolvePreferTcp(cfg.Settings) +} + +// Setting is a key that `anytype config` shows. +type Setting struct { + Key string + // Env names the environment variable that overrides the saved value. + Env string + // RestartToApply means the running account keeps the old value until it + // starts again. + RestartToApply bool + + // readOnly, when set, is the error for `config set` and says how to + // change the key instead. + readOnly string + resolve func(Settings) (string, Source, error) + // set and unset return the value they saved. + set func(*Settings, string) (string, error) + unset func(*Settings) string +} + +var settingKeys = []Setting{ + { + Key: "listen-address", + readOnly: "set it with --listen-address on 'anytype service install', 'serve', 'auth login' or 'auth create' instead, so the service and the account agree on it", + resolve: func(s Settings) (string, Source, error) { + if s.ApiListenAddr == "" { + return DefaultAPIAddress, SourceDefault, nil + } + return s.ApiListenAddr, SourceConfig, nil + }, + }, + { + Key: "prefer-tcp", + Env: PreferTcpEnv, + RestartToApply: true, + resolve: func(s Settings) (string, Source, error) { + value, source, err := ResolvePreferTcp(s) + return strconv.FormatBool(value), source, err + }, + set: func(s *Settings, value string) (string, error) { + v, err := strconv.ParseBool(value) + if err != nil { + return "", fmt.Errorf("invalid value %q: use true or false", value) + } + s.PreferTcp = v + return strconv.FormatBool(v), nil + }, + unset: func(s *Settings) string { + s.PreferTcp = false + return "false" + }, + }, +} + +// SettingKeys lists the keys `anytype config` knows, in display order. +func SettingKeys() []Setting { + return slices.Clone(settingKeys) +} + +// LookupSetting finds a key by name. The error lists the known keys. +func LookupSetting(key string) (Setting, error) { + for _, s := range settingKeys { + if s.Key == key { + return s, nil + } + } + keys := make([]string, len(settingKeys)) + for i, s := range settingKeys { + keys[i] = s.Key + } + return Setting{}, fmt.Errorf("unknown config key %q (known keys: %s)", key, strings.Join(keys, ", ")) +} + +// Resolve returns the effective value and where it comes from. A non-nil +// error is a warning about an ignored override; the value is still usable. +func (s Setting) Resolve(stored Settings) (string, Source, error) { + return s.resolve(stored) +} + +// Settable reports whether `config set` and `config unset` may change the key. +func (s Setting) Settable() bool { + return s.set != nil +} + +// Set validates value, stores it in settings and returns it as saved. +func (s Setting) Set(settings *Settings, value string) (string, error) { + if !s.Settable() { + return "", errors.New(s.readOnly) + } + return s.set(settings, value) +} + +// Unset returns the key to its default and returns that default. +func (s Setting) Unset(settings *Settings) (string, error) { + if !s.Settable() { + return "", errors.New(s.readOnly) + } + return s.unset(settings), nil +} diff --git a/core/config/settings_test.go b/core/config/settings_test.go new file mode 100644 index 0000000..4875841 --- /dev/null +++ b/core/config/settings_test.go @@ -0,0 +1,133 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func TestResolvePreferTcp(t *testing.T) { + tests := []struct { + name string + env string + stored bool + want bool + wantSource Source + wantErr bool + }{ + {"nothing set", "", false, false, SourceDefault, false}, + {"saved", "", true, true, SourceConfig, false}, + {"env on", "true", false, true, SourceEnv, false}, + {"env 1", "1", false, true, SourceEnv, false}, + {"env off overrides saved", "false", true, false, SourceEnv, false}, + {"invalid env falls back to saved", "yes please", true, true, SourceConfig, true}, + {"invalid env falls back to default", "on", false, false, SourceDefault, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(PreferTcpEnv, tt.env) + + got, source, err := ResolvePreferTcp(Settings{PreferTcp: tt.stored}) + if got != tt.want || source != tt.wantSource || (err != nil) != tt.wantErr { + t.Errorf("ResolvePreferTcp() = (%v, %q, %v), want (%v, %q, error %v)", got, source, err, tt.want, tt.wantSource, tt.wantErr) + } + }) + } +} + +// useTestConfigFile points GetConfigManager at a temp file holding content. +func useTestConfigFile(t *testing.T, content string) { + t.Helper() + GetConfigManager() + orig := instance + cm := newTestConfigManager(t, &Config{}) + if err := os.WriteFile(cm.filePath, []byte(content), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + instance = cm + t.Cleanup(func() { instance = orig }) +} + +func TestPreferTcpReadsSavedConfig(t *testing.T) { + t.Setenv(PreferTcpEnv, "") + useTestConfigFile(t, `{"preferTcp":true}`) + + got, source, err := PreferTcp() + if err != nil || !got || source != SourceConfig { + t.Errorf("PreferTcp() = (%v, %q, %v), want (true, %q, nil)", got, source, err, SourceConfig) + } +} + +func TestSettingPreferTcp(t *testing.T) { + t.Setenv(PreferTcpEnv, "") + s, err := LookupSetting("prefer-tcp") + if err != nil { + t.Fatalf("LookupSetting: %v", err) + } + + var settings Settings + if saved, err := s.Set(&settings, "1"); err != nil || saved != "true" { + t.Fatalf("Set(1) = (%q, %v), want \"true\"", saved, err) + } + if !settings.PreferTcp { + t.Error("Set(1) didn't turn PreferTcp on") + } + if value, source, _ := s.Resolve(settings); value != "true" || source != SourceConfig { + t.Errorf("Resolve() = (%q, %q), want (\"true\", %q)", value, source, SourceConfig) + } + + if _, err := s.Set(&settings, "maybe"); err == nil { + t.Error("Set(maybe) succeeded, want an error") + } + if !settings.PreferTcp { + t.Error("an invalid Set changed the setting") + } + + if saved, err := s.Unset(&settings); err != nil || saved != "false" { + t.Fatalf("Unset() = (%q, %v), want \"false\"", saved, err) + } + if value, source, _ := s.Resolve(settings); value != "false" || source != SourceDefault { + t.Errorf("Resolve() after Unset = (%q, %q), want (\"false\", %q)", value, source, SourceDefault) + } +} + +func TestSettingListenAddressIsReadOnly(t *testing.T) { + s, err := LookupSetting("listen-address") + if err != nil { + t.Fatalf("LookupSetting: %v", err) + } + if s.Settable() { + t.Fatal("listen-address is settable, want read-only") + } + + settings := Settings{ApiListenAddr: "0.0.0.0:4000"} + if _, err := s.Set(&settings, "0.0.0.0:5000"); err == nil || !strings.Contains(err.Error(), "--listen-address") { + t.Errorf("Set error = %v, want a hint about --listen-address", err) + } + if _, err := s.Unset(&settings); err == nil { + t.Error("Unset succeeded, want an error") + } + if settings.ApiListenAddr != "0.0.0.0:4000" { + t.Errorf("ApiListenAddr = %q, want it unchanged", settings.ApiListenAddr) + } + + if value, source, _ := s.Resolve(settings); value != "0.0.0.0:4000" || source != SourceConfig { + t.Errorf("Resolve() = (%q, %q), want saved address from config", value, source) + } + if value, source, _ := s.Resolve(Settings{}); value != DefaultAPIAddress || source != SourceDefault { + t.Errorf("Resolve() with nothing saved = (%q, %q), want default address", value, source) + } +} + +func TestLookupSettingUnknownKey(t *testing.T) { + _, err := LookupSetting("accountId") + if err == nil { + t.Fatal("LookupSetting(accountId) succeeded, want an error") + } + for _, key := range []string{"listen-address", "prefer-tcp"} { + if !strings.Contains(err.Error(), key) { + t.Errorf("error %q doesn't list known key %q", err, key) + } + } +} diff --git a/core/localstate/state.go b/core/localstate/state.go index c282b34..a339ac5 100644 --- a/core/localstate/state.go +++ b/core/localstate/state.go @@ -128,7 +128,7 @@ func HumanMutation(args []string) bool { case "service": return words[1] != "status" case "config": - return words[1] != "get" + return words[1] != "get" && words[1] != "list" } return false } diff --git a/core/localstate/state_test.go b/core/localstate/state_test.go index dc4f6e9..6a1ca2f 100644 --- a/core/localstate/state_test.go +++ b/core/localstate/state_test.go @@ -35,3 +35,21 @@ func TestLockAndAtomicPrivateFiles(t *testing.T) { t.Fatal("private file roundtrip", v, err) } } + +func TestHumanMutationConfig(t *testing.T) { + tests := []struct { + args []string + want bool + }{ + {[]string{"config", "list"}, false}, + {[]string{"config", "get", "prefer-tcp"}, false}, + {[]string{"config", "set", "prefer-tcp", "true"}, true}, + {[]string{"config", "unset", "prefer-tcp"}, true}, + {[]string{"config", "set", "--help"}, false}, + } + for _, tt := range tests { + if got := HumanMutation(tt.args); got != tt.want { + t.Errorf("HumanMutation(%q) = %v, want %v", tt.args, got, tt.want) + } + } +} diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 1aee1c9..d4d1b14 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -83,6 +83,11 @@ access. `inspect`, `service status`, and `auth status` share an inspection resul - `account`: full `id`, observed `display_name`, `authenticated`, `observation` (`live` or `configured`), and credential-storage backend; - `network`: `id`, `config_path`, and `observation`; +- `transport`: `prefer_tcp`, its `source` (`env`, `config` or `default`), and + `observation:configured`. It is the value the next account start from the + calling process would send; `env` is the caller's `ANYTYPE_PREFER_TCP`. Heart + does not report the transport the running account uses. An invalid + `ANYTYPE_PREFER_TCP` adds `next_action`; - `api`: effective `origin`, `listen_address`, `bound`, `observation`, and `ready:false`/`readiness:requires_integration_key_probe`, plus `binding_observation` and an actionable `next_action` when unverified; @@ -217,6 +222,12 @@ Network file paths are normalized before comparing effective settings; an exact replay of a relative path remains valid after its absolute form was saved. The selected file's content hash is also bound to the operation. +Account creation and selection send the `prefer-tcp` setting (see +`anytype config`), overridden by `ANYTYPE_PREFER_TCP` in the calling process. +It is not bound to the operation, so a resumed operation sends the current +value. An invalid `ANYTYPE_PREFER_TCP` fails with `invalid_environment` before +any mutation. + Wallet recovery material is durably saved before account creation. Exact live identity plus observed requested name can reconcile a lost create response. After a confirmed account response, deterministic `AccountSelect` can recover a diff --git a/docs/plans/prefer-tcp-setting.md b/docs/plans/prefer-tcp-setting.md new file mode 100644 index 0000000..7b93e03 --- /dev/null +++ b/docs/plans/prefer-tcp-setting.md @@ -0,0 +1,203 @@ +# Spec: `prefer-tcp` setting and a user-facing `anytype config` + +## Background + +Heart dials sync nodes and peers over QUIC (UDP) first and falls back to +TCP (yamux) within the same dial (`any-sync` `peerservice.Dial` tries every +address in order). On networks that block or throttle UDP, every new +connection first waits for a QUIC attempt to fail. Heart also demotes single +peers to TCP-first when their QUIC connections keep dying +(`ANYTYPE_QUIC_AUTO_DEMOTION`, GO-7467), but that only kicks in after repeated +failures. + +`AccountCreate` and `AccountSelect` accept `preferYamuxTransport`. When set, +heart skips `PreferQuic(true)` and dials TCP first, keeping QUIC as the +fallback. Heart reads it once, when the account starts. + +The CLI never sets the field. The only way to use it today is the misspelled +heart variable `ANYTYPE_PEFERYAMUXTRANSPORT=true`, read by heart's +`envconfig`. It only reaches processes started from that shell, and nothing +in the CLI reports it. + +The CLI config (`~/.anytype/config.json`) mixes account state (account Id, +tech space Id, network Id), fallback credentials (account key, session token) +and settings (JSON API listen address). `ClearAccount` keeps settings by +copying `ApiListenAddr` by hand, so any new setting would be wiped on logout. + +A hidden `anytype config get|set|reset` exists. It only knows `accountId` and +`techSpaceId`, and `reset` deletes fallback credentials and the saved listen +address. + +## Goals + +1. Users can turn on TCP-first dialing with one documented command, and it + persists across restarts and logout. +2. Docker and one-off tests can turn it on with an environment variable. +3. `anytype auth status` and `anytype inspect --json` show the effective value + and where it comes from. +4. `anytype config` becomes a public command for user settings only. + +## Non-goals + +- Applying the change to a running account. It applies at the next account + start; the CLI tells the user how to restart. +- Reporting the transport the running account actually uses. Heart doesn't + expose it; the CLI reports the configured value. +- A `--prefer-tcp` flag on `serve`, `service install`, `auth login` or + `auth create`. +- A machine (`--json`) interface for `anytype config`. + +## Design + +### Name + +User-facing name is `prefer-tcp` (config key), `preferTcp` (JSON field) and +`ANYTYPE_PREFER_TCP` (environment). "TCP" is what users recognize; yamux is an +implementation detail. + +### Storage + +`config.Config` embeds a new `Settings` struct: + +```go +type Settings struct { + ApiListenAddr string `json:"apiListenAddr,omitempty"` + PreferTcp bool `json:"preferTcp,omitempty"` +} +``` + +Go flattens embedded structs in JSON, so the file format doesn't change and +existing files load unchanged. `ClearAccount` keeps the whole `Settings` +value and deletes the file only when `Settings` is empty, so future settings +survive logout without further changes. + +`false` and "not set" are the same; `false` is not written. + +### Resolution + +``` +ANYTYPE_PREFER_TCP (non-empty) > preferTcp in config.json > default (false) +``` + +- The environment value is parsed with `strconv.ParseBool`. An empty value + counts as unset. +- An invalid environment value is ignored with a warning and resolution + continues with the saved value. Login must not fail on it, because the + service's auto-login uses the same code. +- `config.ResolvePreferTcp(Settings)` returns `(value, source, err)` where + source is `env`, `config` or `default`. `config.PreferTcp()` loads the saved + config first. + +The environment variable affects only the process that sends the login +request: a foreground `serve`, a Docker container, or a login command run +from that shell. The background service doesn't inherit shell variables, so +the persistent choice is `config set`. + +### Where it's sent + +`PreferYamuxTransport` is set on every account start the CLI makes: + +- `core.Authenticate`: `AccountSelect` (used by `auth login` and the service's + auto-login on start). +- `core.CreateWallet`: `AccountCreate` and `AccountSelect` (`auth create`). +- `core/automation/account.go`: `AccountCreate` and `AccountSelect`. An + invalid environment value fails the operation with + `invalid_environment` before any mutation, since automation fails closed. + +The value is not part of the automation operation's settings digest: +changing it between retries doesn't affect account identity or bindings. + +Because the service's auto-login goes through `core.Authenticate`, +`anytype service restart` applies a changed setting. The service definition +doesn't change. + +### `anytype config` + +The command is unhidden and manages user settings only: + +``` +anytype config list # every key, its effective value and source +anytype config get # the effective value only, for scripts +anytype config set +anytype config unset # back to the default +``` + +Keys: + +| Key | Values | Env override | `set`/`unset` | +|---|---|---|---| +| `listen-address` | `host:port` | — | refused, with a hint to use `--listen-address` on `service install`, `serve`, `auth login` or `auth create` | +| `prefer-tcp` | `true`/`false` (anything `strconv.ParseBool` accepts) | `ANYTYPE_PREFER_TCP` | allowed | + +`listen-address` is read-only here because the installed service definition +can carry its own `--listen-address`; changing only the config would leave the +service and the account disagreeing on the JSON API address (and automation +reports `service_binding_conflict`). + +Output: + +``` +$ anytype config list +KEY VALUE SOURCE +listen-address 127.0.0.1:31012 default +prefer-tcp true config + +$ anytype config set prefer-tcp true +✓ prefer-tcp = true +Applies the next time the account starts. To apply it now, restart the server: + anytype service restart (or stop and start 'anytype serve') +``` + +When `ANYTYPE_PREFER_TCP` is set in the current environment, `set` and +`unset` warn that it overrides the saved value for commands run here. + +Removed: + +- `config get|set accountId|techSpaceId`: internal state, shown by + `auth status` and `inspect --json`. +- `config reset`: it deleted fallback credentials and the listen address. + `config unset ` replaces it. + +`config list` joins `config get` as a read-only command for the instance lock +(`localstate.HumanMutation`). + +### Status + +`anytype auth status` prints ` - Prefer TCP: ()` when the +server is running, and the warning when the environment value is invalid. + +`inspect --json` (and `auth status --json`, `service status --json`, which +share it) gains: + +```json +"transport": {"prefer_tcp": false, "source": "default", "observation": "configured"} +``` + +`source` reflects the calling process's environment. An invalid +environment value adds `next_action`. + +### Docs + +- README: a Configuration section (`config` commands, keys, env override) + and a troubleshooting line for stalled sync on networks that block UDP. +- SELF-HOSTED.md: a troubleshooting entry for nodes whose QUIC (UDP) ports + aren't reachable. +- AUTOMATION.md: document `transport` in the inspection result and + `invalid_environment`. +- CLAUDE.md: list the `config/` command group. + +## Tests + +- `config`: `Settings` JSON round-trip keeps the flat format; + `ClearAccount` keeps every setting and deletes the file when none remain; + `UpdateSettings` persists. +- `ResolvePreferTcp`: table over env unset/empty/true/false/invalid × + stored true/false. +- Setting registry: `set`/`unset` for `prefer-tcp`, invalid value, read-only + `listen-address`, unknown key. +- `cmd/config`: `list`, `get`, `set`, `unset` against a temp `HOME`. +- `localstate.HumanMutation`: `config list` and `config get` don't lock; + `config set` and `config unset` do. +- Automation: `AccountSelect`/`AccountCreate` requests carry + `PreferYamuxTransport` from config and env; invalid env fails with + `invalid_environment`; `inspect` reports `transport`.