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:
commit
2c068ff055
7 changed files with 563 additions and 43 deletions
10
.github/workflows/cli-release.yml
vendored
10
.github/workflows/cli-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
214
backend/main.go
214
backend/main.go
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
201
cli/main.go
201
cli/main.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
120
cli/picker.go
120
cli/picker.go
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue