From e40b30d7462adad94ffa12cca5438ba11e789de3 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:57:29 -0700 Subject: [PATCH] fix(difyctl): improve auth login host prompt UX (#37054) --- cli/src/commands/auth/login/login.test.ts | 19 +++ cli/src/commands/auth/login/login.ts | 58 +++++---- cli/src/sys/io/prompt.test.ts | 147 ++++++++++++++++++++++ cli/src/sys/io/prompt.ts | 93 ++++++++++++++ 4 files changed, 295 insertions(+), 22 deletions(-) create mode 100644 cli/src/sys/io/prompt.test.ts create mode 100644 cli/src/sys/io/prompt.ts diff --git a/cli/src/commands/auth/login/login.test.ts b/cli/src/commands/auth/login/login.test.ts index 705a34e255..d4dab0bf28 100644 --- a/cli/src/commands/auth/login/login.test.ts +++ b/cli/src/commands/auth/login/login.test.ts @@ -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]') + }) }) diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index f1b3abc94d..fc9562e065 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -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 { } async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise { - 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 { - 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 { + 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 { + return promptText({ + 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'}` diff --git a/cli/src/sys/io/prompt.test.ts b/cli/src/sys/io/prompt.test.ts new file mode 100644 index 0000000000..031a6d4cec --- /dev/null +++ b/cli/src/sys/io/prompt.test.ts @@ -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 { + const io = bufferStreams(input) + ;(io as { isErrTTY: boolean }).isErrTTY = true + return io +} + +function eofStreams(): ReturnType { + 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/) + }) +}) diff --git a/cli/src/sys/io/prompt.ts b/cli/src/sys/io/prompt.ts new file mode 100644 index 0000000000..d5cc2498b9 --- /dev/null +++ b/cli/src/sys/io/prompt.ts @@ -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 + = | { ok: true, value: T } + | { ok: false, error: string } + +export type PromptTextOptions = { + readonly io: IOStreams + readonly label: string + readonly hint?: string + readonly default?: string + readonly acceptAsDefault?: (raw: string) => boolean + readonly parse: (raw: string) => ParseResult +} + +function buildPromptLine(opts: Pick, '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, 'default' | 'acceptAsDefault'>): string { + const trimmed = raw.trim() + if (trimmed === '' || opts.acceptAsDefault?.(trimmed)) + return opts.default ?? '' + return trimmed +} + +export async function promptText(opts: PromptTextOptions): Promise { + const prompt = buildPromptLine(opts) + const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) + + return new Promise((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) + }) +}