diff --git a/package-lock.json b/package-lock.json index cf69afa9cc4..f75c960bb11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "@fastify/static": "^10.0.0", + "@inquirer/search": "^4.3.3", "@netlify/ai": "^1.0.1", "@netlify/api": "^15.1.2", "@netlify/blobs": "^11.1.3", @@ -29,7 +30,6 @@ "@netlify/redirect-parser": "^16.1.1", "@netlify/server-dev": "^0.1.1", "@netlify/zip-it-and-ship-it": "^16.2.3", - "@octokit/rest": "^22.0.0", "@opentelemetry/api": "~1.9.0", "@pnpm/tabtab": "^0.5.4", "ansi-escapes": "^7.3.0", @@ -67,7 +67,6 @@ "http-proxy-middleware": "^3.0.5", "https-proxy-agent": "^8.0.0", "inquirer": "^8.2.7", - "inquirer-autocomplete-prompt": "^1.4.0", "is-docker": "^4.0.0", "is-stream": "^4.0.1", "is-wsl": "^3.1.0", @@ -131,7 +130,6 @@ "@types/folder-walker": "^3.2.5", "@types/gitconfiglocal": "^2.0.3", "@types/inquirer": "^9.0.9", - "@types/inquirer-autocomplete-prompt": "^3.0.3", "@types/jsonwebtoken": "^9.0.10", "@types/lodash.shuffle": "^4.2.9", "@types/multiparty": "^4.2.1", @@ -2166,6 +2164,71 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/@inquirer/ansi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", + "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@inquirer/external-editor": { "version": "1.0.2", "license": "MIT", @@ -2201,6 +2264,54 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.3.tgz", + "integrity": "sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", + "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "dev": true, @@ -5718,136 +5829,6 @@ "node": ">= 8" } }, - "node_modules/@octokit/auth-token": { - "version": "6.0.0", - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/core": { - "version": "7.0.5", - "license": "MIT", - "dependencies": { - "@octokit/auth-token": "^6.0.0", - "@octokit/graphql": "^9.0.2", - "@octokit/request": "^10.0.4", - "@octokit/request-error": "^7.0.1", - "@octokit/types": "^15.0.0", - "before-after-hook": "^4.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/endpoint": { - "version": "11.0.1", - "license": "MIT", - "dependencies": { - "@octokit/types": "^15.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/graphql": { - "version": "9.0.2", - "license": "MIT", - "dependencies": { - "@octokit/request": "^10.0.4", - "@octokit/types": "^15.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/openapi-types": { - "version": "26.0.0", - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "13.2.0", - "license": "MIT", - "dependencies": { - "@octokit/types": "^15.0.0" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/plugin-request-log": { - "version": "6.0.0", - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "16.1.0", - "license": "MIT", - "dependencies": { - "@octokit/types": "^15.0.0" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/request": { - "version": "10.0.5", - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^11.0.1", - "@octokit/request-error": "^7.0.1", - "@octokit/types": "^15.0.0", - "fast-content-type-parse": "^3.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/request-error": { - "version": "7.0.1", - "license": "MIT", - "dependencies": { - "@octokit/types": "^15.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/rest": { - "version": "22.0.0", - "license": "MIT", - "dependencies": { - "@octokit/core": "^7.0.2", - "@octokit/plugin-paginate-rest": "^13.0.1", - "@octokit/plugin-request-log": "^6.0.0", - "@octokit/plugin-rest-endpoint-methods": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/types": { - "version": "15.0.0", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^26.0.0" - } - }, "node_modules/@open-draft/deferred-promise": { "version": "2.2.0", "dev": true, @@ -6869,14 +6850,6 @@ "rxjs": "^7.2.0" } }, - "node_modules/@types/inquirer-autocomplete-prompt": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/inquirer": "*" - } - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "dev": true, @@ -9617,10 +9590,6 @@ "dev": true, "license": "MIT" }, - "node_modules/before-after-hook": { - "version": "4.0.0", - "license": "Apache-2.0" - }, "node_modules/better-ajv-errors": { "version": "1.2.0", "license": "Apache-2.0", @@ -12773,20 +12742,6 @@ ], "license": "MIT" }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "license": "MIT" @@ -12908,6 +12863,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "license": "MIT", @@ -14255,127 +14219,6 @@ "node": ">=12.0.0" } }, - "node_modules/inquirer-autocomplete-prompt": { - "version": "1.4.0", - "license": "ISC", - "dependencies": { - "ansi-escapes": "^4.3.1", - "chalk": "^4.0.0", - "figures": "^3.2.0", - "run-async": "^2.4.0", - "rxjs": "^6.6.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "inquirer": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/inquirer-autocomplete-prompt/node_modules/ansi-escapes": { - "version": "4.3.2", - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inquirer-autocomplete-prompt/node_modules/ansi-styles": { - "version": "4.3.0", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/inquirer-autocomplete-prompt/node_modules/chalk": { - "version": "4.1.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/inquirer-autocomplete-prompt/node_modules/color-convert": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/inquirer-autocomplete-prompt/node_modules/escape-string-regexp": { - "version": "1.0.5", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/inquirer-autocomplete-prompt/node_modules/figures": { - "version": "3.2.0", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inquirer-autocomplete-prompt/node_modules/rxjs": { - "version": "6.6.7", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^1.9.0" - }, - "engines": { - "npm": ">=2.0.0" - } - }, - "node_modules/inquirer-autocomplete-prompt/node_modules/supports-color": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer-autocomplete-prompt/node_modules/tslib": { - "version": "1.14.1", - "license": "0BSD" - }, - "node_modules/inquirer-autocomplete-prompt/node_modules/type-fest": { - "version": "0.21.3", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inquirer/node_modules/ansi-escapes": { "version": "4.3.2", "license": "MIT", @@ -20737,10 +20580,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/universal-user-agent": { - "version": "7.0.3", - "license": "ISC" - }, "node_modules/unix-crypt-td-js": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/unix-crypt-td-js/-/unix-crypt-td-js-1.1.4.tgz", diff --git a/package.json b/package.json index 405d08d8492..730b0ef5d95 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ }, "dependencies": { "@fastify/static": "^10.0.0", + "@inquirer/search": "^4.3.3", "@netlify/ai": "^1.0.1", "@netlify/api": "^15.1.2", "@netlify/blobs": "^11.1.3", @@ -71,7 +72,6 @@ "@netlify/redirect-parser": "^16.1.1", "@netlify/server-dev": "^0.1.1", "@netlify/zip-it-and-ship-it": "^16.2.3", - "@octokit/rest": "^22.0.0", "@opentelemetry/api": "~1.9.0", "@pnpm/tabtab": "^0.5.4", "ansi-escapes": "^7.3.0", @@ -109,7 +109,6 @@ "http-proxy-middleware": "^3.0.5", "https-proxy-agent": "^8.0.0", "inquirer": "^8.2.7", - "inquirer-autocomplete-prompt": "^1.4.0", "is-docker": "^4.0.0", "is-stream": "^4.0.1", "is-wsl": "^3.1.0", @@ -169,7 +168,6 @@ "@types/folder-walker": "^3.2.5", "@types/gitconfiglocal": "^2.0.3", "@types/inquirer": "^9.0.9", - "@types/inquirer-autocomplete-prompt": "^3.0.3", "@types/jsonwebtoken": "^9.0.10", "@types/lodash.shuffle": "^4.2.9", "@types/multiparty": "^4.2.1", diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index b0f1ec0302d..963c1e0cd78 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -9,11 +9,10 @@ import { NodeFS, NoopLogger } from '@netlify/build-info/node' import { resolveConfig } from '@netlify/config' import { getGlobalConfigStore, LocalState } from '@netlify/dev-utils' import { isCI } from 'ci-info' +import search from '@inquirer/search' import { Command, CommanderError, Help, Option, type OptionValues } from 'commander' import debug from 'debug' import { findUp } from 'find-up' -import inquirer from 'inquirer' -import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt' import { deepMerge, pick } from '../utils/object-utilities.js' import { getAgent } from '../lib/http-agent.js' @@ -36,6 +35,7 @@ import { logError, } from '../utils/command-helpers.js' import { handleOptionError, isOptionError } from '../utils/command-error-handler.js' +import { EXIT_CODES } from '../utils/exit-codes.js' import type { FeatureFlags } from '../utils/feature-flags.js' import { getFrameworksAPIPaths } from '../utils/frameworks-api.js' import { getSiteByName } from '../utils/get-site.js' @@ -52,8 +52,6 @@ type Analytics = { payload?: Record } -// load the autocomplete plugin -inquirer.registerPrompt('autocomplete', inquirerAutocompletePrompt) /** Netlify CLI client id. Lives in bot@netlify.com */ // TODO: setup client for multiple environments export const CLIENT_ID = 'd6f37de6614df7ae58664cfca524744d73807a377f5ee71f1a254f78412e3750' @@ -149,13 +147,9 @@ async function selectWorkspace(project: Project, filter?: string): Promise + source: (input = '') => (project.workspace?.packages || []) .filter((pkg) => pkg.path.includes(input)) .map((pkg) => ({ @@ -165,8 +159,6 @@ async function selectWorkspace(project: Project, filter?: string): Promise(cached.token, 'GET', '/user') return cached.token } catch { // Token expired or invalid, fall through to re-auth @@ -144,9 +143,8 @@ const selectRepoOwner = async (ghToken: string, repoOwnerFlag?: string): Promise return repoOwnerFlag } - const octokit = new Octokit({ auth: `token ${ghToken}` }) - const { data: user } = await octokit.rest.users.getAuthenticated() - const { data: orgs } = await octokit.rest.orgs.listForAuthenticatedUser() + const user = await requestGitHub(ghToken, 'GET', '/user') + const orgs = await requestGitHub(ghToken, 'GET', '/user/orgs') if (orgs.length === 0) { return user.login diff --git a/src/commands/functions/functions-create.ts b/src/commands/functions/functions-create.ts index 093f829c56b..cc0a713e129 100644 --- a/src/commands/functions/functions-create.ts +++ b/src/commands/functions/functions-create.ts @@ -6,6 +6,7 @@ import path, { dirname, join, relative } from 'path' import process from 'process' import { fileURLToPath, pathToFileURL } from 'url' +import search, { Separator } from '@inquirer/search' import { OptionValues } from 'commander' import { findUp } from 'find-up' import fuzzy from 'fuzzy' @@ -178,7 +179,7 @@ const formatRegistryArrayForInquirer = async function (lang, funcType) { // @ts-expect-error TS(7031) FIXME: Binding element 'languageFromFlag' implicitly has ... Remove this comment to see the full error message const pickTemplate = async function ({ language: languageFromFlag, template: templateFromFlag }, funcType) { const specialCommands = [ - new inquirer.Separator(), + new Separator(), { name: `Clone template from GitHub URL`, value: 'url', @@ -189,7 +190,7 @@ const pickTemplate = async function ({ language: languageFromFlag, template: tem value: 'report', short: 'gh-report', }, - new inquirer.Separator(), + new Separator(), ] let language = languageFromFlag @@ -230,12 +231,9 @@ const pickTemplate = async function ({ language: languageFromFlag, template: tem return match.value } - const { chosenTemplate } = await inquirer.prompt({ - name: 'chosenTemplate', + const chosenTemplate = await search({ message: 'Pick a template', - // @ts-expect-error TS(2769) FIXME: No overload matches this call. - type: 'autocomplete', - source(_answersSoFar: unknown, input: string | undefined) { + source(input: string | undefined) { // if Edge Functions template, don't show url option // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'Separator... Remove this comment to see the full error message const edgeCommands = specialCommands.filter((val) => val.value !== 'url') diff --git a/src/utils/build-info.ts b/src/utils/build-info.ts index 7eca80bb0e1..018090a0da3 100644 --- a/src/utils/build-info.ts +++ b/src/utils/build-info.ts @@ -1,7 +1,7 @@ import type { Settings } from '@netlify/build-info' +import search from '@inquirer/search' import { isCI } from 'ci-info' import fuzzy from 'fuzzy' -import inquirer from 'inquirer' import type BaseCommand from '../commands/base-command.js' import { chalk, log } from './command-helpers.js' @@ -96,12 +96,9 @@ export const detectFrameworkSettings = async ( // multiple matching detectors, make the user choose const scriptInquirerOptions = formatSettingsArrForInquirer(settings, type) - const { chosenSettings } = await inquirer.prompt<{ chosenSettings: Settings }>({ - name: 'chosenSettings', + const chosenSettings = await search({ message: `Multiple possible ${type} commands found`, - // @ts-expect-error is not known by the types as it uses the autocomplete plugin - type: 'autocomplete', - source(_: string, input = '') { + source(input = '') { if (!input) return scriptInquirerOptions // only show filtered results return filterSettings(scriptInquirerOptions, input) diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts index 55b9749687a..19c440b868f 100644 --- a/src/utils/exit-codes.ts +++ b/src/utils/exit-codes.ts @@ -20,6 +20,8 @@ export const EXIT_CODES = { USAGE_ERROR: 2, /** An interactive prompt was required but the session is non-interactive (CI or `--non-interactive`) */ NON_INTERACTIVE_PROMPT: 4, + /** The user cancelled an interactive prompt with Ctrl+C, matching a SIGINT-terminated process */ + INTERRUPTED: 130, } as const export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES] diff --git a/src/utils/gh-auth.ts b/src/utils/gh-auth.ts index 9e459139482..a0100608a49 100644 --- a/src/utils/gh-auth.ts +++ b/src/utils/gh-auth.ts @@ -2,12 +2,12 @@ import http from 'http' import process from 'process' -import { Octokit } from '@octokit/rest' import getPort from 'get-port' import inquirer from 'inquirer' import { log } from './command-helpers.js' import createDeferred from './create-deferred.js' +import { requestGitHub, type GitHubUser } from './github-api.js' import openBrowser from './open-browser.js' const SERVER_PORT = 3000 @@ -104,10 +104,7 @@ const authWithToken = async (): Promise => { throw new Error('GitHub authentication failed') } - const octokit = new Octokit({ auth: `token ${token}` }) - const { - data: { login: user }, - } = await octokit.users.getAuthenticated() + const { login: user } = await requestGitHub(token, 'GET', '/user') return { token, user, provider: 'github' } } diff --git a/src/utils/github-api.ts b/src/utils/github-api.ts new file mode 100644 index 00000000000..4ebb1c2464a --- /dev/null +++ b/src/utils/github-api.ts @@ -0,0 +1,77 @@ +const GITHUB_API_URL = 'https://api.github.com' + +export class GitHubAPIError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + // Keeps error output identical to what Octokit printed before it was replaced. + this.name = 'HttpError' + this.status = status + } +} + +export interface GitHubUser { + login: string +} + +export interface GitHubRepo { + id: number + full_name: string + default_branch: string +} + +export interface GitHubWebhook { + config: { url?: string } +} + +interface GitHubErrorBody { + message?: string + errors?: unknown[] + documentation_url?: string +} + +// Mirrors Octokit's message format, which callers rely on to detect specific validation errors. +const readErrorMessage = async (response: Response): Promise => { + const text = await response.text() + let body: GitHubErrorBody + try { + body = JSON.parse(text) as GitHubErrorBody + } catch { + return text || response.statusText + } + const message = body.message ?? response.statusText + const details = body.errors?.length ? `: ${body.errors.map((error) => JSON.stringify(error)).join(', ')}` : '' + const suffix = body.documentation_url ? ` - ${body.documentation_url}` : '' + return `${message}${details}${suffix}` +} + +export const requestGitHub = async ( + token: string, + method: string, + path: string, + { query, body }: { query?: Record; body?: unknown } = {}, +): Promise => { + const url = new URL(path, GITHUB_API_URL) + for (const [key, value] of Object.entries(query ?? {})) { + url.searchParams.set(key, String(value)) + } + + const response = await fetch(url, { + method, + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${token}`, + 'User-Agent': 'netlify-cli', + ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + + if (!response.ok) { + throw new GitHubAPIError(await readErrorMessage(response), response.status) + } + + const text = await response.text() + return (text ? JSON.parse(text) : undefined) as T +} diff --git a/src/utils/init/config-github.ts b/src/utils/init/config-github.ts index 77169451cfe..5dfa04f9458 100644 --- a/src/utils/init/config-github.ts +++ b/src/utils/init/config-github.ts @@ -1,8 +1,8 @@ import type { NetlifyAPI } from '@netlify/api' -import { Octokit } from '@octokit/rest' import { chalk, logAndThrowError, log } from '../command-helpers.js' import { getGitHubToken as ghauth, type Token } from '../gh-auth.js' +import { requestGitHub, type GitHubRepo, type GitHubUser, type GitHubWebhook } from '../github-api.js' import type { GlobalConfigStore } from '../types.js' import type { BaseCommand } from '../../commands/index.js' @@ -25,11 +25,8 @@ export const getGitHubToken = async ({ globalConfig }: { globalConfig: GlobalCon if (githubToken?.user && githubToken.token) { try { - const octokit = getGitHubClient(githubToken.token) - const { status } = await octokit.rest.users.getAuthenticated() - if (status < 400) { - return githubToken.token - } + await requestGitHub(githubToken.token, 'GET', '/user') + return githubToken.token } catch { log(chalk.yellow('Token is expired or invalid!')) log('Generating a new Github token...') @@ -41,31 +38,26 @@ export const getGitHubToken = async ({ globalConfig }: { globalConfig: GlobalCon return newToken.token } -const getGitHubClient = (token: string): Octokit => - new Octokit({ - auth: `token ${token}`, - }) - const addDeployKey = async ({ api, - octokit, repoName, repoOwner, + token, }: { api: NetlifyAPI - octokit: Octokit repoName: string repoOwner: string + token: string }) => { log('Adding deploy key to repository...') const key = await createDeployKey({ api }) try { - await octokit.repos.createDeployKey({ - title: 'Netlify Deploy Key', - key: key.public_key ?? '', - owner: repoOwner, - repo: repoName, - read_only: true, + await requestGitHub(token, 'POST', `/repos/${repoOwner}/${repoName}/keys`, { + body: { + title: 'Netlify Deploy Key', + key: key.public_key ?? '', + read_only: true, + }, }) log('Deploy key added!') return key @@ -81,20 +73,16 @@ const addDeployKey = async ({ } const getGitHubRepo = async ({ - octokit, repoName, repoOwner, + token, }: { - octokit: Octokit repoName: string repoOwner: string + token: string }) => { try { - const { data } = await octokit.repos.get({ - owner: repoOwner, - repo: repoName, - }) - return data + return await requestGitHub(token, 'GET', `/repos/${repoOwner}/${repoName}`) } catch (error) { let message = formatErrorMessage({ message: 'Failed retrieving GitHub repository information', error }) // @ts-expect-error TS(2571) FIXME: Object is of type 'unknown'. @@ -106,15 +94,18 @@ const getGitHubRepo = async ({ } } -// @ts-expect-error TS(7031) FIXME: Binding element 'deployHook' implicitly has an 'an... Remove this comment to see the full error message -const hookExists = async ({ deployHook, octokit, repoName, repoOwner }) => { +interface DeployHookOptions { + deployHook: string | undefined + repoName: string + repoOwner: string + token: string +} + +const hookExists = async ({ deployHook, repoName, repoOwner, token }: DeployHookOptions) => { try { - const { data: hooks } = await octokit.repos.listWebhooks({ - owner: repoOwner, - repo: repoName, - per_page: PAGE_SIZE, + const hooks = await requestGitHub(token, 'GET', `/repos/${repoOwner}/${repoName}/hooks`, { + query: { per_page: PAGE_SIZE }, }) - // @ts-expect-error TS(7006) FIXME: Parameter 'hook' implicitly has an 'any' type. const exists = hooks.some((hook) => hook.config.url === deployHook) return exists } catch { @@ -123,21 +114,20 @@ const hookExists = async ({ deployHook, octokit, repoName, repoOwner }) => { } } -// @ts-expect-error TS(7031) FIXME: Binding element 'deployHook' implicitly has an 'an... Remove this comment to see the full error message -const addDeployHook = async ({ deployHook, octokit, repoName, repoOwner }) => { - const exists = await hookExists({ deployHook, octokit, repoOwner, repoName }) +const addDeployHook = async ({ deployHook, repoName, repoOwner, token }: DeployHookOptions) => { + const exists = await hookExists({ deployHook, repoOwner, repoName, token }) if (!exists) { try { - await octokit.repos.createWebhook({ - owner: repoOwner, - repo: repoName, - name: 'web', - config: { - url: deployHook, - content_type: 'json', + await requestGitHub(token, 'POST', `/repos/${repoOwner}/${repoName}/hooks`, { + body: { + name: 'web', + config: { + url: deployHook, + content_type: 'json', + }, + events: ['push', 'pull_request', 'delete'], + active: true, }, - events: ['push', 'pull_request', 'delete'], - active: true, }) } catch (error) { // Ignore exists error if the list doesn't return all installed hooks @@ -241,10 +231,9 @@ export const configGithub = async ({ log() - const octokit = getGitHubClient(token) const [deployKey, githubRepo] = await Promise.all([ - addDeployKey({ api, octokit, repoOwner, repoName }), - getGitHubRepo({ octokit, repoOwner, repoName }), + addDeployKey({ api, repoOwner, repoName, token }), + getGitHubRepo({ repoOwner, repoName, token }), ]) const repo = { @@ -267,7 +256,7 @@ export const configGithub = async ({ configPlugins: config.plugins ?? [], pluginsToInstall, }) - await addDeployHook({ deployHook: updatedSite.deploy_hook, octokit, repoOwner, repoName }) + await addDeployHook({ deployHook: updatedSite.deploy_hook, repoOwner, repoName, token }) log() await addNotificationHooks({ siteId, api, token }) } diff --git a/tests/integration/__snapshots__/framework-detection.test.ts.snap b/tests/integration/__snapshots__/framework-detection.test.ts.snap index 829c8d25346..c65e2c4f2ff 100644 --- a/tests/integration/__snapshots__/framework-detection.test.ts.snap +++ b/tests/integration/__snapshots__/framework-detection.test.ts.snap @@ -86,12 +86,30 @@ exports[`frameworks/framework-detection > should print specific error when comma `; exports[`frameworks/framework-detection > should prompt when multiple frameworks are detected 1`] = ` -"? Multiple possible dev commands found (Use arrow keys or type to search) -> [Gatsby] 'npm run develop' - [Create React App] 'npm run start' ? Multiple possible dev commands found - [Gatsby] 'npm run develop' -> [Create React App] 'npm run start' ? Multiple possible dev commands found Create React App-npm run start +"? Multiple possible dev commands found +↑↓ navigate • ⏎ select? Multiple possible dev commands found +> [Gatsby] 'npm run develop' + [Create React App] 'npm run start' + +↑↓ navigate • ⏎ select? Multiple possible dev commands found +> [Gatsby] 'npm run develop' + [Create React App] 'npm run start' + +↑↓ navigate • ⏎ select? Multiple possible dev commands found + [Gatsby] 'npm run develop' +> [Create React App] 'npm run start' + +↑↓ navigate • ⏎ select? Multiple possible dev commands found + [Gatsby] 'npm run develop' +> [Create React App] 'npm run start' + +↑↓ navigate • ⏎ select? Multiple possible dev commands found + [Gatsby] 'npm run develop' +> [Create React App] 'npm run start' + +↑↓ navigate • ⏎ select✔ Multiple possible dev commands found Create React App-npm run start +[?25h Update your netlify.toml to avoid this selection prompt next time: [build] diff --git a/tests/unit/commands/base-command-on-end.test.ts b/tests/unit/commands/base-command-on-end.test.ts new file mode 100644 index 00000000000..a1ce27ce997 --- /dev/null +++ b/tests/unit/commands/base-command-on-end.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import BaseCommand from '../../../src/commands/base-command.js' +import { exit, logError } from '../../../src/utils/command-helpers.js' +import { EXIT_CODES } from '../../../src/utils/exit-codes.js' + +vi.mock('../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../src/utils/command-helpers.js')), + exit: vi.fn(), + logError: vi.fn(), +})) + +vi.mock('../../../src/utils/telemetry/index.js', async () => ({ + ...(await vi.importActual('../../../src/utils/telemetry/index.js')), + track: vi.fn(), +})) + +const createExitPromptError = () => { + const error = new Error('User force closed the prompt with SIGINT') + error.name = 'ExitPromptError' + return error +} + +describe('BaseCommand.onEnd', () => { + beforeEach(() => { + vi.mocked(exit).mockClear() + vi.mocked(logError).mockClear() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + test('exits with the interrupted code without logging when a prompt is cancelled with Ctrl+C', async () => { + await new BaseCommand('netlify').onEnd(createExitPromptError()) + + expect(logError).not.toHaveBeenCalled() + expect(exit).toHaveBeenCalledWith(EXIT_CODES.INTERRUPTED) + }) + + test('logs other errors and exits with code 1', async () => { + const error = new Error('Something broke') + + await new BaseCommand('netlify').onEnd(error) + + expect(logError).toHaveBeenCalledWith(error) + expect(exit).toHaveBeenCalledWith(EXIT_CODES.GENERAL_ERROR) + }) + + test('does not exit when the command succeeded', async () => { + await new BaseCommand('netlify').onEnd() + + expect(exit).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/utils/exit-codes.test.ts b/tests/unit/utils/exit-codes.test.ts index e0401c870fc..2b401542b1f 100644 --- a/tests/unit/utils/exit-codes.test.ts +++ b/tests/unit/utils/exit-codes.test.ts @@ -9,6 +9,7 @@ describe('EXIT_CODES', () => { GENERAL_ERROR: 1, USAGE_ERROR: 2, NON_INTERACTIVE_PROMPT: 4, + INTERRUPTED: 130, }) }) diff --git a/tests/unit/utils/github-api.test.ts b/tests/unit/utils/github-api.test.ts new file mode 100644 index 00000000000..a0fe7d7b461 --- /dev/null +++ b/tests/unit/utils/github-api.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { GitHubAPIError, requestGitHub } from '../../../src/utils/github-api.js' + +const TOKEN = 'gh_test_token' + +const jsonResponse = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('requestGitHub', () => { + test('sends an authenticated request to the GitHub REST API and returns the parsed body', async () => { + const user = { login: 'spongebob' } + vi.mocked(fetch).mockResolvedValue(jsonResponse(200, user)) + + const result = await requestGitHub(TOKEN, 'GET', '/user') + + expect(result).toEqual(user) + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect((url as URL).href).toBe('https://api.github.com/user') + expect(init?.method).toBe('GET') + expect(init?.headers).toMatchObject({ + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${TOKEN}`, + }) + expect(init?.body).toBeUndefined() + }) + + test('encodes query parameters and serializes the JSON body', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(201, { id: 1 })) + const body = { name: 'web', active: true } + + await requestGitHub(TOKEN, 'POST', '/repos/owner/repo/hooks', { query: { per_page: 100 }, body }) + + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect((url as URL).href).toBe('https://api.github.com/repos/owner/repo/hooks?per_page=100') + expect(init?.body).toBe(JSON.stringify(body)) + expect(init?.headers).toMatchObject({ 'Content-Type': 'application/json; charset=utf-8' }) + }) + + test('resolves to undefined for responses without content', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 })) + + await expect(requestGitHub(TOKEN, 'DELETE', '/repos/owner/repo/hooks/1')).resolves.toBeUndefined() + }) + + test('rejects with the status and an Octokit-compatible HttpError message on an error response', async () => { + const documentationUrl = 'https://docs.github.com/rest' + vi.mocked(fetch).mockResolvedValue( + jsonResponse(401, { message: 'Bad credentials', documentation_url: documentationUrl }), + ) + + const error = await requestGitHub(TOKEN, 'GET', '/user').catch((error_: unknown) => error_) + + expect(error).toBeInstanceOf(GitHubAPIError) + expect(error).toMatchObject({ name: 'HttpError', status: 401, message: `Bad credentials - ${documentationUrl}` }) + }) + + test('includes validation error details in the error message', async () => { + const validationError = { resource: 'Hook', code: 'custom', message: 'Hook already exists on this repository' } + vi.mocked(fetch).mockResolvedValue(jsonResponse(422, { message: 'Validation Failed', errors: [validationError] })) + + const error = await requestGitHub(TOKEN, 'POST', '/repos/owner/repo/hooks').catch((error_: unknown) => error_) + + expect(error).toMatchObject({ + status: 422, + message: `Validation Failed: ${JSON.stringify(validationError)}`, + }) + }) + + test('uses the raw body as the message when the error body is not JSON', async () => { + const body = 'oops' + vi.mocked(fetch).mockResolvedValue(new Response(body, { status: 502, statusText: 'Bad Gateway' })) + + const error = await requestGitHub(TOKEN, 'GET', '/user').catch((error_: unknown) => error_) + + expect(error).toMatchObject({ status: 502, message: body }) + }) +}) diff --git a/tests/unit/utils/init/config-github.test.ts b/tests/unit/utils/init/config-github.test.ts index 76538567967..a97416a4b2b 100644 --- a/tests/unit/utils/init/config-github.test.ts +++ b/tests/unit/utils/init/config-github.test.ts @@ -1,5 +1,4 @@ -import { Octokit } from '@octokit/rest' -import { beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import type { GlobalConfigStore } from '../../../../src/utils/types.js' import { getGitHubToken } from '../../../../src/utils/init/config-github.js' @@ -19,19 +18,6 @@ vi.mock('../../../../src/utils/gh-auth.js', () => ({ }), })) -vi.mock('@octokit/rest', () => { - const Client = vi.fn() - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - Client.prototype.rest = { - users: { getAuthenticated: vi.fn() }, - } - - return { - Octokit: Client, - } -}) - describe('getGitHubToken', () => { // mocked configstore let globalConfig: GlobalConfigStore @@ -52,20 +38,20 @@ describe('getGitHubToken', () => { user: 'spongebob', }) - // @ts-expect-error: Missing from type definition - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - Octokit.mockClear() + vi.stubGlobal('fetch', vi.fn()) }) - test('should create a octokit client with the provided token if the token is valid', async () => { - // @ts-expect-error: Missing from type definition - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - Octokit.prototype.rest.users.getAuthenticated.mockImplementation(() => Promise.resolve({ status: 200 })) + afterEach(() => { + vi.unstubAllGlobals() + }) + + test('should keep the stored token when GitHub accepts it', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ login: 'spongebob' }), { status: 200 })) const token = await getGitHubToken({ globalConfig }) - expect(Octokit).toHaveBeenCalledOnce() - expect(Octokit).toHaveBeenCalledWith({ auth: 'token old_token' }) + expect(fetch).toHaveBeenCalledOnce() + expect(vi.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'token old_token' }) expect(token).toBe('old_token') expect(globalConfig.get(`users.spongebob.auth.github`)).toEqual({ @@ -76,19 +62,12 @@ describe('getGitHubToken', () => { }) test('should renew the github token when the provided token is not valid', async () => { - // @ts-expect-error: Missing from type definition - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - Octokit.prototype.rest.users.getAuthenticated.mockImplementation(() => { - const authError = new Error('Bad Credentials') - // @ts-expect-error TS(2339) FIXME: Property 'status' does not exist on type 'Error'. - authError.status = 401 + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ message: 'Bad credentials' }), { status: 401 })) - throw authError - }) const token = await getGitHubToken({ globalConfig }) - expect(Octokit).toHaveBeenCalledOnce() - expect(Octokit).toHaveBeenCalledWith({ auth: 'token old_token' }) + expect(fetch).toHaveBeenCalledOnce() + expect(vi.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'token old_token' }) expect(token).toBe('new_token') expect(globalConfig.get(`users.spongebob.auth.github`)).toEqual({