mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:13:59 +08:00
fix(difyctl): improve auth login host prompt UX (#37054)
This commit is contained in:
parent
a1d9340a62
commit
e40b30d746
@ -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]')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'}`
|
||||
|
||||
147
cli/src/sys/io/prompt.test.ts
Normal file
147
cli/src/sys/io/prompt.test.ts
Normal 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
93
cli/src/sys/io/prompt.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user