fcemail 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/go.sum ADDED
@@ -0,0 +1,49 @@
1
+ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
2
+ github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
3
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
4
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5
+ github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs=
6
+ github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8=
7
+ github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
8
+ github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
9
+ github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
10
+ github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
11
+ github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
12
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
13
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
14
+ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
15
+ github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
16
+ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
17
+ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
18
+ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
19
+ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
20
+ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
21
+ github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
22
+ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
23
+ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
24
+ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
25
+ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
26
+ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
27
+ github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
28
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
29
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
30
+ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
31
+ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
32
+ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
33
+ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
34
+ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
35
+ github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
36
+ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
37
+ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
38
+ github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
39
+ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
40
+ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
41
+ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
42
+ github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
43
+ github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
44
+ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
45
+ golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
46
+ golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
47
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
48
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
49
+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,197 @@
1
+ package api
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "time"
10
+
11
+ "github.com/DishIs/fce-cli/internal/config"
12
+ )
13
+
14
+ const baseURL = "https://api2.freecustom.email/v1"
15
+
16
+ type Client struct {
17
+ apiKey string
18
+ httpClient *http.Client
19
+ }
20
+
21
+ func New() (*Client, error) {
22
+ key, err := config.LoadAPIKey()
23
+ if err != nil {
24
+ return nil, err
25
+ }
26
+ return &Client{
27
+ apiKey: key,
28
+ httpClient: &http.Client{Timeout: 15 * time.Second},
29
+ }, nil
30
+ }
31
+
32
+ func (c *Client) request(method, path string, body interface{}) ([]byte, int, error) {
33
+ var bodyReader io.Reader
34
+ if body != nil {
35
+ data, err := json.Marshal(body)
36
+ if err != nil {
37
+ return nil, 0, err
38
+ }
39
+ bodyReader = bytes.NewReader(data)
40
+ }
41
+
42
+ req, err := http.NewRequest(method, baseURL+path, bodyReader)
43
+ if err != nil {
44
+ return nil, 0, err
45
+ }
46
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
47
+ req.Header.Set("Content-Type", "application/json")
48
+ req.Header.Set("User-Agent", "fce-cli/1.0.0")
49
+
50
+ resp, err := c.httpClient.Do(req)
51
+ if err != nil {
52
+ return nil, 0, fmt.Errorf("request failed: %w", err)
53
+ }
54
+ defer resp.Body.Close()
55
+
56
+ data, err := io.ReadAll(resp.Body)
57
+ return data, resp.StatusCode, err
58
+ }
59
+
60
+ func (c *Client) get(path string) (map[string]interface{}, error) {
61
+ data, status, err := c.request("GET", path, nil)
62
+ if err != nil {
63
+ return nil, err
64
+ }
65
+ return parseResponse(data, status)
66
+ }
67
+
68
+ func (c *Client) post(path string, body interface{}) (map[string]interface{}, error) {
69
+ data, status, err := c.request("POST", path, body)
70
+ if err != nil {
71
+ return nil, err
72
+ }
73
+ return parseResponse(data, status)
74
+ }
75
+
76
+ func (c *Client) delete(path string) (map[string]interface{}, error) {
77
+ data, status, err := c.request("DELETE", path, nil)
78
+ if err != nil {
79
+ return nil, err
80
+ }
81
+ return parseResponse(data, status)
82
+ }
83
+
84
+ func parseResponse(data []byte, status int) (map[string]interface{}, error) {
85
+ var result map[string]interface{}
86
+ if err := json.Unmarshal(data, &result); err != nil {
87
+ return nil, fmt.Errorf("failed to parse response")
88
+ }
89
+ if status >= 400 {
90
+ msg, _ := result["message"].(string)
91
+ errCode, _ := result["error"].(string)
92
+ if msg == "" {
93
+ msg = fmt.Sprintf("HTTP %d", status)
94
+ }
95
+ if errCode != "" {
96
+ return nil, fmt.Errorf("[%s] %s", errCode, msg)
97
+ }
98
+ return nil, fmt.Errorf("%s", msg)
99
+ }
100
+ return result, nil
101
+ }
102
+
103
+ // ── API Methods ───────────────────────────────────────────────────────────────
104
+
105
+ func (c *Client) GetMe() (map[string]interface{}, error) {
106
+ return c.get("/me")
107
+ }
108
+
109
+ func (c *Client) GetUsage() (map[string]interface{}, error) {
110
+ return c.get("/usage")
111
+ }
112
+
113
+ func (c *Client) ListInboxes() ([]interface{}, error) {
114
+ result, err := c.get("/inboxes")
115
+ if err != nil {
116
+ return nil, err
117
+ }
118
+ data, _ := result["data"].([]interface{})
119
+ return data, nil
120
+ }
121
+
122
+ func (c *Client) RegisterInbox(inbox string) (map[string]interface{}, error) {
123
+ return c.post("/inboxes", map[string]string{"inbox": inbox})
124
+ }
125
+
126
+ func (c *Client) UnregisterInbox(inbox string) (map[string]interface{}, error) {
127
+ return c.delete("/inboxes/" + inbox)
128
+ }
129
+
130
+ func (c *Client) ListMessages(inbox string) ([]interface{}, error) {
131
+ result, err := c.get("/inboxes/" + inbox + "/messages")
132
+ if err != nil {
133
+ return nil, err
134
+ }
135
+ data, _ := result["data"].([]interface{})
136
+ return data, nil
137
+ }
138
+
139
+ func (c *Client) GetMessage(inbox, id string) (map[string]interface{}, error) {
140
+ result, err := c.get("/inboxes/" + inbox + "/messages/" + id)
141
+ if err != nil {
142
+ return nil, err
143
+ }
144
+ data, _ := result["data"].(map[string]interface{})
145
+ if data == nil {
146
+ data = result
147
+ }
148
+ return data, nil
149
+ }
150
+
151
+ func (c *Client) GetOTP(inbox string) (map[string]interface{}, error) {
152
+ return c.get("/inboxes/" + inbox + "/otp")
153
+ }
154
+
155
+ func (c *Client) ListDomains() ([]interface{}, error) {
156
+ result, err := c.get("/domains")
157
+ if err != nil {
158
+ return nil, err
159
+ }
160
+ data, _ := result["data"].([]interface{})
161
+ return data, nil
162
+ }
163
+
164
+ func (c *Client) GetAPIKey() string {
165
+ return c.apiKey
166
+ }
167
+
168
+ // ── Plan helpers ──────────────────────────────────────────────────────────────
169
+
170
+ type PlanLevel int
171
+
172
+ const (
173
+ PlanFree PlanLevel = 0
174
+ PlanDeveloper PlanLevel = 1
175
+ PlanStartup PlanLevel = 2
176
+ PlanGrowth PlanLevel = 3
177
+ PlanEnterprise PlanLevel = 4
178
+ )
179
+
180
+ var planLevels = map[string]PlanLevel{
181
+ "free": PlanFree,
182
+ "developer": PlanDeveloper,
183
+ "startup": PlanStartup,
184
+ "growth": PlanGrowth,
185
+ "enterprise": PlanEnterprise,
186
+ }
187
+
188
+ func PlanLevelFor(plan string) PlanLevel {
189
+ if l, ok := planLevels[plan]; ok {
190
+ return l
191
+ }
192
+ return PlanFree
193
+ }
194
+
195
+ func HasPlan(userPlan string, required PlanLevel) bool {
196
+ return PlanLevelFor(userPlan) >= required
197
+ }
@@ -0,0 +1,204 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net"
7
+ "net/http"
8
+ "os/exec"
9
+ "runtime"
10
+ "time"
11
+
12
+ "github.com/DishIs/fce-cli/internal/config"
13
+ "github.com/DishIs/fce-cli/internal/display"
14
+ )
15
+
16
+ const (
17
+ loginBaseURL = "https://www.freecustom.email/api/cli-auth"
18
+ callbackPort = 9876
19
+ callbackPath = "/callback"
20
+ timeoutSeconds = 120
21
+ )
22
+
23
+ // Login opens the browser to the auth page, starts a local server to receive
24
+ // the API key, then stores it securely.
25
+ func Login() error {
26
+ // Check if already logged in
27
+ if config.IsLoggedIn() {
28
+ cfg, _ := config.LoadConfig()
29
+ display.Warn("Already logged in" + func() string {
30
+ if cfg != nil && cfg.Plan != "" {
31
+ return " (" + display.PlanBadge(cfg.Plan) + ")"
32
+ }
33
+ return ""
34
+ }())
35
+ display.Info("Run `fce logout` first to switch accounts.")
36
+ return nil
37
+ }
38
+
39
+ // Start local callback server
40
+ keyCh := make(chan string, 1)
41
+ errCh := make(chan error, 1)
42
+ server := startCallbackServer(keyCh, errCh)
43
+
44
+ // Build the auth URL — includes the callback port so the site knows where to redirect
45
+ authURL := fmt.Sprintf("%s?callback=http://localhost:%d%s", loginBaseURL, callbackPort, callbackPath)
46
+
47
+ display.Step(1, 3, "Opening browser…")
48
+ display.Info(fmt.Sprintf("If the browser doesn't open, visit:\n %s", authURL))
49
+ fmt.Println()
50
+
51
+ if err := openBrowser(authURL); err != nil {
52
+ display.Warn("Could not open browser automatically.")
53
+ display.Info("Open this URL manually:")
54
+ fmt.Printf("\n %s\n\n", authURL)
55
+ }
56
+
57
+ display.Step(2, 3, "Waiting for authentication…")
58
+ display.Info("Complete login in the browser. This window will update automatically.")
59
+ fmt.Println()
60
+
61
+ ctx, cancel := context.WithTimeout(context.Background(), timeoutSeconds*time.Second)
62
+ defer cancel()
63
+
64
+ var apiKey string
65
+ select {
66
+ case key := <-keyCh:
67
+ apiKey = key
68
+ case err := <-errCh:
69
+ server.Shutdown(ctx)
70
+ return fmt.Errorf("auth error: %w", err)
71
+ case <-ctx.Done():
72
+ server.Shutdown(ctx)
73
+ return fmt.Errorf("login timed out after %d seconds", timeoutSeconds)
74
+ }
75
+
76
+ server.Shutdown(ctx)
77
+
78
+ if apiKey == "" {
79
+ return fmt.Errorf("received empty API key")
80
+ }
81
+
82
+ display.Step(3, 3, "Saving credentials…")
83
+ if err := config.SaveAPIKey(apiKey); err != nil {
84
+ return fmt.Errorf("failed to save API key: %w", err)
85
+ }
86
+
87
+ // Mark first login done
88
+ cfg, _ := config.LoadConfig()
89
+ isFirst := cfg.FirstLogin
90
+ cfg.FirstLogin = false
91
+ _ = config.SaveConfig(cfg)
92
+
93
+ fmt.Println()
94
+ if isFirst {
95
+ display.PrintLogo()
96
+ }
97
+ display.Success("Logged in successfully!")
98
+ display.Info("Run `fce status` to see your account details.")
99
+ fmt.Println()
100
+
101
+ return nil
102
+ }
103
+
104
+ // Logout removes the stored API key
105
+ func Logout() error {
106
+ if !config.IsLoggedIn() {
107
+ display.Warn("Not currently logged in.")
108
+ return nil
109
+ }
110
+ if err := config.DeleteAPIKey(); err != nil {
111
+ return fmt.Errorf("failed to remove credentials: %w", err)
112
+ }
113
+ // Reset config
114
+ cfg := &config.Config{FirstLogin: true}
115
+ _ = config.SaveConfig(cfg)
116
+ display.Success("Logged out.")
117
+ return nil
118
+ }
119
+
120
+ // ── Local callback server ─────────────────────────────────────────────────────
121
+
122
+ func startCallbackServer(keyCh chan<- string, errCh chan<- error) *http.Server {
123
+ mux := http.NewServeMux()
124
+ mux.HandleFunc(callbackPath, func(w http.ResponseWriter, r *http.Request) {
125
+ key := r.URL.Query().Get("api_key")
126
+ if key == "" {
127
+ errCh <- fmt.Errorf("no api_key in callback")
128
+ w.Header().Set("Content-Type", "text/html")
129
+ fmt.Fprint(w, callbackHTMLError)
130
+ return
131
+ }
132
+ keyCh <- key
133
+ w.Header().Set("Content-Type", "text/html")
134
+ fmt.Fprint(w, callbackHTMLSuccess)
135
+ })
136
+
137
+ server := &http.Server{
138
+ Addr: fmt.Sprintf("localhost:%d", callbackPort),
139
+ Handler: mux,
140
+ }
141
+
142
+ listener, err := net.Listen("tcp", server.Addr)
143
+ if err != nil {
144
+ errCh <- fmt.Errorf("could not start callback server: %w", err)
145
+ return server
146
+ }
147
+
148
+ go func() {
149
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
150
+ // ignore — normal on shutdown
151
+ }
152
+ }()
153
+
154
+ return server
155
+ }
156
+
157
+ // ── Browser opener ────────────────────────────────────────────────────────────
158
+
159
+ func openBrowser(url string) error {
160
+ var cmd *exec.Cmd
161
+ switch runtime.GOOS {
162
+ case "darwin":
163
+ cmd = exec.Command("open", url)
164
+ case "windows":
165
+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
166
+ default: // linux
167
+ cmd = exec.Command("xdg-open", url)
168
+ }
169
+ return cmd.Start()
170
+ }
171
+
172
+ // ── Callback HTML pages ───────────────────────────────────────────────────────
173
+
174
+ const callbackHTMLSuccess = `<!DOCTYPE html><html><head><meta charset="utf-8">
175
+ <title>FreeCustom.Email CLI</title>
176
+ <style>
177
+ *{margin:0;padding:0;box-sizing:border-box}
178
+ body{background:#000;color:#fff;font-family:monospace;display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:16px}
179
+ .icon{font-size:48px}
180
+ h1{font-size:18px;font-weight:600;letter-spacing:-.01em}
181
+ p{font-size:13px;color:#666;line-height:1.6;text-align:center;max-width:320px}
182
+ .badge{border:1px solid #333;border-radius:4px;padding:4px 10px;font-size:11px;color:#999;margin-top:8px}
183
+ </style>
184
+ </head><body>
185
+ <div class="icon">✓</div>
186
+ <h1>Authentication successful</h1>
187
+ <p>You're now logged in to the FreeCustom.Email CLI. You can close this tab.</p>
188
+ <div class="badge">fce</div>
189
+ </body></html>`
190
+
191
+ const callbackHTMLError = `<!DOCTYPE html><html><head><meta charset="utf-8">
192
+ <title>FreeCustom.Email CLI</title>
193
+ <style>
194
+ *{margin:0;padding:0;box-sizing:border-box}
195
+ body{background:#000;color:#fff;font-family:monospace;display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:16px}
196
+ .icon{font-size:48px}
197
+ h1{font-size:18px;font-weight:600}
198
+ p{font-size:13px;color:#666;text-align:center;max-width:320px}
199
+ </style>
200
+ </head><body>
201
+ <div class="icon">✗</div>
202
+ <h1>Authentication failed</h1>
203
+ <p>Something went wrong. Please try running <code>fce login</code> again.</p>
204
+ </body></html>`
@@ -0,0 +1,112 @@
1
+ package config
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "runtime"
9
+
10
+ "github.com/zalando/go-keyring"
11
+ )
12
+
13
+ const (
14
+ keyringService = "fce-cli"
15
+ keyringUser = "api-key"
16
+ configFileName = "config.json"
17
+ )
18
+
19
+ // Config holds persisted non-secret settings
20
+ type Config struct {
21
+ FirstLogin bool `json:"first_login"`
22
+ Plan string `json:"plan"`
23
+ PlanLabel string `json:"plan_label"`
24
+ }
25
+
26
+ // ── API key (stored in OS keyring) ───────────────────────────────────────────
27
+
28
+ func SaveAPIKey(key string) error {
29
+ return keyring.Set(keyringService, keyringUser, key)
30
+ }
31
+
32
+ func LoadAPIKey() (string, error) {
33
+ key, err := keyring.Get(keyringService, keyringUser)
34
+ if err != nil {
35
+ // Fallback: check env var
36
+ if env := os.Getenv("FCE_API_KEY"); env != "" {
37
+ return env, nil
38
+ }
39
+ return "", fmt.Errorf("not logged in — run: fce login")
40
+ }
41
+ return key, nil
42
+ }
43
+
44
+ func DeleteAPIKey() error {
45
+ return keyring.Delete(keyringService, keyringUser)
46
+ }
47
+
48
+ func IsLoggedIn() bool {
49
+ _, err := LoadAPIKey()
50
+ return err == nil
51
+ }
52
+
53
+ // ── Config file (non-secret settings) ────────────────────────────────────────
54
+
55
+ func configDir() string {
56
+ switch runtime.GOOS {
57
+ case "windows":
58
+ if appData := os.Getenv("APPDATA"); appData != "" {
59
+ return filepath.Join(appData, "fce")
60
+ }
61
+ case "darwin":
62
+ if home := os.Getenv("HOME"); home != "" {
63
+ return filepath.Join(home, "Library", "Application Support", "fce")
64
+ }
65
+ }
66
+ // Linux / fallback
67
+ if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
68
+ return filepath.Join(xdg, "fce")
69
+ }
70
+ if home := os.Getenv("HOME"); home != "" {
71
+ return filepath.Join(home, ".config", "fce")
72
+ }
73
+ return filepath.Join(os.TempDir(), "fce")
74
+ }
75
+
76
+ func configPath() string {
77
+ return filepath.Join(configDir(), configFileName)
78
+ }
79
+
80
+ func LoadConfig() (*Config, error) {
81
+ data, err := os.ReadFile(configPath())
82
+ if os.IsNotExist(err) {
83
+ return &Config{FirstLogin: true}, nil
84
+ }
85
+ if err != nil {
86
+ return &Config{FirstLogin: true}, nil
87
+ }
88
+ var cfg Config
89
+ if err := json.Unmarshal(data, &cfg); err != nil {
90
+ return &Config{FirstLogin: true}, nil
91
+ }
92
+ return &cfg, nil
93
+ }
94
+
95
+ func SaveConfig(cfg *Config) error {
96
+ dir := configDir()
97
+ if err := os.MkdirAll(dir, 0700); err != nil {
98
+ return err
99
+ }
100
+ data, err := json.Marshal(cfg)
101
+ if err != nil {
102
+ return err
103
+ }
104
+ return os.WriteFile(configPath(), data, 0600)
105
+ }
106
+
107
+ // Purge removes all local configuration and credentials
108
+ func Purge() error {
109
+ _ = DeleteAPIKey()
110
+ _ = os.RemoveAll(configDir())
111
+ return nil
112
+ }