diff --git a/cmd/picoclaw/internal/config/command.go b/cmd/picoclaw/internal/config/command.go new file mode 100644 index 00000000..3329d491 --- /dev/null +++ b/cmd/picoclaw/internal/config/command.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func NewConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage configuration", + } + + cmd.AddCommand(newResetCommand()) + return cmd +} + +func newResetCommand() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "reset", + Short: "Reset configuration to factory defaults", + Args: cobra.NoArgs, + Example: ` picoclaw config reset + picoclaw config reset --force`, + RunE: func(_ *cobra.Command, _ []string) error { + if !force { + fmt.Print("Reset config to factory defaults? API keys will be preserved. (y/n): ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) != "y" { + fmt.Println("Aborted.") + return nil + } + } + + configPath := internal.GetConfigPath() + if err := config.ResetToDefaults(configPath); err != nil { + return fmt.Errorf("reset failed: %w", err) + } + fmt.Println("Configuration has been reset to factory defaults.") + fmt.Println("A backup of the previous config was created in the same directory.") + return nil + }, + } + + cmd.Flags().BoolVarP(&force, "force", "f", false, + "Skip confirmation prompt") + + return cmd +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index abcf03a3..1b5d0974 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -17,6 +17,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" + configcmd "github.com/sipeed/picoclaw/cmd/picoclaw/internal/config" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/mcp" @@ -82,6 +83,7 @@ picoclaw --no-color status`, }) cmd.AddCommand( + configcmd.NewConfigCommand(), onboard.NewOnboardCommand(), agent.NewAgentCommand(), auth.NewAuthCommand(), diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 037c7c2e..e4f9de5e 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -39,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) { allowedCommands := []string{ "agent", "auth", + "config", "cron", "gateway", "mcp", diff --git a/pkg/config/config.go b/pkg/config/config.go index 4afb0439..1fea4736 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1266,7 +1266,7 @@ func LoadConfig(path string) (*Config, error) { return nil, err } - err = makeBackup(path) + err = MakeBackup(path) if err != nil { return nil, err } @@ -1320,7 +1320,7 @@ func LoadConfig(path string) (*Config, error) { return nil, err } - err = makeBackup(path) + err = MakeBackup(path) if err != nil { return nil, err } @@ -1372,7 +1372,7 @@ func LoadConfig(path string) (*Config, error) { return nil, err } - err = makeBackup(path) + err = MakeBackup(path) if err != nil { return nil, err } @@ -1523,7 +1523,7 @@ func applySkillsRegistryEnvCompat(cfg *Config) { cfg.Tools.Skills.Registries.Set("github", githubCfg) } -func makeBackup(path string) error { +func MakeBackup(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { return nil } @@ -1651,6 +1651,19 @@ func (c *Config) SecurityCopyFrom(path string) error { return loadSecurityConfig(c, securityPath(path)) } +// ResetToDefaults backs up the current config, creates a default config, +// preserves security credentials from the existing config, and saves it. +func ResetToDefaults(configPath string) error { + if err := MakeBackup(configPath); err != nil { + return fmt.Errorf("backup before reset: %w", err) + } + cfg := DefaultConfig() + if err := cfg.SecurityCopyFrom(configPath); err != nil { + logger.WarnF("could not preserve security config", map[string]any{"error": err}) + } + return SaveConfig(configPath, cfg) +} + func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { var expanded []*ModelConfig diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 69cff925..968fa975 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2681,7 +2681,7 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { } // --------------------------------------------------------------------------- -// makeBackup tests +// MakeBackup tests // --------------------------------------------------------------------------- // TestMakeBackup_WithDateSuffix verifies backup files include a date suffix. @@ -2692,8 +2692,8 @@ func TestMakeBackup_WithDateSuffix(t *testing.T) { t.Fatalf("WriteFile: %v", err) } - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, err := os.ReadDir(dir) @@ -2732,8 +2732,8 @@ func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) { os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) os.WriteFile(secPath, []byte(`model_list:\n test:0:\n api_keys:\n - "sk-test"\n`), 0o600) - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, err := os.ReadDir(dir) @@ -2759,14 +2759,14 @@ func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) { } } -// TestMakeBackup_NonexistentFileSkipsBackup verifies that makeBackup returns nil +// TestMakeBackup_NonexistentFileSkipsBackup verifies that MakeBackup returns nil // when the config file does not exist (no error, no panic). func TestMakeBackup_NonexistentFileSkipsBackup(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "nonexistent.json") - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup on nonexistent file should return nil, got: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup on nonexistent file should return nil, got: %v", err) } } @@ -2777,8 +2777,8 @@ func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) { configPath := filepath.Join(dir, "config.json") os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, _ := os.ReadDir(dir) @@ -2801,7 +2801,7 @@ func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) { } // TestMakeBackup_SameDateSuffix verifies that config and security backups -// share the same date suffix (they are created in the same makeBackup call). +// share the same date suffix (they are created in the same MakeBackup call). func TestMakeBackup_SameDateSuffix(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -2810,8 +2810,8 @@ func TestMakeBackup_SameDateSuffix(t *testing.T) { os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) os.WriteFile(secPath, []byte(`key: value`), 0o600) - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, _ := os.ReadDir(dir) diff --git a/web/backend/api/config.go b/web/backend/api/config.go index ee3dd504..6bea6733 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -18,6 +18,7 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/config", h.handleGetConfig) mux.HandleFunc("PUT /api/config", h.handleUpdateConfig) mux.HandleFunc("PATCH /api/config", h.handlePatchConfig) + mux.HandleFunc("POST /api/config/reset", h.handleResetConfig) mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns) } @@ -211,6 +212,32 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } +// handleResetConfig resets the configuration to factory defaults. +// API keys and security credentials are preserved. +// +// POST /api/config/reset +func (h *Handler) handleResetConfig(w http.ResponseWriter, r *http.Request) { + if err := config.ResetToDefaults(h.configPath); err != nil { + http.Error(w, fmt.Sprintf("Failed to reset config: %v", err), http.StatusInternalServerError) + return + } + + h.applyRuntimeLogLevel() + logger.Infof("configuration reset to factory defaults") + + // Restart gateway if running + status := h.gatewayStatusData() + gatewayStatus, _ := status["gateway_status"].(string) + if gatewayStatus == "running" { + if _, err := h.RestartGateway(); err != nil { + logger.ErrorF("failed to restart gateway after config reset", map[string]any{"error": err.Error()}) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + // handleTestCommandPatterns tests a command against whitelist and blacklist patterns. // // POST /api/config/test-command-patterns diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index 42a3a060..36389ca8 100644 --- a/web/frontend/src/api/channels.ts +++ b/web/frontend/src/api/channels.ts @@ -77,6 +77,12 @@ export async function patchAppConfig( }) } +export async function resetAppConfig(): Promise { + return request("/api/config/reset", { + method: "POST", + }) +} + // WeChat QR login flow API export interface WeixinFlowResponse { diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 8af5815f..4db059cd 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" -import { patchAppConfig } from "@/api/channels" +import { patchAppConfig, resetAppConfig } from "@/api/channels" import { launcherFetch } from "@/api/http" import { postLauncherDashboardSetup } from "@/api/launcher-auth" import { @@ -41,6 +41,17 @@ import { } from "@/components/config/form-model" import { PageHeader } from "@/components/page-header" import { Badge } from "@/components/ui/badge" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" @@ -72,15 +83,24 @@ export function ConfigPage() { const [autoStartEnabled, setAutoStartEnabled] = useState(false) const [autoStartBaseline, setAutoStartBaseline] = useState(false) const [saving, setSaving] = useState(false) + const [showFactoryResetDialog, setShowFactoryResetDialog] = useState(false) const { data, isLoading, error } = useQuery({ queryKey: ["config"], queryFn: async () => { - const res = await launcherFetch("/api/config") - if (!res.ok) { - throw new Error("Failed to load config") + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 5000) + try { + const res = await launcherFetch("/api/config", { + signal: controller.signal, + }) + if (!res.ok) { + throw new Error("Failed to load config") + } + return res.json() + } finally { + clearTimeout(timer) } - return res.json() }, }) @@ -208,6 +228,27 @@ export function ConfigPage() { toast.info(t("pages.config.reset_success")) } + const handleFactoryReset = async () => { + try { + await resetAppConfig() + const fresh = await launcherFetch("/api/config").then((r) => r.json()) + const parsed = buildFormFromConfig(fresh) + setForm(parsed) + setBaseline(parsed) + await queryClient.invalidateQueries({ queryKey: ["config"] }) + await refreshGatewayState() + toast.success(t("pages.config.factory_reset_success")) + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : t("pages.config.factory_reset_error"), + ) + } finally { + setShowFactoryResetDialog(false) + } + } + const handleSave = async () => { try { setSaving(true) @@ -625,8 +666,38 @@ export function ConfigPage() { } } + const factoryResetButton = ( + + + + + + + + {t("pages.config.factory_reset_confirm_title")} + + + {t("pages.config.factory_reset_confirm_desc")} + + + + {t("common.cancel")} + + {t("pages.config.factory_reset_confirm")} + + + + + ) + const actionButtons = (
+ {factoryResetButton}