From f9b2874e5e3676c8d7f3115b1bbdc38b3b1ab25b Mon Sep 17 00:00:00 2001 From: Andrew Carr Date: Wed, 18 Mar 2026 13:31:08 +1100 Subject: [PATCH] Add --label flag, list, and cleanup commands for instance management --- help.txt | 8 ++- main.go | 139 ++++++++++++++++++++++++++++++++++++++++++++++++--- main_test.go | 112 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 246 insertions(+), 13 deletions(-) diff --git a/help.txt b/help.txt index 79bac7f..5330ce0 100644 --- a/help.txt +++ b/help.txt @@ -1,11 +1,17 @@ rodney - Chrome automation from the command line Browser lifecycle: - rodney start [--show] [--insecure | -k] Launch Chrome (headless by default, --show for visible) + rodney start [--show] [--insecure | -k] [--label=NAME] + Launch Chrome (headless by default, --show for visible) rodney connect Connect to existing Chrome on remote debug port rodney stop Shut down Chrome rodney status Show browser status +Instance management: + rodney list List all discovered rodney instances + rodney cleanup Remove stale state for dead instances + rodney cleanup --all Stop all instances and remove state + Navigation: rodney open Navigate to URL rodney back Go back in history diff --git a/main.go b/main.go index a5188db..820d8d5 100644 --- a/main.go +++ b/main.go @@ -85,6 +85,7 @@ type State struct { DataDir string `json:"data_dir"` ProxyPID int `json:"proxy_pid,omitempty"` // PID of auth proxy helper ProxyPort int `json:"proxy_port,omitempty"` // local port of auth proxy + Label string `json:"label,omitempty"` // optional label to identify this instance } func stateDir() string { @@ -154,6 +155,123 @@ func getActivePage(browser *rod.Browser, s *State) (*rod.Page, error) { return pages[idx], nil } +// pidAlive checks whether a process with the given PID is still running. +func pidAlive(pid int) bool { + return syscall.Kill(pid, 0) == nil +} + +// listEntry represents a discovered rodney instance. +type listEntry struct { + Dir string + State *State + Alive bool +} + +// discoverInstances scans known locations for rodney state files. +func discoverInstances() []listEntry { + var entries []listEntry + seen := map[string]bool{} + + tryDir := func(dir string) { + sp := filepath.Join(dir, "state.json") + if seen[sp] { + return + } + seen[sp] = true + data, err := os.ReadFile(sp) + if err != nil { + return + } + var s State + if err := json.Unmarshal(data, &s); err != nil { + return + } + entries = append(entries, listEntry{ + Dir: dir, + State: &s, + Alive: s.ChromePID > 0 && pidAlive(s.ChromePID), + }) + } + + // Global default + home, _ := os.UserHomeDir() + tryDir(filepath.Join(home, ".rodney")) + + // RODNEY_HOME override + if rh := os.Getenv("RODNEY_HOME"); rh != "" { + tryDir(rh) + } + + // Local session in cwd + if cwd, err := os.Getwd(); err == nil { + tryDir(filepath.Join(cwd, ".rodney")) + } + + // Scan /tmp/rodney-* for RODNEY_HOME-based sessions + matches, _ := filepath.Glob("/tmp/rodney-*") + for _, m := range matches { + tryDir(m) + } + + return entries +} + +func cmdList(args []string) { + entries := discoverInstances() + if len(entries) == 0 { + fmt.Println("No rodney instances found") + return + } + for _, e := range entries { + status := "alive" + if !e.Alive { + status = "dead" + } + label := "" + if e.State.Label != "" { + label = fmt.Sprintf(" label=%s", e.State.Label) + } + fmt.Printf("%-30s pid=%-8d %s%s\n", e.Dir, e.State.ChromePID, status, label) + } +} + +func cmdCleanup(args []string) { + all := false + for _, a := range args { + if a == "--all" { + all = true + } + } + + entries := discoverInstances() + if len(entries) == 0 { + fmt.Println("No rodney instances found") + return + } + + cleaned := 0 + for _, e := range entries { + if e.Alive && !all { + continue + } + if e.Alive { + // --all: kill the Chrome process + syscall.Kill(e.State.ChromePID, syscall.SIGTERM) + if e.State.ProxyPID > 0 { + syscall.Kill(e.State.ProxyPID, syscall.SIGTERM) + } + fmt.Printf("Stopped: %s (pid=%d label=%s)\n", e.Dir, e.State.ChromePID, e.State.Label) + } else { + fmt.Printf("Cleaned: %s (pid=%d label=%s)\n", e.Dir, e.State.ChromePID, e.State.Label) + } + os.Remove(filepath.Join(e.Dir, "state.json")) + cleaned++ + } + if cleaned == 0 { + fmt.Println("Nothing to clean up") + } +} + func printUsage() { fmt.Print(helpText) } @@ -293,6 +411,10 @@ func main() { cmdAXFind(args) case "ax-node": cmdAXNode(args) + case "list": + cmdList(args) + case "cleanup": + cmdCleanup(args) case "help", "-h", "--help": printUsage() os.Exit(0) @@ -337,26 +459,27 @@ func withPage() (*State, *rod.Browser, *rod.Page) { // --- Commands --- // parseStartArgs parses the flags for the "start" command. -// Returns ignoreCertErrors, headless, and an error for unknown flags. -func parseStartArgs(args []string) (ignoreCertErrors bool, headless bool, err error) { +// Returns ignoreCertErrors, headless, label, and an error for unknown flags. +func parseStartArgs(args []string) (ignoreCertErrors bool, headless bool, label string, err error) { fs := flag.NewFlagSet("start", flag.ContinueOnError) fs.SetOutput(io.Discard) fs.BoolVar(&ignoreCertErrors, "insecure", false, "") fs.BoolVar(&ignoreCertErrors, "k", false, "") show := fs.Bool("show", false, "") + fs.StringVar(&label, "label", "", "") if parseErr := fs.Parse(args); parseErr != nil { - return false, true, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", findUnknownFlag(args, fs)) + return false, true, "", fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure] [--label=NAME]", findUnknownFlag(args, fs)) } if fs.NArg() > 0 { - return false, true, fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure]", fs.Arg(0)) + return false, true, "", fmt.Errorf("unknown flag: %s\nusage: rodney start [--show] [--insecure] [--label=NAME]", fs.Arg(0)) } headless = !*show - return ignoreCertErrors, headless, nil + return ignoreCertErrors, headless, label, nil } func cmdStart(args []string) { - ignoreCertErrors, headless, err := parseStartArgs(args) + ignoreCertErrors, headless, label, err := parseStartArgs(args) if err != nil { fatal("%s", err) } @@ -441,6 +564,7 @@ func cmdStart(args []string) { DataDir: dataDir, ProxyPID: proxyPID, ProxyPort: proxyPort, + Label: label, } if err := saveState(state); err != nil { @@ -540,6 +664,9 @@ func cmdStatus(args []string) { pages, _ := browser.Pages() fmt.Printf("Browser running (PID %d)\n", s.ChromePID) fmt.Printf("Debug URL: %s\n", s.DebugURL) + if s.Label != "" { + fmt.Printf("Label: %s\n", s.Label) + } fmt.Printf("Pages: %d\n", len(pages)) fmt.Printf("Active page: %d\n", s.ActivePage) if page, err := getActivePage(browser, s); err == nil { diff --git a/main_test.go b/main_test.go index 1753da9..e3e8e83 100644 --- a/main_test.go +++ b/main_test.go @@ -1079,7 +1079,7 @@ func TestFormatAssertFail_EqualityWithMessage(t *testing.T) { // ===================== func TestParseStartArgs_NoFlags(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{}) + insecure, headless, label, err := parseStartArgs([]string{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1089,10 +1089,13 @@ func TestParseStartArgs_NoFlags(t *testing.T) { if !headless { t.Error("expected headless=true with no flags") } + if label != "" { + t.Errorf("expected empty label, got %q", label) + } } func TestParseStartArgs_ShowFlag(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--show"}) + insecure, headless, _, err := parseStartArgs([]string{"--show"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1105,7 +1108,7 @@ func TestParseStartArgs_ShowFlag(t *testing.T) { } func TestParseStartArgs_InsecureFlag(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--insecure"}) + insecure, headless, _, err := parseStartArgs([]string{"--insecure"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1118,7 +1121,7 @@ func TestParseStartArgs_InsecureFlag(t *testing.T) { } func TestParseStartArgs_InsecureShortFlag(t *testing.T) { - insecure, _, err := parseStartArgs([]string{"-k"}) + insecure, _, _, err := parseStartArgs([]string{"-k"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1128,7 +1131,7 @@ func TestParseStartArgs_InsecureShortFlag(t *testing.T) { } func TestParseStartArgs_ShowAndInsecure(t *testing.T) { - insecure, headless, err := parseStartArgs([]string{"--show", "--insecure"}) + insecure, headless, _, err := parseStartArgs([]string{"--show", "--insecure"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1141,7 +1144,7 @@ func TestParseStartArgs_ShowAndInsecure(t *testing.T) { } func TestParseStartArgs_UnknownFlag(t *testing.T) { - _, _, err := parseStartArgs([]string{"--bogus"}) + _, _, _, err := parseStartArgs([]string{"--bogus"}) if err == nil { t.Fatal("expected error for unknown flag --bogus") } @@ -1150,6 +1153,103 @@ func TestParseStartArgs_UnknownFlag(t *testing.T) { } } +func TestParseStartArgs_LabelFlag(t *testing.T) { + _, _, label, err := parseStartArgs([]string{"--label=my-session"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if label != "my-session" { + t.Errorf("expected label 'my-session', got %q", label) + } +} + +func TestParseStartArgs_LabelFlagSeparate(t *testing.T) { + _, _, label, err := parseStartArgs([]string{"--label", "my-session"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if label != "my-session" { + t.Errorf("expected label 'my-session', got %q", label) + } +} + +func TestParseStartArgs_LabelWithOtherFlags(t *testing.T) { + insecure, headless, label, err := parseStartArgs([]string{"--show", "--label=test", "--insecure"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !insecure { + t.Error("expected insecure=true") + } + if headless { + t.Error("expected headless=false") + } + if label != "test" { + t.Errorf("expected label 'test', got %q", label) + } +} + +func TestStateLabelSerialization(t *testing.T) { + s := &State{ + DebugURL: "ws://127.0.0.1:9222", + ChromePID: 12345, + ActivePage: 0, + DataDir: "/tmp/test", + Label: "my-label", + } + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var loaded State + if err := json.Unmarshal(data, &loaded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if loaded.Label != "my-label" { + t.Errorf("expected label 'my-label', got %q", loaded.Label) + } +} + +func TestStateLabelOmittedWhenEmpty(t *testing.T) { + s := &State{ + DebugURL: "ws://127.0.0.1:9222", + ChromePID: 12345, + ActivePage: 0, + DataDir: "/tmp/test", + } + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(data), "label") { + t.Errorf("label should be omitted when empty, got: %s", data) + } +} + +func TestDiscoverInstances_FindsTempDirs(t *testing.T) { + dir := t.TempDir() + stateFile := filepath.Join(dir, "state.json") + data := `{"debug_url":"ws://127.0.0.1:9222","chrome_pid":99999,"active_page":0,"data_dir":"/tmp","label":"test-discover"}` + os.WriteFile(stateFile, []byte(data), 0644) + + // Set RODNEY_HOME to point to our temp dir + t.Setenv("RODNEY_HOME", dir) + + entries := discoverInstances() + found := false + for _, e := range entries { + if e.Dir == dir && e.State.Label == "test-discover" { + found = true + if e.Alive { + t.Error("expected dead instance (pid 99999 should not exist)") + } + } + } + if !found { + t.Errorf("expected to discover instance in %s", dir) + } +} + func TestInsecureFlag_WithSelfSignedCert(t *testing.T) { // Create HTTPS server with self-signed certificate mux := http.NewServeMux()