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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
package display
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
|
|
7
|
+
"github.com/charmbracelet/lipgloss"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// ── Styles ────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
var (
|
|
13
|
+
// Adaptive colors for both light and dark terminals
|
|
14
|
+
colorDim = lipgloss.AdaptiveColor{Light: "244", Dark: "240"}
|
|
15
|
+
colorBright = lipgloss.AdaptiveColor{Light: "235", Dark: "255"}
|
|
16
|
+
colorMuted = lipgloss.AdaptiveColor{Light: "248", Dark: "245"}
|
|
17
|
+
colorAccent = lipgloss.AdaptiveColor{Light: "232", Dark: "255"}
|
|
18
|
+
colorBorder = lipgloss.AdaptiveColor{Light: "250", Dark: "238"}
|
|
19
|
+
|
|
20
|
+
styleDim = lipgloss.NewStyle().Foreground(colorDim)
|
|
21
|
+
styleBright = lipgloss.NewStyle().Foreground(colorBright).Bold(true)
|
|
22
|
+
styleMuted = lipgloss.NewStyle().Foreground(colorMuted)
|
|
23
|
+
styleAccent = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
|
|
24
|
+
styleBorder = lipgloss.NewStyle().Foreground(colorBorder)
|
|
25
|
+
styleSuccess = lipgloss.NewStyle().Foreground(colorBright)
|
|
26
|
+
styleError = lipgloss.NewStyle().Foreground(colorBright).Bold(true)
|
|
27
|
+
styleWarn = lipgloss.NewStyle().Foreground(colorDim)
|
|
28
|
+
|
|
29
|
+
panelStyle = lipgloss.NewStyle().
|
|
30
|
+
Border(lipgloss.RoundedBorder()).
|
|
31
|
+
BorderForeground(colorBorder).
|
|
32
|
+
Padding(0, 2)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// ── Logo ──────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
// ASCII logo optimized for readability and Go raw string constraints
|
|
38
|
+
// Using @@ as a placeholder for backticks
|
|
39
|
+
const logoASCII = `
|
|
40
|
+
______ ______ __ ______ _ __
|
|
41
|
+
/ ____/________ ___ / ____/_ _______/ /_____ ____ ___ / ____/___ ___ ____ _(_) /
|
|
42
|
+
/ /_ / ___/ _ \/ _ \ / / / / / ___/ __/ __ \/ __ @@__ \ / __/ / __ @@__ \/ __ @@/ / /
|
|
43
|
+
/ __/ / / / __/ __/ /___/ /_/ (__ ) /_/ /_/ / / / / / / / /___/ / / / / / /_/ / / /
|
|
44
|
+
/_/ /_/ \___/\___/\____/\__,_/____/\__/\____/_/ /_/ /_/ /_____/_/ /_/ /_/\__,_/_/_/
|
|
45
|
+
`
|
|
46
|
+
|
|
47
|
+
const tagline = ` FreeCustom.Email — disposable inbox API`
|
|
48
|
+
|
|
49
|
+
// PrintLogo prints the full logo + wordmark on first login
|
|
50
|
+
func PrintLogo() {
|
|
51
|
+
fixedLogo := strings.ReplaceAll(logoASCII, "@@", "`")
|
|
52
|
+
logo := styleBright.Render(fixedLogo)
|
|
53
|
+
tag := styleMuted.Render(tagline)
|
|
54
|
+
fmt.Println(logo)
|
|
55
|
+
fmt.Println(tag)
|
|
56
|
+
fmt.Println()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// PrintInlineLogo prints a compact single-line logo for command headers
|
|
60
|
+
func PrintInlineLogo() {
|
|
61
|
+
icon := styleBright.Render("◉")
|
|
62
|
+
name := styleBright.Render("fce")
|
|
63
|
+
fmt.Printf("%s %s ", icon, name)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Section headers ───────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
func Header(title string) {
|
|
69
|
+
bar := styleDim.Render(strings.Repeat("─", 48))
|
|
70
|
+
head := styleAccent.Render(title)
|
|
71
|
+
fmt.Println()
|
|
72
|
+
fmt.Println(bar)
|
|
73
|
+
fmt.Printf(" %s\n", head)
|
|
74
|
+
fmt.Println(bar)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Status messages ───────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
func Success(msg string) {
|
|
80
|
+
fmt.Printf(" %s %s\n", styleBright.Render("✓"), styleSuccess.Render(msg))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func Error(msg string) {
|
|
84
|
+
fmt.Printf(" %s %s\n", styleBright.Render("✗"), styleError.Render(msg))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func Warn(msg string) {
|
|
88
|
+
fmt.Printf(" %s %s\n", styleBright.Render("!"), styleWarn.Render(msg))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func Info(msg string) {
|
|
92
|
+
fmt.Printf(" %s %s\n", styleDim.Render("·"), styleMuted.Render(msg))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func Step(n int, total int, msg string) {
|
|
96
|
+
counter := styleDim.Render(fmt.Sprintf("[%d/%d]", n, total))
|
|
97
|
+
fmt.Printf(" %s %s\n", counter, styleMuted.Render(msg))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Tables ────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
type Row struct {
|
|
103
|
+
Key string
|
|
104
|
+
Value string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func Table(rows []Row) {
|
|
108
|
+
maxKey := 0
|
|
109
|
+
for _, r := range rows {
|
|
110
|
+
if len(r.Key) > maxKey {
|
|
111
|
+
maxKey = len(r.Key)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
fmt.Println()
|
|
115
|
+
for _, r := range rows {
|
|
116
|
+
pad := strings.Repeat(" ", maxKey-len(r.Key)+2)
|
|
117
|
+
key := styleDim.Render(r.Key)
|
|
118
|
+
sep := styleBorder.Render("·")
|
|
119
|
+
value := styleBright.Render(r.Value)
|
|
120
|
+
fmt.Printf(" %s%s%s %s\n", key, pad, sep, value)
|
|
121
|
+
}
|
|
122
|
+
fmt.Println()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── List ──────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
func List(items []string) {
|
|
128
|
+
fmt.Println()
|
|
129
|
+
for i, item := range items {
|
|
130
|
+
n := styleDim.Render(fmt.Sprintf("%02d", i+1))
|
|
131
|
+
dot := styleBorder.Render("·")
|
|
132
|
+
fmt.Printf(" %s %s %s\n", n, dot, styleBright.Render(item))
|
|
133
|
+
}
|
|
134
|
+
fmt.Println()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Plan badge ────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
func PlanBadge(plan string) string {
|
|
140
|
+
p := strings.ToLower(plan)
|
|
141
|
+
if p == "free" || p == "developer" {
|
|
142
|
+
return styleDim.Render("[" + strings.ToUpper(plan) + "]")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// High visibility solid badge for paid plans
|
|
146
|
+
return lipgloss.NewStyle().
|
|
147
|
+
Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "0"}).
|
|
148
|
+
Background(lipgloss.AdaptiveColor{Light: "235", Dark: "255"}).
|
|
149
|
+
Padding(0, 1).
|
|
150
|
+
Bold(true).
|
|
151
|
+
Render(strings.ToUpper(plan))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Live event (for watch) ────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
func EmailEvent(id, from, subject, otp, link string, timestamp string) {
|
|
157
|
+
div := styleDim.Render(strings.Repeat("─", 52))
|
|
158
|
+
fmt.Println()
|
|
159
|
+
fmt.Println(div)
|
|
160
|
+
fmt.Printf(" %s %s\n", styleDim.Render("ID "), styleDim.Render(id))
|
|
161
|
+
fmt.Printf(" %s %s\n", styleDim.Render("FROM"), styleBright.Render(from))
|
|
162
|
+
fmt.Printf(" %s %s\n", styleDim.Render("SUBJ"), styleMuted.Render(subject))
|
|
163
|
+
fmt.Printf(" %s %s\n", styleDim.Render("TIME"), styleDim.Render(timestamp))
|
|
164
|
+
if otp != "" {
|
|
165
|
+
otpVal := styleAccent.Render(otp)
|
|
166
|
+
fmt.Printf(" %s %s\n", styleBright.Render("OTP "), otpVal)
|
|
167
|
+
}
|
|
168
|
+
if link != "" {
|
|
169
|
+
button := lipgloss.NewStyle().
|
|
170
|
+
Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "0"}).
|
|
171
|
+
Background(lipgloss.AdaptiveColor{Light: "235", Dark: "255"}).
|
|
172
|
+
Padding(0, 1).
|
|
173
|
+
Bold(true).
|
|
174
|
+
Render("OPEN EMAIL")
|
|
175
|
+
fmt.Printf("\n %s %s\n", button, styleDim.Render(link))
|
|
176
|
+
}
|
|
177
|
+
fmt.Println(div)
|
|
178
|
+
fmt.Println()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func MessageContent(data map[string]interface{}) {
|
|
182
|
+
from := fmt.Sprintf("%v", data["from"])
|
|
183
|
+
subject := fmt.Sprintf("%v", data["subject"])
|
|
184
|
+
date := fmt.Sprintf("%v", data["date"])
|
|
185
|
+
text := fmt.Sprintf("%v", data["text"])
|
|
186
|
+
body := fmt.Sprintf("%v", data["body"])
|
|
187
|
+
html := fmt.Sprintf("%v", data["html"])
|
|
188
|
+
|
|
189
|
+
Header("Message Details")
|
|
190
|
+
Table([]Row{
|
|
191
|
+
{Key: "From", Value: from},
|
|
192
|
+
{Key: "Subject", Value: subject},
|
|
193
|
+
{Key: "Date", Value: date},
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
if text != "" && text != "<nil>" {
|
|
197
|
+
fmt.Println("\n" + styleDim.Render("── Text Content ──────────────────────────────────────────────"))
|
|
198
|
+
fmt.Println(text)
|
|
199
|
+
} else if body != "" && body != "<nil>" {
|
|
200
|
+
fmt.Println("\n" + styleDim.Render("── Body ──────────────────────────────────────────────────────"))
|
|
201
|
+
fmt.Println(body)
|
|
202
|
+
} else if html != "" && html != "<nil>" {
|
|
203
|
+
fmt.Println("\n" + styleDim.Render("── HTML (Source) ─────────────────────────────────────────────"))
|
|
204
|
+
fmt.Println(html)
|
|
205
|
+
}
|
|
206
|
+
fmt.Println()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Waiting spinner (simple) ──────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
func Waiting(msg string) {
|
|
212
|
+
fmt.Printf(" %s %s\n", styleDim.Render("◌"), styleMuted.Render(msg))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Plan gate error ───────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
func PlanGate(requiredPlan string, feature string) {
|
|
218
|
+
fmt.Println()
|
|
219
|
+
Error(fmt.Sprintf("%s requires %s plan or above.", feature, strings.Title(requiredPlan)))
|
|
220
|
+
Info("Upgrade at: https://www.freecustom.email/api/pricing")
|
|
221
|
+
fmt.Println()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Divider ───────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
func Divider() {
|
|
227
|
+
fmt.Println(styleDim.Render(" " + strings.Repeat("─", 48)))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// TableBadge returns a formatted string with a badge and value
|
|
231
|
+
func TableBadge(label, value string) string {
|
|
232
|
+
badge := lipgloss.NewStyle().
|
|
233
|
+
Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "0"}).
|
|
234
|
+
Background(lipgloss.AdaptiveColor{Light: "244", Dark: "240"}).
|
|
235
|
+
Padding(0, 1).
|
|
236
|
+
Bold(true).
|
|
237
|
+
Width(10).
|
|
238
|
+
Align(lipgloss.Center).
|
|
239
|
+
Render(strings.ToUpper(label))
|
|
240
|
+
|
|
241
|
+
return fmt.Sprintf(" %s %s", badge, styleBright.Render(value))
|
|
242
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package ws
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net/url"
|
|
7
|
+
"os"
|
|
8
|
+
"os/signal"
|
|
9
|
+
"syscall"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/DishIs/fce-cli/internal/display"
|
|
13
|
+
"github.com/gorilla/websocket"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const wsBaseURL = "wss://api2.freecustom.email/v1/ws"
|
|
17
|
+
|
|
18
|
+
type emailEvent struct {
|
|
19
|
+
Type string `json:"type"`
|
|
20
|
+
ID string `json:"id"`
|
|
21
|
+
From string `json:"from"`
|
|
22
|
+
To string `json:"to"`
|
|
23
|
+
Subject string `json:"subject"`
|
|
24
|
+
Date string `json:"date"`
|
|
25
|
+
OTP string `json:"otp"`
|
|
26
|
+
VerificationLink string `json:"verificationLink"`
|
|
27
|
+
HasAttachment bool `json:"hasAttachment"`
|
|
28
|
+
Plan string `json:"plan"`
|
|
29
|
+
// Connected event fields
|
|
30
|
+
SubscribedInboxes []string `json:"subscribed_inboxes"`
|
|
31
|
+
ConnectionCount int `json:"connection_count"`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Watch opens a WebSocket connection and streams emails to the terminal.
|
|
35
|
+
// mailbox can be "" to watch all registered inboxes.
|
|
36
|
+
func Watch(apiKey, mailbox string) error {
|
|
37
|
+
u, _ := url.Parse(wsBaseURL)
|
|
38
|
+
q := u.Query()
|
|
39
|
+
q.Set("api_key", apiKey)
|
|
40
|
+
if mailbox != "" {
|
|
41
|
+
q.Set("mailbox", mailbox)
|
|
42
|
+
}
|
|
43
|
+
u.RawQuery = q.Encode()
|
|
44
|
+
|
|
45
|
+
// Connect
|
|
46
|
+
dialer := websocket.DefaultDialer
|
|
47
|
+
conn, _, err := dialer.Dial(u.String(), nil)
|
|
48
|
+
if err != nil {
|
|
49
|
+
return fmt.Errorf("could not connect to WebSocket: %w", err)
|
|
50
|
+
}
|
|
51
|
+
defer conn.Close()
|
|
52
|
+
|
|
53
|
+
// Graceful shutdown on SIGINT / SIGTERM
|
|
54
|
+
sigCh := make(chan os.Signal, 1)
|
|
55
|
+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
56
|
+
go func() {
|
|
57
|
+
<-sigCh
|
|
58
|
+
fmt.Println()
|
|
59
|
+
display.Info("Disconnected.")
|
|
60
|
+
conn.WriteMessage(websocket.CloseMessage,
|
|
61
|
+
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
|
62
|
+
conn.Close()
|
|
63
|
+
os.Exit(0)
|
|
64
|
+
}()
|
|
65
|
+
|
|
66
|
+
// Ping loop to keep alive
|
|
67
|
+
go func() {
|
|
68
|
+
ticker := time.NewTicker(25 * time.Second)
|
|
69
|
+
defer ticker.Stop()
|
|
70
|
+
for range ticker.C {
|
|
71
|
+
msg, _ := json.Marshal(map[string]string{"type": "ping"})
|
|
72
|
+
conn.WriteMessage(websocket.TextMessage, msg)
|
|
73
|
+
}
|
|
74
|
+
}()
|
|
75
|
+
|
|
76
|
+
// Read loop
|
|
77
|
+
for {
|
|
78
|
+
_, raw, err := conn.ReadMessage()
|
|
79
|
+
if err != nil {
|
|
80
|
+
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
|
81
|
+
return nil
|
|
82
|
+
}
|
|
83
|
+
return fmt.Errorf("connection error: %w", err)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
var event emailEvent
|
|
87
|
+
if err := json.Unmarshal(raw, &event); err != nil {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
switch event.Type {
|
|
92
|
+
case "connected":
|
|
93
|
+
inboxList := mailbox
|
|
94
|
+
if inboxList == "" {
|
|
95
|
+
inboxList = fmt.Sprintf("%d inbox(es)", len(event.SubscribedInboxes))
|
|
96
|
+
}
|
|
97
|
+
display.Success(fmt.Sprintf("Watching %s %s",
|
|
98
|
+
inboxList,
|
|
99
|
+
display.PlanBadge(event.Plan),
|
|
100
|
+
))
|
|
101
|
+
display.Info("Waiting for emails… (press Ctrl+C to stop)")
|
|
102
|
+
|
|
103
|
+
case "pong":
|
|
104
|
+
// keepalive — ignore
|
|
105
|
+
|
|
106
|
+
case "error":
|
|
107
|
+
// plan gate or connection error
|
|
108
|
+
var errEvent struct {
|
|
109
|
+
Type string `json:"type"`
|
|
110
|
+
Code string `json:"code"`
|
|
111
|
+
Message string `json:"message"`
|
|
112
|
+
}
|
|
113
|
+
json.Unmarshal(raw, &errEvent)
|
|
114
|
+
display.Error(errEvent.Message)
|
|
115
|
+
return nil
|
|
116
|
+
|
|
117
|
+
default:
|
|
118
|
+
// Email event
|
|
119
|
+
id := event.ID
|
|
120
|
+
otp := safeStr(event.OTP)
|
|
121
|
+
link := safeStr(event.VerificationLink)
|
|
122
|
+
ts := formatTimestamp(event.Date)
|
|
123
|
+
display.EmailEvent(id, event.From, event.Subject, otp, link, ts)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func safeStr(s string) string {
|
|
129
|
+
if s == "null" || s == "__DETECTED__" || s == "__UPGRADE_REQUIRED__" {
|
|
130
|
+
return ""
|
|
131
|
+
}
|
|
132
|
+
return s
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func formatTimestamp(t string) string {
|
|
136
|
+
if t == "" {
|
|
137
|
+
return time.Now().Format("15:04:05")
|
|
138
|
+
}
|
|
139
|
+
parsed, err := time.Parse(time.RFC3339Nano, t)
|
|
140
|
+
if err != nil {
|
|
141
|
+
return t
|
|
142
|
+
}
|
|
143
|
+
return parsed.Local().Format("15:04:05")
|
|
144
|
+
}
|
package/logo.webp
ADDED
|
Binary file
|
package/main.go
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fcemail",
|
|
3
|
+
"version": "0.1.11",
|
|
4
|
+
"description": "FreeCustom.Email CLI — Manage disposable inboxes from your terminal.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"fce": "bin/fce"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"postinstall": "node scripts/install-binary.js"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/DishIs/fce-cli.git"
|
|
14
|
+
},
|
|
15
|
+
"author": "DishIs",
|
|
16
|
+
"license": "MIT"
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
console.log('Downloading fce-cli binary...');
|
|
6
|
+
try {
|
|
7
|
+
execSync('curl -fsSL https://raw.githubusercontent.com/DishIs/fce-cli/main/scripts/install.sh | BIN_DIR=./bin sh');
|
|
8
|
+
} catch (err) {
|
|
9
|
+
console.error('Failed to download binary:', err.message);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Config
|
|
5
|
+
OWNER="DishIs"
|
|
6
|
+
REPO="fce-cli"
|
|
7
|
+
BINARY_NAME="fce"
|
|
8
|
+
GITHUB_API="https://api.github.com/repos/$OWNER/$REPO/releases/latest"
|
|
9
|
+
|
|
10
|
+
# Detect OS
|
|
11
|
+
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
|
12
|
+
case "$OS" in
|
|
13
|
+
linux*) OS='linux';;
|
|
14
|
+
darwin*) OS='darwin';;
|
|
15
|
+
msys*|cygwin*|mingw*) OS='windows';;
|
|
16
|
+
*) echo "Unsupported OS: $OS"; exit 1;;
|
|
17
|
+
esac
|
|
18
|
+
|
|
19
|
+
# Detect Arch
|
|
20
|
+
ARCH=$(uname -m)
|
|
21
|
+
case "$ARCH" in
|
|
22
|
+
x86_64) ARCH='amd64';;
|
|
23
|
+
arm64|aarch64) ARCH='arm64';;
|
|
24
|
+
*) echo "Unsupported architecture: $ARCH"; exit 1;;
|
|
25
|
+
esac
|
|
26
|
+
|
|
27
|
+
# Allow overriding install directory (useful for NPM)
|
|
28
|
+
INSTALL_DIR=${BIN_DIR:-"/usr/local/bin"}
|
|
29
|
+
|
|
30
|
+
echo "Installing $BINARY_NAME for $OS/$ARCH..."
|
|
31
|
+
|
|
32
|
+
# Get latest version and download URL
|
|
33
|
+
DOWNLOAD_URL=$(curl -s $GITHUB_API | grep "browser_download_url" | grep "${OS}_${ARCH}" | cut -d '"' -f 4 | head -n 1)
|
|
34
|
+
|
|
35
|
+
if [ -z "$DOWNLOAD_URL" ]; then
|
|
36
|
+
echo "Could not find a release for $OS/$ARCH"
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# Create temp directory
|
|
41
|
+
TMP_DIR=$(mktemp -d)
|
|
42
|
+
trap 'rm -rf "$TMP_DIR"' EXIT
|
|
43
|
+
|
|
44
|
+
# Download and extract
|
|
45
|
+
echo "Downloading $DOWNLOAD_URL..."
|
|
46
|
+
curl -sL "$DOWNLOAD_URL" -o "$TMP_DIR/archive"
|
|
47
|
+
|
|
48
|
+
if [ "$OS" = "windows" ]; then
|
|
49
|
+
unzip -q "$TMP_DIR/archive" -d "$TMP_DIR"
|
|
50
|
+
else
|
|
51
|
+
tar -xzf "$TMP_DIR/archive" -C "$TMP_DIR"
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# Move to install directory
|
|
55
|
+
if [ "$INSTALL_DIR" = "./bin" ]; then
|
|
56
|
+
mkdir -p "$INSTALL_DIR"
|
|
57
|
+
mv "$TMP_DIR/$BINARY_NAME" "$INSTALL_DIR/"
|
|
58
|
+
echo "Successfully installed $BINARY_NAME to $INSTALL_DIR!"
|
|
59
|
+
else
|
|
60
|
+
echo "Installing to $INSTALL_DIR/$BINARY_NAME (requires sudo)..."
|
|
61
|
+
sudo mv "$TMP_DIR/$BINARY_NAME" "$INSTALL_DIR/"
|
|
62
|
+
echo "Successfully installed $BINARY_NAME!"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
$BINARY_NAME version || "$INSTALL_DIR/$BINARY_NAME" version || echo "Installation complete."
|