// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package cmd import ( "context" "database/sql" "fmt" "log/slog" "os" "strings" "text/tabwriter" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/db" "git.coopcloud.tech/wiki-cafe/member-console/internal/integration" "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations" "git.coopcloud.tech/wiki-cafe/member-console/internal/logging" "github.com/spf13/cobra" "github.com/spf13/viper" ) // settingsCmd is the console's only supported way to manage stored config // override rows from the command line (design D11, typed-config-keys): the // same three verbs the operator settings page offers, and no more. Its // subcommands call internal/integration.SettingsService, the exact seam // the page's save, Clear and orphan-delete handlers call, so the CLI and // the page cannot behave differently. // // It is a separate process from the running server, so it never claims to // know what that server is doing: it reports this process's own // environment/flag/config-file resolution and the stored override, and // states once that a change takes effect on the app's next restart. It // never states what the running app currently has in force or whether a // restart is pending: the operator settings page owns that, from its own // boot-effective snapshot. var settingsCmd = &cobra.Command{ Use: "config", Short: "List, set, clear and check the deployment's configuration", } var settingsListCmd = &cobra.Command{ Use: "list [integration]", Short: "List every declared key's environment value, stored override, and next-boot winner", Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { svc, database, specs := connectSettings() defer database.Close() rows, err := svc.List(context.Background(), specs) if err != nil { fmt.Fprintln(os.Stderr, "config: "+err.Error()) os.Exit(1) } if len(args) == 1 { names, ok := integrationKeyNames(args[0]) if !ok { fmt.Fprintf(os.Stderr, "unknown integration %s\n", args[0]) os.Exit(1) } rows = filterSettingsRows(rows, names) } fmt.Print(renderSettingsList(rows)) }, } var settingsSetCmd = &cobra.Command{ Use: "set ", Short: "Store a value as an override, parsed against its key's declared type", Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { svc, database, specs := connectSettings() defer database.Close() key, raw := args[0], args[1] if err := svc.Set(context.Background(), specs, key, raw, ""); err != nil { fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) } fmt.Printf("%s set. Applies on restart.\n", key) }, } var settingsClearCmd = &cobra.Command{ Use: "clear ", Short: "Delete a stored override", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { svc, database, _ := connectSettings() defer database.Close() key := args[0] found, err := svc.Clear(context.Background(), key) if err != nil { fmt.Fprintln(os.Stderr, "config: "+err.Error()) os.Exit(1) } if !found { fmt.Fprintf(os.Stderr, "no override stored for %s\n", key) os.Exit(1) } fmt.Printf("%s cleared. Applies on restart.\n", key) }, } // connectSettings loads every installed integration's ConfigSpec and opens // a plain database connection, the same way config validate does (design // D10): no migrations, no server, just enough to read and write // core.integration_config_overrides through the settings service. func connectSettings() (*integration.SettingsService, *sql.DB, []config.ConfigKey) { var specs []config.ConfigKey for _, integ := range integrations.All() { if cp, ok := integ.(config.ConfigProvider); ok { specs = append(specs, cp.ConfigSpec()...) } } logger := slog.Default() if logging.AppLogger != nil { logger = logging.AppLogger } dbConfig := db.DefaultDBConfig(viper.GetString("db-dsn")) database, err := db.ConnectPlain(context.Background(), logger, dbConfig) if err != nil { fmt.Fprintln(os.Stderr, "config: connecting to the database: "+err.Error()) os.Exit(1) } return integration.NewSettingsService(database), database, specs } // integrationKeyNames returns the ConfigSpec names declared by the // installed integration whose Key() equals integrationKey, and whether // that integration is installed at all. func integrationKeyNames(integrationKey string) (map[string]bool, bool) { for _, integ := range integrations.All() { if integ.Key() != integrationKey { continue } cp, ok := integ.(config.ConfigProvider) if !ok { return map[string]bool{}, true } names := make(map[string]bool, len(cp.ConfigSpec())) for _, spec := range cp.ConfigSpec() { names[spec.Name] = true } return names, true } return nil, false } // filterSettingsRows keeps only the rows whose key is in names, the // `list ` filter. An unrecognized row belongs to no // integration by definition, so it never survives a filtered listing; the // bare `list` is the only form that shows one. func filterSettingsRows(rows []integration.SettingsRow, names map[string]bool) []integration.SettingsRow { out := make([]integration.SettingsRow, 0, len(rows)) for _, row := range rows { if names[row.Key] { out = append(out, row) } } return out } // renderSettingsList formats rows as a plain aligned table: the key, the // value this process's environment or default gives it, the stored // override (blank when none), and which one wins at the next boot. A // secret key's value and stored override are masked to "set"/"not set" // rather than ever printed; an unrecognized row (design D11, "unrecognized // rows listed as such") carries no environment value and names itself in // the winner column instead of one. The closing line states the one fact // this process can state about a running app it is not: that it applies // whatever is stored here on its next restart. func renderSettingsList(rows []integration.SettingsRow) string { var buf strings.Builder tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) fmt.Fprintln(tw, "KEY\tENVIRONMENT/DEFAULT\tOVERRIDE\tNEXT BOOT") for _, row := range rows { env := settingsEnvironmentCell(row) override := settingsOverrideCell(row) winner := settingsWinnerCell(row) fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", row.Key, env, override, winner) } tw.Flush() buf.WriteString("The running app applies changes on restart.\n") return buf.String() } func settingsEnvironmentCell(row integration.SettingsRow) string { if !row.Declared { return "" } if row.Secret { if row.SecretSet { return "set" } return "not set" } if row.Environment == "" { return "not set" } return row.Environment } func settingsOverrideCell(row integration.SettingsRow) string { if !row.HasOverride { return "" } if row.Secret { return "set" } return row.Override } func settingsWinnerCell(row integration.SettingsRow) string { if !row.Declared { return "(unrecognized)" } switch row.Winner { case config.SourceOverride: return "Override" case config.SourceEnvironment: return "Environment" default: return "Default" } } func init() { settingsCmd.AddCommand(settingsListCmd, settingsSetCmd, settingsClearCmd) rootCmd.AddCommand(settingsCmd) }