diff --git a/cli/auth.go b/cli/auth.go new file mode 100644 index 0000000..e7ba028 --- /dev/null +++ b/cli/auth.go @@ -0,0 +1,23 @@ +package cli + +import "github.com/spf13/cobra" + +func makeOperationAuthCmd() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "auth", + Short: "Inspect and manage your authentication state", + } + statusCmd, err := makeOperationAuthStatusCmd() + if err != nil { + return nil, err + } + cmd.AddCommand(statusCmd) + + logoutCmd, err := makeOperationAuthLogoutCmd() + if err != nil { + return nil, err + } + cmd.AddCommand(logoutCmd) + + return cmd, nil +} diff --git a/cli/auth_login.go b/cli/auth_login.go new file mode 100644 index 0000000..1071239 --- /dev/null +++ b/cli/auth_login.go @@ -0,0 +1,115 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/latitudesh/lsh/internal/authclient" + "github.com/latitudesh/lsh/internal/config" + "github.com/latitudesh/lsh/internal/version" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// defaultAPIVersion resolves the API version sent to the API, honoring +// LATITUDE_API_VERSION with a stable fallback. +func defaultAPIVersion() string { + if v := os.Getenv("LATITUDE_API_VERSION"); v != "" { + return v + } + return "2023-06-01" +} + +func makeOperationLoginCmd() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "login [DEPRECATED: api-token]", + Short: "Authenticate with Latitude", + Long: `Without arguments, opens your browser to authorize this CLI through the +Latitude dashboard and saves the resulting credential locally. + +Pass --with-token to skip the browser flow and use an existing token. + +A positional argument is still accepted for backward +compatibility but is deprecated and will be removed in a future release.`, + Args: cobra.MaximumNArgs(1), + RunE: runOperationLogin, + } + cmd.Flags().String("with-token", "", "use an existing API token instead of the browser flow") + cmd.Flags().String("profile", "", "save credentials under this profile name (default: team slug)") + return cmd, nil +} + +func runOperationLogin(cmd *cobra.Command, args []string) error { + token, _ := cmd.Flags().GetString("with-token") + profileName, _ := cmd.Flags().GetString("profile") + + if len(args) == 1 { + if token != "" { + return errors.New("cannot combine positional token with --with-token") + } + fmt.Fprintln(cmd.ErrOrStderr(), + "warning: passing the token as a positional argument is deprecated; use --with-token instead") + token = args[0] + } + + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + client := newAuthClient() + + if token != "" { + return loginWithToken(ctx, client, token, profileName) + } + return loginViaBrowser(ctx, client, profileName) +} + +// newAuthClient builds an authclient using the current hostname/scheme +// from viper. This is the same configuration the generated SDK uses, +// so dev overrides (--hostname / --scheme) flow through naturally. +func newAuthClient() *authclient.Client { + hostname := viper.GetString("hostname") + if hostname == "" { + hostname = "api.latitude.sh" + } + scheme := viper.GetString("scheme") + if scheme == "" { + scheme = "https" + } + ua := fmt.Sprintf("lsh/%s", version.Version) + return authclient.New(scheme+"://"+hostname, ua, defaultAPIVersion()) +} + +// saveProfile resolves the profile name (override > team slug > "default") +// and upserts it in the config file. SetProfile promotes it to the default +// only when no default exists yet, so logging in to add a second profile +// (e.g. another team) does not silently change the active context — use +// `lsh profile use` to switch explicitly. +func saveProfile(override string, p config.Profile) (string, error) { + f, err := config.Load() + if err != nil { + return "", err + } + name := override + if name == "" { + name = p.TeamSlug + } + if name == "" { + // No override and no team slug (e.g. a token whose team lookup + // returned empty). Saving as "default" would silently overwrite an + // existing "default" profile's credential, so require an explicit + // name in that case. + if _, exists := f.Profiles["default"]; exists { + return "", errors.New("could not determine a profile name for this token (no team was returned); pass --profile so an existing profile isn't overwritten") + } + name = "default" + } + f.SetProfile(name, p) + if err := config.Save(f); err != nil { + return "", err + } + return name, nil +} diff --git a/cli/auth_login_browser.go b/cli/auth_login_browser.go new file mode 100644 index 0000000..4dc96f8 --- /dev/null +++ b/cli/auth_login_browser.go @@ -0,0 +1,165 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "time" + + "github.com/latitudesh/lsh/internal/authclient" + "github.com/latitudesh/lsh/internal/browser" + "github.com/latitudesh/lsh/internal/config" + "github.com/latitudesh/lsh/internal/version" +) + +const ( + pollInterval = 2 * time.Second + pollMaxBackoff = 5 * time.Second + loginTimeout = 5*time.Minute + 30*time.Second // a touch over the API TTL + + // Window after CreateSession during which a 404 from the poll + // endpoint is treated as "not yet visible" instead of "expired". + // Covers initial Redis propagation and the gap between the create + // returning and the dashboard/CLI being able to read the session. + earlyNotFoundGrace = 15 * time.Second +) + +func loginViaBrowser(ctx context.Context, client *authclient.Client, profileOverride string) error { + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + defer cancel() + + session, err := client.CreateSession(ctx, authclient.CreateSessionRequest{ + ClientName: "lsh", + ClientVersion: version.Version, + }) + if err != nil { + return fmt.Errorf("could not start login session: %w", err) + } + + headless := browser.LooksHeadless() + printAuthorizePrompt(session, headless) + + if !headless { + if err := browser.Open(session.AuthorizeURL); err != nil { + fmt.Fprintln(os.Stderr, "Could not open browser automatically; please open the URL above manually.") + } + } + + approved, err := pollUntilApproved(ctx, client, session.ID, session.Secret) + if err != nil { + return err + } + + if approved.APIKey == nil || approved.Team == nil || approved.User == nil { + return errors.New("login session approved but the credential payload was incomplete") + } + + profile := config.Profile{ + Authorization: approved.APIKey.Token, + KeyID: approved.APIKey.ID, + KeyName: approved.APIKey.Name, + TeamID: approved.Team.ID, + TeamName: approved.Team.Name, + TeamSlug: approved.Team.Slug, + Email: approved.User.Email, + Source: config.SourceBrowser, + APIVersion: defaultAPIVersion(), + } + + name, err := saveProfile(profileOverride, profile) + if err != nil { + return fmt.Errorf("could not save profile: %w", err) + } + + fmt.Printf("\n✅ Logged in as %s on team %s (profile: %s)\n", profile.Email, profile.TeamName, name) + return nil +} + +func printAuthorizePrompt(session *authclient.Session, headless bool) { + fmt.Println("Opening your browser to authorize this CLI...") + fmt.Println() + fmt.Println(" URL:") + fmt.Printf(" %s\n", session.AuthorizeURL) + fmt.Println() + fmt.Println(" Confirm this code matches what your browser shows:") + fmt.Printf(" %s\n", session.UserCode) + if headless { + fmt.Println() + fmt.Println(" (detected headless environment — open the URL above on a machine with a browser)") + } + fmt.Println() + fmt.Println("Waiting for approval... press Ctrl+C to cancel.") +} + +// pollUntilApproved retries GET /auth/cli_sessions/ until the +// session is approved, terminally errored, or the deadline expires. +func pollUntilApproved(ctx context.Context, client *authclient.Client, id, secret string) (*authclient.Session, error) { + start := time.Now() + deadline := start.Add(loginTimeout) + interval := pollInterval + + for { + if ctx.Err() != nil { + return nil, ctx.Err() + } + session, err := client.PollSession(ctx, id, secret) + if err == nil { + // A successful response means the server is healthy; reset the + // interval so a previous transient error doesn't keep us polling + // at the backed-off rate for the rest of the session. + interval = pollInterval + if session.Status == "approved" { + if session.APIKey != nil { + return session, nil + } + // Approval and key are written together server-side, so an + // approved session with no key is anomalous — fail fast + // instead of polling silently until the deadline. + return nil, errors.New("login was approved but no API key was returned; please run `lsh login` again") + } + // status=pending → keep polling + } else { + var httpErr *authclient.HTTPError + if errors.As(err, &httpErr) { + switch httpErr.StatusCode { + case 404: + // 404 right after CreateSession just means the session + // has not propagated yet — retry within the grace + // window before treating it as terminal. + if time.Since(start) >= earlyNotFoundGrace { + return nil, errors.New("login session expired or was cancelled") + } + case 410: + return nil, errors.New("login session has already been used; please run `lsh login` again") + case 401: + return nil, errors.New("login session secret rejected (this should not happen)") + default: + // transient — back off and retry until deadline + } + } else { + // network error — back off and retry + } + interval = nextBackoff(interval) + } + + if time.Now().After(deadline) { + return nil, errors.New("login session expired before approval") + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(interval): + } + } +} + +func nextBackoff(current time.Duration) time.Duration { + doubled := current * 2 + if doubled > pollMaxBackoff { + return pollMaxBackoff + } + return doubled +} diff --git a/cli/auth_login_flows_test.go b/cli/auth_login_flows_test.go new file mode 100644 index 0000000..a56a3f9 --- /dev/null +++ b/cli/auth_login_flows_test.go @@ -0,0 +1,80 @@ +package cli + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/latitudesh/lsh/internal/authclient" + "github.com/latitudesh/lsh/internal/browser" + "github.com/latitudesh/lsh/internal/config" +) + +func TestLoginWithTokenSavesProfile(t *testing.T) { + withTempHome(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/user/profile": + w.Write([]byte(`{"data":{"attributes":{"id":"u","email":"u@example.com"}}}`)) + case "/team": + w.Write([]byte(`{"data":[{"id":"team_abc","attributes":{"name":"Acme","slug":"acme"}}]}`)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + client := authclient.New(srv.URL, "lsh-test", "2023-06-01") + if err := loginWithToken(context.Background(), client, "ak_xxx", ""); err != nil { + t.Fatalf("loginWithToken: %v", err) + } + + f, _ := config.Load() + p, ok := f.Profiles["acme"] + if !ok { + t.Fatalf("expected profile 'acme', got %+v", f.Profiles) + } + if p.Authorization != "ak_xxx" || p.Email != "u@example.com" || p.TeamSlug != "acme" { + t.Fatalf("unexpected profile: %+v", p) + } + if p.Source != config.SourceWithToken { + t.Fatalf("expected with-token source, got %q", p.Source) + } +} + +func TestLoginViaBrowserSavesProfile(t *testing.T) { + withTempHome(t) + + prevOpener := browser.Opener + browser.Opener = func(string) error { return nil } + t.Cleanup(func() { browser.Opener = prevOpener }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/auth/cli_sessions": + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"data":{"id":"sid","secret":"shh","authorize_url":"https://example/auth","user_code":"WXYZ","expires_at":"2030-01-01T00:00:00Z"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/auth/cli_sessions/sid": + w.Write([]byte(`{"data":{"status":"approved","api_key":{"id":"k","token":"ak_browser","name":"lsh"},"team":{"id":"team_abc","name":"Acme","slug":"acme"},"user":{"id":"u","email":"u@example.com"}}}`)) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + client := authclient.New(srv.URL, "lsh-test", "2023-06-01") + if err := loginViaBrowser(context.Background(), client, ""); err != nil { + t.Fatalf("loginViaBrowser: %v", err) + } + + f, _ := config.Load() + p, ok := f.Profiles["acme"] + if !ok { + t.Fatalf("expected profile 'acme', got %+v", f.Profiles) + } + if p.Authorization != "ak_browser" || p.KeyID != "k" || p.Source != config.SourceBrowser { + t.Fatalf("unexpected profile: %+v", p) + } +} diff --git a/cli/auth_login_test.go b/cli/auth_login_test.go new file mode 100644 index 0000000..1d3c0a6 --- /dev/null +++ b/cli/auth_login_test.go @@ -0,0 +1,64 @@ +package cli + +import ( + "testing" + + "github.com/latitudesh/lsh/internal/config" +) + +func TestSaveProfileUsesTeamSlugAndSetsDefault(t *testing.T) { + withTempHome(t) + + name, err := saveProfile("", config.Profile{Authorization: "t", TeamSlug: "acme", TeamName: "Acme"}) + if err != nil { + t.Fatalf("saveProfile: %v", err) + } + if name != "acme" { + t.Fatalf("expected name from team slug, got %q", name) + } + f, _ := config.Load() + if f.DefaultProfile != "acme" { + t.Fatalf("first profile should become default, got %q", f.DefaultProfile) + } +} + +func TestSaveProfileOverrideWins(t *testing.T) { + withTempHome(t) + + name, err := saveProfile("staging", config.Profile{Authorization: "t", TeamSlug: "acme"}) + if err != nil { + t.Fatalf("saveProfile: %v", err) + } + if name != "staging" { + t.Fatalf("expected override name, got %q", name) + } +} + +func TestSaveProfileFallbackToDefaultWhenNoDefaultExists(t *testing.T) { + withTempHome(t) + + // No override, no team slug, and no existing "default" → allowed. + name, err := saveProfile("", config.Profile{Authorization: "t"}) + if err != nil { + t.Fatalf("saveProfile: %v", err) + } + if name != "default" { + t.Fatalf("expected fallback name 'default', got %q", name) + } +} + +func TestSaveProfileRefusesToClobberExistingDefault(t *testing.T) { + home := withTempHome(t) + writeConfig(t, home, `{"default_profile":"default","profiles":{"default":{"authorization":"original"}}}`) + + // No override and no team slug would fall back to "default" and silently + // overwrite the existing credential — must error instead. + _, err := saveProfile("", config.Profile{Authorization: "new"}) + if err == nil { + t.Fatal("expected error to avoid clobbering existing default profile") + } + f, _ := config.Load() + if f.Profiles["default"].Authorization != "original" { + t.Fatalf("existing default credential must be untouched, got %q", f.Profiles["default"].Authorization) + } +} diff --git a/cli/auth_login_token.go b/cli/auth_login_token.go new file mode 100644 index 0000000..fa5872d --- /dev/null +++ b/cli/auth_login_token.go @@ -0,0 +1,55 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + "github.com/latitudesh/lsh/internal/authclient" + "github.com/latitudesh/lsh/internal/config" +) + +// loginWithToken validates an existing API token by calling +// GET /user/profile and persists it under a profile. +func loginWithToken(ctx context.Context, client *authclient.Client, token, profileOverride string) error { + if token == "" { + return errors.New("--with-token requires a non-empty value") + } + + profileResp, err := client.GetUserProfile(ctx, token) + if err != nil { + var httpErr *authclient.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == 401 || httpErr.StatusCode == 403) { + return errors.New("token rejected by the API — check the value and try again") + } + return fmt.Errorf("could not validate token: %w", err) + } + + // The user profile payload doesn't include the team. Fetch it + // separately — GET /team is scoped to the token's membership + // server-side, so it returns exactly the team this token is bound to. + team, err := client.GetCurrentTeam(ctx, token) + if err != nil { + return fmt.Errorf("could not fetch team for this token: %w", err) + } + + profile := config.Profile{ + Authorization: token, + Email: profileResp.Email, + Source: config.SourceWithToken, + APIVersion: defaultAPIVersion(), + } + if team != nil { + profile.TeamID = team.ID + profile.TeamName = team.Name + profile.TeamSlug = team.Slug + } + + name, err := saveProfile(profileOverride, profile) + if err != nil { + return fmt.Errorf("could not save profile: %w", err) + } + + fmt.Printf("✅ Logged in as %s on team %s (profile: %s)\n", profile.Email, profile.TeamName, name) + return nil +} diff --git a/cli/auth_logout.go b/cli/auth_logout.go new file mode 100644 index 0000000..2fa33da --- /dev/null +++ b/cli/auth_logout.go @@ -0,0 +1,113 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/latitudesh/lsh/internal/authclient" + "github.com/latitudesh/lsh/internal/config" + "github.com/spf13/cobra" +) + +func makeOperationAuthLogoutCmd() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "logout", + Short: "Remove a stored profile and (for browser logins) revoke the API key", + Long: `Removes the named profile from the local config. If the profile was +created by the browser-assisted login flow, also asks the API to +revoke the API key so it cannot be reused. + +With --all, removes every stored profile (and revokes browser-created +keys on a best-effort basis).`, + Args: cobra.NoArgs, + RunE: runAuthLogout, + } + cmd.Flags().String("profile", "", "logout the named profile (default: active profile)") + cmd.Flags().Bool("all", false, "logout every stored profile") + return cmd, nil +} + +func runAuthLogout(cmd *cobra.Command, _ []string) error { + override, _ := cmd.Flags().GetString("profile") + all, _ := cmd.Flags().GetBool("all") + + if all && override != "" { + return errors.New("--profile and --all are mutually exclusive") + } + + f, err := config.Load() + if err != nil { + return err + } + out := cmd.OutOrStdout() + if len(f.Profiles) == 0 { + fmt.Fprintln(out, "Nothing to do — no profiles are stored.") + return nil + } + + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + client := newAuthClient() + + if all { + removed := f.SortedProfileNames() + for _, name := range removed { + revokeIfBrowserSourced(ctx, client, f.Profiles[name], name) + f.RemoveProfile(name) + } + // Persist before confirming, so a failed write doesn't print + // "Removed" for profiles that are still on disk. + if err := config.Save(f); err != nil { + return err + } + for _, name := range removed { + fmt.Fprintf(out, "Removed profile %q\n", name) + } + return nil + } + + name, profile, err := f.Resolve(override) + if err != nil { + if errors.Is(err, config.ErrProfileNotFound) { + return errors.New("no matching profile to logout") + } + return err + } + wasDefault := f.DefaultProfile == name + revokeIfBrowserSourced(ctx, client, profile, name) + f.RemoveProfile(name) + + // If we just removed the active profile, fall back to another stored + // one so the user keeps a usable context instead of landing on + // "Not logged in" despite other profiles existing. + var promoted string + if wasDefault { + promoted = f.EnsureDefault() + } + + if err := config.Save(f); err != nil { + return err + } + fmt.Fprintf(out, "Removed profile %q\n", name) + if promoted != "" { + fmt.Fprintf(out, "Active profile is now %q.\n", promoted) + } + return nil +} + +// revokeIfBrowserSourced asks the API to delete the key when the +// profile was created via browser login. Errors are surfaced as +// warnings but do not block local cleanup: the user can always retry +// the revoke from the dashboard. +func revokeIfBrowserSourced(ctx context.Context, client *authclient.Client, profile config.Profile, name string) { + if profile.Source != config.SourceBrowser || profile.KeyID == "" || profile.Authorization == "" { + return + } + if err := client.RevokeAPIKey(ctx, profile.Authorization, profile.KeyID); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not revoke API key for profile %q: %v\n", name, err) + } +} diff --git a/cli/auth_logout_test.go b/cli/auth_logout_test.go new file mode 100644 index 0000000..00776f5 --- /dev/null +++ b/cli/auth_logout_test.go @@ -0,0 +1,79 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/latitudesh/lsh/internal/config" +) + +func runAuthLogoutCmd(t *testing.T, args ...string) string { + t.Helper() + cmd, err := makeOperationAuthLogoutCmd() + if err != nil { + t.Fatalf("make cmd: %v", err) + } + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + return buf.String() +} + +func TestLogoutPromotesFallbackDefault(t *testing.T) { + home := withTempHome(t) + // Two with-token profiles (no remote revoke), teamA active. + writeConfig(t, home, `{"default_profile":"teamA","profiles":{ + "teamA":{"authorization":"a","team_slug":"teamA","source":"with-token"}, + "teamB":{"authorization":"b","team_slug":"teamB","source":"with-token"} + }}`) + + out := runAuthLogoutCmd(t) + if !strings.Contains(out, `Active profile is now "teamB"`) { + t.Fatalf("expected fallback promotion to teamB, got\n%s", out) + } + f, _ := config.Load() + if _, gone := f.Profiles["teamA"]; gone { + t.Fatal("teamA should be removed") + } + if f.DefaultProfile != "teamB" { + t.Fatalf("expected default promoted to teamB, got %q", f.DefaultProfile) + } +} + +func TestLogoutAllRemovesEverything(t *testing.T) { + home := withTempHome(t) + writeConfig(t, home, `{"default_profile":"teamA","profiles":{ + "teamA":{"authorization":"a","team_slug":"teamA","source":"with-token"}, + "teamB":{"authorization":"b","team_slug":"teamB","source":"with-token"} + }}`) + + out := runAuthLogoutCmd(t, "--all") + for _, want := range []string{`Removed profile "teamA"`, `Removed profile "teamB"`} { + if !strings.Contains(out, want) { + t.Fatalf("logout --all missing %q\n%s", want, out) + } + } + f, _ := config.Load() + if len(f.Profiles) != 0 { + t.Fatalf("expected all profiles removed, got %+v", f.Profiles) + } +} + +func TestLogoutLastProfileNoPromotion(t *testing.T) { + home := withTempHome(t) + writeConfig(t, home, `{"default_profile":"only","profiles":{"only":{"authorization":"x","team_slug":"only","source":"with-token"}}}`) + + out := runAuthLogoutCmd(t) + if strings.Contains(out, "Active profile is now") { + t.Fatalf("no promotion expected when removing the last profile, got\n%s", out) + } + f, _ := config.Load() + if len(f.Profiles) != 0 || f.DefaultProfile != "" { + t.Fatalf("expected empty config, got %+v", f) + } +} diff --git a/cli/auth_status.go b/cli/auth_status.go new file mode 100644 index 0000000..61c1a19 --- /dev/null +++ b/cli/auth_status.go @@ -0,0 +1,201 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "github.com/latitudesh/lsh/internal/authclient" + "github.com/latitudesh/lsh/internal/config" + "github.com/latitudesh/lsh/internal/tui" + "github.com/spf13/cobra" +) + +func makeOperationAuthStatusCmd() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "status", + Short: "Show the current authentication context", + Long: `Prints the email, team, default project, key name and source of the +profile that lsh would use right now, followed by every other stored +profile. Honors --profile and the LSH_PROFILE / LATITUDESH_TOKEN +environment variables. + +With --check, each stored profile's token is validated against the API +and shown as valid / invalid.`, + Args: cobra.NoArgs, + RunE: runAuthStatus, + } + cmd.Flags().String("profile", "", "show status for this profile (overrides default)") + cmd.Flags().Bool("check", false, "validate each stored profile's token against the API") + return cmd, nil +} + +func runAuthStatus(cmd *cobra.Command, _ []string) error { + override, _ := cmd.Flags().GetString("profile") + check, _ := cmd.Flags().GetBool("check") + + f, err := config.Load() + if err != nil { + return err + } + + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + var client *authclient.Client + if check { + client = newAuthClient() + } + + out := cmd.OutOrStdout() + + // Active context. LATITUDESH_TOKEN takes precedence over stored + // profiles (mirrors HydrateFromActiveProfile), so report it first. + switch { + case os.Getenv("LATITUDESH_TOKEN") != "": + fmt.Fprintln(out, "Profile: - (using LATITUDESH_TOKEN)") + fmt.Fprintln(out, "Email: -") + fmt.Fprintln(out, "Team: -") + fmt.Fprintln(out, "API key: -") + fmt.Fprintln(out, "Source: environment (LATITUDESH_TOKEN)") + if check { + envProfile := config.Profile{Authorization: os.Getenv("LATITUDESH_TOKEN")} + fmt.Fprintf(out, "Token: %s\n", styleValidity(profileValidity(ctx, client, envProfile))) + } + default: + name, profile, rErr := f.Resolve(override) + if rErr != nil && !errors.Is(rErr, config.ErrProfileNotFound) { + return rErr + } + if errors.Is(rErr, config.ErrProfileNotFound) { + if len(f.Profiles) == 0 { + fmt.Fprintln(out, "Not logged in. Run `lsh login` to authenticate.") + return nil + } + fmt.Fprintln(out, "No active profile selected. Run `lsh profile use `.") + } else { + fmt.Fprintf(out, "Profile: %s%s\n", name, defaultMarker(f.DefaultProfile, name)) + fmt.Fprintf(out, "Email: %s\n", emptyAsDash(profile.Email)) + fmt.Fprintf(out, "Team: %s\n", formatTeam(profile)) + fmt.Fprintf(out, "API key: %s\n", emptyAsDash(profile.KeyName)) + fmt.Fprintf(out, "Source: %s\n", emptyAsDash(profile.Source)) + if check { + fmt.Fprintf(out, "Token: %s\n", styleValidity(profileValidity(ctx, client, profile))) + } + } + } + + printStoredProfiles(out, ctx, f, client) + return nil +} + +// printStoredProfiles lists every stored profile, marking the default with +// "*". When client is non-nil, each profile's token is validated. +func printStoredProfiles(w io.Writer, ctx context.Context, f *config.File, client *authclient.Client) { + names := f.SortedProfileNames() + if len(names) == 0 { + return + } + fmt.Fprintln(w, "\nProfiles (* = default):") + width := 0 + for _, n := range names { + if l := len(n) + 2; l > width { // +2 for the surrounding parentheses + width = l + } + } + for _, n := range names { + p := f.Profiles[n] + active := n == f.DefaultProfile + marker := " " + if active { + marker = "* " + } + line := fmt.Sprintf("%s%-*s %s", marker, width, "("+n+")", teamLabel(p)) + if active { + line = tui.FocusedStyle.Render(line) + } + // Validity keeps its own color, appended after styling so its ANSI + // codes aren't nested inside the active-row highlight. + if client != nil { + line += " [" + styleValidity(profileValidity(ctx, client, p)) + "]" + } + fmt.Fprintln(w, line) + } + + if len(names) > 1 { + fmt.Fprintln(w, tui.HelpStyle.Render("Switch with: lsh profile use ")) + } +} + +// styleValidity colors a token-validity word: green=valid, red=invalid, +// amber for everything else (no token / check failed). +func styleValidity(status string) string { + switch status { + case "valid": + return tui.SuccessStyle.Render(status) + case "invalid": + return tui.ErrorStyle.Render(status) + default: + return tui.WarningStyle.Render(status) + } +} + +// profileValidity validates a profile's token via GET /user/profile. +// Returns "valid", "invalid" (401/403), "no token", or "unknown" when the +// check itself failed (e.g. network error). +func profileValidity(ctx context.Context, client *authclient.Client, p config.Profile) string { + if p.Authorization == "" { + return "no token" + } + if _, err := client.GetUserProfile(ctx, p.Authorization); err != nil { + var httpErr *authclient.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == 401 || httpErr.StatusCode == 403) { + return "invalid" + } + return "unknown" + } + return "valid" +} + +func defaultMarker(defaultName, current string) string { + if defaultName == current { + return " (default)" + } + return "" +} + +// teamLabel returns the human team name (falling back to the slug, then +// "-"). Unlike formatTeam it omits the slug, since the profile list already +// shows the profile name — usually the slug — in parentheses. +func teamLabel(p config.Profile) string { + if p.TeamName != "" { + return p.TeamName + } + if p.TeamSlug != "" { + return p.TeamSlug + } + return "-" +} + +func formatTeam(p config.Profile) string { + if p.TeamName == "" && p.TeamSlug == "" { + return "-" + } + if p.TeamSlug == "" { + return p.TeamName + } + if p.TeamName == "" { + return p.TeamSlug + } + return fmt.Sprintf("%s (%s)", p.TeamName, p.TeamSlug) +} + +func emptyAsDash(v string) string { + if v == "" { + return "-" + } + return v +} diff --git a/cli/auth_status_test.go b/cli/auth_status_test.go new file mode 100644 index 0000000..3eded17 --- /dev/null +++ b/cli/auth_status_test.go @@ -0,0 +1,57 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func runAuthStatusCmd(t *testing.T, args ...string) string { + t.Helper() + cmd, err := makeOperationAuthStatusCmd() + if err != nil { + t.Fatalf("make cmd: %v", err) + } + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + return buf.String() +} + +func TestAuthStatusNotLoggedIn(t *testing.T) { + withTempHome(t) + t.Setenv("LATITUDESH_TOKEN", "") + out := runAuthStatusCmd(t) + if !strings.Contains(out, "Not logged in") { + t.Fatalf("expected 'Not logged in', got\n%s", out) + } +} + +func TestAuthStatusListsAllProfiles(t *testing.T) { + home := withTempHome(t) + t.Setenv("LATITUDESH_TOKEN", "") + writeConfig(t, home, `{"default_profile":"labs","profiles":{ + "labs":{"authorization":"x","team_name":"Labs","team_slug":"labs","email":"a@x.com","source":"browser"}, + "teamb":{"authorization":"y","team_name":"Team B","team_slug":"teamb","source":"with-token"} + }}`) + + out := runAuthStatusCmd(t) + for _, want := range []string{"Profile: labs", "Profiles (* = default):", "labs", "teamb", "Switch with:"} { + if !strings.Contains(out, want) { + t.Fatalf("auth status missing %q\n%s", want, out) + } + } +} + +func TestAuthStatusHonorsEnvToken(t *testing.T) { + withTempHome(t) + t.Setenv("LATITUDESH_TOKEN", "ak_env") + out := runAuthStatusCmd(t) + if !strings.Contains(out, "LATITUDESH_TOKEN") { + t.Fatalf("expected env-token source, got\n%s", out) + } +} diff --git a/cli/cli.go b/cli/cli.go index 499f8e9..2bc6640 100755 --- a/cli/cli.go +++ b/cli/cli.go @@ -56,6 +56,30 @@ func makeClient(cmd *cobra.Command, _ []string) (*client.LatitudeShAPI, error) { func MakeRootCmd(rootCmd *cobra.Command) (*cobra.Command, error) { lsh.InitViperConfigs() + // Run ancestor PersistentPreRunE hooks even when a subcommand defines + // its own. Cobra otherwise runs only the nearest one, which would + // silently skip the root hook below (profile hydration + project + // resolution) if a generated command is ever regenerated with its own. + cobra.EnableTraverseRunHooks = true + + // Re-resolve the active profile once flags have been parsed so that + // `--profile ` overrides LSH_PROFILE / default_profile for the + // duration of the command. Then resolve the --project flag (env > + // --all-projects > interactive prompt) for commands that need it. + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + // Hydrate the active profile into viper for commands that authenticate + // against the API. Skip the login/auth/profile subtree: there --profile + // names a profile to create/inspect/remove (it may not exist yet), and + // those commands handle the flag themselves. + profile, _ := cmd.Flags().GetString("profile") + if profile != "" && !managesProfiles(cmd) { + if err := lsh.HydrateFromActiveProfile(profile); err != nil { + return err + } + } + return resolveProjectFlag(cmd) + } + // Edit commands template rootCmd.SetVersionTemplate(fmt.Sprintf("lsh %s\n", rootCmd.Version)) @@ -78,6 +102,8 @@ func MakeRootCmd(rootCmd *cobra.Command) (*cobra.Command, error) { var noInput bool rootCmd.PersistentFlags().BoolVar(&noInput, "no-input", false, "skip interactive mode") + rootCmd.PersistentFlags().String("profile", "", "use the named profile from the lsh config (overrides LSH_PROFILE and default_profile)") + // configure config location rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file path") @@ -86,13 +112,29 @@ func MakeRootCmd(rootCmd *cobra.Command) (*cobra.Command, error) { return nil, err } - // add login with api -oken + // add login (browser-assisted by default, with --with-token escape hatch) operationLoginCmd, err := makeOperationLoginCmd() if err != nil { return nil, err } rootCmd.AddCommand(operationLoginCmd) + // `auth` group (status, logout) + operationAuthCmd, err := makeOperationAuthCmd() + if err != nil { + return nil, err + } + rootCmd.AddCommand(operationAuthCmd) + + // `profile` group (use, list) — manages which stored profile is active. + // Singular form ("profile") to keep the namespace clear vs. `lsh teams` + // (plural) which manages team resources via the API. + operationProfileCmd, err := makeOperationProfileCmd() + if err != nil { + return nil, err + } + rootCmd.AddCommand(operationProfileCmd) + operationUpdateCmd, err := makeOperationUpdateCmd() if err != nil { return nil, err @@ -155,6 +197,24 @@ func registerAuthInoWriterFlags(cmd *cobra.Command) error { return nil } +// managesProfiles reports whether cmd belongs to the login/auth/profile +// subtree. There, --profile names a profile to create, inspect, or remove +// — so it may legitimately not exist yet, and the root hydration hook must +// not require it. Every other command uses --profile to pick an existing +// profile to authenticate with. +func managesProfiles(cmd *cobra.Command) bool { + top := cmd + for top.Parent() != nil && top.Parent().HasParent() { + top = top.Parent() + } + switch top.Name() { + case "login", "auth", "profile": + return true + default: + return false + } +} + // makeAuthInfoWriter retrieves cmd flags and construct an auth info writer func makeAuthInfoWriter(_ *cobra.Command) (runtime.ClientAuthInfoWriter, error) { auths := []runtime.ClientAuthInfoWriter{} diff --git a/cli/get_servers_operation.go b/cli/get_servers_operation.go index 83405ea..07c0f57 100755 --- a/cli/get_servers_operation.go +++ b/cli/get_servers_operation.go @@ -26,6 +26,10 @@ func makeOperationServersGetServersCmd() (*cobra.Command, error) { return nil, err } + // MANUAL — keep when regenerating. Lets the user skip the interactive + // project prompt and list servers from every project. + cmd.Flags().Bool("all-projects", false, "list servers across all projects in the active team") + return cmd, nil } diff --git a/cli/get_virtual_networks_operation.go b/cli/get_virtual_networks_operation.go index add6cf4..a26875d 100755 --- a/cli/get_virtual_networks_operation.go +++ b/cli/get_virtual_networks_operation.go @@ -25,6 +25,10 @@ func makeOperationVirtualNetworksGetVirtualNetworksCmd() (*cobra.Command, error) return nil, err } + // MANUAL — keep when regenerating. Lets the user skip the interactive + // project prompt and list virtual networks across all projects. + cmd.Flags().Bool("all-projects", false, "list virtual networks across all projects in the active team") + return cmd, nil } diff --git a/cli/login.go b/cli/login.go deleted file mode 100644 index aeaf4fe..0000000 --- a/cli/login.go +++ /dev/null @@ -1,64 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "path" - - homedir "github.com/mitchellh/go-homedir" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -func makeOperationLoginCmd() (*cobra.Command, error) { - // loginCmd represents the login command - cmd := &cobra.Command{ - Use: "login [api-token]", - Short: "Set your Auth Token", - Long: `login will create a configuration file and save your API authentication -token in it, allowing it to be used when interacting with the API. - -The configuration will be stored in your home directory and also copied to -root's directory so sudo commands work seamlessly.`, - Args: cobra.MaximumNArgs(1), - RunE: runOperationLogin, - } - - return cmd, nil -} - -func runOperationLogin(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - cmd.Help() - os.Exit(0) - } - - home, err := homedir.Dir() - cobra.CheckErr(err) - - folderPath := path.Join(home, ".config", exeName) - - if _, err := os.Stat(folderPath); os.IsNotExist(err) { - os.MkdirAll(folderPath, 0700) - } - - configPath := path.Join(folderPath, "config.json") - f, err := os.Create(configPath) - if err != nil { - return err - } - defer f.Close() - - viper.Set("API-Version", "2023-06-01") - viper.Set("authorization", args[0]) - viper.WriteConfig() - - fmt.Println("✅ Success! Configuration file updated.") - fmt.Printf(" Config stored at: %s\n", configPath) - fmt.Printf("\n") - fmt.Printf("You can now use both regular and sudo commands:\n") - fmt.Printf(" lsh servers list\n") - fmt.Printf(" sudo lsh block mount --id \n") - - return nil -} diff --git a/cli/manages_profiles_test.go b/cli/manages_profiles_test.go new file mode 100644 index 0000000..b04fbdd --- /dev/null +++ b/cli/manages_profiles_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestManagesProfiles(t *testing.T) { + root := &cobra.Command{Use: "lsh"} + + login := &cobra.Command{Use: "login"} + root.AddCommand(login) + + auth := &cobra.Command{Use: "auth"} + authStatus := &cobra.Command{Use: "status"} + auth.AddCommand(authStatus) + root.AddCommand(auth) + + profile := &cobra.Command{Use: "profile"} + profileUse := &cobra.Command{Use: "use"} + profile.AddCommand(profileUse) + root.AddCommand(profile) + + servers := &cobra.Command{Use: "servers"} + serversList := &cobra.Command{Use: "list"} + servers.AddCommand(serversList) + root.AddCommand(servers) + + cases := []struct { + cmd *cobra.Command + want bool + }{ + {login, true}, + {authStatus, true}, // nested under auth + {auth, true}, + {profileUse, true}, // nested under profile + {servers, false}, + {serversList, false}, // generated API command → must hydrate + } + for _, c := range cases { + if got := managesProfiles(c.cmd); got != c.want { + t.Fatalf("managesProfiles(%q) = %v, want %v", c.cmd.Name(), got, c.want) + } + } +} diff --git a/cli/profile.go b/cli/profile.go new file mode 100644 index 0000000..e1fdb80 --- /dev/null +++ b/cli/profile.go @@ -0,0 +1,143 @@ +package cli + +import ( + "errors" + "fmt" + "io" + + "github.com/latitudesh/lsh/internal/config" + "github.com/latitudesh/lsh/internal/tui" + "github.com/spf13/cobra" +) + +func makeOperationProfileCmd() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "profile", + Short: "Manage local CLI profiles (one per team you are logged into)", + } + useCmd, err := makeOperationProfileUseCmd() + if err != nil { + return nil, err + } + cmd.AddCommand(useCmd) + + listCmd, err := makeOperationProfileListCmd() + if err != nil { + return nil, err + } + cmd.AddCommand(listCmd) + + return cmd, nil +} + +func makeOperationProfileUseCmd() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "use [profile-name-or-team-slug]", + Short: "Set the active profile (and therefore the active team)", + Long: "Picks a locally stored profile and marks it as the default. The " + + "argument can be the profile name or the slug/id of the team it is " + + "bound to. Run `lsh profile list` to see what is stored locally.", + Args: cobra.MaximumNArgs(1), + RunE: runProfileUse, + } + return cmd, nil +} + +func makeOperationProfileListCmd() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "list", + Short: "List the locally stored profiles (one per team)", + Args: cobra.NoArgs, + RunE: runProfileList, + } + return cmd, nil +} + +func runProfileUse(cmd *cobra.Command, args []string) error { + f, err := config.Load() + if err != nil { + return err + } + if len(f.Profiles) == 0 { + return errors.New("no profiles stored — run `lsh login` first") + } + + if len(args) == 0 { + out := cmd.OutOrStdout() + printProfileList(out, f) + fmt.Fprintln(out) + return errors.New("provide a profile name or team slug to switch to (see list above)") + } + + target := args[0] + + // Primary: exact profile name match. + if profile, ok := f.Profiles[target]; ok { + f.DefaultProfile = target + if err := config.Save(f); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Active profile is now %q (team: %s)\n", target, formatTeam(profile)) + return nil + } + + // Fallback: match by team identifier so users can pick by team slug/id. + for name, profile := range f.Profiles { + if profile.TeamID == target || profile.TeamSlug == target { + f.DefaultProfile = name + if err := config.Save(f); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Active profile is now %q (team: %s)\n", name, formatTeam(profile)) + return nil + } + } + return fmt.Errorf("no local profile matches %q — run `lsh login` to add it", target) +} + +func runProfileList(cmd *cobra.Command, _ []string) error { + f, err := config.Load() + if err != nil { + return err + } + out := cmd.OutOrStdout() + if len(f.Profiles) == 0 { + fmt.Fprintln(out, "No profiles stored. Run `lsh login` to authenticate.") + return nil + } + printProfileList(out, f) + return nil +} + +func printProfileList(w io.Writer, f *config.File) { + names := f.SortedProfileNames() + + // Size the columns to their content so long names/teams stay aligned. + nameW, teamW := len("PROFILE"), len("TEAM") + for _, name := range names { + if len(name) > nameW { + nameW = len(name) + } + if t := teamLabel(f.Profiles[name]); len(t) > teamW { + teamW = len(t) + } + } + + fmt.Fprintf(w, " %-*s %-*s %s\n", nameW, "PROFILE", teamW, "TEAM", "EMAIL") + for _, name := range names { + p := f.Profiles[name] + active := name == f.DefaultProfile + marker := " " + if active { + marker = "* " + } + line := fmt.Sprintf("%s%-*s %-*s %s", marker, nameW, name, teamW, teamLabel(p), emptyAsDash(p.Email)) + if active { + line = tui.FocusedStyle.Render(line) + } + fmt.Fprintln(w, line) + } + if f.DefaultProfile != "" { + fmt.Fprintln(w, "\n* = active profile") + } +} diff --git a/cli/profile_test.go b/cli/profile_test.go new file mode 100644 index 0000000..e67b2b8 --- /dev/null +++ b/cli/profile_test.go @@ -0,0 +1,32 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/latitudesh/lsh/internal/config" +) + +func TestPrintProfileList(t *testing.T) { + f := &config.File{ + DefaultProfile: "labs", + Profiles: map[string]config.Profile{ + "labs": {TeamName: "Labs", TeamSlug: "labs", Email: "a@x.com"}, + "teamb": {TeamName: "Team B", TeamSlug: "teamb", Email: "b@x.com"}, + }, + } + + var buf bytes.Buffer + printProfileList(&buf, f) + out := buf.String() + + for _, want := range []string{"PROFILE", "TEAM", "EMAIL", "labs", "teamb", "Labs", "Team B", "* = active profile"} { + if !strings.Contains(out, want) { + t.Fatalf("profile list missing %q\n%s", want, out) + } + } + if !strings.Contains(out, "* ") { + t.Fatalf("expected an active-row marker, got\n%s", out) + } +} diff --git a/cli/project_flag.go b/cli/project_flag.go new file mode 100644 index 0000000..03cdb37 --- /dev/null +++ b/cli/project_flag.go @@ -0,0 +1,75 @@ +package cli + +import ( + "errors" + "fmt" + "os" + + "github.com/latitudesh/lsh/internal/prompt" + "github.com/latitudesh/lsh/internal/util" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// isInteractive reports whether we can prompt the user. It's a package +// var so tests can force the non-interactive path deterministically. +var isInteractive = util.IsTTY + +// resolveProjectFlag ensures the active command has a project value +// when it needs one. The resolution order is: +// +// 1. user passed --project explicitly → use it +// 2. $LSH_PROJECT is set → use it +// 3. command supports --all-projects and it's set → skip (no filter) +// 4. interactive TTY → prompt and pick one +// 5. otherwise → fail with an actionable error +// +// Commands without a "project" flag are left alone. +func resolveProjectFlag(cmd *cobra.Command) error { + projectFlag := cmd.Flags().Lookup("project") + if projectFlag == nil { + return nil + } + if projectFlag.Changed { + return nil + } + + if env := os.Getenv("LSH_PROJECT"); env != "" { + return cmd.Flags().Set("project", env) + } + + supportsAll := cmd.Flags().Lookup("all-projects") != nil + if supportsAll { + // Only skip the project requirement when --all-projects is actually + // true; an explicit --all-projects=false must not bypass it. + if allProjects, _ := cmd.Flags().GetBool("all-projects"); allProjects { + return nil + } + } + + if !isInteractive() { + hint := "pass --project= or set LSH_PROJECT" + if supportsAll { + hint = "pass --project=, --all-projects, or set LSH_PROJECT" + } + return fmt.Errorf("--project is required (%s)", hint) + } + + token := viper.GetString("Authorization") + if token == "" { + return errors.New("not logged in — run `lsh login` first") + } + + client := newAuthClient() + + selected, err := prompt.SelectProject(cmd.Context(), client, token, supportsAll) + if err != nil { + return err + } + if selected == prompt.AllProjectsSentinel { + // User picked "All projects" in the prompt → leave the flag + // unset; the generated command will skip the filter. + return nil + } + return cmd.Flags().Set("project", selected) +} diff --git a/cli/project_flag_test.go b/cli/project_flag_test.go new file mode 100644 index 0000000..bf6cc79 --- /dev/null +++ b/cli/project_flag_test.go @@ -0,0 +1,86 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// newProjectCmd builds a command with the flags resolveProjectFlag inspects. +func newProjectCmd(withAllProjects bool) *cobra.Command { + cmd := &cobra.Command{Use: "x"} + cmd.Flags().String("project", "", "") + if withAllProjects { + cmd.Flags().Bool("all-projects", false, "") + } + return cmd +} + +// forceNonInteractive makes resolveProjectFlag take the deterministic +// (non-prompt) path for the duration of the test. +func forceNonInteractive(t *testing.T) { + t.Helper() + prev := isInteractive + isInteractive = func() bool { return false } + t.Cleanup(func() { isInteractive = prev }) +} + +func TestResolveProjectFlag_NoProjectFlag_NoOp(t *testing.T) { + cmd := &cobra.Command{Use: "x"} // no --project flag at all + if err := resolveProjectFlag(cmd); err != nil { + t.Fatalf("expected no-op for command without --project, got %v", err) + } +} + +func TestResolveProjectFlag_ExplicitProject_Passes(t *testing.T) { + cmd := newProjectCmd(true) + _ = cmd.Flags().Set("project", "proj_123") + if err := resolveProjectFlag(cmd); err != nil { + t.Fatalf("expected pass with explicit --project, got %v", err) + } +} + +func TestResolveProjectFlag_EnvProject_Passes(t *testing.T) { + t.Setenv("LSH_PROJECT", "proj_env") + cmd := newProjectCmd(true) + if err := resolveProjectFlag(cmd); err != nil { + t.Fatalf("expected pass with LSH_PROJECT, got %v", err) + } + if v, _ := cmd.Flags().GetString("project"); v != "proj_env" { + t.Fatalf("expected project set from env, got %q", v) + } +} + +func TestResolveProjectFlag_AllProjectsTrue_Skips(t *testing.T) { + cmd := newProjectCmd(true) + _ = cmd.Flags().Set("all-projects", "true") + if err := resolveProjectFlag(cmd); err != nil { + t.Fatalf("expected skip with --all-projects=true, got %v", err) + } +} + +func TestResolveProjectFlag_AllProjectsFalse_DoesNotBypass(t *testing.T) { + forceNonInteractive(t) + cmd := newProjectCmd(true) + _ = cmd.Flags().Set("all-projects", "false") + err := resolveProjectFlag(cmd) + if err == nil { + t.Fatal("--all-projects=false must not bypass the project requirement") + } + if !strings.Contains(err.Error(), "--all-projects") { + t.Fatalf("hint should mention --all-projects on a command that has it, got %q", err.Error()) + } +} + +func TestResolveProjectFlag_NonInteractiveHintOmitsAllProjectsWhenUnsupported(t *testing.T) { + forceNonInteractive(t) + cmd := newProjectCmd(false) // no --all-projects flag + err := resolveProjectFlag(cmd) + if err == nil { + t.Fatal("expected project-required error") + } + if strings.Contains(err.Error(), "--all-projects") { + t.Fatalf("hint must not mention --all-projects when the command lacks it, got %q", err.Error()) + } +} diff --git a/cli/testhelpers_test.go b/cli/testhelpers_test.go new file mode 100644 index 0000000..6e62492 --- /dev/null +++ b/cli/testhelpers_test.go @@ -0,0 +1,44 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + homedir "github.com/mitchellh/go-homedir" +) + +// withTempHome points the config path at a throwaway directory for the +// duration of the test. config.Path resolves via homedir.Dir (which reads +// $HOME and caches), so we set $HOME and clear the cache before and after. +func withTempHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + homedir.Reset() + t.Cleanup(homedir.Reset) + return dir +} + +// writeConfig seeds the isolated config file with raw JSON. +func writeConfig(t *testing.T, home, body string) { + t.Helper() + p := filepath.Join(home, ".config", "lsh", "config.json") + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +// readConfig returns the raw isolated config file contents. +func readConfig(t *testing.T, home string) string { + t.Helper() + p := filepath.Join(home, ".config", "lsh", "config.json") + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read config: %v", err) + } + return string(b) +} diff --git a/cmd/lsh/lsh.go b/cmd/lsh/lsh.go index a16d589..16cb2d9 100644 --- a/cmd/lsh/lsh.go +++ b/cmd/lsh/lsh.go @@ -3,6 +3,7 @@ package lsh import ( "context" + "errors" "fmt" "log" "os" @@ -10,6 +11,7 @@ import ( "path" latitudeshgosdk "github.com/latitudesh/latitudesh-go-sdk" + "github.com/latitudesh/lsh/internal/config" "github.com/latitudesh/lsh/internal/version" "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" @@ -81,7 +83,63 @@ func InitViperConfigs() { if sudoUser != "" { LogDebugf("[CONFIG] Also searched sudo user paths\n") } - return + } else { + LogDebugf("[CONFIG] ✓ Using config file: %v\n", viper.ConfigFileUsed()) + } + + // Hydrate the legacy top-level `Authorization` / `api-version` viper + // keys (which the generated commands read) from the active profile. + // This keeps every existing operation working unchanged while the + // new login flow stores credentials per profile. + _ = HydrateFromActiveProfile("") +} + +// HydrateFromActiveProfile resolves the active profile (honoring +// LATITUDESH_TOKEN, the explicit override, LSH_PROFILE and the stored +// default_profile, in that order) and sets the per-request viper keys +// used by the generated SDK calls. It is safe to call multiple times — +// useful when a `--profile` flag is parsed after the initial load. +func HydrateFromActiveProfile(override string) error { + if token := os.Getenv("LATITUDESH_TOKEN"); token != "" { + viper.Set("Authorization", token) + if viper.GetString("api-version") == "" { + viper.Set("api-version", "2023-06-01") + } + LogDebugf("[AUTH] Using LATITUDESH_TOKEN from environment") + if override != "" { + fmt.Fprintf(os.Stderr, "warning: --profile %q ignored because LATITUDESH_TOKEN is set\n", override) + } + return nil + } + + f, err := config.Load() + if err != nil { + if override != "" { + return fmt.Errorf("could not load profile config: %w", err) + } + LogDebugf("[CONFIG] Could not load profile config: %v", err) + return nil + } + + _, profile, err := f.Resolve(override) + if err != nil { + // An explicit --profile that can't be resolved must fail loudly: + // silently falling back to the default profile would run the + // command under the wrong team's credentials. + if override != "" { + return fmt.Errorf("profile %q not found — run `lsh profile list` to see available profiles", override) + } + if !errors.Is(err, config.ErrProfileNotFound) { + LogDebugf("[CONFIG] Could not resolve profile: %v", err) + } + return nil + } + + if profile.Authorization != "" { + viper.Set("Authorization", profile.Authorization) + } + if profile.APIVersion != "" { + viper.Set("api-version", profile.APIVersion) } - LogDebugf("[CONFIG] ✓ Using config file: %v\n", viper.ConfigFileUsed()) + return nil } diff --git a/go.mod b/go.mod index 8a0acf8..e22a5fa 100644 --- a/go.mod +++ b/go.mod @@ -62,6 +62,7 @@ require ( github.com/oklog/ulid v1.3.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect diff --git a/go.sum b/go.sum index 925ab74..0e83c17 100644 --- a/go.sum +++ b/go.sum @@ -167,6 +167,8 @@ github.com/pb33f/libopenapi v0.15.14 h1:A0fn45jbthDyFGXfu5bYIZVsWyPI6hJYm3wG143M github.com/pb33f/libopenapi v0.15.14/go.mod h1:PEXNwvtT4KNdjrwudp5OYnD1ryqK6uJ68aMNyWvoMuc= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -282,6 +284,7 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/authclient/client.go b/internal/authclient/client.go new file mode 100644 index 0000000..98bd4b6 --- /dev/null +++ b/internal/authclient/client.go @@ -0,0 +1,285 @@ +// Package authclient is a minimal HTTP client for the CLI session and +// profile endpoints that lsh uses during login/logout. These endpoints +// are not in the generated go-swagger client, so we keep a small, +// dependency-free wrapper here. +package authclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +const ( + defaultTimeout = 30 * time.Second +) + +// Client talks to the Latitude API for auth-related endpoints. +type Client struct { + baseURL string + userAgent string + apiVersion string + httpClient *http.Client +} + +// New builds a Client. baseURL should include scheme and host (e.g. +// "https://api.latitude.sh"). apiVersion is sent as the API-Version +// header on every request, matching what the generated SDK does. +func New(baseURL, userAgent, apiVersion string) *Client { + return &Client{ + baseURL: baseURL, + userAgent: userAgent, + apiVersion: apiVersion, + httpClient: &http.Client{Timeout: defaultTimeout}, + } +} + +// CreateSessionRequest is the body of POST /auth/cli_sessions. +type CreateSessionRequest struct { + ClientName string `json:"client_name,omitempty"` + ClientVersion string `json:"client_version,omitempty"` +} + +// Session is the public payload returned by the API on create and on +// the secret-gated poll (with the credential fields populated). +type Session struct { + ID string `json:"id"` + Secret string `json:"secret,omitempty"` + UserCode string `json:"user_code,omitempty"` + AuthorizeURL string `json:"authorize_url,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + Status string `json:"status,omitempty"` + APIKey *APIKey `json:"api_key,omitempty"` + Team *Team `json:"team,omitempty"` + User *User `json:"user,omitempty"` +} + +type APIKey struct { + ID string `json:"id"` + Token string `json:"token"` + Name string `json:"name"` +} + +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type User struct { + ID string `json:"id"` + Email string `json:"email"` +} + +// HTTPError is returned when the API responds with a non-2xx status. +type HTTPError struct { + StatusCode int + Body string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("api error: status=%d body=%s", e.StatusCode, e.Body) +} + +// CreateSession opens a new CLI login session. Unauthenticated. +func (c *Client) CreateSession(ctx context.Context, req CreateSessionRequest) (*Session, error) { + var session struct { + Data Session `json:"data"` + } + if err := c.do(ctx, http.MethodPost, "/auth/cli_sessions", nil, req, &session); err != nil { + return nil, err + } + return &session.Data, nil +} + +// PollSession reads the session with the secret. Returns the Session +// (with credential fields when status=approved) or HTTPError. Callers +// treat 410 (gone) and 404 (not found) as terminal. +func (c *Client) PollSession(ctx context.Context, id, secret string) (*Session, error) { + if id == "" { + return nil, errors.New("authclient: empty session id") + } + if secret == "" { + return nil, errors.New("authclient: empty secret") + } + headers := map[string]string{"X-CLI-Secret": secret} + var resp struct { + Data Session `json:"data"` + } + if err := c.do(ctx, http.MethodGet, "/auth/cli_sessions/"+id, headers, nil, &resp); err != nil { + return nil, err + } + return &resp.Data, nil +} + +// RevokeAPIKey deletes an API key by id. Used by `lsh auth logout` on +// sessions created via the browser flow. +func (c *Client) RevokeAPIKey(ctx context.Context, token, keyID string) error { + if keyID == "" { + return errors.New("authclient: empty key id") + } + headers := map[string]string{"Authorization": token} + return c.do(ctx, http.MethodDelete, "/auth/api_keys/"+keyID, headers, nil, nil) +} + +// UserProfile is the subset of GET /user/profile that lsh uses to +// validate a token and to populate config after a --with-token login. +type UserProfile struct { + ID string `json:"id"` + Email string `json:"email"` + Team Team `json:"team"` +} + +// GetUserProfile validates the token and returns user/team context. +// Used by `lsh login --with-token `. Note: the Rails resource does +// not return the team in this payload; use GetCurrentTeam to fetch it. +func (c *Client) GetUserProfile(ctx context.Context, token string) (*UserProfile, error) { + headers := map[string]string{"Authorization": token} + var resp struct { + Data struct { + Attributes UserProfile `json:"attributes"` + } `json:"data"` + } + if err := c.do(ctx, http.MethodGet, "/user/profile", headers, nil, &resp); err != nil { + return nil, err + } + return &resp.Data.Attributes, nil +} + +// Project is the subset of fields the interactive project picker needs. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// ListProjects returns every project accessible to the token's team. +// Used by the interactive project picker when a command needs a project +// but the user did not pass --project. It pages through the API (which +// defaults to 20 per page) so teams with many projects are fully listed. +func (c *Client) ListProjects(ctx context.Context, token string) ([]Project, error) { + headers := map[string]string{"Authorization": token} + const pageSize = 100 + const maxPages = 100 // safety cap (≤ 10k projects) + + var projects []Project + total := -1 + for page := 1; page <= maxPages; page++ { + q := url.Values{} + q.Set("page[size]", strconv.Itoa(pageSize)) + q.Set("page[number]", strconv.Itoa(page)) + q.Set("stats[total]", "count") + + var resp struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Name string `json:"name"` + Slug string `json:"slug"` + } `json:"attributes"` + } `json:"data"` + Meta struct { + Stats struct { + Total struct { + Count int `json:"count"` + } `json:"total"` + } `json:"stats"` + } `json:"meta"` + } + if err := c.do(ctx, http.MethodGet, "/projects?"+q.Encode(), headers, nil, &resp); err != nil { + return nil, err + } + for _, p := range resp.Data { + projects = append(projects, Project{ID: p.ID, Name: p.Attributes.Name, Slug: p.Attributes.Slug}) + } + if total < 0 { + total = resp.Meta.Stats.Total.Count + } + // Stop when the page is empty or we've collected the reported total. + // The empty-page guard covers a missing/zero total. + if len(resp.Data) == 0 || (total >= 0 && len(projects) >= total) { + break + } + } + return projects, nil +} + +// GetCurrentTeam returns the team bound to the token's membership. +// GET /team is server-side scoped to current_user_membership, so the +// returned list contains exactly one entry for a valid token. +func (c *Client) GetCurrentTeam(ctx context.Context, token string) (*Team, error) { + headers := map[string]string{"Authorization": token} + var resp struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Name string `json:"name"` + Slug string `json:"slug"` + } `json:"attributes"` + } `json:"data"` + } + if err := c.do(ctx, http.MethodGet, "/team", headers, nil, &resp); err != nil { + return nil, err + } + if len(resp.Data) == 0 { + return nil, nil + } + first := resp.Data[0] + return &Team{ID: first.ID, Name: first.Attributes.Name, Slug: first.Attributes.Slug}, nil +} + +func (c *Client) do(ctx context.Context, method, path string, headers map[string]string, body, out any) error { + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("authclient: marshal request: %w", err) + } + reqBody = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) + if err != nil { + return fmt.Errorf("authclient: build request: %w", err) + } + req.Header.Set("Accept", "application/json") + if reqBody != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.userAgent != "" { + req.Header.Set("User-Agent", c.userAgent) + } + if c.apiVersion != "" { + req.Header.Set("API-Version", c.apiVersion) + } + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("authclient: do request: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &HTTPError{StatusCode: resp.StatusCode, Body: string(respBody)} + } + + if out == nil || len(respBody) == 0 { + return nil + } + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("authclient: decode response: %w", err) + } + return nil +} diff --git a/internal/authclient/client_test.go b/internal/authclient/client_test.go new file mode 100644 index 0000000..711deef --- /dev/null +++ b/internal/authclient/client_test.go @@ -0,0 +1,220 @@ +package authclient + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, func()) { + t.Helper() + srv := httptest.NewServer(h) + return New(srv.URL, "lsh-test/0.0.0", "2023-06-01"), srv.Close +} + +func TestCreateSession(t *testing.T) { + c, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/cli_sessions" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Fatalf("missing Content-Type") + } + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"data":{"id":"sid","secret":"shh","user_code":"WDJB-MJHT","authorize_url":"https://x/y?session=sid","expires_at":"2026-01-01T00:00:00Z"}}`)) + }) + defer stop() + + got, err := c.CreateSession(context.Background(), CreateSessionRequest{ClientName: "lsh", ClientVersion: "1.0"}) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if got.ID != "sid" || got.Secret != "shh" || got.UserCode != "WDJB-MJHT" { + t.Fatalf("unexpected session payload: %+v", got) + } +} + +func TestPollSessionPending(t *testing.T) { + c, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/cli_sessions/sid" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("X-CLI-Secret") != "shh" { + t.Fatalf("missing X-CLI-Secret") + } + w.Write([]byte(`{"data":{"status":"pending"}}`)) + }) + defer stop() + + got, err := c.PollSession(context.Background(), "sid", "shh") + if err != nil { + t.Fatalf("PollSession: %v", err) + } + if got.Status != "pending" { + t.Fatalf("expected pending, got %s", got.Status) + } + if got.APIKey != nil { + t.Fatalf("did not expect api_key on pending payload") + } +} + +func TestPollSessionApproved(t *testing.T) { + c, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"data":{"status":"approved","api_key":{"id":"k","token":"t","name":"lsh"},"team":{"id":"team_abc","name":"Acme","slug":"acme"},"user":{"id":"u","email":"u@example.com"}}}`)) + }) + defer stop() + + got, err := c.PollSession(context.Background(), "sid", "shh") + if err != nil { + t.Fatalf("PollSession: %v", err) + } + if got.Status != "approved" { + t.Fatalf("expected approved, got %s", got.Status) + } + if got.APIKey == nil || got.APIKey.Token != "t" { + t.Fatalf("missing api_key in approved payload: %+v", got) + } + if got.Team == nil || got.Team.Slug != "acme" { + t.Fatalf("missing team: %+v", got.Team) + } +} + +func TestPollSessionGone(t *testing.T) { + c, stop := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusGone) + w.Write([]byte(`{"data":{"status":"gone"}}`)) + }) + defer stop() + + _, err := c.PollSession(context.Background(), "sid", "shh") + if err == nil { + t.Fatal("expected HTTPError on 410") + } + var httpErr *HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("expected *HTTPError, got %T", err) + } + if httpErr.StatusCode != http.StatusGone { + t.Fatalf("expected 410, got %d", httpErr.StatusCode) + } +} + +func TestPollSessionRejectsEmptyArgs(t *testing.T) { + c := New("http://example.invalid", "lsh-test/0.0.0", "2023-06-01") + if _, err := c.PollSession(context.Background(), "", "shh"); err == nil { + t.Fatal("expected error on empty id") + } + if _, err := c.PollSession(context.Background(), "sid", ""); err == nil { + t.Fatal("expected error on empty secret") + } +} + +func TestRevokeAPIKey(t *testing.T) { + called := false + c, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + called = true + if r.URL.Path != "/auth/api_keys/key_xyz" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodDelete { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.Header.Get("Authorization") != "ak_xxx" { + t.Fatalf("expected Authorization header to carry the token directly, got %q", r.Header.Get("Authorization")) + } + w.WriteHeader(http.StatusNoContent) + }) + defer stop() + + if err := c.RevokeAPIKey(context.Background(), "ak_xxx", "key_xyz"); err != nil { + t.Fatalf("RevokeAPIKey: %v", err) + } + if !called { + t.Fatal("expected API to be called") + } +} + +func TestGetUserProfile(t *testing.T) { + c, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/user/profile" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "ak_xxx" { + t.Fatalf("expected Authorization to carry token directly") + } + w.Write([]byte(`{"data":{"attributes":{"id":"u","email":"u@example.com","team":{"id":"team_abc","name":"Acme","slug":"acme"}}}}`)) + }) + defer stop() + + got, err := c.GetUserProfile(context.Background(), "ak_xxx") + if err != nil { + t.Fatalf("GetUserProfile: %v", err) + } + if got.Email != "u@example.com" || got.Team.Slug != "acme" { + t.Fatalf("unexpected profile payload: %+v", got) + } +} + +func TestSendsAPIVersionHeader(t *testing.T) { + var gotVersion string + c, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotVersion = r.Header.Get("API-Version") + w.Write([]byte(`{"data":{"status":"pending"}}`)) + }) + defer stop() + + if _, err := c.PollSession(context.Background(), "sid", "shh"); err != nil { + t.Fatalf("PollSession: %v", err) + } + if gotVersion != "2023-06-01" { + t.Fatalf("expected API-Version header, got %q", gotVersion) + } +} + +func TestListProjectsPaginates(t *testing.T) { + all := []struct{ id, name, slug string }{ + {"p1", "One", "one"}, {"p2", "Two", "two"}, {"p3", "Three", "three"}, + } + const perPage = 2 // server caps below the client's requested page size + + var requestedPages []string + c, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/projects" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + requestedPages = append(requestedPages, r.URL.Query().Get("page[number]")) + page, _ := strconv.Atoi(r.URL.Query().Get("page[number]")) + if page < 1 { + page = 1 + } + var items []string + for i := (page - 1) * perPage; i < page*perPage && i < len(all); i++ { + p := all[i] + items = append(items, fmt.Sprintf(`{"id":%q,"attributes":{"name":%q,"slug":%q}}`, p.id, p.name, p.slug)) + } + fmt.Fprintf(w, `{"data":[%s],"meta":{"stats":{"total":{"count":%d}}}}`, strings.Join(items, ","), len(all)) + }) + defer stop() + + got, err := c.ListProjects(context.Background(), "ak_xxx") + if err != nil { + t.Fatalf("ListProjects: %v", err) + } + if len(got) != len(all) { + t.Fatalf("expected %d projects across pages, got %d", len(all), len(got)) + } + if got[2].Slug != "three" { + t.Fatalf("last paged project missing: %+v", got) + } + if len(requestedPages) < 2 { + t.Fatalf("expected the client to page through results, requested pages: %v", requestedPages) + } +} diff --git a/internal/browser/open.go b/internal/browser/open.go new file mode 100644 index 0000000..1f360d0 --- /dev/null +++ b/internal/browser/open.go @@ -0,0 +1,39 @@ +// Package browser opens a URL in the user's default browser, with a +// hook so tests can substitute the real implementation. +package browser + +import ( + "os" + "runtime" + + "github.com/pkg/browser" +) + +// Opener is the function used to open URLs. Tests can replace this +// with a no-op or capture function. +var Opener = browser.OpenURL + +// Open invokes Opener for the given URL. +func Open(url string) error { + return Opener(url) +} + +// LooksHeadless reports whether the current environment seems +// incapable of opening a desktop browser (SSH session, no display, +// or non-TTY stdin). Callers use this to decide whether to attempt +// browser.Open at all — if headless, the URL is printed and the user +// opens it manually on their own machine. +func LooksHeadless() bool { + if os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "" { + return true + } + if runtime.GOOS == "linux" && os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { + return true + } + if info, err := os.Stdin.Stat(); err == nil { + if (info.Mode() & os.ModeCharDevice) == 0 { + return true + } + } + return false +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c0fa834 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,196 @@ +// Package config loads and persists the lsh config file with support +// for multiple profiles (one per team), env var overrides, and a +// migration path from the previous single-token format. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + + homedir "github.com/mitchellh/go-homedir" +) + +// File holds the on-disk shape of ~/.config/lsh/config.json. +type File struct { + DefaultProfile string `json:"default_profile,omitempty"` + Profiles map[string]Profile `json:"profiles,omitempty"` + + // Hostname/Scheme/BasePath are kept at the top level (not per + // profile) — they apply globally and are usually only overridden + // for development. Existing config files use lowercase keys. + Hostname string `json:"hostname,omitempty"` + Scheme string `json:"scheme,omitempty"` + BasePath string `json:"base_path,omitempty"` + + // Output / Json control rendering for the existing generated + // commands. Kept top-level for backward compatibility. + Output string `json:"output,omitempty"` + JSON bool `json:"json,omitempty"` +} + +// ErrProfileNotFound is returned when a profile name is requested but +// is not defined in the config file. +var ErrProfileNotFound = errors.New("config: profile not found") + +const ( + dirPerm = 0o700 + filePerm = 0o600 + dirName = ".config" + exeName = "lsh" +) + +// homeDir is the package-level home directory resolver. Tests +// substitute this with a function that returns a temporary directory. +var homeDir = homedir.Dir + +// Path returns the absolute path to the config file (existing or not). +func Path() (string, error) { + home, err := homeDir() + if err != nil { + return "", err + } + return filepath.Join(home, dirName, exeName, "config.json"), nil +} + +// Load reads the config file from disk. If it does not exist, returns +// a zero-valued *File (callers can then populate and Save). +func Load() (*File, error) { + p, err := Path() + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return &File{Profiles: map[string]Profile{}}, nil + } + return nil, fmt.Errorf("config: read %s: %w", p, err) + } + f := &File{} + if err := json.Unmarshal(data, f); err != nil { + return nil, fmt.Errorf("config: parse %s: %w", p, err) + } + if migrateLegacyInto(f, data) { + // Persist the migrated format so the on-disk file stops being + // legacy. Best-effort: a read-only environment shouldn't turn a + // successful load into an error — the in-memory result is correct + // regardless. + _ = Save(f) + } + if f.Profiles == nil { + f.Profiles = map[string]Profile{} + } + return f, nil +} + +// Save writes the config back to disk, creating ~/.config/lsh if needed. +func Save(f *File) error { + p, err := Path() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), dirPerm); err != nil { + return fmt.Errorf("config: mkdir: %w", err) + } + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return fmt.Errorf("config: marshal: %w", err) + } + // Write to a temp file in the same directory, then atomically rename + // over the target. A crash mid-write can't truncate the existing + // credential store this way (os.WriteFile would). + tmp, err := os.CreateTemp(filepath.Dir(p), ".config-*.tmp") + if err != nil { + return fmt.Errorf("config: create temp: %w", err) + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("config: write temp: %w", err) + } + if err := tmp.Chmod(filePerm); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("config: chmod temp: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("config: close temp: %w", err) + } + if err := os.Rename(tmpName, p); err != nil { + os.Remove(tmpName) + return fmt.Errorf("config: rename: %w", err) + } + return nil +} + +// SetProfile inserts or replaces a profile by name. +func (f *File) SetProfile(name string, p Profile) { + if f.Profiles == nil { + f.Profiles = map[string]Profile{} + } + f.Profiles[name] = p + if f.DefaultProfile == "" { + f.DefaultProfile = name + } +} + +// RemoveProfile deletes a profile by name. If the deleted profile was +// the default, DefaultProfile is cleared (callers may pick a new one). +func (f *File) RemoveProfile(name string) { + delete(f.Profiles, name) + if f.DefaultProfile == name { + f.DefaultProfile = "" + } +} + +// SortedProfileNames returns the stored profile names in alphabetical order. +func (f *File) SortedProfileNames() []string { + names := make([]string, 0, len(f.Profiles)) + for name := range f.Profiles { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// EnsureDefault promotes the alphabetically-first profile to default when +// no default is set but profiles still exist (e.g. after the active +// profile is logged out). Returns the chosen name, or "" if none remain. +func (f *File) EnsureDefault() string { + if f.DefaultProfile != "" { + return f.DefaultProfile + } + names := f.SortedProfileNames() + if len(names) == 0 { + return "" + } + f.DefaultProfile = names[0] + return f.DefaultProfile +} + +// Resolve returns the active profile name and its data based on the +// supplied override (e.g. `--profile ` flag value), the +// LSH_PROFILE env var, and finally DefaultProfile. +func (f *File) Resolve(override string) (string, Profile, error) { + name := override + if name == "" { + name = os.Getenv("LSH_PROFILE") + } + if name == "" { + name = f.DefaultProfile + } + if name == "" { + return "", Profile{}, ErrProfileNotFound + } + p, ok := f.Profiles[name] + if !ok { + return name, Profile{}, ErrProfileNotFound + } + return name, p, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..4d87b48 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,267 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// withTempHome substitutes the package-level homeDir resolver so +// Load/Save touch only the test directory. +func withTempHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + original := homeDir + homeDir = func() (string, error) { return dir, nil } + t.Cleanup(func() { homeDir = original }) + return dir +} + +func writeFile(t *testing.T, p string, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestLoadEmptyWhenMissing(t *testing.T) { + withTempHome(t) + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(f.Profiles) != 0 || f.DefaultProfile != "" { + t.Fatalf("expected empty config, got %+v", f) + } +} + +func TestLoadMigratesLegacyTopLevelToken(t *testing.T) { + home := withTempHome(t) + cfgPath := filepath.Join(home, ".config", "lsh", "config.json") + writeFile(t, cfgPath, `{"Authorization":"old-token","API-Version":"2023-06-01","hostname":"api.latitude.sh"}`) + + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if f.DefaultProfile != "default" { + t.Fatalf("expected default_profile=default, got %q", f.DefaultProfile) + } + p, ok := f.Profiles["default"] + if !ok { + t.Fatal("expected migrated profile 'default'") + } + if p.Authorization != "old-token" { + t.Fatalf("expected migrated token, got %q", p.Authorization) + } + if p.Source != SourceWithToken { + t.Fatalf("expected source=%s, got %q", SourceWithToken, p.Source) + } + if p.APIVersion != "2023-06-01" { + t.Fatalf("expected api_version migrated, got %q", p.APIVersion) + } +} + +func TestLoadDoesNotOverwriteExistingProfiles(t *testing.T) { + home := withTempHome(t) + cfgPath := filepath.Join(home, ".config", "lsh", "config.json") + // Both legacy AND new fields present — should keep the new profiles. + body := `{ + "Authorization":"legacy-token", + "default_profile":"acme", + "profiles":{"acme":{"authorization":"ak_new","team_slug":"acme","source":"browser"}} + }` + writeFile(t, cfgPath, body) + + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if f.DefaultProfile != "acme" { + t.Fatalf("expected default_profile=acme, got %q", f.DefaultProfile) + } + if _, ok := f.Profiles["default"]; ok { + t.Fatal("legacy migration should not run when profiles already exist") + } + if f.Profiles["acme"].Authorization != "ak_new" { + t.Fatalf("expected ak_new, got %q", f.Profiles["acme"].Authorization) + } +} + +func TestSaveCreatesDirAndRoundtrips(t *testing.T) { + home := withTempHome(t) + f := &File{ + DefaultProfile: "acme", + Profiles: map[string]Profile{ + "acme": {Authorization: "ak_xxx", TeamSlug: "acme", Email: "u@x.com", Source: SourceBrowser}, + }, + } + if err := Save(f); err != nil { + t.Fatalf("Save: %v", err) + } + + cfgPath := filepath.Join(home, ".config", "lsh", "config.json") + raw, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var dec File + if err := json.Unmarshal(raw, &dec); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if dec.DefaultProfile != "acme" || dec.Profiles["acme"].Authorization != "ak_xxx" { + t.Fatalf("unexpected on-disk content: %s", raw) + } +} + +func TestResolveOrder(t *testing.T) { + f := &File{ + DefaultProfile: "default-team", + Profiles: map[string]Profile{ + "default-team": {Authorization: "default-tok", TeamSlug: "default-team"}, + "other-team": {Authorization: "other-tok", TeamSlug: "other-team"}, + }, + } + + t.Run("explicit override wins", func(t *testing.T) { + t.Setenv("LSH_PROFILE", "default-team") + name, p, err := f.Resolve("other-team") + if err != nil { + t.Fatal(err) + } + if name != "other-team" || p.Authorization != "other-tok" { + t.Fatalf("unexpected: %s/%s", name, p.Authorization) + } + }) + + t.Run("LSH_PROFILE beats default", func(t *testing.T) { + t.Setenv("LSH_PROFILE", "other-team") + name, _, err := f.Resolve("") + if err != nil { + t.Fatal(err) + } + if name != "other-team" { + t.Fatalf("expected other-team, got %s", name) + } + }) + + t.Run("falls back to default_profile", func(t *testing.T) { + t.Setenv("LSH_PROFILE", "") + name, _, err := f.Resolve("") + if err != nil { + t.Fatal(err) + } + if name != "default-team" { + t.Fatalf("expected default-team, got %s", name) + } + }) + + t.Run("ErrProfileNotFound when nothing resolves", func(t *testing.T) { + empty := &File{Profiles: map[string]Profile{}} + _, _, err := empty.Resolve("") + if err == nil { + t.Fatal("expected error") + } + if err != ErrProfileNotFound { + t.Fatalf("expected ErrProfileNotFound, got %v", err) + } + }) +} + +func TestSetAndRemoveProfile(t *testing.T) { + f := &File{} + f.SetProfile("a", Profile{Authorization: "ta"}) + if f.DefaultProfile != "a" { + t.Fatalf("expected default to be set on first insert, got %q", f.DefaultProfile) + } + f.SetProfile("b", Profile{Authorization: "tb"}) + if f.DefaultProfile != "a" { + t.Fatalf("expected default to stay on first insert, got %q", f.DefaultProfile) + } + f.RemoveProfile("a") + if _, ok := f.Profiles["a"]; ok { + t.Fatal("profile a should be gone") + } + if f.DefaultProfile != "" { + t.Fatalf("expected default cleared after removing default, got %q", f.DefaultProfile) + } +} + +func TestSortedProfileNames(t *testing.T) { + f := &File{Profiles: map[string]Profile{"zebra": {}, "alpha": {}, "mike": {}}} + got := f.SortedProfileNames() + want := []string{"alpha", "mike", "zebra"} + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected sorted %v, got %v", want, got) + } + } +} + +func TestEnsureDefault(t *testing.T) { + // Promotes the alphabetically-first profile when none is set. + f := &File{Profiles: map[string]Profile{"teamB": {}, "teamA": {}}} + if got := f.EnsureDefault(); got != "teamA" { + t.Fatalf("expected promotion to teamA, got %q", got) + } + if f.DefaultProfile != "teamA" { + t.Fatalf("default not set, got %q", f.DefaultProfile) + } + + // Keeps an existing default untouched. + f2 := &File{DefaultProfile: "teamB", Profiles: map[string]Profile{"teamA": {}, "teamB": {}}} + if got := f2.EnsureDefault(); got != "teamB" { + t.Fatalf("expected existing default kept, got %q", got) + } + + // Returns "" when there are no profiles. + f3 := &File{} + if got := f3.EnsureDefault(); got != "" { + t.Fatalf("expected empty when no profiles, got %q", got) + } +} + +func TestSaveIsAtomicAndPermissioned(t *testing.T) { + dir := withTempHome(t) + f := &File{DefaultProfile: "a", Profiles: map[string]Profile{"a": {Authorization: "tok"}}} + if err := Save(f); err != nil { + t.Fatalf("Save: %v", err) + } + + cfgDir := filepath.Join(dir, dirName, exeName) + entries, err := os.ReadDir(cfgDir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + // Only config.json should remain — no leftover temp files. + for _, e := range entries { + if e.Name() != "config.json" { + t.Fatalf("unexpected leftover file: %q", e.Name()) + } + } + + p, _ := Path() + info, err := os.Stat(p) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if info.Mode().Perm() != filePerm { + t.Fatalf("expected perms %o, got %o", filePerm, info.Mode().Perm()) + } + + // Re-load to confirm the write round-trips. + got, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.DefaultProfile != "a" || got.Profiles["a"].Authorization != "tok" { + t.Fatalf("round-trip mismatch: %+v", got) + } +} diff --git a/internal/config/migrate.go b/internal/config/migrate.go new file mode 100644 index 0000000..eff3d44 --- /dev/null +++ b/internal/config/migrate.go @@ -0,0 +1,55 @@ +package config + +import "encoding/json" + +// legacyTopLevel is the previous single-token config shape that this +// CLI shipped before profile support. We detect it by parsing the raw +// JSON for an `Authorization` (or `authorization`) field at the top +// level and, if present, migrate it into a "default" profile. +type legacyTopLevel struct { + AuthorizationA string `json:"Authorization"` + AuthorizationB string `json:"authorization"` + APIVersionA string `json:"API-Version"` + APIVersionB string `json:"api-version"` +} + +// migrateLegacyInto inspects raw bytes for the old top-level token and, +// if found and no profiles exist yet, materializes a "default" profile +// from it. Returns true when a migration was applied so the caller can +// persist the new format. Idempotent: a second load on a migrated file +// is a no-op and returns false. +func migrateLegacyInto(f *File, raw []byte) bool { + if len(f.Profiles) > 0 { + return false + } + var legacy legacyTopLevel + if err := json.Unmarshal(raw, &legacy); err != nil { + return false + } + token := firstNonEmpty(legacy.AuthorizationA, legacy.AuthorizationB) + if token == "" { + return false + } + apiVersion := firstNonEmpty(legacy.APIVersionA, legacy.APIVersionB) + if f.Profiles == nil { + f.Profiles = map[string]Profile{} + } + f.Profiles["default"] = Profile{ + Authorization: token, + APIVersion: apiVersion, + Source: SourceWithToken, + } + if f.DefaultProfile == "" { + f.DefaultProfile = "default" + } + return true +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/config/profile.go b/internal/config/profile.go new file mode 100644 index 0000000..477d18b --- /dev/null +++ b/internal/config/profile.go @@ -0,0 +1,26 @@ +package config + +// Profile is one (team, api_key) binding stored locally. +// Token sources: "browser" (created by `lsh login` via cli_sessions) or +// "with-token" (created by `lsh login --with-token `). +type Profile struct { + Authorization string `json:"authorization"` + KeyID string `json:"key_id,omitempty"` + KeyName string `json:"key_name,omitempty"` + TeamID string `json:"team_id,omitempty"` + TeamName string `json:"team_name,omitempty"` + TeamSlug string `json:"team_slug,omitempty"` + Email string `json:"email,omitempty"` + Source string `json:"source,omitempty"` + APIVersion string `json:"api_version,omitempty"` +} + +// SourceBrowser is set on profiles created via the browser-assisted +// login flow. Only these profiles are eligible for remote key revoke +// on logout. +const SourceBrowser = "browser" + +// SourceWithToken is set on profiles created via `lsh login --with-token`. +// On logout, only the local config entry is removed; the key is kept +// on the server because it may have been generated for use elsewhere. +const SourceWithToken = "with-token" diff --git a/internal/prompt/project.go b/internal/prompt/project.go new file mode 100644 index 0000000..a9cb7cc --- /dev/null +++ b/internal/prompt/project.go @@ -0,0 +1,69 @@ +package prompt + +import ( + "context" + "errors" + "fmt" + + "github.com/latitudesh/lsh/internal/authclient" + "github.com/latitudesh/lsh/internal/tui" +) + +// AllProjectsSentinel is returned by SelectProject when the user +// picks the "All projects" entry. Commands that support listing +// across projects should treat this as "skip the --project filter". +const AllProjectsSentinel = "__all_projects__" + +// ProjectPicker abstracts the project-listing call so callers don't +// need to thread http clients through the prompt API. +type ProjectPicker interface { + ListProjects(ctx context.Context, token string) ([]authclient.Project, error) +} + +// SelectProject lists the team's projects and prompts the user to pick +// one interactively. Returns the picked project's id_hash, or +// AllProjectsSentinel if the user chose "All projects". +// +// The "All projects" entry is appended only when allowAll is true — +// it makes sense for list commands but not for commands that require +// a single project (e.g. servers create). +func SelectProject(ctx context.Context, client ProjectPicker, token string, allowAll bool) (string, error) { + if token == "" { + return "", errors.New("not logged in — run `lsh login` first") + } + projects, err := client.ListProjects(ctx, token) + if err != nil { + return "", fmt.Errorf("could not list projects: %w", err) + } + if len(projects) == 0 { + return "", errors.New("no projects found for the active team") + } + + items := make([]string, 0, len(projects)+1) + descriptions := make([]string, 0, len(projects)+1) + for _, p := range projects { + items = append(items, p.Slug) + descriptions = append(descriptions, fmt.Sprintf("%s — %s", p.Name, p.ID)) + } + if allowAll { + items = append(items, "All projects") + descriptions = append(descriptions, "Run across every project in this team") + } + + choice, err := tui.RunList("Select a project", items, descriptions) + if err != nil { + return "", err + } + // Match a real project first, so a project that happens to be named + // "All projects" wins over the sentinel entry rather than being + // silently treated as "skip the filter". + for _, p := range projects { + if p.Slug == choice { + return p.ID, nil + } + } + if allowAll && choice == "All projects" { + return AllProjectsSentinel, nil + } + return "", fmt.Errorf("unexpected selection: %s", choice) +} diff --git a/internal/tui/list.go b/internal/tui/list.go index 8004c64..92dc736 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "io" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" @@ -16,6 +17,40 @@ func (i item) Title() string { return i.title } func (i item) Description() string { return i.desc } func (i item) FilterValue() string { return i.title } +// compactDelegate renders each item on a single line ("title desc"), +// with no blank line between items, so many more entries are visible than +// with the default two-line delegate. The selected row is highlighted and +// the description is dimmed. +type compactDelegate struct{} + +func (compactDelegate) Height() int { return 1 } +func (compactDelegate) Spacing() int { return 0 } +func (compactDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil } + +func (compactDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + it, ok := listItem.(item) + if !ok { + return + } + + var line string + if index == m.Index() { + s := "❯ " + it.title + if it.desc != "" { + s += " " + it.desc + } + line = FocusedStyle.Render(s) + } else { + line = " " + it.title + if it.desc != "" { + line += " " + lipgloss.NewStyle().Foreground(MutedColor).Render(it.desc) + } + } + + // Keep every item exactly one line so the list height stays correct. + fmt.Fprint(w, lipgloss.NewStyle().MaxWidth(m.Width()).Render(line)) +} + type ListModel struct { list list.Model choice string @@ -35,7 +70,7 @@ func NewList(title string, items []string, descriptions []string) ListModel { const defaultWidth = 80 const listHeight = 14 - l := list.New(listItems, list.NewDefaultDelegate(), defaultWidth, listHeight) + l := list.New(listItems, compactDelegate{}, defaultWidth, listHeight) l.Title = title l.SetShowStatusBar(false) l.SetFilteringEnabled(true) @@ -56,6 +91,11 @@ func (m ListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.list.SetWidth(msg.Width) + // Use the available terminal height (minus room for the title and + // help/pagination footer) so as many items as possible are visible. + if h := msg.Height - 6; h > 4 { + m.list.SetHeight(h) + } return m, nil case tea.KeyMsg: diff --git a/internal/tui/styles.go b/internal/tui/styles.go index de69796..91d20eb 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -32,6 +32,10 @@ var ( Foreground(SuccessColor). Bold(true) + WarningStyle = lipgloss.NewStyle(). + Foreground(WarningColor). + Bold(true) + // Border styles BoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). diff --git a/internal/util/tty.go b/internal/util/tty.go new file mode 100644 index 0000000..3c4f943 --- /dev/null +++ b/internal/util/tty.go @@ -0,0 +1,16 @@ +// Package util holds small cross-cutting helpers shared by the +// command layer and the prompt/auth subpackages. +package util + +import "os" + +// IsTTY reports whether stdin is connected to a terminal. +// Used to decide whether to run interactive prompts vs. fail with +// an actionable error in non-interactive contexts (CI, pipes). +func IsTTY() bool { + info, err := os.Stdin.Stat() + if err != nil { + return false + } + return (info.Mode() & os.ModeCharDevice) != 0 +}