copilot: Ensure minimum Node version (#38945)

Smit Barmase created

Closes #38918

Release Notes:

- N/A

Change summary

Cargo.lock                    |  1 
crates/copilot/Cargo.toml     |  1 
crates/copilot/src/copilot.rs | 41 +++++++++++++++++++++++++++++++++++++
3 files changed, 43 insertions(+)

Detailed changes

Cargo.lock 🔗

@@ -3696,6 +3696,7 @@ dependencies = [
  "paths",
  "project",
  "rpc",
+ "semver",
  "serde",
  "serde_json",
  "settings",

crates/copilot/Cargo.toml 🔗

@@ -43,6 +43,7 @@ node_runtime.workspace = true
 parking_lot.workspace = true
 paths.workspace = true
 project.workspace = true
+semver.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true

crates/copilot/src/copilot.rs 🔗

@@ -25,6 +25,7 @@ use node_runtime::{NodeRuntime, VersionStrategy};
 use parking_lot::Mutex;
 use project::DisableAiSettings;
 use request::StatusNotification;
+use semver::Version;
 use serde_json::json;
 use settings::Settings;
 use settings::SettingsStore;
@@ -485,6 +486,8 @@ impl Copilot {
         let start_language_server = async {
             let server_path = get_copilot_lsp(fs, node_runtime.clone()).await?;
             let node_path = node_runtime.binary_path().await?;
+            ensure_node_version_for_copilot(&node_path).await?;
+
             let arguments: Vec<OsString> = vec![server_path.into(), "--stdio".into()];
             let binary = LanguageServerBinary {
                 path: node_path,
@@ -1161,6 +1164,44 @@ async fn clear_copilot_config_dir() {
     remove_matching(copilot_chat::copilot_chat_config_dir(), |_| true).await
 }
 
+async fn ensure_node_version_for_copilot(node_path: &Path) -> anyhow::Result<()> {
+    const MIN_COPILOT_NODE_VERSION: Version = Version::new(20, 8, 0);
+
+    log::info!("Checking Node.js version for Copilot at: {:?}", node_path);
+
+    let output = util::command::new_smol_command(node_path)
+        .arg("--version")
+        .output()
+        .await
+        .with_context(|| format!("checking Node.js version at {:?}", node_path))?;
+
+    if !output.status.success() {
+        anyhow::bail!(
+            "failed to run node --version for Copilot. stdout: {}, stderr: {}",
+            String::from_utf8_lossy(&output.stdout),
+            String::from_utf8_lossy(&output.stderr),
+        );
+    }
+
+    let version_str = String::from_utf8_lossy(&output.stdout);
+    let version = Version::parse(version_str.trim().trim_start_matches('v'))
+        .with_context(|| format!("parsing Node.js version from '{}'", version_str.trim()))?;
+
+    if version < MIN_COPILOT_NODE_VERSION {
+        anyhow::bail!(
+            "GitHub Copilot language server requires Node.js {MIN_COPILOT_NODE_VERSION} or later, but found {version}. \
+            Please update your Node.js version or configure a different Node.js path in settings."
+        );
+    }
+
+    log::info!(
+        "Node.js version {} meets Copilot requirements (>= {})",
+        version,
+        MIN_COPILOT_NODE_VERSION
+    );
+    Ok(())
+}
+
 async fn get_copilot_lsp(fs: Arc<dyn Fs>, node_runtime: NodeRuntime) -> anyhow::Result<PathBuf> {
     const PACKAGE_NAME: &str = "@github/copilot-language-server";
     const SERVER_PATH: &str =