From 85ba7de0c0befcd689f71c4a49c94333cb59ff71 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Thu, 17 Sep 2026 11:54:43 +0700 Subject: [PATCH 1/2] fix(install): verified replaces, preserved MCP env, truthful self-update Windows locked-exe false success: Install-BinaryAtomic no longer treats a surviving old binary as success; unique retire/promote/rollback with hash verification; final version gate before [OK]. PATH copies source from the extracted release and verify; MCP JSON merge preserves env and exact-case keys on PS 5.1/7. Self-update verifies installed bytes/version, unique Windows retirements, rollback on probe failure. Regression tests: scripts/test-installer.ps1 (19 checks), update_e2e native-exe fixtures. --- README.md | 4 +- crates/core/src/commands/update.rs | 13 ++ crates/core/src/update.rs | 161 +++++++++++++--- crates/core/tests/update_e2e.rs | 295 ++++++++++++++++++++++++++++- install.ps1 | 292 ++++++++++++++++++---------- install.sh | 59 +++++- scripts/test-installer.ps1 | 108 +++++++++++ 7 files changed, 781 insertions(+), 151 deletions(-) create mode 100644 scripts/test-installer.ps1 diff --git a/README.md b/README.md index 012bf5a..ff78148 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,9 @@ cargo install --path crates/core The installers auto-detect your platform, fetch the matching binary from GitHub Releases, verify the SHA-256, and atomically install to `~/.local/bin/hashline`. They also auto-detect supported MCP hosts (claude-code, codex, cursor, windsurf, vscode, gemini, opencode) and upsert a `hashline` MCP server entry for each. -After installing, `hashline update` upgrades in place from GitHub Releases with the same checksum verification. Once a day, interactive commands print a one-line notice to stderr when a newer release is available — disable it with `HASHLINE_NO_UPDATE_CHECK=1`. +An **update-available notice does not install anything**. `hashline update --check` only checks; `hashline update` downloads and installs a release, verifying the candidate's version and the installed executable's bytes and version before reporting success. Release archive checksums are verified when published. + +Self-update replaces **only the invoked executable** (`current_exe`), whose exact path is reported; it does not update other PATH copies or edit MCP configuration. Restart/reconnect already-running MCP sessions to load the updated binary, and update other installations separately using their explicit executable paths. Interactive commands check for a notice at most once a day; disable notices with `HASHLINE_NO_UPDATE_CHECK=1`. --- diff --git a/crates/core/src/commands/update.rs b/crates/core/src/commands/update.rs index 8f6fd88..345fd5e 100644 --- a/crates/core/src/commands/update.rs +++ b/crates/core/src/commands/update.rs @@ -50,6 +50,19 @@ pub fn run( )?; } + writeln!( + ctx.stderr(), + "NOTE updated only {}; other installations are unchanged. Restart/reconnect running MCP sessions to load this executable.", + report.path.display() + )?; + if let Some(backup) = &report.retained_backup { + writeln!( + ctx.stderr(), + "NOTE old image retained at {}; remove it after old processes exit", + backup.display() + )?; + } + match ctx.output_mode() { OutputMode::Json => { let output = serde_json::json!({ diff --git a/crates/core/src/update.rs b/crates/core/src/update.rs index 9a7b9b3..e17a4d6 100644 --- a/crates/core/src/update.rs +++ b/crates/core/src/update.rs @@ -323,6 +323,8 @@ pub struct UpdateReport { pub current: String, pub path: PathBuf, pub checksum_verified: bool, + /// A still-running old image may prevent cleanup on Windows. + pub retained_backup: Option, } /// Download `release`, verify its checksum, and replace the running binary. @@ -371,31 +373,80 @@ pub fn download_and_install_into(release: &Release, target: &Path) -> Result Result<(), String> { +/// Execute the payload rather than trusting release metadata or file existence. +fn verify_binary( + path: &Path, + expected_version: &str, + expected_hash: &str, +) -> Result { + let verify_hash = || { + if sha256_hex(path)? != expected_hash { + return Err(format!( + "installed payload checksum mismatch at {}", + path.display() + )); + } + Ok(()) + }; + verify_hash()?; + let output = Command::new(path) + .arg("--version") + .env("HASHLINE_NO_UPDATE_CHECK", "1") + .output() + .map_err(|e| format!("cannot verify {} --version: {e}", path.display()))?; + if !output.status.success() { + return Err(format!( + "{} --version failed ({}): {}", + path.display(), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let mut words = stdout.split_whitespace(); + let name = words.next(); + let version = words.next().unwrap_or_default(); + if name != Some("hashline") || version != expected_version || words.next().is_some() { + return Err(format!( + "version mismatch at {}: expected 'hashline {expected_version}', got {:?}", + path.display(), + stdout.trim() + )); + } + // Also catch a payload changed while the version command was running. + verify_hash()?; + Ok(version.to_string()) +} + +fn replace_binary( + new_binary: &Path, + target: &Path, + expected_version: &str, +) -> Result<(String, Option), String> { let target_dir = target .parent() .ok_or_else(|| format!("invalid binary path {}", target.display()))?; fs::create_dir_all(target_dir) .map_err(|e| format!("cannot create {}: {e}", target_dir.display()))?; - let staged_name = format!( - ".{}.update-{}", - target - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("hashline"), - std::process::id() - ); - let staged = target_dir.join(staged_name); - + // A unique, same-filesystem directory reserves both names. Never touch + // another attempt's backup: an old MCP process may still be using it. + let attempt = tempfile::Builder::new() + .prefix(".hashline-update-") + .tempdir_in(target_dir) + .map_err(|e| format!("cannot stage update beside {}: {e}", target.display()))?; + let staged = attempt.path().join("candidate.exe"); + let retired = attempt.path().join("retired.exe"); + let expected_hash = sha256_hex(new_binary)?; fs::copy(new_binary, &staged).map_err(|e| format!("cannot stage new binary: {e}"))?; #[cfg(unix)] { @@ -403,25 +454,73 @@ fn replace_binary(new_binary: &Path, target: &Path) -> Result<(), String> { fs::set_permissions(&staged, fs::Permissions::from_mode(0o755)) .map_err(|e| format!("cannot chmod staged binary: {e}"))?; } - if let Ok(staged_file) = fs::File::open(&staged) { - let _ = staged_file.sync_all(); - } - - // Windows keeps the running exe locked: move the old image aside first, - // then put the new binary in place and best-effort delete the old one - // (the delete succeeds on a later run once the process is gone). - #[cfg(windows)] - let retired = { - let retired = target.with_extension("exe.old"); - let _ = fs::rename(target, &retired); - retired + fs::OpenOptions::new() + .write(true) + .open(&staged) + .and_then(|file| file.sync_all()) + .map_err(|e| format!("cannot flush staged binary: {e}"))?; + verify_binary(&staged, expected_version, &expected_hash)?; + + // From this point automatic temp cleanup must not remove the only backup + // if rollback fails. Keep this attempt until promotion has been verified. + let attempt_path = attempt.keep(); + let had_target = match fs::rename(target, &retired) { + Ok(()) => true, + Err(e) if e.kind() == io::ErrorKind::NotFound && !target.exists() => false, + Err(e) => { + let _ = fs::remove_dir_all(&attempt_path); + return Err(format!( + "cannot retire {} (original left in place): {e}", + target.display() + )); + } }; - fs::rename(&staged, target).map_err(|e| format!("cannot replace {}: {e}", target.display()))?; - #[cfg(windows)] - { - let _ = fs::remove_file(&retired); + let mut promoted = false; + let result = (|| { + fs::rename(&staged, target) + .map_err(|e| format!("cannot promote new binary to {}: {e}", target.display()))?; + promoted = true; + verify_binary(target, expected_version, &expected_hash) + })(); + match result { + Ok(version) => { + let retained = fs::remove_dir_all(&attempt_path).err().map(|_| retired); + Ok((version, retained)) + } + Err(error) => { + // Rename (rather than delete) a failed image, including on Windows. + // Never overwrite or discard the working backup on a rollback error. + let rollback = (|| -> io::Result<()> { + if promoted { + fs::rename(target, &staged)?; + } + if had_target { + fs::rename(&retired, target)?; + } + Ok(()) + })(); + if let Err(e) = rollback { + return Err(format!( + "{error}; rollback failed for {}: {e}; recovery files retained at {}", + target.display(), + attempt_path.display() + )); + } + let cleanup = fs::remove_dir_all(&attempt_path); + let recovery = if had_target { + "original restored" + } else { + "failed installation removed" + }; + match cleanup { + Ok(()) => Err(format!("{error}; {recovery}")), + Err(e) => Err(format!( + "{error}; {recovery}; cleanup failed at {}: {e}", + attempt_path.display() + )), + } + } } - Ok(()) } // ── Periodic update notice ────────────────────────────────────────── @@ -556,7 +655,7 @@ pub fn maybe_print_update_notice(stderr_is_terminal: bool) { let status = notice_status(&cache_path, epoch_now(), fetch_latest_release); if let UpdateStatus::Outdated { latest } = status { eprintln!( - "NOTE update available: latest={latest} installed={} (run `hashline update`)", + "NOTE update available: latest={latest} installed={} (notice only; run `hashline update` to install)", current_version() ); } diff --git a/crates/core/tests/update_e2e.rs b/crates/core/tests/update_e2e.rs index 985aa30..870428c 100644 --- a/crates/core/tests/update_e2e.rs +++ b/crates/core/tests/update_e2e.rs @@ -138,13 +138,57 @@ fn binary_name() -> &'static str { } } -fn marker_content(tag: &str) -> String { - format!("#!/bin/sh\necho 'hashline test binary {tag}'\n") +/// Compile a native executable on every platform, not a script masquerading +/// as a Windows executable. It can fail only after promotion to test rollback. +fn compile_binary(dir: &Path, tag: &str, mode: &str) -> PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let source = dir.join("fixture.rs"); + let binary = dir.join(binary_name()); + let version = tag.trim_start_matches('v'); + std::fs::write( + &source, + format!( + r#" +fn main() {{ + if std::env::args().nth(1).as_deref() == Some("--hold") {{ + std::fs::write(std::env::args().nth(2).unwrap(), "ready").unwrap(); + std::thread::sleep(std::time::Duration::from_secs(120)); + return; + }} + assert_eq!(std::env::args().nth(1).as_deref(), Some("--version")); + println!("hashline {version}"); + let installed = std::env::current_exe().unwrap().file_name().unwrap() == {name:?}; + if {mode:?} == "nonzero" || ({mode:?} == "fail-installed" && installed) {{ + std::process::exit(7); + }} +}} +"#, + name = binary_name() + ), + ) + .unwrap(); + let output = StdCommand::new("rustc") + .arg("--crate-name") + .arg("update_fixture") + .arg(&source) + .arg("-o") + .arg(&binary) + .output() + .expect("compile native release fixture"); + assert!( + output.status.success(), + "rustc: {}", + String::from_utf8_lossy(&output.stderr) + ); + binary } -/// Build a release archive named exactly like the real release asset for -/// the host platform, containing a fake `hashline` binary. fn build_fixture(tag: &str, dir: &Path) -> PathBuf { + build_fixture_mode(tag, tag, "ok", dir) +} + +/// Package a real executable under the host's release asset name. +fn build_fixture_mode(tag: &str, payload_tag: &str, mode: &str, dir: &Path) -> PathBuf { let platform = update::release_platform().expect("host platform has a release asset"); // Mirrors update.rs archive_ext (kept private there). let ext = if platform.starts_with("windows") { @@ -156,8 +200,13 @@ fn build_fixture(tag: &str, dir: &Path) -> PathBuf { let asset_path = dir.join(&asset_name); let stage = dir.join("stage"); + let payload = compile_binary( + &dir.join(format!("payload-{mode}-{payload_tag}")), + payload_tag, + mode, + ); std::fs::create_dir_all(&stage).expect("create stage dir"); - std::fs::write(stage.join(binary_name()), marker_content(tag)).expect("write fake binary"); + std::fs::copy(&payload, stage.join(binary_name())).expect("stage real executable"); if cfg!(windows) { let script = format!( @@ -295,6 +344,129 @@ fn cli_check_fails_cleanly_when_mirror_unreachable() { assert!(output.stdout.is_empty(), "stdout must stay clean"); } +fn temporary_cli(dir: &Path) -> PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let path = dir.join(binary_name()); + std::fs::copy(assert_cmd::cargo::cargo_bin("hashline"), &path).unwrap(); + path +} + +fn run_update(exe: &Path, home: &Path, mirror: &Mirror) -> std::process::Output { + StdCommand::new(exe) + .args(["update", "--json"]) + .env("HASHLINE_RELEASES_BASE_URL", &mirror.base) + .env("HASHLINE_NO_UPDATE_CHECK", "1") + .env("HOME", home) + .env("USERPROFILE", home) + .output() + .expect("run temporary CLI self-update") +} + +fn assert_version(exe: &Path, tag: &str) { + let output = StdCommand::new(exe).arg("--version").output().unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + format!("hashline {}", tag.trim_start_matches('v')) + ); +} + +#[test] +fn cli_self_update_verifies_bytes_version_and_exact_path_without_touching_other_installs() { + let temp = tempfile::tempdir().unwrap(); + let tag = next_version_tag(); + let fixture = build_fixture(&tag, temp.path()); + let mirror = spawn_mirror(MirrorConfig { + tag: tag.clone(), + fixture: Some(fixture), + fail_api: false, + }); + let exe = temporary_cli(&temp.path().join("invoked")); + let other = temporary_cli(&temp.path().join("other")); + let old_bytes = std::fs::read(&other).unwrap(); + let output = run_update(&exe, temp.path(), &mirror); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["status"], "updated"); + assert_eq!(json["current"], tag.trim_start_matches('v')); + assert_eq!( + std::fs::canonicalize(json["path"].as_str().unwrap()).unwrap(), + std::fs::canonicalize(&exe).unwrap() + ); + assert_eq!( + std::fs::read(&exe).unwrap(), + std::fs::read(temp.path().join("stage").join(binary_name())).unwrap() + ); + assert_version(&exe, &tag); + assert_eq!(std::fs::read(&other).unwrap(), old_bytes); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("other installations are unchanged")); + assert!(stderr.contains("Restart/reconnect")); +} + +#[test] +fn cli_rejects_mismatched_and_nonzero_candidates_without_false_success() { + for (payload, mode, expected_error) in [ + (current_version_tag(), "ok", "version mismatch"), + (next_version_tag(), "nonzero", "--version failed"), + ] { + let temp = tempfile::tempdir().unwrap(); + let tag = next_version_tag(); + let fixture = build_fixture_mode(&tag, &payload, mode, temp.path()); + let mirror = spawn_mirror(MirrorConfig { + tag: tag.clone(), + fixture: Some(fixture), + fail_api: false, + }); + let exe = temporary_cli(&temp.path().join("invoked")); + let before = std::fs::read(&exe).unwrap(); + let output = run_update(&exe, temp.path(), &mirror); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains(expected_error), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("\"status\":\"updated\"")); + assert_eq!(std::fs::read(&exe).unwrap(), before); + assert_version(&exe, ¤t_version_tag()); + // This cache means release observed, not successfully installed. + let cache: serde_json::Value = serde_json::from_slice( + &std::fs::read(temp.path().join(".hashline/update-check.json")).unwrap(), + ) + .unwrap(); + assert_eq!(cache["latest_version"], tag.trim_start_matches('v')); + } +} + +#[test] +fn cli_rolls_back_when_installed_version_probe_fails() { + let temp = tempfile::tempdir().unwrap(); + let tag = next_version_tag(); + let fixture = build_fixture_mode(&tag, &tag, "fail-installed", temp.path()); + let mirror = spawn_mirror(MirrorConfig { + tag, + fixture: Some(fixture), + fail_api: false, + }); + let exe = temporary_cli(&temp.path().join("invoked")); + let before = std::fs::read(&exe).unwrap(); + let output = run_update(&exe, temp.path(), &mirror); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("original restored"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("\"status\":\"updated\"")); + assert_eq!(std::fs::read(&exe).unwrap(), before); + assert_version(&exe, ¤t_version_tag()); +} + // ── Library end-to-end ─────────────────────────────────────────────── #[test] @@ -318,12 +490,21 @@ fn library_download_verifies_and_replaces_target() { assert_eq!(report.current, tag.trim_start_matches('v')); assert!(report.checksum_verified, "checksum must be verified"); assert_eq!(report.path, target); + assert!( + report.retained_backup.is_none(), + "clean install keeps no backup" + ); - let installed = std::fs::read(&target).expect("read replaced binary"); + // Verify this test copies a real native executable before the bytes are + // promoted: run it directly rather than trusting release metadata. + let output = StdCommand::new(&target) + .arg("--version") + .output() + .expect("execute installed payload"); + assert!(output.status.success()); assert_eq!( - String::from_utf8_lossy(&installed), - marker_content(&tag), - "target must contain the new binary payload" + String::from_utf8_lossy(&output.stdout).trim(), + format!("hashline {}", tag.trim_start_matches('v')) ); // The API path of fetch_latest_release works against the same mirror. @@ -331,6 +512,102 @@ fn library_download_verifies_and_replaces_target() { assert_eq!(latest.version, tag.trim_start_matches('v')); } +#[cfg(windows)] +struct RunningFixture(std::process::Child); + +#[cfg(windows)] +impl RunningFixture { + fn start(exe: &Path, ready: &Path) -> Self { + let child = StdCommand::new(exe) + .arg("--hold") + .arg(ready) + .spawn() + .unwrap(); + let fixture = Self(child); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !ready.exists() { + assert!( + std::time::Instant::now() < deadline, + "fixture did not start" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + fixture + } +} + +#[cfg(windows)] +impl Drop for RunningFixture { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[cfg(windows)] +#[test] +fn windows_repeated_update_with_running_images_and_legacy_backup() { + let temp = tempfile::tempdir().unwrap(); + let tag = next_version_tag(); + let fixture = build_fixture(&tag, temp.path()); + let mirror = spawn_mirror(MirrorConfig { + tag: tag.clone(), + fixture: Some(fixture), + fail_api: false, + }); + let (_env, _lock) = EnvGuard::set("HASHLINE_RELEASES_BASE_URL", &mirror.base); + let target = compile_binary(&temp.path().join("installed"), ¤t_version_tag(), "ok"); + let legacy = target.with_extension("exe.old"); + std::fs::copy(&target, &legacy).unwrap(); + let legacy_bytes = std::fs::read(&legacy).unwrap(); + let _legacy_process = RunningFixture::start(&legacy, &temp.path().join("legacy-ready")); + let _first_process = RunningFixture::start(&target, &temp.path().join("first-ready")); + let first = update::download_and_install_into(&Release::from_version(&tag), &target).unwrap(); + assert_version(&target, &tag); + let _second_process = RunningFixture::start(&target, &temp.path().join("second-ready")); + let second = update::download_and_install_into(&Release::from_version(&tag), &target).unwrap(); + assert_version(&target, &tag); + assert_eq!(std::fs::read(&legacy).unwrap(), legacy_bytes); + // Windows may allow delete-pending images, so retained files are optional; + // if both remain, they must belong to distinct attempts. + if let (Some(first), Some(second)) = (first.retained_backup, second.retained_backup) { + assert_ne!(first, second); + } +} + +#[cfg(windows)] +#[test] +fn windows_retirement_failure_preserves_target_and_returns_error() { + use std::os::windows::fs::OpenOptionsExt; + let temp = tempfile::tempdir().unwrap(); + let tag = next_version_tag(); + let fixture = build_fixture(&tag, temp.path()); + let mirror = spawn_mirror(MirrorConfig { + tag: tag.clone(), + fixture: Some(fixture), + fail_api: false, + }); + let (_env, _lock) = EnvGuard::set("HASHLINE_RELEASES_BASE_URL", &mirror.base); + let target = temporary_cli(&temp.path().join("installed")); + let before = std::fs::read(&target).unwrap(); + // FILE_SHARE_READ but deliberately no FILE_SHARE_DELETE: a real OS-level + // retirement failure, not a mocked rename result. + let _locked = std::fs::OpenOptions::new() + .read(true) + .share_mode(1) + .open(&target) + .unwrap(); + let error = + update::download_and_install_into(&Release::from_version(&tag), &target).unwrap_err(); + assert!(error.contains("cannot retire"), "{error}"); + assert_eq!(std::fs::read(&target).unwrap(), before); + assert_eq!( + std::fs::read_dir(target.parent().unwrap()).unwrap().count(), + 1, + "failed attempt should be cleaned" + ); +} + #[test] fn library_fetch_falls_back_to_redirect_when_api_fails() { let tag = next_version_tag(); diff --git a/install.ps1 b/install.ps1 index 224e017..907c283 100644 --- a/install.ps1 +++ b/install.ps1 @@ -233,37 +233,43 @@ function Update-UserPath { function Install-BinaryAtomic { param([string] $SourcePath, [string] $DestPath) - $tmp = "$DestPath.tmp.$PID" - Copy-Item -LiteralPath $SourcePath -Destination $tmp -Force - - $destDir = Split-Path -Parent $DestPath - $oldName = "$BinaryFile.old.$PID" - $oldPath = Join-Path $destDir $oldName - - # Phase 1: try a direct replace (works when the old binary is not in use). - # $ErrorActionPreference is 'Continue', so Move-Item failures do NOT - # terminate — we check Test-Path afterward. - Remove-Item -LiteralPath $DestPath -Force -ErrorAction SilentlyContinue - Move-Item -LiteralPath $tmp -Destination $DestPath -Force -ErrorAction SilentlyContinue - if (Test-Path -LiteralPath $DestPath) { return } - - # Phase 2: the old binary is likely in use. Windows allows renaming a - # running executable even though it cannot be deleted/overwritten. - # Rename the old file out of the way, then copy the new one into place. - # Use Copy-Item (not Move-Item) because Copy-Item works on locked files. - Rename-Item -LiteralPath $DestPath -NewName $oldName -ErrorAction SilentlyContinue - Copy-Item -LiteralPath $tmp -Destination $DestPath -Force -ErrorAction SilentlyContinue - - if (-not (Test-Path -LiteralPath $DestPath)) { - # Restore the old binary and die. - Move-Item -LiteralPath $oldPath -Destination $DestPath -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue - Die "failed to write $DestPath" - } - - # Clean up the *.old file. If still held by a running process the - # remove will fail silently; it'll be cleaned on the next upgrade. - Remove-Item -LiteralPath $oldPath -Force -ErrorAction SilentlyContinue + $id = [guid]::NewGuid().ToString('N') + $tmp = "$DestPath.tmp.$id" + $oldPath = "$DestPath.old.$id" + $retired = $false + $promoted = $false + try { + $expected = (Get-FileHash -LiteralPath $SourcePath -Algorithm SHA256 -ErrorAction Stop).Hash + Copy-Item -LiteralPath $SourcePath -Destination $tmp -ErrorAction Stop + if ((Get-FileHash -LiteralPath $tmp -Algorithm SHA256 -ErrorAction Stop).Hash -ne $expected) { + throw 'staged binary checksum mismatch' + } + + # Retain the old image until verification succeeds. Running Windows + # executables can often be renamed, but never assume that succeeded. + if (Test-Path -LiteralPath $DestPath) { + Move-Item -LiteralPath $DestPath -Destination $oldPath -ErrorAction Stop + $retired = $true + } + Move-Item -LiteralPath $tmp -Destination $DestPath -ErrorAction Stop + $promoted = $true + if ((Get-FileHash -LiteralPath $DestPath -Algorithm SHA256 -ErrorAction Stop).Hash -ne $expected) { + throw 'installed binary checksum mismatch' + } + } catch { + $failure = $_.Exception.Message + try { + if ($promoted) { Remove-Item -LiteralPath $DestPath -Force -ErrorAction Stop } + if ($retired) { Move-Item -LiteralPath $oldPath -Destination $DestPath -ErrorAction Stop } + } catch { + throw "failed to install ${DestPath}: $failure; rollback failed: $($_.Exception.Message); backup: $oldPath" + } + throw "failed to install ${DestPath}: $failure" + } finally { + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + } + # A running process may still hold the retired image; retain it if locked. + if ($retired) { Remove-Item -LiteralPath $oldPath -Force -ErrorAction SilentlyContinue } } # ============================================================================ @@ -275,6 +281,111 @@ function Install-BinaryAtomic { # Failures here just print a hint; the binary install has already succeeded. # ============================================================================ +function ConvertFrom-JsonRaw { + # PS 7: ConvertFrom-Json -AsHashtable is exact-case and lossless. + # Windows PowerShell 5.1 lacks it; DataContractJsonSerializer's XML + # reader round-trips case-distinct keys through a hashtable instead of + # PSCustomObject (which would collapse them). + param([string]$Raw) + if ($PSVersionTable.PSVersion.Major -ge 6) { + return ($Raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop) + } + Add-Type -AssemblyName System.Runtime.Serialization + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Raw) + $reader = [System.Runtime.Serialization.Json.JsonReaderWriterFactory]::CreateJsonReader( + $bytes, [System.Xml.XmlDictionaryReaderQuotas]::Max) + $xml = New-Object System.Xml.XmlDocument + try { + $xml.Load($reader) + } finally { + $reader.Dispose() + } + return ConvertFrom-JsonXmlNode -Value $xml.DocumentElement +} + +function ConvertFrom-JsonXmlNode { + param($Value) + switch ($Value.GetAttribute('type')) { + 'object' { + $table = New-Object System.Collections.Specialized.OrderedDictionary + foreach ($child in $Value.ChildNodes) { + # The JSON XML reader mangles keys with slashes/colons into a + # namespaced element; recover the + # original name from the `item` attribute. + $name = $child.LocalName + if ($child.LocalName -eq 'item' -and $child.NamespaceURI -eq 'item') { + $name = $child.GetAttribute('item') + } + $table[$name] = ConvertFrom-JsonXmlNode -Value $child + } + return $table + } + 'array' { + $items = @() + foreach ($child in $Value.ChildNodes) { + $items += , (ConvertFrom-JsonXmlNode -Value $child) + } + return $items + } + 'null' { return $null } + 'boolean' { return ($Value.InnerText -eq 'true') } + 'number' { return [double]$Value.InnerText } + default { + # Numbers encode as 1 / 0 in the XML text; honor the type hint. + $t = $Value.GetAttribute('type') + if ($t -eq 'number') { return [double]$Value.InnerText } + return $Value.InnerText + } + } +} + +function ConvertTo-JsonRawHashtable { + param($Value) + if ($Value -is [System.Collections.IDictionary]) { + $pairs = foreach ($key in $Value.Keys) { + '{0}: {1}' -f (ConvertTo-Json -InputObject ([string]$key) -Compress), ( + ConvertTo-JsonRawHashtable -Value $Value[$key] + ) + } + return '{' + ($pairs -join ',') + '}' + } + if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { + $items = foreach ($item in $Value) { ConvertTo-JsonRawHashtable -Value $item } + return '[' + ($items -join ',') + ']' + } + if ($Value -is [bool]) { return $(if ($Value) { 'true' } else { 'false' }) } + if ($null -eq $Value) { return 'null' } + if ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal] -or $Value -is [float] -or $Value -is [int16] -or $Value -is [byte]) { + return ([string]$Value) + } + return (ConvertTo-Json -InputObject ([string]$Value) -Compress) +} + +function Test-EntryDeepEqual { + param($Left, $Right) + if (($null -eq $Left) -ne ($null -eq $Right)) { return $false } + if ($null -eq $Left) { return $true } + if (($Left -is [System.Collections.IDictionary]) -ne ($Right -is [System.Collections.IDictionary])) { return $false } + if ($Left -is [System.Collections.IDictionary]) { + if ($Left.Keys.Count -ne $Right.Keys.Count) { return $false } + foreach ($key in $Left.Keys) { + if (-not $Right.Contains($key)) { return $false } + if (-not (Test-EntryDeepEqual -Left $Left[$key] -Right $Right[$key])) { return $false } + } + return $true + } + if (($Left -is [System.Collections.IEnumerable]) -and -not ($Left -is [string]) -and + ($Right -is [System.Collections.IEnumerable]) -and -not ($Right -is [string])) { + $l = @($Left); $r = @($Right) + if ($l.Count -ne $r.Count) { return $false } + for ($i = 0; $i -lt $l.Count; $i++) { + if (-not (Test-EntryDeepEqual -Left $l[$i] -Right $r[$i])) { return $false } + } + return $true + } + return ("$Left" -eq "$Right") +} + function Update-HashlineMcpConfig { param( [string]$Path, @@ -282,55 +393,60 @@ function Update-HashlineMcpConfig { [string]$BinaryPath ) - $entry = [pscustomobject]@{ + $managed = @{ command = $BinaryPath args = @('mcp') } - # Load existing config (or start empty). + # Load existing config (or start empty). Hashtable keeps exact-case keys + # so case-distinct project entries survive the round trip. $config = $null $status = 'installed' if (Test-Path $Path) { try { $raw = Get-Content -Path $Path -Raw -ErrorAction Stop if ($raw.Trim().Length -gt 0) { - $config = $raw | ConvertFrom-Json -ErrorAction Stop + $config = ConvertFrom-JsonRaw -Raw $raw } } catch { Write-Warn "Skipping $Path (invalid JSON: $($_.Exception.Message))" return $null } } - if ($null -eq $config) { $config = [pscustomobject]@{} } - - # Ensure servers container exists. - $servers = $config.PSObject.Properties[$ServersKey] - if ($null -eq $servers) { - Add-Member -InputObject $config -MemberType NoteProperty ` - -Name $ServersKey -Value ([pscustomobject]@{}) -Force - } - $serversObj = $config.$ServersKey - - # Compare existing entry (if any) for unchanged/updated signal. - $existing = $serversObj.PSObject.Properties['hashline'] - if ($existing) { - $existingJson = $existing.Value | ConvertTo-Json -Compress - $entryJson = $entry | ConvertTo-Json -Compress - if ($existingJson -eq $entryJson) { - return @{ Status = 'unchanged'; Path = $Path } + if ($null -eq $config) { $config = @{} } + + # Ensure servers container exists (dotted keys like amp.mcpServers). + $parts = $ServersKey.Split('.') + $cursor = $config + foreach ($part in $parts) { + if (-not $cursor.Contains($part) -or -not ($cursor[$part] -is [System.Collections.IDictionary])) { + $cursor[$part] = @{} } - $status = 'updated' + $cursor = $cursor[$part] } - - # Upsert entry and write file (pretty JSON for human inspection). - Add-Member -InputObject $serversObj -MemberType NoteProperty ` - -Name 'hashline' -Value $entry -Force + $serversObj = $cursor + + # Merge first, then deep-compare: unchanged means the merged entry is + # identical to what is already on disk, regardless of key order or how + # the equality would look field-by-field. + $existing = $serversObj['hashline'] + if ($existing -is [System.Collections.IDictionary]) { + foreach ($key in @($existing.Keys)) { if (-not $managed.Contains($key)) { $managed[$key] = $existing[$key] } } + } + $serversObj['hashline'] = $managed + if (Test-EntryDeepEqual -Left $existing -Right $managed) { + return @{ Status = 'unchanged'; Path = $Path } + } + $status = if ($existing) { 'updated' } else { 'installed' } $dir = Split-Path -Parent $Path if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } - $config | ConvertTo-Json -Depth 32 | Set-Content -Path $Path -Encoding UTF8 + $json = ConvertTo-JsonRawHashtable -Value $config + $staged = "$Path.tmp.$PID" + Set-Content -LiteralPath $staged -Value $json -Encoding UTF8 + Move-Item -LiteralPath $staged -Destination $Path -Force return @{ Status = $status; Path = $Path } } @@ -488,9 +604,9 @@ The version you asked for ($Version) does not include $archive. Either: # ----------------------------------------------------------------------- # Also replace any existing `hashline` on PATH at a different location - # so `hashline --version` always shows the latest version. - # On Windows, a running .exe can't be overwritten but CAN be renamed, - # so we rename the old one out of the way first. + # so `hashline --version` always shows the latest version. Source is the + # extracted release binary -- never the primary destination, which may + # be stale if its own replace failed. Best-effort: report, don't die. # ----------------------------------------------------------------------- $existingOnPath = Get-Command $BinaryName -ErrorAction SilentlyContinue if ($existingOnPath) { @@ -499,46 +615,11 @@ The version you asked for ($Version) does not include $archive. Either: $destDirNorm = $Dest.TrimEnd('\').ToLower() if ($existingDirNorm -ne $destDirNorm) { Write-Info "also updating existing $BinaryName at $($existingOnPath.Source)" - $srcFile = Join-Path $Dest $BinaryFile - $dstFile = $existingOnPath.Source - $tmpFile = "$dstFile.tmp.$PID" - # Copy new binary to a temp name next to the old one try { - Copy-Item -LiteralPath $srcFile -Destination $tmpFile -Force - # Rename old binary out of the way (Windows allows renaming running exes) - $oldFile = "$dstFile.old.$PID" - Rename-Item -LiteralPath $dstFile -NewName $oldFile -ErrorAction SilentlyContinue - # Copy new binary into place (Copy-Item works on locked files) - Copy-Item -LiteralPath $tmpFile -Destination $dstFile -Force -ErrorAction SilentlyContinue - if (Test-Path -LiteralPath $dstFile) { - Write-Ok "replaced $dstFile" - Remove-Item -LiteralPath $oldFile -Force -ErrorAction SilentlyContinue - } else { - Remove-Item -LiteralPath $tmpFile -ErrorAction SilentlyContinue - # Likely blocked by permissions (e.g. Program Files) — retry - # the rename+copy in an elevated child process via UAC. - Write-Info "permission denied — retrying with elevation (UAC prompt)" - $elevated = $false - try { - $inner = "Copy-Item -LiteralPath '$srcFile' -Destination '$oldFile.new' -Force; " + - "Rename-Item -LiteralPath '$dstFile' -NewName '$oldFile' -ErrorAction SilentlyContinue; " + - "Copy-Item -LiteralPath '$oldFile.new' -Destination '$dstFile' -Force; " + - "Remove-Item -LiteralPath '$oldFile.new','$oldFile' -Force -ErrorAction SilentlyContinue" - $proc = Start-Process powershell -ArgumentList @('-NoProfile','-Command', $inner) -Verb RunAs -Wait -PassThru -ErrorAction Stop - $elevated = ($proc.ExitCode -eq 0) -and (Test-Path -LiteralPath $dstFile) - } catch { - $elevated = $false - } - if ($elevated -and (Test-Path -LiteralPath $dstFile)) { - Write-Ok "replaced $dstFile (elevated)" - } else { - Move-Item -LiteralPath $oldFile -Destination $dstFile -Force -ErrorAction SilentlyContinue - Write-Warn "could not update $dstFile — you may need to run as admin or remove it manually" - } - } + Install-BinaryAtomic -SourcePath $bin.FullName -DestPath $existingOnPath.Source + Write-Ok "replaced $($existingOnPath.Source)" } catch { - Remove-Item -LiteralPath $tmpFile -ErrorAction SilentlyContinue - Write-Warn "could not update $dstFile — you may need to run as admin or remove it manually" + Write-Warn "could not update $($existingOnPath.Source) -- you may need to run as admin or remove it manually ($($_.Exception.Message))" } } } @@ -553,12 +634,17 @@ The version you asked for ($Version) does not include $archive. Either: # MCP install runs last so failures don't undo the binary install above. Invoke-McpAutoInstall + # Final gate: the installed binary must report the requested version. + # Never print [OK] on a stale destination again. + $destBin = Join-Path $Dest $BinaryFile + $v = $null + try { $v = & $destBin --version 2>$null } catch { } + if (-not $v -or ($v -notmatch [regex]::Escape($Version.TrimStart('v')))) { + Die "installed binary reports '$v' but requested $Version at $destBin" + } Write-Host "" - Write-Host "[OK] $BinaryName installed -> $(Join-Path $Dest $BinaryFile)" -ForegroundColor Green - try { - $v = & (Join-Path $Dest $BinaryFile) --version 2>$null - if ($v) { Write-Host " version: $v" } - } catch { } + Write-Host "[OK] $BinaryName installed -> $destBin" -ForegroundColor Green + Write-Host " version: $v" # Hunt down stale copies: any other `hashline` on PATH whose version # differs from what we just installed is an outdated shadow -- delete it diff --git a/install.sh b/install.sh index 8d0b384..db7733b 100644 --- a/install.sh +++ b/install.sh @@ -286,12 +286,48 @@ install_binary_atomic() { local src="$1" local dest="$2" local tmp="${dest}.tmp.$$" + local backup="${dest}.old.$$" + local expected staged="" retired=0 - install -m 0755 "$src" "$tmp" - mv -f "$tmp" "$dest" || { + expected=$(checksum_file "$src") || die "cannot checksum $src" + + install -m 0755 "$src" "$tmp" || { rm -f "$tmp" - die "Failed to install binary" + die "Failed to stage $src" } + + if [ ! -f "$tmp" ] || [ "$(checksum_file "$tmp")" != "$expected" ]; then + rm -f "$tmp" + die "Staged binary verification failed for $dest" + fi + + # Retain the old image until the new one verifies in place. + if [ -e "$dest" ]; then + mv -f "$dest" "$backup" || { + rm -f "$tmp" + die "Failed to retire $dest" + } + retired=1 + fi + + if mv -f "$tmp" "$dest"; then + staged=1 + fi + + if [ -n "$staged" ] && [ -f "$dest" ] && [ "$(checksum_file "$dest")" = "$expected" ]; then + rm -f "$backup" 2>/dev/null || true + return 0 + fi + + # Roll back to the retained copy before failing loudly. + rm -f "$dest" 2>/dev/null || true + if [ "$retired" = "1" ]; then + if mv -f "$backup" "$dest" 2>/dev/null; then + die "Failed to install $dest — restored previous binary from $backup" + fi + die "Failed to install $dest — could not replace or restore it; backup: $backup" + fi + die "Failed to install $dest" } maybe_add_path() { @@ -520,6 +556,9 @@ main() { fi extract_archive "$TMP/$archive" + # Keep the extracted release payload around: secondary PATH copies + # must be sourced from the release, never from the primary + # destination (which may still be stale after a failed replace). binary_path=$(find_extracted_binary "$platform") install_binary_atomic "$binary_path" "$DEST/$BINARY_NAME" else @@ -542,13 +581,19 @@ main() { existing_dir=$(dirname "$existing_bin") if [ "$(cd "$existing_dir" && pwd)" != "$(cd "$DEST" && pwd)" ]; then log_info "also updating existing $BINARY_NAME at $existing_bin" - if cp -f "$DEST/$BINARY_NAME" "$existing_bin" 2>/dev/null; then + # Never source from $DEST/$BINARY_NAME: a failed primary replace + # leaves it stale. Fall back to the verified destination, or the + # extracted release payload when the primary replace failed. + if [ -n "${binary_path:-}" ] && [ -f "$binary_path" ]; then + update_source="$binary_path" + else + update_source="$DEST/$BINARY_NAME" + fi + if install_binary_atomic "$update_source" "$existing_bin" 2>/dev/null; then log_success "replaced $existing_bin" - elif command -v sudo >/dev/null 2>&1 && sudo -n cp -f "$DEST/$BINARY_NAME" "$existing_bin" 2>/dev/null; then - log_success "replaced $existing_bin (via passwordless sudo)" elif command -v sudo >/dev/null 2>&1 && [ -t 0 ]; then log_info "need elevated permission to replace $existing_bin — you may be prompted for your password" - if sudo cp -f "$DEST/$BINARY_NAME" "$existing_bin" 2>/dev/null; then + if sudo sh -c "install_binary_atomic '$update_source' '$existing_bin'" 2>/dev/null; then log_success "replaced $existing_bin (via sudo)" else log_warn "could not update $existing_bin even with sudo — remove it manually" diff --git a/scripts/test-installer.ps1 b/scripts/test-installer.ps1 new file mode 100644 index 0000000..dc04e54 --- /dev/null +++ b/scripts/test-installer.ps1 @@ -0,0 +1,108 @@ +<# +test-installer.ps1 -- isolated regression tests for install.ps1. + +Extracts installer functions by AST (never runs the real install main, +network, live user configs, or PATH) and exercises them in temp dirs. + +Run: + powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\test-installer.ps1 +#> + +$ErrorActionPreference = 'Stop' +$script:failures = 0 + +function Assert-True { + param([bool]$Cond, [string]$Name) + if ($Cond) { Write-Host "PASS: $Name" } + else { $script:failures++; Write-Host "FAIL: $Name" -ForegroundColor Red } +} + +$installer = Join-Path $PSScriptRoot '..\install.ps1' +$installer = [System.IO.Path]::GetFullPath($installer) +$tokens = $null; $errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($installer, [ref]$tokens, [ref]$errors) +if ($errors.Count -gt 0) { throw "install.ps1 has parse errors" } + +foreach ($n in @('Install-BinaryAtomic', 'ConvertFrom-JsonRaw', 'ConvertFrom-JsonXmlNode', + 'ConvertTo-JsonRawHashtable', 'Test-EntryDeepEqual', 'Update-HashlineMcpConfig')) { + $f = $ast.Find({ param($x) $x -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $x.Name -eq $n }, $true) + if (-not $f) { throw "missing function: $n" } + Invoke-Expression $f.Extent.Text +} +function Die { param($msg) throw "DIE: $msg" } +function Write-Warn { param($m) } + +# --- Install-BinaryAtomic: normal replace --- +$root = Join-Path ([System.IO.Path]::GetTempPath()) ('hl-it-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Force -Path (Join-Path $root 'dest'), (Join-Path $root 'src') | Out-Null +$src = Join-Path (Join-Path $root 'src') 'new.exe' +$dst = Join-Path (Join-Path $root 'dest') 'hashline.exe' +Set-Content -LiteralPath $src 'NEW-BINARY' +Set-Content -LiteralPath $dst 'OLD-BINARY' +Install-BinaryAtomic -SourcePath $src -DestPath $dst +$leftoverOld = @(Get-ChildItem (Join-Path $root 'dest') -Filter 'hashline.exe.old.*' -ErrorAction SilentlyContinue) +$onlyExe = @((Get-ChildItem (Join-Path $root 'dest') -Force) | Where-Object { $_.Name -ne 'hashline.exe' }).Count -eq 0 +$ok = ((Get-FileHash $dst).Hash -eq (Get-FileHash $src).Hash) -and + (-not (Test-Path "$dst.old.*")) -and ($leftoverOld.Count -eq 0) -and $onlyExe +Assert-True $ok 'atomic: normal replace, hash matches, no leftovers' + +# --- Install-BinaryAtomic: running-exe style lock (delete-share) triggers rename fallback --- +Set-Content -LiteralPath $dst 'OLD-BINARY' +$fs = $null +try { + $fs = [System.IO.File]::Open($dst, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, + ([System.IO.FileShare]::Read -bor [System.IO.FileShare]::Delete)) + Install-BinaryAtomic -SourcePath $src -DestPath $dst + Assert-True $true 'atomic: locked replace succeeds via rename fallback' +} catch { Assert-True $false ('atomic: locked replace threw: ' + $_.Exception.Message) } +finally { if ($fs) { $fs.Close() } } +Assert-True ((Get-FileHash $dst).Hash -eq (Get-FileHash $src).Hash) 'atomic: locked replace hash matches payload' + +# --- Install-BinaryAtomic: hard lock fails loudly (no false success) --- +Set-Content -LiteralPath $dst 'OLD-BINARY' +$fs2 = $null +$threw = $false +try { + $fs2 = [System.IO.File]::Open($dst, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) + try { Install-BinaryAtomic -SourcePath $src -DestPath $dst } catch { $threw = $true } +} finally { if ($fs2) { $fs2.Close() } } +Assert-True $threw 'atomic: unrenamable lock throws instead of false success' +Assert-True ((Get-Content -LiteralPath $dst -Raw) -like '*OLD-BINARY*') 'atomic: failed replace keeps old binary' + +# --- Update-HashlineMcpConfig fixtures --- +$cfg = Join-Path $root 'claude.json' +Set-Content -LiteralPath $cfg -Value '{"mcpServers":{"hashline":{"command":"OLD","args":["mcp"],"env":{"HASHLINE_RETURN_ANCHORS":"1"}},"other":{"command":"keep"}},"projects":{"C:/Users/ADMIN/Documents/Projects":{"x":1},"c:/Users/ADMIN/Documents/Projects":{"y":2}}}' +$r = Update-HashlineMcpConfig -Path $cfg -ServersKey 'mcpServers' -BinaryPath 'C:\new\hashline.exe' +Assert-True ($r.Status -eq 'updated') 'config: first run updates command' +$p = ConvertFrom-JsonRaw -Raw (Get-Content $cfg -Raw) +Assert-True ($p['mcpServers']['hashline']['command'] -eq 'C:\new\hashline.exe') 'config: command updated' +Assert-True ($p['mcpServers']['hashline']['env']['HASHLINE_RETURN_ANCHORS'] -eq '1') 'config: env preserved' +Assert-True ($p['mcpServers']['other']['command'] -eq 'keep') 'config: other server preserved' +Assert-True ($p['projects'].Keys.Count -eq 2) 'config: case-distinct keys preserved' +$r2 = Update-HashlineMcpConfig -Path $cfg -ServersKey 'mcpServers' -BinaryPath 'C:\new\hashline.exe' +Assert-True ($r2.Status -eq 'unchanged') 'config: second run is unchanged' +Assert-True ((Get-Content $cfg -Raw) -eq (Get-Content $cfg -Raw)) 'config: unchanged write is content-stable' + +$bad = Join-Path $root 'bad.json' +Set-Content -LiteralPath $bad -Value '{not json' +$before = Get-Content $bad -Raw +$rb = Update-HashlineMcpConfig -Path $bad -ServersKey 'mcpServers' -BinaryPath 'X' +Assert-True ($null -eq $rb) 'config: invalid json skipped' +Assert-True ((Get-Content $bad -Raw) -eq $before) 'config: invalid json untouched' + +$empty = Join-Path $root 'empty.json' +Set-Content -LiteralPath $empty -Value '' +$re = Update-HashlineMcpConfig -Path $empty -ServersKey 'mcpServers' -BinaryPath 'X' +Assert-True ($re.Status -eq 'installed') 'config: empty file installs' +Assert-True ((ConvertFrom-JsonRaw -Raw (Get-Content $empty -Raw))['mcpServers']['hashline']['command'] -eq 'X') 'config: empty file populated' + +$amp = Join-Path $root 'amp.json' +Set-Content -LiteralPath $amp -Value '{"amp":{"mcpServers":{"other":{"command":"keep"}}}}' +Update-HashlineMcpConfig -Path $amp -ServersKey 'amp.mcpServers' -BinaryPath 'Y' | Out-Null +$pa = ConvertFrom-JsonRaw -Raw (Get-Content $amp -Raw) +Assert-True ($pa['amp']['mcpServers']['hashline']['command'] -eq 'Y') 'config: dotted key hashline' +Assert-True ($pa['amp']['mcpServers']['other']['command'] -eq 'keep') 'config: dotted key other' + +Remove-Item $root -Recurse -Force +if ($script:failures -gt 0) { Write-Host "$($script:failures) FAILURES" -ForegroundColor Red; exit 1 } +Write-Host 'ALL INSTALLER TESTS PASS' From eaa100c82daadce5414431b6788c90206962088b Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Thu, 17 Sep 2026 12:20:33 +0700 Subject: [PATCH 2/2] docs(guide): document --emit-anchors / return_updated_anchors (#119) hashline guide never mentioned the opt-in fresh-anchor feature added yesterday (--emit-anchors, HASHLINE_RETURN_ANCHORS=1, MCP return_updated_anchors) despite it shipping with CLI/MCP support and tests. Add it to the flags list, a chained-edit example, and tips. --- crates/core/src/commands/guide.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/core/src/commands/guide.rs b/crates/core/src/commands/guide.rs index fca3d0b..4c8727b 100644 --- a/crates/core/src/commands/guide.rs +++ b/crates/core/src/commands/guide.rs @@ -166,6 +166,7 @@ pub fn run( Other flags: hashline patch --dry-run Preview only hashline patch --safe Atomic temp-file + fsync + hashline patch --emit-anchors Append fresh anchors (chain edits without re-read; or HASHLINE_RETURN_ANCHORS=1 / MCP return_updated_anchors) hashline read --no-cache Skip snapshot cache hashline find-block --pretty Pretty-print JSON (with --json) hashline write --force Overwrite existing file @@ -258,6 +259,15 @@ pub fn run( ~20:f1| pub fn new(name: String) -> Self { ~25:7c| Self { name, enabled: true } + # Chain edits without re-reading: the [file#hash] anchor block after + # the summary lists fresh N:hh|content lines — copy anchors from there. + $ hashline patch config.rs --emit-anchors 'SWAP 10:c3: + + pub const TIMEOUT: u64 = 5000;' + OK config.rs#9a1b edits=1 changed=1 + ~10:b1| pub const TIMEOUT: u64 = 5000; + [config.rs#9a1b] + 10:b1| pub const TIMEOUT: u64 = 5000; + # Human-readable output (full file after patch) $ hashline patch config.rs --verbose 'SWAP 10: + pub const TIMEOUT: u64 = 5000;' @@ -273,6 +283,7 @@ pub fn run( • Use --verbose for human-readable full file dump after mutations • Use --json for structured output with changed lines array • Always read before editing — anchors change when files change + • --emit-anchors (or MCP return_updated_anchors) appends fresh anchors so follow-up patches skip the re-read • Use --dry-run to preview patches before writing • An anchor like "42:a3" refers to line 42 with xxh32 hash a3 • If an anchor fails, re-read the file for fresh hashes