diff --git a/.github/workflows/neovim.yml b/.github/workflows/neovim.yml new file mode 100644 index 0000000..dab0d11 --- /dev/null +++ b/.github/workflows/neovim.yml @@ -0,0 +1,64 @@ +name: Neovim Plugin + +# Lua unit tests (plenary) and the editor-independent conformance suite, which +# drives a headless Neovim through the real MCP server against a Go fixture +# debugged by delve. + +on: + push: + branches: [ main, 'feat/**' ] + paths: [ 'editors/neovim/**', 'editors/conformance/**', 'editors/vscode/src/**', 'schema/**', '.github/workflows/neovim.yml' ] + pull_request: + paths: [ 'editors/neovim/**', 'editors/conformance/**', 'editors/vscode/src/**', 'schema/**', '.github/workflows/neovim.yml' ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, macos-latest ] + runs-on: ${{ matrix.os }} + env: + NVIM_DAP_PATH: ${{ github.workspace }}/.deps/nvim-dap + PLENARY_PATH: ${{ github.workspace }}/.deps/plenary.nvim + steps: + - uses: actions/checkout@v7 + + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: v0.10.4 + + - uses: actions/setup-node@v7 + with: + node-version: 22 + + - uses: actions/setup-go@v7 + with: + go-version: stable + cache: false + + - name: Install delve + run: go install github.com/go-delve/delve/cmd/dlv@latest && echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Fetch nvim-dap and plenary + run: | + git clone --depth 1 https://github.com/mfussenegger/nvim-dap "$NVIM_DAP_PATH" + git clone --depth 1 https://github.com/nvim-lua/plenary.nvim "$PLENARY_PATH" + + - name: Lua unit tests + working-directory: editors/neovim + run: nvim --headless --noplugin -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests/unit { minimal_init = 'tests/minimal_init.lua', sequential = true }" + + # The conformance suite talks to the window through the real MCP server. + - name: Build the MCP server + working-directory: editors/vscode + run: npm ci && npm run compile + + - name: Conformance (Neovim + delve) + working-directory: editors/conformance + run: npm ci && npm run test:nvim diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de9dd12..540126f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ Thank you for your interest in contributing to Blackbox! We welcome contribution ## Releasing (maintainers) -Each VS Code extension version is a tag `vscode-v`, a [GitHub Release](https://github.com/akash-aman/blackbox/releases) and an entry in [`editors/vscode/CHANGELOG.md`](editors/vscode/CHANGELOG.md). +Each VS Code extension version is a tag `vscode-v`, a [GitHub Release](https://github.com/xcode-studio/blackbox/releases) and an entry in [`editors/vscode/CHANGELOG.md`](editors/vscode/CHANGELOG.md). 1. Bump `version` in `editors/vscode/package.json` (`npm version --no-git-tag-version`). 2. In `editors/vscode/CHANGELOG.md`, rename `## Unreleased` (where changes collect, one bullet per user-visible change) to `## (Pre-release) โ€” `. Leave out `(Pre-release)` for a stable release. Check it with `node scripts/release-notes.js `. diff --git a/README.md b/README.md index e480b06..705c1c2 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ This project contains implementations for the following editors: |-----|--------|-----------| | **VS Code** | Active | [`editors/vscode/`](editors/vscode/) | | **JetBrains** | Planned | [`editors/jetbrains/`](editors/jetbrains/) | -| **Neovim** | Planned | [`editors/neovim/`](editors/neovim/) | +| **Neovim** | Working, not yet published ([install from this repo](editors/neovim/README.md)) | [`editors/neovim/`](editors/neovim/) | ## ๐Ÿ“š Packages in this Monorepo @@ -72,7 +72,7 @@ The core debugging engine that powers Blackbox. It provides framework-agnostic M ## ๐Ÿค Contributing -Contributions, issues, and feature requests are welcome\! Feel free to check the [issues page](https://github.com/akash-aman/blackbox/issues). +Contributions, issues, and feature requests are welcome\! Feel free to check the [issues page](https://github.com/xcode-studio/blackbox/issues). ## ๐Ÿ“ License diff --git a/docs/architecture.md b/docs/architecture.md index 3b9cc88..9684047 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,10 +88,14 @@ Layers on the MCP side, each depending only on the ones below: --- +## Neovim Implementation +`editors/neovim/` is the same window side in Lua on top of nvim-dap. It writes the same registry entry (`app: "Neovim"`; `pid` and `appPid` are both the Neovim process, so an AI tool in `:terminal` is matched to it), listens on the same kind of socket, and answers the same requests. The MCP server needs no changes. Pause state, output and waits port `debugEvents.ts` onto `dap.listeners`. nvim-dap has no function or disabled breakpoints, so the plugin keeps those itself. + ## Extension Guide: Adding New IDEs -To integrate a new editor into the Blackbox ecosystem, follow these steps: +A new editor only needs the **window side**; the MCP server is shared. 1. **Namespace:** Create a new directory under `editors//`. -2. **Compliance:** Implement the functions defined in `schema/tools.json` using the target IDE's native APIs. -3. **Transport:** Expose the implementation via an MCP-compliant transport (typically stdio or a native IDE plugin API). -4. **Automation:** Register a dedicated CI workflow in `.github/workflows/.yml` to validate implementation against the schema. \ No newline at end of file +2. **Window protocol:** Per window, listen on `/.sock` (a randomly named pipe on Windows) in the private per-user folder (`/tmp/blackbox-/`, mode 0700, checked for ownership), and write `/.json` with the `RegistryEntry` fields from `editors/vscode/src/ipc/protocol.ts` (`app`, `appPid`, `protocol: 2`, โ€ฆ). Answer newline-delimited JSON requests `{id, tool, args}` with `{id, result}` or `{id, error}`. +3. **Compliance:** Implement every tool in `schema/tools.json` except `ide_*` (answered by the MCP server), plus the internal `window_status`. +4. **Conformance:** Add a harness to `editors/conformance/harness/` that starts the editor, and run `editors/conformance/test` against it. It drives the editor through the real MCP server. +5. **Automation:** Register a CI workflow in `.github/workflows/.yml`. diff --git a/editors/conformance/fixtures/go/.vscode/launch.json b/editors/conformance/fixtures/go/.vscode/launch.json new file mode 100644 index 0000000..b4fe24d --- /dev/null +++ b/editors/conformance/fixtures/go/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + // Used by the Blackbox conformance suite. + "version": "0.2.0", + "configurations": [ + { + "name": "Fixture", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}" + }, + { + // The adapter refuses it, to check that the error is reported. + "name": "Fixture (broken)", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/does-not-exist" + }, + { + // Its adapter never answers, to check the stalled-start explanation. + "name": "Fixture (stalled)", + "type": "blackbox-stall", + "request": "launch" + } + ] +} diff --git a/editors/conformance/fixtures/go/app.go b/editors/conformance/fixtures/go/app.go new file mode 100644 index 0000000..d8d7d7d --- /dev/null +++ b/editors/conformance/fixtures/go/app.go @@ -0,0 +1,18 @@ +// Fixture for the Blackbox conformance suite (editors/conformance/test). +// The tests refer to these line numbers; keep them stable. +package main + +import "fmt" + +func add(a, b int) int { + return a + b +} + +func main() { + total := 0 + for i := 1; i <= 2; i++ { + total = add(total, i) // line 14: breakpoint + fmt.Println("total", total) // line 15: logpoint + } + fmt.Println("done", total) +} diff --git a/editors/conformance/fixtures/go/go.mod b/editors/conformance/fixtures/go/go.mod new file mode 100644 index 0000000..5cc3420 --- /dev/null +++ b/editors/conformance/fixtures/go/go.mod @@ -0,0 +1,3 @@ +module blackbox.test/fixture + +go 1.21 diff --git a/editors/conformance/harness/nvim.js b/editors/conformance/harness/nvim.js new file mode 100644 index 0000000..3b6f8bb --- /dev/null +++ b/editors/conformance/harness/nvim.js @@ -0,0 +1,65 @@ +// Starts a headless Neovim with blackbox.nvim and nvim-dap, the way a user's +// Neovim would run it, for the conformance suite. +// +// NVIM_DAP_PATH where nvim-dap is checked out (default: lazy.nvim's folder) + +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const PLUGIN = path.resolve(__dirname, '../../neovim'); +const NVIM_DAP = process.env.NVIM_DAP_PATH || path.join(os.homedir(), '.local/share/nvim/lazy/nvim-dap'); + +function initScript(workspace) { + return ` +vim.opt.rtp:prepend(${JSON.stringify(PLUGIN)}) +vim.opt.rtp:prepend(${JSON.stringify(NVIM_DAP)}) +vim.cmd.cd(${JSON.stringify(workspace)}) +local dap = require('dap') +-- Delve, as nvim-dap-go would configure it. +dap.adapters.go = { + type = 'server', port = '\${port}', + executable = { command = 'dlv', args = { 'dap', '-l', '127.0.0.1:\${port}' } }, +} +-- An adapter that starts but never answers, for the stalled-start check. +dap.adapters['blackbox-stall'] = { + type = 'executable', command = ${JSON.stringify(process.execPath)}, args = { '-e', 'setInterval(function () {}, 1000)' }, +} +require('blackbox').setup() +`; +} + +async function startNvim({ ipcDir, workspace }) { + const init = path.join(ipcDir, 'init.lua'); + fs.writeFileSync(init, initScript(workspace)); + const nvim = spawn('nvim', ['--headless', '-u', init], { + cwd: workspace, + env: { ...process.env, BLACKBOX_IPC_DIR: ipcDir }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + nvim.stderr.on('data', chunk => { stderr += chunk; }); + + // Ready once its registry entry exists. + const registry = path.join(ipcDir, `${nvim.pid}.json`); + for (let i = 0; i < 100 && !fs.existsSync(registry); i++) { + await new Promise(r => setTimeout(r, 100)); + } + if (!fs.existsSync(registry)) { + nvim.kill(); + throw new Error(`Neovim did not register within 10s.\n${stderr}`); + } + return { + name: 'Neovim', + // Resolves at once if Neovim already exited (e.g. crashed during a test). + stop: () => new Promise(resolve => { + if (nvim.exitCode !== null || nvim.signalCode !== null) { return resolve(); } + nvim.once('exit', resolve); + nvim.kill('SIGTERM'); + }), + stderr: () => stderr, + }; +} + +module.exports = { startNvim }; diff --git a/editors/conformance/package-lock.json b/editors/conformance/package-lock.json new file mode 100644 index 0000000..53ac6cb --- /dev/null +++ b/editors/conformance/package-lock.json @@ -0,0 +1,2169 @@ +{ + "name": "blackbox-conformance", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "blackbox-conformance", + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "mocha": "^10.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz", + "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.0.tgz", + "integrity": "sha512-8BviX/hK4Gd2eL1KdTwm0gfl4d3wdoErW0Jd00h6nomUAtk6IJk5vwQJc6kfWtJTzN5OEV/lgIiJKI8fiDGAlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hono": { + "version": "4.13.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz", + "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha": { + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workerpool": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/editors/conformance/package.json b/editors/conformance/package.json new file mode 100644 index 0000000..f3f3449 --- /dev/null +++ b/editors/conformance/package.json @@ -0,0 +1,12 @@ +{ + "name": "blackbox-conformance", + "private": true, + "description": "Editor-independent checks that an editor's Blackbox window side implements the tool contract, driven through the real MCP server.", + "scripts": { + "test:nvim": "mocha --ui tdd --timeout 120000 \"test/**/*.test.js\"" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "mocha": "^10.0.0" + } +} diff --git a/editors/conformance/test/loop.test.js b/editors/conformance/test/loop.test.js new file mode 100644 index 0000000..437c3ea --- /dev/null +++ b/editors/conformance/test/loop.test.js @@ -0,0 +1,167 @@ +// Conformance: the AI debugging loop against an editor's window side, through +// the real Blackbox MCP server, on the Go fixture (delve). Mirrors +// editors/vscode/src/test/suite/loop.test.ts. +// +// BLACKBOX_SERVER MCP server to use (default: the VS Code build's out/mcp/server.js) + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { Client } = require('@modelcontextprotocol/sdk/client/index.js'); +const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js'); +const { startNvim } = require('../harness/nvim'); + +const WORKSPACE = fs.realpathSync(path.resolve(__dirname, '../fixtures/go')); +const FIXTURE = path.join(WORKSPACE, 'app.go'); +const BREAKPOINT_LINE = 14; +const LOGPOINT_LINE = 15; +const SERVER = process.env.BLACKBOX_SERVER || path.resolve(__dirname, '../../vscode/out/mcp/server.js'); + +suite('Debug loop (Go fixture, Neovim)', () => { + let ipcDir; + let editor; + let client; + + async function call(tool, args = {}) { + const result = await client.callTool({ name: tool, arguments: args }); + const text = result.content[0].text; + if (result.isError) { throw new Error(`${tool}: ${text}`); } + return text; + } + const callJson = async (tool, args) => JSON.parse(await call(tool, args)); + + suiteSetup(async function () { + this.timeout(30_000); + ipcDir = fs.realpathSync(fs.mkdtempSync(path.join(process.platform === 'win32' ? os.tmpdir() : '/tmp', 'bbx-conf-'))); + fs.chmodSync(ipcDir, 0o700); + editor = await startNvim({ ipcDir, workspace: WORKSPACE }); + client = new Client({ name: 'conformance', version: '0' }); + await client.connect(new StdioClientTransport({ + command: process.execPath, + args: [SERVER], + cwd: WORKSPACE, + env: { ...process.env, BLACKBOX_IPC_DIR: ipcDir }, + })); + }); + + suiteTeardown(async () => { + await client?.close(); + await editor?.stop(); + fs.rmSync(ipcDir, { recursive: true, force: true }); + }); + + teardown(async function () { + if (this.currentTest?.state === 'failed') { console.error(editor.stderr()); } + await call('debug_stop').catch(() => undefined); + await call('debug_remove_all_breakpoints').catch(() => undefined); + }); + + test('the window is listed with its editor', async () => { + const [window] = await callJson('ide_list_windows'); + assert.strictEqual(window.app.name, editor.name); + assert.strictEqual(window.reachable, true); + assert.deepStrictEqual(window.folders, [WORKSPACE]); + }); + + test('start by name, wait, step, read logpoints, stop', async () => { + await call('debug_set_breakpoint', { breakpoints: [ + { file: FIXTURE, line: BREAKPOINT_LINE }, + { file: FIXTURE, line: LOGPOINT_LINE, logMessage: 'logpoint total={total}' }, + ] }); + assert.match(await call('debug_start', { configName: 'Fixture' }), /started/); + + const hit = await callJson('debug_wait_for_stop', { timeoutMs: 60_000 }); + assert.strictEqual(hit.state, 'stopped', JSON.stringify(hit)); + assert.strictEqual(hit.line, BREAKPOINT_LINE, JSON.stringify(hit)); + assert.ok(hit.file.endsWith('app.go')); + + const stepped = await callJson('debug_step_over'); + assert.strictEqual(stepped.state, 'stopped', JSON.stringify(stepped)); + assert.strictEqual(stepped.line, LOGPOINT_LINE); + + const filters = await callJson('debug_set_exception_breakpoints'); + assert.ok(Array.isArray(filters.available), JSON.stringify(filters)); + + await call('debug_remove_breakpoint', { file: FIXTURE, line: BREAKPOINT_LINE }); + assert.match(await call('debug_continue'), /debug_wait_for_stop/); + const ended = await callJson('debug_wait_for_stop', { timeoutMs: 30_000 }); + assert.strictEqual(ended.state, 'terminated', JSON.stringify(ended)); + + const output = await callJson('debug_get_output', { match: 'logpoint total=' }); + const texts = output.entries.map(e => e.text).join(''); + assert.match(texts, /logpoint total=1/); + assert.match(texts, /logpoint total=3/); + }); + + test('an unknown launch configuration name lists the available ones', async () => { + await assert.rejects(call('debug_start', { configName: 'Nope' }), /Available: Fixture/); + }); + + test('hit counts, threads, frames, set variable, toggle, function breakpoints, run to line', async () => { + await call('debug_set_breakpoint', { file: FIXTURE, line: BREAKPOINT_LINE, hitCondition: '2' }); + await call('debug_start', { configName: 'Fixture' }); + const hit = await callJson('debug_wait_for_stop', { timeoutMs: 60_000 }); + assert.strictEqual(hit.line, BREAKPOINT_LINE, JSON.stringify(hit)); + assert.strictEqual((await callJson('debug_evaluate', { expression: 'i' })).result, '2', 'hit condition skips the first pass'); + + const { threads } = await callJson('debug_list_threads'); + assert.ok(threads.some(t => t.id === hit.threadId && t.stopped), JSON.stringify(threads)); + + const context = await callJson('debug_get_source_context', { lines: 1 }); + assert.deepStrictEqual(context.lines.map(l => l.line), [BREAKPOINT_LINE - 1, BREAKPOINT_LINE, BREAKPOINT_LINE + 1]); + assert.match(context.lines.find(l => l.current).text, /total = add\(total, i\)/); + + const frames = await callJson('debug_get_stack_trace', { levels: 2 }); + assert.ok(frames.length >= 1); + const locals = await callJson('debug_get_variables', { frameId: frames[0].id, filter: 'total' }); + assert.ok(JSON.stringify(locals).includes('"total"'), JSON.stringify(locals)); + + assert.match(await call('debug_set_variable', { name: 'total', value: '100' }), /100/); + assert.strictEqual((await callJson('debug_evaluate', { expression: 'total' })).result, '100'); + + assert.match(await call('debug_toggle_breakpoints', { enabled: false }), /Disabled 1/); + assert.strictEqual((await callJson('debug_list_breakpoints'))[0].enabled, false); + assert.match(await call('debug_toggle_breakpoints', { enabled: true, breakpoints: [{ file: FIXTURE, line: BREAKPOINT_LINE }] }), /Enabled 1/); + assert.strictEqual((await callJson('debug_list_breakpoints'))[0].hitCondition, '2', 'toggling keeps the hit condition'); + + assert.match(await call('debug_set_function_breakpoint', { name: 'main.add' }), /ok: function main.add/); + assert.ok((await callJson('debug_list_breakpoints')).some(b => b.type === 'function' && b.name === 'main.add')); + assert.match(await call('debug_remove_breakpoint', { functions: ['main.add'] }), /removed function main.add/); + + const reached = await callJson('debug_run_to_line', { file: FIXTURE, line: LOGPOINT_LINE, timeoutMs: 20_000 }); + assert.strictEqual(reached.reachedTarget, true, JSON.stringify(reached)); + // The changed variable took effect: add(100, 2). (Program stdout isn't + // reliably a DAP output event, e.g. delve prints it to its terminal.) + assert.strictEqual((await callJson('debug_evaluate', { expression: 'total' })).result, '102'); + const lines = (await callJson('debug_list_breakpoints')).map(b => b.line); + assert.deepStrictEqual(lines, [BREAKPOINT_LINE], 'the temporary breakpoint is removed'); + + await call('debug_remove_all_breakpoints'); + await call('debug_continue'); + assert.strictEqual((await callJson('debug_wait_for_stop', { timeoutMs: 30_000 })).state, 'terminated'); + }); + + test('after a stop, tools answer at once instead of talking to the ended session', async () => { + await call('debug_set_breakpoint', { file: FIXTURE, line: BREAKPOINT_LINE }); + await call('debug_start', { configName: 'Fixture' }); + assert.strictEqual((await callJson('debug_wait_for_stop', { timeoutMs: 60_000 })).line, BREAKPOINT_LINE); + await call('debug_stop'); + const started = Date.now(); + await assert.rejects(call('debug_evaluate', { expression: 'total' }), /no active debug session|session (has )?ended/); + assert.strictEqual((await callJson('debug_wait_for_stop', { timeoutMs: 10_000 })).state, 'terminated'); + assert.ok(Date.now() - started < 5_000, `answered in ${Date.now() - started}ms`); + }); + + test('a launch the adapter refuses is reported as an error, and nothing is left waiting', async () => { + await assert.rejects(call('debug_start', { configName: 'Fixture (broken)' }), /refused to launch/); + const after = await callJson('debug_wait_for_stop', { timeoutMs: 10_000 }); + assert.strictEqual(after.state, 'terminated', JSON.stringify(after)); + }); + + test('a start that never comes up fails with an explanation instead of hanging', async () => { + const started = Date.now(); + await assert.rejects(call('debug_start', { configName: 'Fixture (stalled)' }), /has not started after 20s/); + assert.ok(Date.now() - started < 30_000, 'answers within the stall limit'); + }); +}); diff --git a/editors/neovim/README.md b/editors/neovim/README.md index d1a9b2c..b1aef51 100644 --- a/editors/neovim/README.md +++ b/editors/neovim/README.md @@ -1,22 +1,57 @@ -# Blackbox โ€” Neovim Plugin - -> **Status**: Planned - -Neovim plugin implementing the Blackbox tool contract. - -## Architecture - -- **Language**: Lua -- **Debug**: [`nvim-dap`](https://github.com/mfussenegger/nvim-dap) (DAP client) -- **Editor**: `vim.api.nvim_buf_*`, `vim.fn.bufload` -- **Workspace**: `vim.fn.glob`, `vim.lsp.diagnostic` -- **MCP Transport**: Lua stdio server or Node.js MCP binary - -## Dependencies - -- Neovim >= 0.9 -- `nvim-dap` (required for debug tools) - -## Tool Contract - -All tools must match the schemas defined in [`/schema/tools.json`](../../schema/tools.json). +# Blackbox for Neovim + +> **Status:** working, not yet published. Install it from this repository (below). + +Lets AI tools (Claude Code, Cursor, any MCP client) drive Neovim's debugger: set breakpoints, start sessions by launch configuration name, wait for pauses, step, inspect and change variables, read logpoint output. It uses [nvim-dap](https://github.com/mfussenegger/nvim-dap), so it works with any debug adapter you've set up there (PHP/Xdebug, Go/delve, Python/debugpy, Node, โ€ฆ). + +It speaks the same protocol as the [VS Code extension](../vscode/), so one Blackbox MCP server sees Neovim next to VS Code, Cursor and Antigravity windows. + +## Requirements +- Neovim 0.10 or later +- [nvim-dap](https://github.com/mfussenegger/nvim-dap), with an adapter for your language +- The Blackbox MCP server. Today it comes with the VS Code extension: install Blackbox in VS Code (or Cursor/Antigravity) once and it keeps `~/.blackbox/blackbox-mcp.js` up to date. + +## Install (lazy.nvim) +```lua +{ + dir = "~/GitHub/blackbox/editors/neovim", -- this folder + dependencies = { "mfussenegger/nvim-dap" }, + opts = {}, -- calls require("blackbox").setup() +} +``` +Without a plugin manager: add this folder to `runtimepath` and call `require("blackbox").setup()`. + +## Connect your AI tool +Run `:BlackboxCopyMcpConfig` and add the copied entry under `mcpServers` in your MCP client's config (for Claude Code: `~/.claude.json`), then reconnect it. The entry is: +```json +{ "blackbox": { "command": "/path/to/node", "args": ["/Users//.blackbox/blackbox-mcp.js"] } } +``` + +## How a session finds this Neovim +- **`claude` (or another AI tool) started in Neovim's `:terminal`:** it uses this Neovim automatically. Neovim is a parent process, and `BLACKBOX_WINDOW` is set there. +- **Elsewhere:** the window whose working directory contains the AI tool's folder. If several windows match, the AI calls `ide_list_windows` and `ide_select_window`, where this window shows as `"app": "Neovim"`. + +## Commands +| Command | | +|---|---| +| `:BlackboxStatus` | Socket, folders, debug sessions, and whether the MCP launcher is installed | +| `:BlackboxCopyMcpConfig` | Copy an MCP server entry (absolute `node` path + launcher) | + +## Launch configurations +`debug_start {configName}` looks in each folder's `.vscode/launch.json` and in `dap.configurations`. The configuration's `type` must have an adapter in `dap.adapters`. For PHP, for example, install `php-debug-adapter` with `:MasonInstall php-debug-adapter` and point `dap.adapters.php` at it. + +## How it differs from VS Code +- **Disabled and function breakpoints:** nvim-dap has neither, so Blackbox keeps them itself. Disabled breakpoints aren't sent to the adapter. Function breakpoints are sent to running sessions and to each new one. +- **Program output:** depends on the adapter. Delve, for example, prints the program's stdout to its terminal, not as debug output, while logpoints always arrive. +- **Exception filters** are whatever the adapter offers, so the list may be empty. + +## Development +- **Lua unit tests:** + ```sh + nvim --headless --noplugin -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests/unit { minimal_init = 'tests/minimal_init.lua', sequential = true }" + ``` +- **Conformance:** the same checks as the VS Code extension, through the real MCP server, against a Go fixture with delve: + ```sh + (cd ../vscode && npm ci && npm run compile) && (cd ../conformance && npm ci && npm run test:nvim) + ``` + Set `NVIM_DAP_PATH` / `PLENARY_PATH` if they aren't in lazy.nvim's folder. diff --git a/editors/neovim/doc/blackbox.txt b/editors/neovim/doc/blackbox.txt new file mode 100644 index 0000000..7362031 --- /dev/null +++ b/editors/neovim/doc/blackbox.txt @@ -0,0 +1,48 @@ +*blackbox.txt* AI-driven debugging for Neovim through the Blackbox MCP server + +============================================================================== +CONTENTS *blackbox* + + Setup ............................................ |blackbox-setup| + Commands ......................................... |blackbox-commands| + Choosing a window ................................ |blackbox-windows| + Launch configurations ............................ |blackbox-launch| + +============================================================================== +SETUP *blackbox-setup* + +Requires Neovim 0.10+ and nvim-dap. Call: >lua + require('blackbox').setup() +< +This starts a small bridge: a private socket in /tmp/blackbox-/ and a +registry entry describing this Neovim. The Blackbox MCP server +(~/.blackbox/blackbox-mcp.js, installed with the VS Code extension) finds it +there and forwards AI tool calls to it. + +============================================================================== +COMMANDS *blackbox-commands* + +:BlackboxStatus *:BlackboxStatus* + Show the bridge socket, folders, debug sessions and whether the MCP + launcher is installed. + +:BlackboxCopyMcpConfig *:BlackboxCopyMcpConfig* + Copy an MCP server entry for your AI tool's config (absolute node path + and the launcher) to the + and " registers. + +============================================================================== +CHOOSING A WINDOW *blackbox-windows* + +An AI tool started in |:terminal| uses this Neovim automatically +($BLACKBOX_WINDOW is set there). Otherwise the MCP server picks the window +whose working directory contains the tool's folder, or asks the AI to choose +with ide_list_windows / ide_select_window. + +============================================================================== +LAUNCH CONFIGURATIONS *blackbox-launch* + +debug_start {configName} looks in each folder's .vscode/launch.json and in +|dap.configurations|. Its type needs an adapter in dap.adapters, e.g. for +PHP install php-debug-adapter with :MasonInstall and set dap.adapters.php. + + vim:tw=78:ts=8:ft=help:norl: diff --git a/editors/neovim/lua/blackbox/commands.lua b/editors/neovim/lua/blackbox/commands.lua new file mode 100644 index 0000000..755e87f --- /dev/null +++ b/editors/neovim/lua/blackbox/commands.lua @@ -0,0 +1,58 @@ +-- :BlackboxStatus and :BlackboxCopyMcpConfig. + +local uv = vim.uv or vim.loop + +local M = {} + +local function launcher_path() + local home = os.getenv('BLACKBOX_HOME') + if not home or home == '' then home = vim.fs.joinpath(uv.os_homedir(), '.blackbox') end + return vim.fs.joinpath(home, 'blackbox-mcp.js') +end + +-- Absolute path of node through the login shell: apps started from the Dock +-- often lack nvm & co. on their PATH (same as findNode in the VS Code side). +local function find_node() + local cmd = vim.fn.has('win32') == 1 and { 'where', 'node' } or { os.getenv('SHELL') or '/bin/sh', '-lc', 'command -v node' } + local ok, result = pcall(vim.fn.systemlist, cmd) + if ok and vim.v.shell_error == 0 then + for _, line in ipairs(result) do + if vim.fn.isabsolutepath ~= nil and vim.fn.isabsolutepath(line) == 1 then return line end + if line:sub(1, 1) == '/' then return line end + end + end + return 'node' +end + +function M.status() + local blackbox = require('blackbox') + local server = blackbox.state.server + local lines = {} + if not server then + table.insert(lines, 'Bridge: not running (call require("blackbox").setup())') + else + table.insert(lines, 'Bridge: listening on ' .. server.entry.socket) + table.insert(lines, 'Window id: ' .. server.entry.id .. ' folders: ' .. table.concat(server.entry.folders, ', ')) + end + local ok, dap = pcall(require, 'dap') + if ok then + local count = vim.tbl_count(dap.sessions()) + table.insert(lines, ('Debug sessions: %d%s'):format(count, dap.session() and (' (focused: ' .. dap.session().config.name .. ')') or '')) + else + table.insert(lines, 'nvim-dap: not installed') + end + local launcher = launcher_path() + table.insert(lines, uv.fs_stat(launcher) and ('MCP launcher: ' .. launcher) + or 'MCP launcher: not installed yet (install Blackbox in VS Code once, or run the MCP server directly)') + vim.notify(table.concat(lines, '\n'), vim.log.levels.INFO, { title = 'Blackbox' }) +end + +function M.copy_mcp_config() + local config = { blackbox = { command = find_node(), args = { launcher_path() } } } + local text = vim.json.encode(config) + vim.fn.setreg('+', text) + vim.fn.setreg('"', text) + vim.notify('Blackbox MCP server configuration copied (add it under "mcpServers"):\n' .. text, vim.log.levels.INFO) +end + +return M diff --git a/editors/neovim/lua/blackbox/events.lua b/editors/neovim/lua/blackbox/events.lua new file mode 100644 index 0000000..1abefc9 --- /dev/null +++ b/editors/neovim/lua/blackbox/events.lua @@ -0,0 +1,256 @@ +-- Follows nvim-dap's sessions so tools can wait for the debugger to pause and +-- read program output (port of editors/vscode/src/tools/impl/debugEvents.ts). +-- +-- Pause state is kept per thread: some adapters (Xdebug) pause each thread, +-- i.e. each HTTP request, on its own. Waits cover every session, because some +-- adapters (js-debug) pause a child session rather than the one started. + +local uv = vim.uv or vim.loop + +local M = {} + +local MAX_OUTPUT = 1000 +local DEFAULT_OUTPUT_LIMIT = 200 +-- Some adapters log their own protocol traffic as output (php-debug with +-- "log": true sends megabytes), so keep responses small. +local MAX_ENTRY_CHARS = 2000 +local MAX_OUTPUT_CHARS = 50000 +local NO_THREAD = -1 + +-- Responses after which threads run until their next stopped event. Only +-- continue-like ones: a stepped thread's stopped event can be processed +-- before the step's response, and Blackbox's own steps mark the thread as +-- running before sending the request anyway. +local RESUMING = { 'continue', 'reverseContinue' } + +local tracked = {} -- session id -> { session, paused = { [thread] = { stop, order } }, last_stop } +local listeners = {} -- set of waiter callbacks +local output, output_seq, stop_counter = {}, 0, 0 + +local function track(session) + local t = tracked[session.id] + if not t then + t = { session = session, paused = {} } + tracked[session.id] = t + end + return t +end + +local function notify(result) + for listener in pairs(listeners) do listener(result) end +end + +local function live_sessions() + local ok, dap = pcall(require, 'dap') + return ok and dap.sessions() or {} +end + +-- Event and response handlers; exposed for tests. +M.on = {} + +function M.on.stopped(session, body) + local t = track(session) + local stop = { reason = body.reason, threadId = body.threadId, description = body.description, text = body.text } + t.last_stop = stop + stop_counter = stop_counter + 1 + t.paused[body.threadId or NO_THREAD] = { stop = stop, order = stop_counter } + if body.allThreadsStopped then t.all_stopped = true end + notify({ kind = 'stopped', session = session, stop = stop }) +end + +-- Marks one thread, or with no thread id every thread, as running. +function M.on.resumed(session, thread_id) + local t = track(session) + if thread_id == nil then + t.paused = {} + else + t.paused[thread_id] = nil + t.paused[NO_THREAD] = nil + end +end + +-- As in DAP and debugEvents.ts: only the given thread, unless the adapter +-- says all threads continued. +function M.on.continued(session, body) + M.on.resumed(session, body.allThreadsContinued and nil or body.threadId) +end + +-- A response to a resuming request the user sent from Neovim. +-- A continue response resumes every thread unless it says otherwise. +function M.on.resume_response(session, err, response, request_args) + if err then return end + local thread = type(request_args) == 'table' and request_args.threadId or nil + local all = not (type(response) == 'table' and response.allThreadsContinued == false) + M.on.resumed(session, all and nil or thread) +end + +function M.on.output(session, body) + if type(body.output) ~= 'string' or body.category == 'telemetry' then return end + output_seq = output_seq + 1 + table.insert(output, { + seq = output_seq, + session = session.config and session.config.name or tostring(session.id), + category = body.category or 'console', + text = body.output, + }) + if #output > MAX_OUTPUT then table.remove(output, 1) end +end + +function M.on.ended(session, sessions_left) + tracked[session.id] = nil + require('blackbox.tools.common').session_closed(session) + if sessions_left == 0 then notify({ kind = 'terminated' }) end +end + +function M.attach(dap) + local key = 'blackbox' + local after = dap.listeners.after + after.event_stopped[key] = function(session, body) M.on.stopped(session, body or {}) end + after.event_continued[key] = function(session, body) M.on.continued(session, body or {}) end + after.event_output[key] = function(session, body) M.on.output(session, body or {}) end + for _, command in ipairs(RESUMING) do + after[command][key] = function(session, err, response, args) M.on.resume_response(session, err, response, args) end + end + -- Every way a session ends goes through Session:close() and its on_close + -- hooks (terminate response, disconnect timeout, adapter crash, ...), so + -- hook in as soon as the adapter is up. The hook may run in a libuv + -- callback, hence vim.schedule. + after.initialize[key] = function(session) + track(session) + session.on_close[key] = function(s) + vim.schedule(function() M.on.ended(s, M.live_count()) end) + end + end +end + +-- Sessions not closed yet (nvim-dap drops closed ones a little later). +function M.live_count() + local count = 0 + for _, s in pairs(live_sessions()) do + if not s.closed then count = count + 1 end + end + return count +end + +local function latest_pause(t) + local latest + for _, pause in pairs(t.paused) do + if not latest or pause.order > latest.order then latest = pause end + end + return latest +end + +-- The most recent pause still in effect, across sessions, with its location +-- when nvim-dap knows the current frame. +function M.current_stop() + local best + for _, t in pairs(tracked) do + local pause = not t.session.closed and latest_pause(t) or nil + if pause and (not best or pause.order > best.order) then + best = { order = pause.order, session = t.session, stop = pause.stop } + end + end + if not best then return nil end + local frame = best.session.current_frame + if frame and frame.source and frame.source.path then + best.location = { file = frame.source.path, line = frame.line } + end + return best +end + +function M.is_paused(session_id) + local t = tracked[session_id] + return t ~= nil and not t.session.closed and next(t.paused) ~= nil +end + +function M.paused_threads(session_id) + local ids = {} + for id in pairs((tracked[session_id] or {}).paused or {}) do + if id ~= NO_THREAD then table.insert(ids, id) end + end + return ids +end + +-- The most recent pause in effect in this session, else the last one seen. +function M.last_stop(session_id) + local t = tracked[session_id] + if not t then return nil end + local pause = latest_pause(t) + return pause and pause.stop or t.last_stop +end + +-- Starts listening now, before a request is sent, so a fast pause is not +-- missed. Returns wait(timeout_ms), to call from a coroutine. +function M.arm() + local result = M.live_count() == 0 and { kind = 'terminated' } or nil + local waiting -- the coroutine to resume + local listener + listener = function(r) + listeners[listener] = nil + result = r + if waiting then + local co = waiting + waiting = nil + coroutine.resume(co) + end + end + if not result then listeners[listener] = true end + + return function(timeout_ms) + if result then return result end + waiting = assert(coroutine.running(), 'wait() must run in a coroutine') + local timer = uv.new_timer() + timer:start(timeout_ms, 0, vim.schedule_wrap(function() + if waiting then listener({ kind = 'timeout' }) end + end)) + coroutine.yield() + timer:stop() + timer:close() + return result + end +end + +-- Returns the current pause at once, unless `next` asks for a new one. +function M.wait_for_stop(timeout_ms, next_only) + local current = not next_only and M.current_stop() or nil + if current then + return { kind = 'stopped', session = current.session, stop = current.stop } + end + return M.arm()(timeout_ms) +end + +local function truncate(text, max) + if #text <= max then return text end + return ('%sโ€ฆ (%d more characters)'):format(text:sub(1, max), #text - max) +end + +---@param query { since: integer?, category: string?, match: string?, limit: integer? } +function M.read_output(query) + local since, category = query.since or 0, query.category + local limit = math.max(1, query.limit or DEFAULT_OUTPUT_LIMIT) + local needle = query.match and query.match:lower() or nil + local matching = {} + for _, e in ipairs(output) do + if e.seq > since and (not category or e.category == category) + and (not needle or e.text:lower():find(needle, 1, true)) then + table.insert(matching, e) + end + end + local entries, budget = {}, MAX_OUTPUT_CHARS + for _, e in ipairs(matching) do + local text = truncate(e.text, MAX_ENTRY_CHARS) + if #entries >= limit or (#entries > 0 and #text > budget) then break end + table.insert(entries, { seq = e.seq, session = e.session, category = e.category, text = text }) + budget = budget - #text + end + local more = #entries < #matching + local next_since = more and entries[#entries].seq or math.max(since, output_seq) + return { entries = entries, nextSince = next_since, more = more } +end + +-- For tests: forget everything. +function M._reset() + tracked, listeners, output, output_seq, stop_counter = {}, {}, {}, 0, 0 +end + +return M diff --git a/editors/neovim/lua/blackbox/init.lua b/editors/neovim/lua/blackbox/init.lua new file mode 100644 index 0000000..b434756 --- /dev/null +++ b/editors/neovim/lua/blackbox/init.lua @@ -0,0 +1,93 @@ +-- Blackbox for Neovim: lets AI tools (Claude Code, Cursor, ...) drive this +-- Neovim's debugger through the Blackbox MCP server. +-- +-- require('blackbox').setup() + +local M = {} + +M.version = '0.1.0' + +local uv = vim.uv or vim.loop + +---@class blackbox.State +---@field server any +---@field focused boolean +M.state = { server = nil, focused = true } + +-- Folders this window works in: the global cwd and every tab-local cwd. +function M.folders() + local seen, folders = {}, {} + local function add(dir) + local real = dir and dir ~= '' and (uv.fs_realpath(dir) or dir) + if real and not seen[real] then + seen[real] = true + table.insert(folders, real) + end + end + add(vim.fn.getcwd(-1, -1)) + for tab = 1, vim.fn.tabpagenr('$') do + add(vim.fn.getcwd(-1, tab)) + end + return folders +end + +local function app_info() + local v = vim.version() + return { name = 'Neovim', scheme = 'nvim', version = ('%d.%d.%d'):format(v.major, v.minor, v.patch) } +end + +function M.start() + if M.state.server then return M.state.server end + local Server = require('blackbox.server') + local server = Server.new({ + folders = M.folders, + app = app_info(), + extension_version = M.version, + handlers = require('blackbox.tools.catalog').handlers(), + }) + server:start() + M.state.server = server + + -- AI tools started in :terminal inherit it and use this window. + vim.env.BLACKBOX_WINDOW = server.entry.id + + local group = vim.api.nvim_create_augroup('blackbox', { clear = true }) + vim.api.nvim_create_autocmd({ 'DirChanged', 'TabNew', 'TabClosed' }, { + group = group, callback = function() server:set_folders(M.folders()) end, + }) + vim.api.nvim_create_autocmd('FocusGained', { + group = group, callback = function() M.state.focused = true; server:mark_focused() end, + }) + vim.api.nvim_create_autocmd('FocusLost', { + group = group, callback = function() M.state.focused = false end, + }) + vim.api.nvim_create_autocmd('VimLeavePre', { + group = group, callback = function() M.stop() end, + }) + return server +end + +function M.stop() + if M.state.server then + M.state.server:dispose() + M.state.server = nil + end +end + +---@param opts table? reserved for future options +function M.setup(opts) + M.opts = opts or {} + local ok, dap = pcall(require, 'dap') + if ok then + require('blackbox.events').attach(dap) + require('blackbox.tools.breakpoints').attach(dap) + else + vim.notify('blackbox: nvim-dap is not installed; debug tools will report that', vim.log.levels.WARN) + end + local started, err = pcall(M.start) + if not started then + vim.notify('blackbox: could not start the MCP bridge (' .. tostring(err) .. ')', vim.log.levels.ERROR) + end +end + +return M diff --git a/editors/neovim/lua/blackbox/protocol.lua b/editors/neovim/lua/blackbox/protocol.lua new file mode 100644 index 0000000..9566d57 --- /dev/null +++ b/editors/neovim/lua/blackbox/protocol.lua @@ -0,0 +1,97 @@ +-- The Blackbox window protocol, shared with the VS Code extension +-- (editors/vscode/src/ipc/protocol.ts). Each editor window listens on its own +-- socket in a private per-user folder and describes itself in a registry +-- file next to it; the MCP server reads the registry to find windows. +-- +-- /.sock the socket (a named pipe on Windows) +-- /.json the registry entry + +local uv = vim.uv or vim.loop + +local M = {} + +-- Bumped when the registry entry or internal tools change shape. +M.PROTOCOL_VERSION = 2 +-- Internal tool the MCP server uses to describe windows. +M.STATUS_TOOL = 'window_status' + +local is_windows = vim.fn.has('win32') == 1 +M.is_windows = is_windows + +-- Same location rule as ipcDir() in protocol.ts: a fixed per-user folder, +-- because editors and MCP servers often see different TMPDIRs. +function M.ipc_dir() + local override = os.getenv('BLACKBOX_IPC_DIR') + if override and override ~= '' then + return override + end + if is_windows then + return vim.fs.joinpath(os.getenv('TEMP') or uv.os_tmpdir(), 'blackbox') + end + return '/tmp/blackbox-' .. uv.getuid() +end + +-- Windows named pipes share one namespace across users, so the name gets an +-- unguessable part that only the private registry file reveals. +function M.socket_path(id) + if is_windows then + local random = vim.fn.sha256(tostring(uv.hrtime()) .. tostring(math.random())):sub(1, 24) + return ('\\\\.\\pipe\\blackbox-%s-%s'):format(id, random) + end + return vim.fs.joinpath(M.ipc_dir(), id .. '.sock') +end + +function M.registry_path(id) + return vim.fs.joinpath(M.ipc_dir(), id .. '.json') +end + +local OTHERS_MASK = tonumber('077', 8) + +-- Error message unless dir is a real directory owned by this user and closed +-- to everyone else (mirrors assertPrivateDir). nil when it is fine. +function M.private_dir_problem(dir) + if is_windows then + return nil -- %TEMP% is already per-user. + end + local stat = uv.fs_lstat(dir) + if not stat or stat.type ~= 'directory' then + return dir .. ' is not a directory' + end + if stat.uid ~= uv.getuid() then + return dir .. ' is owned by another user' + end + if bit.band(stat.mode, OTHERS_MASK) ~= 0 then + return ('%s is accessible to other users (mode %o)'):format(dir, bit.band(stat.mode, tonumber('777', 8))) + end + return nil +end + +-- Creates the folder, or tightens one this user already owns; errors if +-- another user could still reach it (mirrors ensurePrivateDir). +function M.ensure_private_dir(dir) + if is_windows then + vim.fn.mkdir(dir, 'p') + return + end + local private = tonumber('700', 8) + uv.fs_mkdir(dir, private) + local stat = uv.fs_lstat(dir) + if stat and stat.type == 'directory' and stat.uid == uv.getuid() and bit.band(stat.mode, OTHERS_MASK) ~= 0 then + uv.fs_chmod(dir, private) + end + local problem = M.private_dir_problem(dir) + if problem then + error(problem, 0) + end +end + +-- Writes then renames, so readers never see a half-written file. +function M.write_atomic(path, content) + local tmp = ('%s.%d.tmp'):format(path, uv.os_getpid()) + local fd = assert(uv.fs_open(tmp, 'w', tonumber('600', 8))) + assert(uv.fs_write(fd, content)) + assert(uv.fs_close(fd)) + assert(uv.fs_rename(tmp, path)) +end + +return M diff --git a/editors/neovim/lua/blackbox/server.lua b/editors/neovim/lua/blackbox/server.lua new file mode 100644 index 0000000..816512e --- /dev/null +++ b/editors/neovim/lua/blackbox/server.lua @@ -0,0 +1,196 @@ +-- This Neovim instance's side of the Blackbox bridge (mirrors IPCServer in +-- editors/vscode/src/ipc/server.ts): a socket the MCP server connects to, +-- plus a registry entry describing this window. Requests are newline- +-- delimited JSON: {id, tool, args} -> {id, result} | {id, error}. + +local protocol = require('blackbox.protocol') + +local uv = vim.uv or vim.loop + +local HEALTH_CHECK_MS = 5000 + +-- Milliseconds since the epoch, like Date.now() on the VS Code side. +local function now_ms() + local sec, usec = uv.gettimeofday() + return sec * 1000 + math.floor(usec / 1000) +end + +local Server = {} +Server.__index = Server + +---@class blackbox.ServerOptions +---@field id string? window id (default: this process's pid) +---@field folders fun(): string[] +---@field app { name: string, scheme: string, version: string } +---@field extension_version string +---@field handlers table run inside a coroutine +---@field health_check_ms integer? + +---@param opts blackbox.ServerOptions +function Server.new(opts) + local id = opts.id or tostring(uv.os_getpid()) + local now = now_ms() + local self = setmetatable({ + handlers = opts.handlers, + folders = opts.folders, + health_check_ms = opts.health_check_ms or HEALTH_CHECK_MS, + connections = {}, + disposed = false, + entry = { + id = id, + pid = uv.os_getpid(), + socket = protocol.socket_path(id), + folders = opts.folders(), + startedAt = now, + focusedAt = now, + app = opts.app, + -- Neovim is a single process: it is both the window and the editor. + appPid = uv.os_getpid(), + extensionVersion = opts.extension_version, + protocol = protocol.PROTOCOL_VERSION, + }, + }, Server) + return self +end + +function Server:start() + protocol.ensure_private_dir(protocol.ipc_dir()) + self:listen() + self:write_registry() + self.health_timer = uv.new_timer() + self.health_timer:start(self.health_check_ms, self.health_check_ms, vim.schedule_wrap(function() + self:check_health() + end)) +end + +function Server:listen() + if not protocol.is_windows then + uv.fs_unlink(self.entry.socket) -- Leftover of a previous process with this pid. + end + local pipe = assert(uv.new_pipe(false)) + assert(pipe:bind(self.entry.socket)) + assert(pipe:listen(64, function(err) + if err then return end + local client = uv.new_pipe(false) + if pipe:accept(client) then + self:handle_connection(client) + else + client:close() + end + end)) + self.pipe = pipe + self.socket_ino = not protocol.is_windows and uv.fs_stat(self.entry.socket).ino or nil +end + +function Server:handle_connection(client) + self.connections[client] = true + local buffer = '' + client:read_start(function(err, chunk) + if err or not chunk then + self.connections[client] = nil + if not client:is_closing() then client:close() end + return + end + buffer = buffer .. chunk + while true do + local nl = buffer:find('\n', 1, true) + if not nl then break end + local line = buffer:sub(1, nl - 1) + buffer = buffer:sub(nl + 1) + -- Editor APIs can't be used from libuv callbacks: answer on the main loop. + vim.schedule(function() self:handle_message(line, client) end) + end + end) +end + +local function reply(client, response) + if client:is_closing() then return end -- The client hung up (e.g. timed out). + client:write(vim.json.encode(response) .. '\n') +end + +function Server:handle_message(line, client) + -- luanil: JSON null becomes nil, not vim.NIL (which is truthy). + local ok, request = pcall(vim.json.decode, line, { luanil = { object = true, array = true } }) + if not ok or type(request) ~= 'table' then return end + + local handler = self.handlers[request.tool] + if not handler then + return reply(client, { id = request.id, error = 'unknown tool: ' .. tostring(request.tool) }) + end + -- A coroutine, so handlers can wait for debug adapter responses. + coroutine.wrap(function() + local args = type(request.args) == 'table' and request.args or {} + local done, result = xpcall(handler, function(e) + -- Drop "file.lua:12: " so the AI sees just the message. + return type(e) == 'string' and (e:gsub('^[^:]*:%d+: ', '')) or tostring(e) + end, args) + if done then + reply(client, { id = request.id, result = result or '' }) + else + reply(client, { id = request.id, error = result }) + end + end)() +end + +function Server:write_registry() + if self.disposed then return end + local ok, err = pcall(protocol.write_atomic, protocol.registry_path(self.entry.id), vim.json.encode(self.entry)) + if not ok then + vim.notify('blackbox: could not write the registry entry: ' .. tostring(err), vim.log.levels.WARN) + end +end + +function Server:set_folders(folders) + self.entry.folders = folders + self:write_registry() +end + +function Server:mark_focused() + self.entry.focusedAt = now_ms() + self:write_registry() +end + +function Server:owns_socket() + local stat = uv.fs_stat(self.entry.socket) + return stat ~= nil and stat.ino == self.socket_ino +end + +-- Recreates the socket or registry file if something removed them. +function Server:check_health() + if self.disposed then return end + if not uv.fs_stat(protocol.registry_path(self.entry.id)) then + self:write_registry() + end + if protocol.is_windows or self:owns_socket() then return end + self:close_pipe() + local ok, err = pcall(self.listen, self) + if not ok then + vim.notify('blackbox: could not restart the bridge: ' .. tostring(err), vim.log.levels.WARN) + end +end + +-- Stops listening and drops open connections, so the address is free at once. +function Server:close_pipe() + if self.pipe and not self.pipe:is_closing() then self.pipe:close() end + self.pipe = nil + for client in pairs(self.connections) do + if not client:is_closing() then client:close() end + end + self.connections = {} +end + +function Server:dispose() + if self.disposed then return end + self.disposed = true + if self.health_timer then + self.health_timer:stop() + self.health_timer:close() + end + self:close_pipe() + if not protocol.is_windows and self:owns_socket() then + uv.fs_unlink(self.entry.socket) + end + uv.fs_unlink(protocol.registry_path(self.entry.id)) +end + +return Server diff --git a/editors/neovim/lua/blackbox/tools/breakpoints.lua b/editors/neovim/lua/blackbox/tools/breakpoints.lua new file mode 100644 index 0000000..b60461a --- /dev/null +++ b/editors/neovim/lua/blackbox/tools/breakpoints.lua @@ -0,0 +1,302 @@ +-- Breakpoint tools on nvim-dap's breakpoint store. nvim-dap has no function +-- breakpoints and no disabled state, so Blackbox keeps those two lists +-- itself and sends them to the debug adapters. + +local common = require('blackbox.tools.common') + +local M = {} + +local SYNC_MS = 1000 + +local function_breakpoints = {} -- { name, condition, hitCondition, logMessage, enabled } +local disabled = {} -- source breakpoints switched off: { file, line, condition, hitCondition, logMessage } + +-- Loads nvim-dap itself first: that runs its plugin/ setup (breakpoint +-- signs), including when a plugin manager loads nvim-dap lazily. +local function store() + common.dap() + return require('dap.breakpoints') +end + +local function bufnr_for(file) + local bufnr = vim.fn.bufadd(file) + vim.fn.bufload(bufnr) + return bufnr +end + +local function file_of(bufnr) return vim.api.nvim_buf_get_name(bufnr) end + +-- Root sessions and their children (js-debug runs the program in a child). +local function all_sessions() + local all = {} + for _, s in pairs(common.dap().sessions()) do + table.insert(all, s) + for _, child in pairs(s.children or {}) do table.insert(all, child) end + end + return all +end + +-- Sends the current breakpoints of these buffers to every running debug +-- adapter and waits (up to 1s) until all have answered, so a continue right +-- after a change can't race it. +local function sync(bufnrs) + local all = all_sessions() + if #all == 0 or #bufnrs == 0 then return end + local done, wait = common.counter(#all) + for _, session in ipairs(all) do + local payload = {} + for _, bufnr in ipairs(bufnrs) do + payload[bufnr] = store().get(bufnr)[bufnr] or {} + end + session:set_breakpoints(payload, done) + end + wait(SYNC_MS) +end + +local function send_function_breakpoints(session) + if not session.capabilities.supportsFunctionBreakpoints then return end + local enabled = {} + for _, bp in ipairs(function_breakpoints) do + if bp.enabled then + table.insert(enabled, { name = bp.name, condition = bp.condition, hitCondition = bp.hitCondition }) + end + end + session:request('setFunctionBreakpoints', { breakpoints = enabled }, function() end) +end + +local function sync_functions() + for _, session in ipairs(all_sessions()) do send_function_breakpoints(session) end +end + +-- New sessions get the function breakpoints once they are initialized. +function M.attach(dap) + dap.listeners.after.event_initialized['blackbox_function_breakpoints'] = function(session) + if #function_breakpoints > 0 then send_function_breakpoints(session) end + end +end + +local function describe(spec) + return spec.file .. ':' .. spec.line + .. (spec.condition and (' (if: ' .. spec.condition .. ')') or '') + .. (spec.hitCondition and (' (hits: ' .. spec.hitCondition .. ')') or '') + .. (spec.logMessage and (' (log: ' .. spec.logMessage .. ')') or '') +end + +local function unsupported_note(capability, feature) + local session = common.find_session() + if session and next(session.capabilities or {}) and not session.capabilities[capability] then + return ('\nnote: the "%s" debug adapter does not support %s; it may be ignored'):format(session.config.type, feature) + end + return '' +end + +local function add(spec) + store().set({ condition = spec.condition, hit_condition = spec.hitCondition, log_message = spec.logMessage }, + bufnr_for(spec.file), spec.line) +end + +local function remove_disabled(file, line) + for i = #disabled, 1, -1 do + if common.same_file(disabled[i].file, file) and disabled[i].line == line then table.remove(disabled, i) end + end +end + +function M.set(args) + local specs = args.breakpoints or { { + file = args.file, line = args.line, condition = args.condition, hitCondition = args.hitCondition, logMessage = args.logMessage, + } } + local results, bufnrs = {}, {} + for _, spec in ipairs(specs) do + if not spec.file or not spec.line or spec.line < 1 then + table.insert(results, 'skip: invalid โ€” file and line (>= 1) required') + else + add(spec) + remove_disabled(spec.file, spec.line) + table.insert(bufnrs, bufnr_for(spec.file)) + table.insert(results, 'ok: ' .. describe(spec)) + end + end + sync(bufnrs) + local hits = vim.iter(specs):any(function(s) return s.hitCondition ~= nil end) + return table.concat(results, '\n') + .. (hits and unsupported_note('supportsHitConditionalBreakpoints', 'hit conditions') or '') +end + +function M.set_function(args) + if not args.name or args.name == '' then error('function name is required', 0) end + for i = #function_breakpoints, 1, -1 do + if function_breakpoints[i].name == args.name then table.remove(function_breakpoints, i) end + end + table.insert(function_breakpoints, { + name = args.name, condition = args.condition, hitCondition = args.hitCondition, logMessage = args.logMessage, enabled = true, + }) + sync_functions() + return 'ok: function ' .. args.name + .. (args.condition and (' (if: ' .. args.condition .. ')') or '') + .. (args.hitCondition and (' (hits: ' .. args.hitCondition .. ')') or '') + .. unsupported_note('supportsFunctionBreakpoints', 'function breakpoints') +end + +local function source_at(file, line) + local bufnr = common.buffer_for(file) + if bufnr == -1 then return nil end + for _, bp in ipairs(store().get(bufnr)[bufnr] or {}) do + if bp.line == line then return bufnr, bp end + end + return nil +end + +function M.remove(args) + local specs = args.breakpoints or (args.file and { { file = args.file, line = args.line } } or {}) + local results, bufnrs = {}, {} + for _, spec in ipairs(specs) do + local bufnr = source_at(spec.file, spec.line) + local was_disabled = vim.iter(disabled):any(function(d) return common.same_file(d.file, spec.file) and d.line == spec.line end) + if bufnr then + store().remove(bufnr, spec.line) + table.insert(bufnrs, bufnr) + end + remove_disabled(spec.file, spec.line) + table.insert(results, (bufnr or was_disabled) and ('ok: removed ' .. spec.file .. ':' .. spec.line) + or ('skip: no breakpoint at ' .. spec.file .. ':' .. spec.line)) + end + local functions_changed = false + for _, name in ipairs(args.functions or {}) do + local before = #function_breakpoints + function_breakpoints = vim.tbl_filter(function(bp) return bp.name ~= name end, function_breakpoints) + local removed = #function_breakpoints < before + functions_changed = functions_changed or removed + table.insert(results, removed and ('ok: removed function ' .. name) or ('skip: no function breakpoint on ' .. name)) + end + sync(bufnrs) + if functions_changed then sync_functions() end + return #results > 0 and table.concat(results, '\n') or 'Nothing to remove: pass file+line, breakpoints or functions' +end + +function M.remove_all() + local count = #disabled + #function_breakpoints + local bufnrs = {} + for bufnr, bps in pairs(store().get()) do + count = count + #bps + table.insert(bufnrs, bufnr) + end + if count == 0 then return 'No breakpoints to remove' end + store().clear() + disabled, function_breakpoints = {}, {} + sync(bufnrs) + sync_functions() + return 'Removed all ' .. count .. ' breakpoint(s)' +end + +function M.toggle(args) + if type(args.enabled) ~= 'boolean' then error('"enabled" (true or false) is required', 0) end + local all = args.breakpoints == nil and args.functions == nil + local changed, already, bufnrs = 0, 0, {} + + local function wanted_source(file, line) + if all then return true end + return vim.iter(args.breakpoints or {}):any(function(b) return common.same_file(b.file, file) and b.line == line end) + end + + if args.enabled then + for i = #disabled, 1, -1 do + local d = disabled[i] + if wanted_source(d.file, d.line) then + add(d) + table.insert(bufnrs, bufnr_for(d.file)) + table.remove(disabled, i) + changed = changed + 1 + end + end + else + for bufnr, bps in pairs(store().get()) do + for _, bp in ipairs(bps) do + if wanted_source(file_of(bufnr), bp.line) then + table.insert(disabled, { file = file_of(bufnr), line = bp.line, condition = bp.condition, hitCondition = bp.hitCondition, logMessage = bp.logMessage }) + store().remove(bufnr, bp.line) + table.insert(bufnrs, bufnr) + changed = changed + 1 + end + end + end + end + for _, bp in ipairs(function_breakpoints) do + if all or vim.tbl_contains(args.functions or {}, bp.name) then + if bp.enabled == args.enabled then + already = already + 1 + else + bp.enabled = args.enabled + changed = changed + 1 + end + end + end + sync(bufnrs) + sync_functions() + return ('%s %d breakpoint(s)'):format(args.enabled and 'Enabled' or 'Disabled', changed) + .. (already > 0 and ('; %d already %s'):format(already, args.enabled and 'enabled' or 'disabled') or '') + .. ((changed + already) == 0 and '. No matching breakpoints.' or '') +end + +function M.list() + local result = {} + for bufnr, bps in pairs(store().get()) do + for _, bp in ipairs(bps) do + table.insert(result, { + type = 'source', file = file_of(bufnr), line = bp.line, enabled = true, + condition = bp.condition, hitCondition = bp.hitCondition, logMessage = bp.logMessage, + }) + end + end + for _, d in ipairs(disabled) do + table.insert(result, { + type = 'source', file = d.file, line = d.line, enabled = false, + condition = d.condition, hitCondition = d.hitCondition, logMessage = d.logMessage, + }) + end + table.sort(result, function(a, b) return a.file == b.file and a.line < b.line or a.file < b.file end) + for _, bp in ipairs(function_breakpoints) do + table.insert(result, { type = 'function', name = bp.name, enabled = bp.enabled, condition = bp.condition, hitCondition = bp.hitCondition }) + end + return #result > 0 and common.json(result) or '[]' +end + +-- Breakpoints the store doesn't count: disabled and function ones. +function M.extra_count() + return #disabled + #function_breakpoints +end + +-- Used by run_to_line: a plain breakpoint added and later removed. +function M.add_temporary(file, line) + if source_at(file, line) then return false end + add({ file = file, line = line }) + sync({ bufnr_for(file) }) + return true +end + +function M.remove_temporary(file, line) + local bufnr = source_at(file, line) + if bufnr then + store().remove(bufnr, line) + sync({ bufnr }) + end +end + +function M.set_exception_filters(args) + local session = common.active_session() + local available = session.capabilities.exceptionBreakpointFilters or {} + if args.filters == nil then + return common.json({ session = session.config.name, available = available }) + end + local known = {} + for _, f in ipairs(available) do known[f.filter] = true end + local unknown = vim.tbl_filter(function(f) return not known[f] end, args.filters) + if #available > 0 and #unknown > 0 then + error(('unknown exception filter(s): %s. Available: %s'):format( + table.concat(unknown, ', '), table.concat(vim.tbl_map(function(f) return f.filter end, available), ', ')), 0) + end + common.request(session, 'setExceptionBreakpoints', { filters = args.filters }) + return ('Exception breakpoints for "%s": %s. Note: this resets when a new debug session starts.'):format( + session.config.name, #args.filters > 0 and table.concat(args.filters, ', ') or '(none)') +end + +return M diff --git a/editors/neovim/lua/blackbox/tools/catalog.lua b/editors/neovim/lua/blackbox/tools/catalog.lua new file mode 100644 index 0000000..981dda6 --- /dev/null +++ b/editors/neovim/lua/blackbox/tools/catalog.lua @@ -0,0 +1,61 @@ +-- Every tool this plugin serves, by name (mirrors tools/catalog.ts in the +-- VS Code extension). Names and arguments follow schema/tools.json; the +-- ide_* tools are answered by the MCP server itself. + +local M = {} + +function M.handlers() + local editor = require('blackbox.tools.editor') + local bp = require('blackbox.tools.breakpoints') + local session = require('blackbox.tools.session') + local exec = require('blackbox.tools.execution') + local inspect = require('blackbox.tools.inspect') + + return { + -- Breakpoints + debug_set_breakpoint = bp.set, + debug_set_function_breakpoint = bp.set_function, + debug_remove_breakpoint = bp.remove, + debug_remove_all_breakpoints = bp.remove_all, + debug_toggle_breakpoints = bp.toggle, + debug_list_breakpoints = bp.list, + debug_set_exception_breakpoints = bp.set_exception_filters, + + -- Session control + debug_get_launch_configs = session.launch_configs, + debug_start = session.start, + debug_stop = session.stop, + debug_restart = session.restart, + + -- Execution + debug_continue = exec.continue, + debug_pause = exec.pause, + debug_step_over = exec.step_over, + debug_step_into = exec.step_into, + debug_step_out = exec.step_out, + debug_run_to_line = exec.run_to_line, + debug_wait_for_stop = exec.wait_for_stop, + + -- Inspection + debug_list_threads = inspect.list_threads, + debug_get_stack_trace = inspect.stack_trace, + debug_get_variables = inspect.variables, + debug_evaluate = inspect.evaluate, + debug_inspect = inspect.inspect, + debug_set_variable = inspect.set_variable, + debug_watch = inspect.watch, + debug_get_source_context = inspect.source_context, + debug_get_output = inspect.output, + + -- Editor and workspace + editor_open_file = editor.open_file, + editor_get_open_files = editor.get_open_files, + workspace_find_file = editor.find_file, + workspace_get_diagnostics = editor.get_diagnostics, + + -- Internal: describes this window to the MCP server. + window_status = editor.window_status, + } +end + +return M diff --git a/editors/neovim/lua/blackbox/tools/common.lua b/editors/neovim/lua/blackbox/tools/common.lua new file mode 100644 index 0000000..d64f0e3 --- /dev/null +++ b/editors/neovim/lua/blackbox/tools/common.lua @@ -0,0 +1,243 @@ +-- Helpers shared by the debug tools (mirrors the helper section of +-- editors/vscode/src/tools/impl/debug.ts). Everything here runs inside the +-- request coroutine started by server.lua. + +local events = require('blackbox.events') + +local uv = vim.uv or vim.loop + +local M = {} + +M.STEP_WAIT_MS = 10000 +M.PAUSE_WAIT_MS = 5000 +M.DEFAULT_STOP_WAIT_MS = 30000 +M.STOP_FRAMES = 5 + +function M.dap() + local ok, dap = pcall(require, 'dap') + if not ok then + error('nvim-dap is not installed; Blackbox debug tools need https://github.com/mfussenegger/nvim-dap', 0) + end + return dap +end + +-- The full text of a DAP error response: the short message plus the +-- adapter's detailed one (body.error.format, with its {variables} filled). +function M.error_text(err) + local detail = type(err) == 'table' and type(err.body) == 'table' and type(err.body.error) == 'table' and err.body.error or nil + local text = detail and detail.format and detail.format:gsub('{(%w+)}', function(name) + local value = (detail.variables or {})[name] + return value ~= nil and tostring(value) or ('{' .. name .. '}') + end) or nil + local message + if type(err) == 'table' then message = err.message else message = tostring(err) end + if text and message and text:sub(1, #message) ~= message then + return message .. ': ' .. text + end + if text then return text end -- Already includes the message (e.g. delve). + return message or text or 'unknown error' +end + +local REQUEST_TIMEOUT_MS = 60000 + +-- Requests waiting for an answer, per session: closing a session drops its +-- pending callbacks inside nvim-dap, so they are failed from here instead. +local pending = setmetatable({}, { __mode = 'k' }) + +-- Resumes a suspended coroutine once; later calls do nothing. +local function resumer(co) + local used = false + return function(...) + if used or coroutine.status(co) ~= 'suspended' then return end + used = true + coroutine.resume(co, ...) + end +end + +-- Sends a DAP request and waits for its response, failing if the session +-- closes or the adapter doesn't answer in time. +function M.request(session, command, arguments) + if session.closed then error('the debug session has ended', 0) end + local co = assert(coroutine.running(), 'request() must run in a coroutine') + local resume = resumer(co) + pending[session] = pending[session] or {} + pending[session][resume] = true + + local timer = uv.new_timer() + timer:start(REQUEST_TIMEOUT_MS, 0, vim.schedule_wrap(function() + resume({ message = ('the debug adapter did not answer %s within %ds'):format(command, REQUEST_TIMEOUT_MS / 1000) }) + end)) + session:request(command, arguments, function(err, response) resume(err, response) end) + local err, response = coroutine.yield() + timer:stop() + timer:close() + pending[session][resume] = nil + if err then + error(M.error_text(err), 0) + end + return response or {} +end + +-- Called when a session closes: fails every request still waiting on it. +function M.session_closed(session) + for resume in pairs(pending[session] or {}) do + resume({ message = 'the debug session ended' }) + end + pending[session] = nil +end + +-- The session commands act on: the focused one if it is paused, otherwise +-- the most recently paused one (adapters such as js-debug pause a child +-- session), otherwise the focused one. +function M.find_session() + local focused = M.dap().session() + if focused and events.is_paused(focused.id) then return focused end + local current = events.current_stop() + return current and current.session or focused +end + +function M.active_session() + return M.find_session() or error('no active debug session', 0) +end + +-- The session the user started; child sessions end with it. +function M.root_session(session) + while session.parent do session = session.parent end + return session +end + +-- The thread a command acts on: the one asked for, the one nvim-dap has +-- focused, the one that last paused, then the first thread. Thread ids can +-- be 0 (js-debug), so test for nil, not falsiness. +function M.resolve_thread(session, thread_id) + if thread_id ~= nil then return thread_id end + if session.stopped_thread_id ~= nil then return session.stopped_thread_id end + local stopped = events.last_stop(session.id) + if stopped and stopped.threadId ~= nil then return stopped.threadId end + local threads = M.request(session, 'threads', nil).threads or {} + if threads[1] == nil then + error('no threads โ€” is the debugger stopped at a breakpoint?', 0) + end + return threads[1].id +end + +local function to_frame(f) + return { + id = f.id, + name = f.name, + file = f.source and (f.source.path or f.source.name) or '(unknown)', + line = f.line, + } +end + +function M.stack_frames(session, thread_id, levels) + local response = M.request(session, 'stackTrace', { threadId = thread_id, startFrame = 0, levels = levels }) + return vim.tbl_map(to_frame, response.stackFrames or {}) +end + +function M.top_frame_id(session, thread_id) + local frame = M.stack_frames(session, thread_id, 1)[1] + if not frame then + error('no stack frames โ€” is the debugger stopped at a breakpoint?', 0) + end + return frame.id +end + +-- The frame to inspect: the one asked for, else the top of the chosen thread. +function M.frame_id_for(session, target) + if target.frameId ~= nil then return target.frameId end + return M.top_frame_id(session, M.resolve_thread(session, target.threadId)) +end + +function M.describe_stop(session, stop) + local thread_id = stop.threadId + if thread_id == nil then thread_id = M.resolve_thread(session) end + local ok, frames = pcall(M.stack_frames, session, thread_id, M.STOP_FRAMES) + frames = ok and frames or {} + local top = frames[1] or {} + return { + state = 'stopped', + session = session.config and session.config.name, + reason = stop.reason, + description = stop.description, + threadId = thread_id, + file = top.file, + line = top.line, + ['function'] = top.name, + frames = frames, + } +end + +function M.describe_wait(result) + if result.kind == 'stopped' then + return M.describe_stop(result.session, result.stop) + elseif result.kind == 'timeout' then + return { state = 'running', hint = 'Not paused yet. Call debug_wait_for_stop to keep waiting.' } + end + return { state = 'terminated' } +end + +-- vim.json.encode escapes "/" as "\/" (and 0.10 has no option to stop it). +-- Undo that for readability: a literal backslash is encoded as "\\", so +-- the remaining "\\/" still decodes to the same text. +function M.json(value) + return (vim.json.encode(value):gsub('\\/', '/')) +end + +-- Waits for `count` callbacks (or the timeout), from a coroutine. +-- Returns a function to pass as each callback, and a wait() function. +function M.counter(count) + local remaining, waiting, timed_out = count, nil, false + local function done() + remaining = remaining - 1 + if remaining <= 0 and waiting then + local co = waiting + waiting = nil + coroutine.resume(co) + end + end + local function wait(timeout_ms) + if remaining <= 0 then return true end + waiting = coroutine.running() + local timer = uv.new_timer() + timer:start(timeout_ms, 0, vim.schedule_wrap(function() + if waiting then + timed_out = true + local co = waiting + waiting = nil + coroutine.resume(co) + end + end)) + coroutine.yield() + timer:stop() + timer:close() + return not timed_out + end + return done, wait +end + +-- Lets the editor process pending events, from a coroutine. +function M.sleep(ms) + local co = coroutine.running() + vim.defer_fn(function() coroutine.resume(co) end, ms) + coroutine.yield() +end + +-- The buffer showing exactly this file, or -1. (vim.fn.bufnr() would also +-- accept a unique partial match, i.e. possibly another file.) +function M.buffer_for(file) + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + local name = vim.api.nvim_buf_get_name(bufnr) + if name ~= '' and M.same_file(name, file) then return bufnr end + end + return -1 +end + +-- Same file, seeing through symlinks (e.g. WordPress wp/ -> wordpress-develop/src/). +function M.same_file(a, b) + if not a or not b then return false end + if a == b then return true end + return (uv.fs_realpath(a) or vim.fs.normalize(a)) == (uv.fs_realpath(b) or vim.fs.normalize(b)) +end + +return M diff --git a/editors/neovim/lua/blackbox/tools/editor.lua b/editors/neovim/lua/blackbox/tools/editor.lua new file mode 100644 index 0000000..bdf48be --- /dev/null +++ b/editors/neovim/lua/blackbox/tools/editor.lua @@ -0,0 +1,118 @@ +-- Editor and workspace tools, plus window_status (mirrors +-- editors/vscode/src/tools/impl/editor.ts, workspace.ts and window.ts). + +local M = {} + +local json = require('blackbox.tools.common').json + +local function folders() + return require('blackbox').folders() +end + +function M.open_file(args) + local file, line = args.file, args.line + if not file or file == '' then error('file path is required', 0) end + if vim.fn.filereadable(file) == 0 then error('Error opening file: no such file ' .. file, 0) end + vim.cmd.edit(vim.fn.fnameescape(file)) + if line and line > 0 then + local last = vim.api.nvim_buf_line_count(0) + vim.api.nvim_win_set_cursor(0, { math.min(line, last), 0 }) + end + return 'Opened ' .. file .. (line and (':' .. line) or '') +end + +function M.get_open_files() + local current = vim.api.nvim_get_current_buf() + local files = {} + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + local name = vim.api.nvim_buf_get_name(buf) + if vim.bo[buf].buflisted and vim.bo[buf].buftype == '' and name ~= '' then + table.insert(files, { file = name, active = buf == current, dirty = vim.bo[buf].modified }) + end + end + return json(files) +end + +-- Turns a glob such as "**/*.php" into a Lua pattern on the relative path. +local function glob_to_pattern(glob) + local pattern = glob:gsub('[%^%$%(%)%%%.%[%]%+%-]', '%%%0') + pattern = pattern:gsub('%*%*/', '\1'):gsub('%*%*', '\2'):gsub('%*', '[^/]*'):gsub('%?', '[^/]') + pattern = pattern:gsub('\1', '.-'):gsub('\2', '.*') + return '^' .. pattern .. '$' +end + +function M.find_file(args) + local glob, limit = args.pattern, args.maxResults or 20 + if not glob or glob == '' then error('glob pattern is required', 0) end + local pattern = glob_to_pattern(glob) + local found = {} + for _, root in ipairs(folders()) do + for name, kind in vim.fs.dir(root, { + depth = 25, + -- dir is relative to root (e.g. packages/a/node_modules): skip at any depth. + skip = function(dir) + local name = vim.fs.basename(dir) + return name ~= 'node_modules' and name ~= '.git' + end, + }) do + if kind == 'file' and (name:match(pattern) or ('/' .. name):match(pattern)) then + table.insert(found, vim.fs.joinpath(root, name)) + if #found >= limit then return json(found) end + end + end + end + return json(found) +end + +local SEVERITY = { [vim.diagnostic.severity.ERROR] = 'error', [vim.diagnostic.severity.WARN] = 'warning' } + +function M.get_diagnostics(args) + local max = args.severity == 'error' and vim.diagnostic.severity.ERROR + or args.severity == 'warning' and vim.diagnostic.severity.WARN or nil + local bufnr = args.file and require('blackbox.tools.common').buffer_for(args.file) or nil + if args.file and bufnr == -1 then return 'No diagnostics found' end + local by_file, order = {}, {} + for _, d in ipairs(vim.diagnostic.get(bufnr)) do + if not max or d.severity <= max then + local file = vim.api.nvim_buf_get_name(d.bufnr) + if not by_file[file] then + by_file[file] = {} + table.insert(order, file) + end + table.insert(by_file[file], { + line = d.lnum + 1, severity = SEVERITY[d.severity] or 'info', message = d.message, source = d.source, + }) + end + end + if #order == 0 then return 'No diagnostics found' end + local result = {} + for _, file in ipairs(order) do + table.insert(result, { file = file, issues = by_file[file] }) + end + return json(result) +end + +function M.window_status() + local state = require('blackbox').state + local ok, dap = pcall(require, 'dap') + local breakpoints, debug = 0, vim.NIL + if ok then + for _, bps in pairs(require('dap.breakpoints').get()) do + breakpoints = breakpoints + #bps + end + breakpoints = breakpoints + require('blackbox.tools.breakpoints').extra_count() + local session = dap.session() + if session then + local stop = require('blackbox.events').current_stop() + debug = { + name = session.config.name or 'Debug', + type = session.config.type or '', + state = stop and 'stopped' or 'running', + stoppedAt = stop and stop.location or nil, + } + end + end + return json({ folders = folders(), focused = state.focused, breakpoints = breakpoints, debug = debug }) +end + +return M diff --git a/editors/neovim/lua/blackbox/tools/execution.lua b/editors/neovim/lua/blackbox/tools/execution.lua new file mode 100644 index 0000000..e821410 --- /dev/null +++ b/editors/neovim/lua/blackbox/tools/execution.lua @@ -0,0 +1,70 @@ +-- Continue, pause, step, run-to-line and waiting for the debugger to pause. + +local common = require('blackbox.tools.common') +local events = require('blackbox.events') + +local M = {} + +-- Whether nvim-dap's own command can do it: same session, its stopped +-- thread. Then Neovim's signs and UI stay in step with what happened. +local function nvim_dap_owns(session, thread_id) + return session == common.dap().session() and thread_id == session.stopped_thread_id +end + +-- Sends a resuming request and reports where the debugger pauses next. +local function resume_and_report(command, thread_id, wait_ms) + local session = common.active_session() + local thread = common.resolve_thread(session, thread_id) + local wait = events.arm() -- Before the request, so a fast pause is not missed. + if command ~= 'pause' then events.on.resumed(session, thread) end + local dap = common.dap() + local native = { next = dap.step_over, stepIn = dap.step_into, stepOut = dap.step_out } + if native[command] and nvim_dap_owns(session, thread) then + native[command]() + else + common.request(session, command, { threadId = thread }) + end + return common.json(common.describe_wait(wait(wait_ms))) +end + +function M.continue(args) + local session = common.active_session() + local thread = common.resolve_thread(session, args.threadId) + events.on.resumed(session, thread) + if nvim_dap_owns(session, thread) then + common.dap().continue() + else + common.request(session, 'continue', { threadId = thread }) + end + return 'Resumed execution. Call debug_wait_for_stop to wait for the next pause.' +end + +function M.pause(args) return resume_and_report('pause', args.threadId, common.PAUSE_WAIT_MS) end +function M.step_over(args) return resume_and_report('next', args.threadId, common.STEP_WAIT_MS) end +function M.step_into(args) return resume_and_report('stepIn', args.threadId, common.STEP_WAIT_MS) end +function M.step_out(args) return resume_and_report('stepOut', args.threadId, common.STEP_WAIT_MS) end + +function M.wait_for_stop(args) + return common.json(common.describe_wait(events.wait_for_stop(args.timeoutMs or common.DEFAULT_STOP_WAIT_MS, args.next))) +end + +function M.run_to_line(args) + if not args.file or not args.line or args.line < 1 then error('file and line (>= 1) are required', 0) end + local breakpoints = require('blackbox.tools.breakpoints') + local session = common.active_session() + local added = breakpoints.add_temporary(args.file, args.line) + local ok, report = pcall(function() + local thread = common.resolve_thread(session, args.threadId) + local wait = events.arm() + events.on.resumed(session, thread) + common.request(session, 'continue', { threadId = thread }) + local result = common.describe_wait(wait(args.timeoutMs or common.DEFAULT_STOP_WAIT_MS)) + result.reachedTarget = result.state == 'stopped' and result.line == args.line and common.same_file(result.file, args.file) + return result + end) + if added then breakpoints.remove_temporary(args.file, args.line) end + if not ok then error(report, 0) end + return common.json(report) +end + +return M diff --git a/editors/neovim/lua/blackbox/tools/inspect.lua b/editors/neovim/lua/blackbox/tools/inspect.lua new file mode 100644 index 0000000..5bc8abf --- /dev/null +++ b/editors/neovim/lua/blackbox/tools/inspect.lua @@ -0,0 +1,193 @@ +-- Threads, stack, variables, evaluation, watches, source context and output. + +local common = require('blackbox.tools.common') +local events = require('blackbox.events') + +local M = {} + +local watches = {} -- watch expressions, kept across steps and sessions + +function M.list_threads() + local session = common.active_session() + local response = common.request(session, 'threads', nil) + local paused = {} + for _, id in ipairs(events.paused_threads(session.id)) do paused[id] = true end + local threads = vim.tbl_map(function(t) + return { id = t.id, name = t.name, stopped = paused[t.id] or nil } + end, response.threads or {}) + return common.json({ session = session.config.name, threads = threads }) +end + +function M.stack_trace(args) + local session = common.active_session() + local levels = math.min(args.levels or 20, 200) + return common.json(common.stack_frames(session, common.resolve_thread(session, args.threadId), levels)) +end + +local function variable_view(v, expandable) + local view = { name = v.name, value = v.value, type = v.type ~= '' and v.type or nil } + if expandable then + view.expandable = (v.variablesReference or 0) > 0 + view.variablesReference = (v.variablesReference or 0) > 0 and v.variablesReference or nil + end + return view +end + +function M.variables(args) + local session = common.active_session() + if args.variablesReference then + local vars = common.request(session, 'variables', { variablesReference = args.variablesReference }).variables or {} + return common.json(vim.tbl_map(function(v) return variable_view(v, true) end, vars)) + end + local frame_id = common.frame_id_for(session, args) + local scopes = common.request(session, 'scopes', { frameId = frame_id }).scopes or {} + local result = vim.empty_dict() + local needle = args.filter and args.filter:lower() + for _, scope in ipairs(scopes) do + local vars = common.request(session, 'variables', { variablesReference = scope.variablesReference }).variables or {} + if needle then + vars = vim.tbl_filter(function(v) return v.name:lower():find(needle, 1, true) ~= nil end, vars) + end + result[scope.name] = vim.tbl_map(function(v) return variable_view(v, false) end, vars) + end + return common.json(result) +end + +function M.evaluate(args) + local session = common.active_session() + local response = common.request(session, 'evaluate', { + expression = args.expression, frameId = common.frame_id_for(session, args), context = 'repl', + }) + return common.json({ + expression = args.expression, + result = response.result, + type = response.type ~= '' and response.type or nil, + variablesReference = (response.variablesReference or 0) > 0 and response.variablesReference or nil, + }) +end + +local function expand(session, ref, depth, max_depth, max_items) + if depth >= max_depth or ref <= 0 then return '...' end + local items = common.request(session, 'variables', { variablesReference = ref }).variables or {} + local result = vim.empty_dict() + for i = 1, math.min(#items, max_items) do + local v = items[i] + result[v.name] = (v.variablesReference or 0) > 0 and expand(session, v.variablesReference, depth + 1, max_depth, max_items) or v.value + end + if #items > max_items then result['...'] = ('(%d more items)'):format(#items - max_items) end + return result +end + +function M.inspect(args) + if not args.variable or args.variable == '' then error('variable expression is required', 0) end + local session = common.active_session() + local max_depth = math.min(args.depth or 2, 5) + local max_items = math.min(args.maxItems or 50, 200) + local response = common.request(session, 'evaluate', { + expression = args.variable, frameId = common.frame_id_for(session, args), context = 'repl', + }) + local value = (response.variablesReference or 0) > 0 + and expand(session, response.variablesReference, 0, max_depth, max_items) or response.result + return common.json({ variable = args.variable, type = response.type ~= '' and response.type or nil, value = value }) +end + +-- The scope reference holding `name` in the given frame, if any. +local function scope_containing(session, frame_id, name) + for _, scope in ipairs(common.request(session, 'scopes', { frameId = frame_id }).scopes or {}) do + for _, v in ipairs(common.request(session, 'variables', { variablesReference = scope.variablesReference }).variables or {}) do + if v.name == name then return scope.variablesReference end + end + end + return nil +end + +function M.set_variable(args) + if not args.name or args.value == nil then error('"name" and "value" are required', 0) end + local session = common.active_session() + local caps = session.capabilities or {} + local frame_id = common.frame_id_for(session, args) + if caps.supportsSetVariable then + local ref = args.variablesReference or scope_containing(session, frame_id, args.name) + if ref then + local r = common.request(session, 'setVariable', { variablesReference = ref, name = args.name, value = args.value }) + return common.json({ name = args.name, value = r.value, type = r.type }) + end + end + if caps.supportsSetExpression then + local r = common.request(session, 'setExpression', { expression = args.name, value = args.value, frameId = frame_id }) + return common.json({ name = args.name, value = r.value, type = r.type }) + end + error(caps.supportsSetVariable + and ('no variable named "%s" in the current frame; pass variablesReference for a nested one'):format(args.name) + or ('the "%s" debug adapter cannot change variables'):format(session.config.type), 0) +end + +function M.watch(args) + local action, expressions = args.action, args.expressions or {} + if action == 'add' then + if #expressions == 0 then error('expressions array required for add', 0) end + for _, e in ipairs(expressions) do + if not vim.tbl_contains(watches, e) then table.insert(watches, e) end + end + return ('Watching %d expression(s). Total watches: %d\n%s'):format(#expressions, #watches, table.concat(watches, ', ')) + elseif action == 'remove' then + if #expressions == 0 then error('expressions array required for remove', 0) end + watches = vim.tbl_filter(function(e) return not vim.tbl_contains(expressions, e) end, watches) + return ('Removed %d. Remaining watches: %d'):format(#expressions, #watches) + elseif action == 'clear' then + local count = #watches + watches = {} + return ('Cleared all %d watch expression(s)'):format(count) + elseif action == 'list' then + if #watches == 0 then return 'No watch expressions set. Use action="add" first.' end + local session = common.find_session() + if not session then + return ('Watch expressions (%d): %s\n(No active debug session โ€” values not available)'):format(#watches, table.concat(watches, ', ')) + end + local frame_id = common.frame_id_for(session, args) + local result = vim.empty_dict() + for _, e in ipairs(watches) do + local ok, r = pcall(common.request, session, 'evaluate', { expression = e, frameId = frame_id, context = 'watch' }) + if not ok then + result[e] = '' + elseif (r.variablesReference or 0) > 0 then + result[e] = expand(session, r.variablesReference, 0, 1, 20) + else + result[e] = r.result + end + end + return common.json(result) + end + error('action must be add, remove, list, or clear', 0) +end + +local function read_lines(file) + local bufnr = common.buffer_for(file) + if bufnr ~= -1 and vim.api.nvim_buf_is_loaded(bufnr) then + return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) -- Includes unsaved edits. + end + return vim.fn.readfile(file) +end + +function M.source_context(args) + local session = common.active_session() + local radius = math.max(0, math.min(args.lines or 5, 50)) + local frames = common.stack_frames(session, common.resolve_thread(session, args.threadId), 200) + local frame = frames[1] + if args.frameId ~= nil then + frame = vim.iter(frames):find(function(f) return f.id == args.frameId end) + end + if not frame then error('no such stack frame โ€” is the debugger stopped at a breakpoint?', 0) end + local lines = read_lines(frame.file) + local result = {} + for n = math.max(1, frame.line - radius), math.min(#lines, frame.line + radius) do + table.insert(result, { line = n, text = lines[n], current = n == frame.line or nil }) + end + return common.json({ file = frame.file, line = frame.line, ['function'] = frame.name, lines = result }) +end + +function M.output(args) + return common.json(events.read_output(args)) +end + +return M diff --git a/editors/neovim/lua/blackbox/tools/session.lua b/editors/neovim/lua/blackbox/tools/session.lua new file mode 100644 index 0000000..60a3877 --- /dev/null +++ b/editors/neovim/lua/blackbox/tools/session.lua @@ -0,0 +1,203 @@ +-- Launch configurations and session control on nvim-dap. + +local common = require('blackbox.tools.common') + +local uv = vim.uv or vim.loop + +local M = {} + +local START_PROMPT_MS = 20000 +-- Below the MCP server's 60s limit for debug_start, so the note arrives. +local LAUNCH_MS = 45000 +local TICK_MS = 1000 + +-- Launch configurations: each folder's .vscode/launch.json, then +-- nvim-dap's own dap.configurations (per filetype). +local function launch_files() + local files = {} + local ok_vscode, vscode = pcall(require, 'dap.ext.vscode') + for _, folder in ipairs(require('blackbox').folders()) do + local path = vim.fs.joinpath(folder, '.vscode', 'launch.json') + if ok_vscode and uv.fs_stat(path) then + local ok, configs = pcall(vscode.getconfigs, path) + if ok and configs and #configs > 0 then + table.insert(files, { folder = folder, configurations = configs, from_launch_json = true }) + end + end + end + for filetype, configs in pairs(common.dap().configurations or {}) do + if type(configs) == 'table' and #configs > 0 then + table.insert(files, { folder = 'nvim-dap configurations (' .. filetype .. ')', configurations = configs }) + end + end + return files +end + +-- Configurations may hold functions (nvim-dap allows them); JSON shows a marker. +local function printable(value) + if type(value) == 'function' then return '' end + if type(value) ~= 'table' then return value end + local copy = {} + for k, v in pairs(value) do copy[k] = printable(v) end + return copy +end + +function M.launch_configs() + local files = vim.tbl_map(function(f) return { folder = f.folder, configurations = f.configurations } end, launch_files()) + if #files == 0 then return 'No launch configurations found (.vscode/launch.json or dap.configurations)' end + return common.json(printable(files)) +end + +-- Fills ${workspaceFolder} (and its basename) with the folder the +-- configuration came from: nvim-dap would use the current window's cwd, +-- which differs for configurations from another tab's folder. +local function with_folder(value, folder) + if type(value) == 'string' then + return (value:gsub('%${workspaceFolder}', function() return folder end) + :gsub('%${workspaceFolderBasename}', function() return vim.fs.basename(folder) end)) + elseif type(value) == 'table' then + local copy = {} + for k, v in pairs(value) do copy[k] = with_folder(v, folder) end + return copy + end + return value +end +M._with_folder = with_folder -- for tests + +-- The named configuration, with its folder filled in when it came from a +-- launch.json. +local function find_config(name, folder) + local names = {} + for _, file in ipairs(launch_files()) do + if not folder or file.folder == folder then + for _, config in ipairs(file.configurations) do + if config.name == name then + return file.from_launch_json and with_folder(config, file.folder) or vim.deepcopy(config) + end + table.insert(names, config.name) + end + end + end + error(('no launch configuration named "%s". Available: %s'):format( + name, #names > 0 and table.concat(names, ', ') or '(none)'), 0) +end + +local function check_adapter(config) + local dap = common.dap() + if not dap.adapters[config.type] then + error(('no nvim-dap adapter for "%s". Install one (e.g. :MasonInstall php-debug-adapter for PHP) ' + .. 'and define dap.adapters.%s'):format(config.type, config.type), 0) + end +end + +-- Starts the session and waits until nvim-dap has one, or explains what is +-- probably holding it up instead of hanging. +-- Starts the session and waits for the adapter's answer to the launch (or +-- attach) request: its error is returned instead of a false "started". +-- If no adapter comes up at all, explains what is probably holding it up +-- instead of hanging. +local function run_watched(config) + local dap = common.dap() + local co = coroutine.running() + local outcome -- nil while waiting; then 'started' | 'slow' | 'stalled' | { error = message } + local initialized = false + local key = 'blackbox_start_' .. tostring(uv.hrtime()) + local started_at = uv.now() + + local function finish(value) + if outcome then return end + outcome = value + vim.schedule(function() + if coroutine.status(co) == 'suspended' then coroutine.resume(co) end + end) + end + -- Works on every nvim-dap version (listeners.on_session is newer). + dap.listeners.after.initialize[key] = function(session) + initialized = true + session.on_close[key] = function() + finish({ error = 'the debug session ended before it launched' }) + end + end + for _, request in ipairs({ 'launch', 'attach' }) do + dap.listeners.after[request][key] = function(session, err) + session.on_close[key] = nil + finish(err and { error = common.error_text(err) } or 'started') + end + end + local timer = uv.new_timer() + timer:start(TICK_MS, TICK_MS, vim.schedule_wrap(function() + local elapsed = uv.now() - started_at + if not initialized and elapsed >= START_PROMPT_MS then + finish('stalled') + elseif initialized and elapsed >= LAUNCH_MS then + finish('slow') -- Up and initialized; the launch itself takes long (e.g. a big build). + end + end)) + + -- In a coroutine of its own: nvim-dap may yield inside run() (e.g. a + -- process picker), and its errors must not skip the cleanup below. + coroutine.wrap(function() + local ok, err = pcall(dap.run, config, { new = true }) + if not ok then finish({ error = tostring(err) }) end + end)() + if not outcome then coroutine.yield() end + + dap.listeners.after.initialize[key] = nil + dap.listeners.after.launch[key] = nil + dap.listeners.after.attach[key] = nil + timer:stop() + timer:close() + + if outcome == 'stalled' then + error(('the debugger has not started after %ds. Check the adapter (:DapShowLog) and any prompt ' + .. 'Neovim is showing; if it starts later, call debug_wait_for_stop rather than starting again.'):format(START_PROMPT_MS / 1000), 0) + elseif type(outcome) == 'table' then + error(('the debug adapter refused to %s: %s'):format(config.request, outcome.error), 0) + end + return outcome +end + +function M.start(args) + local config + if type(args.configName) == 'string' and args.configName ~= '' then + config = find_config(args.configName, args.folder) + else + if not args.type then error('"type" is required (e.g. php, go, python, node), or pass "configName"', 0) end + if not args.request then error('"request" is required (launch or attach)', 0) end + config = vim.deepcopy(args) + config.name = config.name or ('Debug (' .. config.type .. ')') + end + check_adapter(config) + local outcome = run_watched(config) + local note = outcome == 'slow' and ' (the adapter is still launching; call debug_wait_for_stop)' or '' + return ('Debug session "%s" started%s%s'):format(config.name, + args.configName and ' from its launch configuration' or (' (type: ' .. config.type .. ', request: ' .. config.request .. ')'), note) +end + +function M.stop() + local session = common.find_session() + if not session then return 'No active debug session' end + local root = common.root_session(session) + local dap = common.dap() + dap.set_session(root) + local done, wait = common.counter(1) + dap.terminate(nil, nil, done) + wait(5000) + return 'Debug session "' .. root.config.name .. '" stopped' +end + +function M.restart() + local session = common.find_session() + if not session then error('no active debug session', 0) end + local root = common.root_session(session) + if root.capabilities.supportsRestartRequest then + common.request(root, 'restart', {}) + return 'Debug session "' .. root.config.name .. '" restarted' + end + local dap = common.dap() + dap.set_session(root) + dap.restart() + return 'Debug session "' .. root.config.name .. '" restarted (terminated and started again)' +end + +return M diff --git a/editors/neovim/plugin/blackbox.lua b/editors/neovim/plugin/blackbox.lua new file mode 100644 index 0000000..a775f3b --- /dev/null +++ b/editors/neovim/plugin/blackbox.lua @@ -0,0 +1,11 @@ +-- Commands only; nothing starts until require('blackbox').setup(). +if vim.g.loaded_blackbox then return end +vim.g.loaded_blackbox = true + +vim.api.nvim_create_user_command('BlackboxStatus', function() + require('blackbox.commands').status() +end, { desc = 'Blackbox: show the MCP bridge status' }) + +vim.api.nvim_create_user_command('BlackboxCopyMcpConfig', function() + require('blackbox.commands').copy_mcp_config() +end, { desc = 'Blackbox: copy an MCP server configuration entry' }) diff --git a/editors/neovim/tests/minimal_init.lua b/editors/neovim/tests/minimal_init.lua new file mode 100644 index 0000000..db31099 --- /dev/null +++ b/editors/neovim/tests/minimal_init.lua @@ -0,0 +1,10 @@ +-- Test init: this plugin plus plenary and nvim-dap. +-- PLENARY_PATH / NVIM_DAP_PATH override where they are checked out. +local lazy = vim.fn.expand('~/.local/share/nvim/lazy') +local root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h:h') +vim.opt.rtp:prepend(root) +vim.opt.rtp:prepend(os.getenv('PLENARY_PATH') or (lazy .. '/plenary.nvim')) +vim.opt.rtp:prepend(os.getenv('NVIM_DAP_PATH') or (lazy .. '/nvim-dap')) +vim.cmd('runtime plugin/plenary.vim') +-- What a plugin manager does when it loads nvim-dap (defines its signs). +vim.cmd('runtime plugin/dap.lua') diff --git a/editors/neovim/tests/unit/events_spec.lua b/editors/neovim/tests/unit/events_spec.lua new file mode 100644 index 0000000..b755e2d --- /dev/null +++ b/editors/neovim/tests/unit/events_spec.lua @@ -0,0 +1,134 @@ +-- Port of editors/vscode/src/test/unit/events.test.ts: the events hub fed +-- with fake sessions, as nvim-dap's listeners would. +local events = require('blackbox.events') + +local function session(id, name) return { id = id, config = { name = name } } end + +-- Runs fn in a coroutine and waits for it (wait() needs one). +local function run(fn) + local result, finished + coroutine.wrap(function() result = fn(); finished = true end)() + vim.wait(2000, function() return finished end, 5) + return result +end + +describe('events', function() + local parent, child + before_each(function() + events._reset() + parent, child = session(1, 'Launch app'), session(2, 'app.go') + -- Pretend both sessions are live. + package.loaded['dap'] = { sessions = function() return { parent, child } end } + end) + after_each(function() package.loaded['dap'] = nil end) + + it('a wait armed before the request sees a fast pause', function() + local result = run(function() + local wait = events.arm() + events.on.stopped(child, { reason = 'step', threadId = 3 }) + return wait(1000) + end) + assert.are.same({ 'stopped', 2, 'step', 3 }, { result.kind, result.session.id, result.stop.reason, result.stop.threadId }) + end) + + it('a pause on a child session satisfies the wait', function() + local result = run(function() + vim.defer_fn(function() events.on.stopped(child, { reason = 'breakpoint', threadId = 1 }) end, 10) + return events.wait_for_stop(1000) + end) + assert.equals(2, result.session.id) + end) + + it('waiting while already paused returns at once; next waits for a new pause', function() + events.on.stopped(child, { reason = 'breakpoint', threadId = 2 }) + assert.equals('stopped', run(function() return events.wait_for_stop(1) end).kind) + local result = run(function() + vim.defer_fn(function() events.on.stopped(child, { reason = 'breakpoint', threadId = 3 }) end, 10) + return events.wait_for_stop(1000, true) + end) + assert.equals(3, result.stop.threadId) + assert.equals('timeout', run(function() return events.wait_for_stop(20, true) end).kind) + end) + + it('times out without a pause', function() + assert.equals('timeout', run(function() return events.wait_for_stop(20) end).kind) + end) + + it('resolves as terminated when the last session ends', function() + local result = run(function() + vim.defer_fn(function() events.on.ended(child, 0) end, 10) + return events.wait_for_stop(1000) + end) + assert.equals('terminated', result.kind) + end) + + it('tracks several paused threads in one session (Xdebug requests)', function() + events.on.stopped(child, { reason = 'breakpoint', threadId = 2 }) + events.on.stopped(child, { reason = 'breakpoint', threadId = 3 }) + local threads = events.paused_threads(2) + table.sort(threads) + assert.are.same({ 2, 3 }, threads) + assert.equals(3, events.current_stop().stop.threadId) + events.on.resumed(child, 3) + assert.are.same({ 2 }, events.paused_threads(2)) + assert.equals(2, events.last_stop(2).threadId) + end) + + it('a continued event affects only its thread unless all threads continued (as DAP says)', function() + events.on.stopped(child, { reason = 'breakpoint', threadId = 2 }) + events.on.stopped(child, { reason = 'breakpoint', threadId = 3 }) + events.on.continued(child, { threadId = 2 }) + assert.are.same({ 3 }, events.paused_threads(2)) + events.on.continued(child, { threadId = 3, allThreadsContinued = true }) + assert.are.same({}, events.paused_threads(2)) + end) + + it('a continue response resumes every thread unless it says otherwise', function() + events.on.stopped(child, { threadId = 2 }) + events.on.stopped(child, { threadId = 3 }) + events.on.resume_response(child, nil, { allThreadsContinued = false }, { threadId = 3 }) + assert.are.same({ 2 }, events.paused_threads(2)) + events.on.resume_response(child, nil, {}, { threadId = 2 }) + assert.is_false(events.is_paused(2)) + events.on.stopped(child, { threadId = 2 }) + events.on.resume_response(child, { message = 'no' }, nil, { threadId = 2 }) + assert.is_true(events.is_paused(2), 'a failed continue leaves it paused') + end) + + it('a closed session no longer counts as paused, and its waits end', function() + events.on.stopped(child, { threadId = 1 }) + child.closed = true + assert.is_false(events.is_paused(2)) + assert.is_nil(events.current_stop()) + assert.equals(1, events.live_count()) + end) + + it('keeps output with sequence numbers, filters and pages it', function() + events.on.output(child, { category = 'stdout', output = 'hello\n' }) + events.on.output(parent, { category = 'console', output = 'logpoint: x=1\n' }) + events.on.output(parent, { category = 'telemetry', output = 'ignored' }) + local all = events.read_output({}) + assert.are.same({ 1, 2 }, vim.tbl_map(function(e) return e.seq end, all.entries)) + assert.equals('app.go', all.entries[1].session) + assert.equals(2, all.nextSince) + assert.are.same({ 2 }, vim.tbl_map(function(e) return e.seq end, events.read_output({ since = 1 }).entries)) + assert.are.same({ 2 }, vim.tbl_map(function(e) return e.seq end, events.read_output({ match = 'LOGPOINT' }).entries)) + local page = events.read_output({ limit = 1 }) + assert.is_true(page.more) + assert.equals(1, page.nextSince) + end) + + it('shortens huge entries and keeps a response within its budget', function() + events.on.output(child, { output = ('x'):rep(3000000) }) + local huge = events.read_output({}).entries[1] + assert.is_true(#huge.text < 2100) + assert.is_truthy(huge.text:find('more characters%)$')) + events._reset() + for _ = 1, 60 do events.on.output(child, { output = ('y'):rep(1900) }) end + local page = events.read_output({}) + local size = 0 + for _, e in ipairs(page.entries) do size = size + #e.text end + assert.is_true(size <= 50000) + assert.is_true(page.more) + end) +end) diff --git a/editors/neovim/tests/unit/protocol_spec.lua b/editors/neovim/tests/unit/protocol_spec.lua new file mode 100644 index 0000000..4a6d531 --- /dev/null +++ b/editors/neovim/tests/unit/protocol_spec.lua @@ -0,0 +1,46 @@ +local protocol = require('blackbox.protocol') +local uv = vim.uv or vim.loop + +local function tmpdir() + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, 'p') + return uv.fs_realpath(dir) +end + +describe('protocol', function() + it('uses /tmp/blackbox-, or BLACKBOX_IPC_DIR', function() + if protocol.is_windows then return end + local saved = os.getenv('BLACKBOX_IPC_DIR') + vim.env.BLACKBOX_IPC_DIR = nil + assert.equals('/tmp/blackbox-' .. uv.getuid(), protocol.ipc_dir()) + vim.env.BLACKBOX_IPC_DIR = '/elsewhere' + assert.equals('/elsewhere', protocol.ipc_dir()) + vim.env.BLACKBOX_IPC_DIR = saved + end) + + it('tightens its own folder and refuses symlinks', function() + if protocol.is_windows then return end + local dir = tmpdir() + uv.fs_chmod(dir, tonumber('755', 8)) + protocol.ensure_private_dir(dir) + assert.equals(tonumber('700', 8), bit.band(uv.fs_stat(dir).mode, tonumber('777', 8))) + local link = dir .. '-link' + uv.fs_symlink(dir, link) + assert.is_truthy(protocol.private_dir_problem(link):find('not a directory')) + assert.has_error(function() protocol.ensure_private_dir(link) end) + uv.fs_unlink(link) + end) + + it('flags a folder other users can reach', function() + if protocol.is_windows then return end + local dir = tmpdir() + uv.fs_chmod(dir, tonumber('777', 8)) + assert.is_truthy(protocol.private_dir_problem(dir):find('accessible to other users')) + end) + + it('writes files atomically', function() + local file = tmpdir() .. '/entry.json' + protocol.write_atomic(file, '{"a":1}') + assert.are.same({ '{"a":1}' }, vim.fn.readfile(file)) + end) +end) diff --git a/editors/neovim/tests/unit/server_spec.lua b/editors/neovim/tests/unit/server_spec.lua new file mode 100644 index 0000000..33da572 --- /dev/null +++ b/editors/neovim/tests/unit/server_spec.lua @@ -0,0 +1,85 @@ +-- The bridge server over a real socket: line framing, errors, registry, cleanup. +local Server = require('blackbox.server') +local protocol = require('blackbox.protocol') +local uv = vim.uv or vim.loop + +local function request(socket, lines) + local client = uv.new_pipe(false) + local received, connected = '', false + client:connect(socket, function(err) + assert(not err, err) + connected = true + client:read_start(function(_, chunk) if chunk then received = received .. chunk end end) + -- Split across writes, to exercise the framing. + local text = table.concat(lines, '\n') .. '\n' + client:write(text:sub(1, 7)) + client:write(text:sub(8)) + end) + vim.wait(2000, function() local _, n = received:gsub('\n', ''); return connected and n >= #lines end, 5) + client:close() + return vim.tbl_map(vim.json.decode, vim.split(vim.trim(received), '\n')) +end + +describe('server', function() + local dir, server + before_each(function() + dir = vim.fn.tempname() + vim.fn.mkdir(dir, 'p') + vim.env.BLACKBOX_IPC_DIR = dir + server = Server.new({ + id = 'T', + folders = function() return { '/proj' } end, + app = { name = 'Neovim', scheme = 'nvim', version = '0.10.4' }, + extension_version = '0.1.0', + handlers = { + echo = function(args) return 'echo:' .. args.text end, + boom = function() error('kaboom') end, + nil_check = function(args) return args.threadId == nil and 'nil' or type(args.threadId) end, + }, + }) + server:start() + end) + after_each(function() + server:dispose() + vim.env.BLACKBOX_IPC_DIR = nil + end) + + it('answers requests split across packets, in order, with errors as errors', function() + local replies = request(server.entry.socket, { + vim.json.encode({ id = '1', tool = 'echo', args = { text = 'hi' } }), + vim.json.encode({ id = '2', tool = 'boom', args = {} }), + vim.json.encode({ id = '3', tool = 'nope', args = {} }), + }) + table.sort(replies, function(a, b) return a.id < b.id end) + assert.are.same({ id = '1', result = 'echo:hi' }, replies[1]) + assert.are.same({ id = '2', error = 'kaboom' }, replies[2]) + assert.are.same({ id = '3', error = 'unknown tool: nope' }, replies[3]) + end) + + it('turns JSON null arguments into nil, not vim.NIL', function() + local reply = request(server.entry.socket, { '{"id":"5","tool":"nil_check","args":{"threadId":null}}' }) + assert.equals('nil', reply[1].result) + end) + + it('writes a registry entry in the shared format and removes it on dispose', function() + local entry = vim.json.decode(table.concat(vim.fn.readfile(protocol.registry_path('T')), '')) + assert.equals('T', entry.id) + assert.equals(uv.os_getpid(), entry.pid) + assert.equals(entry.pid, entry.appPid) + assert.equals(protocol.PROTOCOL_VERSION, entry.protocol) + assert.are.same({ name = 'Neovim', scheme = 'nvim', version = '0.10.4' }, entry.app) + assert.are.same({ '/proj' }, entry.folders) + server:dispose() + assert.is_nil(uv.fs_stat(protocol.registry_path('T'))) + assert.is_nil(uv.fs_stat(server.entry.socket)) + end) + + it('recreates its socket if the file is deleted', function() + if protocol.is_windows then return end + uv.fs_unlink(server.entry.socket) + server:check_health() + assert.is_truthy(uv.fs_stat(server.entry.socket)) + local reply = request(server.entry.socket, { vim.json.encode({ id = '9', tool = 'echo', args = { text = 'back' } }) }) + assert.equals('echo:back', reply[1].result) + end) +end) diff --git a/editors/neovim/tests/unit/tools_spec.lua b/editors/neovim/tests/unit/tools_spec.lua new file mode 100644 index 0000000..f6f4a8e --- /dev/null +++ b/editors/neovim/tests/unit/tools_spec.lua @@ -0,0 +1,157 @@ +-- Breakpoint stores and helpers that don't need a running debug adapter. +local breakpoints = require('blackbox.tools.breakpoints') +local common = require('blackbox.tools.common') + +local function run(fn) + local result, err, finished + coroutine.wrap(function() + local ok, r = pcall(fn) + if ok then result = r else err = r end + finished = true + end)() + vim.wait(2000, function() return finished end, 5) + if err then error(err, 0) end + return result +end + +describe('breakpoint tools (no session)', function() + local file + before_each(function() + run(breakpoints.remove_all) + file = vim.fn.tempname() .. '.go' + vim.fn.writefile({ 'package main', 'func main() {', ' x := 1', ' _ = x', '}' }, file) + end) + + it('sets, lists, disables, enables and removes source and function breakpoints', function() + assert.is_truthy(run(function() return breakpoints.set({ file = file, line = 3, hitCondition = '2' }) end):find('ok: ')) + run(function() return breakpoints.set_function({ name = 'main.main' }) end) + local list = vim.json.decode(run(breakpoints.list)) + assert.equals(2, #list) + assert.are.same({ 'source', 3, true, '2' }, { list[1].type, list[1].line, list[1].enabled, list[1].hitCondition }) + assert.are.same({ 'function', 'main.main', true }, { list[2].type, list[2].name, list[2].enabled }) + + assert.equals('Disabled 2 breakpoint(s)', run(function() return breakpoints.toggle({ enabled = false }) end)) + list = vim.json.decode(run(breakpoints.list)) + assert.is_false(list[1].enabled) + assert.equals('2', list[1].hitCondition, 'keeps the hit condition while disabled') + assert.equals(2, breakpoints.extra_count()) + + assert.equals('Enabled 1 breakpoint(s)', run(function() return breakpoints.toggle({ enabled = true, breakpoints = { { file = file, line = 3 } } }) end)) + assert.is_true(vim.json.decode(run(breakpoints.list))[1].enabled) + + local removed = run(function() return breakpoints.remove({ file = file, line = 3, functions = { 'main.main', 'nope' } }) end) + assert.is_truthy(removed:find('ok: removed ' .. vim.pesc(file) .. ':3')) + assert.is_truthy(removed:find('ok: removed function main.main')) + assert.is_truthy(removed:find('skip: no function breakpoint on nope')) + assert.equals('[]', run(breakpoints.list)) + end) + + it('rejects invalid input with plain messages', function() + assert.is_truthy(run(function() return breakpoints.set({ file = file }) end):find('skip: invalid')) + assert.has_error(function() run(function() return breakpoints.toggle({}) end) end, '"enabled" (true or false) is required') + assert.has_error(function() run(function() return breakpoints.set_exception_filters({}) end) end, 'no active debug session') + end) +end) + +describe('helpers', function() + it('json does not escape slashes and round-trips', function() + local text = common.json({ p = '/a/b', b = 'x\\/y' }) + assert.equals('{"p":"/a/b","b":"x\\\\/y"}', text:gsub('"b":"x\\\\/y","p":"/a/b"', '"p":"/a/b","b":"x\\\\/y"')) + assert.are.same({ p = '/a/b', b = 'x\\/y' }, vim.json.decode(text)) + end) + + it('same_file sees through symlinks', function() + local dir = vim.uv.fs_realpath(vim.fn.tempname():match('(.*)/')) .. '/bbx-link-' .. vim.uv.hrtime() + vim.fn.mkdir(dir .. '/src', 'p') + vim.fn.writefile({}, dir .. '/src/index.php') + vim.uv.fs_symlink(dir .. '/src', dir .. '/wp') + assert.is_true(common.same_file(dir .. '/wp/index.php', dir .. '/src/index.php')) + assert.is_false(common.same_file(dir .. '/wp/index.php', dir .. '/src/other.php')) + vim.fn.delete(dir, 'rf') + end) +end) + +describe('error_text', function() + it('adds the adapter detail with its variables filled in', function() + local err = { message = 'Failed to launch', body = { error = { format = 'Version of {tool} is too old', variables = { tool = 'Delve' } } } } + assert.equals('Failed to launch: Version of Delve is too old', common.error_text(err)) + end) + it('falls back to the message or the format alone', function() + assert.equals('boom', common.error_text({ message = 'boom' })) + assert.equals('only detail', common.error_text({ body = { error = { format = 'only detail' } } })) + assert.equals('same', common.error_text({ message = 'same', body = { error = { format = 'same' } } })) + assert.equals('Failed to launch: too old', common.error_text({ message = 'Failed to launch', body = { error = { format = 'Failed to launch: too old' } } })) + end) +end) + +describe('requests', function() + -- A session whose adapter never answers, like one that is closing. + local function silent_session() return { id = 99, request = function() end } end + + it('fail at once when the session closes instead of waiting forever', function() + local session = silent_session() + local finished, err + coroutine.wrap(function() + local ok, e = pcall(common.request, session, 'evaluate', { expression = 'x' }) + finished, err = true, (not ok) and e or nil + end)() + assert.is_nil(finished) + common.session_closed(session) + vim.wait(1000, function() return finished end, 5) + assert.equals('the debug session ended', err) + end) + + it('refuse a session that already closed', function() + local session = silent_session() + session.closed = true + assert.has_error(function() run(function() return common.request(session, 'threads') end) end, 'the debug session has ended') + end) +end) + +describe('buffer_for', function() + it('matches the exact file only, not a partial name', function() + local root = vim.uv.fs_realpath(vim.fn.tempname():match('(.*)/')) + local inner, outer = root .. '/bbx-y-' .. vim.uv.hrtime() .. '/x/app.go', root .. '/bbx-x-' .. vim.uv.hrtime() .. '/app.go' + vim.fn.mkdir(vim.fs.dirname(inner), 'p') + vim.fn.writefile({}, inner) + local bufnr = vim.fn.bufadd(inner) + assert.equals(bufnr, common.buffer_for(inner)) + assert.equals(-1, common.buffer_for(outer), 'a file that only shares the tail is not this buffer') + end) +end) + +describe('launch configurations', function() + it('fill ${workspaceFolder} with the folder they came from', function() + local config = require('blackbox.tools.session')._with_folder( + { program = '${workspaceFolder}/app.go', name = 'x (${workspaceFolderBasename})', args = { '${workspaceFolder}' } }, '/sites/manheim.wpx') + assert.are.same({ program = '/sites/manheim.wpx/app.go', name = 'x (manheim.wpx)', args = { '/sites/manheim.wpx' } }, config) + end) +end) + +describe('find_file', function() + it('skips node_modules and .git at any depth', function() + local root = vim.uv.fs_realpath(vim.fn.tempname():match('(.*)/')) .. '/bbx-find-' .. vim.uv.hrtime() + for _, dir in ipairs({ '/src', '/packages/a/node_modules/dep', '/packages/a/.git' }) do vim.fn.mkdir(root .. dir, 'p') end + vim.fn.writefile({}, root .. '/src/target.lua') + vim.fn.writefile({}, root .. '/packages/a/node_modules/dep/target.lua') + vim.fn.writefile({}, root .. '/packages/a/.git/target.lua') + local saved = require('blackbox').folders + require('blackbox').folders = function() return { root } end + local found = vim.json.decode(require('blackbox.tools.editor').find_file({ pattern = '**/target.lua' })) + require('blackbox').folders = saved + assert.are.same({ root .. '/src/target.lua' }, found) + vim.fn.delete(root, 'rf') + end) +end) + +describe('output', function() + it('never fails on a limit of 0', function() + local events = require('blackbox.events') + events._reset() + events.on.output({ id = 1, config = { name = 's' } }, { output = 'a' }) + events.on.output({ id = 1, config = { name = 's' } }, { output = 'b' }) + local page = events.read_output({ limit = 0 }) + assert.equals(1, #page.entries) + assert.is_true(page.more) + end) +end) diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index 2962d77..65a873c 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -All notable changes to the Blackbox VS Code extension. Each version is also a [GitHub Release](https://github.com/akash-aman/blackbox/releases) (tag `vscode-v`) with these notes and the packaged `.vsix`. +All notable changes to the Blackbox VS Code extension. Each version is also a [GitHub Release](https://github.com/xcode-studio/blackbox/releases) (tag `vscode-v`) with these notes and the packaged `.vsix`. Releases are published by pushing a `vscode-v` tag; CI refuses to release a version without an entry here. Changes collect under `## Unreleased`; at release time that heading becomes `## (Pre-release) โ€” ` (leave out `(Pre-release)` for a stable release). diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 1f6699f..d6b5af5 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -24,7 +24,7 @@ > Works in VS Code and VS Code-based editors such as Cursor and Antigravity. > -> What's new in each version: [CHANGELOG](CHANGELOG.md) ยท [GitHub Releases](https://github.com/akash-aman/blackbox/releases). +> What's new in each version: [CHANGELOG](CHANGELOG.md) ยท [GitHub Releases](https://github.com/xcode-studio/blackbox/releases). Made with โค๏ธ by [Akash Aman](https://linktr.ee/akash_aman) diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 18888f4..909ccdb 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -9,7 +9,7 @@ "pricing": "Free", "repository": { "type": "git", - "url": "https://github.com/akash-aman/blackbox" + "url": "https://github.com/xcode-studio/blackbox" }, "engines": { "vscode": "^1.99.0"