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 91cff463cd0..154f48d61c3 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 @@ -72,11 +72,11 @@ class CodeWhispererConfigurable(private val project: Project) : textFieldWithBrowseButton(fileChooserDescriptor = fileChooserDescriptor) .bindText( - { LspSettings.getInstance().getArtifactPath() }, - { LspSettings.getInstance().setArtifactPath(it.takeIf { v -> v.isNotBlank() }) } + { LspSettings.getInstance().getArtifactPath().orEmpty() }, + { LspSettings.getInstance().setArtifactPath(it) } ) .applyToComponent { - emptyText.text = "Choose a file to upload" + emptyText.text = message("executableCommon.auto_managed") } .resizableColumn() .align(Align.FILL) 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 517ccec8539..8a2e905f8b2 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 @@ -18,6 +18,7 @@ import com.intellij.openapi.components.serviceIfCreated import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Key +import com.intellij.openapi.util.SystemInfo import com.intellij.util.io.await import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -45,6 +46,7 @@ import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.isDeveloperMode +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.DefaultModuleDependenciesService import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager @@ -53,6 +55,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument.TextDoc import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata +import software.aws.toolkits.jetbrains.settings.LspSettings import java.io.IOException import java.io.OutputStreamWriter import java.io.PipedInputStream @@ -245,8 +248,12 @@ private class AmazonQServerInstance(private val project: Project, private val cs } init { + // will cause slow service init, but maybe fine for now. will not block UI since fetch/extract will be under background progress + val artifact = runBlocking { ArtifactManager(project, manifestRange = null).fetchArtifact() }.toAbsolutePath() + val node = if (SystemInfo.isWindows) "node.exe" else "node" val cmd = GeneralCommandLine( - "amazon-q-lsp", + artifact.resolve(node).toString(), + LspSettings.getInstance().getArtifactPath() ?: artifact.resolve("aws-lsp-codewhisperer.js").toString(), "--stdio", "--set-credentials-encryption-key", ) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt index 0c361fe0154..7724a8c2255 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt @@ -7,16 +7,18 @@ import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.util.text.StringUtil import com.intellij.util.io.DigestUtil import com.intellij.util.system.CpuArch +import software.aws.toolkits.core.utils.ZIP_PROPERTY_POSIX import software.aws.toolkits.core.utils.createParentDirectories import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.hasPosixFilePermissions import java.io.FileNotFoundException -import java.io.FileOutputStream +import java.net.URI +import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.nio.file.StandardCopyOption import java.security.MessageDigest -import java.util.zip.ZipFile import kotlin.io.path.isDirectory import kotlin.io.path.listDirectoryEntries @@ -78,17 +80,20 @@ fun extractZipFile(zipFilePath: Path, destDir: Path) { } try { - ZipFile(zipFilePath.toFile()).use { zipFile -> - zipFile.entries() - .asSequence() - .filterNot { it.isDirectory } - .map { zipEntry -> - val destPath = destDir.resolve(zipEntry.name) - destPath.createParentDirectories() - FileOutputStream(destPath.toFile()).use { targetFile -> - zipFile.getInputStream(zipEntry).copyTo(targetFile) + FileSystems.newFileSystem( + // jar prefix due to potentially ambiguous resolution to wrong fs impl for zipfs on windows + URI("jar:${zipFilePath.toUri()}"), + mapOf(ZIP_PROPERTY_POSIX to destDir.hasPosixFilePermissions()) + ).use { zipfs -> + Files.walk(zipfs.getPath("/")).use { paths -> + paths + .filter { !it.isDirectory() } + .forEach { zipEntry -> + val destPath = Paths.get(destDir.toString(), zipEntry.toString()) + destPath.createParentDirectories() + Files.copy(zipEntry, destPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES) } - }.toList() + } } } catch (e: Exception) { throw LspException("Failed to extract zip file: ${e.message}", LspException.ErrorCode.UNZIP_FAILED, cause = e) 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 8ac9d527651..61060774e79 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 @@ -10,7 +10,7 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage import com.intellij.openapi.components.service -import com.intellij.util.xmlb.annotations.Property +import com.intellij.util.text.nullize @Service @State(name = "lspSettings", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)]) @@ -26,11 +26,7 @@ class LspSettings : PersistentStateComponent { fun getArtifactPath() = state.artifactPath fun setArtifactPath(artifactPath: String?) { - if (artifactPath == null) { - state.artifactPath = "" - } else { - state.artifactPath = artifactPath - } + state.artifactPath = artifactPath.nullize(nullizeSpaces = true) } companion object { @@ -39,6 +35,5 @@ class LspSettings : PersistentStateComponent { } class LspConfiguration : BaseState() { - @get:Property - var artifactPath: String = "" + var artifactPath by string() } diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtilsTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtilsTest.kt new file mode 100644 index 00000000000..607b94f34bf --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtilsTest.kt @@ -0,0 +1,103 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.testFramework.utils.io.createDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import software.aws.toolkits.core.utils.ZIP_PROPERTY_POSIX +import software.aws.toolkits.core.utils.hasPosixFilePermissions +import software.aws.toolkits.core.utils.putNextEntry +import software.aws.toolkits.core.utils.test.assertPosixPermissions +import software.aws.toolkits.core.utils.writeText +import software.aws.toolkits.jetbrains.utils.satisfiesKt +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermissions +import java.util.zip.ZipOutputStream +import kotlin.io.path.isRegularFile +import kotlin.io.path.setPosixFilePermissions + +class LspUtilsTest { + @Test + fun `extractZipFile works`(@TempDir tempDir: Path) { + val source = tempDir.resolve("source").also { it.createDirectory() } + val target = tempDir.resolve("target").also { it.createDirectory() } + + source.resolve("file1").writeText("contents1") + source.resolve("file2").writeText("contents2") + source.resolve("file3").writeText("contents3") + + val sourceZip = tempDir.resolve("source.zip") + ZipOutputStream(Files.newOutputStream(sourceZip)).use { zip -> + Files.walk(source).use { paths -> + paths + .filter { it.isRegularFile() } + .forEach { + zip.putNextEntry(source.relativize(it).toString(), it) + } + val precedingSlashFile = source.resolve("file4").also { it.writeText("contents4") } + zip.putNextEntry("/${source.relativize(precedingSlashFile)}", precedingSlashFile) + } + } + + extractZipFile(sourceZip, target) + + assertThat(target).satisfiesKt { + val files = Files.list(it).use { stream -> stream.toList() } + assertThat(files.size).isEqualTo(4) + assertThat(target.resolve("file1")).hasContent("contents1") + assertThat(target.resolve("file2")).hasContent("contents2") + assertThat(target.resolve("file3")).hasContent("contents3") + assertThat(target.resolve("file4")).hasContent("contents4") + } + } + + @Test + fun `extractZipFile respects posix`(@TempDir tempDir: Path) { + assumeTrue(tempDir.hasPosixFilePermissions()) + + val source = tempDir.resolve("source").also { it.createDirectory() } + val target = tempDir.resolve("target").also { it.createDirectory() } + + source.resolve("regularFile").also { + it.writeText("contents1") + it.setPosixFilePermissions(PosixFilePermissions.fromString("rw-r--r--")) + } + source.resolve("executableFile").also { + it.writeText("contents2") + it.setPosixFilePermissions(PosixFilePermissions.fromString("rwxr-xr-x")) + } + + val sourceZip = tempDir.resolve("source.zip") + FileSystems.newFileSystem( + sourceZip, + mapOf( + "create" to true, + ZIP_PROPERTY_POSIX to true, + ) + ).use { zipfs -> + Files.walk(source).use { paths -> + paths + .filter { it.isRegularFile() } + .forEach { file -> + Files.copy(file, zipfs.getPath("/").resolve(source.relativize(file).toString()), StandardCopyOption.COPY_ATTRIBUTES) + } + } + } + + extractZipFile(sourceZip, target) + + assertThat(target).satisfiesKt { + val files = Files.list(it).use { stream -> stream.toList() } + assertThat(files.size).isEqualTo(2) + assertPosixPermissions(target.resolve("regularFile"), "rw-r--r--") + assertPosixPermissions(target.resolve("executableFile"), "rwxr-xr-x") + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt index 3d1e240beb7..6b9d425ba3d 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt @@ -20,21 +20,27 @@ class LspSettingsTest { } @Test - fun `artifact path is empty by default`() { - assertThat(lspSettings.getArtifactPath()).isEmpty() + fun `artifact path is null by default`() { + assertThat(lspSettings.getArtifactPath()).isNull() } @Test fun `artifact path can be set`() { lspSettings.setArtifactPath("test\\lsp.js") - assertThat(lspSettings.getArtifactPath()).isNotEmpty() - assertThat(lspSettings.getArtifactPath()).isEqualTo("test\\lsp.js") + assertThat(lspSettings.getArtifactPath()) + .isEqualTo("test\\lsp.js") } @Test - fun `artifact path cannot be null`() { - lspSettings.setArtifactPath(null) - assertThat(lspSettings.getArtifactPath()).isEmpty() + fun `empty artifact path is null`() { + lspSettings.setArtifactPath("") + assertThat(lspSettings.getArtifactPath()).isNull() + } + + @Test + fun `blank artifact path is null`() { + lspSettings.setArtifactPath(" ") + assertThat(lspSettings.getArtifactPath()).isNull() } @Test @@ -42,7 +48,7 @@ class LspSettingsTest { val element = xmlElement( """ - + """.trimIndent() ) lspSettings.setArtifactPath("temp\\lsp.js") @@ -51,22 +57,26 @@ class LspSettingsTest { val actual = XMLOutputter().outputString(element) - val expected = "\n" + - "" + // language=XML + val expected = """ + + + """.trimIndent() - assertThat(actual).isEqualTo(expected) + assertThat(actual).isEqualToIgnoringWhitespace(expected) } @Test fun `deserialize empty settings to ensure backwards compatibility`() { val element = xmlElement( """ - - - """ + + + """ ) val actual = XmlSerializer.deserialize(element, LspConfiguration::class.java) - assertThat(actual.artifactPath).isEmpty() + assertThat(actual.artifactPath).isNull() } @Test diff --git a/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt b/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt index 9e209a1a32d..dc4755688b0 100644 --- a/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt +++ b/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt @@ -212,3 +212,6 @@ private fun tryOrLogShortException(log: Logger, block: () -> T) = try { log.warn { "${e::class.simpleName}: ${e.message}" } null } + +// https://github.com/corretto/corretto-21/blob/364eb35886643e504344136075f4a2442d6c0cb0/src/jdk.zipfs/share/classes/jdk/nio/zipfs/ZipFileSystem.java#L90C33-L90C78 +const val ZIP_PROPERTY_POSIX = "enablePosixFileAttributes" diff --git a/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt b/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt index a91ea7b9686..b1229f570ab 100644 --- a/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt +++ b/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt @@ -3,7 +3,6 @@ package software.aws.toolkits.core.utils -import java.io.BufferedInputStream import java.io.ByteArrayInputStream import java.io.IOException import java.io.InputStream @@ -17,7 +16,7 @@ import java.util.zip.ZipOutputStream */ fun ZipOutputStream.putNextEntry(entryName: String, file: Path) { try { - BufferedInputStream(Files.newInputStream(file)).use { inputStream -> + Files.newInputStream(file).buffered().use { inputStream -> putNextEntry(entryName, inputStream) } } catch (e: IOException) { 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 6fd97883b49..94bdea402bf 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -1256,7 +1256,7 @@ ecs.service.not_found=Service {0} not found in cluster {1} ecs.task_definition.json_schema_name=AWS ECS Task Definition ecs.task_definitions=Task Definitions environment.variables.dialog.title=Environment Variables -executableCommon.auto_managed=Managed by AWS Toolkit +executableCommon.auto_managed=Managed by AWS executableCommon.auto_resolved=Auto-detected: {0} executableCommon.cli_not_configured={0} executable not configured executableCommon.configurable.title=External Tools