diff --git a/packages/amazonq/.changes/next-release/Feature-d6714581-799f-49dc-bb63-f08d461e9bde.json b/packages/amazonq/.changes/next-release/Feature-d6714581-799f-49dc-bb63-f08d461e9bde.json new file mode 100644 index 00000000000..c1ff05f38ff --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-d6714581-799f-49dc-bb63-f08d461e9bde.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Launch LSP with bundled artifacts as fallback" +} diff --git a/packages/amazonq/src/lsp/activation.ts b/packages/amazonq/src/lsp/activation.ts index 84bae8a01a6..e2c1b6899d9 100644 --- a/packages/amazonq/src/lsp/activation.ts +++ b/packages/amazonq/src/lsp/activation.ts @@ -5,8 +5,8 @@ import vscode from 'vscode' import { startLanguageServer } from './client' -import { AmazonQLspInstaller } from './lspInstaller' -import { lspSetupStage, ToolkitError, messages } from 'aws-core-vscode/shared' +import { AmazonQLspInstaller, getBundledResourcePaths } from './lspInstaller' +import { lspSetupStage, ToolkitError, messages, getLogger } from 'aws-core-vscode/shared' export async function activate(ctx: vscode.ExtensionContext): Promise { try { @@ -16,6 +16,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }) } catch (err) { const e = err as ToolkitError - void messages.showViewLogsMessage(`Failed to launch Amazon Q language server: ${e.message}`) + getLogger('amazonqLsp').warn(`Failed to start downloaded LSP, falling back to bundled LSP: ${e.message}`) + try { + await lspSetupStage('all', async () => { + await lspSetupStage('launch', async () => await startLanguageServer(ctx, getBundledResourcePaths(ctx))) + }) + } catch (error) { + void messages.showViewLogsMessage( + `Failed to launch Amazon Q language server: ${(error as ToolkitError).message}` + ) + } } } diff --git a/packages/amazonq/src/lsp/chat/webviewProvider.ts b/packages/amazonq/src/lsp/chat/webviewProvider.ts index 1a513f1df3f..bb190b5eb67 100644 --- a/packages/amazonq/src/lsp/chat/webviewProvider.ts +++ b/packages/amazonq/src/lsp/chat/webviewProvider.ts @@ -19,6 +19,7 @@ import { AmazonQPromptSettings, LanguageServerResolver, amazonqMark, + getLogger, } from 'aws-core-vscode/shared' import { AuthUtil, RegionProfile } from 'aws-core-vscode/codewhisperer' import { featureConfig } from 'aws-core-vscode/amazonq' @@ -44,9 +45,12 @@ export class AmazonQChatViewProvider implements WebviewViewProvider { ) { const lspDir = Uri.file(LanguageServerResolver.defaultDir()) const dist = Uri.joinPath(globals.context.extensionUri, 'dist') - - const resourcesRoots = [lspDir, dist] - + const bundledResources = Uri.joinPath(globals.context.extensionUri, 'resources/language-server') + let resourcesRoots = [lspDir, dist] + if (this.mynahUIPath?.startsWith(globals.context.extensionUri.fsPath)) { + getLogger('amazonqLsp').info(`Using bundled webview resources ${bundledResources.fsPath}`) + resourcesRoots = [bundledResources, dist] + } /** * if the mynah chat client is defined, then make sure to add it to the resource roots, otherwise * it will 401 when trying to load diff --git a/packages/amazonq/src/lsp/lspInstaller.ts b/packages/amazonq/src/lsp/lspInstaller.ts index 84d5ee8961b..9ac19601fe7 100644 --- a/packages/amazonq/src/lsp/lspInstaller.ts +++ b/packages/amazonq/src/lsp/lspInstaller.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import vscode from 'vscode' import { fs, getNodeExecutableName, getRgExecutableName, BaseLspInstaller, ResourcePaths } from 'aws-core-vscode/shared' import path from 'path' import { ExtendedAmazonQLSPConfig, getAmazonQLspConfig } from './config' @@ -54,3 +55,13 @@ export class AmazonQLspInstaller extends BaseLspInstaller.BaseLspInstaller< protected override downloadMessageOverride: string | undefined = 'Updating Amazon Q plugin' } + +export function getBundledResourcePaths(ctx: vscode.ExtensionContext): AmazonQResourcePaths { + const assetDirectory = vscode.Uri.joinPath(ctx.extensionUri, 'resources', 'language-server').fsPath + return { + lsp: path.join(assetDirectory, 'servers', 'aws-lsp-codewhisperer.js'), + node: process.execPath, + ripGrep: '', + ui: path.join(assetDirectory, 'clients', 'amazonq-ui.js'), + } +} diff --git a/scripts/lspArtifact.ts b/scripts/lspArtifact.ts new file mode 100644 index 00000000000..fb055ad94b7 --- /dev/null +++ b/scripts/lspArtifact.ts @@ -0,0 +1,181 @@ +import * as https from 'https' +import * as fs from 'fs' +import * as crypto from 'crypto' +import * as path from 'path' +import * as os from 'os' +import AdmZip from 'adm-zip' + +interface ManifestContent { + filename: string + url: string + hashes: string[] + bytes: number +} + +interface ManifestTarget { + platform: string + arch: string + contents: ManifestContent[] +} + +interface ManifestVersion { + serverVersion: string + isDelisted: boolean + targets: ManifestTarget[] +} + +interface Manifest { + versions: ManifestVersion[] +} +async function verifyFileHash(filePath: string, expectedHash: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha384') + const stream = fs.createReadStream(filePath) + + stream.on('data', (data) => { + hash.update(data) + }) + + stream.on('end', () => { + const fileHash = hash.digest('hex') + // Remove 'sha384:' prefix from expected hash if present + const expectedHashValue = expectedHash.replace('sha384:', '') + resolve(fileHash === expectedHashValue) + }) + + stream.on('error', reject) + }) +} + +async function ensureDirectoryExists(dirPath: string): Promise { + if (!fs.existsSync(dirPath)) { + await fs.promises.mkdir(dirPath, { recursive: true }) + } +} + +export async function downloadLanguageServer(): Promise { + const tempDir = path.join(os.tmpdir(), 'amazonq-download-temp') + const resourcesDir = path.join(__dirname, '../packages/amazonq/resources/language-server') + + // clear previous cached language server + try { + if (fs.existsSync(resourcesDir)) { + fs.rmdirSync(resourcesDir, { recursive: true }) + } + } catch (e) { + throw Error(`Failed to clean up language server ${resourcesDir}`) + } + + await ensureDirectoryExists(tempDir) + await ensureDirectoryExists(resourcesDir) + + return new Promise((resolve, reject) => { + const manifestUrl = 'https://aws-toolkit-language-servers.amazonaws.com/qAgenticChatServer/0/manifest.json' + + https + .get(manifestUrl, (res) => { + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', async () => { + try { + const manifest: Manifest = JSON.parse(data) + + const latestVersion = manifest.versions + .filter((v) => !v.isDelisted) + .sort((a, b) => b.serverVersion.localeCompare(a.serverVersion))[0] + + if (!latestVersion) { + throw new Error('No valid version found in manifest') + } + + const darwinArm64Target = latestVersion.targets.find( + (t) => t.platform === 'darwin' && t.arch === 'arm64' + ) + + if (!darwinArm64Target) { + throw new Error('No darwin arm64 target found') + } + + for (const content of darwinArm64Target.contents) { + const fileName = content.filename + const fileUrl = content.url + const expectedHash = content.hashes[0] + const tempFilePath = path.join(tempDir, fileName) + const fileFolderName = content.filename.replace('.zip', '') + + console.log(`Downloading ${fileName} from ${fileUrl} ...`) + + await new Promise((downloadResolve, downloadReject) => { + https + .get(fileUrl, (fileRes) => { + const fileStream = fs.createWriteStream(tempFilePath) + fileRes.pipe(fileStream) + + fileStream.on('finish', () => { + fileStream.close() + downloadResolve(void 0) + }) + + fileStream.on('error', (err) => { + fs.unlink(tempFilePath, () => {}) + downloadReject(err) + }) + }) + .on('error', (err) => { + fs.unlink(tempFilePath, () => {}) + downloadReject(err) + }) + }) + + console.log(`Verifying hash for ${fileName}...`) + const isHashValid = await verifyFileHash(tempFilePath, expectedHash) + + if (!isHashValid) { + fs.unlinkSync(tempFilePath) + throw new Error(`Hash verification failed for ${fileName}`) + } + + console.log(`Extracting ${fileName}...`) + const zip = new AdmZip(tempFilePath) + zip.extractAllTo(path.join(resourcesDir, fileFolderName), true) // true for overwrite + + // Clean up temp file + fs.unlinkSync(tempFilePath) + console.log(`Successfully processed ${fileName}`) + } + + // Clean up temp directory + fs.rmdirSync(tempDir) + fs.rmdirSync(path.join(resourcesDir, 'servers', 'indexing'), { recursive: true }) + fs.rmdirSync(path.join(resourcesDir, 'servers', 'ripgrep'), { recursive: true }) + fs.rmSync(path.join(resourcesDir, 'servers', 'node')) + if (!fs.existsSync(path.join(resourcesDir, 'servers', 'aws-lsp-codewhisperer.js'))) { + throw new Error(`Extracting aws-lsp-codewhisperer.js failure`) + } + if (!fs.existsSync(path.join(resourcesDir, 'clients', 'amazonq-ui.js'))) { + throw new Error(`Extracting amazonq-ui.js failure`) + } + console.log('Download and extraction completed successfully') + resolve() + } catch (err) { + // Clean up temp directory on error + if (fs.existsSync(tempDir)) { + fs.rmdirSync(tempDir, { recursive: true }) + } + reject(err) + } + }) + }) + .on('error', (err) => { + // Clean up temp directory on error + if (fs.existsSync(tempDir)) { + fs.rmdirSync(tempDir, { recursive: true }) + } + reject(err) + }) + }) +} diff --git a/scripts/package.ts b/scripts/package.ts index 84622ac12c0..203777e8131 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -20,6 +20,7 @@ import * as child_process from 'child_process' // eslint-disable-line no-restricted-imports import * as nodefs from 'fs' // eslint-disable-line no-restricted-imports import * as path from 'path' +import { downloadLanguageServer } from './lspArtifact' function parseArgs() { // Invoking this script with argument "foo": @@ -105,7 +106,7 @@ function getVersionSuffix(feature: string, debug: boolean): string { return `${debugSuffix}${featureSuffix}${commitSuffix}` } -function main() { +async function main() { const args = parseArgs() // It is expected that this will package from a packages/{subproject} folder. // There is a base config in packages/ @@ -155,6 +156,12 @@ function main() { } nodefs.writeFileSync(packageJsonFile, JSON.stringify(packageJson, undefined, ' ')) + + // add language server bundle + if (packageJson.name === 'amazon-q-vscode') { + await downloadLanguageServer() + } + child_process.execFileSync( 'vsce', [