bluesky viewer in the terminal
1package main
2
3import (
4 "context"
5 "fmt"
6
7 "github.com/stormlightlabs/skypanel/cli/internal/imports"
8 "github.com/stormlightlabs/skypanel/cli/internal/registry"
9 "github.com/stormlightlabs/skypanel/cli/internal/setup"
10 "github.com/stormlightlabs/skypanel/cli/internal/store"
11 "github.com/stormlightlabs/skypanel/cli/internal/ui"
12 "github.com/urfave/cli/v3"
13)
14
15func LoginCommand() *cli.Command {
16 return &cli.Command{
17 Name: "login",
18 Usage: "Authenticate with Bluesky",
19 Description: `Authenticate with Bluesky using one of two methods:
20
21 1. Direct credentials via flags:
22 skycli login --handle @user.bsky.social --password your-app-password
23
24 2. Credentials from an env file:
25 skycli login --file /path/to/.env
26
27 The env file should contain:
28 BLUESKY_HANDLE=your.handle.bsky.social
29 BLUESKY_PASSWORD=your-app-password
30
31 File paths can be relative or absolute.`,
32 Flags: []cli.Flag{
33 &cli.StringFlag{
34 Name: "file",
35 Aliases: []string{"f"},
36 Usage: "Path to env file containing BLUESKY_HANDLE and BLUESKY_PASSWORD",
37 },
38 &cli.StringFlag{
39 Name: "handle",
40 Aliases: []string{"u"},
41 Usage: "Your Bluesky handle (e.g., @user.bsky.social)",
42 },
43 &cli.StringFlag{
44 Name: "password",
45 Aliases: []string{"p"},
46 Usage: "Your app password",
47 },
48 },
49 Action: LoginAction,
50 }
51}
52
53func LoginAction(ctx context.Context, cmd *cli.Command) error {
54 if err := setup.EnsurePersistenceReady(ctx); err != nil {
55 return fmt.Errorf("persistence layer not ready: %w", err)
56 }
57
58 reg := registry.Get()
59
60 var handle, password string
61 filePath := cmd.String("file")
62
63 if filePath != "" {
64 env, err := imports.ParseEnvFile(filePath)
65 if err != nil {
66 return fmt.Errorf("failed to parse env file: %w", err)
67 }
68
69 handle = env["BLUESKY_HANDLE"]
70 password = env["BLUESKY_PASSWORD"]
71
72 if handle == "" {
73 return fmt.Errorf("BLUESKY_HANDLE not found in env file")
74 }
75 if password == "" {
76 return fmt.Errorf("BLUESKY_PASSWORD not found in env file")
77 }
78 } else {
79 handle = cmd.String("handle")
80 password = cmd.String("password")
81
82 if handle == "" || password == "" {
83 return fmt.Errorf("either --file or both --handle and --password are required")
84 }
85 }
86
87 logger.Info("Authenticating with Bluesky", "handle", handle)
88
89 service, err := reg.GetService()
90 if err != nil {
91 return fmt.Errorf("failed to get service: %w", err)
92 }
93
94 credentials := map[string]string{
95 "identifier": handle,
96 "password": password,
97 }
98
99 if err := service.Authenticate(ctx, credentials); err != nil {
100 logger.Error("Authentication failed", "error", err)
101 return err
102 }
103
104 sessionRepo, err := reg.GetSessionRepo()
105 if err != nil {
106 return fmt.Errorf("failed to get session repository: %w", err)
107 }
108
109 session, err := createSessionFromService(service, handle)
110 if err != nil {
111 return fmt.Errorf("failed to create session: %w", err)
112 }
113
114 if err := sessionRepo.Save(ctx, session); err != nil {
115 logger.Error("Failed to save session", "error", err)
116 return fmt.Errorf("authentication succeeded but failed to save session: %w", err)
117 }
118
119 logger.Debug("Session saved successfully", "did", session.ID(), "handle", handle)
120 ui.Successln("Successfully authenticated as %s", handle)
121 return nil
122}
123
124// createSessionFromService creates a SessionModel from an authenticated service
125func createSessionFromService(service *store.BlueskyService, handle string) (*store.SessionModel, error) {
126 did := service.GetDid()
127 if did == "" {
128 return nil, fmt.Errorf("no DID available from authenticated service")
129 }
130
131 accessToken := service.GetAccessToken()
132 refreshToken := service.GetRefreshToken()
133
134 session := &store.SessionModel{
135 Handle: handle,
136 Token: accessToken + "|" + refreshToken,
137 ServiceURL: service.BaseURL(),
138 IsValid: true,
139 }
140 session.SetID(did)
141
142 return session, nil
143}