| 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 <path>", |
| 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 <path> <val>", |
| 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 <json>", |
| 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) |
| } |
|
|