Skip to content

Commit b5a44db

Browse files
biilmannclaudeDavidWellssean-roberts
authored
feat: agent-friendly login flow (--request / --check) (#7971)
* feat: add --request and --check flags to netlify login for agent-friendly auth Agents can't complete the browser-based OAuth flow on their own. This adds a non-blocking two-step flow: create a ticket with --request to get a shareable URL, then check its status with --check to finalize login. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs sync via npm run docs * feat: pass message to API when creating login ticket via --request Update @netlify/api to 14.0.18 which adds support for an optional message field on createTicket. Forward the --request <message> value through to the API call. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add type assertion for options.request to satisfy eslint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: pass apiOpts and globalConfig to login helpers Use command.netlify.apiOpts and command.netlify.globalConfig instead of hardcoding API options and calling getGlobalConfigStore() directly, so login --request and --check respect proxy/custom API URL settings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: pass missing args in login-check rethrow tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: DavidWells <hello@davidwells.io> Co-authored-by: Sean Roberts <sean.roberts90@gmail.com>
1 parent fb2e776 commit b5a44db

File tree

11 files changed

+323
-42
lines changed

11 files changed

+323
-42
lines changed

docs/commands/login.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ netlify login
1919

2020
**Flags**
2121

22+
- `check` (*string*) - Check the status of a login ticket created with --request
23+
- `json` (*boolean*) - Output as JSON (for use with --request or --check)
2224
- `new` (*boolean*) - Login to new Netlify account
25+
- `request` (*string*) - Create a login ticket for agent/human-in-the-loop auth
2326
- `debug` (*boolean*) - Print debugging information
2427
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in
2528

package-lock.json

Lines changed: 0 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/base-command.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ type Analytics = {
5151
inquirer.registerPrompt('autocomplete', inquirerAutocompletePrompt)
5252
/** Netlify CLI client id. Lives in bot@netlify.com */
5353
// TODO: setup client for multiple environments
54-
const CLIENT_ID = 'd6f37de6614df7ae58664cfca524744d73807a377f5ee71f1a254f78412e3750'
54+
export const CLIENT_ID = 'd6f37de6614df7ae58664cfca524744d73807a377f5ee71f1a254f78412e3750'
5555

5656
const NANO_SECS_TO_MSECS = 1e6
5757
/** The fallback width for the help terminal */
@@ -175,6 +175,26 @@ export type BaseOptionValues = {
175175
verbose?: boolean
176176
}
177177

178+
export function storeToken(
179+
globalConfig: Awaited<ReturnType<typeof getGlobalConfigStore>>,
180+
{ userId, name, email, accessToken }: { userId: string; name?: string; email?: string; accessToken: string },
181+
) {
182+
const userData = merge(globalConfig.get(`users.${userId}`), {
183+
id: userId,
184+
name,
185+
email,
186+
auth: {
187+
token: accessToken,
188+
github: {
189+
user: undefined,
190+
token: undefined,
191+
},
192+
},
193+
})
194+
globalConfig.set('userId', userId)
195+
globalConfig.set(`users.${userId}`, userData)
196+
}
197+
178198
/** Base command class that provides tracking and config initialization */
179199
export default class BaseCommand extends Command {
180200
/** The netlify object inside each command with the state */
@@ -441,30 +461,21 @@ export default class BaseCommand extends Command {
441461

442462
log(`Opening ${authLink}`)
443463
await openBrowser({ url: authLink })
464+
log()
465+
log(`To request authorization from a human, run: ${chalk.cyanBright('netlify login --request "<msg>"')}`)
466+
log()
444467

445468
const accessToken = await pollForToken({
446469
api: this.netlify.api,
447470
ticket,
448471
})
449472

450473
const { email, full_name: name, id: userId } = await this.netlify.api.getCurrentUser()
474+
if (!userId) {
475+
return logAndThrowError('Could not retrieve user ID from Netlify API')
476+
}
451477

452-
const userData = merge(this.netlify.globalConfig.get(`users.${userId}`), {
453-
id: userId,
454-
name,
455-
email,
456-
auth: {
457-
token: accessToken,
458-
github: {
459-
user: undefined,
460-
token: undefined,
461-
},
462-
},
463-
})
464-
// Set current userId
465-
this.netlify.globalConfig.set('userId', userId)
466-
// Set user data
467-
this.netlify.globalConfig.set(`users.${userId}`, userData)
478+
storeToken(this.netlify.globalConfig, { userId, name, email, accessToken })
468479

469480
await identify({
470481
name,

src/commands/login/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export const createLoginCommand = (program: BaseCommand) =>
1111
Opens a web browser to acquire an OAuth token.`,
1212
)
1313
.option('--new', 'Login to new Netlify account')
14+
.option('--request <message>', 'Create a login ticket for agent/human-in-the-loop auth')
15+
.option('--check <ticket-id>', 'Check the status of a login ticket created with --request')
16+
.option('--json', 'Output as JSON (for use with --request or --check)')
1417
.addHelpText('after', () => {
1518
const docsUrl = 'https://docs.netlify.com/cli/get-started/#authentication'
1619
return `

src/commands/login/login-check.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { NetlifyAPI } from '@netlify/api'
2+
import { OptionValues } from 'commander'
3+
4+
import { log, logAndThrowError, logJson } from '../../utils/command-helpers.js'
5+
import { storeToken } from '../base-command.js'
6+
import type { NetlifyOptions } from '../types.js'
7+
8+
export const loginCheck = async (
9+
options: OptionValues,
10+
apiOpts: NetlifyOptions['apiOpts'],
11+
globalConfig: NetlifyOptions['globalConfig'],
12+
) => {
13+
const ticketId = options.check as string
14+
15+
const api = new NetlifyAPI('', apiOpts)
16+
17+
let ticket: { authorized?: boolean }
18+
try {
19+
ticket = await api.showTicket({ ticketId })
20+
} catch (error) {
21+
const status = (error as { status?: number }).status
22+
if (status === 401 || status === 404) {
23+
logJson({ status: 'denied' })
24+
log('Status: denied')
25+
return
26+
}
27+
throw error
28+
}
29+
30+
if (!ticket.authorized) {
31+
logJson({ status: 'pending' })
32+
log('Status: pending')
33+
return
34+
}
35+
36+
const tokenResponse = await api.exchangeTicket({ ticketId })
37+
const accessToken = tokenResponse.access_token
38+
if (!accessToken) {
39+
return logAndThrowError('Could not retrieve access token')
40+
}
41+
42+
api.accessToken = accessToken
43+
const user = await api.getCurrentUser()
44+
if (!user.id) {
45+
return logAndThrowError('Could not retrieve user ID from Netlify API')
46+
}
47+
48+
storeToken(globalConfig, {
49+
userId: user.id,
50+
name: user.full_name,
51+
email: user.email,
52+
accessToken,
53+
})
54+
55+
logJson({
56+
status: 'authorized',
57+
user: { id: user.id, email: user.email, name: user.full_name },
58+
})
59+
60+
log('Status: authorized')
61+
log(`Name: ${user.full_name ?? ''}`)
62+
log(`Email: ${user.email ?? ''}`)
63+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { NetlifyAPI } from '@netlify/api'
2+
3+
import { log, logAndThrowError, logJson } from '../../utils/command-helpers.js'
4+
import { CLIENT_ID } from '../base-command.js'
5+
import type { NetlifyOptions } from '../types.js'
6+
7+
export const loginRequest = async (message: string, apiOpts: NetlifyOptions['apiOpts']) => {
8+
const webUI = process.env.NETLIFY_WEB_UI || 'https://app.netlify.com'
9+
10+
const api = new NetlifyAPI('', apiOpts)
11+
12+
const ticket = await api.createTicket({ clientId: CLIENT_ID, body: { message } })
13+
14+
if (!ticket.id) {
15+
return logAndThrowError('Failed to create login ticket')
16+
}
17+
const ticketId = ticket.id
18+
const url = `${webUI}/authorize?response_type=ticket&ticket=${ticketId}`
19+
20+
logJson({ ticket_id: ticketId, url, check_command: `netlify login --check ${ticketId}` })
21+
22+
log(`Ticket ID: ${ticketId}`)
23+
log(`Authorize URL: ${url}`)
24+
log()
25+
log(`After authorizing, run: netlify login --check ${ticketId}`)
26+
}

0 commit comments

Comments
 (0)