diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e57a6e..b6b506a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,8 +32,14 @@ jobs: 17 - name: Check script process deadlines and cleanup run: node scripts/test-process.mjs + - name: Test the SDK lock verification + run: node scripts/sdk/test-verify-sdk.mjs - name: Verify vendored Java SDK sources run: node scripts/sdk/verify-sdk.mjs + # Network check: the tag still points at the locked commit, the release + # asset has the locked SHA-256, and vendor/ is the tag's unmodified source. + - name: Verify the SDK lock against the published release + run: node scripts/sdk/verify-sdk.mjs --download - name: Check manifest and build the archive run: node scripts/package.mjs - name: Test the build provider's snapshot with a fake Maven (Java 17) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d6fa4d..9f74fab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,9 +41,9 @@ jobs: - name: Publish release env: GH_TOKEN: ${{ github.token }} - # v0.1.0 was a trial release. v0.1.2 needs a host offering + # v0.1.0 was a trial release. v0.1.2 and v0.1.3 need a host offering # litheLogging 1, which stable Lithe 0.6.7 lacks. - PRERELEASE_TAGS: v0.1.0 v0.1.2 + PRERELEASE_TAGS: v0.1.0 v0.1.2 v0.1.3 run: | prerelease=false for tag in $PRERELEASE_TAGS; do @@ -72,3 +72,17 @@ jobs: release_flags+=(--prerelease) fi gh release create "$GITHUB_REF_NAME" dist/*.zip --title "Maven $GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md" "${release_flags[@]}" + # Read the flag back: a prerelease published as a normal release would + # become Latest and reach stable users. Correct it, or fail the job. + actual="$(gh release view "$GITHUB_REF_NAME" --json isPrerelease --jq .isPrerelease)" + if [[ "$actual" != "$prerelease" ]]; then + echo "Release $GITHUB_REF_NAME has isPrerelease=$actual, expected $prerelease." >&2 + if [[ "$prerelease" == true ]]; then + gh release edit "$GITHUB_REF_NAME" --prerelease + actual="$(gh release view "$GITHUB_REF_NAME" --json isPrerelease --jq .isPrerelease)" + fi + fi + if [[ "$actual" != "$prerelease" ]]; then + echo "Release $GITHUB_REF_NAME still has isPrerelease=$actual, expected $prerelease." >&2 + exit 1 + fi diff --git a/NOTICE b/NOTICE index a81ec60..4cd1d3f 100644 --- a/NOTICE +++ b/NOTICE @@ -14,6 +14,7 @@ Version 2.0, at revision 158b854d47ac9af2bbd99c0d7c354a5875cb6f81: Copyright 2000-2024 JetBrains s.r.o. and contributors. The backend includes the Apache-2.0 licensed official Lithe Java plugin SDK -from Lithe-IDEA/lithe-plugin-docs. Its production sources and LICENSE are -vendored in vendor/, with upstream base revision, local structured logging -changes and exact SHA-256 per input recorded in sdk.lock.json. +0.3.0 from Lithe-IDEA/lithe-plugin-docs (tag sdk-java-v0.3.0). Its unmodified +production sources and LICENSE are vendored in vendor/, with the release tag, +commit, release asset SHA-256 and exact SHA-256 per input recorded in +sdk.lock.json. diff --git a/README.md b/README.md index 74474c4..29ef033 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ > Maven with a small bundled core extension that only reads the reactor, from a Java build provider that keeps the > result in a snapshot, so reopening a project does not run Maven again until a POM or setting changes. -> **预览版**:0.1.2 需要提供结构化插件诊断(`litheLogging: 1`)的 Lithe `preview` 开发版。稳定版 Lithe 0.6.7 没有 `litheLogging: 1`,能安装但无法启动构建后端,请继续使用 Lithe Store 中的 0.1.1。0.1.2 以预发布版本提供,暂不收录 Lithe Store。 +> **预览版**:0.1.3 使用官方发布的 Java SDK 0.3.0,需要提供结构化插件诊断(`litheLogging: 1`)的 Lithe `preview` 开发版。稳定版 Lithe 0.6.7 没有 `litheLogging: 1`,能安装但无法启动构建后端,请继续使用 Lithe Store 中的 0.1.1。0.1.3 以预发布版本提供,暂不收录 Lithe Store。 ## 功能 @@ -64,7 +64,7 @@ brew install maven ## 安装 稳定版 Lithe 在 **设置 → 插件 → 插件市场** 中搜索 “Maven”,安装 0.1.1 并按提示重启。 -0.1.2 预览版请从 [Releases](https://github.com/Lithe-IDEA/lithe-plugin-maven/releases) 下载 `maven-build-0.1.2-darwin.zip`, +0.1.3 预览版请从 [Releases](https://github.com/Lithe-IDEA/lithe-plugin-maven/releases) 下载 `maven-build-0.1.3-darwin.zip`, 在 **设置 → 插件** 的 ⚙ 菜单中选择 **从磁盘安装插件…**;Store 无法区分不提供 `litheLogging: 1` 的稳定版宿主,所以暂不收录。 Lithe 已预置 Lithe Store 社区插件源;如果你之前移除了它,先在 **⚙ → 管理插件仓库…** 中重新添加 `https://github.com/Lithe-IDEA/Lithe-store`。 @@ -73,7 +73,7 @@ Lithe 已预置 Lithe Store 社区插件源;如果你之前移除了它,先 ## 诊断日志 -0.1.2 起要求宿主提供 `litheLogging: 1`。SDK 通过类型化的 `lithe/log` 通知,把 Maven provider +0.1.2 起要求宿主提供 `litheLogging: 1`;0.1.3 起使用官方发布的 Java SDK 0.3.0,需要提供 `litheLogging: 1` 的 Lithe 构建。SDK 通过类型化的 `lithe/log` 通知,把 Maven provider 操作交给宿主 v2 目录和统一 writer,写入当前会话的 `plugins/io.github.lithe-idea.maven-build/host` 通道。 关键成功终态为 INFO、失败终态为 ERROR,包含单调耗时和稳定分类;取消不记失败。 缓存保存失败或读取中项目变化属于可恢复降级,使用 WARN `degraded`,不记录异常正文、路径或环境值。 @@ -81,9 +81,9 @@ Lithe 已预置 Lithe Store 社区插件源;如果你之前移除了它,先 Maven 失败由 SDK 的请求边界记录一次,provider 不再重复打印构建输出。 完整构建输出仍属于 Build Output,模型/依赖树 JSON 属于工作流数据。 -后端从 `vendor/` 直接编译官方 Java SDK 的结构化日志实现,使用 `lithe/log` 发送稳定分类、 -单调耗时和操作身份;原始异常与构建 tail 不进入日志。`sdk.lock.json` 记录真实上游基线、 -本轮尚未发布的源代码改动及每个文件的 SHA-256,没有沿用或伪造旧发版 JAR 的校验值。 +后端从 `vendor/` 直接编译官方发布的 Java SDK 0.3.0,使用 `lithe/log` 发送稳定分类、 +单调耗时和操作身份;原始异常与构建 tail 不进入日志。`sdk.lock.json` 固定该发布版的 tag、 +tag 指向的提交、发布 JAR 的 SHA-256,以及每个源文件的上游路径和 SHA-256;`vendor/` 里没有对 SDK 的本地改动。 排障时使用 Lithe 的独立日志窗口和诊断导出;不要开启输出源码、凭据、环境变量或协议正文的日志。 Windows 的日志实现仍为 v1,未声明 Windows v2 已完成验收。 @@ -96,8 +96,9 @@ io.github.lithe-idea.maven-build/icons/ # 工具窗口和工具栏图标 extension/ # Maven 核心扩展,导出模型和依赖树 backend/ # 构建后端(Java 8,Lithe 插件 SDK),快照和 Maven 运行 vendor/ # 官方 Java SDK 生产源码和许可证(不带测试、JAR、target) -sdk.lock.json # SDK 上游基线、本轮改动身份、逐文件 SHA-256 -scripts/sdk/verify-sdk.mjs # 编译前拒绝漂移、遗漏或额外 SDK 文件 +sdk.lock.json # 官方 Java SDK 发布版的 tag、提交、JAR SHA-256 和逐文件 SHA-256 +scripts/sdk/verify-sdk.mjs # 编译前拒绝漂移、遗漏或额外 SDK 文件;--download 再核对发布版 +scripts/sdk/test-verify-sdk.mjs # SDK 锁校验的回归测试(不联网) test/fixtures/reactor/ # 三个模块的示例反应堆,供集成测试使用 scripts/package.mjs # 构建扩展并打包成 dist/maven-build-<版本>-darwin.zip scripts/test.mjs # 用真实 Maven 经后端读取项目、运行清单里的命令并检查输出 @@ -113,7 +114,15 @@ docs/releases/v<版本>.md # 该版本的变化,发布 打包前先校验 `sdk.lock.json`,缺失、改动或额外未登记文件都会拒绝;只读源码不共享可变构建状态。 不再从网络下载 SDK JAR,也不接受 `LITHE_SDK_JAR` 替换构建输入。 SDK 以 Apache-2.0 分发,源码许可证位于 `vendor/LICENSE`,安装包附带 `SDK-LICENSE`。 -如需更新,先从官方 SDK 仓库取真实生产源码,审查变更并更新基线和逐文件摘要,再执行验证。 +如需升级 SDK,改用新的官方发布版:从该发布 tag 取未经修改的生产源码替换 `vendor/`,在 `sdk.lock.json` 中更新 +版本、tag、提交、发布 JAR 的 URL 和 SHA-256 以及逐文件摘要,再执行验证。不要在 `vendor/` 里改 SDK 源码; +需要的改动先进入官方 SDK 并发布。 + +```bash +node scripts/sdk/verify-sdk.mjs # 离线:vendor/ 与锁文件逐文件一致 +node scripts/sdk/verify-sdk.mjs --download # 联网:tag 仍指向锁定提交、发布 JAR 的 SHA-256、各文件与 tag 上的原文件一致 +node scripts/sdk/test-verify-sdk.mjs # 锁校验自身的回归测试 +``` 开发或发布时直接运行: @@ -164,14 +173,16 @@ node scripts/verify-scripts.mjs --host-bundle /Applications/Lithe.app ## 发布新版本 1. 同时修改 `plugin.json`、`extension/pom.xml` 和 `backend/pom.xml` 的版本(例如 `0.2.0`;打包脚本会检查三者一致), - 确认 `sdk.lock.json` 已登记所用 SDK 源码、上游基线与全部 SHA-256,合入 `main`。 + 确认 `sdk.lock.json` 锁定的是官方发布的 SDK(tag、提交、发布 JAR 的 SHA-256 与全部源文件 SHA-256, + `node scripts/sdk/verify-sdk.mjs --download` 通过),合入 `main`。 2. 推送同名标签: ```bash git tag v0.2.0 && git push origin v0.2.0 ``` -3. `Release` 工作流会核对标签与版本、打包、测试并发布 Release(说明里附 SHA-256)。`release.yml` 里 `PRERELEASE_TAGS` 列出的版本(`v0.1.0` 试用版、需要 `litheLogging: 1` 的 `v0.1.2`)会标为预发布版本。 +3. `Release` 工作流会核对标签与版本、打包、测试并发布 Release(说明里附 SHA-256)。`release.yml` 里 `PRERELEASE_TAGS` 列出的版本(`v0.1.0` 试用版、需要 `litheLogging: 1` 的 `v0.1.2` 和 `v0.1.3`)会标为预发布版本; + 创建 Release 后工作流会回读 `isPrerelease`,与预期不符时先执行 `gh release edit --prerelease`,仍不符则任务失败。 4. Lithe Store 的自动追踪会发现稳定版本并开合并请求,合并后用户即可在 Lithe 中更新。自动追踪会跳过预发布版本;需要收录时在 Store 中手工提交条目,并使用已发布 ZIP 的实际 SHA-256。 ## 许可 diff --git a/backend/pom.xml b/backend/pom.xml index e8f76ac..f76fa14 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -5,8 +5,9 @@ persistent snapshot, and runs Maven (with the extension in ../extension) only when the snapshot is missing, stale or a refresh was asked for. - The official SDK production sources are vendored with per-file hashes in - sdk.lock.json, verified by scripts/package.mjs, and compiled into this JAR. + The production sources of the released official SDK are vendored + unmodified, pinned in sdk.lock.json, verified by scripts/package.mjs, and + compiled into this JAR. --> io.github.lithe-idea lithe-maven-backend - 0.1.2 + 0.1.3 jar Lithe Maven build provider diff --git a/docs/releases/v0.1.3.md b/docs/releases/v0.1.3.md new file mode 100644 index 0000000..e8dd27c --- /dev/null +++ b/docs/releases/v0.1.3.md @@ -0,0 +1,4 @@ +0.1.3 的变化: + +- SDK 改为锁定官方发布的 Java SDK 0.3.0(tag `sdk-java-v0.3.0` + 发布 JAR 的 SHA-256),`vendor/` 下的源码就是该发布版的原文件;行为与 0.1.2 相同。 +- 仍然需要提供结构化插件诊断(`litheLogging: 1`)的 Lithe `preview` 开发版,稳定版 Lithe 0.6.7 没有 `litheLogging: 1`,请在稳定版上继续使用 0.1.1。 diff --git a/extension/pom.xml b/extension/pom.xml index 9496aa3..99f14ae 100644 --- a/extension/pom.xml +++ b/extension/pom.xml @@ -15,7 +15,7 @@ io.github.lithe-idea lithe-maven-extension - 0.1.2 + 0.1.3 jar Lithe Maven extension diff --git a/io.github.lithe-idea.maven-build/plugin.json b/io.github.lithe-idea.maven-build/plugin.json index ecafdac..2d7720e 100644 --- a/io.github.lithe-idea.maven-build/plugin.json +++ b/io.github.lithe-idea.maven-build/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 2, "id": "io.github.lithe-idea.maven-build", "displayName": "Maven", - "version": "0.1.2", + "version": "0.1.3", "pluginAPIVersion": 3, "requires": { "buildProviders": 1, "buildSystems": 1, "serverProcess": 1 }, "vendor": { diff --git a/scripts/package.mjs b/scripts/package.mjs index 18e958b..30694c1 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -9,9 +9,9 @@ // every Mac. The unpacked package stays in dist/package/ for installing a // local development copy into Lithe. // -// The backend compiles the official SDK sources pinned by sdk.lock.json into -// the same JAR. Every input is verified before Maven runs; build outputs and -// plugin runtime state never become part of the source lock. +// The backend compiles the released official SDK sources pinned by +// sdk.lock.json into the same JAR. Every input is verified before Maven runs; +// build outputs and plugin runtime state never become part of the source lock. // // Usage: node scripts/package.mjs [--expect-tag ] diff --git a/scripts/sdk/test-verify-sdk.mjs b/scripts/sdk/test-verify-sdk.mjs new file mode 100644 index 0000000..ecfe0e4 --- /dev/null +++ b/scripts/sdk/test-verify-sdk.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +// Regressions for scripts/sdk/verify-sdk.mjs. No network and no Maven: the +// release check runs against injected fetchers and a local git repository. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { appendFileSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { runCommand } from "../process.mjs"; +import { createReport } from "../report.mjs"; +import { readLock, resolveTagCommit, verifySdk, verifySdkRelease } from "./verify-sdk.mjs"; + +const report = createReport("sdk-lock"); +const scratch = mkdtempSync(join(tmpdir(), "lithe-sdk-lock-test-")); +let failed = false; +let sequence = 0; +const check = async (name, body) => { + try { await report.step(name, body); } + catch (error) { failed = true; console.error(error.stack); } +}; +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); +const lockPath = (root) => join(root, "sdk.lock.json"); + +/** A disposable copy of the files verifySdk reads. */ +function copyRepository() { + const root = join(scratch, `repository-${++sequence}`); + mkdirSync(root); + cpSync(resolve("vendor"), join(root, "vendor"), { recursive: true }); + cpSync(resolve("sdk.lock.json"), lockPath(root)); + return root; +} +const editLock = (root, change) => { + const lock = JSON.parse(readFileSync(lockPath(root), "utf8")); + change(lock); + writeFileSync(lockPath(root), `${JSON.stringify(lock, null, 2)}\n`); +}; +const backend = (lock) => lock.files.find((entry) => entry.path.endsWith("/Backend.java")); +const git = (cwd, ...args) => runCommand("git", args, { + cwd, + stdio: "ignore", + timeoutMs: 20_000, + env: { + ...process.env, + GIT_AUTHOR_NAME: "test", GIT_AUTHOR_EMAIL: "test@example.invalid", + GIT_COMMITTER_NAME: "test", GIT_COMMITTER_EMAIL: "test@example.invalid", + GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", + }, +}); + +try { + await check("the repository's vendored SDK matches its lock", async () => { + const lock = await verifySdk(); + assert.equal(lock.tag, `sdk-java-v${lock.version}`); + assert.match(lock.commit, /^[0-9a-f]{40}$/); + assert.match(lock.sha256, /^[0-9a-f]{64}$/); + assert.ok(lock.files.every((entry) => Object.keys(entry).length === 3), "files carry no local-change markers"); + }); + + await check("a modified, extra or missing vendored file is rejected", async () => { + const modified = copyRepository(); + appendFileSync(join(modified, backend(await readLock(modified)).path), "// local change\n"); + await assert.rejects(verifySdk(modified), /SDK SHA-256 mismatch: .*Backend\.java/); + + const extra = copyRepository(); + writeFileSync(join(extra, "vendor/lithe-plugin-sdk-java/Extra.java"), "class Extra {}\n"); + await assert.rejects(verifySdk(extra), /Unpinned SDK input: vendor\/lithe-plugin-sdk-java\/Extra\.java/); + + const missing = copyRepository(); + rmSync(join(missing, backend(await readLock(missing)).path)); + await assert.rejects(verifySdk(missing), /ENOENT/); + }); + + await check("a lock that is not a fully pinned release is rejected", async () => { + const cases = { + "unsupported schema": (lock) => { lock.schemaVersion = 1; }, + "another repository": (lock) => { lock.repository = "https://github.com/example/lithe-plugin-docs"; }, + "tag not derived from the version": (lock) => { lock.tag = "sdk-java-v9.9.9"; }, + "abbreviated commit": (lock) => { lock.commit = lock.commit.slice(0, 7); }, + "asset url not derived from the tag": (lock) => { lock.url = "https://example.invalid/lithe-plugin-sdk-java.jar"; }, + "missing asset checksum": (lock) => { delete lock.sha256; }, + "local-change marker": (lock) => { backend(lock).localChanges = true; }, + "path outside vendor": (lock) => { backend(lock).path = "elsewhere/Backend.java"; }, + "upstream not the vendored file's origin": (lock) => { backend(lock).upstream = "sdk/node/Backend.java"; }, + "duplicate entry": (lock) => { lock.files.push({ ...lock.files[0] }); }, + "path traversal": (lock) => { backend(lock).path = "vendor/../sdk.lock.json"; }, + }; + for (const [name, change] of Object.entries(cases)) { + const root = copyRepository(); + editLock(root, change); + await assert.rejects(verifySdk(root), /SDK lock|lock entry/, name); + } + }); + + await check("the release check passes only when tag, asset and upstream files all match", async () => { + const root = copyRepository(); + const lock = await readLock(root); + const sources = new Map(lock.files.map((entry) => [entry.upstream, readFileSync(join(root, entry.path))])); + const requested = []; + const fetchSource = async (url) => { + requested.push(url); + const upstream = url.slice(`https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/${lock.commit}/`.length); + if (!sources.has(upstream)) throw new Error(`unexpected ${url}`); + return sources.get(upstream); + }; + const asset = Buffer.from("released jar bytes"); + editLock(root, (value) => { value.sha256 = sha256(asset); }); + const resolveTag = async (remote, tag) => { + assert.equal(remote, "https://github.com/Lithe-IDEA/lithe-plugin-docs.git"); + assert.equal(tag, lock.tag); + return lock.commit; + }; + const fetchAsset = async (url) => { + assert.equal(url, lock.url); + return asset; + }; + await verifySdkRelease(root, { fetchAsset, fetchSource, resolveTag }); + assert.equal(requested.length, lock.files.length, "every locked file is compared with the tag's copy"); + + await assert.rejects( + verifySdkRelease(root, { fetchAsset, fetchSource, resolveTag: async () => "1".repeat(40) }), + /points at 1{40}, but the lock pins/, + ); + await assert.rejects( + verifySdkRelease(root, { fetchAsset: async () => Buffer.from("replaced"), fetchSource, resolveTag }), + /release asset checksum mismatch/, + ); + await assert.rejects( + verifySdkRelease(root, { + fetchAsset, resolveTag, + fetchSource: async (url) => (url.endsWith("/Backend.java") ? Buffer.from("tampered") : fetchSource(url)), + }), + /Backend\.java differs from .* at sdk-java-v/, + ); + await assert.rejects( + verifySdkRelease(root, { fetchAsset, resolveTag, fetchSource: async () => { throw new Error("HTTP 404"); } }), + /HTTP 404/, + ); + }); + + await check("the tag resolver follows annotated and lightweight tags in a real git repository", async () => { + const remote = join(scratch, "remote"); + mkdirSync(remote); + await git(remote, "init", "-q"); + writeFileSync(join(remote, "file.txt"), "one\n"); + await git(remote, "add", "file.txt"); + await git(remote, "commit", "-q", "-m", "one"); + await git(remote, "tag", "-a", "-m", "annotated release", "annotated-v1"); + await git(remote, "tag", "lightweight-v1"); + const head = readFileSync(join(remote, ".git/refs/heads", readFileSync(join(remote, ".git/HEAD"), "utf8").trim().split("/").pop()), "utf8").trim(); + assert.equal(await resolveTagCommit(remote, "annotated-v1"), head, "an annotated tag resolves to its commit, not the tag object"); + assert.equal(await resolveTagCommit(remote, "lightweight-v1"), head); + await assert.rejects(resolveTagCommit(remote, "missing-v1"), /Tag missing-v1 was not found/); + }); +} finally { + rmSync(scratch, { recursive: true, force: true }); + report.write(); +} +process.exitCode = failed ? 1 : 0; diff --git a/scripts/sdk/verify-sdk.mjs b/scripts/sdk/verify-sdk.mjs index b352bb4..8fea98f 100644 --- a/scripts/sdk/verify-sdk.mjs +++ b/scripts/sdk/verify-sdk.mjs @@ -1,28 +1,91 @@ +// Verifies the vendored Java SDK against sdk.lock.json. +// +// The lock pins the officially released SDK: repository, version, release tag, +// the commit that tag points to, the release asset URL with its SHA-256, and +// every vendored file with its upstream path and SHA-256. +// +// Usage: node scripts/sdk/verify-sdk.mjs [--download] +// +// Without --download the check is offline: it validates the lock and requires +// vendor/ to contain exactly the locked files with the locked hashes. It runs +// before every package build. With --download it also proves, over the network, +// that the lock still describes the release: the tag resolves to the locked +// commit, the release asset has the locked SHA-256, and every locked file at +// that commit has the locked SHA-256 (so the vendored sources are the released +// sources, unmodified). import { createHash } from 'node:crypto'; -import { lstat, readFile, readdir } from 'node:fs/promises'; +import { lstat, mkdtemp, open, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { runCommand } from '../process.mjs'; const repository = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -/** Verify every vendored production source before building the backend. */ -export async function verifySdk(root = repository) { +export const SDK_REPOSITORY = 'https://github.com/Lithe-IDEA/lithe-plugin-docs'; +const RAW_BASE = 'https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs'; +const SDK_SOURCE_UPSTREAM = 'sdk/java/plugin-sdk/src/main/java/'; +const SDK_SOURCE_VENDORED = 'vendor/lithe-plugin-sdk-java/src/main/java/'; +const FETCH_TIMEOUT_MS = 60_000; +const ASSET_LIMIT_BYTES = 16 * 1024 * 1024; +const FILE_LIMIT_BYTES = 4 * 1024 * 1024; +const LS_REMOTE_TIMEOUT_MS = 60_000; + +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const isHash = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); + +function safeRelative(value) { + return typeof value === 'string' && value !== '' && !value.includes('\\') + && value.split('/').every((part) => part && part !== '.' && part !== '..'); +} + +/** The only upstream locations that may be vendored, and where each one lives. */ +function vendoredPathFor(upstream) { + if (upstream === 'LICENSE') return 'vendor/LICENSE'; + if (upstream.startsWith(SDK_SOURCE_UPSTREAM) && upstream.endsWith('.java')) { + return SDK_SOURCE_VENDORED + upstream.slice(SDK_SOURCE_UPSTREAM.length); + } + return null; +} + +/** Reads sdk.lock.json and rejects anything that is not a fully pinned release. */ +export async function readLock(root = repository) { const lock = JSON.parse(await readFile(join(root, 'sdk.lock.json'), 'utf8')); - if (lock.schemaVersion !== 1 || !/^[0-9a-f]{40}$/.test(lock.source?.revision) || !Array.isArray(lock.files) || lock.source.repository !== 'https://github.com/Lithe-IDEA/lithe-plugin-docs') { - throw new Error('The SDK lock must identify its upstream base and every source hash.'); + const version = lock.version; + const valid = lock.schemaVersion === 2 + && lock.repository === SDK_REPOSITORY + && /^\d+\.\d+\.\d+$/.test(version) + && lock.tag === `sdk-java-v${version}` + && /^[0-9a-f]{40}$/.test(lock.commit) + && lock.url === `${SDK_REPOSITORY}/releases/download/${lock.tag}/lithe-plugin-sdk-java-${version}.jar` + && isHash(lock.sha256) + && Array.isArray(lock.files) && lock.files.length > 0; + if (!valid) { + throw new Error('The SDK lock must pin the released SDK: repository, version, tag, commit, asset url and sha256.'); } - const expected = new Set(); + const paths = new Set(); + const upstreams = new Set(); for (const entry of lock.files) { - if (!entry.path?.startsWith('vendor/') || entry.path.includes('\\') - || entry.path.split('/').some((part) => !part || part === '.' || part === '..') - || !/^[0-9a-f]{64}$/.test(entry.sha256) || expected.has(entry.path) - || (entry.localChanges === true && (!Object.hasOwn(entry, 'upstreamSha256') || (entry.upstreamSha256 !== null && !/^[0-9a-f]{64}$/.test(entry.upstreamSha256))))) { - throw new Error('Invalid or duplicate SDK source lock entry.'); + const keys = Object.keys(entry ?? {}).sort().join(','); + if (keys !== 'path,sha256,upstream' || !safeRelative(entry.upstream) || !safeRelative(entry.path) + || !isHash(entry.sha256) || vendoredPathFor(entry.upstream) !== entry.path + || paths.has(entry.path) || upstreams.has(entry.upstream)) { + throw new Error(`Invalid or duplicate SDK source lock entry: ${entry?.path ?? '(no path)'}`); } + paths.add(entry.path); + upstreams.add(entry.upstream); + } + return lock; +} + +/** Offline check: vendor/ holds exactly the locked files with the locked hashes. */ +export async function verifySdk(root = repository) { + const lock = await readLock(root); + const expected = new Set(); + for (const entry of lock.files) { const file = join(root, entry.path); if (!(await lstat(file)).isFile()) throw new Error(`SDK input is not a regular file: ${entry.path}`); - const digest = createHash('sha256').update(await readFile(file)).digest('hex'); - if (digest !== entry.sha256) throw new Error(`SDK SHA-256 mismatch: ${entry.path}`); + if (sha256(await readFile(file)) !== entry.sha256) throw new Error(`SDK SHA-256 mismatch: ${entry.path}`); expected.add(entry.path); } async function walk(directory, prefix) { @@ -34,9 +97,85 @@ export async function verifySdk(root = repository) { } await walk(join(root, 'vendor'), 'vendor'); if (expected.size) throw new Error('A pinned SDK source is missing.'); + return lock; +} + +/** GET a URL with a deadline and a size cap; redirects are followed. */ +export async function fetchBytes(url, limit) { + const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!response.ok) throw new Error(`GET ${url} failed: HTTP ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > limit) throw new Error(`GET ${url} returned more than ${limit} bytes`); + return bytes; +} + +/** Resolves a release tag to the commit it points at, annotated or not. */ +export async function resolveTagCommit(remote, tag) { + const scratch = await mkdtemp(join(tmpdir(), 'lithe-sdk-ls-remote-')); + try { + const output = join(scratch, 'refs.txt'); + const handle = await open(output, 'w'); + try { + await runCommand('git', ['ls-remote', '--tags', remote, `refs/tags/${tag}`, `refs/tags/${tag}^{}`], { + stdio: ['ignore', handle.fd, 'inherit'], + timeoutMs: LS_REMOTE_TIMEOUT_MS, + }); + } finally { + await handle.close(); + } + const refs = new Map(); + for (const line of (await readFile(output, 'utf8')).split('\n')) { + const [commit, name] = line.split('\t'); + if (commit && name) refs.set(name, commit); + } + // An annotated tag lists the tag object and, after ^{}, the commit it points at. + const commit = refs.get(`refs/tags/${tag}^{}`) ?? refs.get(`refs/tags/${tag}`); + if (!commit) throw new Error(`Tag ${tag} was not found in ${remote}`); + return commit; + } finally { + await rm(scratch, { recursive: true, force: true }); + } +} + +/** + * Network check that the lock still describes the release. The fetchers are + * parameters so tests can run without the network. + */ +export async function verifySdkRelease(root = repository, { + fetchAsset = (url) => fetchBytes(url, ASSET_LIMIT_BYTES), + fetchSource = (url) => fetchBytes(url, FILE_LIMIT_BYTES), + resolveTag = resolveTagCommit, +} = {}) { + const lock = await verifySdk(root); + const commit = await resolveTag(`${lock.repository}.git`, lock.tag); + if (commit !== lock.commit) { + throw new Error(`Tag ${lock.tag} points at ${commit}, but the lock pins ${lock.commit}.`); + } + const asset = sha256(await fetchAsset(lock.url)); + if (asset !== lock.sha256) { + throw new Error(`SDK release asset checksum mismatch: expected ${lock.sha256}, got ${asset} (${lock.url})`); + } + for (const entry of lock.files) { + const url = `${RAW_BASE}/${lock.commit}/${entry.upstream}`; + const digest = sha256(await fetchSource(url)); + if (digest !== entry.sha256) { + throw new Error(`Vendored ${entry.path} differs from ${entry.upstream} at ${lock.tag}: expected ${entry.sha256}, upstream has ${digest}.`); + } + } + return lock; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await verifySdk(); - console.log('Vendored Java SDK source hashes verified.'); + const args = process.argv.slice(2); + if (args.some((arg) => arg !== '--download') || args.length > 1) { + console.error('Usage: node scripts/sdk/verify-sdk.mjs [--download]'); + process.exit(2); + } + if (args.length) { + const lock = await verifySdkRelease(); + console.log(`Vendored Java SDK ${lock.version} matches release ${lock.tag} (${lock.commit}) and its published asset.`); + } else { + await verifySdk(); + console.log('Vendored Java SDK source hashes verified.'); + } } diff --git a/sdk.lock.json b/sdk.lock.json index 453b59b..73ab93b 100644 --- a/sdk.lock.json +++ b/sdk.lock.json @@ -1,10 +1,12 @@ { - "schemaVersion": 1, - "source": { - "repository": "https://github.com/Lithe-IDEA/lithe-plugin-docs", - "revision": "29b245c0e1d77c761d9032a3144a301d1818bfc7", - "localChanges": "Structured logging lithe/log implementation from the accompanying SDK working tree. Per-file hashes identify these reviewed inputs; no published release is claimed." - }, + "schemaVersion": 2, + "comment": "Official Lithe Java plugin SDK, compiled into backend/ from vendor/. vendor/ holds the unmodified files of the released tag below. scripts/sdk/verify-sdk.mjs checks every file hash offline; with --download it also checks that the tag still points at commit, that the release asset at url has sha256, and that each file matches its upstream copy at commit.", + "version": "0.3.0", + "repository": "https://github.com/Lithe-IDEA/lithe-plugin-docs", + "tag": "sdk-java-v0.3.0", + "commit": "940394fcd8455186ee985e6c34a319032414607f", + "url": "https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/download/sdk-java-v0.3.0/lithe-plugin-sdk-java-0.3.0.jar", + "sha256": "5a84ebdb5e34a86bdaf373eef7369b02f1ccb13d1ad5af483659d298f5fa8059", "files": [ { "upstream": "LICENSE", @@ -14,23 +16,17 @@ { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java", "path": "vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Backend.java", - "sha256": "c5430ea55f5ee73a88b8c6c3517cf6bc2237827b009ae1844c105254b7f48a24", - "localChanges": true, - "upstreamSha256": "c1943d63121ce20dfadf24dfcac50581da4371356fb5f7ee30962b41b2fd04a2" + "sha256": "aa7183b97b01c053281ce0f6f109a68bda9d40397a2f8f4da2c4edf1f81a8cf5" }, { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java", "path": "vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java", - "sha256": "c41fcc2cbc6533215ee9034409292ddeb6237d6c3defdd65b2cdfb8fb848dde8", - "localChanges": true, - "upstreamSha256": "8157d980a585b85ed31b96ad974fe980e167b6c0b7701b2636328ba84c0b62af" + "sha256": "c41fcc2cbc6533215ee9034409292ddeb6237d6c3defdd65b2cdfb8fb848dde8" }, { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java", "path": "vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java", - "sha256": "826c246360a607eb40ce950202db0988a465718082dc22be539f045139671db3", - "localChanges": true, - "upstreamSha256": "87eb1a9b673f59774ac1688393f62f8aa5379ead1ed2464b50ab64a0d6ad80d9" + "sha256": "826c246360a607eb40ce950202db0988a465718082dc22be539f045139671db3" }, { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java", @@ -40,9 +36,7 @@ { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Diagnostics.java", "path": "vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Diagnostics.java", - "sha256": "e6b70f167f1b030509fccf399745bc7693023eabeff1201696e866340d546ec9", - "localChanges": true, - "upstreamSha256": null + "sha256": "1268691d9cb950776e9f659d11d9480e51069e90aa6437f2809ac0e00c3a57d4" }, { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java", @@ -57,9 +51,7 @@ { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostChannel.java", "path": "vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/HostChannel.java", - "sha256": "79e337d8fc495c1c993d33205f70d79a0baa5fe49c054ef7032f02ce500f147c", - "localChanges": true, - "upstreamSha256": "1830ac36651759f3b4f77bda5fb23fd4b478a2c153671fc01920af38faefd09f" + "sha256": "79e337d8fc495c1c993d33205f70d79a0baa5fe49c054ef7032f02ce500f147c" }, { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostError.java", @@ -74,9 +66,7 @@ { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java", "path": "vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java", - "sha256": "882485659c7e77833ebb383dd8175b7eede292596696f63678fbcd868c029806", - "localChanges": true, - "upstreamSha256": "33dec00015fc1611084bd99063e90dbce3c306e99b83faed1cd6a2f8cab24de7" + "sha256": "882485659c7e77833ebb383dd8175b7eede292596696f63678fbcd868c029806" }, { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java", @@ -86,9 +76,7 @@ { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java", "path": "vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Processes.java", - "sha256": "86b50e196fb23ccd28e576b524a8da2d8928ac3113bbcd105180ceaef9981319", - "localChanges": true, - "upstreamSha256": "e1a499d9fc526179c624be1084188e2a394e46f5d5c512cc5c3e3b3d39661aeb" + "sha256": "86b50e196fb23ccd28e576b524a8da2d8928ac3113bbcd105180ceaef9981319" }, { "upstream": "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java", diff --git a/vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Backend.java index b71b683..15bee55 100644 --- a/vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ b/vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Backend.java @@ -13,6 +13,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.Base64; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; @@ -45,6 +48,74 @@ public interface Handler { Object handle(Map params, CallContext context) throws Exception; } + /** Answers an independently negotiated shortcut import request. */ + @FunctionalInterface + public interface KeymapImportHandler { + Object handle(KeymapSnapshot snapshot, CallContext context) throws Exception; + } + + /** One file within the user's selected source; its path is a relative label. */ + public static final class KeymapSourceFile { + private final String id; + private final String path; + private final long byteCount; + KeymapSourceFile(String id, String path, long byteCount) { this.id = id; this.path = path; this.byteCount = byteCount; } + public String id() { return id; } + public String path() { return path; } + public long byteCount() { return byteCount; } + } + + /** Opaque selected source scoped to one import and backend session. */ + public static final class KeymapSnapshot { + private final String providerId; + private final String snapshotId; + private final Host host; + KeymapSnapshot(String providerId, String snapshotId, Host host) { + this.providerId = providerId; this.snapshotId = snapshotId; this.host = host; + } + public String providerId() { return providerId; } + public String snapshotId() { return snapshotId; } + private Map scope() { + Map result = singleton("providerID", providerId); + result.put("snapshotID", snapshotId); + return result; + } + public CompletableFuture> listInputs() { + return host.request("lithe/host/keymapImport/listInputs", scope()).thenApply(raw -> { + Object files = raw instanceof Map ? ((Map) raw).get("files") : null; + if (!(files instanceof List) || ((List) files).size() > 128) throw invalidKeymapResponse(); + List result = new ArrayList<>(); + for (Object item : (List) files) { + if (!(item instanceof Map)) throw invalidKeymapResponse(); + Map file = (Map) item; + Object id = file.get("id"), path = file.get("path"), count = file.get("byteCount"); + if (!(id instanceof String) || !validSnapshotToken((String) id) || !(path instanceof String) + || ((String) path).isEmpty() || ((String) path).getBytes(StandardCharsets.UTF_8).length > 4096 + || !(count instanceof Number) || ((Number) count).doubleValue() != ((Number) count).longValue() + || ((Number) count).longValue() < 0 || ((Number) count).longValue() > 1048576) throw invalidKeymapResponse(); + result.add(new KeymapSourceFile((String) id, (String) path, ((Number) count).longValue())); + } + return result; + }); + } + public CompletableFuture readChunk(String fileId, long offset, int maximumBytes) { + if (!validSnapshotToken(fileId) || offset < 0 || offset > 1048576 || maximumBytes < 1 || maximumBytes > 131072) { + throw invalidKeymapResponse(); + } + Map params = scope(); + params.put("fileID", fileId); params.put("offset", offset); params.put("maximumBytes", maximumBytes); + return host.request("lithe/host/keymapImport/readChunk", params).thenApply(raw -> { + Object encoded = raw instanceof Map ? ((Map) raw).get("data") : null; + if (!(encoded instanceof String) || ((String) encoded).length() > 174764) throw invalidKeymapResponse(); + final byte[] bytes; + try { bytes = Base64.getDecoder().decode((String) encoded); } + catch (IllegalArgumentException error) { throw invalidKeymapResponse(); } + if (bytes.length > maximumBytes || !Base64.getEncoder().encodeToString(bytes).equals(encoded)) throw invalidKeymapResponse(); + return bytes; + }); + } + } + /** Answers a standard LSP request, for a backend that is also a language server. */ @FunctionalInterface public interface RequestHandler { @@ -139,6 +210,8 @@ private enum Phase { IDLE, LISTENING, INITIALIZING, READY, SHUTTING_DOWN, EXITED private final Object lock = new Object(); private final Object writeLock = new Object(); private final Map functions = new HashMap<>(); + private final Map keymapProviders = new HashMap<>(); + private final Set selectedKeymapProviders = new TreeSet<>(); private final Map buildHandlers = new HashMap<>(); private final Map requestHandlers = new HashMap<>(); private final Map notificationHandlers = new HashMap<>(); @@ -169,9 +242,10 @@ private Backend(String name, String version, Map capabilities) { if (experimental instanceof Map && (((Map) experimental).containsKey("litheBuild") || ((Map) experimental).containsKey("litheFunctions") + || ((Map) experimental).containsKey("litheKeymapImport") || ((Map) experimental).containsKey("litheLogging"))) { throw new PluginSdkError("sdk.capabilities.reserved", - "litheBuild and litheFunctions are negotiated by the SDK; declare handlers instead."); + "litheBuild, litheFunctions, litheKeymapImport and litheLogging are negotiated by the SDK; declare handlers instead."); } this.name = name == null ? "lithe-plugin-backend" : name; this.version = version; @@ -278,6 +352,18 @@ public Backend function(String id, Handler handler) { return this; } + /** Declares a shortcut import provider before initialization. */ + public Backend keymapImportProvider(String id, KeymapImportHandler handler) { + synchronized (lock) { + if (!beforeAnswer() || !validKeymapProvider(id) || handler == null + || keymapProviders.containsKey(id) || keymapProviders.size() >= 8) { + throw new PluginSdkError("sdk.keymap.invalidRegistration", "Invalid or duplicate shortcut import provider."); + } + keymapProviders.put(id, handler); + } + return this; + } + /** Handles a standard LSP request, for a backend that is also a language server. */ public Backend onRequest(String method, RequestHandler handler) { checkDeclared(method, handler); @@ -594,6 +680,10 @@ private void handleRequest(Object id, String method, Object params) { invokeFunction(id, params); return; } + if (method.equals("lithe/keymap/import")) { + invokeKeymap(id, params); + return; + } Handler build; RequestHandler handler; synchronized (lock) { @@ -765,6 +855,22 @@ private void initialize(Object id, Map params) { } Map experimental = new LinkedHashMap<>(experimental(capabilities)); experimental.put("litheLogging", singleton("version", 1)); + Map offeredCapabilities = experimental(params.get("capabilities")); + Object keymap = offeredCapabilities.get("litheKeymapImport"); + if (offers(keymap, 1) && keymap instanceof Map) { + Object providers = ((Map) keymap).get("providers"); + if (providers instanceof List && ((List) providers).size() <= 8) { + selectedKeymapProviders.clear(); + for (Object provider : (List) providers) { + if (provider instanceof String && keymapProviders.containsKey(provider)) selectedKeymapProviders.add((String) provider); + } + if (!selectedKeymapProviders.isEmpty()) { + Map entry = singleton("version", 1); + entry.put("providers", new ArrayList<>(selectedKeymapProviders)); + experimental.put("litheKeymapImport", entry); + } + } + } if (agreed.build()) { experimental.put("litheBuild", singleton("version", agreed.buildVersion())); } @@ -819,6 +925,34 @@ private void shutdown(Object id) { }); } + private void invokeKeymap(Object id, Object raw) { + Map params = raw instanceof Map ? Json.object(raw) : new LinkedHashMap<>(); + Object provider = params.get("providerID"), snapshot = params.get("snapshotID"); + KeymapImportHandler handler; + synchronized (lock) { + handler = provider instanceof String && selectedKeymapProviders.contains(provider) ? keymapProviders.get(provider) : null; + } + if (params.size() != 2 || !(provider instanceof String) || !validKeymapProvider((String) provider) + || !(snapshot instanceof String) || !validSnapshotToken((String) snapshot) || handler == null) { + respond(id, null, error(Protocol.INVALID_PARAMS, "Invalid shortcut import request.")); + return; + } + run(id, null, null, null, context -> handler.handle(new KeymapSnapshot((String) provider, (String) snapshot, context.host()), context), + Backend::functionResult, null, null, "lithe/keymap/import"); + } + + private static boolean validKeymapProvider(String id) { + return id != null && id.matches("[a-z][a-z0-9-]{0,63}"); + } + + private static boolean validSnapshotToken(String id) { + return id != null && id.matches("[a-zA-Z0-9_-]{1,128}"); + } + + private static PluginSdkError invalidKeymapResponse() { + return new PluginSdkError("sdk.keymap.invalidResponse", "Invalid shortcut source response."); + } + private void invokeFunction(Object id, Object raw) { Map invocation = raw instanceof Map ? Json.object(raw) : new LinkedHashMap<>(); Object functionId = invocation.get("functionID"); diff --git a/vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Diagnostics.java b/vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Diagnostics.java index 6912594..c4d9bd4 100644 --- a/vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Diagnostics.java +++ b/vendor/lithe-plugin-sdk-java/src/main/java/io/github/litheidea/plugin/sdk/Diagnostics.java @@ -15,7 +15,8 @@ public final class Diagnostics { "lithe/build/discoverTests", "lithe/build/prepareRun", "lithe/build/prepareDebug", "lithe/vcs/status", "lithe/vcs/baseContent", "lithe/vcs/history", "lithe/vcs/fileHistory", "lithe/vcs/annotate", "lithe/vcs/checkin", "lithe/vcs/rollback", "lithe/vcs/update", - "lithe/vcs/fileOperation", "lithe/vcs/resolve", "lithe/vcs/cleanup")); + "lithe/vcs/fileOperation", "lithe/vcs/resolve", "lithe/vcs/cleanup", + "lithe/keymap/import")); private final Backend backend; private final String operationId;