diff --git a/docs/commands/login.md b/docs/commands/login.md index 2fdd7b4865b..6304e5dceb4 100644 --- a/docs/commands/login.md +++ b/docs/commands/login.md @@ -19,7 +19,10 @@ netlify login **Flags** +- `check` (*string*) - Check the status of a login ticket created with --request +- `json` (*boolean*) - Output as JSON (for use with --request or --check) - `new` (*boolean*) - Login to new Netlify account +- `request` (*string*) - Create a login ticket for agent/human-in-the-loop auth - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 99e1eb360e5..d710b892c96 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -51,7 +51,7 @@ type Analytics = { inquirer.registerPrompt('autocomplete', inquirerAutocompletePrompt) /** Netlify CLI client id. Lives in bot@netlify.com */ // TODO: setup client for multiple environments -const CLIENT_ID = 'd6f37de6614df7ae58664cfca524744d73807a377f5ee71f1a254f78412e3750' +export const CLIENT_ID = 'd6f37de6614df7ae58664cfca524744d73807a377f5ee71f1a254f78412e3750' const NANO_SECS_TO_MSECS = 1e6 /** The fallback width for the help terminal */ @@ -175,6 +175,26 @@ export type BaseOptionValues = { verbose?: boolean } +export function storeToken( + globalConfig: Awaited>, + { userId, name, email, accessToken }: { userId: string; name?: string; email?: string; accessToken: string }, +) { + const userData = merge(globalConfig.get(`users.${userId}`), { + id: userId, + name, + email, + auth: { + token: accessToken, + github: { + user: undefined, + token: undefined, + }, + }, + }) + globalConfig.set('userId', userId) + globalConfig.set(`users.${userId}`, userData) +} + /** Base command class that provides tracking and config initialization */ export default class BaseCommand extends Command { /** The netlify object inside each command with the state */ @@ -441,6 +461,9 @@ export default class BaseCommand extends Command { log(`Opening ${authLink}`) await openBrowser({ url: authLink }) + log() + log(`To request authorization from a human, run: ${chalk.cyanBright('netlify login --request ""')}`) + log() const accessToken = await pollForToken({ api: this.netlify.api, @@ -448,23 +471,11 @@ export default class BaseCommand extends Command { }) const { email, full_name: name, id: userId } = await this.netlify.api.getCurrentUser() + if (!userId) { + return logAndThrowError('Could not retrieve user ID from Netlify API') + } - const userData = merge(this.netlify.globalConfig.get(`users.${userId}`), { - id: userId, - name, - email, - auth: { - token: accessToken, - github: { - user: undefined, - token: undefined, - }, - }, - }) - // Set current userId - this.netlify.globalConfig.set('userId', userId) - // Set user data - this.netlify.globalConfig.set(`users.${userId}`, userData) + storeToken(this.netlify.globalConfig, { userId, name, email, accessToken }) await identify({ name, diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 11aa119a2a7..e97caff7d26 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -11,6 +11,9 @@ export const createLoginCommand = (program: BaseCommand) => Opens a web browser to acquire an OAuth token.`, ) .option('--new', 'Login to new Netlify account') + .option('--request ', 'Create a login ticket for agent/human-in-the-loop auth') + .option('--check ', 'Check the status of a login ticket created with --request') + .option('--json', 'Output as JSON (for use with --request or --check)') .addHelpText('after', () => { const docsUrl = 'https://docs.netlify.com/cli/get-started/#authentication' return ` diff --git a/src/commands/login/login-check.ts b/src/commands/login/login-check.ts new file mode 100644 index 00000000000..24afd2b25e9 --- /dev/null +++ b/src/commands/login/login-check.ts @@ -0,0 +1,56 @@ +import { NetlifyAPI } from '@netlify/api' +import { getGlobalConfigStore } from '@netlify/dev-utils' +import { OptionValues } from 'commander' + +import { log, logAndThrowError, logJson, USER_AGENT } from '../../utils/command-helpers.js' +import { storeToken } from '../base-command.js' + +export const loginCheck = async (options: OptionValues) => { + const ticketId = options.check as string + + const api = new NetlifyAPI('', { userAgent: USER_AGENT }) + + let ticket: { authorized?: boolean } + try { + ticket = await api.showTicket({ ticketId }) + } catch { + logJson({ status: 'denied' }) + log('Status: denied') + return + } + + if (!ticket.authorized) { + logJson({ status: 'pending' }) + log('Status: pending') + return + } + + const tokenResponse = await api.exchangeTicket({ ticketId }) + const accessToken = tokenResponse.access_token + if (!accessToken) { + return logAndThrowError('Could not retrieve access token') + } + + api.accessToken = accessToken + const user = await api.getCurrentUser() + if (!user.id) { + return logAndThrowError('Could not retrieve user ID from Netlify API') + } + + const globalConfig = await getGlobalConfigStore() + storeToken(globalConfig, { + userId: user.id, + name: user.full_name, + email: user.email, + accessToken, + }) + + logJson({ + status: 'authorized', + user: { id: user.id, email: user.email, name: user.full_name }, + }) + + log('Status: authorized') + log(`Name: ${user.full_name ?? ''}`) + log(`Email: ${user.email ?? ''}`) +} diff --git a/src/commands/login/login-request.ts b/src/commands/login/login-request.ts new file mode 100644 index 00000000000..3ebcd251b47 --- /dev/null +++ b/src/commands/login/login-request.ts @@ -0,0 +1,25 @@ +import { NetlifyAPI } from '@netlify/api' + +import { log, logAndThrowError, logJson, USER_AGENT } from '../../utils/command-helpers.js' +import { CLIENT_ID } from '../base-command.js' + +export const loginRequest = async () => { + const webUI = process.env.NETLIFY_WEB_UI || 'https://app.netlify.com' + + const api = new NetlifyAPI('', { userAgent: USER_AGENT }) + + const ticket = await api.createTicket({ clientId: CLIENT_ID }) + + if (!ticket.id) { + return logAndThrowError('Failed to create login ticket') + } + const ticketId = ticket.id + const url = `${webUI}/authorize?response_type=ticket&ticket=${ticketId}` + + logJson({ ticket_id: ticketId, url, check_command: `netlify login --check ${ticketId}` }) + + log(`Ticket ID: ${ticketId}`) + log(`Authorize URL: ${url}`) + log() + log(`After authorizing, run: netlify login --check ${ticketId}`) +} diff --git a/src/commands/login/login.ts b/src/commands/login/login.ts index c4ca33f562b..fe232dc9621 100644 --- a/src/commands/login/login.ts +++ b/src/commands/login/login.ts @@ -1,6 +1,6 @@ import { OptionValues } from 'commander' -import { chalk, exit, getToken, log } from '../../utils/command-helpers.js' +import { chalk, exit, getToken, log, logAndThrowError } from '../../utils/command-helpers.js' import { TokenLocation } from '../../utils/types.js' import BaseCommand from '../base-command.js' @@ -18,6 +18,22 @@ const msg = function (location: TokenLocation) { } export const login = async (options: OptionValues, command: BaseCommand) => { + if (options.request && options.check) { + return logAndThrowError('`--request` and `--check` are mutually exclusive') + } + + if (options.request) { + const { loginRequest } = await import('./login-request.js') + await loginRequest() + return + } + + if (options.check) { + const { loginCheck } = await import('./login-check.js') + await loginCheck(options) + return + } + const [accessToken, location] = await getToken() command.setAnalyticsPayload({ new: options.new }) diff --git a/src/commands/main.ts b/src/commands/main.ts index cc8724ea8d4..5b1e95b4ed9 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -255,7 +255,10 @@ export const createMainCommand = (): BaseCommand => { const cliDocsEntrypointUrl = 'https://developers.netlify.com/cli' const docsUrl = 'https://docs.netlify.com' const bugsUrl = pkg.bugs?.url ?? '' - return `→ For more help with the CLI, visit ${NETLIFY_CYAN( + return `To get started run: ${NETLIFY_CYAN('netlify login')} +To ask a human for credentials: ${NETLIFY_CYAN('netlify login --request ')} + +→ For more help with the CLI, visit ${NETLIFY_CYAN( terminalLink(cliDocsEntrypointUrl, cliDocsEntrypointUrl, { fallback: false }), )} → For help with Netlify, visit ${NETLIFY_CYAN(terminalLink(docsUrl, docsUrl, { fallback: false }))} diff --git a/tests/integration/commands/help/__snapshots__/help.test.ts.snap b/tests/integration/commands/help/__snapshots__/help.test.ts.snap index c564dcaa2d9..4422d3af839 100644 --- a/tests/integration/commands/help/__snapshots__/help.test.ts.snap +++ b/tests/integration/commands/help/__snapshots__/help.test.ts.snap @@ -41,6 +41,9 @@ COMMANDS $ unlink Unlink a local folder from a Netlify project $ watch Watch for project deploy to finish +To get started run: netlify login +To ask a human for credentials: netlify login --request + → For more help with the CLI, visit https://developers.netlify.com/cli → For help with Netlify, visit https://docs.netlify.com → To report a CLI bug, visit https://github.com/netlify/cli/issues" diff --git a/tests/unit/commands/login/login-check.test.ts b/tests/unit/commands/login/login-check.test.ts new file mode 100644 index 00000000000..e33ed8b6b0e --- /dev/null +++ b/tests/unit/commands/login/login-check.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + showTicket: vi.fn(), + exchangeTicket: vi.fn(), + getCurrentUser: vi.fn(), + globalConfigGet: vi.fn().mockReturnValue({}), + globalConfigSet: vi.fn(), +})) + +vi.mock('@netlify/api', () => ({ + NetlifyAPI: vi.fn().mockImplementation(() => ({ + showTicket: mocks.showTicket, + exchangeTicket: mocks.exchangeTicket, + getCurrentUser: mocks.getCurrentUser, + set accessToken(_val: string) { + // no-op for test + }, + })), +})) + +vi.mock('@netlify/dev-utils', () => ({ + getGlobalConfigStore: vi.fn().mockResolvedValue({ + get: mocks.globalConfigGet, + set: mocks.globalConfigSet, + }), +})) + +import { loginCheck } from '../../../../src/commands/login/login-check.js' + +describe('loginCheck', () => { + let stdoutOutput: string[] + const originalWrite = process.stdout.write.bind(process.stdout) + + beforeEach(() => { + stdoutOutput = [] + process.stdout.write = vi.fn((chunk: string) => { + stdoutOutput.push(chunk) + return true + }) as typeof process.stdout.write + }) + + afterEach(() => { + process.stdout.write = originalWrite + }) + + test('outputs pending when ticket is not authorized', async () => { + mocks.showTicket.mockResolvedValue({ authorized: false }) + + await loginCheck({ check: 'ticket-abc' }) + + const output = stdoutOutput.join('') + expect(output).toContain('Status: pending') + }) + + test('outputs denied when showTicket throws', async () => { + mocks.showTicket.mockRejectedValue(new Error('Not found')) + + await loginCheck({ check: 'ticket-bad' }) + + const output = stdoutOutput.join('') + expect(output).toContain('Status: denied') + }) + + test('outputs authorized and stores token when ticket is authorized', async () => { + mocks.showTicket.mockResolvedValue({ authorized: true }) + mocks.exchangeTicket.mockResolvedValue({ access_token: 'test-token-xyz' }) + mocks.getCurrentUser.mockResolvedValue({ + id: 'user-1', + email: 'test@example.com', + full_name: 'Test User', + }) + + await loginCheck({ check: 'ticket-ok' }) + + const output = stdoutOutput.join('') + expect(output).toContain('Status: authorized') + expect(output).toContain('Name: Test User') + expect(output).toContain('Email: test@example.com') + + expect(mocks.globalConfigSet).toHaveBeenCalledWith('userId', 'user-1') + expect(mocks.globalConfigSet).toHaveBeenCalledWith( + 'users.user-1', + expect.objectContaining({ + id: 'user-1', + email: 'test@example.com', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + auth: expect.objectContaining({ token: 'test-token-xyz' }), + }), + ) + }) +}) diff --git a/tests/unit/commands/login/login-request.test.ts b/tests/unit/commands/login/login-request.test.ts new file mode 100644 index 00000000000..14ff51e57cb --- /dev/null +++ b/tests/unit/commands/login/login-request.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + createTicket: vi.fn().mockResolvedValue({ id: 'test-ticket-123' }), +})) + +vi.mock('@netlify/api', () => ({ + NetlifyAPI: vi.fn().mockImplementation(() => ({ + createTicket: mocks.createTicket, + })), +})) + +import { loginRequest } from '../../../../src/commands/login/login-request.js' + +describe('loginRequest', () => { + let stdoutOutput: string[] + const originalEnv = { ...process.env } + const originalWrite = process.stdout.write.bind(process.stdout) + + beforeEach(() => { + stdoutOutput = [] + process.stdout.write = vi.fn((chunk: string) => { + stdoutOutput.push(chunk) + return true + }) as typeof process.stdout.write + }) + + afterEach(() => { + process.env = { ...originalEnv } + process.stdout.write = originalWrite + }) + + test('outputs ticket info as plain text', async () => { + await loginRequest() + + const output = stdoutOutput.join('') + expect(output).toContain('Ticket ID: test-ticket-123') + expect(output).toContain( + 'Authorize URL: https://app.netlify.com/authorize?response_type=ticket&ticket=test-ticket-123', + ) + expect(output).toContain('netlify login --check test-ticket-123') + }) + + test('uses custom NETLIFY_WEB_UI when set', async () => { + process.env.NETLIFY_WEB_UI = 'https://custom.netlify.com' + + await loginRequest() + + const output = stdoutOutput.join('') + expect(output).toContain('https://custom.netlify.com/authorize?response_type=ticket&ticket=test-ticket-123') + }) +})