From 7df035d6cbd83d75b1293eec47b5b1c8ce545924 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:33:44 +0000 Subject: [PATCH] [EPIC-D][LOU-D40] dockerode, MCP SDK and prompts become optional peers with install hints Move dockerode, @modelcontextprotocol/sdk and prompts from dependencies to optional peerDependencies (kept in devDependencies). The missing-peer error now names the feature and the exact npm install command; loushy doctor lists the three with what they enable; the http tool uses the global fetch unless validateSSL is false, so undici loads only then. yaml stays a dependency. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016bY29ibQgv3MDdjAbUT4YR --- .fallowrc.json | 3 +- CHANGELOG.md | 2 + docs/deployment.md | 4 +- docs/guardrails.md | 4 +- docs/installation.md | 32 ++++++- llms-full.txt | 40 ++++++-- package-lock.json | 109 +++++++++++++++++++++- package.json | 18 +++- packages/create-loushy-agent/package.json | 3 +- src/cli/doctor.test.ts | 2 + src/cli/doctor.ts | 31 +++--- src/cli/doctorChecks.ts | 57 +++++++---- src/cli/doctorCore.test.ts | 50 +++++++++- src/cli/init/prompts.test.ts | 28 +++++- src/cli/init/prompts.ts | 7 +- src/providers/importGraph.test.ts | 1 + src/providers/missingPeer.test.ts | 42 ++++++++- src/providers/optionalPeer.ts | 48 +++++++++- src/tools/built-in/http.test.ts | 21 +++++ src/tools/built-in/http.ts | 23 +++-- tsup.config.ts | 3 +- 21 files changed, 453 insertions(+), 75 deletions(-) diff --git a/.fallowrc.json b/.fallowrc.json index 6fa5f6152..9aa2bd107 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -20,7 +20,8 @@ "ignoreDependencies": [ "@ai-sdk/anthropic", "@ai-sdk/openai", - "ollama-ai-provider" + "ollama-ai-provider", + "prompts" ], "usedClassMembers": [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index f5719e15f..ecd98136b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - README revamped into a short front page (LOU-D52); the details it dropped moved to `docs/` (new pages: tools, approvals, providers, cli, flows, guardrails, utilities). ### Changed +- Heavy dependencies are optional peers (LOU-D40): `dockerode` (Docker sandboxing, `SubprocessSandbox`), `@modelcontextprotocol/sdk` (`serveMcp`, `loushy mcp`, MCP clients) and `prompts` (the interactive questions of `loushy init`) moved from `dependencies` to `peerDependencies`, marked optional in `peerDependenciesMeta`. They were already loaded lazily; now a project that uses none of them does not install them. Using one without its package fails that call with `MissingPeerDependencyError`, which now also carries `feature` and names the exact command (for example `npm install dockerode@^5.0.1`). `loushy doctor` lists the three with what they enable (a missing `dockerode` fails the check when the agent spec uses a sandboxed tool), and its version lookup no longer returns `undefined` for packages whose `exports` map hides `package.json`. **Migration:** install the peer if you use Docker sandboxing (`npm install dockerode`), MCP (`npm install @modelcontextprotocol/sdk`), or interactive `loushy init` (`npm install prompts`; `npm create loushy-agent` already does, and `loushy init --yes` needs nothing). See docs/installation.md. +- The `http` tool no longer loads `undici` for default requests (LOU-D40): with TLS verification on (the default) it uses the runtime's global `fetch`; `undici` (still a regular dependency) is loaded only for `validateSSL: false`, whose per-request TLS dispatcher the global `fetch` cannot express. `yaml` stays a dependency (agent specs and skills need a YAML parser). - Retries no longer stack (LOU-V7.2): the built-in providers (`OpenAIProvider`, `AnthropicProvider`, `OllamaProvider`, `OpenRouterProvider`) now pass `maxRetries` to the `ai` SDK calls: `config.maxRetries`, or 2 (the `ai` SDK's default, so behaviour is unchanged) when unset. `createAgent()` builds the providers it resolves from strings with `maxRetries: 0` and retries in its `withRetry()` wrapper instead, so retries happen in one place (at most 3 calls by default, as before) and each one is observable. If you wrap a built-in provider in `withRetry()` / `resilientProvider()` yourself, build it with `maxRetries: 0`; note that a `maxRetries` you already set in a provider config is now honoured by the `ai` SDK. - `AgentType` is optional and deprecated (LOU-D34, not breaking): `AgentBuilder.build()` no longer requires `setType()` and `AgentConfig.agentType` is now optional (`createAgent()` and `specToAgent()` agents carry no type). `AgentType`, `setType()` and the `agent-types` registry/validators are marked `@deprecated`: they have no runtime effect and will be removed in the next minor; they stay exported for now. Drop your `setType(...)` calls. - BREAKING: `encrypt()` now generates a random salt per call instead of a hardcoded one. Ciphertext produced before this change cannot be decrypted with the new code and must be re-encrypted. diff --git a/docs/deployment.md b/docs/deployment.md index d2bad77b5..b3b65d37b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -82,8 +82,8 @@ Workers have no Node.js builtins, so this target currently supports: - tools: `current-date` and `day-name`. `http` is **not** supported: its SSRF protection resolves the hostname via `node:dns` and checks *every* resolved address against a denylist before connecting (closing a - DNS-rebinding gap), then pins TLS settings per request via a dedicated - `undici` `Agent`. Workers' native `fetch()` has no equivalent hook to + DNS-rebinding gap), then, for `validateSSL: false`, pins that TLS setting + per request via a dedicated `undici` `Agent`. Workers' native `fetch()` has no equivalent hook to resolve a hostname up front and pin the connection to the verified IP, so a Workers version of this tool built on plain `fetch()` would silently drop that protection rather than just losing convenience functionality - diff --git a/docs/guardrails.md b/docs/guardrails.md index 24d2524c2..071407967 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -63,8 +63,8 @@ await AgentExecutor.execute({ agent, input, provider, toolRegistry, sandbox: new isolation. - `SubprocessSandbox` runs each command in a new, network-isolated, auto-removed Docker container, with no host directory mounted except the - `cwd` you pass. It needs a running Docker daemon and the `dockerode` package - (loaded on first use). + `cwd` you pass. It needs a running Docker daemon and the optional peer + `dockerode` (`npm install dockerode@^5.0.1`; loaded on first use). - Implement `SandboxAdapter` (`name`, `run(cmd, args, opts)`, `writeFile(path, content)`) for another backend. diff --git a/docs/installation.md b/docs/installation.md index 9fffb3c58..fd83dff06 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -56,9 +56,35 @@ try { } ``` -The same applies to the dependencies the SDK ships with but only loads when a -feature needs them: `undici` (the built-in `http` tool), `dockerode` -(`SubprocessSandbox`) and `@modelcontextprotocol/sdk` (`serveMcp`). +### Optional peers + +Three heavier packages are optional peers too. A project that never uses +Docker sandboxing, MCP or the `loushy init` questions installs none of them: + +| Package | Range | Enables | Install | +| --------------------------- | --------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| `dockerode` | `^5.0.1` | Docker sandboxing: `SubprocessSandbox` (and `loushy doctor`'s Docker ping) | `npm install dockerode@^5.0.1` | +| `@modelcontextprotocol/sdk` | `^1.30.1` | MCP: `serveMcp()`, `loushy mcp`, and the `Client` you connect before `loadMcpTools()` | `npm install @modelcontextprotocol/sdk@^1.30.1` | +| `prompts` | `^2.4.2` | The interactive questions of `loushy init` (`loushy init --yes` and flags need nothing) | `npm install prompts@^2.4.2` | + +Like the provider packages, each is loaded on first use, never at import +time, and a missing one fails that call with a `MissingPeerDependencyError` +that names the feature and the exact command, for example: + +```text +The optional package 'dockerode' is not installed, but Docker sandboxing (SubprocessSandbox) needs it. Run: npm install dockerode@^5.0.1 +``` + +`npm create loushy-agent` installs `prompts` itself, so scaffolding with it +needs nothing extra. `loushy doctor` lists every optional peer, what it +enables and whether it is installed (a missing `dockerode` is an error only +when the agent spec uses a sandboxed tool). + +`undici` and `yaml` stay regular dependencies. `yaml` parses agent specs, +`agent.yaml` files and skills, and Node has no built-in YAML parser. `undici` +is only loaded by the `http` tool when a request sets `validateSSL: false` +(a per-request TLS setting that the global `fetch` cannot express); every +other request uses the runtime's global `fetch`. ## Installing from a local build diff --git a/llms-full.txt b/llms-full.txt index 368704a29..8fc18fbf6 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -317,9 +317,35 @@ try { } ``` -The same applies to the dependencies the SDK ships with but only loads when a -feature needs them: `undici` (the built-in `http` tool), `dockerode` -(`SubprocessSandbox`) and `@modelcontextprotocol/sdk` (`serveMcp`). +### Optional peers + +Three heavier packages are optional peers too. A project that never uses +Docker sandboxing, MCP or the `loushy init` questions installs none of them: + +| Package | Range | Enables | Install | +| --------------------------- | --------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| `dockerode` | `^5.0.1` | Docker sandboxing: `SubprocessSandbox` (and `loushy doctor`'s Docker ping) | `npm install dockerode@^5.0.1` | +| `@modelcontextprotocol/sdk` | `^1.30.1` | MCP: `serveMcp()`, `loushy mcp`, and the `Client` you connect before `loadMcpTools()` | `npm install @modelcontextprotocol/sdk@^1.30.1` | +| `prompts` | `^2.4.2` | The interactive questions of `loushy init` (`loushy init --yes` and flags need nothing) | `npm install prompts@^2.4.2` | + +Like the provider packages, each is loaded on first use, never at import +time, and a missing one fails that call with a `MissingPeerDependencyError` +that names the feature and the exact command, for example: + +```text +The optional package 'dockerode' is not installed, but Docker sandboxing (SubprocessSandbox) needs it. Run: npm install dockerode@^5.0.1 +``` + +`npm create loushy-agent` installs `prompts` itself, so scaffolding with it +needs nothing extra. `loushy doctor` lists every optional peer, what it +enables and whether it is installed (a missing `dockerode` is an error only +when the agent spec uses a sandboxed tool). + +`undici` and `yaml` stay regular dependencies. `yaml` parses agent specs, +`agent.yaml` files and skills, and Node has no built-in YAML parser. `undici` +is only loaded by the `http` tool when a request sets `validateSSL: false` +(a per-request TLS setting that the global `fetch` cannot express); every +other request uses the runtime's global `fetch`. ## Installing from a local build @@ -2632,8 +2658,8 @@ Workers have no Node.js builtins, so this target currently supports: - tools: `current-date` and `day-name`. `http` is **not** supported: its SSRF protection resolves the hostname via `node:dns` and checks *every* resolved address against a denylist before connecting (closing a - DNS-rebinding gap), then pins TLS settings per request via a dedicated - `undici` `Agent`. Workers' native `fetch()` has no equivalent hook to + DNS-rebinding gap), then, for `validateSSL: false`, pins that TLS setting + per request via a dedicated `undici` `Agent`. Workers' native `fetch()` has no equivalent hook to resolve a hostname up front and pin the connection to the verified IP, so a Workers version of this tool built on plain `fetch()` would silently drop that protection rather than just losing convenience functionality - @@ -3274,8 +3300,8 @@ await AgentExecutor.execute({ agent, input, provider, toolRegistry, sandbox: new isolation. - `SubprocessSandbox` runs each command in a new, network-isolated, auto-removed Docker container, with no host directory mounted except the - `cwd` you pass. It needs a running Docker daemon and the `dockerode` package - (loaded on first use). + `cwd` you pass. It needs a running Docker daemon and the optional peer + `dockerode` (`npm install dockerode@^5.0.1`; loaded on first use). - Implement `SandboxAdapter` (`name`, `run(cmd, args, opts)`, `writeFile(path, content)`) for another backend. diff --git a/package-lock.json b/package-lock.json index d785830d3..f24a53999 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,6 @@ "packages/*" ], "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.1", - "dockerode": "^5.0.1", - "prompts": "^2.4.2", "undici": "^8.11.2", "yaml": "^2.9.1" }, @@ -25,6 +22,7 @@ "devDependencies": { "@ai-sdk/anthropic": "^0.0.42", "@ai-sdk/openai": "^0.0.42", + "@modelcontextprotocol/sdk": "^1.30.1", "@opentelemetry/api": "^1.9.1", "@opentelemetry/sdk-trace-node": "^2.11.0", "@types/dockerode": "^4.0.1", @@ -33,9 +31,11 @@ "@types/react": "^18.3.31", "@types/react-test-renderer": "^18.3.1", "@vitest/coverage-v8": "^1.0.0", + "dockerode": "^5.0.1", "esbuild": "^0.25.12", "eslint": "^10.11.0", "fallow": "^3.31.0", + "prompts": "^2.4.2", "react": "^18.3.1", "react-test-renderer": "^18.3.1", "selfsigned": "^5.5.0", @@ -53,9 +53,12 @@ "peerDependencies": { "@ai-sdk/anthropic": "^0.0.42", "@ai-sdk/openai": "^0.0.42", + "@modelcontextprotocol/sdk": "^1.30.1", "@opentelemetry/api": "^1.9.1", "ai": "^4.3.19", + "dockerode": "^5.0.1", "ollama-ai-provider": "^1.2.0", + "prompts": "^2.4.2", "react": "^18 || ^19", "tsup": "^8.0.0", "zod": "^3.25.76" @@ -67,12 +70,21 @@ "@ai-sdk/openai": { "optional": true }, + "@modelcontextprotocol/sdk": { + "optional": true + }, "@opentelemetry/api": { "optional": true }, + "dockerode": { + "optional": true + }, "ollama-ai-provider": { "optional": true }, + "prompts": { + "optional": true + }, "react": { "optional": true }, @@ -736,6 +748,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@bcoe/v8-coverage": { @@ -1843,6 +1856,7 @@ "version": "1.14.5", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -1856,6 +1870,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", @@ -1874,6 +1889,7 @@ "version": "0.7.15", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", @@ -1892,6 +1908,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -2636,6 +2653,7 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -2719,6 +2737,7 @@ "version": "1.30.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz", "integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==", + "dev": true, "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -2759,6 +2778,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2775,6 +2795,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, "node_modules/@noble/hashes": { @@ -3193,30 +3214,35 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.1" @@ -3226,24 +3252,28 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@rolldown/pluginutils": { @@ -3880,6 +3910,7 @@ "version": "20.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz", "integrity": "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -5008,6 +5039,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -5025,6 +5057,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -5041,6 +5074,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, "node_modules/ansi-regex": { @@ -5094,6 +5128,7 @@ "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": "~2.1.0" @@ -5142,6 +5177,7 @@ "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", @@ -5175,6 +5211,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "tweetnacl": "^0.14.3" @@ -5184,6 +5221,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -5284,6 +5322,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, "funding": [ { "type": "github", @@ -5308,6 +5347,7 @@ "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, "optional": true, "engines": { "node": ">=10.0.0" @@ -5487,6 +5527,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, "license": "ISC" }, "node_modules/classcat": { @@ -5499,6 +5540,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -5513,6 +5555,7 @@ "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" @@ -5522,6 +5565,7 @@ "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" @@ -5537,12 +5581,14 @@ "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/cliui/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", @@ -5557,6 +5603,7 @@ "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" @@ -5569,6 +5616,7 @@ "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", @@ -5601,6 +5649,7 @@ "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" @@ -5613,6 +5662,7 @@ "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/combined-stream": { @@ -5747,6 +5797,7 @@ "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, "hasInstallScript": true, "optional": true, "dependencies": { @@ -5771,6 +5822,7 @@ "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", @@ -6090,6 +6142,7 @@ "version": "5.0.7", "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "debug": "^4.1.1", @@ -6105,6 +6158,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@balena/dockerignore": "^1.0.2", @@ -6172,6 +6226,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -6292,6 +6347,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6569,6 +6625,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -6581,6 +6638,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -6657,6 +6715,7 @@ "version": "8.7.0", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -6761,6 +6820,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -6788,6 +6848,7 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, "funding": [ { "type": "github", @@ -6994,6 +7055,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, "license": "MIT" }, "node_modules/fs.realpath": { @@ -7041,6 +7103,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -7229,6 +7292,7 @@ "version": "4.13.10", "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.10.tgz", "integrity": "sha512-dQuLsa5oO+47QVMVMaaD9cIv8ctmVtK1iRvwWngkfloFJMeFeuoUFDswIqZGxmGX3hrRzREgEArjkK9OgsQEhA==", + "dev": true, "license": "MIT", "engines": { "node": ">=16.9.0" @@ -7339,6 +7403,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, "funding": [ { "type": "github", @@ -7397,6 +7462,7 @@ "version": "10.7.2", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -7425,6 +7491,7 @@ "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" @@ -7473,6 +7540,7 @@ "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/istanbul-lib-coverage": { @@ -7549,6 +7617,7 @@ "version": "6.2.12", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -7679,6 +7748,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -7846,6 +7916,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, "license": "MIT" }, "node_modules/lodash.sortby": { @@ -7859,6 +7930,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/loose-envify": { @@ -8133,6 +8205,7 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, "license": "MIT" }, "node_modules/mlly": { @@ -8170,6 +8243,7 @@ "version": "2.29.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "dev": true, "license": "MIT", "optional": true }, @@ -8488,6 +8562,7 @@ "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" @@ -8571,6 +8646,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=16.20.0" @@ -8780,6 +8856,7 @@ "version": "7.6.6", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -8820,6 +8897,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -9005,6 +9083,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -9040,6 +9119,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9049,6 +9129,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9133,6 +9214,7 @@ "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", @@ -9315,6 +9397,7 @@ "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" @@ -9327,6 +9410,7 @@ "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" @@ -9458,12 +9542,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "dev": true, "license": "ISC" }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "dev": true, "hasInstallScript": true, "dependencies": { "asn1": "^0.2.6", @@ -9504,6 +9590,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -9789,6 +9876,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -9801,6 +9889,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, "license": "MIT", "dependencies": { "bl": "^4.0.3", @@ -10584,6 +10673,7 @@ "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, "license": "Unlicense" }, "node_modules/type-check": { @@ -10767,6 +10857,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unenv": { @@ -10842,6 +10933,7 @@ "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/vary": { @@ -11525,6 +11617,7 @@ "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" @@ -12270,6 +12363,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -12301,6 +12395,7 @@ "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -12319,6 +12414,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -12328,6 +12424,7 @@ "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" @@ -12337,12 +12434,14 @@ "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/yargs/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", @@ -12357,6 +12456,7 @@ "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" @@ -12466,7 +12566,8 @@ "packages/create-loushy-agent": { "version": "0.1.0", "dependencies": { - "@loushy/build-ai-agent": "^1.0.0-alpha.8" + "@loushy/build-ai-agent": "^1.0.0-alpha.8", + "prompts": "^2.4.2" }, "bin": { "create-loushy-agent": "bin/cli.js" diff --git a/package.json b/package.json index 02779dcb8..3a101ecd0 100644 --- a/package.json +++ b/package.json @@ -157,9 +157,12 @@ "peerDependencies": { "@ai-sdk/anthropic": "^0.0.42", "@ai-sdk/openai": "^0.0.42", + "@modelcontextprotocol/sdk": "^1.30.1", "@opentelemetry/api": "^1.9.1", "ai": "^4.3.19", + "dockerode": "^5.0.1", "ollama-ai-provider": "^1.2.0", + "prompts": "^2.4.2", "react": "^18 || ^19", "tsup": "^8.0.0", "zod": "^3.25.76" @@ -171,12 +174,21 @@ "@ai-sdk/openai": { "optional": true }, + "@modelcontextprotocol/sdk": { + "optional": true + }, "@opentelemetry/api": { "optional": true }, + "dockerode": { + "optional": true + }, "ollama-ai-provider": { "optional": true }, + "prompts": { + "optional": true + }, "react": { "optional": true }, @@ -185,15 +197,13 @@ } }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.1", - "dockerode": "^5.0.1", - "prompts": "^2.4.2", "undici": "^8.11.2", "yaml": "^2.9.1" }, "devDependencies": { "@ai-sdk/anthropic": "^0.0.42", "@ai-sdk/openai": "^0.0.42", + "@modelcontextprotocol/sdk": "^1.30.1", "@opentelemetry/api": "^1.9.1", "@opentelemetry/sdk-trace-node": "^2.11.0", "@types/dockerode": "^4.0.1", @@ -202,9 +212,11 @@ "@types/react": "^18.3.31", "@types/react-test-renderer": "^18.3.1", "@vitest/coverage-v8": "^1.0.0", + "dockerode": "^5.0.1", "esbuild": "^0.25.12", "eslint": "^10.11.0", "fallow": "^3.31.0", + "prompts": "^2.4.2", "react": "^18.3.1", "react-test-renderer": "^18.3.1", "selfsigned": "^5.5.0", diff --git a/packages/create-loushy-agent/package.json b/packages/create-loushy-agent/package.json index 750d228de..1fbfb203c 100644 --- a/packages/create-loushy-agent/package.json +++ b/packages/create-loushy-agent/package.json @@ -16,7 +16,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@loushy/build-ai-agent": "^1.0.0-alpha.8" + "@loushy/build-ai-agent": "^1.0.0-alpha.8", + "prompts": "^2.4.2" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts index 87b60ff21..55e005ad4 100644 --- a/src/cli/doctor.test.ts +++ b/src/cli/doctor.test.ts @@ -40,6 +40,8 @@ describe('real environment', () => { const env = buildEnvironment({ json: false }); expect(env.resolvePackageVersion('zod')).toMatch(/^\d+\.\d+\.\d+/); expect(env.resolvePackageVersion('definitely-not-installed-pkg')).toBeNull(); + // The MCP SDK's "exports" map sends `/package.json` to a nested, version-less manifest. + expect(env.resolvePackageVersion('@modelcontextprotocol/sdk')).toMatch(/^\d+\.\d+\.\d+/); expect(env.sdk.engines?.node).toBeTruthy(); }); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 344440052..8ff7cb0f3 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import { createRequire } from 'node:module'; import * as path from 'node:path'; +import { MissingPeerDependencyError, loadOptionalPeer } from '../providers/optionalPeer'; import { loadSpec } from '../spec/loadSpec'; import { resolveSpecTool } from '../spec/specToAgent'; import { runDoctor } from './doctorCore'; @@ -42,17 +43,18 @@ function findPackageJson(entry: string, name: string): string | null { /** Version of `name` as Node would resolve it from `cwd`, or null when it is not installed. */ function resolveVersion(cwd: string, name: string): string | null { const require = createRequire(path.join(cwd, 'noop.js')); - try { - return JSON.parse(fs.readFileSync(require.resolve(`${name}/package.json`), 'utf8')).version; - } catch { - // Not installed, or the package's "exports" hides package.json: fall back to its entry point. - } - try { - const manifest = findPackageJson(require.resolve(name), name); - return manifest ? JSON.parse(fs.readFileSync(manifest, 'utf8')).version : null; - } catch { - return null; + // The first specifier can land on a nested package.json (the MCP SDK's exports map sends + // `/package.json` to dist/cjs/package.json), so walk up to the manifest named `name`. + // Not installed, or "exports" hides both specifiers: null. + for (const specifier of [`${name}/package.json`, name]) { + try { + const manifest = findPackageJson(require.resolve(specifier), name); + if (manifest) return JSON.parse(fs.readFileSync(manifest, 'utf8')).version; + } catch { + // try the next specifier + } } + return null; } function commandExists(command: string, env: NodeJS.ProcessEnv): boolean { @@ -65,7 +67,14 @@ function commandExists(command: string, env: NodeJS.ProcessEnv): boolean { } async function dockerReachable(): Promise { - const { default: Docker } = await import('dockerode'); + let Docker: typeof import('dockerode'); + try { + Docker = (await loadOptionalPeer('dockerode', () => import('dockerode'))).default; + } catch (error) { + // Without the optional dockerode peer there is no way to ask; its own check reports that. + if (error instanceof MissingPeerDependencyError) return false; + throw error; + } const ping = new Docker().ping().then(() => true); const timeout = new Promise((resolve) => setTimeout(() => resolve(false), DOCKER_TIMEOUT_MS).unref()); return Promise.race([ping, timeout]); diff --git a/src/cli/doctorChecks.ts b/src/cli/doctorChecks.ts index d1734c8e3..7d659b381 100644 --- a/src/cli/doctorChecks.ts +++ b/src/cli/doctorChecks.ts @@ -2,6 +2,7 @@ * The individual `loushy doctor` checks. Each is a small function over the * injected DoctorEnvironment returning one or more DoctorCheck lines. */ +import { FEATURE_PEERS } from '../providers/optionalPeer'; import { listProviders, modelFromEnv, type ProviderInfo } from '../providers/providerSpec'; import type { DoctorCheck, DoctorEnvironment } from './doctorTypes'; import { satisfiesRange } from './versionRange'; @@ -68,39 +69,55 @@ export function checkRequiredPeers(env: DoctorEnvironment): DoctorCheck[] { } interface OptionalPeer { - providers: string[]; - /** The `npm install` argument from providerSpec.ts, e.g. `@ai-sdk/openai@^0.0.42`. */ + title: string; + /** What the package enables, appended to the finding (feature peers only). */ + enables?: string; + /** The `npm install` argument, e.g. `@ai-sdk/openai@^0.0.42`. */ install: string; + /** True when the agent spec cannot work without it (not installed becomes a failure). */ + required: boolean; } -/** The optional peer packages behind the providers, each with the providers that need it. */ -function optionalPeerProviders(): Map { +/** The optional peer packages behind the providers; required when the spec uses one of its providers. */ +function providerPeers(needs: SpecNeeds): Map { const peers = new Map(); for (const info of listProviders()) { - const existing = peers.get(info.peerPackage); peers.set(info.peerPackage, { - providers: [...(existing?.providers ?? []), info.name], + title: `Provider package ${info.peerPackage}`, install: info.peerInstall, + required: (peers.get(info.peerPackage)?.required ?? false) || needs.providers.has(info.name), }); } return peers; } -function checkOptionalPeer( - env: DoctorEnvironment, - name: string, - peer: OptionalPeer, - needs: SpecNeeds -): DoctorCheck { - const required = peer.providers.some((provider) => needs.providers.has(provider)); +/** The optional peers behind SDK features (LOU-D40); only Docker can be known from the spec. */ +function featurePeers(needs: SpecNeeds): Map { + return new Map( + Object.entries(FEATURE_PEERS).map(([name, peer]) => [ + name, + { + title: `Optional package ${name}`, + enables: peer.feature, + install: `${name}@${peer.range}`, + required: name === 'dockerode' && needs.usesSandbox, + }, + ]) + ); +} + +function checkOptionalPeer(env: DoctorEnvironment, name: string, peer: OptionalPeer): DoctorCheck { const fix = `npm install ${peer.install}`; - const base = { id: `optional-peer.${name}`, title: `Provider package ${name}` }; + const base = { id: `optional-peer.${name}`, title: peer.title }; + const enables = peer.enables ? ` - enables ${peer.enables}` : ''; const version = env.resolvePackageVersion(name); if (version === null) { return { ...base, - status: required ? 'fail' : 'warn', - finding: required ? 'not installed, and the agent spec needs it' : 'not installed (optional)', + status: peer.required ? 'fail' : 'warn', + finding: peer.required + ? `not installed, and the agent spec needs it${enables}` + : `not installed (optional)${enables}`, fix, }; } @@ -108,16 +125,16 @@ function checkOptionalPeer( if (range && !satisfiesRange(version, range)) { return { ...base, - status: required ? 'fail' : 'warn', - finding: `${version} installed, but the SDK expects ${range}`, + status: peer.required ? 'fail' : 'warn', + finding: `${version} installed, but the SDK expects ${range}${enables}`, fix, }; } - return { ...base, status: 'ok', finding: `${version} installed` }; + return { ...base, status: 'ok', finding: `${version} installed${enables}` }; } export function checkOptionalPeers(env: DoctorEnvironment, needs: SpecNeeds): DoctorCheck[] { - return [...optionalPeerProviders()].map(([name, peer]) => checkOptionalPeer(env, name, peer, needs)); + return [...providerPeers(needs), ...featurePeers(needs)].map(([name, peer]) => checkOptionalPeer(env, name, peer)); } function checkApiKey(env: DoctorEnvironment, info: ProviderInfo, needs: SpecNeeds): DoctorCheck { diff --git a/src/cli/doctorCore.test.ts b/src/cli/doctorCore.test.ts index e1341b88e..2404c2146 100644 --- a/src/cli/doctorCore.test.ts +++ b/src/cli/doctorCore.test.ts @@ -12,6 +12,9 @@ const INSTALLED: Record = { '@ai-sdk/openai': '0.0.42', '@ai-sdk/anthropic': '0.0.42', 'ollama-ai-provider': '1.2.0', + dockerode: '5.0.1', + '@modelcontextprotocol/sdk': '1.30.1', + prompts: '2.4.2', }; function makeEnv(overrides: Partial = {}): DoctorEnvironment { @@ -26,6 +29,9 @@ function makeEnv(overrides: Partial = {}): DoctorEnvironment '@ai-sdk/openai': '^0.0.42', '@ai-sdk/anthropic': '^0.0.42', 'ollama-ai-provider': '^1.2.0', + dockerode: '^5.0.1', + '@modelcontextprotocol/sdk': '^1.30.1', + prompts: '^2.4.2', }, }, resolvePackageVersion: (name) => INSTALLED[name] ?? null, @@ -121,6 +127,45 @@ describe('optional provider peers', () => { }); }); +describe('optional feature peers (LOU-D40)', () => { + const without = (missing: string) => (n: string) => (n === missing ? null : INSTALLED[n] ?? null); + + it.each([ + ['dockerode', 'Docker sandboxing'], + ['@modelcontextprotocol/sdk', 'MCP'], + ['prompts', 'loushy init'], + ])('%s: ok line says what it enables', async (name, enables) => { + const result = await check(makeEnv(), `optional-peer.${name}`); + expect(result.status).toBe('ok'); + expect(result.finding).toContain(enables); + }); + + it.each([ + ['dockerode', 'npm install dockerode@^5.0.1'], + ['@modelcontextprotocol/sdk', 'npm install @modelcontextprotocol/sdk@^1.30.1'], + ['prompts', 'npm install prompts@^2.4.2'], + ])('%s: warn with the exact install command when missing', async (name, fix) => { + const result = await check(makeEnv({ resolvePackageVersion: without(name) }), `optional-peer.${name}`); + expect(result.status).toBe('warn'); + expect(result.fix).toBe(fix); + }); + + it('dockerode fails when missing and the spec uses a sandboxed tool', async () => { + const env = makeEnv({ + resolvePackageVersion: without('dockerode'), + specPath: 'a.yaml', + loadSpec: () => spec({ tools: ['sandboxed'] }), + resolveTool: () => ({ requiresSandbox: true }), + }); + expect((await check(env, 'optional-peer.dockerode')).status).toBe('fail'); + }); + + it('warns on an out-of-range version', async () => { + const old = (n: string) => (n === 'prompts' ? '1.0.0' : INSTALLED[n] ?? null); + expect((await check(makeEnv({ resolvePackageVersion: old }), 'optional-peer.prompts')).finding).toContain('expects ^2.4.2'); + }); +}); + describe('API keys', () => { it('ok and prints only "set" when the variable is set', async () => { const result = await check(makeEnv({ env: { OPENAI_API_KEY: SECRET } }), 'env.openai'); @@ -321,6 +366,9 @@ describe('report, rendering and exit codes', () => { [ ok ] Provider package @ai-sdk/anthropic: 0.0.42 installed [warn] Provider package ollama-ai-provider: not installed (optional) fix: npm install ollama-ai-provider@^1.2.0 + [ ok ] Optional package dockerode: 5.0.1 installed - enables Docker sandboxing (SubprocessSandbox) + [ ok ] Optional package @modelcontextprotocol/sdk: 1.30.1 installed - enables MCP (serveMcp, \`loushy mcp\` and MCP client connections) + [ ok ] Optional package prompts: 2.4.2 installed - enables the interactive prompts of \`loushy init\` (pass --yes to skip them) [ ok ] openai (OPENAI_API_KEY): set [warn] anthropic (ANTHROPIC_API_KEY): not set fix: Set ANTHROPIC_API_KEY in your environment, e.g. export ANTHROPIC_API_KEY= @@ -330,7 +378,7 @@ describe('report, rendering and exit codes', () => { [ ok ] Default provider for createAgent(): would use 'openai/gpt-4o-mini' [ ok ] Docker: daemon not reachable (only needed for sandboxed tools; none configured) - 8 ok, 3 warnings, 1 failure" + 11 ok, 3 warnings, 1 failure" `); expect(text).not.toMatch(/[^\x20-\x7e\n]/); }); diff --git a/src/cli/init/prompts.test.ts b/src/cli/init/prompts.test.ts index b7f7cce32..c89e66391 100644 --- a/src/cli/init/prompts.test.ts +++ b/src/cli/init/prompts.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import prompts from 'prompts'; import { askMissing } from './prompts'; @@ -33,3 +33,29 @@ describe('askMissing', () => { await expect(askMissing({ known: {}, defaults })).rejects.toThrow('cancelled'); }); }); + +describe('askMissing without the optional prompts peer (LOU-D40)', () => { + afterEach(() => { + vi.doUnmock('prompts'); + vi.resetModules(); + }); + + it('rejects with the install hint (and a way around it) instead of failing at import time', async () => { + vi.resetModules(); + vi.doMock('prompts', () => { + throw Object.assign(new Error("Cannot find module 'prompts'"), { code: 'MODULE_NOT_FOUND' }); + }); + const { askMissing: ask } = await import('./prompts'); + const error = await ask({ known: {}, defaults }).catch((e: unknown) => e); + expect(error).toMatchObject({ name: 'MissingPeerDependencyError', installCommand: 'npm install prompts@^2.4.2' }); + expect((error as Error).message).toContain('--yes'); + }); + + it('importing the module does not load the peer', async () => { + vi.resetModules(); + vi.doMock('prompts', () => { + throw Object.assign(new Error("Cannot find module 'prompts'"), { code: 'MODULE_NOT_FOUND' }); + }); + await expect(import('./prompts')).resolves.toHaveProperty('askMissing'); + }); +}); diff --git a/src/cli/init/prompts.ts b/src/cli/init/prompts.ts index efbd5165a..60b043d3e 100644 --- a/src/cli/init/prompts.ts +++ b/src/cli/init/prompts.ts @@ -1,4 +1,5 @@ -import prompts, { type PromptObject } from 'prompts'; +import type { PromptObject } from 'prompts'; +import { loadOptionalPeer } from '../../providers/optionalPeer'; import { PROVIDER_NAMES, TEMPLATES } from './options'; /** The answers `loushy init` can ask for; a key is only asked when it is missing. */ @@ -48,9 +49,11 @@ function questions({ known, defaults }: AskInput): PromptObject { + const { default: prompts } = await loadOptionalPeer('prompts', () => import('prompts')); let cancelled = false; const response = await prompts(questions(input), { onCancel: () => { diff --git a/src/providers/importGraph.test.ts b/src/providers/importGraph.test.ts index a4bf59ba2..1e655d900 100644 --- a/src/providers/importGraph.test.ts +++ b/src/providers/importGraph.test.ts @@ -19,6 +19,7 @@ const NEVER_LOADED_AT_IMPORT = [ 'undici', 'dockerode', '@modelcontextprotocol/sdk', + 'prompts', // LOU-D40: only `loushy init`'s interactive questions use it, on first ask 'node:sqlite', // LOU-W5: only the lazily-loaded /sqlite subpath may use it, and only when a store is constructed ]; diff --git a/src/providers/missingPeer.test.ts b/src/providers/missingPeer.test.ts index e26e7d635..41bd4c9db 100644 --- a/src/providers/missingPeer.test.ts +++ b/src/providers/missingPeer.test.ts @@ -4,7 +4,8 @@ * the install command. */ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { MissingPeerDependencyError, loadOptionalPeer, lazyValue } from './optionalPeer'; +import { readFileSync } from 'node:fs'; +import { FEATURE_PEERS, MissingPeerDependencyError, loadOptionalPeer, lazyValue } from './optionalPeer'; function moduleNotFound(packageName: string): Error { return Object.assign(new Error(`Cannot find module '${packageName}'`), { code: 'MODULE_NOT_FOUND' }); @@ -98,11 +99,48 @@ describe('SubprocessSandbox with dockerode not installed', () => { await expect(sandbox.run('echo', ['hi'])).rejects.toMatchObject({ name: 'MissingPeerDependencyError', packageName: 'dockerode', - installCommand: 'npm install dockerode', + installCommand: 'npm install dockerode@^5.0.1', }); }); }); +describe('feature peers (LOU-D40)', () => { + const manifest = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')); + + it.each(Object.entries(FEATURE_PEERS))('%s is an optional peer whose range matches package.json', (name, peer) => { + expect(manifest.peerDependencies[name]).toBe(peer.range); + expect(manifest.peerDependenciesMeta[name]).toEqual({ optional: true }); + expect(manifest.devDependencies[name]).toBe(peer.range); // the repo's own tests still run + expect(manifest.dependencies?.[name]).toBeUndefined(); + }); + + it.each([ + ['dockerode', 'Docker sandboxing', 'npm install dockerode@^5.0.1'], + ['@modelcontextprotocol/sdk', 'MCP', 'npm install @modelcontextprotocol/sdk@^1.30.1'], + ['prompts', '`loushy init`', 'npm install prompts@^2.4.2'], + ])('the error for %s names the feature and the exact install command', async (name, feature, command) => { + const error = await loadOptionalPeer(name, () => Promise.reject(moduleNotFound(name))).catch((e: unknown) => e); + expect(error).toBeInstanceOf(MissingPeerDependencyError); + expect(error).toMatchObject({ packageName: name, installCommand: command }); + expect((error as MissingPeerDependencyError).feature).toContain(feature); + expect((error as Error).message).toContain(feature); + expect((error as Error).message).toContain(`Run: ${command}`); + }); + + it('buildServer() rejects with the MCP install hint when the MCP SDK is missing', async () => { + vi.resetModules(); + vi.doMock('@modelcontextprotocol/sdk/server/mcp.js', () => { + throw moduleNotFound('@modelcontextprotocol/sdk/server/mcp.js'); + }); + const { buildServer } = await import('../tools/mcp/server/buildServer'); + await expect(buildServer({ name: 'x', version: '1', tools: [] } as never)).rejects.toMatchObject({ + name: 'MissingPeerDependencyError', + installCommand: 'npm install @modelcontextprotocol/sdk@^1.30.1', + }); + vi.doUnmock('@modelcontextprotocol/sdk/server/mcp.js'); + }); +}); + describe('loadOptionalPeer', () => { it('rethrows errors that are not a missing package, and a missing transitive module as is', async () => { const boom = new Error('boom'); diff --git a/src/providers/optionalPeer.ts b/src/providers/optionalPeer.ts index 34c872392..2baf32ada 100644 --- a/src/providers/optionalPeer.ts +++ b/src/providers/optionalPeer.ts @@ -4,10 +4,43 @@ * Importing the SDK never loads a provider SDK: each is imported with a * dynamic `import()` on first use, and a missing package becomes a * {@link MissingPeerDependencyError} with the exact install command. + * + * LOU-D40 extends this to the feature peers (`dockerode`, the MCP SDK and + * `prompts`): each is described in {@link FEATURE_PEERS}, so its error also + * names the feature that needs it. */ import { peerInstallCommand } from './providerSpec'; +/** An optional peer that enables one SDK feature rather than one LLM provider. */ +export interface FeaturePeer { + /** The `peerDependencies` range; a test keeps it equal to package.json. */ + range: string; + /** What needs the package, as it reads in the error: " needs it". */ + feature: string; +} + +/** + * The optional peers behind SDK features (not providers; those live in + * providerSpec.ts). Also what `loushy doctor` lists as "what it enables". + */ +export const FEATURE_PEERS: Readonly> = { + dockerode: { range: '^5.0.1', feature: 'Docker sandboxing (SubprocessSandbox)' }, + '@modelcontextprotocol/sdk': { + range: '^1.30.1', + feature: 'MCP (serveMcp, `loushy mcp` and MCP client connections)', + }, + prompts: { + range: '^2.4.2', + feature: 'the interactive prompts of `loushy init` (pass --yes to skip them)', + }, +}; + +function installCommandFor(packageName: string): string { + const peer = FEATURE_PEERS[packageName]; + return peer ? `npm install ${packageName}@${peer.range}` : peerInstallCommand(packageName); +} + /** * Thrown on first use of a feature whose optional package is not installed. * @@ -28,13 +61,17 @@ export class MissingPeerDependencyError extends Error { readonly packageName: string, /** The command that installs it, e.g. `npm install @ai-sdk/openai@^0.0.42`. */ readonly installCommand: string, - options?: { cause?: unknown } + options?: { cause?: unknown; feature?: string } ) { super( - `The optional package '${packageName}' is not installed, but this feature needs it. Run: ${installCommand}`, - options + `The optional package '${packageName}' is not installed, but ${options?.feature ?? 'this feature'} needs it. Run: ${installCommand}`, + options?.cause === undefined ? undefined : { cause: options.cause } ); + this.feature = options?.feature; } + + /** The feature that needs the package, when known (every feature peer; not provider peers). */ + readonly feature?: string; } /** @@ -60,7 +97,10 @@ export async function loadOptionalPeer(packageName: string, importer: () => P return await importer(); } catch (error) { if (!isMissingPackage(error, packageName)) throw error; - throw new MissingPeerDependencyError(packageName, peerInstallCommand(packageName), { cause: error }); + throw new MissingPeerDependencyError(packageName, installCommandFor(packageName), { + cause: error, + feature: FEATURE_PEERS[packageName]?.feature, + }); } } diff --git a/src/tools/built-in/http.test.ts b/src/tools/built-in/http.test.ts index 71beafd36..4d8edba39 100644 --- a/src/tools/built-in/http.test.ts +++ b/src/tools/built-in/http.test.ts @@ -277,6 +277,27 @@ describe('makeHttpRequest', () => { } }); + it('uses the global fetch and never loads undici while TLS verification is on (LOU-D40)', async () => { + server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('plain'); + }); + const baseUrl = await listen(server); + vi.resetModules(); + vi.doMock('undici', () => { + throw new Error('undici must not be loaded for a default request'); + }); + try { + const { makeHttpRequest: fresh } = await import('./http'); + expect(await fresh({ url: baseUrl, method: 'GET' })).toBe('plain'); + expect(await fresh({ url: baseUrl, method: 'GET', options: { validateSSL: true } })).toBe('plain'); + await expect(fresh({ url: baseUrl, method: 'GET', options: { validateSSL: false } })).rejects.toThrow(/mocking a module|undici must not be loaded/); + } finally { + vi.doUnmock('undici'); + vi.resetModules(); + } + }); + it('rejects a self-signed certificate by default (validateSSL unset)', async () => { httpsServer = https.createServer({ cert, key }, (_req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); diff --git a/src/tools/built-in/http.ts b/src/tools/built-in/http.ts index 8804cadb1..721be0ad1 100644 --- a/src/tools/built-in/http.ts +++ b/src/tools/built-in/http.ts @@ -144,9 +144,10 @@ async function isBlockedHost(hostname: string): Promise { /** * A minimal fetch-response-shaped transport function that actually performs * the outbound request. `makeHttpRequest()` is transport-agnostic: the - * default transport calls undici's `fetch` directly (unchanged, original - * behavior); `makeHttpRequestViaSandbox()` below supplies a transport that - * routes the same request through a SandboxAdapter instead (LOU-K2). + * default transport calls `fetch` directly (undici's, only for + * `validateSSL: false`); `makeHttpRequestViaSandbox()` below supplies a + * transport that routes the same request through a SandboxAdapter instead + * (LOU-K2). */ type HttpTransport = ( url: string, @@ -160,20 +161,22 @@ type HttpTransport = ( ) => Promise; /** - * Default transport: the exact undici `fetch` + per-request TLS dispatcher - * behavior this file always had. Per-request TLS verification is scoped to - * a dedicated undici Agent (dispatcher) rather than the process-wide - * NODE_TLS_REJECT_UNAUTHORIZED env var, since the env var is global mutable - * state and toggling it around an await point would race under concurrent - * requests. + * Default transport. With TLS verification on (the default) it is the + * runtime's global `fetch`: nothing about the request needs `undici`, so the + * package is not even loaded (LOU-D40). Only `validateSSL: false` needs a + * per-request TLS setting, which is scoped to a dedicated undici Agent + * (dispatcher) rather than the process-wide NODE_TLS_REJECT_UNAUTHORIZED env + * var, since the env var is global mutable state and toggling it around an + * await point would race under concurrent requests. */ function createDirectTransport(validateSSL: boolean): { transport: HttpTransport; close: () => Promise } { + if (validateSSL) return { transport: (url, init) => fetch(url, init), close: async () => {} }; // undici is loaded on first request, not at import time (LOU-D19). let started = false; const load = lazyValue(async () => { const { Agent, fetch } = await loadOptionalPeer('undici', () => import('undici')); started = true; - return { fetch, dispatcher: new Agent({ connect: { rejectUnauthorized: validateSSL } }) }; + return { fetch, dispatcher: new Agent({ connect: { rejectUnauthorized: false } }) }; }); return { transport: async (url, init) => { diff --git a/tsup.config.ts b/tsup.config.ts index 83c5e7220..1e746a45f 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -47,7 +47,8 @@ export default defineConfig({ clean: true, treeshake: true, minify: false, - external: ['ai', 'zod', '@opentelemetry/api'], + // Optional peers (LOU-D40) stay external too: tsup would externalize them from package.json anyway. + external: ['ai', 'zod', '@opentelemetry/api', 'dockerode', '@modelcontextprotocol/sdk', 'prompts'], onSuccess: async () => { copyDevUi(); },