fix(cli): replace shell-based update with native Rust implementation#7148
fix(cli): replace shell-based update with native Rust implementation#7148jamadeo merged 2 commits intoblock:mainfrom
Conversation
Replace the curl+bash update mechanism with a pure Rust implementation that works on Windows, macOS, and Linux without requiring external tools. The previous implementation downloaded and executed download_cli.sh via curl and bash, which fails on native Windows where neither is available. This has been broken since v1.9.0 (~4 months). The new implementation: - Uses reqwest to download the release archive directly - Extracts .zip on Windows, .tar.bz2 on macOS/Linux - Handles the goose-package/ subdirectory in archives - Uses rename-away strategy on Windows (running exe can be renamed) - Copies companion DLLs on Windows GNU builds - Includes 10 unit tests covering all platform-specific logic Also fixes editor.rs tests that failed to compile on Windows due to unconditional use of std::os::unix. Closes block#7146 Signed-off-by: JF <john.franklin@gmail.com>
15e6714 to
01b2ca9
Compare
|
fixes: #7146 |
There was a problem hiding this comment.
Pull request overview
Replaces the goose update command’s shell-based installer (curl/bash) with a native Rust implementation to support Windows/macOS/Linux updates reliably.
Changes:
- Rewrote
goose updateto download release assets viareqwest, extract archives, and replace the running binary (with Windows-specific rename-away behavior + DLL copying). - Updated CLI dispatch to
awaitthe now-async updater. - Adjusted two editor tests to compile only on Unix.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/goose-cli/src/commands/update.rs | Native Rust updater: download, extract (zip/tar.bz2), locate binary, replace in-place, Windows DLL handling, and new unit tests. |
| crates/goose-cli/src/cli.rs | Awaits the async update() command. |
| crates/goose-cli/Cargo.toml | Adds dependencies needed for native download/extraction (reqwest/zip/bzip2). |
| crates/goose-cli/src/session/editor.rs | Gates Unix-only symlink tests behind #[cfg(unix)]. |
| Cargo.lock | Lockfile updates for newly added dependencies. |
| #[cfg(all(target_os = "windows", target_arch = "x86_64"))] | ||
| { | ||
| "goose-x86_64-pc-windows-gnu.zip" | ||
| } |
There was a problem hiding this comment.
asset_name() has no fallback for unsupported target triples, which can produce a confusing compile error on new/unsupported platforms; consider adding an explicit #[cfg(not(any(...)))] compile_error!(...) (or returning a clear error) so the failure mode is intentional and readable.
| } | |
| } | |
| #[cfg(not(any( | |
| all(target_os = "macos", target_arch = "aarch64"), | |
| all(target_os = "macos", target_arch = "x86_64"), | |
| all(target_os = "linux", target_arch = "x86_64"), | |
| all(target_os = "linux", target_arch = "aarch64"), | |
| all(target_os = "windows", target_arch = "x86_64"), | |
| )))] | |
| { | |
| compile_error!( | |
| "goose update is not supported on this target; no prebuilt binary asset is available" | |
| ); | |
| } |
| // --- Download ----------------------------------------------------------- | ||
| let response = reqwest::get(&url) | ||
| .await | ||
| .context("Failed to download release archive")?; |
There was a problem hiding this comment.
The update download uses reqwest::get without any explicit timeout, so goose update can hang indefinitely on stalled networks; consider using a reqwest::Client with a reasonable request/overall timeout (and reuse it for the body download).
| let decoder = BzDecoder::new(data); | ||
| let mut archive = tar::Archive::new(decoder); | ||
| archive | ||
| .unpack(dest) | ||
| .context("Failed to extract tar.bz2 archive")?; |
There was a problem hiding this comment.
tar::Archive::unpack can write files with paths from the archive as-is; to avoid potential path traversal (writing outside dest) when extracting remote archives, consider iterating entries and using Entry::unpack_in(dest) (and rejecting absolute/parent components).
| if package_dir.is_dir() { | ||
| let p = package_dir.join(binary_name); | ||
| if p.exists() { | ||
| return Some(p); | ||
| } |
There was a problem hiding this comment.
find_binary() uses Path::exists() which can match directories; using is_file() (and possibly metadata checks) avoids returning a non-file path that will later fail to copy/execute.
| if let Ok(entries) = fs::read_dir(source_dir) { | ||
| for entry in entries.flatten() { | ||
| let path = entry.path(); | ||
| if let Some(ext) = path.extension() { | ||
| if ext.eq_ignore_ascii_case("dll") { | ||
| let file_name = path.file_name().unwrap(); | ||
| let dest = dest_dir.join(file_name); | ||
| // Remove existing DLL first (it may be locked by another process) | ||
| if dest.exists() { | ||
| let _ = fs::remove_file(&dest); | ||
| } | ||
| fs::copy(&path, &dest).with_context(|| { | ||
| format!("Failed to copy {} to {}", path.display(), dest.display()) | ||
| })?; | ||
| println!(" Copied {}", file_name.to_string_lossy()); | ||
| } |
There was a problem hiding this comment.
copy_dlls() silently ignores read_dir failures via if let Ok(entries) = ..., which can report a successful update while skipping required DLL copies; consider propagating the error with context so update fails loudly when DLL enumeration isn’t possible.
| if let Ok(entries) = fs::read_dir(source_dir) { | |
| for entry in entries.flatten() { | |
| let path = entry.path(); | |
| if let Some(ext) = path.extension() { | |
| if ext.eq_ignore_ascii_case("dll") { | |
| let file_name = path.file_name().unwrap(); | |
| let dest = dest_dir.join(file_name); | |
| // Remove existing DLL first (it may be locked by another process) | |
| if dest.exists() { | |
| let _ = fs::remove_file(&dest); | |
| } | |
| fs::copy(&path, &dest).with_context(|| { | |
| format!("Failed to copy {} to {}", path.display(), dest.display()) | |
| })?; | |
| println!(" Copied {}", file_name.to_string_lossy()); | |
| } | |
| let entries = fs::read_dir(source_dir) | |
| .with_context(|| format!("Failed to read directory {}", source_dir.display()))?; | |
| for entry in entries.flatten() { | |
| let path = entry.path(); | |
| if let Some(ext) = path.extension() { | |
| if ext.eq_ignore_ascii_case("dll") { | |
| let file_name = path.file_name().unwrap(); | |
| let dest = dest_dir.join(file_name); | |
| // Remove existing DLL first (it may be locked by another process) | |
| if dest.exists() { | |
| let _ = fs::remove_file(&dest); | |
| } | |
| fs::copy(&path, &dest).with_context(|| { | |
| format!("Failed to copy {} to {}", path.display(), dest.display()) | |
| })?; | |
| println!(" Copied {}", file_name.to_string_lossy()); |
| base64 = { workspace = true } | ||
| regex = "1.11.1" | ||
| tar = "0.4" | ||
| reqwest = { workspace = true, features = ["blocking", "rustls-tls-native-roots"] } |
There was a problem hiding this comment.
reqwest is enabled with the blocking feature but the new updater uses the async API (reqwest::get(...).await); dropping the blocking feature would reduce dependency surface/compile time unless it’s needed elsewhere in goose-cli.
| reqwest = { workspace = true, features = ["blocking", "rustls-tls-native-roots"] } | |
| reqwest = { workspace = true, features = ["rustls-tls-native-roots"] } |
jamadeo
left a comment
There was a problem hiding this comment.
Looks great, thanks for this. Tested this out on mac + windows
* main: (54 commits) docs: add monitoring subagent activity section (#7323) docs: document Desktop UI recipe editing for model/provider and extensions (#7327) docs: add CLAUDE_THINKING_BUDGET and CLAUDE_THINKING_ENABLED environm… (#7330) fix: display 'Code Mode' instead of 'code_execution' in CLI (#7321) docs: add Permission Policy documentation for MCP Apps (#7325) update RPI plan prompt (#7326) docs: add CLI syntax highlighting theme customization (#7324) fix(cli): replace shell-based update with native Rust implementation (#7148) docs: rename Code Execution extension to Code Mode extension (#7316) docs: remove ALPHA_FEATURES flag from documentation (#7315) docs: escape variable syntax in recipes (#7314) docs: update OTel environment variable and config guides (#7221) docs: system proxy settings (#7311) docs: add Summon extension tutorial and update Skills references (#7310) docs: agent session id (#7289) fix(gemini-cli): restore streaming lost in #7247 (#7291) Update more instructions (#7305) feat: add Moonshot and Kimi Code declarative providers (#7304) fix(cli): handle Reasoning content and fix streaming thinking display (#7296) feat: add GOOSE_SUBAGENT_MODEL and GOOSE_SUBAGENT_PROVIDER config options (#7277) ...
* 'main' of github.com:block/goose: (40 commits) Remove trailing space from links (#7156) fix: detect low balance and prompt for top up (#7166) feat(apps): add support for MCP apps to sample (#7039) Typescript SDK for ACP extension methods (#7319) chore: upgrade to rmcp 0.16.0 (#7274) docs: add monitoring subagent activity section (#7323) docs: document Desktop UI recipe editing for model/provider and extensions (#7327) docs: add CLAUDE_THINKING_BUDGET and CLAUDE_THINKING_ENABLED environm… (#7330) fix: display 'Code Mode' instead of 'code_execution' in CLI (#7321) docs: add Permission Policy documentation for MCP Apps (#7325) update RPI plan prompt (#7326) docs: add CLI syntax highlighting theme customization (#7324) fix(cli): replace shell-based update with native Rust implementation (#7148) docs: rename Code Execution extension to Code Mode extension (#7316) docs: remove ALPHA_FEATURES flag from documentation (#7315) docs: escape variable syntax in recipes (#7314) docs: update OTel environment variable and config guides (#7221) docs: system proxy settings (#7311) docs: add Summon extension tutorial and update Skills references (#7310) docs: agent session id (#7289) ...
* origin/main: fix(ci): deflake smoke tests for Google models (#7344) feat: add Cerebras provider support (#7339) fix: skip whitespace-only text blocks in Anthropic message (#7343) fix(goose-acp): heap allocations (#7322) Remove trailing space from links (#7156) fix: detect low balance and prompt for top up (#7166) feat(apps): add support for MCP apps to sample (#7039) Typescript SDK for ACP extension methods (#7319) chore: upgrade to rmcp 0.16.0 (#7274) docs: add monitoring subagent activity section (#7323) docs: document Desktop UI recipe editing for model/provider and extensions (#7327) docs: add CLAUDE_THINKING_BUDGET and CLAUDE_THINKING_ENABLED environm… (#7330) fix: display 'Code Mode' instead of 'code_execution' in CLI (#7321) docs: add Permission Policy documentation for MCP Apps (#7325) update RPI plan prompt (#7326) docs: add CLI syntax highlighting theme customization (#7324) fix(cli): replace shell-based update with native Rust implementation (#7148) docs: rename Code Execution extension to Code Mode extension (#7316)
Summary
Replaces the shell-based
goose updateimplementation (which shells out tocurl+bash) with a native Rust implementation that works on Windows, macOS, and Linux.Problem
goose updatehas been broken on native Windows since ~v1.9.0. The current implementation downloadsdownload_cli.shand executes it viabash- neithercurlnorbashexist on native Windows. This affects all Windows users who try to update via the CLI.See: #7146, #5006
Solution
Complete rewrite of
crates/goose-cli/src/commands/update.rs(~37 lines -> ~460 lines) to use native Rust libraries instead of shell commands:curlreqwest(blocking + rustls)bash+tarzipcrate (Windows) /tar+bzip2(Unix)mvvia bashstd::fs::renamewith rename-away strategy on WindowsKey design decisions
#[cfg]attributes - no runtime OS sniffinggoose.exe->goose.exe.old, then new ->goose.exe) because Windows locks running executablesgoose-package/subdirectory in archives (matches existing release structure)update(canary, reconfigure)signature unchanged, just madeasyncdisable-updatefeature flag still worksFiles Changed
Cargo.lockcrates/goose-cli/Cargo.tomlreqwest(blocking, rustls-tls-native-roots),zip7.4 (deflate),bzip20.5crates/goose-cli/src/commands/update.rscrates/goose-cli/src/cli.rs.awaittoupdate()callcrates/goose-cli/src/session/editor.rs#[cfg(unix)]to 2 test functions (bonus fix for pre-existing Windows test compilation)Testing
Unit tests (10 new, all pass)
test_asset_name_valid- correct platform asset selectiontest_binary_name- correct binary name per OStest_find_binary_*(4 tests) - binary discovery in various archive layoutstest_extract_zip_with_package_dir- zip extraction with goose-package subdirtest_replace_binary_*(3 tests) - binary replacement including Windows rename-awayEnd-to-end (manual, Windows)
goose update- successfully downloaded 50MB stable release, extracted, replaced binary + 3 DLLsgoose update --canary- successfully downloaded 89MB canary releasegoose --versionconfirms updated version after each testRegression
Closes
goose updatefails on Windows with 'Unsupported OS' error