Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
84 changes: 82 additions & 2 deletions cmd/auth/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ func loginWithWeb(hostname string, sessionCookieName string, knownEmail string)

type LoginCmdOpts struct {
Hostname string
OrgID string
}

func LoginCmd() *cobra.Command {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -350,15 +405,40 @@ 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)
return err
}

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
Expand Down
127 changes: 127 additions & 0 deletions cmd/auth/login/login_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 2 additions & 7 deletions cmd/down/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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("")

Expand Down Expand Up @@ -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() {
Expand Down
5 changes: 5 additions & 0 deletions cmd/select/account/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 3 additions & 3 deletions cmd/select/exitnode/exitnode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand All @@ -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")
}
Expand Down
17 changes: 8 additions & 9 deletions cmd/select/org/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading