-
Notifications
You must be signed in to change notification settings - Fork 445
feat: agent-friendly login flow (--request / --check) #7971
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ae3371e
358955c
cfa5bbe
ce0f510
63bf195
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n src/commands/login/login-check.ts | head -40Repository: netlify/cli Length of output: 1434 🏁 Script executed: fd -t f "node_modules/@netlify/api" -o "package.json" | head -5Repository: netlify/cli Length of output: 198 🏁 Script executed: rg "showTicket" --type ts --type tsx -A 5 -B 5 | head -50Repository: netlify/cli Length of output: 82 🏁 Script executed: rg "showTicket" --type ts -A 5 -B 5Repository: netlify/cli Length of output: 4326 🏁 Script executed: cat package.json | jq '.dependencies."@netlify/api"' 2>/dev/null || grep '@netlify/api' package.jsonRepository: 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 -80Repository: 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 -100Repository: netlify/cli Length of output: 6503 🏁 Script executed: rg "APIError" --type ts -B 3 -A 3Repository: 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 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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 ?? ''}`) | ||
| } | ||
| 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}`) | ||
| } |
| 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' | ||
|
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cd /repo && git ls-files | grep -E "(login|command)" | head -20Repository: netlify/cli Length of output: 110 🏁 Script executed: cat -n src/commands/login/login.ts | head -50Repository: netlify/cli Length of output: 1902 🏁 Script executed: fd -t f "login-request\|login-check" --type fRepository: 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 jsRepository: netlify/cli Length of output: 132 🏁 Script executed: rg "export.*loginCheck" -t ts -t jsRepository: netlify/cli Length of output: 149 🏁 Script executed: cat -n src/commands/login/login-request.tsRepository: netlify/cli Length of output: 1077 🏁 Script executed: cat -n src/commands/login/login-check.tsRepository: netlify/cli Length of output: 1949 🏁 Script executed: rg "command\.netlify" -t ts -A 3 -B 1 | head -50Repository: netlify/cli Length of output: 2979 🏁 Script executed: cat -n src/commands/base-command.ts | head -100Repository: netlify/cli Length of output: 4263 🏁 Script executed: rg "apiOpts|globalConfig" -t ts -B 2 -A 2 | head -60Repository: netlify/cli Length of output: 4043 🏁 Script executed: rg "new NetlifyAPI" -t ts -A 2 -B 2 | head -80Repository: 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 -80Repository: netlify/cli Length of output: 1004 🏁 Script executed: rg "type NetlifyOptions|interface NetlifyOptions" -t ts -A 20Repository: netlify/cli Length of output: 1134 🏁 Script executed: cat -n src/commands/types.ts | head -80Repository: netlify/cli Length of output: 108 🏁 Script executed: rg "apiOpts" src/commands/base-command.ts -B 5 -A 5Repository: 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 -40Repository: 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 -20Repository: netlify/cli Length of output: 665 🏁 Script executed: rg "loginRequest\(" -t ts -B 3 -A 3Repository: 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 -40Repository: netlify/cli Length of output: 37 🏁 Script executed: rg "NetlifyAPI\(" -t ts -B 2 -A 4 | head -80Repository: 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 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 |
||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const [accessToken, location] = await getToken() | ||
|
|
||
| command.setAnalyticsPayload({ new: options.new }) | ||
|
|
||
| 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' }), | ||
| }), | ||
| ) | ||
| }) | ||
| }) |
| 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') | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
lodash merge undefined values overwrite destination property💡 Result:
_.merge()is designed to skipundefinedvalues from the source when the destination already has a value: “Source properties that resolve toundefinedare skipped if a destination value exists.” [1]. So{ a: undefined }in the source will not overwrite an existingdest.a.Also,
_.mergeWith()won’t help you force “writeundefined”, because if the customizer returnsundefined, Lodash treats that as “fall back to normal merge behavior” [1]—which still skipsundefined.Workarounds (pick one):
null(or another sentinel) instead ofundefinedwhen you want to clear a value, so merge will overwrite.undefined), use a non-merge approach (e.g.,Object.assign/ spread) for those fields (note: shallow) [1].undefinedin the source onto the result (works for shallow keys; for deep paths you’d need to walk the object).Example (shallow keys):
Sources: Lodash docs for
_.merge/_.mergeWithbehavior [1]; discussion/example ofmerge()skippingundefined[2].[1] (lodash.com)
[2] (onlinecode.org)
Citations:
Reset token-reset semantics explicitly instead of relying on
mergewithundefined.lodash/mergeskips source properties that areundefinedwhen a destination value already exists. This means staleauth.githubcredentials 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
📝 Committable suggestion
🤖 Prompt for AI Agents