fix(difyctl): improve auth login host prompt UX (#37054)

This commit is contained in:
Xiyuan Chen 2026-06-05 00:57:29 -07:00 committed by GitHub
parent a1d9340a62
commit e40b30d746
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 295 additions and 22 deletions

View File

@ -200,4 +200,23 @@ describe('runLogin', () => {
})
expect(io.errBuf()).toContain('--no-browser requested')
})
it('TTY: prompts for host when --host omitted, uses typed URL', async () => {
const io = bufferStreams(`${mock.url}\n`)
;(io as { isErrTTY: boolean }).isErrTTY = true
const store = new MemStore()
const reg = await runLogin({
io,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})
expect(reg.resolveActive()?.ctx.account.email).toBe('tester@dify.ai')
expect(io.errBuf()).toContain('Enter Dify host URL')
expect(io.errBuf()).toContain('[default: https://cloud.dify.ai]')
})
})

View File

@ -2,17 +2,18 @@ import type { Clock } from './device-flow.js'
import type { CodeResponse, PollSuccess } from '@/api/oauth-device'
import type { AccountContext, Workspace } from '@/auth/hosts'
import type { StorageMode, Store } from '@/store/store'
import type { ParseResult } from '@/sys/io/prompt'
import type { IOStreams } from '@/sys/io/streams'
import type { BrowserEnv, BrowserOpener } from '@/util/browser'
import * as os from 'node:os'
import * as readline from 'node:readline'
import { DeviceFlowApi } from '@/api/oauth-device'
import { Registry } from '@/auth/hosts'
import { BaseError } from '@/errors/base'
import { BaseError, isBaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { createHttpClient } from '@/http/client'
import { getTokenStore, tokenKey } from '@/store/manager'
import { colorEnabled, colorScheme } from '@/sys/io/color'
import { promptText } from '@/sys/io/prompt'
import { startSpinner } from '@/sys/io/spinner'
import { decideOpen, OpenDecision, openUrl, realEnv } from '@/util/browser'
import { bareHost, DEFAULT_HOST, openAPIBase, resolveHost, validateVerificationURI } from '@/util/host'
@ -86,32 +87,45 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
}
async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise<string> {
let raw = opts.host?.trim() ?? ''
if (raw === '') {
if (!opts.io.isErrTTY) {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: '--host is required (no TTY)',
hint: 'pass the host explicitly, e.g. \'difyctl auth login --host cloud.dify.ai\'',
})
}
raw = await promptHost(opts.io)
const raw = opts.host?.trim() ?? ''
if (raw !== '')
return resolveHost({ raw, insecure })
if (!opts.io.isErrTTY) {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: '--host is required (no TTY)',
hint: 'pass the host explicitly, e.g. \'difyctl auth login --host cloud.dify.ai\'',
})
}
return resolveHost({ raw, insecure })
return promptHost(opts.io, insecure)
}
async function promptHost(io: IOStreams): Promise<string> {
io.err.write(`? Dify host [${DEFAULT_HOST}]: `)
const rl = readline.createInterface({ input: io.in, output: io.err, terminal: false })
try {
const line: string = await new Promise(resolve => rl.once('line', resolve))
return line.trim()
}
finally {
rl.close()
function makeHostParser(insecure: boolean): (raw: string) => ParseResult<string> {
return (raw: string) => {
try {
return { ok: true, value: resolveHost({ raw, insecure }) }
}
catch (err) {
if (isBaseError(err)) {
const msg = err.hint !== undefined ? `${err.message}${err.hint}` : err.message
return { ok: false, error: msg }
}
return { ok: false, error: String(err) }
}
}
}
async function promptHost(io: IOStreams, insecure: boolean): Promise<string> {
return promptText<string>({
io,
label: 'Enter Dify host URL',
hint: insecure ? 'e.g. https://cloud.dify.ai or http://localhost' : 'e.g. https://your-dify.com',
default: DEFAULT_HOST,
acceptAsDefault: raw => /^y(?:es)?$/i.test(raw.trim()),
parse: makeHostParser(insecure),
})
}
function defaultDeviceLabel(): string {
const host = os.hostname()
return `difyctl on ${host !== '' ? host : 'unknown-host'}`

View File

@ -0,0 +1,147 @@
import { PassThrough } from 'node:stream'
import { describe, expect, it } from 'vitest'
import { promptText } from './prompt'
import { bufferStreams } from './streams'
function ttyStreams(input: string): ReturnType<typeof bufferStreams> {
const io = bufferStreams(input)
;(io as { isErrTTY: boolean }).isErrTTY = true
return io
}
function eofStreams(): ReturnType<typeof bufferStreams> {
const io = bufferStreams()
const ended = new PassThrough()
ended.end()
;(io as { in: NodeJS.ReadableStream }).in = ended
;(io as { isErrTTY: boolean }).isErrTTY = true
return io
}
const parseString = (raw: string) =>
raw === 'hello'
? { ok: true as const, value: raw }
: { ok: false as const, error: `expected "hello", got "${raw}"` }
describe('promptText (non-TTY)', () => {
it('writes label to stderr and returns parsed value', async () => {
const io = bufferStreams('hello\n')
const result = await promptText({ io, label: 'Enter name', parse: parseString })
expect(result).toBe('hello')
expect(io.errBuf()).toContain('Enter name: ')
})
it('includes hint and default in prompt when provided', async () => {
const io = bufferStreams('hello\n')
await promptText({
io,
label: 'Enter name',
hint: 'e.g. world',
default: 'world',
parse: parseString,
})
expect(io.errBuf()).toBe('Enter name (e.g. world) [default: world]: ')
})
it('substitutes default on empty Enter', async () => {
const io = bufferStreams('\n')
const result = await promptText({
io,
label: 'Enter name',
default: 'hello',
parse: parseString,
})
expect(result).toBe('hello')
})
it('throws on invalid input (no retry in non-TTY)', async () => {
const io = bufferStreams('bad\n')
await expect(
promptText({ io, label: 'Enter name', parse: parseString }),
).rejects.toThrow(/expected "hello"/)
})
it('throws UsageMissingArg on EOF before reading input', async () => {
const io = bufferStreams()
const ended = new PassThrough()
ended.end()
;(io as { in: NodeJS.ReadableStream }).in = ended
await expect(
promptText({ io, label: 'Enter name', parse: parseString }),
).rejects.toThrow(/input closed/)
})
it('acceptAsDefault: treats matching input as default', async () => {
const io = bufferStreams('y\n')
const result = await promptText({
io,
label: 'Enter URL',
default: 'https://example.com',
acceptAsDefault: raw => /^y(?:es)?$/i.test(raw),
parse: raw => ({ ok: true, value: raw }),
})
expect(result).toBe('https://example.com')
})
})
describe('promptText (TTY)', () => {
it('returns parsed value on first valid input', async () => {
const io = ttyStreams('hello\n')
const result = await promptText({ io, label: 'Enter name', parse: parseString })
expect(result).toBe('hello')
expect(io.errBuf()).toContain('Enter name: ')
})
it('prints error and re-prompts on invalid input, accepts next valid', async () => {
const io = ttyStreams('bad\nhello\n')
const result = await promptText({ io, label: 'Enter name', parse: parseString })
expect(result).toBe('hello')
const err = io.errBuf()
expect(err).toContain('expected "hello", got "bad"')
expect(err.split('Enter name: ').length - 1).toBe(2)
})
it('substitutes default on empty Enter', async () => {
const io = ttyStreams('\n')
const result = await promptText({
io,
label: 'Enter name',
default: 'hello',
parse: parseString,
})
expect(result).toBe('hello')
})
it('acceptAsDefault: treats matching input as empty → default', async () => {
const io = ttyStreams('y\n')
const result = await promptText({
io,
label: 'Enter URL',
default: 'https://example.com',
acceptAsDefault: raw => /^y(?:es)?$/i.test(raw),
parse: raw => ({ ok: true, value: raw }),
})
expect(result).toBe('https://example.com')
})
it('acceptAsDefault: case-insensitive — YES and Yes also map to default', async () => {
for (const input of ['YES\n', 'Yes\n']) {
const io = ttyStreams(input)
const result = await promptText({
io,
label: 'Enter URL',
default: 'https://example.com',
acceptAsDefault: raw => /^y(?:es)?$/i.test(raw),
parse: raw => ({ ok: true, value: raw }),
})
expect(result).toBe('https://example.com')
}
})
it('throws UsageMissingArg on EOF before valid input is given', async () => {
const io = eofStreams()
await expect(
promptText({ io, label: 'Enter name', parse: parseString }),
).rejects.toThrow(/input closed/)
})
})

93
cli/src/sys/io/prompt.ts Normal file
View File

@ -0,0 +1,93 @@
import type { IOStreams } from './streams'
import * as readline from 'node:readline'
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { colorEnabled, colorScheme } from './color'
export type ParseResult<T>
= | { ok: true, value: T }
| { ok: false, error: string }
export type PromptTextOptions<T> = {
readonly io: IOStreams
readonly label: string
readonly hint?: string
readonly default?: string
readonly acceptAsDefault?: (raw: string) => boolean
readonly parse: (raw: string) => ParseResult<T>
}
function buildPromptLine(opts: Pick<PromptTextOptions<unknown>, 'label' | 'hint' | 'default'>): string {
let line = opts.label
if (opts.hint !== undefined)
line += ` (${opts.hint})`
if (opts.default !== undefined)
line += ` [default: ${opts.default}]`
return `${line}: `
}
function normalize(raw: string, opts: Pick<PromptTextOptions<unknown>, 'default' | 'acceptAsDefault'>): string {
const trimmed = raw.trim()
if (trimmed === '' || opts.acceptAsDefault?.(trimmed))
return opts.default ?? ''
return trimmed
}
export async function promptText<T>(opts: PromptTextOptions<T>): Promise<T> {
const prompt = buildPromptLine(opts)
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
return new Promise<T>((resolve, reject) => {
let settled = false
const settle = (fn: () => void): void => {
if (settled)
return
settled = true
fn()
}
const rl = readline.createInterface({ input: opts.io.in, output: opts.io.err, terminal: false })
rl.on('close', () => {
settle(() =>
reject(new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'input closed before a valid value was provided',
})),
)
})
const onLine = (raw: string): void => {
const value = normalize(raw, opts)
if (!opts.io.isErrTTY) {
const result = opts.parse(value)
rl.off('line', onLine)
settle(() => {
rl.close()
if (result.ok)
resolve(result.value)
else
reject(new BaseError({ code: ErrorCode.UsageInvalidFlag, message: result.error }))
})
return
}
const result = opts.parse(value)
if (result.ok) {
rl.off('line', onLine)
settle(() => {
rl.close()
resolve(result.value)
})
}
else {
opts.io.err.write(` ${cs.failureIcon()} ${result.error}\n`)
opts.io.err.write(prompt)
}
}
opts.io.err.write(prompt)
rl.on('line', onLine)
})
}