diff --git a/package-lock.json b/package-lock.json index 3a285888240..ff1c5292850 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,6 @@ "folder-walker": "3.2.0", "fuzzy": "0.1.3", "get-port": "5.1.1", - "gh-release-fetch": "4.0.3", "git-repo-info": "2.1.1", "gitconfiglocal": "2.1.0", "http-proxy": "1.18.1", @@ -81,6 +80,7 @@ "log-update": "7.2.0", "maxstache": "1.0.7", "maxstache-stream": "1.0.4", + "modern-tar": "^0.7.5", "multiparty": "4.2.3", "nanospinner": "1.2.2", "netlify-redirector": "0.5.0", @@ -6692,16 +6692,6 @@ "version": "0.4.1", "license": "MIT" }, - "node_modules/@sindresorhus/is": { - "version": "5.6.0", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", "license": "MIT", @@ -6758,20 +6748,6 @@ "acorn": "^8.9.0" } }, - "node_modules/@szmarczak/http-timer": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.1" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "license": "MIT" - }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "license": "MIT" @@ -6912,10 +6888,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "license": "MIT" - }, "node_modules/@types/http-errors": { "version": "2.0.5", "dev": true, @@ -8292,174 +8264,6 @@ "node": ">=18.0.0" } }, - "node_modules/@xhmikosr/archive-type": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "file-type": "^18.5.0" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress": { - "version": "9.0.1", - "license": "MIT", - "dependencies": { - "@xhmikosr/decompress-tar": "^7.0.0", - "@xhmikosr/decompress-tarbz2": "^7.0.0", - "@xhmikosr/decompress-targz": "^7.0.0", - "@xhmikosr/decompress-unzip": "^6.0.0", - "graceful-fs": "^4.2.11", - "make-dir": "^4.0.0", - "strip-dirs": "^3.0.0" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress-tar": { - "version": "7.0.0", - "license": "MIT", - "dependencies": { - "file-type": "^18.5.0", - "is-stream": "^3.0.0", - "tar-stream": "^3.1.4" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress-tar/node_modules/is-stream": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/decompress-tarbz2": { - "version": "7.0.0", - "license": "MIT", - "dependencies": { - "@xhmikosr/decompress-tar": "^7.0.0", - "file-type": "^18.5.0", - "is-stream": "^3.0.0", - "seek-bzip": "^1.0.6", - "unbzip2-stream": "^1.4.3" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress-tarbz2/node_modules/is-stream": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/decompress-targz": { - "version": "7.0.0", - "license": "MIT", - "dependencies": { - "@xhmikosr/decompress-tar": "^7.0.0", - "file-type": "^18.5.0", - "is-stream": "^3.0.0" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress-targz/node_modules/is-stream": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/decompress-unzip": { - "version": "6.0.0", - "license": "MIT", - "dependencies": { - "file-type": "^18.5.0", - "get-stream": "^6.0.1", - "yauzl": "^2.10.0" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress-unzip/node_modules/get-stream": { - "version": "6.0.1", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/downloader": { - "version": "13.0.1", - "license": "MIT", - "dependencies": { - "@xhmikosr/archive-type": "^6.0.1", - "@xhmikosr/decompress": "^9.0.1", - "content-disposition": "^0.5.4", - "ext-name": "^5.0.0", - "file-type": "^18.5.0", - "filenamify": "^5.1.1", - "get-stream": "^6.0.1", - "got": "^12.6.1", - "merge-options": "^3.0.4", - "p-event": "^5.0.1" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/downloader/node_modules/get-stream": { - "version": "6.0.1", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/downloader/node_modules/p-event": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "p-timeout": "^5.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/downloader/node_modules/p-timeout": { - "version": "5.1.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/abbrev": { "version": "3.0.1", "license": "ISC", @@ -9378,39 +9182,6 @@ "node": ">=8" } }, - "node_modules/cacheable-lookup": { - "version": "7.0.0", - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request": { - "version": "10.2.14", - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "^4.0.2", - "get-stream": "^6.0.1", - "http-cache-semantics": "^4.1.1", - "keyv": "^4.5.3", - "mimic-response": "^4.0.0", - "normalize-url": "^8.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request/node_modules/get-stream": { - "version": "6.0.1", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "license": "MIT", @@ -10120,6 +9891,7 @@ }, "node_modules/content-disposition": { "version": "0.5.4", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -10386,6 +10158,7 @@ }, "node_modules/decompress-response": { "version": "6.0.0", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -10399,6 +10172,7 @@ }, "node_modules/decompress-response/node_modules/mimic-response": { "version": "3.1.0", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10485,6 +10259,7 @@ }, "node_modules/defer-to-connect": { "version": "2.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -11900,27 +11675,6 @@ "node": ">= 0.6" } }, - "node_modules/ext-list": { - "version": "2.2.2", - "license": "MIT", - "dependencies": { - "mime-db": "^1.28.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ext-name": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "ext-list": "^2.0.0", - "sort-keys-length": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/extend": { "version": "3.0.2", "dev": true, @@ -12215,50 +11969,10 @@ "node": ">=16.0.0" } }, - "node_modules/file-type": { - "version": "18.7.0", - "license": "MIT", - "dependencies": { - "readable-web-to-node-stream": "^3.0.2", - "strtok3": "^7.0.0", - "token-types": "^5.0.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/file-uri-to-path": { "version": "1.0.0", "license": "MIT" }, - "node_modules/filename-reserved-regex": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/filenamify": { - "version": "5.1.1", - "license": "MIT", - "dependencies": { - "filename-reserved-regex": "^3.0.0", - "strip-outer": "^2.0.0", - "trim-repeated": "^2.0.0" - }, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fill-range": { "version": "7.1.1", "license": "MIT", @@ -12448,13 +12162,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "2.1.4", - "license": "MIT", - "engines": { - "node": ">= 14.17" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "license": "MIT", @@ -12656,18 +12363,6 @@ "assert-plus": "^1.0.0" } }, - "node_modules/gh-release-fetch": { - "version": "4.0.3", - "license": "MIT", - "dependencies": { - "@xhmikosr/downloader": "^13.0.0", - "node-fetch": "^3.3.1", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.18.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/git-repo-info": { "version": "2.1.1", "license": "MIT", @@ -12820,29 +12515,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/got": { - "version": "12.6.1", - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^5.2.0", - "@szmarczak/http-timer": "^5.0.1", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^10.2.8", - "decompress-response": "^6.0.0", - "form-data-encoder": "^2.1.2", - "get-stream": "^6.0.1", - "http2-wrapper": "^2.1.10", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^3.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, "node_modules/got-cjs": { "version": "12.5.4", "dev": true, @@ -12983,16 +12655,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/got/node_modules/get-stream": { - "version": "6.0.1", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "license": "ISC" @@ -13154,6 +12816,7 @@ }, "node_modules/http-cache-semantics": { "version": "4.2.0", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/http-errors": { @@ -13225,6 +12888,7 @@ }, "node_modules/http2-wrapper": { "version": "2.2.1", + "dev": true, "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", @@ -13666,13 +13330,6 @@ "node": ">=8" } }, - "node_modules/inspect-with-kind": { - "version": "1.0.5", - "license": "ISC", - "dependencies": { - "kind-of": "^6.0.2" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "license": "MIT", @@ -14151,6 +13808,7 @@ }, "node_modules/json-buffer": { "version": "3.0.1", + "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { @@ -14319,18 +13977,12 @@ }, "node_modules/keyv": { "version": "4.5.4", + "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/kuler": { "version": "2.0.0", "license": "MIT" @@ -14825,16 +14477,6 @@ "node": ">=4" } }, - "node_modules/lowercase-keys": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "dev": true, @@ -14880,6 +14522,7 @@ }, "node_modules/make-dir": { "version": "4.0.0", + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -15070,6 +14713,7 @@ }, "node_modules/mime-db": { "version": "1.52.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -15106,16 +14750,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-response": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "9.0.5", "license": "ISC", @@ -15174,6 +14808,15 @@ "ufo": "^1.6.1" } }, + "node_modules/modern-tar": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.5.tgz", + "integrity": "sha512-YTefgdpKKFgoTDbEUqXqgUJct2OG6/4hs4XWLsxcHkDLj/x/V8WmKIRppPnXP5feQ7d1vuYWSp3qKkxfwaFaxA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/module-definition": { "version": "6.0.1", "license": "MIT", @@ -15447,16 +15090,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-url": { - "version": "8.1.1", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npm-normalize-package-bin": { "version": "4.0.0", "dev": true, @@ -15778,13 +15411,6 @@ "dev": true, "license": "MIT" }, - "node_modules/p-cancelable": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, "node_modules/p-event": { "version": "6.0.1", "license": "MIT", @@ -16156,17 +15782,6 @@ "node": ">= 14.16" } }, - "node_modules/peek-readable": { - "version": "5.4.2", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/peek-stream": { "version": "1.1.3", "dev": true, @@ -16703,6 +16318,7 @@ }, "node_modules/quick-lru": { "version": "5.1.1", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -16955,20 +16571,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/readable-web-to-node-stream": { - "version": "3.0.4", - "license": "MIT", - "dependencies": { - "readable-stream": "^4.7.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/readdir-glob": { "version": "1.1.3", "license": "Apache-2.0", @@ -17100,6 +16702,7 @@ }, "node_modules/resolve-alpn": { "version": "1.2.1", + "dev": true, "license": "MIT" }, "node_modules/resolve-from": { @@ -17117,19 +16720,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/responselike": { - "version": "3.0.0", - "license": "MIT", - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/restore-cursor": { "version": "3.1.0", "license": "MIT", @@ -17356,21 +16946,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/seek-bzip": { - "version": "1.0.6", - "license": "MIT", - "dependencies": { - "commander": "^2.8.1" - }, - "bin": { - "seek-bunzip": "bin/seek-bunzip", - "seek-table": "bin/seek-bzip-table" - } - }, - "node_modules/seek-bzip/node_modules/commander": { - "version": "2.20.3", - "license": "MIT" - }, "node_modules/semver": { "version": "7.7.2", "license": "ISC", @@ -17663,33 +17238,6 @@ "atomic-sleep": "^1.0.0" } }, - "node_modules/sort-keys": { - "version": "1.1.2", - "license": "MIT", - "dependencies": { - "is-plain-obj": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sort-keys-length": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "sort-keys": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sort-keys/node_modules/is-plain-obj": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map": { "version": "0.6.1", "license": "BSD-3-Clause", @@ -17922,21 +17470,6 @@ "node": ">=8" } }, - "node_modules/strip-dirs": { - "version": "3.0.0", - "license": "ISC", - "dependencies": { - "inspect-with-kind": "^1.0.5", - "is-plain-obj": "^1.1.0" - } - }, - "node_modules/strip-dirs/node_modules/is-plain-obj": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/strip-final-newline": { "version": "2.0.0", "license": "MIT", @@ -17971,31 +17504,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strip-outer": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strtok3": { - "version": "7.1.1", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^5.1.3" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/stubborn-fs": { "version": "1.2.5" }, @@ -18370,21 +17878,6 @@ "node": ">=0.6" } }, - "node_modules/token-types": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/toml": { "version": "3.0.0", "license": "MIT" @@ -18431,16 +17924,6 @@ "tree-kill": "cli.js" } }, - "node_modules/trim-repeated": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^5.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/triple-beam": { "version": "1.4.1", "license": "MIT", @@ -18665,14 +18148,6 @@ "ulid": "dist/cli.js" } }, - "node_modules/unbzip2-stream": { - "version": "1.4.3", - "license": "MIT", - "dependencies": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, "node_modules/uncrypto": { "version": "0.1.3", "license": "MIT" @@ -18712,6 +18187,7 @@ }, "node_modules/unix-dgram": { "version": "2.0.7", + "hasInstallScript": true, "license": "ISC", "optional": true, "dependencies": { diff --git a/package.json b/package.json index e008e72ce8b..e10c56c53b5 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,6 @@ "folder-walker": "3.2.0", "fuzzy": "0.1.3", "get-port": "5.1.1", - "gh-release-fetch": "4.0.3", "git-repo-info": "2.1.1", "gitconfiglocal": "2.1.0", "http-proxy": "1.18.1", @@ -128,6 +127,7 @@ "log-update": "7.2.0", "maxstache": "1.0.7", "maxstache-stream": "1.0.4", + "modern-tar": "^0.7.5", "multiparty": "4.2.3", "nanospinner": "1.2.2", "netlify-redirector": "0.5.0", @@ -138,8 +138,8 @@ "p-map": "7.0.3", "p-wait-for": "6.0.0", "parallel-transform": "1.2.0", - "pg": "8.20.0", "parse-github-url": "1.0.3", + "pg": "8.20.0", "prettyjson": "1.2.5", "raw-body": "3.0.1", "read-package-up": "12.0.0", @@ -183,8 +183,8 @@ "@types/node": "22.18.11", "@types/node-fetch": "2.6.13", "@types/parallel-transform": "1.1.4", - "@types/pg": "8.18.0", "@types/parse-github-url": "1.0.3", + "@types/pg": "8.18.0", "@types/picomatch": "4.0.2", "@types/prettyjson": "0.0.33", "@types/semver": "7.7.1", diff --git a/src/lib/exec-fetcher.ts b/src/lib/exec-fetcher.ts index 8c861f9b764..e30efff17d9 100644 --- a/src/lib/exec-fetcher.ts +++ b/src/lib/exec-fetcher.ts @@ -1,11 +1,19 @@ +import { execFile as execFileCb } from 'child_process' +import { createReadStream, createWriteStream } from 'fs' +import { mkdir, unlink } from 'fs/promises' import path from 'path' +import { pipeline } from 'stream/promises' import process from 'process' +import { promisify } from 'util' +import { createGunzip } from 'zlib' -import { fetchLatest, fetchVersion, newerVersion, updateAvailable } from 'gh-release-fetch' import { isexe } from 'isexe' +import { unpackTar } from 'modern-tar/fs' +import semver from 'semver' import { NETLIFYDEVWARN, logAndThrowError, getTerminalLink, log } from '../utils/command-helpers.js' -import execa from '../utils/execa.js' + +const execFile = promisify(execFileCb) const isWindows = () => process.platform === 'win32' @@ -13,12 +21,78 @@ const getRepository = ({ packageName }: { packageName: string }) => `netlify/${p export const getExecName = ({ execName }: { execName: string }) => (isWindows() ? `${execName}.exe` : execName) -const getOptions = () => { - // this is used in out CI tests to avoid hitting GitHub API limit - // when calling gh-release-fetch +const getGitHubHeaders = (): Record => { + const headers: Record = { Accept: 'application/vnd.github.v3+json' } if (process.env.NETLIFY_TEST_GITHUB_TOKEN) { - return { - headers: { Authorization: `token ${process.env.NETLIFY_TEST_GITHUB_TOKEN}` }, + headers.Authorization = `token ${process.env.NETLIFY_TEST_GITHUB_TOKEN}` + } + return headers +} + +const resolveLatestTag = async (repository: string): Promise => { + const response = await fetch(`https://api.github.com/repos/${repository}/releases/latest`, { + headers: getGitHubHeaders(), + }) + if (!response.ok) { + throw new Error(`Failed to fetch latest release for ${repository}: ${String(response.status)}`) + } + const data = (await response.json()) as { tag_name: string } + return data.tag_name +} + +const newerVersion = (latestVersion: string, currentVersion: string): boolean => { + if (!latestVersion) return false + if (!currentVersion) return true + const latest = latestVersion.replace(/^v/, '') + const current = currentVersion.replace(/^v/, '') + return semver.gt(latest, current) +} + +const updateAvailable = async (repository: string, currentVersion: string): Promise => { + const latestTag = await resolveLatestTag(repository) + return newerVersion(latestTag, currentVersion) +} + +const downloadAndExtract = async (url: string, destination: string): Promise => { + const response = await fetch(url, { + headers: getGitHubHeaders(), + redirect: 'follow', + }) + if (!response.ok) { + throw Object.assign(new Error(`Download failed: ${String(response.status)}`), { statusCode: response.status }) + } + if (!response.body) { + throw new Error('Empty response body') + } + + await mkdir(destination, { recursive: true }) + + if (url.endsWith('.zip')) { + const tmpFile = path.join(destination, '_download.zip') + const fileStream = createWriteStream(tmpFile) + await pipeline(response.body, fileStream) + try { + if (isWindows()) { + await execFile('powershell.exe', [ + '-NoProfile', + '-Command', + `Expand-Archive -Force -LiteralPath '${tmpFile}' -DestinationPath '${destination}'`, + ]) + } else { + await execFile('unzip', ['-o', tmpFile, '-d', destination]) + } + } finally { + await unlink(tmpFile) + } + } else { + const tmpFile = path.join(destination, '_download.tar.gz') + const fileStream = createWriteStream(tmpFile) + await pipeline(response.body, fileStream) + try { + const extractStream = unpackTar(destination) + await pipeline(createReadStream(tmpFile), createGunzip(), extractStream) + } finally { + await unlink(tmpFile) } } } @@ -35,9 +109,7 @@ const isVersionOutdated = async ({ if (latestVersion) { return newerVersion(latestVersion, currentVersion) } - const options = getOptions() - const outdated = await updateAvailable(getRepository({ packageName }), currentVersion, options) - return outdated + return await updateAvailable(getRepository({ packageName }), currentVersion) } export const shouldFetchLatestVersion = async ({ @@ -62,7 +134,7 @@ export const shouldFetchLatestVersion = async ({ return true } - const { stdout } = await execa(execPath, execArgs) + const { stdout } = await execFile(execPath, execArgs) if (!stdout) { return false @@ -118,23 +190,13 @@ export const fetchLatestVersion = async ({ const arch = getArch() const platform = win ? 'windows' : process.platform const pkgName = `${execName}-${platform}-${arch}.${extension}` + const repository = getRepository({ packageName }) - const release = { - repository: getRepository({ packageName }), - package: pkgName, - destination, - extract: true, - } - - const options = getOptions() - const fetch = latestVersion - ? // @ts-expect-error(serhalp) -- Either `gh-release-fetch` is not typed correctly or `options.headers` is useless - fetchVersion({ ...release, version: latestVersion }, options) - : // @ts-expect-error(serhalp) -- Either `gh-release-fetch` is not typed correctly or `options.version` should be passed - fetchLatest(release, options) + const version = latestVersion ?? (await resolveLatestTag(repository)) + const url = `https://github.com/${repository}/releases/download/${version}/${pkgName}` try { - await fetch + await downloadAndExtract(url, destination) } catch (error_) { if (error_ != null && typeof error_ === 'object' && 'statusCode' in error_ && error_.statusCode === 404) { const createIssueLink = new URL('https://github.com/netlify/cli/issues/new') diff --git a/src/utils/live-tunnel.ts b/src/utils/live-tunnel.ts index 4708bd41647..39918666deb 100644 --- a/src/utils/live-tunnel.ts +++ b/src/utils/live-tunnel.ts @@ -1,6 +1,5 @@ import { platform } from 'process' -import fetch from 'node-fetch' import pWaitFor from 'p-wait-for' import { v4 as uuidv4 } from 'uuid' diff --git a/tests/unit/lib/exec-fetcher.test.ts b/tests/unit/lib/exec-fetcher.test.ts index 7b604800ca8..76d34f3934e 100644 --- a/tests/unit/lib/exec-fetcher.test.ts +++ b/tests/unit/lib/exec-fetcher.test.ts @@ -1,19 +1,14 @@ +import { readFile, rm } from 'fs/promises' +import os from 'os' +import path from 'path' import process from 'process' +import { gzipSync } from 'zlib' -import { fetchLatest } from 'gh-release-fetch' -import { afterAll, afterEach, beforeAll, expect, test, vi, type MockInstance } from 'vitest' +import { packTar } from 'modern-tar' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi, type MockInstance } from 'vitest' import { fetchLatestVersion, getArch, getExecName } from '../../../src/lib/exec-fetcher.js' -vi.mock('gh-release-fetch', async () => { - const actual = await vi.importActual('gh-release-fetch') - - return { - ...actual, - fetchLatest: vi.fn(actual.fetchLatest), - } -}) - let processArchSpy: MockInstance<() => typeof process.arch> let processPlatformSpy: MockInstance<() => typeof process.platform> @@ -22,8 +17,14 @@ beforeAll(() => { processPlatformSpy = vi.spyOn(process, 'platform', 'get') }) +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + afterEach(() => { vi.clearAllMocks() + vi.unstubAllGlobals() + vi.unstubAllEnvs() processArchSpy.mockReset() processPlatformSpy.mockReset() }) @@ -32,90 +33,124 @@ afterAll(() => { vi.restoreAllMocks() }) -test(`should use 386 if process architecture is ia32`, () => { +const FETCH_ARGS = { + packageName: 'traffic-mesh-agent', + execName: 'traffic-mesh', + destination: '', + extension: 'tar.gz', + latestVersion: 'v1.0.0', +} as const + +test('getArch maps ia32 to 386', () => { processArchSpy.mockReturnValue('ia32') expect(getArch()).toBe('386') }) -test(`should use amd64 if process architecture is x64`, () => { +test('getArch maps x64 to amd64', () => { processArchSpy.mockReturnValue('x64') expect(getArch()).toBe('amd64') }) -test(`should append .exe on windows for the executable name`, () => { +test('getExecName appends .exe on Windows', () => { processPlatformSpy.mockReturnValue('win32') - const execName = 'some-binary-file' - expect(getExecName({ execName })).toBe(`${execName}.exe`) + expect(getExecName({ execName: 'some-binary' })).toBe('some-binary.exe') }) -test(`should not append anything on darwin to executable`, () => { +test('getExecName leaves the name unchanged on macOS', () => { processPlatformSpy.mockReturnValue('darwin') - const execName = 'some-binary-file' - expect(getExecName({ execName })).toBe(execName) -}) - -test(`should not append anything on linux to executable`, () => { - processPlatformSpy.mockReturnValue('linux') - const execName = 'some-binary-file' - expect(getExecName({ execName })).toBe(execName) + expect(getExecName({ execName: 'some-binary' })).toBe('some-binary') }) -test('should test if an error is thrown if the cpu architecture and the os are not available', async () => { - processArchSpy.mockReturnValue('x64') - processPlatformSpy.mockReturnValue('win32') - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - vi.mocked(fetchLatest).mockReturnValue(Promise.reject({ statusCode: 404 })) - - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'zip', - }), - ).rejects.toThrowError(/The operating system windows with the CPU architecture amd64 is currently not supported!/) -}) - -test('should provide the error if it is not a 404', async () => { - const error = new Error('Got Rate limited for example') - - vi.mocked(fetchLatest).mockReturnValue(Promise.reject(error)) - - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'zip', - }), - ).rejects.toThrowError(error.message) -}) - -test('should map linux x64 to amd64 arch', async () => { - processArchSpy.mockReturnValue('x64') +test('getExecName leaves the name unchanged on Linux', () => { processPlatformSpy.mockReturnValue('linux') - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - vi.mocked(fetchLatest).mockReturnValue(Promise.reject({ statusCode: 404 })) - - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'zip', - }), - ).rejects.toThrowError(/The operating system linux with the CPU architecture amd64 is currently not supported!/) + expect(getExecName({ execName: 'some-binary' })).toBe('some-binary') }) -test('should not throw when the request passes', async () => { - vi.mocked(fetchLatest).mockReturnValue(Promise.resolve(undefined)) - - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'zip', - }), - ).resolves.not.toThrowError() +describe('fetchLatestVersion', () => { + test('throws a user-friendly error on 404 mentioning OS and arch', async () => { + processArchSpy.mockReturnValue('x64') + processPlatformSpy.mockReturnValue('win32') + vi.mocked(fetch).mockResolvedValue(new Response('Not Found', { status: 404 })) + + await expect(fetchLatestVersion({ ...FETCH_ARGS, extension: 'zip' })).rejects.toThrowError( + /The operating system windows with the CPU architecture amd64 is currently not supported!/, + ) + }) + + test('throws the HTTP status on non-404 download errors', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('Internal Server Error', { status: 500 })) + + await expect(fetchLatestVersion(FETCH_ARGS)).rejects.toThrowError(/Download failed: 500/) + }) + + test('includes the platform and arch in the 404 error for linux-x64', async () => { + processArchSpy.mockReturnValue('x64') + processPlatformSpy.mockReturnValue('linux') + vi.mocked(fetch).mockResolvedValue(new Response('Not Found', { status: 404 })) + + await expect(fetchLatestVersion(FETCH_ARGS)).rejects.toThrowError( + /The operating system linux with the CPU architecture amd64 is currently not supported!/, + ) + }) + + test('resolves the latest tag from GitHub when latestVersion is omitted', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(new Response(JSON.stringify({ tag_name: 'v2.0.0' }))) + .mockResolvedValueOnce(new Response('Not Found', { status: 404 })) + + await expect(fetchLatestVersion({ ...FETCH_ARGS, latestVersion: undefined })).rejects.toThrowError() + + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + 'https://api.github.com/repos/netlify/traffic-mesh-agent/releases/latest', + expect.objectContaining({ headers: expect.any(Object) as unknown }), + ) + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + expect.stringContaining('/releases/download/v2.0.0/'), + expect.any(Object) as unknown, + ) + }) + + test('throws when the GitHub releases API returns an error', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 403 })) + + await expect(fetchLatestVersion({ ...FETCH_ARGS, latestVersion: undefined })).rejects.toThrowError( + /Failed to fetch latest release.*403/, + ) + }) + + test('downloads and extracts a tar.gz release to the destination', async () => { + const destination = path.join(os.tmpdir(), `exec-fetcher-test-${String(Date.now())}`) + const fileContent = 'hello from test binary' + + try { + const tarBuffer = await packTar([ + { header: { name: 'test-binary', size: fileContent.length }, body: fileContent }, + ]) + const gzipped = gzipSync(Buffer.from(tarBuffer)) + + vi.mocked(fetch).mockResolvedValue(new Response(gzipped)) + + await fetchLatestVersion({ ...FETCH_ARGS, destination }) + + const extracted = await readFile(path.join(destination, 'test-binary'), 'utf-8') + expect(extracted).toBe(fileContent) + } finally { + await rm(destination, { recursive: true, force: true }) + } + }) + + test('sends an Authorization header when NETLIFY_TEST_GITHUB_TOKEN is set', async () => { + vi.stubEnv('NETLIFY_TEST_GITHUB_TOKEN', 'test-token-123') + + vi.mocked(fetch).mockResolvedValue(new Response('error', { status: 500 })) + + await expect(fetchLatestVersion(FETCH_ARGS)).rejects.toThrowError() + + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'token test-token-123' }) as unknown, + }), + ) + }) }) diff --git a/tests/unit/utils/live-tunnel.test.ts b/tests/unit/utils/live-tunnel.test.ts new file mode 100644 index 00000000000..5c1e215901c --- /dev/null +++ b/tests/unit/utils/live-tunnel.test.ts @@ -0,0 +1,183 @@ +import { EventEmitter } from 'events' + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { startLiveTunnel, getLiveTunnelSlug } from '../../../src/utils/live-tunnel.js' +import type { LocalState } from '../../../src/utils/types.js' + +vi.mock('../../../src/lib/exec-fetcher.js', () => ({ + shouldFetchLatestVersion: vi.fn().mockResolvedValue(false), + fetchLatestVersion: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../../../src/lib/settings.js', () => ({ + getPathInHome: vi.fn((...args: string[][]) => `/mock/home/${args.flat().join('/')}`), +})) + +vi.mock('../../../src/utils/execa.js', () => ({ + default: vi.fn(() => new EventEmitter()), +})) + +vi.mock('../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual( + '../../../src/utils/command-helpers.js', + )), + exit: vi.fn((code?: number) => { + throw new Error(`process.exit(${String(code)})`) + }), + log: vi.fn(), +})) + +vi.mock('p-wait-for', () => ({ + default: vi.fn(async (fn: () => Promise) => { + let ready = false + while (!ready) { + ready = await fn() + } + }), +})) + +const jsonResponse = (status: number, body: Record) => new Response(JSON.stringify(body), { status }) + +const TUNNEL_ARGS = { + siteId: 'site-456', + netlifyApiToken: 'fake-token', + localPort: 8888, + slug: 'test', +} as const + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('startLiveTunnel', () => { + const mockSessionCreatedThenOnline = (sessionId = 'session-123', sessionUrl = 'https://test.netlify.live') => { + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse(201, { id: sessionId, session_url: sessionUrl, state: 'connecting' })) + .mockResolvedValueOnce(jsonResponse(200, { id: sessionId, state: 'online' })) + } + + test('returns the session URL', async () => { + mockSessionCreatedThenOnline('session-123', 'https://test--my-site.netlify.live') + + const result = await startLiveTunnel(TUNNEL_ARGS) + + expect(result).toBe('https://test--my-site.netlify.live') + }) + + test('creates a session via the Netlify API with auth', async () => { + mockSessionCreatedThenOnline() + + await startLiveTunnel(TUNNEL_ARGS) + + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + 'https://api.netlify.com/api/v1/live_sessions?site_id=site-456&slug=test', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Bearer fake-token' }) as unknown, + }), + ) + }) + + test('polls the session until it is online', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + jsonResponse(201, { id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), + ) + .mockResolvedValueOnce(jsonResponse(200, { id: 'session-123', state: 'booting' })) + .mockResolvedValueOnce(jsonResponse(200, { id: 'session-123', state: 'online' })) + + await startLiveTunnel(TUNNEL_ARGS) + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(3) + }) + + test('spawns the tunnel client binary with the session ID and port', async () => { + const { default: execa } = await import('../../../src/utils/execa.js') + mockSessionCreatedThenOnline('session-abc') + + await startLiveTunnel({ ...TUNNEL_ARGS, localPort: 3000 }) + + expect(vi.mocked(execa)).toHaveBeenCalledWith( + '/mock/home/tunnel/bin/live-tunnel-client', + ['connect', '-s', 'session-abc', '-t', 'fake-token', '-l', '3000'], + { stdio: 'inherit' }, + ) + }) + + test('installs the tunnel client when a new version is available', async () => { + const { shouldFetchLatestVersion, fetchLatestVersion } = await import('../../../src/lib/exec-fetcher.js') + vi.mocked(shouldFetchLatestVersion).mockResolvedValueOnce(true) + mockSessionCreatedThenOnline() + + await startLiveTunnel(TUNNEL_ARGS) + + expect(vi.mocked(fetchLatestVersion)).toHaveBeenCalledWith( + expect.objectContaining({ packageName: 'live-tunnel-client', execName: 'live-tunnel-client' }), + ) + }) + + test('skips installing the tunnel client when already up to date', async () => { + const { fetchLatestVersion } = await import('../../../src/lib/exec-fetcher.js') + mockSessionCreatedThenOnline() + + await startLiveTunnel(TUNNEL_ARGS) + + expect(vi.mocked(fetchLatestVersion)).not.toHaveBeenCalled() + }) + + test('exits when siteId is missing', async () => { + await expect(startLiveTunnel({ ...TUNNEL_ARGS, siteId: undefined })).rejects.toThrowError('process.exit(1)') + }) + + test('exits when netlifyApiToken is missing', async () => { + await expect(startLiveTunnel({ ...TUNNEL_ARGS, netlifyApiToken: null })).rejects.toThrowError('process.exit(1)') + }) + + test('throws the API error message when session creation fails', async () => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(422, { message: 'Slug already taken' })) + + await expect(startLiveTunnel({ ...TUNNEL_ARGS, slug: 'taken-slug' })).rejects.toThrowError('Slug already taken') + }) + + test('throws the API error message when polling fails', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + jsonResponse(201, { id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), + ) + .mockResolvedValueOnce(jsonResponse(500, { message: 'Internal server error' })) + + await expect(startLiveTunnel(TUNNEL_ARGS)).rejects.toThrowError('Internal server error') + }) +}) + +describe('getLiveTunnelSlug', () => { + const createMockState = (slug?: string) => { + const state = { get: vi.fn().mockReturnValue(slug), set: vi.fn() } + return state as typeof state & LocalState + } + + test('uses the override slug without reading state', () => { + const state = createMockState() + expect(getLiveTunnelSlug(state, 'custom-slug')).toBe('custom-slug') + expect(state.get).not.toHaveBeenCalled() + }) + + test('returns the existing slug from state', () => { + const state = createMockState('existing-slug') + expect(getLiveTunnelSlug(state, undefined)).toBe('existing-slug') + }) + + test('generates a hex slug and persists it when none exists', () => { + const state = createMockState(undefined) + const slug = getLiveTunnelSlug(state, undefined) + expect(slug).toMatch(/^[\da-f]{8}$/) + expect(state.set).toHaveBeenCalledWith('liveTunnelSlug', slug) + }) +})