diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt index e03521841da..4a8d97575a5 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt @@ -83,6 +83,21 @@ class CodeWhispererConfigurable(private val project: Project) : .resizableColumn() .align(Align.FILL) } + row(message("amazonqFeatureDev.placeholder.node_runtime_path")) { + val fileChooserDescriptor = FileChooserDescriptorFactory.createSingleFileDescriptor() + fileChooserDescriptor.isForcedToUseIdeaFileChooser = true + + textFieldWithBrowseButton(fileChooserDescriptor = fileChooserDescriptor) + .bindText( + { LspSettings.getInstance().getNodeRuntimePath().orEmpty() }, + { LspSettings.getInstance().setNodeRuntimePath(it) } + ) + .applyToComponent { + emptyText.text = message("executableCommon.auto_managed") + } + .resizableColumn() + .align(Align.FILL) + } } group(message("aws.settings.codewhisperer.group.general")) { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt index c2a0dd461dc..cb9dd485504 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt @@ -5,16 +5,19 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp import com.google.gson.ToNumberPolicy import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.PathEnvironmentVariableUtil import com.intellij.execution.impl.ExecutionManagerImpl import com.intellij.execution.process.KillableColoredProcessHandler import com.intellij.execution.process.KillableProcessHandler import com.intellij.execution.process.ProcessEvent import com.intellij.execution.process.ProcessListener import com.intellij.execution.process.ProcessOutputType +import com.intellij.notification.NotificationAction import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Key @@ -76,6 +79,8 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceS import software.aws.toolkits.jetbrains.services.amazonq.profile.QDefaultServiceConfig import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata import software.aws.toolkits.jetbrains.settings.LspSettings +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message import java.io.IOException import java.io.OutputStreamWriter import java.io.PipedInputStream @@ -86,7 +91,9 @@ import java.net.Proxy import java.net.URI import java.nio.charset.StandardCharsets import java.nio.file.Files +import java.nio.file.Path import java.util.concurrent.Future +import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.seconds // https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/server/LSPProcessListener.java @@ -372,7 +379,9 @@ private class AmazonQServerInstance(private val project: Project, private val cs } val node = if (SystemInfo.isWindows) "node.exe" else "node" - val cmd = NodeExePatcher.patch(artifact.resolve(node)) + var nodePath = getNodeRuntimePath(artifact.resolve(node)) + + val cmd = NodeExePatcher.patch(nodePath) .withParameters( LspSettings.getInstance().getArtifactPath() ?: artifact.resolve("aws-lsp-codewhisperer.js").toString(), "--stdio", @@ -492,6 +501,103 @@ private class AmazonQServerInstance(private val project: Project, private val cs } } + /** + * Resolves the path to a valid Node.js runtime in the following order of preference: + * 1. Uses the provided nodePath if it exists and is executable + * 2. Uses user-specified runtime path from LSP settings if available + * 3. Uses system Node.js if version 18+ is available + * 4. Falls back to original nodePath with a notification to configure runtime + * + * @param nodePath The initial Node.js runtime path to check, typically from the artifact directory + * @return Path The resolved Node.js runtime path to use for the LSP server + * + * Side effects: + * - Logs warnings if initial runtime path is invalid + * - Logs info when using alternative runtime path + * - Shows notification to user if no valid Node.js runtime is found + * + * Note: The function will return a path even if no valid runtime is found, but the LSP server + * may fail to start in that case. The caller should handle potential runtime initialization failures. + */ + private fun getNodeRuntimePath(nodePath: Path): Path { + if (Files.exists(nodePath) && Files.isExecutable(nodePath)) { + return nodePath + } + // use alternative node runtime if it is not found + LOG.warn { "Node Runtime download failed. Fallback to user specified node runtime " } + // attempt to use user provided node runtime path + val nodeRuntime = LspSettings.getInstance().getNodeRuntimePath() + if (!nodeRuntime.isNullOrEmpty()) { + LOG.info { "Using node from $nodeRuntime " } + return Path.of(nodeRuntime) + } else { + val localNode = locateNodeCommand() + if (localNode != null) { + LOG.info { "Using node from ${localNode.toAbsolutePath()}" } + return localNode + } + notifyInfo( + "Amazon Q", + message("amazonqFeatureDev.placeholder.node_runtime_message"), + project = project, + listOf( + NotificationAction.create( + message("codewhisperer.actions.open_settings.title") + ) { _, notification -> + ShowSettingsUtil.getInstance().showSettingsDialog(project, message("aws.settings.codewhisperer.configurable.title")) + }, + NotificationAction.create( + message("codewhisperer.notification.custom.simple.button.got_it") + ) { _, notification -> notification.expire() } + ) + ) + return nodePath + } + } + + /** + * Locates node executable ≥18 in system PATH. + * Uses IntelliJ's PathEnvironmentVariableUtil to find executables. + * + * @return Path? The absolute path to node ≥18 if found, null otherwise + */ + private fun locateNodeCommand(): Path? { + val exeName = if (SystemInfo.isWindows) "node.exe" else "node" + + return PathEnvironmentVariableUtil.findAllExeFilesInPath(exeName) + .asSequence() + .map { it.toPath() } + .filter { Files.isRegularFile(it) && Files.isExecutable(it) } + .firstNotNullOfOrNull { path -> + try { + val process = ProcessBuilder(path.toString(), "--version") + .redirectErrorStream(true) + .start() + + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroy() + null + } else if (process.exitValue() == 0) { + val version = process.inputStream.bufferedReader().readText().trim() + val majorVersion = version.removePrefix("v").split(".")[0].toIntOrNull() + + if (majorVersion != null && majorVersion >= 18) { + path.toAbsolutePath() + } else { + LOG.debug { "Node version < 18 found at: $path (version: $version)" } + null + } + } else { + LOG.debug { "Failed to get version from node at: $path" } + null + } + } catch (e: Exception) { + LOG.debug(e) { "Failed to check version for node at: $path" } + null + } + } + } + override fun dispose() { if (!launcherFuture.isDone) { try { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/LspSettings.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/LspSettings.kt index 61060774e79..bc199de4917 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/LspSettings.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/LspSettings.kt @@ -25,10 +25,16 @@ class LspSettings : PersistentStateComponent { fun getArtifactPath() = state.artifactPath + fun getNodeRuntimePath() = state.nodeRuntimePath + fun setArtifactPath(artifactPath: String?) { state.artifactPath = artifactPath.nullize(nullizeSpaces = true) } + fun setNodeRuntimePath(nodeRuntimePath: String?) { + state.nodeRuntimePath = nodeRuntimePath.nullize(nullizeSpaces = true) + } + companion object { fun getInstance(): LspSettings = service() } @@ -36,4 +42,5 @@ class LspSettings : PersistentStateComponent { class LspConfiguration : BaseState() { var artifactPath by string() + var nodeRuntimePath by string() } diff --git a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties index d50132769c1..0871d6258f5 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -146,6 +146,8 @@ amazonqFeatureDev.placeholder.downloading_and_extracting_lsp_artifacts=Downloadi amazonqFeatureDev.placeholder.generating_code=Generating code... amazonqFeatureDev.placeholder.lsp=LSP amazonqFeatureDev.placeholder.new_plan=Describe your task or issue in as much detail as possible +amazonqFeatureDev.placeholder.node_runtime_message=Please provide the absolute path of your node js v18+ runtime executable in Settings. Re-open IDE to apply this change. +amazonqFeatureDev.placeholder.node_runtime_path=Node Runtime Path amazonqFeatureDev.placeholder.provide_code_feedback=Provide feedback or comments amazonqFeatureDev.placeholder.select_lsp_artifact=Select LSP Artifact amazonqFeatureDev.placeholder.write_new_prompt=Write a new prompt