Skip to content

Commit cf85d3f

Browse files
Merge main into feature/dev-execution
2 parents d1079e5 + aec71d9 commit cf85d3f

File tree

8 files changed

+328
-46
lines changed

8 files changed

+328
-46
lines changed

plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,7 @@ cloudformation.explorer.tab.title=CloudFormation
573573
cloudformation.invalid_property=Property {0} has invalid value {1}
574574
cloudformation.key_not_found={0} not found on resource {1}
575575
cloudformation.lsp.action.configure_node=Configure Node.js
576+
cloudformation.lsp.action.download_node=Download Node.js
576577
cloudformation.lsp.error.download_failed=Failed to download CloudFormation LSP. Check your network connection.
577578
cloudformation.lsp.error.extraction_failed=Failed to extract CloudFormation LSP.
578579
cloudformation.lsp.error.hash_mismatch=Downloaded file integrity check failed. The file may be corrupted.

plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<extensions defaultExtensionNs="com.intellij">
66
<applicationService serviceImplementation="software.aws.toolkits.jetbrains.settings.CfnLspSettings"/>
77
<applicationService serviceImplementation="software.aws.toolkits.jetbrains.services.cfnlsp.CfnTelemetryPromptState"/>
8+
<applicationService serviceImplementation="software.aws.toolkits.jetbrains.services.cfnlsp.CfnNodePromptState"/>
89

910
<postStartupActivity implementation="software.aws.toolkits.jetbrains.services.cfnlsp.CfnLspStartupActivity"/>
1011
<postStartupActivity implementation="software.aws.toolkits.jetbrains.services.cfnlsp.CfnTelemetryPrompter"/>
@@ -25,6 +26,7 @@
2526
/>
2627

2728
<notificationGroup id="aws.cfn.telemetry" displayType="STICKY_BALLOON" key="cloudformation.telemetry.prompt.title"/>
29+
<notificationGroup id="aws.cfn.node" displayType="STICKY_BALLOON" key="cloudformation.lsp.error.title"/>
2830

2931
<statusBarWidgetFactory
3032
id="aws.toolkit.cloudformation.operation.status"

plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnCredentialsService.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection
3030
import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener
3131
import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.UpdateCredentialsParams
3232
import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader
33+
import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerSupportProvider
3334
import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager
3435
import software.aws.toolkits.jetbrains.settings.CfnLspSettingsChangeListener
3536
import java.security.SecureRandom
@@ -110,11 +111,13 @@ internal class CfnCredentialsService(private val project: Project) : Disposable
110111
)
111112
}
112113

114+
@Suppress("UnstableApiUsage")
113115
private fun subscribeToSettingsChanges(appBus: com.intellij.util.messages.MessageBusConnection) {
114116
appBus.subscribe(
115117
CfnLspSettingsChangeListener.TOPIC,
116118
CfnLspSettingsChangeListener {
117119
notifyConfigurationChanged()
120+
LspServerManager.getInstance(project).stopAndRestartIfNeeded(CfnLspServerSupportProvider::class.java)
118121
}
119122
)
120123
}

plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerSuppor
1111

1212
internal class CfnLspStartupActivity : StartupActivity {
1313
override fun runActivity(project: Project) {
14+
CfnCredentialsService.getInstance(project) // eagerly initialize to register settings change listener
1415
LspServerManager.getInstance(project).ensureServerStarted(
1516
CfnLspServerSupportProvider::class.java,
1617
CfnLspServerDescriptor.getInstance(project)

plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerSupportProvider.kt

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package software.aws.toolkits.jetbrains.services.cfnlsp.server
55

66
import com.intellij.execution.configurations.GeneralCommandLine
77
import com.intellij.ide.BrowserUtil
8+
import com.intellij.notification.Notification
89
import com.intellij.notification.NotificationAction
10+
import com.intellij.notification.NotificationType
911
import com.intellij.openapi.options.ShowSettingsUtil
1012
import com.intellij.openapi.project.Project
1113
import com.intellij.openapi.vfs.VirtualFile
@@ -25,10 +27,12 @@ import software.aws.toolkits.jetbrains.core.lsp.NodeRuntimeResolver
2527
import software.aws.toolkits.jetbrains.services.cfnlsp.CfnCredentialsService
2628
import software.aws.toolkits.jetbrains.services.cfnlsp.CfnLspExtensionConfig
2729
import software.aws.toolkits.jetbrains.services.cfnlsp.CfnLspServerProtocol
30+
import software.aws.toolkits.jetbrains.services.cfnlsp.CfnNodePromptState
2831
import software.aws.toolkits.jetbrains.settings.AwsSettings
2932
import software.aws.toolkits.jetbrains.settings.CfnLspSettings
3033
import software.aws.toolkits.jetbrains.utils.notifyError
3134
import software.aws.toolkits.resources.AwsToolkitBundle.message
35+
import java.nio.file.Files
3236
import java.nio.file.Path
3337

3438
internal val CFN_SUPPORTED_EXTENSIONS = setOf("yaml", "yml", "json", "template", "cfn", "txt")
@@ -72,10 +76,14 @@ class CfnLspServerDescriptor private constructor(project: Project) :
7276

7377
val nodePath = try {
7478
resolveNodeRuntime()
75-
} catch (e: CfnLspException) {
79+
} catch (e: Exception) {
7680
LOG.warn(e) { "Failed to resolve Node.js runtime" }
7781
notifyNodeError()
78-
throw e
82+
throw (e as? CfnLspException) ?: CfnLspException(
83+
message("cloudformation.lsp.error.node_not_found"),
84+
CfnLspException.ErrorCode.NODE_NOT_FOUND,
85+
e
86+
)
7987
}
8088

8189
LOG.info { "Starting CloudFormation LSP: node=$nodePath, server=$serverPath" }
@@ -88,7 +96,9 @@ class CfnLspServerDescriptor private constructor(project: Project) :
8896
val settings = CfnLspSettings.getInstance()
8997

9098
if (settings.nodeRuntimePath.isNotBlank()) {
91-
return Path.of(settings.nodeRuntimePath)
99+
val configured = Path.of(settings.nodeRuntimePath)
100+
if (Files.isExecutable(configured)) return configured
101+
LOG.warn { "Configured Node.js path is not executable: $configured, falling back to auto-detection" }
92102
}
93103

94104
return NodeRuntimeResolver.resolve()
@@ -116,19 +126,37 @@ class CfnLspServerDescriptor private constructor(project: Project) :
116126
}
117127

118128
private fun notifyNodeError() {
119-
notifyError(
120-
title = message("cloudformation.lsp.error.title"),
121-
content = message("cloudformation.lsp.error.node_not_found"),
122-
project = project,
123-
notificationActions = listOf(
124-
NotificationAction.createSimple("Download Node.js") {
125-
BrowserUtil.browse("https://nodejs.org/en/download")
126-
},
127-
NotificationAction.createSimple(message("cloudformation.lsp.action.configure_node")) {
128-
ShowSettingsUtil.getInstance().showSettingsDialog(project, message("aws.settings.title"))
129-
}
130-
)
129+
val promptState = CfnNodePromptState.getInstance()
130+
if (!promptState.shouldPrompt()) return
131+
132+
var actionTaken = false
133+
134+
val notification = Notification(
135+
"aws.cfn.node",
136+
message("cloudformation.lsp.error.title"),
137+
message("cloudformation.lsp.error.node_not_found"),
138+
NotificationType.WARNING
139+
)
140+
141+
notification.addAction(
142+
NotificationAction.createSimple(message("cloudformation.lsp.action.download_node")) {
143+
BrowserUtil.browse("https://nodejs.org/en/download")
144+
actionTaken = true
145+
}
146+
)
147+
148+
notification.addAction(
149+
NotificationAction.createSimple(message("cloudformation.lsp.action.configure_node")) {
150+
ShowSettingsUtil.getInstance().showSettingsDialog(project, "aws.cloudformation")
151+
actionTaken = true
152+
}
131153
)
154+
155+
notification.whenExpired {
156+
if (!actionTaken) promptState.dismissTemporarily()
157+
}
158+
159+
notification.notify(project)
132160
}
133161

134162
override fun createInitializationOptions(): Any {

plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolver.kt

Lines changed: 135 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,160 @@
33

44
package software.aws.toolkits.jetbrains.core.lsp
55

6+
import com.intellij.execution.configurations.GeneralCommandLine
67
import com.intellij.execution.configurations.PathEnvironmentVariableUtil
78
import com.intellij.execution.util.ExecUtil
89
import com.intellij.openapi.util.SystemInfo
10+
import org.jetbrains.annotations.VisibleForTesting
911
import software.aws.toolkits.core.utils.debug
1012
import software.aws.toolkits.core.utils.getLogger
13+
import java.nio.file.FileSystems
1114
import java.nio.file.Files
1215
import java.nio.file.Path
1316

17+
internal enum class Platform { MAC, LINUX, WINDOWS }
18+
19+
private val BIN_DIR = mapOf(Platform.MAC to "bin/", Platform.LINUX to "bin/", Platform.WINDOWS to "")
20+
private val EXE_NAME = mapOf(Platform.MAC to "node", Platform.LINUX to "node", Platform.WINDOWS to "node.exe")
21+
22+
@VisibleForTesting
23+
internal fun buildWellKnownPaths(platform: Platform, home: Path): List<Path> {
24+
val exeName = EXE_NAME.getValue(platform)
25+
return buildList {
26+
if (platform == Platform.MAC) {
27+
add(Path.of("/opt/homebrew/bin/$exeName"))
28+
add(Path.of("/usr/local/bin/$exeName"))
29+
add(home.resolve(".asdf/shims/$exeName"))
30+
}
31+
if (platform == Platform.LINUX) {
32+
add(Path.of("/usr/bin/$exeName"))
33+
add(Path.of("/usr/local/bin/$exeName"))
34+
add(Path.of("/snap/bin/$exeName"))
35+
add(Path.of("/home/linuxbrew/.linuxbrew/bin/$exeName"))
36+
add(home.resolve(".asdf/shims/$exeName"))
37+
}
38+
if (platform == Platform.WINDOWS) {
39+
add(Path.of("C:/Program Files/nodejs/$exeName"))
40+
add(Path.of("C:/ProgramData/chocolatey/bin/$exeName"))
41+
add(home.resolve("scoop/apps/nodejs/current/$exeName"))
42+
}
43+
}
44+
}
45+
46+
@VisibleForTesting
47+
internal fun buildGlobPatterns(platform: Platform, home: Path, env: (String) -> String?): List<String> {
48+
val exeName = EXE_NAME.getValue(platform)
49+
val bin = BIN_DIR.getValue(platform)
50+
51+
return buildList {
52+
if (platform == Platform.MAC) {
53+
add("/opt/homebrew/Cellar/node*/*/bin/$exeName")
54+
add("/usr/local/Cellar/node*/*/bin/$exeName")
55+
}
56+
57+
// nvm
58+
val nvmDir = env("NVM_DIR")?.let { Path.of(it) } ?: home.resolve(".nvm")
59+
if (platform != Platform.WINDOWS) {
60+
add("$nvmDir/versions/node/v*/bin/$exeName")
61+
} else {
62+
val nvmHome = env("NVM_HOME")?.let { Path.of(it) }
63+
?: env("APPDATA")?.let { Path.of(it, "nvm") }
64+
nvmHome?.let { add("$it/v*/$exeName") }
65+
}
66+
67+
// fnm
68+
val fnmBase = when (platform) {
69+
Platform.MAC -> home.resolve("Library/Application Support/fnm")
70+
Platform.LINUX -> (env("XDG_DATA_HOME")?.let { Path.of(it) } ?: home.resolve(".local/share")).resolve("fnm")
71+
Platform.WINDOWS -> env("APPDATA")?.let { Path.of(it, "fnm") }
72+
}
73+
fnmBase?.let { add("$it/node-versions/v*/installation/${bin}$exeName") }
74+
75+
// volta
76+
val voltaHome = if (platform == Platform.WINDOWS) {
77+
env("LOCALAPPDATA")?.let { Path.of(it, "Volta") }
78+
} else {
79+
home.resolve(".volta")
80+
}
81+
voltaHome?.let { add("$it/tools/image/node/*/${bin}$exeName") }
82+
}
83+
}
84+
85+
/**
86+
* Resolves a Node.js executable across system PATH, well-known install locations,
87+
* and version managers (nvm, fnm, volta). GUI-launched IDEs don't inherit shell
88+
* PATH modifications, so we search common locations directly.
89+
*/
1490
internal object NodeRuntimeResolver {
1591
private val LOG = getLogger<NodeRuntimeResolver>()
92+
private val home: Path = Path.of(System.getProperty("user.home"))
93+
94+
private val platform: Platform = when {
95+
SystemInfo.isMac -> Platform.MAC
96+
SystemInfo.isWindows -> Platform.WINDOWS
97+
else -> Platform.LINUX
98+
}
1699

17-
/**
18-
* Locates a Node.js executable with version >= minVersion.
19-
* Uses IntelliJ's PathEnvironmentVariableUtil to search PATH.
20-
*
21-
* @return Path to valid Node.js executable, or null if not found
22-
*/
23-
fun resolve(minVersion: Int = 18): Path? {
24-
val exeName = if (SystemInfo.isWindows) "node.exe" else "node"
100+
private val exeName = EXE_NAME.getValue(platform)
101+
private val wellKnownPaths: List<Path> = buildWellKnownPaths(platform, home)
102+
private val globPatterns: List<String> by lazy { buildGlobPatterns(platform, home) { System.getenv(it) } }
25103

26-
return PathEnvironmentVariableUtil.findAllExeFilesInPath(exeName)
104+
fun resolve(minVersion: Int = 18): Path? =
105+
resolveFromPath(minVersion) ?: resolveFromWellKnownLocations(minVersion)
106+
107+
private fun resolveFromPath(minVersion: Int): Path? =
108+
PathEnvironmentVariableUtil.findAllExeFilesInPath(exeName)
27109
.asSequence()
28110
.map { it.toPath() }
29111
.filter { Files.isRegularFile(it) && Files.isExecutable(it) }
30-
.firstNotNullOfOrNull { validateVersion(it, minVersion) }
31-
}
112+
.firstNotNullOfOrNull { it.takeIfVersionAtLeast(minVersion) }
32113

33-
private fun validateVersion(path: Path, minVersion: Int): Path? = try {
34-
val output = ExecUtil.execAndGetOutput(
35-
com.intellij.execution.configurations.GeneralCommandLine(path.toString(), "--version"),
36-
5000
37-
)
38-
39-
if (output.exitCode == 0) {
40-
val version = output.stdout.trim()
41-
val majorVersion = version.removePrefix("v").split(".")[0].toIntOrNull()
42-
43-
if (majorVersion != null && majorVersion >= minVersion) {
44-
LOG.debug { "Node $version found at: $path" }
45-
path.toAbsolutePath()
46-
} else {
47-
LOG.debug { "Node version < $minVersion at: $path (version: $version)" }
48-
null
114+
private fun resolveFromWellKnownLocations(minVersion: Int): Path? {
115+
val fromFixed = wellKnownPaths.asSequence()
116+
.filter { Files.isRegularFile(it) && Files.isExecutable(it) }
117+
118+
val fromGlobs = globPatterns.asSequence()
119+
.flatMap { expandGlob(it) }
120+
121+
return (fromFixed + fromGlobs)
122+
.mapNotNull { path ->
123+
val version = path.nodeVersion()
124+
if (version != null && version >= minVersion) version to path.toAbsolutePath() else null
49125
}
126+
.maxByOrNull { it.first }
127+
?.second
128+
}
129+
130+
private fun expandGlob(glob: String): Sequence<Path> {
131+
val parent = Path.of(glob.substringBefore("*")).parent ?: return emptySequence()
132+
if (!Files.isDirectory(parent)) return emptySequence()
133+
134+
val matcher = FileSystems.getDefault().getPathMatcher("glob:$glob")
135+
val depth = glob.removePrefix(parent.toString()).count { it == '/' || it == '\\' } + 1
136+
137+
return Files.walk(parent, depth).use { stream ->
138+
stream
139+
.filter { matcher.matches(it) && Files.isRegularFile(it) && Files.isExecutable(it) }
140+
.toList()
141+
}.asSequence()
142+
}
143+
144+
private fun Path.nodeVersion(): Int? = try {
145+
val output = ExecUtil.execAndGetOutput(GeneralCommandLine(toString(), "--version"), 5000)
146+
if (output.exitCode == 0) output.stdout.trim().removePrefix("v").split(".")[0].toIntOrNull() else null
147+
} catch (e: Exception) {
148+
LOG.debug(e) { "Failed to get version from node at: $this" }
149+
null
150+
}
151+
152+
private fun Path.takeIfVersionAtLeast(minVersion: Int): Path? {
153+
val version = nodeVersion() ?: return null
154+
return if (version >= minVersion) {
155+
LOG.debug { "Node v$version found at: $this" }
156+
toAbsolutePath()
50157
} else {
51-
LOG.debug { "Failed to get version from node at: $path" }
158+
LOG.debug { "Node v$version < $minVersion at: $this" }
52159
null
53160
}
54-
} catch (e: Exception) {
55-
LOG.debug(e) { "Failed to check version for node at: $path" }
56-
null
57161
}
58162
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.cfnlsp
5+
6+
import com.intellij.openapi.components.PersistentStateComponent
7+
import com.intellij.openapi.components.RoamingType
8+
import com.intellij.openapi.components.Service
9+
import com.intellij.openapi.components.State
10+
import com.intellij.openapi.components.Storage
11+
import com.intellij.openapi.components.service
12+
13+
private const val FIFTEEN_DAYS_MS = 15L * 24 * 60 * 60 * 1000
14+
15+
@Service
16+
@State(name = "cfnNodePromptState", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)])
17+
internal class CfnNodePromptState : PersistentStateComponent<CfnNodePromptState.State> {
18+
private var state = State()
19+
20+
override fun getState(): State = state
21+
override fun loadState(state: State) { this.state = state }
22+
23+
fun shouldPrompt(): Boolean {
24+
if (state.lastPromptTime == 0L) return true
25+
return System.currentTimeMillis() - state.lastPromptTime >= FIFTEEN_DAYS_MS
26+
}
27+
28+
fun dismissTemporarily() {
29+
state.lastPromptTime = System.currentTimeMillis()
30+
}
31+
32+
class State(
33+
var lastPromptTime: Long = 0L,
34+
)
35+
36+
companion object {
37+
fun getInstance(): CfnNodePromptState = service()
38+
}
39+
}

0 commit comments

Comments
 (0)