diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53957dc..55eca90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,5 +109,8 @@ jobs: - name: Install dependencies run: npm ci + - name: Run tests + run: npm test + - name: Package check (dry run) - run: npx @vscode/vsce package --out tmp.vsix && rm -f tmp.vsix + run: npm run package:check diff --git a/iii-lsp-vscode/AGENTS.md b/iii-lsp-vscode/AGENTS.md new file mode 100644 index 0000000..46ae252 --- /dev/null +++ b/iii-lsp-vscode/AGENTS.md @@ -0,0 +1,11 @@ +# iii-lsp-vscode Agent Notes + +Use containers for Node commands. Do not install host-global Node packages. + +```bash +cd iii-lsp-vscode +docker build -t iii-lsp-vscode-dev . +docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/workspace" -w /workspace iii-lsp-vscode-dev npm ci +docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/workspace" -w /workspace iii-lsp-vscode-dev npm test +docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/workspace" -w /workspace iii-lsp-vscode-dev npm run package:check +``` diff --git a/iii-lsp-vscode/Dockerfile b/iii-lsp-vscode/Dockerfile new file mode 100644 index 0000000..435bb34 --- /dev/null +++ b/iii-lsp-vscode/Dockerfile @@ -0,0 +1,3 @@ +FROM node:20-bookworm + +WORKDIR /workspace diff --git a/iii-lsp-vscode/Makefile b/iii-lsp-vscode/Makefile new file mode 100644 index 0000000..7c9aeb5 --- /dev/null +++ b/iii-lsp-vscode/Makefile @@ -0,0 +1,29 @@ +.PHONY: image install test package-check build install-cursor clean + +IMAGE ?= iii-lsp-vscode-dev +VSIX ?= iii-lsp.vsix +NPM_CACHE ?= /tmp/npm-cache + +DOCKER_RUN = docker run --rm -u "$$(id -u):$$(id -g)" -e npm_config_cache=$(NPM_CACHE) -v "$(CURDIR):/workspace" -w /workspace $(IMAGE) + +image: + docker build -t $(IMAGE) . + +install: image + $(DOCKER_RUN) npm ci + +test: image + $(DOCKER_RUN) npm test + +package-check: image + $(DOCKER_RUN) npm run package:check + +build: image + $(DOCKER_RUN) npm ci + $(DOCKER_RUN) npx vsce package --out $(VSIX) + +install-cursor: build + cursor --install-extension $(VSIX) --force + +clean: + rm -f $(VSIX) iii-lsp-cursor.vsix iii-lsp-smoke.vsix tmp.vsix diff --git a/iii-lsp-vscode/README.md b/iii-lsp-vscode/README.md index 094c448..57274ac 100644 --- a/iii-lsp-vscode/README.md +++ b/iii-lsp-vscode/README.md @@ -1,6 +1,8 @@ # III Language Server - VS Code Extension -Autocompletion, hover documentation, and diagnostics for [III engine](https://github.com/iii-org) functions and triggers. +Autocompletion, hover documentation, and diagnostics for [III engine](https://github.com/iii-hq/iii) functions and triggers. + +![III LSP demo](./lsp.gif) ## Supported Languages @@ -10,21 +12,20 @@ Autocompletion, hover documentation, and diagnostics for [III engine](https://gi ## Prerequisites -1. **iii-lsp binary** - Build it from the `iii-lsp/` crate: +1. **iii-lsp binary** - The extension downloads and installs the pinned `iii-lsp/v0.1.0` binary on first activation. - ```bash - cd iii-lsp - cargo build --release - ``` + The binary is stored under VS Code's extension global storage directory and the absolute path is saved to `iii-lsp.serverPath` in global settings. - Then either: - - Add `iii-lsp/target/release` to your `PATH`, or - - Set the binary path in the extension settings (see below) + To use a custom binary instead, set `iii-lsp.serverPath` to an existing executable path before activation. 2. **III engine** running locally (default: `ws://127.0.0.1:49134`) ## Installation +After the extension is installed, open a supported TypeScript, TSX, Python, or Rust file to activate it. On first activation, the extension downloads the matching `iii-lsp/v0.1.0` binary, verifies its SHA-256 checksum, installs it under extension global storage, and saves the installed path to `iii-lsp.serverPath`. + +If automatic install fails, the extension warns and falls back to the configured `iii-lsp.serverPath` or `iii-lsp` on `PATH`. + ### From source (development) 1. Install dependencies: @@ -62,11 +63,29 @@ Autocompletion, hover documentation, and diagnostics for [III engine](https://gi code --install-extension iii-lsp-*.vsix ``` +### Local smoke test with Cursor + +Build a local VSIX and install it in Cursor: + +```bash +cd iii-lsp-vscode +make build +cursor --install-extension iii-lsp.vsix --force +``` + +Open a supported file to activate the extension: + +```bash +cursor ../iii-lsp/src/main.rs +``` + +After activation, `iii-lsp.serverPath` should point to the downloaded binary in Cursor settings. To install the same VSIX in VS Code instead, replace `cursor` with `code`. + ## Settings | Setting | Default | Description | |---------|---------|-------------| -| `iii-lsp.serverPath` | `""` (uses `iii-lsp` from PATH) | Path to the `iii-lsp` binary | +| `iii-lsp.serverPath` | `""` (auto-filled after first activation) | Path to the installed or custom `iii-lsp` binary | | `iii-lsp.engineUrl` | `ws://127.0.0.1:49134` | WebSocket URL of the III engine | Configure via **Settings** > search "III LSP", or in `settings.json`: diff --git a/iii-lsp-vscode/extension.js b/iii-lsp-vscode/extension.js index 2600433..6a30d81 100644 --- a/iii-lsp-vscode/extension.js +++ b/iii-lsp-vscode/extension.js @@ -1,12 +1,24 @@ -const { workspace } = require("vscode"); +const vscode = require("vscode"); const { LanguageClient, TransportKind } = require("vscode-languageclient/node"); +const { ensureServerBinary } = require("./installer"); + let client; -function activate(context) { - const config = workspace.getConfiguration("iii-lsp"); - const serverPath = config.get("serverPath") || "iii-lsp"; +async function activate(context) { + const config = vscode.workspace.getConfiguration("iii-lsp"); const engineUrl = config.get("engineUrl") || "ws://127.0.0.1:49134"; + let serverPath; + + try { + serverPath = await ensureServerBinary(context, vscode); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + vscode.window.showWarningMessage( + `Failed to install iii-lsp binary. Falling back to configured path or PATH lookup. ${message}` + ); + serverPath = config.get("serverPath") || "iii-lsp"; + } const serverOptions = { run: { diff --git a/iii-lsp-vscode/installer.js b/iii-lsp-vscode/installer.js new file mode 100644 index 0000000..3d2c555 --- /dev/null +++ b/iii-lsp-vscode/installer.js @@ -0,0 +1,248 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const https = require('node:https'); +const os = require('node:os'); +const path = require('node:path'); +const { pipeline } = require('node:stream/promises'); + +const extractZip = require('extract-zip'); +const tar = require('tar'); + +const SERVER_VERSION = '0.1.0'; +const RELEASE_TAG = `iii-lsp/v${SERVER_VERSION}`; +const RELEASE_BASE_URL = `https://github.com/iii-hq/workers/releases/download/${RELEASE_TAG}`; + +const PLATFORM_TARGETS = { + 'darwin/arm64': 'aarch64-apple-darwin', + 'darwin/x64': 'x86_64-apple-darwin', + 'linux/arm64': 'aarch64-unknown-linux-gnu', + 'linux/arm': 'armv7-unknown-linux-gnueabihf', + 'linux/x64': 'x86_64-unknown-linux-gnu', + 'win32/arm64': 'aarch64-pc-windows-msvc', + 'win32/ia32': 'i686-pc-windows-msvc', + 'win32/x64': 'x86_64-pc-windows-msvc', +}; + +function getPlatformTarget(platform = process.platform, arch = process.arch) { + const key = `${platform}/${arch}`; + const target = PLATFORM_TARGETS[key]; + + if (!target) { + throw new Error(`Unsupported iii-lsp platform: ${key}`); + } + + return target; +} + +function getArchiveName(target, platform = process.platform) { + return `iii-lsp-${target}${platform === 'win32' ? '.zip' : '.tar.gz'}`; +} + +function getBinaryName(platform = process.platform) { + return platform === 'win32' ? 'iii-lsp.exe' : 'iii-lsp'; +} + +function getChecksumName(target) { + return `iii-lsp-${target}.sha256`; +} + +function getDownloadUrl(assetName) { + return `${RELEASE_BASE_URL}/${assetName}`; +} + +function normalizeChecksum(text) { + const checksum = String(text).trim().split(/\s+/, 1)[0].toLowerCase(); + + if (!/^[0-9a-f]{64}$/.test(checksum)) { + throw new Error('Invalid sha256 checksum file'); + } + + return checksum; +} + +async function fileExists(filePath) { + try { + const stat = await fsp.stat(filePath); + + if (!stat.isFile()) { + return false; + } + + if (process.platform !== 'win32') { + await fsp.access(filePath, fs.constants.X_OK); + } + + return true; + } catch { + return false; + } +} + +function fetchResponse(url, httpsModule, redirectsRemaining) { + return new Promise((resolve, reject) => { + const request = httpsModule.get(url, (response) => { + const { statusCode, headers } = response; + + if (statusCode >= 300 && statusCode < 400 && headers.location) { + response.resume(); + + if (redirectsRemaining <= 0) { + reject(new Error(`Too many redirects while fetching ${url}`)); + return; + } + + resolve(fetchResponse(new URL(headers.location, url).href, httpsModule, redirectsRemaining - 1)); + return; + } + + if (statusCode !== 200) { + response.resume(); + reject(new Error(`Unexpected status code ${statusCode} for ${url}`)); + return; + } + + resolve(response); + }); + + request.on('error', reject); + }); +} + +async function downloadText(url, httpsModule = https, redirectsRemaining = 5) { + const response = await fetchResponse(url, httpsModule, redirectsRemaining); + + return await new Promise((resolve, reject) => { + const chunks = []; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + chunks.push(chunk); + }); + response.on('end', () => { + resolve(chunks.join('')); + }); + response.on('error', reject); + }); +} + +async function downloadFile(url, destination, httpsModule = https, redirectsRemaining = 5) { + await fsp.mkdir(path.dirname(destination), { recursive: true }); + const response = await fetchResponse(url, httpsModule, redirectsRemaining); + const output = fs.createWriteStream(destination, { mode: 0o600 }); + + await pipeline(response, output); +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + const input = fs.createReadStream(filePath); + + for await (const chunk of input) { + hash.update(chunk); + } + + return hash.digest('hex'); +} + +async function extractArchive(archivePath, installDir, platform = process.platform) { + await fsp.rm(installDir, { recursive: true, force: true }); + await fsp.mkdir(installDir, { recursive: true }); + + if (platform === 'win32') { + await extractZip(archivePath, { dir: installDir }); + } else { + await tar.x({ file: archivePath, cwd: installDir }); + } + + const binaryPath = path.join(installDir, getBinaryName(platform)); + + if (platform !== 'win32') { + await fsp.chmod(binaryPath, 0o755); + } + + return binaryPath; +} + +function getInstallPaths(context, platform = process.platform, arch = process.arch) { + const target = getPlatformTarget(platform, arch); + const installDir = path.join(context.globalStorageUri.fsPath, 'server', SERVER_VERSION, target); + const binaryPath = path.join(installDir, getBinaryName(platform)); + const archivePath = path.join(os.tmpdir(), getArchiveName(target, platform)); + + return { + target, + installDir, + binaryPath, + archivePath, + }; +} + +async function ensureServerBinary(context, vscodeApi, options = {}) { + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + const fileExistsImpl = options.fileExists ?? fileExists; + const downloadTextImpl = options.downloadText ?? downloadText; + const downloadFileImpl = options.downloadFile ?? downloadFile; + const sha256FileImpl = options.sha256File ?? sha256File; + const extractArchiveImpl = options.extractArchive ?? extractArchive; + const configuration = vscodeApi.workspace.getConfiguration('iii-lsp'); + const configuredServerPath = configuration.get('serverPath') ?? ''; + const installPaths = getInstallPaths(context, platform, arch); + + if (configuredServerPath && (await fileExistsImpl(configuredServerPath))) { + return configuredServerPath; + } + + if (!(await fileExistsImpl(installPaths.binaryPath))) { + const checksumName = getChecksumName(installPaths.target); + const archiveName = getArchiveName(installPaths.target, platform); + const checksumUrl = getDownloadUrl(checksumName); + const archiveUrl = getDownloadUrl(archiveName); + const checksum = normalizeChecksum(await downloadTextImpl(checksumUrl)); + + await downloadFileImpl(archiveUrl, installPaths.archivePath); + + const actualChecksum = await sha256FileImpl(installPaths.archivePath); + + if (actualChecksum !== checksum) { + throw new Error(`Checksum mismatch for ${archiveName}: expected ${checksum}, got ${actualChecksum}`); + } + + const extractedPath = await extractArchiveImpl( + installPaths.archivePath, + installPaths.installDir, + platform + ); + + if (extractedPath !== installPaths.binaryPath) { + throw new Error(`Expected extracted binary at ${installPaths.binaryPath}, got ${extractedPath}`); + } + } + + if (configuredServerPath !== installPaths.binaryPath) { + await configuration.update('serverPath', installPaths.binaryPath, vscodeApi.ConfigurationTarget.Global); + } + + return installPaths.binaryPath; +} + +module.exports = { + SERVER_VERSION, + RELEASE_TAG, + RELEASE_BASE_URL, + downloadFile, + downloadText, + ensureServerBinary, + extractArchive, + fileExists, + getArchiveName, + getBinaryName, + getChecksumName, + getDownloadUrl, + getInstallPaths, + getPlatformTarget, + normalizeChecksum, + sha256File, +}; diff --git a/iii-lsp-vscode/lsp.gif b/iii-lsp-vscode/lsp.gif new file mode 100644 index 0000000..f91fd6d Binary files /dev/null and b/iii-lsp-vscode/lsp.gif differ diff --git a/iii-lsp-vscode/package-lock.json b/iii-lsp-vscode/package-lock.json index 660be94..c5b5c9f 100644 --- a/iii-lsp-vscode/package-lock.json +++ b/iii-lsp-vscode/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "license": "ISC", "dependencies": { + "extract-zip": "^2.0.1", + "tar": "^7.5.13", "vscode-languageclient": "^9.0.1" }, "devDependencies": { @@ -274,6 +276,18 @@ "node": ">=18" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -875,6 +889,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~7.19.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -889,6 +913,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.4.tgz", @@ -1350,7 +1384,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -1595,7 +1628,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1863,9 +1895,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -1956,6 +1986,26 @@ "node": ">=6" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2011,7 +2061,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -2157,6 +2206,21 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -3025,12 +3089,23 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -3043,7 +3118,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mute-stream": { @@ -3172,9 +3246,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", - "optional": true, "dependencies": { "wrappy": "1" } @@ -3396,7 +3468,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -3462,9 +3533,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -4133,6 +4202,22 @@ "node": ">=8" } }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -4165,6 +4250,24 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -4308,6 +4411,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT", + "optional": true + }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", @@ -4463,9 +4573,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/wsl-utils": { "version": "0.1.0", @@ -4518,7 +4626,6 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", diff --git a/iii-lsp-vscode/package.json b/iii-lsp-vscode/package.json index 65dfbbf..74da266 100644 --- a/iii-lsp-vscode/package.json +++ b/iii-lsp-vscode/package.json @@ -32,13 +32,21 @@ } }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test", + "package:check": "vsce package --out tmp.vsix && rm -f tmp.vsix" }, "keywords": [], "author": "", + "repository": { + "type": "git", + "url": "https://github.com/iii-hq/workers.git", + "directory": "iii-lsp-vscode" + }, "license": "ISC", "type": "commonjs", "dependencies": { + "extract-zip": "^2.0.1", + "tar": "^7.5.13", "vscode-languageclient": "^9.0.1" }, "devDependencies": { diff --git a/iii-lsp-vscode/test/installer.test.js b/iii-lsp-vscode/test/installer.test.js new file mode 100644 index 0000000..82d0df8 --- /dev/null +++ b/iii-lsp-vscode/test/installer.test.js @@ -0,0 +1,277 @@ +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const tar = require('tar'); + +const installer = require('../installer'); +const { + SERVER_VERSION, + ensureServerBinary, + extractArchive, + fileExists, + getDownloadUrl, + normalizeChecksum, +} = installer; + +function createFakeVscode(initialServerPath = '') { + const updates = []; + const configuration = { + get(key) { + if (key === 'serverPath') { + return initialServerPath; + } + + return undefined; + }, + update(key, value, target) { + updates.push([key, value, target]); + return Promise.resolve(); + }, + }; + + return { + api: { + ConfigurationTarget: { + Global: 'global', + }, + workspace: { + getConfiguration(section) { + assert.equal(section, 'iii-lsp'); + return configuration; + }, + }, + }, + updates, + }; +} + +test('supported platform mapping', () => { + const cases = [ + ['darwin', 'arm64', 'aarch64-apple-darwin'], + ['darwin', 'x64', 'x86_64-apple-darwin'], + ['linux', 'arm64', 'aarch64-unknown-linux-gnu'], + ['linux', 'arm', 'armv7-unknown-linux-gnueabihf'], + ['linux', 'x64', 'x86_64-unknown-linux-gnu'], + ['win32', 'arm64', 'aarch64-pc-windows-msvc'], + ['win32', 'ia32', 'i686-pc-windows-msvc'], + ['win32', 'x64', 'x86_64-pc-windows-msvc'], + ]; + + for (const [platform, arch, expected] of cases) { + assert.equal(installer.getPlatformTarget(platform, arch), expected); + } +}); + +test('unsupported platform error', () => { + assert.throws( + () => installer.getPlatformTarget('freebsd', 'x64'), + /Unsupported iii-lsp platform: freebsd\/x64/ + ); +}); + +test('archive extension by platform', () => { + assert.equal( + installer.getArchiveName('aarch64-apple-darwin', 'darwin'), + 'iii-lsp-aarch64-apple-darwin.tar.gz' + ); + assert.equal( + installer.getArchiveName('x86_64-unknown-linux-gnu', 'linux'), + 'iii-lsp-x86_64-unknown-linux-gnu.tar.gz' + ); + assert.equal( + installer.getArchiveName('x86_64-pc-windows-msvc', 'win32'), + 'iii-lsp-x86_64-pc-windows-msvc.zip' + ); +}); + +test('binary filename by platform', () => { + assert.equal(installer.getBinaryName('win32'), 'iii-lsp.exe'); + assert.equal(installer.getBinaryName('linux'), 'iii-lsp'); +}); + +test('pinned release url', () => { + assert.equal(installer.SERVER_VERSION, '0.1.0'); + assert.equal(installer.RELEASE_TAG, 'iii-lsp/v0.1.0'); + assert.equal( + installer.RELEASE_BASE_URL, + 'https://github.com/iii-hq/workers/releases/download/iii-lsp/v0.1.0' + ); +}); + +test('checksum and download url', () => { + assert.equal( + installer.getChecksumName('aarch64-apple-darwin'), + 'iii-lsp-aarch64-apple-darwin.sha256' + ); + assert.equal( + installer.getDownloadUrl('iii-lsp-aarch64-apple-darwin.tar.gz'), + 'https://github.com/iii-hq/workers/releases/download/iii-lsp/v0.1.0/iii-lsp-aarch64-apple-darwin.tar.gz' + ); +}); + +test('parses sha256 files from GitHub release assets', () => { + assert.equal( + normalizeChecksum( + '04dc683db6f30a983017e71ed7f4aa3ccb7fc5124261274d3a733a8e77c66da4 iii-lsp-aarch64-apple-darwin.tar.gz\n' + ), + '04dc683db6f30a983017e71ed7f4aa3ccb7fc5124261274d3a733a8e77c66da4' + ); +}); + +test('rejects malformed sha256 files', () => { + assert.throws(() => normalizeChecksum('not-a-checksum'), /Invalid sha256/); +}); + +test('fileExists requires a regular executable binary on non-win32', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'iii-lsp-vscode-test-')); + const tempFile = path.join(tempDir, 'iii-lsp'); + + await fs.mkdir(path.join(tempDir, 'subdir'), { recursive: true }); + await fs.writeFile(tempFile, 'fake binary'); + + assert.equal(await fileExists(path.join(tempDir, 'subdir')), false); + assert.equal(await fileExists(tempFile), false); + + if (process.platform !== 'win32') { + await fs.chmod(tempFile, 0o755); + } + + assert.equal(await fileExists(tempFile), true); +}); + +test('extractArchive unpacks a tar.gz binary and marks it executable', async () => { + if (process.platform === 'win32') { + return; + } + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'iii-lsp-vscode-test-')); + const archiveDir = path.join(tempDir, 'archive'); + const installDir = path.join(tempDir, 'install'); + const archivePath = path.join(tempDir, 'iii-lsp-linux.tar.gz'); + const binaryPath = path.join(archiveDir, 'iii-lsp'); + + await fs.mkdir(archiveDir, { recursive: true }); + await fs.writeFile(binaryPath, '#!/bin/sh\necho iii-lsp\n'); + await fs.chmod(binaryPath, 0o644); + await tar.c({ + gzip: true, + cwd: archiveDir, + file: archivePath, + }, ['iii-lsp']); + + const extractedPath = await extractArchive(archivePath, installDir, 'linux'); + const extractedStat = await fs.stat(extractedPath); + + assert.equal(extractedPath, path.join(installDir, 'iii-lsp')); + assert.equal(await fileExists(extractedPath), true); + assert.ok((extractedStat.mode & 0o111) !== 0); +}); + +test('installs missing binary and saves global serverPath', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'iii-lsp-vscode-test-')); + const fakeVscode = createFakeVscode(); + const downloaded = []; + const checksum = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + + const binaryPath = await ensureServerBinary( + { globalStorageUri: { fsPath: tempDir } }, + fakeVscode.api, + { + platform: 'darwin', + arch: 'arm64', + fileExists: async () => false, + downloadText: async (url) => { + assert.equal( + url, + 'https://github.com/iii-hq/workers/releases/download/iii-lsp/v0.1.0/iii-lsp-aarch64-apple-darwin.sha256' + ); + return `${checksum} iii-lsp-aarch64-apple-darwin.tar.gz\n`; + }, + downloadFile: async (url, destination) => { + downloaded.push([url, destination]); + await fs.writeFile(destination, ''); + }, + sha256File: async () => checksum, + extractArchive: async (_archivePath, installDir) => { + const installedPath = path.join(installDir, 'iii-lsp'); + await fs.mkdir(installDir, { recursive: true }); + await fs.writeFile(installedPath, 'fake binary'); + return installedPath; + }, + } + ); + + assert.equal(binaryPath, path.join(tempDir, 'server', SERVER_VERSION, 'aarch64-apple-darwin', 'iii-lsp')); + assert.equal( + downloaded[0][0], + getDownloadUrl('iii-lsp-aarch64-apple-darwin.tar.gz') + ); + assert.deepEqual(fakeVscode.updates, [['serverPath', binaryPath, 'global']]); +}); + +test('uses valid configured serverPath without downloading', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'iii-lsp-vscode-test-')); + const configuredPath = path.join(tempDir, 'configured-server'); + await fs.writeFile(configuredPath, 'fake binary'); + const fakeVscode = createFakeVscode(configuredPath); + const calls = []; + + const binaryPath = await ensureServerBinary( + { globalStorageUri: { fsPath: tempDir } }, + fakeVscode.api, + { + platform: 'darwin', + arch: 'arm64', + fileExists: async (filePath) => { + calls.push(filePath); + return filePath === configuredPath; + }, + downloadText: async () => { + throw new Error('downloadText should not be called'); + }, + downloadFile: async () => { + throw new Error('downloadFile should not be called'); + }, + sha256File: async () => { + throw new Error('sha256File should not be called'); + }, + extractArchive: async () => { + throw new Error('extractArchive should not be called'); + }, + } + ); + + assert.equal(binaryPath, configuredPath); + assert.deepEqual(calls, [configuredPath]); + assert.deepEqual(fakeVscode.updates, []); +}); + +test('rejects archives whose checksum does not match', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'iii-lsp-vscode-test-')); + const fakeVscode = createFakeVscode(); + const expectedChecksum = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const actualChecksum = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + await assert.rejects( + ensureServerBinary( + { globalStorageUri: { fsPath: tempDir } }, + fakeVscode.api, + { + platform: 'linux', + arch: 'x64', + fileExists: async () => false, + downloadText: async () => `${expectedChecksum} iii-lsp-x86_64-unknown-linux-gnu.tar.gz\n`, + downloadFile: async (_url, destination) => { + await fs.writeFile(destination, ''); + }, + sha256File: async () => actualChecksum, + extractArchive: async () => { + throw new Error('extractArchive should not be called'); + }, + } + ), + /Checksum mismatch/ + ); +});