diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a031378..17e8320 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: jobs: ci: - name: Format & Build + name: Format, Build, Type-check & Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -28,3 +28,9 @@ jobs: - name: Build run: npm run build + + - name: Type-check tests + run: npm run typecheck + + - name: Test with coverage thresholds + run: npm run test:coverage diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9a7bceb..d5d4ea1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -49,5 +49,7 @@ jobs: - name: Install dependencies run: npm ci + # 2.x (OpenCode v2 only) ships under `next` so `latest` keeps serving 1.x to + # OpenCode v1 users. Promote later with `npm dist-tag add opencode-easy-vision@ latest`. - name: Publish - run: npm publish --access public + run: npm publish --access public --tag next diff --git a/.gitignore b/.gitignore index c954b7f..c9f8442 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .agents/ .vscode/ .DS_Store +coverage/ diff --git a/AGENTS.md b/AGENTS.md index f5769ff..3be090a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,41 +11,52 @@ Keep this file focused on maintainer instructions for coding agents. Do not copy - User-facing setup and usage: [README.md](./README.md) - Full plugin configuration reference: [CONFIGURATION.md](./CONFIGURATION.md) - Agent-assisted user setup workflow: [AGENT_SETUP.md](./AGENT_SETUP.md) -- Local development and symlink testing: [CONTRIBUTING.md](./CONTRIBUTING.md) +- Local development and installed-package testing: [CONTRIBUTING.md](./CONTRIBUTING.md) - Example config shipped with the package: [opencode-easy-vision.example.jsonc](./opencode-easy-vision.example.jsonc) ## Project Facts - TypeScript, strict mode, ES modules only. -- Runtime target is Node.js `>=18.0.0`. -- Plugin framework is `@opencode-ai/plugin`. +- Runtime target is Node.js `>=22.18.0`. +- Targets OpenCode v2 only; the last OpenCode v1 release is 1.6.1. Plugin framework is `@opencode/plugin` (Effect API); media/message types are from `@opencode/ai`. +- The only runtime import from the OpenCode ecosystem is `effect`, which the host resolves to its own instance. Import `@opencode/*` with `import type` only, so the published package needs no OpenCode dependencies. - Source lives in `src/`; compiled output is `dist/`. -- There is no test framework. Validation is formatting plus TypeScript build, followed by manual OpenCode testing when behavior changes. +- Tests are TypeScript in `test/`, run by Vitest directly against `src/` (one `.test.ts` per source module, shared fixtures in `test/helpers.ts`). Coverage thresholds in `vitest.config.ts` fail CI on regression. End-to-end tests against the real OpenCode binary live in `test/e2e/` and run only through `just e2e`. ## Commands +Use [`just`](./justfile) for every task; run `just` to list recipes. The recipes wrap the npm scripts in `package.json`, which CI calls directly. + ```bash -npm install -npm run format -npm run format:check -npm run build +just install # npm ci +just fmt # format src/ and test/ +just build +just typecheck # type-check src/ and test/ +just test # Vitest; extra args pass through, e.g. `just test config` +just coverage # tests plus coverage thresholds +just check # everything CI runs: fmt-check, build, typecheck, coverage +just e2e # packed plugin against the installed OpenCode binary ``` -Use `npm run build` before committing code changes. CI runs `npm ci`, `npm run format:check`, and `npm run build`. +Run `just check` before committing code changes. -The Husky pre-commit hook runs `npm run format`. It only formats `src/**/*.ts`. +The Husky pre-commit hook runs `npm run format`, which formats `src/` and `test/`. ## Architecture -The plugin entry point is [src/index.ts](./src/index.ts). It registers `experimental.chat.messages.transform` and wires together the modules below. +The plugin entry point is [src/index.ts](./src/index.ts): a thin Effect shell that default-exports a typed `{ id, effect }` plugin object, routes logs to the host logger, loads config, runs cleanup, and registers the MCP transform plus the `context`, `compaction`, `title`, and `generate` hooks. Keep plugin logic out of Effect: hooks delegate to plain async code through `Effect.promise`. - [src/constants.ts](./src/constants.ts): package constants, defaults, MIME mappings, prompt templates. - [src/types.ts](./src/types.ts): shared interfaces and function types. -- [src/config.ts](./src/config.ts): config discovery, JSON/JSONC parsing, validation, precedence, example-config auto-init, and runtime accessors. +- [src/config.ts](./src/config.ts): config discovery, JSONC parsing, validation, precedence, example-config auto-init, and runtime accessors. - [src/patterns.ts](./src/patterns.ts): case-insensitive wildcard model matching. -- [src/images.ts](./src/images.ts): image part detection, URL handling, temp-file writing, and image extraction. +- [src/images.ts](./src/images.ts): normalized image sources, URL handling, deterministic atomic temp-file writing. - [src/prompt.ts](./src/prompt.ts): prompt-template substitution and default injection prompt selection. -- [src/transform.ts](./src/transform.ts): message lookup, model extraction, image-part removal, and text-part replacement/creation. +- [src/transform.ts](./src/transform.ts): immutable message rewriting that keeps part order and metadata. +- [src/request.ts](./src/request.ts): per-request rewriting (model match, location guard, tool routing, transform, failure fallback) over Promise-based host lookups. +- [src/tool-route.ts](./src/tool-route.ts): whether the vision tool is reachable directly, through Code Mode's `execute`, or not at all. +- [src/codemode.ts](./src/codemode.ts): MCP transform that disables Code Mode for the server providing `imageAnalysisTool`, respecting explicit user settings. +- [src/file-lock.ts](./src/file-lock.ts): per-path lock so image writes and cleanup never race. - [src/cleanup.ts](./src/cleanup.ts): startup cleanup for plugin-owned temp files. When adding behavior, preserve these module boundaries. Add a new small module for a new concern instead of growing [src/index.ts](./src/index.ts). @@ -54,14 +65,14 @@ When adding behavior, preserve these module boundaries. Add a new small module f The hook should stay defensive and non-crashing: -1. Find the last user message. -2. Read the model from that message and check it against configured patterns. -3. Extract supported image file parts. -4. Save `data:` images to the configured temp directory; pass `file://` and HTTP(S) images through as paths/URLs. -5. Remove all image parts that the target model cannot consume directly. -6. Inject or update text with the MCP tool instructions and the user's original request. +1. Act only when the request's model matches and the session belongs to this plugin instance's project. +2. Rewrite the outgoing model context only; never the stored chat history. +3. Save inline image data to disk; pass file and HTTP(S) URLs through. Fail with a clear reason for provider refs and unsupported formats. +4. Remove every image from user messages, even when saving fails. Keep all text and non-image media in place. +5. Add tool instructions only to the latest user message, for the route the tool is reachable on. Compaction, title and generate requests get image paths only. +6. Recreate saved files that have gone missing on later passes. -If image processing partially or fully fails, log the failure, show a toast when the user can act on it, and keep OpenCode running. +If image processing fails, log through the injected `Logger`/`Notifier` (host Effect logger; OpenCode discards plugin `console` output) and add model-visible failure notes; the server has no terminal UI dependency. Do not log image bytes, secret URLs, credentials, or raw provider errors. ## Coding Rules @@ -69,11 +80,11 @@ If image processing partially or fully fails, log the failure, show a toast when - Use `node:` prefixes for Node built-ins. - Keep import order as type imports, Node built-ins, external packages, then local modules. - Do not use `require`, `module.exports`, `as any`, `@ts-ignore`, or `@ts-expect-error`. -- Prefer type guards for narrowing, especially around OpenCode `Part` variants. +- Prefer type guards for narrowing around content and media source variants. - Prefer `??` for nullish defaults. - Await filesystem operations and wrap I/O in `try`/`catch` when failure should not crash the plugin. -- Do not mutate parameters; return new data structures for message/part changes. -- Keep functions small and mostly pure. I/O and OpenCode logging/toasts are the expected side effects. +- Do not mutate parameters; return new data structures for message/part changes. The one exception is the OpenCode hook event or MCP editor, whose API contract is mutation: assign the newly built value once (for example `event.messages = ...`). +- Keep functions small and mostly pure. I/O and server logging are the expected side effects. - Prefer Node built-ins over new dependencies unless the dependency removes real complexity. ## Configuration Changes @@ -86,21 +97,26 @@ Configuration behavior is documented in [CONFIGURATION.md](./CONFIGURATION.md) a - Update [CONFIGURATION.md](./CONFIGURATION.md) and the example JSONC file. - Avoid duplicating option tables or examples in this file. -The module-level `pluginConfig` state in [src/config.ts](./src/config.ts) is intentional. Other modules should use exported accessors instead of threading config through every call. +Each adapter instance owns a `createConfigStore()` with isolated accessor closures. Bind accessors to helper factories once rather than threading options through each operation. Never restore module-global configuration: one OpenCode service hosts plugin instances for several projects. Inline host plugin options (`ctx.options`) are not read; config comes only from the dedicated files. ## Validation For code changes, run: ```bash -npm run format:check -npm run build +just check ``` -Run `npm run format` only when formatting changes are needed. For behavior that depends on OpenCode runtime wiring, follow the manual testing flow in [CONTRIBUTING.md](./CONTRIBUTING.md). +For changes to OpenCode runtime wiring (`src/index.ts`, hooks, packaging), also run `just e2e`. + +Every behavior change needs a test in the matching `test/.test.ts`. Do not lower coverage thresholds to make CI pass. Tests may cast to `unknown` only where OpenCode types are impractical to construct (branded IDs, the full plugin `Context`); keep such casts in `test/helpers.ts` or one documented spot per file. + +Run `just fmt` only when formatting changes are needed. For behavior that depends on OpenCode runtime wiring, follow the manual testing flow in [CONTRIBUTING.md](./CONTRIBUTING.md). ## Releases Always confirm with the user before running any release command. -Use `npm version patch`, `npm version minor`, or `npm version major` to bump, commit, and tag. Push both the commit and tag so [.github/workflows/publish.yml](./.github/workflows/publish.yml) can publish to npm. Do not run `npm publish` manually. +2.x is OpenCode v2 only. Do not publish v1 fixes from `main`. The publish workflow tags 2.x releases `next`, so `latest` keeps serving 1.x until the user decides to promote 2.x with `npm dist-tag`. + +Release with `just release patch|minor|major`. It runs `just check`, then `npm version` (bump, commit, tag) and `git push --follow-tags`, so [.github/workflows/publish.yml](./.github/workflows/publish.yml) publishes to npm. The recipe asks for confirmation, but get the user's approval before running it. Do not run `npm publish` manually. diff --git a/AGENT_SETUP.md b/AGENT_SETUP.md index df2115a..92cd6df 100644 --- a/AGENT_SETUP.md +++ b/AGENT_SETUP.md @@ -2,19 +2,23 @@ Set up the `opencode-easy-vision` OpenCode plugin. Read existing configuration before editing it, and update keys in place instead of replacing whole files. +This plugin version (2.x) requires **OpenCode v2**. + --- ## Pre-flight — read context before editing Before editing any files, do all of the following: -1. **Fetch the full configuration reference** and keep it available throughout this session: `https://raw.githubusercontent.com/devadathanmb/opencode-easy-vision/main/CONFIGURATION.md` +1. **Check the OpenCode version:** Run `opencode --version`. If it is 1.x, stop and tell the user that this plugin version needs OpenCode v2; on OpenCode v1 they must use `"plugin": ["opencode-easy-vision@1"]` instead, configured as described in the 1.x README. + +2. **Fetch the full configuration reference** and keep it available throughout this session: `https://raw.githubusercontent.com/devadathanmb/opencode-easy-vision/main/CONFIGURATION.md` -2. **Inspect the existing OpenCode config:** Check `~/.config/opencode/opencode.json` and `.jsonc`, then check the `opencode.json` and `.jsonc` files that apply to the current project. +3. **Inspect the existing OpenCode config:** Check `~/.config/opencode/opencode.json` and `.jsonc`, then check the `opencode.json` and `.jsonc` files that apply to the current project. - Note whether the `mcp` block exists, what keys it already has, and whether `"opencode-easy-vision"` is already in the `plugin` array. + Note whether the `mcp` block exists and which servers it defines (OpenCode v2 uses `mcp.servers`; the older layout with servers directly under `mcp` is still accepted), and whether `"opencode-easy-vision"` is already in the `plugins` array or the older `plugin` array. -3. **Inspect plugin config files:** Check existing `.json` and `.jsonc` files at `~/.config/opencode/opencode-easy-vision.*` and `.opencode/opencode-easy-vision.*`. Also note any legacy `opencode-minimax-easy-vision` config files. +4. **Inspect plugin config files:** Check `~/.config/opencode/opencode-easy-vision.jsonc` and `.opencode/opencode-easy-vision.jsonc`. If you find `opencode-easy-vision.json` or `opencode-minimax-easy-vision.json(c)` files instead, tell the user they are no longer read and offer to rename them to `opencode-easy-vision.jsonc`. If an existing config sets `imageAnalysisTool` to `mcp_minimax_understand_image` (the 1.x default), it must be updated in Question 4. Note the current values of `models` and `imageAnalysisTool`. Do not create a plugin config file unless the chosen options require one. @@ -49,39 +53,39 @@ Ask the user: **If they choose 1 (MiniMax Coding Plan MCP):** -Ask for their MiniMax API key. They can create one at https://platform.minimax.io. Before writing it, tell them the key will be stored in the selected OpenCode config and ask for confirmation. +Ask for their MiniMax API key. They can create one at https://platform.minimax.io. Offer two ways to supply it: an environment variable referenced as `{env:MINIMAX_API_KEY}` (recommended), or the key written directly into the selected OpenCode config. If they choose the latter, tell them the key will be stored in that file and ask for confirmation. -Add or update the `MiniMax` entry in the selected OpenCode config's `mcp` block: +Add or update the `MiniMax` entry under `mcp.servers` in the selected OpenCode config: ```json "MiniMax": { "type": "local", "command": ["uvx", "minimax-coding-plan-mcp"], "environment": { - "MINIMAX_API_KEY": "", + "MINIMAX_API_KEY": "{env:MINIMAX_API_KEY}", "MINIMAX_API_HOST": "https://api.minimax.io" } } ``` -Tool name to use in plugin config: **`mcp_minimax_understand_image`**. +Tool name to use in plugin config: **`MiniMax_understand_image`** (the default). --- **If they choose 2 (openrouter-image-mcp):** -Ask for their OpenRouter API key. They can create one at https://openrouter.ai/keys. Before writing it, tell them the key will be stored in the selected OpenCode config and ask for confirmation. +Ask for their OpenRouter API key. They can create one at https://openrouter.ai/keys. As with MiniMax, offer `{env:OPENROUTER_API_KEY}` (recommended) or storing the key in the selected OpenCode config, and confirm before writing a key to a file. Ask which vision model they want to use. If unsure, recommend `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, a free multimodal model designed for image perception and reasoning. Free model availability changes over time, so check [OpenRouter's free models](https://openrouter.ai/models?fmt=cards&max_price=0) for current alternatives and explain that free endpoints use shared capacity, so latency and availability can vary. -Add or update the `openrouter_image` entry in the selected OpenCode config's `mcp` block: +Add or update the `openrouter_image` entry under `mcp.servers` in the selected OpenCode config: ```json "openrouter_image": { "type": "local", "command": ["npx", "openrouter-image-mcp"], "environment": { - "OPENROUTER_API_KEY": "", + "OPENROUTER_API_KEY": "{env:OPENROUTER_API_KEY}", "OPENROUTER_MODEL": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" } } @@ -95,12 +99,14 @@ Tool name to use in plugin config: **`openrouter_image_analyze_image`**. Ask the user to describe the tool or provide its name or repo URL. Fetch its documentation to find: -- The correct `mcp` block entry for `opencode.json` -- The exact tool name the server exposes (look for function/tool definitions in the README or source) +- The correct `mcp.servers` entry for `opencode.json` +- The exact name of the image-analysis tool the server defines (look for function/tool definitions in the README or source) + +Add or update the entry under `mcp.servers`, checking first whether the block already exists. -Add or update the entry in the `mcp` block, checking first whether the block already exists. +OpenCode exposes the tool as `_`, with characters other than letters, digits, `_` and `-` in the server key replaced by `_`. For server key `vision` and tool `describe_image`, the name is `vision_describe_image`. Names are case-sensitive. -Use the exact tool name OpenCode exposes. Do not construct the name from the MCP server key. +Do not set `codemode` on this server: the plugin turns OpenCode's MCP Code Mode off for it automatically so the model can call the tool directly. --- @@ -150,9 +156,10 @@ For all options except 1: update the `models` key in the selected plugin config. ## Question 4 — MCP tool name -This step is only needed if the tool name is **not** `mcp_minimax_understand_image` (the built-in default). +The plugin config's `imageAnalysisTool` must end up equal to the tool name from Question 2. Check the selected scope's plugin config, and also any config at the other scope that would override it: -If the tool name from Question 2 differs from the default, add or update `imageAnalysisTool` in the selected plugin config: +- **Tool name is `MiniMax_understand_image` (the built-in default):** no `imageAnalysisTool` is needed. If an existing config sets `imageAnalysisTool` to anything else, including the 1.x default `mcp_minimax_understand_image`, remove that key (or set it to `MiniMax_understand_image`). +- **Any other tool name:** add or update `imageAnalysisTool` in the selected plugin config: ```jsonc { @@ -160,22 +167,22 @@ If the tool name from Question 2 differs from the default, add or update `imageA } ``` -Check whether the file already exists. If it does, update the `imageAnalysisTool` key in place. If not, create `opencode-easy-vision.jsonc` at the selected scope. - -If the tool name is `mcp_minimax_understand_image`, skip this step. +If the plugin config file already exists, update the key in place. If not, create `opencode-easy-vision.jsonc` at the selected scope. --- ## Step 5 — Add the plugin -Check whether `"opencode-easy-vision"` is already in the selected OpenCode config's `plugin` array. If it is, skip this step. If not, add it: +Check whether `opencode-easy-vision` is already in the selected OpenCode config's `plugins` array, or in an older `plugin` array (OpenCode v2 still reads it). If it is there as `opencode-easy-vision@next`, skip this step. If it is unpinned or pinned to 1.x (for example `opencode-easy-vision@1`), change it to `opencode-easy-vision@next`: 2.x is published under the `next` tag. If it is missing, add it to `plugins`: ```json { - "plugin": ["opencode-easy-vision"] + "plugins": ["opencode-easy-vision@next"] } ``` +For the all-projects scope you can instead run `opencode plugin add opencode-easy-vision@next`. + --- ## Step 6 — Summarise and verify @@ -185,13 +192,14 @@ Before asking the user to restart, show them a brief summary of every change you - Which OpenCode config was updated (MCP entry, plugin array) - Which plugin config was updated and what was written to it (`models`, `imageAnalysisTool`), or note if no plugin config was needed -Then tell the user to **restart OpenCode**. +Then tell the user to **restart OpenCode**. OpenCode v2 runs plugins in a background service: if the plugin does not load after restarting the terminal, ask the user to run `opencode service restart`. Do not restart the service yourself without their approval. After they restart, ask them to paste an image (`Cmd+V` / `Ctrl+V`) into the chat with the configured model selected and send a message. The model should call the MCP tool and return an image analysis. **If it doesn't work, check in order:** -1. Is the MCP tool name in `imageAnalysisTool` exactly the name OpenCode exposes? +1. Is the MCP tool name in `imageAnalysisTool` exactly the name OpenCode exposes (`_`, case-sensitive)? Is the MCP server connected? 2. Does the model ID match the configured `models` pattern? Remember: OpenRouter-served models won't match `minimax/*` even if the underlying model is from MiniMax. 3. Is the API key set correctly in the MCP environment block in the selected OpenCode config? -4. If in doubt, re-read `https://raw.githubusercontent.com/devadathanmb/opencode-easy-vision/main/CONFIGURATION.md` for the full option reference. +4. Check OpenCode's log (`~/.local/share/opencode/log/opencode.log` by default) for lines tagged `plugin=easy-vision`. +5. If in doubt, re-read `https://raw.githubusercontent.com/devadathanmb/opencode-easy-vision/main/CONFIGURATION.md` for the full option reference. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 32b5068..95971c8 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -1,6 +1,8 @@ # Configuration -This plugin reads its configuration from dedicated `.json` or `.jsonc` files. See [Config File Locations](#config-file-locations) for where to place them. +This plugin reads its configuration from dedicated `opencode-easy-vision.jsonc` files (JSON with comments and trailing commas). See [Config File Locations](#config-file-locations) for where to place them. + +Options set inline in OpenCode's `plugins` array are not read. ## Table of Contents @@ -11,6 +13,7 @@ This plugin reads its configuration from dedicated `.json` or `.jsonc` files. Se - [`promptTemplate`](#prompttemplate) - [`tempDir`](#tempdir) - [`cleanupAfterHours`](#cleanupafterhours) + - [`disableCodeMode`](#disablecodemode) - [Full Example Configs](#full-example-configs) ## Config File Locations @@ -19,12 +22,12 @@ Each config option uses the following priority (highest first): | Priority | Level | Path | | --- | --- | --- | -| 1 | Project | `.opencode/opencode-easy-vision.json` or `.jsonc` | -| 2 | User | `~/.config/opencode/opencode-easy-vision.json` or `.jsonc` | +| 1 | Project | `.opencode/opencode-easy-vision.jsonc` | +| 2 | User | `~/.config/opencode/opencode-easy-vision.jsonc` | The plugin merges options across levels. For example, a project `models` setting overrides the user setting while the user-level `imageAnalysisTool` remains in effect when the project config does not set it. -At each level, a valid JSONC file takes precedence over JSON. The plugin also reads the legacy `opencode-minimax-easy-vision` filenames when no current filename exists at that level. Current filenames take precedence over legacy filenames. +`opencode-easy-vision.json` and the old `opencode-minimax-easy-vision.json(c)` filenames are no longer read; rename them to `opencode-easy-vision.jsonc`. Plain JSON is valid JSONC, so the contents do not need to change. If no config exists, the plugin uses hardcoded defaults. On first load, an example `.jsonc` file is created at `~/.config/opencode/opencode-easy-vision.jsonc`. @@ -57,6 +60,8 @@ Controls which models trigger the plugin. The format is `provider/model` with wi Patterns without a `/` are matched against both the provider ID and the model ID. +Matching is case-insensitive. The pattern splits at the first `/`, so `openrouter/deepseek/*` matches model `deepseek/deepseek-v4-flash` from provider `openrouter`. + **Example:** ```json @@ -68,10 +73,14 @@ Patterns without a `/` are matched against both the provider ID and the model ID ### `imageAnalysisTool` **Type:** `string` -**Default:** `"mcp_minimax_understand_image"` +**Default:** `"MiniMax_understand_image"` The MCP tool the plugin instructs the model to use. Use the exact tool name OpenCode exposes. +OpenCode names MCP tools `_`, replacing characters other than letters, digits, `_` and `-` in the server name with `_`. The default comes from server `MiniMax` and tool `understand_image`. The name is case-sensitive. The 1.x default, `mcp_minimax_understand_image`, no longer works. + +If the tool is not available in a request, for example because the server is disconnected, the model is told so instead of being asked to call it. + **Example:** ```json @@ -87,12 +96,14 @@ The MCP tool the plugin instructs the model to use. Use the exact tool name Open Override the default prompt with a custom template. The template must include **at least one** of the following variables. If none are present, the plugin falls back to the default. Include both `{imageList}` and `{toolName}` when the model needs the image paths and the MCP tool name. +The prompt is added to the latest user message only; images in earlier messages are replaced by their saved paths. If the tool is behind Code Mode, a note on reaching it through `execute` is appended. + | Variable | Description | | -------------- | -------------------------------------------------- | | `{imageList}` | Newline-separated list: `- Image 1: /path/to/file` | | `{imageCount}` | Number of images, e.g. `1`, `3` | | `{toolName}` | The configured MCP tool name | -| `{userText}` | The user's original message text (may be empty) | +| `{userText}` | The text the user typed (may be empty) | **Default template:** @@ -120,6 +131,8 @@ User's request: {userText} Custom directory where pasted images are saved before being passed to the MCP tool. +The directory is on the machine running OpenCode, so the MCP server must be able to read it. Files are named by a hash of their contents and are readable only by your user. + **Example:** ```json @@ -135,6 +148,8 @@ Custom directory where pasted images are saved before being passed to the MCP to Temp files older than this many hours are deleted when the plugin initializes. The plugin does not delete files on a timer while OpenCode is running. +Only files the plugin created are deleted: names that are a 64-character hex hash with a `.png`, `.jpg` or `.webp` extension. + **Example:** ```json @@ -143,6 +158,25 @@ Temp files older than this many hours are deleted when the plugin initializes. T } ``` +### `disableCodeMode` + +**Type:** `boolean` +**Default:** `true` + +OpenCode v2 hides MCP tools behind Code Mode by default: the model cannot call them directly and has to write code for an `execute` tool instead. When this option is `true`, the plugin sets `codemode: false` on the MCP server that provides [`imageAnalysisTool`](#imageanalysistool), so the model can call the tool directly. Only that server is changed, and only if you have not set its `codemode` yourself. + +If the tool name could belong to more than one server (for example, servers `foo` and `foo_bar` with tool `foo_bar_tool`), the plugin changes nothing and logs a message. Set `codemode: false` on the right server yourself. + +Set this option to `false` to leave Code Mode alone. The prompt then tells the model to reach the tool through `execute`. + +**Example:** + +```json +{ + "disableCodeMode": false +} +``` + ## Full Example Configs ### Minimal — just change the models diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c46cb1..840c62c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,44 +1,53 @@ # Contributing -## Local Development +The plugin targets **OpenCode v2**; the last release for OpenCode v1 is 1.6.1. You need Node.js >=22.18.0 and [`just`](https://just.systems). Run `just` to list all tasks. -### 1. Install dependencies and build +## Build and test -```bash -npm install -npm run build +```sh +just install +just check # format check, build, type-check, tests with coverage thresholds ``` -The compiled plugin will be at `dist/index.js`. +Tests are TypeScript, run by [Vitest](https://vitest.dev) directly against `src/`. Each source module has a matching `test/.test.ts`, with shared fixtures in `test/helpers.ts`. `just test config` runs only matching files and `just watch` re-runs on change. `just coverage` writes an HTML report to `coverage/`. -Husky is configured as a `prepare` script and installs automatically on `npm install`. The pre-commit hook runs `npm run format`. +`just fmt` formats the code; the pre-commit hook runs it too. -### 2. Symlink into OpenCode's plugin directory +`@opencode/plugin`, `@opencode/ai` and `effect` are dev dependencies, used for types and tests only. At runtime the plugin imports `effect` from OpenCode itself, so the published package depends only on `strip-json-comments`. Import `@opencode/*` packages with `import type`. -```bash -mkdir -p ~/.config/opencode/plugin -ln -sfn "$PWD/dist/index.js" ~/.config/opencode/plugin/opencode-easy-vision.js -``` +## End-to-end tests -Restart OpenCode to pick up the symlinked plugin. +```sh +just e2e +``` -### 3. Rebuild on changes +This packs the plugin, installs the tarball into a temporary directory, and runs the `opencode` binary on your `PATH` against a fake model provider and a fake vision MCP server, with Code Mode unset, off and on. It checks that the image reaches the model as a saved path, that the model is offered the vision tool on the expected route, and that the MCP server reads the saved file. OpenCode runs with an isolated home directory, so your config and credentials are not used, and no real model is called. -```bash -npm run build -``` +Run it after changes to `src/index.ts`, hooks or packaging. CI does not run it. -OpenCode loads plugins at startup, so restart it after each rebuild to test changes. +## Trying it in OpenCode -## Code Style +Run `just build`, then point a test project's `opencode.json` at your checkout: -```bash -npm run format # format src/ -npm run format:check # check without writing +```jsonc +{ + "plugins": ["/absolute/path/to/opencode-easy-vision"], + "mcp": { + "servers": { + "MiniMax": { + "type": "local", + "command": ["uvx", "minimax-coding-plan-mcp"], + "environment": { "MINIMAX_API_KEY": "{env:MINIMAX_API_KEY}" }, + }, + }, + }, +} ``` -The project uses Prettier with the config in `.prettierrc.json`. +OpenCode loads a plugin directory through its root `index.js`, which re-exports `dist/`. Plugins run in OpenCode's background service, so run `opencode service restart` after rebuilding. + +Plugin logs go to `~/.local/share/opencode/log/opencode.log`, tagged `plugin=easy-vision`. OpenCode discards plugin `console` output. ## CI -CI runs on every push via GitHub Actions (`.github/workflows/ci.yml`). It checks formatting and runs a build. Publishing to npm is gated on CI passing. +CI checks formatting, builds, type-checks and runs the unit tests with coverage thresholds. Publishing to npm happens from a version tag; see `just release`. diff --git a/README.md b/README.md index 73fe088..95dcdd7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,13 @@ An [OpenCode](https://opencode.ai) plugin that adds **vision support** to models > [!IMPORTANT] > -> **Renamed from `opencode-minimax-easy-vision`:** Existing users only need to replace the old package name in the `plugin` array with `opencode-easy-vision`. Existing plugin configuration continues to work; no other migration is required. +> **Version 2.x requires OpenCode v2** and is published under the `next` tag, so install it as `opencode-easy-vision@next`. Plain `opencode-easy-vision` still installs 1.x, for OpenCode v1. To keep v1 setups working when 2.x becomes the default, pin the 1.x release: +> +> ```json +> { "plugin": ["opencode-easy-vision@1"] } +> ``` +> +> Coming from 1.x? See [Upgrading from 1.x](#upgrading-from-1x). ## Table of Contents @@ -17,6 +23,7 @@ An [OpenCode](https://opencode.ai) plugin that adds **vision support** to models - [Configuration](#configuration) - [Supported Image Formats](#supported-image-formats) - [Troubleshooting](#troubleshooting) +- [Upgrading from 1.x](#upgrading-from-1x) - [Uninstallation](#uninstallation) - [Contributing](#contributing) - [License](#license) @@ -35,7 +42,6 @@ This plugin automates the entire workaround. It intercepts pasted images, saves | Without plugin screenshot | With plugin screenshot | | The image is silently ignored by the model. | The model analyzes the attached image correctly before answering. | - https://github.com/user-attachments/assets/da7d8618-5de1-4a00-9250-a91493e3ea16 https://github.com/user-attachments/assets/fdb68339-b95b-46eb-90d4-ac2dbc0f436e @@ -53,12 +59,14 @@ The plugin works with any MCP server that can analyze an image from a local path ```json { "mcp": { - "MiniMax": { - "type": "local", - "command": ["uvx", "minimax-coding-plan-mcp"], - "environment": { - "MINIMAX_API_KEY": "your-api-key-here", - "MINIMAX_API_HOST": "https://api.minimax.io" + "servers": { + "MiniMax": { + "type": "local", + "command": ["uvx", "minimax-coding-plan-mcp"], + "environment": { + "MINIMAX_API_KEY": "{env:MINIMAX_API_KEY}", + "MINIMAX_API_HOST": "https://api.minimax.io" + } } } } @@ -72,12 +80,14 @@ Routes image analysis through OpenRouter, giving you access to any vision-capabl ```json { "mcp": { - "openrouter_image": { - "type": "local", - "command": ["npx", "openrouter-image-mcp"], - "environment": { - "OPENROUTER_API_KEY": "your-api-key-here", - "OPENROUTER_MODEL": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" + "servers": { + "openrouter_image": { + "type": "local", + "command": ["npx", "openrouter-image-mcp"], + "environment": { + "OPENROUTER_API_KEY": "{env:OPENROUTER_API_KEY}", + "OPENROUTER_MODEL": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" + } } } } @@ -92,31 +102,29 @@ Routes image analysis through OpenRouter, giving you access to any vision-capabl > > Any MCP server with an image analysis tool will work. The servers above are examples. For a different tool, set `imageAnalysisTool`; see [Configuration](#configuration). -#### 2. Install the plugin - -**With the OpenCode CLI (v1.3.4+):** - -Global (all projects): +> [!NOTE] +> +> OpenCode v2 hides MCP tools behind **Code Mode** by default, so the model can only reach them by writing code for an `execute` tool. The plugin turns Code Mode off for the server that provides the vision tool, so the model can call it directly. Other servers are left alone, and a `codemode` value you set on that server always wins. See [`disableCodeMode`](./CONFIGURATION.md#disablecodemode). -```bash -opencode plugin opencode-easy-vision --global -``` +#### 2. Install the plugin -Project-level (current directory only): +Install it for all projects with the OpenCode CLI: ```bash -opencode plugin opencode-easy-vision +opencode plugin add opencode-easy-vision@next ``` -**Or manually,** add it to the `plugin` array in your `opencode.json`: +Or add it to the `plugins` array in your `opencode.json`: ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-easy-vision"] + "plugins": ["opencode-easy-vision@next"] } ``` +To work on a local checkout, see [CONTRIBUTING.md](./CONTRIBUTING.md). + --- ### Agent-Assisted Setup @@ -154,10 +162,10 @@ The agent follows the instructions in [AGENT_SETUP.md](./AGENT_SETUP.md). It wil Each option uses the following priority order: -1. **Project level**: `.opencode/opencode-easy-vision.json` (or `.jsonc`) -2. **User level**: `~/.config/opencode/opencode-easy-vision.json` (or `.jsonc`) +1. **Project level**: `.opencode/opencode-easy-vision.jsonc` +2. **User level**: `~/.config/opencode/opencode-easy-vision.jsonc` -On first load, an example config is created at `~/.config/opencode/opencode-easy-vision.jsonc`. See [CONFIGURATION.md](./CONFIGURATION.md) for config precedence, legacy config support, and the full option reference. +On first load, an example config is created at `~/.config/opencode/opencode-easy-vision.jsonc`. See [CONFIGURATION.md](./CONFIGURATION.md) for config precedence and the full option reference. ## Supported Image Formats @@ -165,30 +173,44 @@ PNG, JPEG, and WebP. Exact format support depends on the image analysis tool you ## Troubleshooting +**Plugin not activating?** + +By default, the plugin only activates for MiniMax provider models, with IDs matching `minimax/*`, `minimax-cn/*`, and related patterns. It does not activate for a MiniMax model accessed through OpenRouter or another provider. To use the plugin with a different model or provider, add the model's ID pattern to `models` in your config. See [CONFIGURATION.md](./CONFIGURATION.md). + +**Model says the vision tool is unavailable?** + +OpenCode names MCP tools `_`, so the `MiniMax` server above exposes `MiniMax_understand_image`. Make sure `imageAnalysisTool` matches that name exactly (it is case-sensitive) and that the MCP server is connected. + **Plugin not updating after a new release?** -OpenCode caches plugins under `~/.cache/opencode/packages/`. If it's still running an old version after a release, clear the cache entry and restart: +Run `opencode plugin update`. OpenCode v2 runs plugins in a background service, so restarting the terminal is not enough: run `opencode service restart` to load the new version. -```bash -rm -rf ~/.cache/opencode/packages/opencode-easy-vision@latest -``` +**Using a remote or containerized MCP server?** -**Plugin not activating?** +Images are saved on the machine running OpenCode. The MCP server must be able to read files at those paths. -By default, the plugin only activates for MiniMax provider models, with IDs matching `minimax/*`, `minimax-cn/*`, and related patterns. It does not activate for a MiniMax model accessed through OpenRouter or another provider. To use the plugin with a different model or provider, add the model's ID pattern to `models` in your config. See [CONFIGURATION.md](./CONFIGURATION.md). +**Where are the logs?** + +In OpenCode's log file (`~/.local/share/opencode/log/opencode.log` by default), on lines tagged `plugin=easy-vision`. + +## Upgrading from 1.x + +1. Upgrade to OpenCode v2, then change your plugin entry to `opencode-easy-vision@next`. The old `"plugin"` key still works, though `"plugins"` is the v2 key. +2. OpenCode v2 names MCP tools differently. If your config sets `imageAnalysisTool` to `mcp_minimax_understand_image`, remove it to use the new default, `MiniMax_understand_image`, or set it to your server's `_` name. +3. Config files must now be named `opencode-easy-vision.jsonc`. Rename any `opencode-easy-vision.json` or `opencode-minimax-easy-vision.json(c)` files; their contents do not need to change. ## Uninstallation -1. Remove `opencode-easy-vision` from the `plugin` array in the `opencode.json` file where you installed it. +1. Run `opencode plugin remove opencode-easy-vision`, or remove it from the `plugins` array in your `opencode.json`. 2. Delete the plugin config files you no longer need: ```bash -rm -f ~/.config/opencode/opencode-easy-vision.{json,jsonc} -rm -f .opencode/opencode-easy-vision.{json,jsonc} +rm -f ~/.config/opencode/opencode-easy-vision.jsonc +rm -f .opencode/opencode-easy-vision.jsonc ``` -If you migrated from `opencode-minimax-easy-vision` and no longer use it, you can also delete its legacy config files. +Saved images live in `opencode-easy-vision/` in your OS temp directory (or your configured `tempDir`) and are cleaned up automatically. ## Contributing @@ -201,6 +223,6 @@ AGPL-3.0. See [LICENSE](./LICENSE). ## References - [OpenCode Official Website](https://opencode.ai) -- [OpenCode Plugins Documentation](https://opencode.ai/docs/plugins/) +- [OpenCode V2 Plugins Documentation](https://opencode.ai/v2/docs/build/plugins) - [MiniMax Coding Plan MCP Repository](https://github.com/MiniMax-AI/MiniMax-Coding-Plan-MCP) - [openrouter-image-mcp Repository](https://github.com/JonathanJude/openrouter-image-mcp) diff --git a/index.js b/index.js new file mode 100644 index 0000000..a4c4ffd --- /dev/null +++ b/index.js @@ -0,0 +1,2 @@ +// V2 local-directory loading resolves a root index, rather than package.main. +export { EasyVisionPlugin, default } from "./dist/index.js"; diff --git a/justfile b/justfile new file mode 100644 index 0000000..5db3f81 --- /dev/null +++ b/justfile @@ -0,0 +1,56 @@ +# Task runner for opencode-easy-vision. Run `just` to list recipes. + +set shell := ["bash", "-euo", "pipefail", "-c"] + +# List available recipes +default: + @just --list --unsorted + +# Install dependencies exactly as locked +install: + npm ci + +# Compile src/ to dist/ +build: + npm run build + +# Run tests once; extra args go to Vitest (e.g. `just test config`) +test *args: + npm test -- {{ args }} + +# Re-run tests on change +watch *args: + npm run test:watch -- {{ args }} + +# Run tests and enforce coverage thresholds (HTML report in coverage/) +coverage: + npm run test:coverage + +# Type-check sources and tests without emitting +typecheck: + npm run typecheck + +# Format src/ and test/ +fmt: + npm run format + +# Fail if anything is unformatted +fmt-check: + npm run format:check + +# Everything CI runs, in the same order +check: fmt-check build typecheck coverage + +# End-to-end tests: installs the packed plugin and drives the local `opencode` binary +e2e: build + npm run test:e2e + +# Remove build and coverage output +clean: + rm -rf dist coverage + +# Bump version (patch|minor|major), commit, tag, and push; CI publishes to npm +[confirm("Bump the version, tag, and push? CI will publish to npm.")] +release level: check + npm version {{ level }} + git push --follow-tags diff --git a/opencode-easy-vision.example.jsonc b/opencode-easy-vision.example.jsonc index 3062af5..3403cdb 100644 --- a/opencode-easy-vision.example.jsonc +++ b/opencode-easy-vision.example.jsonc @@ -13,7 +13,8 @@ // MCP tool name for image analysis. // Use the exact tool name OpenCode exposes. - "imageAnalysisTool": "mcp_minimax_understand_image", + // OpenCode names MCP tools _, e.g. server "MiniMax" + tool "understand_image". + "imageAnalysisTool": "MiniMax_understand_image", // Custom prompt template. Must include at least one variable: // {imageList} — newline-separated "- Image N: /path" @@ -23,10 +24,14 @@ // Leave as null to use the built-in default template. "promptTemplate": null, - // Directory where pasted images are saved before being passed to the MCP tool. + // Directory where pasted images are saved; the MCP server must be able to read it. // Leave as null to use the OS temp directory + "opencode-easy-vision/". "tempDir": null, // Delete temp files older than this many hours on plugin startup. "cleanupAfterHours": 24, + + // Turn off OpenCode's MCP Code Mode for the server providing imageAnalysisTool, + // so the model can call it directly. A codemode value set on that server wins. + "disableCodeMode": true, } diff --git a/package-lock.json b/package-lock.json index b1de4a4..5140dac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,77 +12,4649 @@ "strip-json-comments": "^5.0.3" }, "devDependencies": { - "@opencode-ai/plugin": "^1.2.26", + "@opencode/ai": "^2.1.4", + "@opencode/plugin": "^2.1.4", "@types/node": "^25.0.0", + "@vitest/coverage-v8": "^5.0.5", + "effect": "^4.0.1", "husky": "^9.1.7", "prettier": "^3.8.1", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^5.0.5" + }, + "engines": { + "node": ">=22.18.0" + }, + "peerDependencies": { + "effect": "^4.0.1" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1057.0.tgz", + "integrity": "sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.2.tgz", + "integrity": "sha512-ViOBLuGSSyPAmkKGJ6KJXtVN7MOEp3rnI7uH+kWxnHccvo8C9cvunEAnN5/oaZH1laQfsTBw7IOnmNkWlFZl2A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.2", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.72.tgz", + "integrity": "sha512-UlY1+6rV1K7bFeMivM6qfQuVSBrkm/3fXBDzQO+3izAMZ64JAm0QQquCV+Ib/fGJgMNe02koq/Bgs1SBZTwfZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.73.tgz", + "integrity": "sha512-PxlFZiF5IPUsPnuVSqjM9jlmRwZhL0xx40ccWfZiLQS8TRnHQpegpK1uAidatWuEqbJtbbY97A2Sezikz2AMUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.75.tgz", + "integrity": "sha512-Q/uIDy8eAVN7JgSKDg16F/f+hmNJj/ZyLJN8AkW2ofVdDgJVh8fO/A26iZ7YsvM2/k76hsf/u2lLAtyXsK8Ziw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.18.tgz", + "integrity": "sha512-KoyjZ9vdjuxEQ8obAH06xKP8H0WKqGhwUZLMqAOiS7G0xgUAht4X8f0ASLC/vhNLCWGblZOD3VQHWyTROGJHLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/credential-provider-env": "^3.972.73", + "@aws-sdk/credential-provider-http": "^3.972.75", + "@aws-sdk/credential-provider-login": "^3.972.80", + "@aws-sdk/credential-provider-process": "^3.972.73", + "@aws-sdk/credential-provider-sso": "^3.973.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.79", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.80.tgz", + "integrity": "sha512-xF9A0gaO7k+1dYPFoo69OQatA9Ov0QuDZfqE8H0FLyIoOph+mlEC+I1Qo9UULupGuDfSYh+kVpfcDSxSYf/2fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.85", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.85.tgz", + "integrity": "sha512-CrFcML/xImiSlquH4W3V6Npz3aSkPXHDVciAU1JOBN0vqjmkcz9H9up2sCHQqN76J/WrT+TJ580L8h2joksQrg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.73", + "@aws-sdk/credential-provider-http": "^3.972.75", + "@aws-sdk/credential-provider-ini": "^3.973.18", + "@aws-sdk/credential-provider-process": "^3.972.73", + "@aws-sdk/credential-provider-sso": "^3.973.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.79", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.73.tgz", + "integrity": "sha512-tyNnT4aVuOEXmKKKZPExBi828vRd9heshobBsKF0bov5EORHSJc1AGsHOMRP57sDt+dIuVASUOywaixiX26t4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.17.tgz", + "integrity": "sha512-gxKTWaUk2gSbBOH4m3NPRfEKcGXeDAOgHr7nNG5JktR1xZLo0R+DpMjbcleoHbB0uJREOG7hjIRE635IuBWsRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/token-providers": "3.1149.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.79.tgz", + "integrity": "sha512-/jn0Gs/qm6fs6nTIA5yncHayybz42SATZ3rNxCEBYPJ9v69PLrDRgk07E9xTT2gpRcNmDXXbi27ZbuU06N0Z6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1057.0.tgz", + "integrity": "sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.1057.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-ini": "^3.972.46", + "@aws-sdk/credential-provider-login": "^3.972.45", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.6", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.47.tgz", + "integrity": "sha512-bovCKvDsLocNTzaj6VrrMVJ2BGROqO0JlwcGseO67a+I9Bo+08nUJhSQfZUIx4+A4a7I4101GEJd/AYm7dQ68w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/signature-v4-multi-region": "^3.996.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.48.tgz", + "integrity": "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1149.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1149.0.tgz", + "integrity": "sha512-CAwYyA8eVk1w6h8YgLvkjIOakWPwURaUFrP7BRVgkxTFUM+O87thyKsGKJ6rVPbP8eoEV10cBWQ5YfaYl3qSVQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@effect/opentelemetry": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@effect/opentelemetry/-/opentelemetry-4.0.1.tgz", + "integrity": "sha512-MZ7M1a1dxMqTqeNwIGSECjO7RrReEgeEwHo8BnbOqP+8IfVpdiJi/iPCmkFVWlAxanMl4qhsq69e0Hkul9t+bA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <2.0.0", + "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", + "@opentelemetry/resources": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", + "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", + "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", + "effect": "^4.0.1" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/api-logs": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-logs": { + "optional": true + }, + "@opentelemetry/sdk-metrics": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/sdk-trace-node": { + "optional": true + }, + "@opentelemetry/sdk-trace-web": { + "optional": true + } + } + }, + "node_modules/@effect/platform-node": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@effect/platform-node/-/platform-node-4.0.1.tgz", + "integrity": "sha512-FT0Fu5LO0/XP9zBpj0HqrtJNLluK4StNKqf6TSulUsjp8FPBsDVXGJiDuyoN5J2yVMT7tgvhxSwaDcObWVRIaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@effect/platform-node-shared": "^4.0.1", + "undici": "^8.11.2" }, "engines": { "node": ">=18.0.0" }, - "peerDependencies": { - "@opencode-ai/plugin": ">=1.0.0" + "peerDependencies": { + "effect": "^4.0.1", + "redis": ">=5.0.0 <7.0.0" + } + }, + "node_modules/@effect/platform-node-shared": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@effect/platform-node-shared/-/platform-node-shared-4.0.1.tgz", + "integrity": "sha512-VanaejudEhKJ4mpmqnEzdncVUhN6sGSO9ZDND54mRxAlZxj+4dYAWjDTYAy9o4tJo9aALeP2U8cVcOFzEdSeLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ws": "^8.18.1", + "ws": "^8.22.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "effect": "^4.0.1" + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz", + "integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/arborist": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@npmcli/arborist/-/arborist-9.4.0.tgz", + "integrity": "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/config": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/@npmcli/config/-/config-10.8.1.tgz", + "integrity": "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/map-workspaces": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/map-workspaces/-/map-workspaces-5.0.3.tgz", + "integrity": "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/metavuln-calculator/-/metavuln-calculator-9.0.3.tgz", + "integrity": "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/name-from-folder": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/name-from-folder/-/name-from-folder-4.0.0.tgz", + "integrity": "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-5.0.0.tgz", + "integrity": "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@opencode/ai": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.1.4.tgz", + "integrity": "sha512-980bht1i8xv6hN/TQ+3tGujHuxfduje3jW69SKBvmiDxQBZ468Mk7sR4ztOy3NB/4fbI8iWEYhrcc3GbWWMxrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@aws-sdk/credential-providers": "3.1057.0", + "@opencode/schema": "2.1.4", + "@smithy/eventstream-codec": "4.2.14", + "@smithy/util-utf8": "4.2.2", + "aws4fetch": "1.0.20", + "effect": "4.0.1", + "google-auth-library": "10.5.0" + } + }, + "node_modules/@opencode/client": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.1.4.tgz", + "integrity": "sha512-xNHP8Xu4wcq4G849Xnbb8IxYxMTzF0TqvohWVUQZH5aURrVHIIbHMfXbZY6s4i4C9kpVbwjXFFwWAo6KIK3oiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode/protocol": "2.1.4", + "@opencode/schema": "2.1.4" + }, + "peerDependencies": { + "effect": "4.0.1", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "node_modules/@opencode/plugin": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.1.4.tgz", + "integrity": "sha512-SGAkdGp0VRxos2BEkRiyRl75G7DtuvP0ywgiEiHz1PbdUwUe2EAURMrzFgLCYCpWEm/zaNrguxPDVL0carrCsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode/ai": "2.1.4", + "@opencode/client": "2.1.4", + "@opencode/protocol": "2.1.4", + "@opencode/schema": "2.1.4", + "@opencode/util": "2.1.4", + "effect": "4.0.1" + }, + "peerDependencies": { + "@opencode/theme": "2.1.4", + "@opentui/core": ">=0.6.8", + "@opentui/solid": ">=0.6.8", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "@opencode/theme": { + "optional": true + }, + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "node_modules/@opencode/protocol": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.1.4.tgz", + "integrity": "sha512-dnifgvY6p4xdHkXRH3PEaobfCqrHb1AhihKxPNRNkGn5SueRCFCfK2pZW1GkeC36fGCmcLxlfihuIMaQL1BVzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode/schema": "2.1.4", + "effect": "4.0.1" + } + }, + "node_modules/@opencode/schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.1.4.tgz", + "integrity": "sha512-PLr8eFW81Yz6U7YsSgfSUClUUH//8omh3HiXu/L08VX5arorWNAu2uSjoXyHIGkm7TaTbdXBGPmmDfpvrDM2wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.1" + } + }, + "node_modules/@opencode/util": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.1.4.tgz", + "integrity": "sha512-Ihj6DqbnnovgUYSXki4PgvGxNbmB1wggDySIdM55Rz+sTfwFZ+zwROh3PrC4a8Vx/cJ6xQg41Uxa4nbcTbGI4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@effect/opentelemetry": "4.0.1", + "@effect/platform-node": "4.0.1", + "@effect/platform-node-shared": "4.0.1", + "@npmcli/arborist": "9.4.0", + "@npmcli/config": "10.8.1", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/context-async-hooks": "2.8.0", + "@opentelemetry/exporter-trace-otlp-http": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0", + "@opentelemetry/sdk-trace-node": "2.8.0", + "cross-spawn": "7.0.6", + "effect": "4.0.1", + "glob": "13.0.5", + "mime-types": "3.0.2", + "minimatch": "10.2.5", + "npm-package-arg": "13.0.2", + "open": "11.0.4", + "pacote": "21.5.1" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz", + "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.219.0.tgz", + "integrity": "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", + "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-transformer": "0.219.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", + "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", + "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz", + "integrity": "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.8.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/bloom": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.3.0.tgz", + "integrity": "sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/client": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.3.0.tgz", + "integrity": "sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-6.3.0.tgz", + "integrity": "sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/search": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-6.3.0.tgz", + "integrity": "sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/time-series": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.3.0.tgz", + "integrity": "sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@sigstore/bundle": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/core": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.2.tgz", + "integrity": "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.1.tgz", + "integrity": "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.2.tgz", + "integrity": "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.35.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.2.tgz", + "integrity": "sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.5.2.tgz", + "integrity": "sha512-nxu3SgmAw9JXT2CtkU0m/XNLWpP9MsaBx1zAGAypCbYj15tIFlmcYwpF+Oh18le83d+IM9PT7ENdXnE4C+d5mA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.5.2.tgz", + "integrity": "sha512-iq+cW3mAb7vfcxEEpYi3zXKpDtbrIFyanWjQl4zBq4seWD4OSxXDWSfespZxenX6aEaighn+NR3u1nU1DSvs3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.5.tgz", + "integrity": "sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/istanbul-lib-coverage": "^1.0.2", + "@vitest/istanbul-lib-report": "^1.0.2", + "ast-v8-to-istanbul": "^1.0.7", + "magicast": "^0.5.5", + "obug": "^3.0.0", + "std-env": "^4.3.0", + "tinyrainbow": "^3.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "5.0.5", + "vitest": "5.0.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/istanbul-lib-coverage": "1.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/aws4fetch": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bin-links": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.2.tgz", + "integrity": "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w==", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cmd-shim": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", + "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.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/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "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/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/cross-spawn/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/cross-spawn/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/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "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/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/effect": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.1.tgz", + "integrity": "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "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" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/glob": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.5.tgz", + "integrity": "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.2.0.tgz", + "integrity": "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "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", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ip-address": { + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-stringify-nice": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", + "integrity": "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/just-diff": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/just-diff/-/just-diff-6.0.2.tgz", + "integrity": "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/just-diff-apply": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/just-diff-apply/-/just-diff-apply-5.5.0.tgz", + "integrity": "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.4.tgz", + "integrity": "sha512-jQz9sVhrZuphH4NrAIHSgkDJCluQO7Ioi4KGRLa7aTUjAODQ13hENX0YIytqYE/rIkO485gQG2LeBxJaW53kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-fetch-happen": { + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "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/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "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/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-bundled": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-install-checks": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-package-arg": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-packlist": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/open": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.1.1.tgz", + "integrity": "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pacote": { + "version": "21.5.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", + "integrity": "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/parse-conflict-json": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-5.0.1.tgz", + "integrity": "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.7.tgz", + "integrity": "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/proggy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/proggy/-/proggy-4.0.0.tgz", + "integrity": "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/promise-all-reject-late": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz", + "integrity": "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-call-limit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/promise-call-limit/-/promise-call-limit-3.0.2.tgz", + "integrity": "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/read-cmd-shim": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", + "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/redis": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-6.3.0.tgz", + "integrity": "sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@redis/bloom": "6.3.0", + "@redis/client": "6.3.0", + "@redis/json": "6.3.0", + "@redis/search": "6.3.0", + "@redis/time-series": "6.3.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/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/rimraf/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/rimraf/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rolldown": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.155.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "optional": true + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/@opencode-ai/plugin": { - "version": "1.2.26", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.26.tgz", - "integrity": "sha512-pC71KGAI9T0+S84KpbEq9THp5pT7KOq+GmfdXkvQ7KSH5zi+iASWRhqorir73sKmEj2MQfpbe1BxdcU5qbeOwA==", + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", "dev": true, "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.2.26", - "zod": "4.1.8" + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@opencode-ai/sdk": { - "version": "1.2.26", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.26.tgz", - "integrity": "sha512-HPB+0pfvTMPj2KEjNLF3oqgldKW8koTJ7ssqXwzndazqxS+gUynzvdIKIQP4+QIInNcc5nJMG9JtfLcePGgTLQ==", + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "node_modules/string-width-cjs": { + "name": "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", - "bin": { - "husky": "bin.js" + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=18" + "node": ">=8" + } + }, + "node_modules/string-width-cjs/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/string-width-cjs/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/string-width-cjs/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" }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "engines": { + "node": ">=8" } }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "dependencies": { + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=14" + "node": ">=12" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "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-ansi-cjs/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/strip-json-comments": { @@ -97,6 +4669,102 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/treeverse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/treeverse/-/treeverse-3.0.0.tgz", + "integrity": "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -111,6 +4779,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -118,14 +4796,392 @@ "dev": true, "license": "MIT" }, - "node_modules/zod": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", - "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/write-file-atomic": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", + "dev": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", + "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", "dev": true, "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.2.0" + }, + "engines": { + "node": ">=20" + }, "funding": { - "url": "https://github.com/sponsors/colinhacks" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } } } diff --git a/package.json b/package.json index 180c254..8d9c3f5 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,16 @@ "description": "OpenCode plugin that restores the paste-and-ask workflow for text-only models by saving pasted images and injecting MCP tool instructions", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, "type": "module", "files": [ + "index.js", "dist", "opencode-easy-vision.example.jsonc", "AGENT_SETUP.md", @@ -14,10 +22,15 @@ "scripts": { "clean": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "build": "npm run clean && tsc", - "format": "prettier --write src/**/*.ts", - "format:check": "prettier --check src/**/*.ts", + "test": "vitest run", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"vitest*.config.ts\"", + "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"vitest*.config.ts\"", "prepublishOnly": "npm run build", - "prepare": "husky" + "prepare": "husky", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "typecheck": "tsc -p tsconfig.test.json" }, "keywords": [ "opencode", @@ -31,17 +44,18 @@ "author": "Devadathan M B ", "license": "AGPL-3.0", "devDependencies": { - "@opencode-ai/plugin": "^1.2.26", + "@opencode/ai": "^2.1.4", + "@opencode/plugin": "^2.1.4", "@types/node": "^25.0.0", + "@vitest/coverage-v8": "^5.0.5", + "effect": "^4.0.1", "husky": "^9.1.7", "prettier": "^3.8.1", - "typescript": "^5.7.0" - }, - "peerDependencies": { - "@opencode-ai/plugin": ">=1.0.0" + "typescript": "^5.7.0", + "vitest": "^5.0.5" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.18.0" }, "repository": { "type": "git", @@ -49,5 +63,13 @@ }, "dependencies": { "strip-json-comments": "^5.0.3" + }, + "peerDependencies": { + "effect": "^4.0.1" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + } } } diff --git a/src/cleanup.ts b/src/cleanup.ts index 739474d..2a0e4ff 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -1,13 +1,12 @@ +import type { ConfigAccessors, Logger } from "./types.js"; import { join } from "node:path"; import { readdir, stat, unlink } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { getTempDirsForCleanup, getCleanupAfterHours } from "./config.js"; -import type { Logger } from "./types.js"; +import { withFileLock } from "./file-lock.js"; -// Keep matching legacy UUID names while also accepting deterministic SHA-256 names. -// Both formats are plugin-owned, so unrelated files in custom directories are safe. -const PLUGIN_FILE_PATTERN = - /^(?:[0-9a-f]{64}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.(png|jpg|webp)$/; +// Only deterministic SHA-256 names are plugin-owned, so unrelated files in custom +// directories are never touched. +const PLUGIN_FILE_PATTERN = /^[0-9a-f]{64}\.(png|jpg|webp)$/; async function tryDeleteIfExpired( filepath: string, @@ -43,7 +42,11 @@ async function cleanupTempDir( .map((e) => join(dir, e)); const results = await Promise.all( - candidates.map((filepath) => tryDeleteIfExpired(filepath, now, cutoffMs)), + candidates.map((filepath) => + withFileLock(filepath, () => + tryDeleteIfExpired(filepath, now, cutoffMs), + ), + ), ); const deleted = results.filter(Boolean).length; if (deleted > 0) log(`Cleaned up ${deleted} old temp file(s) from ${dir}`); @@ -52,13 +55,16 @@ async function cleanupTempDir( } } -export async function cleanupOldTempFiles(log: Logger): Promise { - const cutoffMs = getCleanupAfterHours() * 60 * 60 * 1000; +export async function cleanupOldTempFiles( + config: ConfigAccessors, + log: Logger, +): Promise { + const cutoffMs = config.getCleanupAfterHours() * 60 * 60 * 1000; const now = Date.now(); await Promise.all( - getTempDirsForCleanup().map((dir) => - cleanupTempDir(dir, now, cutoffMs, log), - ), + config + .getTempDirsForCleanup() + .map((dir) => cleanupTempDir(dir, now, cutoffMs, log)), ); } diff --git a/src/codemode.ts b/src/codemode.ts new file mode 100644 index 0000000..00922b5 --- /dev/null +++ b/src/codemode.ts @@ -0,0 +1,60 @@ +import type { MCPEditor } from "@opencode/plugin/effect/mcp"; +import type { Logger } from "./types.js"; + +// Mirrors OpenCode's MCP tool naming: `${namespace(server)}_${tool}`. +function namespace(server: string): string { + return server.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +/** + * Servers whose namespace could have produced `toolName`. More than one match is + * ambiguous: with servers "foo" and "foo_bar", "foo_bar_tool" may be either + * "foo" + "bar_tool" or "foo_bar" + "tool", and distinct names can share a namespace. + */ +export function findToolServers( + toolName: string, + servers: readonly string[], +): string[] { + return servers.filter((server) => + toolName.startsWith(`${namespace(server)}_`), + ); +} + +// Code Mode hides MCP tools behind `execute`, which contradicts a prompt that names +// the tool directly. Only an unambiguous vision server is changed, and an explicit +// user `codemode` setting always wins. +export function createCodeModeTransform( + getToolName: () => string, + isEnabled: () => boolean, + log: Logger, +) { + const logged = new Set(); + const logOnce = (message: string) => { + if (logged.has(message)) return; + logged.add(message); + log(message); + }; + return (editor: MCPEditor): void => { + if (!isEnabled()) return; + const toolName = getToolName(); + const servers = findToolServers( + toolName, + editor.list().map(([name]) => name), + ); + if (servers.length > 1) { + logOnce( + `Left Code Mode unchanged: ${toolName} could belong to MCP servers ${servers.map((s) => `"${s}"`).join(", ")}. Set "codemode": false on the right server.`, + ); + return; + } + const [server] = servers; + if (server === undefined) return; + editor.update(server, (config) => { + if (config.codemode !== undefined) return; + config.codemode = false; + logOnce( + `Disabled Code Mode for MCP server "${server}" so ${toolName} is directly callable`, + ); + }); + }; +} diff --git a/src/config.ts b/src/config.ts index 91d0304..98fd436 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import type { ConfigAccessors, PluginConfig, Logger } from "./types.js"; import { homedir, tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { copyFile, mkdir, readFile } from "node:fs/promises"; @@ -6,30 +7,16 @@ import { fileURLToPath } from "node:url"; import stripJsonComments from "strip-json-comments"; import { CONFIG_FILENAME, - CONFIG_FILENAME_JSONC, - LEGACY_CONFIG_FILENAME, - LEGACY_CONFIG_FILENAME_JSONC, EXAMPLE_CONFIG_FILENAME, + REMOVED_CONFIG_FILENAME, TEMP_DIR_NAME, - LEGACY_TEMP_DIR_NAME, MAX_TOOL_NAME_LENGTH, PROMPT_TEMPLATE_VARIABLES, DEFAULT_MODEL_PATTERNS, DEFAULT_IMAGE_ANALYSIS_TOOL, DEFAULT_CLEANUP_AFTER_HOURS, + DEFAULT_DISABLE_CODE_MODE, } from "./constants.js"; -import type { PluginConfig, Logger } from "./types.js"; - -let pluginConfig: PluginConfig = {}; - -// Path Resolution -function getUserConfigPath(filename: string): string { - return join(homedir(), ".config", "opencode", filename); -} - -function getProjectConfigPath(directory: string, filename: string): string { - return join(directory, ".opencode", filename); -} // File Parsing function parseModelsArray(value: unknown): string[] | undefined { @@ -68,6 +55,10 @@ function parseCleanupAfterHours(value: unknown): number | undefined { return value; } +function parseDisableCodeMode(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + function parseConfigObject(raw: unknown): PluginConfig { if (!raw || typeof raw !== "object") return {}; @@ -78,6 +69,7 @@ function parseConfigObject(raw: unknown): PluginConfig { promptTemplate: parsePromptTemplate(obj.promptTemplate), tempDir: parseTempDir(obj.tempDir), cleanupAfterHours: parseCleanupAfterHours(obj.cleanupAfterHours), + disableCodeMode: parseDisableCodeMode(obj.disableCodeMode), }; } @@ -98,53 +90,19 @@ async function readConfigFile( } } -type LoadedConfig = { - config: PluginConfig; - path: string; -}; - -type ConfigPaths = { - jsonc: string; - json: string; -}; - -async function readConfigPair( - paths: ConfigPaths, - onParseError: (path: string) => void, -): Promise { - const jsoncConfig = await readConfigFile(paths.jsonc, onParseError); - if (jsoncConfig !== null) return { config: jsoncConfig, path: paths.jsonc }; - - const jsonConfig = await readConfigFile(paths.json, onParseError); - return jsonConfig === null ? null : { config: jsonConfig, path: paths.json }; -} - -function configExists(paths: ConfigPaths): boolean { - return existsSync(paths.jsonc) || existsSync(paths.json); -} - async function loadConfigAtLevel( level: "project" | "user", - currentPaths: ConfigPaths, - legacyPaths: ConfigPaths, + directory: string, onParseError: (path: string) => void, log: Logger, -): Promise { - const current = await readConfigPair(currentPaths, onParseError); - if (current !== null) { - if (configExists(legacyPaths)) { - log( - `Ignoring alternate ${level} config because ${current.path} is present`, - ); - } - return current; - } - - const legacy = await readConfigPair(legacyPaths, onParseError); - if (legacy !== null) { - log(`Using ${level} config at ${legacy.path}`); - } - return legacy; +): Promise { + const removed = join(directory, REMOVED_CONFIG_FILENAME); + if (existsSync(removed)) + log(`WARN: ${removed} is no longer read; rename it to ${CONFIG_FILENAME}`); + const path = join(directory, CONFIG_FILENAME); + const config = await readConfigFile(path, onParseError); + if (config !== null) log(`Using ${level} config at ${path}`); + return config; } // Example Config Auto-Init @@ -194,10 +152,12 @@ function selectConfigValue( return { value: undefined, source: "default" }; } -export async function loadPluginConfig( +async function loadPluginConfig( directory: string, log: Logger, -): Promise { + userDirectory: string, + autoInit: boolean, +): Promise { const onParseError = (path: string) => log(`WARN: Config file has invalid JSON and will be ignored: ${path}`); @@ -216,42 +176,21 @@ export async function loadPluginConfig( } } - const userPaths = { - jsonc: getUserConfigPath(CONFIG_FILENAME_JSONC), - json: getUserConfigPath(CONFIG_FILENAME), - }; - const legacyUserPaths = { - jsonc: getUserConfigPath(LEGACY_CONFIG_FILENAME_JSONC), - json: getUserConfigPath(LEGACY_CONFIG_FILENAME), - }; - const projectPaths = { - jsonc: getProjectConfigPath(directory, CONFIG_FILENAME_JSONC), - json: getProjectConfigPath(directory, CONFIG_FILENAME), - }; - const legacyProjectPaths = { - jsonc: getProjectConfigPath(directory, LEGACY_CONFIG_FILENAME_JSONC), - json: getProjectConfigPath(directory, LEGACY_CONFIG_FILENAME), - }; - + const projectDirectory = join(directory, ".opencode"); const [projectConfig, userConfig] = await Promise.all([ - loadConfigAtLevel( - "project", - projectPaths, - legacyProjectPaths, - onParseError, - log, - ), - loadConfigAtLevel("user", userPaths, legacyUserPaths, onParseError, log), + loadConfigAtLevel("project", projectDirectory, onParseError, log), + loadConfigAtLevel("user", userDirectory, onParseError, log), ]); - if (!configExists(userPaths) && !configExists(legacyUserPaths)) { - await createExampleConfigIfMissing(userPaths.jsonc, log); + // Skip when a pre-2.0 .json exists: the user should rename it instead. + if (autoInit && !existsSync(join(userDirectory, REMOVED_CONFIG_FILENAME))) { + await createExampleConfigIfMissing( + join(userDirectory, CONFIG_FILENAME), + log, + ); } - const models = selectConfigValue( - projectConfig?.config.models, - userConfig?.config.models, - ); + const models = selectConfigValue(projectConfig?.models, userConfig?.models); logSelected( "models", models, @@ -260,8 +199,8 @@ export async function loadPluginConfig( ); const imageAnalysisTool = selectConfigValue( - projectConfig?.config.imageAnalysisTool, - userConfig?.config.imageAnalysisTool, + projectConfig?.imageAnalysisTool, + userConfig?.imageAnalysisTool, ); logSelected( "imageAnalysisTool", @@ -271,8 +210,8 @@ export async function loadPluginConfig( ); const promptTemplate = selectConfigValue( - projectConfig?.config.promptTemplate, - userConfig?.config.promptTemplate, + projectConfig?.promptTemplate, + userConfig?.promptTemplate, ); logSelected( "promptTemplate", @@ -282,14 +221,14 @@ export async function loadPluginConfig( ); const tempDir = selectConfigValue( - projectConfig?.config.tempDir, - userConfig?.config.tempDir, + projectConfig?.tempDir, + userConfig?.tempDir, ); logSelected("tempDir", tempDir, tempDir.value ?? "", null); const cleanupAfterHours = selectConfigValue( - projectConfig?.config.cleanupAfterHours, - userConfig?.config.cleanupAfterHours, + projectConfig?.cleanupAfterHours, + userConfig?.cleanupAfterHours, ); logSelected( "cleanupAfterHours", @@ -298,42 +237,54 @@ export async function loadPluginConfig( `Using default cleanupAfterHours: ${DEFAULT_CLEANUP_AFTER_HOURS}`, ); - pluginConfig = { + const disableCodeMode = selectConfigValue( + projectConfig?.disableCodeMode, + userConfig?.disableCodeMode, + ); + logSelected( + "disableCodeMode", + disableCodeMode, + String(disableCodeMode.value), + null, + ); + + return { models: models.value, imageAnalysisTool: imageAnalysisTool.value, promptTemplate: promptTemplate.value, tempDir: tempDir.value, cleanupAfterHours: cleanupAfterHours.value, + disableCodeMode: disableCodeMode.value, }; } -// Accessors -export function getConfiguredModels(): readonly string[] { - return pluginConfig.models ?? DEFAULT_MODEL_PATTERNS; -} - -export function getImageAnalysisTool(): string { - return pluginConfig.imageAnalysisTool ?? DEFAULT_IMAGE_ANALYSIS_TOOL; -} - -export function getPromptTemplate(): string | undefined { - return pluginConfig.promptTemplate; -} - -export function getTempDir(): string { - return pluginConfig.tempDir ?? join(tmpdir(), TEMP_DIR_NAME); -} - -export function getTempDirsForCleanup(): readonly string[] { - return [ - ...new Set([ - getTempDir(), - join(tmpdir(), TEMP_DIR_NAME), - join(tmpdir(), LEGACY_TEMP_DIR_NAME), - ]), - ]; -} - -export function getCleanupAfterHours(): number { - return pluginConfig.cleanupAfterHours ?? DEFAULT_CLEANUP_AFTER_HOURS; +export function createConfigStore( + initial: PluginConfig = {}, + options: { userDirectory?: string; autoInit?: boolean } = {}, +): ConfigAccessors & { load(directory: string, log: Logger): Promise } { + let config: PluginConfig = { ...initial, models: initial.models?.slice() }; + const getTempDir = () => config.tempDir ?? join(tmpdir(), TEMP_DIR_NAME); + return { + async load(directory, log) { + config = await loadPluginConfig( + directory, + log, + options.userDirectory ?? join(homedir(), ".config", "opencode"), + options.autoInit ?? true, + ); + }, + getConfiguredModels: () => + (config.models ?? DEFAULT_MODEL_PATTERNS).slice(), + getImageAnalysisTool: () => + config.imageAnalysisTool ?? DEFAULT_IMAGE_ANALYSIS_TOOL, + getPromptTemplate: () => config.promptTemplate, + getTempDir, + getTempDirsForCleanup: () => [ + ...new Set([getTempDir(), join(tmpdir(), TEMP_DIR_NAME)]), + ], + getCleanupAfterHours: () => + config.cleanupAfterHours ?? DEFAULT_CLEANUP_AFTER_HOURS, + getDisableCodeMode: () => + config.disableCodeMode ?? DEFAULT_DISABLE_CODE_MODE, + }; } diff --git a/src/constants.ts b/src/constants.ts index 7715f53..6913b2b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,16 +2,13 @@ export const PLUGIN_NAME = "easy-vision"; // Config filenames -export const CONFIG_FILENAME = "opencode-easy-vision.json"; -export const CONFIG_FILENAME_JSONC = "opencode-easy-vision.jsonc"; -export const LEGACY_CONFIG_FILENAME = "opencode-minimax-easy-vision.json"; -export const LEGACY_CONFIG_FILENAME_JSONC = - "opencode-minimax-easy-vision.jsonc"; +export const CONFIG_FILENAME = "opencode-easy-vision.jsonc"; +// Also read by 1.x. 2.x ignores it and only checks for it, to log a rename hint. +export const REMOVED_CONFIG_FILENAME = "opencode-easy-vision.json"; export const EXAMPLE_CONFIG_FILENAME = "opencode-easy-vision.example.jsonc"; // Directories and limits export const TEMP_DIR_NAME = "opencode-easy-vision"; -export const LEGACY_TEMP_DIR_NAME = "opencode-minimax-vision"; // Treat tool names longer than this as a misconfiguration — MCP tool names have a defined upper bound. export const MAX_TOOL_NAME_LENGTH = 256; @@ -27,8 +24,9 @@ export const DEFAULT_MODEL_PATTERNS: readonly string[] = [ "minimax-token-plan/*", "minimax-cn-token-plan/*", ]; -export const DEFAULT_IMAGE_ANALYSIS_TOOL = "mcp_minimax_understand_image"; +export const DEFAULT_IMAGE_ANALYSIS_TOOL = "MiniMax_understand_image"; export const DEFAULT_CLEANUP_AFTER_HOURS = 24; +export const DEFAULT_DISABLE_CODE_MODE = true; // MIME types — OpenCode normalizes all JPEG variants to image/jpeg, // so image/jpg (non-standard, not in IANA registry) is not needed. diff --git a/src/file-lock.ts b/src/file-lock.ts new file mode 100644 index 0000000..c6858b6 --- /dev/null +++ b/src/file-lock.ts @@ -0,0 +1,21 @@ +// Shared only for filesystem coordination, never for project configuration. +const pending = new Map>(); + +export async function withFileLock( + path: string, + action: () => Promise, +): Promise { + const previous = pending.get(path) ?? Promise.resolve(); + let release: () => void = () => {}; + const current = new Promise((resolve) => { + release = resolve; + }); + pending.set(path, current); + await previous; + try { + return await action(); + } finally { + release(); + if (pending.get(path) === current) pending.delete(path); + } +} diff --git a/src/images.ts b/src/images.ts index ea08986..916e6b9 100644 --- a/src/images.ts +++ b/src/images.ts @@ -1,162 +1,156 @@ -import type { Part, FilePart } from "@opencode-ai/sdk"; +import type { + ConfigAccessors, + ImageAttachment, + ImageResult, + Logger, +} from "./types.js"; +import { createHash, randomUUID } from "node:crypto"; +import { + access, + lstat, + mkdir, + rename, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; -import { mkdir, writeFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; -import { SUPPORTED_MIME_TYPES, MIME_TO_EXTENSION } from "./constants.js"; -import { getTempDir } from "./config.js"; -import type { SavedImage, Logger, Notifier } from "./types.js"; +import { MIME_TO_EXTENSION, SUPPORTED_MIME_TYPES } from "./constants.js"; +import { withFileLock } from "./file-lock.js"; -// Type Guards -// -// Messages in OpenCode contain "parts" - an array of different content types: -// - TextPart: The user's typed text -// - FilePart: Attached files (images, PDFs, etc.) with mime type and URL - -export function isImageFilePart(part: Part): part is FilePart { - if (part.type !== "file") return false; - const mime = (part as FilePart).mime?.toLowerCase() ?? ""; - return SUPPORTED_MIME_TYPES.has(mime); -} - -// URL Handlers -// -// Images can arrive via different URL schemes: -// - file:// → Already on disk, just need the local path -// - data: → Base64-encoded, must decode and save to temp file -// - http(s): → Remote URL, pass through for MCP tool to fetch directly - -function handleFileUrl( - url: string, - filePart: FilePart, - log: Logger, -): SavedImage { - const localPath = fileURLToPath(url); - log(`Image already on disk: ${localPath}`); - return { path: localPath, mime: filePart.mime, partId: filePart.id }; +export function normalizeMime(mime: string): string { + return mime.split(";", 1)[0].trim().toLowerCase(); } -function parseBase64DataUrl( - dataUrl: string, -): { mime: string; data: Buffer } | null { - const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); - if (!match) return null; - - try { - return { mime: match[1], data: Buffer.from(match[2], "base64") }; - } catch { - return null; +export function decodeBase64(value: string): Buffer { + const text = value.replace(/\s/g, ""); + if ( + !text || + !/^[A-Za-z0-9+/]*={0,2}$/.test(text) || + text.length % 4 === 1 || + (text.includes("=") && text.length % 4 !== 0) + ) { + throw new Error("Malformed base64 image data"); } -} - -async function handleDataUrl( - url: string, - filePart: FilePart, - log: Logger, - notify: Notifier, -): Promise { - // Pasted clipboard images arrive as base64 data URLs. - // Decode and save to a temp file so the MCP tool can read it. - const parsed = parseBase64DataUrl(url); - if (!parsed) { - log(`Failed to parse data URL for part ${filePart.id}`); - return null; + const bytes = Buffer.from(text, "base64"); + if ( + !bytes.length || + bytes.toString("base64").replace(/=+$/, "") !== text.replace(/=+$/, "") + ) { + throw new Error("Malformed base64 image data"); } + return bytes; +} +// The name is a content hash, but the directory may be shared (e.g. /tmp on +// Linux): only trust a regular file we own with the expected size. Anything else, +// including a planted symlink, is atomically replaced by the rename below. +async function isReusable(path: string, size: number): Promise { try { - const savedPath = await saveImageToTemp( - parsed.data, - filePart.mime, - filePart.id, + const stats = await lstat(path); + return ( + stats.isFile() && + stats.size === size && + stats.uid === (process.getuid?.() ?? stats.uid) ); - log(`Saved image to: ${savedPath}`); - return { path: savedPath, mime: filePart.mime, partId: filePart.id }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log(`Failed to save image: ${msg}`); - notify.error(`Could not save image to disk: ${msg}`, "Easy Vision"); - return null; + } catch { + return false; } } -function handleHttpUrl( - url: string, - filePart: FilePart, - log: Logger, -): SavedImage { - // Remote URLs are passed directly to the MCP tool, which can fetch them itself. - log(`Image is remote URL: ${url}`); - return { path: url, mime: filePart.mime, partId: filePart.id }; -} - -// File Operations - -function getExtensionForMime(mime: string): string { - // PNG fallback: unknown MIME types are saved as .png so the file is always - // openable, even if the extension doesn't perfectly match the content. - return MIME_TO_EXTENSION[mime.toLowerCase()] ?? "png"; -} - -async function ensureTempDir(): Promise { - const dir = getTempDir(); - await mkdir(dir, { recursive: true }); - return dir; -} - -async function saveImageToTemp( - data: Buffer, +async function persist( + bytes: Uint8Array, mime: string, - partId: string, + directory: string, ): Promise { - const tempDir = await ensureTempDir(); - // OpenCode reruns message transforms after tool calls. A stable name keeps every - // pass for the same attachment pointed at the path already shown to the model. - const fileId = createHash("sha256").update(partId).digest("hex"); - const filename = `${fileId}.${getExtensionForMime(mime)}`; - const filepath = join(tempDir, filename); - await writeFile(filepath, data); - return filepath; -} - -// Main Processor - -async function processImagePart( - filePart: FilePart, - log: Logger, - notify: Notifier, -): Promise { - const url = filePart.url; - - if (!url) { - log(`Skipping image part ${filePart.id}: no URL`); - return null; - } - - if (url.startsWith("file://")) { - return handleFileUrl(url, filePart, log); - } - - if (url.startsWith("data:")) { - return handleDataUrl(url, filePart, log, notify); - } - - if (url.startsWith("http://") || url.startsWith("https://")) { - return handleHttpUrl(url, filePart, log); - } - - log(`Unsupported URL scheme for part ${filePart.id}: ${url.slice(0, 50)}...`); - return null; + if (!bytes.length) throw new Error("Empty image data"); + const hash = createHash("sha256") + .update(mime) + .update("\0") + .update(bytes) + .digest("hex"); + const path = join(directory, `${hash}.${MIME_TO_EXTENSION[mime]}`); + await withFileLock(path, async () => { + // Every request replays the whole history, so most saves hit an existing file. + if (await isReusable(path, bytes.length)) { + // Refresh mtime so startup cleanup never deletes a file that is in use. + const now = new Date(); + await utimes(path, now, now); + return; + } + await mkdir(directory, { recursive: true }); + const staging = `${path}.writing-${randomUUID()}`; + try { + await writeFile(staging, bytes, { mode: 0o600 }); + await rename(staging, path); + } finally { + await unlink(staging).catch(() => {}); + } + }); + return path; } -export async function extractImagesFromParts( - parts: Part[], - log: Logger, - notify: Notifier, -): Promise { - const results = await Promise.all( - parts - .filter(isImageFilePart) - .map((part) => processImagePart(part, log, notify)), - ); - return results.filter((r): r is SavedImage => r !== null); +export function createImageProcessor(config: ConfigAccessors, log: Logger) { + return async (attachment: ImageAttachment): Promise => { + const mime = normalizeMime(attachment.mime); + try { + if (!SUPPORTED_MIME_TYPES.has(mime)) + throw new Error(`Unsupported image format (${mime})`); + const source = attachment.source; + let path: string; + switch (source.type) { + case "bytes": + path = await persist(source.data, mime, config.getTempDir()); + break; + case "base64": + path = await persist( + decodeBase64(source.data), + mime, + config.getTempDir(), + ); + break; + case "ref": + throw new Error( + "Provider image reference cannot be resolved; attach the original image instead", + ); + case "url": { + if (attachment.requiresHeaders) + throw new Error( + "Image URL requires private headers; attach the image bytes instead", + ); + if (source.expiresAt !== undefined && source.expiresAt <= Date.now()) + throw new Error("Image URL has expired; attach the image again"); + const url = new URL(source.url); + if (url.protocol === "file:") { + path = fileURLToPath(url); + await access(path); + } else if (url.protocol === "http:" || url.protocol === "https:") { + path = source.url; + } else if (url.protocol === "data:") { + const match = source.url.match(/^data:([^;,]+);base64,([\s\S]*)$/i); + if (!match || normalizeMime(match[1]) !== mime) + throw new Error("Malformed or mismatched image data URL"); + path = await persist( + decodeBase64(match[2]), + mime, + config.getTempDir(), + ); + } else { + throw new Error("Unsupported image URL scheme"); + } + break; + } + } + return { ok: true, image: { path, mime } }; + } catch (error) { + // Never log payloads, source URLs, private headers, or I/O error details. + const reason = + error instanceof Error && !("code" in error) + ? error.message + : "Could not save/read the image; check the server temp directory and permissions"; + log(`Image processing failed: ${reason}`); + return { ok: false, reason }; + } + }; } diff --git a/src/index.ts b/src/index.ts index e2a4ee7..3a3a907 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,126 +1,71 @@ -import type { Plugin } from "@opencode-ai/plugin"; +import type { Plugin } from "@opencode/plugin/effect"; +import type { Logger, Notifier } from "./types.js"; +import { Effect } from "effect"; import { PLUGIN_NAME } from "./constants.js"; -import { loadPluginConfig, getImageAnalysisTool } from "./config.js"; -import { modelMatchesAnyPattern } from "./patterns.js"; -import { isImageFilePart, extractImagesFromParts } from "./images.js"; -import { generateInjectionPrompt } from "./prompt.js"; -import { - isTextPart, - findLastUserMessage, - getModelFromMessage, - removeProcessedImageParts, - withUpdatedTextPart, -} from "./transform.js"; +import { createConfigStore } from "./config.js"; import { cleanupOldTempFiles } from "./cleanup.js"; -import type { Logger, Notifier } from "./types.js"; - -export const EasyVisionPlugin: Plugin = async (input) => { - const { client, directory } = input; - - const log: Logger = (msg) => { - client.app - .log({ body: { service: PLUGIN_NAME, level: "info", message: msg } }) - .catch(() => {}); - }; - - function makeNotifyMethod( - level: "warn" | "error", - variant: "warning" | "error", - ) { - return (message: string, title?: string) => { - client.app - .log({ body: { service: PLUGIN_NAME, level, message } }) - .catch(() => {}); - client.tui - .showToast({ body: { title, message, variant, duration: 5000 } }) - .catch(() => {}); - }; - } - - const notify: Notifier = { - warn: makeNotifyMethod("warn", "warning"), - error: makeNotifyMethod("error", "error"), - }; - - await loadPluginConfig(directory, log); - await cleanupOldTempFiles(log); - log("Plugin initialized"); - - return { - "experimental.chat.messages.transform": async (_input, output) => { - const { messages } = output; - - const result = findLastUserMessage(messages); - if (!result) return; - - const { message: lastUserMessage, index: lastUserIndex } = result; - - const model = getModelFromMessage(lastUserMessage); - if (!modelMatchesAnyPattern(model)) return; - - const hasImages = lastUserMessage.parts.some(isImageFilePart); - if (!hasImages) return; - - log("Found images in message, processing"); - - // Collect all image part IDs up front so failed saves are also removed from the - // message — otherwise raw data: blobs stay in parts and the model can't use them. - const allImagePartIds = new Set( - lastUserMessage.parts.filter(isImageFilePart).map((p) => p.id), +import { createCodeModeTransform } from "./codemode.js"; +import { createRequestRewriter } from "./request.js"; + +// The Effect API is used only for this shell: OpenCode drops plugin console +// output, but Effect logs inherit the host logger and reach OpenCode's log file. +// `effect` resolves to the host's instance at runtime. +export const EasyVisionPlugin: Plugin.Plugin = { + id: "opencode-easy-vision", + effect: Effect.fn(function* (ctx) { + const services = yield* Effect.context(); + const run = Effect.runPromiseWith(services); + const emit = (effect: Effect.Effect) => { + Effect.runForkWith(services)( + effect.pipe(Effect.annotateLogs("plugin", PLUGIN_NAME)), ); + }; + const log: Logger = (message) => emit(Effect.logInfo(message)); + const notify: Notifier = { + warn: (message) => emit(Effect.logWarning(message)), + }; - const savedImages = await extractImagesFromParts( - lastUserMessage.parts, - log, - notify, - ); - if (savedImages.length === 0) { - log("Failed to process any attached images"); - notify.error( - "Could not process attached images. Check logs for details.", - "Easy Vision", - ); - messages[lastUserIndex] = { - ...lastUserMessage, - parts: removeProcessedImageParts( - lastUserMessage.parts, - allImagePartIds, + const config = createConfigStore(); + yield* Effect.promise(() => config.load(ctx.location.directory, log)); + yield* Effect.promise(() => cleanupOldTempFiles(config, log)); + + const requests = createRequestRewriter( + config, + { + directory: ctx.location.directory, + getSessionDirectory: (sessionID) => + run(ctx.session.get({ sessionID })).then( + (session) => session.location.directory, ), - }; - return; - } - - if (savedImages.length < allImagePartIds.size) { - notify.warn( - `${savedImages.length} of ${allImagePartIds.size} image(s) were processed successfully. Some images could not be saved.`, - "Easy Vision", - ); - } - - log(`Saved ${savedImages.length} image(s), injecting tool instructions`); - - const existingTextPart = lastUserMessage.parts.find(isTextPart); - const userText = existingTextPart?.text ?? ""; - - const transformedText = generateInjectionPrompt( - savedImages, - userText, - getImageAnalysisTool(), - ); - - const trimmedParts = removeProcessedImageParts( - lastUserMessage.parts, - allImagePartIds, - ); - messages[lastUserIndex] = { - ...lastUserMessage, - parts: withUpdatedTextPart( - { ...lastUserMessage, parts: trimmedParts }, - transformedText, - ), - }; - }, - }; + listTools: () => run(ctx.tool.list()), + }, + log, + notify, + ); + + yield* ctx.mcp.transform( + createCodeModeTransform( + config.getImageAnalysisTool, + config.getDisableCodeMode, + log, + ), + ); + yield* ctx.session.hook("context", (event) => + Effect.promise(() => requests.rewrite(event, false, event.tools)), + ); + yield* ctx.session.hook("compaction", (event) => + Effect.promise(() => requests.rewrite(event, true)), + ); + yield* ctx.session.hook("title", (event) => + Effect.promise(() => requests.rewrite(event, true)), + ); + // Compaction, title and transient generation get image paths but never tool instructions. + yield* ctx.session.hook("generate", (event) => + Effect.promise(() => requests.rewrite(event, true)), + ); + yield* Effect.addFinalizer(() => Effect.sync(requests.dispose)); + log("Plugin initialized"); + }), }; export default EasyVisionPlugin; diff --git a/src/patterns.ts b/src/patterns.ts index 1fa10e0..46c6256 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -1,5 +1,4 @@ -import { getConfiguredModels } from "./config.js"; -import type { ModelInfo } from "./types.js"; +import type { ConfigAccessors, ModelInfo } from "./types.js"; function matchesWildcardPattern(pattern: string, value: string): boolean { const p = pattern.toLowerCase(); @@ -47,9 +46,11 @@ function matchesSinglePattern(pattern: string, model: ModelInfo): boolean { ); } -export function modelMatchesAnyPattern(model: ModelInfo | undefined): boolean { - if (!model) return false; - - const patterns = getConfiguredModels(); - return patterns.some((pattern) => matchesSinglePattern(pattern, model)); +export function createModelMatcher(config: ConfigAccessors) { + return (model: ModelInfo | undefined): boolean => { + if (!model) return false; + return config + .getConfiguredModels() + .some((pattern) => matchesSinglePattern(pattern, model)); + }; } diff --git a/src/prompt.ts b/src/prompt.ts index d28d293..6ed45a7 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,63 +1,41 @@ +import type { ConfigAccessors, SavedImage, ToolRoute } from "./types.js"; import { DEFAULT_PROMPT_TEMPLATE_MULTIPLE, DEFAULT_PROMPT_TEMPLATE_SINGLE, } from "./constants.js"; -import { getPromptTemplate } from "./config.js"; -import type { SavedImage } from "./types.js"; -// Since the target model doesn't natively understand image attachments, -// we replace them with text instructions that tell the model to use an -// MCP tool (e.g., understand_image) with the file path or URL. -// The user's original text is preserved as "User's request: ...". - -function applyPromptTemplate( - template: string, - vars: { - imageList: string; - imageCount: number; - toolName: string; - userText: string; - }, -): string { - return template - .replace(/\{imageList\}/g, vars.imageList) - .replace(/\{imageCount\}/g, String(vars.imageCount)) - .replace(/\{toolName\}/g, vars.toolName) - .replace(/\{userText\}/g, vars.userText); -} - -export function generateInjectionPrompt( - images: SavedImage[], - userText: string, - toolName: string, -): string { - if (images.length === 0) return userText; - - const imageList = images - .map((img, idx) => `- Image ${idx + 1}: ${img.path}`) - .join("\n"); - - const customTemplate = getPromptTemplate(); - if (customTemplate !== undefined) { - return applyPromptTemplate(customTemplate, { +export function createPromptGenerator(config: ConfigAccessors) { + return ( + images: readonly SavedImage[], + userText: string, + route: ToolRoute, + ): string => { + const toolName = config.getImageAnalysisTool(); + const imageList = images + .map((image, index) => `- Image ${index + 1}: ${image.path}`) + .join("\n"); + if (route === "unavailable") { + return `Saved image context:\n${imageList}\nEasy Vision: ${toolName} is not available for this request. Connect the vision MCP and allow its tool (or execute for Code Mode). Do not claim to have analyzed the images.`; + } + const template = + config.getPromptTemplate() ?? + (images.length === 1 + ? DEFAULT_PROMPT_TEMPLATE_SINGLE + : DEFAULT_PROMPT_TEMPLATE_MULTIPLE); + const variables: Record = { imageList, - imageCount: images.length, + imageCount: String(images.length), toolName, - userText, - }); - } - - const isSingle = images.length === 1; - const template = isSingle - ? DEFAULT_PROMPT_TEMPLATE_SINGLE - : DEFAULT_PROMPT_TEMPLATE_MULTIPLE; - - return applyPromptTemplate(template, { - imageList, - imageCount: images.length, - toolName, - // When the user pastes an image with no accompanying text, {userText} would be - // blank, leaving the model without any instruction. Fall back to a sensible default. - userText: userText || "(analyze the image)", - }); + userText: userText || "(analyze the image)", + }; + const prompt = template.replace( + /\{(imageList|imageCount|toolName|userText)\}/g, + (_, name: string) => variables[name], + ); + const routing = + route === "codemode" + ? `\nThis MCP tool is exposed through Code Mode, not as a direct function. Use execute and its search/catalog facilities to discover the exact ${toolName} tool and signature, then invoke it through the returned tools catalog. Do not guess the namespace or bypass permissions.` + : ""; + return `${prompt}${routing}\nReuse any image analysis already present in this conversation; do not repeat completed analysis.`; + }; } diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..a9c2e84 --- /dev/null +++ b/src/request.ts @@ -0,0 +1,95 @@ +import type { SessionRequest } from "@opencode/plugin/effect/session"; +import type { + ConfigAccessors, + Logger, + Notifier, + RegisteredTool, +} from "./types.js"; +import { resolve } from "node:path"; +import { createModelMatcher } from "./patterns.js"; +import { createImageProcessor } from "./images.js"; +import { createPromptGenerator } from "./prompt.js"; +import { + isImagePart, + stripImagesAfterFailure, + transformMessages, +} from "./transform.js"; +import { resolveToolRoute } from "./tool-route.js"; + +// Host lookups as Promises so request rewriting stays plain async code. +export interface RequestHost { + readonly directory: string; + getSessionDirectory(sessionID: SessionRequest["sessionID"]): Promise; + listTools(): Promise; +} + +export function createRequestRewriter( + config: ConfigAccessors, + host: RequestHost, + log: Logger, + notify: Notifier, +) { + const matches = createModelMatcher(config); + const process = createImageProcessor(config, log); + const prompt = createPromptGenerator(config); + const warnings = new Set(); + const warnOnce = (key: string, message: string) => { + if (warnings.has(key)) return; + if (warnings.size >= 256) { + const oldest = warnings.values().next().value; + if (oldest !== undefined) warnings.delete(oldest); + } + warnings.add(key); + notify.warn(message); + }; + + const rewrite = async ( + event: SessionRequest, + pathsOnly: boolean, + tools: Record = {}, + ): Promise => { + if ( + !matches({ providerID: event.model.providerID, modelID: event.model.id }) + ) + return; + if ( + !event.messages.some( + (message) => + message.role === "user" && message.content.some(isImagePart), + ) + ) + return; + try { + const sessionDirectory = await host.getSessionDirectory(event.sessionID); + if (resolve(sessionDirectory) !== resolve(host.directory)) return; + const registered = pathsOnly ? [] : await host.listTools(); + const route = resolveToolRoute( + config.getImageAnalysisTool(), + tools, + registered, + ); + if (!pathsOnly && route === "unavailable") + warnOnce( + `${event.sessionID}:tool`, + `Vision tool ${config.getImageAnalysisTool()} is unavailable in this request. Check MCP connection, tool name, exposure, and permissions.`, + ); + event.messages = await transformMessages(event.messages, { + process, + prompt, + route, + pathsOnly, + onFailure: (reason, index) => + warnOnce(`${event.sessionID}:${index}:${reason}`, reason), + }); + } catch { + // Still remove the images: the text-only model cannot accept them. + event.messages = stripImagesAfterFailure(event.messages); + warnOnce( + `${event.sessionID}:unexpected`, + "Image processing failed; see the attachment failure note in model context.", + ); + } + }; + + return { rewrite, dispose: () => warnings.clear() }; +} diff --git a/src/tool-route.ts b/src/tool-route.ts new file mode 100644 index 0000000..e1e5956 --- /dev/null +++ b/src/tool-route.ts @@ -0,0 +1,19 @@ +import type { RegisteredTool, ToolRoute } from "./types.js"; + +// How the model can reach the vision tool in this request: as a direct tool, +// through Code Mode's `execute`, or not at all. +export function resolveToolRoute( + toolName: string, + tools: Record, + registered: readonly RegisteredTool[], +): ToolRoute { + if (Object.hasOwn(tools, toolName)) return "direct"; + const tool = registered.find((entry) => entry.id === toolName); + if ( + tool && + tool.options?.codemode !== false && + Object.hasOwn(tools, "execute") + ) + return "codemode"; + return "unavailable"; +} diff --git a/src/transform.ts b/src/transform.ts index a3d931c..4caa420 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,65 +1,139 @@ -import type { Message, Part, TextPart } from "@opencode-ai/sdk"; -import { randomUUID } from "node:crypto"; -import type { ModelInfo } from "./types.js"; +import type { ContentPart, Message } from "@opencode/ai"; +import type { + ImageAttachment, + ImageResult, + SavedImage, + ToolRoute, +} from "./types.js"; +import { normalizeMime } from "./images.js"; -export function isTextPart(part: Part): part is TextPart { - return part.type === "text"; -} +type MediaPart = Extract; -// The transformation flow: -// 1. Find the last user message (most recent request) -// 2. Extract and save any images from its parts -// 3. Remove the image parts (they can't be sent to the model) -// 4. Replace/update the text part with injection instructions +export function isImagePart(part: ContentPart): part is MediaPart { + return ( + part.type === "media" && + (part.media.kind === "image" || + normalizeMime(part.media.mediaType).startsWith("image/")) + ); +} -export function findLastUserMessage( - messages: Array<{ info: Message; parts: Part[] }>, -): { message: { info: Message; parts: Part[] }; index: number } | null { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].info.role === "user") { - return { message: messages[i], index: i }; - } - } - return null; +export function normalizeAttachment(part: MediaPart): ImageAttachment { + return { + mime: normalizeMime(part.media.mediaType), + source: part.media.source, + requiresHeaders: Object.keys(part.media.headers ?? {}).length > 0, + }; } -export function getModelFromMessage(message: { - info: Message; -}): ModelInfo | undefined { - const info = message.info as { model?: ModelInfo }; - return info.model; +const marker = { easyVision: true }; + +// OpenCode flattens skills and attachments into plain text parts alongside the +// user's own text (core session/runner/to-llm-message.ts). Only the typed text +// belongs in {userText}; the rest stays in place but must not be duplicated. +const CORE_TEXT_PREFIXES = [" { + if (part.type !== "text" || part.metadata !== undefined) return false; + return !CORE_TEXT_PREFIXES.some((prefix) => part.text.startsWith(prefix)); } -export function removeProcessedImageParts( - parts: Part[], - processedIds: Set, -): Part[] { - // Remove image parts that were successfully processed; they've been converted - // to file paths in the injection prompt and the model can't interpret raw images. - return parts.filter( - (part) => !(part.type === "file" && processedIds.has(part.id)), +export async function transformMessages( + messages: readonly Message[], + options: { + process: (attachment: ImageAttachment) => Promise; + prompt: ( + images: readonly SavedImage[], + text: string, + route: ToolRoute, + ) => string; + route: ToolRoute; + pathsOnly?: boolean; + onFailure?: (reason: string, messageIndex: number) => void; + }, +): Promise { + let lastUser = -1; + messages.forEach((message, index) => { + if (message.role === "user") lastUser = index; + }); + return Promise.all( + messages.map(async (message, messageIndex) => { + if (message.role !== "user" || !message.content.some(isImagePart)) + return message; + const images: SavedImage[] = []; + const failures: string[] = []; + const content: ContentPart[] = []; + for (const part of message.content) { + if (!isImagePart(part)) { + // Drop guidance from an earlier pass if another plugin reintroduced media. + if (part.type !== "text" || part.metadata?.easyVision !== true) + content.push(part); + continue; + } + let result: ImageResult; + try { + result = await options.process(normalizeAttachment(part)); + } catch { + result = { + ok: false, + reason: + "Unexpected image-processing failure; attach the image again or check server logs", + }; + } + if (result.ok) { + images.push(result.image); + } else { + failures.push(result.reason); + options.onFailure?.(result.reason, messageIndex); + } + content.push({ + type: "text", + text: result.ok + ? `\nAttached image: ${result.image.path}\n` + : `\nEasy Vision attachment failure: ${result.reason}\n`, + metadata: { ...part.metadata, ...marker }, + }); + } + if (images.length && messageIndex === lastUser && !options.pathsOnly) { + const originalText = message.content + .filter(isUserTypedText) + .map((part) => part.text) + .join("\n"); + content.push({ + type: "text", + text: options.prompt(images, originalText, options.route), + metadata: marker, + }); + } + if (failures.length) + content.push({ + type: "text", + metadata: marker, + text: `Easy Vision: ${failures.length} attachment(s) could not be processed${images.length ? `; ${images.length} image(s) remain available` : ""}. Do not infer the missing image contents.`, + }); + return { ...message, content }; + }), ); } -export function withUpdatedTextPart( - message: { info: Message; parts: Part[] }, - newText: string, -): Part[] { - const textPartIndex = message.parts.findIndex(isTextPart); - - if (textPartIndex !== -1) { - return message.parts.map((part, i) => - i === textPartIndex ? ({ ...part, text: newText } as TextPart) : part, - ); - } - - const newTextPart: TextPart = { - id: `transformed-${randomUUID()}`, - sessionID: message.info.sessionID, - messageID: message.info.id, - type: "text", - text: newText, - synthetic: true, // Tells OpenCode this part was generated by a transform, not typed by the user - }; - return [newTextPart, ...message.parts]; +export function stripImagesAfterFailure( + messages: readonly Message[], +): Message[] { + return messages.map((message) => + message.role !== "user" || !message.content.some(isImagePart) + ? message + : { + ...message, + content: message.content.map((part) => + isImagePart(part) + ? { + type: "text" as const, + text: "Easy Vision: attachment processing failed. Reattach the image or check server logs.", + metadata: marker, + } + : part, + ), + }, + ); } diff --git a/src/types.ts b/src/types.ts index a6ab1ed..d21a9bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,15 +1,45 @@ +import type { Media } from "@opencode/ai"; + export interface PluginConfig { models?: string[]; imageAnalysisTool?: string; promptTemplate?: string; tempDir?: string; cleanupAfterHours?: number; + disableCodeMode?: boolean; } export interface SavedImage { path: string; mime: string; - partId: string; // ID of the original FilePart, used to remove it from the message after injection +} + +export interface ImageAttachment { + mime: string; + source: Media.Source; + requiresHeaders?: boolean; +} + +export type ImageResult = + | { ok: true; image: SavedImage } + | { ok: false; reason: string }; + +export interface ConfigAccessors { + getConfiguredModels(): readonly string[]; + getImageAnalysisTool(): string; + getPromptTemplate(): string | undefined; + getTempDir(): string; + getTempDirsForCleanup(): readonly string[]; + getCleanupAfterHours(): number; + getDisableCodeMode(): boolean; +} + +export type ToolRoute = "direct" | "codemode" | "unavailable"; + +// The fields of OpenCode's tool registry entries that routing needs. +export interface RegisteredTool { + id: string; + options?: { codemode?: boolean }; } export interface ModelInfo { @@ -17,11 +47,9 @@ export interface ModelInfo { modelID: string; } -// Logger writes to the app's structured log (silent to the user). -// Notifier surfaces visible toast messages for errors the user should act on. -export type Logger = (msg: string) => void; +// Both go to OpenCode's log; the plugin has no UI of its own. +export type Logger = (message: string) => void; -export type Notifier = { - warn: (message: string, title?: string) => void; - error: (message: string, title?: string) => void; -}; +export interface Notifier { + warn: Logger; +} diff --git a/test/cleanup.test.ts b/test/cleanup.test.ts new file mode 100644 index 0000000..9973beb --- /dev/null +++ b/test/cleanup.test.ts @@ -0,0 +1,111 @@ +import { readdir, readFile, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { cleanupOldTempFiles } from "../src/cleanup.js"; +import { createConfigStore } from "../src/config.js"; +import { PNG_BYTES, fixture, recorder, silent, tempDir } from "./helpers.js"; + +const HASH = "a".repeat(64); +const HOUR = 60 * 60 * 1000; + +async function writeAged(path: string, ageMs: number) { + await writeFile(path, PNG_BYTES); + const when = new Date(Date.now() - ageMs); + await utimes(path, when, when); +} + +function storeFor(dir: string, cleanupAfterHours = 24) { + return { + ...createConfigStore({ tempDir: dir, cleanupAfterHours }), + getTempDirsForCleanup: () => [dir], + }; +} + +describe("cleanupOldTempFiles", () => { + it("deletes only expired plugin-owned files", async () => { + const dir = await tempDir(); + await writeAged(join(dir, `${HASH}.png`), 25 * HOUR); + await writeAged(join(dir, `${"b".repeat(64)}.jpg`), 25 * HOUR); + await writeAged(join(dir, `${"c".repeat(64)}.webp`), 1 * HOUR); + await writeAged(join(dir, "user-owned.png"), 25 * HOUR); + await writeAged(join(dir, `${HASH}.gif`), 25 * HOUR); + await writeAged( + join(dir, "01234567-89ab-cdef-0123-456789abcdef.png"), + 25 * HOUR, + ); + const { log, messages } = recorder(); + await cleanupOldTempFiles(storeFor(dir), log); + expect((await readdir(dir)).sort()).toEqual( + [ + `${"c".repeat(64)}.webp`, + "user-owned.png", + `${HASH}.gif`, + "01234567-89ab-cdef-0123-456789abcdef.png", + ].sort(), + ); + expect(messages.info).toEqual([ + `Cleaned up 2 old temp file(s) from ${dir}`, + ]); + }); + + it("honors cleanupAfterHours", async () => { + const dir = await tempDir(); + await writeAged(join(dir, `${HASH}.png`), 3 * HOUR); + await cleanupOldTempFiles(storeFor(dir, 4), silent); + expect(await readdir(dir)).toHaveLength(1); + await cleanupOldTempFiles(storeFor(dir, 2), silent); + expect(await readdir(dir)).toHaveLength(0); + }); + + it("is silent when nothing was deleted or the directory is missing", async () => { + const dir = await tempDir(); + const { log, messages } = recorder(); + await cleanupOldTempFiles(storeFor(dir), log); + await cleanupOldTempFiles(storeFor(join(dir, "missing")), log); + expect(messages.info).toEqual([]); + }); + + it("keeps a file that a request reused after it expired", async () => { + const f = await fixture(); + const attachment = { + mime: "image/png", + source: { + type: "bytes" as const, + data: PNG_BYTES, + mediaType: "image/png", + }, + }; + const saved = await f.process(attachment); + if (!saved.ok) throw new Error(saved.reason); + const old = new Date(Date.now() - 25 * HOUR); + await utimes(saved.image.path, old, old); + await f.process(attachment); + await cleanupOldTempFiles(storeFor(f.tempDir), silent); + expect(await readdir(f.tempDir)).toEqual([ + saved.image.path.split("/").pop(), + ]); + }); + + it("does not delete a file that is being written concurrently", async () => { + const f = await fixture(); + const attachment = { + mime: "image/png", + source: { + type: "bytes" as const, + data: PNG_BYTES, + mediaType: "image/png", + }, + }; + const first = await f.process(attachment); + if (!first.ok) throw new Error(first.reason); + const old = new Date(Date.now() - 25 * HOUR); + await utimes(first.image.path, old, old); + // Whichever runs first under the per-path lock, the file must survive: + // cleanup-then-save recreates it, save-then-cleanup refreshes its mtime. + await Promise.all([ + cleanupOldTempFiles(storeFor(f.tempDir), silent), + f.process(attachment), + ]); + expect(await readFile(first.image.path)).toEqual(PNG_BYTES); + }); +}); diff --git a/test/codemode.test.ts b/test/codemode.test.ts new file mode 100644 index 0000000..9232c72 --- /dev/null +++ b/test/codemode.test.ts @@ -0,0 +1,119 @@ +import type { MCPEditor } from "@opencode/plugin/effect/mcp"; +import { describe, expect, it } from "vitest"; +import { createCodeModeTransform, findToolServers } from "../src/codemode.js"; +import { mcpEditor as editor, recorder } from "./helpers.js"; + +const codemodeOf = (e: MCPEditor, name: string) => e.get(name)?.codemode; + +describe("findToolServers", () => { + it.each([ + ["MiniMax_understand_image", ["Other", "MiniMax"], ["MiniMax"]], + ["my_server_tool", ["my.server"], ["my.server"]], + ["foo_bar_tool", ["foo", "foo_bar"], ["foo", "foo_bar"]], + ["a_b_tool", ["a.b", "a_b"], ["a.b", "a_b"]], + ["Missing_tool", ["MiniMax"], []], + ["minimax_understand_image", ["MiniMax"], []], + ["MiniMax", ["MiniMax"], []], + ])("%s in %j -> %j", (tool, servers, expected) => { + expect(findToolServers(tool, servers)).toEqual(expected); + }); +}); + +describe("createCodeModeTransform", () => { + it("disables Code Mode only on the server providing the tool", () => { + const e = editor({ MiniMax: {}, Other: {} }); + createCodeModeTransform( + () => "MiniMax_understand_image", + () => true, + () => {}, + )(e); + expect(codemodeOf(e, "MiniMax")).toBe(false); + expect(codemodeOf(e, "Other")).toBeUndefined(); + }); + + it.each([true, false])( + "respects an explicit codemode: %s on the server", + (codemode) => { + const e = editor({ MiniMax: { codemode } }); + createCodeModeTransform( + () => "MiniMax_understand_image", + () => true, + () => {}, + )(e); + expect(codemodeOf(e, "MiniMax")).toBe(codemode); + }, + ); + + it("does nothing when disabled in plugin config", () => { + const e = editor({ MiniMax: {} }); + createCodeModeTransform( + () => "MiniMax_understand_image", + () => false, + () => {}, + )(e); + expect(codemodeOf(e, "MiniMax")).toBeUndefined(); + }); + + it("does nothing when no server provides the tool", () => { + const e = editor({ Other: {} }); + createCodeModeTransform( + () => "MiniMax_understand_image", + () => true, + () => {}, + )(e); + expect(codemodeOf(e, "Other")).toBeUndefined(); + }); + + it("reads config on every run, so reloads take effect", () => { + let tool = "A_tool"; + const transform = createCodeModeTransform( + () => tool, + () => true, + () => {}, + ); + const first = editor({ A: {}, B: {} }); + transform(first); + tool = "B_tool"; + const second = editor({ A: {}, B: {} }); + transform(second); + expect(codemodeOf(first, "A")).toBe(false); + expect(codemodeOf(second, "A")).toBeUndefined(); + expect(codemodeOf(second, "B")).toBe(false); + }); + + it.each([ + ["a prefix of another server", { foo: {}, foo_bar: {} }, "foo_bar_tool"], + ["normalized to the same namespace", { "a.b": {}, a_b: {} }, "a_b_tool"], + ])( + "leaves Code Mode alone and explains why when servers are ambiguous (%s)", + (_name, servers, tool) => { + const { log, messages } = recorder(); + const e = editor(servers); + const transform = createCodeModeTransform( + () => tool, + () => true, + log, + ); + transform(e); + transform(e); + for (const name of Object.keys(servers)) + expect(codemodeOf(e, name)).toBeUndefined(); + expect(messages.info).toHaveLength(1); + expect(messages.info[0]).toContain("Left Code Mode unchanged"); + }, + ); + + it("logs once per server even though OpenCode reruns transforms", () => { + const { log, messages } = recorder(); + const transform = createCodeModeTransform( + () => "MiniMax_understand_image", + () => true, + log, + ); + transform(editor({ MiniMax: {} })); + transform(editor({ MiniMax: {} })); + expect(messages.info).toEqual([ + 'Disabled Code Mode for MCP server "MiniMax" so MiniMax_understand_image is directly callable', + ]); + }); +}); diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..c21312e --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,256 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { + DEFAULT_CLEANUP_AFTER_HOURS, + DEFAULT_IMAGE_ANALYSIS_TOOL, + DEFAULT_MODEL_PATTERNS, + TEMP_DIR_NAME, +} from "../src/constants.js"; +import { recorder, silent, tempDir } from "./helpers.js"; + +async function layout() { + const root = await tempDir("easy-vision-config-"); + const home = join(root, "home"); + const project = join(root, "project"); + await mkdir(home, { recursive: true }); + await mkdir(join(project, ".opencode"), { recursive: true }); + const writeUser = (name: string, body: unknown) => + writeFile( + join(home, name), + typeof body === "string" ? body : JSON.stringify(body), + ); + const writeProject = (name: string, body: unknown) => + writeFile( + join(project, ".opencode", name), + typeof body === "string" ? body : JSON.stringify(body), + ); + const load = async (log = silent, autoInit = false) => { + const store = createConfigStore({}, { userDirectory: home, autoInit }); + await store.load(project, log); + return store; + }; + return { home, project, writeUser, writeProject, load }; +} + +describe("defaults", () => { + it("applies when no config files exist", async () => { + const { load } = await layout(); + const store = await load(); + expect(store.getConfiguredModels()).toEqual(DEFAULT_MODEL_PATTERNS); + expect(store.getImageAnalysisTool()).toBe(DEFAULT_IMAGE_ANALYSIS_TOOL); + expect(store.getPromptTemplate()).toBeUndefined(); + expect(store.getTempDir()).toBe(join(tmpdir(), TEMP_DIR_NAME)); + expect(store.getCleanupAfterHours()).toBe(DEFAULT_CLEANUP_AFTER_HOURS); + expect(store.getDisableCodeMode()).toBe(true); + }); + + it("hands out copies so callers cannot mutate the stored models", async () => { + const store = createConfigStore({ models: ["a/*"] }); + const models = store.getConfiguredModels() as string[]; + models.push("b/*"); + expect(store.getConfiguredModels()).toEqual(["a/*"]); + }); +}); + +describe("precedence", () => { + it("prefers project over user, per field", async () => { + const { writeUser, writeProject, load } = await layout(); + await writeUser("opencode-easy-vision.jsonc", { + models: ["user/*"], + imageAnalysisTool: "User_tool", + cleanupAfterHours: 48, + }); + await writeProject("opencode-easy-vision.jsonc", { + models: ["project/*"], + disableCodeMode: false, + }); + const store = await load(); + expect(store.getConfiguredModels()).toEqual(["project/*"]); + expect(store.getImageAnalysisTool()).toBe("User_tool"); + expect(store.getCleanupAfterHours()).toBe(48); + expect(store.getDisableCodeMode()).toBe(false); + }); + + it("accepts comments and trailing commas", async () => { + const { writeProject, load } = await layout(); + await writeProject( + "opencode-easy-vision.jsonc", + '{\n // comment\n "models": ["jsonc/*"],\n}', + ); + expect((await load()).getConfiguredModels()).toEqual(["jsonc/*"]); + }); + + it("ignores an invalid file and warns", async () => { + const { writeUser, writeProject, load } = await layout(); + await writeUser("opencode-easy-vision.jsonc", { models: ["user/*"] }); + await writeProject("opencode-easy-vision.jsonc", "{ not json"); + const { log, messages } = recorder(); + expect((await load(log)).getConfiguredModels()).toEqual(["user/*"]); + expect(messages.info.join("\n")).toMatch(/invalid JSON.*\.jsonc/); + }); + + it("ignores .json files and asks for a rename", async () => { + const { writeProject, load } = await layout(); + await writeProject("opencode-easy-vision.json", { models: ["json/*"] }); + const { log, messages } = recorder(); + expect((await load(log)).getConfiguredModels()).toEqual( + DEFAULT_MODEL_PATTERNS, + ); + expect(messages.info.join("\n")).toMatch( + /opencode-easy-vision\.json is no longer read; rename it to opencode-easy-vision\.jsonc/, + ); + }); + + it("ignores the removed pre-2.0 config filenames", async () => { + const { writeUser, writeProject, load } = await layout(); + await writeUser("opencode-minimax-easy-vision.json", { + models: ["legacy/*"], + }); + await writeProject("opencode-minimax-easy-vision.jsonc", { + imageAnalysisTool: "Legacy_tool", + }); + const store = await load(); + expect(store.getConfiguredModels()).toEqual(DEFAULT_MODEL_PATTERNS); + expect(store.getImageAnalysisTool()).toBe(DEFAULT_IMAGE_ANALYSIS_TOOL); + }); + + it("replaces the previous config on reload", async () => { + const { writeProject, project, home } = await layout(); + const store = createConfigStore( + {}, + { userDirectory: home, autoInit: false }, + ); + await writeProject("opencode-easy-vision.jsonc", { models: ["first/*"] }); + await store.load(project, silent); + await writeProject("opencode-easy-vision.jsonc", {}); + await store.load(project, silent); + expect(store.getConfiguredModels()).toEqual(DEFAULT_MODEL_PATTERNS); + }); +}); + +describe("validation", () => { + it.each([ + ["models", { models: "minimax/*" }], + ["models", { models: [] }], + ["models", { models: [1, 2] }], + ["imageAnalysisTool", { imageAnalysisTool: " " }], + ["imageAnalysisTool", { imageAnalysisTool: "x".repeat(257) }], + ["imageAnalysisTool", { imageAnalysisTool: 42 }], + ["promptTemplate", { promptTemplate: "no variables here" }], + ["promptTemplate", { promptTemplate: " " }], + ["tempDir", { tempDir: " " }], + ["cleanupAfterHours", { cleanupAfterHours: 0 }], + ["cleanupAfterHours", { cleanupAfterHours: -1 }], + ["cleanupAfterHours", { cleanupAfterHours: "24" }], + ["disableCodeMode", { disableCodeMode: "false" }], + ])("drops an invalid %s value and falls back", async (_field, body) => { + const { writeUser, writeProject, load } = await layout(); + const valid = { + models: ["user/*"], + imageAnalysisTool: "User_tool", + promptTemplate: "{imageList}", + tempDir: "/user-temp", + cleanupAfterHours: 12, + disableCodeMode: false, + }; + await writeUser("opencode-easy-vision.jsonc", valid); + await writeProject("opencode-easy-vision.jsonc", body); + const store = await load(); + expect(store.getConfiguredModels()).toEqual(valid.models); + expect(store.getImageAnalysisTool()).toBe(valid.imageAnalysisTool); + expect(store.getPromptTemplate()).toBe(valid.promptTemplate); + expect(store.getTempDir()).toBe(valid.tempDir); + expect(store.getCleanupAfterHours()).toBe(valid.cleanupAfterHours); + expect(store.getDisableCodeMode()).toBe(valid.disableCodeMode); + }); + + it("keeps only string entries in models", async () => { + const { writeProject, load } = await layout(); + await writeProject("opencode-easy-vision.jsonc", { + models: ["a/*", 3, null, "b/*"], + }); + expect((await load()).getConfiguredModels()).toEqual(["a/*", "b/*"]); + }); + + it("trims promptTemplate and tempDir", async () => { + const { writeProject, load } = await layout(); + await writeProject("opencode-easy-vision.jsonc", { + promptTemplate: " {imageList} ", + tempDir: " /trimmed ", + }); + const store = await load(); + expect(store.getPromptTemplate()).toBe("{imageList}"); + expect(store.getTempDir()).toBe("/trimmed"); + }); + + it("ignores a config file that is not an object", async () => { + const { writeProject, load } = await layout(); + await writeProject("opencode-easy-vision.jsonc", "[1, 2]"); + expect((await load()).getConfiguredModels()).toEqual( + DEFAULT_MODEL_PATTERNS, + ); + }); +}); + +describe("isolation", () => { + it("keeps concurrently loaded stores independent", async () => { + const a = await layout(); + const b = await layout(); + await a.writeProject("opencode-easy-vision.jsonc", { models: ["a/*"] }); + await b.writeProject("opencode-easy-vision.jsonc", { models: ["b/*"] }); + const [storeA, storeB] = await Promise.all([a.load(), b.load()]); + expect(storeA.getConfiguredModels()).toEqual(["a/*"]); + expect(storeB.getConfiguredModels()).toEqual(["b/*"]); + }); +}); + +describe("example config auto-init", () => { + it("creates the user example once and never overwrites it", async () => { + const { home, load } = await layout(); + const target = join(home, "opencode-easy-vision.jsonc"); + await load(silent, true); + const example = await readFile( + new URL("../opencode-easy-vision.example.jsonc", import.meta.url), + "utf8", + ); + expect(await readFile(target, "utf8")).toBe(example); + await writeFile(target, '{ "models": ["mine/*"] }'); + const store = await load(silent, true); + expect(await readFile(target, "utf8")).toBe('{ "models": ["mine/*"] }'); + expect(store.getConfiguredModels()).toEqual(["mine/*"]); + }); + + it("does not create the example when a pre-2.0 user .json exists", async () => { + const { home, writeUser, load } = await layout(); + await writeUser("opencode-easy-vision.json", {}); + await load(silent, true); + await expect( + readFile(join(home, "opencode-easy-vision.jsonc")), + ).rejects.toThrow(); + }); + + it("ships an example that parses to exactly the defaults", async () => { + const { load } = await layout(); + await load(silent, true); + const store = await load(); + expect(store.getConfiguredModels()).toEqual(DEFAULT_MODEL_PATTERNS); + expect(store.getImageAnalysisTool()).toBe(DEFAULT_IMAGE_ANALYSIS_TOOL); + expect(store.getPromptTemplate()).toBeUndefined(); + expect(store.getTempDir()).toBe(join(tmpdir(), TEMP_DIR_NAME)); + expect(store.getCleanupAfterHours()).toBe(DEFAULT_CLEANUP_AFTER_HOURS); + expect(store.getDisableCodeMode()).toBe(true); + }); +}); + +describe("cleanup directories", () => { + it("covers the configured and default temp dirs without duplicates", () => { + const fallback = join(tmpdir(), TEMP_DIR_NAME); + expect(createConfigStore().getTempDirsForCleanup()).toEqual([fallback]); + expect( + createConfigStore({ tempDir: "/custom" }).getTempDirsForCleanup(), + ).toEqual(["/custom", fallback]); + }); +}); diff --git a/test/e2e/harness/index.ts b/test/e2e/harness/index.ts new file mode 100644 index 0000000..a333d65 --- /dev/null +++ b/test/e2e/harness/index.ts @@ -0,0 +1,34 @@ +import type { Plugin } from "@opencode/plugin"; +import { setTimeout } from "node:timers/promises"; + +// Test-only plugin loaded next to Easy Vision. It never touches images. +export const IMAGE_ONLY = "E2E_IMAGE_ONLY"; + +const harness: Plugin.Plugin = { + id: "easy-vision-e2e-harness", + async setup(ctx) { + // `opencode run` can admit a prompt before the MCP server has registered its + // tools; hold admission until the vision tool is in the catalog. + await ctx.session.hook("prompt", async () => { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const tools = await ctx.tool.list(); + if (tools.some((tool) => tool.id === "MiniMax_understand_image")) + return; + await setTimeout(100); + } + throw new Error("MiniMax MCP server did not register its tool in time"); + }); + // `opencode run --file` rejects an empty message, so image-only prompts + // carry a placeholder that is cleared before it is stored. + await ctx.session.hook("prompt", (event) => { + if (event.prompt.text === IMAGE_ONLY) event.prompt.text = ""; + }); + // Lets the fake provider tell the main request from title generation. + await ctx.session.hook("model.request", (event) => { + event.headers["x-e2e-kind"] = event.kind; + }); + }, +}; + +export default harness; diff --git a/test/e2e/mcp-server.ts b/test/e2e/mcp-server.ts new file mode 100644 index 0000000..62a3b71 --- /dev/null +++ b/test/e2e/mcp-server.ts @@ -0,0 +1,61 @@ +import { appendFile, readFile } from "node:fs/promises"; +import { createInterface } from "node:readline"; + +// Minimal stdio MCP server exposing `understand_image`. It checks that the +// path is a readable PNG and records each call to $E2E_CALLS; it does no analysis. +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const tool = { + name: "understand_image", + description: "Test double: confirms the image at `path` is a readable PNG.", + inputSchema: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, +}; + +interface Request { + id?: number | string; + method: string; + params?: { arguments?: { path?: string } }; +} + +async function handle({ method, params }: Request): Promise { + switch (method) { + case "initialize": + return { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "easy-vision-e2e", version: "1.0.0" }, + }; + case "ping": + return {}; + case "tools/list": + return { tools: [tool] }; + case "tools/call": { + const path = params?.arguments?.path ?? ""; + const bytes = await readFile(path); + if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) + throw new Error("Not a PNG"); + await appendFile( + process.env.E2E_CALLS ?? "", + JSON.stringify({ path }) + "\n", + ); + return { content: [{ type: "text", text: "PNG is readable" }] }; + } + default: + throw new Error(`Unsupported method ${method}`); + } +} + +for await (const line of createInterface({ input: process.stdin })) { + const request = JSON.parse(line) as Request; + if (request.id === undefined) continue; + const reply = await handle(request).then( + (result) => ({ result }), + (error: Error) => ({ error: { code: -32603, message: error.message } }), + ); + process.stdout.write( + JSON.stringify({ jsonrpc: "2.0", id: request.id, ...reply }) + "\n", + ); +} diff --git a/test/e2e/opencode.e2e.ts b/test/e2e/opencode.e2e.ts new file mode 100644 index 0000000..355c2df --- /dev/null +++ b/test/e2e/opencode.e2e.ts @@ -0,0 +1,250 @@ +import type { AddressInfo } from "node:net"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { IMAGE_ONLY } from "./harness/index.js"; + +// Drives the real `opencode` binary with the packed plugin installed, a fake +// OpenAI-compatible provider for a text-only model and a fake vision MCP server. +// Build first; `just e2e` does. Nothing here calls a real model or service. + +const run = promisify(execFile); +const repo = new URL("../..", import.meta.url).pathname; +const TOOL = "MiniMax_understand_image"; +const MODEL = "e2e/text-only"; +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "base64", +); + +interface ChatRequest { + messages: { role: string; content: unknown }[]; + tools?: { function?: { name?: string } }[]; +} + +const textOf = (request: ChatRequest) => JSON.stringify(request.messages); +const savedPath = (request: ChatRequest) => + textOf(request).match(/Attached image: ([^\s\\]+)/)?.[1]; +const isContinuation = (request: ChatRequest) => + request.messages.some((message) => message.role === "tool"); + +let root: string; +let pluginDir: string; +let image: string; +let env: NodeJS.ProcessEnv; +// Main (non-title) requests the fake provider received, in order. +const requests: ChatRequest[] = []; +const provider = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + const request = JSON.parse(body) as ChatRequest & { stream?: boolean }; + const primary = req.headers["x-e2e-kind"] === "primary"; + if (primary) requests.push(request); + + // First turn: call the vision tool through whichever route is exposed. + // Afterwards (and for title requests): finish with plain text. + let delta: object = { content: "E2E_DONE" }; + let finish = "stop"; + const path = savedPath(request); + if (primary && !isContinuation(request) && path) { + const names = (request.tools ?? []).map((tool) => tool.function?.name); + const [name, args] = names.includes(TOOL) + ? [TOOL, { path }] + : [ + "execute", + { + code: `return await tools.MiniMax.understand_image(${JSON.stringify({ path })});`, + }, + ]; + delta = { + tool_calls: [ + { + index: 0, + id: "call_e2e", + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }, + ], + }; + finish = "tool_calls"; + } + + const base = { id: "e2e", created: 0, model: "text-only" }; + if (!request.stream) { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + ...base, + object: "chat.completion", + choices: [ + { + index: 0, + message: { role: "assistant", ...delta }, + finish_reason: finish, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + ); + return; + } + const chunk = (delta: object, finish_reason: string | null) => + `data: ${JSON.stringify({ + ...base, + object: "chat.completion.chunk", + choices: [{ index: 0, delta, finish_reason }], + })}\n\n`; + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write(chunk({ role: "assistant", ...delta }, null)); + res.end(`${chunk({}, finish)}data: [DONE]\n\n`); +}); + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), "easy-vision-e2e-")); + // Install the packed tarball, as npm users would get it. + const { stdout } = await run( + "npm", + ["pack", "--ignore-scripts", "--pack-destination", root], + { cwd: repo }, + ); + const install = join(root, "install"); + await mkdir(install); + await writeFile(join(install, "package.json"), "{}"); + await run( + "npm", + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + join(root, stdout.trim()), + ], + { cwd: install }, + ); + pluginDir = join(install, "node_modules/opencode-easy-vision"); + + image = join(root, "image.png"); + await writeFile(image, PNG); + await new Promise((resolve) => + provider.listen(0, "127.0.0.1", resolve), + ); + + // Isolate OpenCode from the developer's config, credentials and service. + const home = join(root, "home"); + env = { + PATH: process.env.PATH, + HOME: home, + TMPDIR: root, + XDG_CONFIG_HOME: join(home, "config"), + XDG_DATA_HOME: join(home, "data"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_STATE_HOME: join(home, "state"), + OPENCODE_CLI_CONFIG_CONTENT: "{}", + }; +}, 120_000); + +afterAll(async () => { + provider.close(); + if (root) await rm(root, { recursive: true, force: true }); +}); + +async function createProject(name: string, codemode: boolean | undefined) { + const project = join(root, name); + const calls = join(project, "calls.jsonl"); + await mkdir(join(project, ".opencode"), { recursive: true }); + const { port } = provider.address() as AddressInfo; + await writeFile( + join(project, "opencode.json"), + JSON.stringify({ + plugins: [pluginDir, join(repo, "test/e2e/harness")], + model: MODEL, + providers: { + e2e: { + name: "E2E fake provider", + package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: `http://127.0.0.1:${port}/v1`, apiKey: "none" }, + models: { + "text-only": { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + limit: { context: 32000, output: 1000 }, + }, + }, + }, + }, + permissions: [{ action: "*", resource: "*", effect: "allow" }], + mcp: { + servers: { + MiniMax: { + type: "local", + command: [process.execPath, join(repo, "test/e2e/mcp-server.ts")], + environment: { E2E_CALLS: calls }, + ...(codemode === undefined ? {} : { codemode }), + }, + }, + }, + }), + ); + await writeFile( + join(project, ".opencode/opencode-easy-vision.jsonc"), + JSON.stringify({ models: [MODEL], tempDir: join(project, "images") }), + ); + return { project, calls }; +} + +async function ask(project: string, text: string) { + const running = run( + "opencode", + ["run", "--standalone", "--model", MODEL, "--file", image, text], + { cwd: project, env, timeout: 120_000 }, + ); + // `opencode run` reads piped stdin as extra input; give it none. + running.child.stdin?.end(); + return (await running).stdout; +} + +describe.each([ + { name: "codemode unset", codemode: undefined, route: TOOL }, + { name: "codemode false", codemode: false, route: TOOL }, + { name: "codemode true", codemode: true, route: "execute" }, +])("OpenCode with $name", ({ name, codemode, route }) => { + it(`replaces the image with a saved path and the model reaches it via ${route}`, async () => { + const { project, calls } = await createProject( + name.replace(/ /g, "-"), + codemode, + ); + const start = requests.length; + expect(await ask(project, "what is in this image?")).toContain("E2E_DONE"); + expect(await ask(project, IMAGE_ONLY)).toContain("E2E_DONE"); + const sent = requests.slice(start); + + // Two tasks, each a first request plus at least one tool continuation. + expect(sent.filter(isContinuation).length).toBeGreaterThanOrEqual(2); + // The text-only model never receives the raw image. + for (const request of sent) + expect(textOf(request)).not.toContain("image_url"); + // The user's text survives, and the vision tool is offered on the expected route. + expect(textOf(sent[0])).toContain("what is in this image?"); + // The image-only task reaches the model with no typed text. + const imageOnly = sent.filter((request) => !isContinuation(request))[1]; + expect(textOf(imageOnly)).toContain("(analyze the image)"); + expect(textOf(imageOnly)).not.toContain(IMAGE_ONLY); + const offered = (sent[0].tools ?? []).map((tool) => tool.function?.name); + expect(offered).toContain(route); + if (route === "execute") expect(offered).not.toContain(TOOL); + + // One stable saved path on every pass, holding the original bytes. + const paths = new Set(sent.map(savedPath)); + expect(paths.size).toBe(1); + const [path] = paths; + expect(path).toMatch(/[0-9a-f]{64}\.png$/); + expect(await readFile(path!)).toEqual(PNG); + + // The MCP server read that file once per task. + const lines = (await readFile(calls, "utf8")).trim().split("\n"); + expect(lines.map((line) => JSON.parse(line).path)).toEqual([path, path]); + }, 240_000); +}); diff --git a/test/file-lock.test.ts b/test/file-lock.test.ts new file mode 100644 index 0000000..402ae2a --- /dev/null +++ b/test/file-lock.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { withFileLock } from "../src/file-lock.js"; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 5)); + +describe("withFileLock", () => { + it("runs actions on the same path one at a time, in order", async () => { + const events: string[] = []; + const action = (name: string) => async () => { + events.push(`${name}:start`); + await tick(); + events.push(`${name}:end`); + return name; + }; + const results = await Promise.all([ + withFileLock("/same", action("a")), + withFileLock("/same", action("b")), + withFileLock("/same", action("c")), + ]); + expect(results).toEqual(["a", "b", "c"]); + expect(events).toEqual([ + "a:start", + "a:end", + "b:start", + "b:end", + "c:start", + "c:end", + ]); + }); + + it("lets different paths run concurrently", async () => { + const events: string[] = []; + const action = (name: string) => async () => { + events.push(`${name}:start`); + await tick(); + events.push(`${name}:end`); + }; + await Promise.all([ + withFileLock("/one", action("a")), + withFileLock("/two", action("b")), + ]); + expect(events.slice(0, 2)).toEqual(["a:start", "b:start"]); + }); + + it("releases the lock when an action throws", async () => { + await expect( + withFileLock("/fails", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + await expect(withFileLock("/fails", async () => "next")).resolves.toBe( + "next", + ); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..4839ffa --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,134 @@ +import type { ContentPart, Message } from "@opencode/ai"; +import type { MCPEditor } from "@opencode/plugin/effect/mcp"; +import type { SessionRequest } from "@opencode/plugin/effect/session"; +import type { PluginConfig, Logger, Notifier } from "../src/types.js"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Media } from "@opencode/ai"; +import { onTestFinished } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { createImageProcessor } from "../src/images.js"; +import { createPromptGenerator } from "../src/prompt.js"; + +export type MediaPart = Extract; + +// A real PNG signature plus one byte: enough for MIME-agnostic byte handling. +export const PNG_BYTES = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 1]); + +export const silent: Logger = () => {}; + +export function image( + bytes: Uint8Array = PNG_BYTES, + mime = "image/png", +): MediaPart { + return { + type: "media", + media: Media.bytes(bytes, mime), + metadata: { attachment: "original" }, + }; +} + +export function media(mime: string): MediaPart { + return { type: "media", media: Media.bytes(PNG_BYTES, mime) }; +} + +export function text(value: string): ContentPart { + return { type: "text", text: value }; +} + +export function user( + content: ContentPart[], + extra: Partial> = {}, +): Message { + return { role: "user", content, ...extra }; +} + +export function assistant(content: ContentPart[]): Message { + return { role: "assistant", content }; +} + +export function hasMedia(message: Message): boolean { + return message.content.some((part) => part.type === "media"); +} + +export function textOf(message: Message): string { + return message.content + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n"); +} + +/** Creates a temp directory that is removed when the current test finishes. */ +export async function tempDir(prefix = "easy-vision-test-"): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + onTestFinished(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +/** Logger/Notifier pair that records every message by level. */ +export function recorder() { + const messages = { info: [] as string[], warn: [] as string[] }; + const log: Logger = (message) => messages.info.push(message); + const notify: Notifier = { warn: (message) => messages.warn.push(message) }; + return { log, notify, messages }; +} + +/** Config, image processor and prompt generator writing into a fresh temp dir. */ +export async function fixture(config: PluginConfig = {}) { + const dir = await tempDir(); + const store = createConfigStore({ + tempDir: dir, + imageAnalysisTool: "Vision_understand_image", + ...config, + }); + return { + tempDir: dir, + config: store, + process: createImageProcessor(store, silent), + prompt: createPromptGenerator(store), + route: "direct" as const, + }; +} + +// SessionRequest uses branded IDs; tests build plain strings and cast once here. +export function sessionRequest( + messages: Message[], + options: { sessionID?: string; providerID?: string; id?: string } = {}, +): SessionRequest { + const plain = { + sessionID: options.sessionID ?? "ses_test", + model: { + providerID: options.providerID ?? "test", + id: options.id ?? "model", + }, + system: [], + messages, + options: {}, + }; + return plain as unknown as SessionRequest; +} + +type ServerConfig = NonNullable>; + +/** In-memory MCP editor over local servers; `set`/`remove` must not be used. */ +export function mcpEditor( + servers: Record, +): MCPEditor { + const configs: Record = Object.fromEntries( + Object.entries(servers).map(([name, config]) => [ + name, + { type: "local", command: ["server"], ...config }, + ]), + ); + return { + list: () => Object.entries(configs), + get: (name) => configs[name], + set: () => { + throw new Error("set must not be used"); + }, + update: (name, update) => update(configs[name]), + remove: () => { + throw new Error("remove must not be used"); + }, + }; +} diff --git a/test/images.test.ts b/test/images.test.ts new file mode 100644 index 0000000..4a09eda --- /dev/null +++ b/test/images.test.ts @@ -0,0 +1,295 @@ +import type { ImageAttachment, ImageResult } from "../src/types.js"; +import { + lstat, + readdir, + readFile, + stat, + symlink, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Media } from "@opencode/ai"; +import { describe, expect, it } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { + createImageProcessor, + decodeBase64, + normalizeMime, +} from "../src/images.js"; +import { PNG_BYTES, fixture, recorder } from "./helpers.js"; + +const bytesSource = ( + data: Uint8Array = PNG_BYTES, + mediaType = "image/png", +): ImageAttachment => ({ + mime: mediaType, + source: { type: "bytes", data, mediaType }, +}); + +function expectSaved(result: ImageResult): string { + if (!result.ok) throw new Error(`expected success, got: ${result.reason}`); + return result.image.path; +} + +function expectFailure(result: ImageResult): string { + if (result.ok) throw new Error(`expected failure, got ${result.image.path}`); + return result.reason; +} + +describe("normalizeMime", () => { + it("lowercases and drops parameters", () => { + expect(normalizeMime(" IMAGE/PNG; charset=binary")).toBe("image/png"); + }); +}); + +describe("decodeBase64", () => { + it("decodes padded, unpadded and whitespace-wrapped input", () => { + const encoded = PNG_BYTES.toString("base64"); + expect(decodeBase64(encoded)).toEqual(PNG_BYTES); + expect(decodeBase64(encoded.replace(/=+$/, ""))).toEqual(PNG_BYTES); + expect(decodeBase64(`${encoded.slice(0, 4)}\n${encoded.slice(4)}`)).toEqual( + PNG_BYTES, + ); + }); + + it.each(["", "%%%", "abcde", "ab=c", "a==="])("rejects %j", (value) => { + expect(() => decodeBase64(value)).toThrow("Malformed base64 image data"); + }); +}); + +describe("createImageProcessor", () => { + it("saves bytes, base64 and data URLs to one content-hashed file", async () => { + const f = await fixture(); + const encoded = PNG_BYTES.toString("base64"); + const results = await Promise.all( + [ + bytesSource(), + { + mime: "IMAGE/PNG", + source: { + type: "base64" as const, + data: encoded, + mediaType: "image/png", + }, + }, + { + mime: "image/png", + source: { + type: "url" as const, + url: `data:image/png;base64,${encoded}`, + }, + }, + ].map(f.process), + ); + const paths = results.map(expectSaved); + expect(new Set(paths).size).toBe(1); + expect(paths[0]).toMatch(/[0-9a-f]{64}\.png$/); + expect(await readFile(paths[0])).toEqual(PNG_BYTES); + expect(await readdir(f.tempDir)).toHaveLength(1); + }); + + it("writes files readable only by the owner", async () => { + const f = await fixture(); + const path = expectSaved(await f.process(bytesSource())); + expect((await stat(path)).mode & 0o777).toBe(0o600); + }); + + it("reuses an intact existing file and refreshes its mtime", async () => { + const f = await fixture(); + const path = expectSaved(await f.process(bytesSource())); + const old = new Date(Date.now() - 48 * 60 * 60 * 1000); + await utimes(path, old, old); + const before = await stat(path); + await f.process(bytesSource()); + const after = await stat(path); + expect(after.ino).toBe(before.ino); + expect(after.mtimeMs).toBeGreaterThan(old.getTime() + 60_000); + }); + + it("restores a missing or damaged file", async () => { + const f = await fixture(); + const path = expectSaved(await f.process(bytesSource())); + await writeFile(path, "damaged"); + await f.process(bytesSource()); + expect(await readFile(path)).toEqual(PNG_BYTES); + await unlink(path); + await f.process(bytesSource()); + expect(await readFile(path)).toEqual(PNG_BYTES); + }); + + it("replaces a symlink planted at the saved path instead of following it", async () => { + const f = await fixture(); + const path = expectSaved(await f.process(bytesSource())); + // A 9-character relative target gives the link the same lstat size as the + // 9-byte image, so only the regular-file check can reject it. + const secret = join(f.tempDir, "secret123"); + await writeFile(secret, "not an image"); + await unlink(path); + await symlink("secret123", path); + expect((await lstat(path)).size).toBe(PNG_BYTES.length); + expect(expectSaved(await f.process(bytesSource()))).toBe(path); + expect((await lstat(path)).isSymbolicLink()).toBe(false); + expect(await readFile(path)).toEqual(PNG_BYTES); + expect(await readFile(secret, "utf8")).toBe("not an image"); + }); + + it("separates identical bytes with different MIME types", async () => { + const f = await fixture(); + const png = expectSaved(await f.process(bytesSource())); + const jpeg = expectSaved( + await f.process(bytesSource(PNG_BYTES, "image/jpeg")), + ); + expect(png.replace(/\.png$/, "")).not.toBe(jpeg.replace(/\.jpg$/, "")); + }); + + it.each([ + ["image/png", "png"], + ["image/jpeg", "jpg"], + ["image/webp", "webp"], + ])("saves %s with a .%s extension", async (mime, extension) => { + const f = await fixture(); + const path = expectSaved(await f.process(bytesSource(PNG_BYTES, mime))); + expect(path.endsWith(`.${extension}`)).toBe(true); + }); + + it("passes file and HTTP(S) URLs through without creating files", async () => { + const f = await fixture(); + const own = join(f.tempDir, "user-owned.png"); + await writeFile(own, PNG_BYTES); + for (const [url, expected] of [ + [pathToFileURL(own).href, own], + ["https://example.test/a.png", "https://example.test/a.png"], + ["http://example.test/a.png", "http://example.test/a.png"], + ]) { + const result = await f.process({ + mime: "image/png", + source: { type: "url", url }, + }); + expect(result).toEqual({ + ok: true, + image: { path: expected, mime: "image/png" }, + }); + } + expect(await readdir(f.tempDir)).toEqual(["user-owned.png"]); + }); + + it.each<[string, ImageAttachment, RegExp]>([ + [ + "malformed base64", + { + mime: "image/png", + source: { type: "base64", data: "%%%", mediaType: "image/png" }, + }, + /Malformed base64/, + ], + [ + "empty bytes", + { + mime: "image/png", + source: { + type: "bytes", + data: new Uint8Array(), + mediaType: "image/png", + }, + }, + /Empty image data/, + ], + [ + "a provider reference", + { + mime: "image/png", + source: Media.ref("test", "secret-reference").source, + }, + /Provider image reference/, + ], + [ + "an unsupported format", + bytesSource(PNG_BYTES, "image/gif"), + /Unsupported image format \(image\/gif\)/, + ], + [ + "an unsupported URL scheme", + { mime: "image/png", source: { type: "url", url: "ftp://x.test/a" } }, + /Unsupported image URL scheme/, + ], + [ + "a missing local file", + { + mime: "image/png", + source: { type: "url", url: "file:///nonexistent/easy-vision" }, + }, + /check the server temp directory/, + ], + [ + "a data URL with a different MIME type", + { + mime: "image/png", + source: { type: "url", url: "data:image/jpeg;base64,YQ==" }, + }, + /mismatched image data URL/, + ], + [ + "a URL that needs private headers", + { + mime: "image/png", + requiresHeaders: true, + source: { type: "url", url: "https://x.test/private" }, + }, + /requires private headers/, + ], + [ + "an expired URL", + { + mime: "image/png", + source: { type: "url", url: "https://x.test/old", expiresAt: 0 }, + }, + /expired/, + ], + ])("fails explicitly for %s", async (_name, attachment, reason) => { + const f = await fixture(); + expect(expectFailure(await f.process(attachment))).toMatch(reason); + }); + + it("never leaks secrets or I/O details into reasons or logs", async () => { + const f = await fixture(); + const blocked = join(f.tempDir, "not-a-directory"); + await writeFile(blocked, "fixture"); + const { log, messages } = recorder(); + const process = createImageProcessor( + createConfigStore({ tempDir: blocked }), + log, + ); + const reason = expectFailure(await process(bytesSource())); + expect(reason).not.toContain(blocked); + const secret = expectFailure( + await createImageProcessor( + createConfigStore({ tempDir: f.tempDir }), + log, + )({ + mime: "image/png", + source: Media.ref("p", "secret-reference").source, + }), + ); + expect(secret).not.toContain("secret-reference"); + expect(messages.info.join("\n")).not.toMatch(/secret-reference|ENOTDIR/); + }); + + it("logs each failure", async () => { + const { log, messages } = recorder(); + const process = createImageProcessor(createConfigStore(), log); + await process(bytesSource(PNG_BYTES, "image/gif")); + expect(messages.info).toEqual([ + "Image processing failed: Unsupported image format (image/gif)", + ]); + }); + + it("does not log successful saves", async () => { + const f = await fixture(); + const { log, messages } = recorder(); + await createImageProcessor(f.config, log)(bytesSource()); + expect(messages.info).toEqual([]); + }); +}); diff --git a/test/package.test.ts b/test/package.test.ts new file mode 100644 index 0000000..afc19e2 --- /dev/null +++ b/test/package.test.ts @@ -0,0 +1,33 @@ +import { readdir, readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +const read = (path: string) => + readFile(new URL(`../${path}`, import.meta.url), "utf8"); + +// OpenCode supplies `effect` and the @opencode packages at runtime; shipping our +// own copies would load a second, incompatible instance. +describe("package contract", () => { + it("depends only on strip-json-comments; effect is an optional peer", async () => { + const manifest = JSON.parse(await read("package.json")) as { + dependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + }; + expect(Object.keys(manifest.dependencies ?? {})).toEqual([ + "strip-json-comments", + ]); + expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(["effect"]); + expect(manifest.peerDependenciesMeta?.effect?.optional).toBe(true); + }); + + it("imports @opencode packages for types only", async () => { + const files = (await readdir(new URL("../src", import.meta.url))).filter( + (name) => name.endsWith(".ts"), + ); + for (const file of files) { + const source = await read(`src/${file}`); + const runtime = source.match(/^import (?!type\b)[^;]*from "@opencode\//m); + expect(runtime, `${file} has a runtime @opencode import`).toBeNull(); + } + }); +}); diff --git a/test/patterns.test.ts b/test/patterns.test.ts new file mode 100644 index 0000000..4aab58c --- /dev/null +++ b/test/patterns.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { DEFAULT_MODEL_PATTERNS } from "../src/constants.js"; +import { createModelMatcher } from "../src/patterns.js"; + +const matcher = (models?: string[]) => + createModelMatcher(createConfigStore({ models })); +const model = (providerID: string, modelID: string) => ({ + providerID, + modelID, +}); + +describe("createModelMatcher", () => { + it("never matches an unknown model", () => { + expect(matcher(["*"])(undefined)).toBe(false); + }); + + it("matches everything with a bare *", () => { + expect(matcher(["*"])(model("any", "thing"))).toBe(true); + }); + + it.each([ + ["minimax/*", model("minimax", "m2"), true], + ["minimax/*", model("minimax-cn", "m2"), false], + ["*/m2", model("anyone", "m2"), true], + ["*/m2", model("anyone", "m2.5"), false], + ["minimax/m2", model("minimax", "m2"), true], + ["minimax/m2", model("minimax", "m2.5"), false], + ["minimax/*m2*", model("minimax", "abab-m2-chat"), true], + ["minimax/*-chat", model("minimax", "abab-m2-chat"), true], + ["minimax/*-chat", model("minimax", "abab-m2-chat-x"), false], + ])("provider/model pattern %s", (pattern, input, expected) => { + expect(matcher([pattern])(input)).toBe(expected); + }); + + it("matches a slash-less pattern against provider or model ID", () => { + const matches = matcher(["minimax*"]); + expect(matches(model("minimax", "x"))).toBe(true); + expect(matches(model("openrouter", "minimax-m2"))).toBe(true); + expect(matches(model("openrouter", "deepseek"))).toBe(false); + }); + + it("is case-insensitive", () => { + expect(matcher(["MiniMax/*"])(model("MINIMAX", "m2"))).toBe(true); + }); + + it("splits on the first slash so nested model IDs work", () => { + const matches = matcher(["openrouter/deepseek/*"]); + expect(matches(model("OPENROUTER", "DeepSeek/model"))).toBe(true); + expect(matches(model("other", "deepseek/model"))).toBe(false); + }); + + it("matches any configured pattern", () => { + const matches = matcher(["a/*", "b/*"]); + expect(matches(model("b", "x"))).toBe(true); + expect(matches(model("c", "x"))).toBe(false); + }); + + it("defaults to the MiniMax provider patterns only", () => { + const matches = matcher(); + for (const pattern of DEFAULT_MODEL_PATTERNS) { + expect(matches(model(pattern.slice(0, -2), "anything"))).toBe(true); + } + expect(matches(model("openrouter", "minimax/minimax-m2"))).toBe(false); + }); +}); diff --git a/test/plugin.test.ts b/test/plugin.test.ts new file mode 100644 index 0000000..1ba8dff --- /dev/null +++ b/test/plugin.test.ts @@ -0,0 +1,249 @@ +import type { MCPEditor } from "@opencode/plugin/effect/mcp"; +import type { Plugin } from "@opencode/plugin/effect"; +import type { SessionRequest } from "@opencode/plugin/effect/session"; +import { mkdir, readdir, readFile, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Effect, Exit, Logger, References, Scope } from "effect"; +import { describe, expect, it, vi } from "vitest"; +import EasyVisionPlugin from "../src/index.js"; +import { + PNG_BYTES, + hasMedia, + image, + mcpEditor, + sessionRequest, + tempDir, + text, + textOf, + user, +} from "./helpers.js"; + +const TOOL = "Vision_understand_image"; +type Hook = (event: SessionRequest) => Effect.Effect; + +interface LogEntry { + message: string; + level: string; + annotations: Record; +} + +/** + * Runs the real plugin effect against fake OpenCode domains, with a capturing + * logger installed the way the host provides its own. + */ +async function start( + options: { config?: object; tools?: { id: string; options?: object }[] } = {}, +) { + const root = await tempDir("easy-vision-plugin-"); + const directory = join(root, "project"); + const home = join(root, "home"); + vi.stubEnv("HOME", home); + await mkdir(join(directory, ".opencode"), { recursive: true }); + await writeFile( + join(directory, ".opencode/opencode-easy-vision.jsonc"), + JSON.stringify({ + models: ["test/*"], + imageAnalysisTool: TOOL, + tempDir: join(directory, "images"), + ...options.config, + }), + ); + + const hooks = new Map(); + const mcpTransforms: ((editor: MCPEditor) => void)[] = []; + const session = { + directory, + toolList: Effect.succeed(options.tools ?? []) as Effect.Effect< + unknown[], + Error + >, + }; + const registration = Effect.succeed({ dispose: Effect.void }); + const fake = { + location: { directory }, + options: {}, + mcp: { + transform: (callback: (editor: MCPEditor) => void) => { + mcpTransforms.push(callback); + return registration; + }, + }, + tool: { list: () => session.toolList }, + session: { + get: () => Effect.succeed({ location: { directory: session.directory } }), + hook: (name: string, callback: Hook) => { + hooks.set(name, callback); + return registration; + }, + }, + }; + // The full Context has ~25 domains; the plugin only touches these. + const ctx = fake as unknown as Plugin.Context; + + const logs: LogEntry[] = []; + const capture = Logger.make((entry) => + logs.push({ + message: [entry.message].flat().join(" "), + level: entry.logLevel, + annotations: entry.fiber.getRef(References.CurrentLogAnnotations), + }), + ); + const scope = Effect.runSync(Scope.make()); + await Effect.runPromise( + EasyVisionPlugin.effect(ctx).pipe( + Scope.provide(scope), + Effect.provide(Logger.layer([capture])), + ), + ); + + const run = async (name: string, event: SessionRequest) => { + const hook = hooks.get(name); + if (!hook) throw new Error(`hook ${name} was not registered`); + await Effect.runPromise(hook(event)); + return event; + }; + const close = () => Effect.runPromise(Scope.close(scope, Exit.void)); + return { directory, home, hooks, mcpTransforms, session, logs, run, close }; +} + +describe("EasyVisionPlugin", () => { + it("has a stable id", () => { + expect(EasyVisionPlugin.id).toBe("opencode-easy-vision"); + }); + + it("registers the MCP transform and the four request hooks", async () => { + const p = await start(); + expect(p.mcpTransforms).toHaveLength(1); + expect([...p.hooks.keys()]).toEqual([ + "context", + "compaction", + "title", + "generate", + ]); + await p.close(); + }); + + it("logs through the host logger, tagged with the plugin name", async () => { + // Regression: OpenCode discards plugin console output, so logs must go + // through the Effect logger the host provides. + const p = await start(); + await vi.waitFor(() => + expect(p.logs).toContainEqual({ + message: "Plugin initialized", + level: "Info", + annotations: { plugin: "easy-vision" }, + }), + ); + expect( + p.logs.every((log) => log.annotations.plugin === "easy-vision"), + ).toBe(true); + await p.close(); + }); + + it("rewrites images in the context hook using the request's tools", async () => { + const p = await start(); + const event = await p.run( + "context", + sessionRequest([user([text("what is it?"), image()])], {}), + ); + expect(hasMedia(event.messages[0])).toBe(false); + expect(textOf(event.messages[0])).toContain("is not available"); + const direct = sessionRequest([user([image()])]); + Object.assign(direct, { tools: { [TOOL]: {} } }); + await p.run("context", direct); + expect(textOf(direct.messages[0])).toContain(`Use the \`${TOOL}\` tool`); + await p.close(); + }); + + it("uses the tool registry to detect Code Mode", async () => { + const p = await start({ tools: [{ id: TOOL }] }); + const event = sessionRequest([user([image()])]); + Object.assign(event, { tools: { execute: {} } }); + await p.run("context", event); + expect(textOf(event.messages[0])).toContain("exposed through Code Mode"); + await p.close(); + }); + + it.each(["compaction", "title", "generate"])( + "gives the %s hook image paths only", + async (name) => { + const p = await start(); + const event = await p.run(name, sessionRequest([user([image()])])); + expect(hasMedia(event.messages[0])).toBe(false); + expect(textOf(event.messages[0])).toContain("Attached image:"); + expect(textOf(event.messages[0])).not.toContain("Use the"); + await p.close(); + }, + ); + + it("leaves requests from another location untouched", async () => { + const p = await start(); + p.session.directory = "/somewhere/else"; + const event = sessionRequest([user([image()])]); + const original = event.messages; + await p.run("context", event); + expect(event.messages).toBe(original); + await p.close(); + }); + + it("logs a warning and strips images when a host call fails", async () => { + const p = await start(); + p.session.toolList = Effect.fail(new Error("private provider response")); + const event = await p.run("context", sessionRequest([user([image()])])); + expect(hasMedia(event.messages[0])).toBe(false); + await vi.waitFor(() => + expect(p.logs).toContainEqual( + expect.objectContaining({ + level: "Warn", + message: expect.stringContaining("Image processing failed"), + }), + ), + ); + expect(JSON.stringify(p.logs)).not.toContain("private provider response"); + await p.close(); + }); + + it("clears warning state when its scope closes", async () => { + const p = await start(); + const warnings = () => p.logs.filter((log) => log.level === "Warn").length; + await p.run("context", sessionRequest([user([image()])])); + await p.run("context", sessionRequest([user([image()])])); + await vi.waitFor(() => expect(warnings()).toBe(1)); + await p.close(); + await p.run("context", sessionRequest([user([image()])])); + await vi.waitFor(() => expect(warnings()).toBe(2)); + }); + + it("wires the MCP transform to the loaded config", async () => { + const p = await start({ config: { imageAnalysisTool: "Mine_tool" } }); + const editor = mcpEditor({ Mine: {}, Vision: {} }); + p.mcpTransforms[0](editor); + expect(editor.get("Mine")?.codemode).toBe(false); + expect(editor.get("Vision")?.codemode).toBeUndefined(); + await p.close(); + }); + + it("cleans expired images from the configured temp dir on startup", async () => { + const root = await tempDir(); + const images = join(root, "images"); + await mkdir(images); + const expired = join(images, `${"a".repeat(64)}.png`); + await writeFile(expired, PNG_BYTES); + await utimes(expired, new Date(0), new Date(0)); + const p = await start({ config: { tempDir: images } }); + expect(await readdir(images)).toEqual([]); + await p.close(); + }); + + it("creates the example user config on first start", async () => { + const p = await start(); + const created = await readFile( + join(p.home, ".config/opencode/opencode-easy-vision.jsonc"), + "utf8", + ); + expect(created).toContain( + `"imageAnalysisTool": "MiniMax_understand_image"`, + ); + await p.close(); + }); +}); diff --git a/test/prompt.test.ts b/test/prompt.test.ts new file mode 100644 index 0000000..5463716 --- /dev/null +++ b/test/prompt.test.ts @@ -0,0 +1,80 @@ +import type { SavedImage } from "../src/types.js"; +import { describe, expect, it } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { createPromptGenerator } from "../src/prompt.js"; + +const one: SavedImage[] = [{ path: "/tmp/a.png", mime: "image/png" }]; +const two: SavedImage[] = [...one, { path: "/tmp/b.jpg", mime: "image/jpeg" }]; +const generator = (promptTemplate?: string) => + createPromptGenerator( + createConfigStore({ imageAnalysisTool: "Vision_tool", promptTemplate }), + ); + +describe("default templates", () => { + it("uses the single-image wording for one image", () => { + const prompt = generator()(one, "what is this?", "direct"); + expect(prompt).toContain("The user has shared an image."); + expect(prompt).toContain("- Image 1: /tmp/a.png"); + expect(prompt).toContain("Use the `Vision_tool` tool"); + expect(prompt).toContain("User's request: what is this?"); + }); + + it("uses the multi-image wording and numbers every image", () => { + const prompt = generator()(two, "compare", "direct"); + expect(prompt).toContain("The user has shared 2 images."); + expect(prompt).toContain("- Image 1: /tmp/a.png\n- Image 2: /tmp/b.jpg"); + }); + + it("asks for analysis when the user typed nothing", () => { + expect(generator()(one, "", "direct")).toContain( + "User's request: (analyze the image)", + ); + }); + + it("asks the model not to repeat completed analysis", () => { + expect(generator()(one, "x", "direct")).toMatch( + /do not repeat completed analysis\.$/, + ); + }); +}); + +describe("custom templates", () => { + it("substitutes every variable, every time", () => { + const prompt = generator( + "{imageCount}|{imageList}|{toolName}|{userText}|{toolName}", + )(one, "hi", "direct"); + expect(prompt.split("\n")[0]).toBe( + "1|- Image 1: /tmp/a.png|Vision_tool|hi|Vision_tool", + ); + }); + + it("inserts values literally, without replacement patterns or re-expansion", () => { + const images = [{ path: "/path/$&.png", mime: "image/png" }]; + const prompt = generator("{imageList}|{userText}")( + images, + "$& {imageCount} $1", + "direct", + ); + expect(prompt).toContain("- Image 1: /path/$&.png|$& {imageCount} $1"); + }); +}); + +describe("tool routes", () => { + it("adds execute guidance when the tool is behind Code Mode", () => { + const prompt = generator()(one, "x", "codemode"); + expect(prompt).toContain("exposed through Code Mode"); + expect(prompt).toContain("discover the exact Vision_tool"); + }); + + it("adds no Code Mode guidance for direct tools", () => { + expect(generator()(one, "x", "direct")).not.toContain("Code Mode"); + }); + + it("lists paths but forbids claiming analysis when the tool is unavailable", () => { + const prompt = generator("{toolName} custom")(one, "x", "unavailable"); + expect(prompt).toContain("- Image 1: /tmp/a.png"); + expect(prompt).toContain("Vision_tool is not available"); + expect(prompt).toContain("Do not claim to have analyzed the images."); + expect(prompt).not.toContain("custom"); + }); +}); diff --git a/test/request.test.ts b/test/request.test.ts new file mode 100644 index 0000000..5c69293 --- /dev/null +++ b/test/request.test.ts @@ -0,0 +1,167 @@ +import type { RequestHost } from "../src/request.js"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { createRequestRewriter } from "../src/request.js"; +import { + hasMedia, + image, + recorder, + sessionRequest as request, + tempDir, + text, + textOf, + user, +} from "./helpers.js"; + +const TOOL = "Vision_understand_image"; + +async function setup( + overrides: (directory: string) => Partial = () => ({}), +) { + const directory = await tempDir(); + const host = overrides(directory); + const config = createConfigStore({ + models: ["test/*"], + imageAnalysisTool: TOOL, + tempDir: join(directory, "images"), + }); + const getSessionDirectory = vi.fn( + host.getSessionDirectory ?? (async () => directory), + ); + const listTools = vi.fn(host.listTools ?? (async () => [])); + const { log, notify, messages } = recorder(); + const rewriter = createRequestRewriter( + config, + { directory, getSessionDirectory, listTools }, + log, + notify, + ); + return { rewriter, getSessionDirectory, listTools, warnings: messages.warn }; +} + +describe("createRequestRewriter", () => { + it("ignores requests for models that do not match", async () => { + const s = await setup(); + const event = request([user([image()])], { providerID: "other" }); + const original = event.messages; + await s.rewriter.rewrite(event, false, { [TOOL]: {} }); + expect(event.messages).toBe(original); + expect(s.getSessionDirectory).not.toHaveBeenCalled(); + }); + + it("ignores requests without user images", async () => { + const s = await setup(); + const event = request([user([text("hi")])]); + const original = event.messages; + await s.rewriter.rewrite(event, false, { [TOOL]: {} }); + expect(event.messages).toBe(original); + expect(s.getSessionDirectory).not.toHaveBeenCalled(); + }); + + it("ignores sessions that belong to another project location", async () => { + const s = await setup(() => ({ + getSessionDirectory: async () => "/elsewhere", + })); + const event = request([user([image()])]); + const original = event.messages; + await s.rewriter.rewrite(event, false, { [TOOL]: {} }); + expect(event.messages).toBe(original); + expect(s.listTools).not.toHaveBeenCalled(); + }); + + it("treats equivalent location paths as the same location", async () => { + const s = await setup((directory) => ({ + getSessionDirectory: async () => `${directory}/./`, + })); + const event = request([user([image()])]); + await s.rewriter.rewrite(event, false, { [TOOL]: {} }); + expect(hasMedia(event.messages[0])).toBe(false); + }); + + it("rewrites with direct-tool guidance when the request exposes the tool", async () => { + const s = await setup(); + const event = request([user([text("what is it?"), image()])]); + await s.rewriter.rewrite(event, false, { [TOOL]: {} }); + expect(hasMedia(event.messages[0])).toBe(false); + expect(textOf(event.messages[0])).toContain(`Use the \`${TOOL}\` tool`); + expect(textOf(event.messages[0])).not.toContain("Code Mode"); + expect(s.warnings).toEqual([]); + }); + + it("routes through execute when the tool is behind Code Mode", async () => { + const s = await setup(() => ({ listTools: async () => [{ id: TOOL }] })); + const event = request([user([image()])]); + await s.rewriter.rewrite(event, false, { execute: {} }); + expect(textOf(event.messages[0])).toContain("exposed through Code Mode"); + }); + + it("warns once per session when the tool is unavailable", async () => { + const s = await setup(); + for (const sessionID of ["ses_a", "ses_a", "ses_b"]) { + const event = request([user([image()])], { sessionID }); + await s.rewriter.rewrite(event, false, {}); + expect(textOf(event.messages[0])).toContain(`${TOOL} is not available`); + } + expect(s.warnings).toHaveLength(2); + expect(s.warnings[0]).toContain(`Vision tool ${TOOL} is unavailable`); + }); + + it("neither looks up tools nor warns for paths-only requests", async () => { + const s = await setup(); + const event = request([user([image()])]); + await s.rewriter.rewrite(event, true); + expect(s.listTools).not.toHaveBeenCalled(); + expect(s.warnings).toEqual([]); + expect(textOf(event.messages[0])).not.toContain("Use the"); + }); + + it("warns once per session for repeated image failures", async () => { + const s = await setup(); + const gif = () => image(undefined, "image/gif"); + await s.rewriter.rewrite(request([user([gif()])]), false, { [TOOL]: {} }); + await s.rewriter.rewrite(request([user([gif()])]), false, { [TOOL]: {} }); + expect(s.warnings).toEqual(["Unsupported image format (image/gif)"]); + }); + + it("strips images and warns when a host lookup fails", async () => { + const s = await setup(() => ({ + listTools: async () => { + throw new Error("private provider response"); + }, + })); + const event = request([user([text("keep"), image()])]); + await s.rewriter.rewrite(event, false, {}); + expect(hasMedia(event.messages[0])).toBe(false); + expect(textOf(event.messages[0])).toContain("attachment processing failed"); + expect(JSON.stringify(event.messages)).not.toContain("private provider"); + expect(s.warnings).toEqual([ + "Image processing failed; see the attachment failure note in model context.", + ]); + }); + + it("forgets the oldest warnings beyond 256 so memory stays bounded", async () => { + const s = await setup(); + for (let i = 0; i <= 256; i++) + await s.rewriter.rewrite( + request([user([image()])], { sessionID: `ses_${i}` }), + false, + {}, + ); + expect(s.warnings).toHaveLength(257); + await s.rewriter.rewrite( + request([user([image()])], { sessionID: "ses_0" }), + false, + {}, + ); + expect(s.warnings).toHaveLength(258); + }); + + it("warns again after dispose", async () => { + const s = await setup(); + await s.rewriter.rewrite(request([user([image()])]), false, {}); + s.rewriter.dispose(); + await s.rewriter.rewrite(request([user([image()])]), false, {}); + expect(s.warnings).toHaveLength(2); + }); +}); diff --git a/test/tool-route.test.ts b/test/tool-route.test.ts new file mode 100644 index 0000000..63d7c18 --- /dev/null +++ b/test/tool-route.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { resolveToolRoute } from "../src/tool-route.js"; + +const TOOL = "Vision_understand_image"; + +describe("resolveToolRoute", () => { + it("is direct when the request exposes the tool", () => { + expect(resolveToolRoute(TOOL, { [TOOL]: {} }, [])).toBe("direct"); + }); + + it("is Code Mode when the tool is registered behind execute", () => { + expect( + resolveToolRoute(TOOL, { execute: {} }, [ + { id: TOOL, options: { codemode: true } }, + ]), + ).toBe("codemode"); + expect(resolveToolRoute(TOOL, { execute: {} }, [{ id: TOOL }])).toBe( + "codemode", + ); + }); + + it.each([ + [ + "the tool is registered but opted out of Code Mode", + { execute: {} }, + [{ id: TOOL, options: { codemode: false } }], + ], + ["execute is not exposed", {}, [{ id: TOOL }]], + ["the tool is not registered", { execute: {} }, []], + ["only a differently-cased tool exists", { [TOOL.toLowerCase()]: {} }, []], + ])("is unavailable when %s", (_name, tools, registered) => { + expect(resolveToolRoute(TOOL, tools, registered)).toBe("unavailable"); + }); + + it("ignores inherited object properties", () => { + expect(resolveToolRoute("toString", {}, [])).toBe("unavailable"); + }); +}); diff --git a/test/transform.test.ts b/test/transform.test.ts new file mode 100644 index 0000000..028043e --- /dev/null +++ b/test/transform.test.ts @@ -0,0 +1,270 @@ +import type { ContentPart, Message } from "@opencode/ai"; +import type { ImageResult } from "../src/types.js"; +import type { MediaPart } from "./helpers.js"; +import { Media } from "@opencode/ai"; +import { describe, expect, it } from "vitest"; +import { + isImagePart, + normalizeAttachment, + stripImagesAfterFailure, + transformMessages, +} from "../src/transform.js"; +import { + PNG_BYTES, + assistant, + fixture, + hasMedia, + image, + media, + text, + textOf, + user, +} from "./helpers.js"; + +const lastText = (message: Message) => { + const part = message.content.at(-1); + if (part?.type !== "text") throw new Error("last part is not text"); + return part.text; +}; + +describe("isImagePart", () => { + it.each<[string, ContentPart, boolean]>([ + ["png media", image(), true], + ["uppercase image MIME", media("IMAGE/WEBP"), true], + ["unsupported image MIME", media("image/gif"), true], + ["pdf media", media("application/pdf"), false], + ["audio media", media("audio/wav"), false], + ["text", text("image/png"), false], + ])("%s", (_name, part, expected) => { + expect(isImagePart(part)).toBe(expected); + }); +}); + +describe("normalizeAttachment", () => { + it("normalizes the MIME type and flags private headers", () => { + const remote: MediaPart = { + type: "media", + media: Media.url("https://x.test/a.png", { + mediaType: "Image/PNG", + headers: { authorization: "secret" }, + }), + }; + expect(normalizeAttachment(remote)).toMatchObject({ + mime: "image/png", + requiresHeaders: true, + source: { type: "url", url: "https://x.test/a.png" }, + }); + expect(normalizeAttachment(image())).toMatchObject({ + requiresHeaders: false, + source: { type: "bytes" }, + }); + }); +}); + +describe("transformMessages", () => { + it("returns messages without images unchanged, by reference", async () => { + const f = await fixture(); + const messages = [user([text("hi")]), assistant([text("hello")])]; + const result = await transformMessages(messages, f); + expect(result[0]).toBe(messages[0]); + expect(result[1]).toBe(messages[1]); + }); + + it("never touches images outside user messages", async () => { + const f = await fixture(); + const reply = assistant([image()]); + const [result] = await transformMessages([reply], f); + expect(result).toBe(reply); + }); + + it("replaces images in place and keeps every other part, its order and metadata", async () => { + const f = await fixture(); + const pdf = media("application/pdf"); + const audio = media("audio/wav"); + const original = user( + [text("first"), image(), pdf, text("second"), audio, image()], + { id: "msg_1", metadata: { original: true } }, + ); + const [result] = await transformMessages([original], f); + + expect(original.content[1].type).toBe("media"); + expect(result).not.toBe(original); + expect(result.id).toBe("msg_1"); + expect(result.metadata).toEqual({ original: true }); + expect(result.content[0]).toBe(original.content[0]); + expect(result.content[2]).toBe(pdf); + expect(result.content[3]).toBe(original.content[3]); + expect(result.content[4]).toBe(audio); + expect(result.content[1]).toMatchObject({ + type: "text", + text: expect.stringMatching(/^\nAttached image: .+[0-9a-f]{64}\.png\n$/), + metadata: { attachment: "original", easyVision: true }, + }); + expect(result.content.filter(isImagePart)).toEqual([]); + }); + + it("gives guidance once, on the latest user message, with all of its images", async () => { + const f = await fixture(); + const [result] = await transformMessages( + [user([text("compare these"), image(), image(PNG_BYTES, "image/jpeg")])], + f, + ); + const guidance = lastText(result); + expect(guidance).toContain("The user has shared 2 images."); + expect(guidance).toContain("Use the `Vision_understand_image` tool"); + expect(guidance).toContain("User's request: compare these"); + expect(textOf(result).match(/Use the/g)).toHaveLength(1); + }); + + it("gives older image turns paths only, no instructions", async () => { + const f = await fixture(); + const result = await transformMessages( + [ + user([image(), text("old request")]), + assistant([text("old answer")]), + user([image()]), + ], + f, + ); + expect(result[0].content).toHaveLength(2); + expect(textOf(result[0])).toContain("Attached image:"); + expect(textOf(result[0])).not.toContain("Use the"); + expect(lastText(result[2])).toContain("(analyze the image)"); + }); + + it("only adds paths in paths-only mode", async () => { + const f = await fixture(); + const [result] = await transformMessages([user([image()])], { + ...f, + pathsOnly: true, + }); + expect(result.content).toHaveLength(1); + expect(textOf(result)).toContain("Attached image:"); + expect(textOf(result)).not.toContain("Use the"); + }); + + it("passes only the user's typed text to {userText}", async () => { + const f = await fixture(); + const [result] = await transformMessages( + [ + user([ + text('\nSKILL BODY\n'), + text("describe this"), + { + type: "text", + text: "\n\nAttached file: notes.txt\n\nFILE BODY", + metadata: { attachment: { name: "notes.txt" } }, + }, + text("and this"), + text("Attached file: /tmp/shot.png"), + image(), + ]), + ], + f, + ); + const guidance = lastText(result); + expect(guidance).toContain("User's request: describe this\nand this"); + for (const leaked of ["SKILL BODY", "FILE BODY", "/tmp/shot.png"]) + expect(guidance).not.toContain(leaked); + expect(textOf(result)).toContain("SKILL BODY"); + expect(textOf(result)).toContain("FILE BODY"); + }); + + it("is stable when applied to its own output", async () => { + const f = await fixture(); + const once = await transformMessages([user([text("x"), image()])], f); + expect(await transformMessages(once, f)).toEqual(once); + }); + + it("drops its own earlier guidance if media is reintroduced", async () => { + const f = await fixture(); + const [once] = await transformMessages([user([text("x"), image()])], f); + const [twice] = await transformMessages( + [{ ...once, content: [...once.content, image()] }], + f, + ); + expect(textOf(twice).match(/Use the/g)).toHaveLength(1); + expect(textOf(twice).match(/Attached image:/g)).toHaveLength(1); + }); + + it("produces the same path on every pass", async () => { + const f = await fixture(); + const first = await transformMessages([user([image()])], f); + const second = await transformMessages([user([image()])], f); + expect(textOf(second[0])).toBe(textOf(first[0])); + }); + + describe("failures", () => { + it("replaces a failed image with its reason and reports the count", async () => { + const f = await fixture(); + let calls = 0; + const failures: [string, number][] = []; + const [result] = await transformMessages( + [user([text("keep me"), image(), image()])], + { + ...f, + process: async (attachment): Promise => + ++calls === 1 + ? f.process(attachment) + : { ok: false, reason: "Cannot save" }, + onFailure: (reason, index) => failures.push([reason, index]), + }, + ); + expect(result.content[0]).toEqual(text("keep me")); + expect(hasMedia(result)).toBe(false); + expect(textOf(result)).toContain( + "Easy Vision attachment failure: Cannot save", + ); + expect(textOf(result)).toContain( + "1 attachment(s) could not be processed; 1 image(s) remain available", + ); + expect(failures).toEqual([["Cannot save", 0]]); + }); + + it("gives no analysis instructions when every image failed", async () => { + const f = await fixture(); + const [result] = await transformMessages([user([image()])], { + ...f, + process: async () => ({ ok: false, reason: "nope" }), + }); + expect(textOf(result)).not.toContain("Use the"); + expect(lastText(result)).toContain( + "1 attachment(s) could not be processed. Do not infer", + ); + }); + + it("hides the details of an unexpected processor error", async () => { + const f = await fixture(); + const [result] = await transformMessages([user([image()])], { + ...f, + process: async () => { + throw new Error("private bytes/URL"); + }, + }); + expect(hasMedia(result)).toBe(false); + expect(textOf(result)).toContain("Unexpected image-processing failure"); + expect(textOf(result)).not.toContain("private bytes/URL"); + }); + }); +}); + +describe("stripImagesAfterFailure", () => { + it("replaces images in user messages and leaves everything else alone", () => { + const plain = user([text("no images")]); + const reply = assistant([image()]); + const pdf = media("application/pdf"); + const [stripped, untouched, replyOut] = stripImagesAfterFailure([ + user([text("keep"), image(), pdf]), + plain, + reply, + ]); + expect(stripped.content[0]).toEqual(text("keep")); + expect(stripped.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining("attachment processing failed"), + }); + expect(stripped.content[2]).toBe(pdf); + expect(untouched).toBe(plain); + expect(replyOut).toBe(reply); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..93961dd --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "declaration": false, + "declarationMap": false + }, + "include": ["src/**/*", "test/**/*", "vitest.config.ts", "vitest.e2e.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..32beb23 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + // Each file runs in its own process, so HOME/env stubs cannot leak between files. + pool: "forks", + unstubEnvs: true, + restoreMocks: true, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + // types.ts has no runtime code. + exclude: ["src/types.ts"], + reporter: ["text", "html"], + // Floors, not targets: CI fails if coverage regresses below these. + thresholds: { + lines: 95, + functions: 95, + statements: 95, + branches: 90, + }, + }, + }, +}); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..9f44b9d --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +// End-to-end tests against the installed `opencode` binary; run with `just e2e`. +export default defineConfig({ + test: { + include: ["test/e2e/**/*.e2e.ts"], + environment: "node", + fileParallelism: false, + }, +});