The bare `msdl` invocation showed all 22 products (17 consumer + 5 eval) as a flat numbered list. Replaces it with a curated landing screen: 5 popular products (picked from real usage telemetry) plus a 2-item eval shortlist, with "list" as an escape hatch to today's full picker and free text routed through the existing search flow. Also fixes a search bug: querying "windows 10" incorrectly matched Windows 11 24H2 because "10" is a substring of its build number (26100.1742). Query words now must start at a word boundary rather than matching anywhere, so digit fragments inside unrelated numbers can no longer collide -- while "arm" still fuzzy-matches "ARM64". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
485 lines
13 KiB
Go
485 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"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"
|
|
|
|
// urlFilename extracts the bare filename from a URL (strips query string and path).
|
|
func urlFilename(rawURL string) string {
|
|
s := rawURL
|
|
if i := strings.LastIndex(s, "?"); i > 0 {
|
|
s = s[:i]
|
|
}
|
|
if i := strings.LastIndex(s, "/"); i >= 0 {
|
|
return s[i+1:]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// 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, "/")
|
|
}
|
|
return "https://api.msdl.tech-latest.com"
|
|
}
|
|
|
|
func contributeURL() string {
|
|
return apiBaseURL() + "/contribute"
|
|
}
|
|
|
|
// fetchLatestVersion fetches /cli/version and returns the latest version
|
|
// string, or "" on any error or after a 500ms timeout.
|
|
func fetchLatestVersion() string {
|
|
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:
|
|
return latest
|
|
case <-time.After(500 * time.Millisecond):
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// printUpdateNotice prints a notice if latest is newer than the running
|
|
// binary's version.
|
|
func printUpdateNotice(latest string) {
|
|
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")
|
|
}
|
|
}
|
|
|
|
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"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// truncateError caps an error string for telemetry (avoids leaking huge bodies,
|
|
// keeps Redis field values small). Keeps both head and tail: the head usually
|
|
// names what failed (e.g. "language X not available for Y"), while wrapped
|
|
// errors put the root cause last (e.g. "fetching X: <root cause>") — a long
|
|
// middle section (URLs, option lists) is the least useful part to keep.
|
|
func truncateError(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
s := err.Error()
|
|
const max = 150
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
const headLen = 60
|
|
const sep = " ... "
|
|
tailLen := max - headLen - len(sep)
|
|
return s[:headLen] + sep + s[len(s)-tailLen:]
|
|
}
|
|
|
|
// 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) {
|
|
payload := struct {
|
|
ProductID string `json:"product_id"`
|
|
SkuID string `json:"sku_id"`
|
|
RawJSON json.RawMessage `json:"raw_json"`
|
|
}{ProductID: productID, SkuID: skuID, RawJSON: rawJSON}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, contributeURL(), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Contribute-Secret", contributeSecret)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func main() {
|
|
if err := run(os.Args[1:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(args []string) error {
|
|
fs := flag.NewFlagSet("msdl", flag.ContinueOnError)
|
|
fs.SetOutput(os.Stderr)
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(os.Stderr, `msdl — Windows ISO downloader
|
|
|
|
Usage:
|
|
msdl [search terms] interactive: filter + pick product, pick language
|
|
msdl --id 3262 skip product picker
|
|
msdl --id 3262 --lang "English" no prompts, print URL directly
|
|
(language names vary by product — omit --lang
|
|
or check the error's "available" list to see them)
|
|
msdl --eval [slug] evaluation ISOs (server-2025, win11-ent, ...)
|
|
msdl --list list all products and exit
|
|
|
|
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)")
|
|
langFlag := fs.String("lang", "", `language name, e.g. "English"`)
|
|
evalMode := fs.Bool("eval", false, "fetch evaluation ISOs")
|
|
listMode := fs.Bool("list", false, "list all products and exit")
|
|
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"
|
|
isHomepage := *productID == "" && !*evalMode && !*listMode && query == "" && isTerminal()
|
|
|
|
// Update check — blocks up to 500ms, then continues regardless. The
|
|
// homepage screen folds this into its own footer instead of printing a
|
|
// separate notice.
|
|
latest := ""
|
|
if !noTelemetry {
|
|
latest = fetchLatestVersion()
|
|
if !isHomepage {
|
|
printUpdateNotice(latest)
|
|
}
|
|
}
|
|
|
|
if *listMode {
|
|
fmt.Fprintln(os.Stderr, "Consumer products:")
|
|
for _, p := range consumerProducts {
|
|
fmt.Fprintf(os.Stderr, " %-6s %s\n", p.ID, p.Name)
|
|
}
|
|
fmt.Fprintln(os.Stderr, "\nEvaluation products:")
|
|
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
|
|
}
|
|
|
|
telAction := "interactive"
|
|
telProductID := ""
|
|
telEvalSlug := ""
|
|
|
|
var err error
|
|
switch {
|
|
case *evalMode:
|
|
telAction = "eval"
|
|
telEvalSlug = query
|
|
err = runEval(query)
|
|
case *productID != "":
|
|
telAction = "fetch"
|
|
telProductID = *productID
|
|
err = runConsumer(*productID, query, *langFlag, noContribute)
|
|
case isHomepage:
|
|
choice, hpErr := showHomepage(latest)
|
|
if hpErr != nil {
|
|
return hpErr
|
|
}
|
|
switch choice.kind {
|
|
case homepageKindProduct:
|
|
telAction = "fetch"
|
|
telProductID = choice.productID
|
|
err = runConsumer(choice.productID, "", *langFlag, noContribute)
|
|
case homepageKindEval:
|
|
telAction = "eval"
|
|
telEvalSlug = choice.evalSlug
|
|
err = runEval(choice.evalSlug)
|
|
case homepageKindSearch:
|
|
telAction = "fetch"
|
|
err = runConsumer("", choice.query, *langFlag, noContribute)
|
|
case homepageKindList:
|
|
isEval, product, ep, pickErr := pickCombined()
|
|
if pickErr != nil {
|
|
return pickErr
|
|
}
|
|
if isEval {
|
|
telAction = "eval"
|
|
telEvalSlug = ep.Slug
|
|
err = runEval(ep.Slug)
|
|
} else {
|
|
telAction = "fetch"
|
|
telProductID = product.ID
|
|
err = runConsumer(product.ID, "", *langFlag, noContribute)
|
|
}
|
|
}
|
|
default:
|
|
err = runConsumer(*productID, query, *langFlag, noContribute)
|
|
}
|
|
|
|
if !noTelemetry {
|
|
sendTelemetry(cliTelemetryPayload{
|
|
Action: telAction,
|
|
ProductID: telProductID,
|
|
EvalSlug: telEvalSlug,
|
|
Platform: runtime.GOOS,
|
|
Version: Version,
|
|
Success: err == nil,
|
|
Error: truncateError(err),
|
|
})
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func runConsumer(productID, query, langName string, noContribute bool) error {
|
|
var product Product
|
|
|
|
if productID != "" {
|
|
p, ok := findProductByID(productID)
|
|
if !ok {
|
|
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)
|
|
}
|
|
}
|
|
if len(candidates) == 1 {
|
|
product = candidates[0]
|
|
fmt.Fprintf(os.Stderr, "Selected: %s\n", product.Name)
|
|
} else {
|
|
var err error
|
|
product, err = pickProduct(candidates)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "Setting up Microsoft session...\n")
|
|
client, sessionID := newSession()
|
|
|
|
langs, err := fetchLanguages(client, sessionID, product.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("fetching languages for %s: %w", product.Name, err)
|
|
}
|
|
|
|
var lang Language
|
|
if langName != "" {
|
|
for _, l := range langs {
|
|
if strings.EqualFold(l.Language, langName) {
|
|
lang = l
|
|
break
|
|
}
|
|
}
|
|
if lang.ID == "" {
|
|
names := make([]string, len(langs))
|
|
for i, l := range langs {
|
|
names[i] = l.Language
|
|
}
|
|
return fmt.Errorf("language %q not available for %s — available: %s", langName, product.Name, strings.Join(names, ", "))
|
|
}
|
|
} else {
|
|
lang, err = pickLanguage(langs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "Fetching download link...\n")
|
|
links, rawJSON, err := fetchDownloadLinks(client, sessionID, product.ID, lang.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("fetching download links: %w", err)
|
|
}
|
|
|
|
// Fire contribute in background before printing so the goroutine starts immediately.
|
|
var wg sync.WaitGroup
|
|
if !noContribute {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
postContribute(product.ID, lang.ID, rawJSON)
|
|
}()
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, "")
|
|
|
|
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, "\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) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runEval(slug string) error {
|
|
var ep EvalProduct
|
|
|
|
if slug != "" {
|
|
p, ok := findEvalProduct(slug)
|
|
if !ok {
|
|
return fmt.Errorf("unknown eval product %q — valid slugs: server-2025, server-2022, server-2019, server-2016, win11-ent", slug)
|
|
}
|
|
ep = p
|
|
} else {
|
|
var err error
|
|
ep, err = pickEvalProduct(evalProducts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "Fetching eval links for %s...\n", ep.Name)
|
|
links, err := fetchEvalLinks(ep.EvalURL)
|
|
if err != nil {
|
|
return fmt.Errorf("fetching eval links for %s: %w", ep.Name, err)
|
|
}
|
|
if len(links) == 0 {
|
|
return fmt.Errorf("no eval links returned for %s", ep.Slug)
|
|
}
|
|
|
|
if len(links) == 1 {
|
|
fmt.Println(links[0].URL)
|
|
if isTerminal() {
|
|
postFetchMenu(links[0].URL)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, "\nSelect a download:")
|
|
for i, link := range links {
|
|
label := link.Lang
|
|
if link.Arch != "" && link.Lang != "" {
|
|
label = fmt.Sprintf("%-6s %s", link.Lang, link.Arch)
|
|
} else if link.Arch != "" {
|
|
label = link.Arch
|
|
}
|
|
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 err
|
|
}
|
|
selected := links[n-1].URL
|
|
fmt.Println(selected)
|
|
if isTerminal() {
|
|
postFetchMenu(selected)
|
|
}
|
|
return nil
|
|
}
|