From b2246762f9949084927f92eccf301d944928e0c1 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:17:32 +0100 Subject: [PATCH 1/6] fix(cli): remove shell completion snippets during uninstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The uninstall command removes containers, data, and the binary, but leaves orphaned completion snippets in shell profiles (.bashrc, .zshrc, PS profile). These error on every terminal open after the binary is gone. Add completion.Uninstall() that reverses Install() — removes marker blocks from profiles and deletes generated completion files (zsh, fish). Call it from runUninstall before binary removal. --- cli/cmd/uninstall.go | 10 +++ cli/internal/completion/install.go | 98 ++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/cli/cmd/uninstall.go b/cli/cmd/uninstall.go index 73ba146848..96e60f8f97 100644 --- a/cli/cmd/uninstall.go +++ b/cli/cmd/uninstall.go @@ -9,6 +9,7 @@ import ( "runtime" "strings" + "github.com/Aureliolo/synthorg/cli/internal/completion" "github.com/Aureliolo/synthorg/cli/internal/config" "github.com/Aureliolo/synthorg/cli/internal/docker" "github.com/charmbracelet/huh" @@ -54,6 +55,15 @@ func runUninstall(cmd *cobra.Command, _ []string) error { } } + // Remove shell completion snippets. + shell := completion.DetectShell() + if shell != completion.Unknown { + _, _ = fmt.Fprintln(out, "Removing shell completions...") + if err := completion.Uninstall(ctx, shell); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not remove shell completions: %v\n", err) + } + } + // Remove data directory. if err := confirmAndRemoveData(cmd, safeDir); err != nil { return err diff --git a/cli/internal/completion/install.go b/cli/internal/completion/install.go index 876d293d05..78d9e899a2 100644 --- a/cli/internal/completion/install.go +++ b/cli/internal/completion/install.go @@ -273,6 +273,104 @@ func fileContains(path, sub string) (bool, error) { return strings.Contains(string(data), sub), nil } +// Uninstall removes shell completion snippets and generated files. +func Uninstall(ctx context.Context, shell ShellType) error { + switch shell { + case Bash: + return uninstallBash() + case Zsh: + return uninstallZsh() + case Fish: + return uninstallFish() + case PowerShell: + return uninstallPowerShell(ctx) + default: + return fmt.Errorf("unsupported shell: %s", shell) + } +} + +func uninstallBash() error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("cannot determine home directory: %w", err) + } + return removeMarkerBlock(filepath.Join(home, ".bashrc")) +} + +func uninstallZsh() error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("cannot determine home directory: %w", err) + } + // Remove completion function file. + compFile := filepath.Join(home, ".zsh", "completion", "_synthorg") + if err := os.Remove(compFile); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing completion file: %w", err) + } + // Remove fpath snippet from .zshrc. + return removeMarkerBlock(filepath.Join(home, ".zshrc")) +} + +func uninstallFish() error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("cannot determine home directory: %w", err) + } + compFile := filepath.Join(home, ".config", "fish", "completions", "synthorg.fish") + if err := os.Remove(compFile); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing completion file: %w", err) + } + return nil +} + +func uninstallPowerShell(ctx context.Context) error { + profile, err := powershellProfilePath(ctx) + if err != nil { + return err + } + return removeMarkerBlock(profile) +} + +// removeMarkerBlock removes lines between (and including) the marker +// and the next non-empty line from a shell profile file. +// If the file does not exist or has no marker, this is a no-op. +func removeMarkerBlock(path string) error { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("reading %s: %w", path, err) + } + content := string(data) + if !strings.Contains(content, marker) { + return nil + } + + var result []string + lines := strings.Split(content, "\n") + inBlock := false + for _, line := range lines { + if strings.TrimSpace(line) == marker { + inBlock = true + continue + } + if inBlock { + // Skip non-empty lines that are part of the snippet. + if strings.TrimSpace(line) != "" { + continue + } + // Empty line ends the block. + inBlock = false + continue + } + result = append(result, line) + } + + cleaned := strings.Join(result, "\n") + return os.WriteFile(path, []byte(cleaned), 0o644) +} + // appendToFile appends content to a file, creating it if needed. func appendToFile(path, content string) error { dir := filepath.Dir(path) From 8d58a76dbbe8957409f44bf5d74db577b7b4b33f Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:30:06 +0100 Subject: [PATCH 2/6] feat(cli): add persistence and memory backend selection to init wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add persistence backend (SQLite) and memory backend (Mem0) selection prompts to `synthorg init`. Choices are persisted in config.json and passed as SYNTHORG_PERSISTENCE_BACKEND and SYNTHORG_MEMORY_BACKEND env vars in the generated compose.yml. Currently only one option per backend (SQLite, Mem0) — the UI is ready for future additions (PostgreSQL, alternative memory backends). --- cli/cmd/init.go | 62 +++++++++++++-------- cli/internal/compose/compose.yml.tmpl | 2 + cli/internal/compose/generate.go | 36 +++++++------ cli/internal/compose/generate_test.go | 78 ++++++++++++++++----------- cli/internal/config/state.go | 32 ++++++----- cli/testdata/compose_custom_ports.yml | 2 + cli/testdata/compose_default.yml | 2 + 7 files changed, 131 insertions(+), 83 deletions(-) diff --git a/cli/cmd/init.go b/cli/cmd/init.go index 0738f4a706..1453d016fd 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -81,25 +81,29 @@ func runInit(cmd *cobra.Command, _ []string) error { // setupAnswers holds raw form input before validation. type setupAnswers struct { - dir string - backendPortStr string - webPortStr string - sandbox bool - dockerSock string - logLevel string - genJWT bool + dir string + backendPortStr string + webPortStr string + sandbox bool + dockerSock string + logLevel string + genJWT bool + persistenceBackend string + memoryBackend string } func runSetupForm() (setupAnswers, error) { defaults := config.DefaultState() a := setupAnswers{ - dir: defaults.DataDir, - backendPortStr: fmt.Sprintf("%d", defaults.BackendPort), - webPortStr: fmt.Sprintf("%d", defaults.WebPort), - sandbox: defaults.Sandbox, - dockerSock: defaultDockerSock(), - logLevel: defaults.LogLevel, - genJWT: true, + dir: defaults.DataDir, + backendPortStr: fmt.Sprintf("%d", defaults.BackendPort), + webPortStr: fmt.Sprintf("%d", defaults.WebPort), + sandbox: defaults.Sandbox, + dockerSock: defaultDockerSock(), + logLevel: defaults.LogLevel, + genJWT: true, + persistenceBackend: defaults.PersistenceBackend, + memoryBackend: defaults.MemoryBackend, } form := huh.NewForm( @@ -126,6 +130,18 @@ func runSetupForm() (setupAnswers, error) { huh.NewConfirm().Title("Generate JWT secret?"). Description("Recommended for API authentication").Value(&a.genJWT), ), + huh.NewGroup( + huh.NewSelect[string]().Title("Persistence backend"). + Description("Database for operational data (tasks, audit, auth)"). + Options( + huh.NewOption("SQLite (recommended)", "sqlite"), + ).Value(&a.persistenceBackend), + huh.NewSelect[string]().Title("Memory backend"). + Description("Agent memory storage (conversations, knowledge)"). + Options( + huh.NewOption("Mem0 (recommended)", "mem0"), + ).Value(&a.memoryBackend), + ), ) if err := form.Run(); err != nil { @@ -173,14 +189,16 @@ func buildState(a setupAnswers) (config.State, error) { } return config.State{ - DataDir: dir, - ImageTag: imageTag, - BackendPort: backendPort, - WebPort: webPort, - Sandbox: a.sandbox, - DockerSock: dockerSock, - LogLevel: a.logLevel, - JWTSecret: jwtSecret, + DataDir: dir, + ImageTag: imageTag, + BackendPort: backendPort, + WebPort: webPort, + Sandbox: a.sandbox, + DockerSock: dockerSock, + LogLevel: a.logLevel, + JWTSecret: jwtSecret, + PersistenceBackend: a.persistenceBackend, + MemoryBackend: a.memoryBackend, }, nil } diff --git a/cli/internal/compose/compose.yml.tmpl b/cli/internal/compose/compose.yml.tmpl index b2a2b9bb26..297e8722b6 100644 --- a/cli/internal/compose/compose.yml.tmpl +++ b/cli/internal/compose/compose.yml.tmpl @@ -13,6 +13,8 @@ services: SYNTHORG_PORT: "8000" SYNTHORG_DB_PATH: "/data/synthorg.db" SYNTHORG_MEMORY_DIR: "/data/memory" + SYNTHORG_PERSISTENCE_BACKEND: {{yamlStr .PersistenceBackend}} + SYNTHORG_MEMORY_BACKEND: {{yamlStr .MemoryBackend}} SYNTHORG_LOG_LEVEL: {{yamlStr .LogLevel}} {{- if .JWTSecret}} SYNTHORG_JWT_SECRET: {{yamlStr .JWTSecret}} diff --git a/cli/internal/compose/generate.go b/cli/internal/compose/generate.go index 914587fef2..f5e98d013b 100644 --- a/cli/internal/compose/generate.go +++ b/cli/internal/compose/generate.go @@ -29,27 +29,31 @@ var allowedLogLevels = map[string]bool{ // Params are the template parameters for compose generation. type Params struct { - CLIVersion string - ImageTag string - BackendPort int - WebPort int - LogLevel string - JWTSecret string - Sandbox bool - DockerSock string + CLIVersion string + ImageTag string + BackendPort int + WebPort int + LogLevel string + JWTSecret string + Sandbox bool + DockerSock string + PersistenceBackend string + MemoryBackend string } // ParamsFromState creates Params from a persisted State. func ParamsFromState(s config.State) Params { return Params{ - CLIVersion: version.Version, - ImageTag: s.ImageTag, - BackendPort: s.BackendPort, - WebPort: s.WebPort, - LogLevel: s.LogLevel, - JWTSecret: s.JWTSecret, - Sandbox: s.Sandbox, - DockerSock: s.DockerSock, + CLIVersion: version.Version, + ImageTag: s.ImageTag, + BackendPort: s.BackendPort, + WebPort: s.WebPort, + LogLevel: s.LogLevel, + JWTSecret: s.JWTSecret, + Sandbox: s.Sandbox, + DockerSock: s.DockerSock, + PersistenceBackend: s.PersistenceBackend, + MemoryBackend: s.MemoryBackend, } } diff --git a/cli/internal/compose/generate_test.go b/cli/internal/compose/generate_test.go index acea3a7298..b17039f42f 100644 --- a/cli/internal/compose/generate_test.go +++ b/cli/internal/compose/generate_test.go @@ -11,11 +11,13 @@ import ( func TestGenerateDefault(t *testing.T) { p := Params{ - CLIVersion: "dev", - ImageTag: "latest", - BackendPort: 8000, - WebPort: 3000, - LogLevel: "info", + CLIVersion: "dev", + ImageTag: "latest", + BackendPort: 8000, + WebPort: 3000, + LogLevel: "info", + PersistenceBackend: "sqlite", + MemoryBackend: "mem0", } out, err := Generate(p) if err != nil { @@ -50,12 +52,14 @@ func TestGenerateDefault(t *testing.T) { func TestGenerateCustomPorts(t *testing.T) { p := Params{ - CLIVersion: "dev", - ImageTag: "v0.2.0", - BackendPort: 9000, - WebPort: 4000, - LogLevel: "debug", - JWTSecret: "test-secret-value", + CLIVersion: "dev", + ImageTag: "v0.2.0", + BackendPort: 9000, + WebPort: 4000, + LogLevel: "debug", + JWTSecret: "test-secret-value", + PersistenceBackend: "sqlite", + MemoryBackend: "mem0", } out, err := Generate(p) if err != nil { @@ -74,13 +78,15 @@ func TestGenerateCustomPorts(t *testing.T) { func TestGenerateWithSandbox(t *testing.T) { p := Params{ - CLIVersion: "dev", - ImageTag: "latest", - BackendPort: 8000, - WebPort: 3000, - LogLevel: "info", - Sandbox: true, - DockerSock: "/var/run/docker.sock", + CLIVersion: "dev", + ImageTag: "latest", + BackendPort: 8000, + WebPort: 3000, + LogLevel: "info", + Sandbox: true, + DockerSock: "/var/run/docker.sock", + PersistenceBackend: "sqlite", + MemoryBackend: "mem0", } out, err := Generate(p) if err != nil { @@ -95,11 +101,13 @@ func TestGenerateWithSandbox(t *testing.T) { func TestGenerateHardeningPresent(t *testing.T) { p := Params{ - CLIVersion: "dev", - ImageTag: "latest", - BackendPort: 8000, - WebPort: 3000, - LogLevel: "info", + CLIVersion: "dev", + ImageTag: "latest", + BackendPort: 8000, + WebPort: 3000, + LogLevel: "info", + PersistenceBackend: "sqlite", + MemoryBackend: "mem0", } out, err := Generate(p) if err != nil { @@ -123,14 +131,16 @@ func TestGenerateHardeningPresent(t *testing.T) { func TestParamsFromState(t *testing.T) { s := config.State{ - DataDir: "/tmp/test", - ImageTag: "v1.0.0", - BackendPort: 9000, - WebPort: 4000, - LogLevel: "debug", - JWTSecret: "secret", - Sandbox: true, - DockerSock: "/var/run/docker.sock", + DataDir: "/tmp/test", + ImageTag: "v1.0.0", + BackendPort: 9000, + WebPort: 4000, + LogLevel: "debug", + JWTSecret: "secret", + Sandbox: true, + DockerSock: "/var/run/docker.sock", + PersistenceBackend: "sqlite", + MemoryBackend: "mem0", } p := ParamsFromState(s) @@ -149,6 +159,12 @@ func TestParamsFromState(t *testing.T) { if p.DockerSock != "/var/run/docker.sock" { t.Errorf("DockerSock = %q", p.DockerSock) } + if p.PersistenceBackend != "sqlite" { + t.Errorf("PersistenceBackend = %q, want sqlite", p.PersistenceBackend) + } + if p.MemoryBackend != "mem0" { + t.Errorf("MemoryBackend = %q, want mem0", p.MemoryBackend) + } } func assertContains(t *testing.T, s, substr string) { diff --git a/cli/internal/config/state.go b/cli/internal/config/state.go index 5622603b3a..1ad8d844cc 100644 --- a/cli/internal/config/state.go +++ b/cli/internal/config/state.go @@ -12,14 +12,16 @@ const stateFileName = "config.json" // State is the persisted CLI configuration written by `synthorg init`. type State struct { - DataDir string `json:"data_dir"` - ImageTag string `json:"image_tag"` - BackendPort int `json:"backend_port"` - WebPort int `json:"web_port"` - Sandbox bool `json:"sandbox"` - DockerSock string `json:"docker_sock,omitempty"` - LogLevel string `json:"log_level"` - JWTSecret string `json:"jwt_secret,omitempty"` + DataDir string `json:"data_dir"` + ImageTag string `json:"image_tag"` + BackendPort int `json:"backend_port"` + WebPort int `json:"web_port"` + Sandbox bool `json:"sandbox"` + DockerSock string `json:"docker_sock,omitempty"` + LogLevel string `json:"log_level"` + JWTSecret string `json:"jwt_secret,omitempty"` + PersistenceBackend string `json:"persistence_backend"` + MemoryBackend string `json:"memory_backend"` } // DefaultState returns a State with sensible defaults for the interactive init @@ -27,12 +29,14 @@ type State struct { // when no config file exists. func DefaultState() State { return State{ - DataDir: DataDir(), - ImageTag: "latest", - BackendPort: 8000, - WebPort: 3000, - Sandbox: true, - LogLevel: "info", + DataDir: DataDir(), + ImageTag: "latest", + BackendPort: 8000, + WebPort: 3000, + Sandbox: true, + LogLevel: "info", + PersistenceBackend: "sqlite", + MemoryBackend: "mem0", } } diff --git a/cli/testdata/compose_custom_ports.yml b/cli/testdata/compose_custom_ports.yml index 7be1dc49cb..c35a04eb4a 100644 --- a/cli/testdata/compose_custom_ports.yml +++ b/cli/testdata/compose_custom_ports.yml @@ -13,6 +13,8 @@ services: SYNTHORG_PORT: "8000" SYNTHORG_DB_PATH: "/data/synthorg.db" SYNTHORG_MEMORY_DIR: "/data/memory" + SYNTHORG_PERSISTENCE_BACKEND: "sqlite" + SYNTHORG_MEMORY_BACKEND: "mem0" SYNTHORG_LOG_LEVEL: "debug" SYNTHORG_JWT_SECRET: "test-secret-value" user: "65532:65532" diff --git a/cli/testdata/compose_default.yml b/cli/testdata/compose_default.yml index 76a5b01660..2cd2844951 100644 --- a/cli/testdata/compose_default.yml +++ b/cli/testdata/compose_default.yml @@ -13,6 +13,8 @@ services: SYNTHORG_PORT: "8000" SYNTHORG_DB_PATH: "/data/synthorg.db" SYNTHORG_MEMORY_DIR: "/data/memory" + SYNTHORG_PERSISTENCE_BACKEND: "sqlite" + SYNTHORG_MEMORY_BACKEND: "mem0" SYNTHORG_LOG_LEVEL: "info" user: "65532:65532" # CIS Docker Benchmark v1.6.0 hardening (5.3, 5.12, 5.25, 5.28) From 75123cb264f2f06a09ef5edf9782f2385fc2dc63 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:45:05 +0100 Subject: [PATCH 3/6] fix(cli): address 14 review findings from 4 agents Pre-reviewed by 4 agents (go-reviewer, security-reviewer, conventions-enforcer, docs-consistency), 14 findings addressed: - Preserve original file permissions in removeMarkerBlock (was hardcoding 0o644, could weaken user's profile permissions) - Only remove first marker block to prevent greedy deletion - Add allowlist validation for PersistenceBackend/MemoryBackend in validateParams and State.validate() - Replace single-option Select with Note widget (no false choice) - Move completion removal after data dir confirmation - Extract rejectUnsafeDir and removeDataDir from confirmAndRemoveData (was 67 lines, now under 50) - Add removeMarkerBlock tests (7 cases + Unix permissions) - Add install/uninstall round-trip tests (Bash, Zsh, Fish) - Add new fields to state round-trip and default tests - Update docker/.env.example and docs/user_guide.md with new env vars - Fix removeMarkerBlock doc comment for multi-line blocks --- cli/cmd/init.go | 12 +- cli/cmd/uninstall.go | 109 +++++++------ cli/coverage.out | 135 ++++++++++++++++ cli/internal/completion/install.go | 22 ++- cli/internal/completion/install_test.go | 204 ++++++++++++++++++++++++ cli/internal/compose/generate.go | 12 ++ cli/internal/config/state.go | 12 ++ cli/internal/config/state_test.go | 26 ++- docker/.env.example | 6 + docs/user_guide.md | 2 + 10 files changed, 467 insertions(+), 73 deletions(-) create mode 100644 cli/coverage.out diff --git a/cli/cmd/init.go b/cli/cmd/init.go index 1453d016fd..fe6e6dce14 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -131,16 +131,8 @@ func runSetupForm() (setupAnswers, error) { Description("Recommended for API authentication").Value(&a.genJWT), ), huh.NewGroup( - huh.NewSelect[string]().Title("Persistence backend"). - Description("Database for operational data (tasks, audit, auth)"). - Options( - huh.NewOption("SQLite (recommended)", "sqlite"), - ).Value(&a.persistenceBackend), - huh.NewSelect[string]().Title("Memory backend"). - Description("Agent memory storage (conversations, knowledge)"). - Options( - huh.NewOption("Mem0 (recommended)", "mem0"), - ).Value(&a.memoryBackend), + huh.NewNote().Title("Backends"). + Description("Persistence: SQLite · Memory: Mem0\n(More options coming soon)"), ), ) diff --git a/cli/cmd/uninstall.go b/cli/cmd/uninstall.go index 96e60f8f97..9212090cc3 100644 --- a/cli/cmd/uninstall.go +++ b/cli/cmd/uninstall.go @@ -55,7 +55,13 @@ func runUninstall(cmd *cobra.Command, _ []string) error { } } - // Remove shell completion snippets. + // Remove data directory. + if err := confirmAndRemoveData(cmd, safeDir); err != nil { + return err + } + + // Remove shell completion snippets (after data dir confirmation + // so aborting early doesn't leave completions removed). shell := completion.DetectShell() if shell != completion.Unknown { _, _ = fmt.Fprintln(out, "Removing shell completions...") @@ -64,11 +70,6 @@ func runUninstall(cmd *cobra.Command, _ []string) error { } } - // Remove data directory. - if err := confirmAndRemoveData(cmd, safeDir); err != nil { - return err - } - // Optionally remove CLI binary. if err := confirmAndRemoveBinary(cmd); err != nil { return err @@ -123,57 +124,63 @@ func confirmAndRemoveData(cmd *cobra.Command, dataDir string) error { if err := form.Run(); err != nil { return err } + if !removeData { + return nil + } + dir := filepath.Clean(dataDir) + if err := rejectUnsafeDir(dir); err != nil { + return err + } + return removeDataDir(cmd, dir) +} - if removeData { - dir := filepath.Clean(dataDir) - // Safety: refuse to remove root, home, UNC share roots, or drive roots. - home, homeErr := os.UserHomeDir() - if homeErr != nil { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: cannot determine home directory: %v\n", homeErr) - } - isHomeDir := false - if homeErr == nil { - home = filepath.Clean(home) - if runtime.GOOS == "windows" { - isHomeDir = strings.EqualFold(dir, home) - } else { - isHomeDir = dir == home - } - } - vol := filepath.VolumeName(dir) - // Only reject UNC share roots (e.g. \\server\share), not arbitrary - // paths under a UNC share (e.g. \\server\share\synthorg\data). - isUNCRoot := vol != "" && - (strings.HasPrefix(vol, `\\`) || strings.HasPrefix(vol, "//")) && - (dir == vol || dir == vol+`\` || dir == vol+"/") - isDriveRoot := len(dir) == 3 && dir[1] == ':' && (dir[2] == '\\' || dir[2] == '/') - if dir == "/" || isHomeDir || isDriveRoot || isUNCRoot { - return fmt.Errorf("refusing to remove %q — does not look like an app data directory", dir) +// rejectUnsafeDir refuses to remove root, home, UNC share roots, or drive roots. +func rejectUnsafeDir(dir string) error { + home, homeErr := os.UserHomeDir() + isHomeDir := false + if homeErr == nil { + home = filepath.Clean(home) + if runtime.GOOS == "windows" { + isHomeDir = strings.EqualFold(dir, home) + } else { + isHomeDir = dir == home } + } + vol := filepath.VolumeName(dir) + // Only reject UNC share roots (e.g. \\server\share), not arbitrary + // paths under a UNC share (e.g. \\server\share\synthorg\data). + isUNCRoot := vol != "" && + (strings.HasPrefix(vol, `\\`) || strings.HasPrefix(vol, "//")) && + (dir == vol || dir == vol+`\` || dir == vol+"/") + isDriveRoot := len(dir) == 3 && dir[1] == ':' && (dir[2] == '\\' || dir[2] == '/') + if dir == "/" || isHomeDir || isDriveRoot || isUNCRoot { + return fmt.Errorf("refusing to remove %q — does not look like an app data directory", dir) + } + return nil +} - // On Windows the running binary cannot be deleted. If it lives - // inside the config directory, remove everything else and leave - // the binary for deferred cleanup in confirmAndRemoveBinary. - execPath, execErr := os.Executable() - if execErr != nil { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: cannot resolve executable path: %v\n", execErr) +// removeDataDir removes the data directory. On Windows, if the running +// binary lives inside the directory, it removes everything except the binary. +func removeDataDir(cmd *cobra.Command, dir string) error { + execPath, execErr := os.Executable() + if execErr != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: cannot resolve executable path: %v\n", execErr) + } + if execErr == nil { + if resolved, err := filepath.EvalSymlinks(execPath); err == nil { + execPath = resolved } - if execErr == nil { - if resolved, err := filepath.EvalSymlinks(execPath); err == nil { - execPath = resolved - } + } + if execErr == nil && runtime.GOOS == "windows" && isInsideDir(execPath, dir) { + if err := removeAllExcept(dir, execPath); err != nil { + return fmt.Errorf("removing config directory: %w", err) } - if execErr == nil && runtime.GOOS == "windows" && isInsideDir(execPath, dir) { - if err := removeAllExcept(dir, execPath); err != nil { - return fmt.Errorf("removing config directory: %w", err) - } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Removed contents of %s (binary skipped — still running)\n", dir) - } else { - if err := os.RemoveAll(dir); err != nil { - return fmt.Errorf("removing config directory: %w", err) - } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Removed %s\n", dir) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Removed contents of %s (binary skipped — still running)\n", dir) + } else { + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("removing config directory: %w", err) } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Removed %s\n", dir) } return nil } diff --git a/cli/coverage.out b/cli/coverage.out new file mode 100644 index 0000000000..41a1fefb48 --- /dev/null +++ b/cli/coverage.out @@ -0,0 +1,135 @@ +mode: set +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:31.36,32.11 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:33.12,34.16 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:35.11,36.15 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:37.12,38.16 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:39.18,40.22 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:41.10,42.19 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:50.30,52.17 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:52.17,54.10 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:55.39,56.15 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:57.38,58.14 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:59.39,60.15 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:61.79,62.21 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:65.2,65.31 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:65.31,67.3 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:68.2,68.16 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:79.92,82.55 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:82.55,84.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:86.2,86.15 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:87.12,88.26 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:89.11,90.34 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:91.12,92.35 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:93.18,94.37 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:95.10,96.57 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:100.46,102.16 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:102.16,104.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:105.2,109.16 4 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:109.16,111.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:112.2,112.15 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:112.15,115.3 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:117.2,118.44 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:121.69,123.16 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:123.16,125.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:128.2,132.45 4 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:132.45,134.3 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:137.2,137.52 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:137.52,139.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:140.2,141.55 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:141.55,143.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:144.2,144.67 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:144.67,146.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:149.2,152.51 4 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:152.51,154.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:155.2,155.16 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:155.16,157.54 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:157.54,159.4 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:162.2,162.17 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:165.70,167.16 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:167.16,169.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:171.2,175.45 4 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:175.45,177.3 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:180.2,180.52 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:180.52,182.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:183.2,184.62 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:184.62,186.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:187.2,187.67 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:187.67,189.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:190.2,190.17 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:193.73,195.16 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:195.16,197.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:198.2,201.51 3 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:201.51,203.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:204.2,204.15 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:204.15,207.3 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:209.2,210.44 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:214.65,216.16 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:216.16,218.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:221.2,222.16 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:222.16,224.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:227.2,227.55 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:227.55,231.17 4 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:231.17,232.12 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:234.3,235.31 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:235.31,236.12 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:238.3,239.25 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:239.25,240.12 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:243.3,244.17 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:244.17,247.4 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:248.3,250.52 3 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:250.52,251.12 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:253.3,253.24 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:257.2,257.31 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:257.31,259.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:260.2,260.94 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:265.51,267.16 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:267.16,268.37 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:268.37,270.4 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:271.3,271.56 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:273.2,273.49 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:277.60,278.15 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:279.12,280.25 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:281.11,282.24 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:283.12,284.25 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:285.18,286.34 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:287.10,288.52 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:292.28,294.16 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:294.16,296.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:297.2,297.58 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:300.27,302.16 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:302.16,304.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:306.2,307.79 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:307.79,309.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:311.2,311.57 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:314.28,316.16 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:316.16,318.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:319.2,320.79 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:320.79,322.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:323.2,323.12 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:326.53,328.16 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:328.16,330.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:331.2,331.35 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:337.43,339.16 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:339.16,340.37 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:340.37,342.4 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:343.3,343.49 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:345.2,346.40 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:346.40,348.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:350.2,353.29 4 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:353.29,354.40 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:354.40,356.12 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:358.3,358.14 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:358.14,360.37 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:360.37,361.13 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:364.4,365.12 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:367.3,367.32 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:370.2,371.51 2 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:375.47,377.48 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:377.48,379.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:380.2,381.16 2 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:381.16,383.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:384.2,386.21 3 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:386.21,388.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:389.2,389.21 1 1 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:389.21,391.3 1 0 +github.com/Aureliolo/synthorg/cli/internal/completion/install.go:392.2,392.12 1 1 diff --git a/cli/internal/completion/install.go b/cli/internal/completion/install.go index 78d9e899a2..24fd9d9ef6 100644 --- a/cli/internal/completion/install.go +++ b/cli/internal/completion/install.go @@ -331,15 +331,22 @@ func uninstallPowerShell(ctx context.Context) error { return removeMarkerBlock(profile) } -// removeMarkerBlock removes lines between (and including) the marker -// and the next non-empty line from a shell profile file. +// removeMarkerBlock removes the first marker block from a shell profile. +// A block starts at the marker line and includes all contiguous non-empty +// lines after it, plus the terminating empty line. Only the first +// occurrence is removed to avoid greedy deletion of unrelated content. +// The original file permissions are preserved. // If the file does not exist or has no marker, this is a no-op. func removeMarkerBlock(path string) error { - data, err := os.ReadFile(path) + info, err := os.Stat(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } + return fmt.Errorf("stat %s: %w", path, err) + } + data, err := os.ReadFile(path) + if err != nil { return fmt.Errorf("reading %s: %w", path, err) } content := string(data) @@ -350,9 +357,11 @@ func removeMarkerBlock(path string) error { var result []string lines := strings.Split(content, "\n") inBlock := false + found := false for _, line := range lines { - if strings.TrimSpace(line) == marker { + if !found && strings.TrimSpace(line) == marker { inBlock = true + found = true continue } if inBlock { @@ -360,7 +369,8 @@ func removeMarkerBlock(path string) error { if strings.TrimSpace(line) != "" { continue } - // Empty line ends the block. + // Empty line terminates the block and is consumed + // (the snippet's trailing newline produced it). inBlock = false continue } @@ -368,7 +378,7 @@ func removeMarkerBlock(path string) error { } cleaned := strings.Join(result, "\n") - return os.WriteFile(path, []byte(cleaned), 0o644) + return os.WriteFile(path, []byte(cleaned), info.Mode()) } // appendToFile appends content to a file, creating it if needed. diff --git a/cli/internal/completion/install_test.go b/cli/internal/completion/install_test.go index 99e2376662..bc24935b31 100644 --- a/cli/internal/completion/install_test.go +++ b/cli/internal/completion/install_test.go @@ -201,3 +201,207 @@ func TestInstallUnknownShell(t *testing.T) { t.Error("expected error for unknown shell") } } + +func TestRemoveMarkerBlock(t *testing.T) { + tests := []struct { + name string + input string + want string + missing bool // file does not exist + }{ + { + name: "removes bash snippet", + input: "export PATH=/usr/bin\n\n# synthorg shell completion\neval \"$(synthorg completion bash)\"\n\nexport FOO=bar\n", + want: "export PATH=/usr/bin\n\nexport FOO=bar\n", + }, + { + name: "removes zsh multi-line snippet", + input: "# zsh config\n\n# synthorg shell completion\nfpath=(~/.zsh/completion $fpath)\nautoload -Uz compinit && compinit\n\n# end\n", + want: "# zsh config\n\n# end\n", + }, + { + name: "marker at end of file with content", + input: "line1\n# synthorg shell completion\nsome content\n", + want: "line1", + }, + { + name: "no marker — unchanged", + input: "export PATH=/usr/bin\n", + want: "export PATH=/usr/bin\n", + }, + { + name: "file does not exist — no-op", + missing: true, + }, + { + name: "only removes first marker", + input: "# synthorg shell completion\nfirst\n\n# synthorg shell completion\nsecond\n\nend\n", + want: "# synthorg shell completion\nsecond\n\nend\n", + }, + { + name: "preserves file permissions", + input: "# synthorg shell completion\nsnippet\n\n", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile") + + if tt.missing { + err := removeMarkerBlock(path) + if err != nil { + t.Fatalf("removeMarkerBlock on missing file: %v", err) + } + return + } + + if err := os.WriteFile(path, []byte(tt.input), 0o644); err != nil { + t.Fatal(err) + } + + if err := removeMarkerBlock(path); err != nil { + t.Fatalf("removeMarkerBlock: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := string(data); got != tt.want { + t.Errorf("content mismatch\ngot: %q\nwant: %q", got, tt.want) + } + + }) + } + + // Unix-only: verify permissions are preserved. + if runtime.GOOS != "windows" { + t.Run("preserves_permissions_unix", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile") + content := "before\n# synthorg shell completion\nsnippet\n\nafter\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if err := removeMarkerBlock(path); err != nil { + t.Fatalf("removeMarkerBlock: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("permissions = %o, want 600", got) + } + }) + } +} + +func TestUninstallBashRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + root := testRootCmd() + + // Install. + if _, err := Install(context.Background(), root, Bash); err != nil { + t.Fatalf("install: %v", err) + } + + // Verify marker is present. + bashrc := filepath.Join(home, ".bashrc") + data, err := os.ReadFile(bashrc) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), marker) { + t.Fatal("marker not found after install") + } + + // Uninstall. + if err := Uninstall(context.Background(), Bash); err != nil { + t.Fatalf("uninstall: %v", err) + } + + // Verify marker is gone. + data, err = os.ReadFile(bashrc) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), marker) { + t.Error("marker still present after uninstall") + } +} + +func TestUninstallZshRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + root := testRootCmd() + + // Install. + if _, err := Install(context.Background(), root, Zsh); err != nil { + t.Fatalf("install: %v", err) + } + + compFile := filepath.Join(home, ".zsh", "completion", "_synthorg") + if _, err := os.Stat(compFile); err != nil { + t.Fatalf("completion file missing after install: %v", err) + } + + // Uninstall. + if err := Uninstall(context.Background(), Zsh); err != nil { + t.Fatalf("uninstall: %v", err) + } + + // Completion file should be gone. + if _, err := os.Stat(compFile); !os.IsNotExist(err) { + t.Error("completion file still exists after uninstall") + } + + // Marker should be gone from .zshrc. + data, err := os.ReadFile(filepath.Join(home, ".zshrc")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), marker) { + t.Error("marker still present in .zshrc after uninstall") + } +} + +func TestUninstallFishRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + root := testRootCmd() + + if _, err := Install(context.Background(), root, Fish); err != nil { + t.Fatalf("install: %v", err) + } + + compFile := filepath.Join(home, ".config", "fish", "completions", "synthorg.fish") + if _, err := os.Stat(compFile); err != nil { + t.Fatalf("completion file missing after install: %v", err) + } + + if err := Uninstall(context.Background(), Fish); err != nil { + t.Fatalf("uninstall: %v", err) + } + + if _, err := os.Stat(compFile); !os.IsNotExist(err) { + t.Error("completion file still exists after uninstall") + } +} + +func TestUninstallUnknownShell(t *testing.T) { + err := Uninstall(context.Background(), Unknown) + if err == nil { + t.Error("expected error for unknown shell") + } +} diff --git a/cli/internal/compose/generate.go b/cli/internal/compose/generate.go index f5e98d013b..c008c294ae 100644 --- a/cli/internal/compose/generate.go +++ b/cli/internal/compose/generate.go @@ -27,6 +27,12 @@ var allowedLogLevels = map[string]bool{ "error": true, } +// allowedPersistenceBackends restricts persistence backend values. +var allowedPersistenceBackends = map[string]bool{"sqlite": true} + +// allowedMemoryBackends restricts memory backend values. +var allowedMemoryBackends = map[string]bool{"mem0": true} + // Params are the template parameters for compose generation. type Params struct { CLIVersion string @@ -104,6 +110,12 @@ func validateParams(p Params) error { return fmt.Errorf("docker socket path %q contains unsafe characters", p.DockerSock) } } + if !allowedPersistenceBackends[p.PersistenceBackend] { + return fmt.Errorf("invalid persistence backend %q: must be one of sqlite", p.PersistenceBackend) + } + if !allowedMemoryBackends[p.MemoryBackend] { + return fmt.Errorf("invalid memory backend %q: must be one of mem0", p.MemoryBackend) + } return nil } diff --git a/cli/internal/config/state.go b/cli/internal/config/state.go index 1ad8d844cc..2988f5a781 100644 --- a/cli/internal/config/state.go +++ b/cli/internal/config/state.go @@ -87,6 +87,12 @@ func Load(dataDir string) (State, error) { return s, nil } +// Valid persistence backend values. +var validPersistenceBackends = map[string]bool{"sqlite": true} + +// Valid memory backend values. +var validMemoryBackends = map[string]bool{"mem0": true} + // validate checks that loaded config values are within safe ranges. func (s State) validate() error { if s.BackendPort < 1 || s.BackendPort > 65535 { @@ -95,6 +101,12 @@ func (s State) validate() error { if s.WebPort < 1 || s.WebPort > 65535 { return fmt.Errorf("invalid web_port %d: must be 1-65535", s.WebPort) } + if s.PersistenceBackend != "" && !validPersistenceBackends[s.PersistenceBackend] { + return fmt.Errorf("invalid persistence_backend %q: must be one of sqlite", s.PersistenceBackend) + } + if s.MemoryBackend != "" && !validMemoryBackends[s.MemoryBackend] { + return fmt.Errorf("invalid memory_backend %q: must be one of mem0", s.MemoryBackend) + } return nil } diff --git a/cli/internal/config/state_test.go b/cli/internal/config/state_test.go index 7091aea96c..8f7a6a1c4a 100644 --- a/cli/internal/config/state_test.go +++ b/cli/internal/config/state_test.go @@ -28,17 +28,25 @@ func TestDefaultState(t *testing.T) { if s.DataDir == "" { t.Error("DataDir should not be empty") } + if s.PersistenceBackend != "sqlite" { + t.Errorf("PersistenceBackend = %q, want sqlite", s.PersistenceBackend) + } + if s.MemoryBackend != "mem0" { + t.Errorf("MemoryBackend = %q, want mem0", s.MemoryBackend) + } } func TestSaveAndLoad(t *testing.T) { tmp := t.TempDir() s := State{ - DataDir: tmp, - ImageTag: "v0.1.5", - BackendPort: 9000, - WebPort: 3001, - LogLevel: "debug", - JWTSecret: "test-secret", + DataDir: tmp, + ImageTag: "v0.1.5", + BackendPort: 9000, + WebPort: 3001, + LogLevel: "debug", + JWTSecret: "test-secret", + PersistenceBackend: "sqlite", + MemoryBackend: "mem0", } if err := Save(s); err != nil { @@ -65,6 +73,12 @@ func TestSaveAndLoad(t *testing.T) { if loaded.LogLevel != s.LogLevel { t.Errorf("LogLevel = %q, want %q", loaded.LogLevel, s.LogLevel) } + if loaded.PersistenceBackend != s.PersistenceBackend { + t.Errorf("PersistenceBackend = %q, want %q", loaded.PersistenceBackend, s.PersistenceBackend) + } + if loaded.MemoryBackend != s.MemoryBackend { + t.Errorf("MemoryBackend = %q, want %q", loaded.MemoryBackend, s.MemoryBackend) + } } func TestSaveCreatesDirectory(t *testing.T) { diff --git a/docker/.env.example b/docker/.env.example index 7ce8d5fc88..e5fa6a1db6 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -20,6 +20,12 @@ SYNTHORG_DB_PATH=/data/synthorg.db # Agent memory storage directory (inside container: /data/memory) SYNTHORG_MEMORY_DIR=/data/memory +# Persistence backend (currently: sqlite) +SYNTHORG_PERSISTENCE_BACKEND=sqlite + +# Memory backend (currently: mem0) +SYNTHORG_MEMORY_BACKEND=mem0 + # --- Container Networking ---------------------------------------------------- # Host port for the backend API BACKEND_PORT=8000 diff --git a/docs/user_guide.md b/docs/user_guide.md index 04a3857b2b..142c8a9e17 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -51,6 +51,8 @@ Configuration is in `docker/.env` (copy from `docker/.env.example`): | `SYNTHORG_JWT_SECRET` | *(auto-generated)* | JWT signing secret. Auto-generated and persisted on first run. Set explicitly only for multi-instance deployments. Must be >= 32 characters if set. | | `SYNTHORG_DB_PATH` | `/data/synthorg.db` | SQLite database path (inside container). | | `SYNTHORG_MEMORY_DIR` | `/data/memory` | Agent memory storage directory (inside container). | +| `SYNTHORG_PERSISTENCE_BACKEND` | `sqlite` | Persistence backend for operational data. | +| `SYNTHORG_MEMORY_BACKEND` | `mem0` | Memory backend for agent memory. | | `BACKEND_PORT` | `8000` | Host port for the backend API. | | `WEB_PORT` | `3000` | Host port for the web dashboard. | | `DOCKER_HOST` | *(unset)* | Docker socket for agent code execution sandbox (optional). | From c3f52373056a15ccf0a51fdc73a22744941939c3 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:58:15 +0100 Subject: [PATCH 4/6] fix(cli): address 7 external review findings from Gemini and CodeRabbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove empty-string bypass in backend validation (Gemini, CodeRabbit) - Centralize backend allowlists — export from config, remove duplicates in compose/generate.go (Gemini, CodeRabbit) - Uninstall completions for all shells, not just detected one (CodeRabbit) - Harden rejectUnsafeDir to reject empty, relative, and "." paths (CodeRabbit) - Cap removeMarkerBlock at 5 lines to prevent unbounded deletion (CodeRabbit) - Use dynamic backend names in init Note from defaults (CodeRabbit) - Add negative tests for invalid/empty backend values in Load (CodeRabbit) - Fix pre-existing tests missing new backend fields --- cli/cmd/config_test.go | 18 +++++----- cli/cmd/init.go | 5 ++- cli/cmd/uninstall.go | 18 ++++++---- cli/internal/completion/install.go | 26 ++++++++++----- cli/internal/compose/generate.go | 10 ++---- cli/internal/config/state.go | 12 +++---- cli/internal/config/state_test.go | 53 +++++++++++++++++++++++++----- 7 files changed, 96 insertions(+), 46 deletions(-) diff --git a/cli/cmd/config_test.go b/cli/cmd/config_test.go index 0fe80bddfc..3edb36a6a9 100644 --- a/cli/cmd/config_test.go +++ b/cli/cmd/config_test.go @@ -46,14 +46,16 @@ func TestConfigShowNotInitialized(t *testing.T) { func TestConfigShowDisplaysFields(t *testing.T) { dir := t.TempDir() state := config.State{ - DataDir: dir, - ImageTag: "v1.2.3", - BackendPort: 9000, - WebPort: 4000, - Sandbox: true, - DockerSock: "/var/run/docker.sock", - LogLevel: "debug", - JWTSecret: "super-secret", + DataDir: dir, + ImageTag: "v1.2.3", + BackendPort: 9000, + WebPort: 4000, + Sandbox: true, + DockerSock: "/var/run/docker.sock", + LogLevel: "debug", + JWTSecret: "super-secret", + PersistenceBackend: "sqlite", + MemoryBackend: "mem0", } data, err := json.MarshalIndent(state, "", " ") diff --git a/cli/cmd/init.go b/cli/cmd/init.go index fe6e6dce14..95550e4fee 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -132,7 +132,10 @@ func runSetupForm() (setupAnswers, error) { ), huh.NewGroup( huh.NewNote().Title("Backends"). - Description("Persistence: SQLite · Memory: Mem0\n(More options coming soon)"), + Description(fmt.Sprintf( + "Persistence: %s · Memory: %s\n(More options coming soon)", + a.persistenceBackend, a.memoryBackend, + )), ), ) diff --git a/cli/cmd/uninstall.go b/cli/cmd/uninstall.go index 9212090cc3..1486dd3ce1 100644 --- a/cli/cmd/uninstall.go +++ b/cli/cmd/uninstall.go @@ -60,13 +60,14 @@ func runUninstall(cmd *cobra.Command, _ []string) error { return err } - // Remove shell completion snippets (after data dir confirmation - // so aborting early doesn't leave completions removed). - shell := completion.DetectShell() - if shell != completion.Unknown { - _, _ = fmt.Fprintln(out, "Removing shell completions...") + // Remove shell completion snippets for all supported shells + // (user may have installed completions for multiple shells). + _, _ = fmt.Fprintln(out, "Removing shell completions...") + for _, shell := range []completion.ShellType{ + completion.Bash, completion.Zsh, completion.Fish, completion.PowerShell, + } { if err := completion.Uninstall(ctx, shell); err != nil { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not remove shell completions: %v\n", err) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not remove %s completions: %v\n", shell, err) } } @@ -134,8 +135,11 @@ func confirmAndRemoveData(cmd *cobra.Command, dataDir string) error { return removeDataDir(cmd, dir) } -// rejectUnsafeDir refuses to remove root, home, UNC share roots, or drive roots. +// rejectUnsafeDir refuses to remove root, home, relative, UNC share roots, or drive roots. func rejectUnsafeDir(dir string) error { + if dir == "" || dir == "." || !filepath.IsAbs(dir) { + return fmt.Errorf("refusing to remove %q — must be an absolute path", dir) + } home, homeErr := os.UserHomeDir() isHomeDir := false if homeErr == nil { diff --git a/cli/internal/completion/install.go b/cli/internal/completion/install.go index 24fd9d9ef6..88e2bd26fb 100644 --- a/cli/internal/completion/install.go +++ b/cli/internal/completion/install.go @@ -331,10 +331,15 @@ func uninstallPowerShell(ctx context.Context) error { return removeMarkerBlock(profile) } +// maxSnippetLines caps how many non-empty lines after the marker are +// treated as part of the snippet, preventing unbounded deletion if a +// user's content follows without a blank-line separator. +const maxSnippetLines = 5 + // removeMarkerBlock removes the first marker block from a shell profile. -// A block starts at the marker line and includes all contiguous non-empty -// lines after it, plus the terminating empty line. Only the first -// occurrence is removed to avoid greedy deletion of unrelated content. +// A block starts at the marker line and includes up to maxSnippetLines +// contiguous non-empty lines after it, plus the terminating empty line. +// Only the first occurrence is removed to avoid greedy deletion. // The original file permissions are preserved. // If the file does not exist or has no marker, this is a no-op. func removeMarkerBlock(path string) error { @@ -358,21 +363,26 @@ func removeMarkerBlock(path string) error { lines := strings.Split(content, "\n") inBlock := false found := false + blockLines := 0 for _, line := range lines { if !found && strings.TrimSpace(line) == marker { inBlock = true found = true + blockLines = 0 continue } if inBlock { - // Skip non-empty lines that are part of the snippet. - if strings.TrimSpace(line) != "" { + if strings.TrimSpace(line) != "" && blockLines < maxSnippetLines { + blockLines++ continue } - // Empty line terminates the block and is consumed - // (the snippet's trailing newline produced it). + // Empty line or cap reached — end the block. inBlock = false - continue + if strings.TrimSpace(line) == "" { + // Consume the terminating empty line. + continue + } + // Cap reached on a non-empty line — keep it. } result = append(result, line) } diff --git a/cli/internal/compose/generate.go b/cli/internal/compose/generate.go index c008c294ae..54c9bb5d5a 100644 --- a/cli/internal/compose/generate.go +++ b/cli/internal/compose/generate.go @@ -27,12 +27,6 @@ var allowedLogLevels = map[string]bool{ "error": true, } -// allowedPersistenceBackends restricts persistence backend values. -var allowedPersistenceBackends = map[string]bool{"sqlite": true} - -// allowedMemoryBackends restricts memory backend values. -var allowedMemoryBackends = map[string]bool{"mem0": true} - // Params are the template parameters for compose generation. type Params struct { CLIVersion string @@ -110,10 +104,10 @@ func validateParams(p Params) error { return fmt.Errorf("docker socket path %q contains unsafe characters", p.DockerSock) } } - if !allowedPersistenceBackends[p.PersistenceBackend] { + if !config.ValidPersistenceBackends[p.PersistenceBackend] { return fmt.Errorf("invalid persistence backend %q: must be one of sqlite", p.PersistenceBackend) } - if !allowedMemoryBackends[p.MemoryBackend] { + if !config.ValidMemoryBackends[p.MemoryBackend] { return fmt.Errorf("invalid memory backend %q: must be one of mem0", p.MemoryBackend) } return nil diff --git a/cli/internal/config/state.go b/cli/internal/config/state.go index 2988f5a781..dd263dcc91 100644 --- a/cli/internal/config/state.go +++ b/cli/internal/config/state.go @@ -87,11 +87,11 @@ func Load(dataDir string) (State, error) { return s, nil } -// Valid persistence backend values. -var validPersistenceBackends = map[string]bool{"sqlite": true} +// ValidPersistenceBackends are the allowed persistence backend values. +var ValidPersistenceBackends = map[string]bool{"sqlite": true} -// Valid memory backend values. -var validMemoryBackends = map[string]bool{"mem0": true} +// ValidMemoryBackends are the allowed memory backend values. +var ValidMemoryBackends = map[string]bool{"mem0": true} // validate checks that loaded config values are within safe ranges. func (s State) validate() error { @@ -101,10 +101,10 @@ func (s State) validate() error { if s.WebPort < 1 || s.WebPort > 65535 { return fmt.Errorf("invalid web_port %d: must be 1-65535", s.WebPort) } - if s.PersistenceBackend != "" && !validPersistenceBackends[s.PersistenceBackend] { + if !ValidPersistenceBackends[s.PersistenceBackend] { return fmt.Errorf("invalid persistence_backend %q: must be one of sqlite", s.PersistenceBackend) } - if s.MemoryBackend != "" && !validMemoryBackends[s.MemoryBackend] { + if !ValidMemoryBackends[s.MemoryBackend] { return fmt.Errorf("invalid memory_backend %q: must be one of mem0", s.MemoryBackend) } return nil diff --git a/cli/internal/config/state_test.go b/cli/internal/config/state_test.go index 8f7a6a1c4a..ccdff6e181 100644 --- a/cli/internal/config/state_test.go +++ b/cli/internal/config/state_test.go @@ -162,6 +162,41 @@ func TestLoadInvalid(t *testing.T) { } } +func TestLoadRejectsInvalidBackends(t *testing.T) { + tests := []struct { + name string + persist string + memory string + }{ + {"empty persistence", "", "mem0"}, + {"empty memory", "sqlite", ""}, + {"unknown persistence", "postgres", "mem0"}, + {"unknown memory", "sqlite", "redis"}, + {"both empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + raw, _ := json.Marshal(map[string]any{ + "data_dir": tmp, + "image_tag": "latest", + "backend_port": 8000, + "web_port": 3000, + "log_level": "info", + "persistence_backend": tt.persist, + "memory_backend": tt.memory, + }) + if err := os.WriteFile(filepath.Join(tmp, stateFileName), raw, 0o600); err != nil { + t.Fatal(err) + } + _, err := Load(tmp) + if err == nil { + t.Errorf("expected validation error for persist=%q memory=%q", tt.persist, tt.memory) + } + }) + } +} + func TestStatePath(t *testing.T) { path := StatePath("/some/dir") if filepath.Base(path) != stateFileName { @@ -172,14 +207,16 @@ func TestStatePath(t *testing.T) { func TestSaveLoadRoundTrip(t *testing.T) { tmp := t.TempDir() original := State{ - DataDir: tmp, - ImageTag: "v2.0.0", - BackendPort: 8080, - WebPort: 3030, - Sandbox: true, - DockerSock: "/custom/docker.sock", - LogLevel: "warn", - JWTSecret: "super-secret-key", + DataDir: tmp, + ImageTag: "v2.0.0", + BackendPort: 8080, + WebPort: 3030, + Sandbox: true, + DockerSock: "/custom/docker.sock", + LogLevel: "warn", + JWTSecret: "super-secret-key", + PersistenceBackend: "sqlite", + MemoryBackend: "mem0", } if err := Save(original); err != nil { From 5c834e649838cec39a57c663fef506f0880fafa8 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:30:53 +0100 Subject: [PATCH 5/6] fix(cli): address CodeRabbit round-2 feedback - Replace exported mutable maps with IsValid... helper functions - Add backend fields to config show output + test assertions - Display PersistenceBackend/MemoryBackend in synthorg config show --- cli/cmd/config.go | 2 ++ cli/cmd/config_test.go | 2 ++ cli/internal/compose/generate.go | 4 ++-- cli/internal/config/state.go | 19 +++++++++++++------ 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/cli/cmd/config.go b/cli/cmd/config.go index ac694d536d..6f33faaa33 100644 --- a/cli/cmd/config.go +++ b/cli/cmd/config.go @@ -68,6 +68,8 @@ func runConfigShow(cmd *cobra.Command, _ []string) error { if state.Sandbox && state.DockerSock != "" { out.KeyValue("Docker socket", state.DockerSock) } + out.KeyValue("Persistence backend", state.PersistenceBackend) + out.KeyValue("Memory backend", state.MemoryBackend) out.KeyValue("JWT secret", maskSecret(state.JWTSecret)) return nil diff --git a/cli/cmd/config_test.go b/cli/cmd/config_test.go index 3edb36a6a9..16e75f9740 100644 --- a/cli/cmd/config_test.go +++ b/cli/cmd/config_test.go @@ -83,6 +83,8 @@ func TestConfigShowDisplaysFields(t *testing.T) { "debug", "/var/run/docker.sock", "****", + "sqlite", + "mem0", } { if !bytes.Contains([]byte(out), []byte(want)) { t.Errorf("expected %q in output, got: %s", want, out) diff --git a/cli/internal/compose/generate.go b/cli/internal/compose/generate.go index 54c9bb5d5a..c01ef67888 100644 --- a/cli/internal/compose/generate.go +++ b/cli/internal/compose/generate.go @@ -104,10 +104,10 @@ func validateParams(p Params) error { return fmt.Errorf("docker socket path %q contains unsafe characters", p.DockerSock) } } - if !config.ValidPersistenceBackends[p.PersistenceBackend] { + if !config.IsValidPersistenceBackend(p.PersistenceBackend) { return fmt.Errorf("invalid persistence backend %q: must be one of sqlite", p.PersistenceBackend) } - if !config.ValidMemoryBackends[p.MemoryBackend] { + if !config.IsValidMemoryBackend(p.MemoryBackend) { return fmt.Errorf("invalid memory backend %q: must be one of mem0", p.MemoryBackend) } return nil diff --git a/cli/internal/config/state.go b/cli/internal/config/state.go index dd263dcc91..bd3aca1d4a 100644 --- a/cli/internal/config/state.go +++ b/cli/internal/config/state.go @@ -87,11 +87,18 @@ func Load(dataDir string) (State, error) { return s, nil } -// ValidPersistenceBackends are the allowed persistence backend values. -var ValidPersistenceBackends = map[string]bool{"sqlite": true} +var validPersistenceBackends = map[string]bool{"sqlite": true} +var validMemoryBackends = map[string]bool{"mem0": true} -// ValidMemoryBackends are the allowed memory backend values. -var ValidMemoryBackends = map[string]bool{"mem0": true} +// IsValidPersistenceBackend reports whether name is a known persistence backend. +func IsValidPersistenceBackend(name string) bool { + return validPersistenceBackends[name] +} + +// IsValidMemoryBackend reports whether name is a known memory backend. +func IsValidMemoryBackend(name string) bool { + return validMemoryBackends[name] +} // validate checks that loaded config values are within safe ranges. func (s State) validate() error { @@ -101,10 +108,10 @@ func (s State) validate() error { if s.WebPort < 1 || s.WebPort > 65535 { return fmt.Errorf("invalid web_port %d: must be 1-65535", s.WebPort) } - if !ValidPersistenceBackends[s.PersistenceBackend] { + if !IsValidPersistenceBackend(s.PersistenceBackend) { return fmt.Errorf("invalid persistence_backend %q: must be one of sqlite", s.PersistenceBackend) } - if !ValidMemoryBackends[s.MemoryBackend] { + if !IsValidMemoryBackend(s.MemoryBackend) { return fmt.Errorf("invalid memory_backend %q: must be one of mem0", s.MemoryBackend) } return nil From b113de54438779f4c555e8a7ede7929a052701af Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:12:22 +0100 Subject: [PATCH 6/6] fix(cli): derive error messages from validation maps Address CodeRabbit round-3: error messages for invalid backend values now derive the allowed options list from the validation maps instead of hardcoding them. Adds sortedKeys helper and exported name functions. --- cli/internal/compose/generate.go | 4 ++-- cli/internal/config/state.go | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/cli/internal/compose/generate.go b/cli/internal/compose/generate.go index c01ef67888..3694bcab31 100644 --- a/cli/internal/compose/generate.go +++ b/cli/internal/compose/generate.go @@ -105,10 +105,10 @@ func validateParams(p Params) error { } } if !config.IsValidPersistenceBackend(p.PersistenceBackend) { - return fmt.Errorf("invalid persistence backend %q: must be one of sqlite", p.PersistenceBackend) + return fmt.Errorf("invalid persistence backend %q: must be one of %s", p.PersistenceBackend, config.PersistenceBackendNames()) } if !config.IsValidMemoryBackend(p.MemoryBackend) { - return fmt.Errorf("invalid memory backend %q: must be one of mem0", p.MemoryBackend) + return fmt.Errorf("invalid memory backend %q: must be one of %s", p.MemoryBackend, config.MemoryBackendNames()) } return nil } diff --git a/cli/internal/config/state.go b/cli/internal/config/state.go index bd3aca1d4a..f5770451dc 100644 --- a/cli/internal/config/state.go +++ b/cli/internal/config/state.go @@ -6,6 +6,8 @@ import ( "fmt" "os" "path/filepath" + "sort" + "strings" ) const stateFileName = "config.json" @@ -90,6 +92,16 @@ func Load(dataDir string) (State, error) { var validPersistenceBackends = map[string]bool{"sqlite": true} var validMemoryBackends = map[string]bool{"mem0": true} +// sortedKeys returns a comma-separated sorted list of map keys. +func sortedKeys(m map[string]bool) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return strings.Join(keys, ", ") +} + // IsValidPersistenceBackend reports whether name is a known persistence backend. func IsValidPersistenceBackend(name string) bool { return validPersistenceBackends[name] @@ -100,6 +112,12 @@ func IsValidMemoryBackend(name string) bool { return validMemoryBackends[name] } +// PersistenceBackendNames returns the allowed persistence backend names. +func PersistenceBackendNames() string { return sortedKeys(validPersistenceBackends) } + +// MemoryBackendNames returns the allowed memory backend names. +func MemoryBackendNames() string { return sortedKeys(validMemoryBackends) } + // validate checks that loaded config values are within safe ranges. func (s State) validate() error { if s.BackendPort < 1 || s.BackendPort > 65535 { @@ -109,10 +127,10 @@ func (s State) validate() error { return fmt.Errorf("invalid web_port %d: must be 1-65535", s.WebPort) } if !IsValidPersistenceBackend(s.PersistenceBackend) { - return fmt.Errorf("invalid persistence_backend %q: must be one of sqlite", s.PersistenceBackend) + return fmt.Errorf("invalid persistence_backend %q: must be one of %s", s.PersistenceBackend, sortedKeys(validPersistenceBackends)) } if !IsValidMemoryBackend(s.MemoryBackend) { - return fmt.Errorf("invalid memory_backend %q: must be one of mem0", s.MemoryBackend) + return fmt.Errorf("invalid memory_backend %q: must be one of %s", s.MemoryBackend, sortedKeys(validMemoryBackends)) } return nil }