diff --git a/CLAUDE.md b/CLAUDE.md index 5d013510..10f4ba72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,98 +1,175 @@ -# Testing Architecture +# CLAUDE.md -This directory contains comprehensive test coverage for CUI services. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Testing Philosophy +## Project Overview -- **Prefer real implementations** over mocks when testing (per project guidelines) -- **Comprehensive unit test coverage** for all services (90%+ target) -- **Mock Claude CLI** using `tests/__mocks__/claude` script for consistent testing -- **Silent logging** in tests (LOG_LEVEL=silent) to reduce noise +**cui-server** (Common Agent UI) is a web UI platform for AI agents powered by Claude Code SDK. It provides a modern web interface that manages Claude CLI processes and supports multi-model agentic workflows. -## Test Structure +## Essential Development Commands +```bash +# Development +npm run dev # Start backend + Vite dev server (port 3001) +npm run dev:web # Start Vite frontend dev server only + +# Building +npm run build # Build both backend and frontend (required before first test run) +npm run build:web # Build frontend only +npm run build:mcp # Build MCP server executable + +# Testing +npm test # Run all tests +npm run unit-tests # Run unit tests only (tests/unit/) +npm run integration-tests # Run integration tests only (tests/integration/) +npm run test:coverage # Run with coverage report (75% lines, 80% functions) +npm run test:watch # Watch mode for TDD +npm run test:debug # Enable debug logs during testing + +# Run specific test files or patterns +npm test -- claude-process-manager.test.ts +npm test -- tests/unit/ +npm test -- --testNamePattern="should start conversation" + +# Quality checks +npm run lint # ESLint checking +npm run typecheck # TypeScript type checking without emitting + +# Production +npm run start # Start production server (requires build first) ``` -tests/ -├── __mocks__ -│ └── claude -├── integration -│ ├── conversation-status-integration.test.ts -│ ├── real-claude-integration.test.ts -│ └── streaming-integration.test.ts -├── setup.ts -├── unit -│ ├── cui-server.test.ts -│ ├── claude-history-reader.test.ts -│ ├── claude-process-long-running.test.ts -│ ├── claude-process-manager.test.ts -│ ├── cli -│ │ ├── get.test.ts -│ │ ├── list.test.ts -│ │ ├── serve.test.ts -│ │ ├── status-simple.test.ts -│ │ ├── status-working.test.ts -│ │ └── status.test.ts -│ ├── conversation-status-tracker.test.ts -│ ├── json-lines-parser.test.ts -│ └── stream-manager.test.ts -└── utils - └── test-helpers.ts -``` -## Mock Claude CLI +## Architecture + +### Core Server Stack + +The application is built around a **single-port Express server** (default: 3001) that: +- Manages Claude CLI child processes via `ClaudeProcessManager` +- Streams real-time updates via Server-Sent Events (SSE) through `StreamManager` +- Handles permissions via MCP (Model Context Protocol) server integration +- Supports multi-model routing via `ClaudeRouterService` (optional) + +### Key Services (`src/services/`) + +**Process Management:** +- `ClaudeProcessManager`: Spawns/manages Claude CLI processes. Each conversation runs as a separate child process. Finds Claude executable from node_modules/.bin or PATH. +- `ClaudeRouterService`: Optional service that wraps `@musistudio/llms` to route requests to different LLM providers (OpenRouter, Ollama, etc.) + +**Streaming & Real-time:** +- `StreamManager`: Manages SSE connections for multiple concurrent conversations. Sends heartbeats every 30s to keep connections alive. +- `ConversationStatusManager`: Tracks active conversation states (working/idle/completed) + +**Data & Persistence:** +- `ClaudeHistoryReader`: Reads conversation history from `~/.claude/` directory +- `SessionInfoService`: Manages extended session metadata in `~/.cui/session-info.db` (SQLite) +- `ConversationCache`: Caches conversation data with 5-minute TTL + +**Permissions & Config:** +- `PermissionTracker`: Tracks tool permission requests from Claude CLI +- `MCPConfigGenerator`: Generates MCP config for cui-mcp-server integration +- `ConfigService`: Manages configuration from `~/.cui/config.json` with hot-reloading + +**Utilities:** +- `JsonLinesParser`: Parses newline-delimited JSON streams from Claude CLI +- `FileSystemService`: Handles file operations with gitignore support, file downloads, and file uploads (to conversation's `uploads/` directory) +- `ToolMetricsService`: Tracks tool usage metrics per conversation +- `NotificationService`: Push notifications (ntfy/web-push) +- `GeminiService`: Dictation via Gemini 2.5 Flash + +### Frontend (`src/web/`) + +- **chat/**: Main chat UI with React +- **hooks/**: useStreaming, useMultipleStreams, useConversationMessages +- **services/api.ts**: API client using fetch +- Built with Vite, React Router v6, Tailwind CSS, shadcn/ui components -The project includes a mock Claude CLI (`tests/__mocks__/claude`) that: -- Simulates real Claude CLI behavior for testing -- Outputs valid JSONL stream format -- Supports various command line arguments -- Enables testing without requiring actual Claude CLI installation +### API Routes (`src/routes/`) -## Testing Patterns +All routes under `/api/` except: +- `/api/permissions` - Before auth (MCP server needs access) +- `/api/notifications` - Before auth (service worker subscription) +**Key endpoints:** +- `POST /api/conversations` - Start new conversation +- `GET /api/conversations/:id` - Get conversation details +- `POST /api/conversations/:id/continue` - Continue conversation +- `DELETE /api/conversations/:id` - Stop/archive conversation +- `GET /api/stream/:streamingId` - SSE stream for real-time updates +- `GET /api/logs/stream` - Server log streaming +- `GET /api/filesystem/download` - Download single file from conversation +- `POST /api/filesystem/upload` - Upload files to conversation's uploads directory + +### Configuration + +All configuration and data stored in `~/.cui/`: +- `config.json` - Server settings, router config, notification config +- `session-info.db` - SQLite database for session metadata +- `mcp-config.json` - Generated MCP server configuration + +## Important Patterns + +### TypeScript Configuration + +This project uses **path aliases** with `@/` prefix: ```typescript -// Integration test pattern with mock Claude CLI -function getMockClaudeExecutablePath(): string { - return path.join(process.cwd(), 'tests', '__mocks__', 'claude'); -} - -// Server setup with random port to avoid conflicts -const serverPort = 9000 + Math.floor(Math.random() * 1000); -const server = new CUIServer({ port: serverPort }); - -// Override ProcessManager with mock path -const mockClaudePath = getMockClaudeExecutablePath(); -const { ClaudeProcessManager } = await import('@/services/claude-process-manager'); -(server as any).processManager = new ClaudeProcessManager(mockClaudePath); +import { ClaudeProcessManager } from '@/services/claude-process-manager.js'; ``` -## Test Configuration +Note: Import paths must include `.js` extension for ESM compatibility. -- **Vitest** for fast and modern testing with TypeScript support -- **Path mapping** using `@/` aliases matching source structure +### Testing Philosophy -## Test Commands +- **Prefer real implementations over mocks** when testing +- **Mock Claude CLI** using `tests/__mocks__/claude` script (outputs valid JSONL) +- **Silent logging** in tests: `LOG_LEVEL=silent` (set in tests/setup.ts) +- **Random ports** for server tests to avoid conflicts (9000 + random) +- **Vitest** with path aliases matching source structure -```bash -# Run specific test files -npm test -- claude-process-manager.test.ts -npm test -- tests/unit/ +### Process Lifecycle -# Run tests matching a pattern -npm test -- --testNamePattern="should start conversation" +1. `ClaudeProcessManager.startConversation()` spawns Claude CLI process +2. `JsonLinesParser` parses stdout as newline-delimited JSON +3. Messages forwarded to `StreamManager` which broadcasts via SSE +4. `ConversationStatusManager` tracks conversation state +5. On close: cleanup permissions, unregister session, close streams + +### MCP Integration + +- `MCPConfigGenerator` creates config pointing to `dist/mcp-server/index.js` +- Claude CLI automatically loads this MCP server when spawned +- MCP server communicates with cui-server via HTTP for permission requests +- Tests can skip MCP if generation fails (controlled by NODE_ENV) + +### Router Integration (Optional) -# Run unit tests only -npm run unit-tests +When `config.router.enabled` is true: +- `ClaudeRouterService` starts a local `@musistudio/llms` server on random port +- Intercepts Claude API requests and routes to configured providers +- Supports provider fallback, model routing, thinking mode +- Hot-reloads on configuration changes -# Run integration tests only -npm run integration-tests +### Error Handling -# Run with coverage -npm run test:coverage +Use `CUIError` class with error codes: +```typescript +throw new CUIError('CODE', 'Message', httpStatusCode); ``` -## Development Practices +Common codes: `MCP_CONFIG_REQUIRED`, `SERVER_INIT_FAILED`, `HTTP_SERVER_START_FAILED` + +## Development Gotchas + +1. **Must build before first test run**: `npm run build` (creates MCP executable) +2. **Don't use `npm run dev` during testing**: Tests use built artifacts +3. **Enable debug logs**: `LOG_LEVEL=debug npm run dev` +4. **ViteExpress only in dev**: Production serves static files from `dist/web/` +5. **Auth token**: Generated on startup, stored in config, can be overridden with `--token` or `--no-auth` + +## Code Style Requirements -- **Meaningful test names** and comprehensive test coverage -- **Silent logging** in tests (LOG_LEVEL=silent) to reduce noise -- **Random ports** for server tests to avoid conflicts -- **Proper cleanup** of resources and processes in tests \ No newline at end of file +- **Use strict TypeScript typing**: Avoid `any`, `undefined`, `unknown` +- **Use path aliases**: `@/services/...` not relative paths +- **Cleanup event listeners**: Especially in streaming logic +- **Never log secrets**: Auth tokens, API keys +- **Follow ESLint config**: Run `npm run lint` before committing +- **Proper error types**: Use `CUIError` with HTTP status codes diff --git a/package-lock.json b/package-lock.json index 5c28f150..2851ed4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,10 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@anthropic-ai/claude-code": "^1.0.70", - "@anthropic-ai/sdk": "^0.54.0", + "@anthropic-ai/claude-code": "^2.0.25", + "@anthropic-ai/sdk": "^0.67.0", "@google/genai": "^1.11.0", - "@modelcontextprotocol/sdk": "^1.17.0", + "@modelcontextprotocol/sdk": "^1.20.1", "@musistudio/llms": "^1.0.19", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", @@ -25,7 +25,9 @@ "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", + "@types/archiver": "^6.0.3", "@types/prismjs": "^1.26.5", + "archiver": "^7.0.1", "better-sqlite3": "^12.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -41,9 +43,9 @@ "pino": "^8.17.1", "prism-react-renderer": "^2.4.1", "prismjs": "^1.30.0", - "react": "^18.2.0", + "react": "^18.3.1", "react-diff-viewer-continued": "^3.4.0", - "react-dom": "^18.2.0", + "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "react-router-dom": "^6.22.0", "tailwind-merge": "^3.3.1", @@ -65,8 +67,8 @@ "@types/multer": "^1.4.13", "@types/node": "^20.19.1", "@types/node-fetch": "^2.6.12", - "@types/react": "^18.2.66", - "@types/react-dom": "^18.2.22", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", "@types/supertest": "^6.0.3", "@types/uuid": "^9.0.7", "@types/web-push": "^3.6.4", @@ -85,8 +87,8 @@ "tsc-alias": "^1.8.16", "tsx": "^4.6.2", "tw-animate-css": "^1.3.6", - "typescript": "^5.3.3", - "vite": "^7.0.6", + "typescript": "^5.8.3", + "vite": "^7.1.11", "vite-express": "^0.21.1", "vite-plugin-pwa": "^1.0.2", "vitest": "^3.2.4" @@ -112,9 +114,9 @@ } }, "node_modules/@anthropic-ai/claude-code": { - "version": "1.0.70", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.70.tgz", - "integrity": "sha512-gJ/bdT/XQ/hp5EKM0QoOWj/eKmK3wvs1TotTLq1unqahiB6B+EAQeRy/uvxv2Ua9nI8p5Bogw8hXB1uUmAHb+A==", + "version": "2.0.25", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.0.25.tgz", + "integrity": "sha512-5gooMB9DCLmzatQ+b2R0/pP2WxUSADcmpF77Qf3fIDpTf30UFreLXl2pe1exJ2kInsfiPK7PvDuJip5MDEv4CQ==", "license": "SEE LICENSE IN README.md", "bin": { "claude": "cli.js" @@ -132,10 +134,23 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.54.0", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.67.0.tgz", + "integrity": "sha512-Buxbf6jYJ+pPtfCgXe1pcFtZmdXPrbdqhBjiscFt9irS1G0hCsmR/fPA+DwKTk4GPjqeNnnCYNecXH6uVZ4G/A==", "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, "bin": { "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } } }, "node_modules/@apideck/better-ajv-errors": { @@ -2076,13 +2091,17 @@ }, "node_modules/@floating-ui/core": { "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.3", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.3", @@ -2090,10 +2109,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.5", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.3" + "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", @@ -2102,6 +2123,8 @@ }, "node_modules/@floating-ui/utils": { "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, "node_modules/@google/genai": { @@ -2388,7 +2411,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -2460,7 +2482,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.1", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz", + "integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==", "license": "MIT", "dependencies": { "ajv": "^6.12.6", @@ -2738,6 +2762,15 @@ "uuid": "^11.1.0" } }, + "node_modules/@musistudio/llms/node_modules/@anthropic-ai/sdk": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.54.0.tgz", + "integrity": "sha512-xyoCtHJnt/qg5GG6IgK+UJEndz8h8ljzt/caKXmq3LfBF81nC/BW6E4x2rOWCZcvsLyVW+e8U5mtIr6UCE/kJw==", + "license": "MIT", + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + }, "node_modules/@musistudio/llms/node_modules/gaxios": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.1.tgz", @@ -2879,7 +2912,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -2903,6 +2935,8 @@ }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.1.3" @@ -2980,6 +3014,8 @@ }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", @@ -3029,18 +3065,20 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.14", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", @@ -3062,6 +3100,51 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "license": "MIT", @@ -3076,10 +3159,12 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.10", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", @@ -3100,6 +3185,12 @@ } } }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.2", "license": "MIT", @@ -3115,6 +3206,8 @@ }, "node_modules/@radix-ui/react-focus-scope": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", @@ -3208,8 +3301,37 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", @@ -3240,6 +3362,8 @@ }, "node_modules/@radix-ui/react-portal": { "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.1.3", @@ -3262,6 +3386,8 @@ }, "node_modules/@radix-ui/react-presence": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", @@ -3284,6 +3410,8 @@ }, "node_modules/@radix-ui/react-primitive": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.2.3" @@ -3305,6 +3433,8 @@ }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", @@ -3373,6 +3503,33 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "license": "MIT", @@ -3476,6 +3633,33 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "license": "MIT", @@ -3524,6 +3708,8 @@ }, "node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", "license": "MIT", "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" @@ -3566,6 +3752,8 @@ }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", "license": "MIT", "dependencies": { "@radix-ui/rect": "1.1.1" @@ -3598,6 +3786,8 @@ }, "node_modules/@radix-ui/react-visually-hidden": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.1.3" @@ -3619,6 +3809,8 @@ }, "node_modules/@radix-ui/rect": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, "node_modules/@remix-run/router": { @@ -4240,6 +4432,15 @@ } } }, + "node_modules/@types/archiver": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz", + "integrity": "sha512-a6wUll6k3zX6qs5KlxIggs1P1JcYJaTCx2gnlr+f0S1yd2DoaEwoIK10HmBaLnZwWneBz+JBm0dwcZu0zECBcQ==", + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "dev": true, @@ -4481,6 +4682,8 @@ }, "node_modules/@types/multer": { "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", "dev": true, "license": "MIT", "dependencies": { @@ -4489,7 +4692,6 @@ }, "node_modules/@types/node": { "version": "20.19.9", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4527,6 +4729,8 @@ }, "node_modules/@types/prop-types": { "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/qs": { @@ -4540,7 +4744,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.23", + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -4549,12 +4755,23 @@ }, "node_modules/@types/react-dom": { "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "dev": true, @@ -4697,16 +4914,18 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/parser": { + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", + "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", - "debug": "^4.3.4" + "@typescript-eslint/utils": "8.38.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4720,8 +4939,39 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/project-service": { + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.38.0", + "@typescript-eslint/tsconfig-utils": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", "dev": true, "license": "MIT", "dependencies": { @@ -4740,26 +4990,35 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/scope-manager": { + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0" - }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", + "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", "dev": true, "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4768,18 +5027,26 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/type-utils": { + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", "dev": true, "license": "MIT", "dependencies": { + "@typescript-eslint/project-service": "8.38.0", + "@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "engines": { @@ -4790,24 +5057,101 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/types": { + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.38.0", + "@typescript-eslint/types": "^8.38.0", + "debug": "^4.3.4" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/typescript-estree": { + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.38.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4833,37 +5177,78 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "@typescript-eslint/tsconfig-utils": "^8.38.0", + "@typescript-eslint/types": "^8.38.0", + "debug": "^4.3.4" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion": { + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/@typescript-eslint/utils": { + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/scope-manager": { "version": "8.38.0", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0" + "@typescript-eslint/visitor-keys": "8.38.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4871,10 +5256,18 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.38.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@typescript-eslint/visitor-keys": { @@ -5185,51 +5578,187 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" + "node_modules/ansi-styles": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } } }, - "node_modules/anymatch": { - "version": "3.1.3", - "dev": true, - "license": "ISC", + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" }, "engines": { - "node": ">= 8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "dev": true, + "node_modules/archiver/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, - "node_modules/append-field": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "dev": true, @@ -5359,7 +5888,6 @@ }, "node_modules/async": { "version": "3.2.6", - "dev": true, "license": "MIT" }, "node_modules/async-function": { @@ -5517,9 +6045,22 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.1.tgz", + "integrity": "sha512-oxSAxTS1hRfnyit2CL5QpAOS5ixfBjj6ex3yTNvXyY/kE719jQ/IjuESJBK2w5v4wwQRAHGseVJXx9QBYOtFGQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, "node_modules/base64-js": { "version": "1.5.1", "funding": [ @@ -5749,6 +6290,15 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "license": "BSD-3-Clause" @@ -6026,7 +6576,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6037,7 +6586,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -6083,6 +6631,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "dev": true, @@ -6151,6 +6731,12 @@ "url": "https://opencollective.com/core-js" } }, + "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.5", "license": "MIT", @@ -6185,6 +6771,47 @@ "node": ">= 6" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "license": "MIT", @@ -6557,7 +7184,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { @@ -6594,7 +7220,6 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -7040,6 +7665,15 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "4.0.0", "license": "MIT", @@ -7167,6 +7801,12 @@ "version": "3.1.3", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "dev": true, @@ -7379,9 +8019,14 @@ } }, "node_modules/fdir": { - "version": "6.4.6", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -7571,7 +8216,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -7964,7 +8608,6 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -8504,7 +9147,6 @@ "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" @@ -8837,7 +9479,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -9005,6 +9646,19 @@ "dequal": "^2.0.3" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -9079,6 +9733,54 @@ "json-buffer": "3.0.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "dev": true, @@ -9231,7 +9933,6 @@ }, "node_modules/lodash": { "version": "4.17.21", - "dev": true, "license": "MIT" }, "node_modules/lodash.castarray": { @@ -10015,7 +10716,6 @@ }, "node_modules/minipass": { "version": "7.1.2", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -10064,6 +10764,8 @@ }, "node_modules/multer": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -10182,7 +10884,6 @@ }, "node_modules/normalize-path": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10356,7 +11057,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -10457,7 +11157,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -10474,7 +11173,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/path-to-regexp": { @@ -10706,6 +11404,12 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "3.0.0", "license": "MIT" @@ -10857,6 +11561,8 @@ }, "node_modules/react": { "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -10892,6 +11598,8 @@ }, "node_modules/react-dom": { "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -11037,6 +11745,36 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "dev": true, @@ -11435,6 +12173,8 @@ }, "node_modules/scheduler": { "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -11682,7 +12422,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -11855,6 +12594,17 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "license": "MIT", @@ -11866,7 +12616,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -11885,7 +12634,6 @@ "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", @@ -11900,14 +12648,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -12024,7 +12770,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -12041,7 +12786,6 @@ "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" @@ -12054,7 +12798,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -12398,6 +13141,29 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/thread-stream": { "version": "2.7.0", "license": "MIT", @@ -12420,12 +13186,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -12554,6 +13322,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "dev": true, @@ -12763,7 +13537,6 @@ }, "node_modules/undici-types": { "version": "6.21.0", - "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -13043,16 +13816,18 @@ } }, "node_modules/vite": { - "version": "7.0.6", + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.6", + "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", - "rollup": "^4.40.0", - "tinyglobby": "^0.2.14" + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" @@ -13718,7 +14493,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -13737,7 +14511,6 @@ "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", @@ -13755,7 +14528,6 @@ "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" @@ -13771,14 +14543,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -13793,7 +14563,6 @@ "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" @@ -13806,7 +14575,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -13896,6 +14664,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "3.25.76", "license": "MIT", diff --git a/package.json b/package.json index 62a7c3d1..2d7f51c4 100644 --- a/package.json +++ b/package.json @@ -54,10 +54,10 @@ "postinstall": "node scripts/postinstall.js" }, "dependencies": { - "@anthropic-ai/claude-code": "^1.0.70", - "@anthropic-ai/sdk": "^0.54.0", + "@anthropic-ai/claude-code": "^2.0.25", + "@anthropic-ai/sdk": "^0.67.0", "@google/genai": "^1.11.0", - "@modelcontextprotocol/sdk": "^1.17.0", + "@modelcontextprotocol/sdk": "^1.20.1", "@musistudio/llms": "^1.0.19", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", @@ -69,7 +69,9 @@ "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", + "@types/archiver": "^6.0.3", "@types/prismjs": "^1.26.5", + "archiver": "^7.0.1", "better-sqlite3": "^12.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -85,9 +87,9 @@ "pino": "^8.17.1", "prism-react-renderer": "^2.4.1", "prismjs": "^1.30.0", - "react": "^18.2.0", + "react": "^18.3.1", "react-diff-viewer-continued": "^3.4.0", - "react-dom": "^18.2.0", + "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "react-router-dom": "^6.22.0", "tailwind-merge": "^3.3.1", @@ -106,8 +108,8 @@ "@types/multer": "^1.4.13", "@types/node": "^20.19.1", "@types/node-fetch": "^2.6.12", - "@types/react": "^18.2.66", - "@types/react-dom": "^18.2.22", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", "@types/supertest": "^6.0.3", "@types/uuid": "^9.0.7", "@types/web-push": "^3.6.4", @@ -126,8 +128,8 @@ "tsc-alias": "^1.8.16", "tsx": "^4.6.2", "tw-animate-css": "^1.3.6", - "typescript": "^5.3.3", - "vite": "^7.0.6", + "typescript": "^5.8.3", + "vite": "^7.1.11", "vite-express": "^0.21.1", "vite-plugin-pwa": "^1.0.2", "vitest": "^3.2.4" diff --git a/src/cui-server.ts b/src/cui-server.ts index a8045462..3fac711b 100644 --- a/src/cui-server.ts +++ b/src/cui-server.ts @@ -173,7 +173,15 @@ export class CUIServer { // Apply overrides if provided (for tests and CLI options) this.port = this.configOverrides?.port ?? config.server.port; this.host = this.configOverrides?.host ?? config.server.host; - + + // Update file system service with config + if (config.server.maxDownloadSize !== undefined) { + this.fileSystemService.setMaxFileSize(config.server.maxDownloadSize); + this.logger.debug('FileSystemService max file size configured', { + maxDownloadSize: config.server.maxDownloadSize + }); + } + this.logger.info('Configuration loaded', { machineId: config.machine_id, port: this.port, @@ -481,9 +489,10 @@ export class CUIServer { this.statusTracker, this.sessionInfoService, this.conversationStatusManager, - this.toolMetricsService + this.toolMetricsService, + this.fileSystemService )); - this.app.use('/api/filesystem', createFileSystemRoutes(this.fileSystemService)); + this.app.use('/api/filesystem', createFileSystemRoutes(this.fileSystemService, this.historyReader)); this.app.use('/api/logs', createLogRoutes()); this.app.use('/api/stream', createStreamingRoutes(this.streamManager)); this.app.use('/api/working-directories', createWorkingDirectoriesRoutes(this.workingDirectoriesService)); diff --git a/src/routes/conversation.routes.ts b/src/routes/conversation.routes.ts index 4ecc3feb..3896cd1b 100644 --- a/src/routes/conversation.routes.ts +++ b/src/routes/conversation.routes.ts @@ -1,5 +1,7 @@ import { Router, Request } from 'express'; -import { +import * as path from 'path'; +import archiver from 'archiver'; +import { StartConversationRequest, StartConversationResponse, ConversationListQuery, @@ -11,13 +13,15 @@ import { SessionUpdateResponse, ConversationMessage, ConversationSummary, - SessionInfo + SessionInfo, + BulkDownloadFilesRequest } from '@/types/index.js'; import { RequestWithRequestId } from '@/types/express.js'; import { ClaudeProcessManager } from '@/services/claude-process-manager.js'; import { ClaudeHistoryReader } from '@/services/claude-history-reader.js'; import { SessionInfoService } from '@/services/session-info-service.js'; import { ConversationStatusManager } from '@/services/conversation-status-manager.js'; +import { FileSystemService } from '@/services/file-system-service.js'; import { createLogger } from '@/services/logger.js'; import { ToolMetricsService } from '@/services/ToolMetricsService.js'; @@ -27,7 +31,8 @@ export function createConversationRoutes( statusTracker: ConversationStatusManager, sessionInfoService: SessionInfoService, conversationStatusManager: ConversationStatusManager, - toolMetricsService: ToolMetricsService + toolMetricsService: ToolMetricsService, + fileSystemService: FileSystemService ): Router { const router = Router(); const logger = createLogger('ConversationRoutes'); @@ -549,20 +554,20 @@ export function createConversationRoutes( // Archive all sessions router.post('/archive-all', async (req: RequestWithRequestId, res, next) => { const requestId = req.requestId; - + logger.debug('Archive all sessions request', { requestId }); - + try { // Archive all sessions const archivedCount = await sessionInfoService.archiveAllSessions(); - + logger.info('All sessions archived successfully', { requestId, archivedCount }); - + res.json({ success: true, archivedCount, @@ -577,5 +582,150 @@ export function createConversationRoutes( } }); + // Bulk download files from conversation as ZIP + router.post('/:sessionId/download-files', async (req: Request<{ sessionId: string }, never, BulkDownloadFilesRequest> & RequestWithRequestId, res, next) => { + const requestId = req.requestId; + const { sessionId } = req.params; + const { files } = req.body; + + logger.debug('Bulk download files request', { + requestId, + sessionId, + fileCount: files?.length + }); + + try { + // Validate required parameters + if (!sessionId || sessionId.trim() === '') { + throw new CUIError('MISSING_SESSION_ID', 'sessionId is required', 400); + } + if (!files || !Array.isArray(files) || files.length === 0) { + throw new CUIError('MISSING_FILES', 'files array is required and must not be empty', 400); + } + + // Get conversation metadata to find the cwd + const metadata = await historyReader.getConversationMetadata(sessionId); + if (!metadata) { + throw new CUIError('CONVERSATION_NOT_FOUND', 'Conversation not found', 404); + } + + // Get the cwd from the first message of the conversation + const messages = await historyReader.fetchConversation(sessionId); + if (messages.length === 0) { + throw new CUIError('NO_MESSAGES', 'No messages found in conversation', 404); + } + + const conversationCwd = messages[0].cwd || metadata.projectPath; + if (!conversationCwd) { + throw new CUIError('NO_CWD', 'Could not determine conversation working directory', 400); + } + + // Normalize cwd for comparison + const normalizedCwd = path.normalize(conversationCwd); + + // Validate all file paths are within conversation's cwd + const invalidFiles: string[] = []; + for (const filePath of files) { + const normalizedPath = path.normalize(filePath); + if (!normalizedPath.startsWith(normalizedCwd)) { + invalidFiles.push(filePath); + } + } + + if (invalidFiles.length > 0) { + logger.warn('Bulk download attempt with files outside conversation cwd', { + requestId, + sessionId, + invalidFiles, + conversationCwd: normalizedCwd + }); + throw new CUIError( + 'FILES_OUTSIDE_CWD', + `Some files are outside the conversation working directory: ${invalidFiles.join(', ')}`, + 403 + ); + } + + // Create ZIP archive + const archive = archiver('zip', { + zlib: { level: 9 } // Maximum compression + }); + + // Set response headers for ZIP download + const zipFilename = `${sessionId}-files.zip`; + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${zipFilename}"`); + + // Pipe archive to response + archive.pipe(res); + + // Track successful and failed files + const successfulFiles: string[] = []; + const failedFiles: Array<{ path: string; error: string }> = []; + + // Add each file to the archive + for (const filePath of files) { + try { + const fileData = await fileSystemService.downloadFile(filePath); + + // Use relative path within ZIP + const relativePath = path.relative(normalizedCwd, filePath); + + archive.append(fileData.buffer, { name: relativePath }); + successfulFiles.push(filePath); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.warn('Failed to add file to ZIP', { + requestId, + sessionId, + filePath, + error: errorMsg + }); + failedFiles.push({ path: filePath, error: errorMsg }); + } + } + + // If no files were successfully added, return error + if (successfulFiles.length === 0) { + throw new CUIError( + 'NO_FILES_ADDED', + 'Failed to add any files to the archive', + 500 + ); + } + + // Add a metadata file if there were any failures + if (failedFiles.length > 0) { + const metadataContent = JSON.stringify({ + sessionId, + generatedAt: new Date().toISOString(), + successfulFiles, + failedFiles + }, null, 2); + archive.append(metadataContent, { name: '_download-metadata.json' }); + } + + // Finalize the archive + await archive.finalize(); + + logger.info('Bulk download completed', { + requestId, + sessionId, + totalFiles: files.length, + successfulFiles: successfulFiles.length, + failedFiles: failedFiles.length, + zipFilename + }); + + } catch (error) { + logger.debug('Bulk download files failed', { + requestId, + sessionId, + error: error instanceof Error ? error.message : String(error) + }); + next(error); + } + }); + return router; } \ No newline at end of file diff --git a/src/routes/filesystem.routes.ts b/src/routes/filesystem.routes.ts index ac2196ad..e81666db 100644 --- a/src/routes/filesystem.routes.ts +++ b/src/routes/filesystem.routes.ts @@ -1,21 +1,37 @@ import { Router, Request } from 'express'; -import { +import * as path from 'path'; +import multer from 'multer'; +import { CUIError, FileSystemListQuery, FileSystemListResponse, FileSystemReadQuery, - FileSystemReadResponse + FileSystemReadResponse, + FileSystemDownloadQuery, + FileSystemUploadQuery, + FileUploadResponse } from '@/types/index.js'; import { RequestWithRequestId } from '@/types/express.js'; import { FileSystemService } from '@/services/file-system-service.js'; +import { ClaudeHistoryReader } from '@/services/claude-history-reader.js'; import { createLogger } from '@/services/logger.js'; export function createFileSystemRoutes( - fileSystemService: FileSystemService + fileSystemService: FileSystemService, + historyReader: ClaudeHistoryReader ): Router { const router = Router(); const logger = createLogger('FileSystemRoutes'); + // Configure multer for file uploads (store in memory) + const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10MB limit + files: 10 // Max 10 files per request + } + }); + // Helper to strictly parse boolean query params (accepts "true"/"false" and booleans) const parseBooleanParam = (value: unknown, paramName: string): boolean | undefined => { if (value === undefined) return undefined; @@ -78,21 +94,21 @@ export function createFileSystemRoutes( requestId, path: req.query.path }); - + try { // Validate required parameters if (!req.query.path) { throw new CUIError('MISSING_PATH', 'path query parameter is required', 400); } - + const result = await fileSystemService.readFile(req.query.path); - + logger.debug('File read successfully', { requestId, path: result.path, size: result.size }); - + res.json(result); } catch (error) { logger.debug('Read file failed', { @@ -104,5 +120,306 @@ export function createFileSystemRoutes( } }); + // Download file (supports binary files, restricted to conversation cwd) + router.get('/download', async (req: Request, never, Record, FileSystemDownloadQuery> & RequestWithRequestId, res, next) => { + const requestId = req.requestId; + logger.debug('Download file request', { + requestId, + path: req.query.path, + sessionId: req.query.sessionId + }); + + try { + // Validate required parameters + if (!req.query.path) { + throw new CUIError('MISSING_PATH', 'path query parameter is required', 400); + } + if (!req.query.sessionId) { + throw new CUIError('MISSING_SESSION_ID', 'sessionId query parameter is required', 400); + } + + // Get conversation metadata to find the cwd + const metadata = await historyReader.getConversationMetadata(req.query.sessionId); + if (!metadata) { + throw new CUIError('CONVERSATION_NOT_FOUND', 'Conversation not found', 404); + } + + // Get the cwd from the first message of the conversation + const messages = await historyReader.fetchConversation(req.query.sessionId); + if (messages.length === 0) { + throw new CUIError('NO_MESSAGES', 'No messages found in conversation', 404); + } + + const conversationCwd = messages[0].cwd || metadata.projectPath; + if (!conversationCwd) { + throw new CUIError('NO_CWD', 'Could not determine conversation working directory', 400); + } + + // Normalize paths for comparison + const normalizedCwd = path.normalize(conversationCwd); + const normalizedPath = path.normalize(req.query.path); + + // Security check: Ensure requested path is within conversation's cwd + if (!normalizedPath.startsWith(normalizedCwd)) { + logger.warn('Download attempt outside conversation cwd', { + requestId, + sessionId: req.query.sessionId, + requestedPath: normalizedPath, + conversationCwd: normalizedCwd + }); + throw new CUIError( + 'PATH_OUTSIDE_CWD', + 'Download is restricted to files within the conversation working directory', + 403 + ); + } + + // Download the file + const fileData = await fileSystemService.downloadFile(req.query.path); + + // Set response headers for download + res.setHeader('Content-Type', fileData.mimeType); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileData.filename)}"`); + res.setHeader('Content-Length', fileData.size); + res.setHeader('Last-Modified', new Date(fileData.lastModified).toUTCString()); + + logger.debug('File download successful', { + requestId, + path: req.query.path, + filename: fileData.filename, + size: fileData.size, + mimeType: fileData.mimeType, + sessionId: req.query.sessionId, + cwd: normalizedCwd + }); + + // Send the file buffer + res.send(fileData.buffer); + } catch (error) { + logger.debug('Download file failed', { + requestId, + path: req.query.path, + sessionId: req.query.sessionId, + error: error instanceof Error ? error.message : String(error) + }); + next(error); + } + }); + + // List uploaded files in the conversation's uploads directory + router.get('/uploads', async (req: Request, never, Record, { sessionId: string }> & RequestWithRequestId, res, next) => { + const requestId = req.requestId; + logger.debug('List uploads request', { + requestId, + sessionId: req.query.sessionId + }); + + try { + // Validate required parameters + if (!req.query.sessionId) { + throw new CUIError('MISSING_SESSION_ID', 'sessionId query parameter is required', 400); + } + + // Get conversation metadata to find the cwd + const metadata = await historyReader.getConversationMetadata(req.query.sessionId); + if (!metadata) { + throw new CUIError('CONVERSATION_NOT_FOUND', 'Conversation not found', 404); + } + + // Get the cwd from the first message of the conversation + const messages = await historyReader.fetchConversation(req.query.sessionId); + if (messages.length === 0) { + throw new CUIError('NO_MESSAGES', 'No messages found in conversation', 404); + } + + const conversationCwd = messages[0].cwd || metadata.projectPath; + if (!conversationCwd) { + throw new CUIError('NO_CWD', 'Could not determine conversation working directory', 400); + } + + // Get the uploads directory path + const uploadsPath = path.join(conversationCwd, 'uploads'); + + // Check if uploads directory exists + try { + await fileSystemService.listDirectory(uploadsPath, false, false); + } catch (error) { + // If directory doesn't exist, return empty list + if (error instanceof CUIError && error.code === 'PATH_NOT_FOUND') { + logger.debug('Uploads directory does not exist', { + requestId, + sessionId: req.query.sessionId, + uploadsPath + }); + res.json({ files: [] }); + return; + } + throw error; + } + + // List files in uploads directory + const result = await fileSystemService.listDirectory(uploadsPath, false, false); + + // Filter to only include files (not directories) + const files = result.entries + .filter(entry => entry.type === 'file') + .map(entry => ({ + name: entry.name, + path: path.join(uploadsPath, entry.name), + size: entry.size, + lastModified: entry.lastModified + })); + + logger.debug('Uploads listed successfully', { + requestId, + sessionId: req.query.sessionId, + fileCount: files.length + }); + + res.json({ files }); + } catch (error) { + logger.debug('List uploads failed', { + requestId, + sessionId: req.query.sessionId, + error: error instanceof Error ? error.message : String(error) + }); + next(error); + } + }); + + // Upload files (restricted to conversation cwd/uploads directory) + router.post('/upload', upload.array('files'), async (req: Request, FileUploadResponse, Record, FileSystemUploadQuery> & RequestWithRequestId, res, next) => { + const requestId = req.requestId; + logger.debug('Upload file request', { + requestId, + sessionId: req.query.sessionId, + fileCount: req.files ? (req.files as Express.Multer.File[]).length : 0 + }); + + try { + // Validate required parameters + if (!req.query.sessionId) { + throw new CUIError('MISSING_SESSION_ID', 'sessionId query parameter is required', 400); + } + + // Check if files were uploaded + if (!req.files || !Array.isArray(req.files) || req.files.length === 0) { + throw new CUIError('NO_FILES', 'No files were uploaded', 400); + } + + // Get conversation metadata to find the cwd + const metadata = await historyReader.getConversationMetadata(req.query.sessionId); + if (!metadata) { + throw new CUIError('CONVERSATION_NOT_FOUND', 'Conversation not found', 404); + } + + // Get the cwd from the first message of the conversation + const messages = await historyReader.fetchConversation(req.query.sessionId); + if (messages.length === 0) { + throw new CUIError('NO_MESSAGES', 'No messages found in conversation', 404); + } + + const conversationCwd = messages[0].cwd || metadata.projectPath; + if (!conversationCwd) { + throw new CUIError('NO_CWD', 'Could not determine conversation working directory', 400); + } + + // Ensure uploads directory exists + const uploadsDir = await fileSystemService.ensureUploadsDirectory(conversationCwd); + + // Use custom destination path if provided, but ensure it's within cwd + let destinationPath = uploadsDir; + if (req.query.destinationPath) { + // If custom path is provided, it should be relative to cwd + const customPath = path.isAbsolute(req.query.destinationPath) + ? req.query.destinationPath + : path.join(conversationCwd, req.query.destinationPath); + + const normalizedCwd = path.normalize(conversationCwd); + const normalizedCustomPath = path.normalize(customPath); + + // Security check: Ensure custom path is within conversation's cwd + if (!normalizedCustomPath.startsWith(normalizedCwd)) { + logger.warn('Upload attempt outside conversation cwd', { + requestId, + sessionId: req.query.sessionId, + requestedPath: normalizedCustomPath, + conversationCwd: normalizedCwd + }); + throw new CUIError( + 'PATH_OUTSIDE_CWD', + 'Upload is restricted to paths within the conversation working directory', + 403 + ); + } + + destinationPath = normalizedCustomPath; + } + + // Upload all files + const uploadedFiles: FileUploadResponse['files'] = []; + const errors: FileUploadResponse['errors'] = []; + + for (const file of req.files as Express.Multer.File[]) { + try { + const result = await fileSystemService.uploadFile( + destinationPath, + file.buffer, + file.originalname + ); + + uploadedFiles.push({ + originalName: file.originalname, + uploadedPath: result.path, + size: result.size + }); + + logger.debug('File uploaded successfully', { + requestId, + originalName: file.originalname, + uploadedPath: result.path, + size: result.size, + sessionId: req.query.sessionId + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('File upload failed', error, { + requestId, + filename: file.originalname, + sessionId: req.query.sessionId + }); + + errors.push({ + filename: file.originalname, + error: errorMessage + }); + } + } + + // Prepare response + const response: FileUploadResponse = { + success: uploadedFiles.length > 0, + files: uploadedFiles, + errors: errors.length > 0 ? errors : undefined + }; + + logger.debug('Upload request completed', { + requestId, + sessionId: req.query.sessionId, + successCount: uploadedFiles.length, + errorCount: errors.length + }); + + res.status(uploadedFiles.length > 0 ? 200 : 400).json(response); + } catch (error) { + logger.debug('Upload request failed', { + requestId, + sessionId: req.query.sessionId, + error: error instanceof Error ? error.message : String(error) + }); + next(error); + } + }); + return router; } \ No newline at end of file diff --git a/src/services/file-system-service.ts b/src/services/file-system-service.ts index 47d66c25..fb8d5187 100644 --- a/src/services/file-system-service.ts +++ b/src/services/file-system-service.ts @@ -17,7 +17,7 @@ export class FileSystemService { private logger: Logger; private maxFileSize: number = 10 * 1024 * 1024; // 10MB default private allowedBasePaths: string[] = []; // Empty means all paths allowed - + constructor(maxFileSize?: number, allowedBasePaths?: string[]) { this.logger = createLogger('FileSystemService'); if (maxFileSize !== undefined) { @@ -28,6 +28,14 @@ export class FileSystemService { } } + /** + * Update the maximum file size limit + */ + setMaxFileSize(maxFileSize: number): void { + this.maxFileSize = maxFileSize; + this.logger.debug('Max file size updated', { maxFileSize }); + } + /** * List directory contents with security checks */ @@ -101,39 +109,39 @@ export class FileSystemService { */ async readFile(requestedPath: string): Promise<{ path: string; content: string; size: number; lastModified: string; encoding: string }> { this.logger.debug('Read file requested', { requestedPath }); - + try { // Validate and normalize path const safePath = await this.validatePath(requestedPath); - + // Check if path exists and is a file const stats = await fs.stat(safePath); if (!stats.isFile()) { throw new CUIError('NOT_A_FILE', `Path is not a file: ${requestedPath}`, 400); } - + // Check file size if (stats.size > this.maxFileSize) { throw new CUIError( - 'FILE_TOO_LARGE', - `File size (${stats.size} bytes) exceeds maximum allowed size (${this.maxFileSize} bytes)`, + 'FILE_TOO_LARGE', + `File size (${stats.size} bytes) exceeds maximum allowed size (${this.maxFileSize} bytes)`, 400 ); } - + // Read file content const content = await fs.readFile(safePath, 'utf-8'); - + // Check if content is valid UTF-8 text if (!this.isValidUtf8(content)) { throw new CUIError('BINARY_FILE', 'File appears to be binary or not valid UTF-8', 400); } - - this.logger.debug('File read successfully', { - path: safePath, - size: stats.size + + this.logger.debug('File read successfully', { + path: safePath, + size: stats.size }); - + return { path: safePath, content, @@ -145,19 +153,300 @@ export class FileSystemService { if (error instanceof CUIError) { throw error; } - + const errorCode = (error as NodeJS.ErrnoException).code; if (errorCode === 'ENOENT') { throw new CUIError('FILE_NOT_FOUND', `File not found: ${requestedPath}`, 404); } else if (errorCode === 'EACCES') { throw new CUIError('ACCESS_DENIED', `Access denied to file: ${requestedPath}`, 403); } - + this.logger.error('Error reading file', error, { requestedPath }); throw new CUIError('READ_FILE_FAILED', `Failed to read file: ${error}`, 500); } } + /** + * Download file with security checks (supports both text and binary files) + * Returns file buffer, mime type, and metadata for download + */ + async downloadFile(requestedPath: string): Promise<{ + buffer: Buffer; + mimeType: string; + filename: string; + size: number; + lastModified: string; + }> { + this.logger.debug('Download file requested', { requestedPath }); + + try { + // Validate and normalize path + const safePath = await this.validatePath(requestedPath); + + // Check if path exists and is a file + const stats = await fs.stat(safePath); + if (!stats.isFile()) { + throw new CUIError('NOT_A_FILE', `Path is not a file: ${requestedPath}`, 400); + } + + // Check file size + if (stats.size > this.maxFileSize) { + throw new CUIError( + 'FILE_TOO_LARGE', + `File size (${stats.size} bytes) exceeds maximum allowed size (${this.maxFileSize} bytes)`, + 400 + ); + } + + // Read file as buffer (supports both text and binary) + const buffer = await fs.readFile(safePath); + + // Determine MIME type based on file extension + const mimeType = this.getMimeType(safePath); + + // Get filename from path + const filename = path.basename(safePath); + + this.logger.debug('File download prepared', { + path: safePath, + size: stats.size, + mimeType, + filename + }); + + return { + buffer, + mimeType, + filename, + size: stats.size, + lastModified: stats.mtime.toISOString() + }; + } catch (error) { + if (error instanceof CUIError) { + throw error; + } + + const errorCode = (error as NodeJS.ErrnoException).code; + if (errorCode === 'ENOENT') { + throw new CUIError('FILE_NOT_FOUND', `File not found: ${requestedPath}`, 404); + } else if (errorCode === 'EACCES') { + throw new CUIError('ACCESS_DENIED', `Access denied to file: ${requestedPath}`, 403); + } + + this.logger.error('Error downloading file', error, { requestedPath }); + throw new CUIError('DOWNLOAD_FILE_FAILED', `Failed to download file: ${error}`, 500); + } + } + + /** + * Ensure uploads directory exists within the given session CWD + * Creates the directory if it doesn't exist + */ + async ensureUploadsDirectory(sessionCwd: string): Promise { + this.logger.debug('Ensuring uploads directory exists', { sessionCwd }); + + try { + // Validate the session CWD path + const safeCwd = await this.validatePath(sessionCwd); + + // Create uploads path within the session CWD + const uploadsPath = path.join(safeCwd, 'uploads'); + + // Check if uploads directory already exists + try { + const stats = await fs.stat(uploadsPath); + if (stats.isDirectory()) { + this.logger.debug('Uploads directory already exists', { uploadsPath }); + return uploadsPath; + } else { + throw new CUIError('NOT_A_DIRECTORY', 'uploads path exists but is not a directory', 400); + } + } catch (error) { + const errorCode = (error as NodeJS.ErrnoException).code; + if (errorCode !== 'ENOENT') { + throw error; + } + // Directory doesn't exist, create it + } + + // Create the uploads directory + await fs.mkdir(uploadsPath, { recursive: true }); + + this.logger.debug('Uploads directory created successfully', { uploadsPath }); + return uploadsPath; + } catch (error) { + if (error instanceof CUIError) { + throw error; + } + + this.logger.error('Error ensuring uploads directory', error, { sessionCwd }); + throw new CUIError('CREATE_UPLOADS_DIR_FAILED', `Failed to create uploads directory: ${error}`, 500); + } + } + + /** + * Upload a file to the specified destination path with security checks + * Handles duplicate filenames by appending timestamps + */ + async uploadFile( + destinationPath: string, + buffer: Buffer, + filename: string + ): Promise<{ path: string; size: number }> { + this.logger.debug('Upload file requested', { destinationPath, filename, size: buffer.length }); + + try { + // Validate filename doesn't contain path separators or invalid characters + const baseFilename = path.basename(filename); + if (baseFilename !== filename) { + throw new CUIError('INVALID_FILENAME', 'Filename must not contain path separators', 400); + } + + // Check for null bytes in filename + if (filename.includes('\u0000')) { + throw new CUIError('INVALID_FILENAME', 'Filename contains null bytes', 400); + } + + // Check for invalid characters in filename + if (/[<>:|?*]/.test(filename)) { + throw new CUIError('INVALID_FILENAME', 'Filename contains invalid characters', 400); + } + + // Reject hidden files (starting with .) + if (filename.startsWith('.')) { + throw new CUIError('INVALID_FILENAME', 'Hidden files are not allowed', 400); + } + + // Check file size + if (buffer.length > this.maxFileSize) { + throw new CUIError( + 'FILE_TOO_LARGE', + `File size (${buffer.length} bytes) exceeds maximum allowed size (${this.maxFileSize} bytes)`, + 413 + ); + } + + // Validate destination path + const safeDestPath = await this.validatePath(destinationPath); + + // Ensure destination is a directory + const stats = await fs.stat(safeDestPath); + if (!stats.isDirectory()) { + throw new CUIError('NOT_A_DIRECTORY', `Destination path is not a directory: ${destinationPath}`, 400); + } + + // Construct final file path + let finalPath = path.join(safeDestPath, filename); + + // Handle duplicate filenames by appending timestamp + if (existsSync(finalPath)) { + const ext = path.extname(filename); + const nameWithoutExt = path.basename(filename, ext); + const timestamp = Date.now(); + const newFilename = `${nameWithoutExt}.${timestamp}${ext}`; + finalPath = path.join(safeDestPath, newFilename); + + this.logger.debug('Duplicate filename detected, using timestamp suffix', { + originalFilename: filename, + newFilename + }); + } + + // Write file to disk + await fs.writeFile(finalPath, buffer); + + this.logger.debug('File uploaded successfully', { + path: finalPath, + size: buffer.length + }); + + return { + path: finalPath, + size: buffer.length + }; + } catch (error) { + if (error instanceof CUIError) { + throw error; + } + + const errorCode = (error as NodeJS.ErrnoException).code; + if (errorCode === 'ENOENT') { + throw new CUIError('PATH_NOT_FOUND', `Destination path not found: ${destinationPath}`, 404); + } else if (errorCode === 'EACCES') { + throw new CUIError('ACCESS_DENIED', `Access denied to destination: ${destinationPath}`, 403); + } + + this.logger.error('Error uploading file', error, { destinationPath, filename }); + throw new CUIError('UPLOAD_FILE_FAILED', `Failed to upload file: ${error}`, 500); + } + } + + /** + * Get MIME type based on file extension + */ + private getMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes: Record = { + // Text + '.txt': 'text/plain', + '.md': 'text/markdown', + '.html': 'text/html', + '.css': 'text/css', + '.js': 'text/javascript', + '.ts': 'text/typescript', + '.json': 'application/json', + '.xml': 'application/xml', + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + + // Images + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + + // Documents + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + + // Archives + '.zip': 'application/zip', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + '.rar': 'application/x-rar-compressed', + '.7z': 'application/x-7z-compressed', + + // Audio/Video + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + + // Programming + '.py': 'text/x-python', + '.java': 'text/x-java', + '.c': 'text/x-c', + '.cpp': 'text/x-c++', + '.rs': 'text/x-rust', + '.go': 'text/x-go', + '.rb': 'text/x-ruby', + '.php': 'text/x-php', + '.sh': 'application/x-sh', + '.sql': 'application/sql' + }; + + return mimeTypes[ext] || 'application/octet-stream'; + } + /** * Validate and normalize a path to prevent path traversal attacks */ diff --git a/src/types/config.ts b/src/types/config.ts index 66614f4b..358a477c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -6,6 +6,11 @@ import { RouterConfiguration } from './router-config.js'; export interface ServerConfig { host: string; port: number; + /** + * Maximum file size for downloads in bytes + * Default: 10485760 (10MB) + */ + maxDownloadSize?: number; } export interface GeminiConfig { @@ -77,7 +82,8 @@ export interface CUIConfig { export const DEFAULT_CONFIG: Omit = { server: { host: 'localhost', - port: 3001 + port: 3001, + maxDownloadSize: 10 * 1024 * 1024 // 10MB default }, interface: { colorScheme: 'system', diff --git a/src/types/index.ts b/src/types/index.ts index a567cde8..a66ec916 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -231,6 +231,37 @@ export interface FileSystemReadResponse { encoding: string; } +export interface FileSystemDownloadQuery { + path: string; + sessionId: string; // Required to validate path is within conversation's cwd +} + +export interface BulkDownloadFilesRequest { + files: string[]; // Array of file paths to include in ZIP +} + +export interface FileSystemUploadQuery { + sessionId: string; // Required to validate path is within conversation's cwd + destinationPath?: string; // Optional destination path (defaults to /uploads) +} + +export interface FileUploadResult { + originalName: string; + uploadedPath: string; + size: number; +} + +export interface FileUploadError { + filename: string; + error: string; +} + +export interface FileUploadResponse { + success: boolean; + files: FileUploadResult[]; + errors?: FileUploadError[]; +} + // Session Info Database types for lowdb export interface SessionInfo { custom_name: string; // Custom name for the session, default: "" diff --git a/src/web/chat/components/ConversationFilesSidebar.tsx b/src/web/chat/components/ConversationFilesSidebar.tsx new file mode 100644 index 00000000..9ba3a046 --- /dev/null +++ b/src/web/chat/components/ConversationFilesSidebar.tsx @@ -0,0 +1,288 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { Download, FileText, FolderOpen, Package, Upload } from 'lucide-react'; +import { api } from '../services/api'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { UploadFileButton } from './UploadFileButton'; +import { + extractFilePathsFromConversation, + getUniqueFilePaths, + type DetectedFile +} from '../../utils/filePathDetector'; + +interface ConversationFilesSidebarProps { + messages: Array<{ + uuid: string; + type: 'user' | 'assistant' | 'system'; + message: { + content?: Array<{ type: string; text?: string; name?: string; input?: unknown }>; + }; + cwd?: string; + }>; + sessionId: string; +} + +interface UploadedFile { + name: string; + path: string; + size?: number; + lastModified: string; +} + +export function ConversationFilesSidebar({ messages, sessionId }: ConversationFilesSidebarProps) { + const [isDownloadingAll, setIsDownloadingAll] = useState(false); + const [downloadError, setDownloadError] = useState(null); + const [uploadTrigger, setUploadTrigger] = useState(0); + const [uploadedFiles, setUploadedFiles] = useState([]); + + // Extract files from conversation + const detectedFiles = useMemo(() => { + return extractFilePathsFromConversation(messages); + }, [messages, uploadTrigger]); + + // Fetch uploaded files + useEffect(() => { + const fetchUploadedFiles = async () => { + try { + const result = await api.listUploadedFiles(sessionId); + setUploadedFiles(result.files); + } catch (err) { + console.error('Failed to fetch uploaded files:', err); + } + }; + + fetchUploadedFiles(); + }, [sessionId, uploadTrigger]); + + const handleUploadComplete = (uploadedPaths: string[]) => { + console.log('Files uploaded:', uploadedPaths); + // Trigger re-fetch of uploaded files + setUploadTrigger(prev => prev + 1); + }; + + const handleDownloadAll = async () => { + try { + setIsDownloadingAll(true); + setDownloadError(null); + + const filePaths = getUniqueFilePaths(detectedFiles); + await api.downloadFilesAsZip(sessionId, filePaths); + } catch (err) { + console.error('Bulk download error:', err); + setDownloadError(err instanceof Error ? err.message : 'Bulk download failed'); + } finally { + setIsDownloadingAll(false); + } + }; + + const handleDownloadSingle = async (filePath: string) => { + try { + await api.downloadFile(filePath, sessionId); + } catch (err) { + console.error('Download error:', err); + } + }; + + if (detectedFiles.length === 0 && uploadedFiles.length === 0) { + return ( +
+
+ +

Files

+
+

+ No files detected in this conversation +

+ +
+ ); + } + + return ( +
+
+
+ +

Files

+ {detectedFiles.length + uploadedFiles.length} +
+

+ Files in this conversation +

+
+
+
+ + +
+ + {downloadError && ( +
+ {downloadError} +
+ )} + + {uploadedFiles.length > 0 && ( + <> +
+
+
+ +

Uploaded Files

+ {uploadedFiles.length} +
+
+ {uploadedFiles.map((file) => ( + handleDownloadSingle(file.path)} + /> + ))} +
+
+ + )} + + {detectedFiles.length > 0 && ( + <> +
+
+
+ +

Referenced Files

+ {detectedFiles.length} +
+
+ {detectedFiles.map((file: DetectedFile) => ( + handleDownloadSingle(file.path)} + /> + ))} +
+
+ + )} +
+
+ ); +} + +interface FileItemProps { + file: DetectedFile; + onDownload: () => void; +} + +function FileItem({ file, onDownload }: FileItemProps) { + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownload = async () => { + try { + setIsDownloading(true); + await onDownload(); + } finally { + setIsDownloading(false); + } + }; + + return ( +
+ +
+
+ {file.filename} + {file.toolUses.length > 0 && ( + + {file.toolUses.length} edit{file.toolUses.length !== 1 ? 's' : ''} + + )} +
+

+ {file.path} +

+
+ +
+ ); +} + +interface UploadedFileItemProps { + file: UploadedFile; + onDownload: () => void; +} + +function UploadedFileItem({ file, onDownload }: UploadedFileItemProps) { + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownload = () => { + try { + setIsDownloading(true); + onDownload(); + } finally { + setIsDownloading(false); + } + }; + + const formatFileSize = (bytes?: number): string => { + if (!bytes) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + return ( +
+ +
+
+ {file.name} + {file.size && ( + + {formatFileSize(file.size)} + + )} +
+

+ {file.path} +

+
+ +
+ ); +} diff --git a/src/web/chat/components/ConversationView/ConversationView.tsx b/src/web/chat/components/ConversationView/ConversationView.tsx index 8a5d0c63..efffbd3d 100644 --- a/src/web/chat/components/ConversationView/ConversationView.tsx +++ b/src/web/chat/components/ConversationView/ConversationView.tsx @@ -1,8 +1,11 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useParams, useLocation, useNavigate } from 'react-router-dom'; +import { FileText } from 'lucide-react'; import { MessageList } from '../MessageList/MessageList'; import { Composer, ComposerRef } from '@/web/chat/components/Composer'; import { ConversationHeader } from '../ConversationHeader/ConversationHeader'; +import { ConversationFilesSidebar } from '../ConversationFilesSidebar'; +import { Button } from '../ui/button'; import { api } from '../../services/api'; import { useStreaming, useConversationMessages } from '../../hooks'; import type { ChatMessage, ConversationDetailsResponse, ConversationMessage, ConversationSummary } from '../../types'; @@ -18,6 +21,8 @@ export function ConversationView() { const [isPermissionDecisionLoading, setIsPermissionDecisionLoading] = useState(false); const [conversationSummary, setConversationSummary] = useState(null); const [currentWorkingDirectory, setCurrentWorkingDirectory] = useState(''); + const [showFilesSidebar, setShowFilesSidebar] = useState(false); + const [conversationMessages, setConversationMessages] = useState([]); const composerRef = useRef(null); // Use shared conversation messages hook @@ -82,9 +87,12 @@ export function ConversationView() { try { const details = await api.getConversationDetails(sessionId); const chatMessages = convertToChatlMessages(details); - + // Always load fresh messages from backend setAllMessages(chatMessages); + + // Store raw messages for file detection + setConversationMessages(details.messages); // Set working directory from the most recent message with a working directory const messagesWithCwd = chatMessages.filter(msg => msg.workingDirectory); @@ -160,6 +168,18 @@ export function ConversationView() { setError(null); + // CRITICAL: Add user message to UI immediately (optimistic update) + // This is necessary because SSE connection may not be established yet when backend sends the message + const optimisticUserMessage: ChatMessage = { + id: '', // No ID yet from backend + messageId: `optimistic-user-${Date.now()}`, + type: 'user', + content: message, + timestamp: new Date().toISOString(), + workingDirectory: workingDirectory || currentWorkingDirectory, + }; + addMessage(optimisticUserMessage); + try { const response = await api.startConversation({ resumedSessionId: sessionId, @@ -169,10 +189,16 @@ export function ConversationView() { permissionMode }); - // Navigate immediately to the new session - navigate(`/c/${response.sessionId}`); + // Only navigate if the session ID changed + if (response.sessionId !== sessionId) { + navigate(`/c/${response.sessionId}`); + } else { + // Same session, just update streaming ID to start receiving SSE events + setStreamingId(response.streamingId); + } } catch (err: any) { setError(err.message || 'Failed to send message'); + // TODO: Remove the optimistic message on error } }; @@ -214,128 +240,177 @@ export function ConversationView() { return ( -
- { - // Update local state immediately for instant feedback - setConversationTitle(newTitle); - - // Update the conversation summary with the new custom name - if (conversationSummary) { - setConversationSummary({ - ...conversationSummary, - sessionInfo: { - ...conversationSummary.sessionInfo, - custom_name: newTitle - } - }); - } - - // Optionally refresh from backend to ensure consistency - try { - const conversationsResponse = await api.getConversations({ limit: 100 }); - const updatedConversation = conversationsResponse.conversations.find( - conv => conv.sessionId === sessionId - ); - if (updatedConversation) { - setConversationSummary(updatedConversation); - const title = updatedConversation.sessionInfo.custom_name || updatedConversation.summary || 'Untitled'; - setConversationTitle(title); - } - } catch (error) { - console.error('Failed to refresh conversation after rename:', error); - } - }} - onPinToggle={async (isPinned) => { - if (conversationSummary) { - setConversationSummary({ - ...conversationSummary, - sessionInfo: { - ...conversationSummary.sessionInfo, - pinned: isPinned - } - }); - } - }} - /> - - {error && ( -
- {error} +
+ {/* Main conversation area */} +
+
+
+ { + // Update local state immediately for instant feedback + setConversationTitle(newTitle); + + // Update the conversation summary with the new custom name + if (conversationSummary) { + setConversationSummary({ + ...conversationSummary, + sessionInfo: { + ...conversationSummary.sessionInfo, + custom_name: newTitle + } + }); + } + + // Optionally refresh from backend to ensure consistency + try { + const conversationsResponse = await api.getConversations({ limit: 100 }); + const updatedConversation = conversationsResponse.conversations.find( + conv => conv.sessionId === sessionId + ); + if (updatedConversation) { + setConversationSummary(updatedConversation); + const title = updatedConversation.sessionInfo.custom_name || updatedConversation.summary || 'Untitled'; + setConversationTitle(title); + } + } catch (error) { + console.error('Failed to refresh conversation after rename:', error); + } + }} + onPinToggle={async (isPinned) => { + if (conversationSummary) { + setConversationSummary({ + ...conversationSummary, + sessionInfo: { + ...conversationSummary.sessionInfo, + pinned: isPinned + } + }); + } + }} + /> +
+ + {/* Files sidebar toggle button */} +
+ +
- )} - + {error && ( +
+ {error} +
+ )} -
-
- { - try { - const response = await api.listDirectory({ - path: directory || currentWorkingDirectory, - recursive: true, - respectGitignore: true, - }); - return response.entries; - } catch (error) { - console.error('Failed to fetch file system entries:', error); - return []; - } - }} - onFetchCommands={async (workingDirectory) => { - try { - const response = await api.getCommands(workingDirectory || currentWorkingDirectory); - return response.commands; - } catch (error) { - console.error('Failed to fetch commands:', error); - return []; - } - }} - /> + + +
+
+ { + try { + const response = await api.listDirectory({ + path: directory || currentWorkingDirectory, + recursive: true, + respectGitignore: true, + }); + return response.entries; + } catch (error) { + console.error('Failed to fetch file system entries:', error); + return []; + } + }} + onFetchCommands={async (workingDirectory) => { + try { + const response = await api.getCommands(workingDirectory || currentWorkingDirectory); + return response.commands; + } catch (error) { + console.error('Failed to fetch commands:', error); + return []; + } + }} + /> +
+ {/* Files sidebar */} + {showFilesSidebar && sessionId && conversationMessages.length > 0 && ( +
+
+ +
+
+ )} + + {/* Mobile files sidebar overlay */} + {showFilesSidebar && sessionId && conversationMessages.length > 0 && ( +
setShowFilesSidebar(false)}> +
e.stopPropagation()} + > +
+ +
+
+
+ )}
); } diff --git a/src/web/chat/components/DownloadFileButton.tsx b/src/web/chat/components/DownloadFileButton.tsx new file mode 100644 index 00000000..522f7f10 --- /dev/null +++ b/src/web/chat/components/DownloadFileButton.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { Download } from 'lucide-react'; +import { api } from '../services/api'; +import { Button } from './ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './ui/tooltip'; + +interface DownloadFileButtonProps { + filePath: string; + sessionId: string; + filename?: string; + variant?: 'inline' | 'default'; +} + +export function DownloadFileButton({ + filePath, + sessionId, + filename, + variant = 'default' +}: DownloadFileButtonProps) { + const [isDownloading, setIsDownloading] = useState(false); + const [error, setError] = useState(null); + + const handleDownload = async () => { + try { + setIsDownloading(true); + setError(null); + await api.downloadFile(filePath, sessionId); + } catch (err) { + console.error('Download error:', err); + setError(err instanceof Error ? err.message : 'Download failed'); + } finally { + setIsDownloading(false); + } + }; + + const displayName = filename || filePath.split('/').pop() || filePath; + + if (variant === 'inline') { + return ( + + + + + + +

{isDownloading ? 'Downloading...' : `Download ${displayName}`}

+ {error &&

{error}

} +
+
+
+ ); + } + + return ( + + + + + + +

Download {displayName}

+ {error &&

{error}

} +
+
+
+ ); +} diff --git a/src/web/chat/components/UploadFileButton.tsx b/src/web/chat/components/UploadFileButton.tsx new file mode 100644 index 00000000..e8b5d326 --- /dev/null +++ b/src/web/chat/components/UploadFileButton.tsx @@ -0,0 +1,157 @@ +import React, { useState, useRef } from 'react'; +import { Upload } from 'lucide-react'; +import { api } from '../services/api'; +import { Button } from './ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './ui/tooltip'; + +interface UploadFileButtonProps { + sessionId: string; + onUploadComplete?: (uploadedPaths: string[]) => void; + multiple?: boolean; + acceptedFileTypes?: string; + variant?: 'default' | 'icon'; +} + +export function UploadFileButton({ + sessionId, + onUploadComplete, + multiple = true, + acceptedFileTypes = '*', + variant = 'default' +}: UploadFileButtonProps) { + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const fileInputRef = useRef(null); + + const handleFileSelect = async (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) { + return; + } + + try { + setIsUploading(true); + setError(null); + setSuccessMessage(null); + + const fileArray = Array.from(files); + const response = await api.uploadFiles(fileArray, sessionId); + + if (response.success) { + const uploadedPaths = response.files.map(f => f.uploadedPath); + setSuccessMessage( + `Successfully uploaded ${response.files.length} file${response.files.length > 1 ? 's' : ''}` + ); + + // Clear success message after 3 seconds + setTimeout(() => setSuccessMessage(null), 3000); + + // Notify parent component + if (onUploadComplete) { + onUploadComplete(uploadedPaths); + } + + // Clear file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + + // Show errors if any files failed + if (response.errors && response.errors.length > 0) { + const errorMessages = response.errors + .map(e => `${e.filename}: ${e.error}`) + .join(', '); + setError(errorMessages); + + // Clear error message after 5 seconds + setTimeout(() => setError(null), 5000); + } + } catch (err) { + console.error('Upload error:', err); + setError(err instanceof Error ? err.message : 'Upload failed'); + + // Clear error message after 5 seconds + setTimeout(() => setError(null), 5000); + } finally { + setIsUploading(false); + } + }; + + const handleButtonClick = () => { + fileInputRef.current?.click(); + }; + + if (variant === 'icon') { + return ( + + + + + + + {successMessage ? ( +

{successMessage}

+ ) : error ? ( +

{error}

+ ) : ( +

{isUploading ? 'Uploading...' : 'Upload files'}

+ )} +
+
+
+ ); + } + + return ( +
+ + + {successMessage && ( +

{successMessage}

+ )} + + {error && ( +

{error}

+ )} +
+ ); +} diff --git a/src/web/chat/hooks/useConversationMessages.ts b/src/web/chat/hooks/useConversationMessages.ts index a1ed25c2..2dd43f92 100644 --- a/src/web/chat/hooks/useConversationMessages.ts +++ b/src/web/chat/hooks/useConversationMessages.ts @@ -82,20 +82,20 @@ export function useConversationMessages(options: UseConversationMessagesOptions if (event.message && Array.isArray(event.message.content)) { const toolResultUpdates: Record = {}; let hasToolResults = false; - + event.message.content.forEach((block) => { if (block.type === 'tool_result' && 'tool_use_id' in block) { hasToolResults = true; const toolUseId = block.tool_use_id; let result: string | ContentBlockParam[] = ''; - + // Extract result content if (typeof block.content === 'string') { result = block.content; } else if (Array.isArray(block.content)) { result = block.content; } - + toolResultUpdates[toolUseId] = { status: 'completed', result, @@ -103,29 +103,30 @@ export function useConversationMessages(options: UseConversationMessagesOptions }; } }); - + if (hasToolResults) { setToolResults(prev => ({ ...prev, ...toolResultUpdates })); // Tool result messages should not be added as child messages - return early break; } } - + // If no tool results, check if this is a child message const userParentToolUseId = event.parent_tool_use_id; - + + // Create user message + const userMessage: ChatMessage = { + id: '', + messageId: `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'user', + content: event.message.content, + timestamp: new Date().toISOString(), + workingDirectory: currentWorkingDirectory, + parentToolUseId: userParentToolUseId, + }; + if (userParentToolUseId) { - // This is a child message - create a user message and add to childrenMessages - const userMessage: ChatMessage = { - id: '', - messageId: `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - type: 'user', - content: event.message.content, - timestamp: new Date().toISOString(), - workingDirectory: currentWorkingDirectory, - parentToolUseId: userParentToolUseId, - }; - + // This is a child message - add to childrenMessages setChildrenMessages(prev => { const newChildren = { ...prev }; if (!newChildren[userParentToolUseId]) { @@ -134,6 +135,32 @@ export function useConversationMessages(options: UseConversationMessagesOptions newChildren[userParentToolUseId] = [...newChildren[userParentToolUseId], userMessage]; return newChildren; }); + } else { + // This is a regular user message - check for duplicates before adding + // (optimistic updates may have already added this message) + setMessages(prev => { + // Check if we already have a very recent user message with the same content + // This handles the case where optimistic update already added the message + const lastMessage = prev[prev.length - 1]; + const messageContent = typeof event.message.content === 'string' + ? event.message.content + : JSON.stringify(event.message.content); + + const isLikelyDuplicate = lastMessage && + lastMessage.type === 'user' && + typeof lastMessage.content === 'string' && + lastMessage.content === messageContent && + (Date.now() - new Date(lastMessage.timestamp).getTime()) < 5000; // Within 5 seconds + + if (isLikelyDuplicate) { + // Skip this message, it's likely a duplicate of our optimistic update + return prev; + } + + // Not a duplicate, add it + return [...prev, userMessage]; + }); + options.onUserMessage?.(userMessage); } break; @@ -254,23 +281,27 @@ export function useConversationMessages(options: UseConversationMessagesOptions } else if (message.type === 'user' && Array.isArray(message.content)) { // Update with tool results from user messages message.content.forEach(block => { - if (block.type === 'tool_result' && 'tool_use_id' in block) { - const toolUseId = block.tool_use_id; - + // Type guard to check if this is a tool result block (using any since tool_result is not in SDK types) + const toolResultBlock = block as any; + if (typeof toolResultBlock === 'object' && toolResultBlock !== null && + 'type' in toolResultBlock && toolResultBlock.type === 'tool_result' && + 'tool_use_id' in toolResultBlock) { + const toolUseId = toolResultBlock.tool_use_id; + // Only update if we've seen this tool use before if (newToolResults[toolUseId]) { let result: string | ContentBlockParam[] = ''; - - if (typeof block.content === 'string') { - result = block.content; - } else if (Array.isArray(block.content)) { - result = block.content; + + if (typeof toolResultBlock.content === 'string') { + result = toolResultBlock.content; + } else if (Array.isArray(toolResultBlock.content)) { + result = toolResultBlock.content; } - + newToolResults[toolUseId] = { status: 'completed', result, - is_error: block.is_error + is_error: toolResultBlock.is_error }; } } diff --git a/src/web/chat/services/api.ts b/src/web/chat/services/api.ts index b161cba6..99ec1b75 100644 --- a/src/web/chat/services/api.ts +++ b/src/web/chat/services/api.ts @@ -11,6 +11,7 @@ import type { FileSystemListQuery, FileSystemListResponse, CommandsResponse, + FileUploadResponse, } from '../types'; import { getAuthToken } from '../../hooks/useAuth'; type GeminiHealthResponse = { status: 'healthy' | 'unhealthy'; message: string; apiKeyValid: boolean }; @@ -247,16 +248,254 @@ class ApiService { const headers: Record = { ...options?.headers as Record, }; - + if (authToken) { headers.Authorization = `Bearer ${authToken}`; } - + return fetch(url, { ...options, headers, }); } + + /** + * Download a single file from a conversation + * Triggers browser download with proper filename + */ + async downloadFile(filePath: string, sessionId: string): Promise { + const authToken = getAuthToken(); + const searchParams = new URLSearchParams({ + path: filePath, + sessionId: sessionId + }); + + const url = `${this.baseUrl}/api/filesystem/download?${searchParams}`; + + console.log(`[API] Downloading file: ${filePath} for session ${sessionId}`); + + const headers: Record = {}; + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + + try { + const response = await fetch(url, { headers }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Download failed: ${errorText}`); + } + + // Get filename from Content-Disposition header + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = filePath.split('/').pop() || filePath.split('\\').pop() || 'download'; + + if (contentDisposition) { + const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); + if (matches && matches[1]) { + filename = matches[1].replace(/['"]/g, ''); + filename = decodeURIComponent(filename); + } + } + + // Create blob and trigger download + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + + console.log(`[API] File downloaded successfully: ${filename}`); + } catch (error) { + console.error(`[API] Download failed:`, error); + throw error; + } + } + + /** + * Download multiple files from a conversation as a ZIP archive + * Triggers browser download with proper filename + */ + async downloadFilesAsZip(sessionId: string, filePaths: string[]): Promise { + const authToken = getAuthToken(); + const url = `${this.baseUrl}/api/conversations/${sessionId}/download-files`; + + console.log(`[API] Downloading ${filePaths.length} files as ZIP for session ${sessionId}`); + + const headers: Record = { + 'Content-Type': 'application/json' + }; + + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ files: filePaths }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Bulk download failed: ${errorText}`); + } + + // Get filename from Content-Disposition header + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = `${sessionId}-files.zip`; + + if (contentDisposition) { + const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); + if (matches && matches[1]) { + filename = matches[1].replace(/['"]/g, ''); + filename = decodeURIComponent(filename); + } + } + + // Create blob and trigger download + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + + console.log(`[API] ZIP archive downloaded successfully: ${filename}`); + } catch (error) { + console.error(`[API] Bulk download failed:`, error); + throw error; + } + } + + /** + * Upload a single file to a conversation's uploads directory + * @param file - The file to upload + * @param sessionId - The conversation session ID + * @param destinationPath - Optional custom destination path (relative to conversation CWD) + */ + async uploadFile( + file: File, + sessionId: string, + destinationPath?: string + ): Promise { + const authToken = getAuthToken(); + const searchParams = new URLSearchParams({ sessionId }); + + if (destinationPath) { + searchParams.append('destinationPath', destinationPath); + } + + const url = `${this.baseUrl}/api/filesystem/upload?${searchParams}`; + + console.log(`[API] Uploading file: ${file.name} for session ${sessionId}`); + + const formData = new FormData(); + formData.append('files', file); + + const headers: Record = {}; + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: formData, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error((data as ApiError).error || `Upload failed with status ${response.status}`); + } + + console.log(`[API] File uploaded successfully:`, data); + return data; + } catch (error) { + console.error(`[API] Upload failed:`, error); + throw error; + } + } + + /** + * Upload multiple files to a conversation's uploads directory + * @param files - Array of files to upload + * @param sessionId - The conversation session ID + * @param destinationPath - Optional custom destination path (relative to conversation CWD) + */ + async uploadFiles( + files: File[], + sessionId: string, + destinationPath?: string + ): Promise { + const authToken = getAuthToken(); + const searchParams = new URLSearchParams({ sessionId }); + + if (destinationPath) { + searchParams.append('destinationPath', destinationPath); + } + + const url = `${this.baseUrl}/api/filesystem/upload?${searchParams}`; + + console.log(`[API] Uploading ${files.length} files for session ${sessionId}`); + + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + const headers: Record = {}; + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: formData, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error((data as ApiError).error || `Upload failed with status ${response.status}`); + } + + console.log(`[API] Files uploaded successfully:`, data); + return data; + } catch (error) { + console.error(`[API] Upload failed:`, error); + throw error; + } + } + + /** + * List uploaded files in a conversation's uploads directory + * @param sessionId - The conversation session ID + */ + async listUploadedFiles(sessionId: string): Promise<{ + files: Array<{ + name: string; + path: string; + size?: number; + lastModified: string; + }>; + }> { + const searchParams = new URLSearchParams({ sessionId }); + return this.apiCall(`/api/filesystem/uploads?${searchParams}`); + } } export const api = new ApiService(); \ No newline at end of file diff --git a/src/web/chat/types/index.ts b/src/web/chat/types/index.ts index ed5bb243..07105ce3 100644 --- a/src/web/chat/types/index.ts +++ b/src/web/chat/types/index.ts @@ -18,6 +18,7 @@ import type { FileSystemListResponse, CommandsResponse, GeminiHealthResponse, + FileUploadResponse, } from '@/types'; // Import ContentBlock from Anthropic SDK @@ -42,6 +43,7 @@ export type { FileSystemListResponse, CommandsResponse, GeminiHealthResponse, + FileUploadResponse, }; // Chat-specific types diff --git a/src/web/utils/filePathDetector.ts b/src/web/utils/filePathDetector.ts new file mode 100644 index 00000000..3abd0bf7 --- /dev/null +++ b/src/web/utils/filePathDetector.ts @@ -0,0 +1,248 @@ +/** + * Utility to detect and extract file paths from conversation messages + */ + +export interface DetectedFile { + path: string; + filename: string; + mentionedIn: string[]; // Array of message UUIDs where this file was mentioned + toolUses: Array<{ + tool: string; // 'Write' | 'Edit' | 'Read' | etc. + messageUuid: string; + }>; +} + +/** + * Extract file paths from a message's content + */ +export function extractFilePathsFromMessage( + message: { + uuid: string; + type: 'user' | 'assistant' | 'system'; + message: { + content?: Array<{ type: string; text?: string; name?: string; input?: unknown }>; + }; + }, + conversationCwd?: string +): DetectedFile[] { + const detectedFiles: Map = new Map(); + + if (message.type !== 'assistant' && message.type !== 'user') { + return []; + } + + const content = message.message.content; + if (!Array.isArray(content)) { + return []; + } + + for (const block of content) { + // Extract paths from tool uses (Write, Edit, Read, etc.) + if (block.type === 'tool_use' && block.name && block.input) { + const toolName = block.name; + const input = block.input as Record; + + // Check for file_path in Write/Edit/Read tools + if (input.file_path && typeof input.file_path === 'string') { + const filePath = input.file_path; + if (isValidAbsolutePath(filePath)) { + addOrUpdateFile(detectedFiles, filePath, message.uuid, toolName); + } + } + + // Check for notebook_path in NotebookEdit tool + if (input.notebook_path && typeof input.notebook_path === 'string') { + const filePath = input.notebook_path; + if (isValidAbsolutePath(filePath)) { + addOrUpdateFile(detectedFiles, filePath, message.uuid, toolName); + } + } + } + + // Extract paths from text content using regex + if (block.type === 'text' && block.text) { + const textPaths = extractPathsFromText(block.text, conversationCwd); + for (const filePath of textPaths) { + addOrUpdateFile(detectedFiles, filePath, message.uuid); + } + } + } + + return Array.from(detectedFiles.values()); +} + +/** + * Extract file paths from all messages in a conversation + */ +export function extractFilePathsFromConversation( + messages: Array<{ + uuid: string; + type: 'user' | 'assistant' | 'system'; + message: { + content?: Array<{ type: string; text?: string; name?: string; input?: unknown }>; + }; + cwd?: string; + }> +): DetectedFile[] { + const allFiles: Map = new Map(); + + const conversationCwd = messages[0]?.cwd; + + for (const message of messages) { + const filesInMessage = extractFilePathsFromMessage(message, conversationCwd); + + for (const file of filesInMessage) { + if (allFiles.has(file.path)) { + // Merge with existing + const existing = allFiles.get(file.path)!; + existing.mentionedIn = [...new Set([...existing.mentionedIn, ...file.mentionedIn])]; + existing.toolUses = [...existing.toolUses, ...file.toolUses]; + } else { + allFiles.set(file.path, file); + } + } + } + + return Array.from(allFiles.values()); +} + +/** + * Extract absolute file paths from text using regex + */ +function extractPathsFromText(text: string, conversationCwd?: string): string[] { + const paths: Set = new Set(); + + // Track quoted ranges to avoid double-matching + const quotedRanges: Array<[number, number]> = []; + + // Extract quoted paths first (supports spaces) + // Matches: "/path/to/My File.txt" or '/path/to/My File.txt' + const quotedPathRegex = /["']([/\\](?:[^"']+[/\\])*(?:[^"'/\\]+\.[\w]+|\.[\w]+|[A-Z_][A-Z_0-9]*))["']/g; + let match; + while ((match = quotedPathRegex.exec(text)) !== null) { + const path = match[1].trim(); + if (path && !isUrlOrFalsePositive(path)) { + paths.add(path); + // Track the range of the entire quoted string + quotedRanges.push([match.index, match.index + match[0].length]); + } + } + + // Helper to check if index is within a quoted range + const isInQuotedRange = (index: number): boolean => { + return quotedRanges.some(([start, end]) => index >= start && index < end); + }; + + // Improved regex for Unix/Linux absolute paths (unquoted, no spaces) + // Matches: /path/to/file.ext, /path/to/.gitignore, /path/to/README + // Supports: brackets, hyphens, underscores, dots (but NOT spaces) + const unixPathRegex = /\/(?:[^\s/]+\/)*(?:[^\s/]+\.[\w]+|\.[\w]+|[A-Z_][A-Z_0-9]*)(?=\s|$|[,;.!?)])/g; + + // Improved regex for Windows absolute paths: C:\path\to\file or C:/path/to/file + const windowsPathRegex = /[A-Z]:[\\/](?:[^\s\\/]+[\\/])*(?:[^\s\\/]+\.[\w]+|\.[\w]+|[A-Z_][A-Z_0-9]*)(?=\s|$|[,;.!?)])/gi; + + // Extract Unix paths (skip if in quoted range) + while ((match = unixPathRegex.exec(text)) !== null) { + if (!isInQuotedRange(match.index)) { + const path = match[0].trim(); + if (path && !isUrlOrFalsePositive(path)) { + paths.add(path); + } + } + } + + // Extract Windows paths (skip if in quoted range) + while ((match = windowsPathRegex.exec(text)) !== null) { + if (!isInQuotedRange(match.index)) { + const path = match[0].trim(); + if (path && !isUrlOrFalsePositive(path)) { + paths.add(path); + } + } + } + + // Convert to array and filter by cwd if provided + const pathArray = Array.from(paths); + if (conversationCwd) { + return pathArray.filter(p => p.startsWith(conversationCwd)); + } + + return pathArray; +} + +/** + * Filter out URLs and common false positives + */ +function isUrlOrFalsePositive(path: string): boolean { + // Filter out URLs (http://, https://, file://) + if (/^https?:\/\//.test(path) || /^file:\/\//.test(path)) { + return true; + } + + // Filter out version-like patterns (e.g., /1.0.0/, /v2.3.4/) + if (/^\/v?\d+\.\d+(\.\d+)?/.test(path)) { + return true; + } + + // Filter out single-level paths that are likely false positives + // (e.g., /usr, /bin) - but allow if they have extensions or start with dot + const parts = path.split(/[/\\]/).filter(Boolean); + if (parts.length === 1 && !path.includes('.')) { + return true; + } + + return false; +} + +/** + * Check if a path is a valid absolute path + */ +function isValidAbsolutePath(path: string): boolean { + // Unix absolute path + if (path.startsWith('/')) { + return true; + } + + // Windows absolute path + if (/^[A-Z]:[\\\/]/.test(path)) { + return true; + } + + return false; +} + +/** + * Add or update a file in the detected files map + */ +function addOrUpdateFile( + files: Map, + filePath: string, + messageUuid: string, + toolName?: string +): void { + const filename = filePath.split('/').pop() || filePath.split('\\').pop() || filePath; + + if (files.has(filePath)) { + const existing = files.get(filePath)!; + if (!existing.mentionedIn.includes(messageUuid)) { + existing.mentionedIn.push(messageUuid); + } + if (toolName) { + existing.toolUses.push({ tool: toolName, messageUuid }); + } + } else { + files.set(filePath, { + path: filePath, + filename, + mentionedIn: [messageUuid], + toolUses: toolName ? [{ tool: toolName, messageUuid }] : [] + }); + } +} + +/** + * Get unique file paths (deduplicated) + */ +export function getUniqueFilePaths(files: DetectedFile[]): string[] { + return [...new Set(files.map(f => f.path))]; +} diff --git a/tests/integration/filesystem.routes.test.ts b/tests/integration/filesystem.routes.test.ts new file mode 100644 index 00000000..1ead3e71 --- /dev/null +++ b/tests/integration/filesystem.routes.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import { Express } from 'express'; +import { CUIServer } from '@/cui-server'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +describe('FileSystem Routes Integration - Upload', () => { + let server: CUIServer; + let app: Express; + let testCwd: string; + let sessionId: string; + + beforeAll(async () => { + // Create server instance for testing + server = new CUIServer({ port: 0 }); // Use port 0 for random available port + app = (server as any).app; // Access the Express app for testing + + // Start the server for integration tests + await server.start(); + }); + + afterAll(async () => { + if (server) { + await server.stop(); + } + }); + + beforeEach(async () => { + // Create a temporary test directory + testCwd = await fs.mkdtemp(path.join(os.tmpdir(), 'cui-upload-integration-')); + + // Initialize git repository + await execAsync('git init', { cwd: testCwd }); + await execAsync('git config user.email "test@example.com"', { cwd: testCwd }); + await execAsync('git config user.name "Test User"', { cwd: testCwd }); + + // Create a test file and commit + await fs.writeFile(path.join(testCwd, 'README.md'), '# Test Project'); + await execAsync('git add .', { cwd: testCwd }); + await execAsync('git commit -m "Initial commit"', { cwd: testCwd }); + + // Start a test conversation to get a valid sessionId + const startResponse = await request(app) + .post('/api/conversations') + .send({ + workingDirectory: testCwd, + initialPrompt: 'test prompt' + }) + .expect(200); + + sessionId = startResponse.body.sessionId; + + // Wait a bit for conversation to initialize + await new Promise(resolve => setTimeout(resolve, 1000)); + }); + + afterEach(async () => { + // Stop the conversation if it exists + if (sessionId) { + try { + await request(app) + .delete(`/api/conversations/${sessionId}`) + .expect(200); + } catch (error) { + // Ignore errors during cleanup + } + } + + // Clean up test directory + if (testCwd) { + await fs.rm(testCwd, { recursive: true, force: true }); + } + }); + + describe('POST /api/filesystem/upload', () => { + it('should upload a single file successfully', async () => { + const fileContent = 'Hello, this is a test file!'; + const filename = 'test.txt'; + + const response = await request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}`) + .attach('files', Buffer.from(fileContent), filename) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.files).toHaveLength(1); + expect(response.body.files[0].originalName).toBe(filename); + expect(response.body.files[0].size).toBe(fileContent.length); + expect(response.body.files[0].uploadedPath).toContain('uploads'); + + // Verify file was actually created + const uploadedContent = await fs.readFile(response.body.files[0].uploadedPath, 'utf-8'); + expect(uploadedContent).toBe(fileContent); + }); + + it('should upload multiple files successfully', async () => { + const files = [ + { name: 'file1.txt', content: 'Content 1' }, + { name: 'file2.txt', content: 'Content 2' }, + { name: 'file3.txt', content: 'Content 3' } + ]; + + const req = request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}`); + + for (const file of files) { + req.attach('files', Buffer.from(file.content), file.name); + } + + const response = await req.expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.files).toHaveLength(3); + + // Verify all files were created + for (let i = 0; i < files.length; i++) { + const uploadedFile = response.body.files[i]; + expect(uploadedFile.originalName).toBe(files[i].name); + + const uploadedContent = await fs.readFile(uploadedFile.uploadedPath, 'utf-8'); + expect(uploadedContent).toBe(files[i].content); + } + }); + + it('should create uploads directory if it does not exist', async () => { + const uploadsDir = path.join(testCwd, 'uploads'); + + // Verify uploads directory doesn't exist yet + await expect(fs.access(uploadsDir)).rejects.toThrow(); + + // Upload a file + await request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}`) + .attach('files', Buffer.from('test'), 'test.txt') + .expect(200); + + // Verify uploads directory was created + const stats = await fs.stat(uploadsDir); + expect(stats.isDirectory()).toBe(true); + }); + + it('should handle duplicate filenames by appending timestamp', async () => { + const fileContent = 'test content'; + const filename = 'duplicate.txt'; + + // First upload + const response1 = await request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}`) + .attach('files', Buffer.from(fileContent), filename) + .expect(200); + + const firstPath = response1.body.files[0].uploadedPath; + + // Second upload with same filename + const response2 = await request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}`) + .attach('files', Buffer.from(fileContent), filename) + .expect(200); + + const secondPath = response2.body.files[0].uploadedPath; + + // Paths should be different + expect(firstPath).not.toBe(secondPath); + expect(secondPath).toMatch(/duplicate\.\d+\.txt$/); + + // Both files should exist + await expect(fs.access(firstPath)).resolves.toBeUndefined(); + await expect(fs.access(secondPath)).resolves.toBeUndefined(); + }); + + it('should reject upload without sessionId', async () => { + const response = await request(app) + .post('/api/filesystem/upload') + .attach('files', Buffer.from('test'), 'test.txt') + .expect(400); + + expect(response.body.error).toContain('sessionId'); + }); + + it('should reject upload without files', async () => { + const response = await request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}`) + .expect(400); + + expect(response.body.error).toContain('No files'); + }); + + it('should reject upload with invalid sessionId', async () => { + const response = await request(app) + .post('/api/filesystem/upload?sessionId=invalid-session-id') + .attach('files', Buffer.from('test'), 'test.txt') + .expect(404); + + expect(response.body.error).toContain('not found'); + }); + + it('should reject files exceeding size limit', async () => { + // Create a file larger than 10MB + const largeBuffer = Buffer.alloc(11 * 1024 * 1024, 'x'); + + const response = await request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}`) + .attach('files', largeBuffer, 'large.txt') + .expect(413); + + expect(response.body.error).toContain('large'); + }); + + it('should reject hidden files', async () => { + const response = await request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}`) + .attach('files', Buffer.from('test'), '.hidden') + .expect(200); + + // Upload succeeds but file should fail with error + expect(response.body.success).toBe(false); + expect(response.body.files).toHaveLength(0); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].error).toContain('Hidden files'); + }); + + it('should handle mixed success and failure uploads', async () => { + const req = request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}`) + .attach('files', Buffer.from('valid content'), 'valid.txt') + .attach('files', Buffer.from('hidden content'), '.hidden'); + + const response = await req.expect(200); + + expect(response.body.success).toBe(true); // At least one succeeded + expect(response.body.files).toHaveLength(1); + expect(response.body.files[0].originalName).toBe('valid.txt'); + expect(response.body.errors).toHaveLength(1); + expect(response.body.errors[0].filename).toBe('.hidden'); + }); + + it('should upload binary files correctly', async () => { + // Create a simple PNG-like binary data + const binaryData = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a // PNG header + ]); + + const response = await request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}`) + .attach('files', binaryData, 'image.png') + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.files[0].size).toBe(binaryData.length); + + // Verify binary data was preserved + const uploadedData = await fs.readFile(response.body.files[0].uploadedPath); + expect(uploadedData).toEqual(binaryData); + }); + + it('should restrict uploads to conversation cwd', async () => { + // Try to upload with a custom path outside cwd + const response = await request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}&destinationPath=/etc`) + .attach('files', Buffer.from('test'), 'test.txt') + .expect(403); + + expect(response.body.error).toContain('restricted'); + }); + + it('should handle various file extensions', async () => { + const files = [ + 'document.pdf', + 'image.jpg', + 'script.js', + 'data.json', + 'archive.zip', + 'video.mp4' + ]; + + const req = request(app) + .post(`/api/filesystem/upload?sessionId=${sessionId}`); + + for (const filename of files) { + req.attach('files', Buffer.from('content'), filename); + } + + const response = await req.expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.files).toHaveLength(files.length); + + // Verify all files have correct names + const uploadedNames = response.body.files.map((f: any) => f.originalName); + expect(uploadedNames).toEqual(files); + }); + }); +}); diff --git a/tests/unit/file-system-service.test.ts b/tests/unit/file-system-service.test.ts index c4e8a1a3..2631369c 100644 --- a/tests/unit/file-system-service.test.ts +++ b/tests/unit/file-system-service.test.ts @@ -204,4 +204,190 @@ describe('FileSystemService', () => { expect(gitHead).toBe(null); }); }); + + describe('File upload operations', () => { + let testDir: string; + + beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cui-upload-test-')); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + describe('ensureUploadsDirectory', () => { + it('should create uploads directory if it does not exist', async () => { + const uploadsPath = await service.ensureUploadsDirectory(testDir); + + expect(uploadsPath).toBe(path.join(testDir, 'uploads')); + + // Verify directory was created + const stats = await fs.stat(uploadsPath); + expect(stats.isDirectory()).toBe(true); + }); + + it('should return existing uploads directory if it already exists', async () => { + // Create uploads directory first + const expectedPath = path.join(testDir, 'uploads'); + await fs.mkdir(expectedPath); + + const uploadsPath = await service.ensureUploadsDirectory(testDir); + + expect(uploadsPath).toBe(expectedPath); + }); + + it('should throw error if uploads path exists but is not a directory', async () => { + // Create a file named 'uploads' instead of a directory + const filePath = path.join(testDir, 'uploads'); + await fs.writeFile(filePath, 'not a directory'); + + await expect(service.ensureUploadsDirectory(testDir)).rejects.toThrow( + new CUIError('NOT_A_DIRECTORY', 'uploads path exists but is not a directory', 400) + ); + }); + + it('should reject paths with hidden directories', async () => { + await expect(service.ensureUploadsDirectory('/home/.hidden')).rejects.toThrow( + new CUIError('INVALID_PATH', 'Path contains hidden files/directories', 400) + ); + }); + }); + + describe('uploadFile', () => { + let uploadsDir: string; + + beforeEach(async () => { + uploadsDir = path.join(testDir, 'uploads'); + await fs.mkdir(uploadsDir); + }); + + it('should upload a file successfully', async () => { + const buffer = Buffer.from('test file content'); + const filename = 'test.txt'; + + const result = await service.uploadFile(uploadsDir, buffer, filename); + + expect(result.path).toBe(path.join(uploadsDir, filename)); + expect(result.size).toBe(buffer.length); + + // Verify file was written + const content = await fs.readFile(result.path, 'utf-8'); + expect(content).toBe('test file content'); + }); + + it('should handle duplicate filenames by appending timestamp', async () => { + const buffer = Buffer.from('test content'); + const filename = 'duplicate.txt'; + + // Create first file + await fs.writeFile(path.join(uploadsDir, filename), 'existing content'); + + // Upload file with same name + const result = await service.uploadFile(uploadsDir, buffer, filename); + + // Should have timestamp in filename + expect(result.path).toMatch(/duplicate\.\d+\.txt$/); + expect(result.path).not.toBe(path.join(uploadsDir, filename)); + + // Verify new file was written + const content = await fs.readFile(result.path, 'utf-8'); + expect(content).toBe('test content'); + }); + + it('should reject files exceeding size limit', async () => { + const smallSizeService = new FileSystemService(100); // 100 bytes max + const largeBuffer = Buffer.alloc(200, 'x'); // 200 bytes + + await expect( + smallSizeService.uploadFile(uploadsDir, largeBuffer, 'large.txt') + ).rejects.toThrow( + new CUIError('FILE_TOO_LARGE', expect.stringContaining('exceeds maximum allowed size'), 413) + ); + }); + + it('should reject filenames with path separators', async () => { + const buffer = Buffer.from('test'); + + await expect( + service.uploadFile(uploadsDir, buffer, '../etc/passwd') + ).rejects.toThrow( + new CUIError('INVALID_FILENAME', 'Filename must not contain path separators', 400) + ); + }); + + it('should reject filenames with null bytes', async () => { + const buffer = Buffer.from('test'); + + await expect( + service.uploadFile(uploadsDir, buffer, 'test\u0000.txt') + ).rejects.toThrow( + new CUIError('INVALID_FILENAME', 'Filename contains null bytes', 400) + ); + }); + + it('should reject filenames with invalid characters', async () => { + const buffer = Buffer.from('test'); + + await expect( + service.uploadFile(uploadsDir, buffer, 'test.txt') + ).rejects.toThrow( + new CUIError('INVALID_FILENAME', 'Filename contains invalid characters', 400) + ); + }); + + it('should reject hidden files', async () => { + const buffer = Buffer.from('test'); + + await expect( + service.uploadFile(uploadsDir, buffer, '.hidden') + ).rejects.toThrow( + new CUIError('INVALID_FILENAME', 'Hidden files are not allowed', 400) + ); + }); + + it('should reject upload to non-directory path', async () => { + // Create a file instead of directory + const filePath = path.join(testDir, 'notadir'); + await fs.writeFile(filePath, 'content'); + + const buffer = Buffer.from('test'); + + await expect( + service.uploadFile(filePath, buffer, 'test.txt') + ).rejects.toThrow( + new CUIError('NOT_A_DIRECTORY', expect.stringContaining('not a directory'), 400) + ); + }); + + it('should reject upload to non-existent path', async () => { + const buffer = Buffer.from('test'); + const nonExistentPath = path.join(testDir, 'does-not-exist'); + + await expect( + service.uploadFile(nonExistentPath, buffer, 'test.txt') + ).rejects.toThrow( + new CUIError('PATH_NOT_FOUND', expect.stringContaining('not found'), 404) + ); + }); + + it('should handle various file extensions correctly', async () => { + const testFiles = [ + 'document.pdf', + 'image.png', + 'script.js', + 'data.json', + 'archive.zip' + ]; + + for (const filename of testFiles) { + const buffer = Buffer.from('content'); + const result = await service.uploadFile(uploadsDir, buffer, filename); + + expect(result.path).toBe(path.join(uploadsDir, filename)); + expect(result.size).toBe(buffer.length); + } + }); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/web/utils/filePathDetector.test.ts b/tests/unit/web/utils/filePathDetector.test.ts new file mode 100644 index 00000000..108bb44d --- /dev/null +++ b/tests/unit/web/utils/filePathDetector.test.ts @@ -0,0 +1,486 @@ +import { describe, it, expect } from 'vitest'; +import { + extractFilePathsFromMessage, + extractFilePathsFromConversation, + getUniqueFilePaths, + type DetectedFile +} from '@/web/utils/filePathDetector.js'; + +describe('filePathDetector', () => { + describe('extractFilePathsFromMessage', () => { + it('should extract file paths from Write tool use', () => { + const message = { + uuid: 'msg-1', + type: 'assistant' as const, + message: { + content: [ + { + type: 'tool_use', + name: 'Write', + input: { file_path: '/home/user/test.ts' } + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/home/user/test.ts'); + expect(files[0].filename).toBe('test.ts'); + expect(files[0].toolUses).toHaveLength(1); + expect(files[0].toolUses[0].tool).toBe('Write'); + }); + + it('should extract file paths from Edit tool use', () => { + const message = { + uuid: 'msg-2', + type: 'assistant' as const, + message: { + content: [ + { + type: 'tool_use', + name: 'Edit', + input: { file_path: '/home/user/config.json' } + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/home/user/config.json'); + }); + + it('should extract notebook paths from NotebookEdit tool use', () => { + const message = { + uuid: 'msg-3', + type: 'assistant' as const, + message: { + content: [ + { + type: 'tool_use', + name: 'NotebookEdit', + input: { notebook_path: '/home/user/analysis.ipynb' } + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/home/user/analysis.ipynb'); + }); + + it('should extract Windows paths from tool use', () => { + const message = { + uuid: 'msg-4', + type: 'assistant' as const, + message: { + content: [ + { + type: 'tool_use', + name: 'Write', + input: { file_path: 'C:\\Users\\test\\file.txt' } + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('C:\\Users\\test\\file.txt'); + }); + + it('should extract paths from text content with standard paths', () => { + const message = { + uuid: 'msg-5', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'I edited /home/user/src/app.ts to add functionality' + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/home/user/src/app.ts'); + }); + + it('should extract paths with spaces when quoted', () => { + const message = { + uuid: 'msg-6', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'Modified "/home/user/My Documents/report.pdf" successfully' + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/home/user/My Documents/report.pdf'); + }); + + it('should extract paths with parentheses and brackets', () => { + const message = { + uuid: 'msg-7', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'Check /home/user/projects/app-v2/src/[id].tsx for details' + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files.length).toBeGreaterThanOrEqual(1); + const paths = files.map(f => f.path); + expect(paths).toContain('/home/user/projects/app-v2/src/[id].tsx'); + }); + + it('should extract dotfiles', () => { + const message = { + uuid: 'msg-8', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'Updated /home/user/project/.gitignore and /home/user/project/.eslintrc.json' + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files.length).toBeGreaterThanOrEqual(2); + const paths = files.map(f => f.path); + expect(paths).toContain('/home/user/project/.gitignore'); + expect(paths).toContain('/home/user/project/.eslintrc.json'); + }); + + it('should extract files without extensions (uppercase constants)', () => { + const message = { + uuid: 'msg-9', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'Check /home/user/project/README and /home/user/project/LICENSE files' + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files.length).toBeGreaterThanOrEqual(2); + const paths = files.map(f => f.path); + expect(paths).toContain('/home/user/project/README'); + expect(paths).toContain('/home/user/project/LICENSE'); + }); + + it('should NOT extract URLs', () => { + const message = { + uuid: 'msg-10', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'Visit https://example.com/path/to/file.js or http://test.com/index.html' + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + const paths = files.map(f => f.path); + expect(paths).not.toContain('https://example.com/path/to/file.js'); + expect(paths).not.toContain('http://test.com/index.html'); + }); + + it('should NOT extract version numbers', () => { + const message = { + uuid: 'msg-11', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'Updated to version /1.0.0/ and /v2.3.4/' + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + const paths = files.map(f => f.path); + expect(paths).not.toContain('/1.0.0/'); + expect(paths).not.toContain('/v2.3.4/'); + }); + + it('should NOT extract single-level system paths', () => { + const message = { + uuid: 'msg-12', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'Check /usr and /bin directories' + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + const paths = files.map(f => f.path); + expect(paths).not.toContain('/usr'); + expect(paths).not.toContain('/bin'); + }); + + it('should handle multiple paths in one message', () => { + const message = { + uuid: 'msg-13', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'Modified /home/user/src/app.ts and /home/user/src/utils.ts' + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files.length).toBeGreaterThanOrEqual(2); + const paths = files.map(f => f.path); + expect(paths).toContain('/home/user/src/app.ts'); + expect(paths).toContain('/home/user/src/utils.ts'); + }); + + it('should filter paths by conversation cwd', () => { + const message = { + uuid: 'msg-14', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'Modified /home/user/project/src/app.ts and /other/path/file.js' + } + ] + }, + cwd: '/home/user/project' + }; + + const files = extractFilePathsFromMessage(message, '/home/user/project'); + const paths = files.map(f => f.path); + expect(paths).toContain('/home/user/project/src/app.ts'); + expect(paths).not.toContain('/other/path/file.js'); + }); + + it('should return empty array for system messages', () => { + const message = { + uuid: 'msg-15', + type: 'system' as const, + message: { + content: [ + { + type: 'text', + text: '/home/user/test.ts' + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files).toHaveLength(0); + }); + + it('should handle messages without content', () => { + const message = { + uuid: 'msg-16', + type: 'assistant' as const, + message: {} + }; + + const files = extractFilePathsFromMessage(message); + expect(files).toHaveLength(0); + }); + + it('should deduplicate paths within same message', () => { + const message = { + uuid: 'msg-17', + type: 'assistant' as const, + message: { + content: [ + { + type: 'tool_use', + name: 'Read', + input: { file_path: '/home/user/test.ts' } + }, + { + type: 'tool_use', + name: 'Edit', + input: { file_path: '/home/user/test.ts' } + }, + { + type: 'text', + text: 'Modified /home/user/test.ts' + } + ] + } + }; + + const files = extractFilePathsFromMessage(message); + expect(files).toHaveLength(1); + expect(files[0].toolUses).toHaveLength(2); + expect(files[0].mentionedIn).toHaveLength(1); + }); + }); + + describe('extractFilePathsFromConversation', () => { + it('should extract files from multiple messages', () => { + const messages = [ + { + uuid: 'msg-1', + type: 'assistant' as const, + message: { + content: [ + { + type: 'tool_use', + name: 'Write', + input: { file_path: '/home/user/file1.ts' } + } + ] + }, + cwd: '/home/user' + }, + { + uuid: 'msg-2', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'Also created /home/user/file2.ts' + } + ] + }, + cwd: '/home/user' + } + ]; + + const files = extractFilePathsFromConversation(messages); + expect(files.length).toBeGreaterThanOrEqual(2); + const paths = files.map(f => f.path); + expect(paths).toContain('/home/user/file1.ts'); + expect(paths).toContain('/home/user/file2.ts'); + }); + + it('should merge duplicate files across messages', () => { + const messages = [ + { + uuid: 'msg-1', + type: 'assistant' as const, + message: { + content: [ + { + type: 'tool_use', + name: 'Write', + input: { file_path: '/home/user/test.ts' } + } + ] + }, + cwd: '/home/user' + }, + { + uuid: 'msg-2', + type: 'assistant' as const, + message: { + content: [ + { + type: 'tool_use', + name: 'Edit', + input: { file_path: '/home/user/test.ts' } + } + ] + }, + cwd: '/home/user' + } + ]; + + const files = extractFilePathsFromConversation(messages); + expect(files).toHaveLength(1); + expect(files[0].mentionedIn).toHaveLength(2); + expect(files[0].mentionedIn).toContain('msg-1'); + expect(files[0].mentionedIn).toContain('msg-2'); + expect(files[0].toolUses).toHaveLength(2); + }); + + it('should use first message cwd for conversation', () => { + const messages = [ + { + uuid: 'msg-1', + type: 'assistant' as const, + message: { + content: [ + { + type: 'text', + text: 'Working in /home/user/project/src/app.ts' + } + ] + }, + cwd: '/home/user/project' + } + ]; + + const files = extractFilePathsFromConversation(messages); + expect(files.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('getUniqueFilePaths', () => { + it('should return unique file paths', () => { + const files: DetectedFile[] = [ + { + path: '/home/user/file1.ts', + filename: 'file1.ts', + mentionedIn: ['msg-1'], + toolUses: [] + }, + { + path: '/home/user/file2.ts', + filename: 'file2.ts', + mentionedIn: ['msg-2'], + toolUses: [] + }, + { + path: '/home/user/file1.ts', + filename: 'file1.ts', + mentionedIn: ['msg-3'], + toolUses: [] + } + ]; + + const uniquePaths = getUniqueFilePaths(files); + expect(uniquePaths).toHaveLength(2); + expect(uniquePaths).toContain('/home/user/file1.ts'); + expect(uniquePaths).toContain('/home/user/file2.ts'); + }); + }); +});