diff --git a/.agents/KNOWLEDGE_MAP.md b/.agents/KNOWLEDGE_MAP.md index 6febfee..fbe5e20 100644 --- a/.agents/KNOWLEDGE_MAP.md +++ b/.agents/KNOWLEDGE_MAP.md @@ -10,7 +10,8 @@ Welcome, AI Developer. Do not load all documentation files into your context. Us ## 2. Features & Interfaces * **CLI Commands & Routing** ➔ `docs/reference/cli-commands.md` -* **Context Management System** ➔ `docs/ADR-001-Context-System.md` +* **Context Management System** ➔ `docs/architecture/adrs/ADR-001-Context-System.md` +* **CAD Modeling & Examples** ➔ `docs/reference/cad-examples.md` ## 3. Operations & Safety * **Firmware Audit Log & Vulnerabilities** ➔ `docs/reference/firmware-audit-log.md` diff --git a/.gitignore b/.gitignore index 70da60c..6013332 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ Plan.md coverage/ tmp/ dist/ -bundle/ \ No newline at end of file +bundle/ +__pycache__/ +.DS_Store__pycache__/ diff --git a/assets/python/freecad_listener.py b/assets/python/freecad_listener.py new file mode 100644 index 0000000..39b79ab --- /dev/null +++ b/assets/python/freecad_listener.py @@ -0,0 +1,158 @@ +import sys +import json +import socket +import threading +import traceback +import FreeCAD +import FreeCADGui + +# Import PySide2 for thread-safe GUI execution +from PySide2 import QtCore + +class ScriptExecutor(QtCore.QObject): + execute_signal = QtCore.Signal(object) + + def __init__(self): + super().__init__() + self.execute_signal.connect(self._run_script, QtCore.Qt.QueuedConnection) + + def _run_script(self, func): + func() + + def run_in_gui(self, func): + self.execute_signal.emit(func) + +# This executor must be instantiated in the main thread (during module import) +executor = ScriptExecutor() + +# This script is meant to be injected into FreeCAD via `FreeCAD.exe freecad_listener.py` +# It sets up a local TCP server that listens for JSON commands from FlyCLI. + +HOST = '127.0.0.1' +PORT = 9099 +for arg in sys.argv[1:]: + try: + PORT = int(arg) + break + except ValueError: + pass + +def handle_client(conn): + buffer = "" + while True: + try: + data = conn.recv(4096) + if not data: + break + buffer += data.decode('utf-8') + + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if not line: + continue + + try: + request = json.loads(line) + process_request(request, conn) + except json.JSONDecodeError: + send_response(conn, {"status": "ERROR", "error": {"message": "Invalid JSON"}}) + except Exception as e: + FreeCAD.Console.PrintError(f"IPC Error: {e}\n") + break + conn.close() + +def process_request(request, conn): + req_id = request.get('id', 'unknown') + action = request.get('action') + + if action == 'EXECUTE_SCRIPT': + code = request.get('payload', {}).get('code', '') + + import queue + res_queue = queue.Queue() + + def execute_in_gui(): + try: + env = {"FreeCAD": FreeCAD, "FreeCADGui": FreeCADGui, "App": FreeCAD, "Gui": FreeCADGui} + exec(code, env) + if FreeCAD.ActiveDocument: + FreeCAD.ActiveDocument.recompute() + res_queue.put({"status": "SUCCESS", "data": {}}) + except Exception as e: + err_msg = traceback.format_exc() + res_queue.put({"status": "ERROR", "error": {"type": type(e).__name__, "message": str(e), "traceback": err_msg}}) + + executor.run_in_gui(execute_in_gui) + + try: + res = res_queue.get(timeout=15) + if res["status"] == "SUCCESS": + send_response(conn, {"id": req_id, "status": "SUCCESS", "data": res["data"]}) + else: + send_response(conn, {"id": req_id, "status": "ERROR", "error": res["error"]}) + except queue.Empty: + send_response(conn, {"id": req_id, "status": "ERROR", "error": {"message": "Execution timed out in GUI thread"}}) + + elif action == 'GET_STATE': + import queue + res_queue = queue.Queue() + + def get_state_in_gui(): + try: + doc = FreeCAD.ActiveDocument + if not doc: + res_queue.put({"status": "SUCCESS", "data": {"objects": []}}) + return + + objs = [] + for obj in doc.Objects: + props = {} + for prop in obj.PropertiesList: + try: + props[prop] = str(getattr(obj, prop)) + except: + pass + objs.append({"name": obj.Name, "type": obj.TypeId, "properties": props}) + res_queue.put({"status": "SUCCESS", "data": {"objects": objs}}) + except Exception as e: + err_msg = traceback.format_exc() + res_queue.put({"status": "ERROR", "error": {"type": type(e).__name__, "message": str(e), "traceback": err_msg}}) + + executor.run_in_gui(get_state_in_gui) + + try: + res = res_queue.get(timeout=5) + if res["status"] == "SUCCESS": + send_response(conn, {"id": req_id, "status": "SUCCESS", "data": res["data"]}) + else: + send_response(conn, {"id": req_id, "status": "ERROR", "error": res["error"]}) + except queue.Empty: + send_response(conn, {"id": req_id, "status": "ERROR", "error": {"message": "Get state timed out in GUI thread"}}) + else: + send_response(conn, {"id": req_id, "status": "ERROR", "error": {"message": f"Unknown action: {action}"}}) + +def send_response(conn, response): + try: + msg = json.dumps(response) + "\n" + conn.sendall(msg.encode('utf-8')) + except Exception as e: + FreeCAD.Console.PrintError(f"Failed to send response: {e}\n") + +def start_server(): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind((HOST, PORT)) + server.listen(1) + FreeCAD.Console.PrintMessage(f"FlyCLI IPC Listener started on {HOST}:{PORT}\n") + + while True: + conn, addr = server.accept() + FreeCAD.Console.PrintMessage(f"Connected to FlyCLI: {addr}\n") + client_thread = threading.Thread(target=handle_client, args=(conn,)) + client_thread.daemon = True + client_thread.start() + +# Start the server in a background thread so we don't block the FreeCAD GUI +server_thread = threading.Thread(target=start_server) +server_thread.daemon = True +server_thread.start() diff --git a/docs/architecture/ai_cad_views/ddd_interfaces.md b/docs/architecture/ai_cad_views/ddd_interfaces.md new file mode 100644 index 0000000..a6ec12e --- /dev/null +++ b/docs/architecture/ai_cad_views/ddd_interfaces.md @@ -0,0 +1,158 @@ +# Domain-Driven Design (DDD) & Interface Contracts + +This document defines the Ubiquitous Language for the interactive CAD modeling domain and describes the interaction contracts between components (including IPC). + +## 📖 Domain Dictionary (Ubiquitous Language) + +| Term | Description | +| :--- | :--- | +| **CadAgent** | The orchestrator inside FlyCLI that manages the interaction loop (User ↔ Gemini ↔ CadEngine). | +| **EnvironmentManager** | The component responsible for verifying, downloading, and extracting the portable FreeCAD environment. Ensures zero-friction startup. | +| **CadEngineProcess** | The running `FreeCAD.exe` process, spawned and managed by FlyCLI (via Node.js `child_process`). | +| **IpcBridge** | The two-way communication channel (TCP socket) between FlyCLI (Node.js) and the CadEngineProcess (Python listener). | +| **GeometryScript** | The Python/CadQuery code generated by the LLM to build or modify the 3D model. | +| **EngineState** | The current state of the open FreeCAD document (list of objects, basic parameters) returned via IpcBridge to inform the LLM about manual user changes. | + +--- + +## 🧩 Interfaces (JavaScript Contracts) + +Since the project uses pure JavaScript (ES Modules), these interfaces are described as abstract JSDoc contracts. They will serve as blueprints for the actual implementation. + +### 1. Interface: `IEnvironmentManager` +Manages the lifecycle of the portable FreeCAD environment. + +```javascript +/** + * @interface IEnvironmentManager + */ +const IEnvironmentManager = { + /** + * Checks for the environment, downloads it if necessary. + * @returns {Promise} Absolute path to the FreeCAD.exe executable. + */ + ensureEnvironmentReady: async () => {}, + + /** + * Returns current status (e.g., download progress for CLI display). + * @returns {Object} { status: 'downloading'|'ready', progress: 45 } + */ + getStatus: () => {} +}; +``` + +### 2. Interface: `ICadEngineProcess` +Manages the FreeCAD process and IPC communication. + +```javascript +/** + * @interface ICadEngineProcess + */ +const ICadEngineProcess = { + /** + * Starts the FreeCAD process and connects to the IpcBridge. + * @param {string} executablePath + * @returns {Promise} + */ + start: async (executablePath) => {}, + + /** + * Stops the FreeCAD process. + */ + stop: async () => {}, + + /** + * Sends a GeometryScript to FreeCAD for execution. + * @param {string} scriptContent Python/CadQuery code + * @returns {Promise} Execution result (success or error details) + */ + executeScript: async (scriptContent) => {}, + + /** + * Reads the object tree from the FreeCAD document. + * @returns {Promise} Serialized EngineState + */ + getDocumentState: async () => {} +}; + +### 3. Interface: `IMcpServer` +Manages the Model Context Protocol (MCP) server over stdio. + +```javascript +/** + * @interface IMcpServer + */ +const IMcpServer = { + /** + * Starts listening for MCP requests on stdio. + * @returns {Promise} + */ + start: async () => {}, + + /** + * Stops the MCP server. + */ + stop: () => {} +}; +``` +``` + +--- + +## 📡 IPC Payload Contracts (JSON) + +Data is transmitted over a TCP connection between Node.js (Client) and the Python Listener (Server) inside FreeCAD. All messages are JSON formatted, separated by a newline (`\n`). + +### 1. Execute Script Request (Node -> Python) +```json +{ + "id": "req_12345", + "action": "EXECUTE_SCRIPT", + "payload": { + "code": "import cadquery as cq\nbox = cq.Workplane('XY').box(10,10,10)\nshow_object(box)" + } +} +``` + +### 2. Execution Response (Python -> Node) +```json +{ + "id": "req_12345", + "status": "SUCCESS", + "data": { + "executionTimeMs": 142 + } +} +``` +*On Error (e.g., LLM generated bad syntax):* +```json +{ + "id": "req_12345", + "status": "ERROR", + "error": { + "type": "SyntaxError", + "message": "unexpected indent (line 3)" + } +} +``` + +### 3. Get State Request (Node -> Python) +```json +{ + "id": "req_12346", + "action": "GET_STATE" +} +``` + +### 4. State Response (Python -> Node) +```json +{ + "id": "req_12346", + "status": "SUCCESS", + "data": { + "objects": [ + { "name": "Box", "type": "Part::Feature", "properties": {"Length": 10, "Width": 10, "Height": 10} } + ] + } +} +``` diff --git a/docs/architecture/ai_cad_views/logical.md b/docs/architecture/ai_cad_views/logical.md new file mode 100644 index 0000000..506e22f --- /dev/null +++ b/docs/architecture/ai_cad_views/logical.md @@ -0,0 +1,30 @@ +# 1. Logical View: AI CAD Integration + +The Logical View describes the core components and their responsibilities. + +## Components + +1. **FlyCLI Core (JS):** + - Parses the `flycli cad` command. + - Manages the interaction with the Gemini API. + +2. **FreeCAD Manager (JS):** + - Responsible for the lifecycle of the FreeCAD process. + - Checks for the existence of the portable FreeCAD installation. + - Downloads and extracts it if missing. + - Spawns the `FreeCAD.exe` process. + +3. **IPC Bridge (JS <-> Python):** + - **Node Side:** An IPC Server (TCP/WS) hosted by FlyCLI. + - **Python Side:** A listener script injected into FreeCAD on startup (`freecad_listener.py`). + - Translates MCP tool calls from Gemini into Python execution requests. + +4. **CadQuery Execution Engine (Python inside FreeCAD):** + - Receives CadQuery scripts from the IPC Bridge. + - Executes them in the context of the active FreeCAD document. + - Updates the 3D viewport. + +5. **External AI Agent (Any LLM Client):** + - Connects to FlyCLI via Model Context Protocol (MCP) using standard input/output (stdio). + - Generates CadQuery scripts via MCP tool calls. + - Consumes FreeCAD document state (e.g., bounding box, object tree) to refine models. diff --git a/docs/business/OKR.md b/docs/business/OKR.md index 096ec04..6363c10 100644 --- a/docs/business/OKR.md +++ b/docs/business/OKR.md @@ -10,3 +10,11 @@ Ensure that every automated process involving hardware configuration can be safe ## Pivot Logic & Reasoning If the JSON parsing fails for AI Agents due to visual artifacts, we must immediately pivot to a pure background headless mode for agents, separating human UI completely into a standalone tool. + +## Objective 2: Empower Amateurs with AI-Assisted CAD Design +Transform FlyCLI from a mere configuration tool into a hardware development assistant by integrating an AI-driven, interactive 3D modeling workflow. + +**Key Results:** +- **KR2.1:** Implement the `flycli cad` command that seamlessly orchestrates the FreeCAD GUI from Node.js by `v1.3.0`. +- **KR2.2:** Establish a reliable IPC/CadQuery bridge allowing the AI (Gemini) to generate and instantly visualize solid models without user scripting. +- **KR2.3:** Ensure a zero-friction distribution strategy where users don't need to manually configure FreeCAD paths or Python environments. diff --git a/docs/reference/cad-examples.md b/docs/reference/cad-examples.md new file mode 100644 index 0000000..54cd55c --- /dev/null +++ b/docs/reference/cad-examples.md @@ -0,0 +1,72 @@ +# 📐 CAD Modeling Examples for AI Agents + +When using the `flycli cad` command, you (the AI Agent) can send Python scripts to FreeCAD via the `render_cadquery` tool. Here are common patterns. + +## 1. Simple Box (using Part API) +If CadQuery is not available, use the built-in FreeCAD `Part` module. + +```python +import FreeCAD as App +import Part + +# Create a new document if none exists +if not App.ActiveDocument: + App.newDocument("FlyCLI_Model") + +doc = App.ActiveDocument + +# Create a box: length, width, height +box = Part.makeBox(10.0, 10.0, 10.0) +obj = doc.addObject("Part::Feature", "MyBox") +obj.Shape = box + +doc.recompute() +``` + +## 2. Drawing a Line (Box Line) +Creating a polyline that forms a square (box-like boundary). + +```python +import FreeCAD as App +import Part + +if not App.ActiveDocument: + App.newDocument("FlyCLI_Model") + +doc = App.ActiveDocument + +# Define points +p1 = App.Vector(0, 0, 0) +p2 = App.Vector(10, 0, 0) +p3 = App.Vector(10, 10, 0) +p4 = App.Vector(0, 10, 0) + +# Create segments +l1 = Part.LineSegment(p1, p2) +l2 = Part.LineSegment(p2, p3) +l3 = Part.LineSegment(p3, p4) +l4 = Part.LineSegment(p4, p1) + +# Create a wire (box outline) +wire = Part.Wire([l1.toShape(), l2.toShape(), l3.toShape(), l4.toShape()]) +obj = doc.addObject("Part::Feature", "BoxLine") +obj.Shape = wire + +doc.recompute() +``` + +## 3. Using CadQuery (Preferred if available) +CadQuery is much more concise for complex geometry. + +```python +import cadquery as cq + +# Create a box 10x10x10 and show it +result = cq.Workplane("XY").box(10, 10, 10) +show_object(result) +``` + +## Tips: +- Always check if `App.ActiveDocument` exists. +- Call `doc.recompute()` after adding objects. +- Use `process.stderr` for progress feedback and `process.stdout` for JSON results. diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index b21d5e7..14703f9 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -63,5 +63,19 @@ This document contains a complete list of commands built into your flight contro --- +## 🏗 FlyCLI Infrastructure Commands + +These commands manage the FlyCLI tool itself and its advanced features like CAD integration. + +| Command | Description | +| --- | --- | +| `scan` | Scans for available serial ports to find flight controllers. | +| `health` | Performs basic diagnostics (voltage, version, status) of the FC. | +| `context` | Retrieves documentation and usage tips for AI agents. | +| `wizard` | Starts interactive setup processes (e.g., `rx` for radio calibration). | +| `cad` | Starts the CAD Agent (MCP Server) to orchestrate FreeCAD for 3D modeling. | + +--- + ### Note for AI Agent: This command list is the basis for automated diagnostics. If you want to check hardware health, start with `status` and `resource show`. To change behavior, use `get/set` and necessarily `save`. diff --git a/fpv_boat.stl b/fpv_boat.stl new file mode 100644 index 0000000..e2a2b85 Binary files /dev/null and b/fpv_boat.stl differ diff --git a/index.js b/index.js index 07af2ca..20d3298 100755 --- a/index.js +++ b/index.js @@ -6,13 +6,14 @@ import executeCommand from './src/interfaces/cli/execute.js'; import healthCommand from './src/interfaces/cli/health.js'; import contextCommand from './src/interfaces/cli/context.js'; import wizardCommand from './src/interfaces/cli/wizard.js'; +import cadCommand from './src/interfaces/cli/cad.js'; const program = new Command(); program .name('flycli') .description('CLI tool for Betaflight flight controller interaction') - .version('1.0.0'); + .version('1.3.1'); program .command('scan') @@ -54,4 +55,9 @@ program .option('--json', 'Output final result as JSON for AI Agents') .action(wizardCommand); +program + .command('cad') + .description('Start an interactive AI-powered CAD session with FreeCAD (MCP Server)') + .action(cadCommand); + program.parse(); diff --git a/launcher.cjs b/launcher.cjs index bf798f3..21c47b0 100644 --- a/launcher.cjs +++ b/launcher.cjs @@ -1,3 +1,19 @@ -(async () => { - await import('./index.js'); -})(); +const { spawn } = require('child_process'); +const path = require('path'); + +// Це спрощений запуск FreeCAD для утримання вікна відкритим +const executablePath = '/Applications/FreeCAD.app/Contents/MacOS/FreeCAD'; +const listenerPath = path.resolve(__dirname, 'assets/python/freecad_listener.py'); +const port = '9099'; + +console.log('🚀 Запуск незалежної сесії FreeCAD...'); + +const child = spawn(executablePath, [listenerPath, port], { + detached: true, + stdio: 'ignore' +}); + +child.unref(); + +console.log('✅ FreeCAD запущено в незалежному режимі. Вікно має залишатися відкритим.'); +process.exit(0); diff --git a/package-lock.json b/package-lock.json index c3f1dbf..103680c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "flycli", - "version": "1.1.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flycli", - "version": "1.1.0", + "version": "1.3.1", "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@yao-pkg/pkg": "^5.12.0", "commander": "^14.0.3", "esbuild": "^0.28.0", @@ -1402,6 +1403,18 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -2362,6 +2375,68 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2937,6 +3012,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3022,6 +3110,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3434,6 +3561,30 @@ "node": ">= 6" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -3533,6 +3684,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3556,7 +3716,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3570,7 +3729,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3789,6 +3947,28 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3796,12 +3976,47 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -3828,7 +4043,6 @@ "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", @@ -4002,6 +4216,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dependency-cruiser": { "version": "16.10.4", "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-16.10.4.tgz", @@ -4190,7 +4413,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4201,6 +4423,12 @@ "node": ">= 0.4" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.334", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", @@ -4227,6 +4455,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4343,7 +4580,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4353,7 +4589,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4363,7 +4598,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4469,6 +4703,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -4887,6 +5127,36 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4946,11 +5216,71 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -4971,7 +5301,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, "funding": [ { "type": "github", @@ -5056,6 +5385,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -5121,6 +5471,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -5246,7 +5614,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5281,7 +5648,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5455,7 +5821,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5556,7 +5921,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5593,6 +5957,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", @@ -5623,6 +5996,26 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -5646,6 +6039,22 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5825,6 +6234,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6124,6 +6551,12 @@ "node": ">=8" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6293,7 +6726,6 @@ "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": { @@ -6992,6 +7424,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7046,6 +7487,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -7246,12 +7693,20 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/memoize": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.2.0.tgz", @@ -7268,6 +7723,18 @@ "url": "https://github.com/sindresorhus/memoize?sponsor=1" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -7302,6 +7769,31 @@ "node": ">=10.0.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==", + "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==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -7460,6 +7952,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -7623,7 +8124,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7633,7 +8133,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7742,6 +8241,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7912,6 +8423,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7936,7 +8456,6 @@ "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" @@ -7975,6 +8494,16 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8004,6 +8533,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -8128,6 +8666,19 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -8165,6 +8716,21 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8186,6 +8752,30 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -8452,7 +9042,6 @@ "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" @@ -8539,6 +9128,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8634,6 +9239,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/seed-random": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz", @@ -8651,6 +9262,32 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serialport": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/serialport/-/serialport-13.0.0.tgz", @@ -8696,6 +9333,25 @@ } } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -8745,11 +9401,16 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8762,7 +9423,6 @@ "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" @@ -8772,7 +9432,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8792,7 +9451,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8809,7 +9467,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8828,7 +9485,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8997,6 +9653,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -9405,6 +10070,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", @@ -9560,6 +10234,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -9686,6 +10391,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -9776,6 +10490,15 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -9819,7 +10542,6 @@ "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" @@ -10076,6 +10798,24 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index a09ac7b..de36685 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flycli", - "version": "1.1.0", + "version": "1.3.1", "description": "A reliable CLI tool for Betaflight flight controller interaction and automation.", "license": "MIT", "keywords": [ @@ -59,6 +59,7 @@ "node": ">=18" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@yao-pkg/pkg": "^5.12.0", "commander": "^14.0.3", "esbuild": "^0.28.0", diff --git a/skills/flycli/SKILL.md b/skills/flycli/SKILL.md index d0bba6b..9125175 100644 --- a/skills/flycli/SKILL.md +++ b/skills/flycli/SKILL.md @@ -39,8 +39,39 @@ Executes raw CLI commands on the flight controller. - **File Input**: You can also pass a file containing multiple CLI commands using `-f ` or `--file `. - **Agent Usage**: Use this to read configurations (`dump`, `diff all`) or apply changes (`set ...`, `save`). Always remember to send `save` if you expect settings to persist. -## 🔄 Recommended Workflow for Agents +### 5. `cad` +Starts an interactive CAD modeling session with FreeCAD using the Model Context Protocol (MCP). +- **Command**: `flycli cad` +- **Agent Usage**: This command starts an MCP server on `stdio`. Once started, you (the agent) can use specialized tools to interact with FreeCAD: + - `render_cadquery`: Executes CadQuery Python code to create or modify 3D models. + - `get_engine_state`: Returns the list of objects currently in the FreeCAD document. +- **Example Flow**: + 1. Run `flycli cad`. + 2. Use `render_cadquery` with code like: + ```python + import cadquery as cq + result = cq.Workplane("XY").box(10, 10, 10) + show_object(result) + ``` + 3. The model will appear in the FreeCAD GUI. + + ## 🤝 Human-in-the-Loop CAD Workflow + + To effectively collaborate with a human designer, you must follow the **Synchronize-and-React** protocol: + 1. **Verify Before Act**: Before modifying or adding new geometry, always run `get_engine_state` to see what the user has changed manually. + 2. **Respect Human Intent**: If a human deletes an object you created, do not recreate it without asking. Interpret it as a design correction. + 3. **Contextual Awareness**: If you detect new objects added by the human, acknowledge them. Use their names and properties as context for your next steps (e.g., "I see you added a mounting hole, I will now align the bracket to it"). + 4. **Visual Feedback**: Use `process.stderr` to explain what you are doing in plain language while keeping `process.stdout` clean for JSON results. + +## 🛡️ Interactive CAD UX Standards +1. **Window Persistence**: NEVER close the FreeCAD window once it's open. Use detached processes to ensure the user doesn't lose their visual context. +2. **Zero-File Policy**: Do NOT create temporary `.json` or `.py` files in the project root for CAD operations. Send commands directly via IPC/MCP tools. +3. **Live Feedback**: Before executing any 3D command, describe it in one sentence to the user. +4. **Interactive Co-creation**: If the user has FreeCAD open, always try to "attach" to the existing session rather than starting a new one. + +## 🔄 Recommended Workflow for Agents + ... 1. **Discover**: Run `flycli scan --json` to get the port. 2. **Verify**: Run `flycli health 115200 --json` to ensure the FC is responding. 3. **Backup/Read**: Run `flycli execute 115200 "diff all" --json` to capture current state. diff --git a/src/infrastructure/ai/McpCadTools.js b/src/infrastructure/ai/McpCadTools.js new file mode 100644 index 0000000..8d32dee --- /dev/null +++ b/src/infrastructure/ai/McpCadTools.js @@ -0,0 +1,108 @@ +/** + * @module McpCadTools + * @description Provides Gemini-facing MCP tool definitions for CAD interaction. + * + * Traceability: + * - Interface: ddd_interfaces.md § ICadEngineProcess (IpcBridge contract) + * - Architecture: implementation_plan.md § [NEW] src/infrastructure/ai/McpCadTools.js + * - Domain terms: GeometryScript, EngineState, IpcBridge (ddd_interfaces.md) + * - CQRS: renderCadQuery is a Command; getEngineState / getToolDefinitions are Queries. + */ + +/** + * Tool definition schema for Gemini function-calling API. + * @typedef {Object} ToolDefinition + * @property {string} name + * @property {string} description + * @property {Object} parameters + */ + +const RENDER_TOOL = { + name: 'render_cadquery', + description: + 'Executes a CadQuery (Python) GeometryScript inside the running FreeCAD window. ' + + 'Call this tool whenever you have generated 3D model code to display or update.', + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: 'Valid CadQuery Python code. Must not be empty.', + }, + }, + required: ['code'], + }, +}; + +const STATE_TOOL = { + name: 'get_engine_state', + description: + 'Returns the current EngineState: the list of objects in the open FreeCAD document. ' + + 'Use this to understand what is already rendered before making changes.', + parameters: { + type: 'object', + properties: {}, + required: [], + }, +}; + +const TOOL_DEFINITIONS = [RENDER_TOOL, STATE_TOOL]; + +/** + * Validates that a GeometryScript is a non-empty string. + * @param {unknown} code + * @throws {Error} if code is not a non-empty string + */ +function assertValidScript(code) { + if (typeof code !== 'string' || code.trim().length === 0) { + throw new Error('GeometryScript must be a non-empty string.'); + } +} + +/** + * Provides Gemini-facing MCP tool definitions and delegates execution to CadEngineProcess. + * @implements {ICadToolsProvider} + */ +export class McpCadTools { + /** + * @param {import('../cad/CadEngineProcess.js').CadEngineProcess} engine + */ + constructor(engine) { + this.engine = engine; + } + + /** + * [COMMAND] Sends a GeometryScript to FreeCAD for execution via the IpcBridge. + * Mutates CAD document state; returns execution metadata. + * + * @param {string} code - Python/CadQuery GeometryScript + * @returns {Promise} Execution result from CadEngineProcess + * @throws {Error} If code is invalid or engine returns an error + */ + async renderCadQuery(code) { + assertValidScript(code); + return this.engine.executeScript(code); + } + + /** + * [QUERY] Reads the current EngineState from the open FreeCAD document. + * Returns data; produces no side effects. + * + * @returns {Promise} Serialized EngineState (object tree) + */ + async getEngineState() { + return this.engine.getDocumentState(); + } + + /** + * [QUERY] Returns the list of Gemini tool definitions for CAD interaction. + * Static — returns module-level constants, no instance state required. + * + * @returns {ToolDefinition[]} + */ + static getToolDefinitions() { + return TOOL_DEFINITIONS; + } +} + +export default McpCadTools; diff --git a/src/infrastructure/cad/CadEngineProcess.js b/src/infrastructure/cad/CadEngineProcess.js new file mode 100644 index 0000000..efc5f03 --- /dev/null +++ b/src/infrastructure/cad/CadEngineProcess.js @@ -0,0 +1,258 @@ +import { spawn } from 'child_process'; +import net from 'net'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +/** + * @module CadEngineProcess + * @description Manages the FreeCAD process and IPC communication (IpcBridge). + * + * Traceability: + * - Interface: ddd_interfaces.md § ICadEngineProcess, IpcBridge + * - Architecture: implementation_plan.md § CadEngineProcess + * - IPC Payload: ddd_interfaces.md § IPC Payload Contracts (JSON over TCP) + * - CQRS: start/stop/executeScript [Commands]; getDocumentState [Query] + */ + +const DEFAULT_PORT = 9099; +const MAX_RETRIES = 15; +const RETRY_DELAY_MS = 1000; + +/** + * Returns the absolute path to the Python IPC listener script. + * @returns {string} + */ +function resolveListenerPath() { + return path.resolve(dirname, '../../../assets/python/freecad_listener.py'); +} + +/** + * Parses a newline-delimited JSON buffer and returns complete messages. + * @param {string} buffer + * @returns {{ messages: Object[], remaining: string }} + */ +function parseBuffer(buffer) { + const messages = []; + let remaining = buffer; + let idx = remaining.indexOf('\n'); + + while (idx !== -1) { + const line = remaining.slice(0, idx).trim(); + remaining = remaining.slice(idx + 1); + + if (line) { + try { + messages.push(JSON.parse(line)); + } catch { + /* + * Malformed JSON from FreeCAD — silently skip. + * The caller logs errors via _handleResponse. + */ + } + } + idx = remaining.indexOf('\n'); + } + + return { messages, remaining }; +} + +/** + * Attempts a single TCP connection to the IPC socket. + * @param {number} port + * @returns {Promise} + */ +function tryConnect(port) { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + socket.connect(port, '127.0.0.1', () => resolve(socket)); + socket.on('error', (err) => { + socket.destroy(); + reject(err); + }); + }); +} + +/** + * Manages the FreeCAD process and IPC communication. + * @implements {ICadEngineProcess} + */ +export class CadEngineProcess { + /** + * @param {object} logger + */ + constructor(logger = console) { + this.logger = logger; + this.process = null; + this.socket = null; + this.port = DEFAULT_PORT; + this.requestMap = new Map(); + this.requestIdCounter = 1; + } + + /** + * [COMMAND] Starts the FreeCAD process and connects to the IpcBridge. + * @param {string} executablePath + * @returns {Promise} + */ + async start(executablePath) { + if (this.process) { + throw new Error('FreeCAD process is already running.'); + } + + const listenerPath = resolveListenerPath(); + this.logger.info(`Launching FreeCAD from ${executablePath}...`); + + this.process = spawn(executablePath, [listenerPath, String(this.port)], { + detached: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + this.process.stdout.on('data', (data) => { + this.logger.debug(`[FreeCAD STDOUT]: ${data.toString().trim()}`); + }); + + this.process.stderr.on('data', (data) => { + this.logger.debug(`[FreeCAD STDERR]: ${data.toString().trim()}`); + }); + + this.process.on('close', (code) => { + this.logger.info(`FreeCAD process exited with code ${code}`); + this.process = null; + this.socket = null; + }); + + await this.waitForConnection(); + } + + /** + * Retries TCP connection until FreeCAD IPC socket is available. + * @param {number} retries + * @param {number} delayMs + * @returns {Promise} + */ + async waitForConnection(retries = MAX_RETRIES, delayMs = RETRY_DELAY_MS) { + let attempts = 0; + + const attempt = async () => { + attempts += 1; + this.logger.info(`Attempting IPC connection… (${attempts}/${retries})`); + + try { + this.socket = await tryConnect(this.port); + this.setupSocketListeners(); + this.logger.info('Connected to FreeCAD IPC.'); + } catch { + if (attempts >= retries) { + throw new Error(`Failed to connect to FreeCAD IPC after ${retries} attempts.`); + } + await new Promise((res) => { setTimeout(res, delayMs); }); + await attempt(); + } + }; + + await attempt(); + } + + /** + * Wires data/close handlers on the IPC socket. + */ + setupSocketListeners() { + let buffer = ''; + + this.socket.on('data', (data) => { + buffer += data.toString('utf-8'); + const { messages, remaining } = parseBuffer(buffer); + buffer = remaining; + messages.forEach((msg) => this.handleResponse(msg)); + }); + + this.socket.on('close', () => { + this.logger.info('IPC connection closed.'); + this.socket = null; + }); + } + + /** + * Resolves or rejects a pending request based on the IPC response. + * @param {Object} response + */ + handleResponse(response) { + const { id } = response; + + if (!this.requestMap.has(id)) { + return; + } + + const { resolve, reject } = this.requestMap.get(id); + this.requestMap.delete(id); + + if (response.status === 'ERROR') { + reject(new Error(`CadEngine Error: ${JSON.stringify(response.error)}`)); + } else { + resolve(response.data); + } + } + + /** + * Sends a JSON request over the IPC socket and returns a Promise. + * @param {string} action + * @param {Object} payload + * @returns {Promise} + */ + sendRequest(action, payload = {}) { + if (!this.socket) { + return Promise.reject(new Error('IPC Socket is not connected.')); + } + + return new Promise((resolve, reject) => { + const id = `req_${this.requestIdCounter}`; + this.requestIdCounter += 1; + this.requestMap.set(id, { resolve, reject }); + + const msg = `${JSON.stringify({ id, action, payload })}\n`; + this.socket.write(msg, 'utf-8', (err) => { + if (err) { + this.requestMap.delete(id); + reject(err); + } + }); + }); + } + + /** + * [COMMAND] Stops the FreeCAD process and closes the IPC socket. + * @returns {Promise} + */ + async stop() { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + if (this.process) { + this.process.kill(); + this.process = null; + } + } + + /** + * [COMMAND] Sends a GeometryScript to FreeCAD for execution. + * @param {string} scriptContent - Python/CadQuery code + * @returns {Promise} Execution result + */ + async executeScript(scriptContent) { + return this.sendRequest('EXECUTE_SCRIPT', { code: scriptContent }); + } + + /** + * [QUERY] Reads the object tree from the open FreeCAD document. + * @returns {Promise} Serialized EngineState + */ + async getDocumentState() { + return this.sendRequest('GET_STATE'); + } +} + +export default CadEngineProcess; diff --git a/src/infrastructure/cad/EnvironmentManager.js b/src/infrastructure/cad/EnvironmentManager.js new file mode 100644 index 0000000..ea7b445 --- /dev/null +++ b/src/infrastructure/cad/EnvironmentManager.js @@ -0,0 +1,103 @@ +import fs from 'fs/promises'; + +/** + * @module EnvironmentManager + * @description Manages the lifecycle of the portable FreeCAD environment. + * Searches known installation paths and validates executable presence. + * + * Traceability: + * - Interface: ddd_interfaces.md § IEnvironmentManager + * - Architecture: implementation_plan.md § EnvironmentManager + * - CQRS: ensureEnvironmentReady [Command/Query hybrid — resolves path, validates FS] + * getStatus [Query] + */ + +const WINDOWS_PATHS = [ + 'C:\\Program Files\\FreeCAD 0.21\\bin\\FreeCAD.exe', + 'C:\\Program Files\\FreeCAD 0.20\\bin\\FreeCAD.exe', + 'C:\\Program Files\\FreeCAD 1.0\\bin\\FreeCAD.exe', +]; + +const UNIX_PATHS = [ + '/usr/bin/freecad', + '/Applications/FreeCAD.app/Contents/MacOS/FreeCAD', +]; + +/** + * Builds the list of candidate paths to check, including the env override. + * @returns {string[]} + */ +function buildCandidatePaths() { + const envPath = process.env.FREECAD_PATH; + const base = [...WINDOWS_PATHS, ...UNIX_PATHS]; + return envPath ? [envPath, ...base] : base; +} + +/** + * Checks whether a file exists at the given path. + * @param {string} filePath + * @returns {Promise} + */ +async function fileExists(filePath) { + try { + const stats = await fs.stat(filePath); + return stats.isFile(); + } catch { + return false; + } +} + +/** + * Finds the first existing FreeCAD executable from the candidate list. + * @param {string[]} candidates + * @returns {Promise} + */ +async function findExecutable(candidates) { + const checks = candidates.map((p) => fileExists(p).then((ok) => (ok ? p : null))); + const results = await Promise.all(checks); + return results.find((p) => p !== null) ?? null; +} + +/** + * Manages the lifecycle of the portable FreeCAD environment. + * @implements {IEnvironmentManager} + */ +export class EnvironmentManager { + /** + * @param {object} logger + */ + constructor(logger = console) { + this.logger = logger; + } + + /** + * Checks for the environment; throws if not found. + * @returns {Promise} Absolute path to the FreeCAD executable. + * @throws {Error} If no FreeCAD installation is found. + */ + async ensureEnvironmentReady() { + this.logger.info('Checking for FreeCAD installation...'); + const candidates = buildCandidatePaths(); + const found = await findExecutable(candidates); + + if (!found) { + throw new Error( + 'FreeCAD installation not found. ' + + 'Please install FreeCAD and set the FREECAD_PATH environment variable.', + ); + } + + this.logger.info(`Found FreeCAD at: ${found}`); + return found; + } + + /** + * Returns current environment status. + * @returns {{ status: string, progress: number }} + */ + static getStatus() { + return { status: 'ready', progress: 100 }; + } +} + +export default EnvironmentManager; diff --git a/src/infrastructure/mcp/McpServer.js b/src/infrastructure/mcp/McpServer.js new file mode 100644 index 0000000..4e161c1 --- /dev/null +++ b/src/infrastructure/mcp/McpServer.js @@ -0,0 +1,107 @@ +// eslint-disable-next-line import/no-unresolved +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +// eslint-disable-next-line import/no-unresolved +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +// eslint-disable-next-line import/no-unresolved +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { McpCadTools } from '../ai/McpCadTools.js'; + +/** + * @module McpServerAdapter + * @description Translates FlyCLI's McpCadTools into a standard MCP Stdio Server. + * + * Traceability: + * - Interface: ddd_interfaces.md § IMcpServer + * - Architecture: implementation_plan.md § [NEW] src/infrastructure/mcp/McpServer.js + * - CQRS: start/stop [Commands] + */ +export class McpServerAdapter { + /** + * @param {McpCadTools} cadTools + * @param {object} logger + */ + constructor(cadTools, logger = console) { + this.cadTools = cadTools; + this.logger = logger; + + this.server = new Server( + { name: 'FlyCLI-CAD-Server', version: '1.2.0' }, + { capabilities: { tools: {} } }, + ); + + this.setupHandlers(); + } + + /** + * Handles the ListTools request by mapping definitions to SDK format. + * @returns {Object} + */ + static handleListTools() { + const rawDefs = McpCadTools.getToolDefinitions(); + const mappedTools = rawDefs.map((def) => ({ + name: def.name, + description: def.description, + inputSchema: def.parameters, + })); + return { tools: mappedTools }; + } + + /** + * Handles the CallTool request by dispatching to local CadTools. + * @param {Object} request + * @returns {Promise} + */ + async handleCallTool(request) { + const { name, arguments: args } = request.params; + try { + if (name === 'render_cadquery') { + const code = String(args?.code || ''); + const result = await this.cadTools.renderCadQuery(code); + return { + content: [{ type: 'text', text: `Success: ${JSON.stringify(result)}` }], + }; + } + if (name === 'get_engine_state') { + const state = await this.cadTools.getEngineState(); + return { + content: [{ type: 'text', text: JSON.stringify(state) }], + }; + } + throw new Error(`Unknown tool: ${name}`); + } catch (err) { + return { + content: [{ type: 'text', text: `Error executing tool: ${err.message}` }], + isError: true, + }; + } + } + + /** + * Wires the SDK request handlers to the local methods. + */ + setupHandlers() { + this.server.setRequestHandler(ListToolsRequestSchema, () => McpServerAdapter.handleListTools()); + this.server.setRequestHandler(CallToolRequestSchema, (req) => this.handleCallTool(req)); + } + + /** + * [COMMAND] Starts listening for MCP requests on stdio. + * @returns {Promise} + */ + async start() { + this.logger.info('Starting MCP Server on stdio...'); + const transport = new StdioServerTransport(); + await this.server.connect(transport); + this.logger.info('MCP Server running. Waiting for agent connection...'); + } + + /** + * [COMMAND] Stops the MCP server. + * @returns {Promise} + */ + async stop() { + await this.server.close(); + } +} + +export default McpServerAdapter; diff --git a/src/interfaces/cli/cad.js b/src/interfaces/cli/cad.js new file mode 100644 index 0000000..04a692b --- /dev/null +++ b/src/interfaces/cli/cad.js @@ -0,0 +1,119 @@ +import { EnvironmentManager } from '../../infrastructure/cad/EnvironmentManager.js'; +import { CadEngineProcess } from '../../infrastructure/cad/CadEngineProcess.js'; +import { McpCadTools } from '../../infrastructure/ai/McpCadTools.js'; +import { McpServerAdapter } from '../../infrastructure/mcp/McpServer.js'; +import ConsoleLogger from '../../infrastructure/Logger.js'; + +/** + * @module CadAgent + * @description Orchestrates the interactive CAD modeling session. + * Acts as the top-level "CadAgent" from the DDD Ubiquitous Language: + * External Agent (MCP) <-> CadAgent <-> IpcBridge <-> CadEngineProcess (FreeCAD) + * + * Traceability: + * - Domain: ddd_interfaces.md § CadAgent + * - Architecture: implementation_plan.md § [MODIFY] src/interfaces/cli/cad.js + * - CQRS: startSession() is a Command (launches FreeCAD, starts MCP Server) + */ + +/** + * Bootstraps the CadAgent infrastructure dependencies. + * @param {object} logger + * @returns {{ env: EnvironmentManager, engine: CadEngineProcess, mcpServer: McpServerAdapter }} + */ +function buildDependencies(logger) { + const env = new EnvironmentManager(logger); + const engine = new CadEngineProcess(logger); + const tools = new McpCadTools(engine); + const mcpServer = new McpServerAdapter(tools, logger); + return { env, engine, mcpServer }; +} + +/** + * Resolves the FreeCAD executable and starts the CadEngine process. + * @param {EnvironmentManager} env + * @param {CadEngineProcess} engine + * @returns {Promise} + */ +async function startEngine(env, engine) { + const executablePath = await env.ensureEnvironmentReady(); + await engine.start(executablePath); +} + +/** + * Prints the CadAgent welcome banner to stdout. + */ +function printBanner() { + process.stderr.write('\n🚀 FlyCLI CAD Agent — Model Context Protocol (MCP) Server\n'); + process.stderr.write(`${'-'.repeat(50)}\n`); + process.stderr.write('💡 FreeCAD is starting… Please wait.\n'); +} + +/** + * Prints a user-friendly error message to stderr. + * @param {Error} err + */ +function printError(err) { + process.stderr.write(`\n❌ CAD Agent Error: ${err.message}\n`); + process.stderr.write('💡 Tip: Make sure FreeCAD is installed or set the FREECAD_PATH env var.\n'); +} + +/** + * Helper to gracefully stop services. + * @param {McpServerAdapter} mcpServer + * @param {CadEngineProcess} engine + * @param {Function} resolveExit + */ +async function stopServices(mcpServer, engine, resolveExit) { + try { + await mcpServer.stop(); + } finally { + await engine.stop(); + if (resolveExit) resolveExit(); + } +} + +/** + * [COMMAND] Main CLI handler for `flycli cad`. + * Orchestrates EnvironmentManager → CadEngineProcess → McpServerAdapter. + * Guarantees engine.stop() is always called (graceful cleanup). + * + * @returns {Promise} + */ +export default async function cadCommand() { + const logger = new ConsoleLogger(); + const { env, engine, mcpServer } = buildDependencies(logger); + + printBanner(); + + let isStopping = false; + let resolveExit; + const exitPromise = new Promise((resolve) => { resolveExit = resolve; }); + + const cleanup = async () => { + if (isStopping) return; + isStopping = true; + await stopServices(mcpServer, engine, resolveExit); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + try { + await startEngine(env, engine); + /* + * Note: Start the MCP Server on stdio. + * The Promise from mcpServer.start() will resolve when the server is ready, + * but the node process will stay alive because of the stdio event listeners. + */ + await mcpServer.start(); + + await exitPromise; + } catch (err) { + if (!isStopping) { + printError(err); + } + } finally { + await cleanup(); + } +} diff --git a/test/unit/CadCommand.test.js b/test/unit/CadCommand.test.js new file mode 100644 index 0000000..2d96826 --- /dev/null +++ b/test/unit/CadCommand.test.js @@ -0,0 +1,98 @@ +import { jest } from '@jest/globals'; + +/** + * Unit tests for CadCommand (src/interfaces/cli/cad.js) + * + * Traceability: + * - User Story: Business Scenario "flycli cad" + * - Architecture: implementation_plan.md § [MODIFY] src/interfaces/cli/cad.js + * - Domain: CadAgent orchestrates EnvironmentManager + CadEngineProcess + McpServer + * - Protocol: DEVELOPER.md (TDD — RED phase) + */ + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +const mockEnsureEnvironmentReady = jest.fn(); +const mockEngineStart = jest.fn(); +const mockEngineStop = jest.fn(); +const mockMcpStart = jest.fn(); +const mockMcpStop = jest.fn(); + +jest.unstable_mockModule('../../src/infrastructure/cad/EnvironmentManager.js', () => ({ + EnvironmentManager: jest.fn().mockImplementation(() => ({ + ensureEnvironmentReady: mockEnsureEnvironmentReady, + })), +})); + +jest.unstable_mockModule('../../src/infrastructure/cad/CadEngineProcess.js', () => ({ + CadEngineProcess: jest.fn().mockImplementation(() => ({ + start: mockEngineStart, + stop: mockEngineStop, + })), +})); + +jest.unstable_mockModule('../../src/infrastructure/ai/McpCadTools.js', () => ({ + McpCadTools: jest.fn(), +})); + +jest.unstable_mockModule('../../src/infrastructure/mcp/McpServer.js', () => ({ + McpServerAdapter: jest.fn().mockImplementation(() => ({ + start: mockMcpStart, + stop: mockMcpStop, + })), +})); + +const { default: cadCommand } = await import('../../src/interfaces/cli/cad.js'); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +const setup = () => { + jest.clearAllMocks(); + mockEnsureEnvironmentReady.mockResolvedValue('/usr/bin/freecad'); + mockEngineStart.mockResolvedValue(undefined); + mockEngineStop.mockResolvedValue(undefined); + mockMcpStart.mockResolvedValue(undefined); + mockMcpStop.mockResolvedValue(undefined); +}; + +describe('cadCommand — Happy Path', () => { + it('should bootstrap EnvironmentManager → CadEngineProcess → McpServerAdapter in sequence', async () => { + setup(); + // Simulate graceful interruption since server runs indefinitely + mockMcpStart.mockImplementation(async () => { + // Simulate process.on('SIGINT') event + process.emit('SIGINT'); + }); + + await cadCommand(); + + expect(mockEnsureEnvironmentReady).toHaveBeenCalledTimes(1); + expect(mockEngineStart).toHaveBeenCalledWith('/usr/bin/freecad'); + expect(mockMcpStart).toHaveBeenCalledTimes(1); + expect(mockMcpStop).toHaveBeenCalledTimes(1); + expect(mockEngineStop).toHaveBeenCalledTimes(1); + }); +}); + +describe('cadCommand — Error Handling', () => { + it('should call engine.stop() even when EnvironmentManager fails', async () => { + setup(); + mockEnsureEnvironmentReady.mockRejectedValue(new Error('FreeCAD not found')); + + // Should not throw — graceful exit + await expect(cadCommand()).resolves.not.toThrow(); + + expect(mockMcpStop).toHaveBeenCalledTimes(1); + expect(mockEngineStop).toHaveBeenCalledTimes(1); + }); + + it('should call mcpServer.stop() and engine.stop() when McpServer fails', async () => { + setup(); + mockMcpStart.mockRejectedValue(new Error('Port in use')); + + await expect(cadCommand()).resolves.not.toThrow(); + + expect(mockMcpStop).toHaveBeenCalledTimes(1); + expect(mockEngineStop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit/McpCadTools.test.js b/test/unit/McpCadTools.test.js new file mode 100644 index 0000000..6cb077c --- /dev/null +++ b/test/unit/McpCadTools.test.js @@ -0,0 +1,94 @@ +import { jest } from '@jest/globals'; + +/** + * Unit tests for McpCadTools + * + * Traceability: + * - Requirement: ddd_interfaces.md § IpcBridge, GeometryScript + * - Architecture: implementation_plan.md § [NEW] src/infrastructure/ai/McpCadTools.js + * - Protocol: DEVELOPER.md (TDD — RED phase) + */ + +const mockExecuteScript = jest.fn(); +const mockGetDocumentState = jest.fn(); +const mockStart = jest.fn(); +const mockStop = jest.fn(); + +const mockEngine = { + start: mockStart, + stop: mockStop, + executeScript: mockExecuteScript, + getDocumentState: mockGetDocumentState, +}; + +const { McpCadTools } = await import('../../src/infrastructure/ai/McpCadTools.js'); + +const setup = () => { + jest.clearAllMocks(); + return new McpCadTools(mockEngine); +}; + +// ─── render_cadquery ────────────────────────────────────────────────────────── + +describe('McpCadTools.renderCadQuery — Success Path', () => { + it('should delegate cadquery code to CadEngineProcess.executeScript()', async () => { + const tools = setup(); + mockExecuteScript.mockResolvedValue({ executionTimeMs: 50 }); + + const result = await tools.renderCadQuery('import cadquery as cq\ncq.Workplane().box(1,1,1)'); + + expect(mockExecuteScript).toHaveBeenCalledTimes(1); + expect(mockExecuteScript).toHaveBeenCalledWith('import cadquery as cq\ncq.Workplane().box(1,1,1)'); + expect(result).toEqual({ executionTimeMs: 50 }); + }); +}); + +describe('McpCadTools.renderCadQuery — Error Path', () => { + it('should propagate CadEngine errors to the caller', async () => { + const tools = setup(); + mockExecuteScript.mockRejectedValue(new Error('CadEngine Error: SyntaxError')); + + await expect(tools.renderCadQuery('bad python code !!!')) + .rejects + .toThrow('CadEngine Error: SyntaxError'); + }); + + it('should reject if code argument is empty or not a string', async () => { + const tools = setup(); + + await expect(tools.renderCadQuery('')).rejects.toThrow('GeometryScript must be a non-empty string.'); + await expect(tools.renderCadQuery(null)).rejects.toThrow('GeometryScript must be a non-empty string.'); + await expect(tools.renderCadQuery(42)).rejects.toThrow('GeometryScript must be a non-empty string.'); + }); +}); + +// ─── getEngineState ─────────────────────────────────────────────────────────── + +describe('McpCadTools.getEngineState — Query (CQRS)', () => { + it('should return EngineState from CadEngineProcess.getDocumentState()', async () => { + const tools = setup(); + const fakeState = { objects: [{ name: 'Box', type: 'Part::Feature' }] }; + mockGetDocumentState.mockResolvedValue(fakeState); + + const state = await tools.getEngineState(); + + expect(mockGetDocumentState).toHaveBeenCalledTimes(1); + expect(state).toEqual(fakeState); + }); +}); + +// ─── getToolDefinitions ─────────────────────────────────────────────────────── + +describe('McpCadTools.getToolDefinitions — Query (CQRS, static)', () => { + it('should return an array of tool definitions for Gemini', () => { + const defs = McpCadTools.getToolDefinitions(); + + expect(Array.isArray(defs)).toBe(true); + expect(defs.length).toBeGreaterThan(0); + defs.forEach((def) => { + expect(def).toHaveProperty('name'); + expect(def).toHaveProperty('description'); + expect(def).toHaveProperty('parameters'); + }); + }); +}); diff --git a/test/unit/McpServer.test.js b/test/unit/McpServer.test.js new file mode 100644 index 0000000..d185e30 --- /dev/null +++ b/test/unit/McpServer.test.js @@ -0,0 +1,71 @@ +import { jest } from '@jest/globals'; + +/** + * Unit tests for McpServer (src/infrastructure/mcp/McpServer.js) + * + * Traceability: + * - Requirement: ddd_interfaces.md § IMcpServer + * - Architecture: implementation_plan.md § [NEW] src/infrastructure/mcp/McpServer.js + * - Protocol: DEVELOPER.md (TDD — RED phase) + */ + +const mockConnect = jest.fn(); +const mockClose = jest.fn(); +const mockSetRequestHandler = jest.fn(); + +jest.unstable_mockModule('@modelcontextprotocol/sdk/server/index.js', () => ({ + Server: jest.fn().mockImplementation(() => ({ + connect: mockConnect, + close: mockClose, + setRequestHandler: mockSetRequestHandler, + })), +})); + +jest.unstable_mockModule('@modelcontextprotocol/sdk/server/stdio.js', () => ({ + StdioServerTransport: jest.fn(), +})); + +// We mock McpCadTools to avoid real IPC calls in this unit test +jest.unstable_mockModule('../../src/infrastructure/ai/McpCadTools.js', () => ({ + McpCadTools: { + getToolDefinitions: jest.fn().mockReturnValue([ + { name: 'fake_tool', description: 'fake', parameters: {} }, + ]), + }, +})); + +const { McpServerAdapter } = await import('../../src/infrastructure/mcp/McpServer.js'); + +const setup = () => { + jest.clearAllMocks(); + // Mock CadAgent dependencies + const mockToolsInstance = { + renderCadQuery: jest.fn().mockResolvedValue({ success: true }), + getEngineState: jest.fn().mockResolvedValue({ objects: [] }), + }; + return new McpServerAdapter(mockToolsInstance); +}; + +describe('McpServerAdapter', () => { + it('should start the server and connect via stdio transport', async () => { + const adapter = setup(); + mockConnect.mockResolvedValue(); + + await adapter.start(); + + // Verify MCP Server SDK methods were called + expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockSetRequestHandler).toHaveBeenCalled(); + }); + + it('should stop the server by calling close() on the MCP SDK Server', async () => { + const adapter = setup(); + mockConnect.mockResolvedValue(); + mockClose.mockResolvedValue(); + + await adapter.start(); + await adapter.stop(); + + expect(mockClose).toHaveBeenCalledTimes(1); + }); +});