diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8725c28..3350c54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,22 +2,24 @@ name: CI on: push: - branches: [ main, develop ] + branches: [main, develop] pull_request: - branches: [ main, develop ] + branches: [main, develop] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: build-and-test: - name: Build and Type Check + name: Build & Test (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest - strategy: matrix: node-version: [18.x, 20.x, 22.x] steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 @@ -28,36 +30,34 @@ jobs: - name: Install dependencies run: npm ci - - name: Run TypeScript type check + - name: Type check run: npm run type-check - - name: Build project + - name: Build run: npm run build + - name: Unit & integration tests + run: npm test + - name: Check build artifacts run: | - echo "Checking if build artifacts exist..." - test -f dist/index.js || (echo "dist/index.js not found" && exit 1) - test -f dist/server.js || (echo "dist/server.js not found" && exit 1) - test -d dist/frontend || (echo "dist/frontend not found" && exit 1) - echo "All build artifacts present!" + test -f dist/index.js + test -f dist/server.js + test -d dist/frontend - name: Upload build artifacts if: matrix.node-version == '20.x' uses: actions/upload-artifact@v4 with: name: build-artifacts - path: | - dist/ + path: dist/ retention-days: 7 lint-check: name: Lint Check runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 @@ -71,11 +71,46 @@ jobs: - name: Check for TypeScript errors run: npm run type-check + e2e: + name: E2E Tests + runs-on: ubuntu-latest + needs: build-and-test + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Install Playwright browsers + run: ./node_modules/.bin/playwright install --with-deps chromium + + - name: Run E2E tests + run: npm run test:e2e + env: + EXCALIDRAW_DB_PATH: /tmp/excalidraw-e2e-ci.db + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + ci-status: runs-on: ubuntu-latest continue-on-error: false name: CI Status Check - needs: [build-and-test, lint-check] + needs: [build-and-test, lint-check, e2e] if: always() permissions: statuses: write diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c8ae0d7..1c36689 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,144 +1,134 @@ -name: Docker Build & Push +name: Docker Build +# PR validation and manual builds. Production pushes are handled by release.yml. on: - push: - branches: [ main ] - tags: - - 'v*.*.*' pull_request: - branches: [ main ] + branches: [main] + paths: + - 'Dockerfile*' + - 'src/**' + - 'frontend/**' + - 'package.json' workflow_dispatch: + inputs: + push: + description: 'Push images to Docker Hub' + required: true + type: boolean + default: false env: - REGISTRY: docker.io IMAGE_NAME_MCP: sanjibdevnath/mcp-excalidraw-local IMAGE_NAME_CANVAS: sanjibdevnath/mcp-excalidraw-local-canvas jobs: - build-and-push-mcp: - name: Build and Push MCP Server Image + build-mcp: + name: Build MCP Server image runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub - if: github.event_name != 'pull_request' + if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Extract metadata for MCP Server + - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_MCP }} + images: ${{ env.IMAGE_NAME_MCP }} tags: | - type=ref,event=branch type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} type=sha,prefix=sha- - type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push MCP Server image + - name: Build MCP Server image uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile - push: ${{ github.event_name != 'pull_request' }} + push: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - build-and-push-canvas: - name: Build and Push Canvas Server Image + build-canvas: + name: Build Canvas Server image runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub - if: github.event_name != 'pull_request' + if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Extract metadata for Canvas Server + - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }} + images: ${{ env.IMAGE_NAME_CANVAS }} tags: | - type=ref,event=branch type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} type=sha,prefix=sha- - type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push Canvas Server image + - name: Build Canvas Server image uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile.canvas - push: ${{ github.event_name != 'pull_request' }} + push: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - test-docker-images: - name: Test Docker Images - needs: [build-and-push-mcp, build-and-push-canvas] - if: github.event_name != 'pull_request' + test-images: + name: Test Docker images + needs: [build-mcp, build-canvas] runs-on: ubuntu-latest - steps: - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: actions/checkout@v4 - - name: Test Canvas Server image + - name: Build and test Canvas image locally run: | - docker pull ${{ env.IMAGE_NAME_CANVAS }}:latest - docker run -d -p 3000:3000 --name test-canvas ${{ env.IMAGE_NAME_CANVAS }}:latest - sleep 10 - curl -f http://localhost:3000/health || exit 1 - docker logs test-canvas + docker build -f Dockerfile.canvas -t canvas-test . + docker run -d -p 3000:3000 --name test-canvas canvas-test + HEALTHY=false + for i in $(seq 1 30); do + if curl -sf http://localhost:3000/health > /dev/null 2>&1; then + echo "Health check passed on attempt $i" + HEALTHY=true + break + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + if [ "$HEALTHY" != "true" ]; then + echo "::error::Health check failed after 30 attempts" + docker logs test-canvas + docker stop test-canvas || true + exit 1 + fi + curl -sf http://localhost:3000/health | jq . docker stop test-canvas - - name: Test MCP Server image - run: | - docker pull ${{ env.IMAGE_NAME_MCP }}:latest - echo "MCP Server image pulled successfully" - docker-status: runs-on: ubuntu-latest continue-on-error: false name: Docker Build Status Check - needs: [build-and-push-mcp, build-and-push-canvas] + needs: [build-mcp, build-canvas, test-images] if: always() permissions: statuses: write diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 98c4d8a..549cb7b 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,26 +1,30 @@ -name: Publish to NPM +name: Publish to NPM (manual) +# Fallback for manual publishing. The primary publish path is release.yml. +# Use this for publishing beta/next tags or re-publishing a failed release. on: - release: - types: [published] workflow_dispatch: inputs: tag: - description: 'Tag to publish (e.g., latest, beta, next)' + description: 'NPM dist-tag (latest, beta, next)' required: true default: 'latest' + type: choice + options: + - latest + - beta + - next jobs: publish: - name: Publish to NPM Registry + name: Publish to NPM (${{ github.event.inputs.tag }}) runs-on: ubuntu-latest permissions: - contents: write + contents: read id-token: write steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 @@ -32,75 +36,43 @@ jobs: - name: Install dependencies run: npm ci - - name: Run type check + - name: Type check run: npm run type-check - - name: Build project + - name: Build run: npm run build + - name: Unit & integration tests + run: npm test + - name: Verify build artifacts run: | - echo "Verifying build artifacts..." - test -f dist/index.js || (echo "ERROR: dist/index.js not found" && exit 1) - test -f dist/server.js || (echo "ERROR: dist/server.js not found" && exit 1) - test -d dist/frontend || (echo "ERROR: dist/frontend not found" && exit 1) - echo "All required artifacts present!" + test -f dist/index.js + test -f dist/server.js + test -d dist/frontend - - name: Get package version - id: package-version - run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + - name: Get version + id: version + run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" - name: Check if version exists on NPM - id: check-version + id: check run: | - if npm view @sanjibdevnath/mcp-excalidraw-local@${{ steps.package-version.outputs.version }} version 2>/dev/null; then - echo "exists=true" >> $GITHUB_OUTPUT - echo "Version ${{ steps.package-version.outputs.version }} already exists on NPM" + if npm view @sanjibdevnath/mcp-excalidraw-local@${{ steps.version.outputs.version }} version 2>/dev/null; then + echo "exists=true" >> "$GITHUB_OUTPUT" else - echo "exists=false" >> $GITHUB_OUTPUT - echo "Version ${{ steps.package-version.outputs.version }} does not exist on NPM" + echo "exists=false" >> "$GITHUB_OUTPUT" fi - - name: Publish to NPM (Release) - if: github.event_name == 'release' && steps.check-version.outputs.exists == 'false' - run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Publish to NPM (Manual) - if: github.event_name == 'workflow_dispatch' && steps.check-version.outputs.exists == 'false' + - name: Publish to NPM + if: steps.check.outputs.exists == 'false' run: npm publish --tag ${{ github.event.inputs.tag }} --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Skip publishing (version exists) - if: steps.check-version.outputs.exists == 'true' + - name: Skip (version exists) + if: steps.check.outputs.exists == 'true' run: | - echo "⚠️ Skipping publish - version ${{ steps.package-version.outputs.version }} already exists on NPM" - echo "Please bump the version in package.json before publishing" - - - name: Create GitHub Release Assets - if: github.event_name == 'release' - run: | - tar -czf mcp-excalidraw-local-${{ steps.package-version.outputs.version }}.tar.gz dist/ - - - name: Upload Release Assets - if: github.event_name == 'release' - uses: softprops/action-gh-release@v1 - with: - files: | - mcp-excalidraw-local-${{ steps.package-version.outputs.version }}.tar.gz - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - notify: - name: Publish Notification - needs: publish - runs-on: ubuntu-latest - if: success() - - steps: - - name: Success notification - run: | - echo "✅ Package successfully published to NPM!" - echo "View at: https://www.npmjs.com/package/@sanjibdevnath/mcp-excalidraw-local" + echo "⚠️ Version ${{ steps.version.outputs.version }} already on NPM" + echo "Bump the version in package.json first" + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ea25028 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,282 @@ +name: Release & Publish + +on: + push: + branches: [main] + paths-ignore: + - '*.md' + - 'docs/**' + - '.github/workflows/ci.yml' + +permissions: + contents: write + id-token: write + +jobs: + # Gate: only release if CI passed and there are releasable commits + check: + name: Check for releasable commits + runs-on: ubuntu-latest + outputs: + bump: ${{ steps.bump.outputs.bump }} + new_version: ${{ steps.bump.outputs.new_version }} + changelog: ${{ steps.bump.outputs.changelog }} + should_release: ${{ steps.bump.outputs.should_release }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version bump from conventional commits + id: bump + run: | + # Find the latest semver tag + LATEST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -n1) + if [ -z "$LATEST_TAG" ]; then + LATEST_TAG=$(git rev-list --max-parents=0 HEAD) + echo "No tags found, using first commit" + fi + echo "Latest tag: $LATEST_TAG" + + # Get commit messages since last tag + COMMITS=$(git log "$LATEST_TAG"..HEAD --pretty=format:"%s" 2>/dev/null || git log --pretty=format:"%s") + + if [ -z "$COMMITS" ]; then + echo "No new commits since $LATEST_TAG" + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Skip if the only commit is a version bump + NON_RELEASE_COMMITS=$(echo "$COMMITS" | grep -v "^chore(release):" || true) + if [ -z "$NON_RELEASE_COMMITS" ]; then + echo "Only release commits found, skipping" + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Commits since $LATEST_TAG:" + echo "$COMMITS" + + # Determine bump type from conventional commit prefixes + BUMP="patch" + if echo "$COMMITS" | grep -qiE "^.*(BREAKING[ -]CHANGE|!)(\(.+\))?:"; then + BUMP="major" + elif echo "$COMMITS" | grep -qiE "^(feat|feature)(\(.+\))?:"; then + BUMP="minor" + elif echo "$COMMITS" | grep -qiE "^✨"; then + BUMP="minor" + fi + + # Read current version and compute new one + CURRENT=$(node -p "require('./package.json').version") + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + case "$BUMP" in + major) NEW_VERSION="$((MAJOR+1)).0.0" ;; + minor) NEW_VERSION="$MAJOR.$((MINOR+1)).0" ;; + patch) NEW_VERSION="$MAJOR.$MINOR.$((PATCH+1))" ;; + esac + + echo "Current: $CURRENT → New: $NEW_VERSION ($BUMP)" + + # Build changelog from conventional commits + CHANGELOG="" + BREAKING=$(echo "$COMMITS" | grep -iE "^.*(BREAKING[ -]CHANGE|!)(\(.+\))?:" || true) + FEATURES=$(echo "$COMMITS" | grep -iE "^(feat|feature|✨)" || true) + FIXES=$(echo "$COMMITS" | grep -iE "^(fix|🐛)" || true) + OTHERS=$(echo "$COMMITS" | grep -viE "^(feat|feature|✨|fix|🐛|chore\(release\))" | grep -viE "BREAKING" || true) + + if [ -n "$BREAKING" ]; then + CHANGELOG="$CHANGELOG + ### ⚠️ Breaking Changes + $(echo "$BREAKING" | sed 's/^/- /')" + fi + if [ -n "$FEATURES" ]; then + CHANGELOG="$CHANGELOG + ### ✨ Features + $(echo "$FEATURES" | sed 's/^/- /')" + fi + if [ -n "$FIXES" ]; then + CHANGELOG="$CHANGELOG + ### 🐛 Bug Fixes + $(echo "$FIXES" | sed 's/^/- /')" + fi + if [ -n "$OTHERS" ]; then + CHANGELOG="$CHANGELOG + ### 📦 Other Changes + $(echo "$OTHERS" | sed 's/^/- /')" + fi + + echo "bump=$BUMP" >> "$GITHUB_OUTPUT" + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "should_release=true" >> "$GITHUB_OUTPUT" + + # Multi-line output for changelog + { + echo "changelog<> "$GITHUB_OUTPUT" + + test: + name: Pre-release tests + needs: check + if: needs.check.outputs.should_release == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run type-check + + - name: Build + run: npm run build + + - name: Unit & integration tests + run: npm test + + release: + name: Version bump & release + needs: [check, test] + if: needs.check.outputs.should_release == 'true' + runs-on: ubuntu-latest + outputs: + version: ${{ needs.check.outputs.new_version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump version in package.json + run: | + npm version ${{ needs.check.outputs.new_version }} --no-git-tag-version + git add package.json package-lock.json + git commit -m "chore(release): v${{ needs.check.outputs.new_version }}" + git tag "v${{ needs.check.outputs.new_version }}" + + - name: Push version commit and tag + run: | + git push origin main + git push origin "v${{ needs.check.outputs.new_version }}" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.check.outputs.new_version }} + name: v${{ needs.check.outputs.new_version }} + body: | + ## What's Changed in v${{ needs.check.outputs.new_version }} + ${{ needs.check.outputs.changelog }} + + **Full Changelog**: https://github.com/${{ github.repository }}/compare/v${{ needs.check.outputs.new_version }}...v${{ needs.check.outputs.new_version }} + + --- + ``` + npm install @sanjibdevnath/mcp-excalidraw-local@${{ needs.check.outputs.new_version }} + ``` + draft: false + prerelease: false + + publish-npm: + name: Publish to NPM + needs: release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: main + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Check if version already on NPM + id: check-npm + run: | + VERSION=$(node -p "require('./package.json').version") + if npm view @sanjibdevnath/mcp-excalidraw-local@$VERSION version 2>/dev/null; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to NPM + if: steps.check-npm.outputs.exists == 'false' + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Published + if: steps.check-npm.outputs.exists == 'false' + run: echo "✅ Published v$(node -p "require('./package.json').version") to NPM" + + - name: Skipped (already exists) + if: steps.check-npm.outputs.exists == 'true' + run: echo "⚠️ Version already on NPM, skipping" + + publish-docker: + name: Build & push Docker images + needs: release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: v${{ needs.release.outputs.version }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push MCP Server image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: | + sanjibdevnath/mcp-excalidraw-local:latest + sanjibdevnath/mcp-excalidraw-local:v${{ needs.release.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + + - name: Build and push Canvas Server image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.canvas + push: true + tags: | + sanjibdevnath/mcp-excalidraw-local-canvas:latest + sanjibdevnath/mcp-excalidraw-local-canvas:v${{ needs.release.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 diff --git a/.gitignore b/.gitignore index e99a849..1750d0a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,10 @@ public/dist/ # Development artifacts *.excalidraw +# Test artifacts +test-results/ +playwright-report/ +coverage/ + docs/* !docs/screenshots/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2d92c4f..8ef5906 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,12 +33,13 @@ RUN apt-get purge -y python3 make g++ && apt-get autoremove -y COPY --from=builder /app/dist ./dist -RUN chown -R nodejs:nodejs /app +RUN mkdir -p /app/data && chown -R nodejs:nodejs /app USER nodejs ENV NODE_ENV=production ENV EXPRESS_SERVER_URL=http://localhost:3000 ENV ENABLE_CANVAS_SYNC=true +ENV EXCALIDRAW_DB_PATH=/app/data/excalidraw.db CMD ["node", "dist/index.js"] diff --git a/Dockerfile.canvas b/Dockerfile.canvas index 65f74ca..47cc40b 100644 --- a/Dockerfile.canvas +++ b/Dockerfile.canvas @@ -46,12 +46,13 @@ RUN apt-get purge -y python3 make g++ && apt-get autoremove -y COPY --from=backend-builder /app/dist ./dist COPY --from=frontend-builder /app/dist/frontend ./dist/frontend -RUN chown -R nodejs:nodejs /app +RUN mkdir -p /app/data && chown -R nodejs:nodejs /app USER nodejs ENV NODE_ENV=production ENV PORT=3000 ENV HOST=0.0.0.0 +ENV EXCALIDRAW_DB_PATH=/app/data/excalidraw.db EXPOSE 3000 diff --git a/README.md b/README.md index c69e2ad..2d3337f 100644 --- a/README.md +++ b/README.md @@ -37,14 +37,13 @@ Click the workspace badge to switch between isolated canvases — each workspace ## Table of Contents - [Screenshots](#screenshots) -- [What It Is](#what-it-is) +- [Prerequisites](#prerequisites) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [Verify Installation](#verify-installation) - [How We Differ from the Official Excalidraw MCP](#how-we-differ-from-the-official-excalidraw-mcp) - [What Changed From Upstream](#what-changed-from-upstream) -- [What's New](#whats-new) - [Architecture](#architecture) -- [Quick Start](#quick-start) -- [Quick Start (Docker)](#quick-start-docker) -- [Configuration](#configuration) - [Environment Variables](#environment-variables) - [Multi-Tenancy (Workspaces)](#multi-tenancy-workspaces) - [Agent Skill (Optional)](#agent-skill-optional) @@ -55,15 +54,233 @@ Click the workspace badge to switch between isolated canvases — each workspace - [Development](#development) - [Credits](#credits) -## What It Is +## Prerequisites -This MCP server gives AI agents a full canvas toolkit to build, inspect, and iteratively refine Excalidraw diagrams — including the ability to see what they drew. +| Requirement | Why | Check | +|---|---|---| +| **Node.js >= 18** (LTS 20 or 22 recommended) | Runtime | `node --version` | +| **C++ build tools** | `better-sqlite3` compiles native bindings | See below | +| **npm** (bundled with Node.js) | Package manager | `npm --version` | -The repo contains a single Node.js process that runs: +### C++ build tools by platform -- **MCP server** (stdio): 32 tools for element CRUD, layout, scene awareness, file I/O, snapshots, search, multi-tenancy, and more -- **Canvas server** (embedded): web UI + REST API + WebSocket updates at `http://localhost:` -- **SQLite database**: persistent storage at `~/.excalidraw-mcp/excalidraw.db` +**macOS:** +```bash +xcode-select --install +``` + +**Ubuntu / Debian:** +```bash +sudo apt install build-essential python3 +``` + +**Windows:** +```bash +npm install --global windows-build-tools +``` + +> `better-sqlite3` ships prebuilt binaries for most Node LTS versions. The build tools are only needed when a prebuilt binary isn't available for your platform/Node combination. + +## Quick Start + +### Path A: Interactive Setup (recommended for first-time users) + +The setup wizard checks your environment, optionally installs the agent skill, and configures MCP clients — all interactively. Every step is skippable. + +```bash +npx @sanjibdevnath/mcp-excalidraw-local setup +``` + +
+Example session + +``` +$ npx @sanjibdevnath/mcp-excalidraw-local setup + + Excalidraw MCP — Setup + + [1/3] Environment + ✔ Node.js v22.12.0 .................. OK + ✔ better-sqlite3 bindings ........... OK + ✔ Frontend build .................... OK + + [2/3] Agent Skill + Install the Excalidraw agent skill? [Y/n]: Y + + Detected agents: + [1] Cursor (~/.cursor) + [2] Claude Code (~/.claude) + + Which agents? (comma-separated, 'all', or 'skip'): all + + Cursor — scope? [G]lobal / [l]ocal: G + ✔ Installed to ~/.cursor/skills/excalidraw-skill/ + + Claude Code — scope? [G]lobal / [l]ocal: G + ✔ Installed to ~/.claude/skills/excalidraw-skill/ + + [3/3] MCP Configuration + Add MCP server to agent configs automatically? [Y/n]: Y + + Cursor — add to ~/.cursor/mcp.json? [Y/n]: Y + ✔ Added 'excalidraw-canvas' to ~/.cursor/mcp.json + + Claude Code — register via CLI? [Y/n]: Y + ✔ Registered 'excalidraw-canvas' via Claude Code CLI + + Done! Open http://localhost:3000 to verify the canvas. +``` +
+ +> **The setup is fully optional.** If you prefer to configure everything manually, skip to Path B or C below. + +### Path B: From Source + +```bash +git clone https://github.com/sanjibdevnathlabs/mcp-excalidraw-local.git +cd mcp-excalidraw-local + +npm install +npm run build +``` + +Then configure your MCP client — see [Configuration](#configuration). + +To run manually (outside an MCP client): +```bash +node dist/index.js +``` + +Open `http://localhost:3000` in your browser. + +### Path C: Docker + +Canvas server: +```bash +docker run -d -p 3000:3000 --name mcp-excalidraw-canvas sanjibdevnath/mcp-excalidraw-local-canvas:latest +``` + +MCP server (stdio) is typically launched by your MCP client: +```json +{ + "mcpServers": { + "excalidraw-canvas": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "CANVAS_PORT=3000", + "sanjibdevnath/mcp-excalidraw-local:latest" + ] + } + } +} +``` + +> **Note:** For Docker on Linux, add `--add-host=host.docker.internal:host-gateway`. + +## Configuration + +This is a standard MCP server communicating over **stdio**. It works with any MCP-compatible client. + +### Cursor + +Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project): + +```json +{ + "mcpServers": { + "excalidraw-canvas": { + "command": "npx", + "args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"], + "env": { + "CANVAS_PORT": "3000" + } + } + } +} +``` + +Or, if installed from source: + +```json +{ + "mcpServers": { + "excalidraw-canvas": { + "command": "node", + "args": ["/absolute/path/to/mcp-excalidraw-local/dist/index.js"], + "env": { + "CANVAS_PORT": "3000" + } + } + } +} +``` + +### Claude Desktop + +Add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "excalidraw-canvas": { + "command": "npx", + "args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"], + "env": { + "CANVAS_PORT": "3000" + } + } + } +} +``` + +### Claude Code + +```bash +claude mcp add excalidraw-canvas --scope user \ + -e CANVAS_PORT=3000 \ + -- npx -y @sanjibdevnath/mcp-excalidraw-local +``` + +### Codex CLI + +Add to `~/.codex/mcp.json`: + +```json +{ + "mcpServers": { + "excalidraw-canvas": { + "command": "npx", + "args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"], + "env": { + "CANVAS_PORT": "3000" + } + } + } +} +``` + +### Key points + +- **Single process** — The canvas server is embedded. No separate terminal or process needed. +- **Browser required for screenshots** — `export_to_image` and `get_canvas_screenshot` rely on the frontend. Open `http://localhost:3000` in a browser. + +## Verify Installation + +After configuring your MCP client, verify everything works: + +```bash +# 1. Check the canvas server is running +curl http://localhost:3000/health + +# 2. Open the canvas in your browser +open http://localhost:3000 + +# 3. In your AI agent, ask it to: +# "Create a blue rectangle labeled 'Hello World' on the Excalidraw canvas" +``` + +If the health check fails, see [Troubleshooting](#troubleshooting). ## How We Differ from the Official Excalidraw MCP @@ -89,8 +306,6 @@ Excalidraw now has an [official MCP](https://github.com/excalidraw/excalidraw-mc | **Multi-agent** | Single user | Multiple agents can draw on the same canvas concurrently | | **Works without MCP** | No | Yes — REST API fallback via agent skill | -**TL;DR** — The official MCP generates diagrams. We give AI agents a full canvas toolkit to build, inspect, and iteratively refine diagrams — including the ability to see what they drew. - ## What Changed From Upstream This fork extends [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw) with the following enhancements: @@ -105,51 +320,6 @@ This fork extends [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_exca | **MCP tools** | 26 | 32 (added search, history, tenants, projects) | | **Workspace switcher** | None | Dropdown with search in canvas UI | | **Sync normalization** | Bound text breaks on reload | Elements normalized to MCP format before storage | -| **Projects** | None | Multiple projects per tenant | -| **Element history** | None | Full version history per element | -| **Search** | None | Full-text search across elements | - -### New MCP Tools (6 added) - -| Tool | Description | -|---|---| -| `search_elements` | Full-text search across element labels and text | -| `element_history` | View version history for any element | -| `list_projects` | List projects within the active tenant | -| `switch_project` | Switch between projects | -| `list_tenants` | List all workspace tenants | -| `switch_tenant` | Switch the active workspace tenant | - -## What's New - -### v1.0 — This Fork (Persistence & Multi-Tenancy) - -- **SQLite persistence**: Elements, projects, tenants, snapshots, and element versions stored in `~/.excalidraw-mcp/excalidraw.db` with WAL mode and `busy_timeout` for multi-process safety -- **Multi-tenancy**: Each workspace gets an isolated canvas. Tenant auto-detected from workspace path via `server.listRoots()`. UI dropdown with search for switching workspaces -- **Embedded canvas**: Canvas server runs inside the MCP process — single `node dist/index.js` starts everything, stops together -- **Auto-sync with debounce**: Canvas changes are automatically persisted after 3s of inactivity. Manual sync button as fallback. Toggle auto-sync on/off -- **Configurable port**: `CANVAS_PORT` env var (default `3000`) -- **Sync normalization**: Excalidraw's internal bound-text representation is normalized to MCP format before storage, preventing text overflow/detachment on reload -- **6 new MCP tools**: `search_elements`, `element_history`, `list_projects`, `switch_project`, `list_tenants`, `switch_tenant` -- **Updated agent skill**: Comprehensive workflow playbook with iterative write-check-review cycle, sizing rules, anti-patterns, and quality checklist -- **Workspace switcher UI**: Click "Workspace: ..." badge to search and switch between workspaces - -### v2.0 — Canvas Toolkit (upstream) - -- 13 new MCP tools (26 total): `get_element`, `clear_canvas`, `export_scene`, `import_scene`, `export_to_image`, `duplicate_elements`, `snapshot_scene`, `restore_snapshot`, `describe_scene`, `get_canvas_screenshot`, `read_diagram_guide`, `export_to_excalidraw_url`, `set_viewport` -- **Closed feedback loop**: AI can now inspect the canvas (`describe_scene`) and see it (`get_canvas_screenshot` returns an image) — enabling iterative refinement -- **Design guide**: `read_diagram_guide` returns best-practice color palettes, sizing rules, layout patterns, and anti-patterns -- **Viewport control**: `set_viewport` with `scrollToContent`, `scrollToElementId`, or manual zoom/offset -- **File I/O**: export/import full `.excalidraw` JSON files -- **Snapshots**: save and restore named canvas states -- **Skill fallback**: Agent skill auto-detects MCP vs REST API mode -- Fixed all previously known issues: `align_elements` / `distribute_elements` fully implemented, points type normalization, removed invalid `label` type, `ungroup_elements` now errors on failure - -### v1.x (upstream) - -- Agent skill: `skills/excalidraw-skill/` (portable instructions + helper scripts for export/import and repeatable CRUD) -- Better testing loop: MCP Inspector CLI examples + browser screenshot checks -- Bugfixes: batch create now preserves element ids (fixes update/delete after batch); frontend entrypoint fixed ## Architecture @@ -159,111 +329,6 @@ This fork extends [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_exca - **SQLite**: Stored at `~/.excalidraw-mcp/excalidraw.db` by default. WAL mode + `busy_timeout` for multi-process safety. - **Multi-tenancy**: Each workspace gets an isolated tenant (SHA-256 hash of workspace path). The UI shows a workspace switcher dropdown with search. -## Quick Start - -### Option A: NPM (recommended) - -```bash -npx @sanjibdevnath/mcp-excalidraw-local -``` - -Or install globally: - -```bash -npm install -g @sanjibdevnath/mcp-excalidraw-local -mcp-excalidraw-local -``` - -### Option B: From source - -**Prerequisites:** Node >= 18, npm or pnpm - -```bash -git clone https://github.com/sanjibdevnathlabs/mcp-excalidraw-local.git -cd mcp-excalidraw-local - -# Install dependencies (pnpm or npm) -pnpm install -pnpm rebuild better-sqlite3 esbuild - -# Build frontend + server -pnpm run build -``` - -The MCP server is typically started by your MCP client — see [Configuration](#configuration). To run manually: - -```bash -node dist/index.js -``` - -This starts the MCP server (stdio) **and** the canvas server. Open `http://localhost:3000` in your browser. - -## Quick Start (Docker) - -Canvas server: -```bash -docker run -d -p 3000:3000 --name mcp-excalidraw-canvas sanjibdevnath/mcp-excalidraw-local-canvas:latest -``` - -MCP server (stdio) is typically launched by your MCP client. If you want a local container, use `sanjibdevnath/mcp-excalidraw-local:latest`. - -## Configuration - -This is a standard MCP server communicating over **stdio**. It works with any MCP-compatible client (Cursor, Claude Desktop, Claude Code, Codex CLI, OpenCode, Gemini, or any other agent that supports the Model Context Protocol). - -### JSON config (most clients) - -Add this to your client's MCP configuration file: - -```json -{ - "mcpServers": { - "excalidraw-canvas": { - "command": "node", - "args": ["/absolute/path/to/mcp-excalidraw-local/dist/index.js"], - "env": { - "CANVAS_PORT": "3000" - } - } - } -} -``` - -Replace `/absolute/path/to/mcp-excalidraw-local` with the actual path where you cloned and built the repo. - -### CLI-based registration - -```bash -# Example for Claude Code -claude mcp add excalidraw-canvas --scope user \ - -e CANVAS_PORT=3000 \ - -- node /absolute/path/to/mcp-excalidraw-local/dist/index.js -``` - -### Docker - -```json -{ - "mcpServers": { - "excalidraw-canvas": { - "command": "docker", - "args": [ - "run", "-i", "--rm", - "-e", "CANVAS_PORT=3000", - "sanjibdevnath/mcp-excalidraw-local:latest" - ] - } - } -} -``` - -> **Note:** For Docker on Linux, you may need `--add-host=host.docker.internal:host-gateway`. - -### Key points - -- **Single process** — The canvas server is embedded. No separate terminal or process needed. -- **Browser required for screenshots** — `export_to_image` and `get_canvas_screenshot` rely on the frontend. Open `http://localhost:3000` in a browser. - ## Environment Variables | Variable | Description | Default | @@ -272,7 +337,6 @@ claude mcp add excalidraw-canvas --scope user \ | `EXCALIDRAW_DB_PATH` | Path to the SQLite database file | `~/.excalidraw-mcp/excalidraw.db` | | `EXCALIDRAW_EXPORT_DIR` | Allowed directory for file exports | `process.cwd()` | | `EXPRESS_SERVER_URL` | Canvas server URL (only if running canvas separately) | `http://localhost:3000` | -| `ENABLE_CANVAS_SYNC` | Enable real-time canvas sync | `true` | ## Multi-Tenancy (Workspaces) @@ -297,50 +361,31 @@ This repo includes a skill at `skills/excalidraw-skill/` that provides: - **Cheatsheet** (`references/cheatsheet.md`): MCP tool and REST API reference for all 32 tools - **Helper scripts** (`scripts/*.cjs`): export, import, clear, healthcheck, CRUD operations -The skill complements the MCP server by giving your AI agent structured workflows to follow. +### Install via Setup Wizard -### Install the Skill +The easiest way to install the skill: + +```bash +npx @sanjibdevnath/mcp-excalidraw-local setup +``` + +The wizard detects your installed agents and lets you choose which ones get the skill. + +### Install Manually Copy the skill folder to your agent's skill directory: ```bash -# Claude Code -mkdir -p ~/.claude/skills -cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill - # Cursor -mkdir -p ~/.cursor/skills cp -R skills/excalidraw-skill ~/.cursor/skills/excalidraw-skill +# Claude Code +cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill + # Codex CLI -mkdir -p ~/.codex/skills cp -R skills/excalidraw-skill ~/.codex/skills/excalidraw-skill - -# Or any agent that supports a skills directory -cp -R skills/excalidraw-skill /path/to/your/agent/skills/ ``` -To update an existing installation, remove the old folder first then re-copy. - -### Use the Skill Scripts - -All scripts respect `EXPRESS_SERVER_URL` (default `http://localhost:3000`) or accept `--url`. - -```bash -EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/healthcheck.cjs -EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/export-elements.cjs --out diagram.elements.json -EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/import-elements.cjs --in diagram.elements.json --mode batch -``` - -### When the Skill Is Useful - -- **Repository workflow**: export elements as JSON, commit it, and re-import later -- **Reliable refactors**: clear + re-import in `sync` mode to make canvas match a file -- **Automated smoke tests**: create/update/delete a known element to validate a deployment -- **Repeatable diagrams**: keep a library of element JSON snippets and import them - -See `skills/excalidraw-skill/SKILL.md` and `skills/excalidraw-skill/references/cheatsheet.md`. - ## MCP Tools (32 Total) | Category | Tools | @@ -385,28 +430,95 @@ npx @modelcontextprotocol/inspector --cli \ --tool-arg width=300 --tool-arg height=200 ``` -### Frontend Screenshots - -If you use a browser automation tool for UI checks: -```bash -# Open the canvas and take a screenshot for verification -open http://127.0.0.1:3000 -# Or use agent-browser, Playwright, Puppeteer, etc. -``` - ## Troubleshooting -- **Canvas not loading**: Ensure `CANVAS_PORT` isn't occupied by another process. Check `lsof -i :3000`. -- **Canvas not updating**: Confirm the MCP process is running and the browser is connected (check the status dot in the header). -- **Wrong workspace shown**: The MCP uses `server.listRoots()` to detect the workspace. Restart your MCP client if the workspace changed. -- **Elements missing after restart**: Check `~/.excalidraw-mcp/excalidraw.db` exists. If you previously ran the upstream (in-memory) version, data wasn't persisted. -- **Port conflict with multiple instances**: Set different `CANVAS_PORT` values for each workspace, or rely on multi-tenancy (same port, different tenants). -- **Updates/deletes fail after batch creation**: Ensure you are on a build that includes the batch id preservation fix. +### `better-sqlite3` compilation failure + +This is the most common installation issue. `better-sqlite3` is a native Node.js module that requires C++ build tools. + +**Symptoms:** +- `npm install` fails with `gyp ERR!` or `prebuild-install` errors +- `npx` command fails during installation +- Error: `Cannot find module 'better-sqlite3'` at runtime + +**Fix:** + +1. Install build tools for your platform (see [Prerequisites](#prerequisites)) +2. Rebuild the module: + ```bash + npm rebuild better-sqlite3 + ``` +3. Or run the setup wizard which handles this automatically: + ```bash + npx @sanjibdevnath/mcp-excalidraw-local setup + ``` + +### EADDRINUSE (port already in use) + +**Symptom:** Error `listen EADDRINUSE: address already in use :::3000` + +**Fix:** +```bash +# Find what's using the port +lsof -i :3000 + +# Either kill the process or use a different port +CANVAS_PORT=3001 node dist/index.js +``` + +> The MCP server automatically detects and reuses an existing healthy canvas server on the same port, so this error is rare. + +### Canvas not loading / "Frontend not found" + +**Symptom:** Browser shows "Frontend not found" or blank page at `http://localhost:3000` + +**Fix:** +```bash +npm run build # builds both frontend and server +node dist/index.js # restart +``` + +### NVM / path issues with npx + +**Symptom:** `npx @sanjibdevnath/mcp-excalidraw-local` hangs or uses the wrong Node version. + +**Fix:** +```bash +# Ensure you're using a supported Node version +nvm use 20 # or 22 + +# Clear npm cache if npx is stale +npm cache clean --force + +# Try with explicit node path in your MCP config +which node # copy this path +``` + +Then use the full path in your MCP config: +```json +{ + "mcpServers": { + "excalidraw-canvas": { + "command": "/Users/you/.nvm/versions/node/v22.12.0/bin/npx", + "args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"], + "env": { "CANVAS_PORT": "3000" } + } + } +} +``` + +### Canvas not updating / elements not syncing + +**Fix:** +- Confirm the MCP process is running and the browser is connected (check the green status dot in the header) +- Click the "Sync" button in the canvas header for a manual sync + +### Wrong workspace shown + +The MCP uses `server.listRoots()` to detect the workspace. Restart your MCP client if the workspace changed. ## Known Issues / TODO -All previously listed bugs from the upstream have been fixed. Remaining items: - - [ ] **Image export requires a browser**: `export_to_image` and `get_canvas_screenshot` rely on the frontend rendering. The canvas UI must be open in a browser. - [ ] **`export_to_excalidraw_url` blocked**: Organizations that block `excalidraw.com` cannot use shareable URL export. Use `export_scene` for local `.excalidraw` files instead. @@ -416,13 +528,13 @@ Contributions welcome! ```bash # Type check -pnpm run type-check +npm run type-check # Full build (frontend + server) -pnpm run build +npm run build # Dev mode (watch) -pnpm run dev +npm run dev ``` ### Database @@ -442,10 +554,13 @@ The canvas server exposes a REST API alongside the WebSocket interface: | POST | `/api/elements` | Create an element | | PUT | `/api/elements/:id` | Update an element | | DELETE | `/api/elements/:id` | Delete an element | +| DELETE | `/api/elements/clear` | Clear all elements | | POST | `/api/elements/sync` | Sync all elements (bulk upsert) | | GET | `/api/tenants` | List all tenants | | GET | `/api/tenant/active` | Get the active tenant | | PUT | `/api/tenant/active` | Set the active tenant | +| GET | `/api/settings/:key` | Read a setting | +| PUT | `/api/settings/:key` | Write a setting | All endpoints accept an `X-Tenant-Id` header for per-request tenant scoping. diff --git a/frontend/index.html b/frontend/index.html index 12e5a1b..6e5e631 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -410,6 +410,51 @@ color: #aaa; font-size: 13px; } + + /* Clear canvas confirmation dialog */ + .confirm-dialog { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 360px; + background: #fff; + border-radius: 12px; + box-shadow: 0 12px 40px rgba(0,0,0,0.22); + padding: 24px; + } + .confirm-title { + font-size: 18px; + font-weight: 700; + color: #333; + margin-bottom: 8px; + } + .confirm-msg { + font-size: 14px; + color: #666; + margin: 0 0 16px; + line-height: 1.4; + } + .confirm-checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #555; + cursor: pointer; + margin-bottom: 20px; + font-weight: 400; + } + .confirm-checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + } + .confirm-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 20aed02..4d948de 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,45 +10,18 @@ import { import type { ExcalidrawElement, NonDeleted, NonDeletedExcalidrawElement } from '@excalidraw/excalidraw/types/element/types' import { convertMermaidToExcalidraw, DEFAULT_MERMAID_CONFIG } from './utils/mermaidConverter' import type { MermaidConfig } from '@excalidraw/mermaid-to-excalidraw' +import { + cleanElementForExcalidraw, + validateAndFixBindings, + computeElementHash, + isImageElement, + normalizeImageElement, + restoreBindings +} from './utils/elementHelpers' +import type { ServerElement } from './utils/elementHelpers' -// Type definitions type ExcalidrawAPIRefValue = ExcalidrawImperativeAPI; -interface ServerElement { - id: string; - type: string; - x: number; - y: number; - width?: number; - height?: number; - backgroundColor?: string; - strokeColor?: string; - strokeWidth?: number; - roughness?: number; - opacity?: number; - text?: string; - fontSize?: number; - fontFamily?: string | number; - label?: { - text: string; - }; - createdAt?: string; - updatedAt?: string; - version?: number; - syncedAt?: string; - source?: string; - syncTimestamp?: string; - boundElements?: any[] | null; - containerId?: string | null; - locked?: boolean; - // Arrow element binding - start?: { id: string }; - end?: { id: string }; - strokeStyle?: string; - endArrowhead?: string; - startArrowhead?: string; -} - interface WebSocketMessage { type: string; element?: ServerElement; @@ -59,6 +32,8 @@ interface WebSocketMessage { source?: string; mermaidDiagram?: string; config?: MermaidConfig; + files?: Record; + [key: string]: any; } interface ApiResponse { @@ -72,68 +47,6 @@ interface ApiResponse { type SyncStatus = 'idle' | 'syncing'; -// Helper function to clean elements for Excalidraw -const cleanElementForExcalidraw = (element: ServerElement): Partial => { - const { - createdAt, - updatedAt, - version, - syncedAt, - source, - syncTimestamp, - ...cleanElement - } = element; - return cleanElement; -} - -// Helper function to validate and fix element binding data -const validateAndFixBindings = (elements: Partial[]): Partial[] => { - const elementMap = new Map(elements.map(el => [el.id!, el])); - - return elements.map(element => { - const fixedElement = { ...element }; - - // Validate and fix boundElements - if (fixedElement.boundElements) { - if (Array.isArray(fixedElement.boundElements)) { - fixedElement.boundElements = fixedElement.boundElements.filter((binding: any) => { - // Ensure binding has required properties - if (!binding || typeof binding !== 'object') return false; - if (!binding.id || !binding.type) return false; - - // Ensure the referenced element exists - const referencedElement = elementMap.get(binding.id); - if (!referencedElement) return false; - - // Validate binding type - if (!['text', 'arrow'].includes(binding.type)) return false; - - return true; - }); - - // Remove boundElements if empty - if (fixedElement.boundElements.length === 0) { - fixedElement.boundElements = null; - } - } else { - // Invalid boundElements format, set to null - fixedElement.boundElements = null; - } - } - - // Validate and fix containerId - if (fixedElement.containerId) { - const containerElement = elementMap.get(fixedElement.containerId); - if (!containerElement) { - // Container doesn't exist, remove containerId - fixedElement.containerId = null; - } - } - - return fixedElement; - }); -} - interface TenantInfo { id: string; name: string; @@ -142,6 +55,7 @@ interface TenantInfo { function App(): JSX.Element { const [excalidrawAPI, setExcalidrawAPI] = useState(null) + const excalidrawAPIRef = useRef(null) const [isConnected, setIsConnected] = useState(false) const websocketRef = useRef(null) @@ -165,7 +79,10 @@ function App(): JSX.Element { const [tenantSearch, setTenantSearch] = useState('') const searchInputRef = useRef(null) - // Keep ref in sync so closures (WebSocket handlers) always see latest tenant + // Keep refs in sync so closures (WebSocket handlers) always see latest values + useEffect(() => { + excalidrawAPIRef.current = excalidrawAPI + }, [excalidrawAPI]) useEffect(() => { activeTenantIdRef.current = activeTenant?.id ?? null }, [activeTenant]) @@ -203,15 +120,6 @@ function App(): JSX.Element { } }, [excalidrawAPI, isConnected]) - const computeElementHash = (elements: readonly { id: string; version: number }[]): string => { - let h = String(elements.length) - for (let i = 0; i < elements.length; i++) { - h += elements[i].id - h += elements[i].version - } - return h - } - // Persist auto-save preference and cancel pending timer when toggled off const toggleAutoSave = () => { setAutoSave(prev => { @@ -250,25 +158,55 @@ function App(): JSX.Element { }, DEBOUNCE_MS) } + const convertElementsPreservingImageProps = ( + cleanedElements: any[] + ): any[] => { + const imageElements = cleanedElements.filter(isImageElement) + const nonImageElements = cleanedElements.filter(el => !isImageElement(el)) + + let convertedNonImage: any[] = [] + if (nonImageElements.length > 0) { + convertedNonImage = convertToExcalidrawElements(nonImageElements, { regenerateIds: false }) as any[] + convertedNonImage = restoreBindings(convertedNonImage, nonImageElements) + } + + const normalizedImages = imageElements.map(normalizeImageElement) + + return [...convertedNonImage, ...normalizedImages] + } + const loadExistingElements = async (): Promise => { try { const response = await fetch('/api/elements', { headers: tenantHeaders() }) const result: ApiResponse = await response.json() - if (result.success && result.elements && result.elements.length > 0) { + if (result.success && result.elements) { + if (result.elements.length === 0) { + excalidrawAPI?.updateScene({ elements: [] }) + return + } const cleanedElements = result.elements.map(cleanElementForExcalidraw) - // Elements with containerId are in Excalidraw native format (from a - // previous sync before the normalization fix). Pass them directly — - // convertToExcalidrawElements would re-create bound text and break layout. const hasNativeFormat = cleanedElements.some((el: any) => el.containerId) if (hasNativeFormat) { const validated = validateAndFixBindings(cleanedElements) excalidrawAPI?.updateScene({ elements: validated as any }) } else { - const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false }) + const convertedElements = convertElementsPreservingImageProps(cleanedElements) excalidrawAPI?.updateScene({ elements: convertedElements }) } } + + // Also load files (image data) + try { + const filesRes = await fetch('/api/files', { headers: tenantHeaders() }) + const filesData = await filesRes.json() + if (filesData.success && filesData.files) { + const fileValues = Object.values(filesData.files) as any[] + if (fileValues.length > 0 && excalidrawAPI) { + excalidrawAPI.addFiles(fileValues) + } + } + } catch {} } catch (error) { console.error('Error loading existing elements:', error) } @@ -317,22 +255,21 @@ function App(): JSX.Element { } const handleWebSocketMessage = async (data: WebSocketMessage): Promise => { - if (!excalidrawAPI) { + const api = excalidrawAPIRef.current + if (!api) { return } try { - const currentElements = excalidrawAPI.getSceneElements() - console.log('Current elements:', currentElements); + const currentElements = api.getSceneElements() switch (data.type) { case 'initial_elements': if (data.elements && data.elements.length > 0) { const cleanedElements = data.elements.map(cleanElementForExcalidraw) const validatedElements = validateAndFixBindings(cleanedElements) - // Preserve server IDs so later update/delete websocket events can match by id. - const convertedElements = convertToExcalidrawElements(validatedElements, { regenerateIds: false }) - excalidrawAPI.updateScene({ + const convertedElements = convertElementsPreservingImageProps(validatedElements) + api.updateScene({ elements: convertedElements, captureUpdate: CaptureUpdateAction.NEVER }) @@ -344,18 +281,16 @@ function App(): JSX.Element { const cleanedNewElement = cleanElementForExcalidraw(data.element) const hasBindings = (cleanedNewElement as any).start || (cleanedNewElement as any).end if (hasBindings) { - // Bound arrow: re-convert all elements together so bindings resolve const allElements = [...currentElements, cleanedNewElement] as any[] const convertedAll = convertToExcalidrawElements(allElements, { regenerateIds: false }) - excalidrawAPI.updateScene({ + api.updateScene({ elements: convertedAll, captureUpdate: CaptureUpdateAction.NEVER }) } else { - // Preserve server IDs so later update/delete websocket events can match by id. const newElement = convertToExcalidrawElements([cleanedNewElement], { regenerateIds: false }) const updatedElementsAfterCreate = [...currentElements, ...newElement] - excalidrawAPI.updateScene({ + api.updateScene({ elements: updatedElementsAfterCreate, captureUpdate: CaptureUpdateAction.NEVER }) @@ -366,12 +301,11 @@ function App(): JSX.Element { case 'element_updated': if (data.element) { const cleanedUpdatedElement = cleanElementForExcalidraw(data.element) - // Preserve server IDs so we can replace the existing element by id. const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement], { regenerateIds: false })[0] const updatedElements = currentElements.map(el => el.id === data.element!.id ? convertedUpdatedElement : el ) - excalidrawAPI.updateScene({ + api.updateScene({ elements: updatedElements, captureUpdate: CaptureUpdateAction.NEVER }) @@ -381,7 +315,7 @@ function App(): JSX.Element { case 'element_deleted': if (data.elementId) { const filteredElements = currentElements.filter(el => el.id !== data.elementId) - excalidrawAPI.updateScene({ + api.updateScene({ elements: filteredElements, captureUpdate: CaptureUpdateAction.NEVER }) @@ -393,18 +327,16 @@ function App(): JSX.Element { const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw) const hasBoundArrows = cleanedBatchElements.some((el: any) => el.start || el.end) if (hasBoundArrows) { - // Convert ALL elements together so arrow bindings resolve to target shapes const allElements = [...currentElements, ...cleanedBatchElements] as any[] - const convertedAll = convertToExcalidrawElements(allElements, { regenerateIds: false }) - excalidrawAPI.updateScene({ + const convertedAll = convertElementsPreservingImageProps(allElements) + api.updateScene({ elements: convertedAll, captureUpdate: CaptureUpdateAction.NEVER }) } else { - // Preserve server IDs so later update/delete websocket events can match by id. - const batchElements = convertToExcalidrawElements(cleanedBatchElements, { regenerateIds: false }) + const batchElements = convertElementsPreservingImageProps(cleanedBatchElements) const updatedElementsAfterBatch = [...currentElements, ...batchElements] - excalidrawAPI.updateScene({ + api.updateScene({ elements: updatedElementsAfterBatch, captureUpdate: CaptureUpdateAction.NEVER }) @@ -414,7 +346,6 @@ function App(): JSX.Element { case 'elements_synced': console.log(`Sync confirmed by server: ${data.count} elements`) - // Sync confirmation already handled by HTTP response break case 'sync_status': @@ -423,7 +354,7 @@ function App(): JSX.Element { case 'canvas_cleared': console.log('Canvas cleared by server') - excalidrawAPI.updateScene({ + api.updateScene({ elements: [], captureUpdate: CaptureUpdateAction.NEVER }) @@ -433,9 +364,9 @@ function App(): JSX.Element { console.log('Received image export request', data) if (data.requestId) { try { - const elements = excalidrawAPI.getSceneElements() - const appState = excalidrawAPI.getAppState() - const files = excalidrawAPI.getFiles() + const elements = api.getSceneElements() + const appState = api.getAppState() + const files = api.getFiles() if (data.format === 'svg') { const svg = await exportToSvg({ @@ -528,20 +459,19 @@ function App(): JSX.Element { if (data.requestId) { try { if (data.scrollToContent) { - const allElements = excalidrawAPI.getSceneElements() + const allElements = api.getSceneElements() if (allElements.length > 0) { - excalidrawAPI.scrollToContent(allElements, { fitToViewport: true, animate: true }) + api.scrollToContent(allElements, { fitToViewport: true, animate: true }) } } else if (data.scrollToElementId) { - const allElements = excalidrawAPI.getSceneElements() + const allElements = api.getSceneElements() const targetElement = allElements.find(el => el.id === data.scrollToElementId) if (targetElement) { - excalidrawAPI.scrollToContent([targetElement], { fitToViewport: false, animate: true }) + api.scrollToContent([targetElement], { fitToViewport: false, animate: true }) } else { throw new Error(`Element ${data.scrollToElementId} not found`) } } else { - // Direct zoom/scroll control const appState: any = {} if (data.zoom !== undefined) { appState.zoom = { value: data.zoom } @@ -553,7 +483,7 @@ function App(): JSX.Element { appState.scrollY = data.offsetY } if (Object.keys(appState).length > 0) { - excalidrawAPI.updateScene({ appState }) + api.updateScene({ appState }) } } @@ -593,13 +523,13 @@ function App(): JSX.Element { if (result.elements && result.elements.length > 0) { const convertedElements = convertToExcalidrawElements(result.elements, { regenerateIds: false }) - excalidrawAPI.updateScene({ + api.updateScene({ elements: convertedElements, captureUpdate: CaptureUpdateAction.IMMEDIATELY }) if (result.files) { - excalidrawAPI.addFiles(Object.values(result.files)) + api.addFiles(Object.values(result.files)) } console.log('Mermaid diagram converted successfully:', result.elements.length, 'elements') @@ -613,6 +543,18 @@ function App(): JSX.Element { } break + case 'files_added': + if (data.files && typeof data.files === 'object') { + const fileValues = Object.values(data.files) as any[] + if (fileValues.length > 0) { + api.addFiles(fileValues) + } + } + break + + case 'file_deleted': + break + case 'tenant_switched': console.log('Tenant switched:', data.tenant) if (data.tenant) { @@ -622,7 +564,7 @@ function App(): JSX.Element { if (incoming.id !== activeTenantIdRef.current) { activeTenantIdRef.current = incoming.id setActiveTenant(incoming) - excalidrawAPI.updateScene({ + api.updateScene({ elements: [], captureUpdate: CaptureUpdateAction.NEVER }) @@ -819,7 +761,46 @@ function App(): JSX.Element { } } - const clearCanvas = async (): Promise => { + // Clear canvas confirmation state (UI button only) + const [showClearConfirm, setShowClearConfirm] = useState(false) + const [clearSkipConfirm, setClearSkipConfirm] = useState(false) + const [dontAskAgain, setDontAskAgain] = useState(false) + + // Load "skip confirm" preference from backend on mount + useEffect(() => { + fetch('/api/settings/clear_canvas_skip_confirm') + .then(r => r.json()) + .then(data => { + if (data.value === 'true') setClearSkipConfirm(true) + }) + .catch(() => {}) + }, []) + + const handleClearCanvasClick = () => { + if (clearSkipConfirm) { + performClearCanvas() + } else { + setDontAskAgain(false) + setShowClearConfirm(true) + } + } + + const handleClearConfirm = async () => { + if (dontAskAgain) { + setClearSkipConfirm(true) + try { + await fetch('/api/settings/clear_canvas_skip_confirm', { + method: 'PUT', + headers: tenantHeaders(), + body: JSON.stringify({ value: 'true' }) + }) + } catch {} + } + setShowClearConfirm(false) + performClearCanvas() + } + + const performClearCanvas = async (): Promise => { if (excalidrawAPI) { try { const response = await fetch('/api/elements', { headers: tenantHeaders() }) @@ -832,14 +813,12 @@ function App(): JSX.Element { await Promise.all(deletePromises) } - // Clear the frontend canvas excalidrawAPI.updateScene({ elements: [], captureUpdate: CaptureUpdateAction.IMMEDIATELY }) } catch (error) { console.error('Error clearing canvas:', error) - // Still clear frontend even if backend fails excalidrawAPI.updateScene({ elements: [], captureUpdate: CaptureUpdateAction.IMMEDIATELY @@ -899,7 +878,7 @@ function App(): JSX.Element { - + @@ -946,6 +925,28 @@ function App(): JSX.Element { ) })()} + {/* Clear canvas confirmation modal (UI button only) */} + {showClearConfirm && ( +
setShowClearConfirm(false)}> +
e.stopPropagation()}> +
Clear Canvas
+

This will permanently delete all elements. Continue?

+ +
+ + +
+
+
+ )} + {/* Canvas Container */}
=> { + const { + createdAt, + updatedAt, + version, + syncedAt, + source, + syncTimestamp, + ...cleanElement + } = element; + return cleanElement; +}; + +export const validateAndFixBindings = (elements: Partial[]): Partial[] => { + const elementMap = new Map(elements.map(el => [el.id!, el])); + + return elements.map(element => { + const fixedElement = { ...element }; + + if (fixedElement.boundElements) { + if (Array.isArray(fixedElement.boundElements)) { + fixedElement.boundElements = fixedElement.boundElements.filter((binding: any) => { + if (!binding || typeof binding !== 'object') return false; + if (!binding.id || !binding.type) return false; + const referencedElement = elementMap.get(binding.id); + if (!referencedElement) return false; + if (!['text', 'arrow'].includes(binding.type)) return false; + return true; + }); + + if (fixedElement.boundElements.length === 0) { + fixedElement.boundElements = null; + } + } else { + fixedElement.boundElements = null; + } + } + + if (fixedElement.containerId) { + const containerElement = elementMap.get(fixedElement.containerId); + if (!containerElement) { + fixedElement.containerId = null; + } + } + + return fixedElement; + }); +}; + +export const isImageElement = (el: Partial): boolean => { + return el.type === 'image'; +}; + +const SHAPE_CONTAINER_TYPES = new Set([ + 'rectangle', 'ellipse', 'diamond', 'arrow', 'line' +]); + +export const isShapeContainerType = (type: string): boolean => { + return SHAPE_CONTAINER_TYPES.has(type); +}; + +export const normalizeImageElement = (el: any): any => { + return { + ...el, + type: 'image', + status: el.status || 'saved', + fileId: el.fileId || null, + scale: el.scale || [1, 1], + angle: el.angle ?? 0, + strokeColor: el.strokeColor ?? 'transparent', + backgroundColor: el.backgroundColor ?? 'transparent', + fillStyle: el.fillStyle ?? 'hachure', + strokeWidth: el.strokeWidth ?? 1, + strokeStyle: el.strokeStyle ?? 'solid', + roughness: el.roughness ?? 1, + opacity: el.opacity ?? 100, + groupIds: el.groupIds ?? [], + roundness: el.roundness ?? null, + isDeleted: el.isDeleted ?? false, + boundElements: el.boundElements ?? null, + locked: el.locked ?? false, + link: el.link ?? null, + }; +}; + +export const restoreBindings = ( + convertedElements: any[], + originalElements: any[] +): any[] => { + const originalMap = new Map(); + for (const el of originalElements) { + if (el.id) originalMap.set(el.id, el); + } + + return convertedElements.map(el => { + const orig = originalMap.get(el.id); + if (!orig) return el; + + const patched = { ...el }; + if (orig.startBinding !== undefined && !patched.startBinding) { + patched.startBinding = orig.startBinding; + } + if (orig.endBinding !== undefined && !patched.endBinding) { + patched.endBinding = orig.endBinding; + } + if (orig.boundElements !== undefined && !patched.boundElements) { + patched.boundElements = orig.boundElements; + } + if (orig.elbowed !== undefined && patched.elbowed === undefined) { + patched.elbowed = orig.elbowed; + } + return patched; + }); +}; + +export const computeElementHash = (elements: readonly { id: string; version: number }[]): string => { + let h = String(elements.length); + for (let i = 0; i < elements.length; i++) { + h += elements[i]!.id; + h += elements[i]!.version; + } + return h; +}; diff --git a/package-lock.json b/package-lock.json index 2e47c16..c42b2ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { - "name": "mcp-excalidraw-server", - "version": "1.0.2", + "name": "@sanjibdevnath/mcp-excalidraw-local", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mcp-excalidraw-server", - "version": "1.0.2", + "name": "@sanjibdevnath/mcp-excalidraw-local", + "version": "1.0.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@excalidraw/excalidraw": "^0.18.0", "@excalidraw/mermaid-to-excalidraw": "^1.1.3", - "@modelcontextprotocol/sdk": "latest", + "@modelcontextprotocol/sdk": "^1.26.0", "better-sqlite3": "^12.6.2", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -26,20 +27,25 @@ "zod-to-json-schema": "^3.22.3" }, "bin": { - "mcp-excalidraw-server": "dist/index.js" + "mcp-excalidraw-local": "dist/index.js" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.19.7", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/supertest": "^7.2.0", "@types/ws": "^8.5.10", "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "^4.1.0", "concurrently": "^9.2.0", + "supertest": "^7.2.2", "typescript": "^5.8.3", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^4.1.0" }, "engines": { "node": ">=18.0.0" @@ -234,9 +240,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -268,13 +274,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -359,19 +365,29 @@ } }, "node_modules/@babel/types": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", - "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@braintree/sanitize-url": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", @@ -1153,6 +1169,18 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -1197,16 +1225,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1224,26 +1252,43 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.15.1.tgz", - "integrity": "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w==", + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { @@ -1260,35 +1305,40 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { @@ -1301,18 +1351,19 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -1343,9 +1394,9 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -1356,7 +1407,11 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { @@ -1368,6 +1423,42 @@ "node": ">= 0.8" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -1399,15 +1490,19 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { @@ -1420,9 +1515,9 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1435,31 +1530,35 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "^4.3.5", + "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "statuses": "^2.0.2" }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -1469,6 +1568,19 @@ }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" } }, "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { @@ -1485,6 +1597,45 @@ "node": ">= 0.6" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", @@ -2540,6 +2691,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2606,6 +2764,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2616,6 +2785,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -2888,6 +3064,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2943,6 +3126,13 @@ "@types/unist": "^2" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3031,6 +3221,30 @@ "@types/send": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -3081,6 +3295,150 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", + "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.0", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.0", + "vitest": "4.1.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3107,21 +3465,38 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3179,12 +3554,55 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3457,6 +3875,16 @@ "integrity": "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==", "license": "MIT" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3640,6 +4068,19 @@ "text-hex": "1.0.x" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -3649,6 +4090,16 @@ "node": ">= 10" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concurrently": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", @@ -3740,6 +4191,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -4325,9 +4783,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4387,6 +4845,16 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4430,6 +4898,17 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", @@ -4542,6 +5021,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4554,6 +5040,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es6-promise-pool": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz", @@ -4621,6 +5123,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4660,6 +5172,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -4707,10 +5229,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, "engines": { "node": ">= 16" }, @@ -4748,12 +5273,29 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -4840,6 +5382,23 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -4852,6 +5411,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5046,6 +5623,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5058,6 +5651,22 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", + "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -5142,6 +5751,15 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5227,6 +5845,54 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", + "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jotai": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", @@ -5278,11 +5944,17 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5450,6 +6122,57 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/marked": { "version": "16.4.1", "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.1.tgz", @@ -6275,6 +6998,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6417,6 +7151,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/png-chunk-text": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/png-chunk-text/-/png-chunk-text-1.0.0.tgz", @@ -6572,15 +7353,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/pwacompat": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/pwacompat/-/pwacompat-2.0.17.tgz", @@ -6628,16 +7400,61 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -6816,6 +7633,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", @@ -6897,12 +7723,13 @@ } }, "node_modules/router/node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", - "engines": { - "node": ">=16" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/rw": { @@ -7179,6 +8006,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -7269,6 +8103,13 @@ "node": "*" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -7278,6 +8119,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7330,6 +8178,81 @@ "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7377,21 +8300,31 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, - "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -7401,11 +8334,14 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -7416,9 +8352,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -7428,6 +8364,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7597,15 +8543,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -7816,6 +8753,101 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -7901,6 +8933,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/winston": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", @@ -8038,12 +9087,12 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.25 || ^4" } }, "node_modules/zustand": { diff --git a/package.json b/package.json index 75dc3da..31140dd 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,21 @@ "dev:server": "npx tsc --watch", "production": "npm run build && npm run canvas", "prepublishOnly": "npm run build", - "type-check": "npx tsc --noEmit" + "postinstall": "prebuild-install --runtime napi || node-gyp rebuild --directory node_modules/better-sqlite3 2>/dev/null || true", + "setup": "node dist/index.js setup", + "type-check": "npx tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:unit": "vitest run tests/backend/db.test.ts tests/frontend/helpers.test.ts", + "test:api": "vitest run tests/backend/api.test.ts", + "test:ws": "vitest run tests/backend/ws.test.ts", + "test:e2e": "npx playwright test", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@excalidraw/excalidraw": "^0.18.0", "@excalidraw/mermaid-to-excalidraw": "^1.1.3", - "@modelcontextprotocol/sdk": "latest", + "@modelcontextprotocol/sdk": "^1.26.0", "better-sqlite3": "^12.6.2", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -38,17 +47,22 @@ "zod-to-json-schema": "^3.22.3" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.19.7", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/supertest": "^7.2.0", "@types/ws": "^8.5.10", "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "^4.1.0", "concurrently": "^9.2.0", + "supertest": "^7.2.2", "typescript": "^5.8.3", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^4.1.0" }, "keywords": [ "mcp", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..25b821b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30000, + expect: { timeout: 5000 }, + fullyParallel: false, + retries: 0, + workers: 1, + reporter: 'list', + use: { + baseURL: 'http://localhost:3100', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'node tests/e2e/start-server.js', + port: 3100, + timeout: 15000, + reuseExistingServer: !process.env.CI, + env: { + CANVAS_PORT: '3100', + HOST: 'localhost', + EXCALIDRAW_DB_PATH: '/tmp/excalidraw-e2e-test.db', + }, + }, +}); diff --git a/skills/excalidraw-skill/SKILL.md b/skills/excalidraw-skill/SKILL.md index 77710dc..9222bbc 100644 --- a/skills/excalidraw-skill/SKILL.md +++ b/skills/excalidraw-skill/SKILL.md @@ -125,6 +125,50 @@ Example (diamonds h=160, gap needed ≥ 120): If the gap is < 80px, arrows will be too short to see — especially with labels like "YES"/"NO". +## Workflow: Multi-Diagram Canvas (Spatial Organization) + +Excalidraw's infinite canvas supports multiple diagrams coexisting side by side. **Never clear the canvas** to make room — place new diagrams spatially offset from existing ones. + +### Before Drawing Anything + +Always call `describe_scene` first. It reports: + +- **Diagram Zones**: grouped elements with bounding boxes, labels, and element counts +- **Canvas bounding box**: the overall occupied area +- **Suggested placement**: a recommended `(x, y)` for the next diagram (300px right of existing content) + +### Creating a New Diagram Alongside Existing Ones + +1. **`describe_scene`** — read the suggested placement coordinates +2. **Offset your new diagram** to the suggested area (or 300px+ from the rightmost edge) +3. **Add a title text element** above your diagram (fontSize 24-28) as an identifier +4. **Create shapes** (Batch 1) with coordinates offset to the new area +5. **Create arrows** (Batch 2) +6. **`group_elements`** — group ALL elements of your new diagram together +7. **`set_viewport`** with `scrollToContent: true` to see everything +8. **Screenshot** to verify + +### Example Layout + +``` + "Architecture" (group A) "User Flow" (group B) + x=0 to x=1100 x=1400 to x=2500 + ┌─────────────────────┐ ┌─────────────────────┐ + │ │ 300px │ │ + │ Diagram A │ gap │ Diagram B │ + │ │ │ │ + └─────────────────────┘ └─────────────────────┘ +``` + +### Rules + +- **Never call `clear_canvas`** unless the user explicitly asks to wipe everything +- **Always `describe_scene` before drawing** to see existing content +- **Group every diagram** so `describe_scene` reports it as a named zone +- **Add a title text element** as the first element of each diagram — this becomes the zone label +- **Use `search_elements`** to find specific diagrams by their title text +- **Use `set_viewport` with `scrollToElementId`** to navigate to a specific diagram + ## Workflow: Draw A Diagram ### Phase 1: Plan @@ -345,7 +389,7 @@ Use history to debug unexpected changes or audit what was modified. | Using `create_from_mermaid` for final diagrams | Overlapping text, poor layout | Use `batch_create_elements` with coordinates | | Shapes too small for text | Truncation, especially in diamonds | Use sizing formulas above | | No gap between connected shapes | Arrows become invisible (0px length) | Maintain 120px+ vertical gap | -| Clearing canvas between diagrams | Loses previous work | Place diagrams side-by-side | +| Clearing canvas between diagrams | Loses previous work, requires `confirm: true` | Place diagrams side-by-side using spatial offset; call `describe_scene` first | | Skipping screenshot verification | Invisible defects compound | Screenshot after EVERY batch | | Shapes + arrows in one batch | Binding errors | Shapes first, arrows second | | Default roughness (hand-drawn look) | Unprofessional for technical diagrams | Set `roughness: 0` on all elements | diff --git a/src/db.ts b/src/db.ts index c4f9c9b..25f6605 100644 --- a/src/db.ts +++ b/src/db.ts @@ -44,6 +44,8 @@ function generateId(): string { } export function initDb(dbPath?: string): void { + if (db) return; // Already initialized + const resolvedPath = dbPath || process.env.EXCALIDRAW_DB_PATH || path.join(os.homedir(), '.excalidraw-mcp', 'excalidraw.db'); @@ -129,6 +131,11 @@ function runMigrations(): void { UNIQUE(project_id, name) ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_elements_project ON elements(project_id); CREATE INDEX IF NOT EXISTS idx_elements_type ON elements(project_id, type); CREATE INDEX IF NOT EXISTS idx_elements_deleted ON elements(project_id, is_deleted); @@ -501,9 +508,21 @@ export function bulkReplaceElements(elements: ServerElement[], projectId?: strin return tx(); } +// ── Settings ── + +export function getSetting(key: string): string | undefined { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined; + return row?.value; +} + +export function setSetting(key: string, value: string): void { + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value); +} + export function closeDb(): void { if (db) { db.close(); + db = undefined as any; logger.info('SQLite database closed'); } } diff --git a/src/index.ts b/src/index.ts index 7fc41c9..2fa81e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,9 @@ import { EXCALIDRAW_ELEMENT_TYPES, ServerElement, ExcalidrawElementType, - validateElement + validateElement, + normalizeFontFamily, + files as globalFiles } from './types.js'; import fetch from 'node-fetch'; import { startCanvasServer, stopCanvasServer } from './server.js'; @@ -63,6 +65,10 @@ const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000'; const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`; const ENABLE_CANVAS_SYNC = true; +// One-time tokens for clear_canvas confirmation (token → expiry timestamp) +const pendingClearTokens = new Map(); +const CLEAR_TOKEN_TTL_MS = 120_000; // 2 minutes + // API Response types interface ApiResponse { success: boolean; @@ -247,7 +253,7 @@ const ElementSchema = z.object({ opacity: z.number().optional(), text: z.string().optional(), fontSize: z.number().optional(), - fontFamily: z.string().optional(), + fontFamily: z.union([z.string(), z.number()]).optional(), groupIds: z.array(z.string()).optional(), locked: z.boolean().optional(), strokeStyle: z.string().optional(), @@ -258,6 +264,9 @@ const ElementSchema = z.object({ endElementId: z.string().optional(), endArrowhead: z.string().optional(), startArrowhead: z.string().optional(), + fileId: z.string().optional(), + status: z.string().optional(), + scale: z.tuple([z.number(), z.number()]).optional(), }); const ElementIdSchema = z.object({ @@ -409,7 +418,7 @@ const tools: Tool[] = [ opacity: { type: 'number' }, text: { type: 'string' }, fontSize: { type: 'number' }, - fontFamily: { type: 'string' }, + fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont (hand-drawn), 2=Helvetica (sans-serif), 3=Cascadia (monospace), 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' }, startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' }, endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' }, endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' }, @@ -640,7 +649,7 @@ const tools: Tool[] = [ opacity: { type: 'number' }, text: { type: 'string' }, fontSize: { type: 'number' }, - fontFamily: { type: 'string' }, + fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont, 2=Helvetica, 3=Cascadia, 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' }, startElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow start to' }, endElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow end to' }, endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' }, @@ -666,10 +675,15 @@ const tools: Tool[] = [ }, { name: 'clear_canvas', - description: 'Clear all elements from the canvas', + description: 'DESTRUCTIVE: Permanently deletes ALL elements from the canvas. Two-step process: (1) Call WITHOUT clearToken to get a preview of what will be deleted — present this to the user and ask for confirmation. (2) Call WITH the returned clearToken to execute the clear. Prefer placing new diagrams alongside existing ones (call describe_scene first).', inputSchema: { type: 'object', - properties: {} + properties: { + clearToken: { + type: 'string', + description: 'One-time token returned by the preview step. Pass this to confirm and execute the clear. Omit on first call to get the preview.' + } + } } }, { @@ -973,11 +987,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) const { startElementId, endElementId, id: customId, ...elementProps } = params; const id = customId || generateId(); + const normalizedFont = normalizeFontFamily(elementProps.fontFamily); const element: ServerElement = { id, ...elementProps, + ...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}), points: elementProps.points ? normalizePoints(elementProps.points) : undefined, - // Convert binding IDs to Excalidraw's start/end format ...(startElementId ? { start: { id: startElementId } } : {}), ...(endElementId ? { end: { id: endElementId } } : {}), createdAt: new Date().toISOString(), @@ -1020,10 +1035,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) if (!id) throw new Error('Element ID is required'); - // Build update payload with timestamp and version increment + const normalizedFont = normalizeFontFamily(updates.fontFamily); const updatePayload: Partial & { id: string } = { id, ...updates, + ...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}), points: rawPoints ? normalizePoints(rawPoints) : undefined, updatedAt: new Date().toISOString() }; @@ -1468,11 +1484,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) for (const elementData of params.elements) { const { startElementId, endElementId, id: customId, ...elementProps } = elementData; const id = customId || generateId(); + const normalizedFont = normalizeFontFamily(elementProps.fontFamily); const element: ServerElement = { id, ...elementProps, + ...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}), points: elementProps.points ? normalizePoints(elementProps.points) : undefined, - // Convert binding IDs to Excalidraw's start/end format ...(startElementId ? { start: { id: startElementId } } : {}), ...(endElementId ? { end: { id: endElementId } } : {}), createdAt: new Date().toISOString(), @@ -1530,23 +1547,99 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) } case 'clear_canvas': { - logger.info('Clearing canvas via MCP'); + const clearParams = z.object({ clearToken: z.string().optional() }).parse(args); - const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { + if (!clearParams.clearToken) { + // Step 1: Preview — show what will be deleted and return a one-time token + const previewResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, { + headers: canvasHeaders() + }); + if (!previewResp.ok) throw new Error('Failed to fetch elements for preview'); + const previewData = await previewResp.json() as ApiResponse; + const elements = previewData.elements || []; + const count = elements.length; + + if (count === 0) { + return { + content: [{ type: 'text', text: 'The canvas is already empty. Nothing to clear.' }] + }; + } + + // Build a summary of what exists + const typeCounts: Record = {}; + for (const el of elements) { + typeCounts[el.type] = (typeCounts[el.type] || 0) + 1; + } + const typesSummary = Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(', '); + + // Generate one-time token + const token = `clr_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + pendingClearTokens.set(token, { expiresAt: Date.now() + CLEAR_TOKEN_TTL_MS, elementCount: count }); + + // Prune expired tokens + for (const [k, v] of pendingClearTokens) { + if (v.expiresAt < Date.now()) pendingClearTokens.delete(k); + } + + return { + content: [{ + type: 'text', + text: [ + `⚠️ CLEAR CANVAS — confirmation required`, + ``, + `This will permanently delete **${count} element${count !== 1 ? 's' : ''}**: ${typesSummary}`, + ``, + `Ask the user: "Do you want me to clear all ${count} elements from the canvas?"`, + ``, + `If the user confirms, call clear_canvas again with clearToken: "${token}"`, + `If the user declines, do NOT call clear_canvas again.`, + ].join('\n') + }] + }; + } + + // Step 2: Execute clear with a valid token + const tokenData = pendingClearTokens.get(clearParams.clearToken); + if (!tokenData) { + return { + content: [{ + type: 'text', + text: 'Clear canvas rejected: invalid or expired clearToken. Call clear_canvas without clearToken first to get a fresh preview and token.' + }], + isError: true + }; + } + if (tokenData.expiresAt < Date.now()) { + pendingClearTokens.delete(clearParams.clearToken); + return { + content: [{ + type: 'text', + text: 'Clear canvas rejected: clearToken has expired. Call clear_canvas without clearToken to get a new one.' + }], + isError: true + }; + } + + // Token valid — consume it and clear + pendingClearTokens.delete(clearParams.clearToken); + + logger.info('Clearing canvas via MCP (user confirmed via token)'); + + const clearResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() }); - if (!response.ok) { - throw new Error(`Failed to clear canvas: ${response.status} ${response.statusText}`); + if (!clearResponse.ok) { + throw new Error(`Failed to clear canvas: ${clearResponse.status} ${clearResponse.statusText}`); } - const data = await response.json() as ApiResponse; + const clearData = await clearResponse.json() as ApiResponse; return { content: [{ type: 'text', - text: `Canvas cleared.\n\n${JSON.stringify(data, null, 2)}` + text: `Canvas cleared.\n\n${JSON.stringify(clearData, null, 2)}` }] }; } @@ -1568,6 +1661,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) const data = await response.json() as ApiResponse; const sceneElements = data.elements || []; + // Collect files from the in-memory store + const exportFiles: Record = {}; + for (const [id, file] of globalFiles) { + exportFiles[id] = file; + } + const excalidrawScene = { type: 'excalidraw', version: 2, @@ -1576,7 +1675,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) appState: { viewBackgroundColor: '#ffffff', gridSize: null - } + }, + files: exportFiles }; const jsonString = JSON.stringify(excalidrawScene, null, 2); @@ -1587,7 +1687,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) return { content: [{ type: 'text', - text: `Scene exported to ${safePath} (${sceneElements.length} elements)` + text: `Scene exported to ${safePath} (${sceneElements.length} elements, ${Object.keys(exportFiles).length} files)` }] }; } @@ -1629,6 +1729,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) throw new Error('No elements found in the import data'); } + // Import files if present + const importedFiles = sceneData.files; + if (importedFiles && typeof importedFiles === 'object') { + for (const [id, fileData] of Object.entries(importedFiles)) { + const file = fileData as any; + globalFiles.set(id, { + id, + mimeType: file.mimeType || 'image/png', + dataURL: file.dataURL, + created: file.created || Date.now(), + }); + } + // Push files to canvas server + try { + await fetch(`${EXPRESS_SERVER_URL}/api/files`, { + method: 'POST', + headers: canvasHeaders(), + body: JSON.stringify({ files: importedFiles }) + }); + } catch {} + } + if (params.mode === 'replace') { await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() }); } @@ -1814,7 +1936,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) if (allElements.length === 0) { return { - content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.' }] + content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.\n\nSuggested placement for a new diagram: x=0, y=0' }] }; } @@ -1824,7 +1946,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) typeCounts[el.type] = (typeCounts[el.type] || 0) + 1; } - // Bounding box + // Overall bounding box let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const el of allElements) { minX = Math.min(minX, el.x); @@ -1833,6 +1955,58 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) maxY = Math.max(maxY, el.y + (el.height || 0)); } + // ── Diagram zone detection ── + // Build a map of groupId → elements + const groupMap: Record = {}; + const ungroupedElements: ServerElement[] = []; + for (const el of allElements) { + if (el.groupIds && el.groupIds.length > 0) { + for (const gid of el.groupIds) { + if (!groupMap[gid]) groupMap[gid] = []; + groupMap[gid]!.push(el); + } + } else { + ungroupedElements.push(el); + } + } + + interface DiagramZone { + groupId: string; + label: string | null; + bbox: { minX: number; minY: number; maxX: number; maxY: number }; + elementCount: number; + } + + const zones: DiagramZone[] = []; + for (const [gid, elements] of Object.entries(groupMap)) { + let zMinX = Infinity, zMinY = Infinity, zMaxX = -Infinity, zMaxY = -Infinity; + let label: string | null = null; + + for (const el of elements) { + zMinX = Math.min(zMinX, el.x); + zMinY = Math.min(zMinY, el.y); + zMaxX = Math.max(zMaxX, el.x + (el.width || 0)); + zMaxY = Math.max(zMaxY, el.y + (el.height || 0)); + + // Use the first text element or label as the zone name + if (!label) { + if (el.type === 'text' && el.text) label = el.text; + else if (el.label?.text) label = el.label.text; + } + } + + zones.push({ + groupId: gid, + label, + bbox: { minX: zMinX, minY: zMinY, maxX: zMaxX, maxY: zMaxY }, + elementCount: elements.length, + }); + } + + // Suggest next placement: 300px to the right of the overall bounding box + const suggestedX = Math.round(maxX + 300); + const suggestedY = Math.round(minY); + // Build element descriptions sorted top-to-bottom, left-to-right const sorted = [...allElements].sort((a, b) => { const rowDiff = Math.floor(a.y / 50) - Math.floor(b.y / 50); @@ -1879,7 +2053,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) lines.push(`## Canvas Description`); lines.push(`Total elements: ${allElements.length}`); lines.push(`Types: ${Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(', ')}`); - lines.push(`Bounding box: (${Math.round(minX)}, ${Math.round(minY)}) to (${Math.round(maxX)}, ${Math.round(maxY)}) = ${Math.round(maxX - minX)}x${Math.round(maxY - minY)}`); + lines.push(`Canvas bounding box: (${Math.round(minX)}, ${Math.round(minY)}) to (${Math.round(maxX)}, ${Math.round(maxY)}) = ${Math.round(maxX - minX)}x${Math.round(maxY - minY)}`); + + // Diagram zones section + if (zones.length > 0) { + lines.push(''); + lines.push('### Diagram Zones (grouped):'); + for (const zone of zones) { + const w = Math.round(zone.bbox.maxX - zone.bbox.minX); + const h = Math.round(zone.bbox.maxY - zone.bbox.minY); + const name = zone.label ? `"${zone.label}"` : '(unnamed)'; + lines.push(` Group ${zone.groupId}: ${name} | bbox (${Math.round(zone.bbox.minX)}, ${Math.round(zone.bbox.minY)}) to (${Math.round(zone.bbox.maxX)}, ${Math.round(zone.bbox.maxY)}) = ${w}x${h} | ${zone.elementCount} elements`); + } + } + + if (ungroupedElements.length > 0 && zones.length > 0) { + lines.push(` + ${ungroupedElements.length} ungrouped elements`); + } + + lines.push(''); + lines.push(`### Suggested placement for new diagram: x=${suggestedX}, y=${suggestedY}`); + lines.push(''); lines.push('### Elements (top-to-bottom, left-to-right):'); lines.push(...elementDescs); @@ -1890,23 +2084,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) lines.push(...connectionDescs); } - // Groups - const groupedElements = allElements.filter(el => el.groupIds && el.groupIds.length > 0); - if (groupedElements.length > 0) { - const groupMap: Record = {}; - for (const el of groupedElements) { - for (const gid of (el.groupIds || [])) { - if (!groupMap[gid]) groupMap[gid] = []; - groupMap[gid]!.push(el.id); - } - } - lines.push(''); - lines.push('### Groups:'); - for (const [gid, ids] of Object.entries(groupMap)) { - lines.push(` Group ${gid}: [${ids.join(', ')}]`); - } - } - return { content: [{ type: 'text', text: lines.join('\n') }] }; @@ -2533,10 +2710,17 @@ if (process.env.DEBUG === 'true') { // Start the server if this file is run directly if (fileURLToPath(import.meta.url) === process.argv[1]) { - runServer().catch(error => { - logger.error('Failed to start server:', error); - process.exit(1); - }); + if (process.argv[2] === 'setup') { + import('./setup.js').then(m => m.runSetup()).catch(error => { + process.stderr.write(`Setup failed: ${(error as Error).message}\n`); + process.exit(1); + }); + } else { + runServer().catch(error => { + logger.error('Failed to start server:', error); + process.exit(1); + }); + } } export default runServer; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 40b0d6f..9b6c596 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,10 +18,13 @@ import { BatchCreatedMessage, SyncStatusMessage, InitialElementsMessage, - Snapshot + Snapshot, + normalizeFontFamily, + ExcalidrawFile, + files } from './types.js'; import * as store from './db.js'; -import { listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js'; +import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js'; import { z } from 'zod'; import WebSocket from 'ws'; @@ -86,6 +89,15 @@ wss.on('connection', (ws: WebSocket) => { elements: store.getAllElements() }; ws.send(JSON.stringify(initialMessage)); + + // Send any stored files (image data) + if (files.size > 0) { + const allFiles: Record = {}; + for (const [id, file] of files) { + allFiles[id] = file; + } + ws.send(JSON.stringify({ type: 'files_added', files: allFiles })); + } // Send sync status to new client const syncMessage: SyncStatusMessage = { @@ -108,7 +120,7 @@ wss.on('connection', (ws: WebSocket) => { // Schema validation const CreateElementSchema = z.object({ - id: z.string().optional(), // Allow passing ID for MCP sync + id: z.string().optional(), type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]), x: z.number(), y: z.number(), @@ -121,11 +133,12 @@ const CreateElementSchema = z.object({ roughness: z.number().optional(), opacity: z.number().optional(), text: z.string().optional(), + originalText: z.string().optional(), label: z.object({ text: z.string() }).optional(), fontSize: z.number().optional(), - fontFamily: z.string().optional(), + fontFamily: z.union([z.string(), z.number()]).optional(), groupIds: z.array(z.string()).optional(), locked: z.boolean().optional(), roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(), @@ -136,7 +149,14 @@ const CreateElementSchema = z.object({ end: z.object({ id: z.string() }).optional(), startArrowhead: z.string().nullable().optional(), endArrowhead: z.string().nullable().optional(), + startBinding: z.any().nullable().optional(), + endBinding: z.any().nullable().optional(), + boundElements: z.any().nullable().optional(), elbowed: z.boolean().optional(), + // Image element properties + fileId: z.string().optional(), + status: z.string().optional(), + scale: z.tuple([z.number(), z.number()]).optional(), }); const UpdateElementSchema = z.object({ @@ -153,11 +173,12 @@ const UpdateElementSchema = z.object({ roughness: z.number().optional(), opacity: z.number().optional(), text: z.string().optional(), + originalText: z.string().optional(), label: z.object({ text: z.string() }).optional(), fontSize: z.number().optional(), - fontFamily: z.string().optional(), + fontFamily: z.union([z.string(), z.number()]).optional(), groupIds: z.array(z.string()).optional(), locked: z.boolean().optional(), roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(), @@ -170,7 +191,13 @@ const UpdateElementSchema = z.object({ end: z.object({ id: z.string() }).optional(), startArrowhead: z.string().nullable().optional(), endArrowhead: z.string().nullable().optional(), + startBinding: z.any().nullable().optional(), + endBinding: z.any().nullable().optional(), + boundElements: z.any().nullable().optional(), elbowed: z.boolean().optional(), + fileId: z.string().optional(), + status: z.string().optional(), + scale: z.tuple([z.number(), z.number()]).optional(), }); // API Routes @@ -202,9 +229,11 @@ app.post('/api/elements', (req: Request, res: Response) => { logger.info('Creating element via API', { type: params.type }); const id = params.id || generateId(); + const normalizedFont = normalizeFontFamily(params.fontFamily); const element: ServerElement = { id, ...params, + ...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: 1 @@ -253,9 +282,11 @@ app.put('/api/elements/:id', (req: Request, res: Response) => { }); } + const normalizedFont = normalizeFontFamily(updates.fontFamily); const updatedElement: ServerElement = { ...existingElement, ...updates, + ...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}), updatedAt: new Date().toISOString(), version: (existingElement.version || 0) + 1 }; @@ -526,11 +557,9 @@ function resolveArrowBindings(batchElements: ServerElement[], projectId?: string el.y = finalStart.y; el.points = [[0, 0], [finalEnd.x - finalStart.x, finalEnd.y - finalStart.y]]; - // Remove start/end refs (they were used for computation only) - delete (el as any).start; - delete (el as any).end; - - // Set binding metadata for Excalidraw + // Keep start/end refs on the element — the frontend's + // convertToExcalidrawElements uses them to compute proper bindings + // (focus, gap, fixedPoint). Also set basic binding metadata for export. if (startEl) { (el as any).startBinding = { elementId: startEl.id, @@ -566,9 +595,11 @@ app.post('/api/elements/batch', (req: Request, res: Response) => { elementsToCreate.forEach(elementData => { const params = CreateElementSchema.parse(elementData); const id = params.id || generateId(); + const normalizedFont = normalizeFontFamily(params.fontFamily); const element: ServerElement = { id, ...params, + ...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: 1 @@ -714,6 +745,70 @@ app.post('/api/elements/sync', (req: Request, res: Response) => { } }); +// ── Files API (image element data) ── + +// Get all files +app.get('/api/files', (_req: Request, res: Response) => { + try { + const allFiles: Record = {}; + for (const [id, file] of files) { + allFiles[id] = file; + } + res.json({ success: true, files: allFiles }); + } catch (error) { + logger.error('Error fetching files:', error); + res.status(500).json({ success: false, error: (error as Error).message }); + } +}); + +// Add files (image data) +app.post('/api/files', (req: Request, res: Response) => { + try { + const incoming = req.body.files; + if (!incoming || typeof incoming !== 'object') { + return res.status(400).json({ success: false, error: 'files object is required' }); + } + + const addedIds: string[] = []; + for (const [id, fileData] of Object.entries(incoming)) { + const file = fileData as ExcalidrawFile; + files.set(id, { + id, + mimeType: file.mimeType || 'image/png', + dataURL: file.dataURL, + created: file.created || Date.now(), + }); + addedIds.push(id); + } + + broadcast({ + type: 'files_added', + files: incoming + }); + + res.json({ success: true, addedIds, count: addedIds.length }); + } catch (error) { + logger.error('Error adding files:', error); + res.status(400).json({ success: false, error: (error as Error).message }); + } +}); + +// Delete a file +app.delete('/api/files/:id', (req: Request, res: Response) => { + try { + const { id } = req.params; + if (!files.has(id!)) { + return res.status(404).json({ success: false, error: `File ${id} not found` }); + } + files.delete(id!); + broadcast({ type: 'file_deleted', fileId: id }); + res.json({ success: true, message: `File ${id} deleted` }); + } catch (error) { + logger.error('Error deleting file:', error); + res.status(500).json({ success: false, error: (error as Error).message }); + } +}); + // Image export: request (MCP -> Express -> WebSocket -> Frontend) interface PendingExport { resolve: (data: { format: string; data: string }) => void; @@ -1052,6 +1147,34 @@ app.put('/api/tenant/active', (req: Request, res: Response) => { } }); +// ── Settings API ── + +app.get('/api/settings/:key', (req: Request, res: Response) => { + try { + const { key } = req.params; + const value = store.getSetting(key!); + res.json({ success: true, key, value: value ?? null }); + } catch (error) { + logger.error('Error reading setting:', error); + res.status(500).json({ success: false, error: (error as Error).message }); + } +}); + +app.put('/api/settings/:key', (req: Request, res: Response) => { + try { + const { key } = req.params; + const { value } = req.body; + if (value === undefined || value === null) { + return res.status(400).json({ success: false, error: 'value is required' }); + } + store.setSetting(key!, String(value)); + res.json({ success: true, key, value: String(value) }); + } catch (error) { + logger.error('Error writing setting:', error); + res.status(500).json({ success: false, error: (error as Error).message }); + } +}); + // Health check endpoint app.get('/health', (req: Request, res: Response) => { const projId = resolveTenantProject(req); @@ -1099,6 +1222,10 @@ export function isCanvasServerOwned(): boolean { } export async function startCanvasServer(): Promise { + // Ensure the database is initialized when running standalone (e.g. Docker: `node dist/server.js`). + // When launched via index.ts (MCP entry point), initDb() is a no-op on the second call. + initDb(); + // Pre-flight: check if an existing healthy canvas server is already on this port. // We do this BEFORE calling httpServer.listen() because Node's listen() can emit // EADDRINUSE as an uncaught exception that bypasses our error handler. diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..3216297 --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,364 @@ +#!/usr/bin/env node + +/** + * Interactive setup wizard for mcp-excalidraw-local. + * Runs via: npx @sanjibdevnath/mcp-excalidraw-local setup + * + * Uses only Node.js built-ins — no third-party dependencies. + * Every phase is optional and skippable. + */ + +import * as readline from 'readline'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ── Helpers ────────────────────────────────────────────────── + +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; +const GREEN = '\x1b[32m'; +const RED = '\x1b[31m'; +const YELLOW = '\x1b[33m'; +const CYAN = '\x1b[36m'; +const RESET = '\x1b[0m'; + +function ok(msg: string) { process.stdout.write(` ${GREEN}✔${RESET} ${msg}\n`); } +function fail(msg: string) { process.stdout.write(` ${RED}✘${RESET} ${msg}\n`); } +function warn(msg: string) { process.stdout.write(` ${YELLOW}⚠${RESET} ${msg}\n`); } +function info(msg: string) { process.stdout.write(` ${msg}\n`); } +function heading(phase: string, title: string) { + process.stdout.write(`\n ${BOLD}[${phase}] ${title}${RESET}\n`); +} + +function ask(rl: readline.Interface, prompt: string): Promise { + return new Promise(resolve => rl.question(` ${prompt}`, resolve)); +} + +async function confirm(rl: readline.Interface, prompt: string, defaultYes = true): Promise { + const hint = defaultYes ? '[Y/n]' : '[y/N]'; + const answer = (await ask(rl, `${prompt} ${hint}: `)).trim().toLowerCase(); + if (answer === '') return defaultYes; + return answer === 'y' || answer === 'yes'; +} + +// ── Agent Definitions ──────────────────────────────────────── + +interface AgentDef { + name: string; + detectPaths: string[]; + skillBasePaths: { global: string; local: string }; + mcpConfigType: 'json-file' | 'cli-command'; + mcpConfigPath?: string; + mcpCliCommand?: string; +} + +function getAgents(): AgentDef[] { + const home = os.homedir(); + return [ + { + name: 'Cursor', + detectPaths: [path.join(home, '.cursor')], + skillBasePaths: { + global: path.join(home, '.cursor', 'skills'), + local: path.join(process.cwd(), '.cursor', 'skills'), + }, + mcpConfigType: 'json-file', + mcpConfigPath: path.join(home, '.cursor', 'mcp.json'), + }, + { + name: 'Claude Code', + detectPaths: [path.join(home, '.claude')], + skillBasePaths: { + global: path.join(home, '.claude', 'skills'), + local: path.join(process.cwd(), '.claude', 'skills'), + }, + mcpConfigType: 'cli-command', + mcpCliCommand: 'claude mcp add excalidraw-canvas --scope user -e CANVAS_PORT=3000 -- npx -y @sanjibdevnath/mcp-excalidraw-local', + }, + { + name: 'Codex CLI', + detectPaths: [path.join(home, '.codex')], + skillBasePaths: { + global: path.join(home, '.codex', 'skills'), + local: path.join(process.cwd(), '.codex', 'skills'), + }, + mcpConfigType: 'json-file', + mcpConfigPath: path.join(home, '.codex', 'mcp.json'), + }, + ]; +} + +function detectInstalledAgents(): AgentDef[] { + return getAgents().filter(a => a.detectPaths.some(p => fs.existsSync(p))); +} + +// ── Phase 1: Environment Check ────────────────────────────── + +async function phaseEnvironment(rl: readline.Interface): Promise { + heading('1/3', 'Environment'); + let allOk = true; + + // Node.js version + const nodeVersion = process.version; + const major = parseInt(nodeVersion.slice(1).split('.')[0] ?? '0', 10); + if (major >= 18) { + ok(`Node.js ${nodeVersion} ${'.' .repeat(Math.max(0, 24 - nodeVersion.length))} OK`); + } else { + fail(`Node.js ${nodeVersion} — requires >= 18.0.0`); + allOk = false; + } + + // better-sqlite3 bindings + try { + execSync('node -e "require(\'better-sqlite3\')"', { stdio: 'pipe', cwd: path.resolve(__dirname, '..') }); + ok('better-sqlite3 bindings ........... OK'); + } catch { + fail('better-sqlite3 bindings ........... FAILED'); + const doFix = await confirm(rl, 'Native module needs rebuild. Fix now?'); + if (doFix) { + try { + info(`${DIM}Running npm rebuild better-sqlite3...${RESET}`); + execSync('npm rebuild better-sqlite3', { + stdio: 'inherit', + cwd: path.resolve(__dirname, '..'), + }); + // Verify + execSync('node -e "require(\'better-sqlite3\')"', { stdio: 'pipe', cwd: path.resolve(__dirname, '..') }); + ok('Rebuild successful'); + } catch { + fail('Rebuild failed. Try manually:'); + info(` cd ${path.resolve(__dirname, '..')}`); + info(' npm rebuild better-sqlite3'); + info(''); + info('Prerequisites:'); + if (process.platform === 'darwin') { + info(' xcode-select --install'); + } else if (process.platform === 'linux') { + info(' sudo apt install build-essential python3'); + } else { + info(' npm install --global windows-build-tools'); + } + allOk = false; + } + } else { + info('Manual fix: npm rebuild better-sqlite3'); + allOk = false; + } + } + + // Frontend build + const frontendIndex = path.resolve(__dirname, '..', 'dist', 'frontend', 'index.html'); + if (fs.existsSync(frontendIndex)) { + ok('Frontend build .................... OK'); + } else { + warn('Frontend build .................... NOT FOUND'); + info(`Expected: ${frontendIndex}`); + info('Run: npm run build'); + } + + return allOk; +} + +// ── Phase 2: Skill Installation ───────────────────────────── + +async function phaseSkillInstall(rl: readline.Interface): Promise { + heading('2/3', 'Agent Skill'); + + const wantSkill = await confirm(rl, 'Install the Excalidraw agent skill?'); + if (!wantSkill) { + info(`${DIM}Skill folder: skills/excalidraw-skill/ (copy manually if needed)${RESET}`); + return; + } + + const agents = detectInstalledAgents(); + if (agents.length === 0) { + warn('No supported agents detected (Cursor, Claude Code, Codex CLI).'); + info(`${DIM}Skill folder: skills/excalidraw-skill/ (copy manually when ready)${RESET}`); + return; + } + + process.stdout.write('\n Detected agents:\n'); + agents.forEach((a, i) => { + process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${a.name}\n`); + }); + + const selection = await ask(rl, "Which agents? (comma-separated, 'all', or 'skip'): "); + const trimmed = selection.trim().toLowerCase(); + + if (trimmed === 'skip' || trimmed === '') return; + + let selectedAgents: AgentDef[]; + if (trimmed === 'all') { + selectedAgents = agents; + } else { + const indices = trimmed.split(',').map(s => parseInt(s.trim(), 10) - 1); + selectedAgents = indices + .filter(i => i >= 0 && i < agents.length) + .map(i => agents[i]!); + } + + if (selectedAgents.length === 0) { + warn('No valid agents selected.'); + return; + } + + const skillSource = path.resolve(__dirname, '..', 'skills', 'excalidraw-skill'); + if (!fs.existsSync(skillSource)) { + fail(`Skill source not found at ${skillSource}`); + return; + } + + for (const agent of selectedAgents) { + const scopeAnswer = await ask(rl, `\n ${agent.name} — scope? [G]lobal / [l]ocal: `); + const scope = scopeAnswer.trim().toLowerCase() === 'l' ? 'local' : 'global'; + const destBase = agent.skillBasePaths[scope]; + const destDir = path.join(destBase, 'excalidraw-skill'); + + try { + fs.mkdirSync(destDir, { recursive: true }); + copyDirSync(skillSource, destDir); + ok(`Installed to ${destDir}`); + } catch (err) { + fail(`Failed to install to ${destDir}: ${(err as Error).message}`); + } + } +} + +function copyDirSync(src: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDirSync(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +// ── Phase 3: MCP Configuration ────────────────────────────── + +async function phaseMcpConfig(rl: readline.Interface): Promise { + heading('3/3', 'MCP Configuration'); + + const wantConfig = await confirm(rl, 'Add MCP server to agent configs automatically?'); + if (!wantConfig) { + printManualConfig(); + return; + } + + const agents = detectInstalledAgents(); + if (agents.length === 0) { + warn('No supported agents detected.'); + printManualConfig(); + return; + } + + for (const agent of agents) { + if (agent.mcpConfigType === 'json-file' && agent.mcpConfigPath) { + const doIt = await confirm(rl, `${agent.name} — add to ${agent.mcpConfigPath}?`); + if (!doIt) { + info(`${DIM}Skipped. Add manually later.${RESET}`); + continue; + } + + try { + mergeJsonConfig(agent.mcpConfigPath); + ok(`Added 'excalidraw-canvas' to ${agent.mcpConfigPath}`); + } catch (err) { + fail(`Failed: ${(err as Error).message}`); + info('Add manually:'); + printManualConfig(); + } + } else if (agent.mcpConfigType === 'cli-command' && agent.mcpCliCommand) { + const doIt = await confirm(rl, `${agent.name} — register via CLI?`); + if (!doIt) { + info(`${DIM}Skipped.${RESET}`); + continue; + } + + try { + execSync(agent.mcpCliCommand, { stdio: 'inherit' }); + ok(`Registered 'excalidraw-canvas' via ${agent.name} CLI`); + } catch (err) { + fail(`CLI registration failed: ${(err as Error).message}`); + info('Register manually:'); + info(` ${agent.mcpCliCommand}`); + } + } + } +} + +function mergeJsonConfig(configPath: string): void { + const mcpEntry = { + command: 'npx', + args: ['-y', '@sanjibdevnath/mcp-excalidraw-local'], + env: { CANVAS_PORT: '3000' }, + }; + + let existing: any = {}; + if (fs.existsSync(configPath)) { + const raw = fs.readFileSync(configPath, 'utf-8'); + existing = JSON.parse(raw); + } + + if (!existing.mcpServers) { + existing.mcpServers = {}; + } + + if (existing.mcpServers['excalidraw-canvas']) { + process.stdout.write(` ${YELLOW}Entry 'excalidraw-canvas' already exists — overwriting.${RESET}\n`); + } + + existing.mcpServers['excalidraw-canvas'] = mcpEntry; + + const dir = path.dirname(configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8'); +} + +function printManualConfig(): void { + process.stdout.write(` + Manual config (JSON): + ${DIM}{ + "mcpServers": { + "excalidraw-canvas": { + "command": "npx", + "args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"], + "env": { "CANVAS_PORT": "3000" } + } + } + }${RESET} +`); +} + +// ── Main ───────────────────────────────────────────────────── + +export async function runSetup(): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + process.stdout.write(`\n ${BOLD}Excalidraw MCP — Setup${RESET}\n`); + + try { + await phaseEnvironment(rl); + await phaseSkillInstall(rl); + await phaseMcpConfig(rl); + + process.stdout.write(`\n ${GREEN}${BOLD}Done!${RESET} Open ${CYAN}http://localhost:3000${RESET} to verify the canvas.\n\n`); + } finally { + rl.close(); + } +} diff --git a/src/types.ts b/src/types.ts index 2fc7852..8f6b2aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,7 +106,7 @@ export interface ExcalidrawBinding { fixedPoint?: readonly [number, number] | null; } -export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw'; +export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'image'; // Excalidraw element types export const EXCALIDRAW_ELEMENT_TYPES: Record = { @@ -116,7 +116,8 @@ export const EXCALIDRAW_ELEMENT_TYPES: Record = { ARROW: 'arrow', TEXT: 'text', FREEDRAW: 'freedraw', - LINE: 'line' + LINE: 'line', + IMAGE: 'image' } as const; // Server-side element with metadata @@ -136,9 +137,16 @@ export interface ServerElement extends Omit { text: string; }; points?: any; + originalText?: string; // Arrow element binding: connect arrows to shapes by element ID start?: { id: string }; end?: { id: string }; + startBinding?: ExcalidrawBinding | null; + endBinding?: ExcalidrawBinding | null; + // Image element properties + fileId?: string; + status?: string; + scale?: [number, number]; } // API Response types @@ -183,7 +191,9 @@ export type WebSocketMessageType = | 'canvas_cleared' | 'export_image_request' | 'set_viewport' - | 'tenant_switched'; + | 'tenant_switched' + | 'files_added' + | 'file_deleted'; export interface InitialElementsMessage extends WebSocketMessage { type: 'initial_elements'; @@ -289,6 +299,46 @@ export interface Snapshot { createdAt: string; } +// Excalidraw file (image) data — stored in-memory alongside element data +export interface ExcalidrawFile { + mimeType: string; + id: string; + dataURL: string; + created: number; + lastRetrieved?: number; +} + +// In-memory file storage (image files are too large for SQLite row storage) +export const files = new Map(); + +// Font family normalization: Excalidraw expects numeric IDs, but agents +// often send string names. Map common names to their numeric equivalents. +const FONT_FAMILY_MAP: Record = { + 'virgil': 1, + 'hand-drawn': 1, + 'excalifont': 1, + 'helvetica': 2, + 'arial': 2, + 'sans-serif': 2, + 'cascadia': 3, + 'monospace': 3, + 'courier': 3, + 'comic shanns': 4, + 'comic sans': 4, + 'liberation sans': 5, + 'nunito': 6, + 'lilita one': 7, +}; + +export function normalizeFontFamily(value: string | number | undefined): number | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === 'number') return value; + const mapped = FONT_FAMILY_MAP[value.toLowerCase().trim()]; + if (mapped !== undefined) return mapped; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? 1 : parsed; +} + // Storage is now handled by src/db.ts (SQLite). // The Map exports below are kept only for backward compatibility with // standalone server.ts usage; they are NOT used when the DB is active. diff --git a/tests/backend/api.test.ts b/tests/backend/api.test.ts new file mode 100644 index 0000000..54bc509 --- /dev/null +++ b/tests/backend/api.test.ts @@ -0,0 +1,432 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting } from '../../src/db.js'; +import type { ServerElement } from '../../src/types.js'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +let dbPath: string; + +// Dynamic import to ensure DB is initialized before module-level code in server.ts runs +let app: any; + +function makeElement(overrides: Partial = {}): ServerElement { + return { + id: `el-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + type: 'rectangle', + x: 100, + y: 200, + width: 150, + height: 80, + version: 1, + ...overrides, + }; +} + +beforeEach(async () => { + dbPath = path.join(os.tmpdir(), `excalidraw-api-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + initDb(dbPath); + const mod = await import('../../src/server.js'); + app = mod.default; +}); + +afterEach(() => { + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbPath + suffix); } catch {} + } +}); + +// ─── Health ────────────────────────────────────────────────── + +describe('GET /health', () => { + it('returns healthy status', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('healthy'); + expect(res.body).toHaveProperty('elements_count'); + expect(res.body).toHaveProperty('timestamp'); + }); +}); + +// ─── Elements CRUD ─────────────────────────────────────────── + +describe('GET /api/elements', () => { + it('returns empty list initially', async () => { + const res = await request(app).get('/api/elements'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.elements).toEqual([]); + expect(res.body.count).toBe(0); + }); + + it('returns elements after creation', async () => { + setElement('e1', makeElement({ id: 'e1' })); + setElement('e2', makeElement({ id: 'e2' })); + + const res = await request(app).get('/api/elements'); + expect(res.body.count).toBe(2); + }); +}); + +describe('POST /api/elements', () => { + it('creates an element and returns it', async () => { + const res = await request(app) + .post('/api/elements') + .send({ type: 'rectangle', x: 10, y: 20, width: 100, height: 50 }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.element.type).toBe('rectangle'); + expect(res.body.element.x).toBe(10); + expect(res.body.element).toHaveProperty('id'); + }); + + it('accepts a custom id', async () => { + const res = await request(app) + .post('/api/elements') + .send({ id: 'custom-id', type: 'ellipse', x: 0, y: 0, width: 50, height: 50 }); + + expect(res.body.element.id).toBe('custom-id'); + }); + + it('rejects invalid element type', async () => { + const res = await request(app) + .post('/api/elements') + .send({ type: 'invalid-type', x: 0, y: 0 }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('rejects missing required fields', async () => { + const res = await request(app) + .post('/api/elements') + .send({ type: 'rectangle' }); + + expect(res.status).toBe(400); + }); +}); + +describe('GET /api/elements/:id', () => { + it('returns element by id', async () => { + setElement('find-me', makeElement({ id: 'find-me', type: 'diamond' })); + + const res = await request(app).get('/api/elements/find-me'); + expect(res.status).toBe(200); + expect(res.body.element.id).toBe('find-me'); + expect(res.body.element.type).toBe('diamond'); + }); + + it('returns 404 for missing element', async () => { + const res = await request(app).get('/api/elements/nonexistent'); + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); +}); + +describe('PUT /api/elements/:id', () => { + it('updates an existing element', async () => { + setElement('up1', makeElement({ id: 'up1', x: 0 })); + + const res = await request(app) + .put('/api/elements/up1') + .send({ x: 500, y: 600 }); + + expect(res.status).toBe(200); + expect(res.body.element.x).toBe(500); + expect(res.body.element.y).toBe(600); + }); + + it('returns 404 for non-existent element', async () => { + const res = await request(app) + .put('/api/elements/missing') + .send({ x: 1 }); + + expect(res.status).toBe(404); + }); +}); + +describe('DELETE /api/elements/:id', () => { + it('deletes an existing element', async () => { + setElement('del1', makeElement({ id: 'del1' })); + + const res = await request(app).delete('/api/elements/del1'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const getRes = await request(app).get('/api/elements/del1'); + expect(getRes.status).toBe(404); + }); + + it('returns 404 for non-existent element', async () => { + const res = await request(app).delete('/api/elements/missing'); + expect(res.status).toBe(404); + }); +}); + +describe('DELETE /api/elements/clear', () => { + it('clears all elements', async () => { + setElement('a', makeElement({ id: 'a' })); + setElement('b', makeElement({ id: 'b' })); + + const res = await request(app).delete('/api/elements/clear'); + expect(res.status).toBe(200); + expect(res.body.count).toBe(2); + + const listRes = await request(app).get('/api/elements'); + expect(listRes.body.count).toBe(0); + }); + + it('returns 0 count when already empty', async () => { + const res = await request(app).delete('/api/elements/clear'); + expect(res.body.count).toBe(0); + }); +}); + +// ─── Batch Create ──────────────────────────────────────────── + +describe('POST /api/elements/batch', () => { + it('creates multiple elements at once', async () => { + const res = await request(app) + .post('/api/elements/batch') + .send({ + elements: [ + { type: 'rectangle', x: 0, y: 0, width: 100, height: 50 }, + { type: 'ellipse', x: 200, y: 200, width: 80, height: 80 }, + { type: 'text', x: 50, y: 50, text: 'Hello' }, + ], + }); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(3); + expect(res.body.elements.length).toBe(3); + }); + + it('rejects non-array input', async () => { + const res = await request(app) + .post('/api/elements/batch') + .send({ elements: 'not-an-array' }); + + expect(res.status).toBe(400); + }); + + it('resolves arrow bindings between batch elements', async () => { + const res = await request(app) + .post('/api/elements/batch') + .send({ + elements: [ + { id: 'box1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 }, + { id: 'box2', type: 'rectangle', x: 300, y: 0, width: 100, height: 50 }, + { id: 'arr1', type: 'arrow', x: 0, y: 0, start: { id: 'box1' }, end: { id: 'box2' } }, + ], + }); + + expect(res.status).toBe(200); + const arrow = res.body.elements.find((e: any) => e.id === 'arr1'); + expect(arrow).toBeDefined(); + expect(arrow.points).toBeDefined(); + expect(arrow.startBinding).toBeDefined(); + expect(arrow.endBinding).toBeDefined(); + }); +}); + +// ─── Search ────────────────────────────────────────────────── + +describe('GET /api/elements/search', () => { + it('filters by type query param', async () => { + setElement('r1', makeElement({ id: 'r1', type: 'rectangle' })); + setElement('e1', makeElement({ id: 'e1', type: 'ellipse' })); + + const res = await request(app).get('/api/elements/search?type=rectangle'); + expect(res.body.count).toBe(1); + expect(res.body.elements[0].type).toBe('rectangle'); + }); + + it('full-text search via q param', async () => { + setElement('t1', makeElement({ id: 't1', type: 'text', label: { text: 'Hello World' } })); + setElement('t2', makeElement({ id: 't2', type: 'text', label: { text: 'Goodbye' } })); + + const res = await request(app).get('/api/elements/search?q=Hello'); + expect(res.body.count).toBe(1); + expect(res.body.elements[0].id).toBe('t1'); + }); +}); + +// ─── Sync ──────────────────────────────────────────────────── + +describe('POST /api/elements/sync', () => { + it('replaces all elements from frontend', async () => { + setElement('old', makeElement({ id: 'old' })); + + const res = await request(app) + .post('/api/elements/sync') + .send({ + elements: [ + { id: 'new1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 }, + { id: 'new2', type: 'ellipse', x: 50, y: 50, width: 20, height: 20 }, + ], + timestamp: new Date().toISOString(), + }); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(2); + + const listRes = await request(app).get('/api/elements'); + expect(listRes.body.count).toBe(2); + expect(listRes.body.elements.map((e: any) => e.id).sort()).toEqual(['new1', 'new2']); + }); + + it('rejects non-array elements', async () => { + const res = await request(app) + .post('/api/elements/sync') + .send({ elements: 'nope', timestamp: new Date().toISOString() }); + + expect(res.status).toBe(400); + }); +}); + +// ─── Snapshots ─────────────────────────────────────────────── + +describe('Snapshots API', () => { + it('POST creates and GET lists snapshots', async () => { + setElement('se1', makeElement({ id: 'se1' })); + + const createRes = await request(app) + .post('/api/snapshots') + .send({ name: 'my-snap' }); + + expect(createRes.status).toBe(200); + expect(createRes.body.name).toBe('my-snap'); + expect(createRes.body.elementCount).toBe(1); + + const listRes = await request(app).get('/api/snapshots'); + expect(listRes.body.count).toBe(1); + }); + + it('GET /api/snapshots/:name returns a specific snapshot', async () => { + setElement('s1', makeElement({ id: 's1' })); + await request(app).post('/api/snapshots').send({ name: 'get-snap' }); + + const res = await request(app).get('/api/snapshots/get-snap'); + expect(res.status).toBe(200); + expect(res.body.snapshot.name).toBe('get-snap'); + }); + + it('GET /api/snapshots/:name returns 404 for missing', async () => { + const res = await request(app).get('/api/snapshots/nonexistent'); + expect(res.status).toBe(404); + }); + + it('POST rejects missing name', async () => { + const res = await request(app).post('/api/snapshots').send({}); + expect(res.status).toBe(400); + }); +}); + +// ─── Tenants API ───────────────────────────────────────────── + +describe('Tenants API', () => { + it('GET /api/tenants returns tenant list', async () => { + const res = await request(app).get('/api/tenants'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.tenants)).toBe(true); + expect(res.body.tenants.length).toBeGreaterThanOrEqual(1); + }); + + it('GET /api/tenant/active returns current tenant', async () => { + const res = await request(app).get('/api/tenant/active'); + expect(res.status).toBe(200); + expect(res.body.tenant.id).toBe('default'); + }); + + it('PUT /api/tenant/active switches tenant', async () => { + const { ensureTenant } = await import('../../src/db.js'); + ensureTenant('switch-test', 'Switch Test', '/test'); + + const res = await request(app) + .put('/api/tenant/active') + .send({ tenantId: 'switch-test' }); + + expect(res.status).toBe(200); + expect(res.body.tenant.id).toBe('switch-test'); + }); + + it('PUT /api/tenant/active rejects missing tenantId', async () => { + const res = await request(app).put('/api/tenant/active').send({}); + expect(res.status).toBe(400); + }); +}); + +// ─── Settings API ──────────────────────────────────────────── + +describe('Settings API', () => { + it('GET returns null for missing key', async () => { + const res = await request(app).get('/api/settings/missing_key'); + expect(res.status).toBe(200); + expect(res.body.value).toBeNull(); + }); + + it('PUT + GET round-trips a value', async () => { + await request(app) + .put('/api/settings/my_key') + .send({ value: 'my_value' }); + + const res = await request(app).get('/api/settings/my_key'); + expect(res.body.value).toBe('my_value'); + }); + + it('PUT rejects missing value', async () => { + const res = await request(app) + .put('/api/settings/no_val') + .send({}); + expect(res.status).toBe(400); + }); +}); + +// ─── Sync Status ───────────────────────────────────────────── + +describe('GET /api/sync/status', () => { + it('returns sync status', async () => { + const res = await request(app).get('/api/sync/status'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body).toHaveProperty('elementCount'); + expect(res.body).toHaveProperty('memoryUsage'); + expect(res.body).toHaveProperty('websocketClients'); + }); +}); + +// ─── Tenant-scoped via X-Tenant-Id header ──────────────────── + +describe('Tenant-scoped requests via X-Tenant-Id', () => { + it('elements are isolated per tenant', async () => { + const { ensureTenant } = await import('../../src/db.js'); + ensureTenant('tenant-a', 'A', '/a'); + ensureTenant('tenant-b', 'B', '/b'); + + await request(app) + .post('/api/elements') + .set('X-Tenant-Id', 'tenant-a') + .send({ type: 'rectangle', x: 0, y: 0, width: 10, height: 10 }); + + await request(app) + .post('/api/elements') + .set('X-Tenant-Id', 'tenant-b') + .send({ type: 'ellipse', x: 0, y: 0, width: 10, height: 10 }); + + const resA = await request(app) + .get('/api/elements') + .set('X-Tenant-Id', 'tenant-a'); + expect(resA.body.count).toBe(1); + expect(resA.body.elements[0].type).toBe('rectangle'); + + const resB = await request(app) + .get('/api/elements') + .set('X-Tenant-Id', 'tenant-b'); + expect(resB.body.count).toBe(1); + expect(resB.body.elements[0].type).toBe('ellipse'); + }); +}); diff --git a/tests/backend/db.test.ts b/tests/backend/db.test.ts new file mode 100644 index 0000000..9e2385f --- /dev/null +++ b/tests/backend/db.test.ts @@ -0,0 +1,426 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + initDb, + closeDb, + setElement, + getElement, + hasElement, + deleteElement, + getAllElements, + getElementCount, + clearElements, + queryElements, + searchElements, + getElementHistory, + getProjectHistory, + saveSnapshot, + getSnapshot, + listSnapshots, + ensureTenant, + setActiveTenant, + getActiveTenant, + getActiveTenantId, + listTenants, + createProject, + listProjects, + setActiveProject, + getActiveProject, + getActiveProjectId, + getDefaultProjectForTenant, + bulkReplaceElements, + getSetting, + setSetting, +} from '../../src/db.js'; +import type { ServerElement } from '../../src/types.js'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +let dbPath: string; + +function makeElement(overrides: Partial = {}): ServerElement { + return { + id: `el-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + type: 'rectangle', + x: 100, + y: 200, + width: 150, + height: 80, + version: 1, + ...overrides, + }; +} + +beforeEach(() => { + dbPath = path.join(os.tmpdir(), `excalidraw-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + initDb(dbPath); + // Reset module-level active tenant/project to 'default' which initDb() always creates + setActiveTenant('default'); +}); + +afterEach(() => { + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbPath + suffix); } catch {} + } +}); + +// ─── Element CRUD ──────────────────────────────────────────── + +describe('Element CRUD', () => { + it('setElement + getElement round-trips correctly', () => { + const el = makeElement({ id: 'e1' }); + setElement('e1', el); + + const fetched = getElement('e1'); + expect(fetched).toBeDefined(); + expect(fetched!.id).toBe('e1'); + expect(fetched!.type).toBe('rectangle'); + expect(fetched!.x).toBe(100); + expect(fetched!.y).toBe(200); + }); + + it('hasElement returns true for existing, false for missing', () => { + expect(hasElement('missing')).toBe(false); + setElement('exists', makeElement({ id: 'exists' })); + expect(hasElement('exists')).toBe(true); + }); + + it('getElement returns undefined for non-existent id', () => { + expect(getElement('nope')).toBeUndefined(); + }); + + it('setElement updates an existing element and increments version', () => { + const el = makeElement({ id: 'e1' }); + setElement('e1', el); + + const updated = makeElement({ id: 'e1', x: 999 }); + setElement('e1', updated); + + const fetched = getElement('e1'); + expect(fetched!.x).toBe(999); + }); + + it('deleteElement soft-deletes and returns true', () => { + setElement('del1', makeElement({ id: 'del1' })); + expect(deleteElement('del1')).toBe(true); + expect(getElement('del1')).toBeUndefined(); + expect(hasElement('del1')).toBe(false); + }); + + it('deleteElement returns false for non-existent id', () => { + expect(deleteElement('nope')).toBe(false); + }); + + it('deleted element can be re-created', () => { + setElement('recr', makeElement({ id: 'recr' })); + deleteElement('recr'); + expect(getElement('recr')).toBeUndefined(); + + setElement('recr', makeElement({ id: 'recr', x: 42 })); + expect(getElement('recr')!.x).toBe(42); + }); + + it('getAllElements returns all non-deleted elements', () => { + setElement('a', makeElement({ id: 'a' })); + setElement('b', makeElement({ id: 'b' })); + setElement('c', makeElement({ id: 'c' })); + deleteElement('b'); + + const all = getAllElements(); + expect(all.length).toBe(2); + expect(all.map(e => e.id).sort()).toEqual(['a', 'c']); + }); + + it('getElementCount returns correct count', () => { + expect(getElementCount()).toBe(0); + setElement('x', makeElement({ id: 'x' })); + setElement('y', makeElement({ id: 'y' })); + expect(getElementCount()).toBe(2); + deleteElement('x'); + expect(getElementCount()).toBe(1); + }); + + it('clearElements soft-deletes all and returns count', () => { + setElement('a', makeElement({ id: 'a' })); + setElement('b', makeElement({ id: 'b' })); + setElement('c', makeElement({ id: 'c' })); + + const count = clearElements(); + expect(count).toBe(3); + expect(getAllElements()).toEqual([]); + expect(getElementCount()).toBe(0); + }); + + it('clearElements on empty canvas returns 0', () => { + expect(clearElements()).toBe(0); + }); +}); + +// ─── Query & Search ────────────────────────────────────────── + +describe('queryElements', () => { + it('filters by type', () => { + setElement('r1', makeElement({ id: 'r1', type: 'rectangle' })); + setElement('e1', makeElement({ id: 'e1', type: 'ellipse' })); + setElement('r2', makeElement({ id: 'r2', type: 'rectangle' })); + + const rects = queryElements('rectangle'); + expect(rects.length).toBe(2); + expect(rects.every(e => e.type === 'rectangle')).toBe(true); + }); + + it('filters by arbitrary property', () => { + setElement('a', makeElement({ id: 'a', x: 10, y: 20 })); + setElement('b', makeElement({ id: 'b', x: 10, y: 99 })); + + const results = queryElements(undefined, { x: 10 }); + expect(results.length).toBe(2); + }); + + it('returns all when no filters', () => { + setElement('a', makeElement({ id: 'a' })); + setElement('b', makeElement({ id: 'b' })); + expect(queryElements().length).toBe(2); + }); +}); + +describe('searchElements (FTS)', () => { + it('finds elements by label text', () => { + setElement('t1', makeElement({ id: 't1', type: 'text', label: { text: 'Hello World' } })); + setElement('t2', makeElement({ id: 't2', type: 'text', label: { text: 'Goodbye' } })); + + const results = searchElements('Hello'); + expect(results.length).toBe(1); + expect(results[0]!.id).toBe('t1'); + }); + + it('finds elements by type in FTS', () => { + setElement('r1', makeElement({ id: 'r1', type: 'rectangle' })); + setElement('e1', makeElement({ id: 'e1', type: 'ellipse' })); + + const results = searchElements('rectangle'); + expect(results.length).toBe(1); + expect(results[0]!.id).toBe('r1'); + }); +}); + +// ─── Version History ───────────────────────────────────────── + +describe('Version History', () => { + it('records create and update operations', () => { + setElement('v1', makeElement({ id: 'v1' })); + setElement('v1', makeElement({ id: 'v1', x: 999 })); + + const history = getElementHistory('v1'); + expect(history.length).toBe(2); + expect(history[0]!.operation).toBe('update'); + expect(history[1]!.operation).toBe('create'); + }); + + it('records delete operation', () => { + setElement('v2', makeElement({ id: 'v2' })); + deleteElement('v2'); + + const history = getElementHistory('v2'); + expect(history.length).toBe(2); + expect(history[0]!.operation).toBe('delete'); + expect(history[1]!.operation).toBe('create'); + }); + + it('getProjectHistory returns all operations across elements', () => { + setElement('a', makeElement({ id: 'a' })); + setElement('b', makeElement({ id: 'b' })); + deleteElement('a'); + + const history = getProjectHistory(); + expect(history.length).toBe(3); + }); + + it('respects limit parameter', () => { + setElement('a', makeElement({ id: 'a' })); + setElement('b', makeElement({ id: 'b' })); + setElement('c', makeElement({ id: 'c' })); + + const history = getProjectHistory(2); + expect(history.length).toBe(2); + }); +}); + +// ─── Snapshots ─────────────────────────────────────────────── + +describe('Snapshots', () => { + it('save and retrieve a snapshot', () => { + const elements = [makeElement({ id: 's1' }), makeElement({ id: 's2' })]; + saveSnapshot('snap1', elements); + + const snapshot = getSnapshot('snap1'); + expect(snapshot).toBeDefined(); + expect(snapshot!.name).toBe('snap1'); + expect(snapshot!.elements.length).toBe(2); + }); + + it('getSnapshot returns undefined for missing name', () => { + expect(getSnapshot('nonexistent')).toBeUndefined(); + }); + + it('listSnapshots returns all snapshots with counts', () => { + saveSnapshot('snap-a', [makeElement()]); + saveSnapshot('snap-b', [makeElement(), makeElement()]); + + const list = listSnapshots(); + expect(list.length).toBe(2); + const snapB = list.find(s => s.name === 'snap-b'); + expect(snapB!.elementCount).toBe(2); + }); + + it('saveSnapshot with same name overwrites', () => { + saveSnapshot('dup', [makeElement()]); + saveSnapshot('dup', [makeElement(), makeElement(), makeElement()]); + + const snapshot = getSnapshot('dup'); + expect(snapshot!.elements.length).toBe(3); + + const list = listSnapshots(); + expect(list.filter(s => s.name === 'dup').length).toBe(1); + }); +}); + +// ─── Tenants ───────────────────────────────────────────────── + +describe('Tenants', () => { + it('default tenant exists after initDb', () => { + const tenant = getActiveTenant(); + expect(tenant).toBeDefined(); + expect(tenant.id).toBe('default'); + }); + + it('ensureTenant creates a new tenant', () => { + const t = ensureTenant('t1', 'Test Tenant', '/workspace/test'); + expect(t.id).toBe('t1'); + expect(t.name).toBe('Test Tenant'); + expect(t.workspace_path).toBe('/workspace/test'); + }); + + it('ensureTenant is idempotent', () => { + ensureTenant('t1', 'Test', '/path'); + const t2 = ensureTenant('t1', 'Test', '/path'); + expect(t2.id).toBe('t1'); + + const tenants = listTenants(); + expect(tenants.filter(t => t.id === 't1').length).toBe(1); + }); + + it('setActiveTenant switches the active tenant', () => { + ensureTenant('t2', 'Tenant 2', '/t2'); + setActiveTenant('t2'); + expect(getActiveTenantId()).toBe('t2'); + }); + + it('setActiveTenant throws for non-existent tenant', () => { + expect(() => setActiveTenant('no-such')).toThrow(); + }); + + it('listTenants returns all tenants', () => { + ensureTenant('a', 'A', '/a'); + ensureTenant('b', 'B', '/b'); + + const tenants = listTenants(); + expect(tenants.length).toBeGreaterThanOrEqual(3); // default + a + b + }); +}); + +// ─── Projects ──────────────────────────────────────────────── + +describe('Projects', () => { + it('default project exists', () => { + const project = getActiveProject(); + expect(project).toBeDefined(); + expect(project.id).toBe('default'); + }); + + it('createProject creates and can be listed', () => { + const proj = createProject('My Project', 'A test project'); + expect(proj.name).toBe('My Project'); + + const projects = listProjects(); + expect(projects.some(p => p.name === 'My Project')).toBe(true); + }); + + it('setActiveProject changes the active project', () => { + const proj = createProject('Switch Me'); + setActiveProject(proj.id); + expect(getActiveProjectId()).toBe(proj.id); + }); + + it('setActiveProject throws for non-existent project', () => { + expect(() => setActiveProject('fake')).toThrow(); + }); + + it('elements are scoped to the active project', () => { + const proj1 = createProject('P1'); + const proj2 = createProject('P2'); + + setActiveProject(proj1.id); + setElement('e1', makeElement({ id: 'e1' })); + + setActiveProject(proj2.id); + setElement('e2', makeElement({ id: 'e2' })); + + setActiveProject(proj1.id); + expect(getAllElements().length).toBe(1); + expect(getAllElements()[0]!.id).toBe('e1'); + + setActiveProject(proj2.id); + expect(getAllElements().length).toBe(1); + expect(getAllElements()[0]!.id).toBe('e2'); + }); + + it('getDefaultProjectForTenant creates a default project if none exists', () => { + ensureTenant('orphan', 'Orphan', '/orphan'); + const projId = getDefaultProjectForTenant('orphan'); + expect(projId).toBe('orphan-default'); + }); +}); + +// ─── Settings ──────────────────────────────────────────────── + +describe('Settings', () => { + it('getSetting returns undefined for missing key', () => { + expect(getSetting('nonexistent')).toBeUndefined(); + }); + + it('setSetting + getSetting round-trips', () => { + setSetting('theme', 'dark'); + expect(getSetting('theme')).toBe('dark'); + }); + + it('setSetting overwrites existing value', () => { + setSetting('key', 'val1'); + setSetting('key', 'val2'); + expect(getSetting('key')).toBe('val2'); + }); +}); + +// ─── Bulk Operations ───────────────────────────────────────── + +describe('bulkReplaceElements', () => { + it('replaces all elements atomically', () => { + setElement('old1', makeElement({ id: 'old1' })); + setElement('old2', makeElement({ id: 'old2' })); + + const newElements = [makeElement({ id: 'new1' }), makeElement({ id: 'new2' }), makeElement({ id: 'new3' })]; + const count = bulkReplaceElements(newElements); + expect(count).toBe(3); + + const all = getAllElements(); + expect(all.length).toBe(3); + expect(all.map(e => e.id).sort()).toEqual(['new1', 'new2', 'new3']); + }); + + it('replaces with empty array clears all', () => { + setElement('x', makeElement({ id: 'x' })); + bulkReplaceElements([]); + expect(getAllElements()).toEqual([]); + }); +}); diff --git a/tests/backend/ws.test.ts b/tests/backend/ws.test.ts new file mode 100644 index 0000000..1449747 --- /dev/null +++ b/tests/backend/ws.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { initDb, closeDb, setElement, clearElements } from '../../src/db.js'; +import type { ServerElement } from '../../src/types.js'; +import WebSocket from 'ws'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +let dbPath: string; +let port: number; +let startCanvasServer: () => Promise; +let stopCanvasServer: () => Promise; + +/** + * Connect a WS client and immediately start buffering all messages. + * Returns the ws handle + a collected messages array. + */ +function connectAndCollect(): Promise<{ ws: WebSocket; messages: any[] }> { + return new Promise((resolve, reject) => { + const messages: any[] = []; + const ws = new WebSocket(`ws://localhost:${port}`); + ws.on('message', (raw) => messages.push(JSON.parse(raw.toString()))); + ws.on('open', () => { + // Give the server a moment to push initial messages + setTimeout(() => resolve({ ws, messages }), 300); + }); + ws.on('error', reject); + }); +} + +function waitForMessageOfType(ws: WebSocket, type: string, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timeout waiting for message type: ${type}`)), timeoutMs); + const handler = (data: WebSocket.RawData) => { + const msg = JSON.parse(data.toString()); + if (msg.type === type) { + clearTimeout(timer); + ws.off('message', handler); + resolve(msg); + } + }; + ws.on('message', handler); + }); +} + +function connectClient(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://localhost:${port}`); + ws.on('open', () => resolve(ws)); + ws.on('error', reject); + }); +} + +function drainInitialMessages(ws: WebSocket): Promise { + return new Promise((resolve) => { + let count = 0; + const handler = () => { + count++; + if (count >= 3) { + ws.off('message', handler); + resolve(); + } + }; + ws.on('message', handler); + setTimeout(() => { + ws.off('message', handler); + resolve(); + }, 1000); + }); +} + +beforeAll(async () => { + port = 3200 + Math.floor(Math.random() * 100); + process.env.CANVAS_PORT = String(port); + process.env.HOST = 'localhost'; + + dbPath = path.join(os.tmpdir(), `excalidraw-ws-test-${Date.now()}.db`); + initDb(dbPath); + + const mod = await import('../../src/server.js'); + startCanvasServer = mod.startCanvasServer; + stopCanvasServer = mod.stopCanvasServer; + await startCanvasServer(); +}); + +afterAll(async () => { + await stopCanvasServer(); + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbPath + suffix); } catch {} + } +}); + +beforeEach(() => { + clearElements(); +}); + +describe('WebSocket connection', () => { + it('connects and receives tenant_switched, initial_elements, sync_status', async () => { + const { ws, messages } = await connectAndCollect(); + + const types = messages.map(m => m.type); + expect(types).toContain('tenant_switched'); + expect(types).toContain('initial_elements'); + expect(types).toContain('sync_status'); + + const initMsg = messages.find(m => m.type === 'initial_elements'); + expect(Array.isArray(initMsg.elements)).toBe(true); + + const syncMsg = messages.find(m => m.type === 'sync_status'); + expect(syncMsg).toHaveProperty('elementCount'); + + ws.close(); + }); + + it('receives initial_elements with existing data', async () => { + setElement('init-el', { + id: 'init-el', type: 'rectangle', x: 10, y: 20, width: 100, height: 50, version: 1, + } as ServerElement); + + const { ws, messages } = await connectAndCollect(); + + const initMsg = messages.find(m => m.type === 'initial_elements'); + expect(initMsg).toBeDefined(); + expect(initMsg.elements.length).toBe(1); + expect(initMsg.elements[0].id).toBe('init-el'); + + ws.close(); + }); +}); + +describe('WebSocket broadcasts', () => { + it('broadcasts element_created on POST /api/elements', async () => { + const ws = await connectClient(); + await drainInitialMessages(ws); + + const createdPromise = waitForMessageOfType(ws, 'element_created'); + + await fetch(`http://localhost:${port}/api/elements`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }), + }); + + const msg = await createdPromise; + expect(msg.element.type).toBe('rectangle'); + + ws.close(); + }); + + it('broadcasts element_deleted on DELETE /api/elements/:id', async () => { + setElement('del-ws', { + id: 'del-ws', type: 'ellipse', x: 0, y: 0, width: 30, height: 30, version: 1, + } as ServerElement); + + const ws = await connectClient(); + await drainInitialMessages(ws); + + const deletedPromise = waitForMessageOfType(ws, 'element_deleted'); + + await fetch(`http://localhost:${port}/api/elements/del-ws`, { method: 'DELETE' }); + + const msg = await deletedPromise; + expect(msg.elementId).toBe('del-ws'); + + ws.close(); + }); + + it('broadcasts element_updated on PUT /api/elements/:id', async () => { + setElement('upd-ws', { + id: 'upd-ws', type: 'rectangle', x: 0, y: 0, width: 50, height: 50, version: 1, + } as ServerElement); + + const ws = await connectClient(); + await drainInitialMessages(ws); + + const updatedPromise = waitForMessageOfType(ws, 'element_updated'); + + await fetch(`http://localhost:${port}/api/elements/upd-ws`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ x: 999 }), + }); + + const msg = await updatedPromise; + expect(msg.element.x).toBe(999); + + ws.close(); + }); + + it('broadcasts canvas_cleared on DELETE /api/elements/clear', async () => { + setElement('clr1', { + id: 'clr1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10, version: 1, + } as ServerElement); + + const ws = await connectClient(); + await drainInitialMessages(ws); + + const clearedPromise = waitForMessageOfType(ws, 'canvas_cleared'); + + await fetch(`http://localhost:${port}/api/elements/clear`, { method: 'DELETE' }); + + const msg = await clearedPromise; + expect(msg.type).toBe('canvas_cleared'); + expect(msg).toHaveProperty('timestamp'); + + ws.close(); + }); + + it('broadcasts elements_batch_created on POST /api/elements/batch', async () => { + const ws = await connectClient(); + await drainInitialMessages(ws); + + const batchPromise = waitForMessageOfType(ws, 'elements_batch_created'); + + await fetch(`http://localhost:${port}/api/elements/batch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + elements: [ + { type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }, + { type: 'ellipse', x: 100, y: 100, width: 40, height: 40 }, + ], + }), + }); + + const msg = await batchPromise; + expect(msg.elements.length).toBe(2); + + ws.close(); + }); + + it('broadcasts to multiple connected clients', async () => { + const ws1 = await connectClient(); + const ws2 = await connectClient(); + await drainInitialMessages(ws1); + await drainInitialMessages(ws2); + + const promise1 = waitForMessageOfType(ws1, 'element_created'); + const promise2 = waitForMessageOfType(ws2, 'element_created'); + + await fetch(`http://localhost:${port}/api/elements`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'diamond', x: 0, y: 0, width: 60, height: 60 }), + }); + + const [msg1, msg2] = await Promise.all([promise1, promise2]); + expect(msg1.element.type).toBe('diamond'); + expect(msg2.element.type).toBe('diamond'); + + ws1.close(); + ws2.close(); + }); +}); diff --git a/tests/e2e/canvas.spec.ts b/tests/e2e/canvas.spec.ts new file mode 100644 index 0000000..8d6bb4c --- /dev/null +++ b/tests/e2e/canvas.spec.ts @@ -0,0 +1,273 @@ +import { test, expect } from '@playwright/test'; + +const API = 'http://localhost:3100'; + +test.beforeEach(async ({ request }) => { + await request.delete(`${API}/api/elements/clear`); +}); + +// ─── Page Load ─────────────────────────────────────────────── + +test.describe('Page Load', () => { + test('canvas page loads successfully', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('.header h1')).toContainText('Excalidraw Canvas'); + }); + + test('shows connected status after WebSocket connects', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 }); + }); + + test('has Clear Canvas button', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('button:has-text("Clear Canvas")')).toBeVisible(); + }); + + test('has Sync button', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('button:has-text("Sync")')).toBeVisible(); + }); +}); + +// ─── Health Endpoint ───────────────────────────────────────── + +test.describe('Health Endpoint', () => { + test('returns healthy status', async ({ request }) => { + const res = await request.get(`${API}/health`); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.status).toBe('healthy'); + }); +}); + +// ─── API Element CRUD via Playwright request ───────────────── + +test.describe('Element CRUD via API', () => { + test('create and list elements', async ({ request }) => { + const createRes = await request.post(`${API}/api/elements`, { + data: { + id: 'e2e-rect-1', + type: 'rectangle', + x: 100, + y: 100, + width: 200, + height: 100, + backgroundColor: '#ff6b6b', + }, + }); + expect(createRes.ok()).toBe(true); + + const listRes = await request.get(`${API}/api/elements`); + const listBody = await listRes.json(); + expect(listBody.count).toBe(1); + expect(listBody.elements[0].id).toBe('e2e-rect-1'); + }); + + test('batch create elements', async ({ request }) => { + const batchRes = await request.post(`${API}/api/elements/batch`, { + data: { + elements: [ + { id: 'batch-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 }, + { id: 'batch-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 }, + { id: 'batch-3', type: 'text', x: 50, y: 100, text: 'E2E Test' }, + ], + }, + }); + expect(batchRes.ok()).toBe(true); + + const listRes = await request.get(`${API}/api/elements`); + const listBody = await listRes.json(); + expect(listBody.count).toBe(3); + }); + + test('delete element', async ({ request }) => { + await request.post(`${API}/api/elements`, { + data: { id: 'del-e2e', type: 'rectangle', x: 50, y: 50, width: 100, height: 100 }, + }); + + const delRes = await request.delete(`${API}/api/elements/del-e2e`); + expect(delRes.ok()).toBe(true); + + const checkRes = await request.get(`${API}/api/elements/del-e2e`); + expect(checkRes.status()).toBe(404); + }); + + test('clear all elements', async ({ request }) => { + await request.post(`${API}/api/elements/batch`, { + data: { + elements: [ + { type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }, + { type: 'ellipse', x: 100, y: 100, width: 40, height: 40 }, + ], + }, + }); + + const clearRes = await request.delete(`${API}/api/elements/clear`); + expect(clearRes.ok()).toBe(true); + const clearBody = await clearRes.json(); + expect(clearBody.count).toBe(2); + + const listRes = await request.get(`${API}/api/elements`); + const listBody = await listRes.json(); + expect(listBody.count).toBe(0); + }); +}); + +// ─── Real-time Sync ────────────────────────────────────────── + +test.describe('Real-time Canvas Sync', () => { + test('element created via API appears in canvas', async ({ page, request }) => { + await page.goto('/'); + await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 }); + await page.waitForTimeout(500); + + await request.post(`${API}/api/elements`, { + data: { + id: 'sync-rect', + type: 'rectangle', + x: 100, + y: 100, + width: 200, + height: 100, + }, + }); + + await page.waitForTimeout(1000); + + // Verify element exists in backend + const verifyRes = await request.get(`${API}/api/elements/sync-rect`); + expect(verifyRes.ok()).toBe(true); + }); + + test('canvas_cleared broadcast clears the canvas', async ({ page, request }) => { + await page.goto('/'); + await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 }); + await page.waitForTimeout(500); + + await request.post(`${API}/api/elements`, { + data: { id: 'clear-test', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }, + }); + await page.waitForTimeout(300); + + await request.delete(`${API}/api/elements/clear`); + await page.waitForTimeout(500); + + const listRes = await request.get(`${API}/api/elements`); + const body = await listRes.json(); + expect(body.count).toBe(0); + }); +}); + +// ─── Clear Canvas UI Confirmation ──────────────────────────── + +test.describe('Clear Canvas UI Confirmation', () => { + test('Clear Canvas button shows confirmation dialog', async ({ page, request }) => { + // Reset the skip-confirm preference + await request.put(`${API}/api/settings/clear_canvas_skip_confirm`, { + data: { value: 'false' }, + }); + + await page.goto('/'); + await page.waitForTimeout(500); + + await page.locator('button:has-text("Clear Canvas")').click(); + + const dialog = page.locator('.confirm-dialog'); + await expect(dialog).toBeVisible({ timeout: 2000 }); + await expect(dialog.locator('.confirm-title')).toContainText('Clear Canvas'); + }); + + test('Cancel closes the confirmation dialog', async ({ page, request }) => { + await request.put(`${API}/api/settings/clear_canvas_skip_confirm`, { + data: { value: 'false' }, + }); + + await page.goto('/'); + await page.waitForTimeout(500); + + await page.locator('button:has-text("Clear Canvas")').click(); + await expect(page.locator('.confirm-dialog')).toBeVisible(); + + await page.locator('.confirm-dialog button:has-text("Cancel")').click(); + await expect(page.locator('.confirm-dialog')).not.toBeVisible(); + }); + + test('Confirm button clears the canvas', async ({ page, request }) => { + await request.put(`${API}/api/settings/clear_canvas_skip_confirm`, { + data: { value: 'false' }, + }); + + await request.post(`${API}/api/elements`, { + data: { type: 'rectangle', x: 0, y: 0, width: 100, height: 50 }, + }); + + await page.goto('/'); + await page.waitForTimeout(500); + + await page.locator('button:has-text("Clear Canvas")').click(); + await expect(page.locator('.confirm-dialog')).toBeVisible(); + + await page.locator('.confirm-dialog button:has-text("Clear")').click(); + await expect(page.locator('.confirm-dialog')).not.toBeVisible(); + + await page.waitForTimeout(1000); + + const listRes = await request.get(`${API}/api/elements`); + const body = await listRes.json(); + expect(body.count).toBe(0); + }); +}); + +// ─── Snapshots ─────────────────────────────────────────────── + +test.describe('Snapshots via API', () => { + test('create and list snapshots', async ({ request }) => { + await request.post(`${API}/api/elements`, { + data: { type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }, + }); + + const snapRes = await request.post(`${API}/api/snapshots`, { + data: { name: 'e2e-snapshot' }, + }); + expect(snapRes.ok()).toBe(true); + + const listRes = await request.get(`${API}/api/snapshots`); + const listBody = await listRes.json(); + expect(listBody.snapshots.some((s: any) => s.name === 'e2e-snapshot')).toBe(true); + }); + + test('get snapshot by name', async ({ request }) => { + await request.post(`${API}/api/elements`, { + data: { type: 'ellipse', x: 10, y: 10, width: 30, height: 30 }, + }); + await request.post(`${API}/api/snapshots`, { + data: { name: 'get-snap' }, + }); + + const res = await request.get(`${API}/api/snapshots/get-snap`); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.snapshot.name).toBe('get-snap'); + }); +}); + +// ─── Settings via API ──────────────────────────────────────── + +test.describe('Settings via API', () => { + test('set and get a setting', async ({ request }) => { + await request.put(`${API}/api/settings/e2e_key`, { + data: { value: 'e2e_value' }, + }); + + const res = await request.get(`${API}/api/settings/e2e_key`); + const body = await res.json(); + expect(body.value).toBe('e2e_value'); + }); + + test('returns null for missing key', async ({ request }) => { + const res = await request.get(`${API}/api/settings/nonexistent_key`); + const body = await res.json(); + expect(body.value).toBeNull(); + }); +}); diff --git a/tests/e2e/start-server.js b/tests/e2e/start-server.js new file mode 100644 index 0000000..1ee0ba1 --- /dev/null +++ b/tests/e2e/start-server.js @@ -0,0 +1,6 @@ +import { initDb } from '../../dist/db.js'; +import { startCanvasServer } from '../../dist/server.js'; + +const dbPath = process.env.EXCALIDRAW_DB_PATH || '/tmp/excalidraw-e2e-test.db'; +initDb(dbPath); +await startCanvasServer(); diff --git a/tests/frontend/helpers.test.ts b/tests/frontend/helpers.test.ts new file mode 100644 index 0000000..1e439df --- /dev/null +++ b/tests/frontend/helpers.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from 'vitest'; +import { + cleanElementForExcalidraw, + validateAndFixBindings, + computeElementHash, +} from '../../frontend/src/utils/elementHelpers.js'; +import type { ServerElement } from '../../frontend/src/utils/elementHelpers.js'; + +// ─── cleanElementForExcalidraw ─────────────────────────────── + +describe('cleanElementForExcalidraw', () => { + it('strips server metadata fields', () => { + const element: ServerElement = { + id: 'el1', + type: 'rectangle', + x: 10, + y: 20, + width: 100, + height: 50, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + version: 3, + syncedAt: '2024-01-02T01:00:00Z', + source: 'mcp', + syncTimestamp: '2024-01-02T01:00:00Z', + }; + + const cleaned = cleanElementForExcalidraw(element); + + expect(cleaned).not.toHaveProperty('createdAt'); + expect(cleaned).not.toHaveProperty('updatedAt'); + expect(cleaned).not.toHaveProperty('version'); + expect(cleaned).not.toHaveProperty('syncedAt'); + expect(cleaned).not.toHaveProperty('source'); + expect(cleaned).not.toHaveProperty('syncTimestamp'); + }); + + it('preserves core element properties', () => { + const element: ServerElement = { + id: 'el2', + type: 'ellipse', + x: 50, + y: 100, + width: 80, + height: 80, + backgroundColor: '#ff0000', + strokeColor: '#000000', + strokeWidth: 2, + opacity: 0.8, + version: 1, + }; + + const cleaned = cleanElementForExcalidraw(element); + + expect(cleaned.id).toBe('el2'); + expect(cleaned.type).toBe('ellipse'); + expect(cleaned.x).toBe(50); + expect(cleaned.y).toBe(100); + expect((cleaned as any).backgroundColor).toBe('#ff0000'); + expect((cleaned as any).strokeColor).toBe('#000000'); + expect((cleaned as any).opacity).toBe(0.8); + }); + + it('preserves label and text fields', () => { + const element: ServerElement = { + id: 'txt1', + type: 'text', + x: 0, + y: 0, + text: 'Hello', + fontSize: 20, + label: { text: 'Label' }, + version: 1, + }; + + const cleaned = cleanElementForExcalidraw(element); + expect((cleaned as any).text).toBe('Hello'); + expect((cleaned as any).label).toEqual({ text: 'Label' }); + expect((cleaned as any).fontSize).toBe(20); + }); + + it('preserves arrow binding references', () => { + const element: ServerElement = { + id: 'arrow1', + type: 'arrow', + x: 0, + y: 0, + start: { id: 'box1' }, + end: { id: 'box2' }, + version: 1, + }; + + const cleaned = cleanElementForExcalidraw(element); + expect((cleaned as any).start).toEqual({ id: 'box1' }); + expect((cleaned as any).end).toEqual({ id: 'box2' }); + }); +}); + +// ─── validateAndFixBindings ────────────────────────────────── + +describe('validateAndFixBindings', () => { + it('keeps valid boundElements references', () => { + const elements = [ + { id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'arrow1', type: 'arrow' }] }, + { id: 'arrow1', type: 'arrow', x: 0, y: 0 }, + ] as any[]; + + const result = validateAndFixBindings(elements); + expect(result[0]!.boundElements).toEqual([{ id: 'arrow1', type: 'arrow' }]); + }); + + it('removes boundElements referencing non-existent elements', () => { + const elements = [ + { id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'missing', type: 'arrow' }] }, + ] as any[]; + + const result = validateAndFixBindings(elements); + expect(result[0]!.boundElements).toBeNull(); + }); + + it('removes invalid binding objects', () => { + const elements = [ + { id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [null, undefined, 'invalid', { id: 'a' }] }, + ] as any[]; + + const result = validateAndFixBindings(elements); + expect(result[0]!.boundElements).toBeNull(); + }); + + it('removes invalid binding types', () => { + const elements = [ + { id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'other', type: 'invalid' }] }, + { id: 'other', type: 'rectangle', x: 0, y: 0 }, + ] as any[]; + + const result = validateAndFixBindings(elements); + expect(result[0]!.boundElements).toBeNull(); + }); + + it('sets non-array boundElements to null', () => { + const elements = [ + { id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: 'not-an-array' }, + ] as any[]; + + const result = validateAndFixBindings(elements); + expect(result[0]!.boundElements).toBeNull(); + }); + + it('nullifies containerId when container does not exist', () => { + const elements = [ + { id: 'text1', type: 'text', x: 0, y: 0, containerId: 'missing-container' }, + ] as any[]; + + const result = validateAndFixBindings(elements); + expect(result[0]!.containerId).toBeNull(); + }); + + it('keeps valid containerId', () => { + const elements = [ + { id: 'container', type: 'rectangle', x: 0, y: 0 }, + { id: 'text1', type: 'text', x: 0, y: 0, containerId: 'container' }, + ] as any[]; + + const result = validateAndFixBindings(elements); + expect(result[1]!.containerId).toBe('container'); + }); + + it('handles empty array input', () => { + expect(validateAndFixBindings([])).toEqual([]); + }); + + it('handles elements with no bindings', () => { + const elements = [ + { id: 'simple', type: 'rectangle', x: 0, y: 0 }, + ] as any[]; + + const result = validateAndFixBindings(elements); + expect(result[0]!.id).toBe('simple'); + }); +}); + +// ─── computeElementHash ────────────────────────────────────── + +describe('computeElementHash', () => { + it('produces consistent hash for same elements', () => { + const elements = [ + { id: 'a', version: 1 }, + { id: 'b', version: 2 }, + ]; + + const hash1 = computeElementHash(elements); + const hash2 = computeElementHash(elements); + expect(hash1).toBe(hash2); + }); + + it('produces different hash when element version changes', () => { + const v1 = [{ id: 'a', version: 1 }]; + const v2 = [{ id: 'a', version: 2 }]; + + expect(computeElementHash(v1)).not.toBe(computeElementHash(v2)); + }); + + it('produces different hash when element is added', () => { + const one = [{ id: 'a', version: 1 }]; + const two = [{ id: 'a', version: 1 }, { id: 'b', version: 1 }]; + + expect(computeElementHash(one)).not.toBe(computeElementHash(two)); + }); + + it('handles empty array', () => { + expect(computeElementHash([])).toBe('0'); + }); + + it('includes element count in hash', () => { + const hash = computeElementHash([{ id: 'x', version: 1 }]); + expect(hash.startsWith('1')).toBe(true); + }); +}); diff --git a/vite.config.js b/vite.config.js index 4e24f2b..72e6ca0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,6 +7,18 @@ export default defineConfig({ build: { outDir: '../dist/frontend', emptyOutDir: true, + rollupOptions: { + output: { + manualChunks(id) { + if ( + id.includes('@excalidraw/excalidraw') && + id.includes('subset') + ) { + return id.split('/').pop()?.replace(/\.[^.]+$/, ''); + } + }, + }, + }, }, server: { port: 5173, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..eca9b04 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['tests/backend/**/*.test.ts', 'tests/frontend/**/*.test.ts'], + exclude: ['tests/e2e/**'], + testTimeout: 15000, + hookTimeout: 15000, + pool: 'forks', + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/setup.ts', 'src/index.ts'], + }, + }, + resolve: { + alias: { + // Vitest resolves .js imports to .ts source files + }, + }, +});