diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72bcea6..3542b4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,20 +48,6 @@ jobs: - name: Run integration tests run: make integration_test - vscode_package: - name: VS Code Package - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - name: Install dependencies - run: bun install --frozen-lockfile - working-directory: editors/vscode - - name: Package extension - run: bun run package - working-directory: editors/vscode - build: name: Build ${{ matrix.platform.project }} - ${{ matrix.platform.release_for }} if: github.event.pull_request.draft == false @@ -151,6 +137,76 @@ jobs: with: name: ${{ matrix.platform.bin }}-${{ matrix.platform.target }} path: ${{ matrix.platform.name }} + + vscode_package: + name: VS Code Package - ${{ matrix.platform.vsce_target }} + if: github.event.pull_request.draft == false + needs: + - build + strategy: + matrix: + platform: + - vsce_target: linux-x64 + rust_target: x86_64-unknown-linux-musl + archive: codebook-lsp-x86_64-unknown-linux-musl.tar.gz + bin: codebook-lsp + - vsce_target: linux-arm64 + rust_target: aarch64-unknown-linux-musl + archive: codebook-lsp-aarch64-unknown-linux-musl.tar.gz + bin: codebook-lsp + - vsce_target: darwin-x64 + rust_target: x86_64-apple-darwin + archive: codebook-lsp-x86_64-apple-darwin.tar.gz + bin: codebook-lsp + - vsce_target: darwin-arm64 + rust_target: aarch64-apple-darwin + archive: codebook-lsp-aarch64-apple-darwin.tar.gz + bin: codebook-lsp + - vsce_target: win32-x64 + rust_target: x86_64-pc-windows-msvc + archive: codebook-lsp-x86_64-pc-windows-msvc.zip + bin: codebook-lsp.exe + - vsce_target: win32-arm64 + rust_target: aarch64-pc-windows-msvc + archive: codebook-lsp-aarch64-pc-windows-msvc.zip + bin: codebook-lsp.exe + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: Install dependencies + run: bun install --frozen-lockfile + working-directory: editors/vscode + - name: Download LSP archive + uses: actions/download-artifact@v8 + with: + name: ${{ matrix.platform.bin }}-${{ matrix.platform.rust_target }} + path: lsp-archive + - name: Stage LSP binary + run: | + mkdir -p editors/vscode/bin + if [[ "${{ matrix.platform.archive }}" == *.zip ]]; then + unzip -o "lsp-archive/${{ matrix.platform.archive }}" -d editors/vscode/bin + else + tar xzf "lsp-archive/${{ matrix.platform.archive }}" -C editors/vscode/bin + fi + chmod +x "editors/vscode/bin/${{ matrix.platform.bin }}" || true + ls -la editors/vscode/bin + - name: Package extension + working-directory: editors/vscode + run: | + bun run build + bunx vsce package \ + --no-dependencies \ + --target ${{ matrix.platform.vsce_target }} \ + -o "codebook-vscode-${{ matrix.platform.vsce_target }}.vsix" + - name: Upload VSIX + uses: actions/upload-artifact@v7 + with: + name: codebook-vscode-${{ matrix.platform.vsce_target }} + path: editors/vscode/codebook-vscode-${{ matrix.platform.vsce_target }}.vsix + release: name: Release permissions: @@ -158,17 +214,20 @@ jobs: needs: - build - test + - vscode_package if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - name: Download artifacts uses: actions/download-artifact@v8 with: - pattern: codebook-lsp* + pattern: codebook-* - name: Make release uses: softprops/action-gh-release@v3 with: - files: codebook-lsp*/* + files: | + codebook-lsp*/* + codebook-vscode-*/* prerelease: true generate_release_notes: true draft: false diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 21a78c4..5259295 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -55,12 +55,7 @@ "codebook.binaryPath": { "type": "string", "default": "", - "description": "Absolute path to an existing codebook-lsp binary. Leave empty to allow the extension to manage the download." - }, - "codebook.enablePrerelease": { - "type": "boolean", - "default": false, - "description": "Allow downloading pre-release builds when managing the language server binary." + "description": "Absolute path to an existing codebook-lsp binary. Leave empty to use the binary bundled with the extension (or one found on PATH)." }, "codebook.logLevel": { "type": "string", diff --git a/editors/vscode/src/binary.ts b/editors/vscode/src/binary.ts index 731c872..4e35f86 100644 --- a/editors/vscode/src/binary.ts +++ b/editors/vscode/src/binary.ts @@ -2,47 +2,18 @@ import * as vscode from "vscode"; import * as fs from "node:fs"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; -import * as os from "node:os"; -import * as https from "node:https"; -import type { IncomingMessage } from "node:http"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { pipeline } from "node:stream/promises"; const BINARY_BASENAME = "codebook-lsp"; const BINARY_FILENAME = process.platform === "win32" ? `${BINARY_BASENAME}.exe` : BINARY_BASENAME; -const VERSION_FILENAME = "codebook.version"; -const USER_AGENT = "codebook-vscode-extension"; -const MAX_REDIRECTS = 5; -const execFileAsync = promisify(execFile); - -interface GithubAsset { - name: string; - browser_download_url: string; -} - -interface GithubRelease { - id: number; - tag_name?: string; - name?: string; - prerelease?: boolean; - assets: GithubAsset[]; -} export class CodebookBinaryManager { - private binaryPath?: string; - constructor( private readonly context: vscode.ExtensionContext, private readonly logger: vscode.OutputChannel, ) {} - async getBinaryPath(forceRedownload = false): Promise { - if (this.binaryPath && !forceRedownload) { - return this.binaryPath; - } - + async getBinaryPath(): Promise { const config = vscode.workspace.getConfiguration("codebook"); const explicitPath = config.get("binaryPath")?.trim(); if (explicitPath) { @@ -50,195 +21,37 @@ export class CodebookBinaryManager { this.logger.appendLine( `Using codebook binary from configuration: ${explicitPath}`, ); - this.binaryPath = explicitPath; return explicitPath; } - if (!forceRedownload) { - const systemBinary = await findOnPath(); - if (systemBinary) { - this.logger.appendLine( - `Using codebook binary from PATH: ${systemBinary}`, - ); - this.binaryPath = systemBinary; - return systemBinary; - } - } - - const storageDir = await this.ensureStorageDir(); - const cached = await this.getCachedBinary(storageDir); - const enablePrerelease = config.get("enablePrerelease", false); - - try { - const release = await fetchDesiredRelease(enablePrerelease); - const releaseVersion = extractReleaseVersion(release); - - if ( - !forceRedownload && - cached && - cached.version === releaseVersion && - (await fileExists(cached.path)) - ) { - this.logger.appendLine( - `Using cached codebook binary version ${releaseVersion}`, - ); - this.binaryPath = cached.path; - return cached.path; - } - - const binaryPath = await this.downloadBinary( - storageDir, - release, - releaseVersion, - ); - this.binaryPath = binaryPath; - return binaryPath; - } catch (error) { - if (cached && (await fileExists(cached.path))) { - this.logger.appendLine( - `Failed to update Codebook (${error}). Falling back to cached binary at ${cached.path}`, - ); - this.binaryPath = cached.path; - return cached.path; - } - throw error; - } - } - - async invalidateCache(): Promise { - this.binaryPath = undefined; - } - - private async ensureStorageDir(): Promise { - const storageDir = this.context.globalStorageUri.fsPath; - await fsp.mkdir(storageDir, { recursive: true }); - return storageDir; - } - - private async getCachedBinary( - storageDir: string, - ): Promise<{ path: string; version: string } | undefined> { - try { - const versionFile = path.join(storageDir, VERSION_FILENAME); - const version = (await fsp.readFile(versionFile, "utf8")).trim(); - if (!version) { - return undefined; - } - - const binaryPath = path.join( - storageDir, - versionDirectoryName(version), - BINARY_FILENAME, - ); - if (await fileExists(binaryPath)) { - return { path: binaryPath, version }; - } - } catch { - // ignore missing cache - } - - return undefined; - } - - private async downloadBinary( - storageDir: string, - release: GithubRelease, - version: string, - ): Promise { - const assetInfo = resolveAssetName(); - const asset = release.assets.find( - (item) => item.name === assetInfo.filename, - ); - if (!asset) { - throw new Error( - `No compatible Codebook build (${assetInfo.descriptor}) was found in the ${version} release.`, + const systemBinary = await findOnPath(); + if (systemBinary) { + this.logger.appendLine( + `Using codebook binary from PATH: ${systemBinary}`, ); + return systemBinary; } - const versionDir = path.join(storageDir, versionDirectoryName(version)); - await fsp.mkdir(versionDir, { recursive: true }); - - const tempFile = await fsp.mkdtemp( - path.join(os.tmpdir(), "codebook-download-"), + const bundled = path.join( + this.context.extensionPath, + "bin", + BINARY_FILENAME, ); - const archivePath = path.join(tempFile, assetInfo.filename); - - try { - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: "Codebook", - }, - async (progress) => { - progress.report({ message: `Downloading ${asset.name}` }); - await downloadFile( - asset.browser_download_url, - archivePath, - (percent) => { - if (percent > 0) { - progress.report({ - message: `Downloading ${asset.name} (${Math.floor(percent * 100)}%)`, - }); - } - }, - ); - - progress.report({ message: "Extracting archive" }); - await extractArchive(archivePath, versionDir); - }, - ); - } finally { - await cleanupTemp(tempFile); - } - - const binaryPath = path.join(versionDir, BINARY_FILENAME); - if (!(await fileExists(binaryPath))) { - throw new Error(`Downloaded archive did not contain ${BINARY_FILENAME}`); - } - - if (process.platform !== "win32") { - await fsp.chmod(binaryPath, 0o755); + if (await fileExists(bundled)) { + if (process.platform !== "win32") { + // The vsix archive may strip the executable bit on extraction. + await fsp.chmod(bundled, 0o755).catch(() => {}); + } + this.logger.appendLine(`Using bundled codebook binary: ${bundled}`); + return bundled; } - await fsp.writeFile( - path.join(storageDir, VERSION_FILENAME), - version, - "utf8", - ); - await this.cleanupOldVersions(storageDir, versionDirectoryName(version)); - - this.logger.appendLine( - `Installed codebook-lsp ${version} to ${binaryPath}`, - ); - return binaryPath; - } - - private async cleanupOldVersions( - storageDir: string, - keepDirName: string, - ): Promise { - const entries = await fsp.readdir(storageDir, { withFileTypes: true }); - await Promise.all( - entries.map(async (entry) => { - if ( - entry.isDirectory() && - entry.name.startsWith("codebook-lsp-") && - entry.name !== keepDirName - ) { - await fsp.rm(path.join(storageDir, entry.name), { - recursive: true, - force: true, - }); - } - }), + throw new Error( + "No codebook-lsp binary was found. Install codebook-lsp on your PATH or set codebook.binaryPath.", ); } } -function versionDirectoryName(version: string): string { - return `codebook-lsp-${version}`; -} - async function ensureExecutable(filePath: string): Promise { try { await fsp.access(filePath, fs.constants.X_OK); @@ -288,187 +101,3 @@ async function fileExists(filePath: string): Promise { return false; } } - -function resolveAssetName(): { - filename: string; - descriptor: string; -} { - let archPart: string; - if (process.arch === "x64") { - archPart = "x86_64"; - } else if (process.arch === "arm64") { - archPart = "aarch64"; - } else { - throw new Error(`Unsupported CPU architecture: ${process.arch}`); - } - - let osPart: string; - let extension: string; - - switch (process.platform) { - case "darwin": - osPart = "apple-darwin"; - extension = "tar.gz"; - break; - case "linux": - osPart = "unknown-linux-musl"; - extension = "tar.gz"; - break; - case "win32": - osPart = "pc-windows-msvc"; - extension = "zip"; - break; - default: - throw new Error(`Unsupported operating system: ${process.platform}`); - } - - return { - filename: `codebook-lsp-${archPart}-${osPart}.${extension}`, - descriptor: `${archPart}-${osPart}`, - }; -} - -async function extractArchive(archivePath: string, destination: string) { - try { - await execFileAsync("tar", ["-xf", archivePath, "-C", destination]); - } catch (error) { - if (process.platform !== "win32") { - throw error; - } - - await execFileAsync("powershell", [ - "-NoProfile", - "-Command", - "Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force", - archivePath, - destination, - ]); - } -} - -function extractReleaseVersion(release: GithubRelease): string { - return release.tag_name ?? release.name ?? String(release.id); -} - -async function fetchDesiredRelease( - includePrerelease: boolean, -): Promise { - if (includePrerelease) { - const releases = await requestJson( - "https://api.github.com/repos/blopker/codebook/releases", - ); - const release = releases.find((item) => item.assets?.length); - if (!release) { - throw new Error("No releases with downloadable assets were found."); - } - return release; - } - - return requestJson( - "https://api.github.com/repos/blopker/codebook/releases/latest", - ); -} - -async function requestJson(url: string): Promise { - const response = await httpRequest(url, { - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const chunks: Buffer[] = []; - for await (const chunk of response) { - chunks.push(Buffer.from(chunk)); - } - - return JSON.parse(Buffer.concat(chunks).toString("utf8")) as T; -} - -async function downloadFile( - url: string, - destination: string, - progress?: (percent: number) => void, -): Promise { - const response = await httpRequest(url, { - headers: { - Accept: "application/octet-stream", - }, - }); - - const totalSize = Number(response.headers["content-length"]); - let downloaded = 0; - - if (progress && Number.isFinite(totalSize)) { - response.on("data", (chunk: Buffer) => { - downloaded += chunk.length; - if (totalSize > 0) { - progress(downloaded / totalSize); - } - }); - } - - await pipeline(response, fs.createWriteStream(destination)); -} - -async function httpRequest( - url: string, - options: https.RequestOptions = {}, - redirectCount = 0, -): Promise { - if (redirectCount > MAX_REDIRECTS) { - throw new Error("Too many redirects while downloading Codebook"); - } - - return new Promise((resolve, reject) => { - const request = https.get( - url, - { - ...options, - headers: { - "User-Agent": USER_AGENT, - ...options.headers, - }, - }, - (response) => { - const location = response.headers.location; - if ( - response.statusCode && - response.statusCode >= 300 && - response.statusCode < 400 && - location - ) { - response.resume(); - const redirectUrl = new URL(location, url).toString(); - resolve(httpRequest(redirectUrl, options, redirectCount + 1)); - return; - } - - if ( - response.statusCode && - response.statusCode >= 200 && - response.statusCode < 300 - ) { - resolve(response); - return; - } - - response.resume(); - reject( - new Error( - `Request to ${url} failed with status ${response.statusCode}`, - ), - ); - }, - ); - - request.setTimeout(30_000, () => { - request.destroy(new Error("Request timed out while contacting GitHub")); - }); - - request.on("error", reject); - }); -} - -async function cleanupTemp(dir: string): Promise { - await fsp.rm(dir, { recursive: true, force: true }); -} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index bdcdee6..402f1d3 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -48,19 +48,11 @@ export async function activate( context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(async (event) => { - const invalidateCache = - event.affectsConfiguration("codebook.binaryPath") || - event.affectsConfiguration("codebook.enablePrerelease"); - const forceDownload = event.affectsConfiguration( - "codebook.enablePrerelease" - ); - if ( event.affectsConfiguration("codebook.binaryPath") || - event.affectsConfiguration("codebook.enablePrerelease") || event.affectsConfiguration("codebook.logLevel") ) { - await queueRefresh({ forceDownload, invalidateCache }); + await queueRefresh(); } }) ); @@ -68,25 +60,16 @@ export async function activate( await queueRefresh(); } -type RefreshOptions = { - forceDownload?: boolean; - invalidateCache?: boolean; -}; - -async function queueRefresh(options: RefreshOptions = {}): Promise { - refreshPromise = refreshPromise.then(() => refreshClients(options)); +async function queueRefresh(): Promise { + refreshPromise = refreshPromise.then(() => refreshClients()); await refreshPromise; } -async function refreshClients(options: RefreshOptions = {}): Promise { +async function refreshClients(): Promise { await stopClient(); - if (options.invalidateCache) { - await binaryManager.invalidateCache(); - } - try { - const binaryPath = await binaryManager.getBinaryPath(options.forceDownload); + const binaryPath = await binaryManager.getBinaryPath(); await startClient(binaryPath); } catch (error) { outputChannel.appendLine(String(error));