diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index da859bed5..1e671579b 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -36,7 +36,7 @@ jobs: - name: Run Claude Code Review id: claude-review - uses: anthropics/claude-code-action@b113f49a56229d8276e2bf05743ad6900121239c # v1.0.45 + uses: anthropics/claude-code-action@68cfeead1890300cc87935dbe2c023825be87b8a # v1.0.52 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: "/git:pr-review REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}" diff --git a/.github/workflows/claude-issue-similar.yml b/.github/workflows/claude-issue-similar.yml index 8fa04c16c..5478ba534 100644 --- a/.github/workflows/claude-issue-similar.yml +++ b/.github/workflows/claude-issue-similar.yml @@ -23,7 +23,7 @@ jobs: fetch-depth: 1 - name: Find Similar Issues - uses: anthropics/claude-code-action@b113f49a56229d8276e2bf05743ad6900121239c # v1.0.45 + uses: anthropics/claude-code-action@68cfeead1890300cc87935dbe2c023825be87b8a # v1.0.52 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/claude-issue-triage.yml b/.github/workflows/claude-issue-triage.yml index aaa546374..a273cb7b0 100644 --- a/.github/workflows/claude-issue-triage.yml +++ b/.github/workflows/claude-issue-triage.yml @@ -23,7 +23,7 @@ jobs: fetch-depth: 1 - name: Run Claude Issue Triage - uses: anthropics/claude-code-action@b113f49a56229d8276e2bf05743ad6900121239c # v1.0.45 + uses: anthropics/claude-code-action@68cfeead1890300cc87935dbe2c023825be87b8a # v1.0.52 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 998403dc3..d2307042c 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -32,7 +32,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@b113f49a56229d8276e2bf05743ad6900121239c # v1.0.45 + uses: anthropics/claude-code-action@68cfeead1890300cc87935dbe2c023825be87b8a # v1.0.52 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/browser/package-lock.json b/browser/package-lock.json index a636a508d..698f4be84 100644 --- a/browser/package-lock.json +++ b/browser/package-lock.json @@ -8,14 +8,14 @@ "license": "MIT", "devDependencies": { "@types/chrome": "^0.1.36", - "@types/node": "^24.10.11", - "@typescript/native-preview": "^7.0.0-dev.20260207.1", + "@types/node": "^24.10.13", + "@typescript/native-preview": "^7.0.0-dev.20260216.1", "jsdom": "^27.4.0", "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18", - "wxt": "^0.20.14" + "wxt": "^0.20.17" }, "engines": { "node": ">=24.0.1" @@ -1884,28 +1884,28 @@ } }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20260214.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260214.1.tgz", - "integrity": "sha512-BDM0ZLf2v6ilR0tDi8OMEr4X08lFCToPk3/p1SSE4GhagzmlU/5b+9slR0kKtaKMrds01FhvaKx6U9+NmAWgbQ==", + "version": "7.0.0-dev.20260223.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260223.1.tgz", + "integrity": "sha512-NEifR9F/0khbTQRztM4Yuxcj9dFuK9ubWIXJwLSmKMlncSp4u1fzRnlfv1vlNKKrXB7BUXoANFHpsM5BEXJ06w==", "dev": true, "license": "Apache-2.0", "bin": { "tsgo": "bin/tsgo.js" }, "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260214.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260214.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20260214.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260214.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20260214.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260214.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20260214.1" + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260223.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260223.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260223.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260223.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260223.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260223.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260223.1" } }, "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20260214.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260214.1.tgz", - "integrity": "sha512-Jb2WcLGpTOC6x58e8QPYC/14xmDbnbFIuKqUvYoI77hVtojVyxZi8L5Y4CgYqXYx8vRWmIFk35c1OGdtPip6Sg==", + "version": "7.0.0-dev.20260223.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260223.1.tgz", + "integrity": "sha512-uDvCfIGr3PR8iKBA6OCNq6w0b2WMvmtkS8KUZVy04CH8ieFsxChYStLiyFTDX4GZs9BtWKeth/7qGDZewY20sQ==", "cpu": [ "arm64" ], @@ -1917,9 +1917,9 @@ ] }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20260214.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260214.1.tgz", - "integrity": "sha512-O9l2gVuQFZsb8NIQtu0HN5Tn/Hw2fwylPOPS/0Y4oW+FUMhkqtvetUkb3zZ0qj7capilZ4YnmyGYg3TDqkP4Nw==", + "version": "7.0.0-dev.20260223.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260223.1.tgz", + "integrity": "sha512-hOKQicSgd1DhFbsqdpC5fMgg0R46sYbbtVjfXgYTAHg/WO6whfZ2SfPy9IIzsQ/CXYUZuwoJElCnc9DTcd66+w==", "cpu": [ "x64" ], @@ -1931,9 +1931,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20260214.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260214.1.tgz", - "integrity": "sha512-TaFrVnx3iXtl/oH1hzwvFyqWj9tzkjW8Ufl2m0Vx2/7GXnzZadm2KA6tFpGbzzWbZJznmXxKHL4O3AZRQYyZqQ==", + "version": "7.0.0-dev.20260223.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260223.1.tgz", + "integrity": "sha512-FVq6XjzqtLC1MVgQiumwpuW7Ug+S+WVEbvCUJQhrs8Szbf6fIFU/6+D6fOGCKzzo9SAD6zq2RNHtejBw74JSFA==", "cpu": [ "arm" ], @@ -1945,9 +1945,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20260214.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260214.1.tgz", - "integrity": "sha512-Hl4e3yxJqzIGgFI8aH/rLGW+a7kSLHJCpAd5JOLG7hHKnamZF4SjlunnoHLV4IcMri+G6UE3W/84i0QvQP5wLA==", + "version": "7.0.0-dev.20260223.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260223.1.tgz", + "integrity": "sha512-oRt0l3O/itqBEwd5rhfDAyziEzbSgWar1NShduK4n2mHWTHCI1I7mFsbSPbox2pdrqOwOr0QW8xu7xEgDWWRXA==", "cpu": [ "arm64" ], @@ -1959,9 +1959,9 @@ ] }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20260214.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260214.1.tgz", - "integrity": "sha512-a/JypIXTc/tdodhYdQm24WH6aTfnJJjDbwxce4BS2g6IzYSc2GFcZBvlq1CJYS2FAVLpiSxj0OFAZmgjpCDAKg==", + "version": "7.0.0-dev.20260223.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260223.1.tgz", + "integrity": "sha512-qpFTW7q8Vvq1v/0bzfT8+D0wLjqydIP0qKlomrEGLlMnCCAnPodo2oLc2JCtacc40TSMZZARvhctTszCn1gWBA==", "cpu": [ "x64" ], @@ -1973,9 +1973,9 @@ ] }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20260214.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260214.1.tgz", - "integrity": "sha512-MJGPEDvdXj8olcWH0P+cWYcaN4r/0J4aSbcaISlen3MZ/2hrrgNl46PV4eGJKKCDniY2pH2fJzrMyJWZOcdb0w==", + "version": "7.0.0-dev.20260223.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260223.1.tgz", + "integrity": "sha512-HHu63F8cDhgIlqFGBnqBVQn7HSiORxyT0M6yPzG4tG4gdzx+aFUdogbYily0nzN5b6NolQTrFfh3Q85UfHCHqg==", "cpu": [ "arm64" ], @@ -1987,9 +1987,9 @@ ] }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20260214.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260214.1.tgz", - "integrity": "sha512-BtF48TRUyiCKznlOcQ7r7EXhonGSanm9X2eu7d8Yq1vaWO5SDgB0e+ISQXSoIfs3a1S3d5S5QV/vTE4+vocPxA==", + "version": "7.0.0-dev.20260223.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260223.1.tgz", + "integrity": "sha512-vSis36O5qT+vOYfei7GtfWWzvIoaNdmxa1zDypBKkGGCCHt/c5vp0pXls85+8jBVS11Ep6p7ECcHlt+R5CBaug==", "cpu": [ "x64" ], @@ -2139,9 +2139,9 @@ "license": "MIT" }, "node_modules/@wxt-dev/browser": { - "version": "0.1.36", - "resolved": "https://registry.npmjs.org/@wxt-dev/browser/-/browser-0.1.36.tgz", - "integrity": "sha512-48Wn8pItPNg7rCdy10c73z6Alto0z5xkBZohPApKK4/uC8C70j9tJRlaxXdHF/u8+SMZGdrvYtz16oLrOxBu6g==", + "version": "0.1.37", + "resolved": "https://registry.npmjs.org/@wxt-dev/browser/-/browser-0.1.37.tgz", + "integrity": "sha512-I32XWCNRy2W6UgbaVXz8BHGBGtm8urGRRBrcNLagUBXTrBi7wCE6zWePUvvK+nUl7qUCZ7iQ1ufdP0c1DEWisw==", "dev": true, "license": "MIT", "dependencies": { @@ -8055,9 +8055,9 @@ } }, "node_modules/wxt": { - "version": "0.20.17", - "resolved": "https://registry.npmjs.org/wxt/-/wxt-0.20.17.tgz", - "integrity": "sha512-3M3og5cKYTGBoBkVbHO+Qjq+SpGQxNkcFe2YZFZjWw8QcIgmCpyxrAsvOGvzXtn7KerxHDf5VvEvf/OvlGzhFg==", + "version": "0.20.18", + "resolved": "https://registry.npmjs.org/wxt/-/wxt-0.20.18.tgz", + "integrity": "sha512-BYnIAFkdJcC8BXzbh4PzmRhOQ5xKELEk45qntzqojW5X1+VGm0GsjaEKSCQnTP72/3jZMDH1pmlEdkY/fPXehg==", "dev": true, "license": "MIT", "dependencies": { @@ -8066,7 +8066,7 @@ "@webext-core/fake-browser": "^1.3.4", "@webext-core/isolated-element": "^1.1.3", "@webext-core/match-patterns": "^1.0.3", - "@wxt-dev/browser": "^0.1.36", + "@wxt-dev/browser": "^0.1.37", "@wxt-dev/storage": "^1.0.0", "async-mutex": "^0.5.0", "c12": "^3.3.3", @@ -8100,7 +8100,7 @@ "perfect-debounce": "^2.1.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", - "publish-browser-extension": "^2.3.0 || ^3.0.2 || ^4.0.0", + "publish-browser-extension": "^2.3.0 || ^3.0.2 || ^4.0.4", "scule": "^1.3.0", "unimport": "^3.13.1 || ^4.0.0 || ^5.0.0", "vite": "^5.4.19 || ^6.3.4 || ^7.0.0", @@ -8621,9 +8621,9 @@ } }, "node_modules/wxt/node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/browser/package.json b/browser/package.json index 68f445a13..c102495b9 100644 --- a/browser/package.json +++ b/browser/package.json @@ -34,14 +34,14 @@ "license": "MIT", "devDependencies": { "@types/chrome": "^0.1.36", - "@types/node": "^24.10.11", - "@typescript/native-preview": "^7.0.0-dev.20260207.1", + "@types/node": "^24.10.13", + "@typescript/native-preview": "^7.0.0-dev.20260216.1", "jsdom": "^27.4.0", "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18", - "wxt": "^0.20.14" + "wxt": "^0.20.17" }, "engines": { "node": ">=24.0.1" diff --git a/package-lock.json b/package-lock.json index 091200ae8..1de9bc49d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "clipboardy": "^5.0.2", "commander": "^14.0.2", "fast-xml-parser": "^5.3.3", - "fflate": "^0.8.2", "git-url-parse": "^16.1.0", "globby": "^16.1.0", "handlebars": "^4.7.8", @@ -31,6 +30,7 @@ "log-update": "^7.0.2", "minimatch": "^10.1.1", "picocolors": "^1.1.1", + "tar": "^7.5.7", "tiktoken": "^1.0.22", "tinypool": "^2.1.0", "web-tree-sitter": "^0.26.3", @@ -797,25 +797,16 @@ "hono": "^4" } }, - "node_modules/@isaacs/balanced-match": { + "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", "dependencies": { - "@isaacs/balanced-match": "^4.0.1" + "minipass": "^7.0.4" }, "engines": { - "node": "20 || >=22" + "node": ">=18.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -2081,6 +2072,15 @@ "node": ">=8" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/binary-extensions": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-3.1.0.tgz", @@ -2137,6 +2137,18 @@ "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==" }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2210,6 +2222,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -2691,9 +2712,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-parser": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", - "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", + "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", "funding": [ { "type": "github", @@ -2702,7 +2723,7 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.0" + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -2717,12 +2738,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -3689,12 +3704,12 @@ } }, "node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", + "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { "node": "20 || >=22" @@ -3716,11 +3731,22 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "engines": { "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4845,9 +4871,9 @@ } }, "node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "funding": [ { "type": "github", @@ -5001,6 +5027,22 @@ "node": ">=8" } }, + "node_modules/tar": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -5531,6 +5573,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index 994dd0852..e10eca1fb 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "clipboardy": "^5.0.2", "commander": "^14.0.2", "fast-xml-parser": "^5.3.3", - "fflate": "^0.8.2", "git-url-parse": "^16.1.0", "globby": "^16.1.0", "handlebars": "^4.7.8", @@ -97,6 +96,7 @@ "log-update": "^7.0.2", "minimatch": "^10.1.1", "picocolors": "^1.1.1", + "tar": "^7.5.7", "tiktoken": "^1.0.22", "tinypool": "^2.1.0", "web-tree-sitter": "^0.26.3", diff --git a/scripts/memory/package-lock.json b/scripts/memory/package-lock.json index d40eb47a2..c180ead21 100644 --- a/scripts/memory/package-lock.json +++ b/scripts/memory/package-lock.json @@ -13,7 +13,7 @@ "repomix": "file:../.." }, "devDependencies": { - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "typescript": "^5.9.3" }, "engines": { diff --git a/scripts/memory/package.json b/scripts/memory/package.json index be01b0460..4227f4611 100644 --- a/scripts/memory/package.json +++ b/scripts/memory/package.json @@ -19,7 +19,7 @@ "repomix": "file:../.." }, "devDependencies": { - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "typescript": "^5.9.3" }, "engines": { diff --git a/src/core/git/gitHubArchive.ts b/src/core/git/gitHubArchive.ts index fdcb711bf..e5e96fa57 100644 --- a/src/core/git/gitHubArchive.ts +++ b/src/core/git/gitHubArchive.ts @@ -1,9 +1,7 @@ -import { createWriteStream } from 'node:fs'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; import { Readable, Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { unzip } from 'fflate'; +import * as zlib from 'node:zlib'; +import { extract as tarExtract } from 'tar'; import { RepomixError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; import { @@ -11,7 +9,6 @@ import { buildGitHubMasterArchiveUrl, buildGitHubTagArchiveUrl, checkGitHubResponse, - getArchiveFilename, } from './gitHubArchiveApi.js'; import type { GitHubRepoInfo } from './gitRemoteParse.js'; @@ -28,27 +25,34 @@ export interface ArchiveDownloadProgress { export type ProgressCallback = (progress: ArchiveDownloadProgress) => void; +export interface ArchiveDownloadDeps { + fetch: typeof globalThis.fetch; + pipeline: typeof pipeline; + Transform: typeof Transform; + tarExtract: typeof tarExtract; + createGunzip: typeof zlib.createGunzip; +} + +const defaultDeps: ArchiveDownloadDeps = { + fetch: globalThis.fetch, + pipeline, + Transform, + tarExtract, + createGunzip: zlib.createGunzip, +}; + /** - * Downloads and extracts a GitHub repository archive + * Downloads and extracts a GitHub repository archive using streaming tar.gz extraction */ export const downloadGitHubArchive = async ( repoInfo: GitHubRepoInfo, targetDirectory: string, options: ArchiveDownloadOptions = {}, onProgress?: ProgressCallback, - deps = { - fetch: globalThis.fetch, - fs, - pipeline, - Transform, - createWriteStream, - }, + deps: ArchiveDownloadDeps = defaultDeps, ): Promise => { const { timeout = 30000, retries = 3 } = options; - // Ensure target directory exists - await deps.fs.mkdir(targetDirectory, { recursive: true }); - let lastError: Error | null = null; // Try downloading with multiple URL formats: main branch, master branch (fallback), then tag format @@ -63,7 +67,7 @@ export const downloadGitHubArchive = async ( try { logger.trace(`Downloading GitHub archive from: ${archiveUrl} (attempt ${attempt}/${retries})`); - await downloadAndExtractArchive(archiveUrl, targetDirectory, repoInfo, timeout, onProgress, deps); + await downloadAndExtractArchive(archiveUrl, targetDirectory, timeout, onProgress, deps); logger.trace('Successfully downloaded and extracted GitHub archive'); return; // Success - exit early @@ -71,12 +75,6 @@ export const downloadGitHubArchive = async ( lastError = error as Error; logger.trace(`Archive download attempt ${attempt} failed:`, lastError.message); - // If it's an extraction error, don't retry - the same archive will fail again - const isExtractionError = lastError instanceof RepomixError && lastError.message.includes('Failed to extract'); - if (isExtractionError) { - throw lastError; - } - // If it's a 404-like error and we have more URLs to try, don't retry this URL const isNotFoundError = lastError instanceof RepomixError && @@ -102,61 +100,22 @@ export const downloadGitHubArchive = async ( }; /** - * Downloads and extracts archive from a single URL + * Downloads and extracts a tar.gz archive from a single URL using streaming pipeline. + * The HTTP response is streamed through gunzip and tar extract directly to disk, + * without writing a temporary archive file. */ const downloadAndExtractArchive = async ( archiveUrl: string, targetDirectory: string, - repoInfo: GitHubRepoInfo, timeout: number, onProgress?: ProgressCallback, - deps = { - fetch: globalThis.fetch, - fs, - pipeline, - Transform, - createWriteStream, - }, -): Promise => { - // Download the archive - const tempArchivePath = path.join(targetDirectory, getArchiveFilename(repoInfo)); - - await downloadFile(archiveUrl, tempArchivePath, timeout, onProgress, deps); - - try { - // Extract the archive - await extractZipArchive(tempArchivePath, targetDirectory, repoInfo, { fs: deps.fs }); - } finally { - // Clean up the downloaded archive file - try { - await deps.fs.unlink(tempArchivePath); - } catch (error) { - logger.trace('Failed to cleanup archive file:', (error as Error).message); - } - } -}; - -/** - * Downloads a file from URL with progress tracking - */ -const downloadFile = async ( - url: string, - filePath: string, - timeout: number, - onProgress?: ProgressCallback, - deps = { - fetch: globalThis.fetch, - fs, - pipeline, - Transform, - createWriteStream, - }, + deps: ArchiveDownloadDeps = defaultDeps, ): Promise => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { - const response = await deps.fetch(url, { + const response = await deps.fetch(archiveUrl, { signal: controller.signal, }); @@ -171,7 +130,6 @@ const downloadFile = async ( let downloaded = 0; let lastProgressUpdate = 0; - // Use Readable.fromWeb for better stream handling const nodeStream = Readable.fromWeb(response.body); // Transform stream for progress tracking @@ -193,7 +151,6 @@ const downloadFile = async ( callback(null, chunk); }, flush(callback) { - // Send final progress update if (onProgress) { onProgress({ downloaded, @@ -205,137 +162,25 @@ const downloadFile = async ( }, }); - // Write to file - const writeStream = deps.createWriteStream(filePath); - await deps.pipeline(nodeStream, progressStream, writeStream); - } finally { - clearTimeout(timeoutId); - } -}; - -/** - * Extracts a ZIP archive using fflate library - */ -const extractZipArchive = async ( - archivePath: string, - targetDirectory: string, - repoInfo: GitHubRepoInfo, - deps = { - fs, - }, -): Promise => { - try { - // Always use in-memory extraction for simplicity and reliability - await extractZipArchiveInMemory(archivePath, targetDirectory, repoInfo, deps); - } catch (error) { - throw new RepomixError(`Failed to extract archive: ${(error as Error).message}`); - } -}; - -/** - * Extracts ZIP archive by loading it entirely into memory (faster for small files) - */ -const extractZipArchiveInMemory = async ( - archivePath: string, - targetDirectory: string, - repoInfo: GitHubRepoInfo, - deps = { - fs, - }, -): Promise => { - // Read the ZIP file as a buffer - const zipBuffer = await deps.fs.readFile(archivePath); - const zipUint8Array = new Uint8Array(zipBuffer); - - // Extract ZIP using fflate - await new Promise((resolve, reject) => { - unzip(zipUint8Array, (err, extracted) => { - if (err) { - reject(new RepomixError(`Failed to extract ZIP archive: ${err.message}`)); - return; - } - - // Process extracted files concurrently in the callback - processExtractedFiles(extracted, targetDirectory, repoInfo, deps).then(resolve).catch(reject); + // Stream: HTTP response -> progress tracking -> gunzip -> tar extract to disk + // strip: 1 removes the top-level "repo-branch/" directory from archive paths + const extractStream = deps.tarExtract({ + cwd: targetDirectory, + strip: 1, }); - }); -}; + const gunzipStream = deps.createGunzip(); -/** - * Process extracted files sequentially to avoid EMFILE errors - */ -const processExtractedFiles = async ( - extracted: Record, - targetDirectory: string, - repoInfo: GitHubRepoInfo, - deps = { - fs, - }, -): Promise => { - const repoPrefix = `${repoInfo.repo}-`; - const createdDirs = new Set(); - - // Process files sequentially to avoid EMFILE errors completely - for (const [filePath, fileData] of Object.entries(extracted)) { - // GitHub archives have a top-level directory like "repo-branch/" - // We need to remove this prefix from the file paths - let relativePath = filePath; - - // Find and remove the repo prefix - const pathParts = filePath.split('/'); - if (pathParts.length > 0 && pathParts[0].startsWith(repoPrefix)) { - // Remove the first directory (repo-branch/) - relativePath = pathParts.slice(1).join('/'); - } - - // Skip empty paths (root directory) - if (!relativePath) { - continue; - } - - // Sanitize relativePath to prevent path traversal attacks - const sanitized = path.normalize(relativePath).replace(/^(\.\.([/\\]|$))+/, ''); - - // Reject absolute paths outright - if (path.isAbsolute(sanitized)) { - logger.trace(`Absolute path detected in archive, skipping: ${relativePath}`); - continue; - } - - const targetPath = path.resolve(targetDirectory, sanitized); - if (!targetPath.startsWith(path.resolve(targetDirectory))) { - logger.trace(`Unsafe path detected in archive, skipping: ${relativePath}`); - continue; - } - - // Check if this entry is a directory (ends with /) or empty file data indicates directory - const isDirectory = filePath.endsWith('/') || (fileData.length === 0 && relativePath.endsWith('/')); - - if (isDirectory) { - // Create directory immediately - if (!createdDirs.has(targetPath)) { - logger.trace(`Creating directory: ${targetPath}`); - await deps.fs.mkdir(targetPath, { recursive: true }); - createdDirs.add(targetPath); - } - } else { - // Create parent directory if needed and write file - const parentDir = path.dirname(targetPath); - if (!createdDirs.has(parentDir)) { - logger.trace(`Creating parent directory for file: ${parentDir}`); - await deps.fs.mkdir(parentDir, { recursive: true }); - createdDirs.add(parentDir); - } - - // Write file sequentially - logger.trace(`Writing file: ${targetPath}`); - try { - await deps.fs.writeFile(targetPath, fileData); - } catch (fileError) { - logger.trace(`Failed to write file ${targetPath}: ${(fileError as Error).message}`); - throw fileError; - } + try { + await deps.pipeline(nodeStream, progressStream, gunzipStream, extractStream); + } finally { + // Explicitly destroy streams to release handles. + // Bun's pipeline() may not fully clean up, causing subsequent worker_threads to hang. + nodeStream.destroy(); + progressStream.destroy(); + gunzipStream.destroy(); } + } finally { + clearTimeout(timeoutId); } }; diff --git a/src/core/git/gitHubArchiveApi.ts b/src/core/git/gitHubArchiveApi.ts index 5627b642f..e0a0dd932 100644 --- a/src/core/git/gitHubArchiveApi.ts +++ b/src/core/git/gitHubArchiveApi.ts @@ -3,9 +3,9 @@ import type { GitHubRepoInfo } from './gitRemoteParse.js'; /** * Constructs GitHub archive download URL - * Format: https://github.com/owner/repo/archive/refs/heads/branch.zip - * For tags: https://github.com/owner/repo/archive/refs/tags/tag.zip - * For commits: https://github.com/owner/repo/archive/commit.zip + * Format: https://github.com/owner/repo/archive/refs/heads/branch.tar.gz + * For tags: https://github.com/owner/repo/archive/refs/tags/tag.tar.gz + * For commits: https://github.com/owner/repo/archive/commit.tar.gz */ export const buildGitHubArchiveUrl = (repoInfo: GitHubRepoInfo): string => { const { owner, repo, ref } = repoInfo; @@ -13,18 +13,18 @@ export const buildGitHubArchiveUrl = (repoInfo: GitHubRepoInfo): string => { if (!ref) { // Default to HEAD (repository's default branch) - return `${baseUrl}/HEAD.zip`; + return `${baseUrl}/HEAD.tar.gz`; } // Check if ref looks like a commit SHA (40 hex chars or shorter) const isCommitSha = /^[0-9a-f]{4,40}$/i.test(ref); if (isCommitSha) { - return `${baseUrl}/${encodeURIComponent(ref)}.zip`; + return `${baseUrl}/${encodeURIComponent(ref)}.tar.gz`; } // For branches and tags, we need to determine the type // Default to branch format, will fallback to tag if needed - return `${baseUrl}/refs/heads/${encodeURIComponent(ref)}.zip`; + return `${baseUrl}/refs/heads/${encodeURIComponent(ref)}.tar.gz`; }; /** @@ -36,7 +36,7 @@ export const buildGitHubMasterArchiveUrl = (repoInfo: GitHubRepoInfo): string | return null; // Only applicable when no ref is specified } - return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/archive/refs/heads/master.zip`; + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/archive/refs/heads/master.tar.gz`; }; /** @@ -48,21 +48,7 @@ export const buildGitHubTagArchiveUrl = (repoInfo: GitHubRepoInfo): string | nul return null; // Not applicable for commits or no ref } - return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/archive/refs/tags/${encodeURIComponent(ref)}.zip`; -}; - -/** - * Gets the expected archive filename from GitHub - * Format: repo-branch.zip or repo-sha.zip - */ -export const getArchiveFilename = (repoInfo: GitHubRepoInfo): string => { - const { repo, ref } = repoInfo; - const refPart = ref || 'HEAD'; - - // GitHub uses the last part of the ref for the filename - const refName = refPart.includes('/') ? refPart.split('/').pop() : refPart; - - return `${repo}-${refName}.zip`; + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/archive/refs/tags/${encodeURIComponent(ref)}.tar.gz`; }; /** diff --git a/tests/core/git/gitHubArchive.test.ts b/tests/core/git/gitHubArchive.test.ts index e07036e04..846404b0b 100644 --- a/tests/core/git/gitHubArchive.test.ts +++ b/tests/core/git/gitHubArchive.test.ts @@ -1,8 +1,7 @@ -import type { createWriteStream, WriteStream } from 'node:fs'; -import type * as fsPromises from 'node:fs/promises'; -import * as path from 'node:path'; -import { Transform } from 'node:stream'; +import { Transform, Writable } from 'node:stream'; import type { pipeline as pipelineType } from 'node:stream/promises'; +import type * as zlib from 'node:zlib'; +import type { extract as tarExtractType } from 'tar'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { type ArchiveDownloadOptions, @@ -15,74 +14,52 @@ import { RepomixError } from '../../../src/shared/errorHandle.js'; // Mock modules vi.mock('../../../src/shared/logger'); -vi.mock('fflate', () => ({ - unzip: vi.fn(), -})); // Type for the deps parameter of downloadGitHubArchive interface MockDeps { fetch: typeof globalThis.fetch; - fs: typeof fsPromises; pipeline: typeof pipelineType; Transform: typeof Transform; - createWriteStream: typeof createWriteStream; + tarExtract: typeof tarExtractType; + createGunzip: typeof zlib.createGunzip; } -// Simple ZIP test data -const mockZipData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // Simple ZIP header +// Simple test data +const mockStreamData = new Uint8Array([0x1f, 0x8b, 0x08, 0x00]); // gzip magic bytes describe('gitHubArchive', () => { - // Define typed mock functions - const mockFs = { - mkdir: vi.fn(), - readFile: vi.fn(), - writeFile: vi.fn(), - unlink: vi.fn(), - }; - let mockFetch: ReturnType>; let mockPipeline: ReturnType>; - let mockTransformConstructor: typeof Transform; - let mockCreateWriteStream: ReturnType>; - let mockUnzip: ReturnType; + let mockTarExtract: ReturnType>; + let mockCreateGunzip: ReturnType>; let mockDeps: MockDeps; - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks(); mockFetch = vi.fn(); - mockPipeline = vi.fn(); - mockTransformConstructor = Transform; - mockCreateWriteStream = vi.fn(); - - // Get the mocked unzip function - const { unzip } = await import('fflate'); - mockUnzip = vi.mocked(unzip); - - // Reset fs mocks - for (const mock of Object.values(mockFs)) { - mock.mockReset(); - } - - // Setup default successful behaviors - mockFs.mkdir.mockResolvedValue(undefined); - mockFs.unlink.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue(Buffer.from(mockZipData)); - mockFs.writeFile.mockResolvedValue(undefined); - mockPipeline.mockResolvedValue(undefined); - mockCreateWriteStream.mockReturnValue({ - write: vi.fn(), - end: vi.fn(), - } as unknown as WriteStream); - - // Create mockDeps with type casting for mock objects - // Using 'as unknown as Type' pattern is idiomatic for test mocks + mockPipeline = vi.fn().mockResolvedValue(undefined); + mockTarExtract = vi.fn().mockReturnValue( + new Writable({ + write(_chunk, _enc, cb) { + cb(); + }, + }) as unknown as ReturnType, + ); + mockCreateGunzip = vi.fn().mockReturnValue( + new Transform({ + transform(chunk, _enc, cb) { + cb(null, chunk); + }, + }) as unknown as ReturnType, + ); + mockDeps = { fetch: mockFetch, - fs: mockFs as unknown as typeof fsPromises, pipeline: mockPipeline as unknown as typeof pipelineType, - Transform: mockTransformConstructor, - createWriteStream: mockCreateWriteStream, + Transform, + tarExtract: mockTarExtract as unknown as typeof tarExtractType, + createGunzip: mockCreateGunzip as unknown as typeof zlib.createGunzip, }; }); @@ -99,134 +76,76 @@ describe('gitHubArchive', () => { retries: 3, }; - test('should successfully download and extract archive', async () => { - // Mock successful response with stream - const mockStream = new ReadableStream({ - start(controller) { - controller.enqueue(mockZipData); - controller.close(); - }, - }); - - mockFetch.mockResolvedValue({ + const createMockResponse = (overrides: Partial = {}): Response => { + return { ok: true, status: 200, - headers: new Map([['content-length', mockZipData.length.toString()]]), - body: mockStream, - } as unknown as Response); - - // Mock unzip to extract files - mockUnzip.mockImplementation((_data, callback) => { - const extracted = { - 'repomix-main/': new Uint8Array(0), // Directory - 'repomix-main/test.txt': new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f]), // "hello" - }; - callback(null, extracted); - }); + headers: new Map([['content-length', mockStreamData.length.toString()]]), + body: new ReadableStream({ + start(controller) { + controller.enqueue(mockStreamData); + controller.close(); + }, + }), + ...overrides, + } as unknown as Response; + }; - await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, mockOptions, undefined, mockDeps); + test('should successfully download and extract archive', async () => { + mockFetch.mockResolvedValue(createMockResponse()); - // Verify directory creation - expect(mockFs.mkdir).toHaveBeenCalledWith(mockTargetDirectory, { recursive: true }); + await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, mockOptions, undefined, mockDeps); - // Verify fetch was called + // Verify fetch was called with tar.gz URL expect(mockFetch).toHaveBeenCalledWith( - 'https://github.com/yamadashy/repomix/archive/refs/heads/main.zip', + 'https://github.com/yamadashy/repomix/archive/refs/heads/main.tar.gz', expect.objectContaining({ signal: expect.any(AbortSignal), }), ); - // Verify file operations - expect(mockFs.writeFile).toHaveBeenCalledWith( - path.resolve(mockTargetDirectory, 'test.txt'), - expect.any(Uint8Array), - ); + // Verify tar extract was called with correct options + expect(mockTarExtract).toHaveBeenCalledWith({ + cwd: mockTargetDirectory, + strip: 1, + }); - // Verify cleanup - expect(mockFs.unlink).toHaveBeenCalledWith(path.join(mockTargetDirectory, 'repomix-main.zip')); + // Verify streaming pipeline was used + expect(mockPipeline).toHaveBeenCalledTimes(1); }); test('should handle progress callback', async () => { const mockProgressCallback: ProgressCallback = vi.fn(); - - const mockStream = new ReadableStream({ - start(controller) { - controller.enqueue(mockZipData); - controller.close(); - }, - }); - - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - headers: new Map([['content-length', mockZipData.length.toString()]]), - body: mockStream, - } as unknown as Response); - - mockUnzip.mockImplementation((_data, callback) => { - callback(null, {}); - }); + mockFetch.mockResolvedValue(createMockResponse()); await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, mockOptions, mockProgressCallback, mockDeps); - // Progress callback is called via Transform stream, which is handled internally - // Just verify the download completed successfully expect(mockFetch).toHaveBeenCalled(); - expect(mockUnzip).toHaveBeenCalled(); + expect(mockPipeline).toHaveBeenCalled(); }); - test('should retry on failure', async () => { - // First two attempts fail, third succeeds + test('should retry on network failure', async () => { mockFetch .mockRejectedValueOnce(new Error('Network error')) .mockRejectedValueOnce(new Error('Network error')) - .mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Map([['content-length', mockZipData.length.toString()]]), - body: new ReadableStream({ - start(controller) { - controller.enqueue(mockZipData); - controller.close(); - }, - }), - } as unknown as Response); - - mockUnzip.mockImplementation((_data, callback) => { - callback(null, {}); - }); + .mockResolvedValueOnce(createMockResponse()); - // Use fewer retries to speed up test await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 2 }, undefined, mockDeps); expect(mockFetch).toHaveBeenCalledTimes(3); }); test('should try fallback URLs on 404', async () => { - // Mock 404 for main branch, success for master branch mockFetch - .mockResolvedValueOnce({ - ok: false, - status: 404, - headers: new Map(), - body: null, - } as unknown as Response) - .mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Map([['content-length', mockZipData.length.toString()]]), - body: new ReadableStream({ - start(controller) { - controller.enqueue(mockZipData); - controller.close(); - }, - }), - } as unknown as Response); - - mockUnzip.mockImplementation((_data, callback) => { - callback(null, {}); - }); + .mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 404, + headers: new Map(), + body: null, + } as unknown as Partial), + ) + .mockResolvedValueOnce(createMockResponse()); const repoInfoNoRef = { owner: 'yamadashy', repo: 'repomix' }; @@ -234,11 +153,11 @@ describe('gitHubArchive', () => { // Should try HEAD first, then master branch expect(mockFetch).toHaveBeenCalledWith( - 'https://github.com/yamadashy/repomix/archive/HEAD.zip', + 'https://github.com/yamadashy/repomix/archive/HEAD.tar.gz', expect.any(Object), ); expect(mockFetch).toHaveBeenCalledWith( - 'https://github.com/yamadashy/repomix/archive/refs/heads/master.zip', + 'https://github.com/yamadashy/repomix/archive/refs/heads/master.tar.gz', expect.any(Object), ); }); @@ -250,161 +169,21 @@ describe('gitHubArchive', () => { downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 2 }, undefined, mockDeps), ).rejects.toThrow(RepomixError); - // Multiple URLs are tried even with ref: main, fallback, tag // 2 retries × 2 URLs (main + tag for "main" ref) = 4 total attempts expect(mockFetch).toHaveBeenCalledTimes(4); }); - test('should handle ZIP extraction error', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - headers: new Map([['content-length', mockZipData.length.toString()]]), - body: new ReadableStream({ - start(controller) { - controller.enqueue(mockZipData); - controller.close(); - }, - }), - } as unknown as Response); - - // Mock unzip to fail - mockUnzip.mockImplementation((_data, callback) => { - callback(new Error('Invalid ZIP file')); - }); + test('should handle extraction error', async () => { + mockFetch.mockResolvedValue(createMockResponse()); + mockPipeline.mockRejectedValue(new Error('tar extraction failed')); await expect( downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 1 }, undefined, mockDeps), ).rejects.toThrow(RepomixError); }); - test('should not retry on extraction error', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - headers: new Map([['content-length', mockZipData.length.toString()]]), - body: new ReadableStream({ - start(controller) { - controller.enqueue(mockZipData); - controller.close(); - }, - }), - } as unknown as Response); - - // Mock unzip to fail - mockUnzip.mockImplementation((_data, callback) => { - callback(new Error('Invalid ZIP file')); - }); - - await expect( - downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 3 }, undefined, mockDeps), - ).rejects.toThrow(RepomixError); - - // Should only fetch once - extraction errors should not trigger retries - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - test('should handle path traversal attack', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - headers: new Map([['content-length', mockZipData.length.toString()]]), - body: new ReadableStream({ - start(controller) { - controller.enqueue(mockZipData); - controller.close(); - }, - }), - } as unknown as Response); - - // Mock unzip with dangerous paths - mockUnzip.mockImplementation((_data, callback) => { - const extracted = { - 'repomix-main/../../../etc/passwd': new Uint8Array([0x65, 0x76, 0x69, 0x6c]), // "evil" - 'repomix-main/safe.txt': new Uint8Array([0x73, 0x61, 0x66, 0x65]), // "safe" - }; - callback(null, extracted); - }); - - await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, mockOptions, undefined, mockDeps); - - // Should write both files - the path normalization doesn't completely prevent this case - expect(mockFs.writeFile).toHaveBeenCalledWith( - path.resolve(mockTargetDirectory, 'safe.txt'), - expect.any(Uint8Array), - ); - - // Verify that both files are written (one was sanitized to remove path traversal) - expect(mockFs.writeFile).toHaveBeenCalledTimes(2); - }); - - test('should handle absolute paths in ZIP', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - headers: new Map([['content-length', mockZipData.length.toString()]]), - body: new ReadableStream({ - start(controller) { - controller.enqueue(mockZipData); - controller.close(); - }, - }), - } as unknown as Response); - - // Mock unzip with absolute path - mockUnzip.mockImplementation((_data, callback) => { - const extracted = { - '/etc/passwd': new Uint8Array([0x65, 0x76, 0x69, 0x6c]), // "evil" - 'repomix-main/safe.txt': new Uint8Array([0x73, 0x61, 0x66, 0x65]), // "safe" - }; - callback(null, extracted); - }); - - await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, mockOptions, undefined, mockDeps); - - // Should only write safe file, not the absolute path - expect(mockFs.writeFile).toHaveBeenCalledWith( - path.resolve(mockTargetDirectory, 'safe.txt'), - expect.any(Uint8Array), - ); - - // Should not write the absolute path file - expect(mockFs.writeFile).not.toHaveBeenCalledWith('/etc/passwd', expect.any(Uint8Array)); - }); - - test('should cleanup archive file even on extraction failure', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - headers: new Map([['content-length', mockZipData.length.toString()]]), - body: new ReadableStream({ - start(controller) { - controller.enqueue(mockZipData); - controller.close(); - }, - }), - } as unknown as Response); - - // Mock unzip to fail - mockUnzip.mockImplementation((_data, callback) => { - callback(new Error('Extraction failed')); - }); - - await expect( - downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 1 }, undefined, mockDeps), - ).rejects.toThrow(); - - // Should still attempt cleanup - expect(mockFs.unlink).toHaveBeenCalledWith(path.join(mockTargetDirectory, 'repomix-main.zip')); - }); - test('should handle missing response body', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - headers: new Map(), - body: null, - } as unknown as Response); + mockFetch.mockResolvedValue(createMockResponse({ body: null } as unknown as Partial)); await expect( downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 1 }, undefined, mockDeps), @@ -412,27 +191,22 @@ describe('gitHubArchive', () => { }); test('should handle timeout', async () => { - // Mock a fetch that takes too long mockFetch.mockImplementation( - () => - new Promise((resolve) => { - setTimeout(() => { - resolve({ - ok: true, - status: 200, - headers: new Map(), - body: new ReadableStream({ - start(controller) { - controller.enqueue(mockZipData); - controller.close(); - }, - }), - } as unknown as Response); - }, 100); // Resolve after 100ms, but timeout is 50ms + (_url: string | URL | Request, init?: RequestInit) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve(createMockResponse()); + }, 100); + + // Respect AbortSignal so timeout actually cancels the fetch + init?.signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(new DOMException('The operation was aborted', 'AbortError')); + }); }), ); - const shortTimeout = 50; // 50ms timeout for faster test + const shortTimeout = 50; await expect( downloadGitHubArchive( diff --git a/tests/core/git/gitHubArchiveApi.test.ts b/tests/core/git/gitHubArchiveApi.test.ts index fdfd8cff8..637e96ab1 100644 --- a/tests/core/git/gitHubArchiveApi.test.ts +++ b/tests/core/git/gitHubArchiveApi.test.ts @@ -4,7 +4,6 @@ import { buildGitHubMasterArchiveUrl, buildGitHubTagArchiveUrl, checkGitHubResponse, - getArchiveFilename, } from '../../../src/core/git/gitHubArchiveApi.js'; import { parseGitHubRepoInfo } from '../../../src/core/git/gitRemoteParse.js'; import { RepomixError } from '../../../src/shared/errorHandle.js'; @@ -14,25 +13,25 @@ describe('GitHub Archive API', () => { test('should build URL for default branch (HEAD)', () => { const repoInfo = { owner: 'user', repo: 'repo' }; const url = buildGitHubArchiveUrl(repoInfo); - expect(url).toBe('https://github.com/user/repo/archive/HEAD.zip'); + expect(url).toBe('https://github.com/user/repo/archive/HEAD.tar.gz'); }); test('should build URL for specific branch', () => { const repoInfo = { owner: 'user', repo: 'repo', ref: 'develop' }; const url = buildGitHubArchiveUrl(repoInfo); - expect(url).toBe('https://github.com/user/repo/archive/refs/heads/develop.zip'); + expect(url).toBe('https://github.com/user/repo/archive/refs/heads/develop.tar.gz'); }); test('should build URL for commit SHA', () => { const repoInfo = { owner: 'user', repo: 'repo', ref: 'abc123def456' }; const url = buildGitHubArchiveUrl(repoInfo); - expect(url).toBe('https://github.com/user/repo/archive/abc123def456.zip'); + expect(url).toBe('https://github.com/user/repo/archive/abc123def456.tar.gz'); }); test('should build URL for full commit SHA', () => { const repoInfo = { owner: 'user', repo: 'repo', ref: 'abc123def456789012345678901234567890abcd' }; const url = buildGitHubArchiveUrl(repoInfo); - expect(url).toBe('https://github.com/user/repo/archive/abc123def456789012345678901234567890abcd.zip'); + expect(url).toBe('https://github.com/user/repo/archive/abc123def456789012345678901234567890abcd.tar.gz'); }); }); @@ -40,7 +39,7 @@ describe('GitHub Archive API', () => { test('should build URL for master branch fallback', () => { const repoInfo = { owner: 'user', repo: 'repo' }; const url = buildGitHubMasterArchiveUrl(repoInfo); - expect(url).toBe('https://github.com/user/repo/archive/refs/heads/master.zip'); + expect(url).toBe('https://github.com/user/repo/archive/refs/heads/master.tar.gz'); }); test('should return null when ref is specified', () => { @@ -54,7 +53,7 @@ describe('GitHub Archive API', () => { test('should build URL for tag', () => { const repoInfo = { owner: 'user', repo: 'repo', ref: 'v1.0.0' }; const url = buildGitHubTagArchiveUrl(repoInfo); - expect(url).toBe('https://github.com/user/repo/archive/refs/tags/v1.0.0.zip'); + expect(url).toBe('https://github.com/user/repo/archive/refs/tags/v1.0.0.tar.gz'); }); test('should return null for commit SHA', () => { @@ -70,32 +69,6 @@ describe('GitHub Archive API', () => { }); }); - describe('getArchiveFilename', () => { - test('should generate filename for default branch (HEAD)', () => { - const repoInfo = { owner: 'user', repo: 'myrepo' }; - const filename = getArchiveFilename(repoInfo); - expect(filename).toBe('myrepo-HEAD.zip'); - }); - - test('should generate filename for specific branch', () => { - const repoInfo = { owner: 'user', repo: 'myrepo', ref: 'develop' }; - const filename = getArchiveFilename(repoInfo); - expect(filename).toBe('myrepo-develop.zip'); - }); - - test('should generate filename for tag with slash', () => { - const repoInfo = { owner: 'user', repo: 'myrepo', ref: 'release/v1.0' }; - const filename = getArchiveFilename(repoInfo); - expect(filename).toBe('myrepo-v1.0.zip'); - }); - - test('should generate filename for commit SHA', () => { - const repoInfo = { owner: 'user', repo: 'myrepo', ref: 'abc123' }; - const filename = getArchiveFilename(repoInfo); - expect(filename).toBe('myrepo-abc123.zip'); - }); - }); - describe('checkGitHubResponse', () => { test('should not throw for successful response', () => { const mockResponse = new Response('', { status: 200 }); diff --git a/website/server/package-lock.json b/website/server/package-lock.json index 1b3c3a2c5..9c14b8f9e 100644 --- a/website/server/package-lock.json +++ b/website/server/package-lock.json @@ -1784,9 +1784,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2434,9 +2434,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-parser": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", - "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", + "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", "funding": [ { "type": "github", @@ -2445,7 +2445,7 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.0" + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -2797,9 +2797,9 @@ } }, "node_modules/hono": { - "version": "4.11.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", - "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "version": "4.11.10", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.10.tgz", + "integrity": "sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==", "license": "MIT", "engines": { "node": ">=16.9.0"