Skip to content
Open
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
3 changes: 3 additions & 0 deletions docs/commands/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 28 additions & 17 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -175,6 +175,26 @@ export type BaseOptionValues = {
verbose?: boolean
}

export function storeToken(
globalConfig: Awaited<ReturnType<typeof getGlobalConfigStore>>,
{ 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,
},
},
})
Comment on lines +182 to +193
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

lodash merge undefined values overwrite destination property

💡 Result:

_.merge() is designed to skip undefined values from the source when the destination already has a value: “Source properties that resolve to undefined are skipped if a destination value exists.” [1]. So { a: undefined } in the source will not overwrite an existing dest.a.

Also, _.mergeWith() won’t help you force “write undefined”, because if the customizer returns undefined, Lodash treats that as “fall back to normal merge behavior” [1]—which still skips undefined.

Workarounds (pick one):

  • Use null (or another sentinel) instead of undefined when you want to clear a value, so merge will overwrite.
  • If you want assignment semantics (including overwriting with undefined), use a non-merge approach (e.g., Object.assign / spread) for those fields (note: shallow) [1].
  • Post-process after merge: set keys that are explicitly undefined in the source onto the result (works for shallow keys; for deep paths you’d need to walk the object).

Example (shallow keys):

const out = _.merge({}, dest, src);
for (const k of Object.keys(src)) {
  if (src[k] === undefined) out[k] = undefined;
}

Sources: Lodash docs for _.merge / _.mergeWith behavior [1]; discussion/example of merge() skipping undefined [2].

[1] (lodash.com)
[2] (onlinecode.org)

Citations:


Reset token-reset semantics explicitly instead of relying on merge with undefined.

lodash/merge skips source properties that are undefined when a destination value already exists. This means stale auth.github credentials will not be cleared by the current code, creating a security risk where old tokens can survive token-reset operations.

Replace the merge with explicit property assignment:

Explicit overwrite approach
-  const userData = merge(globalConfig.get(`users.${userId}`), {
+  const existingUser = globalConfig.get(`users.${userId}`) ?? {}
+  const userData = {
+    ...existingUser,
     id: userId,
     name,
     email,
     auth: {
+      ...(existingUser.auth ?? {}),
       token: accessToken,
       github: {
         user: undefined,
         token: undefined,
       },
     },
-  })
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const userData = merge(globalConfig.get(`users.${userId}`), {
id: userId,
name,
email,
auth: {
token: accessToken,
github: {
user: undefined,
token: undefined,
},
},
})
const existingUser = globalConfig.get(`users.${userId}`) ?? {}
const userData = {
...existingUser,
id: userId,
name,
email,
auth: {
...(existingUser.auth ?? {}),
token: accessToken,
github: {
user: undefined,
token: undefined,
},
},
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/base-command.ts` around lines 182 - 193, The code uses
lodash/merge to build userData which leaves existing auth.github.user and
auth.github.token intact when source values are undefined; replace the
merge-based construction in the userData assignment with an explicit object
creation/assignment that sets auth.github.user and auth.github.token to null (or
empty string) to ensure prior credentials are cleared—locate the userData
variable assignment and the call to merge(globalConfig.get(`users.${userId}`),
...) and change it to build a plain object that explicitly overwrites auth and
auth.github fields (including setting github.user and github.token to null/''),
then write that object back to globalConfig as before.

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 */
Expand Down Expand Up @@ -441,30 +461,21 @@ 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 "<msg>"')}`)
log()

const accessToken = await pollForToken({
api: this.netlify.api,
ticket,
})

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,
Expand Down
3 changes: 3 additions & 0 deletions src/commands/login/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <message>', 'Create a login ticket for agent/human-in-the-loop auth')
.option('--check <ticket-id>', '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 `
Expand Down
56 changes: 56 additions & 0 deletions src/commands/login/login-check.ts
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +14 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/commands/login/login-check.ts | head -40

Repository: netlify/cli

Length of output: 1434


🏁 Script executed:

fd -t f "node_modules/@netlify/api" -o "package.json" | head -5

Repository: netlify/cli

Length of output: 198


🏁 Script executed:

rg "showTicket" --type ts --type tsx -A 5 -B 5 | head -50

Repository: netlify/cli

Length of output: 82


🏁 Script executed:

rg "showTicket" --type ts -A 5 -B 5

Repository: netlify/cli

Length of output: 4326


🏁 Script executed:

cat package.json | jq '.dependencies."@netlify/api"' 2>/dev/null || grep '@netlify/api' package.json

Repository: netlify/cli

Length of output: 64


🏁 Script executed:

cat package.json | grep -A 5 -B 5 '"@netlify/api"'

Repository: netlify/cli

Length of output: 371


🏁 Script executed:

cat -n tests/unit/commands/login/login-check.test.ts | head -80

Repository: netlify/cli

Length of output: 2909


🏁 Script executed:

rg "showTicket" --type ts -B 3 -A 10 | grep -A 10 "catch"

Repository: netlify/cli

Length of output: 583


🏁 Script executed:

rg "NetlifyAPI" --type ts -A 20 | head -100

Repository: netlify/cli

Length of output: 6503


🏁 Script executed:

rg "APIError" --type ts -B 3 -A 3

Repository: netlify/cli

Length of output: 18132


🏁 Script executed:

cat -n src/utils/command-helpers.ts | grep -A 10 -B 5 "APIError"

Repository: netlify/cli

Length of output: 662


Distinguish auth/ticket errors from transport/server failures in error handling.

The catch block currently treats all errors from showTicket() as denied, including network failures and server errors. Use the status property of APIError to map only specific status codes (like 404 for not found, 401 for unauthorized) to denied, and rethrow or handle other errors appropriately. This prevents masking server/transport failures and allows for proper error diagnosis.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/login/login-check.ts` around lines 16 - 21, The catch currently
treats any error from api.showTicket() as an auth denial; change it to
distinguish APIError transport/server failures by checking the error type (e.g.,
instanceof APIError) and inspecting error.status: map only auth-related statuses
(401, 404, etc.) to the existing denied flow (call logJson({status: 'denied'})
and log('Status: denied')), but for other statuses or non-APIError exceptions
rethrow or surface the error (so network/server failures are not masked). Update
the catch around showTicket to perform this conditional handling while keeping
the denied branch for the specific status codes and preserving existing logging
functions (logJson, log).

}

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 ?? ''}`)
}
25 changes: 25 additions & 0 deletions src/commands/login/login-request.ts
Original file line number Diff line number Diff line change
@@ -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}`)
}
18 changes: 17 additions & 1 deletion src/commands/login/login.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
Comment on lines +25 to +34
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /repo && git ls-files | grep -E "(login|command)" | head -20

Repository: netlify/cli

Length of output: 110


🏁 Script executed:

cat -n src/commands/login/login.ts | head -50

Repository: netlify/cli

Length of output: 1902


🏁 Script executed:

fd -t f "login-request\|login-check" --type f

Repository: netlify/cli

Length of output: 37


🏁 Script executed:

find . -name "login-request.js" -o -name "login-request.ts"

Repository: netlify/cli

Length of output: 92


🏁 Script executed:

find . -name "login-check.js" -o -name "login-check.ts"

Repository: netlify/cli

Length of output: 90


🏁 Script executed:

rg "export.*loginRequest" -t ts -t js

Repository: netlify/cli

Length of output: 132


🏁 Script executed:

rg "export.*loginCheck" -t ts -t js

Repository: netlify/cli

Length of output: 149


🏁 Script executed:

cat -n src/commands/login/login-request.ts

Repository: netlify/cli

Length of output: 1077


🏁 Script executed:

cat -n src/commands/login/login-check.ts

Repository: netlify/cli

Length of output: 1949


🏁 Script executed:

rg "command\.netlify" -t ts -A 3 -B 1 | head -50

Repository: netlify/cli

Length of output: 2979


🏁 Script executed:

cat -n src/commands/base-command.ts | head -100

Repository: netlify/cli

Length of output: 4263


🏁 Script executed:

rg "apiOpts|globalConfig" -t ts -B 2 -A 2 | head -60

Repository: netlify/cli

Length of output: 4043


🏁 Script executed:

rg "new NetlifyAPI" -t ts -A 2 -B 2 | head -80

Repository: netlify/cli

Length of output: 1117


🏁 Script executed:

rg "apiOpts.*=" -t ts -B 2 -A 2 | grep -A 5 "const apiOpts"

Repository: netlify/cli

Length of output: 413


🏁 Script executed:

cat -n src/commands/base-command.ts | sed -n '200,300p'

Repository: netlify/cli

Length of output: 4587


🏁 Script executed:

rg "this.netlify" -t ts -A 2 | head -80

Repository: netlify/cli

Length of output: 1004


🏁 Script executed:

rg "type NetlifyOptions|interface NetlifyOptions" -t ts -A 20

Repository: netlify/cli

Length of output: 1134


🏁 Script executed:

cat -n src/commands/types.ts | head -80

Repository: netlify/cli

Length of output: 108


🏁 Script executed:

rg "apiOpts" src/commands/base-command.ts -B 5 -A 5

Repository: netlify/cli

Length of output: 918


🏁 Script executed:

cat -n src/commands/login/login.ts | sed -n '20,40p'

Repository: netlify/cli

Length of output: 770


🏁 Script executed:

rg "loginRequest\|loginCheck" -t ts --type-list | grep -v node_modules | head -40

Repository: netlify/cli

Length of output: 444


🏁 Script executed:

cat -n src/commands/base-command.ts | sed -n '360,420p'

Repository: netlify/cli

Length of output: 2864


🏁 Script executed:

cat -n src/commands/login/login-check.ts | tail -20

Repository: netlify/cli

Length of output: 665


🏁 Script executed:

rg "loginRequest\(" -t ts -B 3 -A 3

Repository: netlify/cli

Length of output: 1552


🏁 Script executed:

cat -n src/commands/base-command.ts | sed -n '475,520p'

Repository: netlify/cli

Length of output: 1711


🏁 Script executed:

rg "command.netlify.apiOpts" -t ts -B 3 -A 3 | head -40

Repository: netlify/cli

Length of output: 37


🏁 Script executed:

rg "NetlifyAPI\(" -t ts -B 2 -A 4 | head -80

Repository: netlify/cli

Length of output: 1492


🏁 Script executed:

cat -n src/commands/login/login-check.ts | sed -n '1,20p'

Repository: netlify/cli

Length of output: 825


Pass command-scoped API/config into request/check flows.

The loginRequest() and loginCheck() functions create API instances with hardcoded configuration, ignoring custom API URLs, proxies, and global config set on the command. This causes divergent behavior from standard CLI auth paths that use command.netlify.apiOpts and command.netlify.globalConfig.

Pass these settings to the helper functions:

Proposed changes
  if (options.request) {
    const { loginRequest } = await import('./login-request.js')
-   await loginRequest()
+   await loginRequest({ apiOpts: command.netlify.apiOpts })
    return
  }

  if (options.check) {
    const { loginCheck } = await import('./login-check.js')
-   await loginCheck(options)
+   await loginCheck(options, {
+     apiOpts: command.netlify.apiOpts,
+     globalConfig: command.netlify.globalConfig,
+   })
    return
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/login/login.ts` around lines 25 - 34, The login helpers ignore
command-scoped API/config; update the calls to loginRequest and loginCheck to
pass the command's network and config so they use the same API options and
global config as the rest of the CLI. Specifically, when calling loginRequest()
and loginCheck(options) pass through command.netlify.apiOpts and
command.netlify.globalConfig (e.g., call loginRequest({ apiOpts:
command.netlify.apiOpts, globalConfig: command.netlify.globalConfig, ... }) and
loginCheck({ options, apiOpts: command.netlify.apiOpts, globalConfig:
command.netlify.globalConfig })) so the functions use those symbols instead of
hardcoded config.

}

const [accessToken, location] = await getToken()

command.setAnalyticsPayload({ new: options.new })
Expand Down
5 changes: 4 additions & 1 deletion src/commands/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <msg>')}

→ 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 }))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <msg>

→ 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"
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/commands/login/login-check.test.ts
Original file line number Diff line number Diff line change
@@ -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' }),
}),
)
})
})
52 changes: 52 additions & 0 deletions tests/unit/commands/login/login-request.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading