diff --git a/docs/lsp-debugging.md b/docs/lsp-debugging.md new file mode 100644 index 00000000000..be65c2882a7 --- /dev/null +++ b/docs/lsp-debugging.md @@ -0,0 +1,23 @@ +## Language Server Debugging + +1. Clone https://github.com/aws/language-servers.git and set it up in the same workspace as this project + + e.g. + + ``` + /aws-toolkit-vscode + /toolkit + /core + /amazonq + /language-servers + ``` + +2. Inside of the language-servers project run: + ``` + npm install + npm run compile + npm run package + ``` + to get the project setup +3. Uncomment the `AWS_LANGUAGE_SERVER_OVERRIDE` variable in `amazonq/.vscode/launch.json` Extension configuration +4. Use the `Launch LSP with Debugging` configuration and set breakpoints in VSCode or the language server diff --git a/package-lock.json b/package-lock.json index dae40a590e6..db334e69287 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6062,6 +6062,15 @@ "yargs": "^17.0.1" } }, + "node_modules/@aws/chat-client-ui-types": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@aws/chat-client-ui-types/-/chat-client-ui-types-0.0.8.tgz", + "integrity": "sha512-aU8r0FaCKIhMiTWvr/yuWYZmVWPgE2vBAPsVcafhlu7ucubiH/+YodqDw+0Owk0R0kxxZDdjdZghPZSyy0G84A==", + "dev": true, + "dependencies": { + "@aws/language-server-runtimes-types": "^0.0.7" + } + }, "node_modules/@aws/fully-qualified-names": { "version": "2.1.4", "dev": true, @@ -6070,6 +6079,93 @@ "web-tree-sitter": "^0.20.8" } }, + "node_modules/@aws/language-server-runtimes": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.27.tgz", + "integrity": "sha512-qWog7upRVc09xLcuL0HladoxO3JbkgdtgkI/RUWRDcr6YB8hBvmSCADGWjUGbOyvK4CpaXqHIr883PAqnosoXg==", + "dev": true, + "dependencies": { + "@aws/language-server-runtimes-types": "^0.0.7", + "jose": "^5.9.6", + "rxjs": "^7.8.1", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.17.5" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws/language-server-runtimes-types": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.0.7.tgz", + "integrity": "sha512-P83YkgWITcUGHaZvYFI0N487nWErgRpejALKNm/xs8jEcHooDfjigOpliN8TgzfF9BGvGeQnnAzIG16UBXc9ig==", + "dev": true, + "dependencies": { + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5" + } + }, + "node_modules/@aws/language-server-runtimes-types/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true + }, + "node_modules/@aws/language-server-runtimes/node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@aws/language-server-runtimes/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@aws/language-server-runtimes/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws/language-server-runtimes/node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dev": true, + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/@aws/language-server-runtimes/node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/@aws/language-server-runtimes/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true + }, "node_modules/@aws/mynah-ui": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.22.1.tgz", @@ -20094,8 +20190,9 @@ "license": "MIT" }, "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.8", - "license": "MIT" + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==" }, "node_modules/vscode-languageserver-types": { "version": "3.17.3", @@ -21321,7 +21418,9 @@ }, "devDependencies": { "@aws-sdk/types": "^3.13.1", + "@aws/chat-client-ui-types": "^0.0.8", "@aws/fully-qualified-names": "^2.1.4", + "@aws/language-server-runtimes": "^0.2.27", "@cspotcode/source-map-support": "^0.8.1", "@sinonjs/fake-timers": "^10.0.2", "@types/adm-zip": "^0.4.34", diff --git a/packages/amazonq/.vscode/launch.json b/packages/amazonq/.vscode/launch.json index 120eacdb44d..be456c89ac1 100644 --- a/packages/amazonq/.vscode/launch.json +++ b/packages/amazonq/.vscode/launch.json @@ -19,6 +19,7 @@ "env": { "SSMDOCUMENT_LANGUAGESERVER_PORT": "6010", "WEBPACK_DEVELOPER_SERVER": "http://localhost:8080" + // "AWS_LANGUAGE_SERVER_OVERRIDE": "${workspaceFolder}/../../../language-servers/app/aws-lsp-codewhisperer-runtimes/out/token-standalone.js", }, "envFile": "${workspaceFolder}/.local.env", "outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/../core/dist/**/*.js"], @@ -140,6 +141,31 @@ "group": "4_E2ETestCurrentFile", "order": 2 } + }, + { + "name": "Attach to Language Server", + "type": "node", + "request": "attach", + "port": 6080, // Hard defined in core/src/shared/lsp/platform.ts + "outFiles": ["${workspaceFolder}/../../../language-servers/**/out/**/*.js"], + "skipFiles": [ + "/**", + "${workspaceFolder}/../../../language-servers/**/node_modules/**/*.js" + ], + "restart": { + "maxAttempts": 10, + "delay": 1000 + } + } + ], + "compounds": [ + { + "name": "Launch LSP with Debugging", + "configurations": ["Extension", "Attach to Language Server"], + "presentation": { + "group": "1_Extension", + "order": 5 + } } ] } diff --git a/packages/amazonq/src/app/inline/activation.ts b/packages/amazonq/src/app/inline/activation.ts new file mode 100644 index 00000000000..d786047b2aa --- /dev/null +++ b/packages/amazonq/src/app/inline/activation.ts @@ -0,0 +1,124 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import vscode from 'vscode' +import { + AuthUtil, + CodeSuggestionsState, + CodeWhispererCodeCoverageTracker, + CodeWhispererConstants, + CodeWhispererSettings, + ConfigurationEntry, + DefaultCodeWhispererClient, + invokeRecommendation, + isInlineCompletionEnabled, + KeyStrokeHandler, + RecommendationHandler, + runtimeLanguageContext, + TelemetryHelper, + UserWrittenCodeTracker, + vsCodeState, +} from 'aws-core-vscode/codewhisperer' +import { Commands, getLogger, globals, sleep } from 'aws-core-vscode/shared' + +export async function activate() { + const codewhispererSettings = CodeWhispererSettings.instance + const client = new DefaultCodeWhispererClient() + + if (isInlineCompletionEnabled()) { + await setSubscriptionsforInlineCompletion() + await AuthUtil.instance.setVscodeContextProps() + } + + function getAutoTriggerStatus(): boolean { + return CodeSuggestionsState.instance.isSuggestionsEnabled() + } + + async function getConfigEntry(): Promise { + const isShowMethodsEnabled: boolean = + vscode.workspace.getConfiguration('editor').get('suggest.showMethods') || false + const isAutomatedTriggerEnabled: boolean = getAutoTriggerStatus() + const isManualTriggerEnabled: boolean = true + const isSuggestionsWithCodeReferencesEnabled = codewhispererSettings.isSuggestionsWithCodeReferencesEnabled() + + // TODO:remove isManualTriggerEnabled + return { + isShowMethodsEnabled, + isManualTriggerEnabled, + isAutomatedTriggerEnabled, + isSuggestionsWithCodeReferencesEnabled, + } + } + + async function setSubscriptionsforInlineCompletion() { + RecommendationHandler.instance.subscribeSuggestionCommands() + + /** + * Automated trigger + */ + globals.context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(async (editor) => { + await RecommendationHandler.instance.onEditorChange() + }), + vscode.window.onDidChangeWindowState(async (e) => { + await RecommendationHandler.instance.onFocusChange() + }), + vscode.window.onDidChangeTextEditorSelection(async (e) => { + await RecommendationHandler.instance.onCursorChange(e) + }), + vscode.workspace.onDidChangeTextDocument(async (e) => { + const editor = vscode.window.activeTextEditor + if (!editor) { + return + } + if (e.document !== editor.document) { + return + } + if (!runtimeLanguageContext.isLanguageSupported(e.document)) { + return + } + + CodeWhispererCodeCoverageTracker.getTracker(e.document.languageId)?.countTotalTokens(e) + UserWrittenCodeTracker.instance.onTextDocumentChange(e) + /** + * Handle this keystroke event only when + * 1. It is not a backspace + * 2. It is not caused by CodeWhisperer editing + * 3. It is not from undo/redo. + */ + if (e.contentChanges.length === 0 || vsCodeState.isCodeWhispererEditing) { + return + } + + if (vsCodeState.lastUserModificationTime) { + TelemetryHelper.instance.setTimeSinceLastModification( + performance.now() - vsCodeState.lastUserModificationTime + ) + } + vsCodeState.lastUserModificationTime = performance.now() + /** + * Important: Doing this sleep(10) is to make sure + * 1. this event is processed by vs code first + * 2. editor.selection.active has been successfully updated by VS Code + * Then this event can be processed by our code. + */ + await sleep(CodeWhispererConstants.vsCodeCursorUpdateDelay) + if (!RecommendationHandler.instance.isSuggestionVisible()) { + await KeyStrokeHandler.instance.processKeyStroke(e, editor, client, await getConfigEntry()) + } + }), + // manual trigger + Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { + invokeRecommendation( + vscode.window.activeTextEditor as vscode.TextEditor, + client, + await getConfigEntry() + ).catch((e) => { + getLogger().error('invokeRecommendation failed: %s', (e as Error).message) + }) + }) + ) + } +} diff --git a/packages/amazonq/src/app/inline/completion.ts b/packages/amazonq/src/app/inline/completion.ts new file mode 100644 index 00000000000..b20690afbd4 --- /dev/null +++ b/packages/amazonq/src/app/inline/completion.ts @@ -0,0 +1,116 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CancellationToken, + InlineCompletionContext, + InlineCompletionItem, + InlineCompletionItemProvider, + InlineCompletionList, + Position, + TextDocument, + commands, + languages, +} from 'vscode' +import { LanguageClient } from 'vscode-languageclient' +import { + InlineCompletionListWithReferences, + InlineCompletionWithReferencesParams, + inlineCompletionWithReferencesRequestType, + logInlineCompletionSessionResultsNotificationType, + LogInlineCompletionSessionResultsParams, +} from '@aws/language-server-runtimes/protocol' + +export const CodewhispererInlineCompletionLanguages = [ + { scheme: 'file', language: 'typescript' }, + { scheme: 'file', language: 'javascript' }, + { scheme: 'file', language: 'json' }, + { scheme: 'file', language: 'yaml' }, + { scheme: 'file', language: 'java' }, + { scheme: 'file', language: 'go' }, + { scheme: 'file', language: 'php' }, + { scheme: 'file', language: 'rust' }, + { scheme: 'file', language: 'kotlin' }, + { scheme: 'file', language: 'terraform' }, + { scheme: 'file', language: 'ruby' }, + { scheme: 'file', language: 'shellscript' }, + { scheme: 'file', language: 'dart' }, + { scheme: 'file', language: 'lua' }, + { scheme: 'file', language: 'powershell' }, + { scheme: 'file', language: 'r' }, + { scheme: 'file', language: 'swift' }, + { scheme: 'file', language: 'systemverilog' }, + { scheme: 'file', language: 'scala' }, + { scheme: 'file', language: 'vue' }, + { scheme: 'file', language: 'csharp' }, +] + +export function registerInlineCompletion(languageClient: LanguageClient) { + const inlineCompletionProvider = new AmazonQInlineCompletionItemProvider(languageClient) + languages.registerInlineCompletionItemProvider(CodewhispererInlineCompletionLanguages, inlineCompletionProvider) + + const onInlineAcceptance = async ( + sessionId: string, + itemId: string, + requestStartTime: number, + firstCompletionDisplayLatency?: number + ) => { + const params: LogInlineCompletionSessionResultsParams = { + sessionId: sessionId, + completionSessionResult: { + [itemId]: { + seen: true, + accepted: true, + discarded: false, + }, + }, + totalSessionDisplayTime: Date.now() - requestStartTime, + firstCompletionDisplayLatency: firstCompletionDisplayLatency, + } + languageClient.sendNotification(logInlineCompletionSessionResultsNotificationType as any, params) + } + commands.registerCommand('aws.sample-vscode-ext-amazonq.accept', onInlineAcceptance) +} + +export class AmazonQInlineCompletionItemProvider implements InlineCompletionItemProvider { + constructor(private readonly languageClient: LanguageClient) {} + + async provideInlineCompletionItems( + document: TextDocument, + position: Position, + context: InlineCompletionContext, + token: CancellationToken + ): Promise { + const requestStartTime = Date.now() + const request: InlineCompletionWithReferencesParams = { + textDocument: { + uri: document.uri.toString(), + }, + position, + context, + } + + const response = await this.languageClient.sendRequest( + inlineCompletionWithReferencesRequestType as any, + request, + token + ) + + const list: InlineCompletionListWithReferences = response as InlineCompletionListWithReferences + this.languageClient.info(`Client: Received ${list.items.length} suggestions`) + const firstCompletionDisplayLatency = Date.now() - requestStartTime + + // Add completion session tracking and attach onAcceptance command to each item to record used decision + for (const item of list.items) { + item.command = { + command: 'aws.sample-vscode-ext-amazonq.accept', + title: 'On acceptance', + arguments: [list.sessionId, item.itemId, requestStartTime, firstCompletionDisplayLatency], + } + } + + return list as InlineCompletionList + } +} diff --git a/packages/amazonq/src/extension.ts b/packages/amazonq/src/extension.ts index a4b53dbf66d..7e8216387d6 100644 --- a/packages/amazonq/src/extension.ts +++ b/packages/amazonq/src/extension.ts @@ -31,6 +31,7 @@ import { setContext, setupUninstallHandler, maybeShowMinVscodeWarning, + Experiments, } from 'aws-core-vscode/shared' import { ExtStartUpSources } from 'aws-core-vscode/telemetry' import { VSCODE_EXTENSION_ID } from 'aws-core-vscode/utils' @@ -39,6 +40,8 @@ import * as semver from 'semver' import * as vscode from 'vscode' import { registerCommands } from './commands' import { focusAmazonQPanel } from 'aws-core-vscode/codewhispererChat' +import { activate as activateAmazonqLsp } from './lsp/activation' +import { activate as activateInlineCompletion } from './app/inline/activation' export const amazonQContextPrefix = 'amazonq' @@ -113,7 +116,13 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is const extContext = { extensionContext: context, } + // This contains every lsp agnostic things (auth, security scan, code scan) await activateCodeWhisperer(extContext as ExtContext) + if (Experiments.instance.get('amazonqLSP', false)) { + await activateAmazonqLsp(context) + } else { + await activateInlineCompletion() + } // Generic extension commands registerGenericCommands(context, amazonQContextPrefix) diff --git a/packages/amazonq/src/extensionNode.ts b/packages/amazonq/src/extensionNode.ts index 6fe0c28ad8f..1a8506c2577 100644 --- a/packages/amazonq/src/extensionNode.ts +++ b/packages/amazonq/src/extensionNode.ts @@ -42,6 +42,7 @@ async function activateAmazonQNode(context: vscode.ExtensionContext) { const extContext = { extensionContext: context, } + await activateCWChat(context) await activateQGumby(extContext as ExtContext) diff --git a/packages/amazonq/src/lsp/activation.ts b/packages/amazonq/src/lsp/activation.ts new file mode 100644 index 00000000000..10e0c93eec5 --- /dev/null +++ b/packages/amazonq/src/lsp/activation.ts @@ -0,0 +1,27 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import vscode from 'vscode' +import { startLanguageServer } from './client' +import { AmazonQLSPResolver } from './lspInstaller' +import { Commands, ToolkitError } from 'aws-core-vscode/shared' + +export async function activate(ctx: vscode.ExtensionContext): Promise { + try { + const installResult = await new AmazonQLSPResolver().resolve() + await startLanguageServer(ctx, installResult.resourcePaths) + ctx.subscriptions.push( + Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { + await vscode.commands.executeCommand('editor.action.inlineSuggest.trigger') + }), + Commands.declare('aws.amazonq.rejectCodeSuggestion', () => async () => { + await vscode.commands.executeCommand('editor.action.inlineSuggest.hide') + }).register() + ) + } catch (err) { + const e = err as ToolkitError + void vscode.window.showInformationMessage(`Unable to launch amazonq language server: ${e.message}`) + } +} diff --git a/packages/amazonq/src/lsp/auth.ts b/packages/amazonq/src/lsp/auth.ts new file mode 100644 index 00000000000..70753e75c6b --- /dev/null +++ b/packages/amazonq/src/lsp/auth.ts @@ -0,0 +1,98 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ConnectionMetadata, + NotificationType, + RequestType, + ResponseMessage, +} from '@aws/language-server-runtimes/protocol' +import * as jose from 'jose' +import * as crypto from 'crypto' +import { LanguageClient } from 'vscode-languageclient' +import { AuthUtil } from 'aws-core-vscode/codewhisperer' +import { Writable } from 'stream' + +export const encryptionKey = crypto.randomBytes(32) + +/** + * Sends a json payload to the language server, who is waiting to know what the encryption key is. + * Code reference: https://github.com/aws/language-servers/blob/7da212185a5da75a72ce49a1a7982983f438651a/client/vscode/src/credentialsActivation.ts#L77 + */ +export function writeEncryptionInit(stream: Writable): void { + const request = { + version: '1.0', + mode: 'JWT', + key: encryptionKey.toString('base64'), + } + stream.write(JSON.stringify(request)) + stream.write('\n') +} + +/** + * Request for custom notifications that Update Credentials and tokens. + * See core\aws-lsp-core\src\credentials\updateCredentialsRequest.ts for details + */ +export interface UpdateCredentialsRequest { + /** + * Encrypted token (JWT or PASETO) + * The token's contents differ whether IAM or Bearer token is sent + */ + data: string + /** + * Used by the runtime based language servers. + * Signals that this client will encrypt its credentials payloads. + */ + encrypted: boolean +} + +export const notificationTypes = { + updateBearerToken: new RequestType( + 'aws/credentials/token/update' + ), + deleteBearerToken: new NotificationType('aws/credentials/token/delete'), + getConnectionMetadata: new RequestType( + 'aws/credentials/getConnectionMetadata' + ), +} + +/** + * Facade over our VSCode Auth that does crud operations on the language server auth + */ +export class AmazonQLspAuth { + constructor(private readonly client: LanguageClient) {} + + async init() { + const activeConnection = AuthUtil.instance.auth.activeConnection + if (activeConnection?.type === 'sso') { + // send the token to the language server + const token = await AuthUtil.instance.getBearerToken() + await this.updateBearerToken(token) + } + } + + private async updateBearerToken(token: string) { + const request = await this.createUpdateCredentialsRequest({ + token, + }) + + await this.client.sendRequest(notificationTypes.updateBearerToken.method, request) + + this.client.info(`UpdateBearerToken: ${JSON.stringify(request)}`) + } + + private async createUpdateCredentialsRequest(data: any) { + const payload = new TextEncoder().encode(JSON.stringify({ data })) + + const jwt = await new jose.CompactEncrypt(payload) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) + .encrypt(encryptionKey) + + return { + data: jwt, + encrypted: true, + } + } +} diff --git a/packages/amazonq/src/lsp/client.ts b/packages/amazonq/src/lsp/client.ts new file mode 100644 index 00000000000..40965af43ed --- /dev/null +++ b/packages/amazonq/src/lsp/client.ts @@ -0,0 +1,99 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import vscode, { env, version } from 'vscode' +import * as nls from 'vscode-nls' +import * as crypto from 'crypto' +import { LanguageClient, LanguageClientOptions } from 'vscode-languageclient' +import { registerInlineCompletion } from '../app/inline/completion' +import { AmazonQLspAuth, encryptionKey, notificationTypes } from './auth' +import { AuthUtil } from 'aws-core-vscode/codewhisperer' +import { ConnectionMetadata } from '@aws/language-server-runtimes/protocol' +import { ResourcePaths, createServerOptions } from 'aws-core-vscode/shared' + +const localize = nls.loadMessageBundle() + +export async function startLanguageServer(extensionContext: vscode.ExtensionContext, resourcePaths: ResourcePaths) { + const toDispose = extensionContext.subscriptions + + const serverModule = resourcePaths.lsp + + const serverOptions = createServerOptions({ + encryptionKey, + executable: resourcePaths.node, + serverModule, + execArgv: [ + '--nolazy', + '--preserve-symlinks', + '--stdio', + '--pre-init-encryption', + '--set-credentials-encryption-key', + ], + }) + + const documentSelector = [{ scheme: 'file', language: '*' }] + + // Options to control the language client + const clientOptions: LanguageClientOptions = { + // Register the server for json documents + documentSelector, + initializationOptions: { + aws: { + clientInfo: { + name: env.appName, + version: version, + extension: { + name: `AWS IDE Extensions for VSCode`, // TODO change this to C9/Amazon + version: '0.0.1', + }, + clientId: crypto.randomUUID(), + }, + awsClientCapabilities: { + window: { + notifications: true, + }, + }, + }, + credentials: { + providesBearerToken: true, + }, + }, + } + + const client = new LanguageClient( + 'amazonq', + localize('amazonq.server.name', 'Amazon Q Language Server'), + serverOptions, + clientOptions + ) + + const disposable = client.start() + toDispose.push(disposable) + + const auth = new AmazonQLspAuth(client) + + return client.onReady().then(async () => { + await auth.init() + registerInlineCompletion(client) + + // Request handler for when the server wants to know about the clients auth connnection + client.onRequest(notificationTypes.getConnectionMetadata.method, () => { + return { + sso: { + startUrl: AuthUtil.instance.auth.startUrl, + }, + } + }) + + toDispose.push( + AuthUtil.instance.auth.onDidChangeActiveConnection(async () => { + await auth.init() + }), + AuthUtil.instance.auth.onDidDeleteConnection(async () => { + client.sendNotification(notificationTypes.deleteBearerToken.method) + }) + ) + }) +} diff --git a/packages/amazonq/src/lsp/lspInstaller.ts b/packages/amazonq/src/lsp/lspInstaller.ts new file mode 100644 index 00000000000..72d0746cdcf --- /dev/null +++ b/packages/amazonq/src/lsp/lspInstaller.ts @@ -0,0 +1,59 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { Range } from 'semver' +import { + ManifestResolver, + LanguageServerResolver, + LspResolver, + fs, + LspResolution, + getNodeExecutableName, + cleanLspDownloads, +} from 'aws-core-vscode/shared' +import path from 'path' + +const manifestURL = 'https://aws-toolkit-language-servers.amazonaws.com/codewhisperer/0/manifest.json' +export const supportedLspServerVersions = '^2.3.0' + +export class AmazonQLSPResolver implements LspResolver { + async resolve(): Promise { + const overrideLocation = process.env.AWS_LANGUAGE_SERVER_OVERRIDE + if (overrideLocation) { + void vscode.window.showInformationMessage(`Using language server override location: ${overrideLocation}`) + return { + assetDirectory: overrideLocation, + location: 'override', + version: '0.0.0', + resourcePaths: { + lsp: overrideLocation, + node: getNodeExecutableName(), + }, + } + } + + // "AmazonQ" is shared across toolkits to provide a common access point, don't change it + const name = 'AmazonQ' + const manifest = await new ManifestResolver(manifestURL, name).resolve() + const installationResult = await new LanguageServerResolver( + manifest, + name, + new Range(supportedLspServerVersions) + ).resolve() + + const nodePath = path.join(installationResult.assetDirectory, `servers/${getNodeExecutableName()}`) + await fs.chmod(nodePath, 0o755) + + await cleanLspDownloads(manifest.versions, path.dirname(installationResult.assetDirectory)) + return { + ...installationResult, + resourcePaths: { + lsp: path.join(installationResult.assetDirectory, 'servers/aws-lsp-codewhisperer.js'), + node: nodePath, + }, + } + } +} diff --git a/packages/amazonq/test/e2e/lsp/lspInstaller.test.ts b/packages/amazonq/test/e2e/lsp/lspInstaller.test.ts new file mode 100644 index 00000000000..0bf2edafa3f --- /dev/null +++ b/packages/amazonq/test/e2e/lsp/lspInstaller.test.ts @@ -0,0 +1,122 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import { AmazonQLSPResolver, supportedLspServerVersions } from '../../../src/lsp/lspInstaller' +import { + fs, + LanguageServerResolver, + makeTemporaryToolkitFolder, + ManifestResolver, + request, +} from 'aws-core-vscode/shared' +import * as semver from 'semver' + +function createVersion(version: string) { + return { + isDelisted: false, + serverVersion: version, + targets: [ + { + arch: process.arch, + platform: process.platform, + contents: [ + { + bytes: 0, + filename: 'servers.zip', + hashes: [], + url: 'http://fakeurl', + }, + ], + }, + ], + } +} + +describe('AmazonQLSPInstaller', () => { + let resolver: AmazonQLSPResolver + let sandbox: sinon.SinonSandbox + let tempDir: string + + beforeEach(async () => { + sandbox = sinon.createSandbox() + resolver = new AmazonQLSPResolver() + tempDir = await makeTemporaryToolkitFolder() + sandbox.stub(LanguageServerResolver.prototype, 'defaultDownloadFolder').returns(tempDir) + }) + + afterEach(async () => { + delete process.env.AWS_LANGUAGE_SERVER_OVERRIDE + sandbox.restore() + await fs.delete(tempDir, { + recursive: true, + }) + }) + + describe('resolve()', () => { + it('uses AWS_LANGUAGE_SERVER_OVERRIDE', async () => { + const overridePath = '/custom/path/to/lsp' + process.env.AWS_LANGUAGE_SERVER_OVERRIDE = overridePath + + const result = await resolver.resolve() + + assert.strictEqual(result.assetDirectory, overridePath) + assert.strictEqual(result.location, 'override') + assert.strictEqual(result.version, '0.0.0') + }) + + it('resolves', async () => { + // First try - should download the file + const download = await resolver.resolve() + + assert.ok(download.assetDirectory.startsWith(tempDir)) + assert.deepStrictEqual(download.location, 'remote') + assert.ok(semver.satisfies(download.version, supportedLspServerVersions)) + + // Second try - Should see the contents in the cache + const cache = await resolver.resolve() + + assert.ok(cache.assetDirectory.startsWith(tempDir)) + assert.deepStrictEqual(cache.location, 'cache') + assert.ok(semver.satisfies(cache.version, supportedLspServerVersions)) + + /** + * Always make sure the latest version is one patch higher. This stops a problem + * where the fallback can't be used because the latest compatible version + * is equal to the min version, so if the cache isn't valid, then there + * would be no fallback location + * + * Instead, increasing the latest compatible lsp version means we can just + * use the one we downloaded earlier in the test as the fallback + */ + const nextVer = semver.inc(cache.version, 'patch', true) + if (!nextVer) { + throw new Error('Could not increment version') + } + sandbox.stub(ManifestResolver.prototype, 'resolve').resolves({ + manifestSchemaVersion: '0.0.0', + artifactId: 'foo', + artifactDescription: 'foo', + isManifestDeprecated: false, + versions: [createVersion(nextVer), createVersion(cache.version)], + }) + + // fail the next http request for the language server + sandbox.stub(request, 'fetch').returns({ + response: Promise.resolve({ + ok: false, + }), + } as any) + + // Third try - Cache doesn't exist and we couldn't download from the internet, fallback to a local version + const fallback = await resolver.resolve() + + assert.ok(fallback.assetDirectory.startsWith(tempDir)) + assert.deepStrictEqual(fallback.location, 'fallback') + assert.ok(semver.satisfies(fallback.version, supportedLspServerVersions)) + }) + }) +}) diff --git a/packages/amazonq/test/unit/amazonq/lsp/lspController.test.ts b/packages/amazonq/test/unit/amazonq/lsp/lspController.test.ts deleted file mode 100644 index d54551e433f..00000000000 --- a/packages/amazonq/test/unit/amazonq/lsp/lspController.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ -import assert from 'assert' -import sinon from 'sinon' -import { Content, LspController } from 'aws-core-vscode/amazonq' -import { createTestFile } from 'aws-core-vscode/test' -import { fs } from 'aws-core-vscode/shared' - -describe('Amazon Q LSP controller', function () { - it('Download mechanism checks against hash, when hash matches', async function () { - const content = { - filename: 'qserver-linux-x64.zip', - url: 'https://x/0.0.6/qserver-linux-x64.zip', - hashes: [ - 'sha384:768412320f7b0aa5812fce428dc4706b3cae50e02a64caa16a782249bfe8efc4b7ef1ccb126255d196047dfedf17a0a9', - ], - bytes: 512, - } as Content - const lspController = new LspController() - sinon.stub(lspController, '_download') - const mockFileName = 'test_case_1.zip' - const mockDownloadFile = await createTestFile(mockFileName) - await fs.writeFile(mockDownloadFile.fsPath, 'test') - const result = await lspController.downloadAndCheckHash(mockDownloadFile.fsPath, content) - assert.strictEqual(result, true) - }) - - it('Download mechanism checks against hash, when hash does not match', async function () { - const content = { - filename: 'qserver-linux-x64.zip', - url: 'https://x/0.0.6/qserver-linux-x64.zip', - hashes: [ - 'sha384:38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b', - ], - bytes: 512, - } as Content - const lspController = new LspController() - sinon.stub(lspController, '_download') - const mockFileName = 'test_case_2.zip' - const mockDownloadFile = await createTestFile(mockFileName) - await fs.writeFile(mockDownloadFile.fsPath, 'file_content') - const result = await lspController.downloadAndCheckHash(mockDownloadFile.fsPath, content) - assert.strictEqual(result, false) - }) - - afterEach(() => { - sinon.restore() - }) -}) diff --git a/packages/core/package.json b/packages/core/package.json index e8158d302ed..1e1895fa3ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -439,6 +439,8 @@ "serveVue": "Local server for Vue.js code for development purposes. Provides faster iteration when updating Vue files" }, "devDependencies": { + "@aws/language-server-runtimes": "^0.2.27", + "@aws/chat-client-ui-types": "^0.0.8", "@aws-sdk/types": "^3.13.1", "@aws/fully-qualified-names": "^2.1.4", "@cspotcode/source-map-support": "^0.8.1", diff --git a/packages/core/src/amazonq/index.ts b/packages/core/src/amazonq/index.ts index 9ca9af7687c..c5abbf7658e 100644 --- a/packages/core/src/amazonq/index.ts +++ b/packages/core/src/amazonq/index.ts @@ -15,7 +15,7 @@ export { walkthroughInlineSuggestionsExample, walkthroughSecurityScanExample, } from './onboardingPage/walkthrough' -export { LspController, Content } from './lsp/lspController' +export { LspController } from './lsp/lspController' export { LspClient } from './lsp/lspClient' export { api } from './extApi' export { AmazonQChatViewProvider } from './webview/webView' diff --git a/packages/core/src/amazonq/lsp/lspClient.ts b/packages/core/src/amazonq/lsp/lspClient.ts index 3969a3313e9..659df3ae078 100644 --- a/packages/core/src/amazonq/lsp/lspClient.ts +++ b/packages/core/src/amazonq/lsp/lspClient.ts @@ -10,7 +10,6 @@ import * as vscode from 'vscode' import * as path from 'path' import * as nls from 'vscode-nls' -import { spawn } from 'child_process' // eslint-disable-line no-restricted-imports import * as crypto from 'crypto' import * as jose from 'jose' @@ -30,27 +29,13 @@ import { GetRepomapIndexJSONRequestType, Usage, } from './types' -import { Writable } from 'stream' import { CodeWhispererSettings } from '../../codewhisperer/util/codewhispererSettings' -import { fs, getLogger, globals } from '../../shared' +import { ResourcePaths, createServerOptions, fs, getLogger, globals } from '../../shared' const localize = nls.loadMessageBundle() const key = crypto.randomBytes(32) -/** - * Sends a json payload to the language server, who is waiting to know what the encryption key is. - * Code reference: https://github.com/aws/language-servers/blob/7da212185a5da75a72ce49a1a7982983f438651a/client/vscode/src/credentialsActivation.ts#L77 - */ -export function writeEncryptionInit(stream: Writable): void { - const request = { - version: '1.0', - mode: 'JWT', - key: key.toString('base64'), - } - stream.write(JSON.stringify(request)) - stream.write('\n') -} /** * LspClient manages the API call between VS Code extension and LSP server * It encryptes the payload of API call. @@ -172,13 +157,11 @@ export class LspClient { * It will create a output channel named Amazon Q Language Server. * This function assumes the LSP server has already been downloaded. */ -export async function activate(extensionContext: ExtensionContext) { +export async function activate(extensionContext: ExtensionContext, resourcePaths: ResourcePaths) { LspClient.instance const toDispose = extensionContext.subscriptions let rangeFormatting: Disposable | undefined - // The server is implemented in node - const serverModule = path.join(extensionContext.extensionPath, 'resources/qserver/lspServer.js') // The debug options for the server // --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging const debugOptions = { execArgv: ['--nolazy', '--preserve-symlinks', '--stdio'] } @@ -197,15 +180,7 @@ export async function activate(extensionContext: ExtensionContext) { delete process.env.Q_WORKER_THREADS } - const nodename = process.platform === 'win32' ? 'node.exe' : 'node' - - const child = spawn(extensionContext.asAbsolutePath(path.join('resources', nodename)), [ - serverModule, - ...debugOptions.execArgv, - ]) - // share an encryption key using stdin - // follow same practice of DEXP LSP server - writeEncryptionInit(child.stdin) + const serverModule = resourcePaths.lsp // If the extension is launch in debug mode the debug server options are use // Otherwise the run options are used @@ -214,7 +189,12 @@ export async function activate(extensionContext: ExtensionContext) { debug: { module: serverModule, transport: TransportKind.ipc, options: debugOptions }, } - serverOptions = () => Promise.resolve(child!) + serverOptions = createServerOptions({ + encryptionKey: key, + executable: resourcePaths.node, + serverModule, + execArgv: debugOptions.execArgv, + }) const documentSelector = [{ scheme: 'file', language: '*' }] diff --git a/packages/core/src/amazonq/lsp/lspController.ts b/packages/core/src/amazonq/lsp/lspController.ts index d3f8960d1fc..3b38d9a17a2 100644 --- a/packages/core/src/amazonq/lsp/lspController.ts +++ b/packages/core/src/amazonq/lsp/lspController.ts @@ -5,23 +5,16 @@ import * as vscode from 'vscode' import * as path from 'path' -import * as crypto from 'crypto' -import { createWriteStream } from 'fs' // eslint-disable-line no-restricted-imports import { getLogger } from '../../shared/logger/logger' import { CurrentWsFolders, collectFilesForIndex } from '../../shared/utilities/workspaceUtils' -import fetch from 'node-fetch' -import request from '../../shared/request' import { LspClient } from './lspClient' -import AdmZip from 'adm-zip' import { RelevantTextDocument } from '@amzn/codewhisperer-streaming' -import { makeTemporaryToolkitFolder, tryRemoveFolder } from '../../shared/filesystemUtilities' import { activate as activateLsp } from './lspClient' import { telemetry } from '../../shared/telemetry' import { isCloud9 } from '../../shared/extensionUtilities' -import { fs, globals, ToolkitError } from '../../shared' -import { isWeb } from '../../shared/extensionGlobals' -import { getUserAgent } from '../../shared/telemetry/util' +import globals, { isWeb } from '../../shared/extensionGlobals' import { isAmazonInternalOs } from '../../shared/vscode/env' +import { WorkspaceLSPResolver } from './workspaceInstaller' export interface Chunk { readonly filePath: string @@ -30,38 +23,6 @@ export interface Chunk { readonly relativePath?: string readonly programmingLanguage?: string } - -export interface Content { - filename: string - url: string - hashes: string[] - bytes: number - serverVersion?: string -} - -export interface Target { - platform: string - arch: string - contents: Content[] -} - -export interface Manifest { - manifestSchemaVersion: string - artifactId: string - artifactDescription: string - isManifestDeprecated: boolean - versions: { - serverVersion: string - isDelisted: boolean - targets: Target[] - }[] -} -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 -const supportedLspServerVersions = ['0.1.32'] - -const nodeBinName = process.platform === 'win32' ? 'node.exe' : 'node' - export interface BuildIndexConfig { startUrl?: string maxIndexSize: number @@ -86,197 +47,11 @@ export class LspController { public static get instance() { return (this.#instance ??= new this()) } - constructor() {} isIndexingInProgress() { return this._isIndexingInProgress } - async _download(localFile: string, remoteUrl: string) { - const res = await fetch(remoteUrl, { - headers: { - 'User-Agent': getUserAgent({ includePlatform: true, includeClientId: true }), - }, - }) - if (!res.ok) { - throw new ToolkitError(`Failed to download. Error: ${JSON.stringify(res)}`) - } - return new Promise((resolve, reject) => { - const file = createWriteStream(localFile) - res.body.pipe(file) - res.body.on('error', (err) => { - reject(err) - }) - file.on('finish', () => { - file.close(resolve) - }) - }) - } - - async fetchManifest() { - try { - const resp = await request.fetch('GET', manifestUrl, { - headers: { - 'User-Agent': getUserAgent({ includePlatform: true, includeClientId: true }), - }, - }).response - if (!resp.ok) { - throw new ToolkitError(`Failed to fetch manifest. Error: ${resp.statusText}`) - } - return resp.json() - } catch (e: any) { - throw new ToolkitError(`Failed to fetch manifest. Error: ${JSON.stringify(e)}`) - } - } - - async getFileSha384(filePath: string): Promise { - const fileBuffer = await fs.readFileBytes(filePath) - const hash = crypto.createHash('sha384') - hash.update(fileBuffer) - return hash.digest('hex') - } - - async isLspInstalled(context: vscode.ExtensionContext) { - const localQServer = context.asAbsolutePath(path.join('resources', 'qserver')) - const localNodeRuntime = context.asAbsolutePath(path.join('resources', nodeBinName)) - return (await fs.exists(localQServer)) && (await fs.exists(localNodeRuntime)) - } - - getQserverFromManifest(manifest: Manifest): Content | undefined { - if (manifest.isManifestDeprecated) { - return undefined - } - for (const version of manifest.versions) { - if (version.isDelisted) { - continue - } - if (!supportedLspServerVersions.includes(version.serverVersion)) { - continue - } - for (const t of version.targets) { - if ( - (t.platform === process.platform || (t.platform === 'windows' && process.platform === 'win32')) && - t.arch === process.arch - ) { - for (const content of t.contents) { - if (content.filename.startsWith('qserver') && content.hashes.length > 0) { - content.serverVersion = version.serverVersion - return content - } - } - } - } - } - return undefined - } - - getNodeRuntimeFromManifest(manifest: Manifest): Content | undefined { - if (manifest.isManifestDeprecated) { - return undefined - } - for (const version of manifest.versions) { - if (version.isDelisted) { - continue - } - if (!supportedLspServerVersions.includes(version.serverVersion)) { - continue - } - for (const t of version.targets) { - if ( - (t.platform === process.platform || (t.platform === 'windows' && process.platform === 'win32')) && - t.arch === process.arch - ) { - for (const content of t.contents) { - if (content.filename.startsWith('node') && content.hashes.length > 0) { - content.serverVersion = version.serverVersion - return content - } - } - } - } - } - return undefined - } - - private async hashMatch(filePath: string, content: Content) { - const sha384 = await this.getFileSha384(filePath) - if ('sha384:' + sha384 !== content.hashes[0]) { - getLogger().error( - `LspController: Downloaded file sha ${sha384} does not match manifest ${content.hashes[0]}.` - ) - await fs.delete(filePath) - return false - } - return true - } - - async downloadAndCheckHash(filePath: string, content: Content) { - await this._download(filePath, content.url) - const match = await this.hashMatch(filePath, content) - if (!match) { - return false - } - return true - } - - async tryInstallLsp(context: vscode.ExtensionContext): Promise { - let tempFolder = undefined - try { - if (await this.isLspInstalled(context)) { - getLogger().info(`LspController: LSP already installed`) - return true - } - // clean up previous downloaded LSP - const qserverPath = context.asAbsolutePath(path.join('resources', 'qserver')) - if (await fs.exists(qserverPath)) { - await tryRemoveFolder(qserverPath) - } - // clean up previous downloaded node runtime - const nodeRuntimePath = context.asAbsolutePath(path.join('resources', nodeBinName)) - if (await fs.exists(nodeRuntimePath)) { - await fs.delete(nodeRuntimePath) - } - // fetch download url for qserver and node runtime - const manifest: Manifest = (await this.fetchManifest()) as Manifest - const qserverContent = this.getQserverFromManifest(manifest) - const nodeRuntimeContent = this.getNodeRuntimeFromManifest(manifest) - if (!qserverContent || !nodeRuntimeContent) { - getLogger().info(`LspController: Did not find LSP URL for ${process.platform} ${process.arch}`) - return false - } - - tempFolder = await makeTemporaryToolkitFolder() - - // download lsp to temp folder - const qserverZipTempPath = path.join(tempFolder, 'qserver.zip') - const downloadOk = await this.downloadAndCheckHash(qserverZipTempPath, qserverContent) - if (!downloadOk) { - return false - } - const zip = new AdmZip(qserverZipTempPath) - zip.extractAllTo(tempFolder) - await fs.rename(path.join(tempFolder, 'qserver'), qserverPath) - - // download node runtime to temp folder - const nodeRuntimeTempPath = path.join(tempFolder, nodeBinName) - const downloadNodeOk = await this.downloadAndCheckHash(nodeRuntimeTempPath, nodeRuntimeContent) - if (!downloadNodeOk) { - return false - } - await fs.chmod(nodeRuntimeTempPath, 0o755) - await fs.rename(nodeRuntimeTempPath, nodeRuntimePath) - return true - } catch (e) { - getLogger().error(`LspController: Failed to setup LSP server ${e}`) - return false - } finally { - // clean up temp folder - if (tempFolder) { - await tryRemoveFolder(tempFolder) - } - } - } - async query(s: string): Promise { const chunks: Chunk[] | undefined = await LspClient.instance.queryVectorIndex(s) const resp: RelevantTextDocument[] = [] @@ -384,12 +159,9 @@ export class LspController { return } setImmediate(async () => { - const ok = await LspController.instance.tryInstallLsp(context) - if (!ok) { - return - } try { - await activateLsp(context) + const installResult = await new WorkspaceLSPResolver().resolve() + await activateLsp(context, installResult.resourcePaths) getLogger().info('LspController: LSP activated') void LspController.instance.buildIndex(buildIndexConfig) // log the LSP server CPU and Memory usage per 30 minutes. diff --git a/packages/core/src/amazonq/lsp/workspaceInstaller.ts b/packages/core/src/amazonq/lsp/workspaceInstaller.ts new file mode 100644 index 00000000000..c4c688d7bc1 --- /dev/null +++ b/packages/core/src/amazonq/lsp/workspaceInstaller.ts @@ -0,0 +1,46 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path' +import { LspResolution, LspResolver } from '../../shared/lsp/types' +import { ManifestResolver } from '../../shared/lsp/manifestResolver' +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 +const supportedLspServerVersions = '0.1.32' + +export class WorkspaceLSPResolver implements LspResolver { + async resolve(): Promise { + const name = 'AmazonQ-Workspace' + const manifest = await new ManifestResolver(manifestUrl, name).resolve() + const installationResult = await new LanguageServerResolver( + manifest, + name, + new Range(supportedLspServerVersions) + ).resolve() + + const nodeName = + process.platform === 'win32' ? getNodeExecutableName() : `node-${process.platform}-${process.arch}` + const nodePath = path.join(installationResult.assetDirectory, nodeName) + await fs.chmod(nodePath, 0o755) + + await cleanLspDownloads(manifest.versions, path.basename(installationResult.assetDirectory)) + return { + ...installationResult, + resourcePaths: { + lsp: path.join( + installationResult.assetDirectory, + `qserver-${process.platform}-${process.arch}/qserver/lspServer.js` + ), + node: nodePath, + }, + } + } +} diff --git a/packages/core/src/codewhisperer/activation.ts b/packages/core/src/codewhisperer/activation.ts index 17934f2fe38..64c6217b383 100644 --- a/packages/core/src/codewhisperer/activation.ts +++ b/packages/core/src/codewhisperer/activation.ts @@ -6,12 +6,9 @@ import * as vscode from 'vscode' import * as nls from 'vscode-nls' import { getTabSizeSetting } from '../shared/utilities/editorUtilities' -import { KeyStrokeHandler } from './service/keyStrokeHandler' import * as EditorContext from './util/editorContext' import * as CodeWhispererConstants from './models/constants' import { - vsCodeState, - ConfigurationEntry, CodeSuggestionsState, CodeScansState, SecurityTreeViewFilterState, @@ -19,13 +16,11 @@ import { CodeScanIssue, CodeIssueGroupingStrategyState, } from './models/model' -import { invokeRecommendation } from './commands/invokeRecommendation' import { acceptSuggestion } from './commands/onInlineAcceptance' import { CodeWhispererSettings } from './util/codewhispererSettings' import { ExtContext } from '../shared/extensions' import { CodeWhispererTracker } from './tracker/codewhispererTracker' import * as codewhispererClient from './client/codewhisperer' -import { runtimeLanguageContext } from './util/runtimeLanguageContext' import { getLogger } from '../shared/logger' import { enableCodeSuggestions, @@ -59,7 +54,6 @@ import { showExploreAgentsView, showCodeIssueGroupingQuickPick, } from './commands/basicCommands' -import { sleep } from '../shared/utilities/timeoutUtils' import { ReferenceLogViewProvider } from './service/referenceLogViewProvider' import { ReferenceHoverProvider } from './service/referenceHoverProvider' import { ReferenceInlineProvider } from './service/referenceInlineProvider' @@ -73,7 +67,6 @@ import { RecommendationHandler } from './service/recommendationHandler' import { Commands, registerCommandErrorHandler, registerDeclaredCommands } from '../shared/vscode/commands2' import { InlineCompletionService, refreshStatusBar } from './service/inlineCompletionService' import { isInlineCompletionEnabled } from './util/commonUtil' -import { CodeWhispererCodeCoverageTracker } from './tracker/codewhispererCodeCoverageTracker' import { AuthUtil } from './util/authUtil' import { ImportAdderProvider } from './service/importAdderProvider' import { TelemetryHelper } from './util/telemetryHelper' @@ -97,13 +90,11 @@ import { SecurityIssueTreeViewProvider } from './service/securityIssueTreeViewPr import { setContext } from '../shared/vscode/setContext' import { syncSecurityIssueWebview } from './views/securityIssue/securityIssueWebview' import { detectCommentAboveLine } from '../shared/utilities/commentUtils' -import { UserWrittenCodeTracker } from './tracker/userWrittenCodeTracker' let localize: nls.LocalizeFunc export async function activate(context: ExtContext): Promise { localize = nls.loadMessageBundle() - const codewhispererSettings = CodeWhispererSettings.instance // initialize AuthUtil earlier to make sure it can listen to connection change events. const auth = AuthUtil.instance @@ -303,16 +294,6 @@ export async function activate(context: ExtContext): Promise { SecurityIssueProvider.instance.issues.some((group) => group.issues.some((issue) => issue.visible)) void setContext('aws.amazonq.security.noMatches', noMatches) }), - // manual trigger - Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { - invokeRecommendation( - vscode.window.activeTextEditor as vscode.TextEditor, - client, - await getConfigEntry() - ).catch((e) => { - getLogger().error('invokeRecommendation failed: %s', (e as Error).message) - }) - }), // select customization selectCustomizationPrompt.register(), // notify new customizations @@ -480,90 +461,6 @@ export async function activate(context: ExtContext): Promise { }) } - function getAutoTriggerStatus(): boolean { - return CodeSuggestionsState.instance.isSuggestionsEnabled() - } - - async function getConfigEntry(): Promise { - const isShowMethodsEnabled: boolean = - vscode.workspace.getConfiguration('editor').get('suggest.showMethods') || false - const isAutomatedTriggerEnabled: boolean = getAutoTriggerStatus() - const isManualTriggerEnabled: boolean = true - const isSuggestionsWithCodeReferencesEnabled = codewhispererSettings.isSuggestionsWithCodeReferencesEnabled() - - // TODO:remove isManualTriggerEnabled - return { - isShowMethodsEnabled, - isManualTriggerEnabled, - isAutomatedTriggerEnabled, - isSuggestionsWithCodeReferencesEnabled, - } - } - - if (isInlineCompletionEnabled()) { - await setSubscriptionsforInlineCompletion() - await AuthUtil.instance.setVscodeContextProps() - } - - async function setSubscriptionsforInlineCompletion() { - RecommendationHandler.instance.subscribeSuggestionCommands() - /** - * Automated trigger - */ - context.extensionContext.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor(async (editor) => { - await RecommendationHandler.instance.onEditorChange() - }), - vscode.window.onDidChangeWindowState(async (e) => { - await RecommendationHandler.instance.onFocusChange() - }), - vscode.window.onDidChangeTextEditorSelection(async (e) => { - await RecommendationHandler.instance.onCursorChange(e) - }), - vscode.workspace.onDidChangeTextDocument(async (e) => { - const editor = vscode.window.activeTextEditor - if (!editor) { - return - } - if (e.document !== editor.document) { - return - } - if (!runtimeLanguageContext.isLanguageSupported(e.document)) { - return - } - - CodeWhispererCodeCoverageTracker.getTracker(e.document.languageId)?.countTotalTokens(e) - UserWrittenCodeTracker.instance.onTextDocumentChange(e) - /** - * Handle this keystroke event only when - * 1. It is not a backspace - * 2. It is not caused by CodeWhisperer editing - * 3. It is not from undo/redo. - */ - if (e.contentChanges.length === 0 || vsCodeState.isCodeWhispererEditing) { - return - } - - if (vsCodeState.lastUserModificationTime) { - TelemetryHelper.instance.setTimeSinceLastModification( - performance.now() - vsCodeState.lastUserModificationTime - ) - } - vsCodeState.lastUserModificationTime = performance.now() - /** - * Important: Doing this sleep(10) is to make sure - * 1. this event is processed by vs code first - * 2. editor.selection.active has been successfully updated by VS Code - * Then this event can be processed by our code. - */ - await sleep(CodeWhispererConstants.vsCodeCursorUpdateDelay) - if (!RecommendationHandler.instance.isSuggestionVisible()) { - await KeyStrokeHandler.instance.processKeyStroke(e, editor, client, await getConfigEntry()) - } - }) - ) - } - void FeatureConfigProvider.instance.fetchFeatureConfigs().catch((error) => { getLogger().error('Failed to fetch feature configs - %s', error) }) diff --git a/packages/core/src/codewhisperer/service/recommendationHandler.ts b/packages/core/src/codewhisperer/service/recommendationHandler.ts index 1f5096ad1cc..66542ee6681 100644 --- a/packages/core/src/codewhisperer/service/recommendationHandler.ts +++ b/packages/core/src/codewhisperer/service/recommendationHandler.ts @@ -50,23 +50,38 @@ import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' * It does not contain UI/UX related logic */ -// below commands override VS Code inline completion commands -const prevCommand = Commands.declare('editor.action.inlineSuggest.showPrevious', () => async () => { - await RecommendationHandler.instance.showRecommendation(-1) -}) -const nextCommand = Commands.declare('editor.action.inlineSuggest.showNext', () => async () => { - await RecommendationHandler.instance.showRecommendation(1) -}) - -const rejectCommand = Commands.declare('aws.amazonq.rejectCodeSuggestion', () => async () => { - telemetry.record({ - traceId: TelemetryHelper.instance.traceId, +/** + * Commands as a level of indirection so that declare doesn't intercept any registrations for the + * language server implementation. + * + * Otherwise you'll get: + * "Unable to launch amazonq language server: Command "aws.amazonq.rejectCodeSuggestion" has already been declared by the Toolkit" + */ +function createCommands() { + // below commands override VS Code inline completion commands + const prevCommand = Commands.declare('editor.action.inlineSuggest.showPrevious', () => async () => { + await RecommendationHandler.instance.showRecommendation(-1) + }) + const nextCommand = Commands.declare('editor.action.inlineSuggest.showNext', () => async () => { + await RecommendationHandler.instance.showRecommendation(1) }) - await vscode.commands.executeCommand('editor.action.inlineSuggest.hide') - RecommendationHandler.instance.reportUserDecisions(-1) - await Commands.tryExecute('aws.amazonq.refreshAnnotation') -}) + const rejectCommand = Commands.declare('aws.amazonq.rejectCodeSuggestion', () => async () => { + telemetry.record({ + traceId: TelemetryHelper.instance.traceId, + }) + + await vscode.commands.executeCommand('editor.action.inlineSuggest.hide') + RecommendationHandler.instance.reportUserDecisions(-1) + await Commands.tryExecute('aws.amazonq.refreshAnnotation') + }) + + return { + prevCommand, + nextCommand, + rejectCommand, + } +} const lock = new AsyncLock({ maxPending: 1 }) @@ -579,6 +594,7 @@ export class RecommendationHandler { // They are subscribed when suggestion starts and disposed when suggestion is accepted/rejected // to avoid impacting other plugins or user who uses this API private registerCommandOverrides() { + const { prevCommand, nextCommand, rejectCommand } = createCommands() this.prev = prevCommand.register() this.next = nextCommand.register() this.reject = rejectCommand.register() diff --git a/packages/core/src/shared/crypto.ts b/packages/core/src/shared/crypto.ts index 808f5730d81..647525098a7 100644 --- a/packages/core/src/shared/crypto.ts +++ b/packages/core/src/shared/crypto.ts @@ -24,11 +24,15 @@ import { isWeb } from './extensionGlobals' export function randomUUID(): `${string}-${string}-${string}-${string}-${string}` { + return getCrypto().randomUUID() +} + +function getCrypto() { if (isWeb()) { - return globalThis.crypto.randomUUID() + return globalThis.crypto } - return require('crypto').randomUUID() + return require('crypto') } /** @@ -54,3 +58,10 @@ export function truncateUuid(uuid: string) { const cleanedUUID = uuid.replace(/-/g, '') return `${cleanedUUID.substring(0, 4)}...${cleanedUUID.substring(cleanedUUID.length - 4)}` } + +export function createHash(algorithm: string, contents: string | Buffer): string { + const crypto = getCrypto() + const hash = crypto.createHash(algorithm) + hash.update(contents) + return `${algorithm}:${hash.digest('hex')}` +} diff --git a/packages/core/src/shared/globalState.ts b/packages/core/src/shared/globalState.ts index defb7658f68..2529bd45d09 100644 --- a/packages/core/src/shared/globalState.ts +++ b/packages/core/src/shared/globalState.ts @@ -45,6 +45,8 @@ export type globalKey = | 'aws.toolkit.amazonq.dismissed' | 'aws.toolkit.amazonqInstall.dismissed' | 'aws.amazonq.workspaceIndexToggleOn' + | 'aws.toolkit.lsp.versions' + | 'aws.toolkit.lsp.manifest' // Deprecated/legacy names. New keys should start with "aws.". | '#sessionCreationDates' // Legacy name from `ssoAccessTokenProvider.ts`. | 'CODECATALYST_RECONNECT' diff --git a/packages/core/src/shared/index.ts b/packages/core/src/shared/index.ts index dffa40df3ee..f82990bc83e 100644 --- a/packages/core/src/shared/index.ts +++ b/packages/core/src/shared/index.ts @@ -13,7 +13,7 @@ export { activate as activateLogger } from './logger/activation' export { activate as activateTelemetry } from './telemetry/activation' export { DefaultAwsContext } from './awsContext' export { DefaultAWSClientBuilder, ServiceOptions } from './awsClientBuilder' -export { Settings, DevSettings } from './settings' +export { Settings, Experiments, DevSettings } from './settings' export * from './extensionUtilities' export * from './extensionStartup' export { RegionProvider } from './regions/regionProvider' @@ -62,3 +62,10 @@ export { i18n } from './i18n-helper' export * from './icons' export * as textDocumentUtil from './utilities/textDocumentUtilities' 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/logger/logger.ts b/packages/core/src/shared/logger/logger.ts index d5bf5f13380..98a7e29d48e 100644 --- a/packages/core/src/shared/logger/logger.ts +++ b/packages/core/src/shared/logger/logger.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode' -export type LogTopic = 'crashMonitoring' | 'dev/beta' | 'notifications' | 'test' | 'childProcess' | 'unknown' +export type LogTopic = 'crashMonitoring' | 'dev/beta' | 'notifications' | 'test' | 'childProcess' | 'lsp' | 'unknown' class ErrorLog { constructor( diff --git a/packages/core/src/shared/languageServer/languageModelCache.ts b/packages/core/src/shared/lsp/languageModelCache.ts similarity index 100% rename from packages/core/src/shared/languageServer/languageModelCache.ts rename to packages/core/src/shared/lsp/languageModelCache.ts diff --git a/packages/core/src/shared/lsp/lspResolver.ts b/packages/core/src/shared/lsp/lspResolver.ts new file mode 100644 index 00000000000..5a907b96a02 --- /dev/null +++ b/packages/core/src/shared/lsp/lspResolver.ts @@ -0,0 +1,354 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from '../fs/fs' +import { ToolkitError } from '../errors' +import * as semver from 'semver' +import * as path from 'path' +import { FileType } from 'vscode' +import AdmZip from 'adm-zip' +import { TargetContent, logger, LspResult, LspVersion, Manifest } from './types' +import { getApplicationSupportFolder } from '../vscode/env' +import { createHash } from '../crypto' +import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher' + +export class LanguageServerResolver { + constructor( + private readonly manifest: Manifest, + private readonly lsName: string, + private readonly versionRange: semver.Range, + private readonly _defaultDownloadFolder?: string + ) {} + + /** + * Downloads and sets up the Language Server, attempting different locations in order: + * 1. Local cache + * 2. Remote download + * 3. Fallback version + * @throws ToolkitError if no compatible version can be found + */ + async resolve() { + const result: LspResult = { + location: 'unknown', + version: '', + assetDirectory: '', + } + + const latestVersion = this.latestCompatibleLspVersion() + const targetContents = this.getLSPTargetContents(latestVersion) + const cacheDirectory = this.getDownloadDirectory(latestVersion.serverVersion) + + if (await this.hasValidLocalCache(cacheDirectory, targetContents)) { + result.location = 'cache' + result.version = latestVersion.serverVersion + result.assetDirectory = cacheDirectory + return result + } else { + // Delete the cached directory since it's invalid + if (await fs.existsDir(cacheDirectory)) { + await fs.delete(cacheDirectory, { + recursive: true, + }) + } + } + + if (await this.downloadRemoteTargetContent(targetContents, latestVersion.serverVersion)) { + result.location = 'remote' + result.version = latestVersion.serverVersion + result.assetDirectory = cacheDirectory + return result + } else { + // clean up any leftover content that may have been downloaded + if (await fs.existsDir(cacheDirectory)) { + await fs.delete(cacheDirectory, { + recursive: true, + }) + } + } + + logger.info( + `Unable to download language server version ${latestVersion.serverVersion}. Attempting to fetch from fallback location` + ) + + const fallbackDirectory = await this.getFallbackDir(latestVersion.serverVersion) + if (!fallbackDirectory) { + throw new ToolkitError('Unable to find a compatible version of the Language Server') + } + + const version = path.basename(fallbackDirectory) + logger.info( + `Unable to install ${this.lsName} language server v${latestVersion.serverVersion}. Launching a previous version from ${fallbackDirectory}` + ) + + result.location = 'fallback' + result.version = version + result.assetDirectory = fallbackDirectory + + return result + } + + /** + * Get all of the compatible language server versions from the manifest + */ + private compatibleManifestLspVersion() { + return this.manifest.versions.filter((x) => this.isCompatibleVersion(x)) + } + + /** + * Returns the path to the most compatible cached LSP version that can serve as a fallback + **/ + private async getFallbackDir(version: string) { + const compatibleLspVersions = this.compatibleManifestLspVersion() + + // determine all folders containing lsp versions in the fallback parent folder + const cachedVersions = (await fs.readdir(this.defaultDownloadFolder())) + .filter(([_, filetype]) => filetype === FileType.Directory) + .map(([pathName, _]) => semver.parse(pathName)) + .filter((ver): ver is semver.SemVer => ver !== null) + .map((x) => x.version) + + const expectedVersion = semver.parse(version) + if (!expectedVersion) { + return undefined + } + + const sortedCachedLspVersions = compatibleLspVersions + .filter((v) => this.isValidCachedVersion(v, cachedVersions, expectedVersion)) + .sort((a, b) => semver.compare(b.serverVersion, a.serverVersion)) + + const fallbackDir = ( + await Promise.all(sortedCachedLspVersions.map((ver) => this.getValidLocalCacheDirectory(ver))) + ).filter((v) => v !== undefined) + return fallbackDir.length > 0 ? fallbackDir[0] : undefined + } + + /** + * Validate the local cache directory of the given lsp version (matches expected hash) + * If valid return cache directory, else return undefined + */ + private async getValidLocalCacheDirectory(version: LspVersion) { + const targetContents = this.getTargetContents(version) + if (targetContents === undefined || targetContents.length === 0) { + return undefined + } + + const cacheDir = this.getDownloadDirectory(version.serverVersion) + const hasValidCache = await this.hasValidLocalCache(cacheDir, targetContents) + + return hasValidCache ? cacheDir : undefined + } + + /** + * Determines if a cached LSP version is valid for use as a fallback. + * A version is considered valid if it exists in the cache and is less than + * or equal to the expected version. + */ + private isValidCachedVersion(version: LspVersion, cachedVersions: string[], expectedVersion: semver.SemVer) { + const serverVersion = semver.parse(version.serverVersion) as semver.SemVer + return cachedVersions.includes(serverVersion.version) && semver.lte(serverVersion, expectedVersion) + } + + /** + * Download and unzip all of the contents into the download directory + * + * @returns + * true, if all of the contents were successfully downloaded and unzipped + * false, if any of the contents failed to download or unzip + */ + private async downloadRemoteTargetContent(contents: TargetContent[], version: string) { + const downloadDirectory = this.getDownloadDirectory(version) + + if (!(await fs.existsDir(downloadDirectory))) { + await fs.mkdir(downloadDirectory) + } + + const downloadTasks = contents.map(async (content) => { + const res = await new HttpResourceFetcher(content.url, { showUrl: true }).get() + if (!res || !res.ok || !res.body) { + return false + } + + const arrBuffer = await res.arrayBuffer() + const data = Buffer.from(arrBuffer) + + const hash = createHash('sha384', data) + if (hash === content.hashes[0]) { + await fs.writeFile(`${downloadDirectory}/${content.filename}`, data) + return true + } + return false + }) + const downloadResults = await Promise.all(downloadTasks) + const downloadResult = downloadResults.every(Boolean) + return downloadResult && this.extractZipFilesFromRemote(downloadDirectory) + } + + private async extractZipFilesFromRemote(downloadDirectory: string) { + // Find all the zips + const zips = (await fs.readdir(downloadDirectory)) + .filter(([fileName, _]) => fileName.endsWith('.zip')) + .map(([fileName, _]) => `${downloadDirectory}/${fileName}`) + + if (zips.length === 0) { + return true + } + + return this.copyZipContents(zips) + } + + private async hasValidLocalCache(localCacheDirectory: string, targetContents: TargetContent[]) { + // check if the zips are still at the present location + const results = await Promise.all( + targetContents.map((content) => { + const path = `${localCacheDirectory}/${content.filename}` + return fs.existsFile(path) + }) + ) + + const allFilesExist = results.every(Boolean) + return allFilesExist && this.ensureUnzippedFoldersMatchZip(localCacheDirectory, targetContents) + } + + /** + * Ensures zip files in cache have an unzipped folder of the same name + * with the same content files (by name) + * + * @returns + * false, if any of the unzipped folder don't match zip contents (by name) + */ + private ensureUnzippedFoldersMatchZip(localCacheDirectory: string, targetContents: TargetContent[]) { + const zipPaths = targetContents + .filter((x) => x.filename.endsWith('.zip')) + .map((y) => `${localCacheDirectory}/${y.filename}`) + + if (zipPaths.length === 0) { + return true + } + + return this.copyZipContents(zipPaths) + } + + /** + * Copies all the contents from zip into the directory + * + * @returns + * false, if any of the unzips fails + */ + private copyZipContents(zips: string[]) { + const unzips = zips.map((zip) => { + try { + // attempt to unzip + const zipFile = new AdmZip(zip) + const extractPath = zip.replace('.zip', '') + zipFile.extractAllTo(extractPath, true) + } catch (e) { + return false + } + return true + }) + + // make sure every one completed successfully + return unzips.every(Boolean) + } + + /** + * Parses the toolkit lsp version object retrieved from the version manifest to determine + * lsp contents + */ + private getLSPTargetContents(version: LspVersion) { + const lspTarget = this.getCompatibleLspTarget(version) + if (!lspTarget) { + throw new ToolkitError("No language server target found matching the system's architecture and platform") + } + + const targetContents = lspTarget.contents + if (!targetContents) { + throw new ToolkitError('No matching target contents found') + } + return targetContents + } + + /** + * Get the latest language server version matching the toolkit compatible version range, + * not de-listed and contains the required target contents: + * architecture, platform and files + */ + private latestCompatibleLspVersion() { + if (this.manifest === null) { + throw new ToolkitError('No valid manifest') + } + + const latestCompatibleVersion = + this.manifest.versions + .filter((ver) => this.isCompatibleVersion(ver) && this.hasRequiredTargetContent(ver)) + .sort((a, b) => semver.compare(b.serverVersion, a.serverVersion))[0] ?? undefined + + if (latestCompatibleVersion === undefined) { + // TODO fix these error range names + throw new ToolkitError( + `Unable to find a language server that satifies one or more of these conditions: version in range [${this.versionRange.range}], matching system's architecture and platform` + ) + } + + return latestCompatibleVersion + } + + /** + * Determine if the given lsp version is toolkit compatible + * i.e. in version range and not de-listed + */ + private isCompatibleVersion(version: LspVersion) { + // invalid version + if (semver.parse(version.serverVersion) === null) { + return false + } + + return semver.satisfies(version.serverVersion, this.versionRange) && !version.isDelisted + } + + /** + * Validates the lsp version contains the required toolkit compatible contents: + * architecture, platform and file + */ + private hasRequiredTargetContent(version: LspVersion) { + const targetContents = this.getTargetContents(version) + return targetContents !== undefined && targetContents.length > 0 + } + + /** + * Returns the target contents of the lsp version that contains the required + * toolkit compatible contents: architecture, platform and file + */ + private getTargetContents(version: LspVersion) { + const target = this.getCompatibleLspTarget(version) + return target?.contents + } + + /** + * Retrives the lsp target matching the user's system architecture and platform + * from the language server version object + */ + private getCompatibleLspTarget(version: LspVersion) { + // TODO make this web friendly + // TODO make this fully support windows + const platform = process.platform + const arch = process.arch + 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() { + return path.join(LanguageServerResolver.defaultDir, `${this.lsName}`) + } + + private getDownloadDirectory(version: string) { + const directory = this._defaultDownloadFolder ?? this.defaultDownloadFolder() + return `${directory}/${version}` + } +} diff --git a/packages/core/src/shared/lsp/manifestResolver.ts b/packages/core/src/shared/lsp/manifestResolver.ts new file mode 100644 index 00000000000..0cf27b1293b --- /dev/null +++ b/packages/core/src/shared/lsp/manifestResolver.ts @@ -0,0 +1,108 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from '../logger/logger' +import { ToolkitError } from '../errors' +import { Timeout } from '../utilities/timeoutUtils' +import globals from '../extensionGlobals' +import { Manifest } from './types' +import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher' + +const logger = getLogger('lsp') + +interface StorageManifest { + etag: string + content: string +} + +type ManifestStorage = Record + +const manifestStorageKey = 'aws.toolkit.lsp.manifest' +const manifestTimeoutMs = 15000 + +export class ManifestResolver { + constructor( + private readonly manifestURL: string, + private readonly lsName: string + ) {} + + /** + * Fetches the latest manifest, falling back to local cache on failure + */ + async resolve(): Promise { + try { + return await this.fetchRemoteManifest() + } catch (error) { + return await this.getLocalManifest() + } + } + + private async fetchRemoteManifest(): Promise { + const resp = await new HttpResourceFetcher(this.manifestURL, { + showUrl: true, + timeout: new Timeout(manifestTimeoutMs), + }).getNewETagContent(this.getEtag()) + + if (!resp.content) { + throw new ToolkitError('New content was not downloaded; fallback to the locally stored manifest') + } + + const manifest = this.parseManifest(resp.content) + await this.saveManifest(resp.eTag, resp.content) + this.checkDeprecation(manifest) + + return manifest + } + + private async getLocalManifest(): Promise { + logger.info('Failed to download latest LSP manifest. Falling back to local manifest.') + const storage = this.getStorage() + const manifestData = storage[this.lsName] + + if (!manifestData?.content) { + throw new ToolkitError('Failed to download LSP manifest and no local manifest found.') + } + + const manifest = this.parseManifest(manifestData.content) + this.checkDeprecation(manifest) + return manifest + } + + private parseManifest(content: string): Manifest { + try { + return JSON.parse(content) as Manifest + } catch (error) { + throw new ToolkitError( + `Failed to parse manifest: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } + + private checkDeprecation(manifest: Manifest): void { + if (manifest.isManifestDeprecated) { + logger.info('This LSP manifest is deprecated. No future updates will be available.') + } + } + + private async saveManifest(etag: string, content: string): Promise { + const storage = this.getStorage() + + globals.globalState.tryUpdate(manifestStorageKey, { + ...storage, + [this.lsName]: { + etag, + content, + }, + }) + } + + private getEtag(): string | undefined { + return this.getStorage()[this.lsName]?.etag + } + + private getStorage(): ManifestStorage { + return globals.globalState.tryGet(manifestStorageKey, Object, {}) + } +} diff --git a/packages/core/src/shared/lsp/types.ts b/packages/core/src/shared/lsp/types.ts new file mode 100644 index 00000000000..4f5a3cf1c87 --- /dev/null +++ b/packages/core/src/shared/lsp/types.ts @@ -0,0 +1,61 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from '../logger/logger' + +export const logger = getLogger('lsp') + +type Location = 'remote' | 'cache' | 'override' | 'fallback' | 'unknown' + +export interface LspResult { + location: Location + version: string + assetDirectory: string +} + +export interface ResourcePaths { + lsp: string + node: string +} +export interface LspResolution extends LspResult { + resourcePaths: ResourcePaths +} + +export interface LspResolver { + resolve(): Promise +} + +export interface TargetContent { + filename: string + url: string + hashes: string[] + bytes: number + serverVersion?: string +} + +export interface Target { + platform: string + arch: string + contents: TargetContent[] +} + +export interface LspVersion { + serverVersion: string + isDelisted: boolean + targets: Target[] +} + +export interface Manifest { + manifestSchemaVersion: string + artifactId: string + artifactDescription: string + isManifestDeprecated: boolean + versions: LspVersion[] +} + +export interface VersionRange { + start: number + end: number +} 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/lsp/utils/platform.ts b/packages/core/src/shared/lsp/utils/platform.ts new file mode 100644 index 00000000000..44e68c423d2 --- /dev/null +++ b/packages/core/src/shared/lsp/utils/platform.ts @@ -0,0 +1,55 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ToolkitError } from '../../errors' +import { ChildProcess } from '../../utilities/processUtils' +import { isDebugInstance } from '../../vscode/env' + +export function getNodeExecutableName(): string { + return process.platform === 'win32' ? 'node.exe' : 'node' +} + +/** + * Get a json payload that will be sent to the language server, who is waiting to know what the encryption key is. + * Code reference: https://github.com/aws/language-servers/blob/7da212185a5da75a72ce49a1a7982983f438651a/client/vscode/src/credentialsActivation.ts#L77 + */ +function getEncryptionInit(key: Buffer): string { + const request = { + version: '1.0', + mode: 'JWT', + key: key.toString('base64'), + } + return JSON.stringify(request) + '\n' +} + +export function createServerOptions({ + encryptionKey, + executable, + serverModule, + execArgv, +}: { + encryptionKey: Buffer + executable: string + serverModule: string + execArgv: string[] +}) { + return async () => { + const debugArgs = isDebugInstance() ? '--inspect=6080' : '' + const lspProcess = new ChildProcess(executable, [debugArgs, serverModule, ...execArgv]) + + // this is a long running process, awaiting it will never resolve + void lspProcess.run() + + // share an encryption key using stdin + // follow same practice of DEXP LSP server + await lspProcess.send(getEncryptionInit(encryptionKey)) + + const proc = lspProcess.proc() + if (!proc) { + throw new ToolkitError('Language Server process was not started') + } + return proc + } +} diff --git a/packages/core/src/shared/languageServer/utils/runner.ts b/packages/core/src/shared/lsp/utils/runner.ts similarity index 100% rename from packages/core/src/shared/languageServer/utils/runner.ts rename to packages/core/src/shared/lsp/utils/runner.ts diff --git a/packages/core/src/shared/settings-toolkit.gen.ts b/packages/core/src/shared/settings-toolkit.gen.ts index ea291352701..8e4cc453d03 100644 --- a/packages/core/src/shared/settings-toolkit.gen.ts +++ b/packages/core/src/shared/settings-toolkit.gen.ts @@ -41,7 +41,8 @@ export const toolkitSettings = { "ssoCacheError": {} }, "aws.experiments": { - "jsonResourceModification": {} + "jsonResourceModification": {}, + "amazonqLSP": {} }, "aws.resources.enabledResources": {}, "aws.lambda.recentlyUploaded": {}, diff --git a/packages/core/src/shared/utilities/processUtils.ts b/packages/core/src/shared/utilities/processUtils.ts index 488ebe14e7b..d06326b93b7 100644 --- a/packages/core/src/shared/utilities/processUtils.ts +++ b/packages/core/src/shared/utilities/processUtils.ts @@ -389,6 +389,10 @@ export class ChildProcess { return this.#processResult } + public proc(): proc.ChildProcess | undefined { + return this.#childProcess + } + public pid(): number { return this.#childProcess?.pid ?? -1 } 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/shared/vscode/env.ts b/packages/core/src/shared/vscode/env.ts index e9c8c1983b1..7d3d0f5d234 100644 --- a/packages/core/src/shared/vscode/env.ts +++ b/packages/core/src/shared/vscode/env.ts @@ -12,6 +12,7 @@ import { onceChanged } from '../utilities/functionUtils' import { ChildProcess } from '../utilities/processUtils' import globals, { isWeb } from '../extensionGlobals' import * as devConfig from '../../dev/config' +import path from 'path' /** * Returns true if the current build is running on CI (build server). @@ -270,3 +271,14 @@ export async function getMachineId(): Promise { // TODO: check exit code. return (await proc.run()).stdout.trim() ?? 'unknown-host' } + +export function getApplicationSupportFolder() { + switch (process.platform) { + case 'darwin': { + return path.join(os.homedir(), 'Library/Application Support') + } + default: { + throw new Error('Only mac is supported right now') + } + } +} diff --git a/packages/core/src/stepFunctions/asl/aslServer.ts b/packages/core/src/stepFunctions/asl/aslServer.ts index 8da1363969c..2d4c4fadde3 100644 --- a/packages/core/src/stepFunctions/asl/aslServer.ts +++ b/packages/core/src/stepFunctions/asl/aslServer.ts @@ -37,8 +37,8 @@ import { import { posix } from 'path' import * as URL from 'url' -import { getLanguageModelCache } from '../../shared/languageServer/languageModelCache' -import { formatError, runSafe, runSafeAsync } from '../../shared/languageServer/utils/runner' +import { getLanguageModelCache } from '../../shared/lsp/languageModelCache' +import { formatError, runSafe, runSafeAsync } from '../../shared/lsp/utils/runner' import { YAML_ASL, JSON_ASL } from '../constants/aslFormats' export const ResultLimitReached: NotificationType = new NotificationType('asl/resultLimitReached') 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]) + }) +}) diff --git a/packages/core/src/testInteg/perf/getFileSha384.test.ts b/packages/core/src/testInteg/perf/getFileSha384.test.ts deleted file mode 100644 index 4f12fbfeeb8..00000000000 --- a/packages/core/src/testInteg/perf/getFileSha384.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ -import assert from 'assert' -import path from 'path' -import sinon from 'sinon' -import { getTestWorkspaceFolder } from '../integrationTestsUtilities' -import { fs, getRandomString } from '../../shared' -import { LspController } from '../../amazonq' -import { getEqualOSTestOptions, performanceTest } from '../../shared/performance/performance' -import { FileSystem } from '../../shared/fs/fs' -import { getFsCallsUpperBound } from './utilities' - -interface SetupResult { - testFile: string - fsSpy: sinon.SinonSpiedInstance -} - -function performanceTestWrapper(label: string, fileSize: number) { - return performanceTest( - getEqualOSTestOptions({ - userCpuUsage: 500, - systemCpuUsage: 35, - heapTotal: 4, - }), - label, - function () { - return { - setup: async () => { - const workspace = getTestWorkspaceFolder() - const fileContent = getRandomString(fileSize) - const testFile = path.join(workspace, 'test-file') - await fs.writeFile(testFile, fileContent) - const fsSpy = sinon.spy(fs) - return { testFile, fsSpy } - }, - execute: async (setup: SetupResult) => { - return await LspController.instance.getFileSha384(setup.testFile) - }, - verify: async (setup: SetupResult, result: string) => { - assert.strictEqual(result.length, 96) - assert.ok(getFsCallsUpperBound(setup.fsSpy) <= 1, 'makes a single call to fs') - }, - } - } - ) -} - -describe('getFileSha384', function () { - describe('performance tests', function () { - afterEach(function () { - sinon.restore() - }) - performanceTestWrapper('1MB', 1000) - performanceTestWrapper('2MB', 2000) - performanceTestWrapper('4MB', 4000) - performanceTestWrapper('8MB', 8000) - }) -}) diff --git a/packages/core/src/testInteg/perf/tryInstallLsp.test.ts b/packages/core/src/testInteg/perf/tryInstallLsp.test.ts deleted file mode 100644 index d84da8626d3..00000000000 --- a/packages/core/src/testInteg/perf/tryInstallLsp.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ -import assert from 'assert' -import sinon from 'sinon' -import { Content } from 'aws-sdk/clients/codecommit' -import AdmZip from 'adm-zip' -import path from 'path' -import { LspController } from '../../amazonq' -import { fs, getRandomString, globals } from '../../shared' -import { createTestWorkspace } from '../../test/testUtil' -import { getEqualOSTestOptions, performanceTest } from '../../shared/performance/performance' -import { getFsCallsUpperBound } from './utilities' -import { FileSystem } from '../../shared/fs/fs' - -// fakeFileContent is matched to fakeQServerContent based on hash. -const fakeHash = '4eb2865c8f40a322aa04e17d8d83bdaa605d6f1cb363af615240a5442a010e0aef66e21bcf4c88f20fabff06efe8a214' - -const fakeQServerContent = { - filename: 'qserver-fake.zip', - url: 'https://aws-language-servers/fake.zip', - hashes: [`sha384:${fakeHash}`], - bytes: 93610849, - serverVersion: '1.1.1', -} - -const fakeNodeContent = { - filename: 'fake-file', - url: 'https://aws-language-servers.fake-file', - hashes: [`sha384:${fakeHash}`], - bytes: 94144448, - serverVersion: '1.1.1', -} - -function createStubs(numberOfFiles: number, fileSize: number): sinon.SinonSpiedInstance { - // Avoid making HTTP request or mocking giant manifest, stub what we need directly from request. - sinon.stub(LspController.prototype, 'fetchManifest') - // Directly feed the runtime specifications. - sinon.stub(LspController.prototype, 'getQserverFromManifest').returns(fakeQServerContent) - sinon.stub(LspController.prototype, 'getNodeRuntimeFromManifest').returns(fakeNodeContent) - // avoid fetch call. - sinon.stub(LspController.prototype, '_download').callsFake(getFakeDownload(numberOfFiles, fileSize)) - // Hard code the hash since we are creating files on the spot, whose hashes can't be predicted. - sinon.stub(LspController.prototype, 'getFileSha384').resolves(fakeHash) - const fsSpy = sinon.spy(fs) - fsSpy.rename.restore() - // Don't allow tryInstallLsp to move runtimes out of temporary folder - sinon.stub(fsSpy, 'rename') - return fsSpy -} - -/** - * Creates a fake zip with some files in it. - * @param filepath where to write the zip to. - * @param _content unused parameter, for compatability with real function. - */ -const getFakeDownload = function (numberOfFiles: number, fileSize: number) { - return async function (filepath: string, _content: Content) { - const dummyFilesPath = ( - await createTestWorkspace(numberOfFiles, { - fileNamePrefix: 'fakeFile', - fileContent: getRandomString(fileSize), - workspaceName: 'workspace', - }) - ).uri.fsPath - await fs.writeFile(path.join(dummyFilesPath, 'qserver'), 'this value shouldnt matter') - const zip = new AdmZip() - zip.addLocalFolder(dummyFilesPath) - zip.writeZip(filepath) - } -} - -function performanceTestWrapper(numFiles: number, fileSize: number, message: string) { - return performanceTest( - getEqualOSTestOptions({ - userCpuUsage: 150, - systemCpuUsage: 35, - heapTotal: 6, - duration: 15, - }), - message, - function () { - return { - setup: async () => { - return createStubs(numFiles, fileSize) - }, - execute: async () => { - return await LspController.instance.tryInstallLsp(globals.context) - }, - verify: async (fsSpy: sinon.SinonSpiedInstance, result: boolean) => { - assert.ok(result) - assert.ok(getFsCallsUpperBound(fsSpy) <= 6 * numFiles) - }, - } - } - ) -} - -describe('tryInstallLsp', function () { - afterEach(function () { - sinon.restore() - }) - describe('performance tests', function () { - performanceTestWrapper(250, 10, '250x10') - performanceTestWrapper(10, 1000, '10x1000') - }) -}) diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 6c83927208c..5e64ef8aa96 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -246,6 +246,10 @@ "jsonResourceModification": { "type": "boolean", "default": false + }, + "amazonqLSP": { + "type": "boolean", + "default": false } }, "additionalProperties": false diff --git a/packages/webpack.web.config.js b/packages/webpack.web.config.js index fc6ca86d1d9..0f82af67389 100644 --- a/packages/webpack.web.config.js +++ b/packages/webpack.web.config.js @@ -41,7 +41,7 @@ module.exports = (env, argv) => { * environments. The following allows compilation to pass in Web mode by never bundling the module in the final output for web mode. */ new webpack.IgnorePlugin({ - resourceRegExp: /httpResourceFetcher/, // matches the path in the require() statement + resourceRegExp: /node\/httpResourceFetcher/, // matches the path in the require() statement }), /** * HACK: the ps-list module breaks Web mode if imported, BUT we still dynamically import this module for non web mode