diff --git a/.agents/skills/stitch-sdk-usage/SKILL.md b/.agents/skills/stitch-sdk-usage/SKILL.md index 8fa3b4a..359ca4d 100644 --- a/.agents/skills/stitch-sdk-usage/SKILL.md +++ b/.agents/skills/stitch-sdk-usage/SKILL.md @@ -68,6 +68,38 @@ const screens = await ds.apply([ ]); ``` +## Uploading Images + +Upload an existing image file (PNG, JPG, JPEG, WEBP) to create a screen directly from a mockup or asset. + +```typescript +import { stitch } from '@google/stitch-sdk'; + +const project = stitch.project("your-project-id"); + +// Upload a local image file +const [screen] = await project.uploadImage('./mockup.png', { + title: 'Home Screen', +}); + +console.log(screen.id); +const html = await screen.getHtml(); +const imageUrl = await screen.getImage(); +``` + +The method reads the file from disk and posts it directly to the Stitch REST API — no output token constraints apply (unlike agent-driven MCP calls). + +**Supported formats:** `.png`, `.jpg`, `.jpeg`, `.webp` + +**Options:** +| Option | Type | Default | Description | +|---|---|---|---| +| `title` | `string` | — | Display title for the created screen | +| `createScreenInstances` | `boolean` | `true` | Whether to add the screen to the canvas | + +**Throws** `StitchError` with codes: `NOT_FOUND` (file not found), `UNKNOWN_ERROR` (unsupported format or upload failure), `AUTH_FAILED` (invalid API key). + + ## Generating and Iterating on Screens ```typescript @@ -150,12 +182,15 @@ Error codes: `AUTH_FAILED`, `NOT_FOUND`, `PERMISSION_DENIED`, `RATE_LIMITED`, `N | `generate(prompt, deviceType?)` | `Promise` | Generate a screen from a text prompt | | `screens()` | `Promise` | List all screens in the project | | `getScreen(screenId)` | `Promise` | Retrieve a specific screen by ID | +| `uploadImage(filePath, opts?)` | `Promise` | Upload an image file and create a screen from it | | `createDesignSystem(designSystem)` | `Promise` | Create a design system for this project | | `listDesignSystems()` | `Promise` | List all design systems | | `designSystem(id)` | `DesignSystem` | Reference by ID (no API call) | `deviceType`: `"MOBILE"` | `"DESKTOP"` | `"TABLET"` | `"AGNOSTIC"` +`uploadImage` supported formats: `.png` `.jpg` `.jpeg` `.webp` + ### DesignSystem Class | Method | Returns | Description | diff --git a/.github/workflows/fleet-analyze.yml b/.github/workflows/fleet-analyze.yml deleted file mode 100644 index c12c44c..0000000 --- a/.github/workflows/fleet-analyze.yml +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by @google/jules-fleet init -# https://github.com/google-labs-code/jules-sdk - -name: Fleet Analyze - -on: - schedule: - - cron: '0 */6 * * *' - workflow_dispatch: - inputs: - goal: - description: 'Path to goal file (or blank for all)' - type: string - default: '' - milestone: - description: 'Milestone ID override' - type: string - default: '' - -concurrency: - group: fleet-analyze - cancel-in-progress: false - -jobs: - analyze: - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - pull-requests: read - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - - name: Decode private key - id: decode-key - run: | - printf '%s' "${{ secrets.FLEET_APP_PRIVATE_KEY }}" | base64 -d > /tmp/fleet-app-key.pem - { - echo "pem<> "$GITHUB_OUTPUT" - rm /tmp/fleet-app-key.pem - - name: Generate Fleet App token - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.FLEET_APP_ID }} - private-key: ${{ steps.decode-key.outputs.pem }} - - run: npx -y --package=@google/jules-fleet jules-fleet analyze --goal "${{ inputs.goal }}" --milestone "${{ inputs.milestone }}" - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - JULES_API_KEY: ${{ secrets.JULES_API_KEY }} - FLEET_APP_ID: ${{ secrets.FLEET_APP_ID }} - FLEET_APP_PRIVATE_KEY: ${{ secrets.FLEET_APP_PRIVATE_KEY }} - FLEET_APP_INSTALLATION_ID: ${{ secrets.FLEET_APP_INSTALLATION_ID }} diff --git a/.github/workflows/fleet-dispatch.yml b/.github/workflows/fleet-dispatch.yml deleted file mode 100644 index a8b14c4..0000000 --- a/.github/workflows/fleet-dispatch.yml +++ /dev/null @@ -1,75 +0,0 @@ -# Generated by @google/jules-fleet init -# https://github.com/google-labs-code/jules-sdk - -name: Fleet Dispatch - -on: - schedule: - - cron: '0 3-23/6 * * *' - workflow_dispatch: - inputs: - milestone: - description: 'Milestone ID to dispatch (leave empty to dispatch all)' - type: string - required: false - -concurrency: - group: fleet-dispatch - cancel-in-progress: false - -jobs: - discover: - runs-on: ubuntu-latest - outputs: - milestones: ${{ steps.list.outputs.milestones }} - steps: - - name: Resolve milestones - id: list - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - INPUT_MILESTONE: ${{ inputs.milestone }} - run: | - if [ -n "$INPUT_MILESTONE" ]; then - echo "milestones=[\"$INPUT_MILESTONE\"]" >> "$GITHUB_OUTPUT" - else - milestones=$(gh api repos/${{ github.repository }}/milestones --jq '[.[].number | tostring]') - echo "milestones=$milestones" >> "$GITHUB_OUTPUT" - fi - - dispatch: - needs: discover - runs-on: ubuntu-latest - strategy: - matrix: - milestone: ${{ fromJSON(needs.discover.outputs.milestones) }} - permissions: - contents: read - issues: write - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - - name: Decode private key - id: decode-key - run: | - printf '%s' "${{ secrets.FLEET_APP_PRIVATE_KEY }}" | base64 -d > /tmp/fleet-app-key.pem - { - echo "pem<> "$GITHUB_OUTPUT" - rm /tmp/fleet-app-key.pem - - name: Generate Fleet App token - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.FLEET_APP_ID }} - private-key: ${{ steps.decode-key.outputs.pem }} - - run: npx -y --package=@google/jules-fleet jules-fleet dispatch --milestone ${{ matrix.milestone }} - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - JULES_API_KEY: ${{ secrets.JULES_API_KEY }} - FLEET_APP_ID: ${{ secrets.FLEET_APP_ID }} - FLEET_APP_PRIVATE_KEY: ${{ secrets.FLEET_APP_PRIVATE_KEY }} - FLEET_APP_INSTALLATION_ID: ${{ secrets.FLEET_APP_INSTALLATION_ID }} diff --git a/.github/workflows/fleet-label.yml b/.github/workflows/fleet-label.yml deleted file mode 100644 index 6796972..0000000 --- a/.github/workflows/fleet-label.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Fleet Label PR -on: - pull_request: - types: [opened, edited, synchronize] - -permissions: - pull-requests: write - issues: read - -jobs: - label_pr: - runs-on: ubuntu-latest - steps: - - name: Check linked issue and apply label/milestone - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_URL: ${{ github.event.pull_request.html_url }} - REPO: ${{ github.repository }} - run: | - # Use GitHub's own closing keyword resolution to find linked issues - ISSUE_NUMBER=$(gh pr view "$PR_URL" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty') - - if [ -z "$ISSUE_NUMBER" ]; then - echo "No closing issue reference found on this PR. Exiting." - exit 0 - fi - - echo "Found linked issue: #$ISSUE_NUMBER" - - # Check if the linked issue has the 'fleet' label - HAS_FLEET=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json labels --jq '[.labels[].name] | any(. == "fleet")') - - if [ "$HAS_FLEET" = "true" ]; then - echo "Linked issue has 'fleet' label. Applying 'fleet-merge-ready' to PR." - gh pr edit "$PR_URL" --add-label "fleet-merge-ready" - - # Check if linked issue has a milestone and copy it - MILESTONE_TITLE=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json milestone --jq '.milestone.title // empty') - if [ -n "$MILESTONE_TITLE" ]; then - echo "Applying milestone '$MILESTONE_TITLE' to PR." - gh pr edit "$PR_URL" --milestone "$MILESTONE_TITLE" - fi - else - echo "Linked issue does not have 'fleet' label. Ignoring." - fi diff --git a/.github/workflows/fleet-merge.yml b/.github/workflows/fleet-merge.yml deleted file mode 100644 index 4db5020..0000000 --- a/.github/workflows/fleet-merge.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Generated by @google/jules-fleet init -# https://github.com/google-labs-code/jules-sdk - -name: Fleet Merge - -on: - schedule: - - cron: '0 */3 * * *' - workflow_dispatch: - inputs: - mode: - description: 'PR selection mode' - type: choice - options: - - label - - fleet-run - default: 'label' - fleet_run_id: - description: 'Fleet run ID (required for fleet-run mode)' - type: string - default: '' - redispatch: - description: 'Enable smart conflict resolution' - type: boolean - default: true - -concurrency: - group: fleet-merge - cancel-in-progress: true - -jobs: - merge: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - issues: write - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - - name: Decode private key - id: decode-key - run: | - printf '%s' "${{ secrets.FLEET_APP_PRIVATE_KEY }}" | base64 -d > /tmp/fleet-app-key.pem - { - echo "pem<> "$GITHUB_OUTPUT" - rm /tmp/fleet-app-key.pem - - name: Generate Fleet App token - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.FLEET_APP_ID }} - private-key: ${{ steps.decode-key.outputs.pem }} - - run: | - REDISPATCH_FLAG="--redispatch" - if [ "${{ inputs.redispatch }}" = "false" ]; then - REDISPATCH_FLAG="" - fi - npx -y --package=@google/jules-fleet jules-fleet merge --mode ${{ inputs.mode || 'label' }} --run-id "${{ inputs.fleet_run_id }}" $REDISPATCH_FLAG - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - JULES_API_KEY: ${{ secrets.JULES_API_KEY }} - FLEET_APP_ID: ${{ secrets.FLEET_APP_ID }} - FLEET_APP_PRIVATE_KEY: ${{ secrets.FLEET_APP_PRIVATE_KEY }} - FLEET_APP_INSTALLATION_ID: ${{ secrets.FLEET_APP_INSTALLATION_ID }} diff --git a/.github/workflows/jules-merge-conflicts.yml b/.github/workflows/jules-merge-conflicts.yml deleted file mode 100644 index d72cef6..0000000 --- a/.github/workflows/jules-merge-conflicts.yml +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by @google/jules-fleet init -# This workflow checks for merge conflicts on pull requests. -name: Conflict Detection - -on: - pull_request: - branches: [main] - -permissions: - contents: read - pull-requests: read - -jobs: - check-conflicts: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Attempt merge - id: merge - continue-on-error: true - run: | - git config user.name "github-actions" - git config user.email "github-actions@github.com" - git fetch origin ${{ github.event.pull_request.base.ref }} - git merge origin/${{ github.event.pull_request.base.ref }} --no-commit --no-ff - - - name: Check for conflicts - if: steps.merge.outcome == 'failure' - run: | - npx -y --package=@google/jules-merge jules-merge check-conflicts --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }} --sha ${{ github.event.pull_request.head.sha }} - - - name: No conflicts - if: steps.merge.outcome == 'success' - run: echo "✅ No merge conflicts detected" diff --git a/bun.lock b/bun.lock index bb79df5..7cb6a5d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "stitch-root", @@ -17,15 +18,17 @@ }, "packages/sdk": { "name": "@google/stitch-sdk", - "version": "0.0.3", + "version": "0.1.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", + "cheerio": "^1.0.0-rc.12", "zod": "^4.3.5", }, "devDependencies": { "@swc/core": "^1.15.18", "@types/node": "^20.0.0", "ai": "^6.0.116", + "domhandler": "5.0.3", "typescript": "^5.5.0", "vitest": "^1.6.0", }, @@ -423,6 +426,8 @@ "body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "boxen": ["boxen@5.1.2", "", { "dependencies": { "ansi-align": "^3.0.0", "camelcase": "^6.2.0", "chalk": "^4.1.0", "cli-boxes": "^2.2.1", "string-width": "^4.2.2", "type-fest": "^0.20.2", "widest-line": "^3.1.0", "wrap-ansi": "^7.0.0" } }, "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -461,6 +466,10 @@ "check-error": ["check-error@1.0.3", "", { "dependencies": { "get-func-name": "^2.0.2" } }, "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg=="], + "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], @@ -547,6 +556,10 @@ "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csv-parse": ["csv-parse@5.6.0", "", {}, "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q=="], @@ -581,6 +594,14 @@ "discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -603,8 +624,12 @@ "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -767,6 +792,8 @@ "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -1037,6 +1064,8 @@ "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], @@ -1073,9 +1102,11 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], - "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -1385,6 +1416,8 @@ "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "undici": ["undici@7.24.7", "", {}, "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], @@ -1433,8 +1466,12 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1551,6 +1588,10 @@ "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "cli-highlight/parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], + + "cli-highlight/parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], + "cli-table3/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], @@ -1579,6 +1620,8 @@ "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], "execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -1615,6 +1658,8 @@ "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "ink/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -1661,7 +1706,7 @@ "ora/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], - "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1727,6 +1772,8 @@ "update-notifier-cjs/is-ci": ["is-ci@2.0.0", "", { "dependencies": { "ci-info": "^2.0.0" }, "bin": { "is-ci": "bin.js" } }, "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "winston/@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], "winston/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -1811,6 +1858,8 @@ "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cli-highlight/parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + "cli-table3/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "cli-table3/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b4eaa3c..aad2c82 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -51,12 +51,14 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", + "cheerio": "^1.0.0-rc.12", "zod": "^4.3.5" }, "devDependencies": { "@swc/core": "^1.15.18", "@types/node": "^20.0.0", "ai": "^6.0.116", + "domhandler": "5.0.3", "typescript": "^5.5.0", "vitest": "^1.6.0" } diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index e40e5d1..a2c9e9b 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -182,6 +182,46 @@ export class StitchToolClient implements StitchToolClientSpec { return this.parseToolResponse(result, name); } + /** + * Make a direct REST POST to the Stitch API. + * + * Used for endpoints not available as MCP tools (e.g. BatchCreateScreens). + * Reuses the same auth headers as callTool. Throws StitchError on HTTP errors. + */ + async httpPost(path: string, body: unknown): Promise { + const url = `${this.config.baseUrl.replace(/\/mcp$/, '').replace(/\/$/, '')}/v1/${path}`; + const response = await fetch(url, { + method: 'POST', + headers: { + ...this.buildAuthHeaders(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + const lowerText = text.toLowerCase(); + let code: StitchErrorCode = 'UNKNOWN_ERROR'; + if (response.status === 429 || lowerText.includes('rate limit')) { + code = 'RATE_LIMITED'; + } else if (response.status === 404 || lowerText.includes('not found')) { + code = 'NOT_FOUND'; + } else if (response.status === 403 || lowerText.includes('permission')) { + code = 'PERMISSION_DENIED'; + } else if (response.status === 401 || lowerText.includes('401') || lowerText.includes('unauthorized') || lowerText.includes('unauthenticated')) { + code = 'AUTH_FAILED'; + } + throw new StitchError({ + code, + message: `HTTP ${response.status}: ${text || response.statusText}`, + recoverable: code === 'RATE_LIMITED', + }); + } + + return response.json() as Promise; + } + async listTools() { if (!this.isConnected) await this.connect(); return this.client.listTools(); diff --git a/packages/sdk/src/download-handler.ts b/packages/sdk/src/download-handler.ts new file mode 100644 index 0000000..71eae84 --- /dev/null +++ b/packages/sdk/src/download-handler.ts @@ -0,0 +1,168 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import * as cheerio from 'cheerio'; +import type { AnyNode } from 'domhandler'; +import type { StitchToolClientSpec } from './spec/client.js'; +import { DownloadAssetsInputSchema } from './spec/download.js'; +import type { DownloadAssetsSpec, DownloadAssetsInput, DownloadAssetsResult } from './spec/download.js'; + +/** Atomically rename src → dest, falling back to copy+delete on EXDEV. */ +async function atomicRename(src: string, dest: string): Promise { + try { + await fs.rename(src, dest); + } catch (err: any) { + if (err?.code === 'EXDEV') { + // Cross-device: tempDir and outputDir are on different filesystems. + await fs.copyFile(src, dest); + await fs.unlink(src); + } else { + throw err; + } + } +} + +export class DownloadAssetsHandler implements DownloadAssetsSpec { + constructor(private client: StitchToolClientSpec) {} + + async execute(rawInput: DownloadAssetsInput): Promise { + try { + const input = DownloadAssetsInputSchema.parse(rawInput); + const { + projectId, + outputDir, + fileMode, + tempDir, + assetsSubdir, + } = input; + const resolvedTempDir = tempDir ?? outputDir; + // Guard assetsSubdir: strip any path separators — only use the basename. + const safeSubdir = path.basename(assetsSubdir) || 'assets'; + const assetsDir = path.join(outputDir, safeSubdir); + await fs.mkdir(assetsDir, { recursive: true }); + + // 1. List screens + const response = await this.client.callTool('list_screens', { projectId }); + const screens = (response as any).screens || []; + + const downloadedScreens: string[] = []; + + for (const screen of screens) { + let htmlUrl = screen.htmlCode?.downloadUrl; + if (!htmlUrl) { + try { + const raw = await this.client.callTool('get_screen', { + projectId, + screenId: screen.id, + name: `projects/${projectId}/screens/${screen.id}`, + }); + htmlUrl = (raw as any)?.htmlCode?.downloadUrl; + } catch (error) { + // Skip if we can't get full screen details + continue; + } + } + if (!htmlUrl) continue; + + const html = await fetch(htmlUrl).then((r) => r.text()); + const $ = cheerio.load(html); + + const assetPromises: Promise[] = []; + + $('img').each((_, el) => { + const src = $(el).attr('src'); + if (src && src.startsWith('http')) { + assetPromises.push(this._downloadAndRewrite($, el, 'src', src, assetsDir, safeSubdir, resolvedTempDir, fileMode)); + } + }); + + $('link[rel="stylesheet"]').each((_, el) => { + const href = $(el).attr('href'); + if (href && href.startsWith('http')) { + assetPromises.push(this._downloadAndRewrite($, el, 'href', href, assetsDir, safeSubdir, resolvedTempDir, fileMode)); + } + }); + + await Promise.all(assetPromises); + + const rewrittenHtml = $.html(); + const filename = `${screen.id}.html`; + const tempFilename = `.tmp-${crypto.randomBytes(8).toString('hex')}`; + const tempPath = path.join(resolvedTempDir, tempFilename); + const targetPath = path.join(outputDir, filename); + + await fs.writeFile(tempPath, rewrittenHtml, { flag: 'wx', mode: fileMode }); + await atomicRename(tempPath, targetPath); + + downloadedScreens.push(screen.id); + } + + return { success: true, downloadedScreens }; + } catch (error) { + return { + success: false, + error: { + code: 'UNKNOWN_ERROR', + message: error instanceof Error ? error.message : String(error), + recoverable: false, + }, + }; + } + } + + private async _downloadAndRewrite( + $: cheerio.CheerioAPI, + el: AnyNode, + attr: string, + url: string, + assetsDir: string, + relativePrefix: string, + resolvedTempDir: string, + fileMode: number, + ): Promise { + const res = await fetch(url); + const buffer = await res.arrayBuffer(); + + const urlObj = new URL(url); + const decodedPath = decodeURIComponent(urlObj.pathname); + const rawFilename = path.basename(decodedPath); + const ext = path.extname(rawFilename); + const hash = crypto.createHash('md5').update(url).digest('hex'); + + // SANITIZATION: Only allow alphanumeric, hyphen, underscore + const sanitizedBase = sanitizeFilename(rawFilename, ext); + + const filename = sanitizedBase ? `${sanitizedBase}-${hash}${ext}` : `${hash}${ext}`; + const fullPath = path.join(assetsDir, filename); + const tempFilename = `.tmp-${crypto.randomBytes(8).toString('hex')}`; + const tempFullPath = path.join(resolvedTempDir, tempFilename); + + await fs.writeFile(tempFullPath, Buffer.from(buffer), { flag: 'wx', mode: fileMode }); + await atomicRename(tempFullPath, fullPath); + + $(el).attr(attr, `${relativePrefix}/${filename}`); + } +} + +export function sanitizeFilename(rawFilename: string, ext: string): string { + const base = path.basename(rawFilename, ext).slice(0, 100); + const allowedChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; + return base + .split('') + .filter((c) => allowedChars.includes(c)) + .join(''); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 3c0b231..4b10dca 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -12,12 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Domain classes (generated) +// Domain classes export { Stitch } from "../generated/src/stitch.js"; -export { Project } from "../generated/src/project.js"; +export { Project } from "./project-ext.js"; // Extended: includes uploadImage() export { Screen } from "../generated/src/screen.js"; export { DesignSystem } from "../generated/src/designsystem.js"; + // Infrastructure (handwritten) export { StitchToolClient } from "./client.js"; export { StitchProxy } from "./proxy/core.js"; @@ -50,3 +51,11 @@ export type { ScreenInstance, ThumbnailScreenshot, } from "./types.js"; + +// Upload types +export type { + UploadImageInput, + UploadImageResult, + UploadImageErrorCode, +} from "./spec/upload.js"; + diff --git a/packages/sdk/src/project-ext.ts b/packages/sdk/src/project-ext.ts new file mode 100644 index 0000000..1311382 --- /dev/null +++ b/packages/sdk/src/project-ext.ts @@ -0,0 +1,246 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Handwritten extension of the generated Project class. + * + * Adds uploadImage() — a REST-based operation that cannot be generated by the + * domain-map pipeline because BatchCreateScreens is a private endpoint with no + * corresponding MCP tool in tools-manifest.json. + * + * The generated Project class is re-exported from this module, so consumers + * import a single Project class that includes both generated and handwritten + * methods. + */ + +import { Project as GeneratedProject } from '../generated/src/project.js'; +import { Screen } from '../generated/src/screen.js'; +import { StitchError, StitchErrorCode } from './spec/errors.js'; +import * as cheerio from 'cheerio'; +import { DownloadAssetsHandler } from './download-handler.js'; +import { DownloadAssetsInputSchema } from './spec/download.js'; +import { + UploadImageInputSchema, + type UploadImageInput, +} from './spec/upload.js'; +import { UploadImageHandler } from './upload-handler.js'; + +interface ExtractionRule { + target: 'customColor' | 'headlineFont' | 'roundness'; + matches: (property: string, value: string) => boolean; + extract: (value: string) => string | undefined; +} + +const EXTRACTION_RULES: ExtractionRule[] = [ + { + target: 'customColor', + matches: (prop, val) => prop.includes('color') && val.startsWith('#') && val.length === 7, + extract: (val) => val, + }, + { + target: 'headlineFont', + matches: (prop, _) => prop.includes('font-family'), + extract: (val) => { + let font = val.split(',')[0].trim(); + if (font.startsWith("'") || font.startsWith('"')) font = font.slice(1); + if (font.endsWith("'") || font.endsWith('"')) font = font.slice(0, -1); + return font; + }, + }, + { + target: 'roundness', + matches: (prop, _) => prop.includes('border-radius'), + extract: (val) => { + if (val.endsWith('px')) { + const px = parseInt(val.slice(0, -2)); + if (px >= 8) return "ROUND_EIGHT"; + if (px >= 4) return "ROUND_FOUR"; + } + return undefined; + }, + }, +]; + +export class Project extends GeneratedProject { + /** + * Upload an image file to the project and create a new Screen from it. + * + * WHY THIS IS NOT GENERATED: + * BatchCreateScreens is a private REST endpoint — it has no MCP tool + * in tools-manifest.json. It also requires reading a file from disk and + * base64-encoding it, which the codegen arg-routing model cannot express. + * + * @param filePath - Absolute or relative path to the image (PNG, JPG, JPEG, WEBP). + * @param opts - Optional screen title and createScreenInstances flag. + * @returns An array of Screen objects created from the upload. + * @throws {StitchError} on file not found, unsupported format, or upload failure. + * + * @example + * const [screen] = await project.uploadImage('./mockup.png', { title: 'Home Screen' }); + * const html = await screen.getHtml(); + */ + async uploadImage( + filePath: string, + opts?: Partial>, + ): Promise { + const input = UploadImageInputSchema.parse({ filePath, ...opts }); + const handler = new UploadImageHandler(this['client']); + const result = await handler.execute(this.projectId, input); + + if (!result.success) { + throw new StitchError({ + code: result.error.code === 'FILE_NOT_FOUND' + ? 'NOT_FOUND' + : result.error.code === 'AUTH_FAILED' + ? 'AUTH_FAILED' + : 'UNKNOWN_ERROR', + message: result.error.message, + recoverable: result.error.recoverable, + }); + } + + return result.screens; + } + + /** + * Parses HTML of a screen to extract theme tokens. + * Maps them to the expected Stitch API Theme schema. + * + * @param screenId - The ID of the screen to analyze. + * @returns A partial theme object. + */ + async inferTheme(screenId: string): Promise { + const screens = await this.screens(); + const screen = screens.find(s => s.id === screenId); + if (!screen) { + throw new Error(`Screen ${screenId} not found in project`); + } + const htmlUrl = await screen.getHtml(); + console.log(`[DEBUG] inferTheme: htmlUrl for screen ${screenId} is "${htmlUrl}"`); + console.log(`[DEBUG] inferTheme: screen data:`, JSON.stringify((screen as any).data)); + + if (!htmlUrl) { + throw new Error(`Screen ${screenId} has no HTML URL`); + } + + const html = await fetch(htmlUrl).then((r: any) => r.text()); + const $ = cheerio.load(html); + + const stylesText = $('style').text(); + const declarations = stylesText.split(';').map(s => s.trim()); + const result: Record = {}; + + for (const decl of declarations) { + const parts = decl.split(':'); + if (parts.length !== 2) continue; + const property = parts[0].trim(); + const value = parts[1].trim(); + + for (const rule of EXTRACTION_RULES) { + if (rule.matches(property, value)) { + const extracted = rule.extract(value); + if (extracted !== undefined) { + result[rule.target] = extracted; + } + } + } + } + + return { + customColor: result.customColor, + headlineFont: result.headlineFont, + roundness: result.roundness + }; + } + + /** + * Injects design system tokens into a prompt to guide generation. + * + * @param prompt - The original user prompt. + * @param theme - The theme tokens to inject. + * @returns The enhanced prompt. + */ + themePrompt(prompt: string, theme: any): string { + let themeInstructions = "\n\nUse the following design system tokens strictly:\n"; + + if (theme.customColor) { + themeInstructions += `- Primary Color: ${theme.customColor}\n`; + } + if (theme.headlineFont) { + themeInstructions += `- Typography: Use ${theme.headlineFont} for headings and body text.\n`; + } + if (theme.roundness) { + themeInstructions += `- Roundness: Use ${theme.roundness} style for corners.\n`; + } + + return `${prompt}${themeInstructions}`; + } + + /** + * Orchestrates the API dance for creating/updating design systems. + * If a design system exists for the project, updates it. + * Otherwise, creates a new one. + * + * @param theme - The theme tokens to sync. + * @returns The DesignSystem handle. + */ + async syncTheme(theme: any): Promise { + const list = await (this as any).listDesignSystems(); + + if (list.length > 0) { + const existing = list[0]; + return await existing.update({ theme }); + } else { + return await (this as any).createDesignSystem({ theme }); + } + } + + /** + * Downloads all screens and their referenced assets to a local directory. + * Rewrites URLs in HTML to be self-contained. + * + * @param outputDir - The directory to save assets to. + */ + async downloadAssets( + outputDir: string, + opts?: { fileMode?: number; tempDir?: string; assetsSubdir?: string }, + ): Promise { + const handler = new DownloadAssetsHandler((this as any).client); + const input = DownloadAssetsInputSchema.parse({ + projectId: (this as any).projectId, + outputDir, + ...opts, + }); + const result = await handler.execute(input); + if (!result.success) { + let code: StitchErrorCode = 'UNKNOWN_ERROR'; + switch (result.error.code) { + case 'PROJECT_NOT_FOUND': + code = 'NOT_FOUND'; + break; + case 'FETCH_FAILED': + code = 'NETWORK_ERROR'; + break; + case 'PATH_TRAVERSAL_ATTEMPT': + code = 'VALIDATION_ERROR'; + break; + } + throw new StitchError({ + code, + message: result.error.message, + recoverable: result.error.recoverable, + }); + } + } +} diff --git a/packages/sdk/src/proxy/handlers/callTool.ts b/packages/sdk/src/proxy/handlers/callTool.ts index 3b8486d..3735710 100644 --- a/packages/sdk/src/proxy/handlers/callTool.ts +++ b/packages/sdk/src/proxy/handlers/callTool.ts @@ -16,6 +16,7 @@ import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { ProxyContext } from '../client.js'; import { forwardToStitch } from '../client.js'; +import { isVirtualTool, handleVirtualTool } from '../virtual-tools.js'; /** * Register the tools/call handler. @@ -28,6 +29,19 @@ export function registerCallToolHandler( const { name, arguments: args } = request.params; console.error(`[stitch-proxy] Calling tool: ${name}`); + if (isVirtualTool(name)) { + try { + return await handleVirtualTool(name, args, ctx); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + console.error(`[stitch-proxy] Virtual tool call failed: ${errorMessage}`); + return { + content: [{ type: 'text', text: `Error calling virtual tool ${name}: ${errorMessage}` }], + isError: true, + }; + } + } + try { const result = await forwardToStitch(ctx.config, 'tools/call', { name, diff --git a/packages/sdk/src/proxy/handlers/listTools.ts b/packages/sdk/src/proxy/handlers/listTools.ts index 541bde2..450602e 100644 --- a/packages/sdk/src/proxy/handlers/listTools.ts +++ b/packages/sdk/src/proxy/handlers/listTools.ts @@ -16,6 +16,7 @@ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { ProxyContext } from '../client.js'; import { refreshTools } from '../client.js'; +import { VIRTUAL_TOOLS } from '../virtual-tools.js'; /** * Register the tools/list handler. @@ -35,6 +36,6 @@ export function registerListToolsHandler( console.warn('[stitch-proxy] Warning: Using stale tools due to refresh failure'); } } - return { tools: ctx.remoteTools }; + return { tools: [...ctx.remoteTools, ...VIRTUAL_TOOLS] }; }); } diff --git a/packages/sdk/src/proxy/virtual-tools.ts b/packages/sdk/src/proxy/virtual-tools.ts new file mode 100644 index 0000000..6e55670 --- /dev/null +++ b/packages/sdk/src/proxy/virtual-tools.ts @@ -0,0 +1,134 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { forwardToStitch } from './client.js'; +import { Project } from '../project-ext.js'; +import type { ProxyContext } from './client.js'; + +export const VIRTUAL_TOOLS: Tool[] = [ + { + name: 'infer_theme', + description: 'Infer theme tokens from a screen HTML', + inputSchema: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'Project ID' }, + screenId: { type: 'string', description: 'Screen ID' } + }, + required: ['projectId', 'screenId'] + } + }, + { + name: 'theme_prompt', + description: 'Inject theme tokens into a prompt', + inputSchema: { + type: 'object', + properties: { + prompt: { type: 'string', description: 'Original prompt' }, + theme: { type: 'object', description: 'Theme tokens' } + }, + required: ['prompt', 'theme'] + } + }, + { + name: 'sync_theme', + description: 'Sync theme tokens to a design system', + inputSchema: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'Project ID' }, + theme: { type: 'object', description: 'Theme tokens' } + }, + required: ['projectId', 'theme'] + } + }, + { + name: 'download_assets', + description: 'Download screens and assets to a local directory', + inputSchema: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'Project ID' }, + outputDir: { type: 'string', description: 'Output directory' } + }, + required: ['projectId', 'outputDir'] + } + } +]; + +async function createProject(projectId: string, ctx: ProxyContext) { + const dummyClient = { + callTool: async (tool: string, args: any) => { + return forwardToStitch(ctx.config, 'tools/call', { + name: tool, + arguments: args, + }); + }, + fetch: async (url: string) => { + return fetch(url, { + headers: { + 'X-Goog-Api-Key': ctx.config.apiKey!, + }, + }); + } + }; + // We cast dummyClient to any to satisfy Project constructor which expects StitchToolClient + return new Project(dummyClient as any, projectId); +} + +export function isVirtualTool(name: string): boolean { + return VIRTUAL_TOOLS.some(t => t.name === name); +} + +export async function handleVirtualTool(name: string, args: any, ctx: ProxyContext): Promise { + switch (name) { + case 'infer_theme': { + const { projectId, screenId } = args; + const project = await createProject(projectId, ctx); + const theme = await project.inferTheme(screenId); + return { + content: [{ type: 'text', text: JSON.stringify(theme, null, 2) }] + }; + } + case 'theme_prompt': { + const { prompt, theme } = args; + // We need a Project instance just to call themePrompt, but it doesn't use any instance state except maybe projectId if we changed it, but currently it doesn't. + // Let's use a dummy project with empty ID. + const project = new Project(null as any, ''); + const enhancedPrompt = project.themePrompt(prompt, theme); + return { + content: [{ type: 'text', text: enhancedPrompt }] + }; + } + case 'sync_theme': { + const { projectId, theme } = args; + const project = await createProject(projectId, ctx); + const result = await project.syncTheme(theme); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] + }; + } + case 'download_assets': { + const { projectId, outputDir } = args; + const project = await createProject(projectId, ctx); + await project.downloadAssets(outputDir); + return { + content: [{ type: 'text', text: `Assets downloaded to ${outputDir}` }] + }; + } + default: + throw new Error(`Unknown virtual tool: ${name}`); + } +} diff --git a/packages/sdk/src/spec/client.ts b/packages/sdk/src/spec/client.ts index 1ab7ece..13818e3 100644 --- a/packages/sdk/src/spec/client.ts +++ b/packages/sdk/src/spec/client.ts @@ -92,6 +92,13 @@ export interface StitchToolClientSpec { */ listTools: () => Promise; + /** + * Make a direct REST POST to the Stitch API. + * Used for endpoints not available as MCP tools (e.g. BatchCreateScreens). + * Throws StitchError on HTTP error responses. + */ + httpPost: (path: string, body: unknown) => Promise; + /** * Close the connection. */ diff --git a/packages/sdk/src/spec/download.ts b/packages/sdk/src/spec/download.ts new file mode 100644 index 0000000..0fdfb3a --- /dev/null +++ b/packages/sdk/src/spec/download.ts @@ -0,0 +1,88 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { z } from 'zod'; +import type { StitchToolClientSpec } from './client.js'; + +// ── Input ────────────────────────────────────────────────────────────────────── + +export const DownloadAssetsInputSchema = z.object({ + /** The ID of the project to download assets for. */ + projectId: z.string().min(1), + /** Absolute path to the directory where screens and assets should be saved. */ + outputDir: z.string().min(1), + /** + * Unix file-permission bits for all written files (HTML and assets). + * Defaults to 0o600 (owner read/write only). + * A CLI serving files via a web server (e.g. nginx) may need 0o644. + */ + fileMode: z.number().int().optional().default(0o600), + /** + * Directory used for atomic temp files before rename. + * Defaults to outputDir. Override to use a RAM disk or OS temp dir. + * + * IMPORTANT: tempDir MUST be on the same filesystem as outputDir for + * fs.rename() to be atomic. Cross-device renames (EXDEV) will fall back + * to a copy-then-delete strategy automatically. + */ + tempDir: z.string().optional(), + /** + * Name of the subdirectory inside outputDir where downloaded assets are saved. + * Defaults to 'assets'. Override to e.g. 'static' or 'public'. + * Path separators are stripped — only the basename is used. + */ + assetsSubdir: z.string().default('assets'), +}); + +/** Type passed by callers — fields with defaults (fileMode, assetsSubdir) are optional. */ +export type DownloadAssetsInput = z.input; + +/** Fully-resolved input after schema.parse() — all fields present. */ +export type DownloadAssetsInputParsed = z.infer; + + +// ── Error Codes ──────────────────────────────────────────────────────────────── + +export const DownloadAssetsErrorCode = z.enum([ + 'PROJECT_NOT_FOUND', + 'FETCH_FAILED', + 'WRITE_FAILED', + 'PATH_TRAVERSAL_ATTEMPT', + 'UNKNOWN_ERROR', +]); + +export type DownloadAssetsErrorCode = z.infer; + +// ── Result ───────────────────────────────────────────────────────────────────── + +export type DownloadAssetsResult = + | { success: true; downloadedScreens: string[] } + | { + success: false; + error: { + code: DownloadAssetsErrorCode; + message: string; + recoverable: boolean; + }; + }; + +// ── Interface ────────────────────────────────────────────────────────────────── + +/** + * Contract for the downloadAssets operation. + * Implementations must never throw — all failures return DownloadAssetsResult. + */ +export interface DownloadAssetsSpec { + execute(input: DownloadAssetsInput): Promise; +} diff --git a/packages/sdk/src/spec/upload.ts b/packages/sdk/src/spec/upload.ts new file mode 100644 index 0000000..48cf51a --- /dev/null +++ b/packages/sdk/src/spec/upload.ts @@ -0,0 +1,80 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { z } from 'zod'; +import type { Screen } from '../../generated/src/screen.js'; + +// ── Supported MIME types ─────────────────────────────────────────────────────── + +/** + * File extensions supported by BatchCreateScreens and their MIME types. + * The check is done in the handler (not as a Zod refinement) so failures + * produce a typed UploadImageErrorCode instead of a generic ZodError. + */ +export const SUPPORTED_MIME_TYPES = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', +} as const; + +export type SupportedExtension = keyof typeof SUPPORTED_MIME_TYPES; + +// ── Input ────────────────────────────────────────────────────────────────────── + +export const UploadImageInputSchema = z.object({ + /** Absolute or relative path to the image file on disk. */ + filePath: z.string().min(1), + /** Optional display title for the created screen. */ + title: z.string().optional(), + /** If true (default), creates screen instances on the project canvas. */ + createScreenInstances: z.boolean().default(true), +}); + +export type UploadImageInput = z.infer; + +// ── Error Codes ──────────────────────────────────────────────────────────────── + +export const UploadImageErrorCode = z.enum([ + 'FILE_NOT_FOUND', + 'UNSUPPORTED_FORMAT', + 'UPLOAD_FAILED', + 'AUTH_FAILED', + 'UNKNOWN_ERROR', +]); + +export type UploadImageErrorCode = z.infer; + +// ── Result ───────────────────────────────────────────────────────────────────── + +export type UploadImageResult = + | { success: true; screens: Screen[] } + | { + success: false; + error: { + code: UploadImageErrorCode; + message: string; + recoverable: boolean; + }; + }; + +// ── Interface ────────────────────────────────────────────────────────────────── + +/** + * Contract for the uploadImage operation. + * Implementations must never throw — all failures return UploadImageResult. + */ +export interface UploadImageSpec { + execute(projectId: string, input: UploadImageInput): Promise; +} diff --git a/packages/sdk/src/upload-handler.ts b/packages/sdk/src/upload-handler.ts new file mode 100644 index 0000000..a7b12c6 --- /dev/null +++ b/packages/sdk/src/upload-handler.ts @@ -0,0 +1,148 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * WHY THIS HANDLER EXISTS: + * BatchCreateScreens is a private REST endpoint — it has no MCP tool entry + * in tools-manifest.json. It cannot go through StitchToolClient.callTool(). + * + * This handler uses StitchToolClient.httpPost() to POST directly to the REST + * API. The SDK runs as Node.js server-side code, so it can read files from + * disk and send arbitrarily large base64-encoded payloads — unlike agent- + * driven MCP calls, which are constrained by output token limits (~16K). + */ + +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import type { StitchToolClientSpec } from './spec/client.js'; +import { + SUPPORTED_MIME_TYPES, + type SupportedExtension, + type UploadImageInput, + type UploadImageResult, + type UploadImageErrorCode, + type UploadImageSpec, +} from './spec/upload.js'; +import { Screen } from '../generated/src/screen.js'; + +/** Build the BatchCreateScreens JSON body. */ +function buildBatchCreateScreensBody( + projectId: string, + fileContentBase64: string, + mimeType: string, + input: UploadImageInput, +) { + const screen: Record = { + screenshot: { + fileContentBase64, + mimeType, + }, + screenType: 'IMAGE', + isCreatedByClient: true, + }; + + if (input.title) { + screen['title'] = input.title; + } + + return { + parent: `projects/${projectId}`, + requests: [{ screen }], + createScreenInstances: input.createScreenInstances ?? true, + }; +} + +/** + * Handler for uploadImage — implements UploadImageSpec. + * + * Never throws. All failures are returned as UploadImageResult with a typed + * error code. The caller (Project.uploadImage) surfaces failures as StitchError. + */ +export class UploadImageHandler implements UploadImageSpec { + constructor(private readonly client: StitchToolClientSpec) {} + + async execute(projectId: string, input: UploadImageInput): Promise { + // ── Step 1: Validate extension → typed error code ──────────────────────── + const ext = path.extname(input.filePath).toLowerCase(); + const mimeType = SUPPORTED_MIME_TYPES[ext as SupportedExtension]; + if (!mimeType) { + const supported = Object.keys(SUPPORTED_MIME_TYPES).join(', '); + return { + success: false, + error: { + code: 'UNSUPPORTED_FORMAT', + message: `Unsupported file extension "${ext}". Supported: ${supported}`, + recoverable: false, + }, + }; + } + + // ── Step 2: Read, encode, POST ─────────────────────────────────────────── + try { + const fileContentBase64 = await fs.readFile(input.filePath, { + encoding: 'base64', + }); + const body = buildBatchCreateScreensBody( + projectId, + fileContentBase64, + mimeType, + input, + ); + + const raw = await this.client.httpPost( + `projects/${projectId}/screens:batchCreate`, + body, + ); + + // ── Step 3: Project the response into Screen[] ─────────────────────── + // BatchCreateScreens returns { results: [{ screen: { ... } }] } + const results: Array<{ screen: any }> = raw?.results ?? []; + const screens: Screen[] = results.map((r) => { + const screenData = { ...r.screen, projectId }; + // If API didn't return an ID but returned a file name, extract ID from it + if (!screenData.id && screenData.screenshot?.name) { + const parts = screenData.screenshot.name.split('/files/'); + if (parts.length === 2) { + screenData.id = parts[1]; + } + } + return new Screen(this.client as any, screenData); + }); + + + return { success: true, screens }; + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { + return { + success: false, + error: { + code: 'FILE_NOT_FOUND', + message: `File not found: ${input.filePath}`, + recoverable: false, + }, + }; + } + const msg = err instanceof Error ? err.message : String(err); + const code: UploadImageErrorCode = + msg.includes('401') || msg.includes('403') || msg.toLowerCase().includes('auth') + ? 'AUTH_FAILED' + : 'UPLOAD_FAILED'; + + return { + success: false, + error: { code, message: msg, recoverable: false }, + }; + } + } +} diff --git a/packages/sdk/test/fixtures/1x1.png b/packages/sdk/test/fixtures/1x1.png new file mode 100644 index 0000000..7b64fd2 Binary files /dev/null and b/packages/sdk/test/fixtures/1x1.png differ diff --git a/packages/sdk/test/fixtures/real-image.png b/packages/sdk/test/fixtures/real-image.png new file mode 100644 index 0000000..5b07ef6 Binary files /dev/null and b/packages/sdk/test/fixtures/real-image.png differ diff --git a/packages/sdk/test/integration/live.test.ts b/packages/sdk/test/integration/live.test.ts index 3eec842..15db251 100644 --- a/packages/sdk/test/integration/live.test.ts +++ b/packages/sdk/test/integration/live.test.ts @@ -13,8 +13,10 @@ // limitations under the License. import { describe, it, expect, beforeAll } from "vitest"; +import { resolve } from "node:path"; import { Stitch } from "../../generated/src/stitch.js"; import { StitchToolClient } from "../../src/client.js"; +import { Project } from "../../src/project-ext.js"; const runIfConfigured = process.env.STITCH_ACCESS_TOKEN ? describe : describe.skip; @@ -49,3 +51,53 @@ runIfConfigured("Stitch Live Integration", () => { console.log("Created & retrieved project via identity map:", project.id); }, 30000); }); + +// ─── E2E: uploadImage (REST path, API key auth) ─────────────────────────────── + +const runIfApiKey = process.env.STITCH_API_KEY ? describe : describe.skip; + +runIfApiKey("Project.uploadImage (E2E)", () => { + let client: StitchToolClient; + let project: Project; + + const FIXTURE_PNG = resolve(import.meta.dirname, "../fixtures/real-image.png"); + + beforeAll(async () => { + client = new StitchToolClient({ apiKey: process.env.STITCH_API_KEY }); + // Create a temp project to upload into (MCP connect needed for createProject) + await client.connect(); + const sdk = new Stitch(client); + const created = await sdk.createProject(`upload-e2e-${Date.now()}`); + project = new Project(client, created.projectId); + console.log("E2E upload project:", project.projectId); + }, 30000); + + it("should return a non-empty Screen[] after uploading a PNG", async () => { + const screens = await project.uploadImage(FIXTURE_PNG, { + title: "e2e-upload-test", + }); + + expect(Array.isArray(screens)).toBe(true); + expect(screens.length).toBeGreaterThan(0); + }, 60000); + + it("should return a screen with a non-empty id", async () => { + const [screen] = await project.uploadImage(FIXTURE_PNG, { + title: "e2e-id-check", + }); + + expect(screen.id).toBeTruthy(); + console.log("Uploaded screen id:", screen.id); + }, 60000); + + it("should return a screen whose getImage() resolves to a URL", async () => { + const [screen] = await project.uploadImage(FIXTURE_PNG, { + title: "e2e-image-url", + }); + + const url = await screen.getImage(); + expect(typeof url).toBe("string"); + expect(url.length).toBeGreaterThan(0); + console.log("Uploaded screen image URL:", url); + }, 60000); +}); diff --git a/packages/sdk/test/integration/theme_bug_bash.test.ts b/packages/sdk/test/integration/theme_bug_bash.test.ts new file mode 100644 index 0000000..a50f7b4 --- /dev/null +++ b/packages/sdk/test/integration/theme_bug_bash.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { Stitch } from "../../generated/src/stitch.js"; +import { StitchToolClient } from "../../src/client.js"; +import { Project } from "../../src/project-ext.js"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +// Load .env if missing +if (!process.env.STITCH_API_KEY) { + const envPath = path.resolve(import.meta.dirname, "../../../../.env"); + if (fs.existsSync(envPath)) { + const content = fs.readFileSync(envPath, "utf-8"); + for (const line of content.split("\n")) { + const parts = line.split("="); + if (parts.length >= 2) { + const key = parts[0].trim(); + const value = parts.slice(1).join("=").trim(); + process.env[key] = value; + } + } + } +} + +const runIfApiKey = process.env.STITCH_API_KEY ? describe : describe.skip; + +runIfApiKey("Design System Tools Bug Bash", () => { + let sdk: Stitch; + let client: StitchToolClient; + let project: Project; + + beforeAll(async () => { + client = new StitchToolClient({ apiKey: process.env.STITCH_API_KEY }); + await client.connect(); + sdk = new Stitch(client); + + // Use existing project + const existingProjectId = "3142715389778135900"; + project = new Project(client, existingProjectId); + console.log("Bug Bash Project ID:", project.id); + }, 30000); + + it("should run full theme workflow", async () => { + // Skipping generation as requested, using existing screens. + console.log("Skipping generation, using existing screens in project."); + + const screens = await project.screens(); + console.log(`Found ${screens.length} screens`); + + let screenWithHtml; + for (const s of screens) { + const htmlUrl = await s.getHtml(); + if (htmlUrl) { + screenWithHtml = s; + break; + } + } + + if (!screenWithHtml && screens.length > 0) { + console.log("Screens found but none have HTML yet. Waiting 10 seconds..."); + await new Promise(resolve => setTimeout(resolve, 10000)); + const screens2 = await project.screens(); + for (const s of screens2) { + const htmlUrl = await s.getHtml(); + if (htmlUrl) { + screenWithHtml = s; + break; + } + } + } + + if (!screenWithHtml) { + console.log("No screens with HTML found, creating one via upload to proceed with test."); + // Fallback to upload + const FIXTURE_PNG = path.resolve(import.meta.dirname, "../fixtures/real-image.png"); + if (fs.existsSync(FIXTURE_PNG)) { + const [uploadedScreen] = await project.uploadImage(FIXTURE_PNG, { title: "Fallback Screen" }); + screenWithHtml = uploadedScreen; + } else { + throw new Error("No screens with HTML available and fixture missing!"); + } + } + + expect(screenWithHtml).toBeDefined(); + const screen = screenWithHtml; + console.log("Testing with screen:", screen.id); + + console.log("Waiting for HTML to be ready..."); + let htmlUrl = await screen.getHtml(); + let attempts = 0; + while (!htmlUrl && attempts < 20) { + console.log(`HTML not ready yet, waiting 10 seconds (attempt ${attempts + 1}/20)...`); + await new Promise(resolve => setTimeout(resolve, 10000)); + htmlUrl = await screen.getHtml(); + attempts++; + } + expect(htmlUrl).toBeTruthy(); + + // 2. Infer Theme + console.log("Inferring theme..."); + const theme = await project.inferTheme(screen.id); + console.log("Inferred theme:", theme); + + expect(theme).toBeDefined(); + + // 3. Theme Prompt + console.log("Testing theme prompt..."); + const prompt = await project.themePrompt("Create a contact page", theme); + console.log("Theme prompt:", prompt); + expect(prompt).toContain("Create a contact page"); + + // 4. Sync Theme + console.log("Syncing theme..."); + const ds = await project.syncTheme(theme); + console.log("Synced Design System:", ds.id); + expect(ds.id).toBeDefined(); + + // 5. Download Assets + console.log("Downloading assets..."); + const outputDir = path.resolve(import.meta.dirname, "../temp_bash_assets"); + await project.downloadAssets(outputDir); + console.log("Assets downloaded to:", outputDir); + expect(fs.existsSync(outputDir)).toBe(true); + + // Cleanup + fs.rmSync(outputDir, { recursive: true, force: true }); + }, 300000); // Long timeout for generation and downloads +}); diff --git a/packages/sdk/test/proxy.test.ts b/packages/sdk/test/proxy.test.ts index 7114ec3..0a78c8a 100644 --- a/packages/sdk/test/proxy.test.ts +++ b/packages/sdk/test/proxy.test.ts @@ -19,6 +19,7 @@ import { forwardToStitch, initializeStitchConnection } from '../src/proxy/client import { registerListToolsHandler } from '../src/proxy/handlers/listTools.js'; import { registerCallToolHandler } from '../src/proxy/handlers/callTool.js'; import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { VIRTUAL_TOOLS } from '../src/proxy/virtual-tools.js'; @@ -192,7 +193,7 @@ describe('Proxy Handlers', () => { const result = await handler({} as any, {} as any); - expect(result).toEqual({ tools: [{ name: 'refreshed-tool' }] }); + expect(result).toEqual({ tools: [{ name: 'refreshed-tool' }, ...VIRTUAL_TOOLS] }); expect(ctx.remoteTools).toEqual([{ name: 'refreshed-tool' }]); }); @@ -212,7 +213,7 @@ describe('Proxy Handlers', () => { const result = await handler({} as any, {} as any); // Should return existing tools if refresh fails - expect(result).toEqual({ tools: [{ name: 'existing-tool' }] }); + expect(result).toEqual({ tools: [{ name: 'existing-tool' }, ...VIRTUAL_TOOLS] }); expect(console.error).toHaveBeenCalledWith( '[stitch-proxy] Failed to refresh tools:', expect.any(Error) diff --git a/packages/sdk/test/tsconfig.json b/packages/sdk/test/tsconfig.json new file mode 100644 index 0000000..2bcab0e --- /dev/null +++ b/packages/sdk/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "**/*", + "../src/**/*", + "../generated/src/**/*" + ] +} diff --git a/packages/sdk/test/unit/client.test.ts b/packages/sdk/test/unit/client.test.ts index fa34b0f..2c9394a 100644 --- a/packages/sdk/test/unit/client.test.ts +++ b/packages/sdk/test/unit/client.test.ts @@ -262,4 +262,47 @@ describe("StitchToolClient", () => { expect(connectCount).toBe(1); }); }); + + // ─── Slice 3: httpPost transport ──────────────────────────────── + describe("httpPost", () => { + // Test 10: sends X-Goog-Api-Key header + it("sends X-Goog-Api-Key header in the request", async () => { + const client = new StitchToolClient({ apiKey: "test-key" }); + let capturedHeaders: Record = {}; + + globalThis.fetch = vi.fn().mockImplementation((_url: string, init: RequestInit) => { + capturedHeaders = Object.fromEntries( + Object.entries(init.headers as Record), + ); + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ screens: [] }), + } as any); + }); + + await client.httpPost("projects/p/screens:batchCreate", { parent: "projects/p", requests: [] }); + expect(capturedHeaders["X-Goog-Api-Key"]).toBe("test-key"); + }); + + // Test 11: throws StitchError with AUTH_FAILED on 401 + it("throws StitchError with AUTH_FAILED on a 401 response", async () => { + const client = new StitchToolClient({ apiKey: "bad-key" }); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: () => Promise.resolve("Unauthorized"), + } as any); + + const { StitchError } = await import("../../src/spec/errors.js"); + await expect( + client.httpPost("projects/p/screens:batchCreate", {}), + ).rejects.toThrow(StitchError); + + await client.httpPost("projects/p/screens:batchCreate", {}).catch((err) => { + expect(err.code).toBe("AUTH_FAILED"); + }); + }); + }); }); diff --git a/packages/sdk/test/unit/download.test.ts b/packages/sdk/test/unit/download.test.ts new file mode 100644 index 0000000..d3ad46d --- /dev/null +++ b/packages/sdk/test/unit/download.test.ts @@ -0,0 +1,241 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi } from 'vitest'; +import { DownloadAssetsHandler, sanitizeFilename } from '../../src/download-handler.js'; + +vi.mock("node:fs/promises", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + }; +}); + +describe('DownloadAssetsHandler', () => { + it('can be instantiated', () => { + const handler = new DownloadAssetsHandler({} as any); + expect(handler).toBeDefined(); + }); + + it('sanitizes asset filenames', async () => { + const fs = await import("node:fs/promises"); + vi.mocked(fs.writeFile).mockClear(); + + const mockClient = { + callTool: vi.fn().mockResolvedValue({ + screens: [{ id: 's1', name: 'projects/p1/screens/s1' }] // Mock screen object + }), + } as any; + + // Wait, getHtml is a method on Screen class in generated code! + // If I mock callTool('list_screens') it returns raw objects! + const mockScreen = { + id: 's1', + htmlCode: { downloadUrl: 'http://fake/s1.html' } + }; + mockClient.callTool.mockResolvedValue({ screens: [mockScreen] }); + + const mockFetch = vi.fn().mockImplementation((url) => { + if (url === 'http://fake/s1.html') { + return Promise.resolve({ text: () => Promise.resolve('') }); + } + return Promise.resolve({ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }); + }); + vi.stubGlobal('fetch', mockFetch); + + const handler = new DownloadAssetsHandler(mockClient); + await handler.execute({ projectId: 'p1', outputDir: '/tmp/out' }); + + // Temp paths contain only random bytes — the sanitized filename only appears in rename dest. + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('.tmp-'), + expect.any(Object), + expect.objectContaining({ flag: 'wx', mode: 0o600 }) + ); + + expect(fs.rename).toHaveBeenCalledWith( + expect.stringContaining('.tmp-'), + expect.stringContaining('badname') + ); + }); + + it('prevents directory traversal', async () => { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + vi.mocked(fs.writeFile).mockClear(); + + const mockClient = { + callTool: vi.fn().mockResolvedValue({ + screens: [{ id: 's1', name: 'projects/p1/screens/s1' }] + }), + } as any; + + const mockScreen = { + id: 's1', + getHtml: vi.fn().mockResolvedValue('http://fake/s1.html'), + }; + mockClient.callTool.mockResolvedValue({ screens: [mockScreen] }); + + const mockFetch = vi.fn().mockImplementation((url) => { + if (url === 'http://fake/s1.html') { + return Promise.resolve({ text: () => Promise.resolve('') }); + } + return Promise.resolve({ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }); + }); + vi.stubGlobal('fetch', mockFetch); + + const handler = new DownloadAssetsHandler(mockClient); + await handler.execute({ projectId: 'p1', outputDir: '/tmp/out' }); + + const calls = vi.mocked(fs.writeFile).mock.calls; + for (const [filePath] of calls) { + expect(typeof filePath).toBe('string'); + if (typeof filePath === 'string') { + if (filePath.includes('/assets/')) { + expect(filePath).toContain('/tmp/out/assets/'); + const filename = path.basename(filePath); + expect(filename).not.toContain('..'); + } else { + expect(filePath).toBe('/tmp/out/s1.html'); + } + } + } + }); + + it('returns failure if list_screens fails', async () => { + const mockClient = { + callTool: vi.fn().mockRejectedValue(new Error('API Error')), + } as any; + + const handler = new DownloadAssetsHandler(mockClient); + const result = await handler.execute({ projectId: 'p1', outputDir: '/tmp/out' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('UNKNOWN_ERROR'); + } + }); + + it('respects custom fileMode option', async () => { + const fs = await import("node:fs/promises"); + vi.mocked(fs.writeFile).mockClear(); + + const mockClient = { callTool: vi.fn() } as any; + const mockScreen = { id: 's1', htmlCode: { downloadUrl: 'http://fake/s1.html' } }; + mockClient.callTool.mockResolvedValue({ screens: [mockScreen] }); + + vi.stubGlobal('fetch', vi.fn().mockImplementation((url) => { + if (url === 'http://fake/s1.html') { + return Promise.resolve({ text: () => Promise.resolve('') }); + } + return Promise.resolve({ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }); + })); + + const handler = new DownloadAssetsHandler(mockClient); + await handler.execute({ projectId: 'p1', outputDir: '/tmp/out', fileMode: 0o644 }); + + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.anything(), + expect.objectContaining({ mode: 0o644 }) + ); + }); + + it('uses custom assetsSubdir option', async () => { + const fs = await import("node:fs/promises"); + vi.mocked(fs.mkdir).mockClear(); + vi.mocked(fs.writeFile).mockClear(); + + const mockClient = { callTool: vi.fn() } as any; + const mockScreen = { + id: 's1', + htmlCode: { downloadUrl: 'http://fake/s1.html' } + }; + mockClient.callTool.mockResolvedValue({ screens: [mockScreen] }); + + vi.stubGlobal('fetch', vi.fn().mockImplementation((url) => { + if (url === 'http://fake/s1.html') { + return Promise.resolve({ text: () => Promise.resolve('') }); + } + return Promise.resolve({ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }); + })); + + const handler = new DownloadAssetsHandler(mockClient); + await handler.execute({ projectId: 'p1', outputDir: '/tmp/out', assetsSubdir: 'static' }); + + expect(fs.mkdir).toHaveBeenCalledWith( + expect.stringContaining('static'), + expect.anything() + ); + }); + + it('uses custom tempDir for atomic temp files', async () => { + const fs = await import("node:fs/promises"); + vi.mocked(fs.writeFile).mockClear(); + vi.mocked(fs.rename).mockClear(); + + const mockClient = { callTool: vi.fn() } as any; + const mockScreen = { id: 's1', htmlCode: { downloadUrl: 'http://fake/s1.html' } }; + mockClient.callTool.mockResolvedValue({ screens: [mockScreen] }); + + vi.stubGlobal('fetch', vi.fn().mockImplementation((url) => { + if (url === 'http://fake/s1.html') { + return Promise.resolve({ text: () => Promise.resolve('') }); + } + return Promise.resolve({ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }); + })); + + const handler = new DownloadAssetsHandler(mockClient); + await handler.execute({ projectId: 'p1', outputDir: '/tmp/out', tempDir: '/custom/tmp' }); + + // Temp writes go to /custom/tmp, final rename targets go to /tmp/out + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('/custom/tmp/'), + expect.anything(), + expect.objectContaining({ flag: 'wx' }) + ); + expect(fs.rename).toHaveBeenCalledWith( + expect.stringContaining('/custom/tmp/'), + expect.stringContaining('/tmp/out/') + ); + }); +}); + + +describe('sanitizeFilename', () => { + it('removes special characters', () => { + const result = sanitizeFilename('bad name!@#$%^&*().png', '.png'); + expect(result).toBe('badname'); + }); + + it('keeps alphanumeric, hyphen, and underscore', () => { + const result = sanitizeFilename('good-name_123.png', '.png'); + expect(result).toBe('good-name_123'); + }); + + it('slices to 100 characters', () => { + const longName = 'a'.repeat(150) + '.png'; + const result = sanitizeFilename(longName, '.png'); + expect(result.length).toBe(100); + expect(result).toBe('a'.repeat(100)); + }); + + it('handles empty base name after sanitization', () => { + const result = sanitizeFilename('!!!.png', '.png'); + expect(result).toBe(''); + }); +}); diff --git a/packages/sdk/test/unit/proxy.test.ts b/packages/sdk/test/unit/proxy.test.ts new file mode 100644 index 0000000..6bad0b7 --- /dev/null +++ b/packages/sdk/test/unit/proxy.test.ts @@ -0,0 +1,115 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { registerListToolsHandler } from '../../src/proxy/handlers/listTools.js'; +import { registerCallToolHandler } from '../../src/proxy/handlers/callTool.js'; +import { VIRTUAL_TOOLS } from '../../src/proxy/virtual-tools.js'; +import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +// Use vi.hoisted to ensure variables are available in mocked modules +const { mockInferTheme, mockForward } = vi.hoisted(() => ({ + mockInferTheme: vi.fn(), + mockForward: vi.fn() +})); + +vi.mock('../../src/project-ext.js', () => ({ + Project: vi.fn().mockImplementation(() => ({ + inferTheme: mockInferTheme + })) +})); + +vi.mock('../../src/proxy/client.js', async () => { + const actual = await vi.importActual('../../src/proxy/client.js'); + return { + ...actual, + refreshTools: vi.fn().mockResolvedValue(undefined), + forwardToStitch: mockForward + }; +}); + +describe("Proxy Handlers", () => { + let mockServer: any; + let mockCtx: any; + let handlers: Map; + + beforeEach(() => { + vi.clearAllMocks(); + handlers = new Map(); + mockServer = { + setRequestHandler: vi.fn().mockImplementation((schema, handler) => { + handlers.set(schema, handler); + }) + }; + + mockCtx = { + config: { apiKey: 'test-key', url: 'https://example.com' }, + remoteTools: [{ name: 'remote_tool', description: 'Remote' }] + }; + }); + + it("should list virtual tools", async () => { + registerListToolsHandler(mockServer, mockCtx); + + const handler = handlers.get(ListToolsRequestSchema); + expect(handler).toBeDefined(); + + const result = await handler(); + expect(result.tools.length).toBe(1 + VIRTUAL_TOOLS.length); + expect(result.tools.find((t: any) => t.name === 'infer_theme')).toBeTruthy(); + }); + + it("should handle virtual tool call", async () => { + mockInferTheme.mockResolvedValue({ color: 'blue' }); + + registerCallToolHandler(mockServer, mockCtx); + + const handler = handlers.get(CallToolRequestSchema); + expect(handler).toBeDefined(); + + const request = { + params: { + name: 'infer_theme', + arguments: { projectId: 'p1', screenId: 's1' } + } + }; + + const result = await handler(request); + expect(mockInferTheme).toHaveBeenCalledWith('s1'); + expect(result.content[0].text).toContain('blue'); + }); + + it("should forward non-virtual tool call", async () => { + mockForward.mockResolvedValue({ content: [{ type: 'text', text: 'forwarded' }] }); + + registerCallToolHandler(mockServer, mockCtx); + + const handler = handlers.get(CallToolRequestSchema); + expect(handler).toBeDefined(); + + const request = { + params: { + name: 'remote_tool', + arguments: { arg1: 'val1' } + } + }; + + const result = await handler(request); + expect(mockForward).toHaveBeenCalledWith(mockCtx.config, 'tools/call', { + name: 'remote_tool', + arguments: { arg1: 'val1' } + }); + expect(result.content[0].text).toBe('forwarded'); + }); +}); diff --git a/packages/sdk/test/unit/theme-extensions.test.ts b/packages/sdk/test/unit/theme-extensions.test.ts new file mode 100644 index 0000000..4f08951 --- /dev/null +++ b/packages/sdk/test/unit/theme-extensions.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Project } from "../../src/project-ext.js"; +import { StitchToolClient } from "../../src/client.js"; + +// Mock the StitchToolClient class +vi.mock("../../src/client"); + +describe("Theme Extensions", () => { + let mockClient: StitchToolClient; + const projectId = "proj-test"; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = new StitchToolClient(); + mockClient.callTool = vi.fn(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("Project.inferTheme()", () => { + it("should infer theme tokens from screen HTML", async () => { + const project = new Project(mockClient, projectId); + const screenId = "screen-1"; + + // Mock tools + vi.mocked(mockClient.callTool).mockImplementation(async (tool: string, args: any) => { + if (tool === "list_screens") { + return { + screens: [ + { id: screenId, name: `projects/${projectId}/screens/${screenId}` } + ] + }; + } + if (tool === "get_screen") { + return { + name: `projects/${projectId}/screens/${screenId}`, + htmlCode: { downloadUrl: "https://example.com/screen1.html" } + }; + } + return {}; + }); + + // Mock fetch for the HTML content + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + text: async () => ` + + + + + Hello + + ` + })); + + + const theme = await project.inferTheme(screenId); + + expect(theme).toEqual({ + customColor: "#112233", + headlineFont: "Inter", + roundness: "ROUND_EIGHT" + }); + }); + }); + + describe("Project.themePrompt()", () => { + it("should inject theme tokens into prompt", () => { + const project = new Project(mockClient, projectId); + const prompt = "Create a landing page"; + const theme = { + customColor: "#112233", + headlineFont: "Inter", + roundness: "ROUND_EIGHT" + }; + + + const enhancedPrompt = project.themePrompt(prompt, theme); + + expect(enhancedPrompt).toContain("Create a landing page"); + expect(enhancedPrompt).toContain("#112233"); + expect(enhancedPrompt).toContain("Inter"); + expect(enhancedPrompt).toContain("ROUND_EIGHT"); + }); + }); + + describe("Project.syncTheme()", () => { + it("should create a new design system if none exists", async () => { + const project = new Project(mockClient, projectId); + const theme = { customColor: "#112233" }; + + (mockClient.callTool as any).mockImplementation(async (tool: string, args: any) => { + if (tool === "list_design_systems") { + return { designSystems: [] }; + } + if (tool === "create_design_system") { + return { + name: `assets/new-ds`, + designSystem: { theme }, + version: "1" + }; + } + return {}; + }); + + + const ds = await project.syncTheme(theme); + + expect(mockClient.callTool).toHaveBeenCalledWith("list_design_systems", { projectId }); + expect(mockClient.callTool).toHaveBeenCalledWith("create_design_system", { + projectId, + designSystem: { theme } + }); + expect(ds).toBeDefined(); + }); + + it("should update existing design system if one exists", async () => { + const project = new Project(mockClient, projectId); + const theme = { customColor: "#445566" }; + + (mockClient.callTool as any).mockImplementation(async (tool: string, args: any) => { + if (tool === "list_design_systems") { + return { + designSystems: [ + { name: `assets/existing-ds`, designSystem: { theme: { customColor: "#112233" } }, version: "1" } + ] + }; + } + if (tool === "update_design_system") { + return { + name: `assets/existing-ds`, + projectId, + designSystem: { theme } + }; + } + return {}; + }); + + + const ds = await project.syncTheme(theme); + + expect(mockClient.callTool).toHaveBeenCalledWith("list_design_systems", { projectId }); + expect(mockClient.callTool).toHaveBeenCalledWith("update_design_system", { + name: `assets/existing-ds`, + projectId, + designSystem: { theme } + }); + expect(ds).toBeDefined(); + }); + }); + + describe("Project.downloadAssets()", () => { + const testOutputDir = "./test/temp_assets"; + + beforeEach(async () => { + const fs = await import('fs/promises'); + await fs.rm(testOutputDir, { recursive: true, force: true }); + }); + + it("should download assets and rewrite HTML", async () => { + const project = new Project(mockClient, projectId); + + (mockClient.callTool as any).mockImplementation(async (tool: string, args: any) => { + if (tool === "list_screens") { + return { + screens: [ + { id: "screen-1", name: `projects/${projectId}/screens/screen-1` } + ] + }; + } + if (tool === "get_screen") { + return { + name: `projects/${projectId}/screens/screen-1`, + htmlCode: { downloadUrl: "https://example.com/screen1.html" } + }; + } + return {}; + }); + + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url: string) => { + if (url === "https://example.com/screen1.html") { + return { + text: async () => ` + + + + + + + + + ` + }; + } + if (url === "https://example.com/style.css") { + const str = "body { color: red; }"; + const ab = new ArrayBuffer(str.length); + const view = new Uint8Array(ab); + for (let i = 0; i < str.length; i++) { + view[i] = str.charCodeAt(i); + } + return { arrayBuffer: async () => ab }; + } + if (url === "https://example.com/image.png") { + return { arrayBuffer: async () => new ArrayBuffer(8) }; + } + return null; + })); + + + await project.downloadAssets(testOutputDir); + + const fs = await import('fs/promises'); + + // Verify files were created + expect(await fs.stat(`${testOutputDir}/screen-1.html`)).toBeTruthy(); + + const assets = await fs.readdir(`${testOutputDir}/assets`); + expect(assets.length).toBe(2); + expect(assets.some(f => f.startsWith('style'))).toBe(true); + expect(assets.some(f => f.startsWith('image'))).toBe(true); + + // Verify HTML was rewritten + const rewritten = await fs.readFile(`${testOutputDir}/screen-1.html`, 'utf-8'); + expect(rewritten).toContain('href="assets/style-'); + expect(rewritten).toContain('src="assets/image-'); + }); + }); +}); diff --git a/packages/sdk/test/unit/upload.test.ts b/packages/sdk/test/unit/upload.test.ts new file mode 100644 index 0000000..1a7cbb7 --- /dev/null +++ b/packages/sdk/test/unit/upload.test.ts @@ -0,0 +1,295 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi } from "vitest"; +import { UploadImageInputSchema } from "../../src/spec/upload.js"; +import { UploadImageHandler } from "../../src/upload-handler.js"; +import type { StitchToolClientSpec } from "../../src/spec/client.js"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function createMockClient( + overrides: Partial> = {}, +): StitchToolClientSpec { + return { + name: "stitch-tool-client", + description: "Authenticated tool pipe for Stitch MCP Server", + connect: vi.fn().mockResolvedValue(undefined), + callTool: vi.fn().mockResolvedValue({}), + listTools: vi.fn().mockResolvedValue({ tools: [] }), + close: vi.fn().mockResolvedValue(undefined), + httpPost: vi.fn().mockResolvedValue({ screens: [] }), + ...overrides, + }; +} + +// ─── Slice 1: Contract Tests ────────────────────────────────────────────────── + +describe("UploadImageInputSchema", () => { + // Test 1: rejects empty filePath + it("rejects an empty filePath", () => { + const result = UploadImageInputSchema.safeParse({ filePath: "" }); + expect(result.success).toBe(false); + }); + + // Test 2: parses valid input, defaults createScreenInstances to true + it("parses valid input with createScreenInstances defaulting to true", () => { + const result = UploadImageInputSchema.safeParse({ filePath: "/img/a.png" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.createScreenInstances).toBe(true); + expect(result.data.title).toBeUndefined(); + } + }); + + // Test 3: title is optional + it("allows input without a title", () => { + const result = UploadImageInputSchema.safeParse({ + filePath: "/img/b.webp", + createScreenInstances: false, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.title).toBeUndefined(); + expect(result.data.createScreenInstances).toBe(false); + } + }); +}); + +// ─── Slice 2: Handler Tests ─────────────────────────────────────────────────── + +describe("UploadImageHandler", () => { + // Test 4: UNSUPPORTED_FORMAT for .gif + it("returns UNSUPPORTED_FORMAT for a .gif file", async () => { + const handler = new UploadImageHandler(createMockClient()); + const result = await handler.execute("proj-1", { + filePath: "/images/animation.gif", + createScreenInstances: true, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("UNSUPPORTED_FORMAT"); + expect(result.error.recoverable).toBe(false); + } + }); + + // Test 5: FILE_NOT_FOUND for a path that doesn't exist + // Note: vi.mock at module level stubs fs.access globally, so we need to + // temporarily restore the real behavior for this test. + it("returns FILE_NOT_FOUND for a nonexistent .png path", async () => { + const fs = await import("node:fs/promises"); + const realReadFile = (await vi.importActual("node:fs/promises")).readFile; + vi.mocked(fs.readFile).mockImplementationOnce(realReadFile as any); + + const handler = new UploadImageHandler(createMockClient()); + const result = await handler.execute("proj-1", { + filePath: "/absolutely/nonexistent/photo.png", + createScreenInstances: true, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("FILE_NOT_FOUND"); + expect(result.error.recoverable).toBe(false); + } + }); + + // Test 6: successful upload → httpPost called with correct path → Screen[] + it("calls httpPost with the correct REST path and returns Screen[]", async () => { + const httpPost = vi.fn().mockResolvedValue({ + screens: [{ name: "projects/proj-1/screens/s-123", title: "Uploaded" }], + }); + + // Use the package.json as a stand-in "image" — it exists on disk. + // We rename it conceptually by reading the real path with a .png extension alias + // via symlinking would be complex; instead we swap fs.access/readFile via vi.mock. + // Since we can't dynamically mock fs here without vi.mock at module level, + // we use the vitest.config.ts file (which exists) and override the extension + // check by using a .png path that references a real file. + // + // Practical approach: mock the entire 'node:fs/promises' module. + // This test is deferred to the vi.mock block below. + expect(httpPost).toBeDefined(); // placeholder — covered by mocked block below + }); + + // Test 7: UPLOAD_FAILED when httpPost throws generic error + it("returns UPLOAD_FAILED when httpPost throws a generic server error", async () => { + // To reach httpPost the file must pass ext + access checks. + // We'll use a real file path with .png extension — handled via the mock block. + // Placeholder: extension guard verified here using .gif + const handler = new UploadImageHandler( + createMockClient({ + httpPost: vi.fn().mockRejectedValue(new Error("Internal Server Error")), + }), + ); + const result = await handler.execute("proj-1", { + filePath: "/tmp/missing.gif", + createScreenInstances: true, + }); + // .gif hits UNSUPPORTED_FORMAT before httpPost + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("UNSUPPORTED_FORMAT"); + } + }); + + // Test 8: AUTH_FAILED when httpPost throws with 401 in message + it("returns AUTH_FAILED when httpPost throws with 401 in message", async () => { + // Deferred to fs-mocked block below. Verify format guard works for .gif here. + const handler = new UploadImageHandler( + createMockClient({ + httpPost: vi.fn().mockRejectedValue(new Error("HTTP 401")), + }), + ); + const result = await handler.execute("proj-1", { + filePath: "/tmp/none.gif", + createScreenInstances: true, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("UNSUPPORTED_FORMAT"); + } + }); + + // Test 9: correct REST path is passed to httpPost + it("passes the right REST path to httpPost", async () => { + // Covered by the vi.mock block below — placeholder here + expect(true).toBe(true); + }); +}); + +// ─── Slice 2b: Handler Tests with mocked fs ─────────────────────────────────── + +vi.mock("node:fs/promises", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + access: vi.fn().mockResolvedValue(undefined), // file always exists + readFile: vi.fn().mockResolvedValue("base64data"), // dummy base64 + }; +}); + +describe("UploadImageHandler (fs mocked)", () => { + // Test 6 (real): successful upload returns Screen[] + it("returns Screen[] on a successful upload", async () => { + const httpPost = vi.fn().mockResolvedValue({ + results: [{ screen: { name: "projects/proj-1/screens/s-abc", title: "Test" } }], + }); + const handler = new UploadImageHandler(createMockClient({ httpPost })); + const result = await handler.execute("proj-1", { + filePath: "/fake/design.png", + createScreenInstances: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.screens).toHaveLength(1); + } + }); + + it("does not call fs.access when reading file", async () => { + const fs = await import("node:fs/promises"); + vi.mocked(fs.access).mockClear(); + + const httpPost = vi.fn().mockResolvedValue({ results: [] }); + const handler = new UploadImageHandler(createMockClient({ httpPost })); + + await handler.execute("proj-1", { + filePath: "/fake/design.png", + createScreenInstances: true, + }); + + expect(fs.access).not.toHaveBeenCalled(); + }); + + // Test 7 (real): UPLOAD_FAILED when httpPost throws + it("returns UPLOAD_FAILED when httpPost throws a generic server error", async () => { + const httpPost = vi.fn().mockRejectedValue(new Error("Internal Server Error")); + const handler = new UploadImageHandler(createMockClient({ httpPost })); + const result = await handler.execute("proj-1", { + filePath: "/fake/design.png", + createScreenInstances: true, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("UPLOAD_FAILED"); + } + }); + + // Test 8 (real): AUTH_FAILED when httpPost throws 401 + it("returns AUTH_FAILED when httpPost throws with 401 in message", async () => { + const httpPost = vi.fn().mockRejectedValue(new Error("HTTP 401: Unauthorized")); + const handler = new UploadImageHandler(createMockClient({ httpPost })); + const result = await handler.execute("proj-1", { + filePath: "/fake/design.png", + createScreenInstances: true, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("AUTH_FAILED"); + } + }); + + // Test 9: correct REST path is passed to httpPost + it("calls httpPost with the correct REST path", async () => { + const httpPost = vi.fn().mockResolvedValue({ screens: [] }); + const handler = new UploadImageHandler(createMockClient({ httpPost })); + await handler.execute("my-proj-id", { + filePath: "/fake/design.webp", + createScreenInstances: true, + }); + expect(httpPost).toHaveBeenCalledWith( + "projects/my-proj-id/screens:batchCreate", + expect.objectContaining({ parent: "projects/my-proj-id" }), + ); + }); +}); + +// ─── Slice 4: Integration Tests (Project.uploadImage) ──────────────────────── + +import { Project } from "../../src/project-ext.js"; +import { StitchError } from "../../src/spec/errors.js"; +import { StitchToolClient } from "../../src/client.js"; + +vi.mock( + "../../src/client.js", + async (importOriginal) => { + const real = await importOriginal(); + return real; + }, +); + +describe("Project.uploadImage (integration)", () => { + function createProjectWithMockedClient(httpPostMock: ReturnType) { + // Create a real Project with a mock client that satisfies StitchToolClientSpec + const mockClient = createMockClient({ httpPost: httpPostMock as unknown as StitchToolClientSpec['httpPost'] }); + return new Project(mockClient as unknown as StitchToolClient, "test-project-id"); + } + + // Test 12: throws StitchError when handler returns failure (UNSUPPORTED_FORMAT) + it("throws StitchError when the image format is unsupported", async () => { + const proj = createProjectWithMockedClient(vi.fn()); + await expect( + proj.uploadImage("/path/to/animation.gif"), + ).rejects.toThrow(StitchError); + }); + + // Test 13: returns Screen[] on success + it("returns Screen[] when the upload succeeds", async () => { + const httpPost = vi.fn().mockResolvedValue({ + results: [{ screen: { name: "projects/test-project-id/screens/s-xyz", title: "Uploaded" } }], + }); + const proj = createProjectWithMockedClient(httpPost); + const screens = await proj.uploadImage("/fake/design.png"); + expect(screens).toHaveLength(1); + }); +});