diff --git a/.dockerignore b/.dockerignore index 6359978833..4579d61580 100644 --- a/.dockerignore +++ b/.dockerignore @@ -76,14 +76,18 @@ src/node_modules !pnpm-workspace.yaml !scripts/bootstrap.mjs !apps/web-evals/ +!apps/cli/ !src/ !webview-ui/ !packages/evals/.docker/entrypoints/runner.sh !packages/build/ !packages/config-eslint/ !packages/config-typescript/ +!packages/core/ !packages/evals/ !packages/ipc/ !packages/telemetry/ !packages/types/ +!packages/vscode-shim/ +!packages/cloud/ !locales/ diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000000..42e1c9f16e --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,230 @@ +# @roo-code/cli + +Command Line Interface for Roo Code - Run the Roo Code agent from the terminal without VSCode. + +## Overview + +This CLI uses the `@roo-code/vscode-shim` package to provide a VSCode API compatibility layer, allowing the main Roo Code extension to run in a Node.js environment. + +## Installation + +### Quick Install (Recommended) + +Install the Roo Code CLI with a single command: + +```bash +curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +``` + +**Requirements:** + +- Node.js 20 or higher +- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64) + +**Custom installation directory:** + +```bash +ROO_INSTALL_DIR=/opt/roo-code ROO_BIN_DIR=/usr/local/bin curl -fsSL ... | sh +``` + +**Install a specific version:** + +```bash +ROO_VERSION=0.1.0 curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +``` + +### Updating + +Re-run the install script to update to the latest version: + +```bash +curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +``` + +### Uninstalling + +```bash +rm -rf ~/.roo/cli ~/.local/bin/roo +``` + +### Development Installation + +For contributing or development: + +```bash +# From the monorepo root. +pnpm install + +# Build the main extension first. +pnpm --filter zgsm bundle + +# Build the cli. +pnpm --filter @roo-code/cli build +``` + +## Usage + +### Interactive Mode (Default) + +By default, the CLI prompts for approval before executing actions: + +```bash +export OPENROUTER_API_KEY=sk-or-v1-... + +roo "What is this project?" --workspace ~/Documents/my-project +``` + +In interactive mode: + +- Tool executions prompt for yes/no approval +- Commands prompt for yes/no approval +- Followup questions show suggestions and wait for user input +- Browser and MCP actions prompt for approval + +### Non-Interactive Mode (`-y`) + +For automation and scripts, use `-y` to auto-approve all actions: + +```bash +roo -y "Refactor the utils.ts file" --workspace ~/Documents/my-project +``` + +In non-interactive mode: + +- Tool, command, browser, and MCP actions are auto-approved +- Followup questions show a 10-second timeout, then auto-select the first suggestion +- Typing any key cancels the timeout and allows manual input + +## Options + +| Option | Description | Default | +| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- | +| `-w, --workspace ` | Workspace path to operate in | Current directory | +| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | +| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` | +| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | +| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` | +| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` | +| `-k, --api-key ` | API key for the LLM provider | From env var | +| `-p, --provider ` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` | +| `-m, --model ` | Model to use | Provider default | +| `-M, --mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | +| `-r, --reasoning-effort ` | Reasoning effort level (none, minimal, low, medium, high, xhigh) | `medium` | + +By default, the CLI runs in quiet mode (suppressing VSCode/extension logs) and only shows assistant output. Use `-v` to see all logs, or `-d` for detailed debug information. + +## Environment Variables + +The CLI will look for API keys in environment variables if not provided via `--api-key`: + +| Provider | Environment Variable | +| ------------- | -------------------- | +| anthropic | `ANTHROPIC_API_KEY` | +| openai | `OPENAI_API_KEY` | +| openrouter | `OPENROUTER_API_KEY` | +| google/gemini | `GOOGLE_API_KEY` | +| mistral | `MISTRAL_API_KEY` | +| deepseek | `DEEPSEEK_API_KEY` | +| bedrock | `AWS_ACCESS_KEY_ID` | + +## Architecture + +``` +┌─────────────────┐ +│ CLI Entry │ +│ (index.ts) │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ ExtensionHost │ +│ (extension- │ +│ host.ts) │ +└────────┬────────┘ + │ + ┌────┴────┐ + │ │ + ▼ ▼ +┌───────┐ ┌──────────┐ +│vscode │ │Extension │ +│-shim │ │ Bundle │ +└───────┘ └──────────┘ +``` + +## How It Works + +1. **CLI Entry Point** (`index.ts`): Parses command line arguments and initializes the ExtensionHost + +2. **ExtensionHost** (`extension-host.ts`): + - Creates a VSCode API mock using `@roo-code/vscode-shim` + - Intercepts `require('vscode')` to return the mock + - Loads and activates the extension bundle + - Manages bidirectional message flow + +3. **Message Flow**: + - CLI → Extension: `emit("webviewMessage", {...})` + - Extension → CLI: `emit("extensionWebviewMessage", {...})` + +## Current Limitations + +- **No TUI**: Output is plain text (no React/Ink UI yet) +- **No configuration file**: Settings are passed via command line flags +- **No persistence**: Each run is a fresh session + +## Development + +```bash +# Watch mode for development +pnpm dev + +# Run tests +pnpm test + +# Type checking +pnpm check-types + +# Linting +pnpm lint +``` + +## Releasing + +To create a new release, run the release script from the monorepo root: + +```bash +# Release using version from package.json +./apps/cli/scripts/release.sh + +# Release with a specific version +./apps/cli/scripts/release.sh 0.1.0 +``` + +The script will: + +1. Build the extension and CLI +2. Create a platform-specific tarball (for your current OS/architecture) +3. Create a GitHub release with the tarball attached + +**Prerequisites:** + +- GitHub CLI (`gh`) installed and authenticated (`gh auth login`) +- pnpm installed + +## Troubleshooting + +### Extension bundle not found + +Make sure you've built the main extension first: + +```bash +cd src +pnpm bundle +``` + +### Module resolution errors + +The CLI expects the extension to be a CommonJS bundle. Make sure the extension's esbuild config outputs CommonJS. + +### "vscode" module not found + +The CLI intercepts `require('vscode')` calls. If you see this error, the module resolution interception may have failed. diff --git a/apps/cli/eslint.config.mjs b/apps/cli/eslint.config.mjs new file mode 100644 index 0000000000..694bf73664 --- /dev/null +++ b/apps/cli/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@roo-code/config-eslint/base" + +/** @type {import("eslint").Linter.Config} */ +export default [...config] diff --git a/apps/cli/install.sh b/apps/cli/install.sh new file mode 100755 index 0000000000..ca82ecfdd8 --- /dev/null +++ b/apps/cli/install.sh @@ -0,0 +1,287 @@ +#!/bin/sh +# Roo Code CLI Installer +# Usage: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +# +# Environment variables: +# ROO_INSTALL_DIR - Installation directory (default: ~/.roo/cli) +# ROO_BIN_DIR - Binary symlink directory (default: ~/.local/bin) +# ROO_VERSION - Specific version to install (default: latest) + +set -e + +# Configuration +INSTALL_DIR="${ROO_INSTALL_DIR:-$HOME/.roo/cli}" +BIN_DIR="${ROO_BIN_DIR:-$HOME/.local/bin}" +REPO="RooCodeInc/Roo-Code" +MIN_NODE_VERSION=20 + +# Color output (only if terminal supports it) +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + BOLD='\033[1m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + BOLD='' + NC='' +fi + +info() { printf "${GREEN}==>${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; } +error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; } + +# Check Node.js version +check_node() { + if ! command -v node >/dev/null 2>&1; then + error "Node.js is not installed. Please install Node.js $MIN_NODE_VERSION or higher. + +Install Node.js: + - macOS: brew install node + - Linux: https://nodejs.org/en/download/package-manager + - Or use a version manager like fnm, nvm, or mise" + fi + + NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1) + if [ "$NODE_VERSION" -lt "$MIN_NODE_VERSION" ]; then + error "Node.js $MIN_NODE_VERSION+ required. Found: $(node -v) + +Please upgrade Node.js to version $MIN_NODE_VERSION or higher." + fi + + info "Found Node.js $(node -v)" +} + +# Detect OS and architecture +detect_platform() { + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$OS" in + darwin) OS="darwin" ;; + linux) OS="linux" ;; + mingw*|msys*|cygwin*) + error "Windows is not supported by this installer. Please use WSL or install manually." + ;; + *) error "Unsupported OS: $OS" ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH="x64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) error "Unsupported architecture: $ARCH" ;; + esac + + PLATFORM="${OS}-${ARCH}" + info "Detected platform: $PLATFORM" +} + +# Get latest release version or use specified version +get_version() { + if [ -n "$ROO_VERSION" ]; then + VERSION="$ROO_VERSION" + info "Using specified version: $VERSION" + return + fi + + info "Fetching latest version..." + + # Try to get the latest cli release + RELEASES_JSON=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" 2>/dev/null) || { + error "Failed to fetch releases from GitHub. Check your internet connection." + } + + # Extract the latest cli-v* tag + VERSION=$(echo "$RELEASES_JSON" | + grep -o '"tag_name": "cli-v[^"]*"' | + head -1 | + sed 's/"tag_name": "cli-v//' | + sed 's/"//') + + if [ -z "$VERSION" ]; then + error "Could not find any CLI releases. The CLI may not have been released yet." + fi + + info "Latest version: $VERSION" +} + +# Download and extract +download_and_install() { + TARBALL="roo-cli-${PLATFORM}.tar.gz" + URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}" + + info "Downloading from $URL..." + + # Create temp directory + TMP_DIR=$(mktemp -d) + trap "rm -rf $TMP_DIR" EXIT + + # Download with progress indicator + HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || { + if [ "$HTTP_CODE" = "404" ]; then + error "Release not found for platform $PLATFORM version $VERSION. + +Available at: https://github.com/$REPO/releases" + fi + error "Download failed. HTTP code: $HTTP_CODE" + } + + # Verify we got something + if [ ! -s "$TMP_DIR/$TARBALL" ]; then + error "Downloaded file is empty. Please try again." + fi + + # Remove old installation if exists + if [ -d "$INSTALL_DIR" ]; then + info "Removing previous installation..." + rm -rf "$INSTALL_DIR" + fi + + mkdir -p "$INSTALL_DIR" + + # Extract + info "Extracting to $INSTALL_DIR..." + tar -xzf "$TMP_DIR/$TARBALL" -C "$INSTALL_DIR" --strip-components=1 || { + error "Failed to extract tarball. The download may be corrupted." + } + + # Save ripgrep binary before npm install (npm install will overwrite node_modules) + RIPGREP_BIN="" + if [ -f "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" ]; then + RIPGREP_BIN="$TMP_DIR/rg" + cp "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" "$RIPGREP_BIN" + fi + + # Install npm dependencies + info "Installing dependencies..." + cd "$INSTALL_DIR" + npm install --production --silent 2>/dev/null || { + warn "npm install failed, trying with --legacy-peer-deps..." + npm install --production --legacy-peer-deps --silent 2>/dev/null || { + error "Failed to install dependencies. Make sure npm is available." + } + } + cd - > /dev/null + + # Restore ripgrep binary after npm install + if [ -n "$RIPGREP_BIN" ] && [ -f "$RIPGREP_BIN" ]; then + mkdir -p "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin" + cp "$RIPGREP_BIN" "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" + chmod +x "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" + fi + + # Make executable + chmod +x "$INSTALL_DIR/bin/roo" + + # Also make ripgrep executable if it exists + if [ -f "$INSTALL_DIR/bin/rg" ]; then + chmod +x "$INSTALL_DIR/bin/rg" + fi +} + +# Create symlink in bin directory +setup_bin() { + mkdir -p "$BIN_DIR" + + # Remove old symlink if exists + if [ -L "$BIN_DIR/roo" ] || [ -f "$BIN_DIR/roo" ]; then + rm -f "$BIN_DIR/roo" + fi + + ln -sf "$INSTALL_DIR/bin/roo" "$BIN_DIR/roo" + info "Created symlink: $BIN_DIR/roo" +} + +# Check if bin dir is in PATH and provide instructions +check_path() { + case ":$PATH:" in + *":$BIN_DIR:"*) + # Already in PATH + return 0 + ;; + esac + + warn "$BIN_DIR is not in your PATH" + echo "" + echo "Add this line to your shell profile:" + echo "" + + # Detect shell and provide specific instructions + SHELL_NAME=$(basename "$SHELL") + case "$SHELL_NAME" in + zsh) + echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.zshrc" + echo " source ~/.zshrc" + ;; + bash) + if [ -f "$HOME/.bashrc" ]; then + echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bashrc" + echo " source ~/.bashrc" + else + echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bash_profile" + echo " source ~/.bash_profile" + fi + ;; + fish) + echo " set -Ux fish_user_paths $BIN_DIR \$fish_user_paths" + ;; + *) + echo " export PATH=\"$BIN_DIR:\$PATH\"" + ;; + esac + echo "" +} + +# Verify installation +verify_install() { + if [ -x "$BIN_DIR/roo" ]; then + info "Verifying installation..." + # Just check if it runs without error + "$BIN_DIR/roo" --version >/dev/null 2>&1 || true + fi +} + +# Print success message +print_success() { + echo "" + printf "${GREEN}${BOLD}✓ Roo Code CLI installed successfully!${NC}\n" + echo "" + echo " Installation: $INSTALL_DIR" + echo " Binary: $BIN_DIR/roo" + echo " Version: $VERSION" + echo "" + echo " ${BOLD}Get started:${NC}" + echo " roo --help" + echo "" + echo " ${BOLD}Example:${NC}" + echo " export OPENROUTER_API_KEY=sk-or-v1-..." + echo " roo \"What is this project?\" --workspace ~/my-project" + echo "" +} + +# Main +main() { + echo "" + printf "${BLUE}${BOLD}" + echo " ╭─────────────────────────────────╮" + echo " │ Roo Code CLI Installer │" + echo " ╰─────────────────────────────────╯" + printf "${NC}" + echo "" + + check_node + detect_platform + get_version + download_and_install + setup_bin + check_path + verify_install + print_success +} + +main "$@" diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000000..f4c4a3bcb5 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,35 @@ +{ + "name": "@roo-code/cli", + "version": "0.1.0", + "description": "Roo Code CLI - Run the Roo Code agent from the command line", + "private": true, + "type": "module", + "main": "dist/index.js", + "bin": { + "roo": "dist/index.js" + }, + "scripts": { + "format": "prettier --write 'src/**/*.ts'", + "lint": "eslint src --ext .ts --max-warnings=0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "build": "tsup", + "start": "node dist/index.js", + "clean": "rimraf dist .turbo" + }, + "dependencies": { + "@roo-code/types": "workspace:^", + "@roo-code/vscode-shim": "workspace:^", + "@vscode/ripgrep": "^1.15.9", + "commander": "^12.1.0" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "^24.1.0", + "rimraf": "^6.0.1", + "tsup": "^8.4.0", + "typescript": "5.8.3", + "vitest": "^3.2.3" + } +} diff --git a/apps/cli/scripts/release.sh b/apps/cli/scripts/release.sh new file mode 100755 index 0000000000..43d5298956 --- /dev/null +++ b/apps/cli/scripts/release.sh @@ -0,0 +1,363 @@ +#!/bin/bash +# Roo Code CLI Release Script +# +# Usage: +# ./apps/cli/scripts/release.sh [version] +# +# Examples: +# ./apps/cli/scripts/release.sh # Use version from package.json +# ./apps/cli/scripts/release.sh 0.1.0 # Specify version +# +# This script: +# 1. Builds the extension and CLI +# 2. Creates a tarball for the current platform +# 3. Creates a GitHub release and uploads the tarball +# +# Prerequisites: +# - GitHub CLI (gh) installed and authenticated +# - pnpm installed +# - Run from the monorepo root directory + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +info() { printf "${GREEN}==>${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; } +error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; } +step() { printf "${BLUE}${BOLD}[%s]${NC} %s\n" "$1" "$2"; } + +# Get script directory and repo root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +CLI_DIR="$REPO_ROOT/apps/cli" + +# Detect current platform +detect_platform() { + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$OS" in + darwin) OS="darwin" ;; + linux) OS="linux" ;; + *) error "Unsupported OS: $OS" ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH="x64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) error "Unsupported architecture: $ARCH" ;; + esac + + PLATFORM="${OS}-${ARCH}" +} + +# Check prerequisites +check_prerequisites() { + step "1/7" "Checking prerequisites..." + + if ! command -v gh &> /dev/null; then + error "GitHub CLI (gh) is not installed. Install it with: brew install gh" + fi + + if ! gh auth status &> /dev/null; then + error "GitHub CLI is not authenticated. Run: gh auth login" + fi + + if ! command -v pnpm &> /dev/null; then + error "pnpm is not installed." + fi + + if ! command -v node &> /dev/null; then + error "Node.js is not installed." + fi + + info "Prerequisites OK" +} + +# Get version +get_version() { + if [ -n "$1" ]; then + VERSION="$1" + else + VERSION=$(node -p "require('$CLI_DIR/package.json').version") + fi + + # Validate semver format + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then + error "Invalid version format: $VERSION (expected semver like 0.1.0)" + fi + + TAG="cli-v$VERSION" + info "Version: $VERSION (tag: $TAG)" +} + +# Build everything +build() { + step "2/7" "Building extension bundle..." + cd "$REPO_ROOT" + pnpm bundle + + step "3/7" "Building CLI..." + pnpm --filter @roo-code/cli build + + info "Build complete" +} + +# Create release tarball +create_tarball() { + step "4/7" "Creating release tarball for $PLATFORM..." + + RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}" + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Clean up any previous build + rm -rf "$RELEASE_DIR" + rm -f "$REPO_ROOT/$TARBALL" + + # Create directory structure + mkdir -p "$RELEASE_DIR/bin" + mkdir -p "$RELEASE_DIR/lib" + mkdir -p "$RELEASE_DIR/extension" + + # Copy CLI dist files + info "Copying CLI files..." + cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/" + + # Create package.json for npm install (only runtime dependencies) + info "Creating package.json..." + node -e " + const pkg = require('$CLI_DIR/package.json'); + const newPkg = { + name: '@roo-code/cli', + version: pkg.version, + type: 'module', + dependencies: { + commander: pkg.dependencies.commander + } + }; + console.log(JSON.stringify(newPkg, null, 2)); + " > "$RELEASE_DIR/package.json" + + # Copy extension bundle + info "Copying extension bundle..." + cp -r "$REPO_ROOT/src/dist/"* "$RELEASE_DIR/extension/" + + # Add package.json to extension directory to mark it as CommonJS + # This is necessary because the main package.json has "type": "module" + # but the extension bundle is CommonJS + echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" + + # Find and copy ripgrep binary + # The extension looks for ripgrep at: appRoot/node_modules/@vscode/ripgrep/bin/rg + # The CLI sets appRoot to the CLI package root, so we need to put ripgrep there + info "Looking for ripgrep binary..." + RIPGREP_PATH=$(find "$REPO_ROOT/node_modules" -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) + if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then + info "Found ripgrep at: $RIPGREP_PATH" + # Create the expected directory structure for the extension to find ripgrep + mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/" + chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg" + # Also keep a copy in bin/ for direct access + mkdir -p "$RELEASE_DIR/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" + chmod +x "$RELEASE_DIR/bin/rg" + else + warn "ripgrep binary not found - users will need ripgrep installed" + fi + + # Create the wrapper script + info "Creating wrapper script..." + cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF' +#!/usr/bin/env node + +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Set environment variables for the CLI +process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); +process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg'); + +// Import and run the actual CLI +await import(join(__dirname, '..', 'lib', 'index.js')); +WRAPPER_EOF + + chmod +x "$RELEASE_DIR/bin/roo" + + # Create version file + echo "$VERSION" > "$RELEASE_DIR/VERSION" + + # Create tarball + info "Creating tarball..." + cd "$REPO_ROOT" + tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")" + + # Clean up release directory + rm -rf "$RELEASE_DIR" + + # Show size + TARBALL_PATH="$REPO_ROOT/$TARBALL" + TARBALL_SIZE=$(ls -lh "$TARBALL_PATH" | awk '{print $5}') + info "Created: $TARBALL ($TARBALL_SIZE)" +} + +# Create checksum +create_checksum() { + step "5/7" "Creating checksum..." + cd "$REPO_ROOT" + + if command -v sha256sum &> /dev/null; then + sha256sum "$TARBALL" > "${TARBALL}.sha256" + elif command -v shasum &> /dev/null; then + shasum -a 256 "$TARBALL" > "${TARBALL}.sha256" + else + warn "No sha256sum or shasum found, skipping checksum" + return + fi + + info "Checksum: $(cat "${TARBALL}.sha256")" +} + +# Check if release already exists +check_existing_release() { + step "6/7" "Checking for existing release..." + + if gh release view "$TAG" &> /dev/null; then + warn "Release $TAG already exists" + read -p "Do you want to delete it and create a new one? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + info "Deleting existing release..." + gh release delete "$TAG" --yes + # Also delete the tag if it exists + git tag -d "$TAG" 2>/dev/null || true + git push origin ":refs/tags/$TAG" 2>/dev/null || true + else + error "Aborted. Use a different version or delete the existing release manually." + fi + fi +} + +# Create GitHub release +create_release() { + step "7/7" "Creating GitHub release..." + cd "$REPO_ROOT" + + RELEASE_NOTES=$(cat << EOF +## Installation + +\`\`\`bash +curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +\`\`\` + +Or install a specific version: +\`\`\`bash +ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh +\`\`\` + +## Requirements + +- Node.js 20 or higher +- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64) + +## Usage + +\`\`\`bash +# Set your API key +export OPENROUTER_API_KEY=sk-or-v1-... + +# Run a task +roo "What is this project?" --workspace ~/my-project + +# See all options +roo --help +\`\`\` + +## Platform Support + +This release includes: +- \`roo-cli-${PLATFORM}.tar.gz\` - Built on $(uname -s) $(uname -m) + +> **Note:** Additional platforms will be added as needed. If you need a different platform, please open an issue. + +## Checksum + +\`\`\` +$(cat "${TARBALL}.sha256" 2>/dev/null || echo "N/A") +\`\`\` +EOF +) + + # Get the current commit SHA for the release target + COMMIT_SHA=$(git rev-parse HEAD) + info "Creating release at commit: ${COMMIT_SHA:0:8}" + + # Create release (gh will create the tag automatically) + info "Creating release..." + RELEASE_FILES="$TARBALL" + if [ -f "${TARBALL}.sha256" ]; then + RELEASE_FILES="$RELEASE_FILES ${TARBALL}.sha256" + fi + + gh release create "$TAG" \ + --title "Roo Code CLI v$VERSION" \ + --notes "$RELEASE_NOTES" \ + --prerelease \ + --target "$COMMIT_SHA" \ + $RELEASE_FILES + + info "Release created!" +} + +# Cleanup +cleanup() { + info "Cleaning up..." + cd "$REPO_ROOT" + rm -f "$TARBALL" "${TARBALL}.sha256" +} + +# Print summary +print_summary() { + echo "" + printf "${GREEN}${BOLD}✓ Release v$VERSION created successfully!${NC}\n" + echo "" + echo " Release URL: https://github.com/RooCodeInc/Roo-Code/releases/tag/$TAG" + echo "" + echo " Install with:" + echo " curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" + echo "" +} + +# Main +main() { + echo "" + printf "${BLUE}${BOLD}" + echo " ╭─────────────────────────────────╮" + echo " │ Roo Code CLI Release Script │" + echo " ╰─────────────────────────────────╯" + printf "${NC}" + echo "" + + detect_platform + check_prerequisites + get_version "$1" + build + create_tarball + create_checksum + check_existing_release + create_release + cleanup + print_summary +} + +main "$@" diff --git a/apps/cli/src/__tests__/extension-host.test.ts b/apps/cli/src/__tests__/extension-host.test.ts new file mode 100644 index 0000000000..509ad27d1e --- /dev/null +++ b/apps/cli/src/__tests__/extension-host.test.ts @@ -0,0 +1,1164 @@ +// pnpm --filter @roo-code/cli test src/__tests__/extension-host.test.ts + +import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js" +import { EventEmitter } from "events" +import type { ProviderName } from "@roo-code/types" + +vi.mock("@roo-code/vscode-shim", () => ({ + createVSCodeAPI: vi.fn(() => ({ + context: { extensionPath: "/test/extension" }, + })), +})) + +/** + * Create a test ExtensionHost with default options + */ +function createTestHost({ + mode = "code", + apiProvider = "openrouter", + model = "test-model", + ...options +}: Partial = {}): ExtensionHost { + return new ExtensionHost({ + mode, + apiProvider, + model, + workspacePath: "/test/workspace", + extensionPath: "/test/extension", + ...options, + }) +} + +// Type for accessing private members +type PrivateHost = Record + +/** + * Helper to access private members for testing + */ +function getPrivate(host: ExtensionHost, key: string): T { + return (host as unknown as PrivateHost)[key] as T +} + +/** + * Helper to call private methods for testing + */ +function callPrivate(host: ExtensionHost, method: string, ...args: unknown[]): T { + const fn = (host as unknown as PrivateHost)[method] as ((...a: unknown[]) => T) | undefined + if (!fn) throw new Error(`Method ${method} not found`) + return fn.apply(host, args) +} + +/** + * Helper to spy on private methods + * This uses a more permissive type to avoid TypeScript errors with vi.spyOn on private methods + */ +function spyOnPrivate(host: ExtensionHost, method: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return vi.spyOn(host as any, method) +} + +describe("ExtensionHost", () => { + beforeEach(() => { + vi.resetAllMocks() + // Clean up globals + delete (global as Record).vscode + delete (global as Record).__extensionHost + }) + + describe("constructor", () => { + it("should store options correctly", () => { + const options: ExtensionHostOptions = { + mode: "code", + workspacePath: "/my/workspace", + extensionPath: "/my/extension", + verbose: true, + quiet: true, + apiKey: "test-key", + apiProvider: "openrouter", + model: "test-model", + } + + const host = new ExtensionHost(options) + + expect(getPrivate(host, "options")).toEqual(options) + }) + + it("should be an EventEmitter instance", () => { + const host = createTestHost() + expect(host).toBeInstanceOf(EventEmitter) + }) + + it("should initialize with default state values", () => { + const host = createTestHost() + + expect(getPrivate(host, "isWebviewReady")).toBe(false) + expect(getPrivate(host, "pendingMessages")).toEqual([]) + expect(getPrivate(host, "vscode")).toBeNull() + expect(getPrivate(host, "extensionModule")).toBeNull() + }) + }) + + describe("buildApiConfiguration", () => { + it.each([ + [ + "anthropic", + "test-key", + "test-model", + { apiProvider: "anthropic", apiKey: "test-key", apiModelId: "test-model" }, + ], + [ + "openrouter", + "or-key", + "or-model", + { + apiProvider: "openrouter", + openRouterApiKey: "or-key", + openRouterModelId: "or-model", + }, + ], + [ + "gemini", + "gem-key", + "gem-model", + { apiProvider: "gemini", geminiApiKey: "gem-key", apiModelId: "gem-model" }, + ], + [ + "openai-native", + "oai-key", + "oai-model", + { apiProvider: "openai-native", openAiNativeApiKey: "oai-key", apiModelId: "oai-model" }, + ], + [ + "openai", + "oai-key", + "oai-model", + { apiProvider: "openai", openAiApiKey: "oai-key", openAiModelId: "oai-model" }, + ], + [ + "mistral", + "mis-key", + "mis-model", + { apiProvider: "mistral", mistralApiKey: "mis-key", apiModelId: "mis-model" }, + ], + [ + "deepseek", + "ds-key", + "ds-model", + { apiProvider: "deepseek", deepSeekApiKey: "ds-key", apiModelId: "ds-model" }, + ], + ["xai", "xai-key", "xai-model", { apiProvider: "xai", xaiApiKey: "xai-key", apiModelId: "xai-model" }], + [ + "groq", + "groq-key", + "groq-model", + { apiProvider: "groq", groqApiKey: "groq-key", apiModelId: "groq-model" }, + ], + [ + "fireworks", + "fw-key", + "fw-model", + { apiProvider: "fireworks", fireworksApiKey: "fw-key", apiModelId: "fw-model" }, + ], + [ + "cerebras", + "cer-key", + "cer-model", + { apiProvider: "cerebras", cerebrasApiKey: "cer-key", apiModelId: "cer-model" }, + ], + [ + "sambanova", + "sn-key", + "sn-model", + { apiProvider: "sambanova", sambaNovaApiKey: "sn-key", apiModelId: "sn-model" }, + ], + [ + "ollama", + "oll-key", + "oll-model", + { apiProvider: "ollama", ollamaApiKey: "oll-key", ollamaModelId: "oll-model" }, + ], + ["lmstudio", undefined, "lm-model", { apiProvider: "lmstudio", lmStudioModelId: "lm-model" }], + [ + "litellm", + "lite-key", + "lite-model", + { apiProvider: "litellm", litellmApiKey: "lite-key", litellmModelId: "lite-model" }, + ], + [ + "huggingface", + "hf-key", + "hf-model", + { apiProvider: "huggingface", huggingFaceApiKey: "hf-key", huggingFaceModelId: "hf-model" }, + ], + ["chutes", "ch-key", "ch-model", { apiProvider: "chutes", chutesApiKey: "ch-key", apiModelId: "ch-model" }], + [ + "featherless", + "fl-key", + "fl-model", + { apiProvider: "featherless", featherlessApiKey: "fl-key", apiModelId: "fl-model" }, + ], + [ + "unbound", + "ub-key", + "ub-model", + { apiProvider: "unbound", unboundApiKey: "ub-key", unboundModelId: "ub-model" }, + ], + [ + "requesty", + "req-key", + "req-model", + { apiProvider: "requesty", requestyApiKey: "req-key", requestyModelId: "req-model" }, + ], + [ + "deepinfra", + "di-key", + "di-model", + { apiProvider: "deepinfra", deepInfraApiKey: "di-key", deepInfraModelId: "di-model" }, + ], + [ + "vercel-ai-gateway", + "vai-key", + "vai-model", + { + apiProvider: "vercel-ai-gateway", + vercelAiGatewayApiKey: "vai-key", + vercelAiGatewayModelId: "vai-model", + }, + ], + ["zai", "zai-key", "zai-model", { apiProvider: "zai", zaiApiKey: "zai-key", apiModelId: "zai-model" }], + [ + "baseten", + "bt-key", + "bt-model", + { apiProvider: "baseten", basetenApiKey: "bt-key", apiModelId: "bt-model" }, + ], + ["doubao", "db-key", "db-model", { apiProvider: "doubao", doubaoApiKey: "db-key", apiModelId: "db-model" }], + [ + "moonshot", + "ms-key", + "ms-model", + { apiProvider: "moonshot", moonshotApiKey: "ms-key", apiModelId: "ms-model" }, + ], + [ + "minimax", + "mm-key", + "mm-model", + { apiProvider: "minimax", minimaxApiKey: "mm-key", apiModelId: "mm-model" }, + ], + [ + "io-intelligence", + "io-key", + "io-model", + { apiProvider: "io-intelligence", ioIntelligenceApiKey: "io-key", ioIntelligenceModelId: "io-model" }, + ], + ])("should configure %s provider correctly", (provider, apiKey, model, expected) => { + const host = createTestHost({ + apiProvider: provider as ProviderName, + apiKey, + model, + }) + + const config = callPrivate>(host, "buildApiConfiguration") + + expect(config).toEqual(expected) + }) + + it("should use default provider when not specified", () => { + const host = createTestHost({ + apiKey: "test-key", + model: "test-model", + }) + + const config = callPrivate>(host, "buildApiConfiguration") + + expect(config.apiProvider).toBe("openrouter") + }) + + it("should handle missing apiKey gracefully", () => { + const host = createTestHost({ + apiProvider: "anthropic", + model: "test-model", + }) + + const config = callPrivate>(host, "buildApiConfiguration") + + expect(config.apiProvider).toBe("anthropic") + expect(config.apiKey).toBeUndefined() + expect(config.apiModelId).toBe("test-model") + }) + + it("should use default config for unknown providers", () => { + const host = createTestHost({ + apiProvider: "unknown-provider" as ProviderName, + apiKey: "test-key", + model: "test-model", + }) + + const config = callPrivate>(host, "buildApiConfiguration") + + expect(config.apiProvider).toBe("unknown-provider") + expect(config.apiKey).toBe("test-key") + expect(config.apiModelId).toBe("test-model") + }) + }) + + describe("webview provider registration", () => { + it("should register webview provider", () => { + const host = createTestHost() + const mockProvider = { resolveWebviewView: vi.fn() } + + host.registerWebviewProvider("test-view", mockProvider) + + const providers = getPrivate>(host, "webviewProviders") + expect(providers.get("test-view")).toBe(mockProvider) + }) + + it("should unregister webview provider", () => { + const host = createTestHost() + const mockProvider = { resolveWebviewView: vi.fn() } + + host.registerWebviewProvider("test-view", mockProvider) + host.unregisterWebviewProvider("test-view") + + const providers = getPrivate>(host, "webviewProviders") + expect(providers.has("test-view")).toBe(false) + }) + + it("should handle unregistering non-existent provider gracefully", () => { + const host = createTestHost() + + expect(() => { + host.unregisterWebviewProvider("non-existent") + }).not.toThrow() + }) + }) + + describe("webview ready state", () => { + describe("isInInitialSetup", () => { + it("should return true before webview is ready", () => { + const host = createTestHost() + expect(host.isInInitialSetup()).toBe(true) + }) + + it("should return false after markWebviewReady is called", () => { + const host = createTestHost() + host.markWebviewReady() + expect(host.isInInitialSetup()).toBe(false) + }) + }) + + describe("markWebviewReady", () => { + it("should set isWebviewReady to true", () => { + const host = createTestHost() + host.markWebviewReady() + expect(getPrivate(host, "isWebviewReady")).toBe(true) + }) + + it("should emit webviewReady event", () => { + const host = createTestHost() + const listener = vi.fn() + + host.on("webviewReady", listener) + host.markWebviewReady() + + expect(listener).toHaveBeenCalled() + }) + + it("should flush pending messages", () => { + const host = createTestHost() + const emitSpy = vi.spyOn(host, "emit") + + // Queue messages before ready + host.sendToExtension({ type: "test1" }) + host.sendToExtension({ type: "test2" }) + + // Mark ready (should flush) + host.markWebviewReady() + + // Check that webviewMessage events were emitted for pending messages + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test1" }) + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test2" }) + }) + }) + }) + + describe("sendToExtension", () => { + it("should queue message when webview not ready", () => { + const host = createTestHost() + const message = { type: "test" } + + host.sendToExtension(message) + + const pending = getPrivate(host, "pendingMessages") + expect(pending).toContain(message) + }) + + it("should emit webviewMessage event when webview is ready", () => { + const host = createTestHost() + const emitSpy = vi.spyOn(host, "emit") + const message = { type: "test" } + + host.markWebviewReady() + host.sendToExtension(message) + + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", message) + }) + + it("should not queue message when webview is ready", () => { + const host = createTestHost() + + host.markWebviewReady() + host.sendToExtension({ type: "test" }) + + const pending = getPrivate(host, "pendingMessages") + expect(pending).toHaveLength(0) + }) + }) + + describe("handleExtensionMessage", () => { + it("should route state messages to handleStateMessage", () => { + const host = createTestHost() + const handleStateSpy = spyOnPrivate(host, "handleStateMessage") + + callPrivate(host, "handleExtensionMessage", { type: "state", state: {} }) + + expect(handleStateSpy).toHaveBeenCalled() + }) + + it("should route messageUpdated to handleMessageUpdated", () => { + const host = createTestHost() + const handleMsgUpdatedSpy = spyOnPrivate(host, "handleMessageUpdated") + + callPrivate(host, "handleExtensionMessage", { type: "messageUpdated", clineMessage: {} }) + + expect(handleMsgUpdatedSpy).toHaveBeenCalled() + }) + + it("should route action messages to handleActionMessage", () => { + const host = createTestHost() + const handleActionSpy = spyOnPrivate(host, "handleActionMessage") + + callPrivate(host, "handleExtensionMessage", { type: "action", action: "test" }) + + expect(handleActionSpy).toHaveBeenCalled() + }) + + it("should route invoke messages to handleInvokeMessage", () => { + const host = createTestHost() + const handleInvokeSpy = spyOnPrivate(host, "handleInvokeMessage") + + callPrivate(host, "handleExtensionMessage", { type: "invoke", invoke: "test" }) + + expect(handleInvokeSpy).toHaveBeenCalled() + }) + }) + + describe("handleSayMessage", () => { + let host: ExtensionHost + let outputSpy: ReturnType + let outputErrorSpy: ReturnType + + beforeEach(() => { + host = createTestHost() + // Mock process.stdout.write and process.stderr.write which are used by output() and outputError() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + vi.spyOn(process.stderr, "write").mockImplementation(() => true) + // Spy on the output methods + outputSpy = spyOnPrivate(host, "output") + outputErrorSpy = spyOnPrivate(host, "outputError") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should emit taskComplete for completion_result", () => { + const emitSpy = vi.spyOn(host, "emit") + + callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false) + + expect(emitSpy).toHaveBeenCalledWith("taskComplete") + expect(outputSpy).toHaveBeenCalledWith("\n[task complete]", "Task done") + }) + + it("should output error messages without emitting taskError", () => { + const emitSpy = vi.spyOn(host, "emit") + + callPrivate(host, "handleSayMessage", 123, "error", "Something went wrong", false) + + // Errors are informational - they don't terminate the task + // The agent should decide what to do next + expect(emitSpy).not.toHaveBeenCalledWith("taskError", "Something went wrong") + expect(outputErrorSpy).toHaveBeenCalledWith("\n[error]", "Something went wrong") + }) + + it("should handle command_output messages", () => { + // Mock writeStream since command_output now uses it directly + const writeStreamSpy = spyOnPrivate(host, "writeStream") + + callPrivate(host, "handleSayMessage", 123, "command_output", "output text", false) + + // command_output now uses writeStream to bypass quiet mode + expect(writeStreamSpy).toHaveBeenCalledWith("\n[command output] ") + expect(writeStreamSpy).toHaveBeenCalledWith("output text") + expect(writeStreamSpy).toHaveBeenCalledWith("\n") + }) + + it("should handle tool messages", () => { + callPrivate(host, "handleSayMessage", 123, "tool", "tool usage", false) + + expect(outputSpy).toHaveBeenCalledWith("\n[tool]", "tool usage") + }) + + it("should skip already displayed complete messages", () => { + // First display + callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false) + outputSpy.mockClear() + + // Second display should be skipped + callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false) + + expect(outputSpy).not.toHaveBeenCalled() + }) + + it("should not output completion_result for partial messages", () => { + const emitSpy = vi.spyOn(host, "emit") + + // Partial message should not trigger output or taskComplete + callPrivate(host, "handleSayMessage", 123, "completion_result", "", true) + + expect(outputSpy).not.toHaveBeenCalled() + expect(emitSpy).not.toHaveBeenCalledWith("taskComplete") + }) + + it("should output completion_result text when complete message arrives after partial", () => { + const emitSpy = vi.spyOn(host, "emit") + + // First, a partial message with empty text (simulates streaming) + callPrivate(host, "handleSayMessage", 123, "completion_result", "", true) + outputSpy.mockClear() + emitSpy.mockClear() + + // Then, the complete message with the actual completion text + callPrivate(host, "handleSayMessage", 123, "completion_result", "Task completed successfully!", false) + + expect(outputSpy).toHaveBeenCalledWith("\n[task complete]", "Task completed successfully!") + expect(emitSpy).toHaveBeenCalledWith("taskComplete") + }) + + it("should track displayed messages", () => { + callPrivate(host, "handleSayMessage", 123, "tool", "test", false) + + const displayed = getPrivate>(host, "displayedMessages") + expect(displayed.has(123)).toBe(true) + }) + }) + + describe("handleAskMessage", () => { + let host: ExtensionHost + let outputSpy: ReturnType + + beforeEach(() => { + // Use nonInteractive mode for display-only behavior tests + host = createTestHost({ nonInteractive: true }) + // Mock process.stdout.write which is used by output() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + outputSpy = spyOnPrivate(host, "output") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should handle command type in non-interactive mode", () => { + callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) + + expect(outputSpy).toHaveBeenCalledWith("\n[command]", "ls -la") + }) + + it("should handle tool type with JSON parsing in non-interactive mode", () => { + const toolInfo = JSON.stringify({ tool: "write_file", path: "/test/file.txt" }) + + callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) + + expect(outputSpy).toHaveBeenCalledWith("\n[tool] write_file") + expect(outputSpy).toHaveBeenCalledWith(" path: /test/file.txt") + }) + + it("should handle tool type with content preview in non-interactive mode", () => { + const toolInfo = JSON.stringify({ + tool: "write_file", + content: "This is the content that will be written to the file. It might be long.", + }) + + callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) + + // Content is now shown (all tool parameters are displayed) + expect(outputSpy).toHaveBeenCalledWith("\n[tool] write_file") + expect(outputSpy).toHaveBeenCalledWith( + " content: This is the content that will be written to the file. It might be long.", + ) + }) + + it("should handle tool type with invalid JSON in non-interactive mode", () => { + callPrivate(host, "handleAskMessage", 123, "tool", "not json", false) + + expect(outputSpy).toHaveBeenCalledWith("\n[tool]", "not json") + }) + + it("should not display duplicate messages for same ts", () => { + const toolInfo = JSON.stringify({ tool: "read_file" }) + + // First call + callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) + outputSpy.mockClear() + + // Same ts - should be duplicate (already displayed) + callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) + + // Should not log again + expect(outputSpy).not.toHaveBeenCalled() + }) + + it("should handle other ask types in non-interactive mode", () => { + callPrivate(host, "handleAskMessage", 123, "question", "What is your name?", false) + + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What is your name?") + }) + + it("should skip partial messages", () => { + callPrivate(host, "handleAskMessage", 123, "command", "ls -la", true) + + // Partial messages should be skipped + expect(outputSpy).not.toHaveBeenCalled() + }) + }) + + describe("handleAskMessage - interactive mode", () => { + let host: ExtensionHost + let outputSpy: ReturnType + + beforeEach(() => { + // Default interactive mode + host = createTestHost({ nonInteractive: false }) + // Mock process.stdout.write which is used by output() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + outputSpy = spyOnPrivate(host, "output") + // Mock readline to prevent actual prompting + vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should mark ask as pending in interactive mode", () => { + // This will try to prompt, but we're testing the pendingAsks tracking + callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) + + const pendingAsks = getPrivate>(host, "pendingAsks") + expect(pendingAsks.has(123)).toBe(true) + }) + + it("should skip already pending asks", () => { + // First call - marks as pending + callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) + const callCount1 = outputSpy.mock.calls.length + + // Second call - should skip + callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) + const callCount2 = outputSpy.mock.calls.length + + // Should not have logged again + expect(callCount2).toBe(callCount1) + }) + }) + + describe("handleFollowupQuestion", () => { + let host: ExtensionHost + let outputSpy: ReturnType + + beforeEach(() => { + host = createTestHost({ nonInteractive: false }) + // Mock process.stdout.write which is used by output() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + outputSpy = spyOnPrivate(host, "output") + // Mock readline to prevent actual prompting + vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should parse followup question JSON with suggestion objects containing answer and mode", async () => { + // This is the format from AskFollowupQuestionTool + // { question: "...", suggest: [{ answer: "text", mode: "code" }, ...] } + const text = JSON.stringify({ + question: "What would you like to do?", + suggest: [ + { answer: "Write code", mode: "code" }, + { answer: "Debug issue", mode: "debug" }, + { answer: "Just explain", mode: null }, + ], + }) + + // Call the handler (it will try to prompt but we just want to test parsing) + callPrivate(host, "handleFollowupQuestion", 123, text) + + // Should display the question + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What would you like to do?") + + // Should display suggestions with answer text and mode hints + expect(outputSpy).toHaveBeenCalledWith("\nSuggested answers:") + expect(outputSpy).toHaveBeenCalledWith(" 1. Write code (mode: code)") + expect(outputSpy).toHaveBeenCalledWith(" 2. Debug issue (mode: debug)") + expect(outputSpy).toHaveBeenCalledWith(" 3. Just explain") + }) + + it("should handle followup question with suggestions that have no mode", async () => { + const text = JSON.stringify({ + question: "What path?", + suggest: [{ answer: "./src/file.ts" }, { answer: "./lib/other.ts" }], + }) + + callPrivate(host, "handleFollowupQuestion", 123, text) + + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What path?") + expect(outputSpy).toHaveBeenCalledWith(" 1. ./src/file.ts") + expect(outputSpy).toHaveBeenCalledWith(" 2. ./lib/other.ts") + }) + + it("should handle plain text (non-JSON) as the question", async () => { + callPrivate(host, "handleFollowupQuestion", 123, "What is your name?") + + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What is your name?") + }) + + it("should handle empty suggestions array", async () => { + const text = JSON.stringify({ + question: "Tell me more", + suggest: [], + }) + + callPrivate(host, "handleFollowupQuestion", 123, text) + + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "Tell me more") + // Should not show "Suggested answers:" if array is empty + expect(outputSpy).not.toHaveBeenCalledWith("\nSuggested answers:") + }) + }) + + describe("handleFollowupQuestionWithTimeout", () => { + let host: ExtensionHost + let outputSpy: ReturnType + const originalIsTTY = process.stdin.isTTY + + beforeEach(() => { + // Non-interactive mode uses the timeout variant + host = createTestHost({ nonInteractive: true }) + // Mock process.stdout.write which is used by output() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + outputSpy = spyOnPrivate(host, "output") + // Mock stdin - set isTTY to false so setRawMode is not called + Object.defineProperty(process.stdin, "isTTY", { value: false, writable: true }) + vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "removeListener").mockImplementation(() => process.stdin) + }) + + afterEach(() => { + vi.restoreAllMocks() + Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, writable: true }) + }) + + it("should parse followup question JSON and display question with suggestions", () => { + const text = JSON.stringify({ + question: "What would you like to do?", + suggest: [ + { answer: "Option A", mode: "code" }, + { answer: "Option B", mode: null }, + ], + }) + + // Call the handler - it will display the question and start the timeout + callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text) + + // Should display the question + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What would you like to do?") + + // Should display suggestions + expect(outputSpy).toHaveBeenCalledWith("\nSuggested answers:") + expect(outputSpy).toHaveBeenCalledWith(" 1. Option A (mode: code)") + expect(outputSpy).toHaveBeenCalledWith(" 2. Option B") + }) + + it("should handle non-JSON text as plain question", () => { + callPrivate(host, "handleFollowupQuestionWithTimeout", 123, "Plain question text") + + expect(outputSpy).toHaveBeenCalledWith("\n[question]", "Plain question text") + }) + + it("should include auto-select hint in prompt when suggestions exist", () => { + const stdoutWriteSpy = vi.spyOn(process.stdout, "write") + const text = JSON.stringify({ + question: "Choose one", + suggest: [{ answer: "First option" }], + }) + + callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text) + + // Should show prompt with timeout hint + expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 10s")) + }) + }) + + describe("handleAskMessageNonInteractive - followup handling", () => { + let host: ExtensionHost + let _outputSpy: ReturnType + let handleFollowupTimeoutSpy: ReturnType + const originalIsTTY = process.stdin.isTTY + + beforeEach(() => { + host = createTestHost({ nonInteractive: true }) + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + _outputSpy = spyOnPrivate(host, "output") + handleFollowupTimeoutSpy = spyOnPrivate(host, "handleFollowupQuestionWithTimeout") + // Mock stdin - set isTTY to false so setRawMode is not called + Object.defineProperty(process.stdin, "isTTY", { value: false, writable: true }) + vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin) + vi.spyOn(process.stdin, "removeListener").mockImplementation(() => process.stdin) + }) + + afterEach(() => { + vi.restoreAllMocks() + Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, writable: true }) + }) + + it("should call handleFollowupQuestionWithTimeout for followup asks in non-interactive mode", () => { + const text = JSON.stringify({ + question: "What to do?", + suggest: [{ answer: "Do something" }], + }) + + callPrivate(host, "handleAskMessageNonInteractive", 123, "followup", text) + + expect(handleFollowupTimeoutSpy).toHaveBeenCalledWith(123, text) + }) + + it("should add ts to pendingAsks for followup in non-interactive mode", () => { + const text = JSON.stringify({ + question: "What to do?", + suggest: [{ answer: "Do something" }], + }) + + callPrivate(host, "handleAskMessageNonInteractive", 123, "followup", text) + + const pendingAsks = getPrivate>(host, "pendingAsks") + expect(pendingAsks.has(123)).toBe(true) + }) + }) + + describe("streamContent", () => { + let host: ExtensionHost + let writeStreamSpy: ReturnType + + beforeEach(() => { + host = createTestHost() + // Mock process.stdout.write + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + writeStreamSpy = spyOnPrivate(host, "writeStream") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should output header and text for new messages", () => { + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + + expect(writeStreamSpy).toHaveBeenCalledWith("\n[Test] ") + expect(writeStreamSpy).toHaveBeenCalledWith("Hello") + }) + + it("should compute delta for growing text", () => { + // First call - establishes baseline + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + writeStreamSpy.mockClear() + + // Second call - should only output delta + callPrivate(host, "streamContent", 123, "Hello World", "[Test]") + + expect(writeStreamSpy).toHaveBeenCalledWith(" World") + }) + + it("should skip when text has not grown", () => { + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + writeStreamSpy.mockClear() + + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + + expect(writeStreamSpy).not.toHaveBeenCalled() + }) + + it("should skip when text does not match prefix", () => { + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + writeStreamSpy.mockClear() + + // Different text entirely + callPrivate(host, "streamContent", 123, "Goodbye", "[Test]") + + expect(writeStreamSpy).not.toHaveBeenCalled() + }) + + it("should track currently streaming ts", () => { + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + + expect(getPrivate(host, "currentlyStreamingTs")).toBe(123) + }) + }) + + describe("finishStream", () => { + let host: ExtensionHost + let writeStreamSpy: ReturnType + + beforeEach(() => { + host = createTestHost() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + writeStreamSpy = spyOnPrivate(host, "writeStream") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should add newline when finishing current stream", () => { + // Set up streaming state + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + writeStreamSpy.mockClear() + + callPrivate(host, "finishStream", 123) + + expect(writeStreamSpy).toHaveBeenCalledWith("\n") + expect(getPrivate(host, "currentlyStreamingTs")).toBeNull() + }) + + it("should not add newline for different ts", () => { + callPrivate(host, "streamContent", 123, "Hello", "[Test]") + writeStreamSpy.mockClear() + + callPrivate(host, "finishStream", 456) + + expect(writeStreamSpy).not.toHaveBeenCalled() + }) + }) + + describe("quiet mode", () => { + describe("setupQuietMode", () => { + it("should not modify console when quiet mode disabled", () => { + const host = createTestHost({ quiet: false }) + const originalLog = console.log + + callPrivate(host, "setupQuietMode") + + expect(console.log).toBe(originalLog) + }) + + it("should suppress console.log, warn, debug, info when enabled", () => { + const host = createTestHost({ quiet: true }) + const originalLog = console.log + + callPrivate(host, "setupQuietMode") + + // These should be no-ops now (different from original) + expect(console.log).not.toBe(originalLog) + + // Verify they are actually no-ops by calling them (should not throw) + expect(() => console.log("test")).not.toThrow() + expect(() => console.warn("test")).not.toThrow() + expect(() => console.debug("test")).not.toThrow() + expect(() => console.info("test")).not.toThrow() + + // Restore for other tests + callPrivate(host, "restoreConsole") + }) + + it("should preserve console.error", () => { + const host = createTestHost({ quiet: true }) + const originalError = console.error + + callPrivate(host, "setupQuietMode") + + expect(console.error).toBe(originalError) + + callPrivate(host, "restoreConsole") + }) + + it("should store original console methods", () => { + const host = createTestHost({ quiet: true }) + const originalLog = console.log + + callPrivate(host, "setupQuietMode") + + const stored = getPrivate<{ log: typeof console.log }>(host, "originalConsole") + expect(stored.log).toBe(originalLog) + + callPrivate(host, "restoreConsole") + }) + }) + + describe("restoreConsole", () => { + it("should restore original console methods", () => { + const host = createTestHost({ quiet: true }) + const originalLog = console.log + + callPrivate(host, "setupQuietMode") + callPrivate(host, "restoreConsole") + + expect(console.log).toBe(originalLog) + }) + + it("should handle case where console was not suppressed", () => { + const host = createTestHost({ quiet: false }) + + expect(() => { + callPrivate(host, "restoreConsole") + }).not.toThrow() + }) + }) + + describe("suppressNodeWarnings", () => { + it("should suppress process.emitWarning", () => { + const host = createTestHost() + const originalEmitWarning = process.emitWarning + + callPrivate(host, "suppressNodeWarnings") + + expect(process.emitWarning).not.toBe(originalEmitWarning) + + // Restore + callPrivate(host, "restoreConsole") + }) + }) + }) + + describe("dispose", () => { + let host: ExtensionHost + + beforeEach(() => { + host = createTestHost() + }) + + it("should remove message listener", async () => { + const listener = vi.fn() + ;(host as unknown as Record).messageListener = listener + host.on("extensionWebviewMessage", listener) + + await host.dispose() + + expect(getPrivate(host, "messageListener")).toBeNull() + }) + + it("should call extension deactivate if available", async () => { + const deactivateMock = vi.fn() + ;(host as unknown as Record).extensionModule = { + deactivate: deactivateMock, + } + + await host.dispose() + + expect(deactivateMock).toHaveBeenCalled() + }) + + it("should clear vscode reference", async () => { + ;(host as unknown as Record).vscode = { context: {} } + + await host.dispose() + + expect(getPrivate(host, "vscode")).toBeNull() + }) + + it("should clear extensionModule reference", async () => { + ;(host as unknown as Record).extensionModule = {} + + await host.dispose() + + expect(getPrivate(host, "extensionModule")).toBeNull() + }) + + it("should clear webviewProviders", async () => { + host.registerWebviewProvider("test", {}) + + await host.dispose() + + const providers = getPrivate>(host, "webviewProviders") + expect(providers.size).toBe(0) + }) + + it("should delete global vscode", async () => { + ;(global as Record).vscode = {} + + await host.dispose() + + expect((global as Record).vscode).toBeUndefined() + }) + + it("should delete global __extensionHost", async () => { + ;(global as Record).__extensionHost = {} + + await host.dispose() + + expect((global as Record).__extensionHost).toBeUndefined() + }) + + it("should restore console if it was suppressed", async () => { + const restoreConsoleSpy = spyOnPrivate(host, "restoreConsole") + + await host.dispose() + + expect(restoreConsoleSpy).toHaveBeenCalled() + }) + }) + + describe("waitForCompletion", () => { + it("should resolve when taskComplete is emitted", async () => { + const host = createTestHost() + + const promise = callPrivate>(host, "waitForCompletion") + + // Emit completion after a short delay + setTimeout(() => host.emit("taskComplete"), 10) + + await expect(promise).resolves.toBeUndefined() + }) + + it("should reject when taskError is emitted", async () => { + const host = createTestHost() + + const promise = callPrivate>(host, "waitForCompletion") + + setTimeout(() => host.emit("taskError", "Test error"), 10) + + await expect(promise).rejects.toThrow("Test error") + }) + + it("should timeout after configured duration", async () => { + const host = createTestHost() + + // Use fake timers for this test + vi.useFakeTimers() + + const promise = callPrivate>(host, "waitForCompletion") + + // Fast-forward past the timeout (10 minutes) + vi.advanceTimersByTime(10 * 60 * 1000 + 1) + + await expect(promise).rejects.toThrow("Task timed out") + + vi.useRealTimers() + }) + }) +}) diff --git a/apps/cli/src/__tests__/integration.test.ts b/apps/cli/src/__tests__/integration.test.ts new file mode 100644 index 0000000000..158438decb --- /dev/null +++ b/apps/cli/src/__tests__/integration.test.ts @@ -0,0 +1,144 @@ +/** + * Integration tests for CLI + * + * These tests require a valid OPENROUTER_API_KEY environment variable. + * They will be skipped if the API key is not available. + * + * Run with: OPENROUTER_API_KEY=sk-or-v1-... pnpm test + */ + +import { ExtensionHost } from "../extension-host.js" +import path from "path" +import fs from "fs" +import os from "os" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY +const hasApiKey = !!OPENROUTER_API_KEY + +// Find the extension path - we need a built extension for integration tests +function findExtensionPath(): string | null { + // From apps/cli/src/__tests__, go up to monorepo root then to src/dist + const monorepoPath = path.resolve(__dirname, "../../../../src/dist") + if (fs.existsSync(path.join(monorepoPath, "extension.js"))) { + return monorepoPath + } + // Also try from the apps/cli level + const altPath = path.resolve(__dirname, "../../../src/dist") + if (fs.existsSync(path.join(altPath, "extension.js"))) { + return altPath + } + return null +} + +const extensionPath = findExtensionPath() +const hasExtension = !!extensionPath + +// Create a temporary workspace directory for tests +function createTempWorkspace(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "roo-cli-test-")) + return tempDir +} + +// Clean up temporary workspace +function cleanupWorkspace(workspacePath: string): void { + try { + fs.rmSync(workspacePath, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } +} + +describe.skipIf(!hasApiKey || !hasExtension)( + "CLI Integration Tests (requires OPENROUTER_API_KEY and built extension)", + () => { + let workspacePath: string + let host: ExtensionHost + + beforeAll(() => { + console.log("Integration tests running with:") + console.log(` - API Key: ${OPENROUTER_API_KEY?.substring(0, 12)}...`) + console.log(` - Extension Path: ${extensionPath}`) + }) + + beforeEach(() => { + workspacePath = createTempWorkspace() + }) + + afterEach(async () => { + if (host) { + await host.dispose() + } + cleanupWorkspace(workspacePath) + }) + + /** + * Main integration test - tests the complete end-to-end flow + * + * NOTE: Due to the extension using singletons (TelemetryService, etc.), + * only one integration test can run per process. This single test covers + * the main functionality: activation, task execution, completion, and disposal. + */ + it("should complete end-to-end task execution with proper lifecycle", async () => { + host = new ExtensionHost({ + mode: "code", + apiProvider: "openrouter", + apiKey: OPENROUTER_API_KEY!, + model: "anthropic/claude-haiku-4.5", // Use fast, cheap model for tests. + workspacePath, + extensionPath: extensionPath!, + quiet: true, + }) + + // Test activation + await host.activate() + + // Track state messages + const stateMessages: unknown[] = [] + host.on("extensionWebviewMessage", (msg: Record) => { + if (msg.type === "state") { + stateMessages.push(msg) + } + }) + + // Test task execution with completion + // Note: runTask internally waits for webview to be ready before sending messages + await expect(host.runTask("Say hello in exactly 5 words")).resolves.toBeUndefined() + + // After task completes, webview should have been ready + expect(host.isInInitialSetup()).toBe(false) + + // Verify we received state updates + expect(stateMessages.length).toBeGreaterThan(0) + + // Test disposal + await host.dispose() + expect((global as Record).vscode).toBeUndefined() + expect((global as Record).__extensionHost).toBeUndefined() + }, 120000) // 2 minute timeout + }, +) + +// Additional test to verify skip behavior +describe("Integration test skip behavior", () => { + it("should have OPENROUTER_API_KEY check", () => { + if (hasApiKey) { + console.log("OPENROUTER_API_KEY is set, integration tests will run") + } else { + console.log("OPENROUTER_API_KEY is not set, integration tests will be skipped") + } + expect(true).toBe(true) // Always passes + }) + + it("should have extension check", () => { + if (hasExtension) { + console.log(`Extension found at: ${extensionPath}`) + } else { + console.log("Extension not found, integration tests will be skipped") + } + expect(true).toBe(true) // Always passes + }) +}) diff --git a/apps/cli/src/__tests__/utils.test.ts b/apps/cli/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..34ce825463 --- /dev/null +++ b/apps/cli/src/__tests__/utils.test.ts @@ -0,0 +1,119 @@ +/** + * Unit tests for CLI utility functions + */ + +import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js" +import fs from "fs" +import path from "path" + +// Mock fs module +vi.mock("fs") + +describe("getEnvVarName", () => { + it.each([ + ["anthropic", "ANTHROPIC_API_KEY"], + ["openai", "OPENAI_API_KEY"], + ["openrouter", "OPENROUTER_API_KEY"], + ["google", "GOOGLE_API_KEY"], + ["gemini", "GOOGLE_API_KEY"], + ["bedrock", "AWS_ACCESS_KEY_ID"], + ["ollama", "OLLAMA_API_KEY"], + ["mistral", "MISTRAL_API_KEY"], + ["deepseek", "DEEPSEEK_API_KEY"], + ])("should return %s for %s provider", (provider, expectedEnvVar) => { + expect(getEnvVarName(provider)).toBe(expectedEnvVar) + }) + + it("should handle case-insensitive provider names", () => { + expect(getEnvVarName("ANTHROPIC")).toBe("ANTHROPIC_API_KEY") + expect(getEnvVarName("Anthropic")).toBe("ANTHROPIC_API_KEY") + expect(getEnvVarName("OpenRouter")).toBe("OPENROUTER_API_KEY") + }) + + it("should return uppercase provider name with _API_KEY suffix for unknown providers", () => { + expect(getEnvVarName("custom")).toBe("CUSTOM_API_KEY") + expect(getEnvVarName("myProvider")).toBe("MYPROVIDER_API_KEY") + }) +}) + +describe("getApiKeyFromEnv", () => { + const originalEnv = process.env + + beforeEach(() => { + // Reset process.env before each test + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it("should return API key from environment variable for anthropic", () => { + process.env.ANTHROPIC_API_KEY = "test-anthropic-key" + expect(getApiKeyFromEnv("anthropic")).toBe("test-anthropic-key") + }) + + it("should return API key from environment variable for openrouter", () => { + process.env.OPENROUTER_API_KEY = "test-openrouter-key" + expect(getApiKeyFromEnv("openrouter")).toBe("test-openrouter-key") + }) + + it("should return API key from environment variable for openai", () => { + process.env.OPENAI_API_KEY = "test-openai-key" + expect(getApiKeyFromEnv("openai")).toBe("test-openai-key") + }) + + it("should return undefined when API key is not set", () => { + delete process.env.ANTHROPIC_API_KEY + expect(getApiKeyFromEnv("anthropic")).toBeUndefined() + }) + + it("should handle custom provider names", () => { + process.env.CUSTOM_API_KEY = "test-custom-key" + expect(getApiKeyFromEnv("custom")).toBe("test-custom-key") + }) + + it("should handle case-insensitive provider lookup", () => { + process.env.ANTHROPIC_API_KEY = "test-key" + expect(getApiKeyFromEnv("ANTHROPIC")).toBe("test-key") + }) +}) + +describe("getDefaultExtensionPath", () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it("should return monorepo path when extension.js exists there", () => { + const mockDirname = "/test/apps/cli/dist" + const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist") + + vi.mocked(fs.existsSync).mockReturnValue(true) + + const result = getDefaultExtensionPath(mockDirname) + + expect(result).toBe(expectedMonorepoPath) + expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js")) + }) + + it("should return package path when extension.js does not exist in monorepo path", () => { + const mockDirname = "/test/apps/cli/dist" + const expectedPackagePath = path.resolve(mockDirname, "../extension") + + vi.mocked(fs.existsSync).mockReturnValue(false) + + const result = getDefaultExtensionPath(mockDirname) + + expect(result).toBe(expectedPackagePath) + }) + + it("should check monorepo path first", () => { + const mockDirname = "/some/path" + vi.mocked(fs.existsSync).mockReturnValue(false) + + getDefaultExtensionPath(mockDirname) + + const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist") + expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js")) + }) +}) diff --git a/apps/cli/src/extension-host.ts b/apps/cli/src/extension-host.ts new file mode 100644 index 0000000000..207b1b4ad5 --- /dev/null +++ b/apps/cli/src/extension-host.ts @@ -0,0 +1,1668 @@ +/** + * ExtensionHost - Loads and runs the Roo Code extension in CLI mode + * + * This class is responsible for: + * 1. Creating the vscode-shim mock + * 2. Loading the extension bundle via require() + * 3. Activating the extension + * 4. Managing bidirectional message flow between CLI and extension + */ + +import { EventEmitter } from "events" +import { createRequire } from "module" +import path from "path" +import { fileURLToPath } from "url" +import fs from "fs" +import readline from "readline" + +import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim" +import { ProviderName, ReasoningEffortExtended, RooCodeSettings } from "@roo-code/types" + +// Get the CLI package root directory (for finding node_modules/@vscode/ripgrep) +// When bundled, import.meta.url points to dist/index.js, so go up to package root +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const CLI_PACKAGE_ROOT = path.resolve(__dirname, "..") + +export interface ExtensionHostOptions { + mode: string + reasoningEffort?: ReasoningEffortExtended | "disabled" + apiProvider: ProviderName + apiKey?: string + model: string + workspacePath: string + extensionPath: string + verbose?: boolean + quiet?: boolean + nonInteractive?: boolean +} + +interface ExtensionModule { + activate: (context: unknown) => Promise + deactivate?: () => Promise +} + +/** + * Local interface for webview provider (matches VSCode API) + */ +interface WebviewViewProvider { + resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise +} + +export class ExtensionHost extends EventEmitter { + private vscode: ReturnType | null = null + private extensionModule: ExtensionModule | null = null + private extensionAPI: unknown = null + private webviewProviders: Map = new Map() + private options: ExtensionHostOptions + private isWebviewReady = false + private pendingMessages: unknown[] = [] + private messageListener: ((message: unknown) => void) | null = null + + private originalConsole: { + log: typeof console.log + warn: typeof console.warn + error: typeof console.error + debug: typeof console.debug + info: typeof console.info + } | null = null + + private originalProcessEmitWarning: typeof process.emitWarning | null = null + + // Track pending asks that need a response (by ts) + private pendingAsks: Set = new Set() + + // Readline interface for interactive prompts + private rl: readline.Interface | null = null + + // Track displayed messages by ts to avoid duplicates and show updates + private displayedMessages: Map = new Map() + + // Track streamed content by ts for delta computation + private streamedContent: Map = new Map() + + // Track message processing for verbose debug output + private processedMessageCount = 0 + + // Track if we're currently streaming a message (to manage newlines) + private currentlyStreamingTs: number | null = null + + constructor(options: ExtensionHostOptions) { + super() + this.options = options + } + + private log(...args: unknown[]): void { + if (this.options.verbose) { + // Use original console if available to avoid quiet mode suppression + const logFn = this.originalConsole?.log || console.log + logFn("[ExtensionHost]", ...args) + } + } + + /** + * Suppress Node.js warnings (like MaxListenersExceededWarning) + * This is called regardless of quiet mode to prevent warnings from interrupting output + */ + private suppressNodeWarnings(): void { + // Suppress process warnings (like MaxListenersExceededWarning) + this.originalProcessEmitWarning = process.emitWarning + process.emitWarning = () => {} + + // Also suppress via the warning event handler + process.on("warning", () => {}) + } + + /** + * Suppress console output from the extension when quiet mode is enabled. + * This intercepts console.log, console.warn, console.info, console.debug + * but allows console.error through for critical errors. + */ + private setupQuietMode(): void { + if (!this.options.quiet) { + return + } + + // Save original console methods + this.originalConsole = { + log: console.log, + warn: console.warn, + error: console.error, + debug: console.debug, + info: console.info, + } + + // Replace with no-op functions (except error) + console.log = () => {} + console.warn = () => {} + console.debug = () => {} + console.info = () => {} + // Keep console.error for critical errors + } + + /** + * Restore original console methods and process.emitWarning + */ + private restoreConsole(): void { + if (this.originalConsole) { + console.log = this.originalConsole.log + console.warn = this.originalConsole.warn + console.error = this.originalConsole.error + console.debug = this.originalConsole.debug + console.info = this.originalConsole.info + this.originalConsole = null + } + + if (this.originalProcessEmitWarning) { + process.emitWarning = this.originalProcessEmitWarning + this.originalProcessEmitWarning = null + } + } + + async activate(): Promise { + this.log("Activating extension...") + + // Suppress Node.js warnings (like MaxListenersExceededWarning) before anything else + this.suppressNodeWarnings() + + // Set up quiet mode before loading extension + this.setupQuietMode() + + // Verify extension path exists + const bundlePath = path.join(this.options.extensionPath, "extension.js") + if (!fs.existsSync(bundlePath)) { + this.restoreConsole() + throw new Error(`Extension bundle not found at: ${bundlePath}`) + } + + // 1. Create VSCode API mock + this.log("Creating VSCode API mock...") + this.log("Using appRoot:", CLI_PACKAGE_ROOT) + this.vscode = createVSCodeAPI( + this.options.extensionPath, + this.options.workspacePath, + undefined, // identity + { appRoot: CLI_PACKAGE_ROOT }, // options - point appRoot to CLI package for ripgrep + ) + + // 2. Set global vscode reference for the extension + ;(global as Record).vscode = this.vscode + + // 3. Set up __extensionHost global for webview registration + // This is used by WindowAPI.registerWebviewViewProvider + ;(global as Record).__extensionHost = this + + // 4. Set up module resolution to intercept require('vscode') + const require = createRequire(import.meta.url) + const Module = require("module") + const originalResolve = Module._resolveFilename + + Module._resolveFilename = function (request: string, parent: unknown, isMain: boolean, options: unknown) { + if (request === "vscode") { + return "vscode-mock" + } + return originalResolve.call(this, request, parent, isMain, options) + } + + // Add the mock to require.cache + // Use 'as unknown as' to satisfy TypeScript's Module type requirements + require.cache["vscode-mock"] = { + id: "vscode-mock", + filename: "vscode-mock", + loaded: true, + exports: this.vscode, + children: [], + paths: [], + path: "", + isPreloading: false, + parent: null, + require: require, + } as unknown as NodeJS.Module + + this.log("Loading extension bundle from:", bundlePath) + + // 5. Load extension bundle + try { + this.extensionModule = require(bundlePath) as ExtensionModule + } catch (error) { + // Restore module resolution before throwing + Module._resolveFilename = originalResolve + throw new Error( + `Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + // 6. Restore module resolution + Module._resolveFilename = originalResolve + + this.log("Activating extension...") + + // 7. Activate extension + try { + this.extensionAPI = await this.extensionModule.activate(this.vscode.context) + this.log("Extension activated successfully") + } catch (error) { + throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`) + } + } + + /** + * Called by WindowAPI.registerWebviewViewProvider + * This is triggered when the extension registers its sidebar webview provider + */ + registerWebviewProvider(viewId: string, provider: WebviewViewProvider): void { + this.log(`Webview provider registered: ${viewId}`) + this.webviewProviders.set(viewId, provider) + + // The WindowAPI will call resolveWebviewView automatically + // We don't need to do anything here + } + + /** + * Called when a webview provider is disposed + */ + unregisterWebviewProvider(viewId: string): void { + this.log(`Webview provider unregistered: ${viewId}`) + this.webviewProviders.delete(viewId) + } + + /** + * Returns true during initial extension setup + * Used to prevent the extension from aborting tasks during initialization + */ + isInInitialSetup(): boolean { + return !this.isWebviewReady + } + + /** + * Called by WindowAPI after resolveWebviewView completes + * This indicates the webview is ready to receive messages + */ + markWebviewReady(): void { + this.log("Webview marked as ready") + this.isWebviewReady = true + this.emit("webviewReady") + + // Flush any pending messages + this.flushPendingMessages() + } + + /** + * Send any messages that were queued before the webview was ready + */ + private flushPendingMessages(): void { + if (this.pendingMessages.length > 0) { + this.log(`Flushing ${this.pendingMessages.length} pending messages`) + for (const message of this.pendingMessages) { + this.emit("webviewMessage", message) + } + this.pendingMessages = [] + } + } + + /** + * Send a message to the extension (simulating webview -> extension communication). + */ + sendToExtension(message: unknown): void { + if (!this.isWebviewReady) { + this.log("Queueing message (webview not ready):", message) + this.pendingMessages.push(message) + return + } + + this.log("Sending message to extension:", message) + this.emit("webviewMessage", message) + } + + private applyRuntimeSettings(settings: RooCodeSettings): void { + if (this.options.mode) { + settings.mode = this.options.mode + } + + if (this.options.reasoningEffort) { + if (this.options.reasoningEffort === "disabled") { + settings.enableReasoningEffort = false + } else { + settings.enableReasoningEffort = true + settings.reasoningEffort = this.options.reasoningEffort + } + } + + // Update vscode-shim runtime configuration so + // vscode.workspace.getConfiguration() returns correct values. + setRuntimeConfigValues("roo-cline", settings as Record) + } + + /** + * Build the provider-specific API configuration + * Each provider uses different field names for API key and model + */ + private buildApiConfiguration(): RooCodeSettings { + const provider = this.options.apiProvider || "anthropic" + const apiKey = this.options.apiKey + const model = this.options.model + + // Base config with provider. + const config: RooCodeSettings = { apiProvider: provider } + + // Map provider to the correct API key and model field names. + switch (provider) { + case "zgsm": + if (apiKey) config.zgsmAccessToken = apiKey + if (model) config.zgsmModelId = model + break + + case "anthropic": + if (apiKey) config.apiKey = apiKey + if (model) config.apiModelId = model + break + + case "openrouter": + if (apiKey) config.openRouterApiKey = apiKey + if (model) config.openRouterModelId = model + break + + case "gemini": + if (apiKey) config.geminiApiKey = apiKey + if (model) config.apiModelId = model + break + + case "openai-native": + if (apiKey) config.openAiNativeApiKey = apiKey + if (model) config.apiModelId = model + break + + case "openai": + if (apiKey) config.openAiApiKey = apiKey + if (model) config.openAiModelId = model + break + + case "mistral": + if (apiKey) config.mistralApiKey = apiKey + if (model) config.apiModelId = model + break + + case "deepseek": + if (apiKey) config.deepSeekApiKey = apiKey + if (model) config.apiModelId = model + break + + case "xai": + if (apiKey) config.xaiApiKey = apiKey + if (model) config.apiModelId = model + break + + case "groq": + if (apiKey) config.groqApiKey = apiKey + if (model) config.apiModelId = model + break + + case "fireworks": + if (apiKey) config.fireworksApiKey = apiKey + if (model) config.apiModelId = model + break + + case "cerebras": + if (apiKey) config.cerebrasApiKey = apiKey + if (model) config.apiModelId = model + break + + case "sambanova": + if (apiKey) config.sambaNovaApiKey = apiKey + if (model) config.apiModelId = model + break + + case "ollama": + if (apiKey) config.ollamaApiKey = apiKey + if (model) config.ollamaModelId = model + break + + case "lmstudio": + if (model) config.lmStudioModelId = model + break + + case "litellm": + if (apiKey) config.litellmApiKey = apiKey + if (model) config.litellmModelId = model + break + + case "huggingface": + if (apiKey) config.huggingFaceApiKey = apiKey + if (model) config.huggingFaceModelId = model + break + + case "chutes": + if (apiKey) config.chutesApiKey = apiKey + if (model) config.apiModelId = model + break + + case "featherless": + if (apiKey) config.featherlessApiKey = apiKey + if (model) config.apiModelId = model + break + + case "unbound": + if (apiKey) config.unboundApiKey = apiKey + if (model) config.unboundModelId = model + break + + case "requesty": + if (apiKey) config.requestyApiKey = apiKey + if (model) config.requestyModelId = model + break + + case "deepinfra": + if (apiKey) config.deepInfraApiKey = apiKey + if (model) config.deepInfraModelId = model + break + + case "vercel-ai-gateway": + if (apiKey) config.vercelAiGatewayApiKey = apiKey + if (model) config.vercelAiGatewayModelId = model + break + + case "zai": + if (apiKey) config.zaiApiKey = apiKey + if (model) config.apiModelId = model + break + + case "baseten": + if (apiKey) config.basetenApiKey = apiKey + if (model) config.apiModelId = model + break + + case "doubao": + if (apiKey) config.doubaoApiKey = apiKey + if (model) config.apiModelId = model + break + + case "moonshot": + if (apiKey) config.moonshotApiKey = apiKey + if (model) config.apiModelId = model + break + + case "minimax": + if (apiKey) config.minimaxApiKey = apiKey + if (model) config.apiModelId = model + break + + case "io-intelligence": + if (apiKey) config.ioIntelligenceApiKey = apiKey + if (model) config.ioIntelligenceModelId = model + break + + default: + // Default to apiKey and apiModelId for unknown providers. + if (apiKey) config.apiKey = apiKey + if (model) config.apiModelId = model + } + + return config + } + + /** + * Run a task with the given prompt + */ + async runTask(prompt: string): Promise { + this.log("Running task:", prompt) + + // Wait for webview to be ready + if (!this.isWebviewReady) { + this.log("Waiting for webview to be ready...") + await new Promise((resolve) => { + this.once("webviewReady", resolve) + }) + } + + // Set up message listener for extension responses + this.setupMessageListener() + + // Configure approval settings based on mode + // In non-interactive mode (-y flag), enable auto-approval for everything + // In interactive mode (default), we'll prompt the user for each action + if (this.options.nonInteractive) { + this.log("Non-interactive mode: enabling auto-approval settings...") + + const settings: RooCodeSettings = { + autoApprovalEnabled: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: true, + alwaysAllowWrite: true, + alwaysAllowWriteOutsideWorkspace: true, + alwaysAllowWriteProtected: false, // Keep protected files safe. + alwaysAllowBrowser: true, + alwaysAllowMcp: true, + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + alwaysAllowExecute: true, + alwaysAllowFollowupQuestions: true, + // Allow all commands with wildcard (required for command auto-approval). + allowedCommands: ["*"], + commandExecutionTimeout: 20, + } + + this.applyRuntimeSettings(settings) + this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) + await new Promise((resolve) => setTimeout(resolve, 100)) + } else { + this.log("Interactive mode: user will be prompted for approvals...") + const settings: RooCodeSettings = { autoApprovalEnabled: false } + this.applyRuntimeSettings(settings) + this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + if (this.options.apiKey) { + this.sendToExtension({ type: "updateSettings", updatedSettings: this.buildApiConfiguration() }) + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + this.sendToExtension({ type: "newTask", text: prompt }) + await this.waitForCompletion() + } + + /** + * Set up listener for messages from the extension + */ + private setupMessageListener(): void { + this.messageListener = (message: unknown) => { + this.handleExtensionMessage(message) + } + + this.on("extensionWebviewMessage", this.messageListener) + } + + /** + * Handle messages from the extension + */ + private handleExtensionMessage(message: unknown): void { + const msg = message as Record + + if (this.options.verbose) { + this.log("Received message from extension:", JSON.stringify(msg, null, 2)) + } + + // Handle different message types + switch (msg.type) { + case "state": + this.handleStateMessage(msg) + break + + case "messageUpdated": + // This is the streaming update - handle individual message updates + this.handleMessageUpdated(msg) + break + + case "action": + this.handleActionMessage(msg) + break + + case "invoke": + this.handleInvokeMessage(msg) + break + + default: + // Log unknown message types in verbose mode + if (this.options.verbose) { + this.log("Unknown message type:", msg.type) + } + } + } + + /** + * Output a message to the user (bypasses quiet mode) + * Use this for all user-facing output instead of console.log + */ + private output(...args: unknown[]): void { + const text = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") + process.stdout.write(text + "\n") + } + + /** + * Output an error message to the user (bypasses quiet mode) + * Use this for all user-facing errors instead of console.error + */ + private outputError(...args: unknown[]): void { + const text = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") + process.stderr.write(text + "\n") + } + + /** + * Handle state update messages from the extension + */ + private handleStateMessage(msg: Record): void { + const state = msg.state as Record | undefined + if (!state) return + + const clineMessages = state.clineMessages as Array> | undefined + + if (clineMessages && clineMessages.length > 0) { + // Track message processing for verbose debug output + this.processedMessageCount++ + + // Verbose: log state update summary + if (this.options.verbose) { + this.log(`State update #${this.processedMessageCount}: ${clineMessages.length} messages`) + } + + // Process all messages to find new or updated ones + for (const message of clineMessages) { + if (!message) continue + + const ts = message.ts as number | undefined + const isPartial = message.partial as boolean | undefined + const text = message.text as string + const type = message.type as string + const say = message.say as string | undefined + const ask = message.ask as string | undefined + + if (!ts) continue + + // Handle "say" type messages + if (type === "say" && say) { + this.handleSayMessage(ts, say, text, isPartial) + } + // Handle "ask" type messages + else if (type === "ask" && ask) { + this.handleAskMessage(ts, ask, text, isPartial) + } + } + } + } + + /** + * Handle messageUpdated - individual streaming updates for a single message + * This is where real-time streaming happens! + */ + private handleMessageUpdated(msg: Record): void { + const clineMessage = msg.clineMessage as Record | undefined + if (!clineMessage) return + + const ts = clineMessage.ts as number | undefined + const isPartial = clineMessage.partial as boolean | undefined + const text = clineMessage.text as string + const type = clineMessage.type as string + const say = clineMessage.say as string | undefined + const ask = clineMessage.ask as string | undefined + + if (!ts) return + + // Handle "say" type messages + if (type === "say" && say) { + this.handleSayMessage(ts, say, text, isPartial) + } + // Handle "ask" type messages + else if (type === "ask" && ask) { + this.handleAskMessage(ts, ask, text, isPartial) + } + } + + /** + * Write streaming output directly to stdout (bypassing quiet mode if needed) + */ + private writeStream(text: string): void { + process.stdout.write(text) + } + + /** + * Stream content with delta computation - only output new characters + */ + private streamContent(ts: number, text: string, header: string): void { + const previous = this.streamedContent.get(ts) + + if (!previous) { + // First time seeing this message - output header and initial text + this.writeStream(`\n${header} `) + this.writeStream(text) + this.streamedContent.set(ts, { text, headerShown: true }) + this.currentlyStreamingTs = ts + } else if (text.length > previous.text.length && text.startsWith(previous.text)) { + // Text has grown - output delta + const delta = text.slice(previous.text.length) + this.writeStream(delta) + this.streamedContent.set(ts, { text, headerShown: true }) + } + } + + /** + * Finish streaming a message (add newline) + */ + private finishStream(ts: number): void { + if (this.currentlyStreamingTs === ts) { + this.writeStream("\n") + this.currentlyStreamingTs = null + } + } + + /** + * Handle "say" type messages + */ + private handleSayMessage(ts: number, say: string, text: string, isPartial: boolean | undefined): void { + const previousDisplay = this.displayedMessages.get(ts) + const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial + + switch (say) { + case "text": + // Skip the initial user prompt echo (first message with no prior messages) + if (this.displayedMessages.size === 0 && !previousDisplay) { + this.displayedMessages.set(ts, { text, partial: !!isPartial }) + break + } + + if (isPartial && text) { + // Stream partial content + this.streamContent(ts, text, "[assistant]") + this.displayedMessages.set(ts, { text, partial: true }) + } else if (!isPartial && text && !alreadyDisplayedComplete) { + // Message complete - ensure all content is output + const streamed = this.streamedContent.get(ts) + if (streamed) { + // We were streaming - output any remaining delta and finish + if (text.length > streamed.text.length && text.startsWith(streamed.text)) { + const delta = text.slice(streamed.text.length) + this.writeStream(delta) + } + this.finishStream(ts) + } else { + // Not streamed yet - output complete message + this.output("\n[assistant]", text) + } + this.displayedMessages.set(ts, { text, partial: false }) + this.streamedContent.set(ts, { text, headerShown: true }) + } + break + + case "thinking": + case "reasoning": + // Stream reasoning content in real-time. + this.log(`Received ${say} message: partial=${isPartial}, textLength=${text?.length ?? 0}`) + if (isPartial && text) { + this.streamContent(ts, text, "[reasoning]") + this.displayedMessages.set(ts, { text, partial: true }) + } else if (!isPartial && text && !alreadyDisplayedComplete) { + // Reasoning complete - finish the stream. + const streamed = this.streamedContent.get(ts) + if (streamed) { + if (text.length > streamed.text.length && text.startsWith(streamed.text)) { + const delta = text.slice(streamed.text.length) + this.writeStream(delta) + } + this.finishStream(ts) + } else { + this.output("\n[reasoning]", text) + } + this.displayedMessages.set(ts, { text, partial: false }) + } + break + + case "command_output": + // Stream command output in real-time. + if (isPartial && text) { + this.streamContent(ts, text, "[command output]") + this.displayedMessages.set(ts, { text, partial: true }) + } else if (!isPartial && text && !alreadyDisplayedComplete) { + // Command output complete - finish the stream. + const streamed = this.streamedContent.get(ts) + if (streamed) { + if (text.length > streamed.text.length && text.startsWith(streamed.text)) { + const delta = text.slice(streamed.text.length) + this.writeStream(delta) + } + this.finishStream(ts) + } else { + this.writeStream("\n[command output] ") + this.writeStream(text) + this.writeStream("\n") + } + this.displayedMessages.set(ts, { text, partial: false }) + } + break + + case "completion_result": + // Only process when message is complete (not partial) + if (!isPartial && !alreadyDisplayedComplete) { + this.output("\n[task complete]", text || "") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + this.emit("taskComplete") + } else if (isPartial) { + // Track partial messages but don't output yet - wait for complete message + this.displayedMessages.set(ts, { text: text || "", partial: true }) + } + break + + case "error": + // Display errors to the user but don't terminate the task + // Errors like command timeouts are informational - the agent should decide what to do next + if (!alreadyDisplayedComplete) { + this.outputError("\n[error]", text || "Unknown error") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + case "tool": + // Tool usage - show when complete + if (text && !alreadyDisplayedComplete) { + this.output("\n[tool]", text) + this.displayedMessages.set(ts, { text, partial: false }) + } + break + + case "api_req_started": + // API request started - log in verbose mode + if (this.options.verbose) { + this.log(`API request started: ts=${ts}`) + } + break + + default: + // Other say types - show in verbose mode + if (this.options.verbose) { + this.log(`Unknown say type: ${say}, text length: ${text?.length ?? 0}, partial: ${isPartial}`) + if (text && !alreadyDisplayedComplete) { + this.output(`\n[${say}]`, text || "") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + } + } + } + + /** + * Handle "ask" type messages - these require user responses + * In interactive mode: prompt user for input + * In non-interactive mode: auto-approve (handled by extension settings) + */ + private handleAskMessage(ts: number, ask: string, text: string, isPartial: boolean | undefined): void { + // Special handling for command_output - stream it in real-time + // This needs to happen before the isPartial skip + if (ask === "command_output") { + this.handleCommandOutputAsk(ts, text, isPartial) + return + } + + // Skip partial messages - wait for the complete ask + if (isPartial) { + return + } + + // Check if we already handled this ask + if (this.pendingAsks.has(ts)) { + return + } + + // In non-interactive mode, the extension's auto-approval settings handle everything + // We just need to display the action being taken + if (this.options.nonInteractive) { + this.handleAskMessageNonInteractive(ts, ask, text) + return + } + + // Interactive mode - prompt user for input + this.handleAskMessageInteractive(ts, ask, text) + } + + /** + * Handle ask messages in non-interactive mode + * For followup questions: show prompt with 10s timeout, auto-select first option if no input + * For everything else: auto-approval handles responses + */ + private handleAskMessageNonInteractive(ts: number, ask: string, text: string): void { + const previousDisplay = this.displayedMessages.get(ts) + const alreadyDisplayed = !!previousDisplay + + switch (ask) { + case "followup": + if (!alreadyDisplayed) { + // In non-interactive mode, still prompt the user but with a 10s timeout + // that auto-selects the first option if no input is received + this.pendingAsks.add(ts) + this.handleFollowupQuestionWithTimeout(ts, text) + this.displayedMessages.set(ts, { text, partial: false }) + } + break + + case "command": + if (!alreadyDisplayed) { + this.output("\n[command]", text || "") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + // Note: command_output is handled separately in handleCommandOutputAsk + + case "tool": + if (!alreadyDisplayed && text) { + try { + const toolInfo = JSON.parse(text) + const toolName = toolInfo.tool || "unknown" + this.output(`\n[tool] ${toolName}`) + // Display all tool parameters (excluding 'tool' which is the name) + for (const [key, value] of Object.entries(toolInfo)) { + if (key === "tool") continue + // Format the value - truncate long strings + let displayValue: string + if (typeof value === "string") { + displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value + } else if (typeof value === "object" && value !== null) { + const json = JSON.stringify(value) + displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json + } else { + displayValue = String(value) + } + this.output(` ${key}: ${displayValue}`) + } + } catch { + this.output("\n[tool]", text) + } + this.displayedMessages.set(ts, { text, partial: false }) + } + break + + case "browser_action_launch": + if (!alreadyDisplayed) { + this.output("\n[browser action]", text || "") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + case "use_mcp_server": + if (!alreadyDisplayed) { + try { + const mcpInfo = JSON.parse(text) + this.output(`\n[mcp] ${mcpInfo.server_name || "unknown"}`) + } catch { + this.output("\n[mcp]", text || "") + } + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + case "api_req_failed": + if (!alreadyDisplayed) { + this.output("\n[retrying api Request]") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + case "resume_task": + case "resume_completed_task": + if (!alreadyDisplayed) { + this.output("\n[continuing task]") + this.displayedMessages.set(ts, { text: text || "", partial: false }) + } + break + + case "completion_result": + // Task completion - no action needed + break + + default: + if (!alreadyDisplayed && text) { + this.output(`\n[${ask}]`, text) + this.displayedMessages.set(ts, { text, partial: false }) + } + } + } + + /** + * Handle ask messages in interactive mode - prompt user for input + */ + private handleAskMessageInteractive(ts: number, ask: string, text: string): void { + // Mark this ask as pending so we don't handle it again + this.pendingAsks.add(ts) + + switch (ask) { + case "followup": + this.handleFollowupQuestion(ts, text) + break + + case "command": + this.handleCommandApproval(ts, text) + break + + // Note: command_output is handled separately in handleCommandOutputAsk + + case "tool": + this.handleToolApproval(ts, text) + break + + case "browser_action_launch": + this.handleBrowserApproval(ts, text) + break + + case "use_mcp_server": + this.handleMcpApproval(ts, text) + break + + case "api_req_failed": + this.handleApiFailedRetry(ts, text) + break + + case "resume_task": + case "resume_completed_task": + this.handleResumeTask(ts, ask, text) + break + + case "completion_result": + // Task completion - handled by say message, no response needed + this.pendingAsks.delete(ts) + break + + default: + // Unknown ask type - try to handle as yes/no + this.handleGenericApproval(ts, ask, text) + } + } + + /** + * Handle followup questions - prompt for text input with suggestions + */ + private async handleFollowupQuestion(ts: number, text: string): Promise { + let question = text + // Suggestions are objects with { answer: string, mode?: string } + let suggestions: Array<{ answer: string; mode?: string | null }> = [] + + // Parse the followup question JSON + // Format: { question: "...", suggest: [{ answer: "text", mode: "code" }, ...] } + try { + const data = JSON.parse(text) + question = data.question || text + suggestions = Array.isArray(data.suggest) ? data.suggest : [] + } catch { + // Use raw text if not JSON + } + + this.output("\n[question]", question) + + // Show numbered suggestions + if (suggestions.length > 0) { + this.output("\nSuggested answers:") + suggestions.forEach((suggestion, index) => { + const suggestionText = suggestion.answer || String(suggestion) + const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : "" + this.output(` ${index + 1}. ${suggestionText}${modeHint}`) + }) + this.output("") + } + + try { + const answer = await this.promptForInput( + suggestions.length > 0 + ? "Enter number (1-" + suggestions.length + ") or type your answer: " + : "Your answer: ", + ) + + let responseText = answer.trim() + + // Check if user entered a number corresponding to a suggestion + const num = parseInt(responseText, 10) + if (!isNaN(num) && num >= 1 && num <= suggestions.length) { + const selectedSuggestion = suggestions[num - 1] + if (selectedSuggestion) { + responseText = selectedSuggestion.answer || String(selectedSuggestion) + this.output(`Selected: ${responseText}`) + } + } + + this.sendFollowupResponse(responseText) + // Don't delete from pendingAsks - keep it to prevent re-processing + // if the extension sends another state update before processing our response + } catch { + // If prompt fails (e.g., stdin closed), use first suggestion answer or empty + const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null + const fallback = firstSuggestion?.answer ?? "" + this.output(`[Using default: ${fallback || "(empty)"}]`) + this.sendFollowupResponse(fallback) + } + // Note: We intentionally don't delete from pendingAsks here. + // The ts stays in the set to prevent duplicate handling if the extension + // sends another state update before it processes our response. + // The set is cleared when the task completes or the host is disposed. + } + + /** + * Handle followup questions with a timeout (for non-interactive mode) + * Shows the prompt but auto-selects the first option after 10 seconds + * if the user doesn't type anything. Cancels the timeout on any keypress. + */ + private async handleFollowupQuestionWithTimeout(ts: number, text: string): Promise { + let question = text + // Suggestions are objects with { answer: string, mode?: string } + let suggestions: Array<{ answer: string; mode?: string | null }> = [] + + // Parse the followup question JSON + try { + const data = JSON.parse(text) + question = data.question || text + suggestions = Array.isArray(data.suggest) ? data.suggest : [] + } catch { + // Use raw text if not JSON + } + + this.output("\n[question]", question) + + // Show numbered suggestions + if (suggestions.length > 0) { + this.output("\nSuggested answers:") + suggestions.forEach((suggestion, index) => { + const suggestionText = suggestion.answer || String(suggestion) + const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : "" + this.output(` ${index + 1}. ${suggestionText}${modeHint}`) + }) + this.output("") + } + + // Default to first suggestion or empty string + const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null + const defaultAnswer = firstSuggestion?.answer ?? "" + + try { + const answer = await this.promptForInputWithTimeout( + suggestions.length > 0 + ? `Enter number (1-${suggestions.length}) or type your answer (auto-select in 10s): ` + : "Your answer (auto-select in 10s): ", + 10000, // 10 second timeout + defaultAnswer, + ) + + let responseText = answer.trim() + + // Check if user entered a number corresponding to a suggestion + const num = parseInt(responseText, 10) + if (!isNaN(num) && num >= 1 && num <= suggestions.length) { + const selectedSuggestion = suggestions[num - 1] + if (selectedSuggestion) { + responseText = selectedSuggestion.answer || String(selectedSuggestion) + this.output(`Selected: ${responseText}`) + } + } + + this.sendFollowupResponse(responseText) + } catch { + // If prompt fails, use default + this.output(`[Using default: ${defaultAnswer || "(empty)"}]`) + this.sendFollowupResponse(defaultAnswer) + } + } + + /** + * Prompt user for text input with a timeout + * Returns defaultValue if timeout expires before any input + * Cancels timeout as soon as any character is typed + */ + private promptForInputWithTimeout(prompt: string, timeoutMs: number, defaultValue: string): Promise { + return new Promise((resolve) => { + // Temporarily restore console for interactive prompts + const wasQuiet = this.options.quiet + if (wasQuiet) { + this.restoreConsole() + } + + // Put stdin in raw mode to detect individual keypresses + const wasRaw = process.stdin.isRaw + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + + let inputBuffer = "" + let timeoutCancelled = false + let resolved = false + + // Set up the timeout + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true + cleanup() + this.output(`\n[Timeout - using default: ${defaultValue || "(empty)"}]`) + resolve(defaultValue) + } + }, timeoutMs) + + // Show the prompt + process.stdout.write(prompt) + + // Cleanup function + const cleanup = () => { + clearTimeout(timeout) + process.stdin.removeListener("data", onData) + if (process.stdin.isTTY && wasRaw !== undefined) { + process.stdin.setRawMode(wasRaw) + } + process.stdin.pause() + if (wasQuiet) { + this.setupQuietMode() + } + } + + // Handle keypress data + const onData = (data: Buffer) => { + const char = data.toString() + + // Check for Ctrl+C + if (char === "\x03") { + cleanup() + resolved = true + this.output("\n[cancelled]") + resolve(defaultValue) + return + } + + // Cancel timeout on first character + if (!timeoutCancelled) { + timeoutCancelled = true + clearTimeout(timeout) + } + + // Handle Enter key + if (char === "\r" || char === "\n") { + if (!resolved) { + resolved = true + cleanup() + process.stdout.write("\n") + resolve(inputBuffer) + } + return + } + + // Handle Backspace + if (char === "\x7f" || char === "\b") { + if (inputBuffer.length > 0) { + inputBuffer = inputBuffer.slice(0, -1) + // Erase character on screen: move back, write space, move back + process.stdout.write("\b \b") + } + return + } + + // Regular character - add to buffer and echo + inputBuffer += char + process.stdout.write(char) + } + + process.stdin.on("data", onData) + }) + } + + /** + * Handle command execution approval + */ + private async handleCommandApproval(ts: number, text: string): Promise { + this.output("\n[command request]") + this.output(` Command: ${text || "(no command specified)"}`) + + try { + const approved = await this.promptForYesNo("Execute this command? (y/n): ") + this.sendApprovalResponse(approved) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle tool execution approval + */ + private async handleToolApproval(ts: number, text: string): Promise { + let toolName = "unknown" + let toolInfo: Record = {} + + try { + toolInfo = JSON.parse(text) as Record + toolName = (toolInfo.tool as string) || "unknown" + } catch { + // Use raw text if not JSON + } + + this.output(`\n[Tool Request] ${toolName}`) + // Display all tool parameters (excluding 'tool' which is the name) + for (const [key, value] of Object.entries(toolInfo)) { + if (key === "tool") continue + // Format the value - truncate long strings + let displayValue: string + if (typeof value === "string") { + displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value + } else if (typeof value === "object" && value !== null) { + const json = JSON.stringify(value) + displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json + } else { + displayValue = String(value) + } + this.output(` ${key}: ${displayValue}`) + } + + try { + const approved = await this.promptForYesNo("Approve this action? (y/n): ") + this.sendApprovalResponse(approved) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle browser action approval + */ + private async handleBrowserApproval(ts: number, text: string): Promise { + this.output("\n[browser action request]") + if (text) this.output(` Action: ${text}`) + + try { + const approved = await this.promptForYesNo("Allow browser action? (y/n): ") + this.sendApprovalResponse(approved) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle MCP server access approval + */ + private async handleMcpApproval(ts: number, text: string): Promise { + let serverName = "unknown" + let toolName = "" + let resourceUri = "" + + try { + const mcpInfo = JSON.parse(text) + serverName = mcpInfo.server_name || "unknown" + if (mcpInfo.type === "use_mcp_tool") { + toolName = mcpInfo.tool_name || "" + } else if (mcpInfo.type === "access_mcp_resource") { + resourceUri = mcpInfo.uri || "" + } + } catch { + // Use raw text if not JSON + } + + this.output("\n[mcp request]") + this.output(` Server: ${serverName}`) + if (toolName) this.output(` Tool: ${toolName}`) + if (resourceUri) this.output(` Resource: ${resourceUri}`) + + try { + const approved = await this.promptForYesNo("Allow MCP access? (y/n): ") + this.sendApprovalResponse(approved) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle API request failed - retry prompt + */ + private async handleApiFailedRetry(ts: number, text: string): Promise { + this.output("\n[api request failed]") + this.output(` Error: ${text || "Unknown error"}`) + + try { + const retry = await this.promptForYesNo("Retry the request? (y/n): ") + this.sendApprovalResponse(retry) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle task resume prompt + */ + private async handleResumeTask(ts: number, ask: string, text: string): Promise { + const isCompleted = ask === "resume_completed_task" + this.output(`\n[Resume ${isCompleted ? "Completed " : ""}Task]`) + if (text) this.output(` ${text}`) + + try { + const resume = await this.promptForYesNo("Continue with this task? (y/n): ") + this.sendApprovalResponse(resume) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle generic approval prompts for unknown ask types + */ + private async handleGenericApproval(ts: number, ask: string, text: string): Promise { + this.output(`\n[${ask}]`) + if (text) this.output(` ${text}`) + + try { + const approved = await this.promptForYesNo("Approve? (y/n): ") + this.sendApprovalResponse(approved) + } catch { + this.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + } + // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment + } + + /** + * Handle command_output ask messages - stream the output in real-time + * This is called for both partial (streaming) and complete messages + */ + private handleCommandOutputAsk(ts: number, text: string, isPartial: boolean | undefined): void { + const previousDisplay = this.displayedMessages.get(ts) + const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial + + // Stream partial content + if (isPartial && text) { + this.streamContent(ts, text, "[command output]") + this.displayedMessages.set(ts, { text, partial: true }) + } else if (!isPartial) { + // Message complete - output any remaining content and send approval + if (text && !alreadyDisplayedComplete) { + const streamed = this.streamedContent.get(ts) + if (streamed) { + // We were streaming - output any remaining delta and finish. + if (text.length > streamed.text.length && text.startsWith(streamed.text)) { + const delta = text.slice(streamed.text.length) + this.writeStream(delta) + } + this.finishStream(ts) + } else { + this.writeStream("\n[command output] ") + this.writeStream(text) + this.writeStream("\n") + } + this.displayedMessages.set(ts, { text, partial: false }) + this.streamedContent.set(ts, { text, headerShown: true }) + } + + // Send approval response (only once per ts). + if (!this.pendingAsks.has(ts)) { + this.pendingAsks.add(ts) + this.sendApprovalResponse(true) + } + } + } + + /** + * Prompt user for text input via readline + */ + private promptForInput(prompt: string): Promise { + return new Promise((resolve, reject) => { + // Temporarily restore console for interactive prompts + const wasQuiet = this.options.quiet + if (wasQuiet) { + this.restoreConsole() + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + rl.question(prompt, (answer) => { + rl.close() + + // Restore quiet mode if it was enabled + if (wasQuiet) { + this.setupQuietMode() + } + + resolve(answer) + }) + + // Handle stdin close (e.g., piped input ended) + rl.on("close", () => { + if (wasQuiet) { + this.setupQuietMode() + } + }) + + // Handle errors + rl.on("error", (err) => { + rl.close() + if (wasQuiet) { + this.setupQuietMode() + } + reject(err) + }) + }) + } + + /** + * Prompt user for yes/no input + */ + private async promptForYesNo(prompt: string): Promise { + const answer = await this.promptForInput(prompt) + const normalized = answer.trim().toLowerCase() + // Accept y, yes, Y, Yes, YES, etc. + return normalized === "y" || normalized === "yes" + } + + /** + * Send a followup response (text answer) to the extension + */ + private sendFollowupResponse(text: string): void { + this.sendToExtension({ + type: "askResponse", + askResponse: "messageResponse", + text, + }) + } + + /** + * Send an approval response (yes/no) to the extension + */ + private sendApprovalResponse(approved: boolean): void { + this.sendToExtension({ + type: "askResponse", + askResponse: approved ? "yesButtonClicked" : "noButtonClicked", + }) + } + + /** + * Handle action messages + */ + private handleActionMessage(msg: Record): void { + const action = msg.action as string + + if (this.options.verbose) { + this.log("Action:", action) + } + } + + /** + * Handle invoke messages + */ + private handleInvokeMessage(msg: Record): void { + const invoke = msg.invoke as string + + if (this.options.verbose) { + this.log("Invoke:", invoke) + } + } + + /** + * Wait for the task to complete + */ + private waitForCompletion(): Promise { + return new Promise((resolve, reject) => { + const completeHandler = () => { + cleanup() + resolve() + } + + const errorHandler = (error: string) => { + cleanup() + reject(new Error(error)) + } + + const cleanup = () => { + this.off("taskComplete", completeHandler) + this.off("taskError", errorHandler) + } + + this.once("taskComplete", completeHandler) + this.once("taskError", errorHandler) + + // Set a timeout (10 minutes by default) + const timeout = setTimeout( + () => { + cleanup() + reject(new Error("Task timed out")) + }, + 10 * 60 * 1000, + ) + + // Clear timeout on completion + this.once("taskComplete", () => clearTimeout(timeout)) + this.once("taskError", () => clearTimeout(timeout)) + }) + } + + /** + * Clean up resources + */ + async dispose(): Promise { + this.log("Disposing extension host...") + + // Clear pending asks + this.pendingAsks.clear() + + // Close readline interface if open + if (this.rl) { + this.rl.close() + this.rl = null + } + + // Remove message listener + if (this.messageListener) { + this.off("extensionWebviewMessage", this.messageListener) + this.messageListener = null + } + + // Deactivate extension if it has a deactivate function + if (this.extensionModule?.deactivate) { + try { + await this.extensionModule.deactivate() + } catch (error) { + this.log("Error deactivating extension:", error) + } + } + + // Clear references + this.vscode = null + this.extensionModule = null + this.extensionAPI = null + this.webviewProviders.clear() + + // Clear globals + delete (global as Record).vscode + delete (global as Record).__extensionHost + + // Restore console if it was suppressed + this.restoreConsole() + + this.log("Extension host disposed") + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts new file mode 100644 index 0000000000..15a2786ef4 --- /dev/null +++ b/apps/cli/src/index.ts @@ -0,0 +1,163 @@ +/** + * @roo-code/cli - Command Line Interface for Roo Code + */ + +import { Command } from "commander" +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +import { + type ProviderName, + type ReasoningEffortExtended, + isProviderName, + reasoningEffortsExtended, +} from "@roo-code/types" +import { setLogger } from "@roo-code/vscode-shim" + +import { ExtensionHost } from "./extension-host.js" +import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils.js" + +const DEFAULTS = { + mode: "code", + reasoningEffort: "medium" as const, + model: "anthropic/claude-sonnet-4.5", +} + +const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"] + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const program = new Command() + +program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version("0.1.0") + +program + .argument("", "The prompt/task to execute") + .option("-w, --workspace ", "Workspace path to operate in", process.cwd()) + .option("-e, --extension ", "Path to the extension bundle directory") + .option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false) + .option("-d, --debug", "Enable debug output (includes detailed debug information)", false) + .option("-x, --exit-on-complete", "Exit the process when the task completes (useful for testing)", false) + .option("-y, --yes", "Auto-approve all prompts (non-interactive mode)", false) + .option("-k, --api-key ", "API key for the LLM provider (defaults to ANTHROPIC_API_KEY env var)") + .option("-p, --provider ", "API provider (anthropic, openai, openrouter, etc.)", "openrouter") + .option("-m, --model ", "Model to use", DEFAULTS.model) + .option("-M, --mode ", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULTS.mode) + .option( + "-r, --reasoning-effort ", + "Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)", + DEFAULTS.reasoningEffort, + ) + .action( + async ( + prompt: string, + options: { + workspace: string + extension?: string + verbose: boolean + debug: boolean + exitOnComplete: boolean + yes: boolean + apiKey?: string + provider: ProviderName + model?: string + mode?: string + reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled" + }, + ) => { + // Default is quiet mode - suppress VSCode shim logs unless verbose + // or debug is specified. + if (!options.verbose && !options.debug) { + setLogger({ + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }) + } + + const extensionPath = options.extension || getDefaultExtensionPath(__dirname) + const apiKey = options.apiKey || getApiKeyFromEnv(options.provider) + const workspacePath = path.resolve(options.workspace) + + if (!apiKey) { + console.error( + `[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`, + ) + console.error(`[CLI] For ${options.provider}, set ${getEnvVarName(options.provider)}`) + process.exit(1) + } + + if (!fs.existsSync(workspacePath)) { + console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`) + process.exit(1) + } + + if (!isProviderName(options.provider)) { + console.error(`[CLI] Error: Invalid provider: ${options.provider}`) + process.exit(1) + } + + if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) { + console.error( + `[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`, + ) + process.exit(1) + } + + console.log(`[CLI] Mode: ${options.mode || "default"}`) + console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`) + console.log(`[CLI] Provider: ${options.provider}`) + console.log(`[CLI] Model: ${options.model || "default"}`) + console.log(`[CLI] Workspace: ${workspacePath}`) + + const host = new ExtensionHost({ + mode: options.mode || DEFAULTS.mode, + reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort, + apiProvider: options.provider, + apiKey, + model: options.model || DEFAULTS.model, + workspacePath, + extensionPath: path.resolve(extensionPath), + verbose: options.debug, + quiet: !options.verbose && !options.debug, + nonInteractive: options.yes, + }) + + // Handle SIGINT (Ctrl+C) + process.on("SIGINT", async () => { + console.log("\n[CLI] Received SIGINT, shutting down...") + await host.dispose() + process.exit(130) + }) + + // Handle SIGTERM + process.on("SIGTERM", async () => { + console.log("\n[CLI] Received SIGTERM, shutting down...") + await host.dispose() + process.exit(143) + }) + + try { + await host.activate() + await host.runTask(prompt) + await host.dispose() + + if (options.exitOnComplete) { + process.exit(0) + } + } catch (error) { + console.error("[CLI] Error:", error instanceof Error ? error.message : String(error)) + + if (options.debug && error instanceof Error) { + console.error(error.stack) + } + + await host.dispose() + process.exit(1) + } + }, + ) + +program.parse() diff --git a/apps/cli/src/utils.ts b/apps/cli/src/utils.ts new file mode 100644 index 0000000000..6a70447813 --- /dev/null +++ b/apps/cli/src/utils.ts @@ -0,0 +1,63 @@ +/** + * Utility functions for the Roo Code CLI + */ + +import path from "path" +import fs from "fs" + +/** + * Get the environment variable name for a provider's API key + */ +export function getEnvVarName(provider: string): string { + const envVarMap: Record = { + zgsm: "COSTRICT_API_KEY", + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + openrouter: "OPENROUTER_API_KEY", + google: "GOOGLE_API_KEY", + gemini: "GOOGLE_API_KEY", + bedrock: "AWS_ACCESS_KEY_ID", + ollama: "OLLAMA_API_KEY", + mistral: "MISTRAL_API_KEY", + deepseek: "DEEPSEEK_API_KEY", + } + return envVarMap[provider.toLowerCase()] || `${provider.toUpperCase()}_API_KEY` +} + +/** + * Get API key from environment variable based on provider + */ +export function getApiKeyFromEnv(provider: string): string | undefined { + const envVar = getEnvVarName(provider) + return process.env[envVar] +} + +/** + * Get the default path to the extension bundle. + * This assumes the CLI is installed alongside the built extension. + * + * @param dirname - The __dirname equivalent for the calling module + */ +export function getDefaultExtensionPath(dirname: string): string { + // Check for environment variable first (set by install script) + if (process.env.ROO_EXTENSION_PATH) { + const envPath = process.env.ROO_EXTENSION_PATH + if (fs.existsSync(path.join(envPath, "extension.js"))) { + return envPath + } + } + + // __dirname is apps/cli/dist when bundled + // The extension is at src/dist (relative to monorepo root) + // So from apps/cli/dist, we need to go ../../../src/dist + const monorepoPath = path.resolve(dirname, "../../../src/dist") + + // Try monorepo path first (for development) + if (fs.existsSync(path.join(monorepoPath, "extension.js"))) { + return monorepoPath + } + + // Fallback: when installed via curl script, extension is at ../extension + const packagePath = path.resolve(dirname, "../extension") + return packagePath +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000000..9893fe2966 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@roo-code/config-typescript/base.json", + "compilerOptions": { + "types": ["vitest/globals"], + "outDir": "dist" + }, + "include": ["src", "*.config.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts new file mode 100644 index 0000000000..f692148c3d --- /dev/null +++ b/apps/cli/tsup.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + target: "node20", + platform: "node", + banner: { + js: "#!/usr/bin/env node", + }, + // Bundle workspace packages that export TypeScript + noExternal: ["@roo-code/types", "@roo-code/vscode-shim"], + external: [ + // Keep native modules external + "@anthropic-ai/sdk", + "@anthropic-ai/bedrock-sdk", + "@anthropic-ai/vertex-sdk", + // Keep @vscode/ripgrep external - we bundle the binary separately + "@vscode/ripgrep", + ], +}) diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts new file mode 100644 index 0000000000..a558a62e83 --- /dev/null +++ b/apps/cli/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globals: true, + environment: "node", + watch: false, + testTimeout: 120_000, // 2m for integration tests. + include: ["src/**/*.test.ts"], + }, +}) diff --git a/apps/web-evals/src/actions/runs.ts b/apps/web-evals/src/actions/runs.ts index 9d213547ce..f0c1578aed 100644 --- a/apps/web-evals/src/actions/runs.ts +++ b/apps/web-evals/src/actions/runs.ts @@ -28,10 +28,18 @@ const EVALS_STORAGE_PATH = "/tmp/evals/runs" const EVALS_REPO_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../../evals") -export async function createRun({ suite, exercises = [], timeout, iterations = 1, ...values }: CreateRun) { +export async function createRun({ + suite, + exercises = [], + timeout, + iterations = 1, + executionMethod = "vscode", + ...values +}: CreateRun) { const run = await _createRun({ ...values, timeout, + executionMethod, socketPath: "", // TODO: Get rid of this. }) diff --git a/apps/web-evals/src/app/runs/new/new-run.tsx b/apps/web-evals/src/app/runs/new/new-run.tsx index be015ac8ca..28fb4abfd5 100644 --- a/apps/web-evals/src/app/runs/new/new-run.tsx +++ b/apps/web-evals/src/app/runs/new/new-run.tsx @@ -7,15 +7,26 @@ import { useQuery } from "@tanstack/react-query" import { useForm, FormProvider } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { toast } from "sonner" -import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal, Info, Plus, Minus } from "lucide-react" +import { + X, + Rocket, + Check, + ChevronsUpDown, + SlidersHorizontal, + Info, + Plus, + Minus, + Terminal, + MonitorPlay, +} from "lucide-react" import { + type ProviderSettings, + type GlobalSettings, globalSettingsSchema, providerSettingsSchema, - EVALS_SETTINGS, getModelId, - type ProviderSettings, - type GlobalSettings, + EVALS_SETTINGS, } from "@roo-code/types" import { createRun } from "@/actions/runs" @@ -23,6 +34,7 @@ import { getExercises } from "@/actions/exercises" import { type CreateRun, + type ExecutionMethod, createRunSchema, CONCURRENCY_MIN, CONCURRENCY_MAX, @@ -77,14 +89,12 @@ type ImportedSettings = { currentApiConfigName: string } -// Type for a model selection entry type ModelSelection = { id: string model: string popoverOpen: boolean } -// Type for a config selection entry (for import mode) type ConfigSelection = { id: string configName: string @@ -95,16 +105,15 @@ export function NewRun() { const router = useRouter() const [provider, setModelSource] = useState<"roo" | "openrouter" | "other">("other") + const [executionMethod, setExecutionMethod] = useState("vscode") const [useNativeToolProtocol, setUseNativeToolProtocol] = useState(true) const [commandExecutionTimeout, setCommandExecutionTimeout] = useState(20) const [terminalShellIntegrationTimeout, setTerminalShellIntegrationTimeout] = useState(30) // seconds - // State for multiple model selections const [modelSelections, setModelSelections] = useState([ { id: crypto.randomUUID(), model: "", popoverOpen: false }, ]) - // State for imported settings with multiple config selections const [importedSettings, setImportedSettings] = useState(null) const [configSelections, setConfigSelections] = useState([ { id: crypto.randomUUID(), configName: "", popoverOpen: false }, @@ -119,7 +128,6 @@ export function NewRun() { const exercises = useQuery({ queryKey: ["getExercises"], queryFn: () => getExercises() }) - // State for selected exercises (needed for language toggle buttons) const [selectedExercises, setSelectedExercises] = useState([]) const form = useForm({ @@ -134,6 +142,7 @@ export function NewRun() { timeout: TIMEOUT_DEFAULT, iterations: ITERATIONS_DEFAULT, jobToken: "", + executionMethod: "vscode", }, }) @@ -146,38 +155,49 @@ export function NewRun() { const [suite, settings] = watch(["suite", "settings", "concurrency"]) - // Load settings from localStorage on mount useEffect(() => { const savedConcurrency = localStorage.getItem("evals-concurrency") + if (savedConcurrency) { const parsed = parseInt(savedConcurrency, 10) + if (!isNaN(parsed) && parsed >= CONCURRENCY_MIN && parsed <= CONCURRENCY_MAX) { setValue("concurrency", parsed) } } + const savedTimeout = localStorage.getItem("evals-timeout") + if (savedTimeout) { const parsed = parseInt(savedTimeout, 10) + if (!isNaN(parsed) && parsed >= TIMEOUT_MIN && parsed <= TIMEOUT_MAX) { setValue("timeout", parsed) } } + const savedCommandTimeout = localStorage.getItem("evals-command-execution-timeout") + if (savedCommandTimeout) { const parsed = parseInt(savedCommandTimeout, 10) + if (!isNaN(parsed) && parsed >= 20 && parsed <= 60) { setCommandExecutionTimeout(parsed) } } + const savedShellTimeout = localStorage.getItem("evals-shell-integration-timeout") + if (savedShellTimeout) { const parsed = parseInt(savedShellTimeout, 10) + if (!isNaN(parsed) && parsed >= 30 && parsed <= 60) { setTerminalShellIntegrationTimeout(parsed) } } - // Load saved exercises selection + const savedSuite = localStorage.getItem("evals-suite") + if (savedSuite === "partial") { setValue("suite", "partial") const savedExercises = localStorage.getItem("evals-exercises") @@ -189,48 +209,57 @@ export function NewRun() { setValue("exercises", parsed) } } catch { - // Invalid JSON, ignore + // Invalid JSON, ignore. } } } }, [setValue]) - // Extract unique languages from exercises const languages = useMemo(() => { - if (!exercises.data) return [] + if (!exercises.data) { + return [] + } + const langs = new Set() + for (const path of exercises.data) { const lang = path.split("/")[0] - if (lang) langs.add(lang) + + if (lang) { + langs.add(lang) + } } + return Array.from(langs).sort() }, [exercises.data]) - // Get exercises for a specific language const getExercisesForLanguage = useCallback( (lang: string) => { - if (!exercises.data) return [] + if (!exercises.data) { + return [] + } + return exercises.data.filter((path) => path.startsWith(`${lang}/`)) }, [exercises.data], ) - // Toggle all exercises for a language const toggleLanguage = useCallback( (lang: string) => { const langExercises = getExercisesForLanguage(lang) const allSelected = langExercises.every((ex) => selectedExercises.includes(ex)) let newSelected: string[] + if (allSelected) { - // Remove all exercises for this language newSelected = selectedExercises.filter((ex) => !ex.startsWith(`${lang}/`)) } else { - // Add all exercises for this language (avoiding duplicates) const existing = new Set(selectedExercises) + for (const ex of langExercises) { existing.add(ex) } + newSelected = Array.from(existing) } @@ -241,7 +270,6 @@ export function NewRun() { [getExercisesForLanguage, selectedExercises, setValue], ) - // Check if all exercises for a language are selected const isLanguageSelected = useCallback( (lang: string) => { const langExercises = getExercisesForLanguage(lang) @@ -250,7 +278,6 @@ export function NewRun() { [getExercisesForLanguage, selectedExercises], ) - // Check if some (but not all) exercises for a language are selected const isLanguagePartiallySelected = useCallback( (lang: string) => { const langExercises = getExercisesForLanguage(lang) @@ -260,46 +287,40 @@ export function NewRun() { [getExercisesForLanguage, selectedExercises], ) - // Add a new model selection const addModelSelection = useCallback(() => { setModelSelections((prev) => [...prev, { id: crypto.randomUUID(), model: "", popoverOpen: false }]) }, []) - // Remove a model selection const removeModelSelection = useCallback((id: string) => { setModelSelections((prev) => prev.filter((s) => s.id !== id)) }, []) - // Update a model selection const updateModelSelection = useCallback( (id: string, model: string) => { setModelSelections((prev) => prev.map((s) => (s.id === id ? { ...s, model, popoverOpen: false } : s))) - // Also set the form model field for validation (use first non-empty model) + // Also set the form model field for validation (use first non-empty model). setValue("model", model) }, [setValue], ) - // Toggle popover for a model selection const toggleModelPopover = useCallback((id: string, open: boolean) => { setModelSelections((prev) => prev.map((s) => (s.id === id ? { ...s, popoverOpen: open } : s))) }, []) - // Add a new config selection const addConfigSelection = useCallback(() => { setConfigSelections((prev) => [...prev, { id: crypto.randomUUID(), configName: "", popoverOpen: false }]) }, []) - // Remove a config selection const removeConfigSelection = useCallback((id: string) => { setConfigSelections((prev) => prev.filter((s) => s.id !== id)) }, []) - // Update a config selection const updateConfigSelection = useCallback( (id: string, configName: string) => { setConfigSelections((prev) => prev.map((s) => (s.id === id ? { ...s, configName, popoverOpen: false } : s))) - // Also update the form settings for the first config (for validation) + + // Also update the form settings for the first config (for validation). if (importedSettings) { const providerSettings = importedSettings.apiConfigs[configName] ?? {} setValue("model", getModelId(providerSettings) ?? "") @@ -309,7 +330,6 @@ export function NewRun() { [importedSettings, setValue], ) - // Toggle popover for a config selection const toggleConfigPopover = useCallback((id: string, open: boolean) => { setConfigSelections((prev) => prev.map((s) => (s.id === id ? { ...s, popoverOpen: open } : s))) }, []) @@ -317,24 +337,20 @@ export function NewRun() { const onSubmit = useCallback( async (values: CreateRun) => { try { - // Validate jobToken for Roo Code Cloud provider if (provider === "roo" && !values.jobToken?.trim()) { toast.error("Roo Code Cloud Token is required") return } - // Determine which selections to use based on provider const selectionsToLaunch: { model: string; configName?: string }[] = [] if (provider === "other") { - // For import mode, use config selections for (const config of configSelections) { if (config.configName) { selectionsToLaunch.push({ model: "", configName: config.configName }) } } } else { - // For openrouter/roo, use model selections for (const selection of modelSelections) { if (selection.model) { selectionsToLaunch.push({ model: selection.model }) @@ -347,20 +363,19 @@ export function NewRun() { return } - // Show launching toast const totalRuns = selectionsToLaunch.length toast.info(totalRuns > 1 ? `Launching ${totalRuns} runs (every 20 seconds)...` : "Launching run...") - // Launch runs with 20-second delay between each for (let i = 0; i < selectionsToLaunch.length; i++) { const selection = selectionsToLaunch[i]! - // Wait 20 seconds between runs (except for the first one) + // Wait 20 seconds between runs (except for the first one). if (i > 0) { - await new Promise((resolve) => setTimeout(resolve, 20000)) + await new Promise((resolve) => setTimeout(resolve, 20_000)) } const runValues = { ...values } + runValues.executionMethod = executionMethod if (provider === "openrouter") { runValues.model = selection.model @@ -403,7 +418,6 @@ export function NewRun() { } } - // Navigate back to main evals UI router.push("/") } catch (e) { toast.error(e instanceof Error ? e.message : "An unknown error occurred.") @@ -411,6 +425,7 @@ export function NewRun() { }, [ provider, + executionMethod, modelSelections, configSelections, importedSettings, @@ -442,18 +457,15 @@ export function NewRun() { }) .parse(JSON.parse(await file.text())) - // Store all imported configs for user selection setImportedSettings({ apiConfigs: providerProfiles.apiConfigs, globalSettings, currentApiConfigName: providerProfiles.currentApiConfigName, }) - // Default to the current config for the first selection const defaultConfigName = providerProfiles.currentApiConfigName setConfigSelections([{ id: crypto.randomUUID(), configName: defaultConfigName, popoverOpen: false }]) - // Apply the default config const providerSettings = providerProfiles.apiConfigs[defaultConfigName] ?? {} setValue("model", getModelId(providerSettings) ?? "") setValue("settings", { ...EVALS_SETTINGS, ...providerSettings, ...globalSettings }) @@ -971,6 +983,36 @@ export function NewRun() { + {/* Execution Method */} + ( + + Execution Method + { + const newExecutionMethod = value as ExecutionMethod + setExecutionMethod(newExecutionMethod) + setValue("executionMethod", newExecutionMethod) + }}> + + + + VSCode + + + + CLI + + + + + + )} + /> + + /** * CreateRun */ @@ -29,6 +36,7 @@ export const createRunSchema = z timeout: z.number().int().min(TIMEOUT_MIN).max(TIMEOUT_MAX), iterations: z.number().int().min(ITERATIONS_MIN).max(ITERATIONS_MAX), jobToken: z.string().optional(), + executionMethod: executionMethodSchema, }) .refine((data) => data.suite === "full" || (data.exercises || []).length > 0, { message: "Exercises are required when running a partial suite.", diff --git a/package.json b/package.json index ceec662fcd..ec31b7fa4b 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "glob": ">=11.1.0" }, "onlyBuiltDependencies": [ + "@vscode/ripgrep", "@tailwindcss/oxide", "@vscode/vsce-sign", "better-sqlite3", diff --git a/packages/evals/Dockerfile.runner b/packages/evals/Dockerfile.runner index 19a85c51d0..5d8e113206 100644 --- a/packages/evals/Dockerfile.runner +++ b/packages/evals/Dockerfile.runner @@ -1,14 +1,14 @@ -FROM node:20-slim AS base +# Build with: +# docker compose -f packages/evals/docker-compose.yml build runner -# Install pnpm -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -RUN npm install -g npm@latest npm-run-all +# Test with: +# docker compose -f packages/evals/docker-compose.yml run --rm runner bash + +FROM debian:bookworm-slim AS base -# Install system packages -RUN apt update && \ - apt install -y \ +# Install system packages (excluding language runtimes - those come from mise) +RUN apt-get update && \ + apt-get install -y \ curl \ git \ vim \ @@ -22,18 +22,13 @@ RUN apt update && \ gpg \ xvfb \ cmake \ - golang-go \ - default-jre \ - python3 \ - python3-venv \ - python3-dev \ - python3-pip \ + build-essential \ && rm -rf /var/lib/apt/lists/* # Install Docker cli RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \ - && apt update && apt install -y docker-ce-cli \ + && apt-get update && apt-get install -y docker-ce-cli \ && rm -rf /var/lib/apt/lists/* # Install VS Code @@ -41,15 +36,43 @@ RUN wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor && install -D -o root -g root -m 644 packages.microsoft.gpg /etc/apt/keyrings/packages.microsoft.gpg \ && echo "deb [arch=amd64,arm64,armhf signed-by=/etc/apt/keyrings/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main" | tee /etc/apt/sources.list.d/vscode.list > /dev/null \ && rm -f packages.microsoft.gpg \ - && apt update && apt install -y code \ + && apt-get update && apt-get install -y code \ && rm -rf /var/lib/apt/lists/* WORKDIR /roo -# Install rust -ARG RUST_VERSION=1.87.0 -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain ${RUST_VERSION} \ - && echo 'source $HOME/.cargo/env' >> $HOME/.bashrc +# Install mise (https://mise.jdx.dev) for language runtime management +RUN curl https://mise.run | sh \ + && /root/.local/bin/mise --version + +# Set up mise environment +ENV MISE_DATA_DIR="/root/.local/share/mise" +ENV PATH="/root/.local/share/mise/shims:/root/.local/bin:$PATH" + +# Define language runtime versions (matching setup.sh) +ARG NODE_VERSION=20.19.2 +ARG PYTHON_VERSION=3.13.2 +ARG GO_VERSION=1.24.2 +ARG RUST_VERSION=1.85.1 +ARG JAVA_VERSION=openjdk-17 +ARG UV_VERSION=0.7.11 + +# Install language runtimes via mise +RUN mise use --global node@${NODE_VERSION} \ + && mise use --global python@${PYTHON_VERSION} \ + && mise use --global go@${GO_VERSION} \ + && mise use --global rust@${RUST_VERSION} \ + && mise use --global java@${JAVA_VERSION} \ + && mise use --global uv@${UV_VERSION} \ + && mise reshim + +# Verify installations +RUN node --version && python --version && go version && rustc --version && java --version && uv --version + +# Install pnpm (after node is available from mise) +ENV PNPM_HOME="/root/.local/share/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN npm install -g pnpm npm-run-all # Install VS Code extensions ARG GOLANG_EXT_VERSION=0.46.1 @@ -72,17 +95,20 @@ RUN git clone ${EVALS_REPO_URL} evals \ && cd evals \ && git checkout ${EVALS_COMMIT} -# Install uv and sync python dependencies -ARG UV_VERSION=0.7.11 +# Pre-warm Gradle wrapper cache (./gradlew downloads its own Gradle regardless of system install). +# Find a Java project with gradlew and run it to cache the distribution. +RUN find /roo/evals -name "gradlew" -type f | head -1 | xargs -I {} sh -c 'cd $(dirname {}) && ./gradlew --version' + +# Sync python dependencies for evals WORKDIR /roo/evals/python -RUN curl -LsSf https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-installer.sh | sh \ - && /root/.local/bin/uv sync +RUN uv sync WORKDIR /roo/repo # Install npm packages RUN mkdir -p \ scripts \ + apps/cli \ packages/build \ packages/config-eslint \ packages/config-typescript \ @@ -92,6 +118,7 @@ RUN mkdir -p \ packages/telemetry \ packages/types \ packages/cloud \ + packages/vscode-shim \ src \ webview-ui @@ -99,6 +126,7 @@ COPY ./package.json ./ COPY ./pnpm-lock.yaml ./ COPY ./pnpm-workspace.yaml ./ COPY ./scripts/bootstrap.mjs ./scripts/ +COPY ./apps/cli/package.json ./apps/cli/ COPY ./packages/build/package.json ./packages/build/ COPY ./packages/config-eslint/package.json ./packages/config-eslint/ COPY ./packages/config-typescript/package.json ./packages/config-typescript/ @@ -108,6 +136,7 @@ COPY ./packages/ipc/package.json ./packages/ipc/ COPY ./packages/telemetry/package.json ./packages/telemetry/ COPY ./packages/types/package.json ./packages/types/ COPY ./packages/cloud/package.json ./packages/cloud/ +COPY ./packages/vscode-shim/package.json ./packages/vscode-shim/ COPY ./src/package.json ./src/ COPY ./webview-ui/package.json ./webview-ui/ @@ -128,10 +157,15 @@ COPY packages/evals/.env.local ./packages/evals/ # Copy the pre-installed VS Code extensions RUN cp -r /roo/.vscode-template /roo/.vscode -# Build the Roo Code extension +# Build the Roo Code extension (for VSCode execution method) RUN pnpm vsix -- --out ../bin/roo-code.vsix \ && yes | code --no-sandbox --user-data-dir /roo/.vscode --install-extension bin/roo-code.vsix +# Build the extension bundle and CLI (for CLI execution method) +# The CLI requires the extension bundle (src/dist/extension.js) and the CLI build (apps/cli/dist/index.js) +RUN pnpm --filter roo-cline bundle \ + && pnpm --filter @roo-code/cli build + # Copy entrypoint script COPY packages/evals/.docker/entrypoints/runner.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh diff --git a/packages/evals/src/cli/messageLogDeduper.test.ts b/packages/evals/src/cli/__tests__/messageLogDeduper.test.ts similarity index 95% rename from packages/evals/src/cli/messageLogDeduper.test.ts rename to packages/evals/src/cli/__tests__/messageLogDeduper.test.ts index 5556c0c850..3a7facb8c2 100644 --- a/packages/evals/src/cli/messageLogDeduper.test.ts +++ b/packages/evals/src/cli/__tests__/messageLogDeduper.test.ts @@ -1,4 +1,4 @@ -import { MessageLogDeduper } from "./messageLogDeduper.js" +import { MessageLogDeduper } from "../messageLogDeduper.js" describe("MessageLogDeduper", () => { it("dedupes identical messages for same action+ts", () => { diff --git a/packages/evals/src/cli/index.ts b/packages/evals/src/cli/index.ts index f7c343de2f..bc91f0db8a 100644 --- a/packages/evals/src/cli/index.ts +++ b/packages/evals/src/cli/index.ts @@ -6,7 +6,7 @@ import { EVALS_REPO_PATH } from "../exercises/index.js" import { runCi } from "./runCi.js" import { runEvals } from "./runEvals.js" -import { processTask } from "./runTask.js" +import { processTask } from "./processTask.js" const main = async () => { await run( diff --git a/packages/evals/src/cli/processTask.ts b/packages/evals/src/cli/processTask.ts new file mode 100644 index 0000000000..c0348872cc --- /dev/null +++ b/packages/evals/src/cli/processTask.ts @@ -0,0 +1,150 @@ +import { execa } from "execa" + +import { type TaskEvent, RooCodeEventName } from "@roo-code/types" + +import { findRun, findTask, updateTask } from "../db/index.js" + +import { Logger, getTag, isDockerContainer } from "./utils.js" +import { redisClient, getPubSubKey, registerRunner, deregisterRunner } from "./redis.js" +import { runUnitTest } from "./runUnitTest.js" +import { runTaskWithCli } from "./runTaskInCli.js" +import { runTaskInVscode } from "./runTaskInVscode.js" + +export const processTask = async ({ + taskId, + jobToken, + logger, +}: { + taskId: number + jobToken: string | null + logger?: Logger +}) => { + const task = await findTask(taskId) + const { language, exercise } = task + const run = await findRun(task.runId) + await registerRunner({ runId: run.id, taskId, timeoutSeconds: (run.timeout || 5) * 60 }) + + const containerized = isDockerContainer() + + logger = + logger || + new Logger({ + logDir: containerized ? `/var/log/evals/runs/${run.id}` : `/tmp/evals/runs/${run.id}`, + filename: `${language}-${exercise}.log`, + tag: getTag("runTask", { run, task }), + }) + + try { + const publish = async (e: TaskEvent) => { + const redis = await redisClient() + await redis.publish(getPubSubKey(run.id), JSON.stringify(e)) + } + + const executionMethod = run.executionMethod || "vscode" + logger.info(`running task ${task.id} (${language}/${exercise}) via ${executionMethod}...`) + + if (executionMethod === "cli") { + await runTaskWithCli({ run, task, jobToken, publish, logger }) + } else { + await runTaskInVscode({ run, task, jobToken, publish, logger }) + } + + logger.info(`testing task ${task.id} (${language}/${exercise})...`) + const passed = await runUnitTest({ task, logger }) + + logger.info(`task ${task.id} (${language}/${exercise}) -> ${passed}`) + await updateTask(task.id, { passed }) + + await publish({ + eventName: passed ? RooCodeEventName.EvalPass : RooCodeEventName.EvalFail, + taskId: task.id, + }) + } finally { + await deregisterRunner({ runId: run.id, taskId }) + } +} + +export const processTaskInContainer = async ({ + taskId, + jobToken, + logger, + maxRetries = 10, +}: { + taskId: number + jobToken: string | null + logger: Logger + maxRetries?: number +}) => { + const baseArgs = [ + "--rm", + "--network evals_default", + "-v /var/run/docker.sock:/var/run/docker.sock", + "-v /tmp/evals:/var/log/evals", + "-e HOST_EXECUTION_METHOD=docker", + ] + + if (jobToken) { + baseArgs.push(`-e ROO_CODE_CLOUD_TOKEN=${jobToken}`) + } + + // Pass API keys to the container so the CLI can authenticate + const apiKeyEnvVars = [ + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "DEEPSEEK_API_KEY", + "MISTRAL_API_KEY", + ] + + for (const envVar of apiKeyEnvVars) { + if (process.env[envVar]) { + baseArgs.push(`-e ${envVar}=${process.env[envVar]}`) + } + } + + const command = `pnpm --filter @roo-code/evals cli --taskId ${taskId}` + logger.info(command) + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const containerName = `evals-task-${taskId}.${attempt}` + const args = [`--name ${containerName}`, `-e EVALS_ATTEMPT=${attempt}`, ...baseArgs] + const isRetry = attempt > 0 + + if (isRetry) { + const delayMs = Math.pow(2, attempt - 1) * 1000 * (0.5 + Math.random()) + logger.info(`retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + + logger.info( + `${isRetry ? "retrying" : "executing"} container command (attempt ${attempt + 1}/${maxRetries + 1})`, + ) + + const subprocess = execa(`docker run ${args.join(" ")} evals-runner sh -c "${command}"`, { shell: true }) + // subprocess.stdout?.on("data", (data) => console.log(data.toString())) + // subprocess.stderr?.on("data", (data) => console.error(data.toString())) + + try { + const result = await subprocess + logger.info(`container process completed with exit code: ${result.exitCode}`) + return + } catch (error) { + if (error && typeof error === "object" && "exitCode" in error) { + logger.error( + `container process failed with exit code: ${error.exitCode} (attempt ${attempt + 1}/${maxRetries + 1})`, + ) + } else { + logger.error(`container process failed with error: ${error} (attempt ${attempt + 1}/${maxRetries + 1})`) + } + + if (attempt === maxRetries) { + break + } + } + } + + logger.error(`all ${maxRetries + 1} attempts failed, giving up`) + + // TODO: Mark task as failed. +} diff --git a/packages/evals/src/cli/runEvals.ts b/packages/evals/src/cli/runEvals.ts index 7fe6d7ea4e..cb327938ea 100644 --- a/packages/evals/src/cli/runEvals.ts +++ b/packages/evals/src/cli/runEvals.ts @@ -5,7 +5,7 @@ import { EVALS_REPO_PATH } from "../exercises/index.js" import { Logger, getTag, isDockerContainer, resetEvalsRepo, commitEvalsRepoChanges } from "./utils.js" import { startHeartbeat, stopHeartbeat } from "./redis.js" -import { processTask, processTaskInContainer } from "./runTask.js" +import { processTask, processTaskInContainer } from "./processTask.js" export const runEvals = async (runId: number) => { const run = await findRun(runId) @@ -53,13 +53,18 @@ export const runEvals = async (runId: number) => { } try { - // Add tasks with staggered start times when concurrency > 1 + // Add tasks with staggered start times when concurrency > 1. for (let i = 0; i < filteredTasks.length; i++) { const task = filteredTasks[i] - if (!task) continue + + if (!task) { + continue + } + if (run.concurrency > 1 && i > 0) { await new Promise((resolve) => setTimeout(resolve, STAGGER_DELAY_MS)) } + queue.add(createTaskRunner(task)) } diff --git a/packages/evals/src/cli/runTaskInCli.ts b/packages/evals/src/cli/runTaskInCli.ts new file mode 100644 index 0000000000..1f1ad79161 --- /dev/null +++ b/packages/evals/src/cli/runTaskInCli.ts @@ -0,0 +1,313 @@ +import * as fs from "fs" +import * as path from "path" +import * as os from "node:os" + +import pWaitFor from "p-wait-for" +import { execa } from "execa" + +import { type ToolUsage, TaskCommandName, RooCodeEventName, IpcMessageType } from "@roo-code/types" +import { IpcClient } from "@roo-code/ipc" + +import { updateTask, createTaskMetrics, updateTaskMetrics, createToolError } from "../db/index.js" +import { EVALS_REPO_PATH } from "../exercises/index.js" + +import { type RunTaskOptions } from "./types.js" +import { mergeToolUsage, waitForSubprocessWithTimeout } from "./utils.js" + +/** + * Run a task using the Roo Code CLI (headless mode). + * Uses the same IPC protocol as VSCode since the CLI loads the same extension bundle. + */ +export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: RunTaskOptions) => { + const { language, exercise } = task + const prompt = fs.readFileSync(path.resolve(EVALS_REPO_PATH, `prompts/${language}.md`), "utf-8") + const workspacePath = path.resolve(EVALS_REPO_PATH, language, exercise) + const ipcSocketPath = path.resolve(os.tmpdir(), `evals-cli-${run.id}-${task.id}.sock`) + + const env: Record = { + ...(process.env as Record), + ROO_CODE_IPC_SOCKET_PATH: ipcSocketPath, + } + + if (jobToken) { + env.ROO_CODE_CLOUD_TOKEN = jobToken + } + + const controller = new AbortController() + const cancelSignal = controller.signal + + const cliArgs = [ + "--filter", + "@roo-code/cli", + "start", + "--yes", + "--exit-on-complete", + "--reasoning-effort", + "disabled", + "--workspace", + workspacePath, + ] + + if (run.settings?.mode) { + cliArgs.push("-M", run.settings.mode) + } + + if (run.settings?.apiProvider) { + cliArgs.push("-p", run.settings.apiProvider) + } + + const modelId = run.settings?.apiModelId || run.settings?.openRouterModelId + + if (modelId) { + cliArgs.push("-m", modelId) + } + + cliArgs.push(prompt) + + logger.info(`CLI command: pnpm ${cliArgs.join(" ")}`) + + const subprocess = execa("pnpm", cliArgs, { env, cancelSignal, cwd: process.cwd() }) + + // Buffer for accumulating streaming output until we have complete lines. + let stdoutBuffer = "" + let stderrBuffer = "" + + // Track subprocess exit code - with -x flag the CLI exits immediately after task completion. + let subprocessExitCode: number | null = null + + // Pipe CLI stdout/stderr to the logger for easier debugging. + // Buffer output and only log complete lines to avoid fragmented token-by-token logging. + // Use logger.raw() to output without the verbose prefix (timestamp, tag, etc). + subprocess.stdout?.on("data", (data: Buffer) => { + stdoutBuffer += data.toString() + const lines = stdoutBuffer.split("\n") + + // Keep the last incomplete line in the buffer. + stdoutBuffer = lines.pop() || "" + + // Log all complete lines without the verbose prefix. + for (const line of lines) { + if (line.trim()) { + logger.raw(line) + } + } + }) + + subprocess.stderr?.on("data", (data: Buffer) => { + stderrBuffer += data.toString() + const lines = stderrBuffer.split("\n") + + // Keep the last incomplete line in the buffer. + stderrBuffer = lines.pop() || "" + + // Log all complete lines without the verbose prefix. + for (const line of lines) { + if (line.trim()) { + logger.raw(line) + } + } + }) + + // Log any remaining buffered output when the subprocess exits. + subprocess.on("exit", (code) => { + subprocessExitCode = code + + if (stdoutBuffer.trim()) { + logger.raw(stdoutBuffer) + } + + if (stderrBuffer.trim()) { + logger.raw(stderrBuffer) + } + }) + + // Give CLI some time to start and create IPC server. + await new Promise((resolve) => setTimeout(resolve, 5_000)) + + let client: IpcClient | undefined = undefined + let attempts = 10 // More attempts for CLI startup. + + while (true) { + try { + client = new IpcClient(ipcSocketPath) + await pWaitFor(() => client!.isReady, { interval: 500, timeout: 2_000 }) + break + } catch (_error) { + client?.disconnect() + attempts-- + + if (attempts <= 0) { + logger.error(`unable to connect to IPC socket -> ${ipcSocketPath}`) + throw new Error("Unable to connect to CLI IPC socket.") + } + + // Wait a bit before retrying. + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + } + + // For CLI mode, we need to create taskMetrics immediately because the CLI starts + // the task right away (from command line args). By the time we connect to IPC, + // the TaskStarted event may have already been sent and missed. + // This is different from VSCode mode where we send StartNewTask via IPC and can + // reliably receive TaskStarted. + const taskMetrics = await createTaskMetrics({ + cost: 0, + tokensIn: 0, + tokensOut: 0, + tokensContext: 0, + duration: 0, + cacheWrites: 0, + cacheReads: 0, + }) + + await updateTask(task.id, { taskMetricsId: taskMetrics.id, startedAt: new Date() }) + logger.info(`created taskMetrics with id ${taskMetrics.id}`) + + // The rest of the logic handles IPC events for metrics updates. + let taskStartedAt = Date.now() + let taskFinishedAt: number | undefined + let taskAbortedAt: number | undefined + let taskTimedOut: boolean = false + const taskMetricsId = taskMetrics.id // Already set, no need to wait for TaskStarted. + let rooTaskId: string | undefined + let isClientDisconnected = false + const accumulatedToolUsage: ToolUsage = {} + + // For CLI mode, we don't need verbose IPC message logging since we're logging stdout instead. + // We only track what's needed for metrics and task state management. + const ignoreEventsForBroadcast = [RooCodeEventName.Message] + let isApiUnstable = false + + client.on(IpcMessageType.TaskEvent, async (taskEvent) => { + const { eventName, payload } = taskEvent + + // Track API instability for retry logic. + if ( + eventName === RooCodeEventName.Message && + payload[0].message.say && + ["api_req_retry_delayed", "api_req_retried"].includes(payload[0].message.say) + ) { + isApiUnstable = true + } + + // Publish events to Redis (except Message events) for the web UI. + if (!ignoreEventsForBroadcast.includes(eventName)) { + await publish({ ...taskEvent, taskId: task.id }) + } + + // Handle task lifecycle events. + // For CLI mode, we already created taskMetrics before connecting to IPC, + // but we still want to capture the rooTaskId from TaskStarted if we receive it. + if (eventName === RooCodeEventName.TaskStarted) { + taskStartedAt = Date.now() + rooTaskId = payload[0] + logger.info(`received TaskStarted event, rooTaskId: ${rooTaskId}`) + } + + if (eventName === RooCodeEventName.TaskToolFailed) { + const [_taskId, toolName, error] = payload + await createToolError({ taskId: task.id, toolName, error }) + } + + if (eventName === RooCodeEventName.TaskTokenUsageUpdated || eventName === RooCodeEventName.TaskCompleted) { + // In CLI mode, taskMetricsId is always set before we register event handlers. + const duration = Date.now() - taskStartedAt + + const { totalCost, totalTokensIn, totalTokensOut, contextTokens, totalCacheWrites, totalCacheReads } = + payload[1] + + const incomingToolUsage: ToolUsage = payload[2] ?? {} + mergeToolUsage(accumulatedToolUsage, incomingToolUsage) + + await updateTaskMetrics(taskMetricsId, { + cost: totalCost, + tokensIn: totalTokensIn, + tokensOut: totalTokensOut, + tokensContext: contextTokens, + duration, + cacheWrites: totalCacheWrites ?? 0, + cacheReads: totalCacheReads ?? 0, + toolUsage: accumulatedToolUsage, + }) + } + + if (eventName === RooCodeEventName.TaskAborted) { + taskAbortedAt = Date.now() + } + + if (eventName === RooCodeEventName.TaskCompleted) { + taskFinishedAt = Date.now() + } + }) + + client.on(IpcMessageType.Disconnect, async () => { + logger.info(`disconnected from IPC socket -> ${ipcSocketPath}`) + isClientDisconnected = true + // Note: In CLI mode, we don't need to resolve taskMetricsReady since + // taskMetrics is created synchronously before event handlers are registered. + }) + + // Note: We do NOT send StartNewTask via IPC here because the CLI already + // starts the task from its command line arguments. The IPC connection is + // only used to receive events (TaskStarted, TaskCompleted, etc.) and metrics. + // Sending StartNewTask here would start a SECOND task. + + try { + const timeoutMs = (run.timeout || 5) * 60 * 1_000 + + await pWaitFor(() => !!taskFinishedAt || !!taskAbortedAt || isClientDisconnected, { + interval: 1_000, + timeout: timeoutMs, + }) + } catch (_error) { + taskTimedOut = true + logger.error("time limit reached") + + if (rooTaskId && !isClientDisconnected) { + logger.info("cancelling task") + client.sendCommand({ commandName: TaskCommandName.CancelTask, data: rooTaskId }) + await new Promise((resolve) => setTimeout(resolve, 5_000)) + } + + taskFinishedAt = Date.now() + } + + if (!taskFinishedAt && !taskTimedOut) { + // With -x flag, CLI exits immediately after task completion, which can cause + // IPC disconnection before we receive the TaskCompleted event. + // If subprocess exited cleanly (code 0), treat as successful completion. + if (subprocessExitCode === 0) { + taskFinishedAt = Date.now() + logger.info("subprocess exited cleanly (code 0), treating as task completion") + } else { + logger.error(`client disconnected before task finished (subprocess exit code: ${subprocessExitCode})`) + throw new Error("Client disconnected before task completion.") + } + } + + logger.info("setting task finished at") + await updateTask(task.id, { finishedAt: new Date() }) + + if (rooTaskId && !isClientDisconnected) { + logger.info("closing task") + client.sendCommand({ commandName: TaskCommandName.CloseTask, data: rooTaskId }) + await new Promise((resolve) => setTimeout(resolve, 2_000)) + } + + if (!isClientDisconnected) { + logger.info("disconnecting client") + client.disconnect() + } + + logger.info("waiting for subprocess to finish") + controller.abort() + + await waitForSubprocessWithTimeout({ subprocess, logger }) + + logger.close() + + if (isApiUnstable && !taskFinishedAt) { + throw new Error("API is unstable, throwing to trigger a retry.") + } +} diff --git a/packages/evals/src/cli/runTask.ts b/packages/evals/src/cli/runTaskInVscode.ts similarity index 58% rename from packages/evals/src/cli/runTask.ts rename to packages/evals/src/cli/runTaskInVscode.ts index d93aa5bc37..f6e87a4bda 100644 --- a/packages/evals/src/cli/runTask.ts +++ b/packages/evals/src/cli/runTaskInVscode.ts @@ -1,5 +1,4 @@ import * as fs from "fs" -import * as fsp from "fs/promises" import * as path from "path" import * as os from "node:os" @@ -7,218 +6,23 @@ import pWaitFor from "p-wait-for" import { execa } from "execa" import { - type TaskEvent, type ClineSay, + type ToolUsage, TaskCommandName, RooCodeEventName, IpcMessageType, EVALS_SETTINGS, - type ToolUsage, } from "@roo-code/types" import { IpcClient } from "@roo-code/ipc" -import { - type Run, - type Task, - findRun, - findTask, - updateTask, - createTaskMetrics, - updateTaskMetrics, - createToolError, -} from "../db/index.js" +import { updateTask, createTaskMetrics, updateTaskMetrics, createToolError } from "../db/index.js" import { EVALS_REPO_PATH } from "../exercises/index.js" -import { Logger, getTag, isDockerContainer } from "./utils.js" -import { redisClient, getPubSubKey, registerRunner, deregisterRunner } from "./redis.js" -import { runUnitTest } from "./runUnitTest.js" +import { type RunTaskOptions } from "./types.js" +import { isDockerContainer, copyConversationHistory, mergeToolUsage, waitForSubprocessWithTimeout } from "./utils.js" import { MessageLogDeduper } from "./messageLogDeduper.js" -class SubprocessTimeoutError extends Error { - constructor(timeout: number) { - super(`Subprocess timeout after ${timeout}ms`) - this.name = "SubprocessTimeoutError" - } -} - -/** - * Copy conversation history files from VS Code extension storage to the log directory. - * This allows us to preserve the api_conversation_history.json and ui_messages.json - * files for post-mortem analysis alongside the log files. - */ -async function copyConversationHistory({ - rooTaskId, - logDir, - language, - exercise, - iteration, - logger, -}: { - rooTaskId: string - logDir: string - language: string - exercise: string - iteration: number - logger: Logger -}): Promise { - // VS Code extension global storage path within the container - const extensionStoragePath = "/roo/.vscode/User/globalStorage/rooveterinaryinc.roo-cline" - const taskStoragePath = path.join(extensionStoragePath, "tasks", rooTaskId) - - const filesToCopy = ["api_conversation_history.json", "ui_messages.json"] - - for (const filename of filesToCopy) { - const sourcePath = path.join(taskStoragePath, filename) - // Use sanitized exercise name (replace slashes with dashes) for the destination filename - // Include iteration number to handle multiple attempts at the same exercise - const sanitizedExercise = exercise.replace(/\//g, "-") - const destFilename = `${language}-${sanitizedExercise}.${iteration}_${filename}` - const destPath = path.join(logDir, destFilename) - - try { - // Check if source file exists - await fsp.access(sourcePath) - - // Copy the file - await fsp.copyFile(sourcePath, destPath) - logger.info(`copied ${filename} to ${destPath}`) - } catch (error) { - // File may not exist if task didn't complete properly - this is not fatal - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - logger.info(`${filename} not found at ${sourcePath} - skipping`) - } else { - logger.error(`failed to copy ${filename}:`, error) - } - } - } -} - -export const processTask = async ({ - taskId, - jobToken, - logger, -}: { - taskId: number - jobToken: string | null - logger?: Logger -}) => { - const task = await findTask(taskId) - const { language, exercise } = task - const run = await findRun(task.runId) - await registerRunner({ runId: run.id, taskId, timeoutSeconds: (run.timeout || 5) * 60 }) - - const containerized = isDockerContainer() - - logger = - logger || - new Logger({ - logDir: containerized ? `/var/log/evals/runs/${run.id}` : `/tmp/evals/runs/${run.id}`, - filename: `${language}-${exercise}.log`, - tag: getTag("runTask", { run, task }), - }) - - try { - const publish = async (e: TaskEvent) => { - const redis = await redisClient() - await redis.publish(getPubSubKey(run.id), JSON.stringify(e)) - } - - logger.info(`running task ${task.id} (${language}/${exercise})...`) - await runTask({ run, task, jobToken, publish, logger }) - - logger.info(`testing task ${task.id} (${language}/${exercise})...`) - const passed = await runUnitTest({ task, logger }) - - logger.info(`task ${task.id} (${language}/${exercise}) -> ${passed}`) - await updateTask(task.id, { passed }) - - await publish({ - eventName: passed ? RooCodeEventName.EvalPass : RooCodeEventName.EvalFail, - taskId: task.id, - }) - } finally { - await deregisterRunner({ runId: run.id, taskId }) - } -} - -export const processTaskInContainer = async ({ - taskId, - jobToken, - logger, - maxRetries = 10, -}: { - taskId: number - jobToken: string | null - logger: Logger - maxRetries?: number -}) => { - const baseArgs = [ - "--rm", - "--network evals_default", - "-v /var/run/docker.sock:/var/run/docker.sock", - "-v /tmp/evals:/var/log/evals", - "-e HOST_EXECUTION_METHOD=docker", - ] - - if (jobToken) { - baseArgs.push(`-e ROO_CODE_CLOUD_TOKEN=${jobToken}`) - } - - const command = `pnpm --filter @roo-code/evals cli --taskId ${taskId}` - logger.info(command) - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - const containerName = `evals-task-${taskId}.${attempt}` - const args = [`--name ${containerName}`, `-e EVALS_ATTEMPT=${attempt}`, ...baseArgs] - const isRetry = attempt > 0 - - if (isRetry) { - const delayMs = Math.pow(2, attempt - 1) * 1000 * (0.5 + Math.random()) - logger.info(`retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`) - await new Promise((resolve) => setTimeout(resolve, delayMs)) - } - - logger.info( - `${isRetry ? "retrying" : "executing"} container command (attempt ${attempt + 1}/${maxRetries + 1})`, - ) - - const subprocess = execa(`docker run ${args.join(" ")} evals-runner sh -c "${command}"`, { shell: true }) - // subprocess.stdout?.on("data", (data) => console.log(data.toString())) - // subprocess.stderr?.on("data", (data) => console.error(data.toString())) - - try { - const result = await subprocess - logger.info(`container process completed with exit code: ${result.exitCode}`) - return - } catch (error) { - if (error && typeof error === "object" && "exitCode" in error) { - logger.error( - `container process failed with exit code: ${error.exitCode} (attempt ${attempt + 1}/${maxRetries + 1})`, - ) - } else { - logger.error(`container process failed with error: ${error} (attempt ${attempt + 1}/${maxRetries + 1})`) - } - - if (attempt === maxRetries) { - break - } - } - } - - logger.error(`all ${maxRetries + 1} attempts failed, giving up`) - - // TODO: Mark task as failed. -} - -type RunTaskOptions = { - run: Run - task: Task - jobToken: string | null - publish: (taskEvent: TaskEvent) => Promise - logger: Logger -} - -export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskOptions) => { +export const runTaskInVscode = async ({ run, task, publish, logger, jobToken }: RunTaskOptions) => { const { language, exercise } = task const prompt = fs.readFileSync(path.resolve(EVALS_REPO_PATH, `prompts/${language}.md`), "utf-8") const workspacePath = path.resolve(EVALS_REPO_PATH, language, exercise) @@ -410,24 +214,7 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO // For both TaskTokenUsageUpdated and TaskCompleted: toolUsage is payload[2] const incomingToolUsage: ToolUsage = payload[2] ?? {} - - // Merge incoming tool usage with accumulated data using MAX strategy. - // This handles the case where a task is rehydrated after abort: - // - Empty rehydrated data won't overwrite existing: max(5, 0) = 5 - // - Legitimate restart with additional work is captured: max(5, 8) = 8 - // Each task instance tracks its own cumulative values, so we take the max - // to preserve the highest values seen across all instances. - for (const [toolName, usage] of Object.entries(incomingToolUsage)) { - const existing = accumulatedToolUsage[toolName as keyof ToolUsage] - if (existing) { - accumulatedToolUsage[toolName as keyof ToolUsage] = { - attempts: Math.max(existing.attempts, usage.attempts), - failures: Math.max(existing.failures, usage.failures), - } - } else { - accumulatedToolUsage[toolName as keyof ToolUsage] = { ...usage } - } - } + mergeToolUsage(accumulatedToolUsage, incomingToolUsage) await updateTaskMetrics(taskMetricsId, { cost: totalCost, @@ -514,35 +301,7 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO logger.info("waiting for subprocess to finish") controller.abort() - // Wait for subprocess to finish gracefully, with a timeout. - const SUBPROCESS_TIMEOUT = 10_000 - - try { - await Promise.race([ - subprocess, - new Promise((_, reject) => - setTimeout(() => reject(new SubprocessTimeoutError(SUBPROCESS_TIMEOUT)), SUBPROCESS_TIMEOUT), - ), - ]) - - logger.info("subprocess finished gracefully") - } catch (error) { - if (error instanceof SubprocessTimeoutError) { - logger.error("subprocess did not finish within timeout, force killing") - - try { - if (subprocess.kill("SIGKILL")) { - logger.info("SIGKILL sent to subprocess") - } else { - logger.error("failed to send SIGKILL to subprocess") - } - } catch (killError) { - logger.error("subprocess.kill(SIGKILL) failed:", killError) - } - } else { - throw error - } - } + await waitForSubprocessWithTimeout({ subprocess, logger }) // Copy conversation history files from VS Code extension storage to the log directory // for post-mortem analysis. Only do this in containerized mode where we have a known path. diff --git a/packages/evals/src/cli/types.ts b/packages/evals/src/cli/types.ts new file mode 100644 index 0000000000..bb6012ddeb --- /dev/null +++ b/packages/evals/src/cli/types.ts @@ -0,0 +1,19 @@ +import { type TaskEvent } from "@roo-code/types" + +import type { Run, Task } from "../db/index.js" +import { Logger } from "./utils.js" + +export class SubprocessTimeoutError extends Error { + constructor(timeout: number) { + super(`Subprocess timeout after ${timeout}ms`) + this.name = "SubprocessTimeoutError" + } +} + +export type RunTaskOptions = { + run: Run + task: Task + jobToken: string | null + publish: (taskEvent: TaskEvent) => Promise + logger: Logger +} diff --git a/packages/evals/src/cli/utils.ts b/packages/evals/src/cli/utils.ts index 0bea2ac54c..b711061a8f 100644 --- a/packages/evals/src/cli/utils.ts +++ b/packages/evals/src/cli/utils.ts @@ -1,10 +1,15 @@ import * as fs from "fs" +import * as fsp from "fs/promises" import * as path from "path" -import { execa } from "execa" +import { execa, type ResultPromise } from "execa" + +import type { ToolUsage } from "@roo-code/types" import type { Run, Task } from "../db/index.js" +import { SubprocessTimeoutError } from "./types.js" + export const getTag = (caller: string, { run, task }: { run: Run; task?: Task }) => task ? `${caller} | pid:${process.pid} | run:${run.id} | task:${task.id} | ${task.language}/${task.exercise}` @@ -107,6 +112,22 @@ export class Logger { this.info(message, ...args) } + /** + * Write raw output without any prefix (timestamp, level, tag). + * Useful for streaming CLI output where the prefix would be noise. + */ + public raw(message: string): void { + try { + console.log(message) + + if (this.logStream) { + this.logStream.write(message + "\n") + } + } catch (error) { + console.error(`Failed to write to log file ${this.logFilePath}:`, error) + } + } + public close(): void { if (this.logStream) { this.logStream.end() @@ -114,3 +135,117 @@ export class Logger { } } } + +/** + * Copy conversation history files from VS Code extension storage to the log directory. + * This allows us to preserve the api_conversation_history.json and ui_messages.json + * files for post-mortem analysis alongside the log files. + */ +export async function copyConversationHistory({ + rooTaskId, + logDir, + language, + exercise, + iteration, + logger, +}: { + rooTaskId: string + logDir: string + language: string + exercise: string + iteration: number + logger: Logger +}): Promise { + // VS Code extension global storage path within the container + const extensionStoragePath = "/roo/.vscode/User/globalStorage/rooveterinaryinc.roo-cline" + const taskStoragePath = path.join(extensionStoragePath, "tasks", rooTaskId) + + const filesToCopy = ["api_conversation_history.json", "ui_messages.json"] + + for (const filename of filesToCopy) { + const sourcePath = path.join(taskStoragePath, filename) + // Use sanitized exercise name (replace slashes with dashes) for the destination filename + // Include iteration number to handle multiple attempts at the same exercise + const sanitizedExercise = exercise.replace(/\//g, "-") + const destFilename = `${language}-${sanitizedExercise}.${iteration}_${filename}` + const destPath = path.join(logDir, destFilename) + + try { + // Check if source file exists + await fsp.access(sourcePath) + + // Copy the file + await fsp.copyFile(sourcePath, destPath) + logger.info(`copied ${filename} to ${destPath}`) + } catch (error) { + // File may not exist if task didn't complete properly - this is not fatal + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + logger.info(`${filename} not found at ${sourcePath} - skipping`) + } else { + logger.error(`failed to copy ${filename}:`, error) + } + } + } +} + +/** + * Merge incoming tool usage with accumulated data using MAX strategy. + * This handles the case where a task is rehydrated after abort: + * - Empty rehydrated data won't overwrite existing: max(5, 0) = 5 + * - Legitimate restart with additional work is captured: max(5, 8) = 8 + * Each task instance tracks its own cumulative values, so we take the max + * to preserve the highest values seen across all instances. + */ +export function mergeToolUsage(accumulated: ToolUsage, incoming: ToolUsage): void { + for (const [toolName, usage] of Object.entries(incoming)) { + const existing = accumulated[toolName as keyof ToolUsage] + + if (existing) { + accumulated[toolName as keyof ToolUsage] = { + attempts: Math.max(existing.attempts, usage.attempts), + failures: Math.max(existing.failures, usage.failures), + } + } else { + accumulated[toolName as keyof ToolUsage] = { ...usage } + } + } +} + +/** + * Wait for a subprocess to finish gracefully, with a timeout. + * If the subprocess doesn't finish within the timeout, force kill it with SIGKILL. + */ +export async function waitForSubprocessWithTimeout({ + subprocess, + timeoutMs = 10_000, + logger, +}: { + subprocess: ResultPromise + timeoutMs?: number + logger: Logger +}): Promise { + try { + await Promise.race([ + subprocess, + new Promise((_, reject) => setTimeout(() => reject(new SubprocessTimeoutError(timeoutMs)), timeoutMs)), + ]) + + logger.info("subprocess finished gracefully") + } catch (error) { + if (error instanceof SubprocessTimeoutError) { + logger.error("subprocess did not finish within timeout, force killing") + + try { + if (subprocess.kill("SIGKILL")) { + logger.info("SIGKILL sent to subprocess") + } else { + logger.error("failed to send SIGKILL to subprocess") + } + } catch (killError) { + logger.error("subprocess.kill(SIGKILL) failed:", killError) + } + } else { + throw error + } + } +} diff --git a/packages/evals/src/db/migrations/0006_worried_spectrum.sql b/packages/evals/src/db/migrations/0006_worried_spectrum.sql new file mode 100644 index 0000000000..87c199447b --- /dev/null +++ b/packages/evals/src/db/migrations/0006_worried_spectrum.sql @@ -0,0 +1 @@ +ALTER TABLE "runs" ADD COLUMN "execution_method" text DEFAULT 'vscode' NOT NULL; \ No newline at end of file diff --git a/packages/evals/src/db/migrations/meta/0006_snapshot.json b/packages/evals/src/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000000..683ef57702 --- /dev/null +++ b/packages/evals/src/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,479 @@ +{ + "id": "ae1ebc36-8f5b-43e1-8e47-5a63d72ed05f", + "prevId": "71b54967-86df-42ec-a200-bfd8dad85069", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.runs": { + "name": "runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "task_metrics_id": { + "name": "task_metrics_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contextWindow": { + "name": "contextWindow", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "inputPrice": { + "name": "inputPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "outputPrice": { + "name": "outputPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cacheWritesPrice": { + "name": "cacheWritesPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cacheReadsPrice": { + "name": "cacheReadsPrice", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "jobToken": { + "name": "jobToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socket_path": { + "name": "socket_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_method": { + "name": "execution_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'vscode'" + }, + "concurrency": { + "name": "concurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "passed": { + "name": "passed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed": { + "name": "failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "runs_task_metrics_id_taskMetrics_id_fk": { + "name": "runs_task_metrics_id_taskMetrics_id_fk", + "tableFrom": "runs", + "tableTo": "taskMetrics", + "columnsFrom": ["task_metrics_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.taskMetrics": { + "name": "taskMetrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "taskMetrics_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tokens_context": { + "name": "tokens_context", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cache_writes": { + "name": "cache_writes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cache_reads": { + "name": "cache_reads", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tool_usage": { + "name": "tool_usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tasks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_metrics_id": { + "name": "task_metrics_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exercise": { + "name": "exercise", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tasks_language_exercise_iteration_idx": { + "name": "tasks_language_exercise_iteration_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "exercise", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "iteration", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_run_id_runs_id_fk": { + "name": "tasks_run_id_runs_id_fk", + "tableFrom": "tasks", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_task_metrics_id_taskMetrics_id_fk": { + "name": "tasks_task_metrics_id_taskMetrics_id_fk", + "tableFrom": "tasks", + "tableTo": "taskMetrics", + "columnsFrom": ["task_metrics_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.toolErrors": { + "name": "toolErrors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "toolErrors_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "toolErrors_run_id_runs_id_fk": { + "name": "toolErrors_run_id_runs_id_fk", + "tableFrom": "toolErrors", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "toolErrors_task_id_tasks_id_fk": { + "name": "toolErrors_task_id_tasks_id_fk", + "tableFrom": "toolErrors", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/evals/src/db/migrations/meta/_journal.json b/packages/evals/src/db/migrations/meta/_journal.json index fbdfcd79bf..d70ab18782 100644 --- a/packages/evals/src/db/migrations/meta/_journal.json +++ b/packages/evals/src/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1765167049182, "tag": "0005_strong_skrulls", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1767550126096, + "tag": "0006_worried_spectrum", + "breakpoints": true } ] } diff --git a/packages/evals/src/db/schema.ts b/packages/evals/src/db/schema.ts index 5094e64f20..4d159fe29b 100644 --- a/packages/evals/src/db/schema.ts +++ b/packages/evals/src/db/schema.ts @@ -5,6 +5,12 @@ import type { RooCodeSettings, ToolName, ToolUsage } from "@roo-code/types" import type { ExerciseLanguage } from "../exercises/index.js" +/** + * ExecutionMethod + */ + +export type ExecutionMethod = "vscode" | "cli" + /** * runs */ @@ -24,6 +30,7 @@ export const runs = pgTable("runs", { jobToken: text(), pid: integer(), socketPath: text("socket_path").notNull(), + executionMethod: text("execution_method").default("vscode").notNull().$type(), concurrency: integer().default(2).notNull(), timeout: integer().default(5).notNull(), passed: integer().default(0).notNull(), diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 678aef2138..0930b4b662 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -210,6 +210,7 @@ export const globalSettingsSchema = z.object({ historyPreviewCollapsed: z.boolean().optional(), reasoningBlockCollapsed: z.boolean().optional(), showSpeedInfo: z.boolean().optional(), + automaticallyFocus: z.boolean().optional(), /** * Controls the keyboard behavior for sending messages in the chat input. * - "send": Enter sends message, Shift+Enter creates newline (default) diff --git a/packages/vscode-shim/eslint.config.mjs b/packages/vscode-shim/eslint.config.mjs new file mode 100644 index 0000000000..694bf73664 --- /dev/null +++ b/packages/vscode-shim/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@roo-code/config-eslint/base" + +/** @type {import("eslint").Linter.Config} */ +export default [...config] diff --git a/packages/vscode-shim/package.json b/packages/vscode-shim/package.json new file mode 100644 index 0000000000..f657a6841f --- /dev/null +++ b/packages/vscode-shim/package.json @@ -0,0 +1,20 @@ +{ + "name": "@roo-code/vscode-shim", + "private": true, + "type": "module", + "exports": "./src/index.ts", + "scripts": { + "format": "prettier --write 'src/**/*.ts'", + "lint": "eslint src --ext .ts --max-warnings=0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "clean": "rimraf .turbo" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "^24.1.0", + "vitest": "^3.2.3" + }, + "dependencies": {} +} diff --git a/packages/vscode-shim/src/__tests__/Additional.test.ts b/packages/vscode-shim/src/__tests__/Additional.test.ts new file mode 100644 index 0000000000..7f1abbee14 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/Additional.test.ts @@ -0,0 +1,378 @@ +import { + Location, + DiagnosticRelatedInformation, + Diagnostic, + ThemeColor, + ThemeIcon, + CodeActionKind, + CodeLens, + LanguageModelTextPart, + LanguageModelToolCallPart, + LanguageModelToolResultPart, + FileSystemError, +} from "../classes/Additional.js" +import { Uri } from "../classes/Uri.js" +import { Range } from "../classes/Range.js" +import { Position } from "../classes/Position.js" + +describe("Location", () => { + it("should create location with URI and Range", () => { + const uri = Uri.file("/path/to/file.txt") + const range = new Range(0, 0, 5, 10) + const location = new Location(uri, range) + + expect(location.uri).toBe(uri) + expect(location.range).toBe(range) + }) + + it("should create location with URI and Position", () => { + const uri = Uri.file("/path/to/file.txt") + const position = new Position(5, 10) + const location = new Location(uri, position) + + expect(location.uri).toBe(uri) + expect(location.range).toBe(position) + }) +}) + +describe("DiagnosticRelatedInformation", () => { + it("should create diagnostic related information", () => { + const uri = Uri.file("/path/to/file.txt") + const range = new Range(0, 0, 1, 0) + const location = new Location(uri, range) + const message = "Related issue here" + + const info = new DiagnosticRelatedInformation(location, message) + + expect(info.location).toBe(location) + expect(info.message).toBe(message) + }) +}) + +describe("Diagnostic", () => { + it("should create diagnostic with default severity (Error)", () => { + const range = new Range(0, 0, 0, 10) + const message = "Error message" + + const diagnostic = new Diagnostic(range, message) + + expect(diagnostic.range.isEqual(range)).toBe(true) + expect(diagnostic.message).toBe(message) + expect(diagnostic.severity).toBe(0) // Error + }) + + it("should create diagnostic with custom severity", () => { + const range = new Range(0, 0, 0, 10) + const message = "Warning message" + + const diagnostic = new Diagnostic(range, message, 1) // Warning + + expect(diagnostic.severity).toBe(1) + }) + + it("should allow setting optional properties", () => { + const range = new Range(0, 0, 0, 10) + const diagnostic = new Diagnostic(range, "Test") + + diagnostic.source = "eslint" + diagnostic.code = "no-unused-vars" + diagnostic.tags = [1] // Unnecessary + + expect(diagnostic.source).toBe("eslint") + expect(diagnostic.code).toBe("no-unused-vars") + expect(diagnostic.tags).toEqual([1]) + }) + + it("should allow setting related information", () => { + const range = new Range(0, 0, 0, 10) + const diagnostic = new Diagnostic(range, "Test") + + const relatedUri = Uri.file("/related.txt") + const relatedLocation = new Location(relatedUri, new Range(1, 0, 1, 5)) + const relatedInfo = new DiagnosticRelatedInformation(relatedLocation, "Related issue") + + diagnostic.relatedInformation = [relatedInfo] + + expect(diagnostic.relatedInformation).toHaveLength(1) + expect(diagnostic.relatedInformation[0]?.message).toBe("Related issue") + }) +}) + +describe("ThemeColor", () => { + it("should create theme color with ID", () => { + const color = new ThemeColor("editor.foreground") + + expect(color.id).toBe("editor.foreground") + }) + + it("should handle custom color IDs", () => { + const color = new ThemeColor("myExtension.customColor") + + expect(color.id).toBe("myExtension.customColor") + }) +}) + +describe("ThemeIcon", () => { + it("should create theme icon with ID", () => { + const icon = new ThemeIcon("file") + + expect(icon.id).toBe("file") + expect(icon.color).toBeUndefined() + }) + + it("should create theme icon with ID and color", () => { + const color = new ThemeColor("errorForeground") + const icon = new ThemeIcon("error", color) + + expect(icon.id).toBe("error") + expect(icon.color).toBe(color) + expect(icon.color?.id).toBe("errorForeground") + }) +}) + +describe("CodeActionKind", () => { + describe("static properties", () => { + it("should have Empty kind", () => { + expect(CodeActionKind.Empty.value).toBe("") + }) + + it("should have QuickFix kind", () => { + expect(CodeActionKind.QuickFix.value).toBe("quickfix") + }) + + it("should have Refactor kind", () => { + expect(CodeActionKind.Refactor.value).toBe("refactor") + }) + + it("should have RefactorExtract kind", () => { + expect(CodeActionKind.RefactorExtract.value).toBe("refactor.extract") + }) + + it("should have RefactorInline kind", () => { + expect(CodeActionKind.RefactorInline.value).toBe("refactor.inline") + }) + + it("should have RefactorRewrite kind", () => { + expect(CodeActionKind.RefactorRewrite.value).toBe("refactor.rewrite") + }) + + it("should have Source kind", () => { + expect(CodeActionKind.Source.value).toBe("source") + }) + + it("should have SourceOrganizeImports kind", () => { + expect(CodeActionKind.SourceOrganizeImports.value).toBe("source.organizeImports") + }) + }) + + describe("constructor", () => { + it("should create custom kind", () => { + const kind = new CodeActionKind("custom.action") + expect(kind.value).toBe("custom.action") + }) + }) + + describe("append()", () => { + it("should append to existing kind", () => { + const kind = new CodeActionKind("refactor") + const appended = kind.append("extract") + + expect(appended.value).toBe("refactor.extract") + }) + + it("should handle empty kind", () => { + const kind = new CodeActionKind("") + const appended = kind.append("quickfix") + + expect(appended.value).toBe("quickfix") + }) + }) + + describe("contains()", () => { + it("should return true when kind contains another", () => { + const parent = CodeActionKind.Refactor + const child = CodeActionKind.RefactorExtract + + expect(parent.contains(child)).toBe(true) + }) + + it("should return false when kinds are different hierarchies", () => { + const quickfix = CodeActionKind.QuickFix + const refactor = CodeActionKind.Refactor + + expect(quickfix.contains(refactor)).toBe(false) + }) + + it("should return true for equal kinds", () => { + const kind = new CodeActionKind("quickfix") + expect(kind.contains(CodeActionKind.QuickFix)).toBe(true) + }) + }) + + describe("intersects()", () => { + it("should return true when one contains the other", () => { + const parent = CodeActionKind.Refactor + const child = CodeActionKind.RefactorExtract + + expect(parent.intersects(child)).toBe(true) + expect(child.intersects(parent)).toBe(true) + }) + + it("should return false for non-intersecting kinds", () => { + const quickfix = CodeActionKind.QuickFix + const source = CodeActionKind.Source + + expect(quickfix.intersects(source)).toBe(false) + }) + }) +}) + +describe("CodeLens", () => { + it("should create CodeLens with range only", () => { + const range = new Range(0, 0, 0, 10) + const lens = new CodeLens(range) + + expect(lens.range.isEqual(range)).toBe(true) + expect(lens.command).toBeUndefined() + expect(lens.isResolved).toBe(false) + }) + + it("should create CodeLens with range and command", () => { + const range = new Range(5, 0, 5, 20) + const command = { + command: "myExtension.doSomething", + title: "Click me", + arguments: [1, 2, 3], + } + const lens = new CodeLens(range, command) + + expect(lens.range).toBeDefined() + expect(lens.command?.command).toBe("myExtension.doSomething") + expect(lens.command?.title).toBe("Click me") + expect(lens.command?.arguments).toEqual([1, 2, 3]) + }) +}) + +describe("LanguageModelTextPart", () => { + it("should create text part with value", () => { + const part = new LanguageModelTextPart("Hello, world!") + + expect(part.value).toBe("Hello, world!") + }) +}) + +describe("LanguageModelToolCallPart", () => { + it("should create tool call part", () => { + const part = new LanguageModelToolCallPart("call-123", "searchFiles", { query: "test" }) + + expect(part.callId).toBe("call-123") + expect(part.name).toBe("searchFiles") + expect(part.input).toEqual({ query: "test" }) + }) +}) + +describe("LanguageModelToolResultPart", () => { + it("should create tool result part", () => { + const part = new LanguageModelToolResultPart("call-123", [{ type: "text", text: "result" }]) + + expect(part.callId).toBe("call-123") + expect(part.content).toHaveLength(1) + expect(part.content[0]).toEqual({ type: "text", text: "result" }) + }) +}) + +describe("FileSystemError", () => { + describe("constructor", () => { + it("should create error with message", () => { + const error = new FileSystemError("Something went wrong") + + expect(error.message).toBe("Something went wrong") + expect(error.code).toBe("Unknown") + expect(error.name).toBe("FileSystemError") + }) + + it("should create error with message and code", () => { + const error = new FileSystemError("Custom error", "CustomCode") + + expect(error.message).toBe("Custom error") + expect(error.code).toBe("CustomCode") + }) + }) + + describe("FileNotFound()", () => { + it("should create FileNotFound error from string", () => { + const error = FileSystemError.FileNotFound("File not found: /path/to/file") + + expect(error.message).toBe("File not found: /path/to/file") + expect(error.code).toBe("FileNotFound") + }) + + it("should create FileNotFound error from URI", () => { + const uri = Uri.file("/path/to/file.txt") + const error = FileSystemError.FileNotFound(uri) + + expect(error.message).toContain("/path/to/file.txt") + expect(error.code).toBe("FileNotFound") + }) + + it("should handle undefined input", () => { + const error = FileSystemError.FileNotFound() + + expect(error.message).toContain("unknown") + expect(error.code).toBe("FileNotFound") + }) + }) + + describe("FileExists()", () => { + it("should create FileExists error", () => { + const error = FileSystemError.FileExists("File already exists") + + expect(error.message).toBe("File already exists") + expect(error.code).toBe("FileExists") + }) + + it("should create FileExists error from URI", () => { + const uri = Uri.file("/existing/file.txt") + const error = FileSystemError.FileExists(uri) + + expect(error.message).toContain("/existing/file.txt") + expect(error.code).toBe("FileExists") + }) + }) + + describe("FileNotADirectory()", () => { + it("should create FileNotADirectory error", () => { + const error = FileSystemError.FileNotADirectory("Not a directory") + + expect(error.message).toBe("Not a directory") + expect(error.code).toBe("FileNotADirectory") + }) + }) + + describe("FileIsADirectory()", () => { + it("should create FileIsADirectory error", () => { + const error = FileSystemError.FileIsADirectory("Is a directory") + + expect(error.message).toBe("Is a directory") + expect(error.code).toBe("FileIsADirectory") + }) + }) + + describe("NoPermissions()", () => { + it("should create NoPermissions error", () => { + const error = FileSystemError.NoPermissions("Access denied") + + expect(error.message).toBe("Access denied") + expect(error.code).toBe("NoPermissions") + }) + }) + + describe("Unavailable()", () => { + it("should create Unavailable error", () => { + const error = FileSystemError.Unavailable("Resource unavailable") + + expect(error.message).toBe("Resource unavailable") + expect(error.code).toBe("Unavailable") + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/CancellationToken.test.ts b/packages/vscode-shim/src/__tests__/CancellationToken.test.ts new file mode 100644 index 0000000000..819b38dfa0 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/CancellationToken.test.ts @@ -0,0 +1,156 @@ +import { CancellationTokenSource } from "../classes/CancellationToken.js" + +describe("CancellationToken", () => { + describe("initial state", () => { + it("should not be cancelled initially", () => { + const source = new CancellationTokenSource() + const token = source.token + + expect(token.isCancellationRequested).toBe(false) + }) + + it("should have onCancellationRequested function", () => { + const source = new CancellationTokenSource() + const token = source.token + + expect(typeof token.onCancellationRequested).toBe("function") + }) + }) +}) + +describe("CancellationTokenSource", () => { + describe("token property", () => { + it("should return a CancellationToken", () => { + const source = new CancellationTokenSource() + const token = source.token + + expect(token).toBeDefined() + expect(typeof token.isCancellationRequested).toBe("boolean") + expect(typeof token.onCancellationRequested).toBe("function") + }) + + it("should return the same token instance on multiple accesses", () => { + const source = new CancellationTokenSource() + + expect(source.token).toBe(source.token) + }) + }) + + describe("cancel()", () => { + it("should set isCancellationRequested to true", () => { + const source = new CancellationTokenSource() + + source.cancel() + + expect(source.token.isCancellationRequested).toBe(true) + }) + + it("should fire onCancellationRequested event", () => { + const source = new CancellationTokenSource() + const listener = vi.fn() + + source.token.onCancellationRequested(listener) + source.cancel() + + expect(listener).toHaveBeenCalledTimes(1) + }) + + it("should only fire event once on multiple cancel calls", () => { + const source = new CancellationTokenSource() + const listener = vi.fn() + + source.token.onCancellationRequested(listener) + source.cancel() + source.cancel() + source.cancel() + + expect(listener).toHaveBeenCalledTimes(1) + }) + + it("should be idempotent", () => { + const source = new CancellationTokenSource() + + source.cancel() + source.cancel() + + expect(source.token.isCancellationRequested).toBe(true) + }) + }) + + describe("dispose()", () => { + it("should cancel the token", () => { + const source = new CancellationTokenSource() + + source.dispose() + + expect(source.token.isCancellationRequested).toBe(true) + }) + + it("should fire onCancellationRequested event", () => { + const source = new CancellationTokenSource() + const listener = vi.fn() + + source.token.onCancellationRequested(listener) + source.dispose() + + expect(listener).toHaveBeenCalledTimes(1) + }) + + it("should be safe to call multiple times", () => { + const source = new CancellationTokenSource() + + expect(() => { + source.dispose() + source.dispose() + }).not.toThrow() + }) + }) + + describe("onCancellationRequested", () => { + it("should return a disposable", () => { + const source = new CancellationTokenSource() + const listener = vi.fn() + + const disposable = source.token.onCancellationRequested(listener) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + + it("should stop listening after disposing", () => { + const source = new CancellationTokenSource() + const listener = vi.fn() + + const disposable = source.token.onCancellationRequested(listener) + disposable.dispose() + source.cancel() + + expect(listener).not.toHaveBeenCalled() + }) + + it("should call listener immediately if already cancelled", () => { + const source = new CancellationTokenSource() + source.cancel() + + const listener = vi.fn() + source.token.onCancellationRequested(listener) + + // Event was already fired, listener added after won't be called + // This matches VSCode behavior + expect(listener).not.toHaveBeenCalled() + }) + + it("should support multiple listeners", () => { + const source = new CancellationTokenSource() + const listener1 = vi.fn() + const listener2 = vi.fn() + + source.token.onCancellationRequested(listener1) + source.token.onCancellationRequested(listener2) + source.cancel() + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/CommandsAPI.test.ts b/packages/vscode-shim/src/__tests__/CommandsAPI.test.ts new file mode 100644 index 0000000000..251b9c9d29 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/CommandsAPI.test.ts @@ -0,0 +1,157 @@ +import { CommandsAPI } from "../api/CommandsAPI.js" + +describe("CommandsAPI", () => { + let commands: CommandsAPI + + beforeEach(() => { + commands = new CommandsAPI() + }) + + describe("registerCommand()", () => { + it("should register a command", () => { + const callback = vi.fn() + + commands.registerCommand("test.command", callback) + commands.executeCommand("test.command") + + expect(callback).toHaveBeenCalled() + }) + + it("should return a disposable", () => { + const callback = vi.fn() + + const disposable = commands.registerCommand("test.command", callback) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + + it("should unregister command on dispose", async () => { + const callback = vi.fn() + + const disposable = commands.registerCommand("test.command", callback) + disposable.dispose() + await commands.executeCommand("test.command") + + expect(callback).not.toHaveBeenCalled() + }) + + it("should allow registering multiple commands", () => { + const callback1 = vi.fn() + const callback2 = vi.fn() + + commands.registerCommand("test.command1", callback1) + commands.registerCommand("test.command2", callback2) + + commands.executeCommand("test.command1") + commands.executeCommand("test.command2") + + expect(callback1).toHaveBeenCalled() + expect(callback2).toHaveBeenCalled() + }) + }) + + describe("executeCommand()", () => { + it("should execute registered command", async () => { + const callback = vi.fn().mockReturnValue("result") + + commands.registerCommand("test.command", callback) + const result = await commands.executeCommand("test.command") + + expect(result).toBe("result") + }) + + it("should pass arguments to command handler", async () => { + const callback = vi.fn() + + commands.registerCommand("test.command", callback) + await commands.executeCommand("test.command", "arg1", "arg2", 123) + + expect(callback).toHaveBeenCalledWith("arg1", "arg2", 123) + }) + + it("should return promise for unknown command", () => { + const result = commands.executeCommand("unknown.command") + + expect(result).toBeInstanceOf(Promise) + }) + + it("should resolve to undefined for unknown command", async () => { + const result = await commands.executeCommand("unknown.command") + + expect(result).toBeUndefined() + }) + + it("should reject if handler throws", async () => { + commands.registerCommand("test.error", () => { + throw new Error("Test error") + }) + + await expect(commands.executeCommand("test.error")).rejects.toThrow("Test error") + }) + + it("should handle async command handlers", async () => { + commands.registerCommand("test.async", async () => { + return "async result" + }) + + const result = await commands.executeCommand("test.async") + + expect(result).toBe("async result") + }) + }) + + describe("built-in commands", () => { + it("should handle workbench.action.files.saveFiles", async () => { + const result = await commands.executeCommand("workbench.action.files.saveFiles") + + expect(result).toBeUndefined() + }) + + it("should handle workbench.action.closeWindow", async () => { + const result = await commands.executeCommand("workbench.action.closeWindow") + + expect(result).toBeUndefined() + }) + + it("should handle workbench.action.reloadWindow", async () => { + const result = await commands.executeCommand("workbench.action.reloadWindow") + + expect(result).toBeUndefined() + }) + }) + + describe("generic type support", () => { + it("should support typed return values", async () => { + commands.registerCommand("test.typed", () => 42) + + const result = await commands.executeCommand("test.typed") + + expect(result).toBe(42) + }) + + it("should support complex return types", async () => { + const expected = { name: "test", value: 123 } + commands.registerCommand("test.object", () => expected) + + const result = await commands.executeCommand<{ name: string; value: number }>("test.object") + + expect(result).toEqual(expected) + }) + }) + + describe("command overwriting", () => { + it("should allow registering same command multiple times", () => { + const callback1 = vi.fn().mockReturnValue(1) + const callback2 = vi.fn().mockReturnValue(2) + + commands.registerCommand("test.command", callback1) + commands.registerCommand("test.command", callback2) + + // Last registration wins + const result = commands.executeCommand("test.command") + + expect(result).resolves.toBe(2) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/EventEmitter.test.ts b/packages/vscode-shim/src/__tests__/EventEmitter.test.ts new file mode 100644 index 0000000000..5a5e4b976f --- /dev/null +++ b/packages/vscode-shim/src/__tests__/EventEmitter.test.ts @@ -0,0 +1,133 @@ +import { EventEmitter } from "../classes/EventEmitter.js" + +describe("EventEmitter", () => { + describe("event subscription", () => { + it("should subscribe and receive events", () => { + const emitter = new EventEmitter() + const listener = vi.fn() + + emitter.event(listener) + emitter.fire("test") + + expect(listener).toHaveBeenCalledWith("test") + expect(listener).toHaveBeenCalledTimes(1) + }) + + it("should support multiple listeners", () => { + const emitter = new EventEmitter() + const listener1 = vi.fn() + const listener2 = vi.fn() + + emitter.event(listener1) + emitter.event(listener2) + emitter.fire(42) + + expect(listener1).toHaveBeenCalledWith(42) + expect(listener2).toHaveBeenCalledWith(42) + }) + + it("should bind thisArgs when provided", () => { + const emitter = new EventEmitter() + const context = { name: "test", capturedThis: null as unknown } + + emitter.event(function (this: typeof context) { + this.capturedThis = this + }, context) + + emitter.fire("event") + expect(context.capturedThis).toBe(context) + }) + + it("should add disposable to array when provided", () => { + const emitter = new EventEmitter() + const disposables: { dispose: () => void }[] = [] + + emitter.event(() => {}, undefined, disposables) + + expect(disposables).toHaveLength(1) + expect(typeof disposables[0]?.dispose).toBe("function") + }) + }) + + describe("dispose subscription", () => { + it("should stop receiving events after dispose", () => { + const emitter = new EventEmitter() + const listener = vi.fn() + + const disposable = emitter.event(listener) + emitter.fire("before") + + disposable.dispose() + emitter.fire("after") + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith("before") + }) + }) + + describe("dispose emitter", () => { + it("should remove all listeners on dispose", () => { + const emitter = new EventEmitter() + const listener1 = vi.fn() + const listener2 = vi.fn() + + emitter.event(listener1) + emitter.event(listener2) + + emitter.dispose() + emitter.fire("test") + + expect(listener1).not.toHaveBeenCalled() + expect(listener2).not.toHaveBeenCalled() + }) + + it("should have zero listeners after dispose", () => { + const emitter = new EventEmitter() + emitter.event(() => {}) + emitter.event(() => {}) + + expect(emitter.listenerCount).toBe(2) + + emitter.dispose() + expect(emitter.listenerCount).toBe(0) + }) + }) + + describe("error handling", () => { + it("should not fail if a listener throws", () => { + const emitter = new EventEmitter() + const goodListener = vi.fn() + + emitter.event(() => { + throw new Error("Listener error") + }) + emitter.event(goodListener) + + // Should not throw + expect(() => emitter.fire("test")).not.toThrow() + + // Good listener should still be called + expect(goodListener).toHaveBeenCalledWith("test") + }) + }) + + describe("listenerCount", () => { + it("should track number of listeners", () => { + const emitter = new EventEmitter() + + expect(emitter.listenerCount).toBe(0) + + const d1 = emitter.event(() => {}) + expect(emitter.listenerCount).toBe(1) + + const d2 = emitter.event(() => {}) + expect(emitter.listenerCount).toBe(2) + + d1.dispose() + expect(emitter.listenerCount).toBe(1) + + d2.dispose() + expect(emitter.listenerCount).toBe(0) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/ExtensionContext.test.ts b/packages/vscode-shim/src/__tests__/ExtensionContext.test.ts new file mode 100644 index 0000000000..beb71d7deb --- /dev/null +++ b/packages/vscode-shim/src/__tests__/ExtensionContext.test.ts @@ -0,0 +1,343 @@ +import { ExtensionContextImpl } from "../context/ExtensionContext.js" +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +describe("ExtensionContextImpl", () => { + let tempDir: string + let extensionPath: string + let workspacePath: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "ext-context-test-")) + extensionPath = path.join(tempDir, "extension") + workspacePath = path.join(tempDir, "workspace") + fs.mkdirSync(extensionPath, { recursive: true }) + fs.mkdirSync(workspacePath, { recursive: true }) + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + describe("constructor", () => { + it("should create context with extension path", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + expect(context.extensionPath).toBe(extensionPath) + expect(context.extensionUri.fsPath).toBe(extensionPath) + }) + + it("should use default extension mode (Production)", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + expect(context.extensionMode).toBe(1) // Production + }) + + it("should allow custom extension mode", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + extensionMode: 2, // Development + }) + + expect(context.extensionMode).toBe(2) + }) + + it("should initialize empty subscriptions array", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + expect(context.subscriptions).toEqual([]) + }) + + it("should initialize environmentVariableCollection", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + expect(context.environmentVariableCollection).toEqual({}) + }) + }) + + describe("storage paths", () => { + it("should set up global storage path", () => { + const customStorageDir = path.join(tempDir, "custom-storage") + + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: customStorageDir, + }) + + expect(context.globalStoragePath).toContain("global-storage") + expect(context.globalStorageUri.fsPath).toBe(context.globalStoragePath) + }) + + it("should set up workspace storage path with hash", () => { + const customStorageDir = path.join(tempDir, "custom-storage") + + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: customStorageDir, + }) + + expect(context.storagePath).toContain("workspace-storage") + expect(context.storageUri?.fsPath).toBe(context.storagePath) + }) + + it("should set up log path", () => { + const customStorageDir = path.join(tempDir, "custom-storage") + + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: customStorageDir, + }) + + expect(context.logPath).toContain("logs") + expect(context.logUri.fsPath).toBe(context.logPath) + }) + + it("should create storage directories", () => { + const customStorageDir = path.join(tempDir, "custom-storage") + + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: customStorageDir, + }) + + expect(fs.existsSync(context.globalStoragePath)).toBe(true) + expect(fs.existsSync(context.storagePath!)).toBe(true) + expect(fs.existsSync(context.logPath)).toBe(true) + }) + + it("should generate different workspace hashes for different paths", () => { + const workspace1 = path.join(tempDir, "workspace1") + const workspace2 = path.join(tempDir, "workspace2") + fs.mkdirSync(workspace1, { recursive: true }) + fs.mkdirSync(workspace2, { recursive: true }) + + const context1 = new ExtensionContextImpl({ + extensionPath, + workspacePath: workspace1, + storageDir: path.join(tempDir, "storage1"), + }) + + const context2 = new ExtensionContextImpl({ + extensionPath, + workspacePath: workspace2, + storageDir: path.join(tempDir, "storage2"), + }) + + // The hashes should be different + const hash1 = path.basename(context1.storagePath!) + const hash2 = path.basename(context2.storagePath!) + expect(hash1).not.toBe(hash2) + }) + }) + + describe("workspaceState", () => { + it("should provide workspaceState memento", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + + expect(context.workspaceState).toBeDefined() + expect(typeof context.workspaceState.get).toBe("function") + expect(typeof context.workspaceState.update).toBe("function") + expect(typeof context.workspaceState.keys).toBe("function") + }) + + it("should persist workspace state", async () => { + const storageDir = path.join(tempDir, "storage") + + const context1 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + await context1.workspaceState.update("testKey", "testValue") + + // Create new context with same storage + const context2 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + expect(context2.workspaceState.get("testKey")).toBe("testValue") + }) + }) + + describe("globalState", () => { + it("should provide globalState memento", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + + expect(context.globalState).toBeDefined() + expect(typeof context.globalState.get).toBe("function") + expect(typeof context.globalState.update).toBe("function") + expect(typeof context.globalState.keys).toBe("function") + }) + + it("should have setKeysForSync method", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + + expect(typeof context.globalState.setKeysForSync).toBe("function") + // Should not throw + expect(() => context.globalState.setKeysForSync(["key1", "key2"])).not.toThrow() + }) + + it("should persist global state", async () => { + const storageDir = path.join(tempDir, "storage") + + const context1 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + await context1.globalState.update("globalKey", "globalValue") + + // Create new context with same storage + const context2 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + expect(context2.globalState.get("globalKey")).toBe("globalValue") + }) + }) + + describe("secrets", () => { + it("should provide secrets storage", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + + expect(context.secrets).toBeDefined() + expect(typeof context.secrets.get).toBe("function") + expect(typeof context.secrets.store).toBe("function") + expect(typeof context.secrets.delete).toBe("function") + }) + + it("should persist secrets", async () => { + const storageDir = path.join(tempDir, "storage") + + const context1 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + await context1.secrets.store("apiKey", "secret123") + + // Create new context with same storage + const context2 = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir, + }) + + const secret = await context2.secrets.get("apiKey") + expect(secret).toBe("secret123") + }) + }) + + describe("dispose()", () => { + it("should dispose all subscriptions", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + const disposable1 = { dispose: vi.fn() } + const disposable2 = { dispose: vi.fn() } + + context.subscriptions.push(disposable1) + context.subscriptions.push(disposable2) + + context.dispose() + + expect(disposable1.dispose).toHaveBeenCalledTimes(1) + expect(disposable2.dispose).toHaveBeenCalledTimes(1) + }) + + it("should clear subscriptions array after dispose", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + context.subscriptions.push({ dispose: () => {} }) + context.subscriptions.push({ dispose: () => {} }) + + context.dispose() + + expect(context.subscriptions).toEqual([]) + }) + + it("should handle disposal errors gracefully", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + // Add a disposable that throws + context.subscriptions.push({ + dispose: () => { + throw new Error("Disposal error") + }, + }) + + // Add a normal disposable + const normalDisposable = { dispose: vi.fn() } + context.subscriptions.push(normalDisposable) + + // Should not throw + expect(() => context.dispose()).not.toThrow() + + // Normal disposable should still be called + expect(normalDisposable.dispose).toHaveBeenCalled() + }) + }) + + describe("default storage directory", () => { + it("should use home directory based default when no storageDir provided", () => { + const context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + }) + + // Should contain .vscode-mock in the path + expect(context.globalStoragePath).toContain(".vscode-mock") + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/FileSystemAPI.test.ts b/packages/vscode-shim/src/__tests__/FileSystemAPI.test.ts new file mode 100644 index 0000000000..1b7e0e012c --- /dev/null +++ b/packages/vscode-shim/src/__tests__/FileSystemAPI.test.ts @@ -0,0 +1,129 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { FileSystemAPI } from "../api/FileSystemAPI.js" +import { Uri } from "../classes/Uri.js" + +describe("FileSystemAPI", () => { + let tempDir: string + let fsAPI: FileSystemAPI + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "fs-api-test-")) + fsAPI = new FileSystemAPI() + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + describe("stat()", () => { + it("should stat a file", async () => { + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "test content") + + const uri = Uri.file(filePath) + const stat = await fsAPI.stat(uri) + + expect(stat.type).toBe(1) // File + expect(stat.size).toBeGreaterThan(0) + expect(stat.mtime).toBeGreaterThan(0) + expect(stat.ctime).toBeGreaterThan(0) + }) + + it("should stat a directory", async () => { + const uri = Uri.file(tempDir) + const stat = await fsAPI.stat(uri) + + expect(stat.type).toBe(2) // Directory + }) + + it("should return default stat for non-existent file", async () => { + const uri = Uri.file(path.join(tempDir, "nonexistent.txt")) + const stat = await fsAPI.stat(uri) + + expect(stat.type).toBe(1) // File (default) + expect(stat.size).toBe(0) + }) + }) + + describe("readFile()", () => { + it("should read file content", async () => { + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "Hello, world!") + + const uri = Uri.file(filePath) + const content = await fsAPI.readFile(uri) + + expect(Buffer.from(content).toString()).toBe("Hello, world!") + }) + + it("should throw FileSystemError for non-existent file", async () => { + const uri = Uri.file(path.join(tempDir, "nonexistent.txt")) + + await expect(fsAPI.readFile(uri)).rejects.toThrow() + }) + }) + + describe("writeFile()", () => { + it("should write file content", async () => { + const filePath = path.join(tempDir, "output.txt") + const uri = Uri.file(filePath) + + await fsAPI.writeFile(uri, new TextEncoder().encode("Written content")) + + expect(fs.readFileSync(filePath, "utf-8")).toBe("Written content") + }) + + it("should create parent directories if they don't exist", async () => { + const filePath = path.join(tempDir, "subdir", "nested", "file.txt") + const uri = Uri.file(filePath) + + await fsAPI.writeFile(uri, new TextEncoder().encode("Nested content")) + + expect(fs.readFileSync(filePath, "utf-8")).toBe("Nested content") + }) + }) + + describe("delete()", () => { + it("should delete a file", async () => { + const filePath = path.join(tempDir, "to-delete.txt") + fs.writeFileSync(filePath, "delete me") + + const uri = Uri.file(filePath) + await fsAPI.delete(uri) + + expect(fs.existsSync(filePath)).toBe(false) + }) + + it("should throw error for non-existent file", async () => { + const uri = Uri.file(path.join(tempDir, "nonexistent.txt")) + + await expect(fsAPI.delete(uri)).rejects.toThrow() + }) + }) + + describe("createDirectory()", () => { + it("should create a directory", async () => { + const dirPath = path.join(tempDir, "new-dir") + const uri = Uri.file(dirPath) + + await fsAPI.createDirectory(uri) + + expect(fs.existsSync(dirPath)).toBe(true) + expect(fs.statSync(dirPath).isDirectory()).toBe(true) + }) + + it("should create nested directories", async () => { + const dirPath = path.join(tempDir, "a", "b", "c") + const uri = Uri.file(dirPath) + + await fsAPI.createDirectory(uri) + + expect(fs.existsSync(dirPath)).toBe(true) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/OutputChannel.test.ts b/packages/vscode-shim/src/__tests__/OutputChannel.test.ts new file mode 100644 index 0000000000..043e712d5b --- /dev/null +++ b/packages/vscode-shim/src/__tests__/OutputChannel.test.ts @@ -0,0 +1,117 @@ +import { OutputChannel } from "../classes/OutputChannel.js" +import { setLogger } from "../utils/logger.js" + +describe("OutputChannel", () => { + let mockLogger: { + debug: ReturnType + info: ReturnType + warn: ReturnType + error: ReturnType + } + + beforeEach(() => { + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + setLogger(mockLogger) + }) + + describe("constructor", () => { + it("should create an output channel with the given name", () => { + const channel = new OutputChannel("TestChannel") + + expect(channel.name).toBe("TestChannel") + }) + }) + + describe("name property", () => { + it("should return the channel name", () => { + const channel = new OutputChannel("MyChannel") + + expect(channel.name).toBe("MyChannel") + }) + }) + + describe("append()", () => { + it("should log the value with channel name prefix", () => { + const channel = new OutputChannel("TestChannel") + + channel.append("test message") + + expect(mockLogger.info).toHaveBeenCalledWith( + "[TestChannel] test message", + "VSCode.OutputChannel", + undefined, + ) + }) + + it("should handle empty strings", () => { + const channel = new OutputChannel("TestChannel") + + channel.append("") + + expect(mockLogger.info).toHaveBeenCalledWith("[TestChannel] ", "VSCode.OutputChannel", undefined) + }) + }) + + describe("appendLine()", () => { + it("should log the value with channel name prefix", () => { + const channel = new OutputChannel("TestChannel") + + channel.appendLine("line message") + + expect(mockLogger.info).toHaveBeenCalledWith( + "[TestChannel] line message", + "VSCode.OutputChannel", + undefined, + ) + }) + + it("should handle multi-line strings", () => { + const channel = new OutputChannel("TestChannel") + + channel.appendLine("line1\nline2") + + expect(mockLogger.info).toHaveBeenCalledWith( + "[TestChannel] line1\nline2", + "VSCode.OutputChannel", + undefined, + ) + }) + }) + + describe("clear()", () => { + it("should not throw when called", () => { + const channel = new OutputChannel("TestChannel") + + expect(() => channel.clear()).not.toThrow() + }) + }) + + describe("show()", () => { + it("should not throw when called without arguments", () => { + const channel = new OutputChannel("TestChannel") + + expect(() => channel.show()).not.toThrow() + }) + }) + + describe("hide()", () => { + it("should not throw when called", () => { + const channel = new OutputChannel("TestChannel") + + expect(() => channel.hide()).not.toThrow() + }) + }) + + describe("dispose()", () => { + it("should not throw when called", () => { + const channel = new OutputChannel("TestChannel") + + expect(() => channel.dispose()).not.toThrow() + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/Position.test.ts b/packages/vscode-shim/src/__tests__/Position.test.ts new file mode 100644 index 0000000000..4b417b4003 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/Position.test.ts @@ -0,0 +1,139 @@ +import { Position } from "../classes/Position.js" + +describe("Position", () => { + describe("constructor", () => { + it("should create a position with line and character", () => { + const pos = new Position(5, 10) + expect(pos.line).toBe(5) + expect(pos.character).toBe(10) + }) + + it("should reject negative line numbers", () => { + expect(() => new Position(-1, 0)).toThrow("Line number must be non-negative") + }) + + it("should reject negative character offsets", () => { + expect(() => new Position(0, -1)).toThrow("Character offset must be non-negative") + }) + }) + + describe("isEqual()", () => { + it("should return true for equal positions", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(5, 10) + expect(pos1.isEqual(pos2)).toBe(true) + }) + + it("should return false for different positions", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(5, 11) + expect(pos1.isEqual(pos2)).toBe(false) + }) + }) + + describe("isBefore()", () => { + it("should return true when line is before", () => { + const pos1 = new Position(3, 10) + const pos2 = new Position(5, 5) + expect(pos1.isBefore(pos2)).toBe(true) + }) + + it("should return true when same line but character before", () => { + const pos1 = new Position(5, 8) + const pos2 = new Position(5, 10) + expect(pos1.isBefore(pos2)).toBe(true) + }) + + it("should return false when equal", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(5, 10) + expect(pos1.isBefore(pos2)).toBe(false) + }) + + it("should return false when after", () => { + const pos1 = new Position(6, 0) + const pos2 = new Position(5, 10) + expect(pos1.isBefore(pos2)).toBe(false) + }) + }) + + describe("isAfter()", () => { + it("should return true when line is after", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(3, 10) + expect(pos1.isAfter(pos2)).toBe(true) + }) + + it("should return false when equal", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(5, 10) + expect(pos1.isAfter(pos2)).toBe(false) + }) + }) + + describe("compareTo()", () => { + it("should return -1 when before", () => { + const pos1 = new Position(3, 10) + const pos2 = new Position(5, 10) + expect(pos1.compareTo(pos2)).toBe(-1) + }) + + it("should return 0 when equal", () => { + const pos1 = new Position(5, 10) + const pos2 = new Position(5, 10) + expect(pos1.compareTo(pos2)).toBe(0) + }) + + it("should return 1 when after", () => { + const pos1 = new Position(7, 10) + const pos2 = new Position(5, 10) + expect(pos1.compareTo(pos2)).toBe(1) + }) + }) + + describe("translate()", () => { + it("should translate by delta values", () => { + const pos = new Position(5, 10) + const translated = pos.translate(2, 3) + expect(translated.line).toBe(7) + expect(translated.character).toBe(13) + }) + + it("should translate by change object", () => { + const pos = new Position(5, 10) + const translated = pos.translate({ lineDelta: 1, characterDelta: -2 }) + expect(translated.line).toBe(6) + expect(translated.character).toBe(8) + }) + + it("should handle omitted deltas as zero", () => { + const pos = new Position(5, 10) + const translated = pos.translate() + expect(translated.line).toBe(5) + expect(translated.character).toBe(10) + }) + }) + + describe("with()", () => { + it("should create new position with changed line", () => { + const pos = new Position(5, 10) + const modified = pos.with(8) + expect(modified.line).toBe(8) + expect(modified.character).toBe(10) + }) + + it("should create new position with change object", () => { + const pos = new Position(5, 10) + const modified = pos.with({ line: 8, character: 15 }) + expect(modified.line).toBe(8) + expect(modified.character).toBe(15) + }) + + it("should preserve unchanged properties", () => { + const pos = new Position(5, 10) + const modified = pos.with({ line: 8 }) + expect(modified.line).toBe(8) + expect(modified.character).toBe(10) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/Range.test.ts b/packages/vscode-shim/src/__tests__/Range.test.ts new file mode 100644 index 0000000000..5e85b02b83 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/Range.test.ts @@ -0,0 +1,153 @@ +import { Range } from "../classes/Range.js" +import { Position } from "../classes/Position.js" + +describe("Range", () => { + describe("constructor", () => { + it("should create range from Position objects", () => { + const start = new Position(0, 0) + const end = new Position(5, 10) + const range = new Range(start, end) + + expect(range.start.line).toBe(0) + expect(range.start.character).toBe(0) + expect(range.end.line).toBe(5) + expect(range.end.character).toBe(10) + }) + + it("should create range from numbers", () => { + const range = new Range(0, 0, 5, 10) + + expect(range.start.line).toBe(0) + expect(range.start.character).toBe(0) + expect(range.end.line).toBe(5) + expect(range.end.character).toBe(10) + }) + }) + + describe("isEmpty", () => { + it("should return true for empty range", () => { + const range = new Range(5, 10, 5, 10) + expect(range.isEmpty).toBe(true) + }) + + it("should return false for non-empty range", () => { + const range = new Range(5, 10, 5, 15) + expect(range.isEmpty).toBe(false) + }) + }) + + describe("isSingleLine", () => { + it("should return true for single line range", () => { + const range = new Range(5, 0, 5, 10) + expect(range.isSingleLine).toBe(true) + }) + + it("should return false for multi-line range", () => { + const range = new Range(5, 0, 6, 10) + expect(range.isSingleLine).toBe(false) + }) + }) + + describe("contains()", () => { + it("should return true when range contains position", () => { + const range = new Range(0, 0, 10, 10) + const pos = new Position(5, 5) + expect(range.contains(pos)).toBe(true) + }) + + it("should return false when position is outside range", () => { + const range = new Range(0, 0, 10, 10) + const pos = new Position(15, 5) + expect(range.contains(pos)).toBe(false) + }) + + it("should return true when range contains another range", () => { + const outer = new Range(0, 0, 10, 10) + const inner = new Range(2, 2, 8, 8) + expect(outer.contains(inner)).toBe(true) + }) + + it("should return false when range does not contain another range", () => { + const range1 = new Range(0, 0, 5, 10) + const range2 = new Range(6, 0, 10, 10) + expect(range1.contains(range2)).toBe(false) + }) + }) + + describe("isEqual()", () => { + it("should return true for equal ranges", () => { + const range1 = new Range(0, 0, 5, 10) + const range2 = new Range(0, 0, 5, 10) + expect(range1.isEqual(range2)).toBe(true) + }) + + it("should return false for different ranges", () => { + const range1 = new Range(0, 0, 5, 10) + const range2 = new Range(0, 0, 5, 11) + expect(range1.isEqual(range2)).toBe(false) + }) + }) + + describe("intersection()", () => { + it("should return intersection of overlapping ranges", () => { + const range1 = new Range(0, 0, 10, 10) + const range2 = new Range(5, 5, 15, 15) + const intersection = range1.intersection(range2) + + expect(intersection).toBeDefined() + expect(intersection!.start.line).toBe(5) + expect(intersection!.start.character).toBe(5) + expect(intersection!.end.line).toBe(10) + expect(intersection!.end.character).toBe(10) + }) + + it("should return undefined for non-overlapping ranges", () => { + const range1 = new Range(0, 0, 5, 10) + const range2 = new Range(10, 0, 15, 10) + const intersection = range1.intersection(range2) + + expect(intersection).toBeUndefined() + }) + }) + + describe("union()", () => { + it("should return union of two ranges", () => { + const range1 = new Range(0, 0, 5, 10) + const range2 = new Range(3, 5, 8, 15) + const union = range1.union(range2) + + expect(union.start.line).toBe(0) + expect(union.start.character).toBe(0) + expect(union.end.line).toBe(8) + expect(union.end.character).toBe(15) + }) + + it("should handle non-overlapping ranges", () => { + const range1 = new Range(0, 0, 2, 10) + const range2 = new Range(5, 0, 8, 10) + const union = range1.union(range2) + + expect(union.start.line).toBe(0) + expect(union.end.line).toBe(8) + }) + }) + + describe("with()", () => { + it("should create new range with modified start", () => { + const range = new Range(0, 0, 5, 10) + const modified = range.with(new Position(1, 0)) + + expect(modified.start.line).toBe(1) + expect(modified.end.line).toBe(5) + }) + + it("should create new range with change object", () => { + const range = new Range(0, 0, 5, 10) + const modified = range.with({ end: new Position(8, 15) }) + + expect(modified.start.line).toBe(0) + expect(modified.end.line).toBe(8) + expect(modified.end.character).toBe(15) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/Selection.test.ts b/packages/vscode-shim/src/__tests__/Selection.test.ts new file mode 100644 index 0000000000..208faf0df4 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/Selection.test.ts @@ -0,0 +1,123 @@ +import { Selection } from "../classes/Selection.js" +import { Position } from "../classes/Position.js" + +describe("Selection", () => { + describe("constructor with Position objects", () => { + it("should create selection from Position objects", () => { + const anchor = new Position(0, 0) + const active = new Position(5, 10) + const selection = new Selection(anchor, active) + + expect(selection.anchor.line).toBe(0) + expect(selection.anchor.character).toBe(0) + expect(selection.active.line).toBe(5) + expect(selection.active.character).toBe(10) + }) + + it("should set start and end correctly for non-reversed selection", () => { + const anchor = new Position(0, 0) + const active = new Position(5, 10) + const selection = new Selection(anchor, active) + + expect(selection.start.line).toBe(0) + expect(selection.start.character).toBe(0) + expect(selection.end.line).toBe(5) + expect(selection.end.character).toBe(10) + }) + + it("should set start and end correctly for reversed selection", () => { + const anchor = new Position(5, 10) + const active = new Position(0, 0) + const selection = new Selection(anchor, active) + + // Start/end are inherited from Range, which normalizes + expect(selection.anchor.line).toBe(5) + expect(selection.anchor.character).toBe(10) + expect(selection.active.line).toBe(0) + expect(selection.active.character).toBe(0) + }) + }) + + describe("constructor with line/character numbers", () => { + it("should create selection from line and character numbers", () => { + const selection = new Selection(0, 0, 5, 10) + + expect(selection.anchor.line).toBe(0) + expect(selection.anchor.character).toBe(0) + expect(selection.active.line).toBe(5) + expect(selection.active.character).toBe(10) + }) + + it("should handle reversed selection with numbers", () => { + const selection = new Selection(5, 10, 0, 0) + + expect(selection.anchor.line).toBe(5) + expect(selection.anchor.character).toBe(10) + expect(selection.active.line).toBe(0) + expect(selection.active.character).toBe(0) + }) + }) + + describe("isReversed", () => { + it("should return false when active is after anchor", () => { + const selection = new Selection(0, 0, 5, 10) + expect(selection.isReversed).toBe(false) + }) + + it("should return true when active is before anchor", () => { + const selection = new Selection(5, 10, 0, 0) + expect(selection.isReversed).toBe(true) + }) + + it("should return false when anchor equals active", () => { + const selection = new Selection(5, 10, 5, 10) + expect(selection.isReversed).toBe(false) + }) + + it("should return true when same line but active character is before anchor", () => { + const selection = new Selection(5, 10, 5, 5) + expect(selection.isReversed).toBe(true) + }) + + it("should return false when same line and active character is after anchor", () => { + const selection = new Selection(5, 5, 5, 10) + expect(selection.isReversed).toBe(false) + }) + }) + + describe("inherited Range properties", () => { + it("should have isEmpty property", () => { + const emptySelection = new Selection(5, 10, 5, 10) + expect(emptySelection.isEmpty).toBe(true) + + const nonEmptySelection = new Selection(0, 0, 5, 10) + expect(nonEmptySelection.isEmpty).toBe(false) + }) + + it("should have isSingleLine property", () => { + const singleLineSelection = new Selection(5, 0, 5, 10) + expect(singleLineSelection.isSingleLine).toBe(true) + + const multiLineSelection = new Selection(0, 0, 5, 10) + expect(multiLineSelection.isSingleLine).toBe(false) + }) + + it("should support contains method", () => { + const selection = new Selection(0, 0, 10, 10) + const pos = new Position(5, 5) + expect(selection.contains(pos)).toBe(true) + + const outsidePos = new Position(15, 5) + expect(selection.contains(outsidePos)).toBe(false) + }) + + it("should support isEqual method", () => { + const selection1 = new Selection(0, 0, 5, 10) + const selection2 = new Selection(0, 0, 5, 10) + const selection3 = new Selection(0, 0, 5, 11) + + expect(selection1.isEqual(selection2)).toBe(true) + expect(selection1.isEqual(selection3)).toBe(false) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/StatusBarItem.test.ts b/packages/vscode-shim/src/__tests__/StatusBarItem.test.ts new file mode 100644 index 0000000000..9610357b14 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/StatusBarItem.test.ts @@ -0,0 +1,214 @@ +import { StatusBarItem } from "../classes/StatusBarItem.js" +import { StatusBarAlignment } from "../types.js" + +describe("StatusBarItem", () => { + describe("constructor", () => { + it("should create with alignment", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.alignment).toBe(StatusBarAlignment.Left) + }) + + it("should create with alignment and priority", () => { + const item = new StatusBarItem(StatusBarAlignment.Right, 100) + + expect(item.alignment).toBe(StatusBarAlignment.Right) + expect(item.priority).toBe(100) + }) + + it("should have undefined priority when not provided", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.priority).toBeUndefined() + }) + }) + + describe("text property", () => { + it("should have empty text initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.text).toBe("") + }) + + it("should allow setting text", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.text = "Hello" + + expect(item.text).toBe("Hello") + }) + }) + + describe("tooltip property", () => { + it("should be undefined initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.tooltip).toBeUndefined() + }) + + it("should allow setting tooltip", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.tooltip = "My tooltip" + + expect(item.tooltip).toBe("My tooltip") + }) + + it("should allow setting to undefined", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + item.tooltip = "tooltip" + + item.tooltip = undefined + + expect(item.tooltip).toBeUndefined() + }) + }) + + describe("command property", () => { + it("should be undefined initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.command).toBeUndefined() + }) + + it("should allow setting command", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.command = "myExtension.doSomething" + + expect(item.command).toBe("myExtension.doSomething") + }) + }) + + describe("color property", () => { + it("should be undefined initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.color).toBeUndefined() + }) + + it("should allow setting color", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.color = "#ff0000" + + expect(item.color).toBe("#ff0000") + }) + }) + + describe("backgroundColor property", () => { + it("should be undefined initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.backgroundColor).toBeUndefined() + }) + + it("should allow setting backgroundColor", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.backgroundColor = "#00ff00" + + expect(item.backgroundColor).toBe("#00ff00") + }) + }) + + describe("isVisible property", () => { + it("should be false initially", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(item.isVisible).toBe(false) + }) + + it("should be true after show()", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.show() + + expect(item.isVisible).toBe(true) + }) + + it("should be false after hide()", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + item.show() + + item.hide() + + expect(item.isVisible).toBe(false) + }) + }) + + describe("show()", () => { + it("should make item visible", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.show() + + expect(item.isVisible).toBe(true) + }) + + it("should be idempotent", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + item.show() + item.show() + + expect(item.isVisible).toBe(true) + }) + }) + + describe("hide()", () => { + it("should make item invisible", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + item.show() + + item.hide() + + expect(item.isVisible).toBe(false) + }) + + it("should be safe to call when already hidden", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(() => item.hide()).not.toThrow() + expect(item.isVisible).toBe(false) + }) + }) + + describe("dispose()", () => { + it("should make item invisible", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + item.show() + + item.dispose() + + expect(item.isVisible).toBe(false) + }) + + it("should be safe to call multiple times", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + expect(() => { + item.dispose() + item.dispose() + }).not.toThrow() + }) + }) + + describe("alignment property", () => { + it("should be readonly", () => { + const item = new StatusBarItem(StatusBarAlignment.Left) + + // TypeScript prevents reassignment at compile time + // Just verify the value is what we expect + expect(item.alignment).toBe(StatusBarAlignment.Left) + }) + }) + + describe("priority property", () => { + it("should be readonly", () => { + const item = new StatusBarItem(StatusBarAlignment.Left, 50) + + expect(item.priority).toBe(50) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/TabGroupsAPI.test.ts b/packages/vscode-shim/src/__tests__/TabGroupsAPI.test.ts new file mode 100644 index 0000000000..6337a9a14d --- /dev/null +++ b/packages/vscode-shim/src/__tests__/TabGroupsAPI.test.ts @@ -0,0 +1,163 @@ +import { TabGroupsAPI, type Tab, type TabGroup } from "../api/TabGroupsAPI.js" +import { Uri } from "../classes/Uri.js" + +describe("TabGroupsAPI", () => { + let tabGroups: TabGroupsAPI + + beforeEach(() => { + tabGroups = new TabGroupsAPI() + }) + + describe("all property", () => { + it("should return empty array initially", () => { + expect(tabGroups.all).toEqual([]) + }) + + it("should return array of TabGroup", () => { + expect(Array.isArray(tabGroups.all)).toBe(true) + }) + }) + + describe("onDidChangeTabs()", () => { + it("should return a disposable", () => { + const disposable = tabGroups.onDidChangeTabs(() => {}) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + + it("should call listener when _simulateTabChange is called", () => { + const listener = vi.fn() + tabGroups.onDidChangeTabs(listener) + + tabGroups._simulateTabChange() + + expect(listener).toHaveBeenCalledTimes(1) + }) + + it("should not call listener after dispose", () => { + const listener = vi.fn() + const disposable = tabGroups.onDidChangeTabs(listener) + + disposable.dispose() + tabGroups._simulateTabChange() + + expect(listener).not.toHaveBeenCalled() + }) + + it("should support multiple listeners", () => { + const listener1 = vi.fn() + const listener2 = vi.fn() + + tabGroups.onDidChangeTabs(listener1) + tabGroups.onDidChangeTabs(listener2) + tabGroups._simulateTabChange() + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + }) + }) + + describe("close()", () => { + it("should return false when tab is not found", async () => { + const mockTab: Tab = { + input: { uri: Uri.file("/test/file.txt") }, + label: "file.txt", + isActive: true, + isDirty: false, + } + + const result = await tabGroups.close(mockTab) + + expect(result).toBe(false) + }) + + it("should return a promise", () => { + const mockTab: Tab = { + input: { uri: Uri.file("/test/file.txt") }, + label: "file.txt", + isActive: true, + isDirty: false, + } + + const result = tabGroups.close(mockTab) + + expect(result).toBeInstanceOf(Promise) + }) + }) + + describe("_simulateTabChange()", () => { + it("should fire the onDidChangeTabs event", () => { + const listener = vi.fn() + tabGroups.onDidChangeTabs(listener) + + tabGroups._simulateTabChange() + + expect(listener).toHaveBeenCalled() + }) + }) + + describe("dispose()", () => { + it("should not throw when called", () => { + expect(() => tabGroups.dispose()).not.toThrow() + }) + + it("should stop firing events after dispose", () => { + const listener = vi.fn() + tabGroups.onDidChangeTabs(listener) + + tabGroups.dispose() + // After dispose, internal emitter is disposed so new events shouldn't fire + // But existing listeners may still be registered + }) + + it("should be safe to call multiple times", () => { + expect(() => { + tabGroups.dispose() + tabGroups.dispose() + }).not.toThrow() + }) + }) +}) + +describe("Tab interface", () => { + it("should have required properties", () => { + const tab: Tab = { + input: { uri: Uri.file("/test/file.txt") }, + label: "file.txt", + isActive: true, + isDirty: false, + } + + expect(tab.input).toBeDefined() + expect(tab.label).toBe("file.txt") + expect(tab.isActive).toBe(true) + expect(tab.isDirty).toBe(false) + }) +}) + +describe("TabGroup interface", () => { + it("should have tabs array", () => { + const tabGroup: TabGroup = { + tabs: [], + } + + expect(Array.isArray(tabGroup.tabs)).toBe(true) + }) + + it("should contain Tab objects", () => { + const tab: Tab = { + input: { uri: Uri.file("/test/file.txt") }, + label: "file.txt", + isActive: true, + isDirty: false, + } + + const tabGroup: TabGroup = { + tabs: [tab], + } + + expect(tabGroup.tabs).toHaveLength(1) + expect(tabGroup.tabs[0]).toBe(tab) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/TextEdit.test.ts b/packages/vscode-shim/src/__tests__/TextEdit.test.ts new file mode 100644 index 0000000000..03ac93475b --- /dev/null +++ b/packages/vscode-shim/src/__tests__/TextEdit.test.ts @@ -0,0 +1,263 @@ +import { TextEdit, WorkspaceEdit } from "../classes/TextEdit.js" +import { Position } from "../classes/Position.js" +import { Range } from "../classes/Range.js" +import { Uri } from "../classes/Uri.js" + +describe("TextEdit", () => { + describe("constructor", () => { + it("should create a TextEdit with range and newText", () => { + const range = new Range(0, 0, 0, 5) + const edit = new TextEdit(range, "hello") + + expect(edit.range.start.line).toBe(0) + expect(edit.range.start.character).toBe(0) + expect(edit.range.end.line).toBe(0) + expect(edit.range.end.character).toBe(5) + expect(edit.newText).toBe("hello") + }) + }) + + describe("replace()", () => { + it("should create a replace edit", () => { + const range = new Range(1, 0, 1, 10) + const edit = TextEdit.replace(range, "replacement") + + expect(edit.range.isEqual(range)).toBe(true) + expect(edit.newText).toBe("replacement") + }) + + it("should handle multi-line ranges", () => { + const range = new Range(0, 0, 5, 10) + const edit = TextEdit.replace(range, "new content") + + expect(edit.range.start.line).toBe(0) + expect(edit.range.end.line).toBe(5) + expect(edit.newText).toBe("new content") + }) + }) + + describe("insert()", () => { + it("should create an insert edit at position", () => { + const position = new Position(5, 10) + const edit = TextEdit.insert(position, "inserted text") + + expect(edit.range.start.line).toBe(5) + expect(edit.range.start.character).toBe(10) + expect(edit.range.end.line).toBe(5) + expect(edit.range.end.character).toBe(10) + expect(edit.range.isEmpty).toBe(true) + expect(edit.newText).toBe("inserted text") + }) + + it("should handle insert at beginning of file", () => { + const position = new Position(0, 0) + const edit = TextEdit.insert(position, "prefix") + + expect(edit.range.start.isEqual(position)).toBe(true) + expect(edit.newText).toBe("prefix") + }) + }) + + describe("delete()", () => { + it("should create a delete edit", () => { + const range = new Range(0, 5, 0, 10) + const edit = TextEdit.delete(range) + + expect(edit.range.isEqual(range)).toBe(true) + expect(edit.newText).toBe("") + }) + + it("should handle multi-line deletion", () => { + const range = new Range(0, 0, 5, 0) + const edit = TextEdit.delete(range) + + expect(edit.range.start.line).toBe(0) + expect(edit.range.end.line).toBe(5) + expect(edit.newText).toBe("") + }) + }) + + describe("setEndOfLine()", () => { + it("should create a setEndOfLine edit", () => { + const edit = TextEdit.setEndOfLine() + + expect(edit.range.start.line).toBe(0) + expect(edit.range.start.character).toBe(0) + expect(edit.newText).toBe("") + }) + }) +}) + +describe("WorkspaceEdit", () => { + describe("set() and get()", () => { + it("should set and get edits for a URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + const edits = [ + TextEdit.replace(new Range(0, 0, 0, 5), "hello"), + TextEdit.insert(new Position(1, 0), "world"), + ] + + workspaceEdit.set(uri, edits) + const retrieved = workspaceEdit.get(uri) + + expect(retrieved).toHaveLength(2) + expect(retrieved[0]?.newText).toBe("hello") + expect(retrieved[1]?.newText).toBe("world") + }) + + it("should return empty array for unknown URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/nonexistent.txt") + + expect(workspaceEdit.get(uri)).toEqual([]) + }) + + it("should overwrite edits when setting same URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + + workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "first")]) + workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "second")]) + + const edits = workspaceEdit.get(uri) + expect(edits).toHaveLength(1) + expect(edits[0]?.newText).toBe("second") + }) + }) + + describe("has()", () => { + it("should return true when URI has edits", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + + workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "text")]) + + expect(workspaceEdit.has(uri)).toBe(true) + }) + + it("should return false when URI has no edits", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + + expect(workspaceEdit.has(uri)).toBe(false) + }) + }) + + describe("delete()", () => { + it("should add a delete edit for URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + const range = new Range(0, 5, 0, 10) + + workspaceEdit.delete(uri, range) + + const edits = workspaceEdit.get(uri) + expect(edits).toHaveLength(1) + expect(edits[0]?.newText).toBe("") + expect(edits[0]?.range.start.character).toBe(5) + expect(edits[0]?.range.end.character).toBe(10) + }) + + it("should append to existing edits", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + + workspaceEdit.insert(uri, new Position(0, 0), "text") + workspaceEdit.delete(uri, new Range(1, 0, 1, 5)) + + const edits = workspaceEdit.get(uri) + expect(edits).toHaveLength(2) + }) + }) + + describe("insert()", () => { + it("should add an insert edit for URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + const position = new Position(5, 10) + + workspaceEdit.insert(uri, position, "inserted") + + const edits = workspaceEdit.get(uri) + expect(edits).toHaveLength(1) + expect(edits[0]?.newText).toBe("inserted") + expect(edits[0]?.range.start.line).toBe(5) + expect(edits[0]?.range.start.character).toBe(10) + }) + }) + + describe("replace()", () => { + it("should add a replace edit for URI", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + const range = new Range(0, 0, 0, 10) + + workspaceEdit.replace(uri, range, "replacement") + + const edits = workspaceEdit.get(uri) + expect(edits).toHaveLength(1) + expect(edits[0]?.newText).toBe("replacement") + expect(edits[0]?.range.start.line).toBe(0) + expect(edits[0]?.range.end.character).toBe(10) + }) + }) + + describe("size", () => { + it("should return 0 for empty WorkspaceEdit", () => { + const workspaceEdit = new WorkspaceEdit() + expect(workspaceEdit.size).toBe(0) + }) + + it("should return number of documents with edits", () => { + const workspaceEdit = new WorkspaceEdit() + const uri1 = Uri.file("/path/to/file1.txt") + const uri2 = Uri.file("/path/to/file2.txt") + const uri3 = Uri.file("/path/to/file3.txt") + + workspaceEdit.insert(uri1, new Position(0, 0), "text1") + workspaceEdit.insert(uri2, new Position(0, 0), "text2") + workspaceEdit.insert(uri3, new Position(0, 0), "text3") + + expect(workspaceEdit.size).toBe(3) + }) + + it("should count same URI only once", () => { + const workspaceEdit = new WorkspaceEdit() + const uri = Uri.file("/path/to/file.txt") + + workspaceEdit.insert(uri, new Position(0, 0), "text1") + workspaceEdit.insert(uri, new Position(1, 0), "text2") + workspaceEdit.insert(uri, new Position(2, 0), "text3") + + expect(workspaceEdit.size).toBe(1) + }) + }) + + describe("entries()", () => { + it("should return empty array for empty WorkspaceEdit", () => { + const workspaceEdit = new WorkspaceEdit() + expect(workspaceEdit.entries()).toEqual([]) + }) + + it("should return all URI/edits pairs", () => { + const workspaceEdit = new WorkspaceEdit() + const uri1 = Uri.file("/path/to/file1.txt") + const uri2 = Uri.file("/path/to/file2.txt") + + workspaceEdit.insert(uri1, new Position(0, 0), "text1") + workspaceEdit.replace(uri2, new Range(0, 0, 0, 5), "text2") + + const entries = workspaceEdit.entries() + expect(entries).toHaveLength(2) + + // Entries should have URI-like objects with toString and fsPath + expect(typeof entries[0]?.[0]?.toString).toBe("function") + expect(typeof entries[0]?.[0]?.fsPath).toBe("string") + + // Should contain the edits + expect(entries.some((e) => e[1][0]?.newText === "text1")).toBe(true) + expect(entries.some((e) => e[1][0]?.newText === "text2")).toBe(true) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/TextEditorDecorationType.test.ts b/packages/vscode-shim/src/__tests__/TextEditorDecorationType.test.ts new file mode 100644 index 0000000000..f1ff27ad41 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/TextEditorDecorationType.test.ts @@ -0,0 +1,59 @@ +import { TextEditorDecorationType } from "../classes/TextEditorDecorationType.js" + +describe("TextEditorDecorationType", () => { + describe("constructor", () => { + it("should create with a key", () => { + const decoration = new TextEditorDecorationType("my-decoration") + + expect(decoration.key).toBe("my-decoration") + }) + + it("should allow any string key", () => { + const decoration = new TextEditorDecorationType("decoration-12345") + + expect(decoration.key).toBe("decoration-12345") + }) + }) + + describe("key property", () => { + it("should be accessible", () => { + const decoration = new TextEditorDecorationType("test-key") + + expect(decoration.key).toBe("test-key") + }) + + it("should be mutable", () => { + const decoration = new TextEditorDecorationType("original") + + decoration.key = "modified" + + expect(decoration.key).toBe("modified") + }) + }) + + describe("dispose()", () => { + it("should not throw when called", () => { + const decoration = new TextEditorDecorationType("test") + + expect(() => decoration.dispose()).not.toThrow() + }) + + it("should be safe to call multiple times", () => { + const decoration = new TextEditorDecorationType("test") + + expect(() => { + decoration.dispose() + decoration.dispose() + decoration.dispose() + }).not.toThrow() + }) + }) + + describe("Disposable interface", () => { + it("should implement Disposable interface", () => { + const decoration = new TextEditorDecorationType("test") + + expect(typeof decoration.dispose).toBe("function") + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/Uri.test.ts b/packages/vscode-shim/src/__tests__/Uri.test.ts new file mode 100644 index 0000000000..6988ccb219 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/Uri.test.ts @@ -0,0 +1,102 @@ +import { Uri } from "../classes/Uri.js" + +describe("Uri", () => { + describe("file()", () => { + it("should create a file URI", () => { + const uri = Uri.file("/path/to/file.txt") + expect(uri.scheme).toBe("file") + expect(uri.path).toBe("/path/to/file.txt") + expect(uri.fsPath).toBe("/path/to/file.txt") + }) + + it("should handle Windows paths", () => { + const uri = Uri.file("C:\\Users\\test\\file.txt") + expect(uri.scheme).toBe("file") + expect(uri.fsPath).toBe("C:\\Users\\test\\file.txt") + }) + }) + + describe("parse()", () => { + it("should parse HTTP URLs", () => { + const uri = Uri.parse("https://example.com/path?query=1#fragment") + expect(uri.scheme).toBe("https") + expect(uri.authority).toBe("example.com") + expect(uri.path).toBe("/path") + expect(uri.query).toBe("query=1") + expect(uri.fragment).toBe("fragment") + }) + + it("should parse file URLs", () => { + const uri = Uri.parse("file:///path/to/file.txt") + expect(uri.scheme).toBe("file") + expect(uri.path).toBe("/path/to/file.txt") + }) + + it("should handle invalid URLs by treating as file paths", () => { + const uri = Uri.parse("/just/a/path") + expect(uri.scheme).toBe("file") + expect(uri.fsPath).toBe("/just/a/path") + }) + }) + + describe("joinPath()", () => { + it("should join path segments", () => { + const base = Uri.file("/base/path") + const joined = Uri.joinPath(base, "sub", "file.txt") + expect(joined.fsPath).toContain("sub") + expect(joined.fsPath).toContain("file.txt") + }) + }) + + describe("with()", () => { + it("should create new URI with modified scheme", () => { + const uri = Uri.file("/path/to/file.txt") + const modified = uri.with({ scheme: "vscode" }) + expect(modified.scheme).toBe("vscode") + expect(modified.path).toBe("/path/to/file.txt") + }) + + it("should create new URI with modified path", () => { + const uri = Uri.parse("https://example.com/old/path") + const modified = uri.with({ path: "/new/path" }) + expect(modified.path).toBe("/new/path") + expect(modified.scheme).toBe("https") + }) + + it("should preserve unchanged properties", () => { + const uri = Uri.parse("https://example.com/path?query=1#fragment") + const modified = uri.with({ path: "/newpath" }) + expect(modified.scheme).toBe("https") + expect(modified.query).toBe("query=1") + expect(modified.fragment).toBe("fragment") + }) + }) + + describe("toString()", () => { + it("should convert to URI string", () => { + const uri = Uri.parse("https://example.com/path?query=1#fragment") + const str = uri.toString() + expect(str).toBe("https://example.com/path?query=1#fragment") + }) + + it("should handle file URIs", () => { + const uri = Uri.file("/path/to/file.txt") + const str = uri.toString() + expect(str).toBe("file:///path/to/file.txt") + }) + }) + + describe("toJSON()", () => { + it("should convert to JSON object", () => { + const uri = Uri.parse("https://example.com/path?query=1#fragment") + const json = uri.toJSON() + expect(json).toEqual({ + scheme: "https", + authority: "example.com", + path: "/path", + query: "query=1", + fragment: "fragment", + }) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/WindowAPI.test.ts b/packages/vscode-shim/src/__tests__/WindowAPI.test.ts new file mode 100644 index 0000000000..5af6355b55 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/WindowAPI.test.ts @@ -0,0 +1,305 @@ +import { WindowAPI } from "../api/WindowAPI.js" +import { Uri } from "../classes/Uri.js" +import { StatusBarAlignment } from "../types.js" + +describe("WindowAPI", () => { + let windowAPI: WindowAPI + + beforeEach(() => { + windowAPI = new WindowAPI() + }) + + describe("tabGroups property", () => { + it("should have tabGroups", () => { + expect(windowAPI.tabGroups).toBeDefined() + }) + + it("should return TabGroupsAPI instance", () => { + expect(typeof windowAPI.tabGroups.onDidChangeTabs).toBe("function") + expect(Array.isArray(windowAPI.tabGroups.all)).toBe(true) + }) + }) + + describe("visibleTextEditors property", () => { + it("should be an empty array initially", () => { + expect(windowAPI.visibleTextEditors).toEqual([]) + }) + }) + + describe("createOutputChannel()", () => { + it("should create an output channel with the given name", () => { + const channel = windowAPI.createOutputChannel("TestChannel") + + expect(channel.name).toBe("TestChannel") + }) + + it("should return an OutputChannel instance", () => { + const channel = windowAPI.createOutputChannel("Test") + + expect(typeof channel.append).toBe("function") + expect(typeof channel.appendLine).toBe("function") + expect(typeof channel.dispose).toBe("function") + }) + }) + + describe("createStatusBarItem()", () => { + it("should create with default alignment", () => { + const item = windowAPI.createStatusBarItem() + + expect(item.alignment).toBe(StatusBarAlignment.Left) + }) + + it("should create with specified alignment", () => { + const item = windowAPI.createStatusBarItem(StatusBarAlignment.Right) + + expect(item.alignment).toBe(StatusBarAlignment.Right) + }) + + it("should create with alignment and priority", () => { + const item = windowAPI.createStatusBarItem(StatusBarAlignment.Left, 100) + + expect(item.alignment).toBe(StatusBarAlignment.Left) + expect(item.priority).toBe(100) + }) + + it("should handle overloaded signature with id", () => { + const item = windowAPI.createStatusBarItem("myId", StatusBarAlignment.Right, 50) + + expect(item.alignment).toBe(StatusBarAlignment.Right) + expect(item.priority).toBe(50) + }) + }) + + describe("createTextEditorDecorationType()", () => { + it("should create a decoration type", () => { + const decoration = windowAPI.createTextEditorDecorationType({}) + + expect(decoration).toBeDefined() + expect(decoration.key).toContain("decoration-") + }) + + it("should return unique keys", () => { + const decoration1 = windowAPI.createTextEditorDecorationType({}) + const decoration2 = windowAPI.createTextEditorDecorationType({}) + + expect(decoration1.key).not.toBe(decoration2.key) + }) + }) + + describe("createTerminal()", () => { + it("should create a terminal with default name", () => { + const terminal = windowAPI.createTerminal() + + expect(terminal.name).toBe("Terminal") + }) + + it("should create a terminal with specified name", () => { + const terminal = windowAPI.createTerminal({ name: "MyTerminal" }) + + expect(terminal.name).toBe("MyTerminal") + }) + + it("should return terminal with expected methods", () => { + const terminal = windowAPI.createTerminal() + + expect(typeof terminal.sendText).toBe("function") + expect(typeof terminal.show).toBe("function") + expect(typeof terminal.hide).toBe("function") + expect(typeof terminal.dispose).toBe("function") + }) + + it("should have processId promise", async () => { + const terminal = windowAPI.createTerminal() + + const processId = await terminal.processId + + expect(processId).toBeUndefined() + }) + }) + + describe("showInformationMessage()", () => { + it("should return a promise", () => { + const result = windowAPI.showInformationMessage("Test message") + + expect(result).toBeInstanceOf(Promise) + }) + + it("should resolve to undefined", async () => { + const result = await windowAPI.showInformationMessage("Test message") + + expect(result).toBeUndefined() + }) + }) + + describe("showWarningMessage()", () => { + it("should return a promise", () => { + const result = windowAPI.showWarningMessage("Warning message") + + expect(result).toBeInstanceOf(Promise) + }) + + it("should resolve to undefined", async () => { + const result = await windowAPI.showWarningMessage("Warning message") + + expect(result).toBeUndefined() + }) + }) + + describe("showErrorMessage()", () => { + it("should return a promise", () => { + const result = windowAPI.showErrorMessage("Error message") + + expect(result).toBeInstanceOf(Promise) + }) + + it("should resolve to undefined", async () => { + const result = await windowAPI.showErrorMessage("Error message") + + expect(result).toBeUndefined() + }) + }) + + describe("showQuickPick()", () => { + it("should return first item", async () => { + const result = await windowAPI.showQuickPick(["item1", "item2", "item3"]) + + expect(result).toBe("item1") + }) + + it("should return undefined for empty array", async () => { + const result = await windowAPI.showQuickPick([]) + + expect(result).toBeUndefined() + }) + }) + + describe("showInputBox()", () => { + it("should return empty string", async () => { + const result = await windowAPI.showInputBox() + + expect(result).toBe("") + }) + }) + + describe("showOpenDialog()", () => { + it("should return empty array", async () => { + const result = await windowAPI.showOpenDialog() + + expect(result).toEqual([]) + }) + }) + + describe("showTextDocument()", () => { + it("should return an editor", async () => { + const uri = Uri.file("/test/file.txt") + const editor = await windowAPI.showTextDocument(uri) + + expect(editor).toBeDefined() + expect(editor.document).toBeDefined() + }) + + it("should add editor to visibleTextEditors", async () => { + const uri = Uri.file("/test/file.txt") + await windowAPI.showTextDocument(uri) + + expect(windowAPI.visibleTextEditors.length).toBeGreaterThan(0) + }) + }) + + describe("registerWebviewViewProvider()", () => { + it("should return a disposable", () => { + const mockProvider = { + resolveWebviewView: vi.fn(), + } + + const disposable = windowAPI.registerWebviewViewProvider("myView", mockProvider) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("registerUriHandler()", () => { + it("should return a disposable", () => { + const mockHandler = { + handleUri: vi.fn(), + } + + const disposable = windowAPI.registerUriHandler(mockHandler) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("onDidChangeTextEditorSelection()", () => { + it("should return a disposable", () => { + const disposable = windowAPI.onDidChangeTextEditorSelection(() => {}) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("onDidChangeActiveTextEditor()", () => { + it("should return a disposable", () => { + const disposable = windowAPI.onDidChangeActiveTextEditor(() => {}) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("onDidChangeVisibleTextEditors()", () => { + it("should return a disposable", () => { + const disposable = windowAPI.onDidChangeVisibleTextEditors(() => {}) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("terminal events", () => { + it("onDidCloseTerminal should return disposable", () => { + const disposable = windowAPI.onDidCloseTerminal(() => {}) + + expect(typeof disposable.dispose).toBe("function") + }) + + it("onDidOpenTerminal should return disposable", () => { + const disposable = windowAPI.onDidOpenTerminal(() => {}) + + expect(typeof disposable.dispose).toBe("function") + }) + + it("onDidChangeActiveTerminal should return disposable", () => { + const disposable = windowAPI.onDidChangeActiveTerminal(() => {}) + + expect(typeof disposable.dispose).toBe("function") + }) + + it("onDidChangeTerminalDimensions should return disposable", () => { + const disposable = windowAPI.onDidChangeTerminalDimensions(() => {}) + + expect(typeof disposable.dispose).toBe("function") + }) + + it("onDidWriteTerminalData should return disposable", () => { + const disposable = windowAPI.onDidWriteTerminalData(() => {}) + + expect(typeof disposable.dispose).toBe("function") + }) + }) + + describe("activeTerminal property", () => { + it("should return undefined", () => { + expect(windowAPI.activeTerminal).toBeUndefined() + }) + }) + + describe("terminals property", () => { + it("should return empty array", () => { + expect(windowAPI.terminals).toEqual([]) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/WorkspaceAPI.test.ts b/packages/vscode-shim/src/__tests__/WorkspaceAPI.test.ts new file mode 100644 index 0000000000..449195d825 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/WorkspaceAPI.test.ts @@ -0,0 +1,290 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { WorkspaceAPI } from "../api/WorkspaceAPI.js" +import { Uri } from "../classes/Uri.js" +import { Range } from "../classes/Range.js" +import { Position } from "../classes/Position.js" +import { WorkspaceEdit } from "../classes/TextEdit.js" +import { ExtensionContextImpl } from "../context/ExtensionContext.js" + +describe("WorkspaceAPI", () => { + let tempDir: string + let extensionPath: string + let workspacePath: string + let context: ExtensionContextImpl + let workspaceAPI: WorkspaceAPI + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "workspace-api-test-")) + extensionPath = path.join(tempDir, "extension") + workspacePath = path.join(tempDir, "workspace") + fs.mkdirSync(extensionPath, { recursive: true }) + fs.mkdirSync(workspacePath, { recursive: true }) + + context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + + workspaceAPI = new WorkspaceAPI(workspacePath, context) + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + describe("workspaceFolders", () => { + it("should have workspace folder set", () => { + expect(workspaceAPI.workspaceFolders).toHaveLength(1) + expect(workspaceAPI.workspaceFolders?.[0]?.uri.fsPath).toBe(workspacePath) + expect(workspaceAPI.workspaceFolders?.[0]?.index).toBe(0) + }) + + it("should have workspace name set", () => { + expect(workspaceAPI.name).toBe(path.basename(workspacePath)) + }) + }) + + describe("asRelativePath()", () => { + it("should convert absolute path to relative", () => { + const absolutePath = path.join(workspacePath, "subdir", "file.txt") + const relativePath = workspaceAPI.asRelativePath(absolutePath) + + expect(relativePath).toBe(path.join("subdir", "file.txt")) + }) + + it("should handle URI input", () => { + const uri = Uri.file(path.join(workspacePath, "file.txt")) + const relativePath = workspaceAPI.asRelativePath(uri) + + expect(relativePath).toBe("file.txt") + }) + + it("should return original path if outside workspace", () => { + const outsidePath = "/outside/workspace/file.txt" + const result = workspaceAPI.asRelativePath(outsidePath) + + expect(result).toBe(outsidePath) + }) + + it("should handle empty workspace folders", () => { + workspaceAPI.workspaceFolders = undefined + const absolutePath = "/some/path/file.txt" + const result = workspaceAPI.asRelativePath(absolutePath) + + expect(result).toBe(absolutePath) + }) + }) + + describe("getConfiguration()", () => { + it("should return configuration object", () => { + const config = workspaceAPI.getConfiguration("myExtension") + + expect(config).toBeDefined() + expect(typeof config.get).toBe("function") + expect(typeof config.has).toBe("function") + expect(typeof config.update).toBe("function") + }) + }) + + describe("findFiles()", () => { + it("should return empty array (minimal implementation)", async () => { + const result = await workspaceAPI.findFiles("**/*.txt") + + expect(result).toEqual([]) + }) + }) + + describe("openTextDocument()", () => { + it("should open and return a text document", async () => { + const filePath = path.join(workspacePath, "test.txt") + fs.writeFileSync(filePath, "Line 1\nLine 2\nLine 3") + + const uri = Uri.file(filePath) + const document = await workspaceAPI.openTextDocument(uri) + + expect(document.uri.fsPath).toBe(filePath) + expect(document.fileName).toBe(filePath) + expect(document.lineCount).toBe(3) + expect(document.getText()).toBe("Line 1\nLine 2\nLine 3") + }) + + it("should handle getText with range", async () => { + const filePath = path.join(workspacePath, "test.txt") + fs.writeFileSync(filePath, "Line 1\nLine 2\nLine 3") + + const uri = Uri.file(filePath) + const document = await workspaceAPI.openTextDocument(uri) + + const range = new Range(0, 0, 1, 6) + const text = document.getText(range) + + expect(text).toContain("Line 1") + expect(text).toContain("Line 2") + }) + + it("should provide lineAt method", async () => { + const filePath = path.join(workspacePath, "test.txt") + fs.writeFileSync(filePath, "Hello\nWorld") + + const uri = Uri.file(filePath) + const document = await workspaceAPI.openTextDocument(uri) + + const line = document.lineAt(0) + + expect(line.text).toBe("Hello") + expect(line.isEmptyOrWhitespace).toBe(false) + }) + + it("should add document to textDocuments", async () => { + const filePath = path.join(workspacePath, "test.txt") + fs.writeFileSync(filePath, "content") + + const uri = Uri.file(filePath) + await workspaceAPI.openTextDocument(uri) + + expect(workspaceAPI.textDocuments).toHaveLength(1) + }) + + it("should handle non-existent file gracefully", async () => { + const uri = Uri.file(path.join(workspacePath, "nonexistent.txt")) + const document = await workspaceAPI.openTextDocument(uri) + + expect(document.getText()).toBe("") + expect(document.lineCount).toBe(1) + }) + }) + + describe("applyEdit()", () => { + it("should apply single edit to file", async () => { + const filePath = path.join(workspacePath, "edit-test.txt") + fs.writeFileSync(filePath, "Hello World") + + const edit = new WorkspaceEdit() + const uri = Uri.file(filePath) + edit.replace(uri, new Range(0, 0, 0, 5), "Hi") + + const result = await workspaceAPI.applyEdit(edit) + + expect(result).toBe(true) + expect(fs.readFileSync(filePath, "utf-8")).toBe("Hi World") + }) + + it("should apply insert edit", async () => { + const filePath = path.join(workspacePath, "insert-test.txt") + fs.writeFileSync(filePath, "World") + + const edit = new WorkspaceEdit() + const uri = Uri.file(filePath) + edit.insert(uri, new Position(0, 0), "Hello ") + + const result = await workspaceAPI.applyEdit(edit) + + expect(result).toBe(true) + expect(fs.readFileSync(filePath, "utf-8")).toBe("Hello World") + }) + + it("should apply delete edit", async () => { + const filePath = path.join(workspacePath, "delete-test.txt") + fs.writeFileSync(filePath, "Hello World") + + const edit = new WorkspaceEdit() + const uri = Uri.file(filePath) + edit.delete(uri, new Range(0, 5, 0, 11)) + + const result = await workspaceAPI.applyEdit(edit) + + expect(result).toBe(true) + expect(fs.readFileSync(filePath, "utf-8")).toBe("Hello") + }) + + it("should create file if it doesn't exist", async () => { + const filePath = path.join(workspacePath, "new-file.txt") + + const edit = new WorkspaceEdit() + const uri = Uri.file(filePath) + edit.insert(uri, new Position(0, 0), "New content") + + const result = await workspaceAPI.applyEdit(edit) + + expect(result).toBe(true) + expect(fs.readFileSync(filePath, "utf-8")).toBe("New content") + }) + + it("should update in-memory document", async () => { + const filePath = path.join(workspacePath, "memory-test.txt") + fs.writeFileSync(filePath, "Original") + + // First open the document + const uri = Uri.file(filePath) + const document = await workspaceAPI.openTextDocument(uri) + expect(document.getText()).toBe("Original") + + // Apply edit + const edit = new WorkspaceEdit() + edit.replace(uri, new Range(0, 0, 0, 8), "Modified") + await workspaceAPI.applyEdit(edit) + + // Check in-memory document is updated + expect(document.getText()).toBe("Modified") + }) + }) + + describe("createFileSystemWatcher()", () => { + it("should return a file system watcher object", () => { + const watcher = workspaceAPI.createFileSystemWatcher() + + expect(typeof watcher.onDidChange).toBe("function") + expect(typeof watcher.onDidCreate).toBe("function") + expect(typeof watcher.onDidDelete).toBe("function") + expect(typeof watcher.dispose).toBe("function") + }) + }) + + describe("events", () => { + it("should have onDidChangeWorkspaceFolders event", () => { + expect(typeof workspaceAPI.onDidChangeWorkspaceFolders).toBe("function") + }) + + it("should have onDidOpenTextDocument event", () => { + expect(typeof workspaceAPI.onDidOpenTextDocument).toBe("function") + }) + + it("should have onDidChangeTextDocument event", () => { + expect(typeof workspaceAPI.onDidChangeTextDocument).toBe("function") + }) + + it("should have onDidCloseTextDocument event", () => { + expect(typeof workspaceAPI.onDidCloseTextDocument).toBe("function") + }) + + it("should have onDidChangeConfiguration event", () => { + expect(typeof workspaceAPI.onDidChangeConfiguration).toBe("function") + }) + }) + + describe("fs property", () => { + it("should have FileSystemAPI instance", () => { + expect(workspaceAPI.fs).toBeDefined() + expect(typeof workspaceAPI.fs.stat).toBe("function") + expect(typeof workspaceAPI.fs.readFile).toBe("function") + expect(typeof workspaceAPI.fs.writeFile).toBe("function") + }) + }) + + describe("registerTextDocumentContentProvider()", () => { + it("should return a disposable", () => { + const disposable = workspaceAPI.registerTextDocumentContentProvider("test", { + provideTextDocumentContent: () => Promise.resolve("content"), + }) + + expect(disposable).toBeDefined() + expect(typeof disposable.dispose).toBe("function") + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/WorkspaceConfiguration.test.ts b/packages/vscode-shim/src/__tests__/WorkspaceConfiguration.test.ts new file mode 100644 index 0000000000..e99c91b4c4 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/WorkspaceConfiguration.test.ts @@ -0,0 +1,272 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { + MockWorkspaceConfiguration, + setRuntimeConfig, + setRuntimeConfigValues, + getRuntimeConfig, + clearRuntimeConfig, +} from "../api/WorkspaceConfiguration.js" +import { ExtensionContextImpl } from "../context/ExtensionContext.js" + +describe("MockWorkspaceConfiguration", () => { + let tempDir: string + let extensionPath: string + let workspacePath: string + let context: ExtensionContextImpl + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "config-test-")) + extensionPath = path.join(tempDir, "extension") + workspacePath = path.join(tempDir, "workspace") + fs.mkdirSync(extensionPath, { recursive: true }) + fs.mkdirSync(workspacePath, { recursive: true }) + + context = new ExtensionContextImpl({ + extensionPath, + workspacePath, + storageDir: path.join(tempDir, "storage"), + }) + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + describe("get()", () => { + it("should return default value when key doesn't exist", () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + expect(config.get("nonexistent", "default")).toBe("default") + }) + + it("should return undefined when key doesn't exist and no default provided", () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + expect(config.get("nonexistent")).toBeUndefined() + }) + + it("should return stored value", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("setting", "value") + + expect(config.get("setting")).toBe("value") + }) + + it("should use section prefix", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("nested.setting", "nested value") + + expect(config.get("nested.setting")).toBe("nested value") + }) + + it("should handle complex values", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + const complexValue = { nested: { array: [1, 2, 3] } } + + await config.update("complex", complexValue) + + expect(config.get("complex")).toEqual(complexValue) + }) + }) + + describe("has()", () => { + it("should return false for non-existent key", () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + expect(config.has("nonexistent")).toBe(false) + }) + + it("should return true for existing key", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("exists", "value") + + expect(config.has("exists")).toBe(true) + }) + }) + + describe("inspect()", () => { + it("should return undefined for non-existent key", () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + expect(config.inspect("nonexistent")).toBeUndefined() + }) + + it("should return inspection result for existing key", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("setting", "global value", 1) // Global + + const inspection = config.inspect("setting") + + expect(inspection).toBeDefined() + expect(inspection?.key).toBe("myExtension.setting") + expect(inspection?.globalValue).toBe("global value") + }) + + it("should return workspace value when set", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("workspaceSetting", "workspace value", 2) // Workspace + + const inspection = config.inspect("workspaceSetting") + + expect(inspection).toBeDefined() + expect(inspection?.workspaceValue).toBe("workspace value") + }) + }) + + describe("update()", () => { + it("should update global configuration", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("globalSetting", "global value", 1) // Global + + expect(config.get("globalSetting")).toBe("global value") + }) + + it("should update workspace configuration", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + await config.update("workspaceSetting", "workspace value", 2) // Workspace + + expect(config.get("workspaceSetting")).toBe("workspace value") + }) + + it("should persist configuration across instances", async () => { + const config1 = new MockWorkspaceConfiguration("myExtension", context) + await config1.update("persistent", "value") + + // Create new config instance + const config2 = new MockWorkspaceConfiguration("myExtension", context) + + expect(config2.get("persistent")).toBe("value") + }) + + it("should allow updating with null/undefined to clear value", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + await config.update("toDelete", "value") + + expect(config.get("toDelete")).toBe("value") + + await config.update("toDelete", undefined) + + expect(config.get("toDelete")).toBeUndefined() + }) + }) + + describe("reload()", () => { + it("should not throw when called", () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + + expect(() => config.reload()).not.toThrow() + }) + }) + + describe("getAllConfig()", () => { + it("should return all configuration values", async () => { + const config = new MockWorkspaceConfiguration("myExtension", context) + await config.update("key1", "value1") + await config.update("key2", "value2") + + const allConfig = config.getAllConfig() + + expect(allConfig["myExtension.key1"]).toBe("value1") + expect(allConfig["myExtension.key2"]).toBe("value2") + }) + }) + + describe("Runtime Configuration", () => { + beforeEach(() => { + // Clear runtime config before each test + clearRuntimeConfig() + }) + + afterEach(() => { + // Clean up after each test + clearRuntimeConfig() + }) + + it("should return runtime config value over disk-based values", async () => { + const config = new MockWorkspaceConfiguration("roo-cline", context) + + // Set a value in disk-based storage + await config.update("commandExecutionTimeout", 10) + + // Verify disk value is returned + expect(config.get("commandExecutionTimeout")).toBe(10) + + // Set runtime config (should take precedence) + setRuntimeConfig("roo-cline", "commandExecutionTimeout", 20) + + // Now runtime value should be returned + expect(config.get("commandExecutionTimeout")).toBe(20) + }) + + it("should set and get runtime config values", () => { + setRuntimeConfig("roo-cline", "testSetting", "testValue") + + expect(getRuntimeConfig("roo-cline.testSetting")).toBe("testValue") + }) + + it("should set multiple runtime config values at once", () => { + setRuntimeConfigValues("roo-cline", { + setting1: "value1", + setting2: 42, + setting3: true, + }) + + expect(getRuntimeConfig("roo-cline.setting1")).toBe("value1") + expect(getRuntimeConfig("roo-cline.setting2")).toBe(42) + expect(getRuntimeConfig("roo-cline.setting3")).toBe(true) + }) + + it("should ignore undefined values in setRuntimeConfigValues", () => { + setRuntimeConfigValues("roo-cline", { + defined: "value", + notDefined: undefined, + }) + + expect(getRuntimeConfig("roo-cline.defined")).toBe("value") + expect(getRuntimeConfig("roo-cline.notDefined")).toBeUndefined() + }) + + it("should clear all runtime config values", () => { + setRuntimeConfig("roo-cline", "setting1", "value1") + setRuntimeConfig("roo-cline", "setting2", "value2") + + clearRuntimeConfig() + + expect(getRuntimeConfig("roo-cline.setting1")).toBeUndefined() + expect(getRuntimeConfig("roo-cline.setting2")).toBeUndefined() + }) + + it("should return default value when no runtime config is set", () => { + const config = new MockWorkspaceConfiguration("roo-cline", context) + + expect(config.get("nonexistent", 0)).toBe(0) + expect(config.get("nonexistent", "default")).toBe("default") + }) + + it("should work with MockWorkspaceConfiguration.get() for CLI settings", () => { + // Simulate CLI setting commandExecutionTimeout + setRuntimeConfigValues("roo-cline", { + commandExecutionTimeout: 20, + commandTimeoutAllowlist: ["npm", "yarn"], + }) + + const config = new MockWorkspaceConfiguration("roo-cline", context) + + // These should return the runtime config values + expect(config.get("commandExecutionTimeout", 0)).toBe(20) + expect(config.get("commandTimeoutAllowlist", [])).toEqual(["npm", "yarn"]) + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/logger.test.ts b/packages/vscode-shim/src/__tests__/logger.test.ts new file mode 100644 index 0000000000..56c0622480 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/logger.test.ts @@ -0,0 +1,198 @@ +import { logs, setLogger, type Logger } from "../utils/logger.js" + +describe("Logger", () => { + let originalEnv: string | undefined + let consoleSpy: { + log: ReturnType + warn: ReturnType + error: ReturnType + debug: ReturnType + } + + beforeEach(() => { + originalEnv = process.env.DEBUG + consoleSpy = { + log: vi.spyOn(console, "log").mockImplementation(() => {}), + warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + error: vi.spyOn(console, "error").mockImplementation(() => {}), + debug: vi.spyOn(console, "debug").mockImplementation(() => {}), + } + }) + + afterEach(() => { + process.env.DEBUG = originalEnv + vi.restoreAllMocks() + }) + + describe("logs object (default ConsoleLogger)", () => { + describe("info()", () => { + it("should log info message", () => { + logs.info("Info message") + + expect(consoleSpy.log).toHaveBeenCalled() + expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("Info message") + }) + + it("should include context in log", () => { + logs.info("Info message", "MyContext") + + expect(consoleSpy.log).toHaveBeenCalled() + expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("MyContext") + }) + + it("should use INFO as default context", () => { + logs.info("Info message") + + expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("INFO") + }) + }) + + describe("warn()", () => { + it("should log warning message", () => { + logs.warn("Warning message") + + expect(consoleSpy.warn).toHaveBeenCalled() + expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("Warning message") + }) + + it("should include context in warning", () => { + logs.warn("Warning message", "MyContext") + + expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("MyContext") + }) + + it("should use WARN as default context", () => { + logs.warn("Warning message") + + expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("WARN") + }) + }) + + describe("error()", () => { + it("should log error message", () => { + logs.error("Error message") + + expect(consoleSpy.error).toHaveBeenCalled() + expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("Error message") + }) + + it("should include context in error", () => { + logs.error("Error message", "MyContext") + + expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("MyContext") + }) + + it("should use ERROR as default context", () => { + logs.error("Error message") + + expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("ERROR") + }) + }) + + describe("debug()", () => { + it("should not log debug message when DEBUG env is not set", () => { + delete process.env.DEBUG + + logs.debug("Debug message") + + expect(consoleSpy.debug).not.toHaveBeenCalled() + }) + + it("should log debug message when DEBUG env is set", () => { + process.env.DEBUG = "true" + + logs.debug("Debug message") + + expect(consoleSpy.debug).toHaveBeenCalled() + expect(consoleSpy.debug.mock.calls[0]?.[0]).toContain("Debug message") + }) + + it("should include context in debug when DEBUG is set", () => { + process.env.DEBUG = "true" + + logs.debug("Debug message", "MyContext") + + expect(consoleSpy.debug.mock.calls[0]?.[0]).toContain("MyContext") + }) + }) + }) + + describe("setLogger()", () => { + it("should replace default logger with custom logger", () => { + const customLogger: Logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } + + setLogger(customLogger) + + logs.info("Test message", "TestContext") + + expect(customLogger.info).toHaveBeenCalledWith("Test message", "TestContext", undefined) + }) + + it("should use custom logger for all log levels", () => { + const customLogger: Logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } + + setLogger(customLogger) + + logs.info("Info") + logs.warn("Warn") + logs.error("Error") + logs.debug("Debug") + + expect(customLogger.info).toHaveBeenCalledTimes(1) + expect(customLogger.warn).toHaveBeenCalledTimes(1) + expect(customLogger.error).toHaveBeenCalledTimes(1) + expect(customLogger.debug).toHaveBeenCalledTimes(1) + }) + + it("should pass meta parameter to custom logger", () => { + const customLogger: Logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } + + setLogger(customLogger) + + const meta = { requestId: "123", userId: "456" } + logs.info("Info with meta", "Context", meta) + + expect(customLogger.info).toHaveBeenCalledWith("Info with meta", "Context", meta) + }) + }) + + describe("Logger interface", () => { + it("should accept custom logger implementing Logger interface", () => { + // Create a custom logger that collects messages + const messages: string[] = [] + const customLogger: Logger = { + info: (message) => messages.push(`INFO: ${message}`), + warn: (message) => messages.push(`WARN: ${message}`), + error: (message) => messages.push(`ERROR: ${message}`), + debug: (message) => messages.push(`DEBUG: ${message}`), + } + + setLogger(customLogger) + + logs.info("Test info") + logs.warn("Test warn") + logs.error("Test error") + logs.debug("Test debug") + + expect(messages).toContain("INFO: Test info") + expect(messages).toContain("WARN: Test warn") + expect(messages).toContain("ERROR: Test error") + expect(messages).toContain("DEBUG: Test debug") + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/machine-id.test.ts b/packages/vscode-shim/src/__tests__/machine-id.test.ts new file mode 100644 index 0000000000..45e91add05 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/machine-id.test.ts @@ -0,0 +1,143 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { machineIdSync } from "../utils/machine-id.js" + +describe("machineIdSync", () => { + let originalHome: string | undefined + let tempDir: string + + beforeEach(() => { + originalHome = process.env.HOME + tempDir = fs.mkdtempSync(path.join(tmpdir(), "machine-id-test-")) + process.env.HOME = tempDir + }) + + afterEach(() => { + process.env.HOME = originalHome + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + it("should generate a machine ID", () => { + const machineId = machineIdSync() + + expect(machineId).toBeDefined() + expect(typeof machineId).toBe("string") + expect(machineId.length).toBeGreaterThan(0) + }) + + it("should return a hexadecimal string", () => { + const machineId = machineIdSync() + + // SHA256 hash produces 64 hex characters + expect(machineId).toMatch(/^[a-f0-9]+$/) + expect(machineId.length).toBe(64) + }) + + it("should persist machine ID to file", () => { + const machineId = machineIdSync() + + const idPath = path.join(tempDir, ".vscode-mock", ".machine-id") + expect(fs.existsSync(idPath)).toBe(true) + + const storedId = fs.readFileSync(idPath, "utf-8").trim() + expect(storedId).toBe(machineId) + }) + + it("should return same ID on subsequent calls", () => { + const machineId1 = machineIdSync() + const machineId2 = machineIdSync() + + expect(machineId1).toBe(machineId2) + }) + + it("should read existing ID from file", () => { + // Create the directory and file first + const idDir = path.join(tempDir, ".vscode-mock") + const idPath = path.join(idDir, ".machine-id") + fs.mkdirSync(idDir, { recursive: true }) + fs.writeFileSync(idPath, "existing-machine-id-12345") + + const machineId = machineIdSync() + + expect(machineId).toBe("existing-machine-id-12345") + }) + + it("should create directory if it doesn't exist", () => { + const idDir = path.join(tempDir, ".vscode-mock") + + expect(fs.existsSync(idDir)).toBe(false) + + machineIdSync() + + expect(fs.existsSync(idDir)).toBe(true) + }) + + it("should handle missing HOME environment variable", () => { + // Use USERPROFILE instead (Windows fallback) + delete process.env.HOME + process.env.USERPROFILE = tempDir + + const machineId = machineIdSync() + + expect(machineId).toBeDefined() + expect(machineId.length).toBeGreaterThan(0) + + // Restore + process.env.HOME = tempDir + }) + + it("should generate unique IDs for different hosts", () => { + // This test verifies that the ID generation includes random data + // Since we can't easily change the hostname, we verify multiple generations + // in fresh environments produce unique results (due to random component) + + // First call generates and saves + const machineId1 = machineIdSync() + + // Delete the saved file to force regeneration + const idPath = path.join(tempDir, ".vscode-mock", ".machine-id") + fs.unlinkSync(idPath) + + // Second call should generate a new ID (random component) + const machineId2 = machineIdSync() + + // The IDs should be different due to the random component + expect(machineId1).not.toBe(machineId2) + }) + + it("should handle read errors gracefully", () => { + // Create an unreadable file (directory instead of file) + const idDir = path.join(tempDir, ".vscode-mock") + const idPath = path.join(idDir, ".machine-id") + fs.mkdirSync(idPath, { recursive: true }) // Create directory instead of file + + // Should not throw, should generate new ID + expect(() => machineIdSync()).not.toThrow() + + const machineId = machineIdSync() + expect(machineId).toBeDefined() + expect(machineId.length).toBeGreaterThan(0) + }) + + it("should handle write errors gracefully", () => { + // Make the directory read-only (Unix only) + if (process.platform !== "win32") { + const idDir = path.join(tempDir, ".vscode-mock") + fs.mkdirSync(idDir, { recursive: true }) + fs.chmodSync(idDir, 0o444) // Read-only + + // Should not throw, should still generate ID + expect(() => machineIdSync()).not.toThrow() + + const machineId = machineIdSync() + expect(machineId).toBeDefined() + + // Restore permissions for cleanup + fs.chmodSync(idDir, 0o755) + } + }) +}) diff --git a/packages/vscode-shim/src/__tests__/paths.test.ts b/packages/vscode-shim/src/__tests__/paths.test.ts new file mode 100644 index 0000000000..404d33bc9b --- /dev/null +++ b/packages/vscode-shim/src/__tests__/paths.test.ts @@ -0,0 +1,208 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { VSCodeMockPaths } from "../utils/paths.js" + +describe("VSCodeMockPaths", () => { + let originalHome: string | undefined + let tempDir: string + + beforeEach(() => { + originalHome = process.env.HOME + tempDir = fs.mkdtempSync(path.join(tmpdir(), "paths-test-")) + process.env.HOME = tempDir + }) + + afterEach(() => { + process.env.HOME = originalHome + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + describe("getGlobalStorageDir()", () => { + it("should return path containing .vscode-mock", () => { + const globalDir = VSCodeMockPaths.getGlobalStorageDir() + + expect(globalDir).toContain(".vscode-mock") + }) + + it("should return path containing global-storage", () => { + const globalDir = VSCodeMockPaths.getGlobalStorageDir() + + expect(globalDir).toContain("global-storage") + }) + + it("should use HOME environment variable", () => { + const globalDir = VSCodeMockPaths.getGlobalStorageDir() + + expect(globalDir).toContain(tempDir) + }) + + it("should return consistent path on multiple calls", () => { + const dir1 = VSCodeMockPaths.getGlobalStorageDir() + const dir2 = VSCodeMockPaths.getGlobalStorageDir() + + expect(dir1).toBe(dir2) + }) + }) + + describe("getWorkspaceStorageDir()", () => { + it("should return path containing .vscode-mock", () => { + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace") + + expect(workspaceDir).toContain(".vscode-mock") + }) + + it("should return path containing workspace-storage", () => { + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace") + + expect(workspaceDir).toContain("workspace-storage") + }) + + it("should include hashed workspace path", () => { + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace") + + // Should end with a hash (hex string) + const hash = path.basename(workspaceDir) + expect(hash).toMatch(/^[a-f0-9]+$/) + }) + + it("should return different paths for different workspaces", () => { + const dir1 = VSCodeMockPaths.getWorkspaceStorageDir("/workspace/one") + const dir2 = VSCodeMockPaths.getWorkspaceStorageDir("/workspace/two") + + expect(dir1).not.toBe(dir2) + }) + + it("should return same path for same workspace", () => { + const dir1 = VSCodeMockPaths.getWorkspaceStorageDir("/same/workspace") + const dir2 = VSCodeMockPaths.getWorkspaceStorageDir("/same/workspace") + + expect(dir1).toBe(dir2) + }) + + it("should handle Windows-style paths", () => { + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("C:\\Users\\test\\workspace") + + expect(workspaceDir).toContain("workspace-storage") + // Should still produce a valid hash + const hash = path.basename(workspaceDir) + expect(hash).toMatch(/^[a-f0-9]+$/) + }) + + it("should handle empty workspace path", () => { + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("") + + expect(workspaceDir).toContain("workspace-storage") + }) + }) + + describe("getLogsDir()", () => { + it("should return path containing .vscode-mock", () => { + const logsDir = VSCodeMockPaths.getLogsDir() + + expect(logsDir).toContain(".vscode-mock") + }) + + it("should return path containing logs", () => { + const logsDir = VSCodeMockPaths.getLogsDir() + + expect(logsDir).toContain("logs") + }) + + it("should return consistent path on multiple calls", () => { + const dir1 = VSCodeMockPaths.getLogsDir() + const dir2 = VSCodeMockPaths.getLogsDir() + + expect(dir1).toBe(dir2) + }) + }) + + describe("initializeWorkspace()", () => { + it("should create global storage directory", () => { + VSCodeMockPaths.initializeWorkspace("/test/workspace") + + const globalDir = VSCodeMockPaths.getGlobalStorageDir() + expect(fs.existsSync(globalDir)).toBe(true) + }) + + it("should create workspace storage directory", () => { + const workspacePath = "/test/workspace" + VSCodeMockPaths.initializeWorkspace(workspacePath) + + const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir(workspacePath) + expect(fs.existsSync(workspaceDir)).toBe(true) + }) + + it("should create logs directory", () => { + VSCodeMockPaths.initializeWorkspace("/test/workspace") + + const logsDir = VSCodeMockPaths.getLogsDir() + expect(fs.existsSync(logsDir)).toBe(true) + }) + + it("should not fail if directories already exist", () => { + // Initialize twice + VSCodeMockPaths.initializeWorkspace("/test/workspace") + + expect(() => { + VSCodeMockPaths.initializeWorkspace("/test/workspace") + }).not.toThrow() + }) + + it("should create directories with correct structure", () => { + VSCodeMockPaths.initializeWorkspace("/test/workspace") + + const baseDir = path.join(tempDir, ".vscode-mock") + expect(fs.existsSync(baseDir)).toBe(true) + expect(fs.existsSync(path.join(baseDir, "global-storage"))).toBe(true) + expect(fs.existsSync(path.join(baseDir, "workspace-storage"))).toBe(true) + expect(fs.existsSync(path.join(baseDir, "logs"))).toBe(true) + }) + }) + + describe("hash consistency", () => { + it("should produce deterministic hashes", () => { + // The same workspace path should always produce the same hash + const workspace = "/project/my-project" + + const hash1 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace)) + const hash2 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace)) + const hash3 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace)) + + expect(hash1).toBe(hash2) + expect(hash2).toBe(hash3) + }) + + it("should handle special characters in workspace path", () => { + const workspaces = [ + "/path/with spaces/project", + "/path/with-dashes/project", + "/path/with_underscores/project", + "/path/with.dots/project", + ] + + for (const workspace of workspaces) { + const dir = VSCodeMockPaths.getWorkspaceStorageDir(workspace) + // Should produce valid directory name + expect(path.basename(dir)).toMatch(/^[a-f0-9]+$/) + } + }) + }) + + describe("USERPROFILE fallback (Windows)", () => { + it("should use USERPROFILE when HOME is not set", () => { + delete process.env.HOME + process.env.USERPROFILE = tempDir + + const globalDir = VSCodeMockPaths.getGlobalStorageDir() + + expect(globalDir).toContain(tempDir) + + // Restore for cleanup + process.env.HOME = tempDir + }) + }) +}) diff --git a/packages/vscode-shim/src/__tests__/storage.test.ts b/packages/vscode-shim/src/__tests__/storage.test.ts new file mode 100644 index 0000000000..644911ffe1 --- /dev/null +++ b/packages/vscode-shim/src/__tests__/storage.test.ts @@ -0,0 +1,178 @@ +import * as fs from "fs" +import * as path from "path" +import { tmpdir } from "os" + +import { FileMemento } from "../storage/Memento.js" +import { FileSecretStorage } from "../storage/SecretStorage.js" + +describe("FileMemento", () => { + let tempDir: string + let mementoPath: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "memento-test-")) + mementoPath = path.join(tempDir, "state.json") + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + it("should store and retrieve values", async () => { + const memento = new FileMemento(mementoPath) + + await memento.update("key1", "value1") + await memento.update("key2", 42) + + expect(memento.get("key1")).toBe("value1") + expect(memento.get("key2")).toBe(42) + }) + + it("should return default value when key doesn't exist", () => { + const memento = new FileMemento(mementoPath) + + expect(memento.get("nonexistent", "default")).toBe("default") + expect(memento.get("missing", 0)).toBe(0) + }) + + it("should persist data to file", async () => { + const memento1 = new FileMemento(mementoPath) + await memento1.update("persisted", "value") + + // Create new instance to verify persistence + const memento2 = new FileMemento(mementoPath) + expect(memento2.get("persisted")).toBe("value") + }) + + it("should delete values when updated with undefined", async () => { + const memento = new FileMemento(mementoPath) + + await memento.update("key", "value") + expect(memento.get("key")).toBe("value") + + await memento.update("key", undefined) + expect(memento.get("key")).toBeUndefined() + }) + + it("should return all keys", async () => { + const memento = new FileMemento(mementoPath) + + await memento.update("key1", "value1") + await memento.update("key2", "value2") + await memento.update("key3", "value3") + + const keys = memento.keys() + expect(keys).toHaveLength(3) + expect(keys).toContain("key1") + expect(keys).toContain("key2") + expect(keys).toContain("key3") + }) + + it("should clear all data", async () => { + const memento = new FileMemento(mementoPath) + + await memento.update("key1", "value1") + await memento.update("key2", "value2") + + memento.clear() + + expect(memento.keys()).toHaveLength(0) + expect(memento.get("key1")).toBeUndefined() + }) +}) + +describe("FileSecretStorage", () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "secrets-test-")) + }) + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }) + } + }) + + it("should store and retrieve secrets", async () => { + const storage = new FileSecretStorage(tempDir) + + await storage.store("apiKey", "sk-test-123") + const retrieved = await storage.get("apiKey") + + expect(retrieved).toBe("sk-test-123") + }) + + it("should return undefined for non-existent secrets", async () => { + const storage = new FileSecretStorage(tempDir) + const result = await storage.get("nonexistent") + + expect(result).toBeUndefined() + }) + + it("should delete secrets", async () => { + const storage = new FileSecretStorage(tempDir) + + await storage.store("apiKey", "sk-test-123") + expect(await storage.get("apiKey")).toBe("sk-test-123") + + await storage.delete("apiKey") + expect(await storage.get("apiKey")).toBeUndefined() + }) + + it("should persist secrets across instances", async () => { + const storage1 = new FileSecretStorage(tempDir) + await storage1.store("token", "persistent-value") + + const storage2 = new FileSecretStorage(tempDir) + const value = await storage2.get("token") + + expect(value).toBe("persistent-value") + }) + + it("should fire onDidChange event when secret changes", async () => { + const storage = new FileSecretStorage(tempDir) + const events: string[] = [] + + storage.onDidChange((e) => { + events.push(e.key) + }) + + await storage.store("key1", "value1") + await storage.store("key2", "value2") + await storage.delete("key1") + + expect(events).toEqual(["key1", "key2", "key1"]) + }) + + it("should clear all secrets", async () => { + const storage = new FileSecretStorage(tempDir) + + await storage.store("key1", "value1") + await storage.store("key2", "value2") + + storage.clearAll() + + expect(await storage.get("key1")).toBeUndefined() + expect(await storage.get("key2")).toBeUndefined() + }) + + it("should create secrets.json file with restrictive permissions on Unix", async () => { + if (process.platform === "win32") { + // Skip on Windows + return + } + + const storage = new FileSecretStorage(tempDir) + await storage.store("key", "value") + + const secretsPath = path.join(tempDir, "secrets.json") + const stats = fs.statSync(secretsPath) + const mode = stats.mode & 0o777 + + // Should be 0600 (owner read/write only) + expect(mode).toBe(0o600) + }) +}) diff --git a/packages/vscode-shim/src/api/CommandsAPI.ts b/packages/vscode-shim/src/api/CommandsAPI.ts new file mode 100644 index 0000000000..0cd826f8d0 --- /dev/null +++ b/packages/vscode-shim/src/api/CommandsAPI.ts @@ -0,0 +1,181 @@ +/** + * CommandsAPI class for VSCode API + */ + +import { logs } from "../utils/logger.js" +import { Uri } from "../classes/Uri.js" +import { Position } from "../classes/Position.js" +import { Range } from "../classes/Range.js" +import { Selection } from "../classes/Selection.js" +import { ViewColumn, EndOfLine } from "../types.js" +import type { Thenable } from "../types.js" +import type { TextEditor, TextEditorEdit } from "../interfaces/editor.js" +import type { TextDocument } from "../interfaces/document.js" +import type { Disposable } from "../interfaces/workspace.js" +import type { WorkspaceAPI } from "./WorkspaceAPI.js" +import type { WindowAPI } from "./WindowAPI.js" + +/** + * Commands API mock for CLI mode + */ +export class CommandsAPI { + private commands: Map unknown> = new Map() + + registerCommand(command: string, callback: (...args: unknown[]) => unknown): Disposable { + this.commands.set(command, callback) + return { + dispose: () => { + this.commands.delete(command) + }, + } + } + + executeCommand(command: string, ...rest: unknown[]): Thenable { + const handler = this.commands.get(command) + if (handler) { + try { + const result = handler(...rest) + return Promise.resolve(result as T) + } catch (error) { + return Promise.reject(error) + } + } + + // Handle built-in commands + switch (command) { + case "workbench.action.files.saveFiles": + case "workbench.action.closeWindow": + case "workbench.action.reloadWindow": + return Promise.resolve(undefined as T) + case "vscode.diff": + // Simulate opening a diff view for the CLI + // The extension's DiffViewProvider expects this to create a diff editor + return this.handleDiffCommand( + rest[0] as Uri, + rest[1] as Uri, + rest[2] as string | undefined, + rest[3], + ) as Thenable + default: + logs.warn(`Unknown command: ${command}`, "VSCode.Commands") + return Promise.resolve(undefined as T) + } + } + + private async handleDiffCommand( + originalUri: Uri, + modifiedUri: Uri, + title?: string, + _options?: unknown, + ): Promise { + // The DiffViewProvider is waiting for the modified document to appear in visibleTextEditors + // We need to simulate this by opening the document and adding it to visible editors + + logs.info(`[DIFF] Handling vscode.diff command`, "VSCode.Commands", { + originalUri: originalUri?.toString(), + modifiedUri: modifiedUri?.toString(), + title, + }) + + if (!modifiedUri) { + logs.warn("[DIFF] vscode.diff called without modified URI", "VSCode.Commands") + return + } + + // Get the workspace API to open the document + const workspace = (global as unknown as { vscode?: { workspace?: WorkspaceAPI } }).vscode?.workspace + const window = (global as unknown as { vscode?: { window?: WindowAPI } }).vscode?.window + + if (!workspace || !window) { + logs.warn("[DIFF] VSCode APIs not available for diff command", "VSCode.Commands") + return + } + + logs.info( + `[DIFF] Current visibleTextEditors count: ${window.visibleTextEditors?.length || 0}`, + "VSCode.Commands", + ) + + try { + // The document should already be open from the showTextDocument call + // Find it in the existing textDocuments + logs.info(`[DIFF] Looking for already-opened document: ${modifiedUri.fsPath}`, "VSCode.Commands") + let document = workspace.textDocuments.find((doc: TextDocument) => doc.uri.fsPath === modifiedUri.fsPath) + + if (!document) { + // If not found, open it now + logs.info(`[DIFF] Document not found, opening: ${modifiedUri.fsPath}`, "VSCode.Commands") + document = await workspace.openTextDocument(modifiedUri) + logs.info(`[DIFF] Document opened successfully, lineCount: ${document.lineCount}`, "VSCode.Commands") + } else { + logs.info(`[DIFF] Found existing document, lineCount: ${document.lineCount}`, "VSCode.Commands") + } + + // Create a mock editor for the diff view + const mockEditor: TextEditor = { + document, + selection: new Selection(new Position(0, 0), new Position(0, 0)), + selections: [new Selection(new Position(0, 0), new Position(0, 0))], + visibleRanges: [new Range(new Position(0, 0), new Position(0, 0))], + options: {}, + viewColumn: ViewColumn.One, + edit: async (callback: (editBuilder: TextEditorEdit) => void) => { + // Create a mock edit builder + const editBuilder: TextEditorEdit = { + replace: (_range: Range | Position | Selection, _text: string) => { + // In CLI mode, we don't actually edit here + // The DiffViewProvider will handle the actual edits + logs.debug("Mock edit builder replace called", "VSCode.Commands") + }, + insert: (_position: Position, _text: string) => { + logs.debug("Mock edit builder insert called", "VSCode.Commands") + }, + delete: (_range: Range | Selection) => { + logs.debug("Mock edit builder delete called", "VSCode.Commands") + }, + setEndOfLine: (_endOfLine: EndOfLine) => { + logs.debug("Mock edit builder setEndOfLine called", "VSCode.Commands") + }, + } + callback(editBuilder) + return true + }, + insertSnippet: () => Promise.resolve(true), + setDecorations: () => {}, + revealRange: () => {}, + show: () => {}, + hide: () => {}, + } + + // Add the editor to visible editors + if (!window.visibleTextEditors) { + window.visibleTextEditors = [] + } + + // Check if this editor is already in visibleTextEditors (from showTextDocument) + const existingEditor = window.visibleTextEditors.find( + (e: TextEditor) => e.document.uri.fsPath === modifiedUri.fsPath, + ) + + if (existingEditor) { + logs.info(`[DIFF] Editor already in visibleTextEditors, updating it`, "VSCode.Commands") + // Update the existing editor with the mock editor properties + Object.assign(existingEditor, mockEditor) + } else { + logs.info(`[DIFF] Adding new mock editor to visibleTextEditors`, "VSCode.Commands") + window.visibleTextEditors.push(mockEditor) + } + + logs.info(`[DIFF] visibleTextEditors count: ${window.visibleTextEditors.length}`, "VSCode.Commands") + + // The onDidChangeVisibleTextEditors event was already fired by showTextDocument + // We don't need to fire it again here + logs.info( + `[DIFF] Diff view simulation complete (events already fired by showTextDocument)`, + "VSCode.Commands", + ) + } catch (error) { + logs.error("[DIFF] Error simulating diff view", "VSCode.Commands", { error }) + } + } +} diff --git a/packages/vscode-shim/src/api/FileSystemAPI.ts b/packages/vscode-shim/src/api/FileSystemAPI.ts new file mode 100644 index 0000000000..78358fa938 --- /dev/null +++ b/packages/vscode-shim/src/api/FileSystemAPI.ts @@ -0,0 +1,77 @@ +/** + * FileSystemAPI class for VSCode API + */ + +import * as fs from "fs" +import * as path from "path" +import { Uri } from "../classes/Uri.js" +import { FileSystemError } from "../classes/Additional.js" +import { ensureDirectoryExists } from "../utils/paths.js" +import type { FileStat } from "../types.js" + +/** + * File system API mock for CLI mode + * Provides file operations using Node.js fs module + */ +export class FileSystemAPI { + async stat(uri: Uri): Promise { + try { + const stats = fs.statSync(uri.fsPath) + return { + type: stats.isDirectory() ? 2 : 1, // Directory = 2, File = 1 + ctime: stats.ctimeMs, + mtime: stats.mtimeMs, + size: stats.size, + } + } catch { + // If file doesn't exist, assume it's a file for CLI purposes + return { + type: 1, // File + ctime: Date.now(), + mtime: Date.now(), + size: 0, + } + } + } + + async readFile(uri: Uri): Promise { + try { + const content = fs.readFileSync(uri.fsPath) + return new Uint8Array(content) + } catch (error) { + // Check if it's a file not found error (ENOENT) + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw FileSystemError.FileNotFound(uri) + } + // For other errors, throw a generic FileSystemError + throw new FileSystemError(`Failed to read file: ${uri.fsPath}`) + } + } + + async writeFile(uri: Uri, content: Uint8Array): Promise { + try { + // Ensure directory exists + const dir = path.dirname(uri.fsPath) + ensureDirectoryExists(dir) + fs.writeFileSync(uri.fsPath, content) + } catch { + throw new Error(`Failed to write file: ${uri.fsPath}`) + } + } + + async delete(uri: Uri): Promise { + try { + fs.unlinkSync(uri.fsPath) + } catch { + throw new Error(`Failed to delete file: ${uri.fsPath}`) + } + } + + async createDirectory(uri: Uri): Promise { + try { + fs.mkdirSync(uri.fsPath, { recursive: true }) + } catch { + throw new Error(`Failed to create directory: ${uri.fsPath}`) + } + } +} diff --git a/packages/vscode-shim/src/api/TabGroupsAPI.ts b/packages/vscode-shim/src/api/TabGroupsAPI.ts new file mode 100644 index 0000000000..cba318b433 --- /dev/null +++ b/packages/vscode-shim/src/api/TabGroupsAPI.ts @@ -0,0 +1,69 @@ +/** + * TabGroupsAPI class for VSCode API + */ + +import { EventEmitter } from "../classes/EventEmitter.js" +import type { Uri } from "../classes/Uri.js" +import type { Disposable } from "../interfaces/workspace.js" + +/** + * Tab interface representing an open tab + */ +export interface Tab { + input: TabInputText | unknown + label: string + isActive: boolean + isDirty: boolean +} + +/** + * Tab input for text files + */ +export interface TabInputText { + uri: Uri +} + +/** + * Tab group interface + */ +export interface TabGroup { + tabs: Tab[] +} + +/** + * Tab groups API mock for CLI mode + */ +export class TabGroupsAPI { + private _onDidChangeTabs = new EventEmitter() + private _tabGroups: TabGroup[] = [] + + get all(): TabGroup[] { + return this._tabGroups + } + + onDidChangeTabs(listener: () => void): Disposable { + return this._onDidChangeTabs.event(listener) + } + + async close(tab: Tab): Promise { + // Find and remove the tab from all groups + for (const group of this._tabGroups) { + const index = group.tabs.indexOf(tab) + if (index !== -1) { + group.tabs.splice(index, 1) + this._onDidChangeTabs.fire() + return true + } + } + return false + } + + // Internal method to simulate tab changes for CLI + _simulateTabChange(): void { + this._onDidChangeTabs.fire() + } + + dispose(): void { + this._onDidChangeTabs.dispose() + } +} diff --git a/packages/vscode-shim/src/api/WindowAPI.ts b/packages/vscode-shim/src/api/WindowAPI.ts new file mode 100644 index 0000000000..631a8e6a3a --- /dev/null +++ b/packages/vscode-shim/src/api/WindowAPI.ts @@ -0,0 +1,362 @@ +/** + * WindowAPI class for VSCode API + */ + +import { logs } from "../utils/logger.js" +import { Uri } from "../classes/Uri.js" +import { Position } from "../classes/Position.js" +import { Range } from "../classes/Range.js" +import { Selection } from "../classes/Selection.js" +import { EventEmitter } from "../classes/EventEmitter.js" +import { ThemeIcon } from "../classes/Additional.js" +import { OutputChannel } from "../classes/OutputChannel.js" +import { StatusBarItem } from "../classes/StatusBarItem.js" +import { TextEditorDecorationType } from "../classes/TextEditorDecorationType.js" +import { TabGroupsAPI } from "./TabGroupsAPI.js" +import { StatusBarAlignment, ViewColumn } from "../types.js" +import type { WorkspaceAPI } from "./WorkspaceAPI.js" +import type { Thenable } from "../types.js" +import type { + TextEditor, + TextEditorSelectionChangeEvent, + TextDocumentShowOptions, + DecorationRenderOptions, +} from "../interfaces/editor.js" +import type { TextDocument } from "../interfaces/document.js" +import type { Terminal, TerminalDimensionsChangeEvent, TerminalDataWriteEvent } from "../interfaces/terminal.js" +import type { + WebviewViewProvider, + WebviewView, + Webview, + ViewBadge, + WebviewViewProviderOptions, + UriHandler, +} from "../interfaces/webview.js" +import type { QuickPickOptions, InputBoxOptions, OpenDialogOptions, Disposable } from "../interfaces/workspace.js" +import type { CancellationToken } from "../interfaces/document.js" + +/** + * Window API mock for CLI mode + */ +export class WindowAPI { + public tabGroups: TabGroupsAPI + public visibleTextEditors: TextEditor[] = [] + public _onDidChangeVisibleTextEditors = new EventEmitter() + private _workspace?: WorkspaceAPI + private static _decorationCounter = 0 + + constructor() { + this.tabGroups = new TabGroupsAPI() + } + + setWorkspace(workspace: WorkspaceAPI) { + this._workspace = workspace + } + + createOutputChannel(name: string): OutputChannel { + return new OutputChannel(name) + } + + createStatusBarItem(alignment?: StatusBarAlignment, priority?: number): StatusBarItem + createStatusBarItem(id?: string, alignment?: StatusBarAlignment, priority?: number): StatusBarItem + createStatusBarItem( + idOrAlignment?: string | StatusBarAlignment, + alignmentOrPriority?: StatusBarAlignment | number, + priority?: number, + ): StatusBarItem { + // Handle overloaded signatures + let actualAlignment: StatusBarAlignment + let actualPriority: number | undefined + + if (typeof idOrAlignment === "string") { + // Called with id, alignment, priority + actualAlignment = (alignmentOrPriority as StatusBarAlignment) ?? StatusBarAlignment.Left + actualPriority = priority + } else { + // Called with alignment, priority + actualAlignment = (idOrAlignment as StatusBarAlignment) ?? StatusBarAlignment.Left + actualPriority = alignmentOrPriority as number | undefined + } + + return new StatusBarItem(actualAlignment, actualPriority) + } + + createTextEditorDecorationType(_options: DecorationRenderOptions): TextEditorDecorationType { + return new TextEditorDecorationType(`decoration-${++WindowAPI._decorationCounter}`) + } + + createTerminal(options?: { + name?: string + shellPath?: string + shellArgs?: string[] + cwd?: string + env?: { [key: string]: string | null | undefined } + iconPath?: ThemeIcon + hideFromUser?: boolean + message?: string + strictEnv?: boolean + }): Terminal { + // Return a mock terminal object + return { + name: options?.name || "Terminal", + processId: Promise.resolve(undefined), + creationOptions: options || {}, + exitStatus: undefined, + state: { isInteractedWith: false }, + sendText: (text: string, _addNewLine?: boolean) => { + logs.debug(`Terminal sendText: ${text}`, "VSCode.Terminal") + }, + show: (_preserveFocus?: boolean) => { + logs.debug("Terminal show called", "VSCode.Terminal") + }, + hide: () => { + logs.debug("Terminal hide called", "VSCode.Terminal") + }, + dispose: () => { + logs.debug("Terminal disposed", "VSCode.Terminal") + }, + } + } + + showInformationMessage(message: string, ..._items: string[]): Thenable { + logs.info(message, "VSCode.Window") + return Promise.resolve(undefined) + } + + showWarningMessage(message: string, ..._items: string[]): Thenable { + logs.warn(message, "VSCode.Window") + return Promise.resolve(undefined) + } + + showErrorMessage(message: string, ..._items: string[]): Thenable { + logs.error(message, "VSCode.Window") + return Promise.resolve(undefined) + } + + showQuickPick(items: string[], _options?: QuickPickOptions): Thenable { + // Return first item for CLI + return Promise.resolve(items[0]) + } + + showInputBox(_options?: InputBoxOptions): Thenable { + // Return empty string for CLI + return Promise.resolve("") + } + + showOpenDialog(_options?: OpenDialogOptions): Thenable { + // Return empty array for CLI + return Promise.resolve([]) + } + + async showTextDocument( + documentOrUri: TextDocument | Uri, + columnOrOptions?: ViewColumn | TextDocumentShowOptions, + _preserveFocus?: boolean, + ): Promise { + // Mock implementation for CLI + // In a real VSCode environment, this would open the document in an editor + const uri = documentOrUri instanceof Uri ? documentOrUri : documentOrUri.uri + logs.debug(`showTextDocument called for: ${uri?.toString() || "unknown"}`, "VSCode.Window") + + // Create a placeholder editor first so it's in visibleTextEditors when onDidOpenTextDocument fires + const placeholderEditor: TextEditor = { + document: { uri } as TextDocument, + selection: new Selection(new Position(0, 0), new Position(0, 0)), + selections: [new Selection(new Position(0, 0), new Position(0, 0))], + visibleRanges: [new Range(new Position(0, 0), new Position(0, 0))], + options: {}, + viewColumn: typeof columnOrOptions === "number" ? columnOrOptions : ViewColumn.One, + edit: () => Promise.resolve(true), + insertSnippet: () => Promise.resolve(true), + setDecorations: () => {}, + revealRange: () => {}, + show: () => {}, + hide: () => {}, + } + + // Add placeholder to visible editors BEFORE opening document + this.visibleTextEditors.push(placeholderEditor) + logs.debug( + `Placeholder editor added to visibleTextEditors, total: ${this.visibleTextEditors.length}`, + "VSCode.Window", + ) + + // If we have a URI, open the document (this will fire onDidOpenTextDocument) + let document: TextDocument | Uri = documentOrUri + if (documentOrUri instanceof Uri && this._workspace) { + logs.debug("Opening document via workspace.openTextDocument", "VSCode.Window") + document = await this._workspace.openTextDocument(uri) + logs.debug("Document opened successfully", "VSCode.Window") + + // Update the placeholder editor with the real document + placeholderEditor.document = document + } + + // Fire events immediately using setImmediate + setImmediate(() => { + logs.debug("Firing onDidChangeVisibleTextEditors event", "VSCode.Window") + this._onDidChangeVisibleTextEditors.fire(this.visibleTextEditors) + logs.debug("onDidChangeVisibleTextEditors event fired", "VSCode.Window") + }) + + logs.debug("Returning editor from showTextDocument", "VSCode.Window") + return placeholderEditor + } + + registerWebviewViewProvider( + viewId: string, + provider: WebviewViewProvider, + _options?: WebviewViewProviderOptions, + ): Disposable { + // Store the provider for later use by ExtensionHost + if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) { + const extensionHost = ( + global as unknown as { + __extensionHost: { + registerWebviewProvider: (viewId: string, provider: WebviewViewProvider) => void + isInInitialSetup: () => boolean + markWebviewReady: () => void + } + } + ).__extensionHost + extensionHost.registerWebviewProvider(viewId, provider) + + // Set up webview mock that captures messages from the extension + const mockWebview = { + postMessage: (message: unknown): Thenable => { + // Forward extension messages to ExtensionHost for CLI consumption + if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) { + ;( + global as unknown as { + __extensionHost: { emit: (event: string, message: unknown) => void } + } + ).__extensionHost.emit("extensionWebviewMessage", message) + } + return Promise.resolve(true) + }, + onDidReceiveMessage: (listener: (message: unknown) => void) => { + // This is how the extension listens for messages from the webview + // We need to connect this to our message bridge + if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) { + ;( + global as unknown as { + __extensionHost: { on: (event: string, listener: (message: unknown) => void) => void } + } + ).__extensionHost.on("webviewMessage", listener) + } + return { dispose: () => {} } + }, + asWebviewUri: (uriArg: Uri) => { + // Convert file URIs to webview-compatible URIs + // For CLI, we can just return a mock webview URI + return Uri.parse(`vscode-webview://webview/${uriArg.path}`) + }, + html: "", + options: {}, + cspSource: "vscode-webview:", + } + + // Provide the mock webview to the provider + if (provider.resolveWebviewView) { + const mockWebviewView = { + webview: mockWebview as Webview, + viewType: viewId, + title: viewId, + description: undefined as string | undefined, + badge: undefined as ViewBadge | undefined, + show: () => {}, + onDidChangeVisibility: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + visible: true, + } + + // Call resolveWebviewView immediately with initialization context + // No setTimeout needed - use event-based synchronization instead + ;(async () => { + try { + // Pass isInitialSetup flag in context to prevent task abortion + const context = { + preserveFocus: false, + isInitialSetup: extensionHost.isInInitialSetup(), + } + + logs.debug( + `Calling resolveWebviewView with isInitialSetup=${context.isInitialSetup}`, + "VSCode.Window", + ) + + // Await the result to ensure webview is fully initialized before marking ready + await provider.resolveWebviewView(mockWebviewView as WebviewView, {}, {} as CancellationToken) + + // Mark webview as ready after resolution completes + extensionHost.markWebviewReady() + logs.debug("Webview resolution complete, marked as ready", "VSCode.Window") + } catch (error) { + logs.error("Error resolving webview view", "VSCode.Window", { error }) + } + })() + } + } + return { + dispose: () => { + if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) { + ;( + global as unknown as { + __extensionHost: { unregisterWebviewProvider: (viewId: string) => void } + } + ).__extensionHost.unregisterWebviewProvider(viewId) + } + }, + } + } + + registerUriHandler(_handler: UriHandler): Disposable { + // Store the URI handler for later use + return { + dispose: () => {}, + } + } + + onDidChangeTextEditorSelection(listener: (event: TextEditorSelectionChangeEvent) => void): Disposable { + const emitter = new EventEmitter() + return emitter.event(listener) + } + + onDidChangeActiveTextEditor(listener: (event: TextEditor | undefined) => void): Disposable { + const emitter = new EventEmitter() + return emitter.event(listener) + } + + onDidChangeVisibleTextEditors(listener: (editors: TextEditor[]) => void): Disposable { + return this._onDidChangeVisibleTextEditors.event(listener) + } + + // Terminal event handlers + onDidCloseTerminal(_listener: (terminal: Terminal) => void): Disposable { + return { dispose: () => {} } + } + + onDidOpenTerminal(_listener: (terminal: Terminal) => void): Disposable { + return { dispose: () => {} } + } + + onDidChangeActiveTerminal(_listener: (terminal: Terminal | undefined) => void): Disposable { + return { dispose: () => {} } + } + + onDidChangeTerminalDimensions(_listener: (event: TerminalDimensionsChangeEvent) => void): Disposable { + return { dispose: () => {} } + } + + onDidWriteTerminalData(_listener: (event: TerminalDataWriteEvent) => void): Disposable { + return { dispose: () => {} } + } + + get activeTerminal(): Terminal | undefined { + return undefined + } + + get terminals(): Terminal[] { + return [] + } +} diff --git a/packages/vscode-shim/src/api/WorkspaceAPI.ts b/packages/vscode-shim/src/api/WorkspaceAPI.ts new file mode 100644 index 0000000000..2c00d7b529 --- /dev/null +++ b/packages/vscode-shim/src/api/WorkspaceAPI.ts @@ -0,0 +1,322 @@ +/** + * WorkspaceAPI class for VSCode API + */ + +import * as fs from "fs" +import * as path from "path" +import { logs } from "../utils/logger.js" +import { Uri } from "../classes/Uri.js" +import { Position } from "../classes/Position.js" +import { Range } from "../classes/Range.js" +import { EventEmitter } from "../classes/EventEmitter.js" +import { WorkspaceEdit } from "../classes/TextEdit.js" +import { FileSystemAPI } from "./FileSystemAPI.js" +import { MockWorkspaceConfiguration } from "./WorkspaceConfiguration.js" +import type { ExtensionContextImpl } from "../context/ExtensionContext.js" +import type { + TextDocument, + TextLine, + WorkspaceFoldersChangeEvent, + WorkspaceFolder, + TextDocumentChangeEvent, + ConfigurationChangeEvent, + TextDocumentContentProvider, + FileSystemWatcher, + RelativePattern, +} from "../interfaces/document.js" +import type { Disposable, WorkspaceConfiguration } from "../interfaces/workspace.js" +import type { Thenable } from "../types.js" + +/** + * Workspace API mock for CLI mode + */ +export class WorkspaceAPI { + public workspaceFolders: WorkspaceFolder[] | undefined + public name: string | undefined + public workspaceFile: Uri | undefined + public fs: FileSystemAPI + public textDocuments: TextDocument[] = [] + private _onDidChangeWorkspaceFolders = new EventEmitter() + private _onDidOpenTextDocument = new EventEmitter() + private _onDidChangeTextDocument = new EventEmitter() + private _onDidCloseTextDocument = new EventEmitter() + private context: ExtensionContextImpl + + constructor(workspacePath: string, context: ExtensionContextImpl) { + this.context = context + this.workspaceFolders = [ + { + uri: Uri.file(workspacePath), + name: path.basename(workspacePath), + index: 0, + }, + ] + this.name = path.basename(workspacePath) + this.fs = new FileSystemAPI() + } + + asRelativePath(pathOrUri: string | Uri, includeWorkspaceFolder?: boolean): string { + const fsPath = typeof pathOrUri === "string" ? pathOrUri : pathOrUri.fsPath + + // If no workspace folders, return the original path + if (!this.workspaceFolders || this.workspaceFolders.length === 0) { + return fsPath + } + + // Try to find a workspace folder that contains this path + for (const folder of this.workspaceFolders) { + const workspacePath = folder.uri.fsPath + + // Normalize paths for comparison (handle different path separators) + const normalizedFsPath = path.normalize(fsPath) + const normalizedWorkspacePath = path.normalize(workspacePath) + + // Check if the path is within this workspace folder + if (normalizedFsPath.startsWith(normalizedWorkspacePath)) { + // Get the relative path + let relativePath = path.relative(normalizedWorkspacePath, normalizedFsPath) + + // If includeWorkspaceFolder is true and there are multiple workspace folders, + // prepend the workspace folder name + if (includeWorkspaceFolder && this.workspaceFolders.length > 1) { + relativePath = path.join(folder.name, relativePath) + } + + return relativePath + } + } + + // If not within any workspace folder, return the original path + return fsPath + } + + onDidChangeWorkspaceFolders(listener: (event: WorkspaceFoldersChangeEvent) => void): Disposable { + return this._onDidChangeWorkspaceFolders.event(listener) + } + + onDidChangeConfiguration(listener: (event: ConfigurationChangeEvent) => void): Disposable { + // Create a mock configuration change event emitter + const emitter = new EventEmitter() + return emitter.event(listener) + } + + onDidChangeTextDocument(listener: (event: TextDocumentChangeEvent) => void): Disposable { + return this._onDidChangeTextDocument.event(listener) + } + + onDidOpenTextDocument(listener: (event: TextDocument) => void): Disposable { + logs.debug("Registering onDidOpenTextDocument listener", "VSCode.Workspace") + return this._onDidOpenTextDocument.event(listener) + } + + onDidCloseTextDocument(listener: (event: TextDocument) => void): Disposable { + return this._onDidCloseTextDocument.event(listener) + } + + getConfiguration(section?: string): WorkspaceConfiguration { + return new MockWorkspaceConfiguration(section, this.context) + } + + findFiles(_include: string, _exclude?: string): Thenable { + // Basic implementation - could be enhanced with glob patterns + return Promise.resolve([]) + } + + async openTextDocument(uri: Uri): Promise { + logs.debug(`openTextDocument called for: ${uri.fsPath}`, "VSCode.Workspace") + + // Read file content + let content = "" + try { + content = fs.readFileSync(uri.fsPath, "utf-8") + logs.debug(`File content read successfully, length: ${content.length}`, "VSCode.Workspace") + } catch (error) { + logs.warn(`Failed to read file: ${uri.fsPath}`, "VSCode.Workspace", { error }) + } + + const lines = content.split("\n") + const document: TextDocument = { + uri, + fileName: uri.fsPath, + languageId: "plaintext", + version: 1, + isDirty: false, + isClosed: false, + lineCount: lines.length, + getText: (range?: Range) => { + if (!range) { + return content + } + return lines.slice(range.start.line, range.end.line + 1).join("\n") + }, + lineAt: (line: number): TextLine => { + const text = lines[line] || "" + return { + text, + range: new Range(new Position(line, 0), new Position(line, text.length)), + rangeIncludingLineBreak: new Range(new Position(line, 0), new Position(line + 1, 0)), + firstNonWhitespaceCharacterIndex: text.search(/\S/), + isEmptyOrWhitespace: text.trim().length === 0, + } + }, + offsetAt: (position: Position) => { + let offset = 0 + for (let i = 0; i < position.line && i < lines.length; i++) { + offset += (lines[i]?.length || 0) + 1 // +1 for newline + } + offset += position.character + return offset + }, + positionAt: (offset: number) => { + let currentOffset = 0 + for (let i = 0; i < lines.length; i++) { + const lineLength = (lines[i]?.length || 0) + 1 // +1 for newline + if (currentOffset + lineLength > offset) { + return new Position(i, offset - currentOffset) + } + currentOffset += lineLength + } + return new Position(lines.length - 1, lines[lines.length - 1]?.length || 0) + }, + save: () => Promise.resolve(true), + validateRange: (range: Range) => range, + validatePosition: (position: Position) => position, + } + + // Add to textDocuments array + this.textDocuments.push(document) + logs.debug(`Document added to textDocuments array, total: ${this.textDocuments.length}`, "VSCode.Workspace") + + // Fire the event after a small delay to ensure listeners are fully registered + logs.debug("Waiting before firing onDidOpenTextDocument", "VSCode.Workspace") + await new Promise((resolve) => setTimeout(resolve, 10)) + logs.debug("Firing onDidOpenTextDocument event", "VSCode.Workspace") + this._onDidOpenTextDocument.fire(document) + logs.debug("onDidOpenTextDocument event fired", "VSCode.Workspace") + + return document + } + + async applyEdit(edit: WorkspaceEdit): Promise { + // In CLI mode, we need to apply the edits to the actual files + try { + for (const [uri, edits] of edit.entries()) { + let filePath = uri.fsPath + + // On Windows, strip leading slash if present (e.g., /C:/path becomes C:/path) + if (process.platform === "win32" && filePath.startsWith("/")) { + filePath = filePath.slice(1) + } + + let content = "" + + // Read existing content if file exists + try { + content = fs.readFileSync(filePath, "utf-8") + } catch { + // File doesn't exist, start with empty content + } + + // Apply edits in reverse order to maintain correct positions + const sortedEdits = edits.sort((a, b) => { + const lineDiff = b.range.start.line - a.range.start.line + if (lineDiff !== 0) return lineDiff + return b.range.start.character - a.range.start.character + }) + + const lines = content.split("\n") + for (const textEdit of sortedEdits) { + const startLine = textEdit.range.start.line + const startChar = textEdit.range.start.character + const endLine = textEdit.range.end.line + const endChar = textEdit.range.end.character + + if (startLine === endLine) { + // Single line edit + const line = lines[startLine] || "" + lines[startLine] = line.substring(0, startChar) + textEdit.newText + line.substring(endChar) + } else { + // Multi-line edit + const firstLine = lines[startLine] || "" + const lastLine = lines[endLine] || "" + const newContent = + firstLine.substring(0, startChar) + textEdit.newText + lastLine.substring(endChar) + lines.splice(startLine, endLine - startLine + 1, newContent) + } + } + + // Write back to file + const newContent = lines.join("\n") + fs.writeFileSync(filePath, newContent, "utf-8") + + // Update the in-memory document object to reflect the new content + // This is critical for CLI mode where DiffViewProvider reads from the document object + const document = this.textDocuments.find((doc: TextDocument) => doc.uri.fsPath === filePath) + if (document) { + const newLines = newContent.split("\n") + + // Update document properties with new content + document.lineCount = newLines.length + document.getText = (range?: Range) => { + if (!range) { + return newContent + } + return newLines.slice(range.start.line, range.end.line + 1).join("\n") + } + document.lineAt = (line: number): TextLine => { + const text = newLines[line] || "" + return { + text, + range: new Range(new Position(line, 0), new Position(line, text.length)), + rangeIncludingLineBreak: new Range(new Position(line, 0), new Position(line + 1, 0)), + firstNonWhitespaceCharacterIndex: text.search(/\S/), + isEmptyOrWhitespace: text.trim().length === 0, + } + } + document.offsetAt = (position: Position) => { + let offset = 0 + for (let i = 0; i < position.line && i < newLines.length; i++) { + offset += (newLines[i]?.length || 0) + 1 // +1 for newline + } + offset += position.character + return offset + } + document.positionAt = (offset: number) => { + let currentOffset = 0 + for (let i = 0; i < newLines.length; i++) { + const lineLength = (newLines[i]?.length || 0) + 1 // +1 for newline + if (currentOffset + lineLength > offset) { + return new Position(i, offset - currentOffset) + } + currentOffset += lineLength + } + return new Position(newLines.length - 1, newLines[newLines.length - 1]?.length || 0) + } + } + } + return true + } catch (error) { + logs.error("Failed to apply workspace edit", "VSCode.Workspace", { error }) + return false + } + } + + createFileSystemWatcher( + _globPattern?: string | RelativePattern, + _ignoreCreateEvents?: boolean, + _ignoreChangeEvents?: boolean, + _ignoreDeleteEvents?: boolean, + ): FileSystemWatcher { + const emitter = new EventEmitter() + return { + onDidChange: (listener: (e: Uri) => void) => emitter.event(listener), + onDidCreate: (listener: (e: Uri) => void) => emitter.event(listener), + onDidDelete: (listener: (e: Uri) => void) => emitter.event(listener), + dispose: () => emitter.dispose(), + } + } + + registerTextDocumentContentProvider(_scheme: string, _provider: TextDocumentContentProvider): Disposable { + return { dispose: () => {} } + } +} diff --git a/packages/vscode-shim/src/api/WorkspaceConfiguration.ts b/packages/vscode-shim/src/api/WorkspaceConfiguration.ts new file mode 100644 index 0000000000..33dbc9c7b2 --- /dev/null +++ b/packages/vscode-shim/src/api/WorkspaceConfiguration.ts @@ -0,0 +1,195 @@ +/** + * MockWorkspaceConfiguration class for VSCode API + */ + +import * as path from "path" +import { logs } from "../utils/logger.js" +import { VSCodeMockPaths, ensureDirectoryExists } from "../utils/paths.js" +import { FileMemento } from "../storage/Memento.js" +import { ConfigurationTarget } from "../types.js" +import type { ConfigurationInspect } from "../types.js" +import type { WorkspaceConfiguration } from "../interfaces/workspace.js" +import type { ExtensionContextImpl } from "../context/ExtensionContext.js" + +/** + * In-memory runtime configuration store shared across all MockWorkspaceConfiguration instances. + * This allows configuration to be updated at runtime (e.g., from CLI settings) without + * persisting to disk. Values in this store take precedence over disk-based mementos. + */ +const runtimeConfig: Map = new Map() + +/** + * Set a runtime configuration value. + * @param section The configuration section (e.g., "roo-cline") + * @param key The configuration key (e.g., "commandExecutionTimeout") + * @param value The value to set + */ +export function setRuntimeConfig(section: string, key: string, value: unknown): void { + const fullKey = `${section}.${key}` + runtimeConfig.set(fullKey, value) + logs.debug(`Runtime config set: ${fullKey} = ${JSON.stringify(value)}`, "VSCode.MockWorkspaceConfiguration") +} + +/** + * Set multiple runtime configuration values at once. + * @param section The configuration section (e.g., "roo-cline") + * @param values Object containing key-value pairs to set + */ +export function setRuntimeConfigValues(section: string, values: Record): void { + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) { + setRuntimeConfig(section, key, value) + } + } +} + +/** + * Clear all runtime configuration values. + */ +export function clearRuntimeConfig(): void { + runtimeConfig.clear() + logs.debug("Runtime config cleared", "VSCode.MockWorkspaceConfiguration") +} + +/** + * Get a runtime configuration value. + * @param fullKey The full configuration key (e.g., "roo-cline.commandExecutionTimeout") + * @returns The value or undefined if not set + */ +export function getRuntimeConfig(fullKey: string): unknown { + return runtimeConfig.get(fullKey) +} + +/** + * Mock workspace configuration for CLI mode + * Persists configuration to JSON files + */ +export class MockWorkspaceConfiguration implements WorkspaceConfiguration { + private section: string | undefined + private globalMemento: FileMemento + private workspaceMemento: FileMemento + + constructor(section?: string, context?: ExtensionContextImpl) { + this.section = section + + if (context) { + // Use the extension context's mementos + this.globalMemento = context.globalState as unknown as FileMemento + this.workspaceMemento = context.workspaceState as unknown as FileMemento + } else { + // Fallback: create our own mementos (shouldn't happen in normal usage) + const globalStoragePath = VSCodeMockPaths.getGlobalStorageDir() + const workspaceStoragePath = VSCodeMockPaths.getWorkspaceStorageDir(process.cwd()) + + ensureDirectoryExists(globalStoragePath) + ensureDirectoryExists(workspaceStoragePath) + + this.globalMemento = new FileMemento(path.join(globalStoragePath, "configuration.json")) + this.workspaceMemento = new FileMemento(path.join(workspaceStoragePath, "configuration.json")) + } + } + + get(section: string, defaultValue?: T): T | undefined { + const fullSection = this.section ? `${this.section}.${section}` : section + + // Check runtime configuration first (highest priority - set by CLI at runtime) + const runtimeValue = runtimeConfig.get(fullSection) + if (runtimeValue !== undefined) { + return runtimeValue as T + } + + // Check workspace configuration (persisted to disk) + const workspaceValue = this.workspaceMemento.get(fullSection) + if (workspaceValue !== undefined && workspaceValue !== null) { + return workspaceValue as T + } + + // Check global configuration (persisted to disk) + const globalValue = this.globalMemento.get(fullSection) + if (globalValue !== undefined && globalValue !== null) { + return globalValue as T + } + + // Return default value + return defaultValue + } + + has(section: string): boolean { + const fullSection = this.section ? `${this.section}.${section}` : section + return this.workspaceMemento.get(fullSection) !== undefined || this.globalMemento.get(fullSection) !== undefined + } + + inspect(section: string): ConfigurationInspect | undefined { + const fullSection = this.section ? `${this.section}.${section}` : section + const workspaceValue = this.workspaceMemento.get(fullSection) + const globalValue = this.globalMemento.get(fullSection) + + if (workspaceValue !== undefined || globalValue !== undefined) { + return { + key: fullSection, + defaultValue: undefined, + globalValue: globalValue as T | undefined, + workspaceValue: workspaceValue as T | undefined, + workspaceFolderValue: undefined, + } + } + + return undefined + } + + async update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Promise { + const fullSection = this.section ? `${this.section}.${section}` : section + + try { + // Determine which memento to use based on configuration target + const memento = + configurationTarget === ConfigurationTarget.Workspace ? this.workspaceMemento : this.globalMemento + + const scope = configurationTarget === ConfigurationTarget.Workspace ? "workspace" : "global" + + // Update the memento (this automatically persists to disk) + await memento.update(fullSection, value) + + logs.debug( + `Configuration updated: ${fullSection} = ${JSON.stringify(value)} (${scope})`, + "VSCode.MockWorkspaceConfiguration", + ) + } catch (error) { + logs.error(`Failed to update configuration: ${fullSection}`, "VSCode.MockWorkspaceConfiguration", { + error, + }) + throw error + } + } + + // Additional method to reload configuration from disk + public reload(): void { + // FileMemento automatically loads from disk, so we don't need to do anything special + logs.debug("Configuration reload requested", "VSCode.MockWorkspaceConfiguration") + } + + // Method to get all configuration data (useful for debugging and generic config loading) + public getAllConfig(): Record { + const globalKeys = this.globalMemento.keys() + const workspaceKeys = this.workspaceMemento.keys() + const allConfig: Record = {} + + // Add global settings first + for (const key of globalKeys) { + const value = this.globalMemento.get(key) + if (value !== undefined && value !== null) { + allConfig[key] = value + } + } + + // Add workspace settings (these override global) + for (const key of workspaceKeys) { + const value = this.workspaceMemento.get(key) + if (value !== undefined && value !== null) { + allConfig[key] = value + } + } + + return allConfig + } +} diff --git a/packages/vscode-shim/src/api/create-vscode-api-mock.ts b/packages/vscode-shim/src/api/create-vscode-api-mock.ts new file mode 100644 index 0000000000..ecb24b0a5d --- /dev/null +++ b/packages/vscode-shim/src/api/create-vscode-api-mock.ts @@ -0,0 +1,315 @@ +/** + * Main factory function for creating VSCode API mock + */ + +import { machineIdSync } from "../utils/machine-id.js" +import { logs } from "../utils/logger.js" + +// Import classes +import { Uri } from "../classes/Uri.js" +import { Position } from "../classes/Position.js" +import { Range } from "../classes/Range.js" +import { Selection } from "../classes/Selection.js" +import { EventEmitter } from "../classes/EventEmitter.js" +import { TextEdit, WorkspaceEdit } from "../classes/TextEdit.js" +import { + Location, + Diagnostic, + DiagnosticRelatedInformation, + ThemeColor, + ThemeIcon, + CodeActionKind, + CodeLens, + LanguageModelTextPart, + LanguageModelToolCallPart, + LanguageModelToolResultPart, + FileSystemError, +} from "../classes/Additional.js" +import { CancellationTokenSource } from "../classes/CancellationToken.js" +import { StatusBarItem } from "../classes/StatusBarItem.js" +import { ExtensionContextImpl } from "../context/ExtensionContext.js" + +// Import APIs +import { WorkspaceAPI } from "./WorkspaceAPI.js" +import { WindowAPI } from "./WindowAPI.js" +import { CommandsAPI } from "./CommandsAPI.js" + +// Import types and enums +import { + ConfigurationTarget, + ViewColumn, + TextEditorRevealType, + StatusBarAlignment, + DiagnosticSeverity, + DiagnosticTag, + EndOfLine, + UIKind, + ExtensionMode, + FileType, + DecorationRangeBehavior, + OverviewRulerLane, +} from "../types.js" + +// Import interfaces +import type { CancellationToken } from "../interfaces/document.js" +import type { Disposable, DiagnosticCollection, IdentityInfo } from "../interfaces/workspace.js" +import type { RelativePattern } from "../interfaces/document.js" +import type { UriHandler } from "../interfaces/webview.js" + +// Package version constant +const Package = { version: "1.0.0" } + +/** + * Options for creating the VSCode API mock + */ +export interface VSCodeAPIMockOptions { + /** + * Custom app root path (for locating ripgrep and other VSCode resources). + * Defaults to the directory containing this module. + */ + appRoot?: string +} + +/** + * Create a complete VSCode API mock for CLI mode + */ +export function createVSCodeAPIMock( + extensionRootPath: string, + workspacePath: string, + identity?: IdentityInfo, + options?: VSCodeAPIMockOptions, +) { + const context = new ExtensionContextImpl({ + extensionPath: extensionRootPath, + workspacePath: workspacePath, + }) + const workspace = new WorkspaceAPI(workspacePath, context) + const window = new WindowAPI() + const commands = new CommandsAPI() + + // Link window and workspace for cross-API calls + window.setWorkspace(workspace) + + // Environment mock with identity values + const env = { + appName: `wrapper|cli|cli|${Package.version}`, + appRoot: options?.appRoot || import.meta.dirname, + language: "en", + machineId: identity?.machineId || machineIdSync(), + sessionId: identity?.sessionId || "cli-session-id", + remoteName: undefined, + shell: process.env.SHELL || "/bin/bash", + uriScheme: "vscode", + uiKind: 1, // Desktop + openExternal: async (uri: Uri): Promise => { + logs.info(`Would open external URL: ${uri.toString()}`, "VSCode.Env") + return true + }, + clipboard: { + readText: async (): Promise => { + logs.debug("Clipboard read requested", "VSCode.Clipboard") + return "" + }, + writeText: async (text: string): Promise => { + logs.debug( + `Clipboard write: ${text.substring(0, 100)}${text.length > 100 ? "..." : ""}`, + "VSCode.Clipboard", + ) + }, + }, + } + + return { + version: "1.84.0", + Uri, + EventEmitter, + ConfigurationTarget, + ViewColumn, + TextEditorRevealType, + StatusBarAlignment, + DiagnosticSeverity, + DiagnosticTag, + Position, + Range, + Selection, + Location, + Diagnostic, + DiagnosticRelatedInformation, + TextEdit, + WorkspaceEdit, + EndOfLine, + UIKind, + ExtensionMode, + CodeActionKind, + ThemeColor, + ThemeIcon, + DecorationRangeBehavior, + OverviewRulerLane, + StatusBarItem, + CancellationToken: class CancellationTokenClass implements CancellationToken { + isCancellationRequested = false + onCancellationRequested = (_listener: (e: unknown) => void) => ({ dispose: () => {} }) + }, + CancellationTokenSource, + CodeLens, + LanguageModelTextPart, + LanguageModelToolCallPart, + LanguageModelToolResultPart, + ExtensionContext: ExtensionContextImpl, + FileType, + FileSystemError, + Disposable: class DisposableClass implements Disposable { + dispose(): void { + // No-op for CLI + } + + static from(...disposables: Disposable[]): Disposable { + return { + dispose: () => { + disposables.forEach((d) => d.dispose()) + }, + } + } + }, + TabInputText: class TabInputText { + constructor(public uri: Uri) {} + }, + TabInputTextDiff: class TabInputTextDiff { + constructor( + public original: Uri, + public modified: Uri, + ) {} + }, + workspace, + window, + commands, + env, + context, + // Add more APIs as needed + languages: { + registerCodeActionsProvider: () => ({ dispose: () => {} }), + registerCodeLensProvider: () => ({ dispose: () => {} }), + registerCompletionItemProvider: () => ({ dispose: () => {} }), + registerHoverProvider: () => ({ dispose: () => {} }), + registerDefinitionProvider: () => ({ dispose: () => {} }), + registerReferenceProvider: () => ({ dispose: () => {} }), + registerDocumentSymbolProvider: () => ({ dispose: () => {} }), + registerWorkspaceSymbolProvider: () => ({ dispose: () => {} }), + registerRenameProvider: () => ({ dispose: () => {} }), + registerDocumentFormattingEditProvider: () => ({ dispose: () => {} }), + registerDocumentRangeFormattingEditProvider: () => ({ dispose: () => {} }), + registerSignatureHelpProvider: () => ({ dispose: () => {} }), + getDiagnostics: (uri?: Uri): [Uri, Diagnostic[]][] | Diagnostic[] => { + // In CLI mode, we don't have real diagnostics + // Return empty array or empty diagnostics for the specific URI + if (uri) { + return [] + } + return [] + }, + createDiagnosticCollection: (name?: string): DiagnosticCollection => { + const diagnostics = new Map() + const collection: DiagnosticCollection = { + name: name || "default", + set: ( + uriOrEntries: Uri | [Uri, Diagnostic[] | undefined][], + diagnosticsOrUndefined?: Diagnostic[] | undefined, + ) => { + if (Array.isArray(uriOrEntries)) { + // Handle array of entries + for (const [uri, diags] of uriOrEntries) { + if (diags === undefined) { + diagnostics.delete(uri.toString()) + } else { + diagnostics.set(uri.toString(), diags) + } + } + } else { + // Handle single URI + if (diagnosticsOrUndefined === undefined) { + diagnostics.delete(uriOrEntries.toString()) + } else { + diagnostics.set(uriOrEntries.toString(), diagnosticsOrUndefined) + } + } + }, + delete: (uri: Uri) => { + diagnostics.delete(uri.toString()) + }, + clear: () => { + diagnostics.clear() + }, + forEach: ( + callback: (uri: Uri, diagnostics: Diagnostic[], collection: DiagnosticCollection) => void, + thisArg?: unknown, + ) => { + diagnostics.forEach((diags, uriString) => { + callback.call(thisArg, Uri.parse(uriString), diags, collection) + }) + }, + get: (uri: Uri) => { + return diagnostics.get(uri.toString()) + }, + has: (uri: Uri) => { + return diagnostics.has(uri.toString()) + }, + dispose: () => { + diagnostics.clear() + }, + } + return collection + }, + }, + debug: { + onDidStartDebugSession: () => ({ dispose: () => {} }), + onDidTerminateDebugSession: () => ({ dispose: () => {} }), + }, + tasks: { + onDidStartTask: () => ({ dispose: () => {} }), + onDidEndTask: () => ({ dispose: () => {} }), + }, + extensions: { + all: [], + getExtension: (extensionId: string) => { + // Mock the extension object with extensionUri for theme loading + if (extensionId === "zgsm-ai.zgsm") { + return { + id: extensionId, + extensionUri: context.extensionUri, + extensionPath: context.extensionPath, + isActive: true, + packageJSON: {}, + exports: undefined, + activate: () => Promise.resolve(), + } + } + return undefined + }, + onDidChange: () => ({ dispose: () => {} }), + }, + // Add file system watcher + FileSystemWatcher: class { + onDidChange = () => ({ dispose: () => {} }) + onDidCreate = () => ({ dispose: () => {} }) + onDidDelete = () => ({ dispose: () => {} }) + dispose = () => {} + }, + // Add relative pattern + RelativePattern: class implements RelativePattern { + constructor( + public base: string, + public pattern: string, + ) {} + }, + // Add progress location + ProgressLocation: { + SourceControl: 1, + Window: 10, + Notification: 15, + }, + // Add URI handler + UriHandler: class implements UriHandler { + handleUri = (_uri: Uri) => {} + }, + } +} diff --git a/packages/vscode-shim/src/classes/Additional.ts b/packages/vscode-shim/src/classes/Additional.ts new file mode 100644 index 0000000000..d300eb1e8c --- /dev/null +++ b/packages/vscode-shim/src/classes/Additional.ts @@ -0,0 +1,181 @@ +/** + * Additional VSCode API classes for extension support + * + * This file contains supplementary classes and types that extensions may need. + */ + +import { Range } from "./Range.js" +import type { IUri, IRange, IPosition, DiagnosticSeverity, DiagnosticTag } from "../types.js" + +/** + * Represents a location in source code (URI + Range or Position) + */ +export class Location { + constructor( + public uri: IUri, + public range: IRange | IPosition, + ) {} +} + +/** + * Related diagnostic information + */ +export class DiagnosticRelatedInformation { + constructor( + public location: Location, + public message: string, + ) {} +} + +/** + * Represents a diagnostic (error, warning, etc.) + */ +export class Diagnostic { + range: Range + message: string + severity: DiagnosticSeverity + source?: string + code?: string | number | { value: string | number; target: IUri } + relatedInformation?: DiagnosticRelatedInformation[] + tags?: DiagnosticTag[] + + constructor(range: IRange, message: string, severity?: DiagnosticSeverity) { + this.range = range as Range + this.message = message + this.severity = severity !== undefined ? severity : 0 // Error + } +} + +/** + * Theme color reference + */ +export class ThemeColor { + constructor(public id: string) {} +} + +/** + * Theme icon reference + */ +export class ThemeIcon { + constructor( + public id: string, + public color?: ThemeColor, + ) {} +} + +/** + * Code action kind for categorizing code actions + */ +export class CodeActionKind { + static readonly Empty = new CodeActionKind("") + static readonly QuickFix = new CodeActionKind("quickfix") + static readonly Refactor = new CodeActionKind("refactor") + static readonly RefactorExtract = new CodeActionKind("refactor.extract") + static readonly RefactorInline = new CodeActionKind("refactor.inline") + static readonly RefactorRewrite = new CodeActionKind("refactor.rewrite") + static readonly Source = new CodeActionKind("source") + static readonly SourceOrganizeImports = new CodeActionKind("source.organizeImports") + + constructor(public value: string) {} + + append(parts: string): CodeActionKind { + return new CodeActionKind(this.value ? `${this.value}.${parts}` : parts) + } + + intersects(other: CodeActionKind): boolean { + return this.contains(other) || other.contains(this) + } + + contains(other: CodeActionKind): boolean { + return this.value === other.value || other.value.startsWith(this.value + ".") + } +} + +/** + * Code lens for displaying inline information + */ +export class CodeLens { + public range: Range + public command?: { command: string; title: string; arguments?: unknown[] } | undefined + public isResolved: boolean = false + + constructor(range: IRange, command?: { command: string; title: string; arguments?: unknown[] } | undefined) { + this.range = range as Range + this.command = command + } +} + +/** + * Language Model API parts + */ +export class LanguageModelTextPart { + constructor(public value: string) {} +} + +export class LanguageModelToolCallPart { + constructor( + public callId: string, + public name: string, + public input: unknown, + ) {} +} + +export class LanguageModelToolResultPart { + constructor( + public callId: string, + public content: unknown[], + ) {} +} + +/** + * File system error with specific error codes + */ +export class FileSystemError extends Error { + public code: string + + constructor(message: string, code: string = "Unknown") { + super(message) + this.name = "FileSystemError" + this.code = code + } + + static FileNotFound(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" ? messageOrUri : `File not found: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "FileNotFound") + } + + static FileExists(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" ? messageOrUri : `File exists: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "FileExists") + } + + static FileNotADirectory(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" + ? messageOrUri + : `File is not a directory: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "FileNotADirectory") + } + + static FileIsADirectory(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" + ? messageOrUri + : `File is a directory: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "FileIsADirectory") + } + + static NoPermissions(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" ? messageOrUri : `No permissions: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "NoPermissions") + } + + static Unavailable(messageOrUri?: string | IUri): FileSystemError { + const message = + typeof messageOrUri === "string" ? messageOrUri : `Unavailable: ${messageOrUri?.fsPath || "unknown"}` + return new FileSystemError(message, "Unavailable") + } +} diff --git a/packages/vscode-shim/src/classes/CancellationToken.ts b/packages/vscode-shim/src/classes/CancellationToken.ts new file mode 100644 index 0000000000..1efcd91e4e --- /dev/null +++ b/packages/vscode-shim/src/classes/CancellationToken.ts @@ -0,0 +1,48 @@ +/** + * CancellationToken and CancellationTokenSource for VSCode API + */ + +import { EventEmitter } from "./EventEmitter.js" +import type { Disposable } from "../interfaces/workspace.js" + +/** + * Cancellation token interface + */ +export interface CancellationToken { + isCancellationRequested: boolean + onCancellationRequested: (listener: (e: unknown) => void) => Disposable +} + +/** + * CancellationTokenSource creates and controls a CancellationToken + */ +export class CancellationTokenSource { + private _token: CancellationToken + private _isCancelled = false + private _onCancellationRequestedEmitter = new EventEmitter() + + constructor() { + this._token = { + isCancellationRequested: false, + onCancellationRequested: this._onCancellationRequestedEmitter.event, + } + } + + get token(): CancellationToken { + return this._token + } + + cancel(): void { + if (!this._isCancelled) { + this._isCancelled = true + // Type assertion needed to modify readonly property + ;(this._token as { isCancellationRequested: boolean }).isCancellationRequested = true + this._onCancellationRequestedEmitter.fire(undefined) + } + } + + dispose(): void { + this.cancel() + this._onCancellationRequestedEmitter.dispose() + } +} diff --git a/packages/vscode-shim/src/classes/EventEmitter.ts b/packages/vscode-shim/src/classes/EventEmitter.ts new file mode 100644 index 0000000000..c561114c00 --- /dev/null +++ b/packages/vscode-shim/src/classes/EventEmitter.ts @@ -0,0 +1,88 @@ +import type { Disposable, Event } from "../types.js" + +/** + * VSCode-compatible EventEmitter implementation + * + * Provides a type-safe event emitter that matches VSCode's EventEmitter API. + * Listeners can subscribe to events and will be notified when events are fired. + * + * @example + * ```typescript + * const emitter = new EventEmitter() + * + * // Subscribe to events + * const disposable = emitter.event((value) => { + * console.log('Event fired:', value) + * }) + * + * // Fire an event + * emitter.fire('Hello, world!') + * + * // Clean up + * disposable.dispose() + * emitter.dispose() + * ``` + */ +export class EventEmitter { + readonly #listeners = new Set<(e: T) => void>() + + /** + * The event that listeners can subscribe to + * + * @param listener - The callback function to invoke when the event fires + * @param thisArgs - Optional 'this' context for the listener + * @param disposables - Optional array to add the disposable to + * @returns A disposable to unsubscribe from the event + */ + event: Event = (listener: (e: T) => void, thisArgs?: unknown, disposables?: Disposable[]): Disposable => { + const fn = thisArgs ? listener.bind(thisArgs) : listener + this.#listeners.add(fn) + + const disposable: Disposable = { + dispose: () => { + this.#listeners.delete(fn) + }, + } + + if (disposables) { + disposables.push(disposable) + } + + return disposable + } + + /** + * Fire the event, notifying all subscribers + * + * Failure of one or more listeners will not fail this function call. + * Failed listeners will be caught and ignored to prevent one listener + * from breaking others. + * + * @param data - The event data to pass to listeners + */ + fire(data: T): void { + for (const listener of this.#listeners) { + try { + listener(data) + } catch (error) { + // Silently ignore listener errors to prevent one failing listener + // from affecting others. Consumers can add error handling in their listeners. + console.error("EventEmitter listener error:", error) + } + } + } + + /** + * Dispose this event emitter and remove all listeners + */ + dispose(): void { + this.#listeners.clear() + } + + /** + * Get the current number of listeners (useful for debugging) + */ + get listenerCount(): number { + return this.#listeners.size + } +} diff --git a/packages/vscode-shim/src/classes/OutputChannel.ts b/packages/vscode-shim/src/classes/OutputChannel.ts new file mode 100644 index 0000000000..f5b6c1e778 --- /dev/null +++ b/packages/vscode-shim/src/classes/OutputChannel.ts @@ -0,0 +1,46 @@ +/** + * OutputChannel class for VSCode API + */ + +import { logs } from "../utils/logger.js" +import type { Disposable } from "../interfaces/workspace.js" + +/** + * Output channel mock for CLI mode + * Logs output to the configured logger instead of VSCode's output panel + */ +export class OutputChannel implements Disposable { + private _name: string + + constructor(name: string) { + this._name = name + } + + get name(): string { + return this._name + } + + append(value: string): void { + logs.info(`[${this._name}] ${value}`, "VSCode.OutputChannel") + } + + appendLine(value: string): void { + logs.info(`[${this._name}] ${value}`, "VSCode.OutputChannel") + } + + clear(): void { + // No-op for CLI + } + + show(): void { + // No-op for CLI + } + + hide(): void { + // No-op for CLI + } + + dispose(): void { + // No-op for CLI + } +} diff --git a/packages/vscode-shim/src/classes/Position.ts b/packages/vscode-shim/src/classes/Position.ts new file mode 100644 index 0000000000..729381d126 --- /dev/null +++ b/packages/vscode-shim/src/classes/Position.ts @@ -0,0 +1,148 @@ +import type { IPosition } from "../types.js" + +/** + * Represents a position in a text document + * + * A position is defined by a zero-based line number and a zero-based character offset. + * This class is immutable - all methods that modify the position return a new instance. + * + * @example + * ```typescript + * const pos = new Position(5, 10) // Line 5, character 10 + * const next = pos.translate(1, 0) // Line 6, character 10 + * ``` + */ +export class Position implements IPosition { + /** + * The zero-based line number + */ + public readonly line: number + + /** + * The zero-based character offset + */ + public readonly character: number + + /** + * Create a new Position + * + * @param line - The zero-based line number + * @param character - The zero-based character offset + */ + constructor(line: number, character: number) { + if (line < 0) { + throw new Error("Line number must be non-negative") + } + if (character < 0) { + throw new Error("Character offset must be non-negative") + } + this.line = line + this.character = character + } + + /** + * Check if this position is equal to another position + */ + isEqual(other: IPosition): boolean { + return this.line === other.line && this.character === other.character + } + + /** + * Check if this position is before another position + */ + isBefore(other: IPosition): boolean { + if (this.line < other.line) { + return true + } + if (this.line === other.line) { + return this.character < other.character + } + return false + } + + /** + * Check if this position is before or equal to another position + */ + isBeforeOrEqual(other: IPosition): boolean { + return this.isBefore(other) || this.isEqual(other) + } + + /** + * Check if this position is after another position + */ + isAfter(other: IPosition): boolean { + return !this.isBeforeOrEqual(other) + } + + /** + * Check if this position is after or equal to another position + */ + isAfterOrEqual(other: IPosition): boolean { + return !this.isBefore(other) + } + + /** + * Compare this position to another + * + * @returns -1 if this position is before, 0 if equal, 1 if after + */ + compareTo(other: IPosition): number { + if (this.line < other.line) { + return -1 + } + if (this.line > other.line) { + return 1 + } + if (this.character < other.character) { + return -1 + } + if (this.character > other.character) { + return 1 + } + return 0 + } + + /** + * Create a new position relative to this position + * + * @param lineDelta - The line delta (default: 0) + * @param characterDelta - The character delta (default: 0) + * @returns A new Position + */ + translate(lineDelta?: number, characterDelta?: number): Position + translate(change: { lineDelta?: number; characterDelta?: number }): Position + translate( + lineDeltaOrChange?: number | { lineDelta?: number; characterDelta?: number }, + characterDelta?: number, + ): Position { + if (typeof lineDeltaOrChange === "object") { + return new Position( + this.line + (lineDeltaOrChange.lineDelta || 0), + this.character + (lineDeltaOrChange.characterDelta || 0), + ) + } + return new Position(this.line + (lineDeltaOrChange || 0), this.character + (characterDelta || 0)) + } + + /** + * Create a new position with changed line or character + * + * @param line - The new line number (or undefined to keep current) + * @param character - The new character offset (or undefined to keep current) + * @returns A new Position + */ + with(line?: number, character?: number): Position + with(change: { line?: number; character?: number }): Position + with(lineOrChange?: number | { line?: number; character?: number }, character?: number): Position { + if (typeof lineOrChange === "object") { + return new Position( + lineOrChange.line !== undefined ? lineOrChange.line : this.line, + lineOrChange.character !== undefined ? lineOrChange.character : this.character, + ) + } + return new Position( + lineOrChange !== undefined ? lineOrChange : this.line, + character !== undefined ? character : this.character, + ) + } +} diff --git a/packages/vscode-shim/src/classes/Range.ts b/packages/vscode-shim/src/classes/Range.ts new file mode 100644 index 0000000000..35a3afcb3b --- /dev/null +++ b/packages/vscode-shim/src/classes/Range.ts @@ -0,0 +1,137 @@ +import { Position } from "./Position.js" +import type { IRange, IPosition } from "../types.js" + +/** + * Represents a range in a text document + * + * A range is defined by two positions: a start and an end position. + * This class is immutable - all methods that modify the range return a new instance. + * + * @example + * ```typescript + * // Create a range from line 0 to line 5 + * const range = new Range( + * new Position(0, 0), + * new Position(5, 10) + * ) + * + * // Or use the overload with line/character numbers + * const range2 = new Range(0, 0, 5, 10) + * ``` + */ +export class Range implements IRange { + public readonly start: Position + public readonly end: Position + + /** + * Create a new Range + * + * @param start - The start position + * @param end - The end position + */ + constructor(start: IPosition, end: IPosition) + /** + * Create a new Range from line and character numbers + * + * @param startLine - The start line number + * @param startCharacter - The start character offset + * @param endLine - The end line number + * @param endCharacter - The end character offset + */ + constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number) + constructor( + startOrStartLine: IPosition | number, + endOrStartCharacter: IPosition | number, + endLine?: number, + endCharacter?: number, + ) { + if (typeof startOrStartLine === "number") { + this.start = new Position(startOrStartLine, endOrStartCharacter as number) + this.end = new Position(endLine!, endCharacter!) + } else { + this.start = startOrStartLine as Position + this.end = endOrStartCharacter as Position + } + } + + /** + * Check if this range is empty (start equals end) + */ + get isEmpty(): boolean { + return this.start.isEqual(this.end) + } + + /** + * Check if this range is on a single line + */ + get isSingleLine(): boolean { + return this.start.line === this.end.line + } + + /** + * Check if this range contains a position or range + * + * @param positionOrRange - The position or range to check + * @returns true if the position/range is within this range + */ + contains(positionOrRange: IPosition | IRange): boolean { + if ("start" in positionOrRange && "end" in positionOrRange) { + // It's a range + return this.contains(positionOrRange.start) && this.contains(positionOrRange.end) + } + // It's a position + return positionOrRange.isAfterOrEqual(this.start) && positionOrRange.isBeforeOrEqual(this.end) + } + + /** + * Check if this range is equal to another range + */ + isEqual(other: IRange): boolean { + return this.start.isEqual(other.start) && this.end.isEqual(other.end) + } + + /** + * Get the intersection of this range with another range + * + * @param other - The other range + * @returns The intersection range, or undefined if they don't intersect + */ + intersection(other: IRange): Range | undefined { + const start = this.start.isAfter(other.start) ? this.start : other.start + const end = this.end.isBefore(other.end) ? this.end : other.end + if (start.isAfter(end)) { + return undefined + } + return new Range(start, end) + } + + /** + * Get the union of this range with another range + * + * @param other - The other range + * @returns A new range that spans both ranges + */ + union(other: IRange): Range { + const start = this.start.isBefore(other.start) ? this.start : other.start + const end = this.end.isAfter(other.end) ? this.end : other.end + return new Range(start, end) + } + + /** + * Create a new range with modified start or end positions + * + * @param start - The new start position (or undefined to keep current) + * @param end - The new end position (or undefined to keep current) + * @returns A new Range + */ + with(start?: IPosition, end?: IPosition): Range + with(change: { start?: IPosition; end?: IPosition }): Range + with(startOrChange?: IPosition | { start?: IPosition; end?: IPosition }, end?: IPosition): Range { + // Check if it's a change object (has start or end property, but not line/character like a Position) + if (startOrChange && typeof startOrChange === "object" && !("line" in startOrChange)) { + const change = startOrChange as { start?: IPosition; end?: IPosition } + return new Range(change.start || this.start, change.end || this.end) + } + return new Range((startOrChange as IPosition) || this.start, end || this.end) + } +} diff --git a/packages/vscode-shim/src/classes/Selection.ts b/packages/vscode-shim/src/classes/Selection.ts new file mode 100644 index 0000000000..10fcc9969e --- /dev/null +++ b/packages/vscode-shim/src/classes/Selection.ts @@ -0,0 +1,79 @@ +import { Range } from "./Range.js" +import { Position } from "./Position.js" +import type { ISelection, IPosition } from "../types.js" + +/** + * Represents a text selection in an editor + * + * A selection extends Range with anchor and active positions. + * The anchor is where the selection starts, and the active is where it ends. + * The selection can be reversed if the active position is before the anchor. + * + * @example + * ```typescript + * // Create a selection from position 0,0 to 5,10 + * const selection = new Selection( + * new Position(0, 0), + * new Position(5, 10) + * ) + * + * console.log(selection.isReversed) // false + * ``` + */ +export class Selection extends Range implements ISelection { + /** + * The anchor position (where the selection started) + */ + public readonly anchor: Position + + /** + * The active position (where the selection currently ends) + */ + public readonly active: Position + + /** + * Create a new Selection + * + * @param anchor - The anchor position + * @param active - The active position + */ + constructor(anchor: IPosition, active: IPosition) + /** + * Create a new Selection from line and character numbers + * + * @param anchorLine - The anchor line number + * @param anchorCharacter - The anchor character offset + * @param activeLine - The active line number + * @param activeCharacter - The active character offset + */ + constructor(anchorLine: number, anchorCharacter: number, activeLine: number, activeCharacter: number) + constructor( + anchorOrAnchorLine: IPosition | number, + activeOrAnchorCharacter: IPosition | number, + activeLine?: number, + activeCharacter?: number, + ) { + let anchor: Position + let active: Position + + if (typeof anchorOrAnchorLine === "number") { + anchor = new Position(anchorOrAnchorLine, activeOrAnchorCharacter as number) + active = new Position(activeLine!, activeCharacter!) + } else { + anchor = anchorOrAnchorLine as Position + active = activeOrAnchorCharacter as Position + } + + super(anchor, active) + this.anchor = anchor + this.active = active + } + + /** + * Check if the selection is reversed + * A reversed selection has the active position before the anchor position + */ + get isReversed(): boolean { + return this.anchor.isAfter(this.active) + } +} diff --git a/packages/vscode-shim/src/classes/StatusBarItem.ts b/packages/vscode-shim/src/classes/StatusBarItem.ts new file mode 100644 index 0000000000..bde8f860d6 --- /dev/null +++ b/packages/vscode-shim/src/classes/StatusBarItem.ts @@ -0,0 +1,79 @@ +/** + * StatusBarItem class for VSCode API + */ + +import { StatusBarAlignment } from "../types.js" +import type { Disposable } from "../interfaces/workspace.js" + +/** + * Status bar item mock for CLI mode + */ +export class StatusBarItem implements Disposable { + private _text: string = "" + private _tooltip: string | undefined + private _command: string | undefined + private _color: string | undefined + private _backgroundColor: string | undefined + private _isVisible: boolean = false + + constructor( + public readonly alignment: StatusBarAlignment, + public readonly priority?: number, + ) {} + + get text(): string { + return this._text + } + + set text(value: string) { + this._text = value + } + + get tooltip(): string | undefined { + return this._tooltip + } + + set tooltip(value: string | undefined) { + this._tooltip = value + } + + get command(): string | undefined { + return this._command + } + + set command(value: string | undefined) { + this._command = value + } + + get color(): string | undefined { + return this._color + } + + set color(value: string | undefined) { + this._color = value + } + + get backgroundColor(): string | undefined { + return this._backgroundColor + } + + set backgroundColor(value: string | undefined) { + this._backgroundColor = value + } + + get isVisible(): boolean { + return this._isVisible + } + + show(): void { + this._isVisible = true + } + + hide(): void { + this._isVisible = false + } + + dispose(): void { + this._isVisible = false + } +} diff --git a/packages/vscode-shim/src/classes/TextEdit.ts b/packages/vscode-shim/src/classes/TextEdit.ts new file mode 100644 index 0000000000..503f4d224f --- /dev/null +++ b/packages/vscode-shim/src/classes/TextEdit.ts @@ -0,0 +1,209 @@ +import { Position } from "./Position.js" +import { Range } from "./Range.js" +import type { IRange, IPosition } from "../types.js" + +/** + * Represents a text edit operation + * + * A text edit replaces text in a specific range with new text. + * This is used to modify documents programmatically. + * + * @example + * ```typescript + * // Replace text in a range + * const edit = TextEdit.replace( + * new Range(0, 0, 0, 5), + * 'Hello' + * ) + * + * // Insert text at a position + * const insert = TextEdit.insert( + * new Position(0, 0), + * 'New text' + * ) + * + * // Delete text in a range + * const deletion = TextEdit.delete( + * new Range(0, 0, 0, 10) + * ) + * ``` + */ +export class TextEdit { + /** + * The range to replace + */ + public readonly range: Range + + /** + * The new text (empty string for deletion) + */ + public readonly newText: string + + /** + * Create a new TextEdit + * + * @param range - The range to replace + * @param newText - The new text + */ + constructor(range: IRange, newText: string) { + this.range = range as Range + this.newText = newText + } + + /** + * Create a replace edit + * + * @param range - The range to replace + * @param newText - The new text + * @returns A new TextEdit + */ + static replace(range: IRange, newText: string): TextEdit { + return new TextEdit(range, newText) + } + + /** + * Create an insert edit + * + * @param position - The position to insert at + * @param newText - The text to insert + * @returns A new TextEdit + */ + static insert(position: IPosition, newText: string): TextEdit { + return new TextEdit(new Range(position, position), newText) + } + + /** + * Create a delete edit + * + * @param range - The range to delete + * @returns A new TextEdit + */ + static delete(range: IRange): TextEdit { + return new TextEdit(range, "") + } + + /** + * Create an edit to set the end of line sequence + * + * @returns A new TextEdit (simplified implementation) + */ + static setEndOfLine(): TextEdit { + return new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "") + } +} + +/** + * Represents a collection of text edits for a document + * + * A WorkspaceEdit can contain edits for multiple documents. + * + * @example + * ```typescript + * const edit = new WorkspaceEdit() + * + * // Add edits for a file + * edit.set(uri, [ + * TextEdit.replace(range1, 'new text'), + * TextEdit.insert(pos, 'inserted') + * ]) + * + * // Apply the edit + * await vscode.workspace.applyEdit(edit) + * ``` + */ +export class WorkspaceEdit { + private _edits: Map = new Map() + + /** + * Set edits for a specific URI + * + * @param uri - The document URI + * @param edits - Array of text edits + */ + set(uri: { toString(): string }, edits: TextEdit[]): void { + this._edits.set(uri.toString(), edits) + } + + /** + * Get edits for a specific URI + * + * @param uri - The document URI + * @returns Array of text edits, or empty array if none + */ + get(uri: { toString(): string }): TextEdit[] { + return this._edits.get(uri.toString()) || [] + } + + /** + * Check if edits exist for a URI + * + * @param uri - The document URI + * @returns true if edits exist + */ + has(uri: { toString(): string }): boolean { + return this._edits.has(uri.toString()) + } + + /** + * Add a delete edit for a range + * + * @param uri - The document URI + * @param range - The range to delete + */ + delete(uri: { toString(): string }, range: IRange): void { + const key = uri.toString() + if (!this._edits.has(key)) { + this._edits.set(key, []) + } + this._edits.get(key)!.push(TextEdit.delete(range)) + } + + /** + * Add an insert edit + * + * @param uri - The document URI + * @param position - The position to insert at + * @param newText - The text to insert + */ + insert(uri: { toString(): string }, position: IPosition, newText: string): void { + const key = uri.toString() + if (!this._edits.has(key)) { + this._edits.set(key, []) + } + this._edits.get(key)!.push(TextEdit.insert(position, newText)) + } + + /** + * Add a replace edit + * + * @param uri - The document URI + * @param range - The range to replace + * @param newText - The new text + */ + replace(uri: { toString(): string }, range: IRange, newText: string): void { + const key = uri.toString() + if (!this._edits.has(key)) { + this._edits.set(key, []) + } + this._edits.get(key)!.push(TextEdit.replace(range, newText)) + } + + /** + * Get the number of documents with edits + */ + get size(): number { + return this._edits.size + } + + /** + * Get all URI and edits pairs + * + * @returns Array of [URI, TextEdit[]] pairs + */ + entries(): [{ toString(): string; fsPath: string }, TextEdit[]][] { + return Array.from(this._edits.entries()).map(([uriString, edits]) => { + // Parse the URI string back to a URI-like object + return [{ toString: () => uriString, fsPath: uriString.replace(/^file:\/\//, "") }, edits] + }) + } +} diff --git a/packages/vscode-shim/src/classes/TextEditorDecorationType.ts b/packages/vscode-shim/src/classes/TextEditorDecorationType.ts new file mode 100644 index 0000000000..0e59c67acf --- /dev/null +++ b/packages/vscode-shim/src/classes/TextEditorDecorationType.ts @@ -0,0 +1,20 @@ +/** + * TextEditorDecorationType class for VSCode API + */ + +import type { Disposable } from "../interfaces/workspace.js" + +/** + * Text editor decoration type mock for CLI mode + */ +export class TextEditorDecorationType implements Disposable { + public key: string + + constructor(key: string) { + this.key = key + } + + dispose(): void { + // No-op for CLI + } +} diff --git a/packages/vscode-shim/src/classes/Uri.ts b/packages/vscode-shim/src/classes/Uri.ts new file mode 100644 index 0000000000..7ee7c5dc68 --- /dev/null +++ b/packages/vscode-shim/src/classes/Uri.ts @@ -0,0 +1,124 @@ +import * as path from "path" + +/** + * Uniform Resource Identifier (URI) implementation + * + * Represents a URI following the RFC 3986 standard. + * This class is compatible with VSCode's Uri class and provides + * file system path handling for cross-platform compatibility. + * + * @example + * ```typescript + * // Create a file URI + * const fileUri = Uri.file('/path/to/file.txt') + * console.log(fileUri.fsPath) // '/path/to/file.txt' + * + * // Parse a URI string + * const uri = Uri.parse('https://example.com/path?query=1#fragment') + * console.log(uri.scheme) // 'https' + * console.log(uri.path) // '/path' + * ``` + */ +export class Uri { + public readonly scheme: string + public readonly authority: string + public readonly path: string + public readonly query: string + public readonly fragment: string + + constructor(scheme: string, authority: string, path: string, query: string, fragment: string) { + this.scheme = scheme + this.authority = authority + this.path = path + this.query = query + this.fragment = fragment + } + + /** + * Create a URI from a file system path + * + * @param path - The file system path + * @returns A new Uri instance with 'file' scheme + */ + static file(fsPath: string): Uri { + return new Uri("file", "", fsPath, "", "") + } + + /** + * Parse a URI string + * + * @param value - The URI string to parse + * @returns A new Uri instance + */ + static parse(value: string): Uri { + try { + const url = new URL(value) + return new Uri( + url.protocol.slice(0, -1), + url.hostname, + url.pathname, + url.search.slice(1), + url.hash.slice(1), + ) + } catch { + // If URL parsing fails, treat as file path + return Uri.file(value) + } + } + + /** + * Join a URI with path segments + * + * @param base - The base URI + * @param pathSegments - Path segments to join + * @returns A new Uri with the joined path + */ + static joinPath(base: Uri, ...pathSegments: string[]): Uri { + const joinedPath = path.join(base.path, ...pathSegments) + return new Uri(base.scheme, base.authority, joinedPath, base.query, base.fragment) + } + + /** + * Create a new URI with modifications + * + * @param change - The changes to apply + * @returns A new Uri instance with the changes applied + */ + with(change: { scheme?: string; authority?: string; path?: string; query?: string; fragment?: string }): Uri { + return new Uri( + change.scheme !== undefined ? change.scheme : this.scheme, + change.authority !== undefined ? change.authority : this.authority, + change.path !== undefined ? change.path : this.path, + change.query !== undefined ? change.query : this.query, + change.fragment !== undefined ? change.fragment : this.fragment, + ) + } + + /** + * Get the file system path representation + * Compatible with both Unix and Windows paths + */ + get fsPath(): string { + return this.path + } + + /** + * Convert the URI to a string representation + */ + toString(): string { + return `${this.scheme}://${this.authority}${this.path}${this.query ? "?" + this.query : ""}${this.fragment ? "#" + this.fragment : ""}` + } + + /** + * Convert to JSON representation + */ + toJSON(): object { + return { + scheme: this.scheme, + authority: this.authority, + path: this.path, + query: this.query, + fragment: this.fragment, + } + } +} diff --git a/packages/vscode-shim/src/context/ExtensionContext.ts b/packages/vscode-shim/src/context/ExtensionContext.ts new file mode 100644 index 0000000000..324478bf34 --- /dev/null +++ b/packages/vscode-shim/src/context/ExtensionContext.ts @@ -0,0 +1,158 @@ +import * as path from "path" +import * as fs from "fs" +import { Uri } from "../classes/Uri.js" +import { FileMemento } from "../storage/Memento.js" +import { FileSecretStorage } from "../storage/SecretStorage.js" +import { hashWorkspacePath, ensureDirectoryExists } from "../utils/paths.js" +import type { + ExtensionContext, + Extension, + Disposable, + Memento, + SecretStorage, + ExtensionMode, + ExtensionKind, +} from "../types.js" + +/** + * Options for creating an ExtensionContext + */ +export interface ExtensionContextOptions { + /** + * Path to the extension's root directory + */ + extensionPath: string + + /** + * Path to the workspace directory + */ + workspacePath: string + + /** + * Optional custom storage directory (defaults to ~/.vscode-mock) + */ + storageDir?: string + + /** + * Extension mode (Production, Development, or Test) + */ + extensionMode?: ExtensionMode +} + +/** + * Implementation of VSCode's ExtensionContext + * + * Provides the context object passed to extension activation functions. + * This includes state storage, secrets, and extension metadata. + * + * @example + * ```typescript + * const context = new ExtensionContextImpl({ + * extensionPath: '/path/to/extension', + * workspacePath: '/path/to/workspace' + * }) + * + * // Use in extension activation + * const api = await extension.activate(context) + * ``` + */ +export class ExtensionContextImpl implements ExtensionContext { + public subscriptions: Disposable[] = [] + public workspaceState: Memento + public globalState: Memento & { setKeysForSync(keys: readonly string[]): void } + public secrets: SecretStorage + public extensionUri: Uri + public extensionPath: string + public environmentVariableCollection: Record = {} + public storageUri: Uri | undefined + public storagePath: string | undefined + public globalStorageUri: Uri + public globalStoragePath: string + public logUri: Uri + public logPath: string + public extensionMode: ExtensionMode + public extension: Extension | undefined + + constructor(options: ExtensionContextOptions) { + this.extensionPath = options.extensionPath + this.extensionUri = Uri.file(options.extensionPath) + this.extensionMode = options.extensionMode || 1 // Default to Production + + // Setup storage paths + const baseStorageDir = + options.storageDir || path.join(process.env.HOME || process.env.USERPROFILE || ".", ".vscode-mock") + const workspaceHash = hashWorkspacePath(options.workspacePath) + + this.globalStoragePath = path.join(baseStorageDir, "global-storage") + this.globalStorageUri = Uri.file(this.globalStoragePath) + + const workspaceStoragePath = path.join(baseStorageDir, "workspace-storage", workspaceHash) + this.storagePath = workspaceStoragePath + this.storageUri = Uri.file(workspaceStoragePath) + + this.logPath = path.join(baseStorageDir, "logs") + this.logUri = Uri.file(this.logPath) + + // Ensure directories exist + ensureDirectoryExists(this.globalStoragePath) + ensureDirectoryExists(workspaceStoragePath) + ensureDirectoryExists(this.logPath) + + // Initialize state storage + this.workspaceState = new FileMemento(path.join(workspaceStoragePath, "workspace-state.json")) + + const globalMemento = new FileMemento(path.join(this.globalStoragePath, "global-state.json")) + this.globalState = Object.assign(globalMemento, { + setKeysForSync: (_keys: readonly string[]) => { + // No-op for mock implementation + }, + }) + + this.secrets = new FileSecretStorage(this.globalStoragePath) + + // Load extension metadata (packageJSON) + this.extension = this.loadExtensionMetadata() + } + + /** + * Load extension metadata from package.json + */ + private loadExtensionMetadata(): Extension | undefined { + try { + // Try to load package.json from extension path + const packageJsonPath = path.join(this.extensionPath, "package.json") + if (fs.existsSync(packageJsonPath)) { + const packageJSON = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) + const extensionId = `${packageJSON.publisher || "unknown"}.${packageJSON.name || "unknown"}` + + return { + id: extensionId, + extensionUri: this.extensionUri, + extensionPath: this.extensionPath, + isActive: true, + packageJSON, + exports: undefined, + extensionKind: 1 as ExtensionKind, // UI + activate: () => Promise.resolve(undefined), + } + } + } catch { + // Ignore errors loading package.json + } + return undefined + } + + /** + * Dispose all subscriptions + */ + dispose(): void { + for (const subscription of this.subscriptions) { + try { + subscription.dispose() + } catch (error) { + console.error("Error disposing subscription:", error) + } + } + this.subscriptions = [] + } +} diff --git a/packages/vscode-shim/src/index.ts b/packages/vscode-shim/src/index.ts new file mode 100644 index 0000000000..02c1b2f2b8 --- /dev/null +++ b/packages/vscode-shim/src/index.ts @@ -0,0 +1,112 @@ +/** + * @roo-code/vscode-shim + * + * A production-ready VSCode API mock for running VSCode extensions in Node.js CLI applications. + * This package provides a complete implementation of the VSCode Extension API, allowing you to + * run VSCode extensions without VSCode installed. + * + * @packageDocumentation + */ + +// Export the complete VSCode API implementation +export { + // Main factory function + createVSCodeAPIMock, + + // Classes + Uri, + Position, + Range, + Selection, + EventEmitter, + Location, + Diagnostic, + DiagnosticRelatedInformation, + TextEdit, + WorkspaceEdit, + ThemeColor, + ThemeIcon, + CodeActionKind, + CancellationTokenSource, + CodeLens, + LanguageModelTextPart, + LanguageModelToolCallPart, + LanguageModelToolResultPart, + FileSystemError, + OutputChannel, + StatusBarItem, + TextEditorDecorationType, + ExtensionContext, + + // API classes + WorkspaceAPI, + WindowAPI, + CommandsAPI, + TabGroupsAPI, + FileSystemAPI, + MockWorkspaceConfiguration, + + // Runtime configuration utilities + setRuntimeConfig, + setRuntimeConfigValues, + clearRuntimeConfig, + getRuntimeConfig, + + // Enums + ConfigurationTarget, + ViewColumn, + TextEditorRevealType, + StatusBarAlignment, + DiagnosticSeverity, + DiagnosticTag, + EndOfLine, + UIKind, + ExtensionMode, + ExtensionKind, + FileType, + DecorationRangeBehavior, + OverviewRulerLane, + + // Types + type IdentityInfo, + type Thenable, + type Disposable, + type TextDocument, + type TextLine, + type WorkspaceFolder, + type WorkspaceConfiguration, + type Memento, + type SecretStorage, + type FileStat, + type Terminal, + type CancellationToken, +} from "./vscode.js" + +// Export utilities +export { logs, setLogger, type Logger } from "./utils/logger.js" +export { VSCodeMockPaths } from "./utils/paths.js" +export { machineIdSync } from "./utils/machine-id.js" + +// Re-export as createVSCodeAPI for simpler API +export { createVSCodeAPIMock as createVSCodeAPI } from "./vscode.js" + +/** + * Quick start function to create a complete VSCode API mock + * + * @example + * ```typescript + * import { createVSCodeAPI } from '@roo-code/vscode-shim' + * + * const vscode = createVSCodeAPI({ + * extensionPath: '/path/to/extension', + * workspacePath: '/path/to/workspace' + * }) + * + * // Set global vscode for extension to use + * global.vscode = vscode + * + * // Load and activate extension + * const extension = require('/path/to/extension.js') + * const api = await extension.activate(vscode.context) + * ``` + */ diff --git a/packages/vscode-shim/src/interfaces/document.ts b/packages/vscode-shim/src/interfaces/document.ts new file mode 100644 index 0000000000..0b1ec0eb7d --- /dev/null +++ b/packages/vscode-shim/src/interfaces/document.ts @@ -0,0 +1,114 @@ +/** + * Document-related interfaces for VSCode API + */ + +import type { Range } from "../classes/Range.js" +import type { Position } from "../classes/Position.js" +import type { Uri } from "../classes/Uri.js" +import type { Thenable, Disposable } from "../types.js" + +/** + * Represents a text document in VSCode + */ +export interface TextDocument { + uri: Uri + fileName: string + languageId: string + version: number + isDirty: boolean + isClosed: boolean + lineCount: number + getText(range?: Range): string + lineAt(line: number): TextLine + offsetAt(position: Position): number + positionAt(offset: number): Position + save(): Thenable + validateRange(range: Range): Range + validatePosition(position: Position): Position +} + +/** + * Represents a line of text in a document + */ +export interface TextLine { + text: string + range: Range + rangeIncludingLineBreak: Range + firstNonWhitespaceCharacterIndex: number + isEmptyOrWhitespace: boolean +} + +/** + * Event fired when workspace folders change + */ +export interface WorkspaceFoldersChangeEvent { + added: WorkspaceFolder[] + removed: WorkspaceFolder[] +} + +/** + * Represents a workspace folder + */ +export interface WorkspaceFolder { + uri: Uri + name: string + index: number +} + +/** + * Event fired when a text document changes + */ +export interface TextDocumentChangeEvent { + document: TextDocument + contentChanges: readonly TextDocumentContentChangeEvent[] +} + +/** + * Represents a change in a text document + */ +export interface TextDocumentContentChangeEvent { + range: Range + rangeOffset: number + rangeLength: number + text: string +} + +/** + * Event fired when configuration changes + */ +export interface ConfigurationChangeEvent { + affectsConfiguration(section: string, scope?: Uri): boolean +} + +/** + * Provider for text document content + */ +export interface TextDocumentContentProvider { + provideTextDocumentContent(uri: Uri, token: CancellationToken): Thenable + onDidChange?: (listener: (e: Uri) => void) => Disposable +} + +/** + * Cancellation token interface (must be local to avoid conflict with ES2023 built-in) + */ +export interface CancellationToken { + isCancellationRequested: boolean + onCancellationRequested: (listener: (e: unknown) => void) => Disposable +} + +/** + * File system watcher interface + */ +export interface FileSystemWatcher extends Disposable { + onDidChange: (listener: (e: Uri) => void) => Disposable + onDidCreate: (listener: (e: Uri) => void) => Disposable + onDidDelete: (listener: (e: Uri) => void) => Disposable +} + +/** + * Relative pattern for file matching + */ +export interface RelativePattern { + base: string + pattern: string +} diff --git a/packages/vscode-shim/src/interfaces/editor.ts b/packages/vscode-shim/src/interfaces/editor.ts new file mode 100644 index 0000000000..c1a288abe2 --- /dev/null +++ b/packages/vscode-shim/src/interfaces/editor.ts @@ -0,0 +1,107 @@ +/** + * Editor-related interfaces for VSCode API + */ + +import type { Range } from "../classes/Range.js" +import type { Position } from "../classes/Position.js" +import type { Selection } from "../classes/Selection.js" +import type { Uri } from "../classes/Uri.js" +import type { ThemeColor } from "../classes/Additional.js" +import type { + Thenable, + ViewColumn, + TextEditorRevealType, + EndOfLine, + DecorationRangeBehavior, + OverviewRulerLane, + TextEditorOptions, +} from "../types.js" +import type { TextDocument } from "./document.js" +import type { Disposable } from "../types.js" + +/** + * Represents a text editor in VSCode + */ +export interface TextEditor { + document: TextDocument + selection: Selection + selections: Selection[] + visibleRanges: Range[] + options: TextEditorOptions + viewColumn?: ViewColumn + edit(callback: (editBuilder: TextEditorEdit) => void): Thenable + insertSnippet( + snippet: unknown, + location?: Position | Range | readonly Position[] | readonly Range[], + ): Thenable + setDecorations(decorationType: TextEditorDecorationType, rangesOrOptions: readonly Range[]): void + revealRange(range: Range, revealType?: TextEditorRevealType): void + show(column?: ViewColumn): void + hide(): void +} + +/** + * Builder for text editor edits + */ +export interface TextEditorEdit { + replace(location: Position | Range | Selection, value: string): void + insert(location: Position, value: string): void + delete(location: Range | Selection): void + setEndOfLine(endOfLine: EndOfLine): void +} + +/** + * Event fired when text editor selection changes + */ +export interface TextEditorSelectionChangeEvent { + textEditor: TextEditor + selections: readonly Selection[] + kind?: number +} + +/** + * Options for showing a text document + */ +export interface TextDocumentShowOptions { + viewColumn?: ViewColumn + preserveFocus?: boolean + preview?: boolean + selection?: Range +} + +/** + * Options for rendering decorations + */ +export interface DecorationRenderOptions { + backgroundColor?: string | ThemeColor + border?: string + borderColor?: string | ThemeColor + borderRadius?: string + borderSpacing?: string + borderStyle?: string + borderWidth?: string + color?: string | ThemeColor + cursor?: string + fontStyle?: string + fontWeight?: string + gutterIconPath?: string | Uri + gutterIconSize?: string + isWholeLine?: boolean + letterSpacing?: string + opacity?: string + outline?: string + outlineColor?: string | ThemeColor + outlineStyle?: string + outlineWidth?: string + overviewRulerColor?: string | ThemeColor + overviewRulerLane?: OverviewRulerLane + rangeBehavior?: DecorationRangeBehavior + textDecoration?: string +} + +/** + * Text editor decoration type interface + */ +export interface TextEditorDecorationType extends Disposable { + key: string +} diff --git a/packages/vscode-shim/src/interfaces/terminal.ts b/packages/vscode-shim/src/interfaces/terminal.ts new file mode 100644 index 0000000000..343d0177d3 --- /dev/null +++ b/packages/vscode-shim/src/interfaces/terminal.ts @@ -0,0 +1,76 @@ +/** + * Terminal-related interfaces for VSCode API + */ + +import type { Uri } from "../classes/Uri.js" +import type { ThemeIcon } from "../classes/Additional.js" +import type { Thenable } from "../types.js" + +/** + * Represents a terminal in VSCode + */ +export interface Terminal { + name: string + processId: Thenable + creationOptions: Readonly + exitStatus: TerminalExitStatus | undefined + state: TerminalState + sendText(text: string, addNewLine?: boolean): void + show(preserveFocus?: boolean): void + hide(): void + dispose(): void +} + +/** + * Options for creating a terminal + */ +export interface TerminalOptions { + name?: string + shellPath?: string + shellArgs?: string[] | string + cwd?: string | Uri + env?: { [key: string]: string | null | undefined } + iconPath?: Uri | ThemeIcon + hideFromUser?: boolean + message?: string + strictEnv?: boolean +} + +/** + * Exit status of a terminal + */ +export interface TerminalExitStatus { + code: number | undefined + reason: number +} + +/** + * State of a terminal + */ +export interface TerminalState { + isInteractedWith: boolean +} + +/** + * Event fired when terminal dimensions change + */ +export interface TerminalDimensionsChangeEvent { + terminal: Terminal + dimensions: TerminalDimensions +} + +/** + * Terminal dimensions + */ +export interface TerminalDimensions { + columns: number + rows: number +} + +/** + * Event fired when data is written to terminal + */ +export interface TerminalDataWriteEvent { + terminal: Terminal + data: string +} diff --git a/packages/vscode-shim/src/interfaces/webview.ts b/packages/vscode-shim/src/interfaces/webview.ts new file mode 100644 index 0000000000..c69d3a10e5 --- /dev/null +++ b/packages/vscode-shim/src/interfaces/webview.ts @@ -0,0 +1,92 @@ +/** + * Webview-related interfaces for VSCode API + */ + +import type { Uri } from "../classes/Uri.js" +import type { Thenable, Disposable } from "../types.js" +import type { CancellationToken } from "./document.js" + +/** + * Webview view provider interface + */ +export interface WebviewViewProvider { + resolveWebviewView( + webviewView: WebviewView, + context: WebviewViewResolveContext, + token: CancellationToken, + ): Thenable | void +} + +/** + * Webview view interface + */ +export interface WebviewView { + webview: Webview + viewType: string + title?: string + description?: string + badge?: ViewBadge + show(preserveFocus?: boolean): void + onDidChangeVisibility: (listener: () => void) => Disposable + onDidDispose: (listener: () => void) => Disposable + visible: boolean +} + +/** + * Webview interface + */ +export interface Webview { + html: string + options: WebviewOptions + cspSource: string + postMessage(message: unknown): Thenable + onDidReceiveMessage: (listener: (message: unknown) => void) => Disposable + asWebviewUri(localResource: Uri): Uri +} + +/** + * Webview options interface + */ +export interface WebviewOptions { + enableScripts?: boolean + enableForms?: boolean + localResourceRoots?: readonly Uri[] + portMapping?: readonly WebviewPortMapping[] +} + +/** + * Webview port mapping interface + */ +export interface WebviewPortMapping { + webviewPort: number + extensionHostPort: number +} + +/** + * View badge interface + */ +export interface ViewBadge { + tooltip: string + value: number +} + +/** + * Webview view resolve context + */ +export interface WebviewViewResolveContext { + state?: unknown +} + +/** + * Webview view provider options + */ +export interface WebviewViewProviderOptions { + retainContextWhenHidden?: boolean +} + +/** + * URI handler interface + */ +export interface UriHandler { + handleUri(uri: Uri): void +} diff --git a/packages/vscode-shim/src/interfaces/workspace.ts b/packages/vscode-shim/src/interfaces/workspace.ts new file mode 100644 index 0000000000..5271420ae0 --- /dev/null +++ b/packages/vscode-shim/src/interfaces/workspace.ts @@ -0,0 +1,91 @@ +/** + * Workspace-related interfaces for VSCode API + */ + +import type { Uri } from "../classes/Uri.js" +import type { Thenable, ConfigurationTarget, ConfigurationInspect } from "../types.js" + +/** + * Workspace configuration interface + */ +export interface WorkspaceConfiguration { + get(section: string): T | undefined + get(section: string, defaultValue: T): T + has(section: string): boolean + inspect(section: string): ConfigurationInspect | undefined + update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Thenable +} + +/** + * Quick pick options interface + */ +export interface QuickPickOptions { + placeHolder?: string + canPickMany?: boolean + ignoreFocusOut?: boolean + matchOnDescription?: boolean + matchOnDetail?: boolean +} + +/** + * Input box options interface + */ +export interface InputBoxOptions { + value?: string + valueSelection?: [number, number] + prompt?: string + placeHolder?: string + password?: boolean + ignoreFocusOut?: boolean + validateInput?(value: string): string | undefined | null | Thenable +} + +/** + * Open dialog options interface + */ +export interface OpenDialogOptions { + defaultUri?: Uri + openLabel?: string + canSelectFiles?: boolean + canSelectFolders?: boolean + canSelectMany?: boolean + filters?: { [name: string]: string[] } + title?: string +} + +/** + * Disposable interface for VSCode API (must be local to avoid conflict with ES2023 built-in Disposable) + */ +export interface Disposable { + dispose(): void +} + +/** + * Diagnostic collection interface + */ +export interface DiagnosticCollection extends Disposable { + name: string + set(uri: Uri, diagnostics: import("../classes/Additional.js").Diagnostic[] | undefined): void + set(entries: [Uri, import("../classes/Additional.js").Diagnostic[] | undefined][]): void + delete(uri: Uri): void + clear(): void + forEach( + callback: ( + uri: Uri, + diagnostics: import("../classes/Additional.js").Diagnostic[], + collection: DiagnosticCollection, + ) => void, + thisArg?: unknown, + ): void + get(uri: Uri): import("../classes/Additional.js").Diagnostic[] | undefined + has(uri: Uri): boolean +} + +/** + * Identity information for VSCode environment + */ +export interface IdentityInfo { + machineId: string + sessionId: string + cliUserId?: string +} diff --git a/packages/vscode-shim/src/storage/Memento.ts b/packages/vscode-shim/src/storage/Memento.ts new file mode 100644 index 0000000000..5c26d12c5c --- /dev/null +++ b/packages/vscode-shim/src/storage/Memento.ts @@ -0,0 +1,115 @@ +import * as fs from "fs" +import * as path from "path" +import { ensureDirectoryExists } from "../utils/paths.js" +import type { Memento } from "../types.js" + +/** + * File-based implementation of VSCode's Memento interface + * + * Provides persistent key-value storage backed by a JSON file. + * This implementation automatically loads from and saves to disk. + * + * @example + * ```typescript + * const memento = new FileMemento('/path/to/state.json') + * + * // Store a value + * await memento.update('lastOpenFile', '/path/to/file.txt') + * + * // Retrieve a value + * const file = memento.get('lastOpenFile') + * + * // With default value + * const count = memento.get('count', 0) + * ``` + */ +export class FileMemento implements Memento { + private data: Record = {} + private filePath: string + + /** + * Create a new FileMemento + * + * @param filePath - Path to the JSON file for persistence + */ + constructor(filePath: string) { + this.filePath = filePath + this.loadFromFile() + } + + /** + * Load data from the JSON file + */ + private loadFromFile(): void { + try { + if (fs.existsSync(this.filePath)) { + const content = fs.readFileSync(this.filePath, "utf-8") + this.data = JSON.parse(content) + } + } catch (error) { + console.warn(`Failed to load state from ${this.filePath}:`, error) + this.data = {} + } + } + + /** + * Save data to the JSON file + */ + private saveToFile(): void { + try { + // Ensure directory exists + const dir = path.dirname(this.filePath) + ensureDirectoryExists(dir) + fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2)) + } catch (error) { + console.warn(`Failed to save state to ${this.filePath}:`, error) + } + } + + /** + * Get a value from storage + * + * @param key - The key to retrieve + * @param defaultValue - Optional default value if key doesn't exist + * @returns The stored value or default value + */ + get(key: string): T | undefined + get(key: string, defaultValue: T): T + get(key: string, defaultValue?: T): T | undefined { + const value = this.data[key] + return value !== undefined && value !== null ? (value as T) : defaultValue + } + + /** + * Update a value in storage + * + * @param key - The key to update + * @param value - The value to store (undefined to delete) + * @returns A promise that resolves when the update is complete + */ + async update(key: string, value: unknown): Promise { + if (value === undefined) { + delete this.data[key] + } else { + this.data[key] = value + } + this.saveToFile() + } + + /** + * Get all keys in storage + * + * @returns An array of all keys + */ + keys(): readonly string[] { + return Object.keys(this.data) + } + + /** + * Clear all data from storage + */ + clear(): void { + this.data = {} + this.saveToFile() + } +} diff --git a/packages/vscode-shim/src/storage/SecretStorage.ts b/packages/vscode-shim/src/storage/SecretStorage.ts new file mode 100644 index 0000000000..372a33f403 --- /dev/null +++ b/packages/vscode-shim/src/storage/SecretStorage.ts @@ -0,0 +1,138 @@ +import * as fs from "fs" +import * as path from "path" +import { EventEmitter } from "../classes/EventEmitter.js" +import { ensureDirectoryExists } from "../utils/paths.js" +import type { SecretStorage, SecretStorageChangeEvent } from "../types.js" + +/** + * File-based implementation of VSCode's SecretStorage interface + * + * Stores secrets in a JSON file on disk. While not encrypted like VSCode's + * native keychain integration, this provides a simple, cross-platform solution + * suitable for CLI applications. + * + * **Security Notes:** + * - Secrets are stored as plain JSON (not encrypted) + * - File permissions should be set restrictive (0600) + * - For production, consider using environment variables instead + * - Suitable for development and non-critical secrets + * + * @example + * ```typescript + * const storage = new FileSecretStorage('/path/to/secrets.json') + * + * // Store a secret + * await storage.store('apiKey', 'sk-...') + * + * // Retrieve a secret + * const key = await storage.get('apiKey') + * + * // Listen for changes + * storage.onDidChange((e) => { + * console.log(`Secret ${e.key} changed`) + * }) + * ``` + */ +export class FileSecretStorage implements SecretStorage { + private secrets: Record = {} + private _onDidChange = new EventEmitter() + private filePath: string + + /** + * Create a new FileSecretStorage + * + * @param storagePath - Directory path where secrets.json will be stored + */ + constructor(storagePath: string) { + this.filePath = path.join(storagePath, "secrets.json") + this.loadFromFile() + } + + /** + * Load secrets from the JSON file + */ + private loadFromFile(): void { + try { + if (fs.existsSync(this.filePath)) { + const content = fs.readFileSync(this.filePath, "utf-8") + this.secrets = JSON.parse(content) + } + } catch (error) { + console.warn(`Failed to load secrets from ${this.filePath}:`, error) + this.secrets = {} + } + } + + /** + * Save secrets to the JSON file with restrictive permissions + */ + private saveToFile(): void { + try { + // Ensure directory exists + const dir = path.dirname(this.filePath) + ensureDirectoryExists(dir) + + // Write the file + fs.writeFileSync(this.filePath, JSON.stringify(this.secrets, null, 2)) + + // Set restrictive permissions (owner read/write only) on Unix-like systems + if (process.platform !== "win32") { + try { + fs.chmodSync(this.filePath, 0o600) + } catch { + // Ignore chmod errors (might not be supported on some filesystems) + } + } + } catch (error) { + console.warn(`Failed to save secrets to ${this.filePath}:`, error) + } + } + + /** + * Retrieve a secret by key + * + * @param key - The secret key + * @returns The secret value or undefined if not found + */ + async get(key: string): Promise { + return this.secrets[key] + } + + /** + * Store a secret + * + * @param key - The secret key + * @param value - The secret value + */ + async store(key: string, value: string): Promise { + this.secrets[key] = value + this.saveToFile() + this._onDidChange.fire({ key }) + } + + /** + * Delete a secret + * + * @param key - The secret key to delete + */ + async delete(key: string): Promise { + delete this.secrets[key] + this.saveToFile() + this._onDidChange.fire({ key }) + } + + /** + * Event fired when a secret changes + */ + get onDidChange() { + return this._onDidChange.event + } + + /** + * Clear all secrets (useful for testing) + */ + clearAll(): void { + this.secrets = {} + this.saveToFile() + } +} diff --git a/packages/vscode-shim/src/types.ts b/packages/vscode-shim/src/types.ts new file mode 100644 index 0000000000..d21a99c43d --- /dev/null +++ b/packages/vscode-shim/src/types.ts @@ -0,0 +1,344 @@ +/** + * Core VSCode API type definitions + * + * This file contains TypeScript type definitions that match the VSCode Extension API. + * These types allow VSCode extensions to run in Node.js without VSCode installed. + */ + +/** + * Represents a thenable (Promise-like) value + */ +export type Thenable = Promise + +/** + * Represents a disposable resource that can be cleaned up + */ +export interface Disposable { + dispose(): void +} + +/** + * Represents a Uniform Resource Identifier (URI) + */ +export interface IUri { + scheme: string + authority: string + path: string + query: string + fragment: string + fsPath: string + toString(): string +} + +/** + * Represents a position in a text document (line and character) + */ +export interface IPosition { + line: number + character: number + isEqual(other: IPosition): boolean + isBefore(other: IPosition): boolean + isBeforeOrEqual(other: IPosition): boolean + isAfter(other: IPosition): boolean + isAfterOrEqual(other: IPosition): boolean + compareTo(other: IPosition): number +} + +/** + * Represents a range in a text document (start and end positions) + */ +export interface IRange { + start: IPosition + end: IPosition + isEmpty: boolean + isSingleLine: boolean + contains(positionOrRange: IPosition | IRange): boolean + isEqual(other: IRange): boolean + intersection(other: IRange): IRange | undefined + union(other: IRange): IRange +} + +/** + * Represents a selection in a text editor (extends Range with anchor and active positions) + */ +export interface ISelection extends IRange { + anchor: IPosition + active: IPosition + isReversed: boolean +} + +/** + * Represents a line of text in a document + */ +export interface TextLine { + text: string + range: IRange + rangeIncludingLineBreak: IRange + firstNonWhitespaceCharacterIndex: number + isEmptyOrWhitespace: boolean +} + +/** + * Represents a text document + */ +export interface TextDocument { + uri: IUri + fileName: string + languageId: string + version: number + isDirty: boolean + isClosed: boolean + lineCount: number + getText(range?: IRange): string + lineAt(line: number): TextLine + offsetAt(position: IPosition): number + positionAt(offset: number): IPosition + save(): Thenable + validateRange(range: IRange): IRange + validatePosition(position: IPosition): IPosition +} + +/** + * Configuration target for settings + */ +export enum ConfigurationTarget { + Global = 1, + Workspace = 2, + WorkspaceFolder = 3, +} + +/** + * Workspace folder representation + */ +export interface WorkspaceFolder { + uri: IUri + name: string + index: number +} + +/** + * Workspace configuration interface + */ +export interface WorkspaceConfiguration { + get(section: string): T | undefined + get(section: string, defaultValue: T): T + has(section: string): boolean + inspect(section: string): ConfigurationInspect | undefined + update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Thenable +} + +/** + * Configuration inspection result + */ +export interface ConfigurationInspect { + key: string + defaultValue?: T + globalValue?: T + workspaceValue?: T + workspaceFolderValue?: T +} + +/** + * Memento (state storage) interface + */ +export interface Memento { + get(key: string): T | undefined + get(key: string, defaultValue: T): T + update(key: string, value: unknown): Thenable + keys(): readonly string[] +} + +/** + * Secret storage interface for secure credential storage + */ +export interface SecretStorage { + get(key: string): Thenable + store(key: string, value: string): Thenable + delete(key: string): Thenable + onDidChange: Event +} + +/** + * Secret storage change event + */ +export interface SecretStorageChangeEvent { + key: string +} + +/** + * Represents an extension + */ +export interface Extension { + id: string + extensionUri: IUri + extensionPath: string + isActive: boolean + packageJSON: Record + exports: T + extensionKind: ExtensionKind + activate(): Thenable +} + +/** + * Extension kind enum + */ +export enum ExtensionKind { + UI = 1, + Workspace = 2, +} + +/** + * Extension context provided to extension activation + */ +export interface ExtensionContext { + subscriptions: Disposable[] + workspaceState: Memento + globalState: Memento & { setKeysForSync(keys: readonly string[]): void } + secrets: SecretStorage + extensionUri: IUri + extensionPath: string + environmentVariableCollection: Record + storageUri: IUri | undefined + storagePath: string | undefined + globalStorageUri: IUri + globalStoragePath: string + logUri: IUri + logPath: string + extensionMode: ExtensionMode + extension: Extension | undefined +} + +/** + * Extension mode enum + */ +export enum ExtensionMode { + Production = 1, + Development = 2, + Test = 3, +} + +/** + * Event emitter event type + */ +export type Event = (listener: (e: T) => void, thisArgs?: unknown, disposables?: Disposable[]) => Disposable + +/** + * Cancellation token for async operations + */ +export interface CancellationToken { + isCancellationRequested: boolean + onCancellationRequested: Event +} + +/** + * File system file type enum + */ +export enum FileType { + Unknown = 0, + File = 1, + Directory = 2, + SymbolicLink = 64, +} + +/** + * File system stat information + */ +export interface FileStat { + type: FileType + ctime: number + mtime: number + size: number +} + +/** + * Text editor options + */ +export interface TextEditorOptions { + tabSize?: number + insertSpaces?: boolean + cursorStyle?: number + lineNumbers?: number +} + +/** + * View column enum for editor placement + */ +export enum ViewColumn { + Active = -1, + Beside = -2, + One = 1, + Two = 2, + Three = 3, +} + +/** + * UI Kind enum + */ +export enum UIKind { + Desktop = 1, + Web = 2, +} + +/** + * End of line sequence enum + */ +export enum EndOfLine { + LF = 1, + CRLF = 2, +} + +/** + * Status bar alignment + */ +export enum StatusBarAlignment { + Left = 1, + Right = 2, +} + +/** + * Diagnostic severity levels + */ +export enum DiagnosticSeverity { + Error = 0, + Warning = 1, + Information = 2, + Hint = 3, +} + +/** + * Diagnostic tags + */ +export enum DiagnosticTag { + Unnecessary = 1, + Deprecated = 2, +} + +/** + * Overview ruler lane + */ +export enum OverviewRulerLane { + Left = 1, + Center = 2, + Right = 4, + Full = 7, +} + +/** + * Decoration range behavior + */ +export enum DecorationRangeBehavior { + OpenOpen = 0, + ClosedClosed = 1, + OpenClosed = 2, + ClosedOpen = 3, +} + +/** + * Text editor reveal type + */ +export enum TextEditorRevealType { + Default = 0, + InCenter = 1, + InCenterIfOutsideViewport = 2, + AtTop = 3, +} diff --git a/packages/vscode-shim/src/utils/logger.ts b/packages/vscode-shim/src/utils/logger.ts new file mode 100644 index 0000000000..5d8d387e55 --- /dev/null +++ b/packages/vscode-shim/src/utils/logger.ts @@ -0,0 +1,52 @@ +/** + * Simple logger stub for VSCode mock + * Users can provide their own logger by calling setLogger() + */ + +export interface Logger { + info(message: string, context?: string, meta?: unknown): void + warn(message: string, context?: string, meta?: unknown): void + error(message: string, context?: string, meta?: unknown): void + debug(message: string, context?: string, meta?: unknown): void +} + +class ConsoleLogger implements Logger { + info(message: string, context?: string, _meta?: unknown): void { + console.log(`[${context || "INFO"}] ${message}`) + } + + warn(message: string, context?: string, _meta?: unknown): void { + console.warn(`[${context || "WARN"}] ${message}`) + } + + error(message: string, context?: string, _meta?: unknown): void { + console.error(`[${context || "ERROR"}] ${message}`) + } + + debug(message: string, context?: string, _meta?: unknown): void { + if (process.env.DEBUG) { + console.debug(`[${context || "DEBUG"}] ${message}`) + } + } +} + +let logger: Logger = new ConsoleLogger() + +/** + * Set a custom logger + * + * @param customLogger - Your logger implementation + */ +export function setLogger(customLogger: Logger): void { + logger = customLogger +} + +/** + * Get the current logger + */ +export const logs = { + info: (message: string, context?: string, meta?: unknown) => logger.info(message, context, meta), + warn: (message: string, context?: string, meta?: unknown) => logger.warn(message, context, meta), + error: (message: string, context?: string, meta?: unknown) => logger.error(message, context, meta), + debug: (message: string, context?: string, meta?: unknown) => logger.debug(message, context, meta), +} diff --git a/packages/vscode-shim/src/utils/machine-id.ts b/packages/vscode-shim/src/utils/machine-id.ts new file mode 100644 index 0000000000..744d7d138a --- /dev/null +++ b/packages/vscode-shim/src/utils/machine-id.ts @@ -0,0 +1,44 @@ +/** + * Machine ID generation + * Simple implementation to replace node-machine-id dependency + */ + +import * as fs from "fs" +import * as path from "path" +import * as crypto from "crypto" +import * as os from "os" +import { ensureDirectoryExists } from "./paths.js" + +/** + * Get or create a unique machine ID + * Stores in ~/.vscode-mock/.machine-id for persistence + */ +export function machineIdSync(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || "." + const idPath = path.join(homeDir, ".vscode-mock", ".machine-id") + + // Try to read existing ID + try { + if (fs.existsSync(idPath)) { + return fs.readFileSync(idPath, "utf-8").trim() + } + } catch { + // Fall through to generate new ID + } + + // Generate new ID based on hostname and random data + const hostname = os.hostname() + const randomData = crypto.randomBytes(16).toString("hex") + const machineId = crypto.createHash("sha256").update(`${hostname}-${randomData}`).digest("hex") + + // Save for future use + try { + const dir = path.dirname(idPath) + ensureDirectoryExists(dir) + fs.writeFileSync(idPath, machineId) + } catch { + // Ignore save errors + } + + return machineId +} diff --git a/packages/vscode-shim/src/utils/paths.ts b/packages/vscode-shim/src/utils/paths.ts new file mode 100644 index 0000000000..948c25429e --- /dev/null +++ b/packages/vscode-shim/src/utils/paths.ts @@ -0,0 +1,89 @@ +/** + * Path utilities for VSCode mock storage + */ + +import * as fs from "fs" +import * as path from "path" + +const STORAGE_BASE_DIR = ".vscode-mock" + +/** + * Get the base storage directory + */ +function getBaseStorageDir(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || "." + return path.join(homeDir, STORAGE_BASE_DIR) +} + +/** + * Hash a workspace path to create a unique directory name + * + * @param workspacePath - The workspace path to hash + * @returns A hexadecimal hash string + */ +export function hashWorkspacePath(workspacePath: string): string { + let hash = 0 + for (let i = 0; i < workspacePath.length; i++) { + const char = workspacePath.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32-bit integer + } + return Math.abs(hash).toString(16) +} + +/** + * Ensure a directory exists, creating it if necessary + * + * @param dirPath - The directory path to ensure exists + */ +export function ensureDirectoryExists(dirPath: string): void { + try { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }) + } + } catch (error) { + console.warn(`Failed to create directory ${dirPath}:`, error) + } +} + +/** + * Initialize workspace directories + */ +export function initializeWorkspace(workspacePath: string): void { + const dirs = [getGlobalStorageDir(), getWorkspaceStorageDir(workspacePath), getLogsDir()] + + for (const dir of dirs) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + } +} + +/** + * Get global storage directory + */ +export function getGlobalStorageDir(): string { + return path.join(getBaseStorageDir(), "global-storage") +} + +/** + * Get workspace-specific storage directory + */ +export function getWorkspaceStorageDir(workspacePath: string): string { + const hash = hashWorkspacePath(workspacePath) + return path.join(getBaseStorageDir(), "workspace-storage", hash) +} + +/** + * Get logs directory + */ +export function getLogsDir(): string { + return path.join(getBaseStorageDir(), "logs") +} + +export const VSCodeMockPaths = { + initializeWorkspace, + getGlobalStorageDir, + getWorkspaceStorageDir, + getLogsDir, +} diff --git a/packages/vscode-shim/src/vscode.ts b/packages/vscode-shim/src/vscode.ts new file mode 100644 index 0000000000..27dbedc770 --- /dev/null +++ b/packages/vscode-shim/src/vscode.ts @@ -0,0 +1,153 @@ +/** + * VSCode API Mock - Barrel Export File + * + * This file re-exports all components from the modular files for backwards compatibility. + * All imports from this file will continue to work as before. + */ + +// ============================================================================ +// Classes from ./classes/ +// ============================================================================ +export { Position } from "./classes/Position.js" +export { Range } from "./classes/Range.js" +export { Selection } from "./classes/Selection.js" +export { Uri } from "./classes/Uri.js" +export { EventEmitter } from "./classes/EventEmitter.js" +export { TextEdit, WorkspaceEdit } from "./classes/TextEdit.js" +export { + Location, + Diagnostic, + DiagnosticRelatedInformation, + ThemeColor, + ThemeIcon, + CodeActionKind, + CodeLens, + LanguageModelTextPart, + LanguageModelToolCallPart, + LanguageModelToolResultPart, + FileSystemError, +} from "./classes/Additional.js" +export { CancellationTokenSource, type CancellationToken } from "./classes/CancellationToken.js" +export { OutputChannel } from "./classes/OutputChannel.js" +export { StatusBarItem } from "./classes/StatusBarItem.js" +export { TextEditorDecorationType } from "./classes/TextEditorDecorationType.js" + +// ============================================================================ +// Context +// ============================================================================ +export { ExtensionContextImpl as ExtensionContext } from "./context/ExtensionContext.js" + +// ============================================================================ +// API Classes from ./api/ +// ============================================================================ +export { FileSystemAPI } from "./api/FileSystemAPI.js" +export { + MockWorkspaceConfiguration, + setRuntimeConfig, + setRuntimeConfigValues, + clearRuntimeConfig, + getRuntimeConfig, +} from "./api/WorkspaceConfiguration.js" +export { WorkspaceAPI } from "./api/WorkspaceAPI.js" +export { TabGroupsAPI, type Tab, type TabInputText, type TabGroup } from "./api/TabGroupsAPI.js" +export { WindowAPI } from "./api/WindowAPI.js" +export { CommandsAPI } from "./api/CommandsAPI.js" +export { createVSCodeAPIMock } from "./api/create-vscode-api-mock.js" + +// ============================================================================ +// Enums from ./types.ts +// ============================================================================ +export { + ConfigurationTarget, + ViewColumn, + TextEditorRevealType, + StatusBarAlignment, + DiagnosticSeverity, + DiagnosticTag, + EndOfLine, + UIKind, + ExtensionMode, + ExtensionKind, + FileType, + DecorationRangeBehavior, + OverviewRulerLane, +} from "./types.js" + +// ============================================================================ +// Types from ./types.ts +// ============================================================================ +export type { Thenable, Memento, FileStat, TextEditorOptions, ConfigurationInspect } from "./types.js" + +// ============================================================================ +// Interfaces from ./interfaces/ +// ============================================================================ + +// Document interfaces +export type { + TextDocument, + TextLine, + WorkspaceFoldersChangeEvent, + WorkspaceFolder, + TextDocumentChangeEvent, + TextDocumentContentChangeEvent, + ConfigurationChangeEvent, + TextDocumentContentProvider, + FileSystemWatcher, + RelativePattern, +} from "./interfaces/document.js" + +// Editor interfaces +export type { + TextEditor, + TextEditorEdit, + TextEditorSelectionChangeEvent, + TextDocumentShowOptions, + DecorationRenderOptions, +} from "./interfaces/editor.js" + +// Terminal interfaces +export type { + Terminal, + TerminalOptions, + TerminalExitStatus, + TerminalState, + TerminalDimensionsChangeEvent, + TerminalDimensions, + TerminalDataWriteEvent, +} from "./interfaces/terminal.js" + +// Webview interfaces +export type { + WebviewViewProvider, + WebviewView, + Webview, + WebviewOptions, + WebviewPortMapping, + ViewBadge, + WebviewViewResolveContext, + WebviewViewProviderOptions, + UriHandler, +} from "./interfaces/webview.js" + +// Workspace interfaces +export type { + WorkspaceConfiguration, + QuickPickOptions, + InputBoxOptions, + OpenDialogOptions, + Disposable, + DiagnosticCollection, + IdentityInfo, +} from "./interfaces/workspace.js" + +// ============================================================================ +// Secret Storage interface (backwards compatibility) +// ============================================================================ +export interface SecretStorage { + get(key: string): Thenable + store(key: string, value: string): Thenable + delete(key: string): Thenable +} + +// Import Thenable for SecretStorage interface +import type { Thenable } from "./types.js" diff --git a/packages/vscode-shim/tsconfig.json b/packages/vscode-shim/tsconfig.json new file mode 100644 index 0000000000..2a73ee92bb --- /dev/null +++ b/packages/vscode-shim/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@roo-code/config-typescript/base.json", + "compilerOptions": { + "types": ["vitest/globals"], + "outDir": "dist" + }, + "include": ["src", "scripts", "*.config.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/vscode-shim/vitest.config.ts b/packages/vscode-shim/vitest.config.ts new file mode 100644 index 0000000000..b6d6dbb880 --- /dev/null +++ b/packages/vscode-shim/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globals: true, + environment: "node", + watch: false, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 672e2fc0c9..5d33257cb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,7 @@ importers: version: 2.29.8(@types/node@24.10.4) '@dotenvx/dotenvx': specifier: ^1.34.0 - version: 1.51.2 + version: 1.51.4 '@roo-code/config-typescript': specifier: workspace:^ version: link:packages/config-typescript @@ -50,7 +50,7 @@ importers: version: 9.1.7 knip: specifier: ^5.44.4 - version: 5.76.3(@types/node@24.10.4)(typescript@5.8.3) + version: 5.80.0(@types/node@24.10.4)(typescript@5.8.3) lint-staged: specifier: ^16.0.0 version: 16.2.7 @@ -74,11 +74,48 @@ importers: version: 4.21.0 turbo: specifier: ^2.5.6 - version: 2.7.1 + version: 2.7.3 typescript: specifier: ^5.4.5 version: 5.8.3 + apps/cli: + dependencies: + '@roo-code/types': + specifier: workspace:^ + version: link:../../packages/types + '@roo-code/vscode-shim': + specifier: workspace:^ + version: link:../../packages/vscode-shim + '@vscode/ripgrep': + specifier: ^1.15.9 + version: 1.17.0 + commander: + specifier: ^12.1.0 + version: 12.1.0 + devDependencies: + '@roo-code/config-eslint': + specifier: workspace:^ + version: link:../../packages/config-eslint + '@roo-code/config-typescript': + specifier: workspace:^ + version: link:../../packages/config-typescript + '@types/node': + specifier: ^24.1.0 + version: 24.10.4 + rimraf: + specifier: ^6.0.1 + version: 6.1.2 + tsup: + specifier: ^8.4.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.8.2) + typescript: + specifier: 5.8.3 + version: 5.8.3 + vitest: + specifier: ^3.2.3 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + apps/vscode-e2e: devDependencies: '@roo-code/config-eslint': @@ -128,7 +165,7 @@ importers: dependencies: '@hookform/resolvers': specifier: ^5.1.1 - version: 5.2.2(react-hook-form@7.69.0(react@18.3.1)) + version: 5.2.2(react-hook-form@7.70.0(react@18.3.1)) '@radix-ui/react-alert-dialog': specifier: ^1.1.7 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -176,7 +213,7 @@ importers: version: link:../../packages/types '@tanstack/react-query': specifier: ^5.69.0 - version: 5.90.12(react@18.3.1) + version: 5.90.16(react@18.3.1) archiver: specifier: ^7.0.1 version: 7.0.1 @@ -212,7 +249,7 @@ importers: version: 18.3.1(react@18.3.1) react-hook-form: specifier: ^7.57.0 - version: 7.69.0(react@18.3.1) + version: 7.70.0(react@18.3.1) react-use: specifier: ^17.6.0 version: 17.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -279,7 +316,7 @@ importers: version: link:../../packages/types '@tanstack/react-query': specifier: ^5.79.0 - version: 5.90.12(react@18.3.1) + version: 5.90.16(react@18.3.1) '@vercel/og': specifier: ^0.6.2 version: 0.6.8 @@ -312,7 +349,7 @@ importers: version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) posthog-js: specifier: ^1.248.1 - version: 1.309.1 + version: 1.315.0 react: specifier: ^18.3.1 version: 18.3.1 @@ -407,7 +444,7 @@ importers: version: link:../types ioredis: specifier: ^5.6.1 - version: 5.8.2 + version: 5.9.0 jwt-decode: specifier: ^4.0.0 version: 4.0.0 @@ -416,7 +453,7 @@ importers: version: 5.0.2 socket.io-client: specifier: ^4.8.1 - version: 4.8.1 + version: 4.8.3 zod: specifier: ^3.25.76 version: 3.25.76 @@ -465,13 +502,13 @@ importers: version: 5.2.0(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-turbo: specifier: ^2.4.4 - version: 2.7.1(eslint@9.39.2(jiti@2.6.1))(turbo@2.7.1) + version: 2.7.3(eslint@9.39.2(jiti@2.6.1))(turbo@2.7.3) globals: specifier: ^16.0.0 version: 16.5.0 typescript-eslint: specifier: ^8.26.0 - version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + version: 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) packages/config-typescript: {} @@ -488,7 +525,7 @@ importers: version: 9.6.1 openai: specifier: ^5.12.2 - version: 5.23.2(ws@8.18.3)(zod@3.25.76) + version: 5.23.2(ws@8.19.0)(zod@3.25.76) zod: specifier: ^3.25.61 version: 3.25.76 @@ -519,7 +556,7 @@ importers: version: 0.13.0 drizzle-orm: specifier: ^0.44.1 - version: 0.44.7(postgres@3.4.7) + version: 0.44.7(postgres@3.4.8) execa: specifier: ^9.6.0 version: 9.6.1 @@ -537,7 +574,7 @@ importers: version: 5.0.2 postgres: specifier: ^3.4.7 - version: 3.4.7 + version: 3.4.8 ps-tree: specifier: ^1.2.0 version: 1.2.0 @@ -605,7 +642,7 @@ importers: version: link:../types posthog-node: specifier: ^5.0.0 - version: 5.17.4 + version: 5.19.0 uuid: specifier: ^11.1.0 version: 11.1.0 @@ -654,6 +691,21 @@ importers: specifier: ^3.2.3 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + packages/vscode-shim: + devDependencies: + '@roo-code/config-eslint': + specifier: workspace:^ + version: link:../config-eslint + '@roo-code/config-typescript': + specifier: workspace:^ + version: link:../config-typescript + '@types/node': + specifier: ^24.1.0 + version: 24.10.4 + vitest: + specifier: ^3.2.3 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + src: dependencies: '@anthropic-ai/bedrock-sdk': @@ -667,13 +719,13 @@ importers: version: 0.7.0 '@aws-sdk/client-bedrock-runtime': specifier: ^3.922.0 - version: 3.956.0 + version: 3.964.0 '@aws-sdk/credential-providers': specifier: ^3.922.0 - version: 3.956.0 + version: 3.964.0 '@google/genai': specifier: ^1.29.1 - version: 1.34.0(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.76)) + version: 1.34.0(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@3.25.76)) '@lmstudio/sdk': specifier: ^1.1.1 version: 1.5.0 @@ -682,7 +734,7 @@ importers: version: 1.11.0 '@modelcontextprotocol/sdk': specifier: ^1.13.3 - version: 1.25.1(hono@4.11.1)(zod@3.25.76) + version: 1.25.1(hono@4.11.3)(zod@3.25.76) '@qdrant/js-client-rest': specifier: ^1.14.0 version: 1.16.2(typescript@5.8.3) @@ -761,6 +813,9 @@ importers: get-port: specifier: ^7.1.0 version: 7.1.0 + global-agent: + specifier: ^3.0.0 + version: 3.0.0 google-auth-library: specifier: ^9.15.1 version: 9.15.1 @@ -808,7 +863,7 @@ importers: version: 0.5.18 openai: specifier: ^5.12.2 - version: 5.23.2(ws@8.18.3)(zod@3.25.76) + version: 5.23.2(ws@8.19.0)(zod@3.25.76) os-locale: specifier: ^6.0.2 version: 6.0.2 @@ -874,7 +929,7 @@ importers: version: 3.30.0 socket.io-client: specifier: ^4.8.1 - version: 4.8.1 + version: 4.8.3 sound-play: specifier: ^1.1.0 version: 1.1.0 @@ -902,6 +957,9 @@ importers: turndown: specifier: ^7.2.0 version: 7.2.2 + undici: + specifier: '>=5.29.0' + version: 7.18.2 uri-js: specifier: ^4.4.1 version: 4.4.1 @@ -1091,7 +1149,7 @@ importers: version: 4.1.18(vite@6.3.6(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/react-query': specifier: ^5.68.0 - version: 5.90.12(react@18.3.1) + version: 5.90.16(react@18.3.1) '@types/qrcode': specifier: ^1.5.5 version: 1.5.6 @@ -1157,7 +1215,7 @@ importers: version: 11.12.2 posthog-js: specifier: ^1.227.2 - version: 1.309.1 + version: 1.315.0 pretty-bytes: specifier: ^7.0.0 version: 7.1.0 @@ -1190,7 +1248,7 @@ importers: version: 17.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-virtuoso: specifier: ^4.14.1 - version: 4.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rehype-highlight: specifier: ^7.0.0 version: 7.0.2 @@ -1229,7 +1287,7 @@ importers: version: 2.0.2 styled-components: specifier: ^6.1.13 - version: 6.1.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwind-merge: specifier: ^3.0.0 version: 3.4.0 @@ -1375,123 +1433,123 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-bedrock-runtime@3.956.0': - resolution: {integrity: sha512-8cD53FBKn7uvc4QZtYZr87KcuSrEFMwS/O3HC+Y7+disgePlTXxtOo0naCj5O1yVZPuU3FCVLGzxFk5fhbzAJg==} + '@aws-sdk/client-bedrock-runtime@3.964.0': + resolution: {integrity: sha512-V/unuTXxnFPe2gFg3vG1pRDcrr3c/7//5uNbDR9XqXlboD5lcvTdFg6ISF1BuPUhcxbzDTFSg460Kvf+x3rS2A==} engines: {node: '>=18.0.0'} - '@aws-sdk/client-cognito-identity@3.956.0': - resolution: {integrity: sha512-aFRUb4BY0iAACVFnLdreULiO5ox1jds5TovncPlUogN5TjVA04i+hmfShj9l5Ho1sFa1WKc35tngiRmpQIJSJg==} + '@aws-sdk/client-cognito-identity@3.964.0': + resolution: {integrity: sha512-a3/hoL31S1fAd3jIPqDvF8P5Aybev+73K3d4zaa8lEBt5M6eeBpebakcJxsu42RTGCb26+3OpHs3VG4bitr5gw==} engines: {node: '>=18.0.0'} - '@aws-sdk/client-sso@3.956.0': - resolution: {integrity: sha512-TCxCa9B1IMILvk/7sig0fRQzff+M2zBQVZGWOJL8SAZq/gfElIMAf/nYjQwMhXjyq8PFDRGm4GN8ZhNKPeNleQ==} + '@aws-sdk/client-sso@3.964.0': + resolution: {integrity: sha512-IenVyY8Io2CwBgmS22xk/H5LibmSbvLnPA9oFqLORO6Ji1Ks8z/ow+ud/ZurVjFekz3LD/uxVFX3ZKGo6N7Byw==} engines: {node: '>=18.0.0'} - '@aws-sdk/core@3.956.0': - resolution: {integrity: sha512-BMOCXZNz5z4cR3/SaNHUfeoZQUG/y39bLscdLUgg3RL6mDOhuINIqMc0qc6G3kpwDTLVdXikF4nmx2UrRK9y5A==} + '@aws-sdk/core@3.964.0': + resolution: {integrity: sha512-1gIfbt0KRxI8am1UYFcIxQ5QKb22JyN3k52sxyrKXJYC8Knn/rTUAZbYti45CfETe5PLadInGvWqClwGRlZKNg==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-cognito-identity@3.956.0': - resolution: {integrity: sha512-sqH7k7Uvbvc5V0Y8tB8CZoCd5KEuH5g30+wbrGac9s1D+TXXN6g8KnJhyYrHfwa1rJiY7B1mK80ENjG4LlUr0g==} + '@aws-sdk/credential-provider-cognito-identity@3.964.0': + resolution: {integrity: sha512-R2/BOTifFhKPDNuKeLiQkOjgusqVffNy6mchbZMR0C4ucHZ5cWfSyJhn7pXIU6+/aMs/NbkuSttvxGy/fbyJ6g==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-env@3.956.0': - resolution: {integrity: sha512-aLJavJMPVTvhmggJ0pcdCKEWJk3sL9QkJkUIEoTzOou7HnxWS66N4sC5e8y27AF2nlnYfIxq3hkEiZlGi/vlfA==} + '@aws-sdk/credential-provider-env@3.964.0': + resolution: {integrity: sha512-jWNSXOOBMYuxzI2rXi8x91YL07dhomyGzzh0CdaLej0LRmknmDrZcZNkVpa7Fredy1PFcmOlokwCS5PmZMN8ZQ==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-http@3.956.0': - resolution: {integrity: sha512-VsKzBNhwT6XJdW3HQX6o4KOHj1MAzSwA8/zCsT9mOGecozw1yeCcQPtlWDSlfsfygKVCXz7fiJzU03yl11NKMA==} + '@aws-sdk/credential-provider-http@3.964.0': + resolution: {integrity: sha512-up7dl6vcaoXuYSwGXDvx8RnF8Lwj3jGChhyUR7krZOXLarIfUUN3ILOZnVNK5s/HnVNkEILlkdPvjhr9LVC1/Q==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-ini@3.956.0': - resolution: {integrity: sha512-TlDy+IGr0JIRBwnPdV31J1kWXEcfsR3OzcNVWQrguQdHeTw2lU5eft16kdizo6OruqcZRF/LvHBDwAWx4u51ww==} + '@aws-sdk/credential-provider-ini@3.964.0': + resolution: {integrity: sha512-t4FN9qTWU4nXDU6EQ6jopvyhXw0dbQ3n+3g6x5hmc1ECFAqA+xmFd1i5LljdZCi79cUXHduQWwvW8RJHMf0qJw==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-login@3.956.0': - resolution: {integrity: sha512-p2Y62mdIlUpiyi5tvn8cKTja5kq1e3Rm5gm4wpNQ9caTayfkIEXyKrbP07iepTv60Coaylq9Fx6b5En/siAeGA==} + '@aws-sdk/credential-provider-login@3.964.0': + resolution: {integrity: sha512-c64dmTizMkJXDRzN3NYPTmUpKxegr5lmLOYPeQ60Zcbft6HFwPme8Gwy8pNxO4gG1fw6Ja2Vu6fZuSTn8aDFOQ==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-node@3.956.0': - resolution: {integrity: sha512-ITjp7uAQh17ljUsCWkPRmLjyFfupGlJVUfTLHnZJ+c7G0P0PDRquaM+fBSh0y33tauHsBa5fGnCCLRo5hy9sGQ==} + '@aws-sdk/credential-provider-node@3.964.0': + resolution: {integrity: sha512-FHxDXPOj888/qc/X8s0x4aUBdp4Y3k9VePRehUJBWRhhTsAyuIJis5V0iQeY1qvtqHXYa2qd1EZHGJ3bTjHxSw==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-process@3.956.0': - resolution: {integrity: sha512-wpAex+/LGVWkHPchsn9FWy1ahFualIeSYq3ADFc262ljJjrltOWGh3+cu3OK3gTMkX6VEsl+lFvy1P7Bk7cgXA==} + '@aws-sdk/credential-provider-process@3.964.0': + resolution: {integrity: sha512-HaTLKqj3jeZY88E/iBjsNJsXgmRTTT7TghqeRiF8FKb/7UY1xEvasBO0c1xqfOye8dsyt35nTfTTyIsd/CBfww==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-sso@3.956.0': - resolution: {integrity: sha512-IRFSDF32x8TpOEYSGMcGQVJUiYuJaFkek0aCjW0klNIZHBF1YpflVpUarK9DJe4v4ryfVq3c0bqR/JFui8QFmw==} + '@aws-sdk/credential-provider-sso@3.964.0': + resolution: {integrity: sha512-oR78TjSpjVf1IpPWQnGHEGqlnQs+K4f5nCxLK2P6JDPprXay6oknsoSiU4x2urav6VCyMPMC9KTCGjBoFKUIxQ==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-web-identity@3.956.0': - resolution: {integrity: sha512-4YkmjwZC+qoUKlVOY9xNx7BTKRdJ1R1/Zjk2QSW5aWtwkk2e07ZUQvUpbW4vGpAxGm1K4EgRcowuSpOsDTh44Q==} + '@aws-sdk/credential-provider-web-identity@3.964.0': + resolution: {integrity: sha512-07JQDmbjZjOt3nL/j1wTcvQqjmPkynQYftUV/ooZ+qTbmJXFbCBdal1VCElyeiu0AgBq9dfhw0rBBcbND1ZMlA==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-providers@3.956.0': - resolution: {integrity: sha512-Fc8NWNkPZt61OIS6BRUWS+Po3z3nh3gE4w+4cZ083CdXxx4CCB82Oa0Fe2/E5l7p/z4vhEDXWwuJdiDQQXTplQ==} + '@aws-sdk/credential-providers@3.964.0': + resolution: {integrity: sha512-XUL0QypiD+g8ZmoMTulxtIJgibE7AHAmDjh/tmiW44V2z2KApCizQqqKFVdX9qsVF4bqS3ROIRJGckIBdOmlEw==} engines: {node: '>=18.0.0'} - '@aws-sdk/eventstream-handler-node@3.956.0': - resolution: {integrity: sha512-OdnzsiCyMcK9fkMI3II7+q8qu3hY5iK4coV8VjXI5u05UEbg3iosQynlkv0FXztSodPYYwnuZ0lFtIthsUy0Tw==} + '@aws-sdk/eventstream-handler-node@3.957.0': + resolution: {integrity: sha512-X3e/PBEl66efNCRR840IU2HM4oLMaP/Krc1w7vEqgKKhIbyiLRJ43NSrxNEYadQ19P/U0U7JHMCC9HbwhIMTeg==} engines: {node: '>=18.0.0'} - '@aws-sdk/middleware-eventstream@3.956.0': - resolution: {integrity: sha512-xBhNmBCJxB4ho7ATmhniv3PK3qN5ZEgknUI+7XTM/cnQBzuYG5twAQSkGdInzEjygTSTmKpkdBVh67AxKTotAQ==} + '@aws-sdk/middleware-eventstream@3.957.0': + resolution: {integrity: sha512-zFAx12yGEJcf35cnlv4zepqxZA0Z3orrhQdN7mjzvbHQGqDX+7gAnKEyTEsYdCD5ICZHuHxvhEnfE+pVIx0e7A==} engines: {node: '>=18.0.0'} - '@aws-sdk/middleware-host-header@3.956.0': - resolution: {integrity: sha512-JujNJDp/dj1DbsI0ntzhrz2uJ4jpumcKtr743eMpEhdboYjuu/UzY8/7n1h5JbgU9TNXgqE9lgQNa5QPG0Tvsg==} + '@aws-sdk/middleware-host-header@3.957.0': + resolution: {integrity: sha512-BBgKawVyfQZglEkNTuBBdC3azlyqNXsvvN4jPkWAiNYcY0x1BasaJFl+7u/HisfULstryweJq/dAvIZIxzlZaA==} engines: {node: '>=18.0.0'} - '@aws-sdk/middleware-logger@3.956.0': - resolution: {integrity: sha512-Qff39yEOPYgRsm4SrkHOvS0nSoxXILYnC8Akp0uMRi2lOcZVyXL3WCWqIOtI830qVI4GPa796sleKguxx50RHg==} + '@aws-sdk/middleware-logger@3.957.0': + resolution: {integrity: sha512-w1qfKrSKHf9b5a8O76yQ1t69u6NWuBjr5kBX+jRWFx/5mu6RLpqERXRpVJxfosbep7k3B+DSB5tZMZ82GKcJtQ==} engines: {node: '>=18.0.0'} - '@aws-sdk/middleware-recursion-detection@3.956.0': - resolution: {integrity: sha512-/f4JxL2kSCYhy63wovqts6SJkpalSLvuFe78ozt3ClrGoHGyr69o7tPRYx5U7azLgvrIGjsWUyTayeAk3YHIVQ==} + '@aws-sdk/middleware-recursion-detection@3.957.0': + resolution: {integrity: sha512-D2H/WoxhAZNYX+IjkKTdOhOkWQaK0jjJrDBj56hKjU5c9ltQiaX/1PqJ4dfjHntEshJfu0w+E6XJ+/6A6ILBBA==} engines: {node: '>=18.0.0'} - '@aws-sdk/middleware-user-agent@3.956.0': - resolution: {integrity: sha512-azH8OJ0AIe3NafaTNvJorG/ALaLNTYwVKtyaSeQKOvaL8TNuBVuDnM5iHCiWryIaRgZotomqycwyfNKLw2D3JQ==} + '@aws-sdk/middleware-user-agent@3.964.0': + resolution: {integrity: sha512-/QyBl8WLNtqw3ucyAggumQXVCi8GRxaDGE1ElyYMmacfiwHl37S9y8JVW/QLL1lIEXGcsrhMUKV3pyFJFALA7w==} engines: {node: '>=18.0.0'} - '@aws-sdk/middleware-websocket@3.956.0': - resolution: {integrity: sha512-yH8D1z5stLDPZPXoDsgzyMIwziMUj6v5riHARJ4cECJBtdREJwDmp56c+iCzkhvtWKqeL/viAlj4Kwe2fAmTxw==} + '@aws-sdk/middleware-websocket@3.957.0': + resolution: {integrity: sha512-/VyCEDTS56V2UZ+nNUDhZ9fuMgrKkO+9Od47umpgn9Mq7BZ7Cw9emJkvMSNNZAbtMeDaHN4lUYmmF8XhYgJOPQ==} engines: {node: '>= 14.0.0'} - '@aws-sdk/nested-clients@3.956.0': - resolution: {integrity: sha512-GHDQMkxoWpi3eTrhWGmghw0gsZJ5rM1ERHfBFhlhduCdtV3TyhKVmDgFG84KhU8v18dcVpSp3Pu3KwH7j1tgIg==} + '@aws-sdk/nested-clients@3.964.0': + resolution: {integrity: sha512-ql+ftRwjyZkZeG3qbrRJFVmNR0id83WEUqhFVjvrQMWspNApBhz0Ar4YVSn7Uv0QaKkaR7ALPtmdMzFr3/E4bQ==} engines: {node: '>=18.0.0'} - '@aws-sdk/region-config-resolver@3.956.0': - resolution: {integrity: sha512-byU5XYekW7+rZ3e067y038wlrpnPkdI4fMxcHCHrv+TAfzl8CCk5xLyzerQtXZR8cVPVOXuaYWe1zKW0uCnXUA==} + '@aws-sdk/region-config-resolver@3.957.0': + resolution: {integrity: sha512-V8iY3blh8l2iaOqXWW88HbkY5jDoWjH56jonprG/cpyqqCnprvpMUZWPWYJoI8rHRf2bqzZeql1slxG6EnKI7A==} engines: {node: '>=18.0.0'} - '@aws-sdk/token-providers@3.956.0': - resolution: {integrity: sha512-I01Q9yDeG9oXge14u/bubtSdBpok/rTsPp2AQwy5xj/5PatRTHPbUTP6tef3AH/lFCAqkI0nncIcgx6zikDdUQ==} + '@aws-sdk/token-providers@3.964.0': + resolution: {integrity: sha512-UqouLQbYepZnMFJGB/DVpA5GhF9uT98vNWSMz9PVbhgEPUKa73FECRT6YFZvZOh8kA+0JiENrnmS6d93I70ykQ==} engines: {node: '>=18.0.0'} - '@aws-sdk/types@3.956.0': - resolution: {integrity: sha512-DMRU/p9wAlAJxEjegnLwduCA8YP2pcT/sIJ+17KSF38c5cC6CbBhykwbZLECTo+zYzoFrOqeLbqE6paH8Gx3ug==} + '@aws-sdk/types@3.957.0': + resolution: {integrity: sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg==} engines: {node: '>=18.0.0'} - '@aws-sdk/util-endpoints@3.956.0': - resolution: {integrity: sha512-xZ5CBoubS4rs9JkFniKNShDtfqxaMUnwaebYMoybZm070q9+omFkQkJYXl7kopTViEgZgQl1sAsAkrawBM8qEQ==} + '@aws-sdk/util-endpoints@3.957.0': + resolution: {integrity: sha512-xwF9K24mZSxcxKS3UKQFeX/dPYkEps9wF1b+MGON7EvnbcucrJGyQyK1v1xFPn1aqXkBTFi+SZaMRx5E5YCVFw==} engines: {node: '>=18.0.0'} - '@aws-sdk/util-format-url@3.956.0': - resolution: {integrity: sha512-Piap0XvvmZMtCjeCStuAG/Meq7/U5SR3X+ZDduRYMKlkNtkLcc98e9Sih5AThIJLUdffRS/M+gQRiWvc1sm1ww==} + '@aws-sdk/util-format-url@3.957.0': + resolution: {integrity: sha512-Yyo/tlc0iGFGTPPkuxub1uRAv6XrnVnvSNjslZh5jIYA8GZoeEFPgJa3Qdu0GUS/YwoK8GOLnnaL9h/eH5LDJQ==} engines: {node: '>=18.0.0'} - '@aws-sdk/util-locate-window@3.953.0': - resolution: {integrity: sha512-mPxK+I1LcrgC/RSa3G5AMAn8eN2Ay0VOgw8lSRmV1jCtO+iYvNeCqOdxoJUjOW6I5BA4niIRWqVORuRP07776Q==} + '@aws-sdk/util-locate-window@3.957.0': + resolution: {integrity: sha512-nhmgKHnNV9K+i9daumaIz8JTLsIIML9PE/HUks5liyrjUzenjW/aHoc7WJ9/Td/gPZtayxFnXQSJRb/fDlBuJw==} engines: {node: '>=18.0.0'} - '@aws-sdk/util-user-agent-browser@3.956.0': - resolution: {integrity: sha512-s8KwYR3HqiGNni7a1DN2P3RUog64QoBQ6VCSzJkHBWb6++8KSOpqeeDkfmEz+22y1LOne+bRrpDGKa0aqOc3rQ==} + '@aws-sdk/util-user-agent-browser@3.957.0': + resolution: {integrity: sha512-exueuwxef0lUJRnGaVkNSC674eAiWU07ORhxBnevFFZEKisln+09Qrtw823iyv5I1N8T+wKfh95xvtWQrNKNQw==} - '@aws-sdk/util-user-agent-node@3.956.0': - resolution: {integrity: sha512-H0r6ol3Rr63/3xvrUsLqHps+cA7VkM7uCU5NtuTHnMbv3uYYTKf9M2XFHAdVewmmRgssTzvqemrARc8Ji3SNvg==} + '@aws-sdk/util-user-agent-node@3.964.0': + resolution: {integrity: sha512-jgob8Z/bZIh1dwEgLqE12q+aCf0ieLy7anT8bWpqMijMJqsnrPBToa7smSykfom9YHrdOgrQhXswMpE75dzLRw==} engines: {node: '>=18.0.0'} peerDependencies: aws-crt: '>=1.0.0' @@ -1502,8 +1560,8 @@ packages: '@aws-sdk/util-utf8-browser@3.259.0': resolution: {integrity: sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==} - '@aws-sdk/xml-builder@3.956.0': - resolution: {integrity: sha512-x/IvXUeQYNUEQojpRIQpFt4X7XGxqzjUlXFRdwaTCtTz3q1droXVJvYOhnX3KiMgzeHGlBJfY4Nmq3oZNEUGFw==} + '@aws-sdk/xml-builder@3.957.0': + resolution: {integrity: sha512-Ai5iiQqS8kJ5PjzMhWcLKN0G2yasAkvpnPlq2EnqlIMdB48HsizElt62qcktdxp4neRMyGkFq4NzgmDbXnhRiA==} engines: {node: '>=18.0.0'} '@aws/lambda-invoke-store@0.2.2': @@ -1748,8 +1806,8 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@dotenvx/dotenvx@1.51.2': - resolution: {integrity: sha512-+693mNflujDZxudSEqSNGpn92QgFhJlBn9q2mDQ9yGWyHuz3hZ8B5g3EXCwdAz4DMJAI+OFCIbfEFZS+YRdrEA==} + '@dotenvx/dotenvx@1.51.4': + resolution: {integrity: sha512-AoziS8lRQ3ew/lY5J4JSlzYSN9Fo0oiyMBY37L3Bwq4mOQJT5GSrdZYLFPt6pH1LApDI3ZJceNyx+rHRACZSeQ==} hasBin: true '@drizzle-team/brocli@0.10.2': @@ -1761,11 +1819,11 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 - '@emnapi/core@1.7.1': - resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.7.1': - resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -1943,8 +2001,8 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -2158,8 +2216,8 @@ packages: '@types/node': optional: true - '@ioredis/commands@1.4.0': - resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + '@ioredis/commands@1.5.0': + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} @@ -2263,8 +2321,8 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@napi-rs/wasm-runtime@1.1.0': - resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} '@next/env@13.5.11': resolution: {integrity: sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==} @@ -2443,111 +2501,114 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@oxc-resolver/binding-android-arm-eabi@11.16.0': - resolution: {integrity: sha512-/kFX4o8KISHCZzHRs8fBp/wZOPdkhYGquhMP2PQjc8ePAVbtaXXDPAFkjUKhz2jXNPS4jGA1wNW+8grhnJgstw==} + '@oxc-resolver/binding-android-arm-eabi@11.16.2': + resolution: {integrity: sha512-lVJbvydLQIDZHKUb6Zs9Rq80QVTQ9xdCQE30eC9/cjg4wsMoEOg65QZPymUAIVJotpUAWJD0XYcwE7ugfxx5kQ==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm64@11.16.0': - resolution: {integrity: sha512-kPySx7j7mPxW4mRDrdbADyzJV2XrxVeMPDmNnFvTt0/LT1IA26Uk9hzWKQb4k4aeJY58bnRY1soYSawW5wAlKQ==} + '@oxc-resolver/binding-android-arm64@11.16.2': + resolution: {integrity: sha512-fEk+g/g2rJ6LnBVPqeLcx+/alWZ/Db1UlXG+ZVivip0NdrnOzRL48PAmnxTMGOrLwsH1UDJkwY3wOjrrQltCqg==} cpu: [arm64] os: [android] - '@oxc-resolver/binding-darwin-arm64@11.16.0': - resolution: {integrity: sha512-eB00fkys5TX6oI3lY+1hgHl6dwfmrbhHTmInmJmfD6BysHpE+DUqSdQIRS2v5NI6+j+J9EWBmbW3hRtolr+MSg==} + '@oxc-resolver/binding-darwin-arm64@11.16.2': + resolution: {integrity: sha512-Pkbp1qi7kdUX6k3Fk1PvAg6p7ruwaWKg1AhOlDgrg2vLXjtv9ZHo7IAQN6kLj0W771dPJZWqNxoqTPacp2oYWA==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.16.0': - resolution: {integrity: sha512-B/yMSxqe4MZfh/VoMax0qixl4XxG/sAQVlYtdVGNteBAYKfX/uw2mglkYsApk6D4qD6fVgJ21RwI50lV7oD0Qg==} + '@oxc-resolver/binding-darwin-x64@11.16.2': + resolution: {integrity: sha512-FYCGcU1iSoPkADGLfQbuj0HWzS+0ItjDCt9PKtu2Hzy6T0dxO4Y1enKeCOxCweOlmLEkSxUlW5UPT4wvT3LnAg==} cpu: [x64] os: [darwin] - '@oxc-resolver/binding-freebsd-x64@11.16.0': - resolution: {integrity: sha512-aKj+PNsSdn0owueMt/6TtR8QuLBNL/q2HgMdN8nRCDmoCBPvQlwB2s+AcW+UW1vyiok+9qiI5tVjihbKwQ+Khg==} + '@oxc-resolver/binding-freebsd-x64@11.16.2': + resolution: {integrity: sha512-1zHCoK6fMcBjE54P2EG/z70rTjcRxvyKfvk4E/QVrWLxNahuGDFZIxoEoo4kGnnEcmPj41F0c2PkrQbqlpja5g==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.16.0': - resolution: {integrity: sha512-fxod0D0eMsIlGF98KRAwR3zjLCbpRoknDHjCHx22A9TmyQthGo7t66gwkRCj5g2LBbpaPZ+i6cYd2l9bRrx8+Q==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.16.2': + resolution: {integrity: sha512-+ucLYz8EO5FDp6kZ4o1uDmhoP+M98ysqiUW4hI3NmfiOJQWLrAzQjqaTdPfIOzlCXBU9IHp5Cgxu6wPjVb8dbA==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.16.0': - resolution: {integrity: sha512-5BoVnD0hpEID/13hnj0fCIojE26wfa9p4puCnm12/D5BhGlXA103n8iRaPZPLHS/prQGtrwMiFONiysD6vmIBA==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.16.2': + resolution: {integrity: sha512-qq+TpNXyw1odDgoONRpMLzH4hzhwnEw55398dL8rhKGvvYbio71WrJ00jE+hGlEi7H1Gkl11KoPJRaPlRAVGPw==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm64-gnu@11.16.0': - resolution: {integrity: sha512-dMoKX6A8iuIdShbc4PB/+q6Tx8grgQxNAJQfIAmpaDTZp5NxfgzKrssPL0TCdu3RQMblF8yfXLYUFnOdPYZeRg==} + '@oxc-resolver/binding-linux-arm64-gnu@11.16.2': + resolution: {integrity: sha512-xlMh4gNtplNQEwuF5icm69udC7un0WyzT5ywOeHrPMEsghKnLjXok2wZgAA7ocTm9+JsI+nVXIQa5XO1x+HPQg==} cpu: [arm64] os: [linux] - '@oxc-resolver/binding-linux-arm64-musl@11.16.0': - resolution: {integrity: sha512-oLJsyqVHw53ZZPl3+wPiRNXTvavBFSInRYBB5MaNf+y42+b4XJfH7hVYyc67er0c26cQUCfx2KzqltSx7Jg9jg==} + '@oxc-resolver/binding-linux-arm64-musl@11.16.2': + resolution: {integrity: sha512-OZs33QTMi0xmHv/4P0+RAKXJTBk7UcMH5tpTaCytWRXls/DGaJ48jOHmriQGK2YwUqXl+oneuNyPOUO0obJ+Hg==} cpu: [arm64] os: [linux] - '@oxc-resolver/binding-linux-ppc64-gnu@11.16.0': - resolution: {integrity: sha512-qL7GsXwyytVTIh/o8cLftRYvzrpniD8pFf0jDW3VXlVsl1joCrb4GM26udGls7Zxe76nsZpPvQVB5eZ9xmHxIA==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.16.2': + resolution: {integrity: sha512-UVyuhaV32dJGtF6fDofOcBstg9JwB2Jfnjfb8jGlu3xcG+TsubHRhuTwQ6JZ1sColNT1nMxBiu7zdKUEZi1kwg==} cpu: [ppc64] os: [linux] - '@oxc-resolver/binding-linux-riscv64-gnu@11.16.0': - resolution: {integrity: sha512-CFJEvagoakxPtIoKtRgPoGUqeXSgd63c3/T9hOXrgelOaMv6aEWFfjvc/4Lk5ppk2wv4KeK4IqOKBe8Faqv1Mw==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.16.2': + resolution: {integrity: sha512-YZZS0yv2q5nE1uL/Fk4Y7m9018DSEmDNSG8oJzy1TJjA1jx5HL52hEPxi98XhU6OYhSO/vC1jdkJeE8TIHugug==} cpu: [riscv64] os: [linux] - '@oxc-resolver/binding-linux-riscv64-musl@11.16.0': - resolution: {integrity: sha512-LVuE2tbZ7gjEjY1G8mjf7+pacj0/Rge9EoHxr8DY2gAxxy0qXe5Yh2Qxe3dwwFGObVNioqRH0IPkePmQ/KJK6w==} + '@oxc-resolver/binding-linux-riscv64-musl@11.16.2': + resolution: {integrity: sha512-9VYuypwtx4kt1lUcwJAH4dPmgJySh4/KxtAPdRoX2BTaZxVm/yEXHq0mnl/8SEarjzMvXKbf7Cm6UBgptm3DZw==} cpu: [riscv64] os: [linux] - '@oxc-resolver/binding-linux-s390x-gnu@11.16.0': - resolution: {integrity: sha512-D4Zk48WN7sKsbyq4xD2F09U4S0sIkHXTW9A33BaqjfNXOD/jFXM5nTPahHx2RxBLo5ZEgS3kUW1U8V0oCBcPcg==} + '@oxc-resolver/binding-linux-s390x-gnu@11.16.2': + resolution: {integrity: sha512-3gbwQ+xlL5gpyzgSDdC8B4qIM4mZaPDLaFOi3c/GV7CqIdVJc5EZXW4V3T6xwtPBOpXPXfqQLbhTnUD4SqwJtA==} cpu: [s390x] os: [linux] - '@oxc-resolver/binding-linux-x64-gnu@11.16.0': - resolution: {integrity: sha512-WyqsQwz+x1lDe/rwf5pl/FiTiS4eEM7hEHn1OwjP+EThzXXBup9BeZE5QVB421QGm9n4SyJT1gJgI1LCRvqbaA==} + '@oxc-resolver/binding-linux-x64-gnu@11.16.2': + resolution: {integrity: sha512-m0WcK0j54tSwWa+hQaJMScZdWneqE7xixp/vpFqlkbhuKW9dRHykPAFvSYg1YJ3MJgu9ZzVNpYHhPKJiEQq57Q==} cpu: [x64] os: [linux] - '@oxc-resolver/binding-linux-x64-musl@11.16.0': - resolution: {integrity: sha512-5XCuIoviaMsiAAuaQL4HqnYj1BkADcbtdf2s6Ru4YHF3P/bt2p05hd4xVo85cFT1VXlGYL66XVfepsAGymJs0g==} + '@oxc-resolver/binding-linux-x64-musl@11.16.2': + resolution: {integrity: sha512-ZjUm3w96P2t47nWywGwj1A2mAVBI/8IoS7XHhcogWCfXnEI3M6NPIRQPYAZW4s5/u3u6w1uPtgOwffj2XIOb/g==} cpu: [x64] os: [linux] - '@oxc-resolver/binding-openharmony-arm64@11.16.0': - resolution: {integrity: sha512-gn54HKxOhWTxZG8pNeBMmbRwHT4k/eIf0KxBII2oHUrSTinNTcqu6xn1etqt1Yezi9KzJzkTMS0cl5kTFmCHUQ==} + '@oxc-resolver/binding-openharmony-arm64@11.16.2': + resolution: {integrity: sha512-OFVQ2x3VenTp13nIl6HcQ/7dmhFmM9dg2EjKfHcOtYfrVLQdNR6THFU7GkMdmc8DdY1zLUeilHwBIsyxv5hkwQ==} cpu: [arm64] os: [openharmony] - '@oxc-resolver/binding-wasm32-wasi@11.16.0': - resolution: {integrity: sha512-dUsUjffSI7nlt+TH9C4gGqmD/kNyx3Kghh8u+i8eZZAEFWDO+s51Yw3UADDa0BYrZDeaLjz8rgHWCE8lxpL2XQ==} + '@oxc-resolver/binding-wasm32-wasi@11.16.2': + resolution: {integrity: sha512-+O1sY3RrGyA2AqDnd3yaDCsqZqCblSTEpY7TbbaOaw0X7iIbGjjRLdrQk9StG3QSiZuBy9FdFwotIiSXtwvbAQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-win32-arm64-msvc@11.16.0': - resolution: {integrity: sha512-6EhsnwzA6iT752sU5tv/r+XI5cz6sWUPHJZu3brTW3m96j6yCZ8vnfeKAkFCzuDwZAXOkRLPW8WKrL0GXWfCUQ==} + '@oxc-resolver/binding-win32-arm64-msvc@11.16.2': + resolution: {integrity: sha512-jMrMJL+fkx6xoSMFPOeyQ1ctTFjavWPOSZEKUY5PebDwQmC9cqEr4LhdTnGsOtFrWYLXlEU4xWeMdBoc/XKkOA==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-ia32-msvc@11.16.0': - resolution: {integrity: sha512-YpUXuKrslGs4+In1gZhY25menhzyBbMct4RvWT9je6mYA5VCQ6aGAZf/ky5b+5sNPpR2UBNbCcYk5pP/6MowMw==} + '@oxc-resolver/binding-win32-ia32-msvc@11.16.2': + resolution: {integrity: sha512-tl0xDA5dcQplG2yg2ZhgVT578dhRFafaCfyqMEAXq8KNpor85nJ53C3PLpfxD2NKzPioFgWEexNsjqRi+kW2Mg==} cpu: [ia32] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@11.16.0': - resolution: {integrity: sha512-x3hU0m0c/+frUSFaw3r5Xmde5q/PdsAfznh+8lZloGK2/qfIze0jyQG0H5M6AgrUIQE1oNn8vdGXanza5+naMw==} + '@oxc-resolver/binding-win32-x64-msvc@11.16.2': + resolution: {integrity: sha512-M7z0xjYQq1HdJk2DxTSLMvRMyBSI4wn4FXGcVQBsbAihgXevAReqwMdb593nmCK/OiFwSNcOaGIzUvzyzQ+95w==} cpu: [x64] os: [win32] '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@posthog/core@1.8.1': - resolution: {integrity: sha512-jfzBtQIk9auRi/biO+G/gumK5KxqsD5wOr7XpYMROE/I3pazjP4zIziinp21iQuIQJMXrDvwt9Af3njgOGwtew==} + '@posthog/core@1.9.0': + resolution: {integrity: sha512-j7KSWxJTUtNyKynLt/p0hfip/3I46dWU2dk+pt7dKRoz2l5CYueHuHK4EO7Wlgno5yo1HO4sc4s30MXMTICHJw==} + + '@posthog/types@1.315.0': + resolution: {integrity: sha512-A3HcvYC93OLNSKlpelzI5sCCMsRaq4nREgJUJq09UEKngsexa3mQU4UdeR8TMisO4BLrQPPlqUBbs+C4OlMX7g==} '@puppeteer/browsers@2.11.0': resolution: {integrity: sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==} @@ -3113,113 +3174,128 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rollup/rollup-android-arm-eabi@4.54.0': - resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} + '@rollup/rollup-android-arm-eabi@4.55.1': + resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.54.0': - resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==} + '@rollup/rollup-android-arm64@4.55.1': + resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.54.0': - resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==} + '@rollup/rollup-darwin-arm64@4.55.1': + resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.54.0': - resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==} + '@rollup/rollup-darwin-x64@4.55.1': + resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.54.0': - resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==} + '@rollup/rollup-freebsd-arm64@4.55.1': + resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.54.0': - resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==} + '@rollup/rollup-freebsd-x64@4.55.1': + resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.54.0': - resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.54.0': - resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.54.0': - resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} + '@rollup/rollup-linux-arm64-gnu@4.55.1': + resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.54.0': - resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} + '@rollup/rollup-linux-arm64-musl@4.55.1': + resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.54.0': - resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} + '@rollup/rollup-linux-loong64-gnu@4.55.1': + resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.54.0': - resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} + '@rollup/rollup-linux-loong64-musl@4.55.1': + resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.55.1': + resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.54.0': - resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.54.0': - resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} + '@rollup/rollup-linux-riscv64-musl@4.55.1': + resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.54.0': - resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} + '@rollup/rollup-linux-s390x-gnu@4.55.1': + resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.54.0': - resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} + '@rollup/rollup-linux-x64-gnu@4.55.1': + resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.54.0': - resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} + '@rollup/rollup-linux-x64-musl@4.55.1': + resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] - '@rollup/rollup-openharmony-arm64@4.54.0': - resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} + '@rollup/rollup-openbsd-x64@4.55.1': + resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.55.1': + resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.54.0': - resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==} + '@rollup/rollup-win32-arm64-msvc@4.55.1': + resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.54.0': - resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==} + '@rollup/rollup-win32-ia32-msvc@4.55.1': + resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.54.0': - resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==} + '@rollup/rollup-win32-x64-gnu@4.55.1': + resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.54.0': - resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==} + '@rollup/rollup-win32-x64-msvc@4.55.1': + resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} cpu: [x64] os: [win32] @@ -3682,11 +3758,11 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 - '@tanstack/query-core@5.90.12': - resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} + '@tanstack/query-core@5.90.16': + resolution: {integrity: sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==} - '@tanstack/react-query@5.90.12': - resolution: {integrity: sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==} + '@tanstack/react-query@5.90.16': + resolution: {integrity: sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==} peerDependencies: react: ^18 || ^19 @@ -3997,8 +4073,8 @@ packages: '@types/string-similarity@4.0.2': resolution: {integrity: sha512-LkJQ/jsXtCVMK+sKYAmX/8zEq+/46f1PTQw7YtmQwb74jemS1SlNLmARM2Zml9DgdDTWKAtc5L13WorpHPDjDA==} - '@types/stylis@4.2.5': - resolution: {integrity: sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==} + '@types/stylis@4.2.7': + resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==} '@types/svgo@1.3.6': resolution: {integrity: sha512-AZU7vQcy/4WFEuwnwsNsJnFwupIpbllH1++LXScN6uxT1Z4zPzdrWG97w4/I7eFKFTvfy/bHFStWjdBAg2Vjug==} @@ -4033,63 +4109,63 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@8.50.0': - resolution: {integrity: sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==} + '@typescript-eslint/eslint-plugin@8.52.0': + resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.50.0 + '@typescript-eslint/parser': ^8.52.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.50.0': - resolution: {integrity: sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==} + '@typescript-eslint/parser@8.52.0': + resolution: {integrity: sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.50.0': - resolution: {integrity: sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==} + '@typescript-eslint/project-service@8.52.0': + resolution: {integrity: sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.50.0': - resolution: {integrity: sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==} + '@typescript-eslint/scope-manager@8.52.0': + resolution: {integrity: sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.50.0': - resolution: {integrity: sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==} + '@typescript-eslint/tsconfig-utils@8.52.0': + resolution: {integrity: sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.50.0': - resolution: {integrity: sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==} + '@typescript-eslint/type-utils@8.52.0': + resolution: {integrity: sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.50.0': - resolution: {integrity: sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==} + '@typescript-eslint/types@8.52.0': + resolution: {integrity: sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.50.0': - resolution: {integrity: sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==} + '@typescript-eslint/typescript-estree@8.52.0': + resolution: {integrity: sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.50.0': - resolution: {integrity: sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==} + '@typescript-eslint/utils@8.52.0': + resolution: {integrity: sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.50.0': - resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==} + '@typescript-eslint/visitor-keys@8.52.0': + resolution: {integrity: sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typespec/ts-http-runtime@0.3.2': @@ -4146,6 +4222,9 @@ packages: '@vscode/codicons@0.0.36': resolution: {integrity: sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==} + '@vscode/ripgrep@1.17.0': + resolution: {integrity: sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g==} + '@vscode/test-cli@0.0.11': resolution: {integrity: sha512-qO332yvzFqGhBMJrp6TdwbIydiHgCtxXc2Nl6M58mbH/Z+0CyLR76Jzv4YWPEthhrARprzCRJUqzFvTHFhTj7Q==} engines: {node: '>=18'} @@ -4483,8 +4562,8 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true - basic-ftp@5.0.5: - resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} + basic-ftp@5.1.0: + resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} engines: {node: '>=10.0.0'} better-path-resolve@1.0.0: @@ -4518,6 +4597,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} @@ -4621,8 +4704,8 @@ packages: camelize@1.0.1: resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} - caniuse-lite@1.0.30001761: - resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + caniuse-lite@1.0.30001762: + resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4670,8 +4753,8 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} cheerio-select@2.1.0: @@ -5171,15 +5254,6 @@ packages: resolution: {integrity: sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==} engines: {node: '>=18'} - debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -5291,6 +5365,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -5545,8 +5622,8 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - engine.io-client@6.6.3: - resolution: {integrity: sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==} + engine.io-client@6.6.4: + resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} engine.io-parser@5.2.3: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} @@ -5616,6 +5693,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -5681,8 +5761,8 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-turbo@2.7.1: - resolution: {integrity: sha512-ZC7dTOdw6tGuvx1CeC1WQ0pMkgT/Jmj69QW93d63nysiLbbKRLiDKKA9s/TvwJHq8Uvbou2+hnU8if1L0jHsVQ==} + eslint-plugin-turbo@2.7.3: + resolution: {integrity: sha512-q7kYzJCyvceSLVwHgmn3ZBhqpUihQHxC7LEddq5a1eLe5P+/Ob4TnJrdocP38qO1n9MCuO+cJSUTGUtZb1X3bQ==} peerDependencies: eslint: '>6.6.0' turbo: '>2.0.0' @@ -5718,8 +5798,8 @@ packages: engines: {node: '>=4'} hasBin: true - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -5881,8 +5961,8 @@ packages: fastest-stable-stringify@2.0.2: resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} @@ -6151,6 +6231,10 @@ packages: resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} engines: {node: 20 || >=22} + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -6296,8 +6380,8 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hono@4.11.1: - resolution: {integrity: sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==} + hono@4.11.3: + resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==} engines: {node: '>=16.9.0'} hosted-git-info@4.1.0: @@ -6444,8 +6528,8 @@ packages: resolution: {integrity: sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw==} engines: {node: '>=8'} - ioredis@5.8.2: - resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} + ioredis@5.9.0: + resolution: {integrity: sha512-T3VieIilNumOJCXI9SDgo4NnF6sZkd6XcmPi6qWtw4xqbt8nNz/ZVNiIH1L9puMTSHZh1mUWA4xKa2nWPF4NwQ==} engines: {node: '>=12.22.0'} ip-address@10.1.0: @@ -6881,8 +6965,8 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - knip@5.76.3: - resolution: {integrity: sha512-YLCCzOFzkuNgyL9LdrwFBstV9gpmvPCuolRzs9W++of0mtPH1D3ehE3M4okgayksgq7tWkkMAmyjrDrXxX6aAQ==} + knip@5.80.0: + resolution: {integrity: sha512-K/Ga2f/SHEUXXriVdaw2GfeIUJ5muwdqusHGkCtaG/1qeMmQJiuwZj9KnPxaDbnYPAu8RWjYYh8Nyb+qlJ3d8A==} engines: {node: '>=18.18.0'} hasBin: true peerDependencies: @@ -7194,6 +7278,10 @@ packages: engines: {node: '>= 20'} hasBin: true + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -7483,11 +7571,11 @@ packages: peerDependencies: tslib: ^2.0.1 - motion-dom@12.23.23: - resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + motion-dom@12.24.3: + resolution: {integrity: sha512-ZjMZCwhTglim0LM64kC1iFdm4o+2P9IKk3rl/Nb4RKsb5p4O9HJ1C2LWZXOFdsRtp6twpqWRXaFKOduF30ntow==} - motion-utils@12.23.6: - resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + motion-utils@12.23.28: + resolution: {integrity: sha512-0W6cWd5Okoyf8jmessVK3spOmbyE0yTdNKujHctHH9XdAE4QDuZ1/LjSXC68rrhsJU+TkzXURC5OdSWh9ibOwQ==} mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -7774,8 +7862,8 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxc-resolver@11.16.0: - resolution: {integrity: sha512-I4sHGa1fZUpTQ9ftS0E0cBYbBjNnIKXRSX/trFMIJDIJ4n21dCrLAZhnJS0TSfRIRqZNFyceNZr2kablfgNyTA==} + oxc-resolver@11.16.2: + resolution: {integrity: sha512-Uy76u47vwhhF7VAmVY61Srn+ouiOobf45MU9vGct9GD2ARy6hKoqEElyHDB0L+4JOM6VLuZ431KiLwyjI/A21g==} p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} @@ -8042,19 +8130,19 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - postgres@3.4.7: - resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + postgres@3.4.8: + resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} - posthog-js@1.309.1: - resolution: {integrity: sha512-JUJcQhYzNNKO0cgnSbowCsVi2RTu75XGZ2EmnTQti4tMGRCTOv/HCnZasdFniBGZ0rLugQkaScYca/84Ta2u5Q==} + posthog-js@1.315.0: + resolution: {integrity: sha512-mdL0hCp/8xOQUB41d6JKROZxtgJvReT3gc9XuQIcfBbmzK2ZrNCxW2t+QV3Uj6JM1BNxbX5NE/cRCBdtaiCxrA==} - posthog-node@5.17.4: - resolution: {integrity: sha512-hrd+Do/DMt40By12ESIDUfD81V9OASjq9XHjycZrGiD8cX/ZwCIVSJLUb7nQmvSCWcKII+u+nnPVuc4LjTDl9g==} + posthog-node@5.19.0: + resolution: {integrity: sha512-5Nx+/b1JbAy6TaCENuO+mLFfZIXyYavae+D6FABd52gzkGryRkRTsKi+WLlILI5shSCAMWs5gxzGAZ3nOC0gFA==} engines: {node: '>=20'} - preact@10.28.0: - resolution: {integrity: sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==} + preact@10.28.2: + resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==} prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} @@ -8169,8 +8257,8 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -8209,8 +8297,8 @@ packages: peerDependencies: react: ^18.3.1 - react-hook-form@7.69.0: - resolution: {integrity: sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==} + react-hook-form@7.70.0: + resolution: {integrity: sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 @@ -8321,8 +8409,8 @@ packages: react: '*' react-dom: '*' - react-virtuoso@4.17.0: - resolution: {integrity: sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w==} + react-virtuoso@4.18.1: + resolution: {integrity: sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==} peerDependencies: react: '>=16 || >=17 || >= 18 || >= 19' react-dom: '>=16 || >=17 || >= 18 || >=19' @@ -8524,11 +8612,15 @@ packages: engines: {node: 20 || >=22} hasBin: true + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - rollup@4.54.0: - resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} + rollup@4.55.1: + resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -8643,6 +8735,10 @@ packages: resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==} engines: {node: '>=18'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -8766,12 +8862,12 @@ packages: resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} engines: {node: '>= 18'} - socket.io-client@4.8.1: - resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} engines: {node: '>=10.0.0'} - socket.io-parser@4.2.4: - resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + socket.io-parser@4.2.5: + resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==} engines: {node: '>=10.0.0'} socks-proxy-agent@8.0.5: @@ -8825,6 +8921,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stable@0.1.8: resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' @@ -9010,8 +9109,8 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - styled-components@6.1.19: - resolution: {integrity: sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==} + styled-components@6.2.0: + resolution: {integrity: sha512-ryFCkETE++8jlrBmC+BoGPUN96ld1/Yp0s7t5bcXDobrs4XoXroY1tN+JbFi09hV6a5h3MzbcVi8/BGDP0eCgQ==} engines: {node: '>= 16'} peerDependencies: react: '>= 16.8.0' @@ -9030,9 +9129,6 @@ packages: babel-plugin-macros: optional: true - stylis@4.3.2: - resolution: {integrity: sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==} - stylis@4.3.6: resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} @@ -9225,8 +9321,8 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -9281,38 +9377,38 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} - turbo-darwin-64@2.7.1: - resolution: {integrity: sha512-EaA7UfYujbY9/Ku0WqPpvfctxm91h9LF7zo8vjielz+omfAPB54Si+ADmUoBczBDC6RoLgbURC3GmUW2alnjJg==} + turbo-darwin-64@2.7.3: + resolution: {integrity: sha512-aZHhvRiRHXbJw1EcEAq4aws1hsVVUZ9DPuSFaq9VVFAKCup7niIEwc22glxb7240yYEr1vLafdQ2U294Vcwz+w==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.7.1: - resolution: {integrity: sha512-/pWGSygtBugd7sKQOeMm+jKY3qN1vyB0RiHBM6bN/6qUOo2VHo8IQwBTIaSgINN4Ue6fzEU+WfePNvonSU9yXw==} + turbo-darwin-arm64@2.7.3: + resolution: {integrity: sha512-CkVrHSq+Bnhl9sX2LQgqQYVfLTWC2gvI74C4758OmU0djfrssDKU9d4YQF0AYXXhIIRZipSXfxClQziIMD+EAg==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.7.1: - resolution: {integrity: sha512-Y5H11mdhASw/dJuRFyGtTCDFX5/MPT73EKsVEiHbw5MkFc77lx3nMc5L/Q7bKEhef/vYJAsAb61QuHsB6qdP8Q==} + turbo-linux-64@2.7.3: + resolution: {integrity: sha512-GqDsCNnzzr89kMaLGpRALyigUklzgxIrSy2pHZVXyifgczvYPnLglex78Aj3T2gu+T3trPPH2iJ+pWucVOCC2Q==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.7.1: - resolution: {integrity: sha512-L/r77jD7cqIEXoyu2LGBUrTY5GJSi/XcGLsQ2nZ/fefk6x3MpljTvwsXUVG1BUkiBPc4zaKRj6yGyWMo5MbLxQ==} + turbo-linux-arm64@2.7.3: + resolution: {integrity: sha512-NdCDTfIcIo3dWjsiaAHlxu5gW61Ed/8maah1IAF/9E3EtX0aAHNiBMbuYLZaR4vRJ7BeVkYB6xKWRtdFLZ0y3g==} cpu: [arm64] os: [linux] - turbo-windows-64@2.7.1: - resolution: {integrity: sha512-rkeuviXZ/1F7lCare7TNKvYtT/SH9dZR55FAMrxrFRh88b+ZKwlXEBfq5/1OctEzRUo/VLIm+s5LJMOEy+QshA==} + turbo-windows-64@2.7.3: + resolution: {integrity: sha512-7bVvO987daXGSJVYBoG8R4Q+csT1pKIgLJYZevXRQ0Hqw0Vv4mKme/TOjYXs9Qb1xMKh51Tb3bXKDbd8/4G08g==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.7.1: - resolution: {integrity: sha512-1rZk9htm3+iP/rWCf/h4/DFQey9sMs2TJPC4T5QQfwqAdMWsphgrxBuFqHdxczlbBCgbWNhVw0CH2bTxe1/GFg==} + turbo-windows-arm64@2.7.3: + resolution: {integrity: sha512-nTodweTbPmkvwMu/a55XvjMsPtuyUSC+sV7f/SR57K36rB2I0YG21qNETN+00LOTUW9B3omd8XkiXJkt4kx/cw==} cpu: [arm64] os: [win32] - turbo@2.7.1: - resolution: {integrity: sha512-zAj9jGc7VDvuAo/5Jbos4QTtWz9uUpkMhMKGyTjDJkx//hdL2bM31qQoJSAbU+7JyK5vb0LPzpwf6DUt3zayqg==} + turbo@2.7.3: + resolution: {integrity: sha512-+HjKlP4OfYk+qzvWNETA3cUO5UuK6b5MSc2UJOKyvBceKucQoQGb2g7HlC2H1GHdkfKrk4YF1VPvROkhVZDDLQ==} hasBin: true turndown@7.2.2: @@ -9322,6 +9418,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -9352,8 +9452,8 @@ packages: typed-rest-client@1.8.11: resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==} - typescript-eslint@8.50.0: - resolution: {integrity: sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==} + typescript-eslint@8.52.0: + resolution: {integrity: sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -9367,8 +9467,8 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - ufo@1.6.1: - resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + ufo@1.6.2: + resolution: {integrity: sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==} unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} @@ -9389,8 +9489,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.16.0: - resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + undici@7.18.2: + resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} unicode-trie@2.0.0: @@ -9744,6 +9844,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -9828,8 +9929,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.17.1: - resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -9840,8 +9941,8 @@ packages: utf-8-validate: optional: true - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -9962,8 +10063,8 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} - zod-to-json-schema@3.25.0: - resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: zod: ^3.25 || ^4 @@ -9979,8 +10080,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.2.1: - resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + zod@4.3.5: + resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -10000,8 +10101,8 @@ snapshots: dependencies: '@anthropic-ai/sdk': 0.37.0 '@aws-crypto/sha256-js': 4.0.0 - '@aws-sdk/client-bedrock-runtime': 3.956.0 - '@aws-sdk/credential-providers': 3.956.0 + '@aws-sdk/client-bedrock-runtime': 3.964.0 + '@aws-sdk/credential-providers': 3.964.0 '@smithy/eventstream-serde-node': 2.2.0 '@smithy/fetch-http-handler': 2.5.0 '@smithy/protocol-http': 3.3.0 @@ -10044,13 +10145,13 @@ snapshots: '@aws-crypto/crc32@3.0.0': dependencies: '@aws-crypto/util': 3.0.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 tslib: 1.14.1 '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 tslib: 2.8.1 '@aws-crypto/sha256-browser@5.2.0': @@ -10058,21 +10159,21 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.956.0 - '@aws-sdk/util-locate-window': 3.953.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-locate-window': 3.957.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 '@aws-crypto/sha256-js@4.0.0': dependencies: '@aws-crypto/util': 4.0.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 tslib: 1.14.1 '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -10081,41 +10182,41 @@ snapshots: '@aws-crypto/util@3.0.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@aws-sdk/util-utf8-browser': 3.259.0 tslib: 1.14.1 '@aws-crypto/util@4.0.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@aws-sdk/util-utf8-browser': 3.259.0 tslib: 1.14.1 '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.956.0': + '@aws-sdk/client-bedrock-runtime@3.964.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.956.0 - '@aws-sdk/credential-provider-node': 3.956.0 - '@aws-sdk/eventstream-handler-node': 3.956.0 - '@aws-sdk/middleware-eventstream': 3.956.0 - '@aws-sdk/middleware-host-header': 3.956.0 - '@aws-sdk/middleware-logger': 3.956.0 - '@aws-sdk/middleware-recursion-detection': 3.956.0 - '@aws-sdk/middleware-user-agent': 3.956.0 - '@aws-sdk/middleware-websocket': 3.956.0 - '@aws-sdk/region-config-resolver': 3.956.0 - '@aws-sdk/token-providers': 3.956.0 - '@aws-sdk/types': 3.956.0 - '@aws-sdk/util-endpoints': 3.956.0 - '@aws-sdk/util-user-agent-browser': 3.956.0 - '@aws-sdk/util-user-agent-node': 3.956.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/credential-provider-node': 3.964.0 + '@aws-sdk/eventstream-handler-node': 3.957.0 + '@aws-sdk/middleware-eventstream': 3.957.0 + '@aws-sdk/middleware-host-header': 3.957.0 + '@aws-sdk/middleware-logger': 3.957.0 + '@aws-sdk/middleware-recursion-detection': 3.957.0 + '@aws-sdk/middleware-user-agent': 3.964.0 + '@aws-sdk/middleware-websocket': 3.957.0 + '@aws-sdk/region-config-resolver': 3.957.0 + '@aws-sdk/token-providers': 3.964.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-endpoints': 3.957.0 + '@aws-sdk/util-user-agent-browser': 3.957.0 + '@aws-sdk/util-user-agent-node': 3.964.0 '@smithy/config-resolver': 4.4.5 '@smithy/core': 3.20.0 '@smithy/eventstream-serde-browser': 4.2.7 @@ -10149,21 +10250,21 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-cognito-identity@3.956.0': + '@aws-sdk/client-cognito-identity@3.964.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.956.0 - '@aws-sdk/credential-provider-node': 3.956.0 - '@aws-sdk/middleware-host-header': 3.956.0 - '@aws-sdk/middleware-logger': 3.956.0 - '@aws-sdk/middleware-recursion-detection': 3.956.0 - '@aws-sdk/middleware-user-agent': 3.956.0 - '@aws-sdk/region-config-resolver': 3.956.0 - '@aws-sdk/types': 3.956.0 - '@aws-sdk/util-endpoints': 3.956.0 - '@aws-sdk/util-user-agent-browser': 3.956.0 - '@aws-sdk/util-user-agent-node': 3.956.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/credential-provider-node': 3.964.0 + '@aws-sdk/middleware-host-header': 3.957.0 + '@aws-sdk/middleware-logger': 3.957.0 + '@aws-sdk/middleware-recursion-detection': 3.957.0 + '@aws-sdk/middleware-user-agent': 3.964.0 + '@aws-sdk/region-config-resolver': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-endpoints': 3.957.0 + '@aws-sdk/util-user-agent-browser': 3.957.0 + '@aws-sdk/util-user-agent-node': 3.964.0 '@smithy/config-resolver': 4.4.5 '@smithy/core': 3.20.0 '@smithy/fetch-http-handler': 5.3.8 @@ -10193,20 +10294,20 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso@3.956.0': + '@aws-sdk/client-sso@3.964.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.956.0 - '@aws-sdk/middleware-host-header': 3.956.0 - '@aws-sdk/middleware-logger': 3.956.0 - '@aws-sdk/middleware-recursion-detection': 3.956.0 - '@aws-sdk/middleware-user-agent': 3.956.0 - '@aws-sdk/region-config-resolver': 3.956.0 - '@aws-sdk/types': 3.956.0 - '@aws-sdk/util-endpoints': 3.956.0 - '@aws-sdk/util-user-agent-browser': 3.956.0 - '@aws-sdk/util-user-agent-node': 3.956.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/middleware-host-header': 3.957.0 + '@aws-sdk/middleware-logger': 3.957.0 + '@aws-sdk/middleware-recursion-detection': 3.957.0 + '@aws-sdk/middleware-user-agent': 3.964.0 + '@aws-sdk/region-config-resolver': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-endpoints': 3.957.0 + '@aws-sdk/util-user-agent-browser': 3.957.0 + '@aws-sdk/util-user-agent-node': 3.964.0 '@smithy/config-resolver': 4.4.5 '@smithy/core': 3.20.0 '@smithy/fetch-http-handler': 5.3.8 @@ -10236,10 +10337,10 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/core@3.956.0': + '@aws-sdk/core@3.964.0': dependencies: - '@aws-sdk/types': 3.956.0 - '@aws-sdk/xml-builder': 3.956.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/xml-builder': 3.957.0 '@smithy/core': 3.20.0 '@smithy/node-config-provider': 4.3.7 '@smithy/property-provider': 4.2.7 @@ -10252,28 +10353,28 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-cognito-identity@3.956.0': + '@aws-sdk/credential-provider-cognito-identity@3.964.0': dependencies: - '@aws-sdk/client-cognito-identity': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/client-cognito-identity': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/property-provider': 4.2.7 '@smithy/types': 4.11.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-env@3.956.0': + '@aws-sdk/credential-provider-env@3.964.0': dependencies: - '@aws-sdk/core': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/property-provider': 4.2.7 '@smithy/types': 4.11.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.956.0': + '@aws-sdk/credential-provider-http@3.964.0': dependencies: - '@aws-sdk/core': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/fetch-http-handler': 5.3.8 '@smithy/node-http-handler': 4.4.7 '@smithy/property-provider': 4.2.7 @@ -10283,17 +10384,17 @@ snapshots: '@smithy/util-stream': 4.5.8 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.956.0': - dependencies: - '@aws-sdk/core': 3.956.0 - '@aws-sdk/credential-provider-env': 3.956.0 - '@aws-sdk/credential-provider-http': 3.956.0 - '@aws-sdk/credential-provider-login': 3.956.0 - '@aws-sdk/credential-provider-process': 3.956.0 - '@aws-sdk/credential-provider-sso': 3.956.0 - '@aws-sdk/credential-provider-web-identity': 3.956.0 - '@aws-sdk/nested-clients': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/credential-provider-ini@3.964.0': + dependencies: + '@aws-sdk/core': 3.964.0 + '@aws-sdk/credential-provider-env': 3.964.0 + '@aws-sdk/credential-provider-http': 3.964.0 + '@aws-sdk/credential-provider-login': 3.964.0 + '@aws-sdk/credential-provider-process': 3.964.0 + '@aws-sdk/credential-provider-sso': 3.964.0 + '@aws-sdk/credential-provider-web-identity': 3.964.0 + '@aws-sdk/nested-clients': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/credential-provider-imds': 4.2.7 '@smithy/property-provider': 4.2.7 '@smithy/shared-ini-file-loader': 4.4.2 @@ -10302,11 +10403,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.956.0': + '@aws-sdk/credential-provider-login@3.964.0': dependencies: - '@aws-sdk/core': 3.956.0 - '@aws-sdk/nested-clients': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/nested-clients': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/property-provider': 4.2.7 '@smithy/protocol-http': 5.3.7 '@smithy/shared-ini-file-loader': 4.4.2 @@ -10315,15 +10416,15 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.956.0': + '@aws-sdk/credential-provider-node@3.964.0': dependencies: - '@aws-sdk/credential-provider-env': 3.956.0 - '@aws-sdk/credential-provider-http': 3.956.0 - '@aws-sdk/credential-provider-ini': 3.956.0 - '@aws-sdk/credential-provider-process': 3.956.0 - '@aws-sdk/credential-provider-sso': 3.956.0 - '@aws-sdk/credential-provider-web-identity': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/credential-provider-env': 3.964.0 + '@aws-sdk/credential-provider-http': 3.964.0 + '@aws-sdk/credential-provider-ini': 3.964.0 + '@aws-sdk/credential-provider-process': 3.964.0 + '@aws-sdk/credential-provider-sso': 3.964.0 + '@aws-sdk/credential-provider-web-identity': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/credential-provider-imds': 4.2.7 '@smithy/property-provider': 4.2.7 '@smithy/shared-ini-file-loader': 4.4.2 @@ -10332,21 +10433,21 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.956.0': + '@aws-sdk/credential-provider-process@3.964.0': dependencies: - '@aws-sdk/core': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/property-provider': 4.2.7 '@smithy/shared-ini-file-loader': 4.4.2 '@smithy/types': 4.11.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.956.0': + '@aws-sdk/credential-provider-sso@3.964.0': dependencies: - '@aws-sdk/client-sso': 3.956.0 - '@aws-sdk/core': 3.956.0 - '@aws-sdk/token-providers': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/client-sso': 3.964.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/token-providers': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/property-provider': 4.2.7 '@smithy/shared-ini-file-loader': 4.4.2 '@smithy/types': 4.11.0 @@ -10354,11 +10455,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.956.0': + '@aws-sdk/credential-provider-web-identity@3.964.0': dependencies: - '@aws-sdk/core': 3.956.0 - '@aws-sdk/nested-clients': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/nested-clients': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/property-provider': 4.2.7 '@smithy/shared-ini-file-loader': 4.4.2 '@smithy/types': 4.11.0 @@ -10366,21 +10467,21 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-providers@3.956.0': - dependencies: - '@aws-sdk/client-cognito-identity': 3.956.0 - '@aws-sdk/core': 3.956.0 - '@aws-sdk/credential-provider-cognito-identity': 3.956.0 - '@aws-sdk/credential-provider-env': 3.956.0 - '@aws-sdk/credential-provider-http': 3.956.0 - '@aws-sdk/credential-provider-ini': 3.956.0 - '@aws-sdk/credential-provider-login': 3.956.0 - '@aws-sdk/credential-provider-node': 3.956.0 - '@aws-sdk/credential-provider-process': 3.956.0 - '@aws-sdk/credential-provider-sso': 3.956.0 - '@aws-sdk/credential-provider-web-identity': 3.956.0 - '@aws-sdk/nested-clients': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/credential-providers@3.964.0': + dependencies: + '@aws-sdk/client-cognito-identity': 3.964.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/credential-provider-cognito-identity': 3.964.0 + '@aws-sdk/credential-provider-env': 3.964.0 + '@aws-sdk/credential-provider-http': 3.964.0 + '@aws-sdk/credential-provider-ini': 3.964.0 + '@aws-sdk/credential-provider-login': 3.964.0 + '@aws-sdk/credential-provider-node': 3.964.0 + '@aws-sdk/credential-provider-process': 3.964.0 + '@aws-sdk/credential-provider-sso': 3.964.0 + '@aws-sdk/credential-provider-web-identity': 3.964.0 + '@aws-sdk/nested-clients': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/config-resolver': 4.4.5 '@smithy/core': 3.20.0 '@smithy/credential-provider-imds': 4.2.7 @@ -10391,55 +10492,55 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/eventstream-handler-node@3.956.0': + '@aws-sdk/eventstream-handler-node@3.957.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@smithy/eventstream-codec': 4.2.7 '@smithy/types': 4.11.0 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.956.0': + '@aws-sdk/middleware-eventstream@3.957.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@smithy/protocol-http': 5.3.7 '@smithy/types': 4.11.0 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.956.0': + '@aws-sdk/middleware-host-header@3.957.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@smithy/protocol-http': 5.3.7 '@smithy/types': 4.11.0 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.956.0': + '@aws-sdk/middleware-logger@3.957.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@smithy/types': 4.11.0 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.956.0': + '@aws-sdk/middleware-recursion-detection@3.957.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@aws/lambda-invoke-store': 0.2.2 '@smithy/protocol-http': 5.3.7 '@smithy/types': 4.11.0 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.956.0': + '@aws-sdk/middleware-user-agent@3.964.0': dependencies: - '@aws-sdk/core': 3.956.0 - '@aws-sdk/types': 3.956.0 - '@aws-sdk/util-endpoints': 3.956.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-endpoints': 3.957.0 '@smithy/core': 3.20.0 '@smithy/protocol-http': 5.3.7 '@smithy/types': 4.11.0 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.956.0': + '@aws-sdk/middleware-websocket@3.957.0': dependencies: - '@aws-sdk/types': 3.956.0 - '@aws-sdk/util-format-url': 3.956.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-format-url': 3.957.0 '@smithy/eventstream-codec': 4.2.7 '@smithy/eventstream-serde-browser': 4.2.7 '@smithy/fetch-http-handler': 5.3.8 @@ -10449,20 +10550,20 @@ snapshots: '@smithy/util-hex-encoding': 4.2.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.956.0': + '@aws-sdk/nested-clients@3.964.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.956.0 - '@aws-sdk/middleware-host-header': 3.956.0 - '@aws-sdk/middleware-logger': 3.956.0 - '@aws-sdk/middleware-recursion-detection': 3.956.0 - '@aws-sdk/middleware-user-agent': 3.956.0 - '@aws-sdk/region-config-resolver': 3.956.0 - '@aws-sdk/types': 3.956.0 - '@aws-sdk/util-endpoints': 3.956.0 - '@aws-sdk/util-user-agent-browser': 3.956.0 - '@aws-sdk/util-user-agent-node': 3.956.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/middleware-host-header': 3.957.0 + '@aws-sdk/middleware-logger': 3.957.0 + '@aws-sdk/middleware-recursion-detection': 3.957.0 + '@aws-sdk/middleware-user-agent': 3.964.0 + '@aws-sdk/region-config-resolver': 3.957.0 + '@aws-sdk/types': 3.957.0 + '@aws-sdk/util-endpoints': 3.957.0 + '@aws-sdk/util-user-agent-browser': 3.957.0 + '@aws-sdk/util-user-agent-node': 3.964.0 '@smithy/config-resolver': 4.4.5 '@smithy/core': 3.20.0 '@smithy/fetch-http-handler': 5.3.8 @@ -10492,19 +10593,19 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/region-config-resolver@3.956.0': + '@aws-sdk/region-config-resolver@3.957.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@smithy/config-resolver': 4.4.5 '@smithy/node-config-provider': 4.3.7 '@smithy/types': 4.11.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.956.0': + '@aws-sdk/token-providers@3.964.0': dependencies: - '@aws-sdk/core': 3.956.0 - '@aws-sdk/nested-clients': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/core': 3.964.0 + '@aws-sdk/nested-clients': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/property-provider': 4.2.7 '@smithy/shared-ini-file-loader': 4.4.2 '@smithy/types': 4.11.0 @@ -10512,41 +10613,41 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/types@3.956.0': + '@aws-sdk/types@3.957.0': dependencies: '@smithy/types': 4.11.0 tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.956.0': + '@aws-sdk/util-endpoints@3.957.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@smithy/types': 4.11.0 '@smithy/url-parser': 4.2.7 '@smithy/util-endpoints': 3.2.7 tslib: 2.8.1 - '@aws-sdk/util-format-url@3.956.0': + '@aws-sdk/util-format-url@3.957.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@smithy/querystring-builder': 4.2.7 '@smithy/types': 4.11.0 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.953.0': + '@aws-sdk/util-locate-window@3.957.0': dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.956.0': + '@aws-sdk/util-user-agent-browser@3.957.0': dependencies: - '@aws-sdk/types': 3.956.0 + '@aws-sdk/types': 3.957.0 '@smithy/types': 4.11.0 bowser: 2.13.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.956.0': + '@aws-sdk/util-user-agent-node@3.964.0': dependencies: - '@aws-sdk/middleware-user-agent': 3.956.0 - '@aws-sdk/types': 3.956.0 + '@aws-sdk/middleware-user-agent': 3.964.0 + '@aws-sdk/types': 3.957.0 '@smithy/node-config-provider': 4.3.7 '@smithy/types': 4.11.0 tslib: 2.8.1 @@ -10555,7 +10656,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@aws-sdk/xml-builder@3.956.0': + '@aws-sdk/xml-builder@3.957.0': dependencies: '@smithy/types': 4.11.0 fast-xml-parser: 5.2.5 @@ -10947,7 +11048,7 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@dotenvx/dotenvx@1.51.2': + '@dotenvx/dotenvx@1.51.4': dependencies: commander: 11.1.0 dotenv: 17.2.3 @@ -10965,13 +11066,13 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@emnapi/core@1.7.1': + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.7.1': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true @@ -11077,7 +11178,7 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) eslint-visitor-keys: 3.4.3 @@ -11159,25 +11260,25 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.76))': + '@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@3.25.76))': dependencies: google-auth-library: 10.5.0 - ws: 8.18.3 + ws: 8.19.0 optionalDependencies: - '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.1)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.3)(zod@3.25.76) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@hono/node-server@1.19.7(hono@4.11.1)': + '@hono/node-server@1.19.7(hono@4.11.3)': dependencies: - hono: 4.11.1 + hono: 4.11.3 - '@hookform/resolvers@5.2.2(react-hook-form@7.69.0(react@18.3.1))': + '@hookform/resolvers@5.2.2(react-hook-form@7.70.0(react@18.3.1))': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.69.0(react@18.3.1) + react-hook-form: 7.70.0(react@18.3.1) '@humanfs/core@0.19.1': {} @@ -11264,7 +11365,7 @@ snapshots: '@img/sharp-wasm32@0.33.5': dependencies: - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.8.1 optional: true '@img/sharp-win32-ia32@0.33.5': @@ -11280,7 +11381,7 @@ snapshots: optionalDependencies: '@types/node': 24.10.4 - '@ioredis/commands@1.4.0': {} + '@ioredis/commands@1.5.0': {} '@isaacs/balanced-match@4.0.1': {} @@ -11336,7 +11437,7 @@ snapshots: '@lmstudio/lms-isomorphic@0.4.6': dependencies: - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -11347,7 +11448,7 @@ snapshots: chalk: 4.1.2 jsonschema: 1.5.0 zod: 3.25.76 - zod-to-json-schema: 3.25.0(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -11398,13 +11499,13 @@ snapshots: '@mistralai/mistralai@1.11.0': dependencies: zod: 3.25.76 - zod-to-json-schema: 3.25.0(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@3.25.76) '@mixmark-io/domino@2.2.0': {} - '@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.7(hono@4.11.1) + '@hono/node-server': 1.19.7(hono@4.11.3) ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 @@ -11419,7 +11520,7 @@ snapshots: pkce-challenge: 5.0.1 raw-body: 3.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.0(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - hono - supports-color @@ -11435,15 +11536,15 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.7.1 - '@emnapi/runtime': 1.7.1 + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 '@tybys/wasm-util': 0.10.1 optional: true - '@napi-rs/wasm-runtime@1.1.0': + '@napi-rs/wasm-runtime@1.1.1': dependencies: - '@emnapi/core': 1.7.1 - '@emnapi/runtime': 1.7.1 + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 '@tybys/wasm-util': 0.10.1 optional: true @@ -11558,7 +11659,7 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 + fastq: 1.20.1 '@open-draft/deferred-promise@2.2.0': {} @@ -11569,74 +11670,76 @@ snapshots: '@open-draft/until@2.1.0': {} - '@oxc-resolver/binding-android-arm-eabi@11.16.0': + '@oxc-resolver/binding-android-arm-eabi@11.16.2': optional: true - '@oxc-resolver/binding-android-arm64@11.16.0': + '@oxc-resolver/binding-android-arm64@11.16.2': optional: true - '@oxc-resolver/binding-darwin-arm64@11.16.0': + '@oxc-resolver/binding-darwin-arm64@11.16.2': optional: true - '@oxc-resolver/binding-darwin-x64@11.16.0': + '@oxc-resolver/binding-darwin-x64@11.16.2': optional: true - '@oxc-resolver/binding-freebsd-x64@11.16.0': + '@oxc-resolver/binding-freebsd-x64@11.16.2': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.16.0': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.16.2': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.16.0': + '@oxc-resolver/binding-linux-arm-musleabihf@11.16.2': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.16.0': + '@oxc-resolver/binding-linux-arm64-gnu@11.16.2': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.16.0': + '@oxc-resolver/binding-linux-arm64-musl@11.16.2': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.16.0': + '@oxc-resolver/binding-linux-ppc64-gnu@11.16.2': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.16.0': + '@oxc-resolver/binding-linux-riscv64-gnu@11.16.2': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.16.0': + '@oxc-resolver/binding-linux-riscv64-musl@11.16.2': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.16.0': + '@oxc-resolver/binding-linux-s390x-gnu@11.16.2': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.16.0': + '@oxc-resolver/binding-linux-x64-gnu@11.16.2': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.16.0': + '@oxc-resolver/binding-linux-x64-musl@11.16.2': optional: true - '@oxc-resolver/binding-openharmony-arm64@11.16.0': + '@oxc-resolver/binding-openharmony-arm64@11.16.2': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.16.0': + '@oxc-resolver/binding-wasm32-wasi@11.16.2': dependencies: - '@napi-rs/wasm-runtime': 1.1.0 + '@napi-rs/wasm-runtime': 1.1.1 optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.16.0': + '@oxc-resolver/binding-win32-arm64-msvc@11.16.2': optional: true - '@oxc-resolver/binding-win32-ia32-msvc@11.16.0': + '@oxc-resolver/binding-win32-ia32-msvc@11.16.2': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.16.0': + '@oxc-resolver/binding-win32-x64-msvc@11.16.2': optional: true '@polka/url@1.0.0-next.29': {} - '@posthog/core@1.8.1': + '@posthog/core@1.9.0': dependencies: cross-spawn: 7.0.6 + '@posthog/types@1.315.0': {} + '@puppeteer/browsers@2.11.0': dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -11672,7 +11775,7 @@ snapshots: dependencies: '@qdrant/openapi-typescript-fetch': 1.2.6 typescript: 5.8.3 - undici: 7.16.0 + undici: 7.18.2 '@qdrant/openapi-typescript-fetch@1.2.6': {} @@ -12213,70 +12316,79 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rollup/rollup-android-arm-eabi@4.54.0': + '@rollup/rollup-android-arm-eabi@4.55.1': + optional: true + + '@rollup/rollup-android-arm64@4.55.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.55.1': + optional: true + + '@rollup/rollup-darwin-x64@4.55.1': optional: true - '@rollup/rollup-android-arm64@4.54.0': + '@rollup/rollup-freebsd-arm64@4.55.1': optional: true - '@rollup/rollup-darwin-arm64@4.54.0': + '@rollup/rollup-freebsd-x64@4.55.1': optional: true - '@rollup/rollup-darwin-x64@4.54.0': + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': optional: true - '@rollup/rollup-freebsd-arm64@4.54.0': + '@rollup/rollup-linux-arm-musleabihf@4.55.1': optional: true - '@rollup/rollup-freebsd-x64@4.54.0': + '@rollup/rollup-linux-arm64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + '@rollup/rollup-linux-arm64-musl@4.55.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.54.0': + '@rollup/rollup-linux-loong64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.54.0': + '@rollup/rollup-linux-loong64-musl@4.55.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.54.0': + '@rollup/rollup-linux-ppc64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-loong64-gnu@4.54.0': + '@rollup/rollup-linux-ppc64-musl@4.55.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.54.0': + '@rollup/rollup-linux-riscv64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.54.0': + '@rollup/rollup-linux-riscv64-musl@4.55.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.54.0': + '@rollup/rollup-linux-s390x-gnu@4.55.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.54.0': + '@rollup/rollup-linux-x64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.54.0': + '@rollup/rollup-linux-x64-musl@4.55.1': optional: true - '@rollup/rollup-linux-x64-musl@4.54.0': + '@rollup/rollup-openbsd-x64@4.55.1': optional: true - '@rollup/rollup-openharmony-arm64@4.54.0': + '@rollup/rollup-openharmony-arm64@4.55.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.54.0': + '@rollup/rollup-win32-arm64-msvc@4.55.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.54.0': + '@rollup/rollup-win32-ia32-msvc@4.55.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.54.0': + '@rollup/rollup-win32-x64-gnu@4.55.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.54.0': + '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true '@sec-ant/readable-stream@0.4.1': {} @@ -12901,11 +13013,11 @@ snapshots: tailwindcss: 4.1.18 vite: 6.3.6(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - '@tanstack/query-core@5.90.12': {} + '@tanstack/query-core@5.90.16': {} - '@tanstack/react-query@5.90.12(react@18.3.1)': + '@tanstack/react-query@5.90.16(react@18.3.1)': dependencies: - '@tanstack/query-core': 5.90.12 + '@tanstack/query-core': 5.90.16 react: 18.3.1 '@testing-library/dom@10.4.1': @@ -13259,7 +13371,7 @@ snapshots: '@types/string-similarity@4.0.2': {} - '@types/stylis@4.2.5': {} + '@types/stylis@4.2.7': {} '@types/svgo@1.3.6': {} @@ -13289,95 +13401,95 @@ snapshots: '@types/node': 20.19.27 optional: true - '@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/scope-manager': 8.50.0 - '@typescript-eslint/type-utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/type-utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.52.0 eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.8.3) + ts-api-utils: 2.4.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': dependencies: - '@typescript-eslint/scope-manager': 8.50.0 - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.52.0 debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.2(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.50.0(typescript@5.8.3)': + '@typescript-eslint/project-service@8.52.0(typescript@5.8.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.8.3) - '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.8.3) + '@typescript-eslint/types': 8.52.0 debug: 4.4.3(supports-color@8.1.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.50.0': + '@typescript-eslint/scope-manager@8.52.0': dependencies: - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/visitor-keys': 8.52.0 - '@typescript-eslint/tsconfig-utils@8.50.0(typescript@5.8.3)': + '@typescript-eslint/tsconfig-utils@8.52.0(typescript@5.8.3)': dependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': dependencies: - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.2(jiti@2.6.1) - ts-api-utils: 2.1.0(typescript@5.8.3) + ts-api-utils: 2.4.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.50.0': {} + '@typescript-eslint/types@8.52.0': {} - '@typescript-eslint/typescript-estree@8.50.0(typescript@5.8.3)': + '@typescript-eslint/typescript-estree@8.52.0(typescript@5.8.3)': dependencies: - '@typescript-eslint/project-service': 8.50.0(typescript@5.8.3) - '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.8.3) - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/project-service': 8.52.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.8.3) + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/visitor-keys': 8.52.0 debug: 4.4.3(supports-color@8.1.1) minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 - ts-api-utils: 2.1.0(typescript@5.8.3) + ts-api-utils: 2.4.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.50.0 - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.8.3) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.8.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.50.0': + '@typescript-eslint/visitor-keys@8.52.0': dependencies: - '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/types': 8.52.0 eslint-visitor-keys: 4.2.1 '@typespec/ts-http-runtime@0.3.2': @@ -13471,6 +13583,14 @@ snapshots: '@vscode/codicons@0.0.36': {} + '@vscode/ripgrep@1.17.0': + dependencies: + https-proxy-agent: 7.0.6 + proxy-from-env: 1.1.0 + yauzl: 2.10.0 + transitivePeerDependencies: + - supports-color + '@vscode/test-cli@0.0.11': dependencies: '@types/mocha': 10.0.10 @@ -13816,7 +13936,7 @@ snapshots: autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001761 + caniuse-lite: 1.0.30001762 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.6 @@ -13890,7 +14010,7 @@ snapshots: baseline-browser-mapping@2.9.11: {} - basic-ftp@5.0.5: {} + basic-ftp@5.1.0: {} better-path-resolve@1.0.0: dependencies: @@ -13923,7 +14043,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.1 on-finished: 2.4.1 - qs: 6.14.0 + qs: 6.14.1 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -13931,6 +14051,8 @@ snapshots: boolbase@1.0.0: {} + boolean@3.2.0: {} + bowser@2.13.1: {} brace-expansion@2.0.2: @@ -13946,7 +14068,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.11 - caniuse-lite: 1.0.30001761 + caniuse-lite: 1.0.30001762 electron-to-chromium: 1.5.267 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -14031,14 +14153,14 @@ snapshots: camelize@1.0.1: {} - caniuse-lite@1.0.30001761: {} + caniuse-lite@1.0.30001762: {} ccount@2.0.1: {} chai@5.3.3: dependencies: assertion-error: 2.0.1 - check-error: 2.1.1 + check-error: 2.1.3 deep-eql: 5.0.2 loupe: 3.2.1 pathval: 2.0.1 @@ -14076,7 +14198,7 @@ snapshots: chardet@2.1.1: {} - check-error@2.1.1: {} + check-error@2.1.3: {} cheerio-select@2.1.0: dependencies: @@ -14098,7 +14220,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.16.0 + undici: 7.18.2 whatwg-mimetype: 4.0.0 chevrotain-allstar@0.3.1(chevrotain@11.0.3): @@ -14640,10 +14762,6 @@ snapshots: debounce@2.2.0: {} - debug@4.3.7: - dependencies: - ms: 2.1.3 - debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -14725,6 +14843,8 @@ snapshots: detect-node-es@1.1.0: {} + detect-node@2.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -14813,9 +14933,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.7(postgres@3.4.7): + drizzle-orm@0.44.7(postgres@3.4.8): optionalDependencies: - postgres: 3.4.7 + postgres: 3.4.8 duck@0.1.12: dependencies: @@ -14887,12 +15007,12 @@ snapshots: dependencies: once: 1.4.0 - engine.io-client@6.6.3: + engine.io-client@6.6.4: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7 + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 - ws: 8.17.1 + ws: 8.18.3 xmlhttprequest-ssl: 2.1.2 transitivePeerDependencies: - bufferutil @@ -15028,6 +15148,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es6-error@4.1.1: {} + esbuild-register@3.6.0(esbuild@0.27.2): dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -15118,11 +15240,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@2.7.1(eslint@9.39.2(jiti@2.6.1))(turbo@2.7.1): + eslint-plugin-turbo@2.7.3(eslint@9.39.2(jiti@2.6.1))(turbo@2.7.3): dependencies: dotenv: 16.0.3 eslint: 9.39.2(jiti@2.6.1) - turbo: 2.7.1 + turbo: 2.7.3 eslint-scope@8.4.0: dependencies: @@ -15135,7 +15257,7 @@ snapshots: eslint@9.39.2(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -15155,7 +15277,7 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.6.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -15182,7 +15304,7 @@ snapshots: esprima@4.0.1: {} - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -15329,7 +15451,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.14.0 + qs: 6.14.1 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -15405,7 +15527,7 @@ snapshots: fastest-stable-stringify@2.0.2: {} - fastq@1.19.1: + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -15475,7 +15597,7 @@ snapshots: dependencies: magic-string: 0.30.21 mlly: 1.8.0 - rollup: 4.54.0 + rollup: 4.55.1 flat-cache@4.0.1: dependencies: @@ -15526,8 +15648,8 @@ snapshots: framer-motion@12.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - motion-dom: 12.23.23 - motion-utils: 12.23.6 + motion-dom: 12.24.3 + motion-utils: 12.23.28 tslib: 2.8.1 optionalDependencies: '@emotion/is-prop-valid': 1.2.2 @@ -15673,7 +15795,7 @@ snapshots: get-uri@6.0.5: dependencies: - basic-ftp: 5.0.5 + basic-ftp: 5.1.0 data-uri-to-buffer: 6.0.2 debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: @@ -15696,6 +15818,15 @@ snapshots: minipass: 7.1.2 path-scurry: 2.0.1 + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.3 + serialize-error: 7.0.1 + globals@14.0.0: {} globals@16.5.0: {} @@ -15938,7 +16069,7 @@ snapshots: highlight.js@11.11.1: {} - hono@4.11.1: {} + hono@4.11.3: {} hosted-git-info@4.1.0: dependencies: @@ -16071,9 +16202,9 @@ snapshots: invert-kv@3.0.1: {} - ioredis@5.8.2: + ioredis@5.9.0: dependencies: - '@ioredis/commands': 1.4.0 + '@ioredis/commands': 1.5.0 cluster-key-slot: 1.1.2 debug: 4.4.3(supports-color@8.1.1) denque: 2.1.0 @@ -16414,7 +16545,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.18.3 + ws: 8.19.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -16513,7 +16644,7 @@ snapshots: kind-of@6.0.3: {} - knip@5.76.3(@types/node@24.10.4)(typescript@5.8.3): + knip@5.80.0(@types/node@24.10.4)(typescript@5.8.3): dependencies: '@nodelib/fs.walk': 1.2.8 '@types/node': 24.10.4 @@ -16522,13 +16653,13 @@ snapshots: jiti: 2.6.1 js-yaml: 4.1.1 minimist: 1.2.8 - oxc-resolver: 11.16.0 + oxc-resolver: 11.16.2 picocolors: 1.1.1 picomatch: 4.0.3 smol-toml: 1.6.0 strip-json-comments: 5.0.3 typescript: 5.8.3 - zod: 4.2.1 + zod: 4.3.5 knuth-shuffle-seeded@1.0.6: dependencies: @@ -16806,6 +16937,10 @@ snapshots: marked@16.4.2: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + math-intrinsics@1.1.0: {} mdast-util-definitions@4.0.0: @@ -17317,7 +17452,7 @@ snapshots: acorn: 8.15.0 pathe: 2.0.3 pkg-types: 1.3.1 - ufo: 1.6.1 + ufo: 1.6.2 mocha@11.7.5: dependencies: @@ -17349,11 +17484,11 @@ snapshots: fs-extra: 7.0.1 tslib: 2.8.1 - motion-dom@12.23.23: + motion-dom@12.24.3: dependencies: - motion-utils: 12.23.6 + motion-utils: 12.23.28 - motion-utils@12.23.6: {} + motion-utils@12.23.28: {} mri@1.2.0: {} @@ -17414,7 +17549,7 @@ snapshots: '@swc/counter': 0.1.3 '@swc/helpers': 0.5.15 busboy: 1.6.0 - caniuse-lite: 1.0.30001761 + caniuse-lite: 1.0.30001762 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -17612,9 +17747,9 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - openai@5.23.2(ws@8.18.3)(zod@3.25.76): + openai@5.23.2(ws@8.19.0)(zod@3.25.76): optionalDependencies: - ws: 8.18.3 + ws: 8.19.0 zod: 3.25.76 option@0.2.4: {} @@ -17676,28 +17811,28 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-resolver@11.16.0: + oxc-resolver@11.16.2: optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.16.0 - '@oxc-resolver/binding-android-arm64': 11.16.0 - '@oxc-resolver/binding-darwin-arm64': 11.16.0 - '@oxc-resolver/binding-darwin-x64': 11.16.0 - '@oxc-resolver/binding-freebsd-x64': 11.16.0 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.16.0 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.16.0 - '@oxc-resolver/binding-linux-arm64-gnu': 11.16.0 - '@oxc-resolver/binding-linux-arm64-musl': 11.16.0 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.16.0 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.16.0 - '@oxc-resolver/binding-linux-riscv64-musl': 11.16.0 - '@oxc-resolver/binding-linux-s390x-gnu': 11.16.0 - '@oxc-resolver/binding-linux-x64-gnu': 11.16.0 - '@oxc-resolver/binding-linux-x64-musl': 11.16.0 - '@oxc-resolver/binding-openharmony-arm64': 11.16.0 - '@oxc-resolver/binding-wasm32-wasi': 11.16.0 - '@oxc-resolver/binding-win32-arm64-msvc': 11.16.0 - '@oxc-resolver/binding-win32-ia32-msvc': 11.16.0 - '@oxc-resolver/binding-win32-x64-msvc': 11.16.0 + '@oxc-resolver/binding-android-arm-eabi': 11.16.2 + '@oxc-resolver/binding-android-arm64': 11.16.2 + '@oxc-resolver/binding-darwin-arm64': 11.16.2 + '@oxc-resolver/binding-darwin-x64': 11.16.2 + '@oxc-resolver/binding-freebsd-x64': 11.16.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.16.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.16.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.16.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.16.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.16.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.16.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.16.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.16.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.16.2 + '@oxc-resolver/binding-linux-x64-musl': 11.16.2 + '@oxc-resolver/binding-openharmony-arm64': 11.16.2 + '@oxc-resolver/binding-wasm32-wasi': 11.16.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.16.2 + '@oxc-resolver/binding-win32-ia32-msvc': 11.16.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.16.2 p-filter@2.1.0: dependencies: @@ -17954,21 +18089,22 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres@3.4.7: {} + postgres@3.4.8: {} - posthog-js@1.309.1: + posthog-js@1.315.0: dependencies: - '@posthog/core': 1.8.1 + '@posthog/core': 1.9.0 + '@posthog/types': 1.315.0 core-js: 3.47.0 fflate: 0.4.8 - preact: 10.28.0 + preact: 10.28.2 web-vitals: 4.2.4 - posthog-node@5.17.4: + posthog-node@5.19.0: dependencies: - '@posthog/core': 1.8.1 + '@posthog/core': 1.9.0 - preact@10.28.0: {} + preact@10.28.2: {} prebuild-install@7.1.3: dependencies: @@ -18094,7 +18230,7 @@ snapshots: debug: 4.4.3(supports-color@8.1.1) devtools-protocol: 0.0.1367902 typed-query-selector: 2.12.0 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -18111,7 +18247,7 @@ snapshots: devtools-protocol: 0.0.1534754 typed-query-selector: 2.12.0 webdriver-bidi-protocol: 0.3.10 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -18128,7 +18264,7 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 - qs@6.14.0: + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -18170,7 +18306,7 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-hook-form@7.69.0(react@18.3.1): + react-hook-form@7.70.0(react@18.3.1): dependencies: react: 18.3.1 @@ -18301,7 +18437,7 @@ snapshots: ts-easing: 0.2.0 tslib: 2.8.1 - react-virtuoso@4.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-virtuoso@4.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -18580,34 +18716,46 @@ snapshots: glob: 13.0.0 package-json-from-dist: 1.0.1 + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + robust-predicates@3.0.2: {} - rollup@4.54.0: + rollup@4.55.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.54.0 - '@rollup/rollup-android-arm64': 4.54.0 - '@rollup/rollup-darwin-arm64': 4.54.0 - '@rollup/rollup-darwin-x64': 4.54.0 - '@rollup/rollup-freebsd-arm64': 4.54.0 - '@rollup/rollup-freebsd-x64': 4.54.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.54.0 - '@rollup/rollup-linux-arm-musleabihf': 4.54.0 - '@rollup/rollup-linux-arm64-gnu': 4.54.0 - '@rollup/rollup-linux-arm64-musl': 4.54.0 - '@rollup/rollup-linux-loong64-gnu': 4.54.0 - '@rollup/rollup-linux-ppc64-gnu': 4.54.0 - '@rollup/rollup-linux-riscv64-gnu': 4.54.0 - '@rollup/rollup-linux-riscv64-musl': 4.54.0 - '@rollup/rollup-linux-s390x-gnu': 4.54.0 - '@rollup/rollup-linux-x64-gnu': 4.54.0 - '@rollup/rollup-linux-x64-musl': 4.54.0 - '@rollup/rollup-openharmony-arm64': 4.54.0 - '@rollup/rollup-win32-arm64-msvc': 4.54.0 - '@rollup/rollup-win32-ia32-msvc': 4.54.0 - '@rollup/rollup-win32-x64-gnu': 4.54.0 - '@rollup/rollup-win32-x64-msvc': 4.54.0 + '@rollup/rollup-android-arm-eabi': 4.55.1 + '@rollup/rollup-android-arm64': 4.55.1 + '@rollup/rollup-darwin-arm64': 4.55.1 + '@rollup/rollup-darwin-x64': 4.55.1 + '@rollup/rollup-freebsd-arm64': 4.55.1 + '@rollup/rollup-freebsd-x64': 4.55.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.55.1 + '@rollup/rollup-linux-arm-musleabihf': 4.55.1 + '@rollup/rollup-linux-arm64-gnu': 4.55.1 + '@rollup/rollup-linux-arm64-musl': 4.55.1 + '@rollup/rollup-linux-loong64-gnu': 4.55.1 + '@rollup/rollup-linux-loong64-musl': 4.55.1 + '@rollup/rollup-linux-ppc64-gnu': 4.55.1 + '@rollup/rollup-linux-ppc64-musl': 4.55.1 + '@rollup/rollup-linux-riscv64-gnu': 4.55.1 + '@rollup/rollup-linux-riscv64-musl': 4.55.1 + '@rollup/rollup-linux-s390x-gnu': 4.55.1 + '@rollup/rollup-linux-x64-gnu': 4.55.1 + '@rollup/rollup-linux-x64-musl': 4.55.1 + '@rollup/rollup-openbsd-x64': 4.55.1 + '@rollup/rollup-openharmony-arm64': 4.55.1 + '@rollup/rollup-win32-arm64-msvc': 4.55.1 + '@rollup/rollup-win32-ia32-msvc': 4.55.1 + '@rollup/rollup-win32-x64-gnu': 4.55.1 + '@rollup/rollup-win32-x64-msvc': 4.55.1 fsevents: 2.3.3 roughjs@4.6.6: @@ -18743,6 +18891,10 @@ snapshots: dependencies: type-fest: 4.41.0 + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -18919,21 +19071,21 @@ snapshots: smol-toml@1.6.0: {} - socket.io-client@4.8.1: + socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7 - engine.io-client: 6.6.3 - socket.io-parser: 4.2.4 + debug: 4.4.3(supports-color@8.1.1) + engine.io-client: 6.6.4 + socket.io-parser: 4.2.5 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - socket.io-parser@4.2.4: + socket.io-parser@4.2.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -18985,6 +19137,8 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} + stable@0.1.8: {} stack-generator@2.0.10: @@ -19181,18 +19335,18 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-components@6.1.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + styled-components@6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@emotion/is-prop-valid': 1.2.2 '@emotion/unitless': 0.8.1 - '@types/stylis': 4.2.5 + '@types/stylis': 4.2.7 css-to-react-native: 3.2.0 csstype: 3.2.3 postcss: 8.4.49 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) shallowequal: 1.1.0 - stylis: 4.3.2 + stylis: 4.3.6 tslib: 2.6.2 styled-jsx@5.1.6(react@18.3.1): @@ -19200,8 +19354,6 @@ snapshots: client-only: 0.0.1 react: 18.3.1 - stylis@4.3.2: {} - stylis@4.3.6: {} sucrase@3.35.1: @@ -19419,7 +19571,7 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 - ts-api-utils@2.1.0(typescript@5.8.3): + ts-api-utils@2.4.0(typescript@5.8.3): dependencies: typescript: 5.8.3 @@ -19448,7 +19600,7 @@ snapshots: picocolors: 1.1.1 postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) resolve-from: 5.0.0 - rollup: 4.54.0 + rollup: 4.55.1 source-map: 0.7.6 sucrase: 3.35.1 tinyexec: 0.3.2 @@ -19477,32 +19629,32 @@ snapshots: tunnel@0.0.6: {} - turbo-darwin-64@2.7.1: + turbo-darwin-64@2.7.3: optional: true - turbo-darwin-arm64@2.7.1: + turbo-darwin-arm64@2.7.3: optional: true - turbo-linux-64@2.7.1: + turbo-linux-64@2.7.3: optional: true - turbo-linux-arm64@2.7.1: + turbo-linux-arm64@2.7.3: optional: true - turbo-windows-64@2.7.1: + turbo-windows-64@2.7.3: optional: true - turbo-windows-arm64@2.7.1: + turbo-windows-arm64@2.7.3: optional: true - turbo@2.7.1: + turbo@2.7.3: optionalDependencies: - turbo-darwin-64: 2.7.1 - turbo-darwin-arm64: 2.7.1 - turbo-linux-64: 2.7.1 - turbo-linux-arm64: 2.7.1 - turbo-windows-64: 2.7.1 - turbo-windows-arm64: 2.7.1 + turbo-darwin-64: 2.7.3 + turbo-darwin-arm64: 2.7.3 + turbo-linux-64: 2.7.3 + turbo-linux-arm64: 2.7.3 + turbo-windows-64: 2.7.3 + turbo-windows-arm64: 2.7.3 turndown@7.2.2: dependencies: @@ -19512,6 +19664,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.13.1: {} + type-fest@4.41.0: {} type-is@2.0.1: @@ -19557,16 +19711,16 @@ snapshots: typed-rest-client@1.8.11: dependencies: - qs: 6.14.0 + qs: 6.14.1 tunnel: 0.0.6 underscore: 1.13.7 - typescript-eslint@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3): + typescript-eslint@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/eslint-plugin': 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: @@ -19576,7 +19730,7 @@ snapshots: uc.micro@2.1.0: {} - ufo@1.6.1: {} + ufo@1.6.2: {} unbox-primitive@1.1.0: dependencies: @@ -19598,7 +19752,7 @@ snapshots: undici-types@7.16.0: {} - undici@7.16.0: {} + undici@7.18.2: {} unicode-trie@2.0.0: dependencies: @@ -19891,7 +20045,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.54.0 + rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 20.19.27 @@ -19907,7 +20061,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.54.0 + rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 24.10.4 @@ -20163,10 +20317,10 @@ snapshots: wrappy@1.0.2: {} - ws@8.17.1: {} - ws@8.18.3: {} + ws@8.19.0: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 @@ -20283,7 +20437,7 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 - zod-to-json-schema@3.25.0(zod@3.25.76): + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 @@ -20296,6 +20450,6 @@ snapshots: zod@3.25.76: {} - zod@4.2.1: {} + zod@4.3.5: {} zwitch@2.0.4: {} diff --git a/src/api/providers/__tests__/base-provider.spec.ts b/src/api/providers/__tests__/base-provider.spec.ts new file mode 100644 index 0000000000..ced452f5a5 --- /dev/null +++ b/src/api/providers/__tests__/base-provider.spec.ts @@ -0,0 +1,283 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +import type { ModelInfo } from "@roo-code/types" + +import { BaseProvider } from "../base-provider" +import type { ApiStream } from "../../transform/stream" + +// Create a concrete implementation for testing +class TestProvider extends BaseProvider { + createMessage(_systemPrompt: string, _messages: Anthropic.Messages.MessageParam[]): ApiStream { + throw new Error("Not implemented") + } + + getModel(): { id: string; info: ModelInfo } { + return { + id: "test-model", + info: { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + } + + // Expose protected method for testing + public testConvertToolSchemaForOpenAI(schema: any): any { + return this.convertToolSchemaForOpenAI(schema) + } + + // Expose protected method for testing + public testConvertToolsForOpenAI(tools: any[] | undefined): any[] | undefined { + return this.convertToolsForOpenAI(tools) + } +} + +describe("BaseProvider", () => { + let provider: TestProvider + + beforeEach(() => { + provider = new TestProvider() + }) + + describe("convertToolSchemaForOpenAI", () => { + it("should add additionalProperties: false to object schemas", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.additionalProperties).toBe(false) + }) + + it("should add required array with all properties for strict mode", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.required).toEqual(["name", "age"]) + }) + + it("should recursively add additionalProperties: false to nested objects", () => { + const schema = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.additionalProperties).toBe(false) + expect(result.properties.user.additionalProperties).toBe(false) + }) + + it("should recursively add additionalProperties: false to array item objects", () => { + const schema = { + type: "object", + properties: { + users: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.additionalProperties).toBe(false) + expect(result.properties.users.items.additionalProperties).toBe(false) + }) + + it("should handle deeply nested objects", () => { + const schema = { + type: "object", + properties: { + level1: { + type: "object", + properties: { + level2: { + type: "object", + properties: { + level3: { + type: "object", + properties: { + value: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.additionalProperties).toBe(false) + expect(result.properties.level1.additionalProperties).toBe(false) + expect(result.properties.level1.properties.level2.additionalProperties).toBe(false) + expect(result.properties.level1.properties.level2.properties.level3.additionalProperties).toBe(false) + }) + + it("should convert nullable types to non-nullable", () => { + const schema = { + type: "object", + properties: { + name: { type: ["string", "null"] }, + }, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.properties.name.type).toBe("string") + }) + + it("should return non-object schemas unchanged", () => { + const schema = { type: "string" } + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result).toEqual(schema) + }) + + it("should return null/undefined unchanged", () => { + expect(provider.testConvertToolSchemaForOpenAI(null)).toBeNull() + expect(provider.testConvertToolSchemaForOpenAI(undefined)).toBeUndefined() + }) + + it("should handle empty properties object", () => { + const schema = { + type: "object", + properties: {}, + } + + const result = provider.testConvertToolSchemaForOpenAI(schema) + + expect(result.additionalProperties).toBe(false) + expect(result.required).toEqual([]) + }) + }) + + describe("convertToolsForOpenAI", () => { + it("should return undefined for undefined input", () => { + const result = provider.testConvertToolsForOpenAI(undefined) + expect(result).toBeUndefined() + }) + + it("should set strict: true for non-MCP tools", () => { + const tools = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: {} }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools) + + expect(result?.[0].function.strict).toBe(true) + }) + + it("should set strict: false for MCP tools (mcp-- prefix)", () => { + const tools = [ + { + type: "function", + function: { + name: "mcp--github--get_me", + description: "Get current user", + parameters: { type: "object", properties: {} }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools) + + expect(result?.[0].function.strict).toBe(false) + }) + + it("should apply schema conversion to non-MCP tools", () => { + const tools = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + }, + }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools) + + expect(result?.[0].function.parameters.additionalProperties).toBe(false) + expect(result?.[0].function.parameters.required).toEqual(["path"]) + }) + + it("should not apply schema conversion to MCP tools in base-provider", () => { + // Note: In base-provider, MCP tools are passed through unchanged + // The openai-native provider has its own handling for MCP tools + const tools = [ + { + type: "function", + function: { + name: "mcp--github--get_me", + description: "Get current user", + parameters: { + type: "object", + properties: { + token: { type: "string" }, + }, + required: ["token"], + }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools) + + // MCP tools pass through original parameters in base-provider + expect(result?.[0].function.parameters.additionalProperties).toBeUndefined() + }) + + it("should preserve non-function tools unchanged", () => { + const tools = [ + { + type: "other_type", + data: "some data", + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools) + + expect(result?.[0]).toEqual(tools[0]) + }) + }) +}) diff --git a/src/api/providers/__tests__/openai-native-tools.spec.ts b/src/api/providers/__tests__/openai-native-tools.spec.ts index 1a3b93b9c2..7be4814b63 100644 --- a/src/api/providers/__tests__/openai-native-tools.spec.ts +++ b/src/api/providers/__tests__/openai-native-tools.spec.ts @@ -1,6 +1,8 @@ import OpenAI from "openai" import { OpenAiHandler } from "../openai" +import { OpenAiNativeHandler } from "../openai-native" +import type { ApiHandlerOptions } from "../../../shared/api" describe("OpenAiHandler native tools", () => { it("includes tools in request when custom model info lacks supportsNativeTools (regression test)", async () => { @@ -75,3 +77,220 @@ describe("OpenAiHandler native tools", () => { ) }) }) + +describe("OpenAiNativeHandler MCP tool schema handling", () => { + it("should add additionalProperties: false to MCP tools while keeping strict: false", async () => { + let capturedRequestBody: any + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) + + // Mock the responses API call + const mockClient = { + responses: { + create: vi.fn().mockImplementation((body: any) => { + capturedRequestBody = body + return { + [Symbol.asyncIterator]: async function* () { + yield { + type: "response.done", + response: { + output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], + usage: { input_tokens: 10, output_tokens: 5 }, + }, + } + }, + } + }), + }, + } + ;(handler as any).client = mockClient + + const mcpTools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "mcp--github--get_me", + description: "Get current GitHub user", + parameters: { + type: "object", + properties: { + token: { type: "string", description: "API token" }, + }, + required: ["token"], + }, + }, + }, + ] + + const stream = handler.createMessage("system prompt", [], { + taskId: "test-task-id", + tools: mcpTools, + toolProtocol: "native" as const, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + // Verify the request body + expect(capturedRequestBody.tools).toBeDefined() + expect(capturedRequestBody.tools.length).toBe(1) + + const tool = capturedRequestBody.tools[0] + expect(tool.name).toBe("mcp--github--get_me") + expect(tool.strict).toBe(false) // MCP tools should have strict: false + expect(tool.parameters.additionalProperties).toBe(false) // Should have additionalProperties: false + expect(tool.parameters.required).toEqual(["token"]) // Should preserve original required array + }) + + it("should add additionalProperties: false and required array to non-MCP tools with strict: true", async () => { + let capturedRequestBody: any + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) + + // Mock the responses API call + const mockClient = { + responses: { + create: vi.fn().mockImplementation((body: any) => { + capturedRequestBody = body + return { + [Symbol.asyncIterator]: async function* () { + yield { + type: "response.done", + response: { + output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], + usage: { input_tokens: 10, output_tokens: 5 }, + }, + } + }, + } + }), + }, + } + ;(handler as any).client = mockClient + + const regularTools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file from the filesystem", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + encoding: { type: "string", description: "File encoding" }, + }, + }, + }, + }, + ] + + const stream = handler.createMessage("system prompt", [], { + taskId: "test-task-id", + tools: regularTools, + toolProtocol: "native" as const, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + // Verify the request body + expect(capturedRequestBody.tools).toBeDefined() + expect(capturedRequestBody.tools.length).toBe(1) + + const tool = capturedRequestBody.tools[0] + expect(tool.name).toBe("read_file") + expect(tool.strict).toBe(true) // Non-MCP tools should have strict: true + expect(tool.parameters.additionalProperties).toBe(false) // Should have additionalProperties: false + expect(tool.parameters.required).toEqual(["path", "encoding"]) // Should have all properties as required + }) + + it("should recursively add additionalProperties: false to nested objects in MCP tools", async () => { + let capturedRequestBody: any + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) + + // Mock the responses API call + const mockClient = { + responses: { + create: vi.fn().mockImplementation((body: any) => { + capturedRequestBody = body + return { + [Symbol.asyncIterator]: async function* () { + yield { + type: "response.done", + response: { + output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], + usage: { input_tokens: 10, output_tokens: 5 }, + }, + } + }, + } + }), + }, + } + ;(handler as any).client = mockClient + + const mcpToolsWithNestedObjects: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "mcp--linear--create_issue", + description: "Create a Linear issue", + parameters: { + type: "object", + properties: { + title: { type: "string" }, + metadata: { + type: "object", + properties: { + priority: { type: "number" }, + labels: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ] + + const stream = handler.createMessage("system prompt", [], { + taskId: "test-task-id", + tools: mcpToolsWithNestedObjects, + toolProtocol: "native" as const, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + // Verify the request body + const tool = capturedRequestBody.tools[0] + expect(tool.strict).toBe(false) // MCP tool should have strict: false + expect(tool.parameters.additionalProperties).toBe(false) // Root level + expect(tool.parameters.properties.metadata.additionalProperties).toBe(false) // Nested object + expect(tool.parameters.properties.metadata.properties.labels.items.additionalProperties).toBe(false) // Array items + }) +}) diff --git a/src/api/providers/base-provider.ts b/src/api/providers/base-provider.ts index 64d99b3f0c..a6adeeadbd 100644 --- a/src/api/providers/base-provider.ts +++ b/src/api/providers/base-provider.ts @@ -55,6 +55,7 @@ export abstract class BaseProvider implements ApiHandler { * Converts tool schemas to be compatible with OpenAI's strict mode by: * - Ensuring all properties are in the required array (strict mode requirement) * - Converting nullable types (["type", "null"]) to non-nullable ("type") + * - Adding additionalProperties: false to all object schemas (required by OpenAI Responses API) * - Recursively processing nested objects and arrays * * This matches the behavior of ensureAllRequired in openai-native.ts @@ -66,6 +67,12 @@ export abstract class BaseProvider implements ApiHandler { const result = { ...schema } + // OpenAI Responses API requires additionalProperties: false on all object schemas + // Only add if not already set to false (to avoid unnecessary mutations) + if (result.additionalProperties !== false) { + result.additionalProperties = false + } + if (result.properties) { const allKeys = Object.keys(result.properties) // OpenAI strict mode requires ALL properties to be in required array diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 8ba8c390d3..6856fa9a0a 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -196,6 +196,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio const result = { ...schema } + // OpenAI Responses API requires additionalProperties: false on all object schemas + // Only add if not already set to false (to avoid unnecessary mutations) + if (result.additionalProperties !== false) { + result.additionalProperties = false + } + if (result.properties) { const allKeys = Object.keys(result.properties) result.required = allKeys @@ -219,6 +225,42 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio return result } + // Adds additionalProperties: false to all object schemas recursively + // without modifying required array. Used for MCP tools with strict: false + // to comply with OpenAI Responses API requirements. + const ensureAdditionalPropertiesFalse = (schema: any): any => { + if (!schema || typeof schema !== "object" || schema.type !== "object") { + return schema + } + + const result = { ...schema } + + // OpenAI Responses API requires additionalProperties: false on all object schemas + // Only add if not already set to false (to avoid unnecessary mutations) + if (result.additionalProperties !== false) { + result.additionalProperties = false + } + + if (result.properties) { + // Recursively process nested objects + const newProps = { ...result.properties } + for (const key of Object.keys(result.properties)) { + const prop = newProps[key] + if (prop && prop.type === "object") { + newProps[key] = ensureAdditionalPropertiesFalse(prop) + } else if (prop && prop.type === "array" && prop.items?.type === "object") { + newProps[key] = { + ...prop, + items: ensureAdditionalPropertiesFalse(prop.items), + } + } + } + result.properties = newProps + } + + return result + } + // Build a request body for the OpenAI Responses API. // Ensure we explicitly pass max_output_tokens based on Roo's reserved model response calculation // so requests do not default to very large limits (e.g., 120k). @@ -295,12 +337,15 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio .map((tool) => { // MCP tools use the 'mcp--' prefix - disable strict mode for them // to preserve optional parameters from the MCP server schema + // But we still need to add additionalProperties: false for OpenAI Responses API const isMcp = isMcpTool(tool.function.name) return { type: "function", name: tool.function.name, description: tool.function.description, - parameters: isMcp ? tool.function.parameters : ensureAllRequired(tool.function.parameters), + parameters: isMcp + ? ensureAdditionalPropertiesFalse(tool.function.parameters) + : ensureAllRequired(tool.function.parameters), strict: !isMcp, } }), diff --git a/src/api/providers/zgsm.ts b/src/api/providers/zgsm.ts index 39ec958eab..1c39a6a262 100644 --- a/src/api/providers/zgsm.ts +++ b/src/api/providers/zgsm.ts @@ -40,6 +40,7 @@ import { ClineApiReqCancelReason } from "../../shared/ExtensionMessage" import { getEditorType } from "../../utils/getEditorType" import { ChatCompletionChunk } from "openai/resources/index.mjs" import { convertToZAiFormat } from "../transform/zai-format" +import { isDebug } from "../../utils/getDebugState" const autoModeModelId = "Auto" const isDev = process.env.NODE_ENV === "development" @@ -345,7 +346,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl return { "Accept-Language": metadata?.language || "en", ...COSTRICT_DEFAULT_HEADERS, - ...(this.options.useZgsmCustomConfig ? (this.options.openAiHeaders ?? {}) : {}), + ...(this.options.useZgsmCustomConfig && isDebug() ? (this.options.openAiHeaders ?? {}) : {}), "x-quota-identity": chatType || "system", "X-Request-ID": requestId, "x-user-id": metadata?.userId || "", @@ -378,7 +379,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) } else if (isArk || isLegacyFormat) { convertedMessages = [{ role: "system", content: systemPrompt }, ...convertToSimpleMessages(messages)] - } else if (_mid?.includes("glm") || isMiniMax || _mid?.includes("claude")) { + } else if (isNative && (_mid?.includes("glm") || isMiniMax || _mid?.includes("claude"))) { convertedMessages = [ { role: "system", content: systemPrompt }, ...convertToZAiFormat(messages, { mergeToolResultText: true }), @@ -397,7 +398,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl } : { role: "system" as const, content: systemPrompt } - convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages, { mergeToolResultText: true })] + convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages, { mergeToolResultText: isNative })] } // Apply cache control logic @@ -775,14 +776,18 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl override getModel() { const id = this.options.zgsmModelId ?? zgsmDefaultModelId const defaultInfo = this.modelInfo - const info = this.options.useZgsmCustomConfig - ? { - ...NATIVE_TOOL_DEFAULTS, - ...defaultInfo, - ...(this.options.zgsmAiCustomModelInfo ?? {}), - } - : defaultInfo + const info = + this.options.useZgsmCustomConfig && isDebug() + ? { + ...NATIVE_TOOL_DEFAULTS, + ...defaultInfo, + ...(this.options.zgsmAiCustomModelInfo ?? {}), + } + : defaultInfo const params = getModelParams({ format: "zgsm", modelId: id, model: info, settings: this.options }) + if (!info.id) { + info.id = id + } return { id, info, ...params } } @@ -1021,7 +1026,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl const isAutoMode = modelInfo.id === "Auto" || modelInfo.id === "auto" // Only add max_completion_tokens if includeMaxTokens is true - if (this.options.useZgsmCustomConfig) { + if (this.options.useZgsmCustomConfig && isDebug()) { const maxTokens = this.options.modelMaxTokens || modelInfo.maxTokens // Use user-configured modelMaxTokens if available, otherwise fall back to model's default maxTokens // Using max_completion_tokens as max_tokens is deprecated diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 03e5c40923..7dd7cb9121 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -18,6 +18,7 @@ import type { ApiStreamToolCallEndChunk, } from "../../api/transform/stream" import { MCP_TOOL_PREFIX, MCP_TOOL_SEPARATOR, parseMcpToolName } from "../../utils/mcp-name" +import { fixNativeToolname } from "../../utils/fixNativeToolname" /** * Helper type to extract properly typed native arguments for a given tool. @@ -205,7 +206,7 @@ export class NativeToolCallParser { public static startStreamingToolCall(id: string, name: string): void { this.streamingToolCalls.set(id, { id, - name, + name: fixNativeToolname(name), argumentsAccumulator: "", }) } @@ -862,8 +863,6 @@ export class NativeToolCallParser { default: if (customToolRegistry.has(resolvedName)) { nativeArgs = args as NativeArgsFor - } else { - console.error(`Unhandled tool: ${resolvedName}`) } break diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts index 6ad8c58282..e90646fd9a 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -77,6 +77,18 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { say: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), } + + // Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask + mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { + const existingResult = mockTask.userMessageContent.find( + (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + mockTask.userMessageContent.push(toolResult) + return true + }) }) describe("Custom tool usage recording", () => { diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts index 39d71bc88b..72ee430609 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts @@ -60,6 +60,18 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () => say: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), } + + // Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask + mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { + const existingResult = mockTask.userMessageContent.find( + (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + mockTask.userMessageContent.push(toolResult) + return true + }) }) it("should preserve images in tool_result for native protocol", async () => { diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts index 2c71dc7811..d4ae2764a0 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts @@ -59,6 +59,18 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { say: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), } + + // Add pushToolResultToUserContent method after mockTask is created so 'this' binds correctly + mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { + const existingResult = mockTask.userMessageContent.find( + (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + mockTask.userMessageContent.push(toolResult) + return true + }) }) it("should return error for unknown tool in native protocol", async () => { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 369aae3582..bb64aedb67 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -121,12 +121,12 @@ export async function presentAssistantMessage(cline: Task) { : `MCP tool ${mcpBlock.name} was interrupted and not executed due to user rejecting a previous tool.` if (toolCallId) { - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: errorMessage, is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } break } @@ -136,12 +136,12 @@ export async function presentAssistantMessage(cline: Task) { const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.` if (toolCallId) { - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: errorMessage, is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } break } @@ -180,11 +180,11 @@ export async function presentAssistantMessage(cline: Task) { } if (toolCallId) { - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: resultContent, - } as Anthropic.ToolResultBlockParam) + }) if (imageBlocks.length > 0) { cline.userMessageContent.push(...imageBlocks) @@ -464,12 +464,12 @@ export async function presentAssistantMessage(cline: Task) { if (toolCallId) { // Native protocol: MUST send tool_result for every tool_use - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: errorMessage, is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } else { // XML protocol: send as text cline.userMessageContent.push({ @@ -489,12 +489,12 @@ export async function presentAssistantMessage(cline: Task) { if (toolCallId) { // Native protocol: MUST send tool_result for every tool_use - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: errorMessage, is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } else { // XML protocol: send as text cline.userMessageContent.push({ @@ -556,11 +556,11 @@ export async function presentAssistantMessage(cline: Task) { } // Add tool_result with text content only - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: resultContent, - } as Anthropic.ToolResultBlockParam) + }) // Add image blocks separately after tool_result if (imageBlocks.length > 0) { @@ -767,12 +767,12 @@ export async function presentAssistantMessage(cline: Task) { if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) { // For native protocol, push tool_result directly without setting didAlreadyUseTool - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: typeof errorContent === "string" ? errorContent : "(validation error)", is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } else { // For XML protocol, use the standard pushToolResult pushToolResult(errorContent) @@ -1152,12 +1152,12 @@ export async function presentAssistantMessage(cline: Task) { // Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool // This prevents the stream from being interrupted with "Response interrupted by tool use result" if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) { - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: formatResponse.toolError(errorMessage, toolProtocol), is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } else { pushToolResult(formatResponse.toolError(errorMessage, toolProtocol)) } diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index 1309afb221..ef5af01243 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -334,6 +334,254 @@ describe("getKeepMessagesWithToolBlocks", () => { expect(result.toolUseBlocksToPreserve).toHaveLength(1) expect(result.reasoningBlocksToPreserve).toHaveLength(0) }) + + it("should preserve tool_use when tool_result is in 2nd kept message and tool_use is 2 messages before boundary", () => { + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_second_kept", + name: "read_file", + input: { path: "test.txt" }, + } + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_second_kept", + content: "file contents", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Let me help", ts: 2 }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock], + ts: 3, + }, + { role: "user", content: "Some other message", ts: 4 }, + { role: "assistant", content: "First kept message", ts: 5 }, + { + role: "user", + content: [toolResultBlock, { type: "text" as const, text: "Continue" }], + ts: 6, + }, + { role: "assistant", content: "Third kept message", ts: 7 }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages (ts: 5, 6, 7) + expect(result.keepMessages).toHaveLength(3) + expect(result.keepMessages[0].ts).toBe(5) + expect(result.keepMessages[1].ts).toBe(6) + expect(result.keepMessages[2].ts).toBe(7) + + // Should preserve the tool_use block from message at ts:3 (2 messages before boundary) + expect(result.toolUseBlocksToPreserve).toHaveLength(1) + expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + }) + + it("should preserve tool_use when tool_result is in 3rd kept message and tool_use is at boundary edge", () => { + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_third_kept", + name: "search", + input: { query: "test" }, + } + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_third_kept", + content: "search results", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Start", ts: 1 }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Searching..." }, toolUseBlock], + ts: 2, + }, + { role: "user", content: "First kept message", ts: 3 }, + { role: "assistant", content: "Second kept message", ts: 4 }, + { + role: "user", + content: [toolResultBlock, { type: "text" as const, text: "Done" }], + ts: 5, + }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages (ts: 3, 4, 5) + expect(result.keepMessages).toHaveLength(3) + expect(result.keepMessages[0].ts).toBe(3) + expect(result.keepMessages[1].ts).toBe(4) + expect(result.keepMessages[2].ts).toBe(5) + + // Should preserve the tool_use block from message at ts:2 (at the search boundary edge) + expect(result.toolUseBlocksToPreserve).toHaveLength(1) + expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + }) + + it("should preserve multiple tool_uses when tool_results are in different kept messages", () => { + const toolUseBlock1 = { + type: "tool_use" as const, + id: "toolu_multi_1", + name: "read_file", + input: { path: "file1.txt" }, + } + const toolUseBlock2 = { + type: "tool_use" as const, + id: "toolu_multi_2", + name: "read_file", + input: { path: "file2.txt" }, + } + const toolResultBlock1 = { + type: "tool_result" as const, + tool_use_id: "toolu_multi_1", + content: "contents 1", + } + const toolResultBlock2 = { + type: "tool_result" as const, + tool_use_id: "toolu_multi_2", + content: "contents 2", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Start", ts: 1 }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Reading file 1..." }, toolUseBlock1], + ts: 2, + }, + { role: "user", content: "Some message", ts: 3 }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Reading file 2..." }, toolUseBlock2], + ts: 4, + }, + { + role: "user", + content: [toolResultBlock1, { type: "text" as const, text: "First result" }], + ts: 5, + }, + { + role: "user", + content: [toolResultBlock2, { type: "text" as const, text: "Second result" }], + ts: 6, + }, + { role: "assistant", content: "Got both files", ts: 7 }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages (ts: 5, 6, 7) + expect(result.keepMessages).toHaveLength(3) + + // Should preserve both tool_use blocks + expect(result.toolUseBlocksToPreserve).toHaveLength(2) + expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock1) + expect(result.toolUseBlocksToPreserve).toContainEqual(toolUseBlock2) + }) + + it("should not crash when tool_result references tool_use beyond search boundary", () => { + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_beyond_boundary", + content: "result", + } + + // Tool_use is at ts:1, but with N_MESSAGES_TO_KEEP=3, we only search back 3 messages + // from startIndex-1. StartIndex is 7 (messages.length=10, keepCount=3, startIndex=7). + // So we search from index 6 down to index 4 (7-1 down to 7-3). + // The tool_use at index 0 (ts:1) is beyond the search boundary. + const messages: ApiMessage[] = [ + { + role: "assistant", + content: [ + { type: "text" as const, text: "Way back..." }, + { + type: "tool_use" as const, + id: "toolu_beyond_boundary", + name: "old_tool", + input: {}, + }, + ], + ts: 1, + }, + { role: "user", content: "Message 2", ts: 2 }, + { role: "assistant", content: "Message 3", ts: 3 }, + { role: "user", content: "Message 4", ts: 4 }, + { role: "assistant", content: "Message 5", ts: 5 }, + { role: "user", content: "Message 6", ts: 6 }, + { role: "assistant", content: "Message 7", ts: 7 }, + { + role: "user", + content: [toolResultBlock], + ts: 8, + }, + { role: "assistant", content: "Message 9", ts: 9 }, + { role: "user", content: "Message 10", ts: 10 }, + ] + + // Should not crash + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages + expect(result.keepMessages).toHaveLength(3) + expect(result.keepMessages[0].ts).toBe(8) + expect(result.keepMessages[1].ts).toBe(9) + expect(result.keepMessages[2].ts).toBe(10) + + // Should not preserve the tool_use since it's beyond the search boundary + expect(result.toolUseBlocksToPreserve).toHaveLength(0) + }) + + it("should not duplicate tool_use blocks when same tool_result ID appears multiple times", () => { + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_duplicate", + name: "read_file", + input: { path: "test.txt" }, + } + const toolResultBlock1 = { + type: "tool_result" as const, + tool_use_id: "toolu_duplicate", + content: "result 1", + } + const toolResultBlock2 = { + type: "tool_result" as const, + tool_use_id: "toolu_duplicate", + content: "result 2", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Start", ts: 1 }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Using tool..." }, toolUseBlock], + ts: 2, + }, + { + role: "user", + content: [toolResultBlock1], + ts: 3, + }, + { role: "assistant", content: "Processing", ts: 4 }, + { + role: "user", + content: [toolResultBlock2], // Same tool_use_id as first result + ts: 5, + }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages (ts: 3, 4, 5) + expect(result.keepMessages).toHaveLength(3) + + // Should only preserve the tool_use block once, not twice + expect(result.toolUseBlocksToPreserve).toHaveLength(1) + expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + }) }) describe("getMessagesSinceLastSummary", () => { diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 3238eca707..79bc31ef9f 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -7,6 +7,7 @@ import { t } from "../../i18n" import { ApiHandler } from "../../api" import { ApiMessage } from "../task-persistence/apiMessages" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" +import { findLast } from "../../shared/array" /** * Checks if a message contains tool_result blocks. @@ -30,6 +31,28 @@ function getToolUseBlocks(message: ApiMessage): Anthropic.Messages.ToolUseBlock[ return message.content.filter((block) => block.type === "tool_use") as Anthropic.Messages.ToolUseBlock[] } +/** + * Gets the tool_result blocks from a message. + */ +function getToolResultBlocks(message: ApiMessage): Anthropic.ToolResultBlockParam[] { + if (message.role !== "user" || typeof message.content === "string") { + return [] + } + return message.content.filter((block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result") +} + +/** + * Finds a tool_use block by ID in a message. + */ +function findToolUseBlockById(message: ApiMessage, toolUseId: string): Anthropic.Messages.ToolUseBlock | undefined { + if (message.role !== "assistant" || typeof message.content === "string") { + return undefined + } + return message.content.find( + (block): block is Anthropic.Messages.ToolUseBlock => block.type === "tool_use" && block.id === toolUseId, + ) +} + /** * Gets reasoning blocks from a message's content array. * Task stores reasoning as {type: "reasoning", text: "..."} blocks, @@ -57,11 +80,11 @@ export type KeepMessagesResult = { /** * Extracts tool_use blocks that need to be preserved to match tool_result blocks in keepMessages. - * When the first kept message is a user message with tool_result blocks, - * we need to find the corresponding tool_use blocks from the preceding assistant message. + * Checks ALL kept messages for tool_result blocks and searches backwards through the condensed + * region (bounded by N_MESSAGES_TO_KEEP) to find the matching tool_use blocks by ID. * These tool_use blocks will be appended to the summary message to maintain proper pairing. * - * Also extracts reasoning blocks from the preceding assistant message, which are required + * Also extracts reasoning blocks from messages containing preserved tool_uses, which are required * by DeepSeek and Z.ai for interleaved thinking mode. Without these, the API returns a 400 error * "Missing reasoning_content field in the assistant message". * See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls @@ -78,28 +101,53 @@ export function getKeepMessagesWithToolBlocks(messages: ApiMessage[], keepCount: const startIndex = messages.length - keepCount const keepMessages = messages.slice(startIndex) - // Check if the first kept message is a user message with tool_result blocks - if (keepMessages.length > 0 && hasToolResultBlocks(keepMessages[0])) { - // Look for the preceding assistant message with tool_use blocks - const precedingIndex = startIndex - 1 - if (precedingIndex >= 0) { - const precedingMessage = messages[precedingIndex] - const toolUseBlocks = getToolUseBlocks(precedingMessage) - if (toolUseBlocks.length > 0) { - // Also extract reasoning blocks for DeepSeek/Z.ai interleaved thinking - // Task stores reasoning as {type: "reasoning", text: "..."} content blocks - const reasoningBlocks = getReasoningBlocks(precedingMessage) - // Return the tool_use blocks and reasoning blocks to be merged into the summary message - return { - keepMessages, - toolUseBlocksToPreserve: toolUseBlocks, - reasoningBlocksToPreserve: reasoningBlocks, - } + const toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] = [] + const reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[] = [] + const preservedToolUseIds = new Set() + + // Check ALL kept messages for tool_result blocks + for (const keepMsg of keepMessages) { + if (!hasToolResultBlocks(keepMsg)) { + continue + } + + const toolResults = getToolResultBlocks(keepMsg) + + for (const toolResult of toolResults) { + const toolUseId = toolResult.tool_use_id + + // Skip if we've already found this tool_use + if (preservedToolUseIds.has(toolUseId)) { + continue + } + + // Search backwards through the condensed region (bounded) + const searchStart = startIndex - 1 + const searchEnd = Math.max(0, startIndex - N_MESSAGES_TO_KEEP) + const messagesToSearch = messages.slice(searchEnd, searchStart + 1) + + // Find the message containing this tool_use + const messageWithToolUse = findLast(messagesToSearch, (msg) => { + return findToolUseBlockById(msg, toolUseId) !== undefined + }) + + if (messageWithToolUse) { + const toolUse = findToolUseBlockById(messageWithToolUse, toolUseId)! + toolUseBlocksToPreserve.push(toolUse) + preservedToolUseIds.add(toolUseId) + + // Also preserve reasoning blocks from that message + const reasoning = getReasoningBlocks(messageWithToolUse) + reasoningBlocksToPreserve.push(...reasoning) } } } - return { keepMessages, toolUseBlocksToPreserve: [], reasoningBlocksToPreserve: [] } + return { + keepMessages, + toolUseBlocksToPreserve, + reasoningBlocksToPreserve, + } } export const N_MESSAGES_TO_KEEP = 3 diff --git a/src/core/costrict/activate.ts b/src/core/costrict/activate.ts index aa8d2a37d1..0d899e2924 100644 --- a/src/core/costrict/activate.ts +++ b/src/core/costrict/activate.ts @@ -47,7 +47,7 @@ import { getPanel } from "../../activate/registerCommands" import { t } from "../../i18n" import prettyBytes from "pretty-bytes" import { ensureProjectWikiSubtasksExists } from "./wiki/projectWikiHelpers" -import { isJetbrainsPlatform } from "../../utils/platform" +import { isCliPatform, isJetbrainsPlatform } from "../../utils/platform" import type { ModelRecord } from "../../shared/api" import type { ModelInfo } from "@roo-code/types" @@ -94,7 +94,8 @@ export async function activate( await initialize(provider, logger) startIPCServer() connectIPC() - if (!isJetbrains) { + + if (!isJetbrains && !isCliPatform()) { registerAutoCompletionProvider(context, provider) } const completionStatusBar = CompletionStatusBar.getInstance() @@ -164,18 +165,20 @@ export async function activate( initCodeReview(context, provider, outputChannel) initTelemetry(provider) - context.subscriptions.push( - // Register codelens related commands - vscode.commands.registerTextEditorCommand( - codeLensCallBackCommand.command, - codeLensCallBackCommand.callback(context), - ), - // Construct instruction set - vscode.commands.registerTextEditorCommand( - codeLensCallBackMoreCommand.command, - codeLensCallBackMoreCommand.callback(context), - ), - ) + if (!isCliPatform()) { + context.subscriptions.push( + // Register codelens related commands + vscode.commands.registerTextEditorCommand( + codeLensCallBackCommand.command, + codeLensCallBackCommand.callback(context), + ), + // Construct instruction set + vscode.commands.registerTextEditorCommand( + codeLensCallBackMoreCommand.command, + codeLensCallBackMoreCommand.callback(context), + ), + ) + } if (!isJetbrains) { context.subscriptions.push( diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index f296c1b5c5..a6733d87c8 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -95,6 +95,7 @@ function getOrCreateRenamedTool( */ export function resolveToolAlias(toolName: string): string { const canonical = ALIAS_TO_CANONICAL.get(toolName) + return canonical ?? toolName } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 0630d5569e..25965a4da4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -139,6 +139,7 @@ import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval" import psTree from "ps-tree" import { MessageManager } from "../message-manager" import { validateAndFixToolResultIds } from "./validateToolResultIds" +import { fixNativeToolname } from "../../utils/fixNativeToolname" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds @@ -365,6 +366,28 @@ export class Task extends EventEmitter implements TaskLike { } > = [] userMessageContentReady = false + + /** + * Push a tool_result block to userMessageContent, preventing duplicates. + * This is critical for native tool protocol where duplicate tool_use_ids cause API errors. + * + * @param toolResult - The tool_result block to add + * @returns true if added, false if duplicate was skipped + */ + public pushToolResultToUserContent(toolResult: Anthropic.ToolResultBlockParam): boolean { + const existingResult = this.userMessageContent.find( + (block): block is Anthropic.ToolResultBlockParam => + block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + console.warn( + `[Task#pushToolResultToUserContent] Skipping duplicate tool_result for tool_use_id: ${toolResult.tool_use_id}`, + ) + return false + } + this.userMessageContent.push(toolResult) + return true + } didRejectTool = false didAlreadyUseTool = false didToolFailInCurrentTurn = false @@ -1127,7 +1150,6 @@ export class Task extends EventEmitter implements TaskLike { // state. askTs = Date.now() this.lastMessageTs = askTs - console.log(`Task#ask: new partial ask -> ${type} @ ${askTs}`) await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial, isProtected }) // console.log("Task#ask: current ask promise was ignored (#2)") throw new AskIgnoredError("new partial") @@ -1152,7 +1174,6 @@ export class Task extends EventEmitter implements TaskLike { // So in this case we must make sure that the message ts is // never altered after first setting it. askTs = lastMessage.ts - console.log(`Task#ask: updating previous partial ask -> ${type} @ ${askTs}`) this.lastMessageTs = askTs lastMessage.text = text lastMessage.partial = false @@ -1166,7 +1187,6 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseText = undefined this.askResponseImages = undefined askTs = Date.now() - console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`) this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) } @@ -1177,7 +1197,6 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseText = undefined this.askResponseImages = undefined askTs = Date.now() - console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`) this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) } @@ -1207,15 +1226,9 @@ export class Task extends EventEmitter implements TaskLike { // block (via the `pWaitFor`). const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs) const isMessageQueued = !this.messageQueueService.isEmpty() - const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask" - if (isBlocking) { - console.log(`Task#ask will block -> type: ${type}`) - } - if (isStatusMutable) { - console.log(`Task#ask: status is mutable -> type: ${type}`) const statusMutationTimeout = 2_000 if (isInteractiveAsk(type)) { @@ -1254,8 +1267,6 @@ export class Task extends EventEmitter implements TaskLike { ) } } else if (isMessageQueued) { - console.log(`Task#ask: will process message queue -> type: ${type}`) - const message = this.messageQueueService.dequeueMessage() if (message) { @@ -1317,7 +1328,6 @@ export class Task extends EventEmitter implements TaskLike { // Could happen if we send multiple asks in a row i.e. with // command_output. It's important that when we know an ask could // fail, it is handled gracefully. - console.log("Task#ask: current ask promise was ignored") throw new AskIgnoredError("superseded") } @@ -2652,7 +2662,6 @@ export class Task extends EventEmitter implements TaskLike { // lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list lastMessage.partial = false // instead of streaming partialMessage events, we do a save and post like normal to persist to disk - console.log("updating partial message", lastMessage) } // Update `api_req_started` to have cancelled and cost, so that @@ -2811,7 +2820,7 @@ export class Task extends EventEmitter implements TaskLike { // Create initial partial tool use const partialToolUse: ToolUse = { type: "tool_use", - name: event.name as ToolName, + name: fixNativeToolname(event.name as ToolName), params: {}, partial: true, } @@ -3978,6 +3987,7 @@ export class Task extends EventEmitter implements TaskLike { autoCondenseContextPercent = 100, profileThresholds = {}, showSpeedInfo = false, + automaticallyFocus = false, } = state ?? {} // Get condensing configuration for automatic triggers. diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index f4233f755a..fec23cd980 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2012,3 +2012,205 @@ describe("Queued message processing after condense", () => { expect(taskB.messageQueueService.isEmpty()).toBe(true) }) }) + +describe("pushToolResultToUserContent", () => { + let mockProvider: any + let mockApiConfig: ProviderSettings + + beforeEach(() => { + mockApiConfig = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } + + const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") } + const mockExtensionContext = { + globalState: { + get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + globalStorageUri: storageUri, + workspaceState: { + get: vi.fn().mockImplementation((_key) => undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }, + extensionUri: { fsPath: "/mock/extension/path" }, + extension: { packageJSON: { version: "1.0.0" } }, + } as unknown as vscode.ExtensionContext + + const mockOutputChannel = { + name: "test-output", + appendLine: vi.fn(), + append: vi.fn(), + replace: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + } + + mockProvider = new ClineProvider( + mockExtensionContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockExtensionContext), + ) as any + + mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + }) + + it("should add tool_result when not a duplicate", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const toolResult: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "test-id-1", + content: "Test result", + } + + const added = task.pushToolResultToUserContent(toolResult) + + expect(added).toBe(true) + expect(task.userMessageContent).toHaveLength(1) + expect(task.userMessageContent[0]).toEqual(toolResult) + }) + + it("should prevent duplicate tool_result with same tool_use_id", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const toolResult1: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "duplicate-id", + content: "First result", + } + + const toolResult2: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "duplicate-id", + content: "Second result (should be skipped)", + } + + // Spy on console.warn to verify warning is logged + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + // Add first result - should succeed + const added1 = task.pushToolResultToUserContent(toolResult1) + expect(added1).toBe(true) + expect(task.userMessageContent).toHaveLength(1) + + // Add second result with same ID - should be skipped + const added2 = task.pushToolResultToUserContent(toolResult2) + expect(added2).toBe(false) + expect(task.userMessageContent).toHaveLength(1) + + // Verify only the first result is in the array + expect(task.userMessageContent[0]).toEqual(toolResult1) + + // Verify warning was logged + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Skipping duplicate tool_result for tool_use_id: duplicate-id"), + ) + + warnSpy.mockRestore() + }) + + it("should allow different tool_use_ids to be added", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const toolResult1: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "id-1", + content: "Result 1", + } + + const toolResult2: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "id-2", + content: "Result 2", + } + + const added1 = task.pushToolResultToUserContent(toolResult1) + const added2 = task.pushToolResultToUserContent(toolResult2) + + expect(added1).toBe(true) + expect(added2).toBe(true) + expect(task.userMessageContent).toHaveLength(2) + expect(task.userMessageContent[0]).toEqual(toolResult1) + expect(task.userMessageContent[1]).toEqual(toolResult2) + }) + + it("should handle tool_result with is_error flag", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const errorResult: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "error-id", + content: "Error message", + is_error: true, + } + + const added = task.pushToolResultToUserContent(errorResult) + + expect(added).toBe(true) + expect(task.userMessageContent).toHaveLength(1) + expect(task.userMessageContent[0]).toEqual(errorResult) + }) + + it("should not interfere with other content types in userMessageContent", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Add text and image blocks manually + task.userMessageContent.push( + { type: "text", text: "Some text" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } }, + ) + + const toolResult: Anthropic.ToolResultBlockParam = { + type: "tool_result", + tool_use_id: "test-id", + content: "Result", + } + + const added = task.pushToolResultToUserContent(toolResult) + + expect(added).toBe(true) + expect(task.userMessageContent).toHaveLength(3) + expect(task.userMessageContent[0].type).toBe("text") + expect(task.userMessageContent[1].type).toBe("image") + expect(task.userMessageContent[2]).toEqual(toolResult) + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c9e3d3a6cc..0bd0ccc2cf 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2055,6 +2055,7 @@ export class ClineProvider historyPreviewCollapsed, reasoningBlockCollapsed, showSpeedInfo, + automaticallyFocus, enterBehavior, cloudUserInfo, cloudIsAuthenticated, @@ -2118,7 +2119,10 @@ export class ClineProvider // Check if there's a system prompt override for the current mode const currentMode = mode ?? defaultModeSlug const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode) - + const debug = vscode.workspace.getConfiguration(Package.name).get("debug", isJetbrainsPlatform()) + if (!debug) { + apiConfiguration.useZgsmCustomConfig = false + } return { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, @@ -2215,6 +2219,7 @@ export class ClineProvider historyPreviewCollapsed: historyPreviewCollapsed ?? false, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, showSpeedInfo: showSpeedInfo ?? false, + automaticallyFocus: automaticallyFocus ?? false, enterBehavior: enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, @@ -2262,7 +2267,7 @@ export class ClineProvider openRouterImageApiKey, openRouterImageGenerationSelectedModel, featureRoomoteControlEnabled, - debug: vscode.workspace.getConfiguration(Package.name).get("debug", isJetbrainsPlatform()), + debug, claudeCodeIsAuthenticated: await (async () => { try { const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") @@ -2469,6 +2474,7 @@ export class ClineProvider historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, showSpeedInfo: stateValues.showSpeedInfo ?? false, + automaticallyFocus: stateValues.automaticallyFocus ?? false, enterBehavior: stateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, diff --git a/src/esbuild.mjs b/src/esbuild.mjs index 4bad112751..0e248088ff 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -109,7 +109,10 @@ async function main() { plugins, entryPoints: ["extension.ts"], outfile: "dist/extension.js", - external: ["vscode", "esbuild"], + // global-agent must be external because it dynamically patches Node.js http/https modules + // which breaks when bundled. It needs access to the actual Node.js module instances. + // undici must be bundled because our VSIX is packaged with `--no-dependencies`. + external: ["vscode", "esbuild", "global-agent"], } /** diff --git a/src/extension.ts b/src/extension.ts index 4cc11e49ee..1bbc901c25 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,6 +20,7 @@ import { customToolRegistry } from "@roo-code/core" import "./utils/path" // Necessary to have access to String.prototype.toPosix. import { createOutputChannelLogger, createDualLogger } from "./utils/outputChannelLogger" +import { initializeNetworkProxy } from "./utils/networkProxy" import { Package } from "./shared/package" import { formatLanguage } from "./shared/language" @@ -76,6 +77,11 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(outputChannel) outputChannel.appendLine(`${Package.name} extension activated - ${JSON.stringify(Package)}`) + // Initialize network proxy configuration early, before any network requests. + // When proxyUrl is configured, all HTTP/HTTPS traffic will be routed through it. + // Only applied in debug mode (F5). + await initializeNetworkProxy(context, outputChannel) + // Set extension path for custom tool registry to find bundled esbuild customToolRegistry.setExtensionPath(context.extensionPath) diff --git a/src/integrations/terminal/BaseTerminalProcess.ts b/src/integrations/terminal/BaseTerminalProcess.ts index 22984a260b..ff09b82735 100644 --- a/src/integrations/terminal/BaseTerminalProcess.ts +++ b/src/integrations/terminal/BaseTerminalProcess.ts @@ -154,14 +154,65 @@ export abstract class BaseTerminalProcess extends EventEmitter { - isOutputFirstLine = false callbacks.onLine(line, process) }) process.once("completed", (output) => callbacks.onCompleted(output, process)) @@ -35,10 +32,6 @@ export class ExecaTerminal extends BaseTerminal { process.once("continue", () => resolve()) process.once("error", (error) => reject(error)) process.run(command) - delay(300).then(() => { - if (!isOutputFirstLine) return - callbacks?.triggerUIToProceed?.("", process) - }) }) return mergePromise(process, promise) diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index 07f95409ce..6de37768e0 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -8,6 +8,7 @@ import { BaseTerminalProcess } from "./BaseTerminalProcess" import { getIdeaShellEnvWithUpdatePath } from "../../utils/ideaShellEnvLoader" import { isJetbrainsPlatform } from "../../utils/platform" import { t } from "../../i18n" +import delay from "delay" export class ExecaTerminalProcess extends BaseTerminalProcess { private terminalRef: WeakRef @@ -52,6 +53,8 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { // Ensure UTF-8 encoding for Ruby, CocoaPods, etc. LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8", + LANGUAGE: "en_US.UTF-8", + PYTHONIOENCODING: "utf-8", }, })`${command}` @@ -89,17 +92,20 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { })() await this.terminal.setActiveStream(stream, Promise.resolve(this.pid)) - + let outputIndex = 0 for await (const line of stream) { if (this.aborted) { break } - + outputIndex++ this.fullOutput += line const now = Date.now() - if (this.isListening && (now - this.lastEmitTime_ms > 500 || this.lastEmitTime_ms === 0)) { + if ( + this.isListening && + (now - this.lastEmitTime_ms > 600 || this.lastEmitTime_ms === 0 || outputIndex < 3) + ) { this.emitRemainingBufferIfListening() this.lastEmitTime_ms = now } @@ -107,6 +113,10 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { this.startHotTimer(line) } + await delay(500) + this.emitRemainingBufferIfListening() + this.startHotTimer(this.fullOutput.slice(-2000)) + if (this.aborted) { let timeoutId: NodeJS.Timeout | undefined @@ -150,8 +160,10 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { this.subprocess = undefined } - await this.terminal.setActiveStream(undefined, Promise.resolve(this.pid)) - this.emitRemainingBufferIfListening() + await Promise.all([ + this.terminal.setActiveStream(undefined, Promise.resolve(this.pid)), + this.emitRemainingBufferIfListening(), + ]) this.stopHotTimer() this.emit("completed", this.fullOutput) this.emit("continue") @@ -218,7 +230,6 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { psTree(this.pid, async (err, children) => { if (!err) { const pids = children.map((p) => parseInt(p.PID)) - console.error(`[ExecaTerminalProcess#abort] SIGKILL children -> ${pids.join(", ")}`) for (const pid of pids) { try { diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index 6ac021d508..1e288bbb84 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -13,6 +13,7 @@ import { inspect } from "util" import type { ExitCodeDetails } from "./types" import { BaseTerminalProcess } from "./BaseTerminalProcess" import { Terminal } from "./Terminal" +import delay from "delay" export class TerminalProcess extends BaseTerminalProcess { private terminalRef: WeakRef @@ -132,6 +133,9 @@ export class TerminalProcess extends BaseTerminalProcess { commandToExecute += ` ; start-sleep -milliseconds ${Terminal.getCommandDelay()}` } + // Set UTF-8 encoding for PowerShell + commandToExecute = `$OutputEncoding = [System.Text.Encoding]::UTF8 ; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 ; ${commandToExecute}` + terminal.shellIntegration.executeCommand(commandToExecute) } else if (isCmd) { // For Windows cmd, do not use chcp as it's unreliable @@ -168,7 +172,7 @@ export class TerminalProcess extends BaseTerminalProcess { let preOutput = "" let commandOutputStarted = false - + let outputIndex = 0 /* * Extract clean output from raw accumulated output. FYI: * ]633 is a custom sequence number used by VSCode shell integration: @@ -181,6 +185,7 @@ export class TerminalProcess extends BaseTerminalProcess { // Process stream data for await (let data of stream) { + outputIndex++ // Check for command output start marker if (!commandOutputStarted) { preOutput += data @@ -207,7 +212,10 @@ export class TerminalProcess extends BaseTerminalProcess { // as soon as we get any output we emit to let webview know to show spinner const now = Date.now() - if (this.isListening && (now - this.lastEmitTime_ms > 100 || this.lastEmitTime_ms === 0)) { + if ( + this.isListening && + (now - this.lastEmitTime_ms > 150 || this.lastEmitTime_ms === 0 || outputIndex < 3) + ) { this.emitRemainingBufferIfListening() this.lastEmitTime_ms = now } @@ -215,6 +223,10 @@ export class TerminalProcess extends BaseTerminalProcess { this.startHotTimer(data) } + await delay(500) + this.emitRemainingBufferIfListening() + this.startHotTimer(this.fullOutput.slice(-2000)) + // Set streamClosed immediately after stream ends. await this.terminal.setActiveStream(undefined, this.terminal?.terminal?.processId) @@ -273,7 +285,7 @@ export class TerminalProcess extends BaseTerminalProcess { this.removeAllListeners("line") this.emit("continue") } - public userInput (input: string) { + public userInput(input: string) { this.terminal.terminal.sendText(input) } @@ -477,4 +489,4 @@ export class TerminalProcess extends BaseTerminalProcess { return match133 !== undefined ? match133 : match633 } -} \ No newline at end of file +} diff --git a/src/package.json b/src/package.json index e927db5ec5..32c10e1f45 100644 --- a/src/package.json +++ b/src/package.json @@ -798,6 +798,21 @@ "type": "boolean", "default": false, "description": "%settings.debug.description%" + }, + "zgsm.debugProxy.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%settings.debugProxy.enabled.description%" + }, + "zgsm.debugProxy.serverUrl": { + "type": "string", + "default": "http://127.0.0.1:8888", + "markdownDescription": "%settings.debugProxy.serverUrl.description%" + }, + "zgsm.debugProxy.tlsInsecure": { + "type": "boolean", + "default": false, + "markdownDescription": "%settings.debugProxy.tlsInsecure.description%" } } } @@ -852,6 +867,7 @@ "fzf": "^0.5.2", "get-folder-size": "^5.0.0", "get-port": "^7.1.0", + "global-agent": "^3.0.0", "google-auth-library": "^9.15.1", "gray-matter": "^4.0.3", "i18next": "^25.0.0", @@ -900,6 +916,7 @@ "tree-sitter-wasms": "^0.1.12", "turndown": "^7.2.0", "uri-js": "^4.4.1", + "undici": "^6.21.3", "uuid": "^11.1.0", "vscode-material-icons": "^0.1.1", "web-tree-sitter": "^0.25.6", diff --git a/src/package.nls.json b/src/package.nls.json index b4314b7fba..a35992f5ce 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -76,5 +76,8 @@ "settings.commit.language.description": "Language for commit message generation (auto = use system language)", "settings.toolProtocol.description": "Tool protocol to use for AI interactions. XML is the default and recommended protocol. Native is experimental and may not work with all providers.", "settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60.", - "settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files." + "settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files.", + "settings.debugProxy.enabled.description": "**Enable Debug Proxy** — Route all outbound network requests through a proxy for MITM debugging. Only active when running in debug mode (F5).", + "settings.debugProxy.serverUrl.description": "Proxy URL (e.g., `http://127.0.0.1:8888`). Only used when **Debug Proxy** is enabled.", + "settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging." } diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index d1da196742..49bab157f9 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -78,5 +78,8 @@ "settings.commit.language.description": "提交信息生成语言(auto = 使用系统语言)", "settings.toolProtocol.description": "用于 AI 交互的工具协议。XML 是默认且推荐的协议。本机是实验性的,可能不适用于所有提供商。", "settings.codeIndex.embeddingBatchSize.description": "代码索引期间嵌入操作的批处理大小。根据 API 提供商的限制调整此设置。默认值为 60。", - "settings.debug.description": "启用调试模式以显示额外按钮,用于在临时文件中以格式化 JSON 查看 API 对话历史和 UI 消息。" + "settings.debug.description": "启用调试模式以显示额外按钮,用于在临时文件中以格式化 JSON 查看 API 对话历史和 UI 消息。", + "settings.debugProxy.enabled.description": "**启用 Debug Proxy** — 通过代理转发所有出站网络请求,用于 MITM 调试。只在调试模式 (F5) 运行时生效。", + "settings.debugProxy.serverUrl.description": "代理 URL(例如 `http://127.0.0.1:8888`)。仅在启用 **Debug Proxy** 时使用。", + "settings.debugProxy.tlsInsecure.description": "接受来自代理的 self-signed 证书。**MITM 检查所必需。** ⚠️ 不安全——只在本地调试时使用。" } diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index c5f166ff5e..b67ce43e69 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -78,5 +78,8 @@ "settings.commit.language.description": "提交訊息生成語言(auto = 使用系統語言)", "settings.toolProtocol.description": "用於 AI 互動的工具協議。XML 是預設且推薦的協議。本機是實驗性的,可能不適用於所有提供商。", "settings.codeIndex.embeddingBatchSize.description": "程式碼索引期間嵌入操作的批次大小。根據 API 提供商的限制調整此設定。預設值為 60。", - "settings.debug.description": "啟用偵錯模式以顯示額外按鈕,用於在暫存檔案中以格式化 JSON 檢視 API 對話歷史紀錄和使用者介面訊息。" + "settings.debug.description": "啟用偵錯模式以顯示額外按鈕,用於在暫存檔案中以格式化 JSON 檢視 API 對話歷史紀錄和使用者介面訊息。", + "settings.debugProxy.enabled.description": "**啟用 Debug Proxy** — 將所有出站網路要求透過代理進行路由,以進行 MITM 偵錯。只有在除錯模式 (F5) 執行時才會啟用。", + "settings.debugProxy.serverUrl.description": "代理 URL(例如 `http://127.0.0.1:8888`)。只有在啟用 **Debug Proxy** 時才會使用。", + "settings.debugProxy.tlsInsecure.description": "接受來自代理的 self-signed 憑證。**MITM 檢查所必需。** ⚠️ 不安全——只在本機偵錯時使用。" } diff --git a/src/services/code-index/embedders/openai.ts b/src/services/code-index/embedders/openai.ts index b993e280d9..776b53c116 100644 --- a/src/services/code-index/embedders/openai.ts +++ b/src/services/code-index/embedders/openai.ts @@ -1,5 +1,4 @@ import { OpenAI } from "openai" -import { OpenAiNativeHandler } from "../../../api/providers/openai-native" import { ApiHandlerOptions } from "../../../shared/api" import { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces" import { @@ -18,16 +17,17 @@ import { handleOpenAIError } from "../../../api/providers/utils/openai-error-han /** * OpenAI implementation of the embedder interface with batching and rate limiting */ -export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder { +export class OpenAiEmbedder implements IEmbedder { private embeddingsClient: OpenAI private readonly defaultModelId: string + private readonly options: ApiHandlerOptions & { openAiEmbeddingModelId?: string } /** * Creates a new OpenAI embedder * @param options API handler options */ constructor(options: ApiHandlerOptions & { openAiEmbeddingModelId?: string }) { - super(options) + this.options = options const apiKey = this.options.openAiNativeApiKey ?? "not-provided" // Wrap OpenAI client creation to handle invalid API key characters diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 735eca05f1..4f14d2dc6b 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -322,6 +322,7 @@ export type ExtensionState = Pick< | "includeTaskHistoryInEnhance" | "reasoningBlockCollapsed" | "showSpeedInfo" + | "automaticallyFocus" | "errorCode" | "enterBehavior" | "includeCurrentTime" diff --git a/src/shared/tools.ts b/src/shared/tools.ts index c35dc96d75..7d3f8ebcbf 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -342,6 +342,9 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ */ export const TOOL_ALIASES: Record = { write_file: "write_to_file", + read: "read_file", + apply: "apply_diff", + search: "search_files", } as const export type DiffResult = diff --git a/src/types/global-agent.d.ts b/src/types/global-agent.d.ts new file mode 100644 index 0000000000..1dba1e38e1 --- /dev/null +++ b/src/types/global-agent.d.ts @@ -0,0 +1,47 @@ +/** + * Type declarations for global-agent package. + * + * global-agent is a library that creates a global HTTP/HTTPS agent + * that routes all traffic through a specified proxy. + * + * @see https://github.com/gajus/global-agent + */ + +declare module "global-agent" { + /** + * Bootstrap global-agent to intercept all HTTP/HTTPS requests. + * + * After calling this function, all outgoing HTTP/HTTPS requests + * from the Node.js process will be routed through the proxy + * specified by the GLOBAL_AGENT_HTTP_PROXY and GLOBAL_AGENT_HTTPS_PROXY + * environment variables. + * + * @returns void + */ + export function bootstrap(): void + + /** + * Create a global agent with custom configuration. + * + * @param options Configuration options for the global agent + * @returns void + */ + export function createGlobalProxyAgent(options?: { + /** + * Environment variable namespace prefix. + * Default: "GLOBAL_AGENT_" + */ + environmentVariableNamespace?: string + + /** + * Force global agent to be used for all HTTP/HTTPS requests. + * Default: true + */ + forceGlobalAgent?: boolean + + /** + * Socket connection timeout in milliseconds. + */ + socketConnectionTimeout?: number + }): void +} diff --git a/src/utils/__tests__/networkProxy.spec.ts b/src/utils/__tests__/networkProxy.spec.ts new file mode 100644 index 0000000000..97c046d1b0 --- /dev/null +++ b/src/utils/__tests__/networkProxy.spec.ts @@ -0,0 +1,308 @@ +import * as vscode from "vscode" +import { initializeNetworkProxy, getProxyConfig, isProxyEnabled, isDebugMode } from "../networkProxy" + +// Mock global-agent +vi.mock("global-agent", () => ({ + bootstrap: vi.fn(), +})) + +// Mock vscode +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(), + onDidChangeConfiguration: vi.fn(() => ({ dispose: vi.fn() })), + }, + ExtensionMode: { + Development: 2, + Production: 1, + Test: 3, + }, +})) + +describe("networkProxy", () => { + let mockOutputChannel: vscode.OutputChannel + let mockConfig: { get: ReturnType } + + // Helper to create mock context with configurable extensionMode + function createMockContext(mode: vscode.ExtensionMode = vscode.ExtensionMode.Production): vscode.ExtensionContext { + return { + extensionMode: mode, + subscriptions: [], + extensionPath: "/test/path", + globalState: { + get: vi.fn(), + update: vi.fn(), + keys: vi.fn().mockReturnValue([]), + setKeysForSync: vi.fn(), + }, + workspaceState: { + get: vi.fn(), + update: vi.fn(), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn(), + store: vi.fn(), + delete: vi.fn(), + onDidChange: vi.fn(), + }, + extensionUri: { fsPath: "/test/path" } as vscode.Uri, + globalStorageUri: { fsPath: "/test/global" } as vscode.Uri, + logUri: { fsPath: "/test/logs" } as vscode.Uri, + storageUri: { fsPath: "/test/storage" } as vscode.Uri, + storagePath: "/test/storage", + globalStoragePath: "/test/global", + logPath: "/test/logs", + asAbsolutePath: vi.fn((p) => `/test/path/${p}`), + environmentVariableCollection: {} as vscode.GlobalEnvironmentVariableCollection, + extension: {} as vscode.Extension, + languageModelAccessInformation: {} as vscode.LanguageModelAccessInformation, + } as unknown as vscode.ExtensionContext + } + + beforeEach(() => { + vi.clearAllMocks() + + // Reset environment variables + delete process.env.GLOBAL_AGENT_HTTP_PROXY + delete process.env.GLOBAL_AGENT_HTTPS_PROXY + delete process.env.GLOBAL_AGENT_NO_PROXY + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + + mockConfig = { + get: vi.fn().mockReturnValue(""), + } + + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue( + mockConfig as unknown as vscode.WorkspaceConfiguration, + ) + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + name: "Test", + replace: vi.fn(), + } as unknown as vscode.OutputChannel + }) + + describe("initializeNetworkProxy", () => { + it("should initialize without proxy when debugProxy.enabled is false", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return false + if (key === "debugProxy.serverUrl") return "http://127.0.0.1:8888" + return "" + }) + const context = createMockContext() + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(process.env.GLOBAL_AGENT_HTTP_PROXY).toBeUndefined() + expect(process.env.GLOBAL_AGENT_HTTPS_PROXY).toBeUndefined() + }) + + it("should configure proxy environment variables when debugProxy.enabled is true", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + return "" + }) + // Proxy is only applied in debug mode. + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(process.env.GLOBAL_AGENT_HTTP_PROXY).toBe("http://localhost:8080") + expect(process.env.GLOBAL_AGENT_HTTPS_PROXY).toBe("http://localhost:8080") + }) + + it("should not modify TLS settings in debug mode by default", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + if (key === "debugProxy.tlsInsecure") return false + return "" + }) + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined() + }) + + it("should disable TLS verification when tlsInsecure is enabled (debug mode only)", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + if (key === "debugProxy.tlsInsecure") return true + return "" + }) + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBe("0") + }) + + it("should register configuration change listener in debug mode", () => { + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(vscode.workspace.onDidChangeConfiguration).toHaveBeenCalled() + expect(context.subscriptions.length).toBeGreaterThan(0) + }) + + it("should not register listeners in production mode (early exit)", () => { + const context = createMockContext(vscode.ExtensionMode.Production) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(vscode.workspace.onDidChangeConfiguration).not.toHaveBeenCalled() + expect(context.subscriptions.length).toBe(0) + }) + + it("should not throw in non-debug mode if proxy deps are not installed", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + return "" + }) + const context = createMockContext(vscode.ExtensionMode.Production) + + expect(() => { + void initializeNetworkProxy(context, mockOutputChannel) + }).not.toThrow() + }) + }) + + describe("getProxyConfig", () => { + it("should return default config before initialization", () => { + // Reset the module to clear internal state + vi.resetModules() + + const config = getProxyConfig() + + expect(config.enabled).toBe(false) + expect(config.serverUrl).toBe("http://127.0.0.1:8888") // default value + expect(config.isDebugMode).toBe(false) + }) + + it("should return correct config after initialization", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://proxy.example.com:3128" + if (key === "debugProxy.tlsInsecure") return true + return "" + }) + const context = createMockContext(vscode.ExtensionMode.Production) + + void initializeNetworkProxy(context, mockOutputChannel) + const config = getProxyConfig() + + expect(config.enabled).toBe(true) + expect(config.serverUrl).toBe("http://proxy.example.com:3128") + expect(config.tlsInsecure).toBe(true) + expect(config.isDebugMode).toBe(false) + }) + + it("should trim whitespace from server URL", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.serverUrl") return " http://proxy.example.com:3128 " + return "" + }) + const context = createMockContext() + + void initializeNetworkProxy(context, mockOutputChannel) + const config = getProxyConfig() + + expect(config.serverUrl).toBe("http://proxy.example.com:3128") + }) + + it("should return default URL for empty server URL", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.serverUrl") return " " + return "" + }) + const context = createMockContext() + + void initializeNetworkProxy(context, mockOutputChannel) + const config = getProxyConfig() + + expect(config.serverUrl).toBe("http://127.0.0.1:8888") // falls back to default + }) + }) + + describe("isProxyEnabled", () => { + it("should return false when proxy is not enabled", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return false + return "" + }) + const context = createMockContext() + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(isProxyEnabled()).toBe(false) + }) + + it("should return true when proxy is enabled in debug mode", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + return "" + }) + // Proxy is only applied in debug mode. + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(isProxyEnabled()).toBe(true) + }) + }) + + describe("isDebugMode", () => { + it("should return false in production mode", () => { + const context = createMockContext(vscode.ExtensionMode.Production) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(isDebugMode()).toBe(false) + }) + + it("should return true in development mode", () => { + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(isDebugMode()).toBe(true) + }) + + // Note: This test is skipped because module state persists across tests. + // In a real scenario, isDebugMode() returns false before any initialization. + // The actual behavior is verified in integration testing. + it.skip("should return false before initialization", () => { + // This would require full module isolation which isn't practical here + expect(isDebugMode()).toBe(false) + }) + }) + + describe("security", () => { + it("should not disable TLS verification unless tlsInsecure is enabled", () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === "debugProxy.enabled") return true + if (key === "debugProxy.serverUrl") return "http://localhost:8080" + if (key === "debugProxy.tlsInsecure") return false + return "" + }) + const context = createMockContext(vscode.ExtensionMode.Development) + + void initializeNetworkProxy(context, mockOutputChannel) + + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined() + }) + }) +}) diff --git a/src/utils/fixNativeToolname.ts b/src/utils/fixNativeToolname.ts new file mode 100644 index 0000000000..f0f048cbd3 --- /dev/null +++ b/src/utils/fixNativeToolname.ts @@ -0,0 +1,22 @@ +import { ToolName } from "@roo-code/types" + +export const fixNativeToolname = (toolname: string | ToolName) => { + if ( + !toolname || + (!toolname.includes("") && !toolname.includes("") && !toolname.includes("")) + ) + return toolname as ToolName + let tags = [] as Array + let fixedToolname = toolname as ToolName + if (fixedToolname.includes("")) { + tags = fixedToolname.split("").sort((a, b) => b.length - a.length) + fixedToolname = tags[0] as ToolName + } + + if (fixedToolname.includes("")) { + tags = fixedToolname.split("").sort((a, b) => b.length - a.length) + fixedToolname = tags[0] as ToolName + } + + return fixedToolname +} diff --git a/src/utils/getDebugState.ts b/src/utils/getDebugState.ts new file mode 100644 index 0000000000..d6a50305ac --- /dev/null +++ b/src/utils/getDebugState.ts @@ -0,0 +1,6 @@ +import * as vscode from "vscode" +import { Package } from "../shared/package" +import { isJetbrainsPlatform } from "./platform" + +export const isDebug = () => + vscode.workspace.getConfiguration(Package.name).get("debug", isJetbrainsPlatform()) diff --git a/src/utils/ideaShellEnvLoader.ts b/src/utils/ideaShellEnvLoader.ts index 18614f0d2c..3b7f207530 100644 --- a/src/utils/ideaShellEnvLoader.ts +++ b/src/utils/ideaShellEnvLoader.ts @@ -61,7 +61,6 @@ export function mergePath(shellPath: string) { const shellEntries = shellPath.split(delimiter).filter(Boolean) - // 保留顺序:shell PATH 在前,VS Code PATH 在后 const merged = [...shellEntries, ...currentEntries.filter((p) => !shellEntries.includes(p))] envSnapshot.PATH = merged.join(delimiter) diff --git a/src/utils/networkProxy.ts b/src/utils/networkProxy.ts new file mode 100644 index 0000000000..448bc1b576 --- /dev/null +++ b/src/utils/networkProxy.ts @@ -0,0 +1,364 @@ +/** + * Network Proxy Configuration Module + * + * Provides proxy configuration for all outbound HTTP/HTTPS requests from the Roo Code extension. + * When running in debug mode (F5), a proxy can be enabled for outbound traffic. + * Optionally, TLS certificate verification can be disabled (debug only) to allow + * MITM proxy inspection. + * + * Uses global-agent to globally route all HTTP/HTTPS traffic through the proxy, + * which works with axios, fetch, and most SDKs that use native Node.js http/https. + */ + +import * as vscode from "vscode" +import { Package } from "../shared/package" + +/** + * Proxy configuration state + */ +export interface ProxyConfig { + /** Whether the debug proxy is enabled */ + enabled: boolean + /** The proxy server URL (e.g., http://127.0.0.1:8888) */ + serverUrl: string + /** Accept self-signed/insecure TLS certificates from the proxy (required for MITM) */ + tlsInsecure: boolean + /** Whether running in debug/development mode */ + isDebugMode: boolean +} + +let extensionContext: vscode.ExtensionContext | null = null +let proxyInitialized = false +let undiciProxyInitialized = false +let fetchPatched = false +let originalFetch: typeof fetch | undefined +let outputChannel: vscode.OutputChannel | null = null + +let loggingEnabled = false +let consoleLoggingEnabled = false + +let tlsVerificationOverridden = false +let originalNodeTlsRejectUnauthorized: string | undefined + +function redactProxyUrl(proxyUrl: string | undefined): string { + if (!proxyUrl) { + return "(not set)" + } + + try { + const url = new URL(proxyUrl) + url.username = "" + url.password = "" + return url.toString() + } catch { + // Fallback for invalid URLs: redact basic auth if present. + return proxyUrl.replace(/\/\/[^@/]+@/g, "//REDACTED@") + } +} + +function restoreGlobalFetchPatch(): void { + if (!fetchPatched) { + return + } + + if (originalFetch) { + globalThis.fetch = originalFetch + } + + fetchPatched = false + originalFetch = undefined +} + +function restoreTlsVerificationOverride(): void { + if (!tlsVerificationOverridden) { + return + } + + if (typeof originalNodeTlsRejectUnauthorized === "string") { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalNodeTlsRejectUnauthorized + } else { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + } + + tlsVerificationOverridden = false + originalNodeTlsRejectUnauthorized = undefined +} + +function applyTlsVerificationOverride(config: ProxyConfig): void { + // Only relevant in debug mode with an active proxy. + if (!config.isDebugMode || !config.enabled) { + restoreTlsVerificationOverride() + return + } + + if (!config.tlsInsecure) { + restoreTlsVerificationOverride() + return + } + + if (!tlsVerificationOverridden) { + originalNodeTlsRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED + } + + // CodeQL: debug-only opt-in for MITM debugging. + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" // lgtm[js/disabling-certificate-validation] + tlsVerificationOverridden = true +} + +/** + * Initialize the network proxy module with the extension context. + * Must be called early in extension activation before any network requests. + * + * @param context The VS Code extension context + * @param channel Optional output channel for logging + */ +export async function initializeNetworkProxy( + context: vscode.ExtensionContext, + channel?: vscode.OutputChannel, +): Promise { + extensionContext = context + + // extensionMode is immutable for the process lifetime - exit early if not in debug mode. + // This avoids any overhead (listeners, logging, etc.) in production. + const isDebugMode = context.extensionMode === vscode.ExtensionMode.Development + if (!isDebugMode) { + return + } + + outputChannel = channel ?? null + loggingEnabled = true + consoleLoggingEnabled = !outputChannel + + const config = getProxyConfig() + + log(`Initializing network proxy module...`) + log( + `Proxy config: enabled=${config.enabled}, serverUrl=${redactProxyUrl(config.serverUrl)}, tlsInsecure=${config.tlsInsecure}`, + ) + + // Listen for configuration changes to allow toggling proxy during a debug session. + // Guard for test environments where onDidChangeConfiguration may not be mocked. + if (typeof vscode.workspace.onDidChangeConfiguration === "function") { + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if ( + e.affectsConfiguration(`${Package.name}.debugProxy.enabled`) || + e.affectsConfiguration(`${Package.name}.debugProxy.serverUrl`) || + e.affectsConfiguration(`${Package.name}.debugProxy.tlsInsecure`) + ) { + const newConfig = getProxyConfig() + + if (newConfig.enabled) { + applyTlsVerificationOverride(newConfig) + configureGlobalProxy(newConfig) + configureUndiciProxy(newConfig) + } else { + // Proxy disabled - but we can't easily un-bootstrap global-agent or reset undici dispatcher safely. + // We *can* restore any global fetch patch immediately. + restoreGlobalFetchPatch() + restoreTlsVerificationOverride() + log("Debug proxy disabled. Restart VS Code to fully disable proxy routing.") + } + } + }), + ) + } + + // Ensure we restore any overrides when the extension unloads. + context.subscriptions.push({ + dispose: () => { + restoreGlobalFetchPatch() + restoreTlsVerificationOverride() + }, + }) + + if (config.enabled) { + applyTlsVerificationOverride(config) + await configureGlobalProxy(config) + await configureUndiciProxy(config) + } else { + log(`Debug proxy not enabled.`) + } +} + +/** + * Get the current proxy configuration based on VS Code settings and extension mode. + */ +export function getProxyConfig(): ProxyConfig { + const defaultServerUrl = "http://127.0.0.1:8888" + + if (!extensionContext) { + // Fallback if called before initialization + return { + enabled: false, + serverUrl: defaultServerUrl, + tlsInsecure: false, + isDebugMode: false, + } + } + + const config = vscode.workspace.getConfiguration(Package.name) + const enabled = Boolean(config.get("debugProxy.enabled")) + const rawServerUrl = config.get("debugProxy.serverUrl") + const serverUrl = typeof rawServerUrl === "string" && rawServerUrl.trim() ? rawServerUrl.trim() : defaultServerUrl + const tlsInsecure = Boolean(config.get("debugProxy.tlsInsecure")) + + // Debug mode only. + const isDebugMode = extensionContext.extensionMode === vscode.ExtensionMode.Development + + return { + enabled, + serverUrl, + tlsInsecure, + isDebugMode, + } +} + +/** + * Configure global-agent to route all HTTP/HTTPS traffic through the proxy. + */ +async function configureGlobalProxy(config: ProxyConfig): Promise { + if (proxyInitialized) { + // global-agent can only be bootstrapped once + // Update environment variables for any new connections + log(`Proxy already initialized, updating env vars only`) + updateProxyEnvVars(config) + return + } + + // Set up environment variables before bootstrapping + log(`Setting proxy environment variables before bootstrap (values redacted)...`) + updateProxyEnvVars(config) + + let bootstrap: (() => void) | undefined + try { + const mod = (await import("global-agent")) as typeof import("global-agent") + bootstrap = mod.bootstrap + } catch (error) { + log( + `Failed to load global-agent (proxy support is only available in debug/dev builds): ${error instanceof Error ? error.message : String(error)}`, + ) + return + } + + // Bootstrap global-agent to intercept all HTTP/HTTPS requests + log(`Calling global-agent bootstrap()...`) + try { + bootstrap() + proxyInitialized = true + log(`global-agent bootstrap() completed successfully`) + } catch (error) { + log(`global-agent bootstrap() FAILED: ${error instanceof Error ? error.message : String(error)}`) + return + } + + log(`Network proxy configured: ${redactProxyUrl(config.serverUrl)}`) +} + +/** + * Configure undici's global dispatcher so Node's built-in `fetch()` and any undici-based + * clients route through the proxy. + */ +async function configureUndiciProxy(config: ProxyConfig): Promise { + if (!config.enabled || !config.serverUrl) { + return + } + + if (undiciProxyInitialized) { + log(`undici global dispatcher already configured; restart VS Code to change proxy safely`) + return + } + + try { + const { + ProxyAgent, + setGlobalDispatcher, + fetch: undiciFetch, + } = (await import("undici")) as typeof import("undici") + + const proxyAgent = new ProxyAgent({ + uri: config.serverUrl, + // If the user enabled TLS insecure mode (debug only), apply it to undici. + requestTls: config.tlsInsecure + ? ({ rejectUnauthorized: false } satisfies import("tls").ConnectionOptions) // lgtm[js/disabling-certificate-validation] + : undefined, + proxyTls: config.tlsInsecure + ? ({ rejectUnauthorized: false } satisfies import("tls").ConnectionOptions) // lgtm[js/disabling-certificate-validation] + : undefined, + }) + setGlobalDispatcher(proxyAgent) + undiciProxyInitialized = true + log(`undici global dispatcher configured for proxy: ${redactProxyUrl(config.serverUrl)}`) + + // Node's built-in `fetch()` (Node 18+) is powered by an internal undici copy. + // Setting a dispatcher on our `undici` dependency does NOT affect that internal fetch. + // To ensure Roo Code's `fetch()` calls are proxied, patch global fetch in debug mode. + // This patch is scoped to the extension lifecycle (restored on deactivate) and can be restored + // immediately if the proxy is disabled. + if (!fetchPatched) { + if (typeof globalThis.fetch === "function") { + originalFetch = globalThis.fetch + } + + globalThis.fetch = undiciFetch as unknown as typeof fetch + fetchPatched = true + log(`globalThis.fetch patched to undici.fetch (debug proxy mode)`) + + if (extensionContext) { + extensionContext.subscriptions.push({ + dispose: () => restoreGlobalFetchPatch(), + }) + } + } + } catch (error) { + log(`Failed to configure undici proxy dispatcher: ${error instanceof Error ? error.message : String(error)}`) + } +} +/** + * Update environment variables for proxy configuration. + * global-agent reads from GLOBAL_AGENT_* environment variables. + */ +function updateProxyEnvVars(config: ProxyConfig): void { + if (config.serverUrl) { + // global-agent uses these environment variables + process.env.GLOBAL_AGENT_HTTP_PROXY = config.serverUrl + process.env.GLOBAL_AGENT_HTTPS_PROXY = config.serverUrl + process.env.GLOBAL_AGENT_NO_PROXY = "" // Proxy all requests + } +} + +/** + * Check if a proxy is currently configured and active. + */ +export function isProxyEnabled(): boolean { + const config = getProxyConfig() + // Active proxy is only applied in debug mode. + return config.enabled && config.isDebugMode +} + +/** + * Check if we're running in debug mode. + */ +export function isDebugMode(): boolean { + if (!extensionContext) { + return false + } + return extensionContext.extensionMode === vscode.ExtensionMode.Development +} + +/** + * Log a message to the output channel if available. + */ +function log(message: string): void { + if (!loggingEnabled) { + return + } + + const logMessage = `[NetworkProxy] ${message}` + if (outputChannel) { + outputChannel.appendLine(logMessage) + } + if (consoleLoggingEnabled) { + console.log(logMessage) + } +} diff --git a/src/utils/platform.ts b/src/utils/platform.ts index 0d81c02c60..588246ce2c 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -9,3 +9,7 @@ export function isJetbrainsPlatform(): boolean { return isJetbrains } + +export function isCliPatform(): boolean { + return vscode.env.appName.includes("cli") +} diff --git a/webview-ui/src/components/chat/AutoApproveDropdown.tsx b/webview-ui/src/components/chat/AutoApproveDropdown.tsx index a427359569..41d5586b25 100644 --- a/webview-ui/src/components/chat/AutoApproveDropdown.tsx +++ b/webview-ui/src/components/chat/AutoApproveDropdown.tsx @@ -163,7 +163,7 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }: "max-[300px]:shrink-0", disabled ? "opacity-50 cursor-not-allowed" - : "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer", + : "opacity-90 hover:opacity-100 bg-vscode-input-background hover:border-[rgba(255,255,255,0.15)] cursor-pointer", triggerClassName, )}> {!effectiveAutoApprovalEnabled ? ( diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 304accaef6..02986381d6 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -112,6 +112,7 @@ export const ChatTextArea = forwardRef( // cloudOrganizations, enterBehavior, reviewTask, + automaticallyFocus, } = useExtensionState() const selectedProviderModels = useMemo(() => { if (!apiConfiguration?.apiProvider) return [] @@ -709,6 +710,7 @@ export const ChatTextArea = forwardRef( // Automatically focus on the text box when the window regains focus. useEffect(() => { const handleWindowFocus = () => { + if (!automaticallyFocus) return // When the window regains focus, automatically focus on the text box. // However, avoid focusing when the context menu is displayed, and avoid interrupting user operations in edit mode. if (textAreaRef.current && !showContextMenu && !isEditMode) { @@ -723,7 +725,7 @@ export const ChatTextArea = forwardRef( return () => { window.removeEventListener("focus", handleWindowFocus) } - }, [showContextMenu, isEditMode]) + }, [showContextMenu, isEditMode, automaticallyFocus]) const handleBlur = useCallback(() => { // Only hide the context menu if the user didn't click on it. @@ -1375,7 +1377,7 @@ export const ChatTextArea = forwardRef( {/* ModeSwitch positioned at the top left of the input area */} -
+
@@ -1445,7 +1447,7 @@ export const ChatTextArea = forwardRef( selectedProviderModels={selectedProviderModels} /> )} - + diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 1656c6bf24..d6d75e30b9 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/webview-ui/src/components/chat/ModeSelector.tsx @@ -221,7 +221,7 @@ export const ModeSelector = ({ "transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset", disabled ? "opacity-50 cursor-not-allowed" - : "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer", + : "opacity-90 hover:opacity-100 bg-vscode-input-background hover:border-[rgba(255,255,255,0.15)] cursor-pointer", triggerClassName, !disabled && !hasOpenedModeSelector ? "bg-primary opacity-90 hover:bg-primary-hover text-vscode-button-foreground" @@ -232,7 +232,7 @@ export const ModeSelector = ({ Review... ) : ( - + {selectedMode?.name || t("chat:selectMode")} {selectedMode?.name ? ` (${zgsmCodeMode})` : ""} diff --git a/webview-ui/src/components/chat/ModeSwitch.tsx b/webview-ui/src/components/chat/ModeSwitch.tsx index 167603fbb5..f54075ace5 100644 --- a/webview-ui/src/components/chat/ModeSwitch.tsx +++ b/webview-ui/src/components/chat/ModeSwitch.tsx @@ -29,7 +29,6 @@ const mapModeToDisplay = (mode: ExtensionState["zgsmCodeMode"]): "vibe" | "plan" const SwitchContainer = styled.div<{ disabled: boolean }>` display: flex; align-items: center; - background-color: transparent; border: 1px solid var(--vscode-input-border); border-radius: 12px; overflow: hidden; diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 4be681df9a..f1fc5b896d 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -153,7 +153,7 @@ const ApiOptions = ({ setCachedStateField, }: ApiOptionsProps) => { const { t } = useAppTranslation() - const { setZgsmCodeMode, organizationAllowList, claudeCodeIsAuthenticated /* cloudIsAuthenticated */ } = + const { setZgsmCodeMode, organizationAllowList, claudeCodeIsAuthenticated /* cloudIsAuthenticated */, debug } = useExtensionState() const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { @@ -565,6 +565,7 @@ const ApiOptions = ({ {errorMessage && } {!fromWelcomeView && selectedProvider === "zgsm" && ( -
+
{showLabel && } {tooltip ? ( diff --git a/webview-ui/src/components/settings/ProviderRenderer.tsx b/webview-ui/src/components/settings/ProviderRenderer.tsx index 879a914271..1d8fa1cee6 100644 --- a/webview-ui/src/components/settings/ProviderRenderer.tsx +++ b/webview-ui/src/components/settings/ProviderRenderer.tsx @@ -253,7 +253,7 @@ const ProviderRenderer: React.FC = ({ }}> diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 0e05d1f651..e65fdf11ea 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -219,6 +219,7 @@ const SettingsView = forwardRef(({ onDone, t openRouterImageGenerationSelectedModel, reasoningBlockCollapsed, showSpeedInfo, + automaticallyFocus, enterBehavior, includeCurrentTime, includeCurrentCost, @@ -421,6 +422,7 @@ const SettingsView = forwardRef(({ onDone, t includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, showSpeedInfo: showSpeedInfo ?? false, + automaticallyFocus: automaticallyFocus ?? false, enterBehavior: enterBehavior ?? "send", includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, @@ -893,6 +895,7 @@ const SettingsView = forwardRef(({ onDone, t { reasoningBlockCollapsed: boolean showSpeedInfo: boolean + automaticallyFocus: boolean enterBehavior: "send" | "newline" apiConfiguration?: any setCachedStateField: SetCachedStateField @@ -20,6 +21,7 @@ interface UISettingsProps extends HTMLAttributes { export const UISettings = ({ reasoningBlockCollapsed, showSpeedInfo, + automaticallyFocus, enterBehavior, apiConfiguration, setCachedStateField, @@ -51,6 +53,15 @@ export const UISettings = ({ enabled: showSpeedInfo, }) } + // automaticallyFocus + const handleAutomaticallyFocusChange = (automaticallyFocus: boolean) => { + setCachedStateField("automaticallyFocus", automaticallyFocus) + + // Track telemetry event + telemetryClient.capture("ui_settings_automatically_focus_changed", { + enabled: automaticallyFocus, + }) + } const handleEnterBehaviorChange = (requireCtrlEnter: boolean) => { const newBehavior = requireCtrlEnter ? "newline" : "send" @@ -100,6 +111,18 @@ export const UISettings = ({
)} + {/* Show Speed Info Setting */} +
+ handleAutomaticallyFocusChange(e.target.checked)} + data-testid="show-speed-info-checkbox"> + {t("settings:ui.automaticallyFocus.label")} + +
+ {t("settings:ui.automaticallyFocus.description")} +
+
{/* Enter Key Behavior Setting */}
{ const defaultProps = { reasoningBlockCollapsed: false, showSpeedInfo: false, + automaticallyFocus: false, apiConfiguration: { apiProvider: "zgsm", }, diff --git a/webview-ui/src/components/settings/providers/ZgsmAI.tsx b/webview-ui/src/components/settings/providers/ZgsmAI.tsx index 88414fb482..879301b032 100644 --- a/webview-ui/src/components/settings/providers/ZgsmAI.tsx +++ b/webview-ui/src/components/settings/providers/ZgsmAI.tsx @@ -31,6 +31,7 @@ import { delay } from "lodash-es" type OpenAICompatibleProps = { fromWelcomeView?: boolean + debug?: boolean apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void organizationAllowList: OrganizationAllowList @@ -42,6 +43,7 @@ type OpenAICompatibleProps = { export const ZgsmAI = ({ apiConfiguration, + debug, fromWelcomeView, setApiConfigurationField, setCachedStateField, @@ -215,17 +217,19 @@ export const ZgsmAI = ({ {t("settings:providers.refreshModels.label")}
-
- { - setCachedStateField("useZgsmCustomConfig", e.target.checked) - }}> - - -
+ {debug && ( +
+ { + setCachedStateField("useZgsmCustomConfig", e.target.checked) + }}> + + +
+ )} )} {!fromWelcomeView && useZgsmCustomConfig && ( diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index f677c70136..91db8a0478 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -159,6 +159,8 @@ export interface ExtensionStateContextType extends ExtensionState { setReasoningBlockCollapsed: (value: boolean) => void setShowSpeedInfo: (value: boolean) => void showSpeedInfo?: boolean + setAutomaticallyFocus: (value: boolean) => void + automaticallyFocus?: boolean enterBehavior?: "send" | "newline" setEnterBehavior: (value: "send" | "newline") => void autoCondenseContext: boolean @@ -272,6 +274,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode historyPreviewCollapsed: false, // Initialize the new state (default to expanded) reasoningBlockCollapsed: true, // Default to collapsed showSpeedInfo: false, // Default to not showing speed info + automaticallyFocus: false, // Default to not showing speed info enterBehavior: "send", // Default: Enter sends, Shift+Enter creates newline cloudUserInfo: null, cloudIsAuthenticated: false, @@ -526,6 +529,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode ...state, reasoningBlockCollapsed: state.reasoningBlockCollapsed ?? true, showSpeedInfo: state.showSpeedInfo ?? false, + automaticallyFocus: state.automaticallyFocus ?? false, didHydrateState, showWelcome, theme, @@ -652,6 +656,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setReasoningBlockCollapsed: (value) => setState((prevState) => ({ ...prevState, reasoningBlockCollapsed: value })), setShowSpeedInfo: (value) => setState((prevState) => ({ ...prevState, showSpeedInfo: value })), + setAutomaticallyFocus: (value) => setState((prevState) => ({ ...prevState, automaticallyFocus: value })), enterBehavior: state.enterBehavior ?? "send", setEnterBehavior: (value) => setState((prevState) => ({ ...prevState, enterBehavior: value })), setHasOpenedModeSelector: (value) => setState((prevState) => ({ ...prevState, hasOpenedModeSelector: value })), diff --git a/webview-ui/src/i18n/costrict-i18n/locales/en/chat.json b/webview-ui/src/i18n/costrict-i18n/locales/en/chat.json index 43f71769a2..3441dc72e0 100644 --- a/webview-ui/src/i18n/costrict-i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/costrict-i18n/locales/en/chat.json @@ -5,7 +5,7 @@ "wantsToFetch": "CoStrict wants to fetch detailed instructions to assist with the current task" }, "about": "AI-Powered Strict Coding Assistant for Enterprises", - "error": "CoStrict will automatically fix", + "error": "CoStrict will automatically plan the next step", "text": { "rooSaid": "CoStrict said" }, diff --git a/webview-ui/src/i18n/costrict-i18n/locales/en/settings.json b/webview-ui/src/i18n/costrict-i18n/locales/en/settings.json index a84685778c..ce6fb3bfc5 100644 --- a/webview-ui/src/i18n/costrict-i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/costrict-i18n/locales/en/settings.json @@ -41,7 +41,7 @@ "customModel": { "capabilities": "Configure the capabilities and pricing for your custom OpenAI-compatible model. Be careful when specifying the model capabilities, as they can affect how CoStrict performs." }, - "useZgsmCustomConfig": "Use custom configuration", + "useZgsmCustomConfig": "Use custom configuration (debug enabled)", "geminiCli": { "projectIdDescription": "Specify your Google Cloud Project ID for enterprise or non-free tier access. Leave empty for automatic project discovery with personal accounts. If you encounter an API Error, it is recommended to fill in the Project ID." }, diff --git a/webview-ui/src/i18n/costrict-i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/costrict-i18n/locales/zh-CN/chat.json index c145329b38..8a929505e9 100644 --- a/webview-ui/src/i18n/costrict-i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/costrict-i18n/locales/zh-CN/chat.json @@ -5,7 +5,7 @@ "wantsToFetch": "CoStrict 想要获取详细指令以协助当前任务" }, "about": "企业严肃开发的AI智能伙伴", - "error": "CoStrict 将自动修复", + "error": "CoStrict 将自动规划下一步处理", "text": { "rooSaid": "CoStrict 说" }, diff --git a/webview-ui/src/i18n/costrict-i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/costrict-i18n/locales/zh-CN/settings.json index 1b9d22d85a..2f510f7416 100644 --- a/webview-ui/src/i18n/costrict-i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/costrict-i18n/locales/zh-CN/settings.json @@ -41,7 +41,7 @@ "customModel": { "capabilities": "配置您的自定义OpenAI兼容模型的功能和定价。在指定模型功能时要小心,因为它们会影响 CoStrict 的表现。" }, - "useZgsmCustomConfig": "使用自定义配置", + "useZgsmCustomConfig": "使用自定义配置 (debug 已开启)", "geminiCli": { "projectIdDescription": "为企业或非免费套餐访问指定您的 Google Cloud 项目 ID。如使用个人账号并希望自动发现项目,请留空。如果您遇到 API 错误,建议填写项目ID。" }, diff --git a/webview-ui/src/i18n/costrict-i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/costrict-i18n/locales/zh-TW/chat.json index 2a511bfa31..e02545399d 100644 --- a/webview-ui/src/i18n/costrict-i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/costrict-i18n/locales/zh-TW/chat.json @@ -5,7 +5,7 @@ "wantsToFetch": "CoStrict 想要獲取詳細指令以協助當前任務" }, "about": "企業嚴肅開發的AI智慧夥伴", - "error": "CoStrict 將自動修復", + "error": "CoStrict 將自動規劃下一步處理", "text": { "rooSaid": "CoStrict 說" }, diff --git a/webview-ui/src/i18n/costrict-i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/costrict-i18n/locales/zh-TW/settings.json index a392738d9f..2ae5c30dab 100644 --- a/webview-ui/src/i18n/costrict-i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/costrict-i18n/locales/zh-TW/settings.json @@ -41,7 +41,7 @@ "customModel": { "capabilities": "配置您的自定義OpenAI兼容模型的功能和定價。在指定模型功能時要小心,因為它們會影響 CoStrict 的表現。" }, - "useZgsmCustomConfig": "使用自定義配置", + "useZgsmCustomConfig": "使用自定義配置 (debug 已開啟)", "geminiCli": { "projectIdDescription": "若為企業或非免費方案存取,請指定您的 Google Cloud 專案 ID。如使用個人帳號並希望自動探索專案,請留空。如果您遇到 API 錯誤,建議填寫專案 ID。" }, diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 00dc9fdcc0..492d28bedd 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -71,6 +71,10 @@ "label": "Show speed info", "description": "When enabled, shows token generation speed metrics in messages" }, + "automaticallyFocus": { + "label": "Automatically focus", + "description": "Automatically focus on the text box when the window regains focus" + }, "requireCtrlEnterToSend": { "label": "Require {{primaryMod}}+Enter to send messages", "description": "When enabled, you must press {{primaryMod}}+Enter to send messages instead of just Enter" diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index c92965e9c3..6a6a6a365b 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -997,6 +997,10 @@ "label": "显示速度信息", "description": "启用后,在消息中显示 token 生成速度指标" }, + "automaticallyFocus": { + "label": "自动聚焦", + "description": "窗口重新获得焦点时自动聚焦到文本框" + }, "requireCtrlEnterToSend": { "label": "需要 {{primaryMod}}+Enter 发送消息", "description": "启用后,必须按 {{primaryMod}}+Enter 发送消息,而不仅仅是 Enter" diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 3d90845fe3..283ed6a85a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -997,6 +997,10 @@ "label": "顯示速度資訊", "description": "啟用後,在訊息中顯示 token 生成速度指標" }, + "automaticallyFocus": { + "label": "自動聚焦", + "description": "視窗重新獲得焦點時自動聚焦到文字框" + }, "requireCtrlEnterToSend": { "label": "需要 {{primaryMod}}+Enter 傳送訊息", "description": "啟用後,必須按 {{primaryMod}}+Enter 傳送訊息,而不只是 Enter"