feat(cli): v0.3.0 — telemetry, arch picker, O/C menu, eval in picker

Backend:
- GET /cli/version update-check endpoint
- POST /telemetry anonymous usage counters (Redis-persisted)
- Telemetry counters in /metrics response

CLI:
- Version var injected via ldflags at build time
- Update notice on every run (non-blocking, 500ms timeout)
- Architecture picker when multiple links returned
- Post-fetch [O] Open in browser / [C] Copy / [Enter] Exit menu
- Combined interactive picker includes Evaluation/Enterprise products
  (Windows Server 2025/2022/2019/2016, Windows 11 Enterprise)
- openInBrowser: writes URL to temp .url file — & no longer truncated
- copyToClipboard: calls clip.exe via full SystemRoot path

Frontend:
- Winget elevated to primary install on CLI page + CliHandoff card
- CliHandoff command shows msdl (no .exe suffix)

Docs: README telemetry disclosure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shekhar Vaidya 2026-06-30 07:26:31 +05:30
commit 2c068ff055
7 changed files with 563 additions and 43 deletions

View file

@ -20,11 +20,13 @@ jobs:
- name: Build binaries
working-directory: cli
run: |
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#cli/v}"
mkdir -p ../dist
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../dist/msdl-linux-amd64 .
GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o ../dist/msdl-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o ../dist/msdl-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o ../dist/msdl-windows-amd64.exe .
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../dist/msdl-linux-amd64 .
GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../dist/msdl-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../dist/msdl-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../dist/msdl-windows-amd64.exe .
- name: Create GitHub Release
uses: softprops/action-gh-release@v2

View file

@ -78,6 +78,14 @@ msdl --id 3262 --lang "English" --no-contribute
# or: MSDL_NO_CONTRIBUTE=1
```
### Usage telemetry
By default, each run sends an anonymous event to help us understand which products are popular and which platforms are used. No personal data is sent — only action type (`fetch`, `eval`, `list`, `interactive`), platform (`windows`, `darwin`, `linux`), CLI version, and whether the run succeeded. To opt out:
```bash
MSDL_NO_TELEMETRY=1 msdl --id 3262 --lang "English"
```
---
## Project Structure

View file

@ -12,12 +12,14 @@ import (
"net/http"
"net/url"
"os"
"os/signal"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/google/uuid"
@ -36,6 +38,8 @@ const (
ORG_ID = "y6jn8c31"
CUSTOMER_ID = "560dc9f3-1aa5-4a2f-b63c-9e18f8d0e175"
PORT = ":3002"
latestCLIVersion = "0.3.0"
)
// --- Session cache (short-lived, used to chain /skuinfo → /proxy) ---
@ -316,6 +320,7 @@ func initRedis() {
}
rdb = client
log.Println("redis: connected")
seedMetricsFromRedis()
}
func redisWriteLink(productID, skuID string, raw []byte, expiresAt time.Time) {
@ -378,6 +383,89 @@ func redisSeedLinkCache() {
}
}
// seedMetricsFromRedis loads persisted metric counters from Redis into the
// in-memory atomic vars. Called once on startup after Redis connects.
func seedMetricsFromRedis() {
if rdb == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
loadHash := func(key string) map[string]string {
vals, err := rdb.HGetAll(ctx, key).Result()
if err != nil {
return nil
}
return vals
}
parseI64 := func(m map[string]string, field string) int64 {
if m == nil {
return 0
}
v, _ := strconv.ParseInt(m[field], 10, 64)
return v
}
sku := loadHash("msdl:metrics:sku")
atomic.StoreInt64(&mSkuRequests, parseI64(sku, "requests"))
atomic.StoreInt64(&mSkuCacheHits, parseI64(sku, "cache_hits"))
atomic.StoreInt64(&mSkuFetches, parseI64(sku, "ms_fetches"))
atomic.StoreInt64(&mSkuNegHits, parseI64(sku, "neg_hits"))
link := loadHash("msdl:metrics:link")
atomic.StoreInt64(&mLinkRequests, parseI64(link, "requests"))
atomic.StoreInt64(&mLinkCacheHits, parseI64(link, "cache_hits"))
atomic.StoreInt64(&mLinkFetches, parseI64(link, "ms_fetches"))
atomic.StoreInt64(&mLinkNegHits, parseI64(link, "neg_hits"))
atomic.StoreInt64(&mLinkStale, parseI64(link, "stale"))
eval := loadHash("msdl:metrics:eval")
atomic.StoreInt64(&mEvalRequests, parseI64(eval, "requests"))
atomic.StoreInt64(&mEvalCacheHits, parseI64(eval, "cache_hits"))
atomic.StoreInt64(&mEvalStale, parseI64(eval, "stale"))
log.Println("redis: seeded in-memory metrics from Redis")
}
// flushMetricsToRedis writes current in-memory metric counters to Redis.
// Called periodically (every 5 min) and on graceful shutdown.
func flushMetricsToRedis() {
if rdb == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rdb.HSet(ctx, "msdl:metrics:sku",
"requests", atomic.LoadInt64(&mSkuRequests),
"cache_hits", atomic.LoadInt64(&mSkuCacheHits),
"ms_fetches", atomic.LoadInt64(&mSkuFetches),
"neg_hits", atomic.LoadInt64(&mSkuNegHits),
)
rdb.HSet(ctx, "msdl:metrics:link",
"requests", atomic.LoadInt64(&mLinkRequests),
"cache_hits", atomic.LoadInt64(&mLinkCacheHits),
"ms_fetches", atomic.LoadInt64(&mLinkFetches),
"neg_hits", atomic.LoadInt64(&mLinkNegHits),
"stale", atomic.LoadInt64(&mLinkStale),
)
rdb.HSet(ctx, "msdl:metrics:eval",
"requests", atomic.LoadInt64(&mEvalRequests),
"cache_hits", atomic.LoadInt64(&mEvalCacheHits),
"stale", atomic.LoadInt64(&mEvalStale),
)
log.Println("redis: flushed in-memory metrics to Redis")
}
// startMetricsFlusher flushes in-memory metrics to Redis every 5 minutes.
func startMetricsFlusher() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
flushMetricsToRedis()
}
}
// --- /contribute validation helpers ---
// validContributeProducts is the canonical product ID allow-list for POST /contribute.
@ -482,6 +570,17 @@ func (rl *ipRateLimiter) allow(ip string) bool {
var contributeRL = newIPRateLimiter(5, 5) // 5 requests/min, burst of 5
var telemetryRL = newIPRateLimiter(10, 10) // 10 requests/min, burst 10
type telemetryPayload struct {
Action string `json:"action"`
ProductID string `json:"product_id"`
EvalSlug string `json:"eval_slug"`
Platform string `json:"platform"`
Version string `json:"version"`
Success bool `json:"success"`
}
// --- Evalcenter helpers ---
func detectArch(rawURL string) string {
@ -1514,8 +1613,101 @@ func cleanupCaches() {
}
}
// --- /telemetry endpoint ---
func handleTelemetry(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
ip := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = strings.TrimSpace(strings.SplitN(fwd, ",", 2)[0])
}
if !telemetryRL.allow(ip) {
respondJSONError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
var p telemetryPayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
respondJSONError(w, http.StatusBadRequest, "invalid JSON")
return
}
validActions := map[string]bool{"fetch": true, "eval": true, "list": true, "interactive": true}
if !validActions[p.Action] {
respondJSONError(w, http.StatusBadRequest, "invalid action")
return
}
if rdb != nil {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
rdb.HIncrBy(ctx, "msdl:telemetry:actions", p.Action, 1)
validPlatforms := map[string]bool{"windows": true, "darwin": true, "linux": true}
if validPlatforms[p.Platform] {
rdb.HIncrBy(ctx, "msdl:telemetry:platforms", p.Platform, 1)
}
if p.Version != "" && len(p.Version) <= 20 {
rdb.HIncrBy(ctx, "msdl:telemetry:versions", p.Version, 1)
}
if p.ProductID != "" && len(p.ProductID) <= 10 {
rdb.HIncrBy(ctx, "msdl:telemetry:products", p.ProductID, 1)
}
result := "success"
if !p.Success {
result = "failed"
}
rdb.HIncrBy(ctx, "msdl:telemetry:results", result, 1)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("{}"))
}
// --- /cli/version endpoint ---
func handleCLIVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"latest": latestCLIVersion})
}
// --- /metrics endpoint ---
// loadTelemetryFromRedis reads all telemetry counters from Redis.
// Returns nil maps when Redis is unavailable.
func loadTelemetryFromRedis() map[string]map[string]string {
if rdb == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
keys := []string{
"msdl:telemetry:actions",
"msdl:telemetry:platforms",
"msdl:telemetry:versions",
"msdl:telemetry:products",
"msdl:telemetry:results",
}
out := make(map[string]map[string]string, len(keys))
for _, k := range keys {
vals, err := rdb.HGetAll(ctx, k).Result()
if err == nil {
// Strip the "msdl:telemetry:" prefix for the JSON key
name := strings.TrimPrefix(k, "msdl:telemetry:")
out[name] = vals
}
}
return out
}
func handleMetrics(w http.ResponseWriter, r *http.Request) {
secret := os.Getenv("METRICS_SECRET")
if secret == "" {
@ -1567,6 +1759,8 @@ func handleMetrics(w http.ResponseWriter, r *http.Request) {
evalSize := len(evalCache)
evalCacheMu.RUnlock()
telemetry := loadTelemetryFromRedis()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"sku": map[string]interface{}{
@ -1595,6 +1789,7 @@ func handleMetrics(w http.ResponseWriter, r *http.Request) {
},
"neg_cache_size": negSize,
"total_ms_fetches": skuFetches + linkFetches,
"telemetry": telemetry,
})
}
@ -1620,14 +1815,16 @@ func main() {
go cleanupSessions()
go cleanupCaches()
go warmEvalCache()
go startMetricsFlusher()
mux := http.NewServeMux()
mux.HandleFunc("/skuinfo", handleSkuInfo)
mux.HandleFunc("/proxy", handleProxy)
mux.HandleFunc("/contribute", handleContribute)
mux.HandleFunc("/evallinks", handleEvalLinks)
mux.HandleFunc("/metrics", handleMetrics)
mux.HandleFunc("/telemetry", handleTelemetry)
mux.HandleFunc("/cli/version", handleCLIVersion)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
@ -1641,10 +1838,21 @@ func main() {
})
handler := enableCORS(mux)
srv := &http.Server{Addr: PORT, Handler: handler}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("shutdown: flushing metrics to Redis...")
flushMetricsToRedis()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
log.Printf("Go Backend running on http://localhost%s\n", PORT)
err := http.ListenAndServe(PORT, handler)
if err != nil {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}

View file

@ -8,11 +8,16 @@ import (
"fmt"
"net/http"
"os"
"runtime"
"strings"
"sync"
"time"
)
// Version is injected at build time via -ldflags "-X main.Version=0.3.0".
// Falls back to "dev" for local builds.
var Version = "dev"
// contributeURL is the MSDL backend endpoint that warms the shared link cache.
// Override with MSDL_API_URL env var, e.g. export MSDL_API_URL=http://localhost:3002
const contributeSecret = "msdl-contribute-2026"
@ -29,11 +34,80 @@ func urlFilename(rawURL string) string {
return s
}
func contributeURL() string {
// apiBaseURL returns the backend base URL, with MSDL_API_URL override.
func apiBaseURL() string {
if u := os.Getenv("MSDL_API_URL"); u != "" {
return strings.TrimRight(u, "/") + "/contribute"
return strings.TrimRight(u, "/")
}
return "https://api.msdl.tech-latest.com/contribute"
return "https://api.msdl.tech-latest.com"
}
func contributeURL() string {
return apiBaseURL() + "/contribute"
}
// printUpdateNotice checks /cli/version and prints a notice if a newer version
// is available. Blocks up to 500ms; silently no-ops on timeout or any error.
func printUpdateNotice() {
ch := make(chan string, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBaseURL()+"/cli/version", nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
var body struct {
Latest string `json:"latest"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return
}
ch <- body.Latest
}()
select {
case latest := <-ch:
if latest != "" && latest != Version && Version != "dev" {
fmt.Fprintf(os.Stderr, "\n A new version of msdl is available: v%s\n", latest)
fmt.Fprintf(os.Stderr, " Download: https://github.com/starkSV/windows-iso-downloader/releases/latest\n\n")
}
case <-time.After(500 * time.Millisecond):
}
}
type cliTelemetryPayload struct {
Action string `json:"action"`
ProductID string `json:"product_id,omitempty"`
EvalSlug string `json:"eval_slug,omitempty"`
Platform string `json:"platform"`
Version string `json:"version"`
Success bool `json:"success"`
}
// sendTelemetry posts a single telemetry event. Fire-and-forget: all errors
// are silently ignored so telemetry never affects the user experience.
func sendTelemetry(p cliTelemetryPayload) {
body, err := json.Marshal(p)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL()+"/telemetry", bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
resp.Body.Close()
}
func postContribute(productID, skuID string, rawJSON []byte) {
@ -78,6 +152,13 @@ Usage:
Flags:`)
fs.PrintDefaults()
fmt.Fprintln(os.Stderr, `
Environment:
MSDL_NO_TELEMETRY=1 Disable anonymous usage reporting and update checks
MSDL_NO_CONTRIBUTE=1 Disable cache contribution
MSDL_API_URL=<url> Override backend URL (default: https://api.msdl.tech-latest.com)
More info: https://msdl.tech-latest.com/cli`)
}
productID := fs.String("id", "", "consumer product ID (skips product picker)")
@ -87,10 +168,19 @@ Flags:`)
noContributeFlag := fs.Bool("no-contribute", false, "skip sharing the link with the msdl.tech cache")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil // fs.Usage already printed
}
return err
}
query := strings.Join(fs.Args(), " ")
noContribute := *noContributeFlag || os.Getenv("MSDL_NO_CONTRIBUTE") == "1"
noTelemetry := os.Getenv("MSDL_NO_TELEMETRY") == "1"
// Update check — blocks up to 500ms, then continues regardless
if !noTelemetry {
printUpdateNotice()
}
if *listMode {
fmt.Fprintln(os.Stderr, "Consumer products:")
@ -101,13 +191,49 @@ Flags:`)
for _, p := range evalProducts {
fmt.Fprintf(os.Stderr, " %-20s %s\n", p.Slug, p.Name)
}
if !noTelemetry {
go sendTelemetry(cliTelemetryPayload{
Action: "list",
Platform: runtime.GOOS,
Version: Version,
Success: true,
})
}
return nil
}
if *evalMode {
return runEval(query)
// Determine telemetry fields before running (product_id / eval_slug known at this point)
telAction := "interactive"
telProductID := ""
telEvalSlug := ""
switch {
case *evalMode:
telAction = "eval"
telEvalSlug = query
case *productID != "":
telAction = "fetch"
telProductID = *productID
}
return runConsumer(*productID, query, *langFlag, noContribute)
var err error
if *evalMode {
err = runEval(query)
} else {
err = runConsumer(*productID, query, *langFlag, noContribute)
}
if !noTelemetry {
go sendTelemetry(cliTelemetryPayload{
Action: telAction,
ProductID: telProductID,
EvalSlug: telEvalSlug,
Platform: runtime.GOOS,
Version: Version,
Success: err == nil,
})
}
return err
}
func runConsumer(productID, query, langName string, noContribute bool) error {
@ -119,13 +245,10 @@ func runConsumer(productID, query, langName string, noContribute bool) error {
return fmt.Errorf("unknown product ID %q — run msdl --list to see all products", productID)
}
product = p
} else {
candidates := consumerProducts
if query != "" {
candidates = searchProducts(query)
if len(candidates) == 0 {
return fmt.Errorf("no products match %q — run msdl --list to see all products", query)
}
} else if query != "" {
candidates := searchProducts(query)
if len(candidates) == 0 {
return fmt.Errorf("no products match %q — run msdl --list to see all products", query)
}
if len(candidates) == 1 {
product = candidates[0]
@ -137,6 +260,15 @@ func runConsumer(productID, query, langName string, noContribute bool) error {
return err
}
}
} else {
isEval, selected, ep, err := pickCombined()
if err != nil {
return err
}
if isEval {
return runEval(ep.Slug)
}
product = selected
}
fmt.Fprintf(os.Stderr, "Setting up Microsoft session...\n")
@ -182,18 +314,42 @@ func runConsumer(productID, query, langName string, noContribute bool) error {
}
fmt.Fprintln(os.Stderr, "")
for _, link := range links {
var selectedURI string
if isTerminal() && len(links) > 1 {
link, err := pickArchitecture(links)
if err != nil {
return err
}
name := urlFilename(link.URI)
fmt.Fprintf(os.Stderr, " %s\n", name)
fmt.Fprintf(os.Stderr, " %s\n", strings.Repeat("─", len(name)))
fmt.Println(link.URI) // stdout — stays clean for piping
fmt.Fprintln(os.Stderr, "")
fmt.Fprintf(os.Stderr, "\n %s\n", name)
fmt.Fprintf(os.Stderr, " %s\n\n", strings.Repeat("─", len(name)))
fmt.Println(link.URI)
selectedURI = link.URI
} else {
for _, link := range links {
name := urlFilename(link.URI)
fmt.Fprintf(os.Stderr, " %s\n", name)
fmt.Fprintf(os.Stderr, " %s\n", strings.Repeat("─", len(name)))
fmt.Println(link.URI) // stdout — stays clean for piping
fmt.Fprintln(os.Stderr, "")
}
if len(links) == 1 {
selectedURI = links[0].URI
}
}
if !noContribute {
fmt.Fprintln(os.Stderr, " ✓ Shared with MSDL Web App (https://msdl.tech-latest.com/)")
fmt.Fprintln(os.Stderr, " Opt out: --no-contribute")
fmt.Fprintln(os.Stderr, "")
}
if isTerminal() && selectedURI != "" {
postFetchMenu(selectedURI)
}
if !noContribute {
// Wait up to 5s for the contribution to complete before process exit.
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
@ -233,6 +389,9 @@ func runEval(slug string) error {
if len(links) == 1 {
fmt.Println(links[0].URL)
if isTerminal() {
postFetchMenu(links[0].URL)
}
return nil
}
@ -251,6 +410,10 @@ func runEval(slug string) error {
if err != nil {
return err
}
fmt.Println(links[n-1].URL)
selected := links[n-1].URL
fmt.Println(selected)
if isTerminal() {
postFetchMenu(selected)
}
return nil
}

View file

@ -5,6 +5,8 @@ import (
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
)
@ -76,3 +78,121 @@ func pickEvalProduct(products []EvalProduct) (EvalProduct, error) {
}
return products[n-1], nil
}
func isTerminal() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
func pickCombined() (isEval bool, p Product, ep EvalProduct, err error) {
total := len(consumerProducts) + len(evalProducts)
fmt.Fprintln(os.Stderr, "\nSelect a product:")
for i, prod := range consumerProducts {
fmt.Fprintf(os.Stderr, " %2d. %s\n", i+1, prod.Name)
}
fmt.Fprintln(os.Stderr, "\n ── Evaluation / Enterprise ──")
offset := len(consumerProducts)
for i, e := range evalProducts {
fmt.Fprintf(os.Stderr, " %2d. %s\n", offset+i+1, e.Name)
}
fmt.Fprint(os.Stderr, "\nChoice: ")
n, err := parseChoice(os.Stdin, total)
if err != nil {
return false, Product{}, EvalProduct{}, err
}
if n <= offset {
return false, consumerProducts[n-1], EvalProduct{}, nil
}
return true, Product{}, evalProducts[n-offset-1], nil
}
func pickArchitecture(links []DownloadLink) (DownloadLink, error) {
fmt.Fprintln(os.Stderr, "Select architecture:")
for i, l := range links {
label := l.Architecture
if label == "" {
label = fmt.Sprintf("link %d", i+1)
}
fmt.Fprintf(os.Stderr, " %2d. %s\n", i+1, label)
}
fmt.Fprint(os.Stderr, "\nChoice: ")
n, err := parseChoice(os.Stdin, len(links))
if err != nil {
return DownloadLink{}, err
}
return links[n-1], nil
}
func openInBrowser(rawURL string) error {
switch runtime.GOOS {
case "windows":
// Write URL to a temp .url file (Windows Internet Shortcut format).
// The URL never touches cmd.exe — only the temp filename does, which
// contains no special characters.
f, err := os.CreateTemp("", "msdl-*.url")
if err != nil {
return err
}
name := f.Name()
defer os.Remove(name)
if _, err := fmt.Fprintf(f, "[InternetShortcut]\r\nURL=%s\r\n", rawURL); err != nil {
f.Close()
return err
}
f.Close()
cmdExe := os.Getenv("COMSPEC")
if cmdExe == "" {
cmdExe = `C:\Windows\System32\cmd.exe`
}
return exec.Command(cmdExe, "/c", "start", "", name).Run()
case "darwin":
return exec.Command("open", rawURL).Run()
default:
return exec.Command("xdg-open", rawURL).Run()
}
}
func copyToClipboard(text string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
clipExe := `C:\Windows\System32\clip.exe`
if sr := os.Getenv("SystemRoot"); sr != "" {
clipExe = sr + `\System32\clip.exe`
}
cmd = exec.Command(clipExe)
case "darwin":
cmd = exec.Command("pbcopy")
default:
if _, err := exec.LookPath("xclip"); err == nil {
cmd = exec.Command("xclip", "-selection", "clipboard")
} else {
cmd = exec.Command("xsel", "--clipboard", "--input")
}
}
cmd.Stdin = strings.NewReader(text)
return cmd.Run()
}
func postFetchMenu(uri string) {
fmt.Fprint(os.Stderr, "\n [O] Open in browser [C] Copy to clipboard [Enter] Exit\n > ")
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return
}
switch strings.ToLower(strings.TrimSpace(scanner.Text())) {
case "o":
if err := openInBrowser(uri); err != nil {
fmt.Fprintln(os.Stderr, " Could not open browser:", err)
}
case "c":
if err := copyToClipboard(uri); err != nil {
fmt.Fprintln(os.Stderr, " Could not copy to clipboard:", err)
} else {
fmt.Fprintln(os.Stderr, " ✓ Copied to clipboard")
}
}
}

View file

@ -14,9 +14,29 @@ interface Props {
const RELEASES_URL = 'https://minxl.ink/msdl-github-release'
function InstallSteps() {
const [copied, setCopied] = useState(false)
function handleCopy() {
navigator.clipboard.writeText('winget install starkSV.msdl')
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="space-y-1.5">
<div className="space-y-2">
<p className="text-[10px] font-mono font-semibold uppercase tracking-widest text-zinc-600">1. Install</p>
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-mono font-semibold tracking-widest uppercase text-emerald-500">Windows · winget</span>
<span className="text-[10px] px-1.5 py-0.5 rounded border border-emerald-500/20 text-emerald-600 font-mono">recommended</span>
</div>
<div className="flex items-center justify-between gap-2 rounded bg-black/30 px-3 py-2">
<code className="text-[12px] font-mono text-zinc-300">winget install starkSV.msdl</code>
<button onClick={handleCopy} className="flex-shrink-0 text-zinc-500 hover:text-white transition-colors">
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</button>
</div>
</div>
<a
href={RELEASES_URL}
target="_blank"
@ -24,14 +44,8 @@ function InstallSteps() {
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/4 border border-white/7 text-[12px] text-zinc-400 hover:text-zinc-200 hover:border-white/12 transition-colors"
>
<ExternalLink size={12} className="flex-shrink-0 text-zinc-600" />
Download msdl from GitHub Releases
Other platforms — GitHub Releases
</a>
<p className="text-[12px] text-zinc-400 leading-relaxed pt-0.5">
Rename the downloaded file and add its folder to your PATH:<br />
<span className="text-zinc-500">Windows →</span> <code className="font-mono text-zinc-300 bg-white/5 px-1 rounded">msdl.exe</code>
<span className="mx-2 text-zinc-700">·</span>
<span className="text-zinc-500">macOS/Linux →</span> <code className="font-mono text-zinc-300 bg-white/5 px-1 rounded">msdl</code>
</p>
</div>
)
}
@ -72,8 +86,8 @@ export default function CliHandoff({ productId, langName, langDisplay, defaultOp
const [copied, setCopied] = useState(false)
const command = langName
? `msdl.exe --id ${productId} --lang "${langName}"`
: `msdl.exe --id ${productId}`
? `msdl --id ${productId} --lang "${langName}"`
: `msdl --id ${productId}`
function handleCopy() {
navigator.clipboard.writeText(command)

View file

@ -98,9 +98,19 @@ export default function CliPage() {
<div className="space-y-4">
<h2 className="text-base font-semibold text-white">Install</h2>
{/* winget — Windows primary */}
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-mono font-semibold tracking-widest uppercase text-emerald-500">Windows · winget</span>
<span className="text-[10px] px-1.5 py-0.5 rounded border border-emerald-500/20 text-emerald-600 font-mono">recommended</span>
</div>
<CodeBlock code="winget install starkSV.msdl" />
</div>
{/* Manual / other platforms */}
<div className="rounded-xl border border-white/7 bg-[#111113] p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-[12px] text-zinc-400">Download the latest binary for your platform:</p>
<p className="text-[12px] text-zinc-400">Other platforms — download binary manually:</p>
<a
href={RELEASES_URL}
target="_blank"
@ -115,7 +125,7 @@ export default function CliPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-[12px]">
{[
{ os: 'Windows', file: 'msdl-windows-amd64.exe', rename: 'msdl.exe' },
{ os: 'Windows (manual)', file: 'msdl-windows-amd64.exe', rename: 'msdl.exe' },
{ os: 'macOS (Apple Silicon)', file: 'msdl-darwin-arm64', rename: 'msdl' },
{ os: 'macOS (Intel)', file: 'msdl-darwin-amd64', rename: 'msdl' },
{ os: 'Linux', file: 'msdl-linux-amd64', rename: 'msdl' },
@ -127,11 +137,6 @@ export default function CliPage() {
</div>
))}
</div>
<div className="flex items-center gap-2 pt-1 border-t border-white/5">
<span className="text-[11px] text-zinc-600">winget install starkSV.msdl</span>
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-amber-500">coming soon</span>
</div>
</div>
</div>