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>
Consumer Windows products list their English option as "English" (or
"English International"), not "English (United States)" -- the exact
example this used, which is why it was such a common source of --lang
mismatch errors. Applies to --help, the --lang flag description, and
the release workflow's usage example.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs combined to make v0.3.4's changelog show the previous
release's version-bump commit instead of the actual change:
1. The path filter (cli/ backend/ frontend/) excluded workflow-only
changes, so a CI-only addition (like linux-arm64) never appeared.
2. The "bump version + winget manifest" commit is created *after* its
own tag (it needs the release's SHA256, which doesn't exist until
after tagging) -- so it always lands in the *next* release's diff
range instead of its own, showing up as stale noise.
Adds .github/workflows/cli-release.yml to the path filter, filters
out "chore(release): bump" commits as noise, and falls back to a
generic line if a release genuinely has nothing else to show.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves the README's inline contributing notes into a dedicated file
and expands them with dev setup, PR conventions, and how CLI releases
work, so contributors have one place to look.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Telemetry showed a growing number of Termux (Android) users running
the CLI, but the release workflow only builds linux-amd64 -- those
users had to build from source since Termux runs on ARM64. Adds a
linux-arm64 binary and install line so they can just curl it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Real-world telemetry showed --lang "English (United States)" failing
for several consumer products that only list "English International"
-- the exact example used in --help. The mismatch error now lists the
languages actually available, and the help text notes names vary by
product.
Also fixes truncateError to keep both head and tail of long error
strings (previously kept only the head, which for wrapped errors with
a long URL cut off the actual root cause at the end).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Sentinel lockdown gate served any linkCache hit as "stale"
regardless of whether the entry was still within its own TTL. A link
just contributed via CLI (still valid for ~24h) would get labeled
stale, causing the frontend to hide it and show the CLI-only banner
again instead of the working link it just received.
Now checks the entry's actual expiry: still-fresh entries are labeled
"cached" (frontend shows the link normally); only genuinely expired
entries are labeled "stale".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Failure telemetry only recorded success=false with no context. Adds
a truncated error string (150 chars, CLI-side) forwarded to a new
msdl:telemetry:errors Redis hash so failure causes are diagnosable
from /metrics instead of guessed at.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Telemetry was fired as a goroutine then the process exited immediately,
killing it before the HTTP request could complete. Now called
synchronously — sendTelemetry already has a 2s internal timeout so
worst-case latency is unchanged for the user.
Also moves the combined picker into run() so eval products selected
interactively are tagged action=eval with the correct eval_slug instead
of action=interactive with no slug.
Adds What's New changelog section to release notes, auto-generated from
commits since the previous tag.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
- Interactive `msdl` (no flags) now lists consumer + eval products in one
picker with an Evaluation / Enterprise separator; selecting a server
product routes to the eval flow automatically
- copyToClipboard calls clip.exe by full SystemRoot path, bypassing
cmd.exe PATH lookup that failed in some terminal environments
- CliHandoff card: winget primary install + msdl (no .exe) in run command
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cmd.exe treats & as a command separator, truncating Microsoft's signed
download URLs at the first query parameter boundary. Writing the URL to
a Windows Internet Shortcut (.url) file means cmd.exe only ever sees a
temp filename with no special characters; the browser reads the full URL
from the file contents.
Uses %COMSPEC% to locate cmd.exe reliably across environments.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Microsoft's 403 capacity page was logging hundreds of lines of HTML
per error. Both non-200 error paths now cap the body at 120 chars.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the backend returns 429+lockdown with no cached stale data to serve,
the error section now shows the human-readable lockdown explanation instead
of the raw "Sentinel marked this request as rejected." string.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend (main.go):
- negCacheEntry gains IsSentinel bool; lockdownTTL = 90min constant
- Lockdown gate at top of handleProxy: serves stale with X-MSDL-Lockdown:1
header or 429 {"error":"...","lockdown":true} — zero MS API calls made
- Sentinel stale path: sets 90min lockdown neg cache, adds lockdown header,
suppresses background refresh (retrying extends the Sentinel block timer)
- Non-Sentinel stale path: unchanged (60s neg cache, bg refresh fires)
- Expose X-MSDL-Lockdown in Access-Control-Expose-Headers
Frontend (ProductDetailPage.tsx):
- isLockdown state: set from X-MSDL-Lockdown header or data.lockdown field
- Stale state hides expired download links entirely; shows CLI recommendation
inside the card instead of sending users to look for options elsewhere
- Lockdown: red banner explaining 90min cooldown + highlighted CLI card,
no Refresh button (it cannot help while Sentinel block is active)
- Normal stale: amber banner + highlighted CLI card + secondary Refresh button
- Outer CliHandoff suppressed when already shown inside card (stale states)
Frontend (CliHandoff.tsx):
- CLI command uses msdl.exe (primary audience is Windows users)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When multiple download links are returned (e.g. x32+x64 for consumer
ISOs), an architecture picker is shown before printing the URL.
After the URL is printed, an interactive menu lets the user open the
link in the default browser (O), copy it to the clipboard (C), or exit
(Enter). Menu is suppressed when stdout is not a terminal (piping).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Microsoft's WAF returns HTTP 200 with {"Errors":[{"Value":"Sentinel marked
this request as rejected."}]}. The existing Sentinel check only ran for
non-200 responses, so the session was never evicted — every subsequent
request reused the poisoned session and hit Sentinel again.
Add the same eviction + rateLimitError logic inside the errMap["Value"]
branch so the session is evicted regardless of the WAF's HTTP status code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the dim "coming soon" footnote with a featured green card at the
top of the Install section. Manual binary downloads moved to a secondary
"Other platforms" section below. Ships with the post-winget-approval release.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add platform allowlist validation (windows, darwin, linux) and length caps for version (≤20) and product_id (≤10) in handleTelemetry to prevent unbounded Redis hash growth from attacker-controlled field values.
- Add HTTP method guard to handleCLIVersion to reject non-GET requests with 405 Method Not Allowed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- seedMetricsFromRedis: loads persisted counters into atomics on startup
- flushMetricsToRedis: writes all 12 counters to 3 Redis hashes on demand
- startMetricsFlusher: goroutine that flushes every 5 minutes
- Graceful shutdown: SIGINT/SIGTERM flushes metrics then drains HTTP in 10s
- http.ListenAndServe replaced with http.Server for Shutdown support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When Microsoft Sentinel rejects a request (HTTP 502 with 'Sentinel'
in body), the session was being reused on every retry — hammering the
same poisoned session and making the block worse.
Now: detect Sentinel response, evict the session from cache, and
return a rateLimitError so stale-on-failure kicks in and users get
the last cached link instead of an error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Consumer ISO CDN URLs use P1 (Unix timestamp) instead of se (RFC3339).
extractRawExpiry now tries se first then falls back to P1, so CLI
contributions of consumer ISOs are no longer rejected.
Also refactors parseLinkExpiry to call extractRawExpiry (DRY) and
fixes stale log message referencing 'se param'.