fix(omo-codex): reuse shared lsp mcp
This commit is contained in:
+1
-1
@@ -65,7 +65,7 @@
|
||||
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json && tsgo --noEmit -p packages/omo-codex/tsconfig.json",
|
||||
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
|
||||
"test": "bun test",
|
||||
"test:codex": "bun test src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/link-cached-plugin-agents.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs",
|
||||
"test:codex": "bun test src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/link-cached-plugin-agents.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/install-bin-links.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs",
|
||||
"test:windows-codex": "bun run test:codex",
|
||||
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
|
||||
},
|
||||
|
||||
+1
-1
Submodule packages/lsp-tools-mcp updated: e3cae69bd6...83e634172e
@@ -1,4 +0,0 @@
|
||||
[submodule "packages/lsp-tools-mcp"]
|
||||
path = packages/lsp-tools-mcp
|
||||
url = https://github.com/code-yeongyu/lsp-tools-mcp.git
|
||||
branch = main
|
||||
@@ -2,7 +2,7 @@
|
||||
"mcpServers": {
|
||||
"lsp": {
|
||||
"command": "node",
|
||||
"args": ["./packages/lsp-tools-mcp/dist/cli.js", "mcp"],
|
||||
"args": ["../../../../lsp-tools-mcp/dist/cli.js", "mcp"],
|
||||
"cwd": "."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Reuse the repository-level `packages/lsp-tools-mcp` package instead of carrying a second copy under `components/lsp/packages`.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- Extracted the LSP runtime and MCP server into [`@code-yeongyu/lsp-tools-mcp`](https://github.com/code-yeongyu/lsp-tools-mcp).
|
||||
|
||||
@@ -6,7 +6,7 @@ Codex plugin that ports the standalone LSP runtime from [`pi-lsp-client`](https:
|
||||
|
||||
## Architecture
|
||||
|
||||
The LSP runtime moved to [`lsp-tools-mcp`](https://github.com/code-yeongyu/lsp-tools-mcp) and is consumed here as a git submodule at `packages/lsp-tools-mcp/`.
|
||||
The LSP runtime moved to [`lsp-tools-mcp`](https://github.com/code-yeongyu/lsp-tools-mcp) and is consumed from this repository's root `packages/lsp-tools-mcp/` package.
|
||||
|
||||
- `codex-lsp` keeps Codex-specific integration (`hook post-tool-use`, plugin metadata, package wiring).
|
||||
- `lsp-tools-mcp` owns MCP runtime, LSP manager, and tool implementations.
|
||||
@@ -75,7 +75,7 @@ The plugin ships:
|
||||
- `hooks/hooks.json` for the `PostToolUse` diagnostics hook.
|
||||
- `skills/lsp/SKILL.md` with MCP usage guidance.
|
||||
|
||||
The runtime depends on `@code-yeongyu/lsp-tools-mcp` via `file:./packages/lsp-tools-mcp`, so marketplace builds must include submodule contents.
|
||||
The runtime depends on `@code-yeongyu/lsp-tools-mcp` via `file:../../../../lsp-tools-mcp`, so marketplace builds reuse the root package instead of carrying a second copy under this component.
|
||||
|
||||
The hook command is:
|
||||
|
||||
@@ -86,14 +86,13 @@ node "${PLUGIN_ROOT}/dist/cli.js" hook post-tool-use
|
||||
The MCP command is:
|
||||
|
||||
```bash
|
||||
node ./packages/lsp-tools-mcp/dist/cli.js mcp
|
||||
node ../../../../lsp-tools-mcp/dist/cli.js mcp
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
npm run bootstrap # installs + builds the lsp-tools-mcp submodule
|
||||
npm run bootstrap # installs + builds the root packages/lsp-tools-mcp package
|
||||
npm install
|
||||
npm test
|
||||
npm run typecheck
|
||||
@@ -101,7 +100,7 @@ npm run check
|
||||
npm pack --dry-run
|
||||
```
|
||||
|
||||
The `bootstrap` script installs and builds the `lsp-tools-mcp` git submodule so
|
||||
The `bootstrap` script installs and builds the root `lsp-tools-mcp` package so
|
||||
`@code-yeongyu/lsp-tools-mcp/dist/*.js` is available for the codex-lsp build.
|
||||
|
||||
Smoke-test the hook:
|
||||
|
||||
@@ -36,21 +36,21 @@
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"scripts": {
|
||||
"bootstrap": "node scripts/bootstrap-submodule.mjs",
|
||||
"prebuild": "node scripts/bootstrap-submodule.mjs",
|
||||
"bootstrap": "node scripts/build-lsp-tools.mjs",
|
||||
"prebuild": "node scripts/build-lsp-tools.mjs",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"pretest": "node scripts/bootstrap-submodule.mjs",
|
||||
"pretest": "node scripts/build-lsp-tools.mjs",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"pretypecheck": "node scripts/bootstrap-submodule.mjs",
|
||||
"pretypecheck": "node scripts/build-lsp-tools.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check src test",
|
||||
"lint:fix": "biome check --write src test",
|
||||
"precheck": "node scripts/bootstrap-submodule.mjs",
|
||||
"precheck": "node scripts/build-lsp-tools.mjs",
|
||||
"check": "tsc --noEmit && biome check src test && tsc -p tsconfig.build.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@code-yeongyu/lsp-tools-mcp": "file:./packages/lsp-tools-mcp"
|
||||
"@code-yeongyu/lsp-tools-mcp": "file:../../../../lsp-tools-mcp"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Normalize line endings: store LF in git, check out LF on every platform.
|
||||
# Required so biome's --check passes on Windows (default core.autocrlf=true).
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicit binary types
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.zip binary
|
||||
*.tgz binary
|
||||
*.gz binary
|
||||
-1
@@ -1 +0,0 @@
|
||||
* @code-yeongyu
|
||||
Vendored
-26
@@ -1,26 +0,0 @@
|
||||
name: Bug report
|
||||
description: Report a reproducible lsp-tools-mcp bug.
|
||||
title: "[bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: What happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction
|
||||
description: Exact steps, config, command output, and affected MCP tool or language server.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
placeholder: 0.1.0
|
||||
validations:
|
||||
required: true
|
||||
Vendored
-19
@@ -1,19 +0,0 @@
|
||||
name: Feature request
|
||||
description: Propose a focused lsp-tools-mcp improvement.
|
||||
title: "[feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What workflow should improve?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposal
|
||||
description: What should lsp-tools-mcp do?
|
||||
validations:
|
||||
required: true
|
||||
Vendored
-45
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"name": "main protection",
|
||||
"target": "branch",
|
||||
"enforcement": "active",
|
||||
"conditions": {
|
||||
"ref_name": {
|
||||
"include": ["~DEFAULT_BRANCH"],
|
||||
"exclude": []
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{ "type": "deletion" },
|
||||
{ "type": "non_fast_forward" },
|
||||
{ "type": "required_linear_history" },
|
||||
{
|
||||
"type": "pull_request",
|
||||
"parameters": {
|
||||
"required_approving_review_count": 1,
|
||||
"dismiss_stale_reviews_on_push": true,
|
||||
"require_code_owner_review": true,
|
||||
"require_last_push_approval": false,
|
||||
"required_review_thread_resolution": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "required_status_checks",
|
||||
"parameters": {
|
||||
"strict_required_status_checks_policy": true,
|
||||
"required_status_checks": [
|
||||
{ "context": "test (ubuntu-latest · node 20)" },
|
||||
{ "context": "test (ubuntu-latest · node 22)" },
|
||||
{ "context": "test (macos-latest · node 20)" },
|
||||
{ "context": "test (macos-latest · node 22)" }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"bypass_actors": [
|
||||
{
|
||||
"actor_id": 5,
|
||||
"actor_type": "RepositoryRole",
|
||||
"bypass_mode": "always"
|
||||
}
|
||||
]
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
Vendored
-11
@@ -1,11 +0,0 @@
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Validation
|
||||
|
||||
-
|
||||
|
||||
## Notes
|
||||
|
||||
-
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test (${{ matrix.os }} · node ${{ matrix.node }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: ["20", "22"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check
|
||||
run: npm run check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Package smoke
|
||||
run: npm pack --dry-run
|
||||
Vendored
-51
@@ -1,51 +0,0 @@
|
||||
name: publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check
|
||||
run: npm run check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Package smoke
|
||||
run: npm pack --dry-run
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ -z "$NODE_AUTH_TOKEN" ]; then
|
||||
echo "NODE_AUTH_TOKEN is not configured; skipping npm publish."
|
||||
exit 0
|
||||
fi
|
||||
npm publish --access public --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
coverage/
|
||||
.vitest/
|
||||
@@ -1,20 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented in this file.
|
||||
|
||||
## [0.1.0] - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
- Initial standalone extraction from `codex-lsp`:
|
||||
- LSP runtime (`src/lsp/*`)
|
||||
- MCP server (`src/mcp.ts`)
|
||||
- Tool definitions (`src/tools.ts`)
|
||||
- Standalone CLI (`src/cli.ts`, `mcp` subcommand only)
|
||||
- Config path override support:
|
||||
- `LSP_TOOLS_MCP_PROJECT_CONFIG`
|
||||
- `LSP_TOOLS_MCP_USER_CONFIG`
|
||||
- Full test suite import (excluding Codex-specific hook tests)
|
||||
- CI workflow matrix (ubuntu/macos/windows x node 20/22)
|
||||
- Release-triggered npm publish workflow
|
||||
- Repository governance files (ruleset, CODEOWNERS, dependabot, issue templates, PR template)
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Yeongyu Kim
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,3 +0,0 @@
|
||||
lsp-tools-mcp extracts the standalone LSP runtime from codex-lsp into a reusable package.
|
||||
|
||||
The package includes adapted code originally developed for pi-lsp-client.
|
||||
@@ -1,102 +0,0 @@
|
||||
# lsp-tools-mcp
|
||||
|
||||
[](https://github.com/code-yeongyu/lsp-tools-mcp/actions/workflows/ci.yml) [](LICENSE)
|
||||
|
||||
Standalone Language Server Protocol tools exposed as a stdio MCP server.
|
||||
|
||||
## Used By
|
||||
|
||||
This repository is the upstream source of truth for two downstream plugins. Both consume it as a git submodule:
|
||||
|
||||
| Project | Path | Role |
|
||||
|---------|------|------|
|
||||
| **[codex-lsp](https://github.com/code-yeongyu/codex-lsp)** | `packages/lsp-tools-mcp/` | Codex plugin that ships these LSP MCP tools plus a Codex-specific PostToolUse diagnostics hook. |
|
||||
| **[oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent)** (a.k.a. `oh-my-opencode`) | `vendor/lsp-tools-mcp/` | OpenCode plugin that registers this server as a built-in Tier-1 stdio MCP. Exposes `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_prepare_rename`, `lsp_rename`, and `lsp_status` to all agents. |
|
||||
|
||||
If you fix or extend the LSP runtime here, both downstreams pick up the change by bumping the submodule pointer. Do not fork the runtime into a downstream; land changes here instead.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run check
|
||||
npm test
|
||||
npm run build
|
||||
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/cli.js mcp
|
||||
```
|
||||
|
||||
## MCP Tools
|
||||
|
||||
This server exposes the following tools:
|
||||
|
||||
- `lsp.status`
|
||||
- `lsp.diagnostics`
|
||||
- `lsp.goto_definition`
|
||||
- `lsp.find_references`
|
||||
- `lsp.symbols`
|
||||
- `lsp.prepare_rename`
|
||||
- `lsp.rename`
|
||||
|
||||
Tool aliases are also available for compatibility:
|
||||
|
||||
- `lsp_status`
|
||||
- `lsp_diagnostics`
|
||||
- `lsp_goto_definition`
|
||||
- `lsp_find_references`
|
||||
- `lsp_symbols`
|
||||
- `lsp_prepare_rename`
|
||||
- `lsp_rename`
|
||||
|
||||
When an MCP host registers this server under the name `lsp` (the default in both downstreams), the tools are exposed to agents as `lsp_status`, `lsp_diagnostics`, and so on, matching the alias names above.
|
||||
|
||||
## Configuration
|
||||
|
||||
Default config paths (matches codex-lsp's historical layout):
|
||||
|
||||
- Project: `.codex/lsp-client.json`
|
||||
- User: `~/.codex/lsp-client.json`
|
||||
|
||||
Path overrides via environment variables:
|
||||
|
||||
- `LSP_TOOLS_MCP_PROJECT_CONFIG`
|
||||
- `LSP_TOOLS_MCP_USER_CONFIG`
|
||||
|
||||
Examples (oh-my-openagent points the project config at `.opencode/lsp.json` via the env var):
|
||||
|
||||
```bash
|
||||
LSP_TOOLS_MCP_PROJECT_CONFIG=.opencode/lsp.json node dist/cli.js mcp
|
||||
LSP_TOOLS_MCP_USER_CONFIG=.opencode/lsp.json node dist/cli.js mcp
|
||||
```
|
||||
|
||||
Example config file:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"typescript": {
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx", ".js", ".jsx"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/lsp/*` standalone LSP runtime (process management, JSON-RPC transport, configuration, diagnostics, workspace edits)
|
||||
- `src/tools.ts` MCP tool definitions and handlers
|
||||
- `src/mcp.ts` stdio MCP server entry and registration
|
||||
- `src/cli.ts` standalone CLI entry (`mcp` subcommand only)
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run check
|
||||
npm test
|
||||
npm pack --dry-run
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noDefaultExport": "error",
|
||||
"noEnum": "error",
|
||||
"noNonNullAssertion": "error",
|
||||
"useImportType": "error",
|
||||
"useConst": "error",
|
||||
"useNodejsImportProtocol": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"useLiteralKeys": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "error",
|
||||
"noTsIgnore": "error",
|
||||
"noControlCharactersInRegex": "off",
|
||||
"noEmptyInterface": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 3,
|
||||
"lineWidth": 120
|
||||
},
|
||||
"files": {
|
||||
"includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["vitest.config.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"noDefaultExport": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"name": "@code-yeongyu/lsp-tools-mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "Standalone Language Server Protocol tools exposed as a stdio MCP server.",
|
||||
"type": "module",
|
||||
"packageManager": "npm@11.12.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/code-yeongyu/lsp-tools-mcp",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/code-yeongyu/lsp-tools-mcp.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/code-yeongyu/lsp-tools-mcp/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"lsp",
|
||||
"language-server-protocol",
|
||||
"model-context-protocol",
|
||||
"typescript",
|
||||
"nodejs"
|
||||
],
|
||||
"bin": {
|
||||
"lsp-tools-mcp": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"LICENSE",
|
||||
"NOTICE",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"check": "tsc --noEmit && biome check . && npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@types/node": "^25.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { argv, stderr } from "node:process";
|
||||
|
||||
import { disposeDefaultLspManager } from "./lsp/manager.js";
|
||||
import { runMcpStdioServer } from "./mcp.js";
|
||||
import { writeMcpLifecycleLog } from "./mcp-lifecycle-log.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [command = "mcp"] = argv.slice(2);
|
||||
|
||||
try {
|
||||
if (command === "mcp") {
|
||||
await runMcpStdioServer(process.stdin, process.stdout, {
|
||||
log: writeMcpLifecycleLog,
|
||||
onIdleTimeout: async () => {
|
||||
await disposeDefaultLspManager();
|
||||
process.exit(0);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
stderr.write("Usage: lsp-tools-mcp [mcp]\n");
|
||||
process.exitCode = 2;
|
||||
} finally {
|
||||
await disposeDefaultLspManager();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (error: unknown) => {
|
||||
stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
|
||||
await disposeDefaultLspManager();
|
||||
process.exitCode = 1;
|
||||
});
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
export function reportBestEffortCleanupError(operation: string, error: unknown): void {
|
||||
if (process.env["CODEX_LSP_DEBUG_CLEANUP"] !== "1") return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[codex-lsp] ignored ${operation} failure during cleanup: ${message}`);
|
||||
}
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
|
||||
import type { LspClient } from "./client.js";
|
||||
import {
|
||||
isLspDeadConnectionError,
|
||||
LspInvalidPathError,
|
||||
LspRequestTimeoutError,
|
||||
LspServerInitializingError,
|
||||
LspServerLookupError,
|
||||
} from "./errors.js";
|
||||
import { getLspManager, type LspManager } from "./manager.js";
|
||||
import { findServerForExtension } from "./server-resolution.js";
|
||||
import type { ServerLookupResult } from "./types.js";
|
||||
|
||||
const WORKSPACE_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle"];
|
||||
|
||||
export function isDirectoryPath(filePath: string): boolean {
|
||||
try {
|
||||
return statSync(filePath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function findWorkspaceRoot(filePath: string): string {
|
||||
const abs = resolve(filePath);
|
||||
let dir = abs;
|
||||
|
||||
if (!isDirectoryPath(dir)) {
|
||||
dir = dirname(dir);
|
||||
}
|
||||
|
||||
let prevDir = "";
|
||||
while (dir !== prevDir) {
|
||||
for (const marker of WORKSPACE_MARKERS) {
|
||||
if (existsSync(join(dir, marker))) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
prevDir = dir;
|
||||
dir = dirname(dir);
|
||||
}
|
||||
|
||||
return dirname(abs);
|
||||
}
|
||||
|
||||
export function formatServerLookupError(result: Exclude<ServerLookupResult, { status: "found" }>): string {
|
||||
if (result.status === "not_installed") {
|
||||
const { server, installHint } = result;
|
||||
return [
|
||||
`LSP server '${server.id}' is configured but NOT INSTALLED.`,
|
||||
"",
|
||||
`Command not found: ${server.command[0]}`,
|
||||
"",
|
||||
"To install:",
|
||||
` ${installHint}`,
|
||||
"",
|
||||
`Supported extensions: ${server.extensions.join(", ")}`,
|
||||
"",
|
||||
"After installation, the server will be available automatically.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
`No LSP server configured for extension: ${result.extension}`,
|
||||
"",
|
||||
`Available servers: ${result.availableServers.slice(0, 10).join(", ")}${
|
||||
result.availableServers.length > 10 ? "..." : ""
|
||||
}`,
|
||||
"",
|
||||
"Configure a custom server in '.codex/lsp-client.json':",
|
||||
" {",
|
||||
' "lsp": {',
|
||||
' "my-server": {',
|
||||
' "command": ["my-lsp", "--stdio"],',
|
||||
` "extensions": ["${result.extension}"]`,
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export interface WithLspClientOptions {
|
||||
signal?: AbortSignal;
|
||||
manager?: LspManager;
|
||||
}
|
||||
|
||||
const READ_ONLY_RETRY_TOOLS = new Set([
|
||||
"diagnostics",
|
||||
"definition",
|
||||
"references",
|
||||
"documentSymbols",
|
||||
"workspaceSymbols",
|
||||
"prepareRename",
|
||||
]);
|
||||
|
||||
export async function withLspClient<T>(
|
||||
filePath: string,
|
||||
fn: (client: LspClient) => Promise<T>,
|
||||
toolName: string,
|
||||
options: WithLspClientOptions = {},
|
||||
): Promise<T> {
|
||||
const absPath = resolve(filePath);
|
||||
|
||||
if (isDirectoryPath(absPath)) {
|
||||
throw new LspInvalidPathError(
|
||||
"Directory paths are not supported by this LSP tool. " +
|
||||
"Use lsp.diagnostics with a directory path for directory diagnostics.",
|
||||
);
|
||||
}
|
||||
|
||||
const ext = extname(absPath);
|
||||
const result = findServerForExtension(ext);
|
||||
if (result.status !== "found") {
|
||||
throw new LspServerLookupError(formatServerLookupError(result));
|
||||
}
|
||||
|
||||
const server = result.server;
|
||||
const root = findWorkspaceRoot(absPath);
|
||||
const manager = options.manager ?? getLspManager();
|
||||
|
||||
const acquireAndCall = async (allowRetry: boolean): Promise<T> => {
|
||||
const client = await manager.getClient(root, server, options.signal);
|
||||
|
||||
try {
|
||||
return await fn(client);
|
||||
} catch (err) {
|
||||
if (allowRetry && READ_ONLY_RETRY_TOOLS.has(toolName) && isLspDeadConnectionError(err)) {
|
||||
manager.invalidateClient(root, server.id, client);
|
||||
return acquireAndCall(false);
|
||||
}
|
||||
|
||||
if (err instanceof LspRequestTimeoutError) {
|
||||
if (manager.isServerInitializing(root, server.id)) {
|
||||
throw new LspServerInitializingError(err);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
manager.releaseClient(root, server.id);
|
||||
}
|
||||
};
|
||||
|
||||
return acquireAndCall(true);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { extname, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { LspClientConnection } from "./connection.js";
|
||||
import { getLanguageId } from "./language-mappings.js";
|
||||
import type {
|
||||
Diagnostic,
|
||||
DocumentSymbol,
|
||||
Location,
|
||||
LocationLink,
|
||||
PrepareRenameDefaultBehavior,
|
||||
PrepareRenameResult,
|
||||
Range,
|
||||
SymbolInfo,
|
||||
WorkspaceEdit,
|
||||
} from "./types.js";
|
||||
|
||||
const POST_OPEN_DELAY_MS = 1000;
|
||||
const POST_DIAGNOSTICS_WAIT_MS = 500;
|
||||
|
||||
export class LspClient extends LspClientConnection {
|
||||
private readonly openedFiles = new Set<string>();
|
||||
private readonly documentVersions = new Map<string, number>();
|
||||
private readonly lastSyncedText = new Map<string, string>();
|
||||
private readonly diagnosticPullErrors: Error[] = [];
|
||||
|
||||
getDiagnosticPullErrors(): readonly Error[] {
|
||||
return this.diagnosticPullErrors;
|
||||
}
|
||||
|
||||
async openFile(filePath: string): Promise<void> {
|
||||
const absPath = resolve(filePath);
|
||||
const uri = pathToFileURL(absPath).href;
|
||||
const text = readFileSync(absPath, "utf-8");
|
||||
|
||||
if (!this.openedFiles.has(absPath)) {
|
||||
const ext = extname(absPath);
|
||||
const languageId = getLanguageId(ext);
|
||||
const version = 1;
|
||||
|
||||
await this.sendNotification("textDocument/didOpen", {
|
||||
textDocument: {
|
||||
uri,
|
||||
languageId,
|
||||
version,
|
||||
text,
|
||||
},
|
||||
});
|
||||
|
||||
this.openedFiles.add(absPath);
|
||||
this.documentVersions.set(uri, version);
|
||||
this.lastSyncedText.set(uri, text);
|
||||
await new Promise((r) => setTimeout(r, POST_OPEN_DELAY_MS));
|
||||
return;
|
||||
}
|
||||
|
||||
const prevText = this.lastSyncedText.get(uri);
|
||||
if (prevText === text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1;
|
||||
this.documentVersions.set(uri, nextVersion);
|
||||
this.lastSyncedText.set(uri, text);
|
||||
|
||||
await this.sendNotification("textDocument/didChange", {
|
||||
textDocument: { uri, version: nextVersion },
|
||||
contentChanges: [{ text }],
|
||||
});
|
||||
|
||||
await this.sendNotification("textDocument/didSave", {
|
||||
textDocument: { uri },
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
async definition(
|
||||
filePath: string,
|
||||
line: number,
|
||||
character: number,
|
||||
): Promise<Location | LocationLink | Array<Location | LocationLink> | null> {
|
||||
const absPath = resolve(filePath);
|
||||
await this.openFile(absPath);
|
||||
return this.sendRequest<Location | LocationLink | Array<Location | LocationLink> | null>(
|
||||
"textDocument/definition",
|
||||
{
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
position: { line: line - 1, character },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise<Location[]> {
|
||||
const absPath = resolve(filePath);
|
||||
await this.openFile(absPath);
|
||||
return this.sendRequest<Location[]>("textDocument/references", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
position: { line: line - 1, character },
|
||||
context: { includeDeclaration },
|
||||
});
|
||||
}
|
||||
|
||||
async documentSymbols(filePath: string): Promise<Array<DocumentSymbol | SymbolInfo>> {
|
||||
const absPath = resolve(filePath);
|
||||
await this.openFile(absPath);
|
||||
return this.sendRequest<Array<DocumentSymbol | SymbolInfo>>("textDocument/documentSymbol", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
});
|
||||
}
|
||||
|
||||
async workspaceSymbols(query: string): Promise<SymbolInfo[]> {
|
||||
return this.sendRequest<SymbolInfo[]>("workspace/symbol", { query });
|
||||
}
|
||||
|
||||
private isUnsupportedDiagnosticPullError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const code = "code" in error && typeof error.code === "number" ? error.code : undefined;
|
||||
if (code === -32601) return true;
|
||||
return /unsupported|not supported|method not found|unknown request/i.test(error.message);
|
||||
}
|
||||
|
||||
async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
|
||||
const absPath = resolve(filePath);
|
||||
const uri = pathToFileURL(absPath).href;
|
||||
await this.openFile(absPath);
|
||||
await new Promise((r) => setTimeout(r, POST_DIAGNOSTICS_WAIT_MS));
|
||||
|
||||
try {
|
||||
const result = await this.sendRequest<{ items?: Diagnostic[] }>("textDocument/diagnostic", {
|
||||
textDocument: { uri },
|
||||
});
|
||||
if (result.items) {
|
||||
return { items: result.items };
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.isUnsupportedDiagnosticPullError(error)) {
|
||||
this.diagnosticPullErrors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
return { items: this.getStoredDiagnostics(uri) };
|
||||
}
|
||||
|
||||
async prepareRename(
|
||||
filePath: string,
|
||||
line: number,
|
||||
character: number,
|
||||
): Promise<PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null> {
|
||||
const absPath = resolve(filePath);
|
||||
await this.openFile(absPath);
|
||||
return this.sendRequest<PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null>(
|
||||
"textDocument/prepareRename",
|
||||
{
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
position: { line: line - 1, character },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async rename(filePath: string, line: number, character: number, newName: string): Promise<WorkspaceEdit | null> {
|
||||
const absPath = resolve(filePath);
|
||||
await this.openFile(absPath);
|
||||
return this.sendRequest<WorkspaceEdit | null>("textDocument/rename", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
position: { line: line - 1, character },
|
||||
newName,
|
||||
});
|
||||
}
|
||||
}
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
|
||||
import { BUILTIN_SERVERS } from "./server-definitions.js";
|
||||
import type { ResolvedServer } from "./types.js";
|
||||
|
||||
interface LspEntry {
|
||||
disabled?: boolean;
|
||||
command?: string[];
|
||||
extensions?: string[];
|
||||
priority?: number;
|
||||
env?: Record<string, string>;
|
||||
initialization?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ConfigJson {
|
||||
lsp?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type ConfigSource = "project" | "user";
|
||||
|
||||
export interface ServerWithSource extends ResolvedServer {
|
||||
source: "project" | "user" | "builtin";
|
||||
}
|
||||
|
||||
export function getConfigPaths(): { project: string; user: string } {
|
||||
const cwd = process.cwd();
|
||||
const projectOverride = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
|
||||
const userOverride = process.env["LSP_TOOLS_MCP_USER_CONFIG"];
|
||||
return {
|
||||
project: projectOverride
|
||||
? isAbsolute(projectOverride)
|
||||
? projectOverride
|
||||
: join(cwd, projectOverride)
|
||||
: join(cwd, ".codex", "lsp-client.json"),
|
||||
user: userOverride
|
||||
? isAbsolute(userOverride)
|
||||
? userOverride
|
||||
: join(homedir(), userOverride)
|
||||
: join(homedir(), ".codex", "lsp-client.json"),
|
||||
};
|
||||
}
|
||||
|
||||
function loadJsonFile(path: string): ConfigJson | null {
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
||||
return isConfigJson(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAllConfigs(): Map<ConfigSource, ConfigJson> {
|
||||
const paths = getConfigPaths();
|
||||
const configs = new Map<ConfigSource, ConfigJson>();
|
||||
|
||||
const project = loadJsonFile(paths.project);
|
||||
if (project) configs.set("project", project);
|
||||
|
||||
const user = loadJsonFile(paths.user);
|
||||
if (user) configs.set("user", user);
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
export function getMergedServers(): ServerWithSource[] {
|
||||
const configs = loadAllConfigs();
|
||||
const servers: ServerWithSource[] = [];
|
||||
const disabled = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
|
||||
const sources: ConfigSource[] = ["project", "user"];
|
||||
|
||||
for (const source of sources) {
|
||||
const config = configs.get(source);
|
||||
if (!config?.lsp) continue;
|
||||
|
||||
for (const [id, rawEntry] of Object.entries(config.lsp)) {
|
||||
const entry = parseLspEntry(rawEntry);
|
||||
if (!entry) continue;
|
||||
if (entry.disabled) {
|
||||
disabled.add(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(id)) continue;
|
||||
if (!entry.command || !entry.extensions) continue;
|
||||
|
||||
const server: ServerWithSource = {
|
||||
id,
|
||||
command: entry.command,
|
||||
extensions: entry.extensions,
|
||||
priority: entry.priority ?? 0,
|
||||
source,
|
||||
};
|
||||
if (entry.env !== undefined) {
|
||||
server.env = entry.env;
|
||||
}
|
||||
if (entry.initialization !== undefined) {
|
||||
server.initialization = entry.initialization;
|
||||
}
|
||||
servers.push(server);
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, config] of Object.entries(BUILTIN_SERVERS)) {
|
||||
if (disabled.has(id) || seen.has(id)) continue;
|
||||
|
||||
servers.push({
|
||||
id,
|
||||
command: config.command,
|
||||
extensions: config.extensions,
|
||||
priority: -100,
|
||||
source: "builtin",
|
||||
});
|
||||
}
|
||||
|
||||
return servers.sort((a, b) => {
|
||||
if (a.source !== b.source) {
|
||||
const order: Record<"project" | "user" | "builtin", number> = {
|
||||
project: 0,
|
||||
user: 1,
|
||||
builtin: 2,
|
||||
};
|
||||
return order[a.source] - order[b.source];
|
||||
}
|
||||
return b.priority - a.priority;
|
||||
});
|
||||
}
|
||||
|
||||
function isConfigJson(value: unknown): value is ConfigJson {
|
||||
if (!isRecord(value)) return false;
|
||||
const lsp = value["lsp"];
|
||||
return lsp === undefined || isRecord(lsp);
|
||||
}
|
||||
|
||||
function parseLspEntry(value: unknown): LspEntry | null {
|
||||
return isLspEntry(value) ? value : null;
|
||||
}
|
||||
|
||||
function isLspEntry(value: unknown): value is LspEntry {
|
||||
if (!isRecord(value)) return false;
|
||||
const disabled = value["disabled"];
|
||||
const command = value["command"];
|
||||
const extensions = value["extensions"];
|
||||
const priority = value["priority"];
|
||||
const env = value["env"];
|
||||
const initialization = value["initialization"];
|
||||
return (
|
||||
(disabled === undefined || typeof disabled === "boolean") &&
|
||||
(command === undefined || isStringArray(command)) &&
|
||||
(extensions === undefined || isStringArray(extensions)) &&
|
||||
(priority === undefined || typeof priority === "number") &&
|
||||
(env === undefined || isStringRecord(env)) &&
|
||||
(initialization === undefined || isRecord(initialization))
|
||||
);
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function getDisabledServerIds(): Set<string> {
|
||||
const configs = loadAllConfigs();
|
||||
const disabled = new Set<string>();
|
||||
|
||||
for (const config of configs.values()) {
|
||||
if (!config.lsp) continue;
|
||||
for (const [id, rawEntry] of Object.entries(config.lsp)) {
|
||||
const entry = parseLspEntry(rawEntry);
|
||||
if (!entry) continue;
|
||||
if (entry.disabled) disabled.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return disabled;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { LspClientTransport } from "./transport.js";
|
||||
|
||||
const INITIALIZE_SETTLE_MS = 300;
|
||||
|
||||
export class LspClientConnection extends LspClientTransport {
|
||||
async initialize(): Promise<void> {
|
||||
const rootUri = pathToFileURL(this.root).href;
|
||||
await this.sendRequest("initialize", {
|
||||
processId: process.pid,
|
||||
rootUri,
|
||||
rootPath: this.root,
|
||||
workspaceFolders: [{ uri: rootUri, name: "workspace" }],
|
||||
capabilities: {
|
||||
textDocument: {
|
||||
hover: { contentFormat: ["markdown", "plaintext"] },
|
||||
definition: { linkSupport: true },
|
||||
references: {},
|
||||
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
||||
publishDiagnostics: {},
|
||||
rename: {
|
||||
prepareSupport: true,
|
||||
prepareSupportDefaultBehavior: 1,
|
||||
honorsChangeAnnotations: true,
|
||||
},
|
||||
codeAction: {
|
||||
codeActionLiteralSupport: {
|
||||
codeActionKind: {
|
||||
valueSet: [
|
||||
"quickfix",
|
||||
"refactor",
|
||||
"refactor.extract",
|
||||
"refactor.inline",
|
||||
"refactor.rewrite",
|
||||
"source",
|
||||
"source.organizeImports",
|
||||
"source.fixAll",
|
||||
],
|
||||
},
|
||||
},
|
||||
isPreferredSupport: true,
|
||||
disabledSupport: true,
|
||||
dataSupport: true,
|
||||
resolveSupport: {
|
||||
properties: ["edit", "command"],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
symbol: {},
|
||||
workspaceFolders: true,
|
||||
configuration: true,
|
||||
applyEdit: true,
|
||||
workspaceEdit: {
|
||||
documentChanges: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initializationOptions: this.server.initialization,
|
||||
});
|
||||
await this.sendNotification("initialized");
|
||||
await this.sendNotification("workspace/didChangeConfiguration", {
|
||||
settings: { json: { validate: { enable: true } } },
|
||||
});
|
||||
// Some servers accept initialized before their diagnostics/indexing handlers are ready.
|
||||
await new Promise((r) => setTimeout(r, INITIALIZE_SETTLE_MS));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export const DEFAULT_MAX_REFERENCES = 200;
|
||||
export const DEFAULT_MAX_SYMBOLS = 200;
|
||||
export const DEFAULT_MAX_DIAGNOSTICS = 200;
|
||||
export const DEFAULT_MAX_DIRECTORY_FILES = 50;
|
||||
|
||||
export const REQUEST_TIMEOUT_MS = 15_000;
|
||||
export const INIT_TIMEOUT_MS = 60_000;
|
||||
export const IDLE_TIMEOUT_MS = 5 * 60_000;
|
||||
export const REAPER_INTERVAL_MS = 60_000;
|
||||
export const STOP_HARD_KILL_TIMEOUT_MS = 5_000;
|
||||
export const STOP_SIGKILL_GRACE_MS = 1_000;
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
import { existsSync, lstatSync, readdirSync, type Stats } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { findWorkspaceRoot, formatServerLookupError } from "./client-wrapper.js";
|
||||
import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_DIRECTORY_FILES } from "./constants.js";
|
||||
import { LspInvalidPathError, LspServerLookupError } from "./errors.js";
|
||||
import { filterDiagnosticsBySeverity, formatDiagnostic } from "./formatters.js";
|
||||
import { getLspManager } from "./manager.js";
|
||||
import { findServerForExtension } from "./server-resolution.js";
|
||||
import type { Diagnostic, SeverityFilter } from "./types.js";
|
||||
|
||||
const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"]);
|
||||
|
||||
interface FileDiagnostic {
|
||||
filePath: string;
|
||||
diagnostic: Diagnostic;
|
||||
}
|
||||
|
||||
export function collectFilesWithExtension(dir: string, extension: string, maxFiles: number): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
function walk(currentDir: string): void {
|
||||
if (files.length >= maxFiles) return;
|
||||
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = readdirSync(currentDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (files.length >= maxFiles) return;
|
||||
|
||||
const fullPath = join(currentDir, entry);
|
||||
|
||||
let stat: Stats | undefined;
|
||||
try {
|
||||
stat = lstatSync(fullPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!stat || stat.isSymbolicLink()) continue;
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (!SKIP_DIRECTORIES.has(entry)) {
|
||||
walk(fullPath);
|
||||
}
|
||||
} else if (stat.isFile() && extname(fullPath) === extension) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function aggregateDiagnosticsForDirectory(
|
||||
directory: string,
|
||||
extension: string,
|
||||
severity?: SeverityFilter,
|
||||
maxFiles: number = DEFAULT_MAX_DIRECTORY_FILES,
|
||||
): Promise<string> {
|
||||
if (!extension.startsWith(".")) {
|
||||
throw new LspInvalidPathError(
|
||||
`Extension must start with a dot (e.g., ".ts", not "${extension}"). Use ".${extension}" instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
const absDir = resolve(directory);
|
||||
if (!existsSync(absDir)) {
|
||||
throw new LspInvalidPathError(`Directory does not exist: ${absDir}`);
|
||||
}
|
||||
|
||||
const serverResult = findServerForExtension(extension);
|
||||
if (serverResult.status !== "found") {
|
||||
throw new LspServerLookupError(formatServerLookupError(serverResult));
|
||||
}
|
||||
|
||||
const server = serverResult.server;
|
||||
const allFiles = collectFilesWithExtension(absDir, extension, maxFiles + 1);
|
||||
const wasCapped = allFiles.length > maxFiles;
|
||||
const filesToProcess = allFiles.slice(0, maxFiles);
|
||||
|
||||
if (filesToProcess.length === 0) {
|
||||
return [
|
||||
`Directory: ${absDir}`,
|
||||
`Extension: ${extension}`,
|
||||
"Files scanned: 0",
|
||||
`No files found with extension "${extension}".`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const root = findWorkspaceRoot(absDir);
|
||||
const manager = getLspManager();
|
||||
const allDiagnostics: FileDiagnostic[] = [];
|
||||
const fileErrors: { file: string; error: string }[] = [];
|
||||
|
||||
const client = await manager.getClient(root, server);
|
||||
try {
|
||||
for (const file of filesToProcess) {
|
||||
try {
|
||||
const result = await client.diagnostics(file);
|
||||
const filtered = filterDiagnosticsBySeverity(result.items, severity);
|
||||
allDiagnostics.push(
|
||||
...filtered.map((diagnostic) => ({
|
||||
filePath: file,
|
||||
diagnostic,
|
||||
})),
|
||||
);
|
||||
} catch (e) {
|
||||
fileErrors.push({
|
||||
file,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
manager.releaseClient(root, server.id);
|
||||
}
|
||||
|
||||
const displayDiagnostics = allDiagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS);
|
||||
const wasDiagCapped = allDiagnostics.length > DEFAULT_MAX_DIAGNOSTICS;
|
||||
|
||||
const lines: string[] = [
|
||||
`Directory: ${absDir}`,
|
||||
`Extension: ${extension}`,
|
||||
`Files scanned: ${filesToProcess.length}${wasCapped ? ` (capped at ${maxFiles})` : ""}`,
|
||||
`Files with errors: ${fileErrors.length}`,
|
||||
`Total diagnostics: ${allDiagnostics.length}`,
|
||||
];
|
||||
|
||||
if (fileErrors.length > 0) {
|
||||
lines.push("", "File processing errors:");
|
||||
for (const { file, error } of fileErrors) {
|
||||
lines.push(` ${file}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (displayDiagnostics.length > 0) {
|
||||
lines.push("");
|
||||
for (const { filePath, diagnostic } of displayDiagnostics) {
|
||||
lines.push(`${filePath}: ${formatDiagnostic(diagnostic)}`);
|
||||
}
|
||||
if (wasDiagCapped) {
|
||||
lines.push("", `... (${allDiagnostics.length - DEFAULT_MAX_DIAGNOSTICS} more diagnostics not shown)`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
export class LspConnectionClosedError extends Error {
|
||||
override readonly name = "LspConnectionClosedError";
|
||||
|
||||
constructor(
|
||||
readonly serverId: string,
|
||||
readonly root: string,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? `LSP connection closed for ${serverId} at ${root}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class LspProcessExitedError extends Error {
|
||||
override readonly name = "LspProcessExitedError";
|
||||
|
||||
constructor(
|
||||
readonly serverId: string,
|
||||
readonly root: string,
|
||||
readonly exitCode: number | null,
|
||||
readonly stderrTail?: string,
|
||||
) {
|
||||
const stderrSuffix = stderrTail ? `\nstderr tail: ${stderrTail}` : "";
|
||||
super(`LSP server ${serverId} at ${root} exited with code ${exitCode ?? "null"}${stderrSuffix}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class LspRequestTimeoutError extends Error {
|
||||
override readonly name = "LspRequestTimeoutError";
|
||||
|
||||
constructor(
|
||||
readonly method: string,
|
||||
readonly stderrTail?: string,
|
||||
) {
|
||||
const stderrSuffix = stderrTail ? `\nrecent stderr: ${stderrTail}` : "";
|
||||
super(`LSP request timeout (method: ${method})${stderrSuffix}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class LspInvalidPathError extends Error {
|
||||
override readonly name = "LspInvalidPathError";
|
||||
}
|
||||
|
||||
export class LspServerLookupError extends Error {
|
||||
override readonly name = "LspServerLookupError";
|
||||
}
|
||||
|
||||
export class LspServerInitializingError extends Error {
|
||||
override readonly name = "LspServerInitializingError";
|
||||
|
||||
constructor(readonly originalError: LspRequestTimeoutError) {
|
||||
super(
|
||||
`LSP server is still initializing. Please retry in a few seconds. Original error: ${originalError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class LspProcessSpawnError extends Error {
|
||||
override readonly name = "LspProcessSpawnError";
|
||||
}
|
||||
|
||||
export function isLspDeadConnectionError(err: unknown): err is LspConnectionClosedError | LspProcessExitedError {
|
||||
return err instanceof LspConnectionClosedError || err instanceof LspProcessExitedError;
|
||||
}
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { SEVERITY_MAP, SYMBOL_KIND_MAP } from "./language-mappings.js";
|
||||
import type {
|
||||
Diagnostic,
|
||||
DocumentSymbol,
|
||||
Location,
|
||||
LocationLink,
|
||||
PrepareRenameDefaultBehavior,
|
||||
PrepareRenameResult,
|
||||
Range,
|
||||
SeverityFilter,
|
||||
SymbolInfo,
|
||||
} from "./types.js";
|
||||
import type { ApplyResult } from "./workspace-edit.js";
|
||||
|
||||
type FilteredSeverity = Exclude<SeverityFilter, "all">;
|
||||
|
||||
const DIAGNOSTIC_SEVERITY_FILTERS = {
|
||||
error: 1,
|
||||
warning: 2,
|
||||
information: 3,
|
||||
hint: 4,
|
||||
} as const satisfies Readonly<Record<FilteredSeverity, number>>;
|
||||
|
||||
export function uriToPath(uri: string): string {
|
||||
return fileURLToPath(uri);
|
||||
}
|
||||
|
||||
export function formatLocation(loc: Location | LocationLink): string {
|
||||
if ("targetUri" in loc) {
|
||||
const uri = uriToPath(loc.targetUri);
|
||||
const line = loc.targetRange.start.line + 1;
|
||||
const char = loc.targetRange.start.character;
|
||||
return `${uri}:${line}:${char}`;
|
||||
}
|
||||
|
||||
const uri = uriToPath(loc.uri);
|
||||
const line = loc.range.start.line + 1;
|
||||
const char = loc.range.start.character;
|
||||
return `${uri}:${line}:${char}`;
|
||||
}
|
||||
|
||||
export function formatSymbolKind(kind: number): string {
|
||||
return SYMBOL_KIND_MAP[kind] ?? `Unknown(${kind})`;
|
||||
}
|
||||
|
||||
export function formatSeverity(severity: number | undefined): string {
|
||||
if (!severity) return "unknown";
|
||||
return SEVERITY_MAP[severity] ?? `unknown(${severity})`;
|
||||
}
|
||||
|
||||
export function formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string {
|
||||
const prefix = " ".repeat(indent);
|
||||
const kind = formatSymbolKind(symbol.kind);
|
||||
const line = symbol.range.start.line + 1;
|
||||
let result = `${prefix}${symbol.name} (${kind}) - line ${line}`;
|
||||
|
||||
if (symbol.children && symbol.children.length > 0) {
|
||||
for (const child of symbol.children) {
|
||||
result += `\n${formatDocumentSymbol(child, indent + 1)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatSymbolInfo(symbol: SymbolInfo): string {
|
||||
const kind = formatSymbolKind(symbol.kind);
|
||||
const loc = formatLocation(symbol.location);
|
||||
const container = symbol.containerName ? ` (in ${symbol.containerName})` : "";
|
||||
return `${symbol.name} (${kind})${container} - ${loc}`;
|
||||
}
|
||||
|
||||
export function formatDiagnostic(diag: Diagnostic): string {
|
||||
const severity = formatSeverity(diag.severity);
|
||||
const line = diag.range.start.line + 1;
|
||||
const char = diag.range.start.character;
|
||||
const source = diag.source ? `[${diag.source}]` : "";
|
||||
const code = diag.code ? ` (${diag.code})` : "";
|
||||
return `${severity}${source}${code} at ${line}:${char}: ${diag.message}`;
|
||||
}
|
||||
|
||||
export function filterDiagnosticsBySeverity(diagnostics: Diagnostic[], severityFilter?: SeverityFilter): Diagnostic[] {
|
||||
if (!severityFilter || severityFilter === "all") {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
const targetSeverity = DIAGNOSTIC_SEVERITY_FILTERS[severityFilter];
|
||||
return diagnostics.filter((d) => d.severity === targetSeverity);
|
||||
}
|
||||
|
||||
export function formatPrepareRenameResult(
|
||||
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null,
|
||||
): string {
|
||||
if (!result) return "Cannot rename at this position";
|
||||
|
||||
if ("defaultBehavior" in result) {
|
||||
return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position";
|
||||
}
|
||||
|
||||
if ("range" in result && result.range) {
|
||||
const startLine = result.range.start.line + 1;
|
||||
const startChar = result.range.start.character;
|
||||
const endLine = result.range.end.line + 1;
|
||||
const endChar = result.range.end.character;
|
||||
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : "";
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`;
|
||||
}
|
||||
|
||||
if ("start" in result && "end" in result) {
|
||||
const startLine = result.start.line + 1;
|
||||
const startChar = result.start.character;
|
||||
const endLine = result.end.line + 1;
|
||||
const endChar = result.end.character;
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`;
|
||||
}
|
||||
|
||||
return "Cannot rename at this position";
|
||||
}
|
||||
|
||||
export function formatApplyResult(result: ApplyResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (result.success) {
|
||||
lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`);
|
||||
for (const file of result.filesModified) {
|
||||
lines.push(` - ${file}`);
|
||||
}
|
||||
} else {
|
||||
lines.push("Failed to apply some changes:");
|
||||
for (const err of result.errors) {
|
||||
lines.push(` Error: ${err}`);
|
||||
}
|
||||
if (result.filesModified.length > 0) {
|
||||
lines.push(`Successfully modified: ${result.filesModified.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { lstatSync, readdirSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
|
||||
import { EXT_TO_LANG } from "./language-mappings.js";
|
||||
|
||||
const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"]);
|
||||
const MAX_SCAN_ENTRIES = 500;
|
||||
|
||||
export function inferExtensionFromDirectory(directory: string): string | null {
|
||||
const extensionCounts = new Map<string, number>();
|
||||
let scanned = 0;
|
||||
|
||||
function walk(dir: string): void {
|
||||
if (scanned >= MAX_SCAN_ENTRIES) return;
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (scanned >= MAX_SCAN_ENTRIES) return;
|
||||
|
||||
const fullPath = join(dir, entry);
|
||||
|
||||
let stat: ReturnType<typeof lstatSync> | undefined;
|
||||
try {
|
||||
stat = lstatSync(fullPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) continue;
|
||||
scanned++;
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (!SKIP_DIRECTORIES.has(entry)) {
|
||||
walk(fullPath);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
const ext = extname(fullPath);
|
||||
if (ext && ext in EXT_TO_LANG) {
|
||||
extensionCounts.set(ext, (extensionCounts.get(ext) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(directory);
|
||||
|
||||
if (extensionCounts.size === 0) return null;
|
||||
|
||||
let maxExt = "";
|
||||
let maxCount = 0;
|
||||
for (const [ext, count] of extensionCounts) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
maxExt = ext;
|
||||
}
|
||||
}
|
||||
|
||||
return maxExt || null;
|
||||
}
|
||||
-296
@@ -1,296 +0,0 @@
|
||||
type JsonRpcId = number | string | null;
|
||||
|
||||
interface PendingRequest {
|
||||
resolve(result: unknown): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
type NotificationHandler = (params: unknown) => void;
|
||||
type RequestHandler = (params: unknown) => Promise<unknown> | unknown;
|
||||
|
||||
const HEADER_SEPARATOR = "\r\n\r\n";
|
||||
const PARSE_ERROR = -32700;
|
||||
const INVALID_REQUEST = -32600;
|
||||
const METHOD_NOT_FOUND = -32601;
|
||||
const INTERNAL_ERROR = -32603;
|
||||
|
||||
export class JsonRpcConnection {
|
||||
private readonly pendingRequests = new Map<string, PendingRequest>();
|
||||
private readonly notificationHandlers = new Map<string, NotificationHandler>();
|
||||
private readonly requestHandlers = new Map<string, RequestHandler>();
|
||||
private readonly closeHandlers: Array<() => void> = [];
|
||||
private readonly errorHandlers: Array<(error: Error) => void> = [];
|
||||
private inputBuffer = Buffer.alloc(0);
|
||||
private nextRequestId = 1;
|
||||
private listening = false;
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly reader: NodeJS.ReadableStream,
|
||||
private readonly writer: NodeJS.WritableStream,
|
||||
) {}
|
||||
|
||||
listen(): void {
|
||||
if (this.listening) return;
|
||||
this.listening = true;
|
||||
this.reader.on("data", this.handleData);
|
||||
this.reader.on("close", this.handleClose);
|
||||
this.reader.on("end", this.handleClose);
|
||||
this.reader.on("error", this.handleStreamError);
|
||||
this.writer.on("error", this.handleStreamError);
|
||||
}
|
||||
|
||||
onNotification(method: string, handler: NotificationHandler): void {
|
||||
this.notificationHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
onRequest(method: string, handler: RequestHandler): void {
|
||||
this.requestHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
onClose(handler: () => void): void {
|
||||
this.closeHandlers.push(handler);
|
||||
}
|
||||
|
||||
onError(handler: (error: Error) => void): void {
|
||||
this.errorHandlers.push(handler);
|
||||
}
|
||||
|
||||
async sendRequest<T>(method: string, params?: unknown): Promise<T> {
|
||||
if (this.disposed) throw new Error("JSON-RPC connection is disposed");
|
||||
|
||||
const id = this.nextRequestId;
|
||||
this.nextRequestId += 1;
|
||||
const message = params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params };
|
||||
|
||||
const responsePromise = new Promise<T>((resolve, reject) => {
|
||||
this.pendingRequests.set(String(id), {
|
||||
resolve(result) {
|
||||
resolve(result as T);
|
||||
},
|
||||
reject,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await this.writeMessage(message);
|
||||
} catch (error) {
|
||||
this.pendingRequests.delete(String(id));
|
||||
throw error;
|
||||
}
|
||||
|
||||
return responsePromise;
|
||||
}
|
||||
|
||||
async sendNotification(method: string, params?: unknown): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
const message = params === undefined ? { jsonrpc: "2.0", method } : { jsonrpc: "2.0", method, params };
|
||||
await this.writeMessage(message);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.reader.off("data", this.handleData);
|
||||
this.reader.off("close", this.handleClose);
|
||||
this.reader.off("end", this.handleClose);
|
||||
this.reader.off("error", this.handleStreamError);
|
||||
this.writer.off("error", this.handleStreamError);
|
||||
for (const pending of this.pendingRequests.values()) {
|
||||
pending.reject(new Error("JSON-RPC connection disposed"));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
this.notificationHandlers.clear();
|
||||
this.requestHandlers.clear();
|
||||
}
|
||||
|
||||
private readonly handleData = (chunk: Buffer | string): void => {
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
|
||||
this.inputBuffer = Buffer.concat([this.inputBuffer, chunkBuffer]);
|
||||
this.drainInputBuffer();
|
||||
};
|
||||
|
||||
private readonly handleClose = (): void => {
|
||||
for (const handler of this.closeHandlers) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly handleStreamError = (error: Error): void => {
|
||||
this.emitError(error);
|
||||
};
|
||||
|
||||
private drainInputBuffer(): void {
|
||||
while (true) {
|
||||
const headerEnd = this.inputBuffer.indexOf(HEADER_SEPARATOR);
|
||||
if (headerEnd === -1) return;
|
||||
|
||||
const headers = this.inputBuffer.subarray(0, headerEnd).toString("ascii");
|
||||
const contentLength = parseContentLength(headers);
|
||||
if (contentLength === null) {
|
||||
this.inputBuffer = Buffer.alloc(0);
|
||||
this.emitError(new Error("JSON-RPC message is missing Content-Length header"));
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyStart = headerEnd + Buffer.byteLength(HEADER_SEPARATOR);
|
||||
const bodyEnd = bodyStart + contentLength;
|
||||
if (this.inputBuffer.length < bodyEnd) return;
|
||||
|
||||
const body = this.inputBuffer.subarray(bodyStart, bodyEnd).toString("utf8");
|
||||
this.inputBuffer = this.inputBuffer.subarray(bodyEnd);
|
||||
this.dispatchBody(body);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchBody(body: string): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch (error) {
|
||||
void this.writeError(null, PARSE_ERROR, error instanceof Error ? error.message : "Parse error").catch(
|
||||
(writeError) => this.emitError(toError(writeError)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isJsonRpcObject(parsed)) {
|
||||
void this.writeError(null, INVALID_REQUEST, "Invalid JSON-RPC message").catch((error) =>
|
||||
this.emitError(toError(error)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("id" in parsed && ("result" in parsed || "error" in parsed)) {
|
||||
this.handleResponse(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parsed["method"] !== "string") {
|
||||
const id = getMessageId(parsed) ?? null;
|
||||
void this.writeError(id, INVALID_REQUEST, "Invalid JSON-RPC method").catch((error) =>
|
||||
this.emitError(toError(error)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("id" in parsed) {
|
||||
this.handleRequest(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleNotification(parsed["method"], parsed["params"]);
|
||||
}
|
||||
|
||||
private handleResponse(message: Record<string, unknown>): void {
|
||||
const id = getMessageId(message);
|
||||
if (id === undefined) return;
|
||||
const pending = this.pendingRequests.get(String(id));
|
||||
if (!pending) return;
|
||||
this.pendingRequests.delete(String(id));
|
||||
|
||||
if ("error" in message) {
|
||||
pending.reject(jsonRpcErrorToError(message["error"]));
|
||||
return;
|
||||
}
|
||||
|
||||
pending.resolve(message["result"]);
|
||||
}
|
||||
|
||||
private handleNotification(method: string, params: unknown): void {
|
||||
const handler = this.notificationHandlers.get(method);
|
||||
if (!handler) return;
|
||||
try {
|
||||
handler(params);
|
||||
} catch (error) {
|
||||
this.emitError(toError(error));
|
||||
}
|
||||
}
|
||||
|
||||
private handleRequest(message: Record<string, unknown>): void {
|
||||
const id = getMessageId(message);
|
||||
if (id === undefined) {
|
||||
void this.writeError(null, INVALID_REQUEST, "Invalid JSON-RPC id").catch((error) =>
|
||||
this.emitError(toError(error)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const method = typeof message["method"] === "string" ? message["method"] : "";
|
||||
const handler = this.requestHandlers.get(method);
|
||||
if (!handler) {
|
||||
void this.writeError(id, METHOD_NOT_FOUND, `Method not found: ${method}`).catch((error) =>
|
||||
this.emitError(toError(error)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => handler(message["params"]))
|
||||
.then(
|
||||
(result) => this.writeMessage({ jsonrpc: "2.0", id, result }),
|
||||
(error) => this.writeError(id, INTERNAL_ERROR, toError(error).message),
|
||||
)
|
||||
.catch((error) => this.emitError(toError(error)));
|
||||
}
|
||||
|
||||
private async writeError(id: JsonRpcId, code: number, message: string): Promise<void> {
|
||||
await this.writeMessage({ jsonrpc: "2.0", id, error: { code, message } });
|
||||
}
|
||||
|
||||
private writeMessage(message: Record<string, unknown>): Promise<void> {
|
||||
const body = JSON.stringify(message);
|
||||
const payload = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.writer.write(payload, (error?: Error | null) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private emitError(error: Error): void {
|
||||
for (const handler of this.errorHandlers) {
|
||||
handler(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentLength(headers: string): number | null {
|
||||
for (const line of headers.split("\r\n")) {
|
||||
const separatorIndex = line.indexOf(":");
|
||||
if (separatorIndex === -1) continue;
|
||||
const name = line.slice(0, separatorIndex).trim().toLowerCase();
|
||||
if (name !== "content-length") continue;
|
||||
const value = Number.parseInt(line.slice(separatorIndex + 1).trim(), 10);
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isJsonRpcObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getMessageId(message: Record<string, unknown>): JsonRpcId | undefined {
|
||||
const id = message["id"];
|
||||
if (typeof id === "number" || typeof id === "string" || id === null) return id;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function jsonRpcErrorToError(value: unknown): Error {
|
||||
if (!isJsonRpcObject(value)) return new Error("JSON-RPC request failed");
|
||||
const message = typeof value["message"] === "string" ? value["message"] : "JSON-RPC request failed";
|
||||
const error = new Error(message);
|
||||
if (typeof value["code"] === "number") {
|
||||
error.name = `JsonRpcError(${value["code"]})`;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
export const SYMBOL_KIND_MAP: Record<number, string> = {
|
||||
1: "File",
|
||||
2: "Module",
|
||||
3: "Namespace",
|
||||
4: "Package",
|
||||
5: "Class",
|
||||
6: "Method",
|
||||
7: "Property",
|
||||
8: "Field",
|
||||
9: "Constructor",
|
||||
10: "Enum",
|
||||
11: "Interface",
|
||||
12: "Function",
|
||||
13: "Variable",
|
||||
14: "Constant",
|
||||
15: "String",
|
||||
16: "Number",
|
||||
17: "Boolean",
|
||||
18: "Array",
|
||||
19: "Object",
|
||||
20: "Key",
|
||||
21: "Null",
|
||||
22: "EnumMember",
|
||||
23: "Struct",
|
||||
24: "Event",
|
||||
25: "Operator",
|
||||
26: "TypeParameter",
|
||||
};
|
||||
|
||||
export const SEVERITY_MAP: Record<number, string> = {
|
||||
1: "error",
|
||||
2: "warning",
|
||||
3: "information",
|
||||
4: "hint",
|
||||
};
|
||||
|
||||
export const EXT_TO_LANG: Record<string, string> = {
|
||||
".abap": "abap",
|
||||
".bat": "bat",
|
||||
".bib": "bibtex",
|
||||
".bibtex": "bibtex",
|
||||
".clj": "clojure",
|
||||
".cljs": "clojure",
|
||||
".cljc": "clojure",
|
||||
".edn": "clojure",
|
||||
".coffee": "coffeescript",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".cxx": "cpp",
|
||||
".cc": "cpp",
|
||||
".c++": "cpp",
|
||||
".cs": "csharp",
|
||||
".css": "css",
|
||||
".d": "d",
|
||||
".pas": "pascal",
|
||||
".pascal": "pascal",
|
||||
".diff": "diff",
|
||||
".patch": "diff",
|
||||
".dart": "dart",
|
||||
".dockerfile": "dockerfile",
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
".erl": "erlang",
|
||||
".hrl": "erlang",
|
||||
".fs": "fsharp",
|
||||
".fsi": "fsharp",
|
||||
".fsx": "fsharp",
|
||||
".fsscript": "fsharp",
|
||||
".gitcommit": "git-commit",
|
||||
".gitrebase": "git-rebase",
|
||||
".go": "go",
|
||||
".groovy": "groovy",
|
||||
".gleam": "gleam",
|
||||
".hbs": "handlebars",
|
||||
".handlebars": "handlebars",
|
||||
".hs": "haskell",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".ini": "ini",
|
||||
".java": "java",
|
||||
".js": "javascript",
|
||||
".jsx": "javascriptreact",
|
||||
".json": "json",
|
||||
".jsonc": "jsonc",
|
||||
".tex": "latex",
|
||||
".latex": "latex",
|
||||
".less": "less",
|
||||
".lua": "lua",
|
||||
".makefile": "makefile",
|
||||
makefile: "makefile",
|
||||
".md": "markdown",
|
||||
".markdown": "markdown",
|
||||
".m": "objective-c",
|
||||
".mm": "objective-cpp",
|
||||
".pl": "perl",
|
||||
".pm": "perl",
|
||||
".pm6": "perl6",
|
||||
".php": "php",
|
||||
".ps1": "powershell",
|
||||
".psm1": "powershell",
|
||||
".pug": "jade",
|
||||
".jade": "jade",
|
||||
".py": "python",
|
||||
".pyi": "python",
|
||||
".r": "r",
|
||||
".cshtml": "razor",
|
||||
".razor": "razor",
|
||||
".rb": "ruby",
|
||||
".rake": "ruby",
|
||||
".gemspec": "ruby",
|
||||
".ru": "ruby",
|
||||
".erb": "erb",
|
||||
".html.erb": "erb",
|
||||
".js.erb": "erb",
|
||||
".css.erb": "erb",
|
||||
".json.erb": "erb",
|
||||
".rs": "rust",
|
||||
".scss": "scss",
|
||||
".sass": "sass",
|
||||
".scala": "scala",
|
||||
".shader": "shaderlab",
|
||||
".sh": "shellscript",
|
||||
".bash": "shellscript",
|
||||
".zsh": "shellscript",
|
||||
".ksh": "shellscript",
|
||||
".sql": "sql",
|
||||
".svelte": "svelte",
|
||||
".swift": "swift",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
".mtsx": "typescriptreact",
|
||||
".ctsx": "typescriptreact",
|
||||
".xml": "xml",
|
||||
".xsl": "xsl",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".vue": "vue",
|
||||
".zig": "zig",
|
||||
".zon": "zig",
|
||||
".astro": "astro",
|
||||
".ml": "ocaml",
|
||||
".mli": "ocaml",
|
||||
".tf": "terraform",
|
||||
".tfvars": "terraform-vars",
|
||||
".hcl": "hcl",
|
||||
".nix": "nix",
|
||||
".typ": "typst",
|
||||
".typc": "typst",
|
||||
".ets": "typescript",
|
||||
".lhs": "haskell",
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin",
|
||||
".prisma": "prisma",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
".hh": "cpp",
|
||||
".hxx": "cpp",
|
||||
".h++": "cpp",
|
||||
".objc": "objective-c",
|
||||
".objcpp": "objective-cpp",
|
||||
".fish": "fish",
|
||||
".graphql": "graphql",
|
||||
".gql": "graphql",
|
||||
};
|
||||
|
||||
export function getLanguageId(ext: string): string {
|
||||
return EXT_TO_LANG[ext] ?? "plaintext";
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
import { reportBestEffortCleanupError } from "./cleanup-errors.js";
|
||||
import { LspClient } from "./client.js";
|
||||
import { IDLE_TIMEOUT_MS, INIT_TIMEOUT_MS, REAPER_INTERVAL_MS } from "./constants.js";
|
||||
import { installProcessSignalCleanup } from "./process-signal-cleanup.js";
|
||||
import type { ResolvedServer } from "./types.js";
|
||||
|
||||
interface ManagedClient {
|
||||
client: LspClient;
|
||||
refCount: number;
|
||||
pendingWaiters: number;
|
||||
lastUsedAt: number;
|
||||
initPromise: Promise<void> | null;
|
||||
isInitializing: boolean;
|
||||
initializingSince: number | null;
|
||||
}
|
||||
|
||||
export interface ClientSnapshot {
|
||||
root: string;
|
||||
serverId: string;
|
||||
refCount: number;
|
||||
pendingWaiters: number;
|
||||
lastUsedAt: number;
|
||||
isInitializing: boolean;
|
||||
alive: boolean;
|
||||
command: string[];
|
||||
}
|
||||
|
||||
export interface LspManagerOptions {
|
||||
idleTimeoutMs?: number;
|
||||
initTimeoutMs?: number;
|
||||
reaperIntervalMs?: number;
|
||||
clientFactory?: (root: string, server: ResolvedServer) => LspClient;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
async function stopClientBestEffort(client: LspClient): Promise<void> {
|
||||
try {
|
||||
await client.stop();
|
||||
} catch (error) {
|
||||
reportBestEffortCleanupError("client stop", error);
|
||||
}
|
||||
}
|
||||
|
||||
function awaitWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(err);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export class LspManager {
|
||||
private readonly clients = new Map<string, ManagedClient>();
|
||||
private reaperHandle: NodeJS.Timeout | null = null;
|
||||
private signalDisposer: (() => void) | null = null;
|
||||
private disposed = false;
|
||||
|
||||
private readonly idleTimeoutMs: number;
|
||||
private readonly initTimeoutMs: number;
|
||||
private readonly reaperIntervalMs: number;
|
||||
private readonly clientFactory: (root: string, server: ResolvedServer) => LspClient;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: LspManagerOptions = {}) {
|
||||
this.idleTimeoutMs = options.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
|
||||
this.initTimeoutMs = options.initTimeoutMs ?? INIT_TIMEOUT_MS;
|
||||
this.reaperIntervalMs = options.reaperIntervalMs ?? REAPER_INTERVAL_MS;
|
||||
this.clientFactory = options.clientFactory ?? ((root, server) => new LspClient(root, server));
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
|
||||
this.startReaper();
|
||||
this.signalDisposer = installProcessSignalCleanup(() => this.stopAll());
|
||||
}
|
||||
|
||||
private startReaper(): void {
|
||||
if (this.reaperHandle) return;
|
||||
this.reaperHandle = setInterval(() => {
|
||||
this.reapStale();
|
||||
}, this.reaperIntervalMs);
|
||||
if (typeof this.reaperHandle.unref === "function") {
|
||||
this.reaperHandle.unref();
|
||||
}
|
||||
}
|
||||
|
||||
private getKey(root: string, serverId: string): string {
|
||||
return `${root}::${serverId}`;
|
||||
}
|
||||
|
||||
private reapStale(): void {
|
||||
const t = this.now();
|
||||
for (const [key, managed] of this.clients) {
|
||||
if (
|
||||
managed.isInitializing &&
|
||||
managed.initializingSince !== null &&
|
||||
t - managed.initializingSince > this.initTimeoutMs
|
||||
) {
|
||||
void stopClientBestEffort(managed.client);
|
||||
this.clients.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!managed.isInitializing &&
|
||||
managed.refCount === 0 &&
|
||||
managed.pendingWaiters === 0 &&
|
||||
t - managed.lastUsedAt > this.idleTimeoutMs
|
||||
) {
|
||||
void stopClientBestEffort(managed.client);
|
||||
this.clients.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async tryDeleteIfOrphaned(key: string, managed: ManagedClient): Promise<void> {
|
||||
if (
|
||||
managed.refCount === 0 &&
|
||||
managed.pendingWaiters === 0 &&
|
||||
!managed.isInitializing &&
|
||||
this.clients.get(key) === managed
|
||||
) {
|
||||
this.clients.delete(key);
|
||||
await stopClientBestEffort(managed.client);
|
||||
}
|
||||
}
|
||||
|
||||
async getClient(root: string, server: ResolvedServer, signal?: AbortSignal): Promise<LspClient> {
|
||||
if (this.disposed) {
|
||||
throw new Error("LspManager has been disposed");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const key = this.getKey(root, server.id);
|
||||
let managed = this.clients.get(key);
|
||||
|
||||
if (managed) {
|
||||
const t = this.now();
|
||||
if (
|
||||
managed.isInitializing &&
|
||||
managed.initializingSince !== null &&
|
||||
t - managed.initializingSince > this.initTimeoutMs
|
||||
) {
|
||||
await stopClientBestEffort(managed.client);
|
||||
this.clients.delete(key);
|
||||
managed = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (managed) {
|
||||
if (managed.initPromise) {
|
||||
managed.pendingWaiters++;
|
||||
try {
|
||||
await awaitWithSignal(managed.initPromise, signal);
|
||||
} catch (err) {
|
||||
managed.pendingWaiters--;
|
||||
await this.tryDeleteIfOrphaned(key, managed);
|
||||
throw err;
|
||||
}
|
||||
managed.pendingWaiters--;
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
await this.tryDeleteIfOrphaned(key, managed);
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
|
||||
if (!managed.client.isAlive()) {
|
||||
await stopClientBestEffort(managed.client);
|
||||
this.clients.delete(key);
|
||||
return this.getClient(root, server, signal);
|
||||
}
|
||||
|
||||
managed.refCount++;
|
||||
managed.lastUsedAt = this.now();
|
||||
return managed.client;
|
||||
}
|
||||
|
||||
const client = this.clientFactory(root, server);
|
||||
const initStartedAt = this.now();
|
||||
const initPromise = (async () => {
|
||||
await client.start();
|
||||
await client.initialize();
|
||||
})();
|
||||
|
||||
const newManaged: ManagedClient = {
|
||||
client,
|
||||
refCount: 0,
|
||||
pendingWaiters: 1,
|
||||
lastUsedAt: initStartedAt,
|
||||
initPromise,
|
||||
isInitializing: true,
|
||||
initializingSince: initStartedAt,
|
||||
};
|
||||
this.clients.set(key, newManaged);
|
||||
|
||||
try {
|
||||
await awaitWithSignal(initPromise, signal);
|
||||
} catch (err) {
|
||||
newManaged.pendingWaiters--;
|
||||
if (this.clients.get(key) === newManaged) {
|
||||
this.clients.delete(key);
|
||||
}
|
||||
await stopClientBestEffort(client);
|
||||
throw err;
|
||||
}
|
||||
|
||||
newManaged.pendingWaiters--;
|
||||
newManaged.isInitializing = false;
|
||||
newManaged.initializingSince = null;
|
||||
newManaged.initPromise = null;
|
||||
|
||||
if (signal?.aborted) {
|
||||
await this.tryDeleteIfOrphaned(key, newManaged);
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
|
||||
newManaged.refCount++;
|
||||
newManaged.lastUsedAt = this.now();
|
||||
return client;
|
||||
}
|
||||
|
||||
releaseClient(root: string, serverId: string): void {
|
||||
const key = this.getKey(root, serverId);
|
||||
const managed = this.clients.get(key);
|
||||
if (managed && managed.refCount > 0) {
|
||||
managed.refCount--;
|
||||
managed.lastUsedAt = this.now();
|
||||
}
|
||||
}
|
||||
|
||||
invalidateClient(root: string, serverId: string, client?: LspClient): void {
|
||||
const key = this.getKey(root, serverId);
|
||||
const managed = this.clients.get(key);
|
||||
if (!managed) return;
|
||||
if (client && managed.client !== client) return;
|
||||
this.clients.delete(key);
|
||||
void stopClientBestEffort(managed.client);
|
||||
}
|
||||
|
||||
warmupClient(root: string, server: ResolvedServer): void {
|
||||
if (this.disposed) return;
|
||||
const key = this.getKey(root, server.id);
|
||||
if (this.clients.has(key)) return;
|
||||
|
||||
const client = this.clientFactory(root, server);
|
||||
const initStartedAt = this.now();
|
||||
const initPromise = (async () => {
|
||||
await client.start();
|
||||
await client.initialize();
|
||||
})();
|
||||
|
||||
const managed: ManagedClient = {
|
||||
client,
|
||||
refCount: 0,
|
||||
pendingWaiters: 0,
|
||||
lastUsedAt: initStartedAt,
|
||||
initPromise,
|
||||
isInitializing: true,
|
||||
initializingSince: initStartedAt,
|
||||
};
|
||||
this.clients.set(key, managed);
|
||||
|
||||
initPromise.then(
|
||||
() => {
|
||||
managed.isInitializing = false;
|
||||
managed.initializingSince = null;
|
||||
managed.initPromise = null;
|
||||
managed.lastUsedAt = this.now();
|
||||
},
|
||||
() => {
|
||||
if (this.clients.get(key) === managed) {
|
||||
this.clients.delete(key);
|
||||
}
|
||||
void stopClientBestEffort(client);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
isServerInitializing(root: string, serverId: string): boolean {
|
||||
const managed = this.clients.get(this.getKey(root, serverId));
|
||||
return managed?.isInitializing ?? false;
|
||||
}
|
||||
|
||||
getSnapshot(): ClientSnapshot[] {
|
||||
const snapshots: ClientSnapshot[] = [];
|
||||
for (const [key, managed] of this.clients) {
|
||||
const [root, serverId] = key.split("::") as [string, string];
|
||||
snapshots.push({
|
||||
root,
|
||||
serverId,
|
||||
refCount: managed.refCount,
|
||||
pendingWaiters: managed.pendingWaiters,
|
||||
lastUsedAt: managed.lastUsedAt,
|
||||
isInitializing: managed.isInitializing,
|
||||
alive: managed.client.isAlive(),
|
||||
command: managed.client.command(),
|
||||
});
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
hasClient(root: string, serverId: string): boolean {
|
||||
return this.clients.has(this.getKey(root, serverId));
|
||||
}
|
||||
|
||||
clientCount(): number {
|
||||
return this.clients.size;
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
this.disposed = true;
|
||||
|
||||
if (this.reaperHandle) {
|
||||
clearInterval(this.reaperHandle);
|
||||
this.reaperHandle = null;
|
||||
}
|
||||
|
||||
if (this.signalDisposer) {
|
||||
this.signalDisposer();
|
||||
this.signalDisposer = null;
|
||||
}
|
||||
|
||||
const stopPromises: Promise<void>[] = [];
|
||||
for (const managed of this.clients.values()) {
|
||||
stopPromises.push(stopClientBestEffort(managed.client));
|
||||
}
|
||||
this.clients.clear();
|
||||
await Promise.allSettled(stopPromises);
|
||||
}
|
||||
}
|
||||
|
||||
let _defaultInstance: LspManager | null = null;
|
||||
|
||||
export function getLspManager(): LspManager {
|
||||
if (!_defaultInstance) {
|
||||
_defaultInstance = new LspManager();
|
||||
}
|
||||
return _defaultInstance;
|
||||
}
|
||||
|
||||
export async function disposeDefaultLspManager(): Promise<void> {
|
||||
if (_defaultInstance) {
|
||||
const m = _defaultInstance;
|
||||
_defaultInstance = null;
|
||||
await m.stopAll();
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { reportBestEffortCleanupError } from "./cleanup-errors.js";
|
||||
|
||||
export function installProcessSignalCleanup(cleanup: () => Promise<void>): () => void {
|
||||
const signals: readonly NodeJS.Signals[] =
|
||||
process.platform === "win32" ? ["SIGINT", "SIGTERM", "SIGBREAK"] : ["SIGINT", "SIGTERM"];
|
||||
const handler = () => {
|
||||
void cleanup().catch((error) => {
|
||||
reportBestEffortCleanupError("signal cleanup", error);
|
||||
});
|
||||
};
|
||||
|
||||
for (const signal of signals) {
|
||||
process.on(signal, handler);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const signal of signals) {
|
||||
process.removeListener(signal, handler);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { type ChildProcess, spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { delimiter, join } from "node:path";
|
||||
|
||||
import { reportBestEffortCleanupError } from "./cleanup-errors.js";
|
||||
import { LspInvalidPathError, LspProcessSpawnError } from "./errors.js";
|
||||
|
||||
export interface SpawnedProcess {
|
||||
stdin: NodeJS.WritableStream;
|
||||
stdout: NodeJS.ReadableStream;
|
||||
stderr: NodeJS.ReadableStream;
|
||||
pid: number | undefined;
|
||||
exitCode: number | null;
|
||||
exited: Promise<number>;
|
||||
kill(signal?: NodeJS.Signals): void;
|
||||
killed: boolean;
|
||||
}
|
||||
|
||||
export interface SpawnOptions {
|
||||
cwd: string;
|
||||
env: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface PreparedSpawnCommand {
|
||||
command: string;
|
||||
args: string[];
|
||||
shell: false;
|
||||
}
|
||||
|
||||
function isMissingProcessError(error: unknown): boolean {
|
||||
if (!(error instanceof Error) || !("code" in error)) return false;
|
||||
return error.code === "ESRCH";
|
||||
}
|
||||
|
||||
function reportKillError(context: string, error: unknown): void {
|
||||
if (!isMissingProcessError(error)) {
|
||||
reportBestEffortCleanupError(context, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCwd(cwd: string): { valid: boolean; error?: string } {
|
||||
try {
|
||||
if (!existsSync(cwd)) {
|
||||
return { valid: false, error: `Working directory does not exist: ${cwd}` };
|
||||
}
|
||||
const stats = statSync(cwd);
|
||||
if (!stats.isDirectory()) {
|
||||
return { valid: false, error: `Path is not a directory: ${cwd}` };
|
||||
}
|
||||
return { valid: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function wrap(proc: ChildProcess): SpawnedProcess {
|
||||
const exitedPromise = new Promise<number>((resolve) => {
|
||||
proc.once("close", (code) => resolve(code ?? 0));
|
||||
proc.once("error", () => resolve(1));
|
||||
});
|
||||
|
||||
if (!proc.stdin || !proc.stdout || !proc.stderr) {
|
||||
throw new LspProcessSpawnError("Spawned process is missing one of stdin/stdout/stderr pipes");
|
||||
}
|
||||
|
||||
return {
|
||||
stdin: proc.stdin,
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
get pid() {
|
||||
return proc.pid ?? undefined;
|
||||
},
|
||||
get exitCode() {
|
||||
return proc.exitCode;
|
||||
},
|
||||
get killed() {
|
||||
return proc.killed;
|
||||
},
|
||||
exited: exitedPromise,
|
||||
kill(signal?: NodeJS.Signals) {
|
||||
killProcessTree(proc, signal ?? "SIGTERM");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void {
|
||||
if (process.platform === "win32" && proc.pid) {
|
||||
const result = spawnSync("taskkill", ["/pid", String(proc.pid), "/f", "/t"], { stdio: "ignore" });
|
||||
if (!result.error && result.status === 0) return;
|
||||
if (result.error) reportKillError("windows process tree kill", result.error);
|
||||
}
|
||||
|
||||
if (process.platform !== "win32" && proc.pid) {
|
||||
try {
|
||||
process.kill(-proc.pid, signal);
|
||||
return;
|
||||
} catch (error) {
|
||||
reportKillError("process group kill", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
proc.kill(signal);
|
||||
} catch (error) {
|
||||
reportKillError("process kill", error);
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowsShellShim(command: string): boolean {
|
||||
const lowerCommand = command.toLowerCase();
|
||||
return lowerCommand.endsWith(".cmd") || lowerCommand.endsWith(".bat");
|
||||
}
|
||||
|
||||
function splitPath(pathValue: string, platform: NodeJS.Platform): string[] {
|
||||
const separator = platform === "win32" ? ";" : delimiter;
|
||||
return pathValue.split(separator).filter(Boolean);
|
||||
}
|
||||
|
||||
function getWindowsPathExtensions(env: Record<string, string | undefined>): string[] {
|
||||
const rawExtensions = env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD";
|
||||
const extensions = rawExtensions
|
||||
.split(";")
|
||||
.map((extension) => extension.trim())
|
||||
.filter(Boolean)
|
||||
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`));
|
||||
return [...new Set(["", ...extensions, ".exe", ".cmd", ".bat"])];
|
||||
}
|
||||
|
||||
function resolveWindowsCommand(command: string, env: Record<string, string | undefined>): string {
|
||||
const hasPathSeparator = command.includes("/") || command.includes("\\");
|
||||
const pathValue = env["PATH"] ?? env["Path"] ?? "";
|
||||
const baseDirectories = hasPathSeparator ? [""] : splitPath(pathValue, "win32");
|
||||
const extensions = getWindowsPathExtensions(env);
|
||||
|
||||
for (const baseDirectory of baseDirectories) {
|
||||
for (const extension of extensions) {
|
||||
const candidate = baseDirectory ? join(baseDirectory, `${command}${extension}`) : `${command}${extension}`;
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createSpawnCommand(
|
||||
command: string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
commandProcessor: string = process.env["ComSpec"] ?? "cmd.exe",
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): PreparedSpawnCommand {
|
||||
const [cmd, ...args] = command;
|
||||
if (!cmd) {
|
||||
throw new LspProcessSpawnError("[lsp] empty command");
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
return { command: cmd, args, shell: false };
|
||||
}
|
||||
|
||||
const resolvedCommand = resolveWindowsCommand(cmd, env);
|
||||
if (!isWindowsShellShim(resolvedCommand)) {
|
||||
return { command: resolvedCommand, args, shell: false };
|
||||
}
|
||||
|
||||
return {
|
||||
command: commandProcessor,
|
||||
args: ["/d", "/s", "/c", resolvedCommand, ...args],
|
||||
shell: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function spawnProcess(command: string[], options: SpawnOptions): SpawnedProcess {
|
||||
const cwdValidation = validateCwd(options.cwd);
|
||||
if (!cwdValidation.valid) {
|
||||
throw new LspInvalidPathError(`[lsp] ${cwdValidation.error}`);
|
||||
}
|
||||
|
||||
const [cmd] = command;
|
||||
if (!cmd) {
|
||||
throw new LspProcessSpawnError("[lsp] empty command");
|
||||
}
|
||||
|
||||
const preparedCommand = createSpawnCommand(
|
||||
command,
|
||||
process.platform,
|
||||
process.env["ComSpec"] ?? "cmd.exe",
|
||||
options.env,
|
||||
);
|
||||
const proc = spawn(preparedCommand.command, preparedCommand.args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
shell: preparedCommand.shell,
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
return wrap(proc);
|
||||
}
|
||||
-163
@@ -1,163 +0,0 @@
|
||||
import type { LspServerConfig } from "./types.js";
|
||||
|
||||
export const LSP_INSTALL_HINTS: Record<string, string> = {
|
||||
typescript: "npm install -g typescript-language-server typescript",
|
||||
deno: "Install Deno from https://deno.land",
|
||||
vue: "npm install -g @vue/language-server",
|
||||
eslint: "npm install -g vscode-langservers-extracted",
|
||||
oxlint: "npm install -g oxlint",
|
||||
biome: "npm install -g @biomejs/biome",
|
||||
gopls: "go install golang.org/x/tools/gopls@latest",
|
||||
"ruby-lsp": "gem install ruby-lsp",
|
||||
basedpyright: "pip install basedpyright",
|
||||
pyright: "pip install pyright",
|
||||
ty: "pip install ty",
|
||||
ruff: "pip install ruff",
|
||||
"elixir-ls": "See https://github.com/elixir-lsp/elixir-ls",
|
||||
zls: "See https://github.com/zigtools/zls",
|
||||
csharp: "dotnet tool install -g csharp-ls",
|
||||
fsharp: "dotnet tool install -g fsautocomplete",
|
||||
"sourcekit-lsp": "Included with Xcode or Swift toolchain",
|
||||
rust:
|
||||
"Install rust-analyzer and ensure it is in PATH. If using rustup: rustup component add rust-analyzer. " +
|
||||
"If rust-analyzer exits while loading rust-src: rustup component remove rust-src && rustup component add rust-src.",
|
||||
clangd: "See https://clangd.llvm.org/installation",
|
||||
svelte: "npm install -g svelte-language-server",
|
||||
astro: "npm install -g @astrojs/language-server",
|
||||
"bash-ls": "npm install -g bash-language-server",
|
||||
jdtls: "See https://github.com/eclipse-jdtls/eclipse.jdt.ls",
|
||||
"yaml-ls": "npm install -g yaml-language-server",
|
||||
"lua-ls": "See https://github.com/LuaLS/lua-language-server",
|
||||
php: "npm install -g intelephense",
|
||||
dart: "Included with Dart SDK",
|
||||
"terraform-ls": "See https://github.com/hashicorp/terraform-ls",
|
||||
terraform: "See https://github.com/hashicorp/terraform-ls",
|
||||
prisma: "npm install -g prisma",
|
||||
"ocaml-lsp": "opam install ocaml-lsp-server",
|
||||
texlab: "See https://github.com/latex-lsp/texlab",
|
||||
dockerfile: "npm install -g dockerfile-language-server-nodejs",
|
||||
gleam: "See https://gleam.run/getting-started/installing/",
|
||||
"clojure-lsp": "See https://clojure-lsp.io/installation/",
|
||||
nixd: "nix profile install nixpkgs#nixd",
|
||||
tinymist: "See https://github.com/Myriad-Dreamin/tinymist",
|
||||
"haskell-language-server": "ghcup install hls",
|
||||
bash: "npm install -g bash-language-server",
|
||||
"kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp",
|
||||
};
|
||||
|
||||
export const BUILTIN_SERVERS: Record<string, Omit<LspServerConfig, "id">> = {
|
||||
typescript: {
|
||||
command: ["typescript-language-server", "--stdio"],
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
||||
},
|
||||
deno: { command: ["deno", "lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"] },
|
||||
vue: { command: ["vue-language-server", "--stdio"], extensions: [".vue"] },
|
||||
eslint: {
|
||||
command: ["vscode-eslint-language-server", "--stdio"],
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
|
||||
},
|
||||
oxlint: {
|
||||
command: ["oxlint", "--lsp"],
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"],
|
||||
},
|
||||
biome: {
|
||||
command: ["biome", "lsp-proxy", "--stdio"],
|
||||
extensions: [
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".mts",
|
||||
".cts",
|
||||
".json",
|
||||
".jsonc",
|
||||
".vue",
|
||||
".astro",
|
||||
".svelte",
|
||||
".css",
|
||||
".graphql",
|
||||
".gql",
|
||||
".html",
|
||||
],
|
||||
},
|
||||
gopls: { command: ["gopls"], extensions: [".go"] },
|
||||
"ruby-lsp": {
|
||||
command: ["rubocop", "--lsp"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
},
|
||||
basedpyright: {
|
||||
command: ["basedpyright-langserver", "--stdio"],
|
||||
extensions: [".py", ".pyi"],
|
||||
},
|
||||
pyright: { command: ["pyright-langserver", "--stdio"], extensions: [".py", ".pyi"] },
|
||||
ty: { command: ["ty", "server"], extensions: [".py", ".pyi"] },
|
||||
ruff: { command: ["ruff", "server"], extensions: [".py", ".pyi"] },
|
||||
"elixir-ls": { command: ["elixir-ls"], extensions: [".ex", ".exs"] },
|
||||
zls: { command: ["zls"], extensions: [".zig", ".zon"] },
|
||||
csharp: { command: ["csharp-ls"], extensions: [".cs"] },
|
||||
fsharp: { command: ["fsautocomplete"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"] },
|
||||
"sourcekit-lsp": { command: ["sourcekit-lsp"], extensions: [".swift", ".objc", ".objcpp"] },
|
||||
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
|
||||
clangd: {
|
||||
command: ["clangd", "--background-index", "--clang-tidy"],
|
||||
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
||||
},
|
||||
svelte: { command: ["svelteserver", "--stdio"], extensions: [".svelte"] },
|
||||
astro: { command: ["astro-ls", "--stdio"], extensions: [".astro"] },
|
||||
bash: {
|
||||
command: ["bash-language-server", "start"],
|
||||
extensions: [".sh", ".bash", ".zsh", ".ksh"],
|
||||
},
|
||||
"bash-ls": {
|
||||
command: ["bash-language-server", "start"],
|
||||
extensions: [".sh", ".bash", ".zsh", ".ksh"],
|
||||
},
|
||||
jdtls: { command: ["jdtls"], extensions: [".java"] },
|
||||
"yaml-ls": { command: ["yaml-language-server", "--stdio"], extensions: [".yaml", ".yml"] },
|
||||
"lua-ls": { command: ["lua-language-server"], extensions: [".lua"] },
|
||||
php: { command: ["intelephense", "--stdio"], extensions: [".php"] },
|
||||
dart: { command: ["dart", "language-server", "--lsp"], extensions: [".dart"] },
|
||||
terraform: { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] },
|
||||
"terraform-ls": { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] },
|
||||
prisma: { command: ["prisma", "language-server"], extensions: [".prisma"] },
|
||||
"ocaml-lsp": { command: ["ocamllsp"], extensions: [".ml", ".mli"] },
|
||||
texlab: { command: ["texlab"], extensions: [".tex", ".bib"] },
|
||||
dockerfile: { command: ["docker-langserver", "--stdio"], extensions: [".dockerfile"] },
|
||||
gleam: { command: ["gleam", "lsp"], extensions: [".gleam"] },
|
||||
"clojure-lsp": {
|
||||
command: ["clojure-lsp", "listen"],
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
},
|
||||
nixd: { command: ["nixd"], extensions: [".nix"] },
|
||||
tinymist: { command: ["tinymist"], extensions: [".typ", ".typc"] },
|
||||
"haskell-language-server": {
|
||||
command: ["haskell-language-server-wrapper", "--lsp"],
|
||||
extensions: [".hs", ".lhs"],
|
||||
},
|
||||
"kotlin-ls": { command: ["kotlin-lsp"], extensions: [".kt", ".kts"] },
|
||||
};
|
||||
|
||||
export const AUTO_INSTALLABLE_SERVERS: Record<string, string[]> = {
|
||||
typescript: ["npm", "install", "-g", "typescript-language-server", "typescript"],
|
||||
vue: ["npm", "install", "-g", "@vue/language-server"],
|
||||
eslint: ["npm", "install", "-g", "vscode-langservers-extracted"],
|
||||
oxlint: ["npm", "install", "-g", "oxlint"],
|
||||
biome: ["npm", "install", "-g", "@biomejs/biome"],
|
||||
svelte: ["npm", "install", "-g", "svelte-language-server"],
|
||||
astro: ["npm", "install", "-g", "@astrojs/language-server"],
|
||||
"bash-ls": ["npm", "install", "-g", "bash-language-server"],
|
||||
bash: ["npm", "install", "-g", "bash-language-server"],
|
||||
"yaml-ls": ["npm", "install", "-g", "yaml-language-server"],
|
||||
php: ["npm", "install", "-g", "intelephense"],
|
||||
prisma: ["npm", "install", "-g", "prisma"],
|
||||
dockerfile: ["npm", "install", "-g", "dockerfile-language-server-nodejs"],
|
||||
gopls: ["go", "install", "golang.org/x/tools/gopls@latest"],
|
||||
pyright: ["pip", "install", "pyright"],
|
||||
basedpyright: ["pip", "install", "basedpyright"],
|
||||
ruff: ["pip", "install", "ruff"],
|
||||
ty: ["pip", "install", "ty"],
|
||||
"ruby-lsp": ["gem", "install", "ruby-lsp"],
|
||||
"ocaml-lsp": ["opam", "install", "ocaml-lsp-server"],
|
||||
};
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { delimiter, join } from "node:path";
|
||||
|
||||
export function getAdditionalPathBases(workingDirectory: string): string[] {
|
||||
return [join(workingDirectory, "node_modules", ".bin")];
|
||||
}
|
||||
|
||||
export function isServerInstalled(command: string[]): boolean {
|
||||
if (command.length === 0) return false;
|
||||
|
||||
const [cmd] = command;
|
||||
if (!cmd) return false;
|
||||
|
||||
if (cmd.includes("/") || cmd.includes("\\")) {
|
||||
if (existsSync(cmd)) return true;
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
let exts = [""];
|
||||
if (isWindows) {
|
||||
const pathExt = process.env["PATHEXT"] ?? "";
|
||||
if (pathExt) {
|
||||
const systemExts = pathExt.split(";").filter(Boolean);
|
||||
exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])];
|
||||
} else {
|
||||
exts = ["", ".exe", ".cmd", ".bat", ".ps1"];
|
||||
}
|
||||
}
|
||||
|
||||
let pathEnv = process.env["PATH"] ?? "";
|
||||
if (isWindows && !pathEnv) {
|
||||
pathEnv = process.env["Path"] ?? "";
|
||||
}
|
||||
|
||||
const paths = pathEnv.split(delimiter);
|
||||
|
||||
for (const p of paths) {
|
||||
for (const suffix of exts) {
|
||||
if (existsSync(join(p, cmd + suffix))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const base of getAdditionalPathBases(process.cwd())) {
|
||||
for (const suffix of exts) {
|
||||
if (existsSync(join(base, cmd + suffix))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd === "node") return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
import { getDisabledServerIds, getMergedServers } from "./config-loader.js";
|
||||
import { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./server-definitions.js";
|
||||
import { isServerInstalled } from "./server-installation.js";
|
||||
import type { ServerLookupResult } from "./types.js";
|
||||
|
||||
export function findServerForExtension(ext: string): ServerLookupResult {
|
||||
const servers = getMergedServers();
|
||||
|
||||
for (const server of servers) {
|
||||
if (server.extensions.includes(ext) && isServerInstalled(server.command)) {
|
||||
const resolvedServer = {
|
||||
id: server.id,
|
||||
command: server.command,
|
||||
extensions: server.extensions,
|
||||
priority: server.priority,
|
||||
};
|
||||
if (server.env !== undefined) {
|
||||
return {
|
||||
status: "found",
|
||||
server: {
|
||||
...resolvedServer,
|
||||
env: server.env,
|
||||
...(server.initialization === undefined ? {} : { initialization: server.initialization }),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "found",
|
||||
server: {
|
||||
...resolvedServer,
|
||||
...(server.initialization === undefined ? {} : { initialization: server.initialization }),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const server of servers) {
|
||||
if (server.extensions.includes(ext)) {
|
||||
const installHint =
|
||||
LSP_INSTALL_HINTS[server.id] ?? `Install '${server.command[0]}' and ensure it's in your PATH`;
|
||||
return {
|
||||
status: "not_installed",
|
||||
server: {
|
||||
id: server.id,
|
||||
command: server.command,
|
||||
extensions: server.extensions,
|
||||
},
|
||||
installHint,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const availableServers = [...new Set(servers.map((s) => s.id))];
|
||||
return {
|
||||
status: "not_configured",
|
||||
extension: ext,
|
||||
availableServers,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerStatus {
|
||||
id: string;
|
||||
installed: boolean;
|
||||
extensions: string[];
|
||||
disabled: boolean;
|
||||
source: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export function getAllServers(): ServerStatus[] {
|
||||
const servers = getMergedServers();
|
||||
const disabled = getDisabledServerIds();
|
||||
|
||||
const result: ServerStatus[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const server of servers) {
|
||||
if (seen.has(server.id)) continue;
|
||||
result.push({
|
||||
id: server.id,
|
||||
installed: isServerInstalled(server.command),
|
||||
extensions: server.extensions,
|
||||
disabled: false,
|
||||
source: server.source,
|
||||
priority: server.priority,
|
||||
});
|
||||
seen.add(server.id);
|
||||
}
|
||||
|
||||
for (const id of disabled) {
|
||||
if (seen.has(id)) continue;
|
||||
const builtin = BUILTIN_SERVERS[id];
|
||||
result.push({
|
||||
id,
|
||||
installed: builtin ? isServerInstalled(builtin.command) : false,
|
||||
extensions: builtin?.extensions ?? [],
|
||||
disabled: true,
|
||||
source: "disabled",
|
||||
priority: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import { delimiter } from "node:path";
|
||||
|
||||
import { reportBestEffortCleanupError } from "./cleanup-errors.js";
|
||||
import { REQUEST_TIMEOUT_MS, STOP_HARD_KILL_TIMEOUT_MS, STOP_SIGKILL_GRACE_MS } from "./constants.js";
|
||||
import { LspConnectionClosedError, LspProcessExitedError, LspRequestTimeoutError } from "./errors.js";
|
||||
import { JsonRpcConnection } from "./json-rpc-connection.js";
|
||||
import { type SpawnedProcess, spawnProcess } from "./process.js";
|
||||
import { getAdditionalPathBases } from "./server-installation.js";
|
||||
import type { Diagnostic, ResolvedServer } from "./types.js";
|
||||
|
||||
interface ConfigurationItem {
|
||||
section?: string;
|
||||
}
|
||||
|
||||
interface DiagnosticsParams {
|
||||
uri: string;
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseConfigurationItems(params: unknown): ConfigurationItem[] {
|
||||
if (!isRecord(params) || !Array.isArray(params["items"])) return [];
|
||||
const items: ConfigurationItem[] = [];
|
||||
for (const item of params["items"]) {
|
||||
if (!isRecord(item)) continue;
|
||||
const section = item["section"];
|
||||
items.push(section === undefined || typeof section !== "string" ? {} : { section });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseDiagnosticsParams(params: unknown): DiagnosticsParams | null {
|
||||
if (!isRecord(params) || typeof params["uri"] !== "string") return null;
|
||||
const diagnostics = Array.isArray(params["diagnostics"]) ? params["diagnostics"].filter(isDiagnostic) : [];
|
||||
return { uri: params["uri"], diagnostics };
|
||||
}
|
||||
|
||||
export class LspClientTransport {
|
||||
protected proc: SpawnedProcess | null = null;
|
||||
protected connection: JsonRpcConnection | null = null;
|
||||
protected readonly stderrBuffer: string[] = [];
|
||||
protected processExited = false;
|
||||
protected readonly diagnosticsStore = new Map<string, Diagnostic[]>();
|
||||
|
||||
constructor(
|
||||
protected readonly root: string,
|
||||
protected readonly server: ResolvedServer,
|
||||
) {}
|
||||
|
||||
pid(): number | undefined {
|
||||
return this.proc?.pid;
|
||||
}
|
||||
|
||||
command(): string[] {
|
||||
return [...this.server.command];
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
...this.server.env,
|
||||
};
|
||||
const pathValue = process.platform === "win32" ? (env["PATH"] ?? env["Path"] ?? "") : (env["PATH"] ?? "");
|
||||
const spawnPath = [pathValue, ...getAdditionalPathBases(this.root)].filter(Boolean).join(delimiter);
|
||||
if (process.platform === "win32" && env["Path"] !== undefined) {
|
||||
env["Path"] = spawnPath;
|
||||
}
|
||||
env["PATH"] = spawnPath;
|
||||
|
||||
this.proc = spawnProcess(this.server.command, {
|
||||
cwd: this.root,
|
||||
env,
|
||||
});
|
||||
this.startStderrReading();
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (this.proc.exitCode !== null) {
|
||||
const stderr = this.stderrBuffer.join("\n");
|
||||
throw new LspProcessExitedError(this.server.id, this.root, this.proc.exitCode, stderr.slice(-2000));
|
||||
}
|
||||
|
||||
this.connection = new JsonRpcConnection(this.proc.stdout, this.proc.stdin);
|
||||
|
||||
this.connection.onNotification("textDocument/publishDiagnostics", (params) => {
|
||||
const diagnosticsParams = parseDiagnosticsParams(params);
|
||||
if (diagnosticsParams?.uri) {
|
||||
this.diagnosticsStore.set(diagnosticsParams.uri, diagnosticsParams.diagnostics);
|
||||
}
|
||||
});
|
||||
|
||||
this.connection.onRequest("workspace/configuration", (params) => {
|
||||
const items = parseConfigurationItems(params);
|
||||
return items.map((item) => {
|
||||
if (item.section === "json") return { validate: { enable: true } };
|
||||
return {};
|
||||
});
|
||||
});
|
||||
|
||||
this.connection.onRequest("client/registerCapability", () => null);
|
||||
this.connection.onRequest("window/workDoneProgress/create", () => null);
|
||||
|
||||
this.connection.onClose(() => {
|
||||
this.processExited = true;
|
||||
});
|
||||
|
||||
this.connection.onError((error) => {
|
||||
reportBestEffortCleanupError("connection error notification", error);
|
||||
});
|
||||
|
||||
this.connection.listen();
|
||||
}
|
||||
|
||||
protected startStderrReading(): void {
|
||||
if (!this.proc) return;
|
||||
this.proc.stderr.setEncoding("utf-8");
|
||||
this.proc.stderr.on("data", (chunk: string) => {
|
||||
this.stderrBuffer.push(chunk);
|
||||
if (this.stderrBuffer.length > 100) {
|
||||
this.stderrBuffer.shift();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private isConnectionClosedError(error: unknown): error is Error {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
|
||||
return (
|
||||
code === "ERR_STREAM_DESTROYED" ||
|
||||
/connection closed|connection is disposed|stream was destroyed/i.test(error.message)
|
||||
);
|
||||
}
|
||||
|
||||
protected sendRequest<T>(method: string): Promise<T>;
|
||||
protected sendRequest<T>(method: string, params: unknown): Promise<T>;
|
||||
protected async sendRequest<T>(method: string, ...args: [] | [unknown]): Promise<T> {
|
||||
if (!this.connection) throw new Error("LSP client not started");
|
||||
|
||||
if (this.processExited || (this.proc && this.proc.exitCode !== null)) {
|
||||
const stderrTail = this.stderrBuffer.slice(-10).join("\n");
|
||||
throw new LspProcessExitedError(
|
||||
this.server.id,
|
||||
this.root,
|
||||
this.proc?.exitCode ?? null,
|
||||
stderrTail || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
const stderrTail = this.stderrBuffer.slice(-5).join("\n");
|
||||
reject(new LspRequestTimeoutError(method, stderrTail || undefined));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
try {
|
||||
const requestPromise =
|
||||
args.length === 0
|
||||
? this.connection.sendRequest<T>(method)
|
||||
: this.connection.sendRequest<T>(method, args[0]);
|
||||
const result = await Promise.race([requestPromise, timeoutPromise]);
|
||||
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
||||
if (this.processExited || (this.proc && this.proc.exitCode !== null)) {
|
||||
throw new LspProcessExitedError(
|
||||
this.server.id,
|
||||
this.root,
|
||||
this.proc?.exitCode ?? null,
|
||||
this.stderrBuffer.slice(-10).join("\n") || undefined,
|
||||
);
|
||||
}
|
||||
if (this.isConnectionClosedError(error)) {
|
||||
throw new LspConnectionClosedError(this.server.id, this.root, error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected sendNotification(method: string): Promise<void>;
|
||||
protected sendNotification(method: string, params: unknown): Promise<void>;
|
||||
protected async sendNotification(method: string, ...args: [] | [unknown]): Promise<void> {
|
||||
if (!this.connection) return;
|
||||
if (this.processExited || (this.proc && this.proc.exitCode !== null)) return;
|
||||
try {
|
||||
if (args.length === 0) {
|
||||
await this.connection.sendNotification(method);
|
||||
} else {
|
||||
await this.connection.sendNotification(method, args[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isConnectionClosedError(error)) {
|
||||
throw new LspConnectionClosedError(this.server.id, this.root, error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
isAlive(): boolean {
|
||||
return this.proc !== null && !this.processExited && this.proc.exitCode === null;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.connection) {
|
||||
try {
|
||||
await this.sendRequest<null>("shutdown");
|
||||
} catch (error) {
|
||||
reportBestEffortCleanupError("shutdown request", error);
|
||||
}
|
||||
try {
|
||||
await this.sendNotification("exit");
|
||||
} catch (error) {
|
||||
reportBestEffortCleanupError("exit notification", error);
|
||||
}
|
||||
try {
|
||||
this.connection.dispose();
|
||||
} catch (error) {
|
||||
reportBestEffortCleanupError("connection dispose", error);
|
||||
}
|
||||
this.connection = null;
|
||||
}
|
||||
|
||||
const proc = this.proc;
|
||||
if (proc) {
|
||||
this.proc = null;
|
||||
let exitedBeforeTimeout = false;
|
||||
try {
|
||||
proc.kill();
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
const timeoutPromise = new Promise<void>((resolve) => {
|
||||
timeoutId = setTimeout(resolve, STOP_HARD_KILL_TIMEOUT_MS);
|
||||
});
|
||||
await Promise.race([
|
||||
proc.exited
|
||||
.then(() => {
|
||||
exitedBeforeTimeout = true;
|
||||
})
|
||||
.finally(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
if (!exitedBeforeTimeout) {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
await Promise.race([
|
||||
proc.exited,
|
||||
new Promise<void>((resolve) => setTimeout(resolve, STOP_SIGKILL_GRACE_MS)),
|
||||
]);
|
||||
} catch (error) {
|
||||
reportBestEffortCleanupError("hard process kill", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
reportBestEffortCleanupError("process stop", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.processExited = true;
|
||||
this.diagnosticsStore.clear();
|
||||
}
|
||||
|
||||
getStoredDiagnostics(uri: string): Diagnostic[] {
|
||||
return this.diagnosticsStore.get(uri) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
function isDiagnostic(value: unknown): value is Diagnostic {
|
||||
return isRecord(value) && isRange(value["range"]) && typeof value["message"] === "string";
|
||||
}
|
||||
|
||||
function isRange(value: unknown): value is Diagnostic["range"] {
|
||||
return isRecord(value) && isPosition(value["start"]) && isPosition(value["end"]);
|
||||
}
|
||||
|
||||
function isPosition(value: unknown): value is Diagnostic["range"]["start"] {
|
||||
return isRecord(value) && typeof value["line"] === "number" && typeof value["character"] === "number";
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
export interface LspServerConfig {
|
||||
id: string;
|
||||
command: string[];
|
||||
extensions: string[];
|
||||
disabled?: boolean;
|
||||
env?: Record<string, string>;
|
||||
initialization?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ResolvedServer {
|
||||
id: string;
|
||||
command: string[];
|
||||
extensions: string[];
|
||||
priority: number;
|
||||
env?: Record<string, string>;
|
||||
initialization?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ServerLookupInfo {
|
||||
id: string;
|
||||
command: string[];
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
export type ServerLookupResult =
|
||||
| { status: "found"; server: ResolvedServer }
|
||||
| { status: "not_configured"; extension: string; availableServers: string[] }
|
||||
| { status: "not_installed"; server: ServerLookupInfo; installHint: string };
|
||||
|
||||
export interface Position {
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
|
||||
export interface Range {
|
||||
start: Position;
|
||||
end: Position;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
uri: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export interface LocationLink {
|
||||
targetUri: string;
|
||||
targetRange: Range;
|
||||
targetSelectionRange: Range;
|
||||
originSelectionRange?: Range;
|
||||
}
|
||||
|
||||
export interface SymbolInfo {
|
||||
name: string;
|
||||
kind: number;
|
||||
location: Location;
|
||||
containerName?: string;
|
||||
}
|
||||
|
||||
export interface DocumentSymbol {
|
||||
name: string;
|
||||
kind: number;
|
||||
range: Range;
|
||||
selectionRange: Range;
|
||||
children?: DocumentSymbol[];
|
||||
}
|
||||
|
||||
export interface Diagnostic {
|
||||
range: Range;
|
||||
severity?: number;
|
||||
code?: string | number;
|
||||
source?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TextDocumentIdentifier {
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier {
|
||||
version: number | null;
|
||||
}
|
||||
|
||||
export interface TextEdit {
|
||||
range: Range;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
export interface TextDocumentEdit {
|
||||
textDocument: VersionedTextDocumentIdentifier;
|
||||
edits: TextEdit[];
|
||||
}
|
||||
|
||||
export interface CreateFile {
|
||||
kind: "create";
|
||||
uri: string;
|
||||
options?: { overwrite?: boolean; ignoreIfExists?: boolean };
|
||||
}
|
||||
|
||||
export interface RenameFile {
|
||||
kind: "rename";
|
||||
oldUri: string;
|
||||
newUri: string;
|
||||
options?: { overwrite?: boolean; ignoreIfExists?: boolean };
|
||||
}
|
||||
|
||||
export interface DeleteFile {
|
||||
kind: "delete";
|
||||
uri: string;
|
||||
options?: { recursive?: boolean; ignoreIfNotExists?: boolean };
|
||||
}
|
||||
|
||||
export interface WorkspaceEdit {
|
||||
changes?: { [uri: string]: TextEdit[] };
|
||||
documentChanges?: (TextDocumentEdit | CreateFile | RenameFile | DeleteFile)[];
|
||||
}
|
||||
|
||||
export interface PrepareRenameResult {
|
||||
range: Range;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface PrepareRenameDefaultBehavior {
|
||||
defaultBehavior: boolean;
|
||||
}
|
||||
|
||||
export type SeverityFilter = "error" | "warning" | "information" | "hint" | "all";
|
||||
@@ -1,40 +0,0 @@
|
||||
import { LspProcessExitedError } from "./errors.js";
|
||||
|
||||
const RUST_SRC_REPAIR_MESSAGE = [
|
||||
"rust-analyzer exited while loading Rust standard library sources.",
|
||||
"",
|
||||
"Repair rust-src for the active toolchain:",
|
||||
" rustup component remove rust-src",
|
||||
" rustup component add rust-src",
|
||||
];
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function formatKnownLspStartupFailure(error: unknown): string | null {
|
||||
if (!(error instanceof LspProcessExitedError)) return null;
|
||||
if (error.serverId !== "rust") return null;
|
||||
|
||||
const details = error.stderrTail ?? error.message;
|
||||
const lowerDetails = details.toLowerCase();
|
||||
const isRustSrcFailure =
|
||||
lowerDetails.includes("rust-src") &&
|
||||
(lowerDetails.includes("failed to install component") ||
|
||||
lowerDetails.includes("detected conflict") ||
|
||||
lowerDetails.includes("can't load standard library") ||
|
||||
lowerDetails.includes("try installing") ||
|
||||
lowerDetails.includes("sysroot"));
|
||||
|
||||
if (!isRustSrcFailure) return null;
|
||||
|
||||
return [...RUST_SRC_REPAIR_MESSAGE, "", "Original stderr tail:", details].join("\n");
|
||||
}
|
||||
|
||||
export function handleMissingDependencyError(error: unknown): string | null {
|
||||
const knownStartupFailure = formatKnownLspStartupFailure(error);
|
||||
if (knownStartupFailure) return knownStartupFailure;
|
||||
|
||||
const message = errorMessage(error);
|
||||
return message.includes("NOT INSTALLED") || message.includes("No LSP server configured") ? message : null;
|
||||
}
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
|
||||
import { uriToPath } from "./formatters.js";
|
||||
import type { TextEdit, WorkspaceEdit } from "./types.js";
|
||||
|
||||
export interface ApplyResult {
|
||||
success: boolean;
|
||||
filesModified: string[];
|
||||
totalEdits: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface FileApplyResult {
|
||||
success: boolean;
|
||||
editCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function applyTextEditsToFile(filePath: string, edits: TextEdit[]): FileApplyResult {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
const sortedEdits = [...edits].sort((a, b) => {
|
||||
if (b.range.start.line !== a.range.start.line) {
|
||||
return b.range.start.line - a.range.start.line;
|
||||
}
|
||||
return b.range.start.character - a.range.start.character;
|
||||
});
|
||||
|
||||
for (const edit of sortedEdits) {
|
||||
const startLine = edit.range.start.line;
|
||||
const startChar = edit.range.start.character;
|
||||
const endLine = edit.range.end.line;
|
||||
const endChar = edit.range.end.character;
|
||||
|
||||
if (startLine === endLine) {
|
||||
const line = lines[startLine] ?? "";
|
||||
lines[startLine] = line.substring(0, startChar) + edit.newText + line.substring(endChar);
|
||||
} else {
|
||||
const firstLine = lines[startLine] ?? "";
|
||||
const lastLine = lines[endLine] ?? "";
|
||||
const newContent = firstLine.substring(0, startChar) + edit.newText + lastLine.substring(endChar);
|
||||
lines.splice(startLine, endLine - startLine + 1, ...newContent.split("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(filePath, lines.join("\n"), "utf-8");
|
||||
return { success: true, editCount: edits.length };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
editCount: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
|
||||
if (!edit) {
|
||||
return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] };
|
||||
}
|
||||
|
||||
const result: ApplyResult = { success: true, filesModified: [], totalEdits: 0, errors: [] };
|
||||
|
||||
if (edit.changes) {
|
||||
for (const [uri, edits] of Object.entries(edit.changes)) {
|
||||
const filePath = uriToPath(uri);
|
||||
const applyResult = applyTextEditsToFile(filePath, edits);
|
||||
|
||||
if (applyResult.success) {
|
||||
result.filesModified.push(filePath);
|
||||
result.totalEdits += applyResult.editCount;
|
||||
} else {
|
||||
result.success = false;
|
||||
result.errors.push(`${filePath}: ${applyResult.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (edit.documentChanges) {
|
||||
for (const change of edit.documentChanges) {
|
||||
if (!("kind" in change)) {
|
||||
const filePath = uriToPath(change.textDocument.uri);
|
||||
const applyResult = applyTextEditsToFile(filePath, change.edits);
|
||||
|
||||
if (applyResult.success) {
|
||||
result.filesModified.push(filePath);
|
||||
result.totalEdits += applyResult.editCount;
|
||||
} else {
|
||||
result.success = false;
|
||||
result.errors.push(`${filePath}: ${applyResult.error}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.kind === "create") {
|
||||
try {
|
||||
const filePath = uriToPath(change.uri);
|
||||
writeFileSync(filePath, "", "utf-8");
|
||||
result.filesModified.push(filePath);
|
||||
} catch (err) {
|
||||
result.success = false;
|
||||
result.errors.push(`Create ${change.uri}: ${String(err)}`);
|
||||
}
|
||||
} else if (change.kind === "rename") {
|
||||
try {
|
||||
const oldPath = uriToPath(change.oldUri);
|
||||
const newPath = uriToPath(change.newUri);
|
||||
const content = readFileSync(oldPath, "utf-8");
|
||||
writeFileSync(newPath, content, "utf-8");
|
||||
unlinkSync(oldPath);
|
||||
result.filesModified.push(newPath);
|
||||
} catch (err) {
|
||||
result.success = false;
|
||||
result.errors.push(`Rename ${change.oldUri}: ${String(err)}`);
|
||||
}
|
||||
} else if (change.kind === "delete") {
|
||||
try {
|
||||
const filePath = uriToPath(change.uri);
|
||||
unlinkSync(filePath);
|
||||
result.filesModified.push(filePath);
|
||||
} catch (err) {
|
||||
result.success = false;
|
||||
result.errors.push(`Delete ${change.uri}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
import { appendFileSync, renameSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
type LogFieldValue = boolean | number | string | null;
|
||||
|
||||
const LOG_FILE_NAME = "omo-codex-lsp-mcp.log";
|
||||
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export function mcpLifecycleLogPath(): string {
|
||||
return join(tmpdir(), LOG_FILE_NAME);
|
||||
}
|
||||
|
||||
export function writeMcpLifecycleLog(event: string, fields: Record<string, LogFieldValue> = {}): void {
|
||||
const path = mcpLifecycleLogPath();
|
||||
try {
|
||||
rotateLogIfNeeded(path);
|
||||
appendFileSync(
|
||||
path,
|
||||
`${JSON.stringify({ ts: new Date().toISOString(), event, pid: process.pid, ppid: process.ppid, ...fields })}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function rotateLogIfNeeded(path: string): void {
|
||||
try {
|
||||
if (statSync(path).size < MAX_LOG_BYTES) return;
|
||||
renameSync(path, `${path}.1`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
import { coerceToolArguments, executeLspTool, LSP_MCP_TOOLS, type TextContent } from "./tools.js";
|
||||
|
||||
export type JsonRpcId = string | number | null;
|
||||
export type McpLifecycleLog = (event: string, fields?: Record<string, boolean | number | string | null>) => void;
|
||||
|
||||
export interface McpToolDescriptor {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputSchema: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcError {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcResult {
|
||||
capabilities?: Record<string, unknown>;
|
||||
serverInfo?: Record<string, unknown>;
|
||||
protocolVersion?: string;
|
||||
tools?: McpToolDescriptor[];
|
||||
content?: TextContent[];
|
||||
isError?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: JsonRpcId;
|
||||
result?: JsonRpcResult;
|
||||
error?: JsonRpcError;
|
||||
}
|
||||
|
||||
export interface McpStdioServerOptions {
|
||||
readonly idleTimeoutMs?: number;
|
||||
readonly onIdleTimeout?: () => void | Promise<void>;
|
||||
readonly log?: McpLifecycleLog;
|
||||
}
|
||||
|
||||
const SERVER_NAME = "lsp";
|
||||
const SERVER_VERSION = "0.1.0";
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000;
|
||||
const noopLog: McpLifecycleLog = () => {};
|
||||
|
||||
export async function handleLspMcpRequest(input: unknown): Promise<JsonRpcResponse | undefined> {
|
||||
if (!isRecord(input)) {
|
||||
return errorResponse(null, -32600, "Invalid Request");
|
||||
}
|
||||
|
||||
const id = jsonRpcId(input["id"]);
|
||||
const method = input["method"];
|
||||
if (method === "notifications/initialized") return undefined;
|
||||
if (method === "ping") return successResponse(id, {});
|
||||
if (method === "initialize") {
|
||||
const protocolVersion = requestedProtocolVersion(input["params"]);
|
||||
return successResponse(id, {
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
||||
protocolVersion,
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "tools/list") {
|
||||
return successResponse(id, { tools: LSP_MCP_TOOLS.map(describeTool) });
|
||||
}
|
||||
|
||||
if (method === "tools/call") {
|
||||
return handleToolCall(id, input["params"]);
|
||||
}
|
||||
|
||||
return errorResponse(id, -32601, `Method not found: ${String(method)}`);
|
||||
}
|
||||
|
||||
export async function runMcpStdioServer(
|
||||
input: NodeJS.ReadableStream = process.stdin,
|
||||
output: NodeJS.WritableStream = process.stdout,
|
||||
options: McpStdioServerOptions = {},
|
||||
): Promise<void> {
|
||||
const log = options.log ?? noopLog;
|
||||
const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
||||
let idleTimer: NodeJS.Timeout | null = null;
|
||||
let closed = false;
|
||||
|
||||
const clearIdleTimer = () => {
|
||||
if (idleTimer === null) return;
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
};
|
||||
const armIdleTimer = () => {
|
||||
clearIdleTimer();
|
||||
if (idleTimeoutMs <= 0) return;
|
||||
idleTimer = setTimeout(() => {
|
||||
closed = true;
|
||||
log("idle_timeout", { idle_timeout_ms: idleTimeoutMs });
|
||||
void options.onIdleTimeout?.();
|
||||
}, idleTimeoutMs);
|
||||
idleTimer.unref();
|
||||
};
|
||||
|
||||
log("stdio_started", { cwd: process.cwd(), idle_timeout_ms: idleTimeoutMs });
|
||||
armIdleTimer();
|
||||
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
if (closed) break;
|
||||
armIdleTimer();
|
||||
if (!line.trim()) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
log("parse_error", { message: messageFromError(error) });
|
||||
output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", messageFromError(error)))}\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = isRecord(parsed) ? jsonRpcId(parsed["id"]) : null;
|
||||
const method = isRecord(parsed) && typeof parsed["method"] === "string" ? parsed["method"] : null;
|
||||
log("request", { id: id === null ? null : String(id), method });
|
||||
const response = await handleLspMcpRequest(parsed);
|
||||
if (response) {
|
||||
output.write(`${JSON.stringify(response)}\n`);
|
||||
log("response", { id: String(response.id), method, is_error: response.error !== undefined });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearIdleTimer();
|
||||
log("stdio_stopped");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToolCall(id: JsonRpcId, params: unknown): Promise<JsonRpcResponse> {
|
||||
if (!isRecord(params) || typeof params["name"] !== "string") {
|
||||
return errorResponse(id, -32602, "tools/call requires params.name");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeLspTool(params["name"], coerceToolArguments(params["arguments"]));
|
||||
return successResponse(id, {
|
||||
content: result.content,
|
||||
isError: result.isError ?? false,
|
||||
details: result.details,
|
||||
});
|
||||
} catch (error) {
|
||||
return successResponse(id, {
|
||||
content: [{ type: "text", text: messageFromError(error) }],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function describeTool(tool: (typeof LSP_MCP_TOOLS)[number]): McpToolDescriptor {
|
||||
return {
|
||||
name: tool.name,
|
||||
title: tool.title,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
};
|
||||
}
|
||||
|
||||
function successResponse(id: JsonRpcId, result: JsonRpcResult): JsonRpcResponse {
|
||||
return { jsonrpc: "2.0", id, result };
|
||||
}
|
||||
|
||||
function errorResponse(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse {
|
||||
return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } };
|
||||
}
|
||||
|
||||
function requestedProtocolVersion(params: unknown): string {
|
||||
if (!isRecord(params) || typeof params["protocolVersion"] !== "string") return "2024-11-05";
|
||||
return params["protocolVersion"];
|
||||
}
|
||||
|
||||
function jsonRpcId(value: unknown): JsonRpcId {
|
||||
return typeof value === "string" || typeof value === "number" || value === null ? value : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function messageFromError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { isDirectoryPath, type WithLspClientOptions, withLspClient } from "./lsp/client-wrapper.js";
|
||||
import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_REFERENCES, DEFAULT_MAX_SYMBOLS } from "./lsp/constants.js";
|
||||
import { aggregateDiagnosticsForDirectory } from "./lsp/directory-diagnostics.js";
|
||||
import {
|
||||
filterDiagnosticsBySeverity,
|
||||
formatApplyResult,
|
||||
formatDiagnostic,
|
||||
formatDocumentSymbol,
|
||||
formatLocation,
|
||||
formatPrepareRenameResult,
|
||||
formatSymbolInfo,
|
||||
} from "./lsp/formatters.js";
|
||||
import { inferExtensionFromDirectory } from "./lsp/infer-extension.js";
|
||||
import { getLspManager } from "./lsp/manager.js";
|
||||
import { getAllServers } from "./lsp/server-resolution.js";
|
||||
import type {
|
||||
Diagnostic,
|
||||
DocumentSymbol,
|
||||
Location,
|
||||
LocationLink,
|
||||
PrepareRenameDefaultBehavior,
|
||||
PrepareRenameResult,
|
||||
Range,
|
||||
SeverityFilter,
|
||||
SymbolInfo,
|
||||
WorkspaceEdit,
|
||||
} from "./lsp/types.js";
|
||||
import { handleMissingDependencyError } from "./lsp/utils.js";
|
||||
import { type ApplyResult, applyWorkspaceEdit } from "./lsp/workspace-edit.js";
|
||||
|
||||
export interface TextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ToolExecutionResult {
|
||||
content: TextContent[];
|
||||
isError?: boolean;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonSchema {
|
||||
type: string;
|
||||
description?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
items?: JsonSchema;
|
||||
enum?: string[];
|
||||
}
|
||||
|
||||
export interface LspMcpTool {
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
title: string;
|
||||
description: string;
|
||||
inputSchema: JsonSchema;
|
||||
execute(params: Record<string, unknown>, signal?: AbortSignal): Promise<ToolExecutionResult>;
|
||||
}
|
||||
|
||||
export interface LspDiagnosticsDetails {
|
||||
filePath: string;
|
||||
severity: SeverityFilter;
|
||||
mode: "file" | "directory";
|
||||
diagnostics: Array<{ file: string; diagnostic: Diagnostic }>;
|
||||
totalDiagnostics: number;
|
||||
truncated: boolean;
|
||||
error?: string;
|
||||
errorKind?: "missing_dependency" | "no_files" | "invalid_path";
|
||||
}
|
||||
|
||||
export interface LspGotoDefinitionDetails {
|
||||
filePath: string;
|
||||
line: number;
|
||||
character: number;
|
||||
locations: Array<Location | LocationLink>;
|
||||
error?: string;
|
||||
errorKind?: "missing_dependency";
|
||||
}
|
||||
|
||||
export interface LspFindReferencesDetails {
|
||||
filePath: string;
|
||||
line: number;
|
||||
character: number;
|
||||
references: Location[];
|
||||
totalReferences: number;
|
||||
truncated: boolean;
|
||||
error?: string;
|
||||
errorKind?: "missing_dependency";
|
||||
}
|
||||
|
||||
export interface LspSymbolsDetails {
|
||||
filePath: string;
|
||||
scope: "document" | "workspace";
|
||||
query?: string;
|
||||
symbols: Array<DocumentSymbol | SymbolInfo>;
|
||||
totalSymbols: number;
|
||||
truncated: boolean;
|
||||
error?: string;
|
||||
errorKind?: "missing_dependency" | "missing_query";
|
||||
}
|
||||
|
||||
export interface LspPrepareRenameDetails {
|
||||
filePath: string;
|
||||
line: number;
|
||||
character: number;
|
||||
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null;
|
||||
error?: string;
|
||||
errorKind?: "missing_dependency";
|
||||
}
|
||||
|
||||
export interface LspRenameDetails {
|
||||
filePath: string;
|
||||
line: number;
|
||||
character: number;
|
||||
newName: string;
|
||||
apply: ApplyResult | null;
|
||||
edit: WorkspaceEdit | null;
|
||||
error?: string;
|
||||
errorKind?: "missing_dependency";
|
||||
}
|
||||
|
||||
const objectSchema = (properties: Record<string, JsonSchema>, required: string[] = []): JsonSchema => ({
|
||||
type: "object",
|
||||
properties,
|
||||
required,
|
||||
});
|
||||
|
||||
function text(text: string, details?: unknown, isError = false): ToolExecutionResult {
|
||||
return { content: [{ type: "text", text }], details, isError };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireString(params: Record<string, unknown>, key: string): string {
|
||||
const value = params[key];
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Missing required string parameter '${key}'`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(params: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = params[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function requireNumber(params: Record<string, unknown>, key: string): number {
|
||||
const value = params[key];
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error(`Missing required number parameter '${key}'`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalNumber(params: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = params[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalBoolean(params: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const value = params[key];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function isSeverityFilter(value: unknown): value is SeverityFilter {
|
||||
return value === "error" || value === "warning" || value === "information" || value === "hint" || value === "all";
|
||||
}
|
||||
|
||||
function severityFilter(params: Record<string, unknown>): SeverityFilter {
|
||||
const value = params["severity"];
|
||||
if (isSeverityFilter(value)) return value;
|
||||
return "all";
|
||||
}
|
||||
|
||||
function clientOptions(signal: AbortSignal | undefined): WithLspClientOptions {
|
||||
return signal === undefined ? {} : { signal };
|
||||
}
|
||||
|
||||
function asDiagnosticArray(result: { items?: Diagnostic[] } | Diagnostic[] | null | undefined): Diagnostic[] {
|
||||
if (!result) return [];
|
||||
if (Array.isArray(result)) return result;
|
||||
return result.items ?? [];
|
||||
}
|
||||
|
||||
function isDocumentSymbol(symbol: DocumentSymbol | SymbolInfo): symbol is DocumentSymbol {
|
||||
return "range" in symbol;
|
||||
}
|
||||
|
||||
async function executeLspStatus(): Promise<ToolExecutionResult> {
|
||||
const servers = getAllServers();
|
||||
const snapshots = getLspManager().getSnapshot();
|
||||
const installed = servers.filter((server) => server.installed && !server.disabled);
|
||||
const configuredLines = servers.map((server) => {
|
||||
const state = server.disabled ? "disabled" : server.installed ? "installed" : "missing";
|
||||
return `- ${server.id}: ${state}; source=${server.source}; extensions=${server.extensions.join(", ")}`;
|
||||
});
|
||||
const activeLines = snapshots.map((snapshot) => {
|
||||
const state = snapshot.alive ? (snapshot.isInitializing ? "initializing" : "alive") : "dead";
|
||||
return `- ${snapshot.serverId}: ${state}; root=${snapshot.root}; refs=${snapshot.refCount}`;
|
||||
});
|
||||
const lines = [
|
||||
`Configured LSP servers: ${servers.length}`,
|
||||
`Installed LSP servers: ${installed.length}`,
|
||||
"",
|
||||
...configuredLines,
|
||||
"",
|
||||
`Active LSP clients: ${snapshots.length}`,
|
||||
...activeLines,
|
||||
];
|
||||
return text(lines.join("\n"), { servers, snapshots });
|
||||
}
|
||||
|
||||
export async function executeLspDiagnostics(
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ToolExecutionResult> {
|
||||
const filePath = requireString(params, "filePath");
|
||||
const severity = severityFilter(params);
|
||||
|
||||
try {
|
||||
const absPath = resolve(filePath);
|
||||
if (isDirectoryPath(absPath)) {
|
||||
const extension = inferExtensionFromDirectory(absPath);
|
||||
if (!extension) {
|
||||
const message = `No supported source files found in directory: ${absPath}`;
|
||||
const details: LspDiagnosticsDetails = {
|
||||
filePath,
|
||||
severity,
|
||||
mode: "directory",
|
||||
diagnostics: [],
|
||||
totalDiagnostics: 0,
|
||||
truncated: false,
|
||||
error: message,
|
||||
errorKind: "no_files",
|
||||
};
|
||||
return text(message, details);
|
||||
}
|
||||
|
||||
const output = await aggregateDiagnosticsForDirectory(absPath, extension, severity);
|
||||
const details: LspDiagnosticsDetails = {
|
||||
filePath,
|
||||
severity,
|
||||
mode: "directory",
|
||||
diagnostics: [],
|
||||
totalDiagnostics: 0,
|
||||
truncated: false,
|
||||
};
|
||||
return text(output, details);
|
||||
}
|
||||
|
||||
const result = await withLspClient(
|
||||
filePath,
|
||||
async (client) => client.diagnostics(filePath),
|
||||
"diagnostics",
|
||||
clientOptions(signal),
|
||||
);
|
||||
const diagnostics = filterDiagnosticsBySeverity(asDiagnosticArray(result), severity);
|
||||
const total = diagnostics.length;
|
||||
const truncated = total > DEFAULT_MAX_DIAGNOSTICS;
|
||||
const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics;
|
||||
const output =
|
||||
total === 0
|
||||
? "No diagnostics found"
|
||||
: [
|
||||
...(truncated ? [`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`] : []),
|
||||
...limited.map(formatDiagnostic),
|
||||
].join("\n");
|
||||
const details: LspDiagnosticsDetails = {
|
||||
filePath,
|
||||
severity,
|
||||
mode: "file",
|
||||
diagnostics: diagnostics.map((diagnostic) => ({ file: absPath, diagnostic })),
|
||||
totalDiagnostics: total,
|
||||
truncated,
|
||||
};
|
||||
return text(output, details);
|
||||
} catch (error) {
|
||||
const message = handleMissingDependencyError(error);
|
||||
if (message) {
|
||||
const details: LspDiagnosticsDetails = {
|
||||
filePath,
|
||||
severity,
|
||||
mode: "file",
|
||||
diagnostics: [],
|
||||
totalDiagnostics: 0,
|
||||
truncated: false,
|
||||
error: message,
|
||||
errorKind: "missing_dependency",
|
||||
};
|
||||
return text(message, details);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeLspGotoDefinition(
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ToolExecutionResult> {
|
||||
const filePath = requireString(params, "filePath");
|
||||
const line = requireNumber(params, "line");
|
||||
const character = requireNumber(params, "character");
|
||||
|
||||
try {
|
||||
const result = await withLspClient(
|
||||
filePath,
|
||||
async (client) => client.definition(filePath, line, character),
|
||||
"definition",
|
||||
clientOptions(signal),
|
||||
);
|
||||
const locations = !result ? [] : Array.isArray(result) ? result : [result];
|
||||
const details: LspGotoDefinitionDetails = { filePath, line, character, locations };
|
||||
if (locations.length === 0) return text("No definition found", details);
|
||||
return text(locations.map(formatLocation).join("\n"), details);
|
||||
} catch (error) {
|
||||
const message = handleMissingDependencyError(error);
|
||||
if (message) {
|
||||
return text(message, {
|
||||
filePath,
|
||||
line,
|
||||
character,
|
||||
locations: [],
|
||||
error: message,
|
||||
errorKind: "missing_dependency",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeLspFindReferences(
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ToolExecutionResult> {
|
||||
const filePath = requireString(params, "filePath");
|
||||
const line = requireNumber(params, "line");
|
||||
const character = requireNumber(params, "character");
|
||||
const includeDeclaration = optionalBoolean(params, "includeDeclaration") ?? true;
|
||||
|
||||
try {
|
||||
const result = await withLspClient(
|
||||
filePath,
|
||||
async (client) => client.references(filePath, line, character, includeDeclaration),
|
||||
"references",
|
||||
clientOptions(signal),
|
||||
);
|
||||
const references = Array.isArray(result) ? result : [];
|
||||
const total = references.length;
|
||||
const truncated = total > DEFAULT_MAX_REFERENCES;
|
||||
const limited = truncated ? references.slice(0, DEFAULT_MAX_REFERENCES) : references;
|
||||
const details: LspFindReferencesDetails = {
|
||||
filePath,
|
||||
line,
|
||||
character,
|
||||
references,
|
||||
totalReferences: total,
|
||||
truncated,
|
||||
};
|
||||
if (total === 0) return text("No references found", details);
|
||||
const output = [
|
||||
...(truncated ? [`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`] : []),
|
||||
...limited.map(formatLocation),
|
||||
].join("\n");
|
||||
return text(output, details);
|
||||
} catch (error) {
|
||||
const message = handleMissingDependencyError(error);
|
||||
if (message) {
|
||||
return text(message, {
|
||||
filePath,
|
||||
line,
|
||||
character,
|
||||
references: [],
|
||||
totalReferences: 0,
|
||||
truncated: false,
|
||||
error: message,
|
||||
errorKind: "missing_dependency",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeLspSymbols(params: Record<string, unknown>, signal?: AbortSignal): Promise<ToolExecutionResult> {
|
||||
const filePath = requireString(params, "filePath");
|
||||
const rawScope = optionalString(params, "scope") ?? "document";
|
||||
const scope = rawScope === "workspace" ? "workspace" : "document";
|
||||
const limit = Math.min(optionalNumber(params, "limit") ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS);
|
||||
|
||||
try {
|
||||
if (scope === "workspace") {
|
||||
const query = optionalString(params, "query");
|
||||
if (!query) {
|
||||
const message = "Error: 'query' is required for workspace scope";
|
||||
return text(message, {
|
||||
filePath,
|
||||
scope,
|
||||
symbols: [],
|
||||
totalSymbols: 0,
|
||||
truncated: false,
|
||||
error: message,
|
||||
errorKind: "missing_query",
|
||||
});
|
||||
}
|
||||
|
||||
const symbols = await withLspClient(
|
||||
filePath,
|
||||
async (client) => client.workspaceSymbols(query),
|
||||
"workspaceSymbols",
|
||||
clientOptions(signal),
|
||||
);
|
||||
return formatSymbolsResult(filePath, scope, symbols, limit, query);
|
||||
}
|
||||
|
||||
const symbols = await withLspClient(
|
||||
filePath,
|
||||
async (client) => client.documentSymbols(filePath),
|
||||
"documentSymbols",
|
||||
clientOptions(signal),
|
||||
);
|
||||
return formatSymbolsResult(filePath, scope, symbols, limit);
|
||||
} catch (error) {
|
||||
const message = handleMissingDependencyError(error);
|
||||
if (message) {
|
||||
const query = optionalString(params, "query");
|
||||
return text(message, {
|
||||
filePath,
|
||||
scope,
|
||||
symbols: [],
|
||||
totalSymbols: 0,
|
||||
truncated: false,
|
||||
error: message,
|
||||
errorKind: "missing_dependency",
|
||||
...(query === undefined ? {} : { query }),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSymbolsResult(
|
||||
filePath: string,
|
||||
scope: "document" | "workspace",
|
||||
symbols: Array<DocumentSymbol | SymbolInfo>,
|
||||
limit: number,
|
||||
query?: string,
|
||||
): ToolExecutionResult {
|
||||
const total = symbols.length;
|
||||
const truncated = total > limit;
|
||||
const limited = truncated ? symbols.slice(0, limit) : symbols;
|
||||
const details: LspSymbolsDetails = {
|
||||
filePath,
|
||||
scope,
|
||||
symbols,
|
||||
totalSymbols: total,
|
||||
truncated,
|
||||
...(query === undefined ? {} : { query }),
|
||||
};
|
||||
if (total === 0) return text("No symbols found", details);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (truncated) lines.push(`Found ${total} symbols (showing first ${limit}):`);
|
||||
const documentSymbols = limited.filter(isDocumentSymbol);
|
||||
if (documentSymbols.length === limited.length) {
|
||||
lines.push(...documentSymbols.map((symbol) => formatDocumentSymbol(symbol)));
|
||||
} else {
|
||||
lines.push(...limited.filter((symbol): symbol is SymbolInfo => !isDocumentSymbol(symbol)).map(formatSymbolInfo));
|
||||
}
|
||||
return text(lines.join("\n"), details);
|
||||
}
|
||||
|
||||
async function executeLspPrepareRename(
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ToolExecutionResult> {
|
||||
const filePath = requireString(params, "filePath");
|
||||
const line = requireNumber(params, "line");
|
||||
const character = requireNumber(params, "character");
|
||||
|
||||
try {
|
||||
const result = await withLspClient(
|
||||
filePath,
|
||||
async (client) => client.prepareRename(filePath, line, character),
|
||||
"prepareRename",
|
||||
clientOptions(signal),
|
||||
);
|
||||
const details: LspPrepareRenameDetails = { filePath, line, character, result };
|
||||
return text(formatPrepareRenameResult(result), details);
|
||||
} catch (error) {
|
||||
const message = handleMissingDependencyError(error);
|
||||
if (message) {
|
||||
return text(message, {
|
||||
filePath,
|
||||
line,
|
||||
character,
|
||||
result: null,
|
||||
error: message,
|
||||
errorKind: "missing_dependency",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeLspRename(params: Record<string, unknown>, signal?: AbortSignal): Promise<ToolExecutionResult> {
|
||||
const filePath = requireString(params, "filePath");
|
||||
const line = requireNumber(params, "line");
|
||||
const character = requireNumber(params, "character");
|
||||
const newName = requireString(params, "newName");
|
||||
|
||||
try {
|
||||
const edit = await withLspClient(
|
||||
filePath,
|
||||
async (client) => client.rename(filePath, line, character, newName),
|
||||
"rename",
|
||||
clientOptions(signal),
|
||||
);
|
||||
const apply = applyWorkspaceEdit(edit);
|
||||
const details: LspRenameDetails = { filePath, line, character, newName, apply, edit };
|
||||
return text(formatApplyResult(apply), details, !apply.success);
|
||||
} catch (error) {
|
||||
const message = handleMissingDependencyError(error);
|
||||
if (message) {
|
||||
return text(message, {
|
||||
filePath,
|
||||
line,
|
||||
character,
|
||||
newName,
|
||||
apply: null,
|
||||
edit: null,
|
||||
error: message,
|
||||
errorKind: "missing_dependency",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeLspTool(
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ToolExecutionResult> {
|
||||
const tool = LSP_MCP_TOOLS.find((candidate) => matchesToolName(candidate, name));
|
||||
if (!tool) throw new Error(`Unknown LSP tool: ${name}`);
|
||||
return tool.execute(params, signal);
|
||||
}
|
||||
|
||||
function matchesToolName(tool: LspMcpTool, name: string): boolean {
|
||||
return tool.name === name || (tool.aliases?.includes(name) ?? false);
|
||||
}
|
||||
|
||||
export function coerceToolArguments(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
export const LSP_MCP_TOOLS: LspMcpTool[] = [
|
||||
{
|
||||
name: "status",
|
||||
aliases: ["lsp_status"],
|
||||
title: "LSP Status",
|
||||
description: "List configured and active LSP servers without starting a new language server.",
|
||||
inputSchema: objectSchema({}),
|
||||
execute: executeLspStatus,
|
||||
},
|
||||
{
|
||||
name: "diagnostics",
|
||||
aliases: ["lsp_diagnostics"],
|
||||
title: "LSP Diagnostics",
|
||||
description: "Get errors, warnings, and hints for a source file or directory.",
|
||||
inputSchema: objectSchema(
|
||||
{
|
||||
filePath: { type: "string", description: "File or directory path to check." },
|
||||
severity: {
|
||||
type: "string",
|
||||
enum: ["error", "warning", "information", "hint", "all"],
|
||||
description: "Severity filter. Defaults to all.",
|
||||
},
|
||||
},
|
||||
["filePath"],
|
||||
),
|
||||
execute: executeLspDiagnostics,
|
||||
},
|
||||
{
|
||||
name: "goto_definition",
|
||||
aliases: ["lsp_goto_definition"],
|
||||
title: "LSP Goto Definition",
|
||||
description: "Find where a symbol is defined.",
|
||||
inputSchema: objectSchema(
|
||||
{
|
||||
filePath: { type: "string", description: "Source file containing the symbol." },
|
||||
line: { type: "number", description: "1-based line number." },
|
||||
character: { type: "number", description: "0-based column." },
|
||||
},
|
||||
["filePath", "line", "character"],
|
||||
),
|
||||
execute: executeLspGotoDefinition,
|
||||
},
|
||||
{
|
||||
name: "find_references",
|
||||
aliases: ["lsp_find_references"],
|
||||
title: "LSP Find References",
|
||||
description: "Find references of a symbol across the workspace.",
|
||||
inputSchema: objectSchema(
|
||||
{
|
||||
filePath: { type: "string", description: "Source file containing the symbol." },
|
||||
line: { type: "number", description: "1-based line number." },
|
||||
character: { type: "number", description: "0-based column." },
|
||||
includeDeclaration: { type: "boolean", description: "Include the declaration. Defaults to true." },
|
||||
},
|
||||
["filePath", "line", "character"],
|
||||
),
|
||||
execute: executeLspFindReferences,
|
||||
},
|
||||
{
|
||||
name: "symbols",
|
||||
aliases: ["lsp_symbols"],
|
||||
title: "LSP Symbols",
|
||||
description: "List document symbols or search workspace symbols.",
|
||||
inputSchema: objectSchema(
|
||||
{
|
||||
filePath: { type: "string", description: "File path used as LSP context." },
|
||||
scope: {
|
||||
type: "string",
|
||||
enum: ["document", "workspace"],
|
||||
description: "Use document for file outline or workspace for project-wide search.",
|
||||
},
|
||||
query: { type: "string", description: "Workspace symbol query." },
|
||||
limit: { type: "number", description: "Maximum number of symbols to return." },
|
||||
},
|
||||
["filePath", "scope"],
|
||||
),
|
||||
execute: executeLspSymbols,
|
||||
},
|
||||
{
|
||||
name: "prepare_rename",
|
||||
aliases: ["lsp_prepare_rename"],
|
||||
title: "LSP Prepare Rename",
|
||||
description: "Check whether a symbol can be renamed at a position.",
|
||||
inputSchema: objectSchema(
|
||||
{
|
||||
filePath: { type: "string", description: "Source file path." },
|
||||
line: { type: "number", description: "1-based line number." },
|
||||
character: { type: "number", description: "0-based column." },
|
||||
},
|
||||
["filePath", "line", "character"],
|
||||
),
|
||||
execute: executeLspPrepareRename,
|
||||
},
|
||||
{
|
||||
name: "rename",
|
||||
aliases: ["lsp_rename"],
|
||||
title: "LSP Rename",
|
||||
description: "Rename a symbol across the workspace and apply the returned workspace edit.",
|
||||
inputSchema: objectSchema(
|
||||
{
|
||||
filePath: { type: "string", description: "Source file path." },
|
||||
line: { type: "number", description: "1-based line number." },
|
||||
character: { type: "number", description: "0-based column." },
|
||||
newName: { type: "string", description: "New symbol name." },
|
||||
},
|
||||
["filePath", "line", "character", "newName"],
|
||||
),
|
||||
execute: executeLspRename,
|
||||
},
|
||||
];
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, sep } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getConfigPaths, getMergedServers } from "../src/lsp/config-loader.js";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("config loader", () => {
|
||||
it("uses Codex config locations instead of pi config locations", () => {
|
||||
const paths = getConfigPaths();
|
||||
const expectedSuffix = join(".codex", "lsp-client.json");
|
||||
const piMarker = `${sep}.pi${sep}`;
|
||||
|
||||
expect(paths.project.endsWith(expectedSuffix)).toBe(true);
|
||||
expect(paths.user.endsWith(expectedSuffix)).toBe(true);
|
||||
expect(paths.project).not.toContain(piMarker);
|
||||
expect(paths.user).not.toContain(piMarker);
|
||||
});
|
||||
|
||||
it("supports project and user config path overrides via environment variables", () => {
|
||||
const previousProject = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
|
||||
const previousUser = process.env["LSP_TOOLS_MCP_USER_CONFIG"];
|
||||
|
||||
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = "config/lsp-opencode.json";
|
||||
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = ".opencode/lsp.json";
|
||||
|
||||
try {
|
||||
const paths = getConfigPaths();
|
||||
|
||||
expect(paths.project).toBe(join(process.cwd(), "config", "lsp-opencode.json"));
|
||||
expect(paths.user).toBe(join(process.env["HOME"] ?? "", ".opencode", "lsp.json"));
|
||||
} finally {
|
||||
if (previousProject === undefined) {
|
||||
delete process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
|
||||
} else {
|
||||
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = previousProject;
|
||||
}
|
||||
|
||||
if (previousUser === undefined) {
|
||||
delete process.env["LSP_TOOLS_MCP_USER_CONFIG"];
|
||||
} else {
|
||||
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = previousUser;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps absolute override paths unchanged", () => {
|
||||
const previousProject = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
|
||||
const previousUser = process.env["LSP_TOOLS_MCP_USER_CONFIG"];
|
||||
const absoluteProject = join(process.cwd(), "overrides", "project.json");
|
||||
const absoluteUser = join(process.cwd(), "overrides", "user.json");
|
||||
|
||||
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = absoluteProject;
|
||||
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = absoluteUser;
|
||||
|
||||
try {
|
||||
const paths = getConfigPaths();
|
||||
|
||||
expect(paths.project).toBe(absoluteProject);
|
||||
expect(paths.user).toBe(absoluteUser);
|
||||
} finally {
|
||||
if (previousProject === undefined) {
|
||||
delete process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
|
||||
} else {
|
||||
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = previousProject;
|
||||
}
|
||||
|
||||
if (previousUser === undefined) {
|
||||
delete process.env["LSP_TOOLS_MCP_USER_CONFIG"];
|
||||
} else {
|
||||
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = previousUser;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("#given one invalid LSP config entry #when merging servers #then keeps valid sibling entries", () => {
|
||||
// given
|
||||
const previousProject = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
|
||||
const previousUser = process.env["LSP_TOOLS_MCP_USER_CONFIG"];
|
||||
const root = mkdtempSync(join(tmpdir(), "lsp-tools-config-"));
|
||||
tempDirectories.push(root);
|
||||
const projectConfig = join(root, "project.json");
|
||||
const userConfig = join(root, "user.json");
|
||||
mkdirSync(root, { recursive: true });
|
||||
writeFileSync(
|
||||
projectConfig,
|
||||
JSON.stringify({
|
||||
lsp: {
|
||||
valid: { command: ["valid-lsp", "--stdio"], extensions: [".valid"], priority: 7 },
|
||||
invalid: "not an object",
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(userConfig, JSON.stringify({ lsp: {} }));
|
||||
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = projectConfig;
|
||||
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = userConfig;
|
||||
|
||||
try {
|
||||
// when
|
||||
const servers = getMergedServers();
|
||||
|
||||
// then
|
||||
expect(servers).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "valid",
|
||||
command: ["valid-lsp", "--stdio"],
|
||||
extensions: [".valid"],
|
||||
priority: 7,
|
||||
source: "project",
|
||||
}),
|
||||
);
|
||||
expect(servers.some((server) => server.id === "invalid")).toBe(false);
|
||||
} finally {
|
||||
if (previousProject === undefined) {
|
||||
delete process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"];
|
||||
} else {
|
||||
process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = previousProject;
|
||||
}
|
||||
|
||||
if (previousUser === undefined) {
|
||||
delete process.env["LSP_TOOLS_MCP_USER_CONFIG"];
|
||||
} else {
|
||||
process.env["LSP_TOOLS_MCP_USER_CONFIG"] = previousUser;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { collectFilesWithExtension } from "../src/lsp/directory-diagnostics.js";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("collectFilesWithExtension", () => {
|
||||
it("#given more matching files than max #when collecting diagnostics inputs #then traversal returns only capped files", () => {
|
||||
// given
|
||||
const root = mkdtempSync(join(tmpdir(), "codex-lsp-directory-"));
|
||||
tempDirectories.push(root);
|
||||
mkdirSync(join(root, "src"), { recursive: true });
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
writeFileSync(join(root, "src", `file-${index}.ts`), `export const value${index} = ${index};\n`);
|
||||
}
|
||||
|
||||
// when
|
||||
const files = collectFilesWithExtension(root, ".ts", 2);
|
||||
|
||||
// then
|
||||
expect(files).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
-1
@@ -1 +0,0 @@
|
||||
value: str = 1
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { filterDiagnosticsBySeverity } from "../src/lsp/formatters.js";
|
||||
import type { Diagnostic } from "../src/lsp/types.js";
|
||||
|
||||
const range = {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 1 },
|
||||
};
|
||||
|
||||
function diagnostic(message: string, severity?: number): Diagnostic {
|
||||
return severity === undefined ? { range, message } : { range, message, severity };
|
||||
}
|
||||
|
||||
describe("filterDiagnosticsBySeverity", () => {
|
||||
it("#given all severity filter #when filtering diagnostics #then returns the original diagnostics", () => {
|
||||
// given
|
||||
const diagnostics = [diagnostic("syntax", 1), diagnostic("note", 3)];
|
||||
|
||||
// when
|
||||
const filtered = filterDiagnosticsBySeverity(diagnostics, "all");
|
||||
|
||||
// then
|
||||
expect(filtered).toBe(diagnostics);
|
||||
});
|
||||
|
||||
it("#given mixed severities #when filtering diagnostics #then returns only matching diagnostics", () => {
|
||||
// given
|
||||
const diagnostics = [
|
||||
diagnostic("syntax", 1),
|
||||
diagnostic("lint", 2),
|
||||
diagnostic("note", 3),
|
||||
diagnostic("hint", 4),
|
||||
diagnostic("unknown"),
|
||||
];
|
||||
|
||||
// when / then
|
||||
expect(filterDiagnosticsBySeverity(diagnostics, "error")).toEqual([diagnostics[0]]);
|
||||
expect(filterDiagnosticsBySeverity(diagnostics, "warning")).toEqual([diagnostics[1]]);
|
||||
expect(filterDiagnosticsBySeverity(diagnostics, "information")).toEqual([diagnostics[2]]);
|
||||
expect(filterDiagnosticsBySeverity(diagnostics, "hint")).toEqual([diagnostics[3]]);
|
||||
});
|
||||
});
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
import { LspClient } from "../../src/lsp/client.js";
|
||||
import type { ResolvedServer } from "../../src/lsp/types.js";
|
||||
|
||||
export interface FakeLspClientOptions {
|
||||
startDelayMs?: number;
|
||||
initDelayMs?: number;
|
||||
failStart?: boolean;
|
||||
failInitialize?: boolean;
|
||||
stopDelayMs?: number;
|
||||
startsAlive?: boolean;
|
||||
}
|
||||
|
||||
export class FakeLspClient extends LspClient {
|
||||
private aliveFlag: boolean;
|
||||
startCallCount = 0;
|
||||
initializeCallCount = 0;
|
||||
stopCallCount = 0;
|
||||
|
||||
constructor(
|
||||
root: string,
|
||||
server: ResolvedServer,
|
||||
private readonly opts: FakeLspClientOptions = {},
|
||||
) {
|
||||
super(root, server);
|
||||
this.aliveFlag = opts.startsAlive !== false;
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.startCallCount++;
|
||||
if (this.opts.startDelayMs !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, this.opts.startDelayMs));
|
||||
}
|
||||
if (this.opts.failStart) {
|
||||
this.aliveFlag = false;
|
||||
throw new Error("fake start failed");
|
||||
}
|
||||
}
|
||||
|
||||
override async initialize(): Promise<void> {
|
||||
this.initializeCallCount++;
|
||||
if (this.opts.initDelayMs !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, this.opts.initDelayMs));
|
||||
}
|
||||
if (this.opts.failInitialize) {
|
||||
this.aliveFlag = false;
|
||||
throw new Error("fake initialize failed");
|
||||
}
|
||||
}
|
||||
|
||||
override isAlive(): boolean {
|
||||
return this.aliveFlag;
|
||||
}
|
||||
|
||||
override command(): string[] {
|
||||
return ["fake-server"];
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
this.stopCallCount++;
|
||||
if (this.opts.stopDelayMs !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, this.opts.stopDelayMs));
|
||||
}
|
||||
this.aliveFlag = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function makeServer(id: string, extensions: string[] = [".ts"]): ResolvedServer {
|
||||
return {
|
||||
id,
|
||||
command: ["fake-server", "--stdio"],
|
||||
extensions,
|
||||
priority: 0,
|
||||
};
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { JsonRpcConnection } from "../src/lsp/json-rpc-connection.js";
|
||||
|
||||
function encodeMessage(message: Record<string, unknown>): string {
|
||||
const body = JSON.stringify(message);
|
||||
return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
|
||||
}
|
||||
|
||||
function readOneMessage(stream: PassThrough): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
stream.once("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString("utf8");
|
||||
const bodyStart = text.indexOf("\r\n\r\n") + 4;
|
||||
resolve(JSON.parse(text.slice(bodyStart)) as Record<string, unknown>);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("JsonRpcConnection", () => {
|
||||
it("#given a framed response #when sending request #then resolves the matching result", async () => {
|
||||
// given
|
||||
const serverOutput = new PassThrough();
|
||||
const serverInput = new PassThrough();
|
||||
const connection = new JsonRpcConnection(serverOutput, serverInput);
|
||||
connection.listen();
|
||||
const requestMessage = readOneMessage(serverInput);
|
||||
|
||||
// when
|
||||
const resultPromise = connection.sendRequest<{ capabilities: Record<string, unknown> }>("initialize", {
|
||||
rootUri: "file:///tmp/project",
|
||||
});
|
||||
const request = await requestMessage;
|
||||
serverOutput.write(encodeMessage({ jsonrpc: "2.0", id: request["id"], result: { capabilities: {} } }));
|
||||
|
||||
// then
|
||||
await expect(resultPromise).resolves.toEqual({ capabilities: {} });
|
||||
connection.dispose();
|
||||
});
|
||||
|
||||
it("#given a server request #when handler returns #then writes a json-rpc response", async () => {
|
||||
// given
|
||||
const serverOutput = new PassThrough();
|
||||
const serverInput = new PassThrough();
|
||||
const connection = new JsonRpcConnection(serverOutput, serverInput);
|
||||
connection.onRequest("workspace/configuration", () => [{ validate: { enable: true } }]);
|
||||
connection.listen();
|
||||
|
||||
// when
|
||||
const responseMessage = readOneMessage(serverInput);
|
||||
serverOutput.write(
|
||||
encodeMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: 7,
|
||||
method: "workspace/configuration",
|
||||
params: { items: [{ section: "json" }] },
|
||||
}),
|
||||
);
|
||||
|
||||
// then
|
||||
await expect(responseMessage).resolves.toMatchObject({
|
||||
jsonrpc: "2.0",
|
||||
id: 7,
|
||||
result: [{ validate: { enable: true } }],
|
||||
});
|
||||
connection.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { LspClient } from "../src/lsp/client.js";
|
||||
import { LspManager } from "../src/lsp/manager.js";
|
||||
import type { ResolvedServer } from "../src/lsp/types.js";
|
||||
|
||||
import { FakeLspClient, type FakeLspClientOptions, makeServer } from "./helpers/fake-lsp-client.js";
|
||||
|
||||
interface FakeContext {
|
||||
manager: LspManager;
|
||||
clients: FakeLspClient[];
|
||||
now: { value: number };
|
||||
}
|
||||
|
||||
function setupManager(options?: {
|
||||
idleTimeoutMs?: number;
|
||||
initTimeoutMs?: number;
|
||||
reaperIntervalMs?: number;
|
||||
clientFactoryOptions?: () => FakeLspClientOptions;
|
||||
}): FakeContext {
|
||||
const clients: FakeLspClient[] = [];
|
||||
const now = { value: 1_000 };
|
||||
const manager = new LspManager({
|
||||
idleTimeoutMs: options?.idleTimeoutMs ?? 5_000,
|
||||
initTimeoutMs: options?.initTimeoutMs ?? 1_000,
|
||||
reaperIntervalMs: options?.reaperIntervalMs ?? 100,
|
||||
now: () => now.value,
|
||||
clientFactory: (root: string, server: ResolvedServer): LspClient => {
|
||||
const client = new FakeLspClient(root, server, options?.clientFactoryOptions?.());
|
||||
clients.push(client);
|
||||
return client;
|
||||
},
|
||||
});
|
||||
return { manager, clients, now };
|
||||
}
|
||||
|
||||
type ProcessSignalListener = (...args: never[]) => unknown;
|
||||
|
||||
function findAddedListener(
|
||||
signal: NodeJS.Signals,
|
||||
before: readonly ProcessSignalListener[],
|
||||
): ProcessSignalListener | undefined {
|
||||
return process.listeners(signal).find((listener) => !before.includes(listener));
|
||||
}
|
||||
|
||||
describe("LspManager", () => {
|
||||
it("#given failed start #when later getClient #then failed client is stopped and a fresh client is built", async () => {
|
||||
// given
|
||||
let firstCall = true;
|
||||
const failingFactory = () => {
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
return { failStart: true };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
const { manager, clients } = setupManager({ clientFactoryOptions: failingFactory });
|
||||
const server = makeServer("typescript");
|
||||
|
||||
// when
|
||||
await expect(manager.getClient("/root/a", server)).rejects.toThrow("fake start failed");
|
||||
|
||||
// then
|
||||
expect(manager.getSnapshot()).toEqual([]);
|
||||
expect(clients[0]?.stopCallCount).toBeGreaterThan(0);
|
||||
|
||||
const fresh = await manager.getClient("/root/a", server);
|
||||
expect(clients.length).toBe(2);
|
||||
expect(fresh).toBe(clients[1]);
|
||||
|
||||
await manager.stopAll();
|
||||
});
|
||||
|
||||
it("#given failed initialize #when later getClient #then failed client is stopped and a fresh client is built", async () => {
|
||||
// given
|
||||
let firstCall = true;
|
||||
const failingFactory = () => {
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
return { failInitialize: true };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
const { manager, clients } = setupManager({ clientFactoryOptions: failingFactory });
|
||||
const server = makeServer("typescript");
|
||||
|
||||
// when
|
||||
await expect(manager.getClient("/root/a", server)).rejects.toThrow("fake initialize failed");
|
||||
|
||||
// then
|
||||
expect(manager.getSnapshot()).toEqual([]);
|
||||
expect(clients[0]?.stopCallCount).toBeGreaterThan(0);
|
||||
|
||||
const fresh = await manager.getClient("/root/a", server);
|
||||
expect(clients.length).toBe(2);
|
||||
expect(fresh).toBe(clients[1]);
|
||||
|
||||
await manager.stopAll();
|
||||
});
|
||||
|
||||
it("#given active client #when signal cleanup runs #then client is stopped and handlers unregister", async () => {
|
||||
// given
|
||||
const beforeSigterm = process.listeners("SIGTERM");
|
||||
const { manager, clients } = setupManager();
|
||||
const server = makeServer("typescript");
|
||||
|
||||
try {
|
||||
await manager.getClient("/root/a", server);
|
||||
manager.releaseClient("/root/a", server.id);
|
||||
|
||||
// when
|
||||
const listener = findAddedListener("SIGTERM", beforeSigterm);
|
||||
|
||||
// then
|
||||
expect(listener).toBeDefined();
|
||||
listener?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(clients[0]?.stopCallCount).toBe(1);
|
||||
expect(manager.clientCount()).toBe(0);
|
||||
expect(process.listeners("SIGTERM")).toEqual(beforeSigterm);
|
||||
} finally {
|
||||
await manager.stopAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { handleLspMcpRequest, runMcpStdioServer } from "../src/mcp.js";
|
||||
|
||||
describe("lsp MCP server", () => {
|
||||
it("responds to initialize with tool capabilities", async () => {
|
||||
const response = await handleLspMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "0.0.0" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: "lsp", version: "0.1.0" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lists LSP MCP tools", async () => {
|
||||
const response = await handleLspMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/list",
|
||||
});
|
||||
|
||||
const tools = response?.result?.tools as Array<{ name: string }>;
|
||||
expect(tools.map((tool) => tool.name)).toEqual([
|
||||
"status",
|
||||
"diagnostics",
|
||||
"goto_definition",
|
||||
"find_references",
|
||||
"symbols",
|
||||
"prepare_rename",
|
||||
"rename",
|
||||
]);
|
||||
});
|
||||
|
||||
it("calls status without starting a language server", async () => {
|
||||
const response = await handleLspMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: { name: "status", arguments: {} },
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
result: {
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
expect(response?.result?.content?.[0]?.text).toContain("Configured LSP servers");
|
||||
});
|
||||
|
||||
it("accepts legacy lsp-prefixed tool names without listing them", async () => {
|
||||
const response = await handleLspMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 4,
|
||||
method: "tools/call",
|
||||
params: { name: "lsp_status", arguments: {} },
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
jsonrpc: "2.0",
|
||||
id: 4,
|
||||
result: {
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
expect(response?.result?.content?.[0]?.text).toContain("Configured LSP servers");
|
||||
});
|
||||
|
||||
it("#given idle stdio connection #when no request arrives before timeout #then server exits through idle callback", async () => {
|
||||
// given
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
let idleCallCount = 0;
|
||||
|
||||
// when
|
||||
await runMcpStdioServer(input, output, {
|
||||
idleTimeoutMs: 1,
|
||||
onIdleTimeout: () => {
|
||||
idleCallCount++;
|
||||
input.end();
|
||||
},
|
||||
});
|
||||
|
||||
// then
|
||||
expect(idleCallCount).toBe(1);
|
||||
});
|
||||
});
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type PackageJson = {
|
||||
readonly type: string;
|
||||
readonly packageManager: string;
|
||||
readonly name: string;
|
||||
readonly license: string;
|
||||
readonly bin: Record<string, string>;
|
||||
readonly files: readonly string[];
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function readPackageJson(path: string): PackageJson {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
describe("package metadata", () => {
|
||||
it("#given packaged files #when validating entrypoints #then package metadata is consistent", () => {
|
||||
// given
|
||||
const packageJson = readPackageJson("package.json");
|
||||
const cliSource = readFileSync("src/cli.ts", "utf8");
|
||||
|
||||
// then
|
||||
expect(packageJson.type).toBe("module");
|
||||
expect(packageJson.packageManager).toBe("npm@11.12.1");
|
||||
expect(packageJson.name).toBe("@code-yeongyu/lsp-tools-mcp");
|
||||
expect(packageJson.license).toBe("MIT");
|
||||
expect(packageJson.dependencies ?? {}).toEqual({});
|
||||
expect(packageJson.bin["lsp-tools-mcp"]).toBe("./dist/cli.js");
|
||||
expect(packageJson.files).toEqual(["dist", "LICENSE", "NOTICE", "README.md", "CHANGELOG.md"]);
|
||||
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
|
||||
expect(cliSource).toContain("Usage: lsp-tools-mcp [mcp]");
|
||||
});
|
||||
});
|
||||
|
||||
function isPackageJson(value: unknown): value is PackageJson {
|
||||
const dependencies = isRecord(value) ? value["dependencies"] : undefined;
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["type"] === "module" &&
|
||||
value["packageManager"] === "npm@11.12.1" &&
|
||||
value["name"] === "@code-yeongyu/lsp-tools-mcp" &&
|
||||
value["license"] === "MIT" &&
|
||||
isStringRecord(value["bin"]) &&
|
||||
isStringArray(value["files"]) &&
|
||||
(dependencies === undefined || isRecord(dependencies))
|
||||
);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is readonly string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createSpawnCommand, spawnProcess } from "../src/lsp/process.js";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function readFirstLine(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
|
||||
const cleanup = () => {
|
||||
stream.off("data", onData);
|
||||
stream.off("error", onError);
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer | string) => {
|
||||
buffer += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
||||
const newlineIndex = buffer.indexOf("\n");
|
||||
if (newlineIndex === -1) return;
|
||||
cleanup();
|
||||
resolve(buffer.slice(0, newlineIndex).trim());
|
||||
};
|
||||
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
stream.on("data", onData);
|
||||
stream.on("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function killPidBestEffort(pid: number): void {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
}
|
||||
|
||||
describe("createSpawnCommand", () => {
|
||||
it("#given windows executable command #when building spawn command #then it avoids shell mode", () => {
|
||||
// given
|
||||
const command = ["typescript-language-server", "--stdio"];
|
||||
|
||||
// when
|
||||
const prepared = createSpawnCommand(command, "win32", "cmd.exe");
|
||||
|
||||
// then
|
||||
expect(prepared).toEqual({
|
||||
command: "typescript-language-server",
|
||||
args: ["--stdio"],
|
||||
shell: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("#given windows cmd shim #when building spawn command #then it uses cmd only for the shim", () => {
|
||||
// given
|
||||
const command = ["typescript-language-server.cmd", "--stdio"];
|
||||
|
||||
// when
|
||||
const prepared = createSpawnCommand(command, "win32", "cmd.exe");
|
||||
|
||||
// then
|
||||
expect(prepared).toEqual({
|
||||
command: "cmd.exe",
|
||||
args: ["/d", "/s", "/c", "typescript-language-server.cmd", "--stdio"],
|
||||
shell: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("#given windows PATH shim #when resolving spawn command #then it executes the shim without shell mode", () => {
|
||||
// given
|
||||
const binaryDirectory = mkdtempSync(join(tmpdir(), "codex-lsp-bin-"));
|
||||
tempDirectories.push(binaryDirectory);
|
||||
mkdirSync(binaryDirectory, { recursive: true });
|
||||
const shimPath = join(binaryDirectory, "typescript-language-server.cmd");
|
||||
writeFileSync(shimPath, "@echo off\n");
|
||||
|
||||
// when
|
||||
const prepared = createSpawnCommand(["typescript-language-server", "--stdio"], "win32", "cmd.exe", {
|
||||
PATH: binaryDirectory,
|
||||
PATHEXT: ".cmd;.exe",
|
||||
});
|
||||
|
||||
// then
|
||||
expect(prepared).toEqual({
|
||||
command: "cmd.exe",
|
||||
args: ["/d", "/s", "/c", shimPath, "--stdio"],
|
||||
shell: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnProcess", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"#given child process tree #when killing spawned wrapper #then descendant process exits too",
|
||||
async () => {
|
||||
// given
|
||||
const directory = mkdtempSync(join(tmpdir(), "lsp-tools-process-tree-"));
|
||||
tempDirectories.push(directory);
|
||||
const script = [
|
||||
"const { spawn } = require('node:child_process')",
|
||||
"const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })",
|
||||
"console.error(String(child.pid))",
|
||||
"process.on('SIGTERM', () => process.exit(0))",
|
||||
"setInterval(() => {}, 1000)",
|
||||
].join(";");
|
||||
const proc = spawnProcess([process.execPath, "-e", script], { cwd: directory, env: process.env });
|
||||
const childPid = Number(await readFirstLine(proc.stderr));
|
||||
|
||||
try {
|
||||
// when
|
||||
proc.kill("SIGTERM");
|
||||
await Promise.race([proc.exited, sleep(2_000)]);
|
||||
await sleep(200);
|
||||
|
||||
// then
|
||||
expect(Number.isInteger(childPid)).toBe(true);
|
||||
expect(isPidAlive(childPid)).toBe(false);
|
||||
} finally {
|
||||
killPidBestEffort(childPid);
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AUTO_INSTALLABLE_SERVERS, BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "../src/lsp/server-definitions.js";
|
||||
|
||||
describe("BUILTIN_SERVERS", () => {
|
||||
it("#given rust #when looking it up #then maps to rust-analyzer", () => {
|
||||
// given
|
||||
const rust = BUILTIN_SERVERS["rust"];
|
||||
|
||||
// when / then
|
||||
expect(rust).toBeDefined();
|
||||
expect(rust?.command[0]).toBe("rust-analyzer");
|
||||
expect(rust?.extensions).toEqual([".rs"]);
|
||||
});
|
||||
|
||||
it("#given rust install guidance #when inspecting registry #then rust is manual install only", () => {
|
||||
// given
|
||||
const hint = LSP_INSTALL_HINTS["rust"];
|
||||
|
||||
// when / then
|
||||
expect(AUTO_INSTALLABLE_SERVERS["rust"]).toBeUndefined();
|
||||
expect(hint).toContain("rust-analyzer");
|
||||
expect(hint).toContain("rustup component add rust-analyzer");
|
||||
expect(hint).toContain("rustup component remove rust-src");
|
||||
expect(hint).toContain("rustup component add rust-src");
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LspProcessExitedError } from "../src/lsp/errors.js";
|
||||
import { formatKnownLspStartupFailure, handleMissingDependencyError } from "../src/lsp/utils.js";
|
||||
|
||||
describe("formatKnownLspStartupFailure", () => {
|
||||
it("#given rust-src component conflict #when formatting startup failure #then returns repair guidance", () => {
|
||||
// given
|
||||
const error = new LspProcessExitedError(
|
||||
"rust",
|
||||
"/repo",
|
||||
1,
|
||||
"failed to install component: 'rust-src', detected conflict: 'lib/rustlib/src/rust/library/Cargo.lock'",
|
||||
);
|
||||
|
||||
// when
|
||||
const message = formatKnownLspStartupFailure(error);
|
||||
|
||||
// then
|
||||
expect(message).toContain("rust-analyzer");
|
||||
expect(message).toContain("rustup component remove rust-src");
|
||||
expect(message).toContain("rustup component add rust-src");
|
||||
expect(message).toContain("detected conflict");
|
||||
expect(message).toContain("Cargo.lock");
|
||||
expect(message).not.toContain("automatic repair");
|
||||
});
|
||||
|
||||
it("#given rust-analyzer sysroot error #when handling missing dependency #then returns repair guidance", () => {
|
||||
// given
|
||||
const error = new LspProcessExitedError(
|
||||
"rust",
|
||||
"/repo",
|
||||
1,
|
||||
"can't load standard library from sysroot\ntry installing `rust-src` the same way you installed `rustc`",
|
||||
);
|
||||
|
||||
// when
|
||||
const message = handleMissingDependencyError(error);
|
||||
|
||||
// then
|
||||
expect(message).toContain("rustup component remove rust-src");
|
||||
expect(message).toContain("rustup component add rust-src");
|
||||
expect(message).toContain("can't load standard library");
|
||||
});
|
||||
|
||||
it("#given unrelated process exits #when formatting startup failure #then returns null", () => {
|
||||
// given
|
||||
const typescriptError = new LspProcessExitedError(
|
||||
"typescript",
|
||||
"/repo",
|
||||
1,
|
||||
"failed to install component: 'rust-src', detected conflict",
|
||||
);
|
||||
const rustPanic = new LspProcessExitedError("rust", "/repo", 1, "thread panicked while loading crate graph");
|
||||
|
||||
// when / then
|
||||
expect(formatKnownLspStartupFailure(typescriptError)).toBeNull();
|
||||
expect(formatKnownLspStartupFailure(rustPanic)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleMissingDependencyError", () => {
|
||||
it("#given existing dependency messages #when handling error #then preserves current messages", () => {
|
||||
// given
|
||||
const notInstalled = new Error("LSP server 'typescript' is configured but NOT INSTALLED.");
|
||||
const notConfigured = new Error("No LSP server configured for extension: .md");
|
||||
|
||||
// when / then
|
||||
expect(handleMissingDependencyError(notInstalled)).toBe(notInstalled.message);
|
||||
expect(handleMissingDependencyError(notConfigured)).toBe(notConfigured.message);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["test/**/*"]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"useDefineForClassFields": false,
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
environment: "node",
|
||||
pool: "threads",
|
||||
},
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Bootstrap the lsp-tools-mcp git submodule for local development.
|
||||
// CI runs the install+build steps explicitly in the workflow, so this
|
||||
// script is mostly for `npm run bootstrap` after a fresh clone and as a
|
||||
// chained pre-step before typecheck / test / check so contributors do not
|
||||
// have to remember it.
|
||||
//
|
||||
// Idempotent: skips when dist/cli.js already exists, unless --force is passed.
|
||||
import { existsSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const submoduleDir = join(__dirname, "..", "packages", "lsp-tools-mcp");
|
||||
const submodulePackageJson = join(submoduleDir, "package.json");
|
||||
const submoduleDistCli = join(submoduleDir, "dist", "cli.js");
|
||||
const force = process.argv.includes("--force");
|
||||
|
||||
if (!existsSync(submodulePackageJson)) {
|
||||
console.error(
|
||||
"lsp-tools-mcp submodule is missing. Run: git submodule update --init --recursive",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!force && existsSync(submoduleDistCli)) {
|
||||
// Already built; nothing to do.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("Installing lsp-tools-mcp dependencies...");
|
||||
execSync("npm ci", { cwd: submoduleDir, stdio: "inherit" });
|
||||
|
||||
console.log("Building lsp-tools-mcp...");
|
||||
execSync("npm run build", { cwd: submoduleDir, stdio: "inherit" });
|
||||
|
||||
console.log("Done.");
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
// Build the repository-level lsp-tools-mcp package used by codex-lsp.
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const lspToolsDir = join(__dirname, "..", "..", "..", "..", "..", "lsp-tools-mcp");
|
||||
const packageJson = join(lspToolsDir, "package.json");
|
||||
const distCli = join(lspToolsDir, "dist", "cli.js");
|
||||
const force = process.argv.includes("--force");
|
||||
|
||||
if (!force && existsSync(distCli)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!existsSync(packageJson)) {
|
||||
console.error(
|
||||
`lsp-tools-mcp package metadata is missing at ${packageJson}; build packages/lsp-tools-mcp before codex-lsp`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Installing repository lsp-tools-mcp dependencies...");
|
||||
execSync("npm ci", { cwd: lspToolsDir, stdio: "inherit" });
|
||||
|
||||
console.log("Building repository lsp-tools-mcp...");
|
||||
execSync("npm run build", { cwd: lspToolsDir, stdio: "inherit" });
|
||||
|
||||
console.log("Done.");
|
||||
@@ -9,12 +9,6 @@ type PackageJson = {
|
||||
readonly dependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
type PluginJson = {
|
||||
readonly version: string;
|
||||
readonly hooks: string;
|
||||
readonly mcpServers: string;
|
||||
};
|
||||
|
||||
type HookCommand = {
|
||||
readonly command: string;
|
||||
};
|
||||
@@ -42,12 +36,6 @@ function readPackageJson(path: string): PackageJson {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readPluginJson(path: string): PluginJson {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isPluginJson(parsed)) throw new TypeError(`Invalid plugin metadata: ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readHooksJson(path: string): HooksJson {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`);
|
||||
@@ -61,10 +49,9 @@ function readMcpJson(path: string): McpJson {
|
||||
}
|
||||
|
||||
describe("plugin package metadata", () => {
|
||||
it("#given packaged plugin files #when validating entrypoints #then hook command uses portable plugin root interpolation", () => {
|
||||
it("#given packaged component files #when validating entrypoints #then hook and MCP commands use root LSP tooling", () => {
|
||||
// given
|
||||
const packageJson = readPackageJson("package.json");
|
||||
const pluginJson = readPluginJson(".codex-plugin/plugin.json");
|
||||
const hooksJson = readHooksJson("hooks/hooks.json");
|
||||
const mcpJson = readMcpJson(".mcp.json");
|
||||
const cliSource = readFileSync("src/cli.ts", "utf8");
|
||||
@@ -75,19 +62,16 @@ describe("plugin package metadata", () => {
|
||||
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
|
||||
|
||||
// then
|
||||
expect(pluginJson.version).toBe(packageJson.version);
|
||||
expect(packageJson.type).toBe("module");
|
||||
expect(packageJson.packageManager).toBe("npm@11.12.1");
|
||||
expect(packageJson.dependencies).toEqual({
|
||||
"@code-yeongyu/lsp-tools-mcp": "file:./packages/lsp-tools-mcp",
|
||||
"@code-yeongyu/lsp-tools-mcp": "file:../../../../lsp-tools-mcp",
|
||||
});
|
||||
expect(packageJson.bin["codex-lsp"]).toBe("./dist/cli.js");
|
||||
expect(pluginJson.hooks).toBe("./hooks/hooks.json");
|
||||
expect(pluginJson.mcpServers).toBe("./.mcp.json");
|
||||
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
|
||||
expect(command).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`);
|
||||
expect(lspServer?.command).toBe("node");
|
||||
expect(lspServer?.args).toEqual(["./packages/lsp-tools-mcp/dist/cli.js", "mcp"]);
|
||||
expect(lspServer?.args).toEqual(["../../../../lsp-tools-mcp/dist/cli.js", "mcp"]);
|
||||
});
|
||||
|
||||
it("#given LSP skill guidance #when validating MCP tool instructions #then tool names are not framed as shell commands", () => {
|
||||
@@ -115,15 +99,6 @@ function isPackageJson(value: unknown): value is PackageJson {
|
||||
);
|
||||
}
|
||||
|
||||
function isPluginJson(value: unknown): value is PluginJson {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value["version"] === "string" &&
|
||||
typeof value["hooks"] === "string" &&
|
||||
typeof value["mcpServers"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isHooksJson(value: unknown): value is HooksJson {
|
||||
if (!isRecord(value) || !isRecord(value["hooks"])) return false;
|
||||
return Object.values(value["hooks"]).every(isHookEntries);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { linkCachedPluginBins } from "./install/cache.mjs";
|
||||
import { makeTempDir, writeJson } from "./install-test-fixtures.mjs";
|
||||
|
||||
test("#given Windows platform #when linking cached plugin bins #then writes command shims", async () => {
|
||||
const root = await makeTempDir();
|
||||
const pluginRoot = join(root, "plugin");
|
||||
const binDir = join(root, "bin");
|
||||
|
||||
await mkdir(pluginRoot, { recursive: true });
|
||||
await writeJson(join(pluginRoot, "package.json"), {
|
||||
name: "@example/alpha",
|
||||
bin: {
|
||||
alpha: "./dist/cli.js",
|
||||
},
|
||||
});
|
||||
await mkdir(join(pluginRoot, "dist"), { recursive: true });
|
||||
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n");
|
||||
|
||||
const linked = await linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" });
|
||||
|
||||
assert.deepEqual(linked, [{ name: "alpha", path: join(binDir, "alpha.cmd"), target: join(pluginRoot, "dist", "cli.js") }]);
|
||||
const shim = await readFile(join(binDir, "alpha.cmd"), "utf8");
|
||||
assert.match(shim, /@echo off/);
|
||||
assert.match(shim, new RegExp(`node "${join(pluginRoot, "dist", "cli.js").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" %\\*`));
|
||||
});
|
||||
|
||||
test("#given existing custom Windows command shim #when linking bins #then rejects without overwriting", async () => {
|
||||
const root = await makeTempDir();
|
||||
const pluginRoot = join(root, "plugin");
|
||||
const binDir = join(root, "bin");
|
||||
|
||||
await mkdir(pluginRoot, { recursive: true });
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await writeJson(join(pluginRoot, "package.json"), {
|
||||
name: "@example/alpha",
|
||||
bin: {
|
||||
alpha: "./dist/cli.js",
|
||||
},
|
||||
});
|
||||
await mkdir(join(pluginRoot, "dist"), { recursive: true });
|
||||
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n");
|
||||
await writeFile(join(binDir, "alpha.cmd"), "@echo off\r\necho custom\r\n");
|
||||
|
||||
await assert.rejects(
|
||||
linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }),
|
||||
/already exists and is not a generated command shim/,
|
||||
);
|
||||
assert.match(await readFile(join(binDir, "alpha.cmd"), "utf8"), /echo custom/);
|
||||
});
|
||||
@@ -3,7 +3,12 @@ import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache } from "./install/cache.mjs";
|
||||
import {
|
||||
installCachedPlugin,
|
||||
linkCachedPluginBins,
|
||||
pruneMarketplaceCache,
|
||||
pruneMarketplacePluginCaches,
|
||||
} from "./install/cache.mjs";
|
||||
import { updateCodexConfig } from "./install/config.mjs";
|
||||
import { trustedHookStatesForPlugin } from "./install/hook-trust.mjs";
|
||||
import { defaultRunCommand } from "./install/process.mjs";
|
||||
@@ -14,6 +19,8 @@ import {
|
||||
validatePathSegment,
|
||||
} from "./install/marketplace.mjs";
|
||||
|
||||
const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"];
|
||||
|
||||
export async function installMarketplaceLocally(options = {}) {
|
||||
const repoRoot = resolve(options.repoRoot ?? process.cwd());
|
||||
const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"));
|
||||
@@ -21,11 +28,14 @@ export async function installMarketplaceLocally(options = {}) {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const runCommand = options.runCommand ?? defaultRunCommand;
|
||||
const log = options.log ?? console.log;
|
||||
const marketplace = await readMarketplace(repoRoot);
|
||||
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
|
||||
const marketplace = await readMarketplace(repoRoot, {
|
||||
marketplacePath: join(codexPackageRoot, "marketplace.json"),
|
||||
});
|
||||
const installed = [];
|
||||
|
||||
for (const entry of marketplace.plugins) {
|
||||
const sourcePath = resolvePluginSource(repoRoot, entry);
|
||||
const sourcePath = resolvePluginSource(codexPackageRoot, entry, { pathOverride: "./plugin" });
|
||||
const manifest = await readPluginManifest(sourcePath);
|
||||
if (manifest.name !== entry.name) {
|
||||
throw new Error(
|
||||
@@ -64,9 +74,12 @@ export async function installMarketplaceLocally(options = {}) {
|
||||
)
|
||||
).flat();
|
||||
await pruneMarketplaceCache({ codexHome, marketplaceName: marketplace.name, keepPluginNames: pluginNames });
|
||||
for (const legacyMarketplaceName of legacyCacheMarketplaces(marketplace.name)) {
|
||||
await pruneMarketplacePluginCaches({ codexHome, marketplaceName: legacyMarketplaceName, pluginNames });
|
||||
}
|
||||
await updateCodexConfig({
|
||||
configPath: join(codexHome, "config.toml"),
|
||||
repoRoot,
|
||||
repoRoot: codexPackageRoot,
|
||||
marketplaceName: marketplace.name,
|
||||
pluginNames,
|
||||
trustedHookStates,
|
||||
@@ -79,6 +92,10 @@ export async function installMarketplaceLocally(options = {}) {
|
||||
return { marketplaceName: marketplace.name, installed };
|
||||
}
|
||||
|
||||
function legacyCacheMarketplaces(marketplaceName) {
|
||||
return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : [];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const repoRoot = process.argv[2] ? resolve(process.argv[2]) : process.cwd();
|
||||
const result = await installMarketplaceLocally({ repoRoot });
|
||||
|
||||
@@ -4,35 +4,62 @@ import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { installMarketplaceLocally } from "./install-local.mjs";
|
||||
import { linkCachedPluginBins } from "./install/cache.mjs";
|
||||
import { makeTempDir, writeJson, writePlugin } from "./install-test-fixtures.mjs";
|
||||
import { makeTempDir, writeJson, writePluginAt } from "./install-test-fixtures.mjs";
|
||||
|
||||
test("#given local marketplace #when installing #then copies versioned plugins and enables config", async () => {
|
||||
const repoRoot = await makeTempDir();
|
||||
const codexHome = await makeTempDir();
|
||||
const binDir = await makeTempDir();
|
||||
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
|
||||
const pluginRoot = join(codexPackageRoot, "plugin");
|
||||
|
||||
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
|
||||
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
|
||||
await writeJson(join(codexPackageRoot, "marketplace.json"), {
|
||||
name: "debug-marketplace",
|
||||
plugins: [
|
||||
{
|
||||
name: "alpha",
|
||||
source: "./plugins/alpha",
|
||||
},
|
||||
{
|
||||
name: "beta",
|
||||
source: {
|
||||
source: "local",
|
||||
path: "./plugins/beta",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await writePlugin(repoRoot, "alpha", "1.2.3");
|
||||
await writePlugin(repoRoot, "beta", "0.4.0");
|
||||
await mkdir(join(repoRoot, "plugins", "alpha", "node_modules"), { recursive: true });
|
||||
await writeFile(join(repoRoot, "plugins", "alpha", "node_modules", "skip.txt"), "skip");
|
||||
await writePluginAt(pluginRoot, "alpha", "1.2.3");
|
||||
await mkdir(join(codexPackageRoot, "shared-lsp", "dist"), { recursive: true });
|
||||
await writeJson(join(codexPackageRoot, "shared-lsp", "package.json"), {
|
||||
name: "@example/shared-lsp",
|
||||
version: "0.0.0",
|
||||
type: "module",
|
||||
bin: { "shared-lsp": "./dist/cli.js" },
|
||||
});
|
||||
await writeFile(join(codexPackageRoot, "shared-lsp", "dist", "cli.js"), "#!/usr/bin/env node\n");
|
||||
await writeJson(join(pluginRoot, "package.json"), {
|
||||
name: "@example/alpha",
|
||||
version: "1.2.3",
|
||||
bin: {
|
||||
alpha: "./dist/cli.js",
|
||||
},
|
||||
scripts: {
|
||||
build: "node -e \"require('fs').writeFileSync('dist/cli.js', 'console.log(1)')\"",
|
||||
},
|
||||
dependencies: {
|
||||
"@example/shared-lsp": "file:../shared-lsp",
|
||||
},
|
||||
});
|
||||
await writeJson(join(pluginRoot, ".mcp.json"), {
|
||||
mcpServers: {
|
||||
alpha: {
|
||||
command: "node",
|
||||
args: ["./dist/cli.js", "mcp"],
|
||||
cwd: ".",
|
||||
},
|
||||
shared: {
|
||||
command: "node",
|
||||
args: ["../shared-lsp/dist/cli.js", "mcp"],
|
||||
cwd: ".",
|
||||
},
|
||||
},
|
||||
});
|
||||
await mkdir(join(pluginRoot, "node_modules"), { recursive: true });
|
||||
await writeFile(join(pluginRoot, "node_modules", "skip.txt"), "skip");
|
||||
await mkdir(join(codexHome, "plugins", "cache", "debug-marketplace", "stale", "0.1.0"), { recursive: true });
|
||||
await writeFile(
|
||||
join(codexHome, "config.toml"),
|
||||
@@ -60,19 +87,23 @@ test("#given local marketplace #when installing #then copies versioned plugins a
|
||||
|
||||
assert.deepEqual(
|
||||
result.installed.map((plugin) => `${plugin.name}@${plugin.version}`),
|
||||
["alpha@1.2.3", "beta@0.4.0"],
|
||||
["alpha@1.2.3"],
|
||||
);
|
||||
const alphaCacheRoot = join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3");
|
||||
assert.equal((await stat(join(alphaCacheRoot, ".mcp.json"))).isFile(), true);
|
||||
assert.equal(await readlink(join(binDir, "alpha")), join(alphaCacheRoot, "dist", "cli.js"));
|
||||
const alphaMcp = JSON.parse(await readFile(join(alphaCacheRoot, ".mcp.json"), "utf8"));
|
||||
assert.deepEqual(alphaMcp.mcpServers.alpha.args, [join(alphaCacheRoot, "dist", "cli.js"), "mcp"]);
|
||||
assert.deepEqual(alphaMcp.mcpServers.shared.args, [join(codexPackageRoot, "shared-lsp", "dist", "cli.js"), "mcp"]);
|
||||
assert.equal(
|
||||
Object.hasOwn(alphaMcp.mcpServers.alpha, "cwd"),
|
||||
false,
|
||||
"`cwd: \".\"` must be stripped so the spawned MCP server inherits the caller's workspace cwd",
|
||||
);
|
||||
assert.equal(Object.hasOwn(alphaMcp.mcpServers.shared, "cwd"), false);
|
||||
assert.equal(alphaMcp.mcpServers.alpha.command, "node");
|
||||
const alphaPackageJson = JSON.parse(await readFile(join(alphaCacheRoot, "package.json"), "utf8"));
|
||||
assert.equal(alphaPackageJson.dependencies["@example/shared-lsp"], `file:${join(codexPackageRoot, "shared-lsp")}`);
|
||||
await assert.rejects(
|
||||
stat(join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3", "node_modules")),
|
||||
/code: 'ENOENT'|ENOENT/,
|
||||
@@ -84,12 +115,9 @@ test("#given local marketplace #when installing #then copies versioned plugins a
|
||||
assert.deepEqual(
|
||||
commands.map(([command, args, cwd]) => [command, args.join(" "), cwd]),
|
||||
[
|
||||
["npm", "install", join(repoRoot, "plugins", "alpha")],
|
||||
["npm", "run build", join(repoRoot, "plugins", "alpha")],
|
||||
["npm", "install", pluginRoot],
|
||||
["npm", "run build", pluginRoot],
|
||||
["npm", "install --omit=dev", join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3")],
|
||||
["npm", "install", join(repoRoot, "plugins", "beta")],
|
||||
["npm", "run build", join(repoRoot, "plugins", "beta")],
|
||||
["npm", "install --omit=dev", join(codexHome, "plugins", "cache", "debug-marketplace", "beta", "0.4.0")],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -98,20 +126,49 @@ test("#given local marketplace #when installing #then copies versioned plugins a
|
||||
assert.match(config, /\[marketplaces\.debug-marketplace\]/);
|
||||
assert.match(config, /source_type = "local"/);
|
||||
assert.match(config, /\[plugins\."alpha@debug-marketplace"\]\nenabled = true/);
|
||||
assert.match(config, /\[plugins\."beta@debug-marketplace"\]\nenabled = true/);
|
||||
assert.doesNotMatch(config, /stale@debug-marketplace/);
|
||||
});
|
||||
|
||||
test("#given sisyphuslabs marketplace #when installing #then registers lazycodex git source", async () => {
|
||||
const repoRoot = await makeTempDir();
|
||||
const codexHome = await makeTempDir();
|
||||
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
|
||||
|
||||
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
|
||||
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
|
||||
await writeJson(join(codexPackageRoot, "marketplace.json"), {
|
||||
name: "sisyphuslabs",
|
||||
plugins: [{ name: "omo", source: "./plugins/omo" }],
|
||||
});
|
||||
await writePlugin(repoRoot, "omo", "0.1.0");
|
||||
await writePluginAt(join(codexPackageRoot, "plugin"), "omo", "0.1.0");
|
||||
await mkdir(join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo", "0.1.0"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeJson(join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo", "0.1.0", ".mcp.json"), {
|
||||
mcpServers: {
|
||||
lsp: {
|
||||
command: "node",
|
||||
args: ["old/components/lsp/packages/lsp-tools-mcp/dist/cli.js", "mcp"],
|
||||
},
|
||||
},
|
||||
});
|
||||
await writeFile(
|
||||
join(codexHome, "config.toml"),
|
||||
[
|
||||
"[marketplaces.code-yeongyu-codex-plugins]",
|
||||
'last_updated = "2026-05-01T00:00:00Z"',
|
||||
'source_type = "git"',
|
||||
'source = "https://github.com/code-yeongyu/codex-plugins.git"',
|
||||
"",
|
||||
'[plugins."omo@code-yeongyu-codex-plugins"]',
|
||||
"enabled = true",
|
||||
"",
|
||||
'[plugins."omo@code-yeongyu-codex-plugins".mcp_servers.lsp]',
|
||||
'enabled = true',
|
||||
"",
|
||||
'[hooks.state."omo@code-yeongyu-codex-plugins:hooks/hooks.json:post_tool_use:0:0"]',
|
||||
'trusted_hash = "sha256:old"',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await installMarketplaceLocally({
|
||||
repoRoot,
|
||||
@@ -129,19 +186,24 @@ test("#given sisyphuslabs marketplace #when installing #then registers lazycodex
|
||||
assert.doesNotMatch(config, /\[marketplaces\.lazycodex\]/);
|
||||
assert.doesNotMatch(config, /code-yeongyu-codex-plugins/);
|
||||
assert.doesNotMatch(config, /source_type = "local"/);
|
||||
await assert.rejects(
|
||||
stat(join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo")),
|
||||
/code: 'ENOENT'|ENOENT/,
|
||||
);
|
||||
});
|
||||
|
||||
test("#given plugin hooks #when installing #then records trusted hook hashes", async () => {
|
||||
const repoRoot = await makeTempDir();
|
||||
const codexHome = await makeTempDir();
|
||||
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
|
||||
|
||||
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
|
||||
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
|
||||
await writeJson(join(codexPackageRoot, "marketplace.json"), {
|
||||
name: "debug-marketplace",
|
||||
plugins: [{ name: "alpha", source: "./plugins/alpha" }],
|
||||
});
|
||||
await writePlugin(repoRoot, "alpha", "1.2.3");
|
||||
await writeJson(join(repoRoot, "plugins", "alpha", "hooks", "hooks.json"), {
|
||||
const pluginRoot = join(codexPackageRoot, "plugin");
|
||||
await writePluginAt(pluginRoot, "alpha", "1.2.3");
|
||||
await writeJson(join(pluginRoot, "hooks", "hooks.json"), {
|
||||
hooks: {
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
@@ -173,9 +235,9 @@ test("#given plugin hooks #when installing #then records trusted hook hashes", a
|
||||
test("#given bad plugin source path #when installing #then rejects traversal", async () => {
|
||||
const repoRoot = await makeTempDir();
|
||||
const codexHome = await makeTempDir();
|
||||
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
|
||||
|
||||
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
|
||||
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
|
||||
await writeJson(join(codexPackageRoot, "marketplace.json"), {
|
||||
name: "debug-marketplace",
|
||||
plugins: [
|
||||
{
|
||||
@@ -190,50 +252,3 @@ test("#given bad plugin source path #when installing #then rejects traversal", a
|
||||
/local plugin source path must start with \.\//,
|
||||
);
|
||||
});
|
||||
|
||||
test("#given Windows platform #when linking cached plugin bins #then writes command shims", async () => {
|
||||
const root = await makeTempDir();
|
||||
const pluginRoot = join(root, "plugin");
|
||||
const binDir = join(root, "bin");
|
||||
|
||||
await mkdir(pluginRoot, { recursive: true });
|
||||
await writeJson(join(pluginRoot, "package.json"), {
|
||||
name: "@example/alpha",
|
||||
bin: {
|
||||
alpha: "./dist/cli.js",
|
||||
},
|
||||
});
|
||||
await mkdir(join(pluginRoot, "dist"), { recursive: true });
|
||||
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n");
|
||||
|
||||
const linked = await linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" });
|
||||
|
||||
assert.deepEqual(linked, [{ name: "alpha", path: join(binDir, "alpha.cmd"), target: join(pluginRoot, "dist", "cli.js") }]);
|
||||
const shim = await readFile(join(binDir, "alpha.cmd"), "utf8");
|
||||
assert.match(shim, /@echo off/);
|
||||
assert.match(shim, new RegExp(`node "${join(pluginRoot, "dist", "cli.js").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" %\\*`));
|
||||
});
|
||||
|
||||
test("#given existing custom Windows command shim #when linking bins #then rejects without overwriting", async () => {
|
||||
const root = await makeTempDir();
|
||||
const pluginRoot = join(root, "plugin");
|
||||
const binDir = join(root, "bin");
|
||||
|
||||
await mkdir(pluginRoot, { recursive: true });
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await writeJson(join(pluginRoot, "package.json"), {
|
||||
name: "@example/alpha",
|
||||
bin: {
|
||||
alpha: "./dist/cli.js",
|
||||
},
|
||||
});
|
||||
await mkdir(join(pluginRoot, "dist"), { recursive: true });
|
||||
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n");
|
||||
await writeFile(join(binDir, "alpha.cmd"), "@echo off\r\necho custom\r\n");
|
||||
|
||||
await assert.rejects(
|
||||
linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }),
|
||||
/already exists and is not a generated command shim/,
|
||||
);
|
||||
assert.match(await readFile(join(binDir, "alpha.cmd"), "utf8"), /echo custom/);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,10 @@ export async function writeJson(path, value) {
|
||||
|
||||
export async function writePlugin(root, name, version) {
|
||||
const pluginRoot = join(root, "plugins", name);
|
||||
await writePluginAt(pluginRoot, name, version);
|
||||
}
|
||||
|
||||
export async function writePluginAt(pluginRoot, name, version) {
|
||||
await mkdir(join(pluginRoot, ".codex-plugin"), { recursive: true });
|
||||
await mkdir(join(pluginRoot, "dist"), { recursive: true });
|
||||
await mkdir(join(pluginRoot, "hooks"), { recursive: true });
|
||||
|
||||
@@ -28,6 +28,17 @@ export async function pruneMarketplaceCache({ codexHome, marketplaceName, keepPl
|
||||
}
|
||||
}
|
||||
|
||||
export async function pruneMarketplacePluginCaches({ codexHome, marketplaceName, pluginNames }) {
|
||||
const cacheRoot = join(codexHome, "plugins", "cache", marketplaceName);
|
||||
if (!(await exists(cacheRoot))) return;
|
||||
for (const pluginName of pluginNames) {
|
||||
await rm(join(cacheRoot, pluginName), { recursive: true, force: true });
|
||||
}
|
||||
if ((await readdir(cacheRoot)).length === 0) {
|
||||
await rm(cacheRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function linkCachedPluginBins({ binDir, pluginRoot, platform = process.platform }) {
|
||||
const binLinks = await discoverPackageBins(pluginRoot);
|
||||
await mkdir(binDir, { recursive: true });
|
||||
|
||||
@@ -8,6 +8,7 @@ const LAZYCODEX_MARKETPLACE_SOURCE = {
|
||||
source: "https://github.com/code-yeongyu/lazycodex.git",
|
||||
ref: "main",
|
||||
};
|
||||
const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"];
|
||||
|
||||
export async function updateCodexConfig({
|
||||
configPath,
|
||||
@@ -21,6 +22,11 @@ export async function updateCodexConfig({
|
||||
let config = "";
|
||||
if (await exists(configPath)) config = await readFile(configPath, "utf8");
|
||||
|
||||
for (const legacyMarketplaceName of legacyMarketplaceNames(marketplaceName)) {
|
||||
config = removeMarketplaceBlock(config, legacyMarketplaceName);
|
||||
config = removeStaleMarketplacePluginBlocks(config, legacyMarketplaceName, new Set());
|
||||
config = removeStaleMarketplaceHookStateBlocks(config, legacyMarketplaceName, new Set());
|
||||
}
|
||||
config = removeStaleMarketplacePluginBlocks(config, marketplaceName, new Set(pluginNames));
|
||||
config = removeStaleMarketplaceHookStateBlocks(config, marketplaceName, new Set(pluginNames));
|
||||
config = ensureFeatureEnabled(config, "plugins");
|
||||
@@ -36,6 +42,14 @@ export async function updateCodexConfig({
|
||||
await writeFile(configPath, config.trimEnd() + "\n");
|
||||
}
|
||||
|
||||
function legacyMarketplaceNames(marketplaceName) {
|
||||
return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_MARKETPLACES : [];
|
||||
}
|
||||
|
||||
function removeMarketplaceBlock(config, marketplaceName) {
|
||||
return removeTomlSections(config, (header) => header === `marketplaces.${marketplaceName}`);
|
||||
}
|
||||
|
||||
function defaultMarketplaceSource(marketplaceName, repoRoot) {
|
||||
if (marketplaceName === "sisyphuslabs") return LAZYCODEX_MARKETPLACE_SOURCE;
|
||||
return {
|
||||
@@ -46,7 +60,7 @@ function defaultMarketplaceSource(marketplaceName, repoRoot) {
|
||||
|
||||
function removeStaleMarketplacePluginBlocks(config, marketplaceName, keepPluginNames) {
|
||||
return removeTomlSections(config, (header) => {
|
||||
const pluginKey = parseQuotedPluginHeader(header);
|
||||
const pluginKey = parsePluginHeaderKey(header);
|
||||
if (pluginKey === null) return false;
|
||||
const suffix = `@${marketplaceName}`;
|
||||
if (!pluginKey.endsWith(suffix)) return false;
|
||||
@@ -170,10 +184,28 @@ function parseTomlHeader(line) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
function parseQuotedPluginHeader(header) {
|
||||
function parsePluginHeaderKey(header) {
|
||||
const prefix = "plugins.";
|
||||
if (!header.startsWith(prefix)) return null;
|
||||
return parseJsonString(header.slice(prefix.length));
|
||||
return parseLeadingJsonString(header.slice(prefix.length));
|
||||
}
|
||||
|
||||
function parseLeadingJsonString(value) {
|
||||
if (!value.startsWith('"')) return parseJsonString(value);
|
||||
let escaped = false;
|
||||
for (let index = 1; index < value.length; index += 1) {
|
||||
const char = value[index];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') return parseJsonString(value.slice(0, index + 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonString(value) {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { join } from "node:path";
|
||||
|
||||
import { isRecord } from "./utils.mjs";
|
||||
|
||||
const MARKETPLACE_PATH = ".agents/plugins/marketplace.json";
|
||||
const DEFAULT_MARKETPLACE_PATH = "packages/omo-codex/marketplace.json";
|
||||
|
||||
export async function readMarketplace(repoRoot) {
|
||||
const marketplacePath = join(repoRoot, MARKETPLACE_PATH);
|
||||
export async function readMarketplace(repoRoot, options = {}) {
|
||||
const marketplacePath = options.marketplacePath ?? join(repoRoot, DEFAULT_MARKETPLACE_PATH);
|
||||
const raw = await readFile(marketplacePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!isRecord(parsed)) throw new Error("marketplace.json must be an object");
|
||||
@@ -22,10 +22,10 @@ export async function readMarketplace(repoRoot) {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePluginSource(repoRoot, plugin) {
|
||||
const sourcePath = localSourcePath(plugin.source);
|
||||
export function resolvePluginSource(marketplaceRoot, plugin, options = {}) {
|
||||
const sourcePath = localSourcePath(options.pathOverride ?? plugin.source);
|
||||
const relativePath = sourcePath.slice(2);
|
||||
return join(repoRoot, ...relativePath.split(/[\\/]/));
|
||||
return join(marketplaceRoot, ...relativePath.split(/[\\/]/));
|
||||
}
|
||||
|
||||
export async function readPluginManifest(pluginRoot) {
|
||||
@@ -60,10 +60,21 @@ function normalizeMarketplacePlugin(plugin, index) {
|
||||
throw new Error(`marketplace plugin ${index} name must be a non-empty string`);
|
||||
}
|
||||
validatePathSegment(plugin.name, "plugin name");
|
||||
return {
|
||||
name: plugin.name,
|
||||
source: plugin.source,
|
||||
};
|
||||
if (plugin.source === undefined || typeof plugin.source === "string") {
|
||||
if (typeof plugin.source === "string") validateLocalSourcePath(plugin.source);
|
||||
return {
|
||||
name: plugin.name,
|
||||
source: plugin.source,
|
||||
};
|
||||
}
|
||||
if (isRecord(plugin.source) && plugin.source.source === "local" && typeof plugin.source.path === "string") {
|
||||
validateLocalSourcePath(plugin.source.path);
|
||||
return {
|
||||
name: plugin.name,
|
||||
source: { source: "local", path: plugin.source.path },
|
||||
};
|
||||
}
|
||||
throw new Error("local plugin source must be a string path or { source: \"local\", path } object");
|
||||
}
|
||||
|
||||
function localSourcePath(source) {
|
||||
|
||||
@@ -42,6 +42,21 @@ export async function pruneMarketplaceCache(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function pruneMarketplacePluginCaches(input: {
|
||||
readonly codexHome: string
|
||||
readonly marketplaceName: string
|
||||
readonly pluginNames: readonly string[]
|
||||
}): Promise<void> {
|
||||
const cacheRoot = join(input.codexHome, "plugins", "cache", input.marketplaceName)
|
||||
if (!(await exists(cacheRoot))) return
|
||||
for (const pluginName of input.pluginNames) {
|
||||
await rm(join(cacheRoot, pluginName), { recursive: true, force: true })
|
||||
}
|
||||
if ((await readdir(cacheRoot)).length === 0) {
|
||||
await rm(cacheRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export async function linkCachedPluginBins(input: {
|
||||
readonly binDir: string
|
||||
readonly pluginRoot: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, readFile } from "node:fs/promises"
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { updateCodexConfig } from "./codex-config-toml"
|
||||
@@ -9,6 +9,25 @@ describe("codex-config-toml", () => {
|
||||
// given
|
||||
const root = await mkdtemp(join(tmpdir(), "omo-codex-config-"))
|
||||
const configPath = join(root, "config.toml")
|
||||
await writeFile(
|
||||
configPath,
|
||||
[
|
||||
"[marketplaces.code-yeongyu-codex-plugins]",
|
||||
'last_updated = "2026-05-01T00:00:00Z"',
|
||||
'source_type = "git"',
|
||||
'source = "https://github.com/code-yeongyu/codex-plugins.git"',
|
||||
"",
|
||||
'[plugins."omo@code-yeongyu-codex-plugins"]',
|
||||
"enabled = true",
|
||||
"",
|
||||
'[plugins."omo@code-yeongyu-codex-plugins".mcp_servers.lsp]',
|
||||
"enabled = true",
|
||||
"",
|
||||
'[hooks.state."omo@code-yeongyu-codex-plugins:hooks/hooks.json:post_tool_use:0:0"]',
|
||||
'trusted_hash = "sha256:old"',
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
// when
|
||||
await updateCodexConfig({
|
||||
|
||||
@@ -2,6 +2,8 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"
|
||||
import { dirname } from "node:path"
|
||||
import type { CodexMarketplaceSource, TrustedHookState } from "./types"
|
||||
|
||||
const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const
|
||||
|
||||
export async function updateCodexConfig(input: {
|
||||
readonly configPath: string
|
||||
readonly repoRoot: string
|
||||
@@ -15,6 +17,11 @@ export async function updateCodexConfig(input: {
|
||||
if (await exists(input.configPath)) config = await readFile(input.configPath, "utf8")
|
||||
|
||||
const pluginSet = new Set(input.pluginNames)
|
||||
for (const legacyMarketplaceName of legacyMarketplaceNames(input.marketplaceName)) {
|
||||
config = removeMarketplaceBlock(config, legacyMarketplaceName)
|
||||
config = removeStaleMarketplacePluginBlocks(config, legacyMarketplaceName, new Set())
|
||||
config = removeStaleMarketplaceHookStateBlocks(config, legacyMarketplaceName, new Set())
|
||||
}
|
||||
config = removeStaleMarketplacePluginBlocks(config, input.marketplaceName, pluginSet)
|
||||
config = removeStaleMarketplaceHookStateBlocks(config, input.marketplaceName, pluginSet)
|
||||
config = ensureFeatureEnabled(config, "plugins")
|
||||
@@ -30,9 +37,17 @@ export async function updateCodexConfig(input: {
|
||||
await writeFile(input.configPath, `${config.trimEnd()}\n`)
|
||||
}
|
||||
|
||||
function legacyMarketplaceNames(marketplaceName: string): readonly string[] {
|
||||
return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_MARKETPLACES : []
|
||||
}
|
||||
|
||||
function removeMarketplaceBlock(config: string, marketplaceName: string): string {
|
||||
return removeTomlSections(config, (header) => header === `marketplaces.${marketplaceName}`)
|
||||
}
|
||||
|
||||
function removeStaleMarketplacePluginBlocks(config: string, marketplaceName: string, keepPluginNames: Set<string>): string {
|
||||
return removeTomlSections(config, (header) => {
|
||||
const pluginKey = parseQuotedPluginHeader(header)
|
||||
const pluginKey = parsePluginHeaderKey(header)
|
||||
if (pluginKey === null) return false
|
||||
const suffix = `@${marketplaceName}`
|
||||
if (!pluginKey.endsWith(suffix)) return false
|
||||
@@ -158,10 +173,28 @@ function parseTomlHeader(line: string): string | null {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
|
||||
function parseQuotedPluginHeader(header: string): string | null {
|
||||
function parsePluginHeaderKey(header: string): string | null {
|
||||
const prefix = "plugins."
|
||||
if (!header.startsWith(prefix)) return null
|
||||
return parseJsonString(header.slice(prefix.length))
|
||||
return parseLeadingJsonString(header.slice(prefix.length))
|
||||
}
|
||||
|
||||
function parseLeadingJsonString(value: string): string | null {
|
||||
if (!value.startsWith('"')) return parseJsonString(value)
|
||||
let escaped = false
|
||||
for (let index = 1; index < value.length; index += 1) {
|
||||
const char = value[index]
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (char === '"') return parseJsonString(value.slice(0, index + 1))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseJsonString(value: string): string | null {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, readFile, stat } from "node:fs/promises"
|
||||
import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { runCodexInstaller } from "./install-codex"
|
||||
@@ -13,6 +13,9 @@ describe("install-codex", () => {
|
||||
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-"))
|
||||
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-"))
|
||||
const repoRoot = process.cwd()
|
||||
const legacyCacheRoot = join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo", "0.1.0")
|
||||
await mkdir(legacyCacheRoot, { recursive: true })
|
||||
await writeFile(join(legacyCacheRoot, ".mcp.json"), JSON.stringify({ mcpServers: { lsp: { args: ["old-lsp"] } } }))
|
||||
|
||||
// when
|
||||
const first = await runCodexInstaller({ codexHome, binDir, repoRoot, runCommand: async () => undefined })
|
||||
@@ -37,5 +40,6 @@ describe("install-codex", () => {
|
||||
expect(pluginPath).toContain(join("plugins", "cache", "sisyphuslabs", "omo"))
|
||||
const stats = await stat(pluginPath ?? "")
|
||||
expect(stats.isDirectory()).toBe(true)
|
||||
await expect(stat(join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo"))).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { homedir } from "node:os"
|
||||
import { join, resolve } from "node:path"
|
||||
import { existsSync } from "node:fs"
|
||||
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache } from "./codex-cache"
|
||||
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, pruneMarketplacePluginCaches } from "./codex-cache"
|
||||
import { updateCodexConfig } from "./codex-config-toml"
|
||||
import { trustedHookStatesForPlugin } from "./codex-hook-trust"
|
||||
import { linkCachedPluginAgents } from "./link-cached-plugin-agents"
|
||||
@@ -14,6 +14,7 @@ const LAZYCODEX_MARKETPLACE_SOURCE = {
|
||||
source: "https://github.com/code-yeongyu/lazycodex.git",
|
||||
ref: "main",
|
||||
} as const
|
||||
const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const
|
||||
|
||||
export async function runCodexInstaller(options: CodexInstallOptions = {}): Promise<CodexInstallResult> {
|
||||
const repoRoot = resolve(options.repoRoot ?? findRepoRootFromImporter(import.meta.dir))
|
||||
@@ -78,6 +79,13 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
|
||||
marketplaceName: marketplace.name,
|
||||
keepPluginNames: marketplace.plugins.map((plugin) => plugin.name),
|
||||
})
|
||||
for (const legacyMarketplaceName of legacyCacheMarketplaces(marketplace.name)) {
|
||||
await pruneMarketplacePluginCaches({
|
||||
codexHome,
|
||||
marketplaceName: legacyMarketplaceName,
|
||||
pluginNames: marketplace.plugins.map((plugin) => plugin.name),
|
||||
})
|
||||
}
|
||||
|
||||
const configPath = join(codexHome, "config.toml")
|
||||
await updateCodexConfig({
|
||||
@@ -99,6 +107,10 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
|
||||
}
|
||||
}
|
||||
|
||||
function legacyCacheMarketplaces(marketplaceName: string): readonly string[] {
|
||||
return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : []
|
||||
}
|
||||
|
||||
function findRepoRootFromImporter(importerDir: string): string {
|
||||
let current = importerDir
|
||||
for (let depth = 0; depth <= 5; depth += 1) {
|
||||
|
||||
Reference in New Issue
Block a user