package main import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "github.com/pinchtab/pinchtab/internal/cli" "github.com/pinchtab/pinchtab/internal/config" "github.com/pinchtab/pinchtab/internal/server" "github.com/spf13/cobra" ) var clipboardExecCommand = exec.Command var configCmd = &cobra.Command{ Use: "config", Short: "Manage configuration", Run: func(cmd *cobra.Command, args []string) { handleConfigOverview(loadConfig()) }, } func init() { configCmd.GroupID = "config" configCmd.AddCommand(&cobra.Command{ Use: "show", Short: "Display current configuration", Run: func(cmd *cobra.Command, args []string) { cfg := config.Load() cli.HandleConfigShow(cfg) }, }) configCmd.AddCommand(&cobra.Command{ Use: "init", Short: "Initialize a new config file", Run: func(cmd *cobra.Command, args []string) { handleConfigInit() }, }) configCmd.AddCommand(&cobra.Command{ Use: "path", Short: "Show config file path", Run: func(cmd *cobra.Command, args []string) { handleConfigPath() }, }) configCmd.AddCommand(&cobra.Command{ Use: "validate", Short: "Validate config file", Run: func(cmd *cobra.Command, args []string) { handleConfigValidate() }, }) configCmd.AddCommand(&cobra.Command{ Use: "get ", Short: "Get a config value (e.g., server.port)", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { handleConfigGet(args[0]) }, }) configCmd.AddCommand(&cobra.Command{ Use: "set ", Short: "Set a config value (e.g., server.port 8080)", Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { handleConfigSet(args[0], args[1]) }, }) configCmd.AddCommand(&cobra.Command{ Use: "patch ", Short: "Merge JSON into config", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { handleConfigPatch(args[0]) }, }) rootCmd.AddCommand(configCmd) } func handleConfigOverview(cfg *config.RuntimeConfig) { _, configPath, err := config.LoadFileConfig() if err != nil { fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, fmt.Sprintf("Error loading config path: %v", err))) os.Exit(1) } dashPort := cfg.Port if dashPort == "" { dashPort = "9870" } dashboardURL := fmt.Sprintf("http://localhost:%s", dashPort) running := server.CheckPinchTabRunning(dashPort, cfg.Token) for { fmt.Print(renderConfigOverview(cfg, configPath, dashboardURL, running)) if !isInteractiveTerminal() { return } nextCfg, changed, done, err := promptConfigEdit(cfg) if err != nil { fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error())) fmt.Println() continue } if done { return } if !changed { fmt.Println() continue } cfg = nextCfg dashPort = cfg.Port if dashPort == "" { dashPort = "9870" } dashboardURL = fmt.Sprintf("http://localhost:%s", dashPort) running = server.CheckPinchTabRunning(dashPort, cfg.Token) fmt.Println() } } func renderConfigOverview(cfg *config.RuntimeConfig, configPath, dashboardURL string, running bool) string { out := "" out += cli.StyleStdout(cli.HeadingStyle, "Config") + "\n\n" out += fmt.Sprintf(" 1. %-18s %s\n", "Strategy", cli.StyleStdout(cli.ValueStyle, cfg.Strategy)) out += fmt.Sprintf(" 2. %-18s %s\n", "Allocation policy", cli.StyleStdout(cli.ValueStyle, cfg.AllocationPolicy)) out += fmt.Sprintf(" 3. %-18s %s\n", "Stealth level", cli.StyleStdout(cli.ValueStyle, cfg.StealthLevel)) out += fmt.Sprintf(" 4. %-18s %s\n", "Tab eviction", cli.StyleStdout(cli.ValueStyle, cfg.TabEvictionPolicy)) out += fmt.Sprintf(" 5. %-18s %s\n", "Copy token", cli.StyleStdout(cli.MutedStyle, "clipboard")) out += "\n" out += cli.StyleStdout(cli.HeadingStyle, "More") + "\n\n" out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "File:"), cli.StyleStdout(cli.ValueStyle, configPath)) out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "Token:"), cli.StyleStdout(cli.ValueStyle, config.MaskToken(cfg.Token))) if running { out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "Dashboard:"), cli.StyleStdout(cli.ValueStyle, dashboardURL)) } else { out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "Dashboard:"), cli.StyleStdout(cli.MutedStyle, "not running")) } if isInteractiveTerminal() { out += "\n" out += cli.StyleStdout(cli.MutedStyle, "Edit item (1-5, blank to exit):") + " " } out += "\n" return out } func promptConfigEdit(cfg *config.RuntimeConfig) (*config.RuntimeConfig, bool, bool, error) { choice, err := promptInput("", "") if err != nil { return nil, false, false, err } choice = strings.TrimSpace(choice) if choice == "" { return nil, false, true, nil } switch choice { case "1": nextCfg, changed, err := editConfigSelection("Instance strategy", "multiInstance.strategy", cfg.Strategy, config.ValidStrategies()) return nextCfg, changed, false, err case "2": nextCfg, changed, err := editConfigSelection("Allocation policy", "multiInstance.allocationPolicy", cfg.AllocationPolicy, config.ValidAllocationPolicies()) return nextCfg, changed, false, err case "3": nextCfg, changed, err := editConfigSelection("Default stealth level", "instanceDefaults.stealthLevel", cfg.StealthLevel, config.ValidStealthLevels()) return nextCfg, changed, false, err case "4": nextCfg, changed, err := editConfigSelection("Default tab eviction", "instanceDefaults.tabEvictionPolicy", cfg.TabEvictionPolicy, config.ValidEvictionPolicies()) return nextCfg, changed, false, err case "5": if err := copyConfigToken(cfg.Token); err != nil { return nil, false, false, err } return nil, false, false, nil default: return nil, false, false, fmt.Errorf("invalid selection %q", choice) } } func editConfigSelection(title, path, current string, values []string) (*config.RuntimeConfig, bool, error) { options := make([]menuOption, 0, len(values)+1) for _, value := range values { label := value if value == current { label += " (current)" } options = append(options, menuOption{label: label, value: value}) } options = append(options, menuOption{label: "Cancel", value: "cancel"}) picked, err := promptSelect(title, options) if err != nil { return nil, false, err } if picked == "" || picked == "cancel" { return nil, false, nil } nextCfg, changed, err := updateConfigValue(path, picked) if err != nil { return nil, false, err } if changed { fmt.Println(cli.StyleStdout(cli.SuccessStyle, fmt.Sprintf("Updated %s to %s", path, picked))) fmt.Println(cli.StyleStdout(cli.MutedStyle, "Restart PinchTab to apply file-based changes.")) } return nextCfg, changed, nil } func copyConfigToken(token string) error { if strings.TrimSpace(token) == "" { return fmt.Errorf("server token is empty") } if err := copyToClipboard(token); err == nil { fmt.Println(cli.StyleStdout(cli.SuccessStyle, "Token copied to clipboard.")) return nil } fmt.Println(cli.StyleStdout(cli.WarningStyle, "Clipboard unavailable; copy the token manually:")) fmt.Println(cli.StyleStdout(cli.ValueStyle, token)) return nil } func copyToClipboard(text string) error { candidates := clipboardCommands() var lastErr error for _, candidate := range candidates { if _, err := exec.LookPath(candidate.name); err != nil { lastErr = err continue } cmd := clipboardExecCommand(candidate.name, candidate.args...) cmd.Stdin = strings.NewReader(text) if output, err := cmd.CombinedOutput(); err != nil { if len(strings.TrimSpace(string(output))) > 0 { lastErr = fmt.Errorf("%s: %s", err, strings.TrimSpace(string(output))) } else { lastErr = err } continue } return nil } if lastErr == nil { return fmt.Errorf("no clipboard command available") } return lastErr } type clipboardCommand struct { name string args []string } func clipboardCommands() []clipboardCommand { switch runtime.GOOS { case "darwin": return []clipboardCommand{{name: "pbcopy"}} case "windows": return []clipboardCommand{{name: "clip"}} default: return []clipboardCommand{ {name: "wl-copy"}, {name: "xclip", args: []string{"-selection", "clipboard"}}, {name: "xsel", args: []string{"--clipboard", "--input"}}, } } } func handleConfigInit() { configPath := os.Getenv("PINCHTAB_CONFIG") if configPath == "" { configPath = config.DefaultConfigPath() } if _, err := os.Stat(configPath); err == nil { fmt.Printf("Config file already exists at %s\n", configPath) fmt.Print("Overwrite? (y/N): ") var response string _, _ = fmt.Scanln(&response) if response != "y" && response != "Y" { return } } if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { fmt.Printf("Error creating directory: %v\n", err) os.Exit(1) } fc := config.DefaultFileConfig() token, err := config.GenerateAuthToken() if err != nil { fmt.Printf("Error generating auth token: %v\n", err) os.Exit(1) } fc.Server.Token = token if err := config.SaveFileConfig(&fc, configPath); err != nil { fmt.Printf("Error writing config: %v\n", err) os.Exit(1) } fmt.Printf("Config file created at %s\n", configPath) } func handleConfigPath() { configPath := os.Getenv("PINCHTAB_CONFIG") if configPath == "" { configPath = config.DefaultConfigPath() } fmt.Println(configPath) } func handleConfigGet(path string) { fc, _, err := config.LoadFileConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) os.Exit(1) } value, err := config.GetConfigValue(fc, path) if err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } fmt.Println(value) } func handleConfigSet(path, value string) { fc, configPath, err := config.LoadFileConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) os.Exit(1) } if err := config.SetConfigValue(fc, path, value); err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } if errs := config.ValidateFileConfig(fc); len(errs) > 0 { fmt.Printf("Warning: new value causes validation error(s):\n") for _, err := range errs { fmt.Printf(" - %v\n", err) } fmt.Print("Save anyway? (y/N): ") var response string _, _ = fmt.Scanln(&response) if response != "y" && response != "Y" { return } } if err := config.SaveFileConfig(fc, configPath); err != nil { fmt.Printf("Error saving config: %v\n", err) os.Exit(1) } fmt.Printf("Set %s = %s\n", path, value) } func handleConfigPatch(jsonPatch string) { fc, configPath, err := config.LoadFileConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) os.Exit(1) } if err := config.PatchConfigJSON(fc, jsonPatch); err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } if errs := config.ValidateFileConfig(fc); len(errs) > 0 { fmt.Printf("Warning: patch causes validation error(s):\n") for _, err := range errs { fmt.Printf(" - %v\n", err) } fmt.Print("Save anyway? (y/N): ") var response string _, _ = fmt.Scanln(&response) if response != "y" && response != "Y" { return } } if err := config.SaveFileConfig(fc, configPath); err != nil { fmt.Printf("Error saving config: %v\n", err) os.Exit(1) } fmt.Println("Config patched successfully") } func handleConfigValidate() { configPath := os.Getenv("PINCHTAB_CONFIG") if configPath == "" { configPath = config.DefaultConfigPath() } data, err := os.ReadFile(configPath) if err != nil { fmt.Printf("Error reading config file: %v\n", err) os.Exit(1) } fc := &config.FileConfig{} if err := json.Unmarshal(data, fc); err != nil { fmt.Printf("Error parsing config: %v\n", err) os.Exit(1) } if errs := config.ValidateFileConfig(fc); len(errs) > 0 { fmt.Printf("Config file has %d error(s):\n", len(errs)) for _, err := range errs { fmt.Printf(" - %v\n", err) } os.Exit(1) } fmt.Printf("Config file is valid: %s\n", configPath) }