feat: msdl CLI — direct Windows ISO downloads from user's machine (Phase 2)

feat: msdl CLI — direct Windows ISO downloads from user's machine (Phase 2)
This commit is contained in:
Shekhar 2026-06-18 13:13:04 +05:30 • committed by GitHub
commit 7ebf5a9560
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1048 additions and 0 deletions

85
.github/workflows/cli-release.yml vendored Normal file
View file

@ -0,0 +1,85 @@
name: CLI Release
on:
push:
tags:
- 'cli/v*'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Build binaries
working-directory: cli
run: |
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 .
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: "msdl CLI ${{ github.ref_name }}"
files: dist/*
body: |
## Install
**Windows (winget):**
```
winget install starkSV.msdl
```
**Linux/macOS:**
```bash
# Linux x86_64
curl -L https://github.com/starkSV/windows-iso-downloader/releases/download/${{ github.ref_name }}/msdl-linux-amd64 -o msdl
chmod +x msdl && sudo mv msdl /usr/local/bin/
# macOS Apple Silicon
curl -L https://github.com/starkSV/windows-iso-downloader/releases/download/${{ github.ref_name }}/msdl-darwin-arm64 -o msdl
chmod +x msdl && sudo mv msdl /usr/local/bin/
# macOS Intel
curl -L https://github.com/starkSV/windows-iso-downloader/releases/download/${{ github.ref_name }}/msdl-darwin-amd64 -o msdl
chmod +x msdl && sudo mv msdl /usr/local/bin/
```
**Windows (manual):** Download `msdl-windows-amd64.exe`, rename to `msdl.exe`, place in a directory on your PATH.
## Usage
```
msdl # fully interactive
msdl "windows 11 25h2" # filter products, pick language
msdl --id 3262 --lang "English (United States)" # direct, no prompts
msdl --eval server-2025 # evaluation ISOs
msdl --list # list all products
```
- name: Submit winget update
if: success()
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
run: |
if [ -z "$WINGET_TOKEN" ]; then
echo "WINGET_TOKEN not set, skipping winget update"
exit 0
fi
dotnet tool install --global wingetcreate
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#cli/v}"
wingetcreate update starkSV.msdl \
--version "$VERSION" \
--urls "https://github.com/starkSV/windows-iso-downloader/releases/download/${{ github.ref_name }}/msdl-windows-amd64.exe" \
--submit \
--token "$WINGET_TOKEN"

82
cli/catalog.go Normal file
View file

@ -0,0 +1,82 @@
package main
import "strings"
type Product struct {
ID string
Name string
}
type EvalProduct struct {
Slug string
Name string
EvalURL string
}
var consumerProducts = []Product{
{"52", "Windows 8.1 (9600.17415)"},
{"2378", "Windows 10 22H2 Home China (19045.2006)"},
{"2618", "Windows 10 22H2 v1 (19045.2965)"},
{"3113", "Windows 11 24H2 (26100.1742)"},
{"3114", "Windows 11 24H2 Home China (26100.1742)"},
{"3115", "Windows 11 24H2 Pro China (26100.1742)"},
{"3131", "Windows 11 Arm64 24H2 (26100.1742)"},
{"3132", "Windows 11 Arm64 24H2 Home China (26100.1742)"},
{"3133", "Windows 11 Arm64 24H2 Pro China (26100.1742)"},
{"3262", "Windows 11 25H2 (26200.6584)"},
{"3263", "Windows 11 25H2 Home China (26200.6584)"},
{"3264", "Windows 11 25H2 Pro China (26200.6584)"},
{"3265", "Windows 11 Arm64 25H2 (26200.6584)"},
{"3266", "Windows 11 Arm64 25H2 Home China (26200.6584)"},
{"3267", "Windows 11 Arm64 25H2 Pro China (26200.6584)"},
{"3321", "Windows 11 25H2 (Updated Oct)"},
{"3324", "Windows 11 Arm64 25H2 (Updated Oct)"},
}
var evalProducts = []EvalProduct{
{"server-2025", "Windows Server 2025", "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2025"},
{"server-2022", "Windows Server 2022", "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2022"},
{"server-2019", "Windows Server 2019", "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2019"},
{"server-2016", "Windows Server 2016", "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2016"},
{"win11-ent", "Windows 11 Enterprise", "https://www.microsoft.com/en-us/evalcenter/download-windows-11-enterprise"},
}
func findProductByID(id string) (Product, bool) {
for _, p := range consumerProducts {
if p.ID == id {
return p, true
}
}
return Product{}, false
}
func searchProducts(query string) []Product {
words := strings.Fields(strings.ToLower(query))
if len(words) == 0 {
return consumerProducts
}
var results []Product
for _, p := range consumerProducts {
name := strings.ToLower(p.Name)
match := true
for _, w := range words {
if !strings.Contains(name, w) {
match = false
break
}
}
if match {
results = append(results, p)
}
}
return results
}
func findEvalProduct(slug string) (EvalProduct, bool) {
for _, p := range evalProducts {
if p.Slug == slug {
return p, true
}
}
return EvalProduct{}, false
}

58
cli/catalog_test.go Normal file
View file

@ -0,0 +1,58 @@
package main
import "testing"
func TestFindProductByID_found(t *testing.T) {
p, ok := findProductByID("3262")
if !ok {
t.Fatal("expected product 3262 to exist")
}
if p.ID != "3262" {
t.Errorf("got ID %s, want 3262", p.ID)
}
}
func TestFindProductByID_notFound(t *testing.T) {
_, ok := findProductByID("9999")
if ok {
t.Fatal("expected product 9999 to not exist")
}
}
func TestSearchProducts_match(t *testing.T) {
results := searchProducts("windows 11 25h2")
if len(results) == 0 {
t.Fatal("expected at least one result for 'windows 11 25h2'")
}
}
func TestSearchProducts_noMatch(t *testing.T) {
results := searchProducts("zzznomatch")
if len(results) != 0 {
t.Errorf("expected no results, got %d", len(results))
}
}
func TestSearchProducts_caseInsensitive(t *testing.T) {
results := searchProducts("WINDOWS 11")
if len(results) == 0 {
t.Fatal("expected results for uppercase query")
}
}
func TestFindEvalProduct_found(t *testing.T) {
p, ok := findEvalProduct("server-2025")
if !ok {
t.Fatal("expected server-2025 to exist")
}
if p.Slug != "server-2025" {
t.Errorf("got slug %s, want server-2025", p.Slug)
}
}
func TestFindEvalProduct_notFound(t *testing.T) {
_, ok := findEvalProduct("zzz-nope")
if ok {
t.Fatal("expected zzz-nope to not exist")
}
}

3
cli/go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/starkSV/msdl-cli
go 1.21

177
cli/main.go Normal file
View file

@ -0,0 +1,177 @@
package main
import (
"flag"
"fmt"
"os"
"strings"
)
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 (United States)" no prompts, print URL directly
msdl --eval [slug] evaluation ISOs (server-2025, win11-ent, ...)
msdl --list list all products and exit
Flags:`)
fs.PrintDefaults()
}
productID := fs.String("id", "", "consumer product ID (skips product picker)")
langFlag := fs.String("lang", "", `language name, e.g. "English (United States)"`)
evalMode := fs.Bool("eval", false, "fetch evaluation ISOs")
listMode := fs.Bool("list", false, "list all products and exit")
if err := fs.Parse(args); err != nil {
return err
}
query := strings.Join(fs.Args(), " ")
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)
}
return nil
}
if *evalMode {
return runEval(query)
}
return runConsumer(*productID, query, *langFlag)
}
func runConsumer(productID, query, langName string) 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 == "" {
return fmt.Errorf("language %q not available for %s", langName, product.Name)
}
} else {
lang, err = pickLanguage(langs)
if err != nil {
return err
}
}
fmt.Fprintf(os.Stderr, "Fetching download link...\n")
links, err := fetchDownloadLinks(client, sessionID, product.ID, lang.ID)
if err != nil {
return fmt.Errorf("fetching download links: %w", err)
}
for _, link := range links {
fmt.Println(link.URI)
}
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)
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
}
fmt.Println(links[n-1].URL)
return nil
}

375
cli/microsoft.go Normal file
View file

@ -0,0 +1,375 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
const (
msUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
msProfile = "606624d44113"
msLocale = "en-US"
msOrgID = "y6jn8c31"
msCustomer = "560dc9f3-1aa5-4a2f-b63c-9e18f8d0e175"
)
var (
fwlinkRe = regexp.MustCompile(`https://go\.microsoft\.com/fwlink/[^"'\s<>]+`)
isoLangRe = regexp.MustCompile(`_([a-z]{2}-[a-z]{2})\.iso$`)
reW = regexp.MustCompile(`[&?]w=([^&"'\s]+)`)
reRt = regexp.MustCompile(`rticks[="]+\+?\s*(\d{10,})`)
)
type Language struct {
ID string `json:"Id"`
Language string `json:"Language"`
}
type DownloadLink struct {
URI string
Architecture string
}
type EvalLink struct {
Arch string
Lang string
URL string
}
type simpleCookieJar struct {
mu sync.Mutex
cookies []*http.Cookie
}
func (j *simpleCookieJar) SetCookies(_ *url.URL, cookies []*http.Cookie) {
j.mu.Lock()
defer j.mu.Unlock()
for _, c := range cookies {
found := false
for i, existing := range j.cookies {
if existing.Name == c.Name {
j.cookies[i] = c
found = true
break
}
}
if !found {
j.cookies = append(j.cookies, c)
}
}
}
func (j *simpleCookieJar) Cookies(_ *url.URL) []*http.Cookie {
j.mu.Lock()
defer j.mu.Unlock()
out := make([]*http.Cookie, len(j.cookies))
copy(out, j.cookies)
return out
}
func newSessionID() string {
b := make([]byte, 16)
rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%s-%s-%s-%s-%s",
hex.EncodeToString(b[0:4]), hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]), hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:16]))
}
func referer(productID string) string {
id, err := strconv.Atoi(productID)
if err != nil {
return "https://www.microsoft.com/en-us/software-download/windows8ISO"
}
if id >= 2935 {
return "https://www.microsoft.com/en-us/software-download/windows11"
}
if id >= 2618 {
return "https://www.microsoft.com/en-us/software-download/windows10ISO"
}
return "https://www.microsoft.com/en-us/software-download/windows8ISO"
}
func mapDownloadType(n int) string {
switch n {
case 0:
return "x86"
case 1:
return "x64"
case 2:
return "ARM64"
default:
return fmt.Sprintf("type_%d", n)
}
}
func newSession() (*http.Client, string) {
sessionID := newSessionID()
jar := &simpleCookieJar{}
client := &http.Client{Timeout: 15 * time.Second, Jar: jar}
q1 := url.Values{}
q1.Set("org_id", msOrgID)
q1.Set("session_id", sessionID)
req1, _ := http.NewRequest("GET", "https://vlscppe.microsoft.com/tags?"+q1.Encode(), nil)
req1.Header.Set("User-Agent", msUA)
client.Do(req1)
q2 := url.Values{}
q2.Set("instanceId", msCustomer)
q2.Set("PageId", "si")
q2.Set("session_id", sessionID)
req2, _ := http.NewRequest("GET", "https://ov-df.microsoft.com/mdt.js?"+q2.Encode(), nil)
req2.Header.Set("User-Agent", msUA)
resp2, err := client.Do(req2)
if err != nil {
return client, sessionID
}
body, _ := io.ReadAll(resp2.Body)
resp2.Body.Close()
wMatch := reW.FindStringSubmatch(string(body))
rtMatch := reRt.FindStringSubmatch(string(body))
if len(wMatch) > 1 && len(rtMatch) > 1 {
q3 := url.Values{}
q3.Set("session_id", sessionID)
q3.Set("CustomerId", msCustomer)
q3.Set("PageId", "si")
q3.Set("w", wMatch[1])
q3.Set("mdt", fmt.Sprintf("%d", time.Now().UnixMilli()))
q3.Set("rticks", rtMatch[1])
req3, _ := http.NewRequest("GET", "https://ov-df.microsoft.com/?"+q3.Encode(), nil)
req3.Header.Set("User-Agent", msUA)
client.Do(req3)
}
return client, sessionID
}
func msGet(client *http.Client, reqURL, productID string) ([]byte, error) {
req, _ := http.NewRequest("GET", reqURL, nil)
req.Header.Set("User-Agent", msUA)
req.Header.Set("Referer", referer(productID))
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Microsoft returned HTTP %d", resp.StatusCode)
}
if len(body) > 0 && body[0] == '"' {
var unquoted string
json.Unmarshal(body, &unquoted)
body = []byte(unquoted)
}
return body, nil
}
type msErrorEntry struct {
Type float64 `json:"Type"`
Value string `json:"Value"`
}
func firstError(errs []msErrorEntry) string {
if len(errs) == 0 {
return ""
}
if int(errs[0].Type) == 9 {
if errs[0].Value != "" {
return errs[0].Value
}
return "Your IP has been temporarily blocked by Microsoft (Code 715-123130)"
}
if errs[0].Value != "" {
return errs[0].Value
}
return "Microsoft API error"
}
func parseSkuInfo(raw []byte) ([]Language, error) {
var data struct {
Skus []Language `json:"Skus"`
Errors []msErrorEntry `json:"Errors"`
}
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("invalid SKU response: %w", err)
}
if msg := firstError(data.Errors); msg != "" {
return nil, fmt.Errorf("%s", msg)
}
if len(data.Skus) == 0 {
return nil, fmt.Errorf("no languages found for this product")
}
return data.Skus, nil
}
func parseDownloadLinks(raw []byte) ([]DownloadLink, error) {
var data struct {
ProductDownloadOptions []struct {
Uri string `json:"Uri"`
Architecture interface{} `json:"Architecture"`
DownloadType *float64 `json:"DownloadType"`
} `json:"ProductDownloadOptions"`
Errors []msErrorEntry `json:"Errors"`
}
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("invalid download response: %w", err)
}
if msg := firstError(data.Errors); msg != "" {
return nil, fmt.Errorf("%s", msg)
}
if len(data.ProductDownloadOptions) == 0 {
return nil, fmt.Errorf("no download links found for this SKU")
}
var links []DownloadLink
for _, o := range data.ProductDownloadOptions {
arch := ""
if s, ok := o.Architecture.(string); ok && s != "" {
arch = s
} else if o.DownloadType != nil {
arch = mapDownloadType(int(*o.DownloadType))
}
links = append(links, DownloadLink{URI: o.Uri, Architecture: arch})
}
return links, nil
}
func fetchLanguages(client *http.Client, sessionID, productID string) ([]Language, error) {
q := url.Values{}
q.Set("profile", msProfile)
q.Set("productEditionId", productID)
q.Set("SKU", "undefined")
q.Set("friendlyFileName", "undefined")
q.Set("Locale", msLocale)
q.Set("sessionID", sessionID)
raw, err := msGet(client,
"https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?"+q.Encode(),
productID)
if err != nil {
return nil, err
}
return parseSkuInfo(raw)
}
func fetchDownloadLinks(client *http.Client, sessionID, productID, skuID string) ([]DownloadLink, error) {
wq := url.Values{}
wq.Set("profile", msProfile)
wq.Set("productEditionId", productID)
wq.Set("SKU", "undefined")
wq.Set("friendlyFileName", "undefined")
wq.Set("Locale", msLocale)
wq.Set("sessionID", sessionID)
msGet(client,
"https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?"+wq.Encode(),
productID)
q := url.Values{}
q.Set("profile", msProfile)
q.Set("productEditionId", "undefined")
q.Set("SKU", skuID)
q.Set("friendlyFileName", "undefined")
q.Set("Locale", msLocale)
q.Set("sessionID", sessionID)
raw, err := msGet(client,
"https://www.microsoft.com/software-download-connector/api/GetProductDownloadLinksBySku?"+q.Encode(),
productID)
if err != nil {
return nil, err
}
return parseDownloadLinks(raw)
}
func extractFwlinks(pageHTML string) []string {
matches := fwlinkRe.FindAllString(pageHTML, -1)
seen := map[string]bool{}
var out []string
for _, m := range matches {
link := html.UnescapeString(m)
if !seen[link] {
seen[link] = true
out = append(out, link)
}
}
return out
}
func detectArch(rawURL string) string {
l := strings.ToLower(rawURL)
switch {
case strings.Contains(l, "arm64"):
return "ARM64"
case strings.Contains(l, "x64"):
return "x64"
case strings.Contains(l, "x86"):
return "x86"
}
return "ISO"
}
func detectLang(rawURL string) string {
m := isoLangRe.FindStringSubmatch(strings.ToLower(rawURL))
if len(m) > 1 {
return m[1]
}
return ""
}
func fetchEvalLinks(evalURL string) ([]EvalLink, error) {
client := &http.Client{Timeout: 20 * time.Second}
req, _ := http.NewRequest("GET", evalURL, nil)
req.Header.Set("User-Agent", msUA)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching eval page: %w", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fwlinks := extractFwlinks(string(body))
var (
links []EvalLink
mu sync.Mutex
wg sync.WaitGroup
)
for _, fw := range fwlinks {
wg.Add(1)
go func(fw string) {
defer wg.Done()
r, _ := http.NewRequest("GET", fw, nil)
r.Header.Set("User-Agent", msUA)
resp, err := client.Do(r)
if err != nil {
return
}
resp.Body.Close()
final := resp.Request.URL.String()
if !strings.Contains(strings.ToLower(final), ".iso") {
return
}
mu.Lock()
links = append(links, EvalLink{Arch: detectArch(final), Lang: detectLang(final), URL: final})
mu.Unlock()
}(fw)
}
wg.Wait()
if len(links) == 0 {
return nil, fmt.Errorf("no eval ISO links found")
}
return links, nil
}

97
cli/microsoft_test.go Normal file
View file

@ -0,0 +1,97 @@
package main
import "testing"
func TestParseSkuInfo_success(t *testing.T) {
raw := []byte(`{"Skus":[{"Id":"19675","Language":"English (United States)"},{"Id":"19676","Language":"French"}]}`)
langs, err := parseSkuInfo(raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(langs) != 2 {
t.Fatalf("want 2 languages, got %d", len(langs))
}
if langs[0].ID != "19675" || langs[0].Language != "English (United States)" {
t.Errorf("unexpected first lang: %+v", langs[0])
}
}
func TestParseSkuInfo_rateLimitError(t *testing.T) {
raw := []byte(`{"Errors":[{"Type":9,"Value":"715-123130 blocked"}]}`)
_, err := parseSkuInfo(raw)
if err == nil {
t.Fatal("expected error for Errors array with Type 9")
}
}
func TestParseSkuInfo_empty(t *testing.T) {
_, err := parseSkuInfo([]byte(`{"Skus":[]}`))
if err == nil {
t.Fatal("expected error for empty Skus")
}
}
func TestParseDownloadLinks_architectureString(t *testing.T) {
raw := []byte(`{"ProductDownloadOptions":[{"Uri":"https://example.com/win11.iso?t=abc","Architecture":"x64"}]}`)
links, err := parseDownloadLinks(raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(links) != 1 {
t.Fatalf("want 1 link, got %d", len(links))
}
if links[0].URI != "https://example.com/win11.iso?t=abc" || links[0].Architecture != "x64" {
t.Errorf("unexpected link: %+v", links[0])
}
}
func TestParseDownloadLinks_downloadTypeFallback(t *testing.T) {
raw := []byte(`{"ProductDownloadOptions":[{"Uri":"https://example.com/a.iso","DownloadType":1}]}`)
links, err := parseDownloadLinks(raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if links[0].Architecture != "x64" {
t.Errorf("want x64 from DownloadType 1, got %q", links[0].Architecture)
}
}
func TestParseDownloadLinks_error(t *testing.T) {
raw := []byte(`{"Errors":[{"Type":4,"Value":"no download links found for this SKU"}]}`)
_, err := parseDownloadLinks(raw)
if err == nil {
t.Fatal("expected error for Errors array")
}
}
func TestParseDownloadLinks_empty(t *testing.T) {
_, err := parseDownloadLinks([]byte(`{"ProductDownloadOptions":[]}`))
if err == nil {
t.Fatal("expected error for empty options")
}
}
func TestExtractFwlinks_dedupAndUnescape(t *testing.T) {
html := `
<a href="https://go.microsoft.com/fwlink/?linkid=111&amp;clcid=0x409">x64</a>
<a href="https://go.microsoft.com/fwlink/?linkid=111&amp;clcid=0x409">dup</a>
<a href="https://go.microsoft.com/fwlink/?linkid=222">arm</a>
`
links := extractFwlinks(html)
if len(links) != 2 {
t.Fatalf("want 2 unique fwlinks, got %d: %v", len(links), links)
}
if links[0] != "https://go.microsoft.com/fwlink/?linkid=111&clcid=0x409" {
t.Errorf("expected unescaped &, got %q", links[0])
}
}
func TestDetectArchLang(t *testing.T) {
url := "https://software-static.download.prss.microsoft.com/.../server2025_arm64_en-us.iso"
if got := detectArch(url); got != "ARM64" {
t.Errorf("arch: want ARM64, got %s", got)
}
if got := detectLang(url); got != "en-us" {
t.Errorf("lang: want en-us, got %s", got)
}
}

78
cli/picker.go Normal file
View file

@ -0,0 +1,78 @@
package main
import (
"bufio"
"fmt"
"io"
"os"
"strconv"
"strings"
)
// parseChoice reads a 1-based integer from r, validated in [1, max].
func parseChoice(r io.Reader, max int) (int, error) {
scanner := bufio.NewScanner(r)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return 0, fmt.Errorf("reading input: %w", err)
}
return 0, fmt.Errorf("no input")
}
text := strings.TrimSpace(scanner.Text())
if text == "" {
return 0, fmt.Errorf("enter a number between 1 and %d", max)
}
n, err := strconv.Atoi(text)
if err != nil || n < 1 || n > max {
return 0, fmt.Errorf("enter a number between 1 and %d", max)
}
return n, nil
}
func pickProduct(products []Product) (Product, error) {
if len(products) == 0 {
return Product{}, fmt.Errorf("no products available")
}
fmt.Fprintln(os.Stderr, "\nSelect a product:")
for i, p := range products {
fmt.Fprintf(os.Stderr, " %2d. %s\n", i+1, p.Name)
}
fmt.Fprint(os.Stderr, "\nChoice: ")
n, err := parseChoice(os.Stdin, len(products))
if err != nil {
return Product{}, err
}
return products[n-1], nil
}
func pickLanguage(langs []Language) (Language, error) {
if len(langs) == 0 {
return Language{}, fmt.Errorf("no languages available")
}
fmt.Fprintln(os.Stderr, "\nSelect a language:")
for i, l := range langs {
fmt.Fprintf(os.Stderr, " %2d. %s\n", i+1, l.Language)
}
fmt.Fprint(os.Stderr, "\nChoice: ")
n, err := parseChoice(os.Stdin, len(langs))
if err != nil {
return Language{}, err
}
return langs[n-1], nil
}
func pickEvalProduct(products []EvalProduct) (EvalProduct, error) {
if len(products) == 0 {
return EvalProduct{}, fmt.Errorf("no eval products available")
}
fmt.Fprintln(os.Stderr, "\nSelect an evaluation product:")
for i, p := range products {
fmt.Fprintf(os.Stderr, " %2d. %s\n", i+1, p.Name)
}
fmt.Fprint(os.Stderr, "\nChoice: ")
n, err := parseChoice(os.Stdin, len(products))
if err != nil {
return EvalProduct{}, err
}
return products[n-1], nil
}

52
cli/picker_test.go Normal file
View file

@ -0,0 +1,52 @@
package main
import (
"strings"
"testing"
)
func TestParseChoice_valid(t *testing.T) {
cases := []struct {
input string
max int
want int
}{
{"1\n", 3, 1},
{"3\n", 3, 3},
{" 2 \n", 5, 2},
}
for _, c := range cases {
got, err := parseChoice(strings.NewReader(c.input), c.max)
if err != nil {
t.Errorf("input %q: unexpected error: %v", c.input, err)
continue
}
if got != c.want {
t.Errorf("input %q: got %d, want %d", c.input, got, c.want)
}
}
}
func TestParseChoice_outOfRange(t *testing.T) {
cases := []string{"0\n", "4\n"}
for _, input := range cases {
_, err := parseChoice(strings.NewReader(input), 3)
if err == nil {
t.Errorf("input %q: expected error for out-of-range, got nil", input)
}
}
}
func TestParseChoice_nonNumeric(t *testing.T) {
_, err := parseChoice(strings.NewReader("abc\n"), 3)
if err == nil {
t.Error("expected error for non-numeric input, got nil")
}
}
func TestParseChoice_emptyInput(t *testing.T) {
_, err := parseChoice(strings.NewReader("\n"), 3)
if err == nil {
t.Error("expected error for empty input, got nil")
}
}

View file

@ -0,0 +1,14 @@
PackageIdentifier: starkSV.msdl
PackageVersion: 0.1.0
Platform:
- Windows.Desktop
MinimumOSVersion: 10.0.0.0
InstallerType: portable
Commands:
- msdl
Installers:
- Architecture: x64
InstallerUrl: https://github.com/starkSV/windows-iso-downloader/releases/download/cli%2Fv0.1.0/msdl-windows-amd64.exe
InstallerSha256: 7FE06D224364D400C7A896048B6B3E8DEA7AA19DCA304C6D986A2EDF92DA72B9
ManifestType: installer
ManifestVersion: 1.6.0

View file

@ -0,0 +1,22 @@
PackageIdentifier: starkSV.msdl
PackageVersion: 0.1.0
PackageLocale: en-US
Publisher: starkSV
PublisherUrl: https://github.com/starkSV
PublisherSupportUrl: https://github.com/starkSV/windows-iso-downloader/issues
PackageName: msdl
PackageUrl: https://msdl.tech-latest.com
License: MIT
LicenseUrl: https://github.com/starkSV/windows-iso-downloader/blob/main/LICENSE
ShortDescription: Download Windows ISOs directly from Microsoft
Description: |-
msdl is a command-line tool for downloading Windows ISO files directly from Microsoft's servers.
Supports interactive product and language selection, or fully automated with named arguments.
Covers consumer Windows (10, 11, 8.1) and evaluation builds (Server 2025/2022/2019/2016, Windows 11 Enterprise).
Tags:
- windows
- iso
- download
- microsoft
ManifestType: defaultLocale
ManifestVersion: 1.6.0

View file

@ -0,0 +1,5 @@
PackageIdentifier: starkSV.msdl
PackageVersion: 0.1.0
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.6.0