diff --git a/cmd/auth/login/login.go b/cmd/auth/login/login.go index ad3070e..dba30f3 100644 --- a/cmd/auth/login/login.go +++ b/cmd/auth/login/login.go @@ -10,6 +10,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/utils" @@ -174,6 +175,11 @@ func LoginCmd() *cobra.Command { } func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) error { + if err := companion.GuardMutatingAuth(cmd.Context()); err != nil { + logger.Error("%v", err) + return err + } + apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) @@ -328,11 +334,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/logout/logout.go b/cmd/auth/logout/logout.go index 0d3f4bd..9ca60d7 100644 --- a/cmd/auth/logout/logout.go +++ b/cmd/auth/logout/logout.go @@ -7,6 +7,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" @@ -29,6 +30,11 @@ func LogoutCmd() *cobra.Command { } func logoutMain(cmd *cobra.Command) error { + if err := companion.GuardMutatingAuth(cmd.Context()); err != nil { + logger.Error("%v", err) + return err + } + apiClient := api.FromContext(cmd.Context()) // Check if client is running before logout @@ -84,12 +90,8 @@ func logoutMain(cmd *cobra.Command) error { // If version doesn't match, skip client shutdown and continue with logout } - // Check if there's an active session in the key store - accountStore, err := config.LoadAccountStore() - if err != nil { - logger.Error("Failed to load account store: %s", err) - return err - } + // Check if there's an active session in the account store. + accountStore := config.AccountStoreFromContext(cmd.Context()) if accountStore.ActiveUserID == "" { logger.Success("Already logged out!") diff --git a/cmd/auth/status/status.go b/cmd/auth/status/status.go index c3a234f..d8ef22a 100644 --- a/cmd/auth/status/status.go +++ b/cmd/auth/status/status.go @@ -6,6 +6,7 @@ import ( "strings" "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/utils" @@ -30,14 +31,32 @@ func StatusCmd() *cobra.Command { func statusMain(cmd *cobra.Command) error { apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) + companionState := companion.StateFromContext(cmd.Context()) + + if companionState.Enabled && !companionState.Active { + logger.Info("Status: not logged in") + logger.Info("Open %s to log in", companionState.ProviderName) + logger.Info("Or run 'pangolin companion disable' to use standalone CLI auth") + return fmt.Errorf("not logged in") + } account, err := accountStore.ActiveAccount() if err != nil { logger.Info("Status: %s", err) - logger.Info("Run 'pangolin login' to authenticate") + if companionState.Enabled { + logger.Info("Open %s to log in", companionState.ProviderName) + logger.Info("Or run 'pangolin companion disable' to use standalone CLI auth") + } else { + logger.Info("Run 'pangolin login' to authenticate") + } return err } + if companionState.Active { + logger.Info("Companion mode: using %s session", companionState.ProviderName) + fmt.Println() + } + // Check health before fetching user data healthOk, healthErr := apiClient.CheckHealth() isServerDown := healthErr != nil || !healthOk diff --git a/cmd/companion/companion_other.go b/cmd/companion/companion_other.go new file mode 100644 index 0000000..9ae7322 --- /dev/null +++ b/cmd/companion/companion_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package companioncmd + +import "github.com/spf13/cobra" + +func CompanionCmd() *cobra.Command { + return nil +} diff --git a/cmd/companion/companion_windows.go b/cmd/companion/companion_windows.go new file mode 100644 index 0000000..90b4b5c --- /dev/null +++ b/cmd/companion/companion_windows.go @@ -0,0 +1,130 @@ +//go:build windows + +package companioncmd + +import ( + "fmt" + + "github.com/fosrl/cli/internal/companion" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" + "github.com/spf13/cobra" +) + +func CompanionCmd() *cobra.Command { + clientName := companion.PlatformProviderName() + short := "Manage companion mode with the Pangolin desktop app" + long := "Enable or disable companion mode, which uses the Pangolin desktop app for authentication." + if clientName != "" { + short = "Manage companion mode with " + clientName + long = "Enable or disable companion mode, which uses " + clientName + " for authentication." + } + + cmd := &cobra.Command{ + Use: "companion", + Short: short, + Long: long, + } + + cmd.AddCommand(companionEnableCmd()) + cmd.AddCommand(companionDisableCmd()) + cmd.AddCommand(companionStatusCmd()) + + return cmd +} + +func companionEnableCmd() *cobra.Command { + return &cobra.Command{ + Use: "enable", + Short: "Enable companion mode", + RunE: companionEnableMain, + } +} + +func companionDisableCmd() *cobra.Command { + return &cobra.Command{ + Use: "disable", + Short: "Disable companion mode", + RunE: companionDisableMain, + } +} + +func companionStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show companion mode status", + RunE: companionStatusMain, + } +} + +func companionEnableMain(cmd *cobra.Command, args []string) error { + cfg, err := config.LoadConfig() + if err != nil { + return err + } + + cfg.SetCompanionModeEnabled(true) + if err := cfg.Save(); err != nil { + return err + } + + logger.Success("Companion mode enabled") + logger.Info("This takes effect on the next pangolin command.") + if companion.RequiredDesktopAppVersion() != "" { + clientName := companion.PlatformProviderName() + logger.Info("Companion mode requires %s version %s or later.", clientName, companion.RequiredDesktopAppVersion()) + } + return nil +} + +func companionDisableMain(cmd *cobra.Command, args []string) error { + cfg, err := config.LoadConfig() + if err != nil { + return err + } + + cfg.SetCompanionModeEnabled(false) + if err := cfg.Save(); err != nil { + return err + } + + logger.Success("Companion mode disabled") + logger.Info("This takes effect on the next pangolin command.") + return nil +} + +func companionStatusMain(cmd *cobra.Command, args []string) error { + cfg, err := config.LoadConfig() + if err != nil { + return err + } + + report := companion.EvaluateStatus(cfg) + printStatusReport(report) + return nil +} + +func printStatusReport(report companion.StatusReport) { + fmt.Printf("Companion mode: %s\n", report.ConfigState) + + switch report.ConfigState { + case "disabled": + fmt.Println("Auth source: standalone CLI") + return + case "unavailable": + fmt.Printf("Client: %s\n", report.ClientName) + fmt.Println("Auth source: standalone CLI") + return + } + + fmt.Printf("Client: %s\n", report.ClientName) + if report.Ready { + fmt.Println("Ready: yes") + return + } + + fmt.Println("Ready: no") + if report.Suggestion != "" { + fmt.Println(report.Suggestion) + } +} diff --git a/cmd/root.go b/cmd/root.go index fb98f0e..457c06f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/fosrl/cli/cmd/auth/login" "github.com/fosrl/cli/cmd/auth/logout" "github.com/fosrl/cli/cmd/authdaemon" + companioncmd "github.com/fosrl/cli/cmd/companion" "github.com/fosrl/cli/cmd/down" "github.com/fosrl/cli/cmd/list" "github.com/fosrl/cli/cmd/logs" @@ -24,8 +25,10 @@ import ( "github.com/fosrl/cli/cmd/version" "github.com/fosrl/cli/cmd/watchdog" "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/notice" versionpkg "github.com/fosrl/cli/internal/version" "github.com/spf13/cobra" ) @@ -52,6 +55,9 @@ func RootCommand(initResources bool) (*cobra.Command, error) { cmd.AddCommand(authDaemonCmd) } cmd.AddCommand(apply.ApplyCommand()) + if companionCmd := companioncmd.CompanionCmd(); companionCmd != nil { + cmd.AddCommand(companionCmd) + } cmd.AddCommand(selectcmd.SelectCmd()) cmd.AddCommand(list.ListCmd()) @@ -97,9 +103,59 @@ func RootCommand(initResources bool) (*cobra.Command, error) { logger.InitLogger(cfg.LogLevel) - accountStore, err := config.LoadAccountStore() + ctx := context.Background() + ctx = config.WithConfig(ctx, cfg) + cmd.SetContext(ctx) + + return cmd, nil +} + +func commandNeedsAuthInit(cmd *cobra.Command) bool { + for c := cmd; c != nil; c = c.Parent() { + if c.Name() == "companion" { + return false + } + } + return true +} + +func commandNeedsCompanionReady(cmd *cobra.Command) bool { + if !commandNeedsAuthInit(cmd) { + return false + } + return !commandExemptFromCompanionReady(cmd) +} + +func commandExemptFromCompanionReady(cmd *cobra.Command) bool { + switch cmd.Name() { + case "version", "update", "login", "logout", "account", "org": + return true + } + + if cmd.Name() == "status" && commandHasAncestor(cmd, "auth") { + return true + } + + return false +} + +func commandHasAncestor(cmd *cobra.Command, name string) bool { + for p := cmd.Parent(); p != nil; p = p.Parent() { + if p.Name() == name { + return true + } + } + return false +} + +func initAuthContext(ctx context.Context, cfg *config.Config) (context.Context, error) { + if _, ok := api.ClientFromContext(ctx); ok { + return ctx, nil + } + + companionState, accountStore, err := companion.Resolve(cfg) if err != nil { - return nil, err + return ctx, err } var apiBaseURL string @@ -108,32 +164,47 @@ func RootCommand(initResources bool) (*cobra.Command, error) { if activeAccount, _ := accountStore.ActiveAccount(); activeAccount != nil { apiBaseURL = activeAccount.Host sessionToken = activeAccount.SessionToken - } else { - apiBaseURL = "" - sessionToken = "" } client, err := api.InitClient(apiBaseURL, sessionToken) if err != nil { - return nil, err + return ctx, err } - ctx := context.Background() ctx = api.WithAPIClient(ctx, client) ctx = config.WithAccountStore(ctx, accountStore) - ctx = config.WithConfig(ctx, cfg) - - cmd.SetContext(ctx) - - return cmd, nil + ctx = companion.WithState(ctx, companionState) + return ctx, nil } func mainCommandPreRun(cmd *cobra.Command, args []string) error { cfg := config.ConfigFromContext(cmd.Context()) + if cfg == nil { + return fmt.Errorf("configuration not loaded") + } + + if err := notice.ShowPending(cfg); err != nil { + logger.Debug("Failed to show pending notices: %v", err) + } + + if commandNeedsAuthInit(cmd) { + ctx, err := initAuthContext(cmd.Context(), cfg) + if err != nil { + return err + } + cmd.SetContext(ctx) + cfg = config.ConfigFromContext(ctx) + + if commandNeedsCompanionReady(cmd) { + if err := companion.GuardReady(ctx); err != nil { + return err + } + } + } - // Skip update checks when running self-update. + // Skip update checks when running self-update or companion commands. cmdName := cmd.Name() - if cmdName == "update" { + if cmdName == "update" || !commandNeedsAuthInit(cmd) { logger.Debug("Skipping update check for %q command", cmdName) return nil } diff --git a/cmd/select/account/account.go b/cmd/select/account/account.go index 2189e0d..0a6ccae 100644 --- a/cmd/select/account/account.go +++ b/cmd/select/account/account.go @@ -8,6 +8,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" @@ -44,6 +45,11 @@ func AccountCmd() *cobra.Command { } func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) error { + if err := companion.GuardMutatingAuth(cmd.Context()); err != nil { + logger.Error("%v", err) + return err + } + accountStore := config.AccountStoreFromContext(cmd.Context()) availableAccounts := accountStore.AvailableAccounts() @@ -88,10 +94,10 @@ func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) error { // Optimistic account switching: switch locally first apiClient := api.FromContext(cmd.Context()) - + // 1. Switch account locally first accountStore.ActiveUserID = selectedAccount.UserID - + // Update API client base URL and token from account apiBaseURL := selectedAccount.Host if apiBaseURL != "" { @@ -106,7 +112,7 @@ func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) error { apiClient.SetBaseURL(apiBaseURL) } apiClient.SetToken(selectedAccount.SessionToken) - + if err := accountStore.Save(); err != nil { logger.Error("Error: failed to save account to store: %v", err) return err @@ -173,11 +179,11 @@ func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) 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[selectedAccount.UserID] diff --git a/cmd/select/org/org.go b/cmd/select/org/org.go index 5faf467..1df4df1 100644 --- a/cmd/select/org/org.go +++ b/cmd/select/org/org.go @@ -5,6 +5,7 @@ import ( "os" "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" @@ -39,6 +40,11 @@ func OrgCmd() *cobra.Command { } func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) error { + if err := companion.GuardMutatingAuth(cmd.Context()); err != nil { + logger.Error("%v", err) + return err + } + apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) cfg := config.ConfigFromContext(cmd.Context()) diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go index 28935dc..1995995 100644 --- a/cmd/up/client/client.go +++ b/cmd/up/client/client.go @@ -32,7 +32,7 @@ const ( defaultDNSServer = "1.1.1.1" defaultEnableAPI = true defaultSocketPath = "/var/run/olm.sock" - defaultAgent = "Pangolin CLI" + defaultAgent = olm.AgentName ) type ClientUpCmdOpts struct { @@ -624,11 +624,11 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) if enableAPI { _ = olm.StartApi() } - + // Run StartTunnel in a goroutine so org switching can restart it // without causing the CLI process to exit go olm.StartTunnel(tunnelConfig) - + // Block on context to keep process alive <-ctx.Done() logger.Info("Received shutdown signal, stopping tunnel") diff --git a/internal/api/context.go b/internal/api/context.go index 109783f..79d0a10 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -10,10 +10,15 @@ func WithAPIClient(ctx context.Context, client *Client) context.Context { return context.WithValue(ctx, apiClientCtxKey, client) } +func ClientFromContext(ctx context.Context) (*Client, bool) { + client, ok := ctx.Value(apiClientCtxKey).(*Client) + return client, ok +} + func FromContext(ctx context.Context) *Client { - logger, ok := ctx.Value(apiClientCtxKey).(*Client) + client, ok := ClientFromContext(ctx) if !ok { panic("apiClient not present in context") } - return logger + return client } diff --git a/internal/companion/accounts_hint_other.go b/internal/companion/accounts_hint_other.go new file mode 100644 index 0000000..5822478 --- /dev/null +++ b/internal/companion/accounts_hint_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package companion + +func desktopAccountsHint(dataDir string) (activeUserID string, accountCount int) { + return "", 0 +} diff --git a/internal/companion/accounts_hint_windows.go b/internal/companion/accounts_hint_windows.go new file mode 100644 index 0000000..b95761c --- /dev/null +++ b/internal/companion/accounts_hint_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package companion + +import ( + "encoding/json" + "os" + "path/filepath" +) + +type accountsMetadata struct { + ActiveUserID string `json:"activeUserId"` + Accounts map[string]struct { + UserID string `json:"userId"` + } `json:"accounts"` +} + +func desktopAccountsHint(dataDir string) (activeUserID string, accountCount int) { + if dataDir == "" { + return "", 0 + } + + data, err := os.ReadFile(filepath.Join(dataDir, accountsFileName)) + if err != nil { + return "", 0 + } + + var file accountsMetadata + if err := json.Unmarshal(data, &file); err != nil { + return "", 0 + } + + if file.Accounts == nil { + return file.ActiveUserID, 0 + } + return file.ActiveUserID, len(file.Accounts) +} diff --git a/internal/companion/context.go b/internal/companion/context.go new file mode 100644 index 0000000..da41d2e --- /dev/null +++ b/internal/companion/context.go @@ -0,0 +1,56 @@ +package companion + +import ( + "context" + "fmt" +) + +// GuardReady returns an error when companion mode is enabled but the desktop client session is not ready. +func GuardReady(ctx context.Context) error { + state := StateFromContext(ctx) + if !state.Enabled || state.Active { + return nil + } + return NotReadyError(state.ProviderName, state.NotReadySuggestion) +} + +// NotReadyError formats an error for companion-enabled commands when the desktop client is unavailable. +func NotReadyError(clientName, suggestion string) error { + if clientName == "" { + clientName = "desktop app" + } + + msg := fmt.Sprintf("Companion mode is enabled but the %s is not ready to use", clientName) + if suggestion != "" { + msg += "\n" + suggestion + } + return fmt.Errorf("%s", msg) +} + +type stateCtxKeyType string + +const stateCtxKey stateCtxKeyType = "companionState" + +func WithState(ctx context.Context, state *State) context.Context { + if state == nil { + state = &State{} + } + return context.WithValue(ctx, stateCtxKey, state) +} + +func StateFromContext(ctx context.Context) *State { + state, ok := ctx.Value(stateCtxKey).(*State) + if !ok || state == nil { + return &State{} + } + return state +} + +// GuardMutatingAuth returns an error when companion mode is enabled on this platform. +func GuardMutatingAuth(ctx context.Context) error { + state := StateFromContext(ctx) + if !state.Enabled { + return nil + } + return MutatingAuthError(state.ProviderName) +} diff --git a/internal/companion/provider.go b/internal/companion/provider.go new file mode 100644 index 0000000..8ec5f70 --- /dev/null +++ b/internal/companion/provider.go @@ -0,0 +1,42 @@ +package companion + +import "github.com/fosrl/cli/internal/config" + +// Provider loads authentication state from a desktop application. +type Provider interface { + Name() string + DefaultDataDir() string + LoadSession(dataDir string) (*Session, error) +} + +// Session holds desktop app auth state mapped to CLI account types. +type Session struct { + ActiveUserID string + Accounts map[string]config.Account +} + +// Active returns true when the session has a logged-in active account. +func (s *Session) Active() bool { + if s == nil || s.ActiveUserID == "" { + return false + } + account, ok := s.Accounts[s.ActiveUserID] + if !ok { + return false + } + return account.SessionToken != "" +} + +// PlatformAvailable reports whether companion mode is supported on this platform. +func PlatformAvailable() bool { + return platformProvider() != nil +} + +// PlatformProviderName returns the companion desktop client's OLM agent name when available. +func PlatformProviderName() string { + provider := platformProvider() + if provider == nil { + return "" + } + return provider.Name() +} diff --git a/internal/companion/provider_darwin.go b/internal/companion/provider_darwin.go new file mode 100644 index 0000000..f173e9e --- /dev/null +++ b/internal/companion/provider_darwin.go @@ -0,0 +1,8 @@ +//go:build darwin + +package companion + +// macOS companion mode is not implemented yet. Return nil so the CLI uses standalone auth. +func platformProvider() Provider { + return nil +} diff --git a/internal/companion/provider_other.go b/internal/companion/provider_other.go new file mode 100644 index 0000000..8acba8d --- /dev/null +++ b/internal/companion/provider_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !darwin + +package companion + +func platformProvider() Provider { + return nil +} diff --git a/internal/companion/provider_windows.go b/internal/companion/provider_windows.go new file mode 100644 index 0000000..3ab193f --- /dev/null +++ b/internal/companion/provider_windows.go @@ -0,0 +1,139 @@ +//go:build windows + +package companion + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/olm" +) + +const windowsAppName = "Pangolin" +const accountsFileName = "accounts.json" + +type windowsProvider struct { + secrets SecretsClient +} + +type windowsAccountsFile struct { + ActiveUserID string `json:"activeUserId"` + Accounts map[string]windowsAccount `json:"accounts"` +} + +type windowsAccount struct { + UserID string `json:"userId"` + Email string `json:"email"` + OrgID string `json:"orgId"` + Username string `json:"username"` + Name string `json:"name"` + Hostname string `json:"hostname"` +} + +func (windowsProvider) Name() string { + return olm.CompanionAgentName +} + +func (windowsProvider) DefaultDataDir() string { + appData := os.Getenv("LOCALAPPDATA") + if appData == "" { + appData = os.Getenv("APPDATA") + } + return filepath.Join(appData, windowsAppName) +} + +func (p windowsProvider) LoadSession(dataDir string) (*Session, error) { + if dataDir == "" { + return nil, nil + } + + accountsPath := filepath.Join(dataDir, accountsFileName) + data, err := os.ReadFile(accountsPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var file windowsAccountsFile + if err := json.Unmarshal(data, &file); err != nil { + return nil, err + } + if file.Accounts == nil { + return nil, nil + } + + accounts := make(map[string]config.Account, len(file.Accounts)) + for userID, winAccount := range file.Accounts { + secrets, err := p.secrets.GetUserSecrets(userID) + if err != nil { + logger.Debug("companion: failed to load secrets for %s: %v", userID, err) + continue + } + if secrets.SessionToken == "" { + continue + } + + account := mapWindowsAccount(winAccount, secrets) + accounts[userID] = account + } + + if len(accounts) == 0 { + return nil, nil + } + + activeUserID := file.ActiveUserID + if activeUserID != "" { + if _, ok := accounts[activeUserID]; !ok { + activeUserID = "" + } + } + if activeUserID == "" { + for userID := range accounts { + activeUserID = userID + break + } + } + + session := &Session{ + ActiveUserID: activeUserID, + Accounts: accounts, + } + if !session.Active() { + return nil, nil + } + return session, nil +} + +func mapWindowsAccount(win windowsAccount, secrets UserSecrets) config.Account { + account := config.Account{ + UserID: win.UserID, + Host: win.Hostname, + Email: win.Email, + OrgID: win.OrgID, + SessionToken: secrets.SessionToken, + } + if win.Username != "" { + username := win.Username + account.Username = &username + } + if win.Name != "" { + name := win.Name + account.Name = &name + } + if secrets.OlmID != "" || secrets.OlmSecret != "" { + account.OlmCredentials = &config.OlmCredentials{ + ID: secrets.OlmID, + Secret: secrets.OlmSecret, + } + } + return account +} + +func platformProvider() Provider { + return windowsProvider{secrets: newPipeSecretsClient()} +} diff --git a/internal/companion/secrets_windows.go b/internal/companion/secrets_windows.go new file mode 100644 index 0000000..3a985df --- /dev/null +++ b/internal/companion/secrets_windows.go @@ -0,0 +1,99 @@ +//go:build windows + +package companion + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/Microsoft/go-winio" +) + +const cliSecretsPipePath = `\\.\pipe\pangolin-manager-cli-secrets` + +const ( + secretsStatusOK uint32 = 0 + secretsStatusNotFound uint32 = 1 + secretsStatusError uint32 = 2 +) + +// UserSecrets matches the Windows manager secret store JSON shape. +type UserSecrets struct { + SessionToken string `json:"sessionToken,omitempty"` + OlmID string `json:"olmId,omitempty"` + OlmSecret string `json:"olmSecret,omitempty"` +} + +// SecretsClient fetches user secrets from the Pangolin Manager service. +type SecretsClient interface { + GetUserSecrets(userID string) (UserSecrets, error) +} + +type pipeSecretsClient struct { + pipePath string +} + +func newPipeSecretsClient() SecretsClient { + return &pipeSecretsClient{pipePath: cliSecretsPipePath} +} + +func (c *pipeSecretsClient) GetUserSecrets(userID string) (UserSecrets, error) { + timeout := 3 * time.Second + conn, err := winio.DialPipe(c.pipePath, &timeout) + if err != nil { + return UserSecrets{}, fmt.Errorf("connect to manager secrets pipe: %w", err) + } + defer conn.Close() + + userIDBytes := []byte(userID) + if err := binary.Write(conn, binary.LittleEndian, uint32(len(userIDBytes))); err != nil { + return UserSecrets{}, fmt.Errorf("write user id length: %w", err) + } + if _, err := conn.Write(userIDBytes); err != nil { + return UserSecrets{}, fmt.Errorf("write user id: %w", err) + } + + var status uint32 + if err := binary.Read(conn, binary.LittleEndian, &status); err != nil { + return UserSecrets{}, fmt.Errorf("read secrets status: %w", err) + } + + payloadLen, err := readUint32(conn) + if err != nil { + return UserSecrets{}, err + } + if payloadLen == 0 { + return UserSecrets{}, fmt.Errorf("empty secrets response payload") + } + + payload := make([]byte, payloadLen) + if _, err := io.ReadFull(conn, payload); err != nil { + return UserSecrets{}, fmt.Errorf("read secrets payload: %w", err) + } + + switch status { + case secretsStatusOK: + var secrets UserSecrets + if err := json.Unmarshal(payload, &secrets); err != nil { + return UserSecrets{}, fmt.Errorf("decode secrets json: %w", err) + } + return secrets, nil + case secretsStatusNotFound: + return UserSecrets{}, nil + case secretsStatusError: + return UserSecrets{}, fmt.Errorf("%s", string(payload)) + default: + return UserSecrets{}, fmt.Errorf("unexpected secrets status: %d", status) + } +} + +func readUint32(r io.Reader) (uint32, error) { + var value uint32 + if err := binary.Read(r, binary.LittleEndian, &value); err != nil { + return 0, fmt.Errorf("read uint32: %w", err) + } + return value, nil +} diff --git a/internal/companion/state.go b/internal/companion/state.go new file mode 100644 index 0000000..026abdf --- /dev/null +++ b/internal/companion/state.go @@ -0,0 +1,98 @@ +package companion + +import ( + "fmt" + + "github.com/fosrl/cli/internal/config" +) + +// State describes the resolved companion mode for the current process. +type State struct { + Enabled bool + Active bool + ProviderName string + Session *Session + NotReadySuggestion string +} + +// ResolveDataDir returns the desktop app data directory from config or platform default. +func ResolveDataDir(cfg *config.Config, provider Provider) string { + if override := cfg.CompanionAppDataDirForPlatform(); override != "" { + return override + } + if provider != nil { + return provider.DefaultDataDir() + } + return "" +} + +// Resolve loads companion state and the account store to use for the CLI. +func Resolve(cfg *config.Config) (*State, *config.AccountStore, error) { + provider := platformProvider() + state := &State{} + + if cfg.DisableCompanionMode || provider == nil { + store, err := config.LoadAccountStore(cfg) + return state, store, err + } + + state.Enabled = true + state.ProviderName = provider.Name() + + dataDir := ResolveDataDir(cfg, provider) + session, err := provider.LoadSession(dataDir) + if err != nil { + return state, nil, err + } + + if session != nil && session.Active() { + state.Active = true + state.Session = session + store := config.NewReadOnlyAccountStore(session.ActiveUserID, session.Accounts) + return state, store, nil + } + + state.NotReadySuggestion = notReadySuggestion(provider, dataDir, session) + + // Companion enabled but desktop app is not logged in. + store := config.NewReadOnlyAccountStore("", map[string]config.Account{}) + return state, store, nil +} + +func notReadySuggestion(provider Provider, dataDir string, session *Session) string { + if session != nil && session.Active() { + return "" + } + + activeUserID, accountCount := desktopAccountsHint(dataDir) + if activeUserID != "" && session == nil { + if RequiredDesktopAppVersion() != "" { + return fmt.Sprintf( + "Start %s (version %s or later) and log in, or run 'pangolin companion disable'.", + provider.Name(), + RequiredDesktopAppVersion(), + ) + } + return fmt.Sprintf( + "Start %s and log in, or run 'pangolin companion disable'.", + provider.Name(), + ) + } + + if accountCount > 0 || activeUserID != "" { + return fmt.Sprintf("Open %s and log in.", provider.Name()) + } + + return fmt.Sprintf("Open %s and log in.", provider.Name()) +} + +// MutatingAuthError is returned when auth commands are blocked in companion mode. +func MutatingAuthError(providerName string) error { + return fmt.Errorf( + "Authentication is managed by %s.\n"+ + "Login, logout, and account or organization changes must be done in %s.\n"+ + "To use standalone CLI auth, run 'pangolin companion disable'.", + providerName, + providerName, + ) +} diff --git a/internal/companion/status.go b/internal/companion/status.go new file mode 100644 index 0000000..28a9a9a --- /dev/null +++ b/internal/companion/status.go @@ -0,0 +1,56 @@ +package companion + +import ( + "fmt" + + "github.com/fosrl/cli/internal/config" +) + +const ( + statusDisabled = "disabled" + statusEnabled = "enabled" + statusUnavailable = "unavailable" +) + +// StatusReport describes companion mode configuration and desktop client readiness. +type StatusReport struct { + ConfigState string + ClientName string + Ready bool + Suggestion string +} + +// EvaluateStatus probes companion config and desktop client readiness. +func EvaluateStatus(cfg *config.Config) StatusReport { + if cfg.DisableCompanionMode { + return StatusReport{ConfigState: statusDisabled} + } + + provider := platformProvider() + if provider == nil { + return StatusReport{ + ConfigState: statusUnavailable, + ClientName: "not supported on this platform", + } + } + + report := StatusReport{ + ConfigState: statusEnabled, + ClientName: provider.Name(), + } + + dataDir := ResolveDataDir(cfg, provider) + session, err := provider.LoadSession(dataDir) + if err != nil { + report.Suggestion = fmt.Sprintf("Open %s to log in.", provider.Name()) + return report + } + + if session != nil && session.Active() { + report.Ready = true + return report + } + + report.Suggestion = notReadySuggestion(provider, dataDir, session) + return report +} diff --git a/internal/companion/version.go b/internal/companion/version.go new file mode 100644 index 0000000..48493b4 --- /dev/null +++ b/internal/companion/version.go @@ -0,0 +1,20 @@ +package companion + +import "runtime" + +const ( + windowsMinDesktopAppVersion = "0.11.0" + darwinMinDesktopAppVersion = "" +) + +// RequiredDesktopAppVersion returns the minimum desktop app version required for companion mode on this platform. +func RequiredDesktopAppVersion() string { + switch runtime.GOOS { + case "windows": + return windowsMinDesktopAppVersion + case "darwin": + return darwinMinDesktopAppVersion + default: + return "" + } +} diff --git a/internal/config/accounts.go b/internal/config/accounts.go index 4893d4f..2190b1b 100644 --- a/internal/config/accounts.go +++ b/internal/config/accounts.go @@ -13,18 +13,20 @@ type AccountStore struct { // so they must operate on separate Viper instances. v *viper.Viper + readOnly bool + ActiveUserID string `mapstructure:"activeUserId" json:"activeUserId"` Accounts map[string]Account `mapstructure:"accounts" json:"accounts"` } type Account struct { - UserID string `mapstructure:"userId" json:"userId"` - Host string `mapstructure:"host" json:"host"` - Email string `mapstructure:"email" json:"email"` - Username *string `mapstructure:"username" json:"username,omitempty"` - Name *string `mapstructure:"name" json:"name,omitempty"` - SessionToken string `mapstructure:"sessionToken" json:"sessionToken"` - OrgID string `mapstructure:"orgId" json:"orgId,omitempty"` + UserID string `mapstructure:"userId" json:"userId"` + Host string `mapstructure:"host" json:"host"` + Email string `mapstructure:"email" json:"email"` + Username *string `mapstructure:"username" json:"username,omitempty"` + Name *string `mapstructure:"name" json:"name,omitempty"` + SessionToken string `mapstructure:"sessionToken" json:"sessionToken"` + OrgID string `mapstructure:"orgId" json:"orgId,omitempty"` OlmCredentials *OlmCredentials `mapstructure:"olmCredentials" json:"olmCredentials,omitempty"` ServerInfo *ServerInfo `mapstructure:"serverInfo" json:"serverInfo,omitempty"` } @@ -37,11 +39,11 @@ type OlmCredentials struct { // ServerInfo represents server information including version, build type, and license status // This mirrors api.ServerInfo to avoid import cycles type ServerInfo struct { - Version string `mapstructure:"version" json:"version"` - SupporterStatusValid bool `mapstructure:"supporterStatusValid" json:"supporterStatusValid"` - Build string `mapstructure:"build" json:"build"` // "oss" | "enterprise" | "saas" - EnterpriseLicenseValid bool `mapstructure:"enterpriseLicenseValid" json:"enterpriseLicenseValid"` - EnterpriseLicenseType *string `mapstructure:"enterpriseLicenseType" json:"enterpriseLicenseType,omitempty"` + Version string `mapstructure:"version" json:"version"` + SupporterStatusValid bool `mapstructure:"supporterStatusValid" json:"supporterStatusValid"` + Build string `mapstructure:"build" json:"build"` // "oss" | "enterprise" | "saas" + EnterpriseLicenseValid bool `mapstructure:"enterpriseLicenseValid" json:"enterpriseLicenseValid"` + EnterpriseLicenseType *string `mapstructure:"enterpriseLicenseType" json:"enterpriseLicenseType,omitempty"` } func newAccountViper() (*viper.Viper, error) { @@ -59,7 +61,7 @@ func newAccountViper() (*viper.Viper, error) { return v, nil } -func LoadAccountStore() (*AccountStore, error) { +func LoadAccountStore(cfg *Config) (*AccountStore, error) { v, err := newAccountViper() if err != nil { return nil, err @@ -85,6 +87,23 @@ func LoadAccountStore() (*AccountStore, error) { return &store, nil } +// NewReadOnlyAccountStore builds an in-memory account store from desktop app session data. +func NewReadOnlyAccountStore(activeUserID string, accounts map[string]Account) *AccountStore { + if accounts == nil { + accounts = map[string]Account{} + } + return &AccountStore{ + readOnly: true, + ActiveUserID: activeUserID, + Accounts: accounts, + } +} + +// IsReadOnly reports whether the store is managed by companion mode and cannot be persisted. +func (s *AccountStore) IsReadOnly() bool { + return s.readOnly +} + func (s *AccountStore) ActiveAccount() (*Account, error) { if s.ActiveUserID == "" { return nil, errors.New("not logged in") @@ -108,6 +127,9 @@ func (s *AccountStore) ActiveAccount() (*Account, error) { // // This effectively logs out the account. func (s *AccountStore) Deactivate(userID string) error { + if s.readOnly { + return errors.New("account store is read-only") + } account, exists := s.Accounts[userID] if !exists { return errors.New("account does not exist") @@ -144,6 +166,9 @@ func (s *AccountStore) AvailableAccounts() []Account { // This must be called after modifying an account obtained from ActiveAccount() // because Go maps return copies of values, not references. func (s *AccountStore) UpdateActiveAccount(account *Account) error { + if s.readOnly { + return errors.New("account store is read-only") + } if s.ActiveUserID == "" { return errors.New("not logged in") } @@ -157,6 +182,12 @@ func (s *AccountStore) UpdateActiveAccount(account *Account) error { } func (s *AccountStore) Save() error { + if s.readOnly { + return errors.New("account store is read-only") + } + if s.v == nil { + return errors.New("account store has no backing config file") + } // HACK: If there's a better way to write the config all at once // without having to specify each toplevel struct key, that // would be preferable. @@ -169,6 +200,9 @@ func (s *AccountStore) Save() error { // UpdateAccountUserInfo updates the username and name for a specific account func (s *AccountStore) UpdateAccountUserInfo(userID, username, name string) error { + if s.readOnly { + return errors.New("account store is read-only") + } account, exists := s.Accounts[userID] if !exists { return errors.New("account not found") diff --git a/internal/config/config.go b/internal/config/config.go index f3cb106..96c4ecf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,9 +18,33 @@ type Config struct { // so they must operate on separate Viper instances. v *viper.Viper - LogLevel logger.LogLevel `mapstructure:"log_level" json:"log_level"` - LogFile string `mapstructure:"log_file" json:"log_file"` - DisableUpdateCheck bool `mapstructure:"disable_update_check" json:"disable_update_check"` + LogLevel logger.LogLevel `mapstructure:"log_level" json:"log_level"` + LogFile string `mapstructure:"log_file" json:"log_file"` + DisableUpdateCheck bool `mapstructure:"disable_update_check" json:"disable_update_check"` + DisableCompanionMode bool `mapstructure:"disable_companion_mode" json:"disable_companion_mode"` + CompanionAppDataDirs CompanionAppDataDirs `mapstructure:"companion_app_data_dirs" json:"companion_app_data_dirs"` +} + +// CompanionAppDataDirs holds per-platform overrides for the desktop app data directory. +type CompanionAppDataDirs struct { + Windows string `mapstructure:"windows" json:"windows,omitempty"` + Darwin string `mapstructure:"darwin" json:"darwin,omitempty"` +} + +// CompanionAppDataDirForPlatform returns the configured override for the current OS. +func (c *Config) CompanionAppDataDirForPlatform() string { + return companionAppDataDirForGOOS(c, runtime.GOOS) +} + +func companionAppDataDirForGOOS(c *Config, goos string) string { + switch goos { + case "windows": + return c.CompanionAppDataDirs.Windows + case "darwin": + return c.CompanionAppDataDirs.Darwin + default: + return "" + } } func newConfigViper() (*viper.Viper, error) { @@ -46,6 +70,8 @@ func newConfigViper() (*viper.Viper, error) { v.SetDefault("log_level", "info") v.SetDefault("log_file", defaultLogPath) v.SetDefault("disable_update_check", false) + v.SetDefault("disable_companion_mode", false) + v.SetDefault("companion_app_data_dirs", map[string]string{}) return v, nil } @@ -86,10 +112,35 @@ func (c *Config) Validate() error { } } +// CompanionModeEnabled reports whether companion mode is enabled in config. +func (c *Config) CompanionModeEnabled() bool { + return !c.DisableCompanionMode +} + +// SetCompanionModeEnabled updates the companion mode config flag. +func (c *Config) SetCompanionModeEnabled(enabled bool) { + c.DisableCompanionMode = !enabled +} + func (c *Config) Save() error { c.v.Set("log_level", c.LogLevel) c.v.Set("log_file", c.LogFile) c.v.Set("disable_update_check", c.DisableUpdateCheck) + c.v.Set("disable_companion_mode", c.DisableCompanionMode) + c.v.Set("companion_app_data_dirs", c.CompanionAppDataDirs) + + dir, err := GetPangolinConfigDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + configFile := c.v.ConfigFileUsed() + if _, err := os.Stat(configFile); errors.Is(err, os.ErrNotExist) { + return c.v.WriteConfigAs(configFile) + } return c.v.WriteConfig() } diff --git a/internal/notice/notice.go b/internal/notice/notice.go new file mode 100644 index 0000000..35d9989 --- /dev/null +++ b/internal/notice/notice.go @@ -0,0 +1,111 @@ +package notice + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/version" +) + +var ( + bannerBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(logger.ColorInfo)). + Padding(1, 2). + MarginTop(1). + MarginBottom(1) + + bannerTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(logger.ColorWarning)) + + bannerBodyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) +) + +// Notice is a one-time user message shown on the first eligible CLI invocation. +type Notice struct { + ID string + MinVersion string + MaxVersion string + Condition func(cfg *config.Config) bool + Lines func(cfg *config.Config) []string +} + +// ShowPending prints registered notices that have not been shown yet and match their condition. +func ShowPending(cfg *config.Config) error { + if cfg == nil { + return nil + } + + state, err := loadState() + if err != nil { + return err + } + + changed := false + for _, notice := range registeredNotices { + if state.wasShown(notice.ID) { + continue + } + if !noticeMatchesVersion(notice) { + continue + } + if notice.Condition != nil && !notice.Condition(cfg) { + continue + } + + printBanner(notice.Lines(cfg)) + state.markShown(notice.ID) + changed = true + } + + if !changed { + return nil + } + return saveState(state) +} + +func printBanner(lines []string) { + if len(lines) == 0 { + return + } + + title := lines[0] + body := strings.Join(lines[1:], "\n") + + var content strings.Builder + content.WriteString(bannerTitleStyle.Render(title)) + if body != "" { + content.WriteString("\n\n") + content.WriteString(bannerBodyStyle.Render(body)) + } + + fmt.Println() + fmt.Println(bannerBoxStyle.Render(content.String())) + fmt.Println() +} + +func noticeMatchesVersion(notice Notice) bool { + if notice.MinVersion == "" && notice.MaxVersion == "" { + return true + } + + current := version.Version + if notice.MinVersion != "" { + cmp, err := version.CompareVersions(current, notice.MinVersion) + if err != nil || cmp < 0 { + return false + } + } + if notice.MaxVersion != "" { + cmp, err := version.CompareVersions(current, notice.MaxVersion) + if err != nil || cmp > 0 { + return false + } + } + return true +} diff --git a/internal/notice/notices.go b/internal/notice/notices.go new file mode 100644 index 0000000..6d7e7f9 --- /dev/null +++ b/internal/notice/notices.go @@ -0,0 +1,39 @@ +package notice + +import ( + "runtime" + + "github.com/fosrl/cli/internal/companion" + "github.com/fosrl/cli/internal/config" +) + +const companionModeIntroID = "companion-mode-intro-windows-v1" + +var registeredNotices = []Notice{ + { + ID: companionModeIntroID, + MinVersion: "0.11.0", + MaxVersion: "0.11.0", + Condition: func(cfg *config.Config) bool { + return runtime.GOOS == "windows" && companion.PlatformAvailable() + }, + Lines: companionModeIntroLines, + }, +} + +func companionModeIntroLines(cfg *config.Config) []string { + clientName := companion.PlatformProviderName() + + lines := []string{ + "Pangolin CLI now uses companion mode with " + clientName + ".", + "Login, logout, and account or organization changes are managed in " + clientName + ".", + } + if companion.RequiredDesktopAppVersion() != "" { + lines = append(lines, "Requires "+clientName+" version "+companion.RequiredDesktopAppVersion()+" or later.") + } + lines = append(lines, "Run 'pangolin companion status' for details.") + if cfg.CompanionModeEnabled() { + lines = append(lines, "To use standalone CLI authentication, run 'pangolin companion disable'.") + } + return lines +} diff --git a/internal/notice/state.go b/internal/notice/state.go new file mode 100644 index 0000000..297e747 --- /dev/null +++ b/internal/notice/state.go @@ -0,0 +1,81 @@ +package notice + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/fosrl/cli/internal/config" +) + +const noticesFileName = "notices.json" + +type state struct { + Shown map[string]bool `json:"shown"` +} + +func noticesFilePath() (string, error) { + dir, err := config.GetPangolinConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, noticesFileName), nil +} + +func loadState() (*state, error) { + path, err := noticesFilePath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &state{Shown: map[string]bool{}}, nil + } + return nil, fmt.Errorf("read notices state: %w", err) + } + + var s state + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("parse notices state: %w", err) + } + if s.Shown == nil { + s.Shown = map[string]bool{} + } + return &s, nil +} + +func saveState(s *state) error { + dir, err := config.GetPangolinConfigDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create config directory: %w", err) + } + + path, err := noticesFilePath() + if err != nil { + return err + } + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("marshal notices state: %w", err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write notices state: %w", err) + } + return nil +} + +func (s *state) wasShown(id string) bool { + return s.Shown[id] +} + +func (s *state) markShown(id string) { + s.Shown[id] = true +} diff --git a/internal/olm/client_windows.go b/internal/olm/client_windows.go index be2cb9d..892a04b 100644 --- a/internal/olm/client_windows.go +++ b/internal/olm/client_windows.go @@ -13,6 +13,10 @@ import ( const defaultSocketPath = `\\.\pipe\pangolin-olm` +// CompanionAgentName is the OLM agent identifier used by the Pangolin Windows Client. +// Must match the Agent value in the Windows app's OLM tunnel config. +const CompanionAgentName = "Pangolin Windows" + func getDefaultSocketPath() string { return defaultSocketPath } @@ -39,4 +43,4 @@ func socketExists(path string) bool { } conn.Close() return true -} \ No newline at end of file +}