From 3f69e96d9d5104c71f672f0a76b25dae1269fe51 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Mon, 27 Jan 2025 06:47:21 -0800 Subject: [PATCH 1/6] feat(ec2): graduate EC2 out of Experiments #6435 --- packages/core/package.nls.json | 2 +- packages/core/src/awsexplorer/regionNode.ts | 2 - .../core/src/shared/settings-toolkit.gen.ts | 3 +- ...-a7fb8317-77bc-4459-a278-2bc98fd0a5cf.json | 4 ++ packages/toolkit/package.json | 57 +++++-------------- 5 files changed, 20 insertions(+), 48 deletions(-) create mode 100644 packages/toolkit/.changes/next-release/Feature-a7fb8317-77bc-4459-a278-2bc98fd0a5cf.json diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index bdccd0c9b6d..f8130f4ba04 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -20,7 +20,7 @@ "AWS.configuration.description.suppressPrompts": "Prompts which ask for confirmation. Checking an item suppresses the prompt.", "AWS.configuration.enableCodeLenses": "Enable SAM hints in source code and template.yaml files", "AWS.configuration.description.resources.enabledResources": "AWS resources to display in the 'Resources' portion of the explorer.", - "AWS.configuration.description.experiments": "Try experimental features and give feedback. Note that experimental features may be removed at any time.\n * `jsonResourceModification` - Enables basic create, update, and delete support for cloud resources via the JSON Resources explorer component.\n * `ec2RemoteConnect` - Allows interfacing with EC2 instances with options to start, stop, and establish remote connections. Remote connections are done over SSM and can be through a terminal or a remote VSCode window.", + "AWS.configuration.description.experiments": "Try experimental features and give feedback. Note that experimental features may be removed at any time.\n * `jsonResourceModification` - Enables basic create, update, and delete support for cloud resources via the JSON Resources explorer component.", "AWS.stepFunctions.asl.format.enable.desc": "Enables the default formatter used with Amazon States Language files", "AWS.stepFunctions.asl.maxItemsComputed.desc": "The maximum number of outline symbols and folding regions computed (limited for performance reasons).", "AWS.configuration.description.awssam.debug.api": "API Gateway configuration", diff --git a/packages/core/src/awsexplorer/regionNode.ts b/packages/core/src/awsexplorer/regionNode.ts index a5c5bafe104..575da091d04 100644 --- a/packages/core/src/awsexplorer/regionNode.ts +++ b/packages/core/src/awsexplorer/regionNode.ts @@ -30,7 +30,6 @@ import { getEcsRootNode } from '../awsService/ecs/model' import { compareTreeItems, TreeShim } from '../shared/treeview/utils' import { Ec2ParentNode } from '../awsService/ec2/explorer/ec2ParentNode' import { Ec2Client } from '../shared/clients/ec2Client' -import { Experiments } from '../shared/settings' interface ServiceNode { allRegions?: boolean @@ -64,7 +63,6 @@ const serviceCandidates: ServiceNode[] = [ }, { serviceId: 'ec2', - when: () => Experiments.instance.isExperimentEnabled('ec2RemoteConnect'), createFn: (regionCode: string, partitionId: string) => new Ec2ParentNode(regionCode, partitionId, new Ec2Client(regionCode)), }, diff --git a/packages/core/src/shared/settings-toolkit.gen.ts b/packages/core/src/shared/settings-toolkit.gen.ts index 66b7ac75ee9..ea291352701 100644 --- a/packages/core/src/shared/settings-toolkit.gen.ts +++ b/packages/core/src/shared/settings-toolkit.gen.ts @@ -41,8 +41,7 @@ export const toolkitSettings = { "ssoCacheError": {} }, "aws.experiments": { - "jsonResourceModification": {}, - "ec2RemoteConnect": {} + "jsonResourceModification": {} }, "aws.resources.enabledResources": {}, "aws.lambda.recentlyUploaded": {}, diff --git a/packages/toolkit/.changes/next-release/Feature-a7fb8317-77bc-4459-a278-2bc98fd0a5cf.json b/packages/toolkit/.changes/next-release/Feature-a7fb8317-77bc-4459-a278-2bc98fd0a5cf.json new file mode 100644 index 00000000000..ea12f9b0fe3 --- /dev/null +++ b/packages/toolkit/.changes/next-release/Feature-a7fb8317-77bc-4459-a278-2bc98fd0a5cf.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "EC2 is now available in AWS Explorer:\n\n1. Remote-connect VSCode to your EC2 instances.\n2. Open terminal to your EC2 instances.\n3. Start, stop, and visit the Launch page." +} diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 0d0aa9376e6..4578d3070ec 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -240,17 +240,12 @@ "type": "object", "markdownDescription": "%AWS.configuration.description.experiments%", "default": { - "jsonResourceModification": false, - "ec2RemoteConnect": false + "jsonResourceModification": false }, "properties": { "jsonResourceModification": { "type": "boolean", "default": false - }, - "ec2RemoteConnect": { - "type": "boolean", - "default": false } }, "additionalProperties": false @@ -1206,30 +1201,6 @@ { "command": "aws.toolkit.auth.manageConnections" }, - { - "command": "aws.ec2.openRemoteConnection", - "when": "config.aws.experiments.ec2RemoteConnect" - }, - { - "command": "aws.ec2.openTerminal", - "when": "config.aws.experiments.ec2RemoteConnect" - }, - { - "command": "aws.ec2.linkToLaunch", - "when": "config.aws.experiments.ec2RemoteConnect" - }, - { - "command": "aws.ec2.startInstance", - "when": "config.aws.experiments.ec2RemoteConnect" - }, - { - "command": "aws.ec2.stopInstance", - "when": "config.aws.experiments.ec2RemoteConnect" - }, - { - "command": "aws.ec2.rebootInstance", - "when": "config.aws.experiments.ec2RemoteConnect" - }, { "command": "aws.dev.openMenu", "when": "aws.isDevMode || isCloud9" @@ -1453,66 +1424,66 @@ { "command": "aws.ec2.openTerminal", "group": "0@1", - "when": "viewItem =~ /^(awsEc2(Parent|Running)Node)$/ && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem =~ /^(awsEc2(Parent|Running)Node)$/" }, { "command": "aws.ec2.openTerminal", "group": "inline@1", - "when": "viewItem =~ /^(awsEc2(Parent|Running)Node)$/ && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem =~ /^(awsEc2(Parent|Running)Node)$/" }, { "command": "aws.ec2.linkToLaunch", "group": "0@0", - "when": "viewItem =~ /^(awsEc2ParentNode)$/ && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem =~ /^(awsEc2ParentNode)$/" }, { "command": "aws.ec2.linkToLaunch", "group": "inline@0", - "when": "viewItem =~ /^(awsEc2ParentNode)$/ && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem =~ /^(awsEc2ParentNode)$/" }, { "command": "aws.ec2.openRemoteConnection", "group": "0@1", - "when": "viewItem =~ /^(awsEc2(Parent|Running)Node)$/ && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem =~ /^(awsEc2(Parent|Running)Node)$/" }, { "command": "aws.ec2.openRemoteConnection", "group": "inline@1", - "when": "viewItem =~ /^(awsEc2(Parent|Running)Node)$/ && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem =~ /^(awsEc2(Parent|Running)Node)$/" }, { "command": "aws.ec2.startInstance", "group": "0@1", - "when": "viewItem == awsEc2StoppedNode && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem == awsEc2StoppedNode" }, { "command": "aws.ec2.startInstance", "group": "inline@1", - "when": "viewItem == awsEc2StoppedNode && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem == awsEc2StoppedNode" }, { "command": "aws.ec2.stopInstance", "group": "0@1", - "when": "viewItem == awsEc2RunningNode && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem == awsEc2RunningNode" }, { "command": "aws.ec2.stopInstance", "group": "inline@1", - "when": "viewItem == awsEc2RunningNode && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem == awsEc2RunningNode" }, { "command": "aws.ec2.rebootInstance", "group": "0@1", - "when": "viewItem == awsEc2RunningNode && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem == awsEc2RunningNode" }, { "command": "aws.ec2.rebootInstance", "group": "inline@1", - "when": "viewItem == awsEc2RunningNode && config.aws.experiments.ec2RemoteConnect" + "when": "viewItem == awsEc2RunningNode" }, { "command": "aws.ec2.copyInstanceId", - "when": "view == aws.explorer && viewItem =~ /^(awsEc2(Running|Stopped|Pending)Node)$/ && config.aws.experiments.ec2RemoteConnect", + "when": "view == aws.explorer && viewItem =~ /^(awsEc2(Running|Stopped|Pending)Node)$/", "group": "2@0" }, { From e54d549c6cb8d94c3c5765446594da0dd471a28e Mon Sep 17 00:00:00 2001 From: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Date: Mon, 27 Jan 2025 12:58:09 -0500 Subject: [PATCH 2/6] fix(lint): remove extra space so that lint passes. (#6440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Currently seeing the following error on master ``` passes eslint: AssertionError [ERR_ASSERTION]: , /home/runner/work/aws-toolkit-vscode/aws-toolkit-vscode/packages/core/src/shared/clients/s3Client.ts Error: 468:[31](https://github.com/aws/aws-toolkit-vscode/actions/runs/12991583651/job/36229470175#step:6:32) error Delete `·` prettier/prettier` ``` ## Solution - delete the extra space. --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --- packages/core/src/shared/clients/s3Client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/shared/clients/s3Client.ts b/packages/core/src/shared/clients/s3Client.ts index ae530a68fa3..f4375cc6755 100644 --- a/packages/core/src/shared/clients/s3Client.ts +++ b/packages/core/src/shared/clients/s3Client.ts @@ -465,7 +465,7 @@ export class DefaultS3Client { * Set '' as the default prefix to ensure that the bucket's content will be displayed * when the user has at least list access to the root of the bucket. * https://github.com/aws/aws-toolkit-vscode/issues/4643 - * @default '' + * @default '' */ Prefix: request.folderPath ?? defaultPrefix, ContinuationToken: request.continuationToken, From 0d8c8fcb1c5b0540f0548f9b265f81ba9a23d4e9 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Mon, 27 Jan 2025 11:56:42 -0800 Subject: [PATCH 3/6] ci: rename "jscpd" job to "lint-duplicate-code" #6443 ## Problem The "jscpd" CI check is often unnoticed by contributors, possibly because its purpose is not clear. ## Solution Rename it. --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 0cc8025125d..e8c301c18be 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -57,7 +57,7 @@ jobs: - run: npm run testCompile - run: npm run lint - jscpd: + lint-duplicate-code: needs: lint-commits if: ${{ github.event_name == 'pull_request'}} runs-on: ubuntu-latest From 2200b1544d0c0c3e250b8ae2dc16d5f3e87c4270 Mon Sep 17 00:00:00 2001 From: Tom Zu <138054255+tomcat323@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:59:50 -0500 Subject: [PATCH 4/6] feat(release): check for marketplace update (#6426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Our release MCM currently has a step for each extension (Toolkit and Q) that requires the human operator to wait and check for the marketplace publish and then try to install from the marketplace. This uses human time and can easily be automated. ## Solution Added a final stage in release pipeline: https://code.amazon.com/reviews/CR-173945904/revisions/1#/commits that runs this YML build script that automatically installs and uninstalls the extension from marketplace on a two minute interval. When the version matches the new release version, the code-build succeeds: Screenshot 2025-01-23 at 2 44 45 PM When it doesn't, it keeps retrying until the timeout limit is reached (1 hour): Screenshot 2025-01-23 at 4 52 07 PM --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: tomzu --- buildspec/release/70checkmarketplace.yml | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 buildspec/release/70checkmarketplace.yml diff --git a/buildspec/release/70checkmarketplace.yml b/buildspec/release/70checkmarketplace.yml new file mode 100644 index 00000000000..c4c2314b98f --- /dev/null +++ b/buildspec/release/70checkmarketplace.yml @@ -0,0 +1,48 @@ +version: 0.2 + +phases: + install: + runtime-versions: + nodejs: 16 + + commands: + - apt update + - apt install -y wget gpg + - curl -sSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > packages.microsoft.gpg + - install -o root -g root -m 644 packages.microsoft.gpg /etc/apt/trusted.gpg.d/ + - sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/vscode stable main" > /etc/apt/sources.list.d/vscode.list' + - apt update + - apt install -y code + + pre_build: + commands: + # Check for implicit env vars passed from the release pipeline. + - test -n "${TARGET_EXTENSION}" + + build: + commands: + - VERSION=$(node -e "console.log(require('./packages/${TARGET_EXTENSION}/package.json').version);") + # get extension name + - | + if [ "${TARGET_EXTENSION}" = "amazonq" ]; then + extension_name="amazonwebservices.amazon-q-vscode" + elif [ "${TARGET_EXTENSION}" = "toolkit" ]; then + extension_name="amazonwebservices.aws-toolkit-vscode" + else + echo checkmarketplace: "Unknown TARGET_EXTENSION: ${TARGET_EXTENSION}" + exit 1 + fi + # keep reinstalling the extension until the desired version is updated. Otherwise fail on codebuild timeout (1 hour). + - | + while true; do + code --uninstall-extension "${extension_name}" --no-sandbox --user-data-dir /tmp/vscode + code --install-extension ${extension_name} --no-sandbox --user-data-dir /tmp/vscode + cur_version=$(code --list-extensions --show-versions --no-sandbox --user-data-dir /tmp/vscode | grep ${extension_name} | cut -d'@' -f2) + if [ "${cur_version}" = "${VERSION}" ]; then + echo "checkmarketplace: Extension ${extension_name} is updated to version '${cur_version}.'" + break + else + echo "checkmarketplace: Current version '${cur_version}' does not match expected version '${VERSION}'. Retrying..." + fi + sleep 120 # Wait for 2 minutes before retrying + done From 2ff551866b663b89b2470c3408289a6452213765 Mon Sep 17 00:00:00 2001 From: Will Lo <96078566+Will-ShaoHua@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:14:50 -0800 Subject: [PATCH 5/6] config(chat): try opt in implicit `@workspace` context once for treatment group #6217 ## Problem Most users might have disabled workspace context due to previous sub-process performance issue, therefore #6098 experiment has too few datapoints. ## Solution As we've been actively working on fixes to improve the amazon q helper sub-process performance, the team decided to try turn on (only once) workspace context for a small fraction of all users (20%) to collect few more datapoints. --- packages/core/package.nls.json | 2 ++ packages/core/src/shared/featureConfig.ts | 37 +++++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index f8130f4ba04..8f5de139912 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -295,6 +295,7 @@ "AWS.codewhisperer.customization.notification.new_customizations.learn_more": "Learn More", "AWS.amazonq.title": "Amazon Q", "AWS.amazonq.chat": "Chat", + "AWS.amazonq.chat.workspacecontext.enable.message": "Amazon Q: Workspace index is now enabled. You can disable it from Amazon Q settings.", "AWS.amazonq.security": "Code Issues", "AWS.amazonq.login": "Login", "AWS.amazonq.learnMore": "Learn More About Amazon Q", @@ -404,6 +405,7 @@ "AWS.amazonq.doc.pillText.reject": "Reject", "AWS.amazonq.doc.pillText.makeChanges": "Make changes", "AWS.amazonq.inline.invokeChat": "Inline chat", + "AWS.amazonq.opensettings:": "Open settings", "AWS.toolkit.lambda.walkthrough.quickpickTitle": "Application Builder Walkthrough", "AWS.toolkit.lambda.walkthrough.title": "Get started building your application", "AWS.toolkit.lambda.walkthrough.description": "Your quick guide to build an application visually, iterate locally, and deploy to the cloud!", diff --git a/packages/core/src/shared/featureConfig.ts b/packages/core/src/shared/featureConfig.ts index 73eea42bbf3..1ef07968198 100644 --- a/packages/core/src/shared/featureConfig.ts +++ b/packages/core/src/shared/featureConfig.ts @@ -10,6 +10,7 @@ import { ListFeatureEvaluationsResponse, } from '../codewhisperer/client/codewhispereruserclient' import * as vscode from 'vscode' +import * as nls from 'vscode-nls' import { codeWhispererClient as client } from '../codewhisperer/client/codewhisperer' import { AuthUtil } from '../codewhisperer/util/authUtil' import { getLogger } from './logger' @@ -19,7 +20,9 @@ import globals from './extensionGlobals' import { getClientId, getOperatingSystem } from './telemetry/util' import { extensionVersion } from './vscode/env' import { telemetry } from './telemetry' -import { Auth } from '../auth' +import { Commands } from './vscode/commands2' + +const localize = nls.loadMessageBundle() export class FeatureContext { constructor( @@ -35,6 +38,7 @@ export const Features = { customizationArnOverride: 'customizationArnOverride', dataCollectionFeature: 'IDEProjectContextDataCollection', projectContextFeature: 'ProjectContextV2', + workspaceContextFeature: 'WorkspaceContext', test: 'testFeature', } as const @@ -83,6 +87,21 @@ export class FeatureConfigProvider { } } + getWorkspaceContextGroup(): 'control' | 'treatment' { + const variation = this.featureConfigs.get(Features.projectContextFeature)?.variation + + switch (variation) { + case 'CONTROL': + return 'control' + + case 'TREATMENT': + return 'treatment' + + default: + return 'control' + } + } + public async listFeatureEvaluations(): Promise { const request: ListFeatureEvaluationsRequest = { userContext: { @@ -154,12 +173,26 @@ export class FeatureConfigProvider { await vscode.commands.executeCommand('aws.amazonq.refreshStatusBar') } } - if (Auth.instance.isInternalAmazonUser()) { + if (this.getWorkspaceContextGroup() === 'treatment') { // Enable local workspace index by default only once, for Amzn users. const isSet = globals.globalState.get('aws.amazonq.workspaceIndexToggleOn') || false if (!isSet) { await CodeWhispererSettings.instance.enableLocalIndex() globals.globalState.tryUpdate('aws.amazonq.workspaceIndexToggleOn', true) + + await vscode.window + .showInformationMessage( + localize( + 'AWS.amazonq.chat.workspacecontext.enable.message', + 'Amazon Q: Workspace index is now enabled. You can disable it from Amazon Q settings.' + ), + localize('AWS.amazonq.opensettings', 'Open settings') + ) + .then((r) => { + if (r === 'Open settings') { + void Commands.tryExecute('aws.amazonq.configure').then() + } + }) } } } catch (e) { From 7488e0ae602e74b03b7e83ccad27c0899d32f9d2 Mon Sep 17 00:00:00 2001 From: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Date: Tue, 28 Jan 2025 08:23:45 -0500 Subject: [PATCH 6/6] feat(lsp): older and delisted versions of lsp are automatically removed (#6409) ## Problem - Installed LSP artifacts remain forever. There is no cleanup effort made. ## Solution Implement the following heuristic: - Delete all currently installed delisted versions. - Delete all versions that remain, except the most recent 2. Included in this change is a new utility function `partition`. `partition` is like `filter`, but it produces both the positive and negative result in two separate sublists. See the tests for a simple example. --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --- packages/amazonq/src/lsp/lspInstaller.ts | 3 +- .../src/amazonq/lsp/workspaceInstaller.ts | 3 +- packages/core/src/shared/index.ts | 1 + packages/core/src/shared/lsp/lspResolver.ts | 8 +- packages/core/src/shared/lsp/utils/cleanup.ts | 41 ++++++++ packages/core/src/shared/utilities/tsUtils.ts | 15 +++ .../src/test/shared/lsp/utils/cleanup.test.ts | 93 +++++++++++++++++++ .../src/test/shared/utilities/tsUtils.test.ts | 16 ++++ 8 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/shared/lsp/utils/cleanup.ts create mode 100644 packages/core/src/test/shared/lsp/utils/cleanup.test.ts create mode 100644 packages/core/src/test/shared/utilities/tsUtils.test.ts diff --git a/packages/amazonq/src/lsp/lspInstaller.ts b/packages/amazonq/src/lsp/lspInstaller.ts index a087abf395c..72d0746cdcf 100644 --- a/packages/amazonq/src/lsp/lspInstaller.ts +++ b/packages/amazonq/src/lsp/lspInstaller.ts @@ -12,6 +12,7 @@ import { fs, LspResolution, getNodeExecutableName, + cleanLspDownloads, } from 'aws-core-vscode/shared' import path from 'path' @@ -46,7 +47,7 @@ export class AmazonQLSPResolver implements LspResolver { const nodePath = path.join(installationResult.assetDirectory, `servers/${getNodeExecutableName()}`) await fs.chmod(nodePath, 0o755) - // TODO Cleanup old versions of language servers + await cleanLspDownloads(manifest.versions, path.dirname(installationResult.assetDirectory)) return { ...installationResult, resourcePaths: { diff --git a/packages/core/src/amazonq/lsp/workspaceInstaller.ts b/packages/core/src/amazonq/lsp/workspaceInstaller.ts index 6c5d869a70a..c4c688d7bc1 100644 --- a/packages/core/src/amazonq/lsp/workspaceInstaller.ts +++ b/packages/core/src/amazonq/lsp/workspaceInstaller.ts @@ -10,6 +10,7 @@ import { LanguageServerResolver } from '../../shared/lsp/lspResolver' import { Range } from 'semver' import { getNodeExecutableName } from '../../shared/lsp/utils/platform' import { fs } from '../../shared/fs/fs' +import { cleanLspDownloads } from '../../shared' const manifestUrl = 'https://aws-toolkit-language-servers.amazonaws.com/q-context/manifest.json' // this LSP client in Q extension is only going to work with these LSP server versions @@ -30,7 +31,7 @@ export class WorkspaceLSPResolver implements LspResolver { const nodePath = path.join(installationResult.assetDirectory, nodeName) await fs.chmod(nodePath, 0o755) - // TODO Cleanup old versions of language servers + await cleanLspDownloads(manifest.versions, path.basename(installationResult.assetDirectory)) return { ...installationResult, resourcePaths: { diff --git a/packages/core/src/shared/index.ts b/packages/core/src/shared/index.ts index 7cdf3ad12f0..236badac4be 100644 --- a/packages/core/src/shared/index.ts +++ b/packages/core/src/shared/index.ts @@ -63,6 +63,7 @@ export { TabTypeDataMap } from '../amazonq/webview/ui/tabs/constants' export * from './lsp/manifestResolver' export * from './lsp/lspResolver' export * from './lsp/types' +export * from './lsp/utils/cleanup' export { default as request } from './request' export * from './lsp/utils/platform' export * as processUtils from './utilities/processUtils' diff --git a/packages/core/src/shared/lsp/lspResolver.ts b/packages/core/src/shared/lsp/lspResolver.ts index 4959f675986..5a907b96a02 100644 --- a/packages/core/src/shared/lsp/lspResolver.ts +++ b/packages/core/src/shared/lsp/lspResolver.ts @@ -338,9 +338,13 @@ export class LanguageServerResolver { return version.targets.find((x) => x.arch === arch && x.platform === platform) } + // lazy calls to `getApplicationSupportFolder()` to avoid failure on windows. + public static get defaultDir() { + return path.join(getApplicationSupportFolder(), `aws/toolkits/language-servers`) + } + defaultDownloadFolder() { - const applicationSupportFolder = getApplicationSupportFolder() - return path.join(applicationSupportFolder, `aws/toolkits/language-servers/${this.lsName}`) + return path.join(LanguageServerResolver.defaultDir, `${this.lsName}`) } private getDownloadDirectory(version: string) { diff --git a/packages/core/src/shared/lsp/utils/cleanup.ts b/packages/core/src/shared/lsp/utils/cleanup.ts new file mode 100644 index 00000000000..874f56e46ff --- /dev/null +++ b/packages/core/src/shared/lsp/utils/cleanup.ts @@ -0,0 +1,41 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path' +import { LspVersion } from '../types' +import { fs } from '../../../shared/fs/fs' +import { partition } from '../../../shared/utilities/tsUtils' +import { sort } from 'semver' + +async function getDownloadedVersions(installLocation: string) { + return (await fs.readdir(installLocation)).map(([f, _], __) => f) +} + +function isDelisted(manifestVersions: LspVersion[], targetVersion: string): boolean { + return manifestVersions.find((v) => v.serverVersion === targetVersion)?.isDelisted ?? false +} + +/** + * Delete all delisted versions and keep the two newest versions that remain + * @param manifest + * @param downloadDirectory + */ +export async function cleanLspDownloads(manifestVersions: LspVersion[], downloadDirectory: string): Promise { + const downloadedVersions = await getDownloadedVersions(downloadDirectory) + const [delistedVersions, remainingVersions] = partition(downloadedVersions, (v: string) => + isDelisted(manifestVersions, v) + ) + for (const v of delistedVersions) { + await fs.delete(path.join(downloadDirectory, v), { force: true, recursive: true }) + } + + if (remainingVersions.length <= 2) { + return + } + + for (const v of sort(remainingVersions).slice(0, -2)) { + await fs.delete(path.join(downloadDirectory, v), { force: true, recursive: true }) + } +} diff --git a/packages/core/src/shared/utilities/tsUtils.ts b/packages/core/src/shared/utilities/tsUtils.ts index e4fbf5a2b3f..2e4838c5b7a 100644 --- a/packages/core/src/shared/utilities/tsUtils.ts +++ b/packages/core/src/shared/utilities/tsUtils.ts @@ -94,6 +94,21 @@ export function createFactoryFunction any>(cto return (...args) => new ctor(...args) } +/** + * Split a list into two sublists based on the result of a predicate. + * @param lst list to split + * @param pred predicate to apply to each element + * @returns two nested lists, where for all items x in the left sublist, pred(x) returns true. The remaining elements are in the right sublist. + */ +export function partition(lst: T[], pred: (arg: T) => boolean): [T[], T[]] { + return lst.reduce( + ([leftAcc, rightAcc], item) => { + return pred(item) ? [[...leftAcc, item], rightAcc] : [leftAcc, [...rightAcc, item]] + }, + [[], []] as [T[], T[]] + ) +} + type NoSymbols = { [Property in keyof T]: Property extends symbol ? never : Property }[keyof T] export type InterfaceNoSymbol = Pick> /** diff --git a/packages/core/src/test/shared/lsp/utils/cleanup.test.ts b/packages/core/src/test/shared/lsp/utils/cleanup.test.ts new file mode 100644 index 00000000000..377039566de --- /dev/null +++ b/packages/core/src/test/shared/lsp/utils/cleanup.test.ts @@ -0,0 +1,93 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Uri } from 'vscode' +import { cleanLspDownloads, fs } from '../../../../shared' +import { createTestWorkspaceFolder } from '../../../testUtil' +import path from 'path' +import assert from 'assert' + +async function fakeInstallVersion(version: string, installationDir: string): Promise { + const versionDir = path.join(installationDir, version) + await fs.mkdir(versionDir) + await fs.writeFile(path.join(versionDir, 'file.txt'), 'content') +} + +async function fakeInstallVersions(versions: string[], installationDir: string): Promise { + for (const v of versions) { + await fakeInstallVersion(v, installationDir) + } +} + +describe('cleanLSPDownloads', function () { + let installationDir: Uri + + before(async function () { + installationDir = (await createTestWorkspaceFolder()).uri + }) + + afterEach(async function () { + const files = await fs.readdir(installationDir.fsPath) + for (const [name, _type] of files) { + await fs.delete(path.join(installationDir.fsPath, name), { force: true, recursive: true }) + } + }) + + after(async function () { + await fs.delete(installationDir, { force: true, recursive: true }) + }) + + it('keeps two newest versions', async function () { + await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath) + await cleanLspDownloads([], installationDir.fsPath) + + const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename) + assert.strictEqual(result.length, 2) + assert.ok(result.includes('2.1.1')) + assert.ok(result.includes('1.1.1')) + }) + + it('deletes delisted versions', async function () { + await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath) + await cleanLspDownloads([{ serverVersion: '1.1.1', isDelisted: true, targets: [] }], installationDir.fsPath) + + const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename) + assert.strictEqual(result.length, 2) + assert.ok(result.includes('2.1.1')) + assert.ok(result.includes('1.0.1')) + }) + + it('handles case where less than 2 versions are not delisted', async function () { + await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath) + await cleanLspDownloads( + [ + { serverVersion: '1.1.1', isDelisted: true, targets: [] }, + { serverVersion: '2.1.1', isDelisted: true, targets: [] }, + { serverVersion: '1.0.0', isDelisted: true, targets: [] }, + ], + installationDir.fsPath + ) + + const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename) + assert.strictEqual(result.length, 1) + assert.ok(result.includes('1.0.1')) + }) + + it('handles case where less than 2 versions exist', async function () { + await fakeInstallVersions(['1.0.0'], installationDir.fsPath) + await cleanLspDownloads([], installationDir.fsPath) + + const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename) + assert.strictEqual(result.length, 1) + }) + + it('does not install delisted version when no other option exists', async function () { + await fakeInstallVersions(['1.0.0'], installationDir.fsPath) + await cleanLspDownloads([{ serverVersion: '1.0.0', isDelisted: true, targets: [] }], installationDir.fsPath) + + const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename) + assert.strictEqual(result.length, 0) + }) +}) diff --git a/packages/core/src/test/shared/utilities/tsUtils.test.ts b/packages/core/src/test/shared/utilities/tsUtils.test.ts new file mode 100644 index 00000000000..eb04da035e5 --- /dev/null +++ b/packages/core/src/test/shared/utilities/tsUtils.test.ts @@ -0,0 +1,16 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { partition } from '../../../shared/utilities/tsUtils' +import assert from 'assert' + +describe('partition', function () { + it('should split the list according to predicate', function () { + const items = [1, 2, 3, 4, 5, 6, 7, 8] + const [even, odd] = partition(items, (i) => i % 2 === 0) + assert.deepStrictEqual(even, [2, 4, 6, 8]) + assert.deepStrictEqual(odd, [1, 3, 5, 7]) + }) +})