Files
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

235 lines
7.2 KiB
Go

// 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 <key> <value>",
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 <key>",
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 <integration>` 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)
}