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/cmd/auth/login/login.go b/cmd/auth/login/login.go index 1441bd5..1b5beda 100644 --- a/cmd/auth/login/login.go +++ b/cmd/auth/login/login.go @@ -185,6 +185,7 @@ func loginWithWeb(hostname string, sessionCookieName string, knownEmail string) type LoginCmdOpts struct { Hostname string + OrgID string } func LoginCmd() *cobra.Command { @@ -212,9 +213,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 { if err := companion.GuardMutatingAuth(cmd.Context()); err != nil { logger.Error("%v", err) @@ -350,8 +405,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) @@ -359,6 +423,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 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) +} 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/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/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..44e4bd9 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,20 +110,21 @@ 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 { logger.Success("Successfully selected organization: %s", selectedOrgID) + utils.WarnUnmanagedClient(selectedOrgID) } 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 { @@ -148,7 +148,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 76ec986..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()) @@ -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") @@ -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/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/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 diff --git a/go.mod b/go.mod index 6144bd2..a8766de 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( 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 @@ -57,7 +57,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 e781c64..59a0d9c 100644 --- a/go.sum +++ b/go.sum @@ -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= 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/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 fc83125..0000000 --- a/internal/tui/logpreview.go +++ /dev/null @@ -1,251 +0,0 @@ -package tui - -import ( - "bufio" - "io" - "os" - "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{} - }) -} - -// getLastLogLines reads the last N lines from a log file starting from a position -func getLastLogLines(logPath string, n int, lastPos int64) ([]string, int64) { - 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() - - // If file hasn't grown, return empty - if fileSize <= lastPos { - return []string{}, lastPos - } - - // Read from lastPos to end - file.Seek(lastPos, io.SeekStart) - scanner := bufio.NewScanner(file) - - var lines []string - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - - // Return last N lines - if len(lines) > n { - lines = lines[len(lines)-n:] - } - - return lines, fileSize -} 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) + } +} 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) + } +} 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"