From 415a9ce815b41c421575ddf0967a1cf5e6244c37 Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 10 Apr 2026 15:21:09 -0400 Subject: [PATCH 1/3] bin/devkit: prefer gh release download to dodge Windows curl+schannel bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows curl.exe uses the schannel TLS backend, which aborts with CURLE_WRITE_ERROR (exit 23) partway through responses from release-assets.githubusercontent.com — the CDN that github.com/.../releases/download/... 302-redirects to. Git Bash users on Windows could not install the devkit engine on first run. Replace download_fresh/download_resume with a unified download_release_asset helper that tries, in order: 1. gh release download — Go crypto/tls, no schannel. 2. curl -fsSL [-C -] — unchanged Unix path. 3. wget [-c] — Linux fallback. 4. powershell.exe Invoke-WebRequest — Windows last resort, .NET TLS stack also sidesteps the schannel bug. gh and PowerShell paths are non-resumable, but the outer checksum verification loop catches corruption and re-runs, so a bad retry costs at most one fresh fetch of the ~10MB engine asset. Also pin bin/devkit to LF via .gitattributes so Git for Windows' core.autocrlf does not rewrite it with CRLF on checkout. Fixes #58. --- .gitattributes | 6 ++++ bin/devkit | 96 ++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 75 insertions(+), 27 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9c1edb3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Force LF on the POSIX shell wrapper. Git for Windows' default +# core.autocrlf=true would otherwise rewrite this file with CRLF on +# checkout, and while Git Bash tolerates CRLF today, a strict POSIX +# /bin/sh or a future autocrlf change would break the shebang line +# or embedded heredocs. See issue #58. +bin/devkit text eol=lf diff --git a/bin/devkit b/bin/devkit index 27d7147..227d7cc 100755 --- a/bin/devkit +++ b/bin/devkit @@ -106,35 +106,77 @@ run_downloader() { return "$rc" } -# download_resume: resumable HTTP download to $out. Uses `curl -C -` -# (or `wget -c`) to continue from the file's current size. GitHub -# release assets honour Range requests; if that ever stops being true -# the resume will surface as a download error or a checksum mismatch -# on the next verification step, both of which purge the partial. -download_resume() { - url=$1 - out=$2 - if command -v curl >/dev/null 2>&1; then - run_downloader curl -fsSL --retry 2 --retry-delay 2 -C - -o "$out" "$url" - elif command -v wget >/dev/null 2>&1; then - run_downloader wget -q -c -O "$out" "$url" - else - die "need curl or wget to download engine binary" +# download_release_asset: fetch a GitHub release asset to $out, trying +# downloaders in order of TLS-stack reliability. The preference order +# exists to work around issue #58: on Windows, `curl.exe` is built with +# the schannel TLS backend and aborts with CURLE_WRITE_ERROR (exit 23) +# partway through responses from `release-assets.githubusercontent.com` +# (the CDN that `github.com/.../releases/download/...` 302-redirects +# to), due to a schannel renegotiation interaction on that host. +# +# Args: tag asset out resumable +# tag — release tag (e.g. v2.1.5) +# asset — asset filename (e.g. devkit-windows-amd64.exe) +# out — destination path +# resumable — "1" if the downloader should resume from a partial file +# (large engine binary); "" for a fresh GET (tiny files +# like checksums.txt, where resume offers no benefit). +# +# Order: +# 1. `gh release download` — Go crypto/tls, no schannel involvement. +# 2. `curl` — works everywhere except the Windows+schannel+CDN combo. +# 3. `wget` — rarely present on Windows but common on Linux. +# 4. `powershell.exe Invoke-WebRequest` — Windows last resort, uses +# .NET's TLS stack, also sidesteps the curl+schannel bug. +# +# gh and powershell do not support HTTP range resume, so on those paths +# any existing partial is overwritten. The outer checksum-verify loop +# catches corruption and the network cost of re-fetching the ~10MB +# engine binary on the rare retry is acceptable. +download_release_asset() { + tag=$1 + asset=$2 + out=$3 + resumable=$4 + url="https://github.com/${RELEASE_OWNER}/${RELEASE_REPO}/releases/download/${tag}/${asset}" + + if command -v gh >/dev/null 2>&1; then + if run_downloader gh release download "$tag" \ + -R "${RELEASE_OWNER}/${RELEASE_REPO}" \ + -p "$asset" -O "$out" --clobber; then + return 0 + fi + log "gh release download failed; falling back to curl/wget" fi -} -# download_fresh: non-resumable download for tiny files (checksums) -# where resume offers no benefit and a stale file would be a hazard. -download_fresh() { - url=$1 - out=$2 if command -v curl >/dev/null 2>&1; then - run_downloader curl -fsSL --retry 2 --retry-delay 2 -o "$out" "$url" - elif command -v wget >/dev/null 2>&1; then - run_downloader wget -q -O "$out" "$url" - else - die "need curl or wget to download engine binary" + if [ -n "$resumable" ]; then + run_downloader curl -fsSL --retry 2 --retry-delay 2 -C - -o "$out" "$url" && return 0 + else + run_downloader curl -fsSL --retry 2 --retry-delay 2 -o "$out" "$url" && return 0 + fi fi + + if command -v wget >/dev/null 2>&1; then + if [ -n "$resumable" ]; then + run_downloader wget -q -c -O "$out" "$url" && return 0 + else + run_downloader wget -q -O "$out" "$url" && return 0 + fi + fi + + case "${PLATFORM-}" in + windows-*) + if command -v powershell.exe >/dev/null 2>&1; then + log "trying PowerShell Invoke-WebRequest fallback" + run_downloader powershell.exe -NoProfile -Command \ + "Invoke-WebRequest -UseBasicParsing -Uri '$url' -OutFile '$out'" \ + && return 0 + fi + ;; + esac + + return 1 } # ensure_engine sets ENGINE_PATH as a side effect rather than echoing it, @@ -174,7 +216,7 @@ ensure_engine() { # Fetch the checksums file first so we have a source of truth # before touching the binary. Tiny file, no resume benefit. - download_fresh "${base_url}/checksums.txt" "$sums_file" \ + download_release_asset "$tag" "checksums.txt" "$sums_file" "" \ || die "checksum file download failed from ${base_url}/checksums.txt" expected=$(awk -v name="$asset" '$2 == name || $2 == "*"name { print $1; exit }' "$sums_file") @@ -195,7 +237,7 @@ ensure_engine() { else log "first-run: downloading engine ${tag} (${PLATFORM})…" fi - download_resume "${base_url}/${asset}" "$partial" \ + download_release_asset "$tag" "$asset" "$partial" "1" \ || die "download failed: ${base_url}/${asset}" actual=$(sha256_of "$partial") From ccf8a2f473940262ce76cfd6a2e6e2e10413600d Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 10 Apr 2026 15:39:10 -0400 Subject: [PATCH 2/3] bin/devkit: fix PowerShell fallback path on Windows (review) Two issues caught in PR #59 review: 1. The PowerShell fallback embedded the MSYS output path (/c/Users/...) inside a quoted -Command string. MSYS2/Git Bash only auto-converts POSIX paths to Windows paths when they appear as standalone arguments to a native .exe; paths inside a quoted string are just characters, so PowerShell saw `/c/Users/...` and resolved it against the current drive root as `C:\c\Users\...`, failing with DirectoryNotFoundException. In practice this meant the only working path on Windows was `gh release download` -- users without gh hit the same broken state as before this fix. Convert the output path with `cygpath -w` before embedding it in the -Command string. cygpath ships with Git for Windows and MSYS2; the \`winout=\$out\` initializer is a harmless defensive fallback for exotic shells that lack it. 2. Drop any partial $out after a failed `gh release download` before falling through on the resumable path. Without this, curl -C - would resume from whatever byte offset gh had written and splice two downloaders' byte streams into one file; the outer checksum loop catches it on the next invocation, but the extra round-trip is free to avoid. --- bin/devkit | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/bin/devkit b/bin/devkit index 227d7cc..44419b4 100755 --- a/bin/devkit +++ b/bin/devkit @@ -147,6 +147,13 @@ download_release_asset() { return 0 fi log "gh release download failed; falling back to curl/wget" + # A failed gh run may have written partial bytes to $out. On the + # resumable path the next downloader is `curl -C -`, which would + # resume from gh's offset and splice the two byte streams into + # one file; the outer checksum-verify loop catches the corruption + # on the next invocation but wastes a round-trip. Dropping the + # partial here forces curl to start fresh on this attempt. + rm -f "$out" 2>/dev/null || true fi if command -v curl >/dev/null 2>&1; then @@ -168,9 +175,21 @@ download_release_asset() { case "${PLATFORM-}" in windows-*) if command -v powershell.exe >/dev/null 2>&1; then + # MSYS2/Git Bash only auto-converts POSIX paths to + # Windows paths when they are standalone arguments to a + # native .exe. Paths embedded inside a quoted -Command + # string are just characters to MSYS — no conversion — + # so PowerShell would see `/c/Users/…` and resolve it + # against the current drive root as `C:\c\Users\…`, + # failing with DirectoryNotFoundException. Convert the + # output path explicitly via cygpath before embedding. + winout=$out + if command -v cygpath >/dev/null 2>&1; then + winout=$(cygpath -w "$out") || winout=$out + fi log "trying PowerShell Invoke-WebRequest fallback" run_downloader powershell.exe -NoProfile -Command \ - "Invoke-WebRequest -UseBasicParsing -Uri '$url' -OutFile '$out'" \ + "Invoke-WebRequest -UseBasicParsing -Uri '$url' -OutFile '$winout'" \ && return 0 fi ;; From 91bfb398de2a212531f551e98123dccd08665ef3 Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 10 Apr 2026 15:57:34 -0400 Subject: [PATCH 3/3] bin/devkit: fix gh POSIX-path no-op and global-variable clobber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end test on Windows 11 / Git Bash (MSYS2) against ccf8a2f4 exposed two more bugs in the download_release_asset refactor, both in the same function and both blocking first-run install even after the previous cygpath fix to the PowerShell path. Bug A: gh release download -O silently no-ops. gh.exe is a native Windows binary; MSYS2 only auto-converts POSIX paths to Windows paths for top-level args that "look" like paths, not for values passed to a flag. So when we invoked gh release download ... -O "//bin/checksums-vX.txt" gh saw the leading `/` as the current drive root, silently failed to write anywhere, and exited 0. The next awk lookup then died on an empty file. Fix: run $out through `cygpath -w` once up front and pass the converted form to both gh.exe and powershell.exe. The existing PowerShell path already did this; hoisting the conversion above the gh block means both native-Windows consumers get a real Windows path. Harmless on macOS/Linux where cygpath is absent (the shell falls back to the untouched POSIX path). Bug B: download_release_asset clobbered caller globals. POSIX /bin/sh has no `local`, so the bare `tag=$1 / asset=$2 / out=$3 / resumable=$4` at the top of the function assigned to the global scope and silently rewrote ensure_engine's own $asset after the first call — turning "devkit-windows-amd64.exe" into "checksums.txt" and breaking the awk lookup in the checksum file with `no checksum entry for checksums.txt`. Fix: rename all four params (and the derived $url / $winout) with a `_` prefix so the function's locals no longer collide with caller state. Namespacing discipline rather than a subshell wrapper — keeps `return` semantics simple and avoids a fork on every call. With both fixes applied, bin/devkit mcp `) or + # embedded inside a quoted `-Command` string are just characters to + # MSYS — no conversion — so both tools would see `/c/Users/…` and + # resolve it against the current drive root as `C:\c\Users\…`. + # gh silently no-ops in that case (exit 0, no file); PowerShell + # fails with DirectoryNotFoundException. Resolve via cygpath so + # both get a real Windows path. Harmless on macOS / Linux where + # cygpath does not exist. + _winout=$_out + if command -v cygpath >/dev/null 2>&1; then + _winout=$(cygpath -w "$_out") || _winout=$_out + fi if command -v gh >/dev/null 2>&1; then - if run_downloader gh release download "$tag" \ + if run_downloader gh release download "$_tag" \ -R "${RELEASE_OWNER}/${RELEASE_REPO}" \ - -p "$asset" -O "$out" --clobber; then + -p "$_asset" -O "$_winout" --clobber; then return 0 fi log "gh release download failed; falling back to curl/wget" - # A failed gh run may have written partial bytes to $out. On the - # resumable path the next downloader is `curl -C -`, which would - # resume from gh's offset and splice the two byte streams into - # one file; the outer checksum-verify loop catches the corruption - # on the next invocation but wastes a round-trip. Dropping the - # partial here forces curl to start fresh on this attempt. - rm -f "$out" 2>/dev/null || true + # A failed gh run may have written partial bytes to $_out. On + # the resumable path the next downloader is `curl -C -`, which + # would resume from gh's offset and splice the two byte streams + # into one file; the outer checksum-verify loop catches the + # corruption on the next invocation but wastes a round-trip. + # Dropping the partial here forces curl to start fresh on this + # attempt. + rm -f "$_out" 2>/dev/null || true fi if command -v curl >/dev/null 2>&1; then - if [ -n "$resumable" ]; then - run_downloader curl -fsSL --retry 2 --retry-delay 2 -C - -o "$out" "$url" && return 0 + if [ -n "$_resumable" ]; then + run_downloader curl -fsSL --retry 2 --retry-delay 2 -C - -o "$_out" "$_url" && return 0 else - run_downloader curl -fsSL --retry 2 --retry-delay 2 -o "$out" "$url" && return 0 + run_downloader curl -fsSL --retry 2 --retry-delay 2 -o "$_out" "$_url" && return 0 fi fi if command -v wget >/dev/null 2>&1; then - if [ -n "$resumable" ]; then - run_downloader wget -q -c -O "$out" "$url" && return 0 + if [ -n "$_resumable" ]; then + run_downloader wget -q -c -O "$_out" "$_url" && return 0 else - run_downloader wget -q -O "$out" "$url" && return 0 + run_downloader wget -q -O "$_out" "$_url" && return 0 fi fi case "${PLATFORM-}" in windows-*) if command -v powershell.exe >/dev/null 2>&1; then - # MSYS2/Git Bash only auto-converts POSIX paths to - # Windows paths when they are standalone arguments to a - # native .exe. Paths embedded inside a quoted -Command - # string are just characters to MSYS — no conversion — - # so PowerShell would see `/c/Users/…` and resolve it - # against the current drive root as `C:\c\Users\…`, - # failing with DirectoryNotFoundException. Convert the - # output path explicitly via cygpath before embedding. - winout=$out - if command -v cygpath >/dev/null 2>&1; then - winout=$(cygpath -w "$out") || winout=$out - fi log "trying PowerShell Invoke-WebRequest fallback" run_downloader powershell.exe -NoProfile -Command \ - "Invoke-WebRequest -UseBasicParsing -Uri '$url' -OutFile '$winout'" \ + "Invoke-WebRequest -UseBasicParsing -Uri '$_url' -OutFile '$_winout'" \ && return 0 fi ;;