From 964af89e7f2f7eba9060693cbd0a0041cb58f806 Mon Sep 17 00:00:00 2001 From: ZanzyTHEbar Date: Fri, 8 May 2026 22:58:56 +0100 Subject: [PATCH 1/8] feat(auth): select login org by id Avoid reusing stale stored org IDs when login creates a new session for the authenticated user. --- cmd/auth/login/login.go | 94 ++++++++++++++++++++++++-- cmd/auth/login/login_test.go | 127 +++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 cmd/auth/login/login_test.go diff --git a/cmd/auth/login/login.go b/cmd/auth/login/login.go index ad3070e..cee747d 100644 --- a/cmd/auth/login/login.go +++ b/cmd/auth/login/login.go @@ -143,6 +143,7 @@ func loginWithWeb(hostname string) (string, error) { type LoginCmdOpts struct { Hostname string + OrgID string } func LoginCmd() *cobra.Command { @@ -170,9 +171,63 @@ func LoginCmd() *cobra.Command { }, } + cmd.Flags().StringVar(&opts.OrgID, "org-id", "", "Select an organization by ID without prompting") + return cmd } +func resolveOrgForLogin(client *api.Client, userID, orgID string) (string, error) { + orgsResp, err := client.ListUserOrgs(userID) + if err != nil { + return "", fmt.Errorf("failed to list organizations: %w", err) + } + + for _, org := range orgsResp.Orgs { + if org.OrgID == orgID { + return orgID, nil + } + } + + if len(orgsResp.Orgs) == 0 { + return "", fmt.Errorf("organization %q not found; authenticated user has no organizations", orgID) + } + + return "", fmt.Errorf("organization %q not found for authenticated user; available organizations: %s", orgID, formatOrgChoices(orgsResp.Orgs)) +} + +func orgExistsForLogin(client *api.Client, userID, orgID string) (bool, error) { + if orgID == "" { + return false, nil + } + + orgsResp, err := client.ListUserOrgs(userID) + if err != nil { + return false, fmt.Errorf("failed to list organizations: %w", err) + } + + for _, org := range orgsResp.Orgs { + if org.OrgID == orgID { + return true, nil + } + } + + return false, nil +} + +func formatOrgChoices(orgs []api.Org) string { + choices := make([]string, 0, len(orgs)) + for _, org := range orgs { + if org.Name == "" { + choices = append(choices, org.OrgID) + continue + } + + choices = append(choices, fmt.Sprintf("%s (%s)", org.OrgID, org.Name)) + } + + return strings.Join(choices, ", ") +} + func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) error { apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) @@ -283,8 +338,17 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) error { newAccount.Name = user.Name } - // Ensure new user has an organization selected - if newAccount.OrgID == "" { + // Ensure new user has an organization selected. + orgIDFlag := strings.TrimSpace(opts.OrgID) + if orgIDFlag != "" { + orgID, err := resolveOrgForLogin(apiClient, userID, orgIDFlag) + if err != nil { + logger.Error("Failed to select organization: %v", err) + return err + } + + newAccount.OrgID = orgID + } else if newAccount.OrgID == "" { orgID, err := utils.SelectOrgForm(apiClient, userID) if err != nil { logger.Error("Failed to select organization: %v", err) @@ -292,6 +356,22 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) error { } newAccount.OrgID = orgID + } else { + validOrg, err := orgExistsForLogin(apiClient, userID, newAccount.OrgID) + if err != nil { + logger.Error("Failed to validate organization: %v", err) + return err + } + + if !validOrg { + orgID, err := utils.SelectOrgForm(apiClient, userID) + if err != nil { + logger.Error("Failed to select organization: %v", err) + return err + } + + newAccount.OrgID = orgID + } } // Ensure OLM credentials exist @@ -328,11 +408,11 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) error { } else if apiServerInfo != nil { // Convert api.ServerInfo to config.ServerInfo serverInfo := &config.ServerInfo{ - Version: apiServerInfo.Version, - SupporterStatusValid: apiServerInfo.SupporterStatusValid, - Build: apiServerInfo.Build, - EnterpriseLicenseValid: apiServerInfo.EnterpriseLicenseValid, - EnterpriseLicenseType: apiServerInfo.EnterpriseLicenseType, + Version: apiServerInfo.Version, + SupporterStatusValid: apiServerInfo.SupporterStatusValid, + Build: apiServerInfo.Build, + EnterpriseLicenseValid: apiServerInfo.EnterpriseLicenseValid, + EnterpriseLicenseType: apiServerInfo.EnterpriseLicenseType, } // Update account with server info account := accountStore.Accounts[user.UserID] diff --git a/cmd/auth/login/login_test.go b/cmd/auth/login/login_test.go new file mode 100644 index 0000000..65afc26 --- /dev/null +++ b/cmd/auth/login/login_test.go @@ -0,0 +1,127 @@ +package login + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/fosrl/cli/internal/api" +) + +func TestLoginCmdRegistersOrgIDFlag(t *testing.T) { + cmd := LoginCmd() + if flag := cmd.Flags().Lookup("org-id"); flag == nil { + t.Fatal("login command is missing org-id flag") + } +} + +func TestResolveOrgForLoginValidOrgIDReturnsOrg(t *testing.T) { + client, cleanup := newOrgClient(t, []api.Org{{OrgID: "org-a", Name: "Alpha"}}) + defer cleanup() + + orgID, err := resolveOrgForLogin(client, "user-1", "org-a") + if err != nil { + t.Fatalf("resolveOrgForLogin returned error: %v", err) + } + if orgID != "org-a" { + t.Fatalf("orgID = %q, want org-a", orgID) + } +} + +func TestResolveOrgForLoginInvalidOrgIDErrorsWithAvailableOrgs(t *testing.T) { + client, cleanup := newOrgClient(t, []api.Org{ + {OrgID: "org-a", Name: "Alpha"}, + {OrgID: "org-b", Name: "Beta"}, + }) + defer cleanup() + + _, err := resolveOrgForLogin(client, "user-1", "missing") + if err == nil { + t.Fatal("resolveOrgForLogin returned nil error for invalid org") + } + for _, want := range []string{"missing", "available organizations", "org-a (Alpha)", "org-b (Beta)"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q does not contain %q", err.Error(), want) + } + } +} + +func TestResolveOrgForLoginZeroOrgsErrorsClearly(t *testing.T) { + client, cleanup := newOrgClient(t, nil) + defer cleanup() + + _, err := resolveOrgForLogin(client, "user-1", "missing") + if err == nil { + t.Fatal("resolveOrgForLogin returned nil error for zero orgs") + } + for _, want := range []string{"missing", "no organizations"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q does not contain %q", err.Error(), want) + } + } +} + +func TestOrgExistsForLogin(t *testing.T) { + client, cleanup := newOrgClient(t, []api.Org{{OrgID: "org-a", Name: "Alpha"}}) + defer cleanup() + + tests := []struct { + name string + orgID string + want bool + }{ + {name: "valid org", orgID: "org-a", want: true}, + {name: "missing org", orgID: "missing", want: false}, + {name: "empty org", orgID: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := orgExistsForLogin(client, "user-1", tt.orgID) + if err != nil { + t.Fatalf("orgExistsForLogin returned error: %v", err) + } + if got != tt.want { + t.Fatalf("orgExistsForLogin() = %v, want %v", got, tt.want) + } + }) + } +} + +func newOrgClient(t *testing.T, orgs []api.Org) (*api.Client, func()) { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/user/user-1/orgs" { + t.Errorf("path = %s, want /user/user-1/orgs", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"success":true,"error":false,"data":{"orgs":%s}}`, mustMarshalOrgs(t, orgs)) + })) + + client, err := api.NewClient(api.ClientConfig{BaseURL: server.URL}) + if err != nil { + server.Close() + t.Fatalf("NewClient returned error: %v", err) + } + + return client, server.Close +} + +func mustMarshalOrgs(t *testing.T, orgs []api.Org) string { + t.Helper() + + b, err := json.Marshal(orgs) + if err != nil { + t.Fatalf("failed to marshal orgs: %v", err) + } + + return string(b) +} From 42eb71bb698f7e70c70ccfd59c663a292732f7c7 Mon Sep 17 00:00:00 2001 From: tursom Date: Sat, 24 Oct 2026 23:28:27 +0800 Subject: [PATCH 2/8] fix(tui): bound log preview reads to avoid memory spikes --- internal/tui/logpreview.go | 70 +++++++++--- internal/tui/logpreview_test.go | 190 ++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 internal/tui/logpreview_test.go diff --git a/internal/tui/logpreview.go b/internal/tui/logpreview.go index fc83125..461b0cd 100644 --- a/internal/tui/logpreview.go +++ b/internal/tui/logpreview.go @@ -1,9 +1,10 @@ package tui import ( - "bufio" + "bytes" "io" "os" + "slices" "strings" "time" @@ -212,8 +213,17 @@ func tickStatusUpdate() tea.Cmd { }) } -// getLastLogLines reads the last N lines from a log file starting from a position +// Bound both initial reads and large bursts of new logs so the TUI does not +// scan or retain the entire log file. Lines cut by this window are omitted. +const maxLogPreviewBytes = 64 * 1024 + +// getLastLogLines returns up to n of the newest lines since lastPos, reading +// at most maxLogPreviewBytes plus one byte to check the first line boundary. func getLastLogLines(logPath string, n int, lastPos int64) ([]string, int64) { + if n <= 0 { + return []string{}, lastPos + } + file, err := os.Open(logPath) if err != nil { return []string{}, lastPos @@ -228,24 +238,56 @@ func getLastLogLines(logPath string, n int, lastPos int64) ([]string, int64) { fileSize := info.Size() - // If file hasn't grown, return empty - if fileSize <= lastPos { + // Recover if the log was truncated or replaced with a smaller file. + if lastPos < 0 || lastPos > fileSize { + lastPos = 0 + } + if fileSize == lastPos { return []string{}, lastPos } - // Read from lastPos to end - file.Seek(lastPos, io.SeekStart) - scanner := bufio.NewScanner(file) + start := lastPos + if fileSize-start > maxLogPreviewBytes { + start = fileSize - maxLogPreviewBytes + } + clipped := start > lastPos + if clipped { + start-- // Include the preceding byte to detect a line boundary. + } - var lines []string - for scanner.Scan() { - lines = append(lines, scanner.Text()) + // Read only up to the size observed above, even if the writer keeps appending. + data := make([]byte, fileSize-start) + read, err := file.ReadAt(data, start) + if err != nil && err != io.EOF { + return []string{}, lastPos + } + data = data[:read] + newPos := start + int64(read) + if clipped { + // Drop the preceding byte and any partial line at the window boundary. + if newline := bytes.IndexByte(data, '\n'); newline >= 0 { + data = data[newline+1:] + } else { + data = nil + } + } + if len(data) == 0 { + return []string{}, newPos } - // Return last N lines - if len(lines) > n { - lines = lines[len(lines)-n:] + // Work backwards and allocate strings only for the lines being displayed. + data = bytes.TrimSuffix(data, []byte{'\n'}) + var lines []string + for len(lines) < n { + newline := bytes.LastIndexByte(data, '\n') + line := bytes.TrimSuffix(data[newline+1:], []byte{'\r'}) + lines = append(lines, string(line)) + if newline < 0 { + break + } + data = data[:newline] } + slices.Reverse(lines) - return lines, fileSize + return lines, newPos } diff --git a/internal/tui/logpreview_test.go b/internal/tui/logpreview_test.go new file mode 100644 index 0000000..47d2c2d --- /dev/null +++ b/internal/tui/logpreview_test.go @@ -0,0 +1,190 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestGetLastLogLines(t *testing.T) { + tests := []struct { + name string + content string + n int + lastPos int64 + want []string + }{ + {name: "empty", n: 5}, + {name: "fewer lines", content: "one\ntwo\n", n: 5, want: []string{"one", "two"}}, + {name: "last lines", content: "one\ntwo\nthree\n", n: 2, want: []string{"two", "three"}}, + {name: "no trailing newline", content: "one\ntwo", n: 1, want: []string{"two"}}, + {name: "CRLF", content: "one\r\ntwo\r\n", n: 5, want: []string{"one", "two"}}, + {name: "blank lines", content: "\none\n\n", n: 5, want: []string{"", "one", ""}}, + {name: "single blank line", content: "\n", n: 5, want: []string{""}}, + {name: "unchanged", content: "one\n", n: 5, lastPos: 4}, + {name: "incremental", content: "one\ntwo\n", n: 5, lastPos: 4, want: []string{"two"}}, + {name: "negative offset", content: "one\n", n: 5, lastPos: -1, want: []string{"one"}}, + {name: "zero lines", content: "one\n", n: 0}, + {name: "negative lines", content: "one\n", n: -1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "client.log") + if err := os.WriteFile(path, []byte(tt.content), 0o600); err != nil { + t.Fatal(err) + } + lines, pos := getLastLogLines(path, tt.n, tt.lastPos) + if !slices.Equal(lines, tt.want) { + t.Errorf("lines = %q, want %q", lines, tt.want) + } + wantPos := int64(len(tt.content)) + if tt.n <= 0 { + wantPos = tt.lastPos + } + if pos != wantPos { + t.Errorf("position = %d, want %d", pos, wantPos) + } + }) + } +} + +func TestGetLastLogLinesMissingFile(t *testing.T) { + lines, pos := getLastLogLines(filepath.Join(t.TempDir(), "missing.log"), 5, 123) + if len(lines) != 0 || pos != 123 { + t.Fatalf("got (%q, %d), want no lines and unchanged position", lines, pos) + } +} + +func TestGetLastLogLinesReadWindow(t *testing.T) { + longLine := strings.Repeat("x", maxLogPreviewBytes) + alignedLine := strings.Repeat("x", maxLogPreviewBytes-12) + crlfLine := strings.Repeat("x", maxLogPreviewBytes-9) + tests := []struct { + name string + content string + want []string + }{ + {name: "partial first line", content: longLine + "\none\ntwo\n", want: []string{"one", "two"}}, + {name: "line boundary", content: "old\nfirst\n" + alignedLine + "\nlast\n", want: []string{"first", alignedLine, "last"}}, + {name: "CRLF boundary", content: "old\r\n" + crlfLine + "\r\nlast\r\n", want: []string{crlfLine, "last"}}, + {name: "oversized last line", content: longLine + "x"}, + {name: "oversized terminated line", content: longLine + "x\n"}, + {name: "after oversized line", content: longLine + "x\nlast", want: []string{"last"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "client.log") + if err := os.WriteFile(path, []byte(tt.content), 0o600); err != nil { + t.Fatal(err) + } + lines, pos := getLastLogLines(path, 5, 0) + if !slices.Equal(lines, tt.want) { + t.Errorf("unexpected preview: got %d lines, want %d", len(lines), len(tt.want)) + } + if pos != int64(len(tt.content)) { + t.Errorf("position = %d, want %d", pos, len(tt.content)) + } + }) + } +} + +func TestGetLastLogLinesWindowThreshold(t *testing.T) { + for _, size := range []int{maxLogPreviewBytes - 1, maxLogPreviewBytes, maxLogPreviewBytes + 1} { + for _, lastPos := range []int64{0, 4} { + t.Run(fmt.Sprintf("size_%d_offset_%d", size, lastPos), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "client.log") + first := strings.Repeat("x", size-len("\nlast\n")) + content := strings.Repeat("p", int(lastPos)) + first + "\nlast\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + want := []string{first, "last"} + if size > maxLogPreviewBytes { + want = []string{"last"} + } + lines, pos := getLastLogLines(path, 5, lastPos) + if !slices.Equal(lines, want) || pos != int64(len(content)) { + t.Fatalf("unexpected preview or position: %d lines, position %d", len(lines), pos) + } + }) + } + } +} + +func TestGetLastLogLinesLargeFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "client.log") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + // A sparse file exercises multi-GiB offsets without writing GiB of test data. + const offset int64 = 8 << 30 + const tail = "\none\ntwo\nthree\nfour\nfive\nsix\n" + if _, err := file.WriteAt([]byte(tail), offset); err != nil { + t.Fatal(err) + } + for _, lastPos := range []int64{0, 4} { + t.Run(fmt.Sprintf("offset_%d", lastPos), func(t *testing.T) { + lines, pos := getLastLogLines(path, 5, lastPos) + want := []string{"two", "three", "four", "five", "six"} + if !slices.Equal(lines, want) || pos != offset+int64(len(tail)) { + t.Fatalf("got (%q, %d), want (%q, %d)", lines, pos, want, offset+int64(len(tail))) + } + }) + } +} + +func TestGetLastLogLinesAfterTruncate(t *testing.T) { + path := filepath.Join(t.TempDir(), "client.log") + if err := os.WriteFile(path, []byte("old log line\n"), 0o600); err != nil { + t.Fatal(err) + } + _, lastPos := getLastLogLines(path, 5, 0) + for _, step := range []struct { + content string + want []string + }{ + {content: "new\n", want: []string{"new"}}, + {content: ""}, + {content: "one\n", want: []string{"one"}}, + {content: "one\ntwo\n", want: []string{"two"}}, + {content: "one\ntwo\n"}, + } { + if err := os.WriteFile(path, []byte(step.content), 0o600); err != nil { + t.Fatal(err) + } + lines, pos := getLastLogLines(path, 5, lastPos) + if !slices.Equal(lines, step.want) || pos != int64(len(step.content)) { + t.Fatalf("got (%q, %d), want (%q, %d)", lines, pos, step.want, len(step.content)) + } + lastPos = pos + } +} + +func BenchmarkGetLastLogLines(b *testing.B) { + for _, size := range []int{64 << 10, 16 << 20} { + b.Run(fmt.Sprintf("%dKiB", size>>10), func(b *testing.B) { + path := filepath.Join(b.TempDir(), "client.log") + line := "INFO: synthetic log entry for preview benchmark\n" + content := strings.Repeat(line, size/len(line)) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + b.Fatal(err) + } + for _, lastPos := range []int64{0, int64(len(line))} { + b.Run(fmt.Sprintf("offset_%d", lastPos), func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + lines, _ := getLastLogLines(path, 5, lastPos) + if len(lines) != 5 { + b.Fatalf("got %d lines, want 5", len(lines)) + } + } + }) + } + }) + } +} From 398f1c2dd93db561aed03257ada8cbc880b89e9f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 27 Oct 2026 14:43:11 -0400 Subject: [PATCH 3/8] store exit node per org --- cmd/select/exitnode/exitnode.go | 6 +-- cmd/select/org/org.go | 2 + cmd/up/client/client.go | 4 +- cmd/up/client/exitnode.go | 41 ----------------- cmd/up/client/exitnode_test.go | 80 --------------------------------- internal/config/accounts.go | 58 ++++++++++++++++++++---- internal/utils/exitnode.go | 68 ++++++++++++++++++++++++++++ 7 files changed, 124 insertions(+), 135 deletions(-) delete mode 100644 cmd/up/client/exitnode_test.go create mode 100644 internal/utils/exitnode.go diff --git a/cmd/select/exitnode/exitnode.go b/cmd/select/exitnode/exitnode.go index fbe6027..64d000a 100644 --- a/cmd/select/exitnode/exitnode.go +++ b/cmd/select/exitnode/exitnode.go @@ -96,7 +96,7 @@ func exitNodeMain(cmd *cobra.Command, opts *ExitNodeCmdOpts) error { } } // The saved exit node, scoped to the account's current org. - savedResourceID := activeAccount.ExitNodeResourceID + savedResourceID := activeAccount.ExitNode(orgID) // With a running client, the active exit node is the one it reports; // otherwise it is the saved one, which the next start will apply. isActive := func(g api.SiteResource) bool { @@ -143,7 +143,7 @@ func exitNodeMain(cmd *cobra.Command, opts *ExitNodeCmdOpts) error { return err } } - activeAccount.ClearExitNode() + activeAccount.ClearExitNode(orgID) if !saveExitNode(accountStore, activeAccount, running) { return fmt.Errorf("failed to save exit node") } @@ -158,7 +158,7 @@ func exitNodeMain(cmd *cobra.Command, opts *ExitNodeCmdOpts) error { return err } } - activeAccount.SetExitNode(selected.SiteResourceID) + activeAccount.SetExitNode(orgID, selected.SiteResourceID) if !saveExitNode(accountStore, activeAccount, running) { return fmt.Errorf("failed to save exit node") } diff --git a/cmd/select/org/org.go b/cmd/select/org/org.go index 1df4df1..e91c84b 100644 --- a/cmd/select/org/org.go +++ b/cmd/select/org/org.go @@ -112,6 +112,8 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) error { switched := utils.SwitchActiveClientOrg(selectedOrgID) if switched { monitorOrgSwitch(cfg.LogFile, selectedOrgID) + // Restore this org's saved exit node in the running client. + utils.ApplySavedExitNodeToActiveClient(account.ExitNode(selectedOrgID), selectedOrgID, apiClient.ListGatewayResources) } else { logger.Success("Successfully selected organization: %s", selectedOrgID) } diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go index 76ec986..ae87271 100644 --- a/cmd/up/client/client.go +++ b/cmd/up/client/client.go @@ -444,7 +444,7 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) if credentialsFromKeyring { list = apiClient.ListGatewayResources if activeAccount, err := accountStore.ActiveAccount(); err == nil { - savedResourceID = activeAccount.ExitNodeResourceID + savedResourceID = activeAccount.ExitNode(orgID) } } if opts.ExitNode != "" { @@ -457,7 +457,7 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) return err } } else { - opts.GatewaySiteResourceID, opts.GatewaySiteIDs = resolveSavedExitNode(savedResourceID, orgID, list) + opts.GatewaySiteResourceID, opts.GatewaySiteIDs = utils.ResolveSavedExitNode(savedResourceID, orgID, list) } } else if len(opts.GatewaySiteIDs) > 0 && opts.GatewaySiteResourceID <= 0 { err := fmt.Errorf("--exit-node-site-ids requires --exit-node-resource-id") diff --git a/cmd/up/client/exitnode.go b/cmd/up/client/exitnode.go index c1f3f32..765def7 100644 --- a/cmd/up/client/exitnode.go +++ b/cmd/up/client/exitnode.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/fosrl/cli/internal/api" - "github.com/fosrl/cli/internal/logger" ) // resolveExitNodeByNiceID returns the resource ID and current site IDs of the @@ -36,43 +35,3 @@ func resolveExitNodeByNiceID(niceID string, orgID string, list func(orgID string return 0, nil, fmt.Errorf("exit node '%s' not found", niceID) } - -// resolveSavedExitNode returns the resource ID and current site IDs of the exit -// node saved by `pangolin select exit-node` on the active account, or 0/nil if -// none should be applied. Only the resource ID is saved (scoped to the -// account's org), so its sites always come from the server and can't be stale -// (or break if the resource's niceId was renamed away). -// -// list is nil when there's no user session to query the server with. If the -// saved exit node can't be resolved for any reason, the client connects -// without one and says why. -func resolveSavedExitNode(savedResourceID int, orgID string, list func(orgID string) ([]api.SiteResource, error)) (int, []int) { - if savedResourceID == 0 { - return 0, nil - } - - if list == nil || orgID == "" { - logger.Info("Saved exit node needs a logged-in session to look up; not using it (pass --exit-node-site-ids to set one explicitly)") - return 0, nil - } - - gateways, err := list(orgID) - if err != nil { - logger.Warning("Could not look up saved exit node (%v); connecting without it", err) - return 0, nil - } - - for _, g := range gateways { - if g.SiteResourceID != savedResourceID { - continue - } - if !g.Enabled || len(g.SiteIDs) == 0 { - logger.Warning("Saved exit node '%s' is disabled or has no sites; not using it", g.NiceID) - return 0, nil - } - return g.SiteResourceID, g.SiteIDs - } - - logger.Warning("Saved exit node no longer exists; not using it. Run 'pangolin select exit-node' and choose None to forget it") - return 0, nil -} diff --git a/cmd/up/client/exitnode_test.go b/cmd/up/client/exitnode_test.go deleted file mode 100644 index 440eef1..0000000 --- a/cmd/up/client/exitnode_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package client - -import ( - "errors" - "reflect" - "testing" - - "github.com/fosrl/cli/internal/api" -) - -func TestResolveExitNodeByNiceID(t *testing.T) { - lister := func(gws ...api.SiteResource) func(string) ([]api.SiteResource, error) { - return func(string) ([]api.SiteResource, error) { return gws, nil } - } - - tests := []struct { - name string - niceID string - org string - list func(string) ([]api.SiteResource, error) - want []int - wantID int - wantErr bool - }{ - {"matches by niceId", "gw-a", "org1", - lister(api.SiteResource{SiteResourceID: 11, NiceID: "gw-b", Enabled: true, SiteIDs: []int{1, 2}}, api.SiteResource{SiteResourceID: 12, NiceID: "gw-a", Enabled: true, SiteIDs: []int{2, 3}}), []int{2, 3}, 12, false}, - {"not found", "gw-a", "org1", lister(api.SiteResource{SiteResourceID: 11, NiceID: "gw-b", Enabled: true, SiteIDs: []int{1, 2}}), nil, 0, true}, - {"numeric id is not a niceId", "12", "org1", lister(api.SiteResource{SiteResourceID: 12, NiceID: "gw-a", Enabled: true, SiteIDs: []int{1, 2}}), nil, 0, true}, - {"resource disabled", "gw-a", "org1", lister(api.SiteResource{SiteResourceID: 12, NiceID: "gw-a", Enabled: false, SiteIDs: []int{1, 2}}), nil, 0, true}, - {"resource has no sites", "gw-a", "org1", lister(api.SiteResource{SiteResourceID: 12, NiceID: "gw-a", Enabled: true}), nil, 0, true}, - {"lookup fails", "gw-a", "org1", func(string) ([]api.SiteResource, error) { return nil, errors.New("boom") }, nil, 0, true}, - {"no session to look it up with", "gw-a", "org1", nil, nil, 0, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotID, got, err := resolveExitNodeByNiceID(tt.niceID, tt.org, tt.list) - if (err != nil) != tt.wantErr { - t.Fatalf("got err %v, wantErr %v", err, tt.wantErr) - } - if !reflect.DeepEqual(got, tt.want) || gotID != tt.wantID { - t.Fatalf("got %d %v, want %d %v", gotID, got, tt.wantID, tt.want) - } - }) - } -} - -func TestResolveSavedExitNode(t *testing.T) { - const savedResourceID = 12 - lister := func(gws ...api.SiteResource) func(string) ([]api.SiteResource, error) { - return func(string) ([]api.SiteResource, error) { return gws, nil } - } - - tests := []struct { - name string - resource int - org string - list func(string) ([]api.SiteResource, error) - want []int - wantID int - }{ - {"nothing saved", 0, "org1", lister(), nil, 0}, - {"uses the resource's current sites", savedResourceID, "org1", - lister(api.SiteResource{SiteResourceID: 11, NiceID: "gw-b", Enabled: true, SiteIDs: []int{1, 2}}, api.SiteResource{SiteResourceID: 12, NiceID: "gw-a", Enabled: true, SiteIDs: []int{2, 3}}), []int{2, 3}, 12}, - {"resource deleted", savedResourceID, "org1", lister(api.SiteResource{SiteResourceID: 11, NiceID: "gw-b", Enabled: true, SiteIDs: []int{1, 2}}), nil, 0}, - {"resource disabled", savedResourceID, "org1", lister(api.SiteResource{SiteResourceID: savedResourceID, NiceID: "gw-a", Enabled: false, SiteIDs: []int{1, 2}}), nil, 0}, - {"resource has no sites", savedResourceID, "org1", lister(api.SiteResource{SiteResourceID: savedResourceID, NiceID: "gw-a", Enabled: true}), nil, 0}, - {"lookup fails: connect without it", savedResourceID, "org1", func(string) ([]api.SiteResource, error) { return nil, errors.New("boom") }, nil, 0}, - {"no session to look it up with", savedResourceID, "org1", nil, nil, 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotID, got := resolveSavedExitNode(tt.resource, tt.org, tt.list) - if !reflect.DeepEqual(got, tt.want) || gotID != tt.wantID { - t.Fatalf("got %d %v, want %d %v", gotID, got, tt.wantID, tt.want) - } - }) - } -} diff --git a/internal/config/accounts.go b/internal/config/accounts.go index ae4ae23..0335a54 100644 --- a/internal/config/accounts.go +++ b/internal/config/accounts.go @@ -30,23 +30,63 @@ type Account struct { OlmCredentials *OlmCredentials `mapstructure:"olmCredentials" json:"olmCredentials,omitempty"` ServerInfo *ServerInfo `mapstructure:"serverInfo" json:"serverInfo,omitempty"` + OrgSettings map[string]OrgSettings `mapstructure:"orgSettings" json:"orgSettings,omitempty"` +} + +type OrgSettings struct { // The exit node (a gateway-mode site resource) selected with `pangolin - // select exit-node`, re-applied by `pangolin up`. It can differ per - // account, so it's stored here rather than on the root config, and it - // belongs to the account's currently selected org (OrgID above). Only the + // select exit-node`, re-applied by `pangolin up` for this org. Only the // resource ID is stored (not the niceId, which can be renamed); its sites // are looked up from the server on every start so they can't go stale. ExitNodeResourceID int `mapstructure:"exitNodeResourceId" json:"exitNodeResourceId,omitempty"` } -// SetExitNode records the account's selected exit node (a gateway resource). -func (a *Account) SetExitNode(resourceID int) { - a.ExitNodeResourceID = resourceID +func (s OrgSettings) IsEmpty() bool { + return s.ExitNodeResourceID == 0 +} + +// GetOrgSettings returns the account's settings for orgID, or empty defaults +// if none are stored. +func (a *Account) GetOrgSettings(orgID string) OrgSettings { + return a.OrgSettings[orgID] +} + +// UpdateOrgSettings updates the account's settings for orgID. Empty settings +// remove that org's entry. +func (a *Account) UpdateOrgSettings(orgID string, update func(*OrgSettings)) { + settings := make(map[string]OrgSettings, len(a.OrgSettings)+1) + for id, s := range a.OrgSettings { + settings[id] = s + } + org := settings[orgID] + update(&org) + if org.IsEmpty() { + delete(settings, orgID) + } else { + settings[orgID] = org + } + if len(settings) == 0 { + settings = nil + } + a.OrgSettings = settings +} + +// ExitNode returns the resource ID of the exit node selected for orgID, or 0 +// for none. +func (a *Account) ExitNode(orgID string) int { + return a.GetOrgSettings(orgID).ExitNodeResourceID +} + +// SetExitNode records the exit node (a gateway resource) selected for orgID. +func (a *Account) SetExitNode(orgID string, resourceID int) { + a.UpdateOrgSettings(orgID, func(s *OrgSettings) { + s.ExitNodeResourceID = resourceID + }) } -// ClearExitNode forgets the account's selected exit node. -func (a *Account) ClearExitNode() { - a.SetExitNode(0) +// ClearExitNode forgets the exit node selected for orgID. +func (a *Account) ClearExitNode(orgID string) { + a.SetExitNode(orgID, 0) } type OlmCredentials struct { diff --git a/internal/utils/exitnode.go b/internal/utils/exitnode.go new file mode 100644 index 0000000..b64cfb3 --- /dev/null +++ b/internal/utils/exitnode.go @@ -0,0 +1,68 @@ +package utils + +import ( + "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/olm" +) + +// ResolveSavedExitNode returns the resource ID and current site IDs of the exit +// node saved by `pangolin select exit-node` on the active account, or 0/nil if +// none should be applied. Only the resource ID is saved (per account and org), +// so its sites always come from the server and can't be stale +// (or break if the resource's niceId was renamed away). +// +// list is nil when there's no user session to query the server with. If the +// saved exit node can't be resolved for any reason, the client connects +// without one and says why. +func ResolveSavedExitNode(savedResourceID int, orgID string, list func(orgID string) ([]api.SiteResource, error)) (int, []int) { + if savedResourceID == 0 { + return 0, nil + } + + if list == nil || orgID == "" { + logger.Info("Saved exit node needs a logged-in session to look up; not using it (pass --exit-node-site-ids to set one explicitly)") + return 0, nil + } + + gateways, err := list(orgID) + if err != nil { + logger.Warning("Could not look up saved exit node (%v); connecting without it", err) + return 0, nil + } + + for _, g := range gateways { + if g.SiteResourceID != savedResourceID { + continue + } + if !g.Enabled || len(g.SiteIDs) == 0 { + logger.Warning("Saved exit node '%s' is disabled or has no sites; not using it", g.NiceID) + return 0, nil + } + return g.SiteResourceID, g.SiteIDs + } + + logger.Warning("Saved exit node no longer exists; not using it. Run 'pangolin select exit-node' and choose None to forget it") + return 0, nil +} + +// ApplySavedExitNodeToActiveClient applies orgID's saved exit node (or none) to +// the running client after an org switch, so the new org's choice is restored +// without touching other orgs' saved choices. +func ApplySavedExitNodeToActiveClient(savedResourceID int, orgID string, list func(orgID string) ([]api.SiteResource, error)) { + client := olm.NewClient("") + if !client.IsRunning() { + return + } + + resourceID, siteIDs := ResolveSavedExitNode(savedResourceID, orgID, list) + if resourceID != 0 { + if _, err := client.SelectGateway(resourceID, siteIDs); err != nil { + logger.Warning("Failed to apply saved exit node for this organization: %v", err) + } + return + } + if _, err := client.DisableGateway(); err != nil { + logger.Warning("Failed to clear the previous organization's exit node: %v", err) + } +} From 60c8d7107dcfc857716e140c43f72f9ef6228d62 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 27 Oct 2026 15:14:38 -0400 Subject: [PATCH 4/8] replace log scroller loader with loading indicator --- cmd/down/client/client.go | 9 +- cmd/select/org/org.go | 14 +- cmd/up/client/client.go | 9 +- internal/tui/loading.go | 145 ++++++++++++++++ internal/tui/logpreview.go | 293 -------------------------------- internal/tui/logpreview_test.go | 190 --------------------- 6 files changed, 156 insertions(+), 504 deletions(-) create mode 100644 internal/tui/loading.go delete mode 100644 internal/tui/logpreview.go delete mode 100644 internal/tui/logpreview_test.go diff --git a/cmd/down/client/client.go b/cmd/down/client/client.go index 6629481..d27ea21 100644 --- a/cmd/down/client/client.go +++ b/cmd/down/client/client.go @@ -4,7 +4,6 @@ import ( "errors" "os" - "github.com/fosrl/cli/internal/config" "github.com/fosrl/cli/internal/logger" "github.com/fosrl/cli/internal/olm" "github.com/fosrl/cli/internal/tui" @@ -27,8 +26,6 @@ func ClientDownCmd() *cobra.Command { } func clientDownMain(cmd *cobra.Command) error { - cfg := config.ConfigFromContext(cmd.Context()) - // Get socket path from config or use default client := olm.NewClient("") @@ -59,10 +56,8 @@ func clientDownMain(cmd *cobra.Command) error { return err } - // Show log preview until process stops - completed, _, err := tui.NewLogPreview(tui.LogPreviewConfig{ - LogFile: cfg.LogFile, - Header: "Shutting down client...", + completed, _, err := tui.NewLoading(tui.LoadingConfig{ + Header: "Shutting down client...", ExitCondition: func(client *olm.Client, status *olm.StatusResponse) (bool, bool) { // Exit when process is no longer running (socket doesn't exist) if !client.IsRunning() { diff --git a/cmd/select/org/org.go b/cmd/select/org/org.go index e91c84b..abb587f 100644 --- a/cmd/select/org/org.go +++ b/cmd/select/org/org.go @@ -47,7 +47,6 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) error { apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) - cfg := config.ConfigFromContext(cmd.Context()) activeAccount, err := accountStore.ActiveAccount() if err != nil { @@ -111,7 +110,7 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) error { // Switch active client if running (and started by this CLI) switched := utils.SwitchActiveClientOrg(selectedOrgID) if switched { - monitorOrgSwitch(cfg.LogFile, selectedOrgID) + monitorOrgSwitch(selectedOrgID) // Restore this org's saved exit node in the running client. utils.ApplySavedExitNodeToActiveClient(account.ExitNode(selectedOrgID), selectedOrgID, apiClient.ListGatewayResources) } else { @@ -121,12 +120,10 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) error { return nil } -// monitorOrgSwitch monitors the organization switch process with log preview -func monitorOrgSwitch(logFile string, orgID string) { - // Show live log preview and status during switch - completed, _, err := tui.NewLogPreview(tui.LogPreviewConfig{ - LogFile: logFile, - Header: "Switching organization...", +// monitorOrgSwitch monitors the organization switch process with a loading indicator +func monitorOrgSwitch(orgID string) { + completed, _, err := tui.NewLoading(tui.LoadingConfig{ + Header: "Switching organization...", ExitCondition: func(client *olm.Client, status *olm.StatusResponse) (bool, bool) { // Exit when orgId matches new org AND interface is registered again if status != nil && status.OrgID == orgID && status.Registered { @@ -150,7 +147,6 @@ func monitorOrgSwitch(logFile string, orgID string) { }, }) - // Clear the TUI lines after completion if completed { logger.Success("Successfully switched organization to: %s", orgID) } else if err != nil { diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go index ae87271..4af66ce 100644 --- a/cmd/up/client/client.go +++ b/cmd/up/client/client.go @@ -150,7 +150,7 @@ logs, and removes the service again when you press Ctrl+C.`, } if opts.Attached && opts.Silent { - return errors.New("--silent and --attached options conflict") + return errors.New("--silent and --attach options conflict") } cfg := config.ConfigFromContext(cmd.Context()) @@ -644,10 +644,9 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) return nil } - // Show live log preview and status - completed, statusError, err := tui.NewLogPreview(tui.LogPreviewConfig{ - LogFile: logFile, - Header: "Starting up client...", + // Show loading indicator until the client is connected and registered + completed, statusError, err := tui.NewLoading(tui.LoadingConfig{ + Header: "Starting up client...", ExitCondition: func(client *olm.Client, status *olm.StatusResponse) (bool, bool) { // Exit when both connected and registered if status != nil && status.Connected && status.Registered { diff --git a/internal/tui/loading.go b/internal/tui/loading.go new file mode 100644 index 0000000..7f629c5 --- /dev/null +++ b/internal/tui/loading.go @@ -0,0 +1,145 @@ +package tui + +import ( + "fmt" + "os" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/olm" +) + +// ExitCondition determines if the loading indicator should exit. +// Returns whether to exit, and a completion status (true = success). +type ExitCondition func(client *olm.Client, status *olm.StatusResponse) (shouldExit bool, completed bool) + +// StatusFormatter formats the status text shown beside the spinner. +type StatusFormatter func(isRunning bool, status *olm.StatusResponse) string + +// LoadingConfig configures the loading indicator TUI. +type LoadingConfig struct { + Header string + ExitCondition ExitCondition + OnEarlyExit func(client *olm.Client) // Called when user exits early (Ctrl+C) + OnError func(client *olm.Client, error *olm.StatusError) // Called when error is detected before registration + StatusFormatter StatusFormatter // Status formatter (required) +} + +var loadingSpinner = spinner.Spinner{ + Frames: []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}, + FPS: time.Second / 10, +} + +type loadingModel struct { + config LoadingConfig + olmClient *olm.Client + status *olm.StatusResponse + completed bool + error *olm.StatusError + spinner spinner.Model + quitting bool +} + +func NewLoading(config LoadingConfig) (completed bool, statusError *olm.StatusError, err error) { + s := spinner.New() + s.Spinner = loadingSpinner + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("250")) + + model := &loadingModel{ + config: config, + olmClient: olm.NewClient(""), + spinner: s, + } + + program := tea.NewProgram(model) + finalModel, err := program.Run() + if err != nil { + return false, nil, err + } + + // Erase the spinner line so the caller's result message replaces it. + fmt.Fprint(os.Stdout, "\r\x1b[2K") + + if m, ok := finalModel.(*loadingModel); ok { + return m.completed, m.error, nil + } + return false, nil, nil +} + +func (m *loadingModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, tickStatusUpdate()) +} + +func (m *loadingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + if m.config.OnEarlyExit != nil { + m.config.OnEarlyExit(m.olmClient) + } + m.completed = false + m.quitting = true + return m, tea.Quit + } + return m, nil + + case statusUpdateMsg: + isRunning := m.olmClient.IsRunning() + + if isRunning { + status, err := m.olmClient.GetStatus() + if err == nil { + m.status = status + + // Check for errors before registration + if status.Error != nil && !status.Registered { + m.error = status.Error + if m.config.OnError != nil { + m.config.OnError(m.olmClient, status.Error) + } + m.completed = false + m.quitting = true + return m, tea.Quit + } + } + } else { + m.status = nil + } + + if m.config.ExitCondition != nil { + shouldExit, completed := m.config.ExitCondition(m.olmClient, m.status) + if shouldExit { + m.completed = completed + m.quitting = true + return m, tea.Quit + } + } + return m, tickStatusUpdate() + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + return m, nil +} + +func (m *loadingModel) View() string { + if m.quitting { + return "" + } + statusText := m.config.StatusFormatter(m.olmClient.IsRunning(), m.status) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color(logger.ColorLightGray)) + return fmt.Sprintf("%s %s %s", m.spinner.View(), m.config.Header, muted.Render("· "+statusText)) +} + +type statusUpdateMsg struct{} + +func tickStatusUpdate() tea.Cmd { + return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { + return statusUpdateMsg{} + }) +} diff --git a/internal/tui/logpreview.go b/internal/tui/logpreview.go deleted file mode 100644 index 461b0cd..0000000 --- a/internal/tui/logpreview.go +++ /dev/null @@ -1,293 +0,0 @@ -package tui - -import ( - "bytes" - "io" - "os" - "slices" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/fosrl/cli/internal/logger" - "github.com/fosrl/cli/internal/olm" -) - -// ExitCondition is a function that determines if the preview should exit -// Returns true if should exit, and a completion status (true = success, false = user cancelled) -type ExitCondition func(client *olm.Client, status *olm.StatusResponse) (shouldExit bool, completed bool) - -// StatusFormatter is a function that formats the status display -type StatusFormatter func(isRunning bool, status *olm.StatusResponse) string - -// LogPreviewConfig configures the log preview TUI -type LogPreviewConfig struct { - LogFile string - Header string - ExitCondition ExitCondition - OnEarlyExit func(client *olm.Client) // Called when user exits early (Ctrl+C) - OnError func(client *olm.Client, error *olm.StatusError) // Called when error is detected before registration - StatusFormatter StatusFormatter // Status formatter (required) -} - -// logPreviewModel is the bubbletea model for the live log preview -type logPreviewModel struct { - config LogPreviewConfig - olmClient *olm.Client - logLines []string - status *olm.StatusResponse - lastLogPos int64 - completedTime *time.Time - completed bool - width int - error *olm.StatusError // Store error to return after TUI exits -} - -// NewLogPreview creates and runs a new log preview TUI -// Returns completed status, error from status (if any), and any TUI error -func NewLogPreview(config LogPreviewConfig) (completed bool, statusError *olm.StatusError, err error) { - model := &logPreviewModel{ - config: config, - olmClient: olm.NewClient(""), - logLines: []string{}, - } - - program := tea.NewProgram(model) - finalModel, err := program.Run() - if err != nil { - return false, nil, err - } - - if previewModel, ok := finalModel.(*logPreviewModel); ok { - return previewModel.completed, previewModel.error, nil - } - return false, nil, nil -} - -// Init initializes the model -func (m *logPreviewModel) Init() tea.Cmd { - // Start with initial delay, then start ticking - return tea.Batch( - tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg { - // After delay, start the tickers - return initCompleteMsg{} - }), - tickStatusUpdate(), - ) -} - -// Update handles messages -func (m *logPreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyCtrlC: - // User exited early - call callback if provided - if m.config.OnEarlyExit != nil { - m.config.OnEarlyExit(m.olmClient) - } - m.completed = false - return m, tea.Quit - } - return m, nil - - case initCompleteMsg: - // Start log updates after initial delay - return m, tickLogUpdate() - - case logUpdateMsg: - // Update log lines - lines, newPos := getLastLogLines(m.config.LogFile, 5, m.lastLogPos) - if newPos != m.lastLogPos { - m.lastLogPos = newPos - m.logLines = lines - } - return m, tickLogUpdate() - - case statusUpdateMsg: - // Update status - isRunning := m.olmClient.IsRunning() - - if isRunning { - status, err := m.olmClient.GetStatus() - if err == nil { - m.status = status - - // Check for errors before registration - if status.Error != nil && !status.Registered { - // Store error to return after TUI exits - m.error = status.Error - - // Call error callback if provided (for cleanup, not logging) - if m.config.OnError != nil { - m.config.OnError(m.olmClient, status.Error) - } - - // Exit immediately on error - m.completed = false - return m, tea.Quit - } - } - } else { - // Socket doesn't exist - clear status - m.status = nil - } - - // Check exit condition (pass current status, even if nil) - if m.config.ExitCondition != nil { - shouldExit, completed := m.config.ExitCondition(m.olmClient, m.status) - if shouldExit { - if m.completedTime == nil { - now := time.Now() - m.completedTime = &now - m.completed = completed - } else if time.Since(*m.completedTime) >= 1*time.Second { - // Wait 1 second, then exit - return m, tea.Quit - } - } else { - // Reset completed time if condition no longer met - m.completedTime = nil - } - } - return m, tickStatusUpdate() - - case tea.WindowSizeMsg: - m.width = msg.Width - return m, nil - } - return m, nil -} - -// View renders the model -func (m *logPreviewModel) View() string { - var sb strings.Builder - - // Styles - logStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(logger.ColorLightGray)) - - // Header - sb.WriteString(m.config.Header) - sb.WriteString("\n") - - // Log lines (last 5, always show 5 lines) - for i := 0; i < 5; i++ { - if i < len(m.logLines) { - line := m.logLines[i] - // Truncate long lines - if len(line) > 80 { - line = line[:77] + "..." - } - sb.WriteString(logStyle.Render(line)) - } - sb.WriteString("\n") - } - - // Status line - sb.WriteString("Status: ") - sb.WriteString(m.config.StatusFormatter(m.olmClient.IsRunning(), m.status)) - - return sb.String() -} - -// Messages for bubbletea -type ( - logUpdateMsg struct{} - statusUpdateMsg struct{} - initCompleteMsg struct{} -) - -// tickLogUpdate sends a log update tick -func tickLogUpdate() tea.Cmd { - return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg { - return logUpdateMsg{} - }) -} - -// tickStatusUpdate sends a status update tick -func tickStatusUpdate() tea.Cmd { - return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { - return statusUpdateMsg{} - }) -} - -// Bound both initial reads and large bursts of new logs so the TUI does not -// scan or retain the entire log file. Lines cut by this window are omitted. -const maxLogPreviewBytes = 64 * 1024 - -// getLastLogLines returns up to n of the newest lines since lastPos, reading -// at most maxLogPreviewBytes plus one byte to check the first line boundary. -func getLastLogLines(logPath string, n int, lastPos int64) ([]string, int64) { - if n <= 0 { - return []string{}, lastPos - } - - file, err := os.Open(logPath) - if err != nil { - return []string{}, lastPos - } - defer file.Close() - - // Get file size - info, err := file.Stat() - if err != nil { - return []string{}, lastPos - } - - fileSize := info.Size() - - // Recover if the log was truncated or replaced with a smaller file. - if lastPos < 0 || lastPos > fileSize { - lastPos = 0 - } - if fileSize == lastPos { - return []string{}, lastPos - } - - start := lastPos - if fileSize-start > maxLogPreviewBytes { - start = fileSize - maxLogPreviewBytes - } - clipped := start > lastPos - if clipped { - start-- // Include the preceding byte to detect a line boundary. - } - - // Read only up to the size observed above, even if the writer keeps appending. - data := make([]byte, fileSize-start) - read, err := file.ReadAt(data, start) - if err != nil && err != io.EOF { - return []string{}, lastPos - } - data = data[:read] - newPos := start + int64(read) - if clipped { - // Drop the preceding byte and any partial line at the window boundary. - if newline := bytes.IndexByte(data, '\n'); newline >= 0 { - data = data[newline+1:] - } else { - data = nil - } - } - if len(data) == 0 { - return []string{}, newPos - } - - // Work backwards and allocate strings only for the lines being displayed. - data = bytes.TrimSuffix(data, []byte{'\n'}) - var lines []string - for len(lines) < n { - newline := bytes.LastIndexByte(data, '\n') - line := bytes.TrimSuffix(data[newline+1:], []byte{'\r'}) - lines = append(lines, string(line)) - if newline < 0 { - break - } - data = data[:newline] - } - slices.Reverse(lines) - - return lines, newPos -} diff --git a/internal/tui/logpreview_test.go b/internal/tui/logpreview_test.go deleted file mode 100644 index 47d2c2d..0000000 --- a/internal/tui/logpreview_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package tui - -import ( - "fmt" - "os" - "path/filepath" - "slices" - "strings" - "testing" -) - -func TestGetLastLogLines(t *testing.T) { - tests := []struct { - name string - content string - n int - lastPos int64 - want []string - }{ - {name: "empty", n: 5}, - {name: "fewer lines", content: "one\ntwo\n", n: 5, want: []string{"one", "two"}}, - {name: "last lines", content: "one\ntwo\nthree\n", n: 2, want: []string{"two", "three"}}, - {name: "no trailing newline", content: "one\ntwo", n: 1, want: []string{"two"}}, - {name: "CRLF", content: "one\r\ntwo\r\n", n: 5, want: []string{"one", "two"}}, - {name: "blank lines", content: "\none\n\n", n: 5, want: []string{"", "one", ""}}, - {name: "single blank line", content: "\n", n: 5, want: []string{""}}, - {name: "unchanged", content: "one\n", n: 5, lastPos: 4}, - {name: "incremental", content: "one\ntwo\n", n: 5, lastPos: 4, want: []string{"two"}}, - {name: "negative offset", content: "one\n", n: 5, lastPos: -1, want: []string{"one"}}, - {name: "zero lines", content: "one\n", n: 0}, - {name: "negative lines", content: "one\n", n: -1}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "client.log") - if err := os.WriteFile(path, []byte(tt.content), 0o600); err != nil { - t.Fatal(err) - } - lines, pos := getLastLogLines(path, tt.n, tt.lastPos) - if !slices.Equal(lines, tt.want) { - t.Errorf("lines = %q, want %q", lines, tt.want) - } - wantPos := int64(len(tt.content)) - if tt.n <= 0 { - wantPos = tt.lastPos - } - if pos != wantPos { - t.Errorf("position = %d, want %d", pos, wantPos) - } - }) - } -} - -func TestGetLastLogLinesMissingFile(t *testing.T) { - lines, pos := getLastLogLines(filepath.Join(t.TempDir(), "missing.log"), 5, 123) - if len(lines) != 0 || pos != 123 { - t.Fatalf("got (%q, %d), want no lines and unchanged position", lines, pos) - } -} - -func TestGetLastLogLinesReadWindow(t *testing.T) { - longLine := strings.Repeat("x", maxLogPreviewBytes) - alignedLine := strings.Repeat("x", maxLogPreviewBytes-12) - crlfLine := strings.Repeat("x", maxLogPreviewBytes-9) - tests := []struct { - name string - content string - want []string - }{ - {name: "partial first line", content: longLine + "\none\ntwo\n", want: []string{"one", "two"}}, - {name: "line boundary", content: "old\nfirst\n" + alignedLine + "\nlast\n", want: []string{"first", alignedLine, "last"}}, - {name: "CRLF boundary", content: "old\r\n" + crlfLine + "\r\nlast\r\n", want: []string{crlfLine, "last"}}, - {name: "oversized last line", content: longLine + "x"}, - {name: "oversized terminated line", content: longLine + "x\n"}, - {name: "after oversized line", content: longLine + "x\nlast", want: []string{"last"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "client.log") - if err := os.WriteFile(path, []byte(tt.content), 0o600); err != nil { - t.Fatal(err) - } - lines, pos := getLastLogLines(path, 5, 0) - if !slices.Equal(lines, tt.want) { - t.Errorf("unexpected preview: got %d lines, want %d", len(lines), len(tt.want)) - } - if pos != int64(len(tt.content)) { - t.Errorf("position = %d, want %d", pos, len(tt.content)) - } - }) - } -} - -func TestGetLastLogLinesWindowThreshold(t *testing.T) { - for _, size := range []int{maxLogPreviewBytes - 1, maxLogPreviewBytes, maxLogPreviewBytes + 1} { - for _, lastPos := range []int64{0, 4} { - t.Run(fmt.Sprintf("size_%d_offset_%d", size, lastPos), func(t *testing.T) { - path := filepath.Join(t.TempDir(), "client.log") - first := strings.Repeat("x", size-len("\nlast\n")) - content := strings.Repeat("p", int(lastPos)) + first + "\nlast\n" - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - want := []string{first, "last"} - if size > maxLogPreviewBytes { - want = []string{"last"} - } - lines, pos := getLastLogLines(path, 5, lastPos) - if !slices.Equal(lines, want) || pos != int64(len(content)) { - t.Fatalf("unexpected preview or position: %d lines, position %d", len(lines), pos) - } - }) - } - } -} - -func TestGetLastLogLinesLargeFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "client.log") - file, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - defer file.Close() - // A sparse file exercises multi-GiB offsets without writing GiB of test data. - const offset int64 = 8 << 30 - const tail = "\none\ntwo\nthree\nfour\nfive\nsix\n" - if _, err := file.WriteAt([]byte(tail), offset); err != nil { - t.Fatal(err) - } - for _, lastPos := range []int64{0, 4} { - t.Run(fmt.Sprintf("offset_%d", lastPos), func(t *testing.T) { - lines, pos := getLastLogLines(path, 5, lastPos) - want := []string{"two", "three", "four", "five", "six"} - if !slices.Equal(lines, want) || pos != offset+int64(len(tail)) { - t.Fatalf("got (%q, %d), want (%q, %d)", lines, pos, want, offset+int64(len(tail))) - } - }) - } -} - -func TestGetLastLogLinesAfterTruncate(t *testing.T) { - path := filepath.Join(t.TempDir(), "client.log") - if err := os.WriteFile(path, []byte("old log line\n"), 0o600); err != nil { - t.Fatal(err) - } - _, lastPos := getLastLogLines(path, 5, 0) - for _, step := range []struct { - content string - want []string - }{ - {content: "new\n", want: []string{"new"}}, - {content: ""}, - {content: "one\n", want: []string{"one"}}, - {content: "one\ntwo\n", want: []string{"two"}}, - {content: "one\ntwo\n"}, - } { - if err := os.WriteFile(path, []byte(step.content), 0o600); err != nil { - t.Fatal(err) - } - lines, pos := getLastLogLines(path, 5, lastPos) - if !slices.Equal(lines, step.want) || pos != int64(len(step.content)) { - t.Fatalf("got (%q, %d), want (%q, %d)", lines, pos, step.want, len(step.content)) - } - lastPos = pos - } -} - -func BenchmarkGetLastLogLines(b *testing.B) { - for _, size := range []int{64 << 10, 16 << 20} { - b.Run(fmt.Sprintf("%dKiB", size>>10), func(b *testing.B) { - path := filepath.Join(b.TempDir(), "client.log") - line := "INFO: synthetic log entry for preview benchmark\n" - content := strings.Repeat(line, size/len(line)) - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - b.Fatal(err) - } - for _, lastPos := range []int64{0, int64(len(line))} { - b.Run(fmt.Sprintf("offset_%d", lastPos), func(b *testing.B) { - b.ReportAllocs() - for b.Loop() { - lines, _ := getLastLogLines(path, 5, lastPos) - if len(lines) != 5 { - b.Fatalf("got %d lines, want 5", len(lines)) - } - } - }) - } - }) - } -} From e7e123fef0807ad6285aac7311d5a80f0ca8bb2f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 27 Oct 2026 15:21:32 -0400 Subject: [PATCH 5/8] bump olm version --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index a2f02be..9e285da 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,14 @@ go 1.26.0 require ( github.com/Masterminds/semver/v3 v3.5.0 - github.com/Microsoft/go-winio v0.6.3 + github.com/Microsoft/go-winio v0.6.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 github.com/fosrl/newt v1.19.0 - github.com/fosrl/olm v1.10.1 + github.com/fosrl/olm v1.10.2 github.com/mattn/go-isatty v0.0.24 github.com/pelletier/go-toml/v2 v2.4.3 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -55,7 +55,7 @@ require ( github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/godbus/dbus/v5 v5.2.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/nftables v0.3.0 // indirect diff --git a/go.sum b/go.sum index 75462b4..59a0d9c 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4= -github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg= +github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g= +github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -76,8 +76,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fosrl/newt v1.19.0 h1:7ELF+fIqYBbCNsIAIQVkklYFIweaA9I8Va91+OaIe2M= github.com/fosrl/newt v1.19.0/go.mod h1:T1LpzfkNZkMw4NpdgG66NbL5YTvP1T2Om4XPg8KbBy8= -github.com/fosrl/olm v1.10.1 h1:9q6sMuWpT8MzTj7UX4q5VYzPc8CJmW+9eBqZVIUkNZU= -github.com/fosrl/olm v1.10.1/go.mod h1:WqvwXlFtE5WjDFZj35VTdMYIhIRh4Nb65NDSNpmYE1U= +github.com/fosrl/olm v1.10.2 h1:pj72Fu+b+fzydOv4QZRi+CpeM/iLKL+5jyrk1+oqlVY= +github.com/fosrl/olm v1.10.2/go.mod h1:EBZ9lHq5cbHxIP3J9WyL3w8jlkso8qWKI9vIalOMdWE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= @@ -95,8 +95,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/godbus/dbus/v5 v5.2.4 h1:F2gUCy++NMIxGmo9J+z5le/+ZC8W1OgeR51P4i9ZB/U= +github.com/godbus/dbus/v5 v5.2.4/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= From 6a04511e53c7443f37e944b2f7d4b699db57819a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 27 Oct 2026 15:23:45 -0400 Subject: [PATCH 6/8] bump version --- internal/version/consts.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/version/consts.go b/internal/version/consts.go index ef3b698..728393d 100644 --- a/internal/version/consts.go +++ b/internal/version/consts.go @@ -4,4 +4,4 @@ package version // This value can be overridden at build time using ldflags: // // go build -ldflags "-X github.com/fosrl/cli/internal/version.Version=" -var Version = "0.19.0" +var Version = "0.20.0" From 3585dc5ff94214f7f6661cbf646325d120c02a8e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 27 Oct 2026 15:30:07 -0400 Subject: [PATCH 7/8] update docs --- Makefile | 7 ++++++- docs/pangolin_auth_login.md | 3 ++- docs/pangolin_login.md | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index ce94ea9..418c104 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,13 @@ install: build @go install -ldflags "$(LDFLAGS)" . docs: - @echo "Generating markdown documentation..." + @echo "Generating markdown documentation (linux)..." +ifeq ($(shell go env GOOS),linux) @go run tools/gendocs/main.go -dir docs +else + @docker run --rm -v "$(CURDIR):/src" -w /src golang:1.26-bookworm \ + go run tools/gendocs/main.go -dir docs +endif @echo "Documentation generated in docs/" docker-build: diff --git a/docs/pangolin_auth_login.md b/docs/pangolin_auth_login.md index 37e33d3..cd26bcb 100644 --- a/docs/pangolin_auth_login.md +++ b/docs/pangolin_auth_login.md @@ -13,7 +13,8 @@ pangolin auth login [hostname] [flags] ### Options ``` - -h, --help help for login + -h, --help help for login + --org-id string Select an organization by ID without prompting ``` ### SEE ALSO diff --git a/docs/pangolin_login.md b/docs/pangolin_login.md index bed485c..ee96561 100644 --- a/docs/pangolin_login.md +++ b/docs/pangolin_login.md @@ -13,7 +13,8 @@ pangolin login [hostname] [flags] ### Options ``` - -h, --help help for login + -h, --help help for login + --org-id string Select an organization by ID without prompting ``` ### SEE ALSO From e7277f628308a9267ce7b4ddb731d20c532203a1 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 27 Oct 2026 15:46:54 -0400 Subject: [PATCH 8/8] Fix #121 --- cmd/select/account/account.go | 5 +++++ cmd/select/org/org.go | 1 + internal/utils/org.go | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/cmd/select/account/account.go b/cmd/select/account/account.go index 0a6ccae..1e812ad 100644 --- a/cmd/select/account/account.go +++ b/cmd/select/account/account.go @@ -118,6 +118,11 @@ func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) error { return err } + // Every path below reports success; afterwards, flag a running client + // this CLI doesn't control (e.g. the desktop app's), which keeps its + // own account. + defer utils.WarnUnmanagedClient("") + // Shut down running client only if it was started by this CLI olmClient := olm.NewClient("") if olmClient.IsRunning() { diff --git a/cmd/select/org/org.go b/cmd/select/org/org.go index abb587f..44e4bd9 100644 --- a/cmd/select/org/org.go +++ b/cmd/select/org/org.go @@ -115,6 +115,7 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) error { utils.ApplySavedExitNodeToActiveClient(account.ExitNode(selectedOrgID), selectedOrgID, apiClient.ListGatewayResources) } else { logger.Success("Successfully selected organization: %s", selectedOrgID) + utils.WarnUnmanagedClient(selectedOrgID) } return nil diff --git a/internal/utils/org.go b/internal/utils/org.go index e313e9f..ddb0a0f 100644 --- a/internal/utils/org.go +++ b/internal/utils/org.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/huh" "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/companion" "github.com/fosrl/cli/internal/config" "github.com/fosrl/cli/internal/logger" "github.com/fosrl/cli/internal/olm" @@ -117,3 +118,40 @@ func SwitchActiveClientOrg(orgID string) bool { // Switch was sent successfully return true } + +// WarnUnmanagedClient tells the user when the running client was started by +// another app (e.g. the desktop app on Windows) rather than this CLI, since +// selections made here are saved for the CLI but don't change that client. +// orgID is the newly selected org, or empty when the account changed. +// +// In companion mode the select commands are blocked before reaching this, so +// it only fires with standalone CLI auth. +func WarnUnmanagedClient(orgID string) { + client := olm.NewClient("") + if !client.IsRunning() { + return + } + + status, err := client.GetStatus() + if err != nil || status == nil || status.Agent == olm.AgentName { + return + } + if orgID != "" && status.OrgID == orgID { + return + } + + agent := status.Agent + if agent == "" { + agent = "another application" + } + + if orgID != "" && status.OrgID != "" { + logger.Warning("The running client was started by %s, not this CLI, so it is still connected to organization %s.", agent, status.OrgID) + } else { + logger.Warning("The running client was started by %s, not this CLI, so it was not changed.", agent) + } + + if providerName := companion.PlatformProviderName(); providerName != "" && status.Agent == providerName { + logger.Info("Switch accounts or organizations in %s to change its connection, or run 'pangolin companion enable' to have the CLI use %s's account and organization.", providerName, providerName) + } +}