use std::env;
use std::path::{Path, PathBuf};
use tracing::warn;
use crate::{Error, ErrorKind};
pub(crate) fn copilot_binary_with_extract_dir(
extract_dir: Option<&Path>,
use_runtime_wrapper: bool,
) -> Result<PathBuf, Error> {
if let Ok(value) = env::var("COPILOT_CLI_PATH") {
let candidate = PathBuf::from(&value);
if candidate.is_file() {
return Ok(candidate);
}
warn!(
path = %candidate.display(),
"COPILOT_CLI_PATH is set but does not point to a file; falling back"
);
}
#[cfg(feature = "bundled-cli")]
{
let bundled = if use_runtime_wrapper {
match extract_dir {
Some(dir) => crate::embeddedcli::install_runtime_at(dir),
None => crate::embeddedcli::runtime_path(),
}
} else {
match extract_dir {
Some(dir) => crate::embeddedcli::install_at(dir),
None => crate::embeddedcli::path(),
}
};
if let Some(path) = bundled {
if use_runtime_wrapper {
validate_runtime_pair(&path)?;
}
return Ok(path);
}
}
#[cfg(not(feature = "bundled-cli"))]
{
let _ = extract_dir;
if let Some(program) = extracted_program(use_runtime_wrapper) {
return Ok(program);
}
}
let binary_name = if use_runtime_wrapper {
runtime_binary_name()
} else {
cli_binary_name()
};
Err(ErrorKind::BinaryNotFound {
name: binary_name.into(),
hint: Some(
"the Copilot CLI is not bundled in this build of github-copilot-sdk and \
no applicable path override is set. Either keep the default `bundled-cli` cargo \
feature enabled, set COPILOT_CLI_PATH, or supply an explicit path via \
`CliProgram::Path(...)` on `ClientOptions::program`."
.into(),
),
}
.into())
}
#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
fn extracted_program(use_runtime_wrapper: bool) -> Option<PathBuf> {
let version = env!("COPILOT_SDK_CLI_VERSION");
let dir = match env::var_os("COPILOT_CLI_EXTRACT_DIR") {
Some(custom) => PathBuf::from(custom),
None => dirs::cache_dir()
.unwrap_or_else(env::temp_dir)
.join("github-copilot-sdk")
.join("cli")
.join(sanitize_version(version)),
};
let path = dir.join(if use_runtime_wrapper {
runtime_binary_name()
} else {
cli_binary_name()
});
if use_runtime_wrapper {
if validate_runtime_pair(&path).is_ok() {
return Some(path);
}
} else if path.is_file() {
return Some(path);
}
warn!(
path = %path.display(),
"expected build-time-extracted CLI is missing; rebuild the crate or set COPILOT_CLI_PATH"
);
None
}
#[cfg(all(not(feature = "bundled-cli"), not(has_extracted_cli)))]
fn extracted_program(_use_runtime_wrapper: bool) -> Option<PathBuf> {
None
}
fn validate_runtime_pair(wrapper: &Path) -> Result<(), Error> {
let wrapper_valid = wrapper
.metadata()
.map(|metadata| metadata.is_file() && metadata.len() > 0)
.unwrap_or(false);
let runtime_node = wrapper
.parent()
.map(|parent| parent.join("runtime.node"))
.unwrap_or_else(|| PathBuf::from("runtime.node"));
let runtime_valid = runtime_node
.metadata()
.map(|metadata| metadata.is_file() && metadata.len() > 0)
.unwrap_or(false);
if wrapper_valid && runtime_valid {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = wrapper.metadata().map_err(|e| {
Error::with_message(
ErrorKind::InvalidConfig,
format!(
"failed to inspect Copilot runtime wrapper permissions at '{}': {e}",
wrapper.display()
),
)
})?;
if metadata.permissions().mode() & 0o111 == 0 {
let mut permissions = metadata.permissions();
permissions.set_mode(permissions.mode() | 0o111);
std::fs::set_permissions(wrapper, permissions).map_err(|e| {
Error::with_message(
ErrorKind::InvalidConfig,
format!(
"failed to make Copilot runtime wrapper executable at '{}': {e}",
wrapper.display()
),
)
})?;
}
}
return Ok(());
}
let detail = format!(
"The runtime wrapper and its adjacent runtime.node must both be non-empty files; checked '{}' and '{}'",
wrapper.display(),
runtime_node.display()
);
Err(Error::with_message(
ErrorKind::BinaryNotFound {
name: runtime_binary_name().into(),
hint: Some(detail.clone()),
},
detail,
))
}
fn cli_binary_name() -> &'static str {
if cfg!(windows) {
"copilot.exe"
} else {
"copilot"
}
}
fn runtime_binary_name() -> &'static str {
if cfg!(windows) {
"copilot-runtime.exe"
} else {
"copilot-runtime"
}
}
#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
fn sanitize_version(version: &str) -> String {
version
.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c,
_ => '_',
})
.collect()
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::validate_runtime_pair;
#[test]
fn runtime_pair_requires_adjacent_nonempty_runtime_node() {
let dir = tempdir().expect("temp dir");
let wrapper = dir.path().join(if cfg!(windows) {
"copilot-runtime.exe"
} else {
"copilot-runtime"
});
fs::write(&wrapper, b"wrapper").expect("write wrapper");
let error = validate_runtime_pair(&wrapper).expect_err("runtime.node is required");
assert!(error.to_string().contains("runtime.node"));
fs::write(dir.path().join("runtime.node"), b"runtime").expect("write runtime.node");
validate_runtime_pair(&wrapper).expect("complete pair is valid");
}
}