fix(windows): hide child-process consoles, resolve bundled CLIs, install CLI shim

Three Windows-port fixes driven by first real-device testing:

- Spawned CLIs (git, gh/glab polls, claude probes, taskkill, tunnels...)
  flashed a console window on every invocation. Worse, each console stole
  focus from the main window, so `refetchOnWindowFocus: "always"` auth
  probes re-fired the moment it closed - a self-sustaining ~1/s terminal
  strobe loop. New `platform::process::hide_console` (+`_tokio`) helpers
  apply CREATE_NO_WINDOW at every one-shot spawn site; long-lived children
  were already covered by `configure_new_group`.

- Bundled gh/glab resolution assumed the macOS Contents/Resources bundle
  layout, so the installed Windows app fell back to PATH (and errored once
  per probe on missing glab). `forge::bundled` now probes both the
  exe-adjacent layout (NSIS/MSI) and Contents/Resources (macOS).

- `install_cli` bailed with "symlink only supported on Unix". Windows now
  installs a `.cmd` shim in %LOCALAPPDATA%\Helmor\bin forwarding to the
  bundled helmor-cli.exe, registers that dir on the user PATH
  (HKCU\Environment, REG_EXPAND_SZ-preserving, WM_SETTINGCHANGE broadcast),
  and classifies Managed/Stale by parsing the shim target.

Also makes the Rust test suite buildable and runnable on Windows for the
first time: cfg-gates unix/mac-only tests, and embeds a common-controls v6
app manifest into every linked target via build.rs (tauri-build switched to
new_without_app_manifest) - without it each test exe died at startup with
STATUS_ENTRYPOINT_NOT_FOUND because TaskDialogIndirect needs comctl32 v6.

Verified: clippy --all-targets -D warnings clean; all 11 integration test
targets pass; changed-module unit tests pass (23 pre-existing lib-test
failures remain in unrelated areas, first time the suite has run on
Windows).
This commit is contained in:
Qui Luong
2026-06-07 13:29:47 -04:00
parent a1ac340753
commit e27a932380
22 changed files with 582 additions and 123 deletions
+77
View File
@@ -2193,6 +2193,7 @@ dependencies = [
"url",
"uuid",
"windows 0.58.0",
"winreg 0.52.0",
"wreq",
"wreq-util",
]
@@ -7340,6 +7341,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -7391,6 +7401,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -7448,6 +7473,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -7466,6 +7497,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -7484,6 +7521,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -7514,6 +7557,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -7532,6 +7581,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -7550,6 +7605,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -7568,6 +7629,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -7616,6 +7683,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.55.0"
+2
View File
@@ -112,6 +112,7 @@ security-framework-sys = "2"
[target.'cfg(windows)'.dependencies]
uds_windows = "1"
winreg = "0.52"
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_System_Threading",
@@ -121,6 +122,7 @@ windows = { version = "0.58", features = [
"Win32_Security",
"Win32_Security_Cryptography",
"Win32_System_Memory",
"Win32_UI_WindowsAndMessaging",
] }
[[bin]]
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
Embedded by the linker into every target on Windows/MSVC — app bins and
cargo test executables alike (see build.rs, embed_windows_manifest).
Equivalent to tauri-build's default Windows app manifest: declares the
comctl32 v6 dependency that tao/tauri dialogs need (TaskDialogIndirect).
Test executables especially rely on this — tauri-build never covers them,
and without it the loader binds comctl32 5.82 and every test exe aborts at
startup with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139).
-->
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>
+42 -1
View File
@@ -8,6 +8,7 @@ const UPDATER_PUBKEY_KEY: &str = "HELMOR_UPDATER_PUBKEY";
fn main() {
ensure_external_bin_placeholders();
embed_windows_manifest();
println!("cargo:rerun-if-changed=build.rs");
for key in [
@@ -30,7 +31,47 @@ fn main() {
load_env_var(&env_path, UPDATER_PUBKEY_KEY);
}
tauri_build::build();
if windows_msvc_target() {
// The app manifest is embedded by the linker for ALL targets (see
// embed_test_manifest_on_windows) — tauri-build must not also embed
// one or the duplicate RT_MANIFEST resource fails the link (LNK1123).
tauri_build::try_build(
tauri_build::Attributes::new()
.windows_attributes(tauri_build::WindowsAttributes::new_without_app_manifest()),
)
.expect("failed to run tauri-build");
} else {
tauri_build::build();
}
}
fn windows_msvc_target() -> bool {
env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
}
/// Embed `app.manifest` into every linked target on Windows/MSVC — the app
/// bins AND the cargo test executables.
///
/// tauri-build's default embeds a manifest into the main exe only; test
/// executables get none. They still import comctl32 v6-only symbols
/// (`TaskDialogIndirect`, via tao/tauri dialogs), so without a
/// common-controls-v6 manifest the loader binds comctl32 5.82 and every test
/// exe aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139).
/// `rustc-link-arg-tests` alone doesn't cover the lib unit-test binary
/// (cargo only applies it to integration-test targets), so the manifest goes
/// in via the catch-all `rustc-link-arg` — and tauri-build is switched to
/// `new_without_app_manifest()` so the two embeds don't collide.
fn embed_windows_manifest() {
if !windows_msvc_target() {
return;
}
let manifest =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR should be set"))
.join("app.manifest");
println!("cargo:rerun-if-changed={}", manifest.display());
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display());
}
fn ensure_external_bin_placeholders() {
+1 -2
View File
@@ -28,8 +28,7 @@ struct CliStatusPayload {
}
pub fn cli_status(cli: &Cli) -> Result<()> {
let install_path =
std::path::PathBuf::from(format!("/usr/local/bin/{}", super::installed_cli_name()));
let install_path = crate::platform::cli_install::install_target(super::installed_cli_name());
let installed = install_path.exists();
let current = std::env::current_exe()
.ok()
+82 -27
View File
@@ -140,12 +140,10 @@ pub struct ComponentsUpdateCheck {
pub skills_error: Option<String>,
}
/// Where Helmor installs its managed CLI entrypoint on macOS.
/// Where Helmor installs its managed CLI entrypoint (symlink on Unix,
/// `.cmd` shim on Windows — see `platform::cli_install`).
fn cli_install_target() -> std::path::PathBuf {
std::path::PathBuf::from(format!(
"/usr/local/bin/{}",
crate::cli::installed_cli_name()
))
crate::platform::cli_install::install_target(crate::cli::installed_cli_name())
}
/// Name of the compiled CLI binary produced by `cargo build --bin helmor-cli`.
@@ -193,29 +191,51 @@ fn classify_cli_install(
Err(_) => return CliInstallState::Stale,
};
if !metadata.file_type().is_symlink() {
return CliInstallState::Stale;
#[cfg(windows)]
{
// Windows installs a `.cmd` shim instead of a symlink; "Managed"
// means the shim forwards to the currently bundled CLI binary.
if !metadata.file_type().is_file() {
return CliInstallState::Stale;
}
let Some(target) = crate::platform::cli_install::read_shim_target(install_path) else {
return CliInstallState::Stale;
};
match (
std::fs::canonicalize(target),
std::fs::canonicalize(bundled_cli),
) {
(Ok(installed), Ok(expected)) if installed == expected => CliInstallState::Managed,
_ => CliInstallState::Stale,
}
}
let target = match std::fs::read_link(install_path) {
Ok(target) => target,
Err(_) => return CliInstallState::Stale,
};
let resolved_target = if target.is_absolute() {
target
} else {
install_path
.parent()
.unwrap_or_else(|| std::path::Path::new("/"))
.join(target)
};
#[cfg(not(windows))]
{
if !metadata.file_type().is_symlink() {
return CliInstallState::Stale;
}
match (
std::fs::canonicalize(resolved_target),
std::fs::canonicalize(bundled_cli),
) {
(Ok(installed), Ok(expected)) if installed == expected => CliInstallState::Managed,
_ => CliInstallState::Stale,
let target = match std::fs::read_link(install_path) {
Ok(target) => target,
Err(_) => return CliInstallState::Stale,
};
let resolved_target = if target.is_absolute() {
target
} else {
install_path
.parent()
.unwrap_or_else(|| std::path::Path::new("/"))
.join(target)
};
match (
std::fs::canonicalize(resolved_target),
std::fs::canonicalize(bundled_cli),
) {
(Ok(installed), Ok(expected)) if installed == expected => CliInstallState::Managed,
_ => CliInstallState::Stale,
}
}
}
@@ -314,10 +334,18 @@ fn try_install_symlink_unprivileged(
Ok(())
}
#[cfg(not(unix))]
#[cfg(windows)]
{
// No symlinks without elevation on Windows — install a `.cmd` shim
// and register its directory on the user PATH instead.
crate::platform::cli_install::write_shim(bundled_cli, install_path)?;
Ok(())
}
#[cfg(not(any(unix, windows)))]
{
let _ = bundled_cli;
anyhow::bail!("CLI installation via symlink is only supported on Unix.")
anyhow::bail!("CLI installation is not supported on this platform.")
}
}
@@ -2055,6 +2083,28 @@ mod tests {
);
}
#[cfg(windows)]
#[test]
fn classify_cli_install_reports_managed_for_matching_shim() {
let tmp = tempdir().unwrap();
let bundled_cli = tmp.path().join("Helmor/helmor-cli.exe");
let install_path = tmp.path().join("bin/helmor.cmd");
fs::create_dir_all(bundled_cli.parent().unwrap()).unwrap();
fs::create_dir_all(install_path.parent().unwrap()).unwrap();
fs::write(&bundled_cli, "").unwrap();
fs::write(
&install_path,
format!("@echo off\r\n\"{}\" %*\r\n", bundled_cli.display()),
)
.unwrap();
assert_eq!(
classify_cli_install(&install_path, &bundled_cli),
CliInstallState::Managed
);
}
#[cfg(unix)]
#[test]
fn classify_cli_install_reports_managed_for_matching_symlink() {
let tmp = tempdir().unwrap();
@@ -2087,6 +2137,7 @@ mod tests {
);
}
#[cfg(unix)]
#[test]
fn install_cli_symlink_replaces_stale_copy_with_managed_symlink() {
let tmp = tempdir().unwrap();
@@ -2105,6 +2156,7 @@ mod tests {
);
}
#[cfg(unix)]
#[test]
fn check_and_heal_cli_symlink_repoints_a_stale_link() {
let tmp = tempdir().unwrap();
@@ -2151,6 +2203,7 @@ mod tests {
);
}
#[cfg(target_os = "macos")]
#[test]
fn applescript_shell_arg_quotes_plain_path() {
assert_eq!(
@@ -2159,6 +2212,7 @@ mod tests {
);
}
#[cfg(target_os = "macos")]
#[test]
fn applescript_shell_arg_escapes_single_quote_for_shell_then_applescript() {
// Shell-quote turns `'` into `'\''`; the embedded backslash then needs
@@ -2169,6 +2223,7 @@ mod tests {
);
}
#[cfg(target_os = "macos")]
#[test]
fn applescript_shell_arg_escapes_double_quote_and_backslash() {
assert_eq!(
+12 -6
View File
@@ -589,14 +589,20 @@ fn init_create_git_repo(repo_root: &Path) {
}
fn make_executable_if_script(path: &Path) {
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if path.extension().and_then(|value| value.to_str()) == Some("sh") {
let metadata = fs::metadata(path).unwrap();
let mut permissions = metadata.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap();
if path.extension().and_then(|value| value.to_str()) == Some("sh") {
let metadata = fs::metadata(path).unwrap();
let mut permissions = metadata.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap();
}
}
// Windows: execution is extension-driven; nothing to chmod.
#[cfg(not(unix))]
let _ = path;
}
fn init_git_repo(repo_root: &Path) {
+10 -7
View File
@@ -169,8 +169,9 @@ pub fn sign_in_cloudflare() -> Result<()> {
/// `~/.cloudflared/<uuid>.json`.
pub fn create_named_tunnel(name: &str) -> Result<(String, String)> {
let bin = resolve_cloudflared();
let output = Command::new(&bin)
.args(["tunnel", "create", name])
let mut command = Command::new(&bin);
command.args(["tunnel", "create", name]);
let output = crate::platform::process::hide_console(&mut command)
.output()
.with_context(|| format!("failed to run cloudflared ({})", bin.display()))?;
let combined = format!(
@@ -194,9 +195,9 @@ pub fn create_named_tunnel(name: &str) -> Result<(String, String)> {
/// Delete a named tunnel. Best-effort: a missing tunnel is treated as success.
pub fn delete_named_tunnel(tunnel_uuid: &str) -> Result<()> {
let bin = resolve_cloudflared();
let _ = Command::new(&bin)
.args(["tunnel", "delete", tunnel_uuid])
.output();
let mut command = Command::new(&bin);
command.args(["tunnel", "delete", tunnel_uuid]);
let _ = crate::platform::process::hide_console(&mut command).output();
Ok(())
}
@@ -287,11 +288,13 @@ fn spawn_and_await(
/// exited successfully.
fn run_oneshot_interactive(args: &[String], timeout: Duration) -> Result<bool> {
let bin = resolve_cloudflared();
let mut child = Command::new(&bin)
let mut command = Command::new(&bin);
command
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stderr(Stdio::inherit());
let mut child = crate::platform::process::hide_console(&mut command)
.spawn()
.with_context(|| format!("failed to spawn cloudflared ({})", bin.display()))?;
+66 -36
View File
@@ -72,18 +72,28 @@ fn resolve_from_running_exe() -> BundledForgeCliPaths {
fn resolve_for_exe(exe: &Path) -> Option<BundledForgeCliPaths> {
let exe_dir = exe.parent()?;
let contents_dir = exe_dir.parent()?;
let resources_dir = contents_dir.join("Resources");
// Resource layouts differ per platform: Windows (NSIS/MSI) installs
// resources next to the exe, while macOS puts them in
// `<bundle>/Contents/Resources` (the exe lives in `Contents/MacOS`).
// Probe both roots so the same logic works everywhere.
let mut resource_roots = vec![exe_dir.to_path_buf()];
if let Some(contents_dir) = exe_dir.parent() {
resource_roots.push(contents_dir.join("Resources"));
}
let gh_name = if cfg!(windows) { "gh.exe" } else { "gh" };
let glab_name = if cfg!(windows) { "glab.exe" } else { "glab" };
let gh = resources_dir.join(format!("vendor/gh/{gh_name}"));
let glab = resources_dir.join(format!("vendor/glab/{glab_name}"));
let find = |relative: String| {
resource_roots
.iter()
.map(|root| root.join(&relative))
.find(|path| path.is_file())
};
Some(BundledForgeCliPaths {
gh: gh.is_file().then_some(gh),
glab: glab.is_file().then_some(glab),
gh: find(format!("vendor/gh/{gh_name}")),
glab: find(format!("vendor/glab/{glab_name}")),
})
}
@@ -124,28 +134,54 @@ fn resolve_for_dev_workspace(workspace_root: &Path) -> BundledForgeCliPaths {
mod tests {
use super::*;
fn gh_name() -> &'static str {
if cfg!(windows) {
"gh.exe"
} else {
"gh"
}
}
fn glab_name() -> &'static str {
if cfg!(windows) {
"glab.exe"
} else {
"glab"
}
}
fn write_vendor_binaries(vendor: &Path) {
std::fs::create_dir_all(vendor.join("gh")).unwrap();
std::fs::create_dir_all(vendor.join("glab")).unwrap();
std::fs::write(vendor.join("gh").join(gh_name()), "").unwrap();
std::fs::write(vendor.join("glab").join(glab_name()), "").unwrap();
}
#[test]
fn resolve_finds_binaries_under_resources_vendor() {
let root = tempfile::tempdir().unwrap();
let exe = root.path().join("Helmor.app/Contents/MacOS/Helmor");
let vendor = root.path().join("Helmor.app/Contents/Resources/vendor");
std::fs::create_dir_all(vendor.join("gh")).unwrap();
std::fs::create_dir_all(vendor.join("glab")).unwrap();
std::fs::write(vendor.join("gh/gh"), "").unwrap();
std::fs::write(vendor.join("glab/glab"), "").unwrap();
write_vendor_binaries(&vendor);
let paths = resolve_for_exe(&exe).unwrap();
assert_eq!(
paths.gh.unwrap(),
root.path()
.join("Helmor.app/Contents/Resources/vendor/gh/gh")
);
assert_eq!(
paths.glab.unwrap(),
root.path()
.join("Helmor.app/Contents/Resources/vendor/glab/glab")
);
assert_eq!(paths.gh.unwrap(), vendor.join("gh").join(gh_name()));
assert_eq!(paths.glab.unwrap(), vendor.join("glab").join(glab_name()));
}
#[test]
fn resolve_finds_binaries_next_to_exe() {
// Windows (NSIS/MSI) layout: resources installed beside the exe.
let root = tempfile::tempdir().unwrap();
let exe = root.path().join("Helmor/helmor.exe");
let vendor = root.path().join("Helmor/vendor");
write_vendor_binaries(&vendor);
let paths = resolve_for_exe(&exe).unwrap();
assert_eq!(paths.gh.unwrap(), vendor.join("gh").join(gh_name()));
assert_eq!(paths.glab.unwrap(), vendor.join("glab").join(glab_name()));
}
#[test]
@@ -162,15 +198,12 @@ mod tests {
fn resolve_finds_debug_vendor_under_workspace_root() {
let root = tempfile::tempdir().unwrap();
let vendor = root.path().join("sidecar/dist/vendor");
std::fs::create_dir_all(vendor.join("gh")).unwrap();
std::fs::create_dir_all(vendor.join("glab")).unwrap();
std::fs::write(vendor.join("gh/gh"), "").unwrap();
std::fs::write(vendor.join("glab/glab"), "").unwrap();
write_vendor_binaries(&vendor);
let paths = resolve_for_dev_workspace(root.path());
assert_eq!(paths.gh.unwrap(), vendor.join("gh/gh"));
assert_eq!(paths.glab.unwrap(), vendor.join("glab/glab"));
assert_eq!(paths.gh.unwrap(), vendor.join("gh").join(gh_name()));
assert_eq!(paths.glab.unwrap(), vendor.join("glab").join(glab_name()));
}
#[cfg(debug_assertions)]
@@ -180,20 +213,17 @@ mod tests {
let exe = root.path().join("Helmor.app/Contents/MacOS/Helmor");
let app_vendor = root.path().join("Helmor.app/Contents/Resources/vendor");
let dev_vendor = root.path().join("sidecar/dist/vendor");
std::fs::create_dir_all(app_vendor.join("gh")).unwrap();
std::fs::create_dir_all(app_vendor.join("glab")).unwrap();
std::fs::create_dir_all(dev_vendor.join("gh")).unwrap();
std::fs::create_dir_all(dev_vendor.join("glab")).unwrap();
std::fs::write(app_vendor.join("gh/gh"), "").unwrap();
std::fs::write(app_vendor.join("glab/glab"), "").unwrap();
std::fs::write(dev_vendor.join("gh/gh"), "").unwrap();
std::fs::write(dev_vendor.join("glab/glab"), "").unwrap();
write_vendor_binaries(&app_vendor);
write_vendor_binaries(&dev_vendor);
let paths = resolve_for_exe(&exe)
.unwrap()
.with_fallback(resolve_for_dev_workspace(root.path()));
assert_eq!(paths.gh.unwrap(), app_vendor.join("gh/gh"));
assert_eq!(paths.glab.unwrap(), app_vendor.join("glab/glab"));
assert_eq!(paths.gh.unwrap(), app_vendor.join("gh").join(gh_name()));
assert_eq!(
paths.glab.unwrap(),
app_vendor.join("glab").join(glab_name())
);
}
}
+5 -4
View File
@@ -100,6 +100,7 @@ where
use std::os::unix::process::CommandExt;
command.process_group(0);
}
crate::platform::process::hide_console(&mut command);
let child = command.spawn()?;
let child_pid = child.id();
@@ -148,9 +149,9 @@ fn kill_process(child_pid: u32) {
#[cfg(not(unix))]
fn kill_process(child_pid: u32) {
let pid = child_pid.to_string();
let _ = Command::new("taskkill")
.args(["/PID", pid.as_str(), "/T", "/F"])
.status();
let mut cmd = Command::new("taskkill");
cmd.args(["/PID", pid.as_str(), "/T", "/F"]);
let _ = crate::platform::process::hide_console(&mut cmd).status();
}
pub(crate) fn command_detail(output: &CommandOutput) -> String {
@@ -169,10 +170,10 @@ pub(crate) fn command_detail(output: &CommandOutput) -> String {
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn run_command_with_timeout_kills_stalled_command() {
let started_at = std::time::Instant::now();
+5 -2
View File
@@ -90,6 +90,7 @@ where
command.current_dir(current_dir);
}
crate::platform::process::hide_console(&mut command);
let output = command.output().context("Failed to run git")?;
handle_git_output(output)
}
@@ -116,6 +117,7 @@ where
command.current_dir(current_dir);
}
crate::platform::process::hide_console(&mut command);
let output = command.output().context("Failed to run git")?;
handle_git_output_raw(output)
}
@@ -1407,8 +1409,9 @@ pub fn stash_push_include_untracked(workspace_dir: &Path, message: &str) -> Resu
/// entry intact (git's default), so the caller / agent can retry.
pub fn stash_pop(workspace_dir: &Path) -> Result<StashPopOutcome> {
let workspace_dir_arg = workspace_dir.display().to_string();
let output = Command::new("git")
.args(["-C", workspace_dir_arg.as_str(), "stash", "pop"])
let mut command = Command::new("git");
command.args(["-C", workspace_dir_arg.as_str(), "stash", "pop"]);
let output = crate::platform::process::hide_console(&mut command)
.output()
.with_context(|| format!("Failed to git stash pop in {}", workspace_dir.display()))?;
if output.status.success() {
+3 -3
View File
@@ -823,9 +823,9 @@ mod tests {
use std::process::Command;
fn git(repo: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(repo)
let mut cmd = Command::new("git");
cmd.args(args).current_dir(repo);
let output = crate::platform::process::hide_console(&mut cmd)
.output()
.unwrap_or_else(|e| panic!("git {args:?} failed: {e}"));
assert!(
+9 -10
View File
@@ -648,16 +648,15 @@ fn resolve_source_branch(
let conductor_ws = root.join("workspaces").join(repo_name).join(directory_name);
if conductor_ws.is_dir() {
if let Ok(output) = std::process::Command::new("git")
.args([
"-C",
&conductor_ws.display().to_string(),
"rev-parse",
"--abbrev-ref",
"HEAD",
])
.output()
{
let mut command = std::process::Command::new("git");
command.args([
"-C",
&conductor_ws.display().to_string(),
"rev-parse",
"--abbrev-ref",
"HEAD",
]);
if let Ok(output) = crate::platform::process::hide_console(&mut command).output() {
let actual = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !actual.is_empty()
&& actual != "HEAD"
+1
View File
@@ -28,6 +28,7 @@ pub(super) async fn run_in(
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
crate::platform::process::hide_console_tokio(&mut cmd);
let child = cmd
.spawn()
.map_err(|e| anyhow!("spawn {BIN} failed: {e}"))?;
+13 -11
View File
@@ -141,12 +141,14 @@ pub fn spawn(args: SpawnArgs) -> Result<ServerInstance> {
"Starting bundled llama-server"
);
let mut child = Command::new(&bin)
let mut command = Command::new(&bin);
command
.args(&full_args)
.env("HF_HOME", &args.hf_home)
.env("LLAMA_CACHE", args.hf_home.join("llama.cpp"))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stderr(Stdio::piped());
let mut child = crate::platform::process::hide_console(&mut command)
.spawn()
.with_context(|| format!("spawn bundled llama-server at {}", bin.display()))?;
@@ -286,9 +288,10 @@ pub fn sweep_orphan_pid(pid_path: &Path, tag: &str) {
let _ = fs::remove_file(pid_path);
return;
};
let mut term_cmd = Command::new("kill");
term_cmd.args(["-TERM", &pid.to_string()]);
if is_llama_server_pid(pid)
&& Command::new("kill")
.args(["-TERM", &pid.to_string()])
&& crate::platform::process::hide_console(&mut term_cmd)
.status()
.is_ok()
{
@@ -296,19 +299,18 @@ pub fn sweep_orphan_pid(pid_path: &Path, tag: &str) {
// Give it a moment, then SIGKILL if still alive.
std::thread::sleep(Duration::from_millis(300));
if is_llama_server_pid(pid) {
let _ = Command::new("kill")
.args(["-KILL", &pid.to_string()])
.status();
let mut kill_cmd = Command::new("kill");
kill_cmd.args(["-KILL", &pid.to_string()]);
let _ = crate::platform::process::hide_console(&mut kill_cmd).status();
}
}
let _ = fs::remove_file(pid_path);
}
fn is_llama_server_pid(pid: i32) -> bool {
let Ok(output) = Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm="])
.output()
else {
let mut ps_cmd = Command::new("ps");
ps_cmd.args(["-p", &pid.to_string(), "-o", "comm="]);
let Ok(output) = crate::platform::process::hide_console(&mut ps_cmd).output() else {
return false;
};
if !output.status.success() {
+185
View File
@@ -0,0 +1,185 @@
//! Where and how the managed `helmor` CLI entrypoint is installed.
//!
//! - **Unix:** a symlink at `/usr/local/bin/<name>` pointing at the bundled
//! `helmor-cli` inside the app bundle (creation lives in
//! `commands/system_commands.rs`, which owns the elevation flow).
//! - **Windows:** a `<name>.cmd` shim in `%LOCALAPPDATA%\Helmor\bin` that
//! forwards to the bundled `helmor-cli.exe`, with that bin dir registered
//! on the **user** `PATH` (`HKCU\Environment`) once. A shim (rather than a
//! copy) keeps the CLI auto-tracking app upgrades, mirroring what the Unix
//! symlink achieves.
use std::path::PathBuf;
/// Default install path of the user-facing CLI entrypoint (`helmor`, or
/// `helmor-dev` for debug builds).
pub fn install_target(cli_name: &str) -> PathBuf {
#[cfg(windows)]
{
shim_dir().join(format!("{cli_name}.cmd"))
}
#[cfg(not(windows))]
{
PathBuf::from(format!("/usr/local/bin/{cli_name}"))
}
}
/// `%LOCALAPPDATA%\Helmor\bin` — beside the NSIS per-user install dir, so an
/// uninstall that clears `%LOCALAPPDATA%\Helmor` takes the shim with it.
#[cfg(windows)]
pub fn shim_dir() -> PathBuf {
std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.or_else(|| super::home_dir().map(|home| home.join("AppData").join("Local")))
.unwrap_or_default()
.join("Helmor")
.join("bin")
}
/// Write the `.cmd` shim and make sure its directory is on the user `PATH`.
#[cfg(windows)]
pub fn write_shim(
bundled_cli: &std::path::Path,
install_path: &std::path::Path,
) -> anyhow::Result<()> {
use anyhow::Context;
// `%*` forwards all arguments; quoting handles spaces in the install dir.
let content = format!("@echo off\r\n\"{}\" %*\r\n", bundled_cli.display());
std::fs::write(install_path, content)
.with_context(|| format!("Failed to write CLI shim at {}", install_path.display()))?;
if let Some(dir) = install_path.parent() {
ensure_user_path_contains(dir)?;
}
Ok(())
}
/// Extract the target executable a `.cmd` shim forwards to, if the file
/// looks like one of ours. Used to classify Managed vs. Stale.
#[cfg(windows)]
pub fn read_shim_target(install_path: &std::path::Path) -> Option<PathBuf> {
let content = std::fs::read_to_string(install_path).ok()?;
for line in content.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix('"') {
if let Some(end) = rest.find('"') {
return Some(PathBuf::from(&rest[..end]));
}
}
}
None
}
/// Append `dir` to the user `PATH` (`HKCU\Environment`) if it isn't already
/// there, preserving `REG_EXPAND_SZ` semantics, then broadcast
/// `WM_SETTINGCHANGE` so newly spawned shells see it. Existing shells keep
/// their environment — that's inherent to Windows, not a bug here.
#[cfg(windows)]
fn ensure_user_path_contains(dir: &std::path::Path) -> anyhow::Result<()> {
use anyhow::Context;
use winreg::enums::{RegType, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE};
use winreg::{RegKey, RegValue};
let env = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)
.context("Failed to open HKCU\\Environment")?;
let current: String = env.get_value("Path").unwrap_or_default();
let already_present = std::env::split_paths(&std::ffi::OsString::from(&current))
.any(|entry| entry.as_os_str().eq_ignore_ascii_case(dir.as_os_str()));
if already_present {
return Ok(());
}
let dir = dir.display();
let next = if current.is_empty() {
dir.to_string()
} else {
format!("{};{dir}", current.trim_end_matches(';'))
};
// Write as REG_EXPAND_SZ: user PATH conventionally holds %VAR% references
// and a plain REG_SZ write would stop them expanding.
let mut bytes: Vec<u8> = next
.encode_utf16()
.chain(std::iter::once(0))
.flat_map(u16::to_le_bytes)
.collect();
env.set_raw_value(
"Path",
&RegValue {
bytes: std::mem::take(&mut bytes),
vtype: RegType::REG_EXPAND_SZ,
},
)
.context("Failed to update user PATH")?;
broadcast_environment_change();
tracing::info!(dir = %dir, "Added CLI shim directory to user PATH");
Ok(())
}
/// Tell running apps (notably Explorer, which parents new terminals) that the
/// environment changed. Fire-and-forget; a timeout just means some app was
/// hung.
#[cfg(windows)]
fn broadcast_environment_change() {
use windows::Win32::Foundation::{LPARAM, WPARAM};
use windows::Win32::UI::WindowsAndMessaging::{
SendMessageTimeoutW, HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE,
};
let payload: Vec<u16> = "Environment\0".encode_utf16().collect();
unsafe {
SendMessageTimeoutW(
HWND_BROADCAST,
WM_SETTINGCHANGE,
WPARAM(0),
LPARAM(payload.as_ptr() as isize),
SMTO_ABORTIFHUNG,
2000,
None,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_target_uses_cli_name() {
let target = install_target("helmor-dev");
let name = target.file_name().unwrap().to_string_lossy();
assert!(
name == "helmor-dev" || name == "helmor-dev.cmd",
"unexpected target name: {name}"
);
}
#[cfg(windows)]
#[test]
fn shim_round_trips_target_path() {
let dir = tempfile::tempdir().unwrap();
let bundled = dir.path().join("App Dir").join("helmor-cli.exe");
std::fs::create_dir_all(bundled.parent().unwrap()).unwrap();
std::fs::write(&bundled, "").unwrap();
let shim = dir.path().join("helmor.cmd");
// Write the shim body directly (write_shim would also touch the
// registry, which a unit test must not do).
let content = format!("@echo off\r\n\"{}\" %*\r\n", bundled.display());
std::fs::write(&shim, content).unwrap();
assert_eq!(read_shim_target(&shim).unwrap(), bundled);
}
#[cfg(windows)]
#[test]
fn read_shim_target_rejects_foreign_files() {
let dir = tempfile::tempdir().unwrap();
let shim = dir.path().join("helmor.cmd");
std::fs::write(&shim, "@echo off\r\nrem not ours\r\n").unwrap();
assert_eq!(read_shim_target(&shim), None);
}
}
+1
View File
@@ -4,6 +4,7 @@
//! it so call sites stay platform-agnostic. Submodules are added per phase of
//! the Windows port (see `docs/superpowers/plans/2026-06-07-windows-port.md`).
pub mod cli_install;
pub mod fs;
pub mod ipc;
pub mod process;
+30 -3
View File
@@ -16,6 +16,36 @@
use std::process::Command;
/// `CREATE_NO_WINDOW`: don't pop a console window for child CLIs.
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Suppress the console window for a child CLI on Windows; no-op on Unix.
///
/// Unlike [`configure_new_group`] this has no process-group side effects, so
/// it is the right wrapper for **short-lived, one-shot** commands (`git`,
/// `gh`, `glab`, system probes, …). Without it every spawn on Windows flashes
/// a console window — and because the console steals focus from the main
/// window, focus-driven refetches (`refetchOnWindowFocus: "always"`) re-fire
/// when it closes, producing a self-sustaining spawn loop.
pub fn hide_console(cmd: &mut Command) -> &mut Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
/// [`hide_console`] for `tokio::process::Command`.
pub fn hide_console_tokio(cmd: &mut tokio::process::Command) -> &mut tokio::process::Command {
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
/// Configure `cmd` so the spawned child can later be torn down as a whole tree.
///
/// Call this on the `Command` *before* `spawn()`. On Unix it makes the child a
@@ -31,8 +61,6 @@ pub fn configure_new_group(cmd: &mut Command) -> &mut Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW (0x08000000): don't pop a console for child CLIs.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
@@ -113,7 +141,6 @@ pub fn pid_alive(pid: u32) -> bool {
#[cfg(windows)]
fn taskkill(pid: u32, force: bool) -> std::io::Result<std::process::ExitStatus> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let pid = pid.to_string();
let mut cmd = Command::new("taskkill");
cmd.args(["/PID", pid.as_str(), "/T"]);
@@ -46,6 +46,7 @@ pub(super) fn run_claude_auth_status() -> Result<()> {
// the human-readable mode would still produce parseable JSON here.
let mut cmd = Command::new("claude");
cmd.args(["auth", "status", "--json"]);
crate::platform::process::hide_console(&mut cmd);
let status = wait_with_timeout(&mut cmd, DELEGATED_REFRESH_TIMEOUT)
.ok_or_else(|| anyhow!("`claude auth status` timed out or failed to spawn"))?;
@@ -31,10 +31,10 @@ pub(super) fn claude_code_user_agent() -> String {
}
fn probe_claude_version() -> Option<String> {
let output = run_with_timeout(
Command::new("claude").args(["--allowed-tools", "", "--version"]),
CLAUDE_VERSION_PROBE_TIMEOUT,
)?;
let mut cmd = Command::new("claude");
cmd.args(["--allowed-tools", "", "--version"]);
crate::platform::process::hide_console(&mut cmd);
let output = run_with_timeout(&mut cmd, CLAUDE_VERSION_PROBE_TIMEOUT)?;
parse_claude_version_output(&output)
}
+4 -4
View File
@@ -54,12 +54,12 @@ pub async fn detect_all() -> Vec<SourceHealth> {
async fn detect_lark() -> SourceHealth {
// Phase 1: PATH probe.
let spawn = Command::new("lark-cli")
.arg("--version")
let mut cmd = Command::new("lark-cli");
cmd.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
.stderr(std::process::Stdio::null());
let spawn = crate::platform::process::hide_console_tokio(&mut cmd).spawn();
let mut child = match spawn {
Ok(c) => c,
Err(e) if e.kind() == ErrorKind::NotFound => {
+4 -3
View File
@@ -1085,7 +1085,8 @@ pub(crate) fn run_archive_hook_inner(
let (shell, shell_flag) = archive_shell();
tracing::info!(workspace_id, script = %script, shell = %shell, "Running archive hook");
let status = Command::new(&shell)
let mut command = Command::new(&shell);
command
.arg(shell_flag)
.arg(&script)
.current_dir(workspace_dir)
@@ -1095,8 +1096,8 @@ pub(crate) fn run_archive_hook_inner(
.env(
"HELMOR_DEFAULT_BRANCH",
record.default_branch.as_deref().unwrap_or("main"),
)
.status();
);
let status = crate::platform::process::hide_console(&mut command).status();
match status {
Ok(s) if s.success() => ArchiveHookOutcome::Success,