mirror of
https://github.com/dohooo/helmor.git
synced 2026-09-22 16:40:02 +08:00
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:
Generated
+77
@@ -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"
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(¤t))
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user