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/.github/workflows/release.yml +90 -0
- package/.goreleaser.yaml +83 -0
- package/LICENSE +21 -0
- package/README.md +226 -0
- package/bin/fce +0 -0
- package/cmd/root.go +678 -0
- package/go.mod +26 -0
- package/go.sum +49 -0
- package/internal/api/client.go +197 -0
- package/internal/auth/auth.go +204 -0
- package/internal/config/config.go +112 -0
- package/internal/display/logo.go +242 -0
- package/internal/ws/watch.go +144 -0
- package/logo.webp +0 -0
- package/main.go +7 -0
- package/package.json +17 -0
- package/scripts/install-binary.js +11 -0
- package/scripts/install.sh +65 -0
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
|
+
}
|