Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
407 changes: 123 additions & 284 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
21 changes: 9 additions & 12 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -52,8 +52,6 @@ type Analytics = {
payload?: Record<string, unknown>
}

// 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'
Expand Down Expand Up @@ -149,13 +147,9 @@ async function selectWorkspace(project: Project, filter?: string): Promise<strin
)
}

const { result } = await inquirer.prompt({
name: 'result',
// @ts-expect-error(serhalp) -- I think this is because `inquirer-autocomplete-prompt` extends known
// `type`s but TS doesn't know about it
type: 'autocomplete',
return await search({
message: 'Select the project you want to work with',
source: (_unused: unknown, input = '') =>
source: (input = '') =>
(project.workspace?.packages || [])
.filter((pkg) => pkg.path.includes(input))
.map((pkg) => ({
Expand All @@ -165,8 +159,6 @@ async function selectWorkspace(project: Project, filter?: string): Promise<strin
value: pkg.path,
})),
})

return result
}
return selected.path
}
Expand Down Expand Up @@ -474,6 +466,11 @@ export default class BaseCommand extends Command {
)
}

// Inquirer prompts reject with this on Ctrl+C, but the error class isn't exported.
if (error_ instanceof Error && error_.name === 'ExitPromptError') {
return exit(EXIT_CODES.INTERRUPTED)
}

if (error_ !== undefined) {
logError(error_ instanceof Error ? error_ : format(error_))
exit(1)
Expand Down
10 changes: 4 additions & 6 deletions src/commands/create/create-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import fetch from 'node-fetch'

import type { NetlifyAPI } from '@netlify/api'
import { LocalState } from '@netlify/dev-utils'
import { Octokit } from '@octokit/rest'

import { chalk, logAndThrowError, log, logJson, warn, type APIError } from '../../utils/command-helpers.js'
import { ensureNetlifyIgnore } from '../../utils/gitignore.js'
import { getGitHubToken as promptForGitHubToken } from '../../utils/gh-auth.js'
import { requestGitHub, type GitHubUser } from '../../utils/github-api.js'
import { startSpinner, stopSpinner } from '../../lib/spinner.js'
import { isInteractive } from '../../utils/scripted-commands.js'
import { track } from '../../utils/telemetry/index.js'
Expand All @@ -38,8 +38,7 @@ const resolveGitHubToken = async (globalConfig: {
const cached = globalConfig.get(`users.${userId}.auth.github`) as { token?: string; user?: string } | undefined
if (cached?.token) {
try {
const octokit = new Octokit({ auth: `token ${cached.token}` })
await octokit.rest.users.getAuthenticated()
await requestGitHub<GitHubUser>(cached.token, 'GET', '/user')
return cached.token
} catch {
// Token expired or invalid, fall through to re-auth
Expand Down Expand Up @@ -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<GitHubUser>(ghToken, 'GET', '/user')
const orgs = await requestGitHub<GitHubUser[]>(ghToken, 'GET', '/user/orgs')

if (orgs.length === 0) {
return user.login
Expand Down
12 changes: 5 additions & 7 deletions src/commands/functions/functions-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand Down
9 changes: 3 additions & 6 deletions src/utils/build-info.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/utils/exit-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
7 changes: 2 additions & 5 deletions src/utils/gh-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -104,10 +104,7 @@ const authWithToken = async (): Promise<Token> => {
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<GitHubUser>(token, 'GET', '/user')

return { token, user, provider: 'github' }
}
Expand Down
77 changes: 77 additions & 0 deletions src/utils/github-api.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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 <T>(
token: string,
method: string,
path: string,
{ query, body }: { query?: Record<string, string | number>; body?: unknown } = {},
): Promise<T> => {
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
}
Loading
Loading