fix(cli): exit codes, error hints and run id for server operations

- Logged out, a word no local command claims is not_logged_in (exit 4);
  logged in, a path no command or op matches is unknown_op (exit 6). A
  near miss of a local command stays exit 2.
- A 403 keeps the server's hint instead of telling the user to log in.
- The unknown_op hint points at difyctl help, not the removed ops command.
- A folded run keeps run_id from the stream's workflow_run_id.
- A downloaded file keeps only the base name the server sent.
This commit is contained in:
GareArc
2026-10-16 00:51:38 +00:00
committed by GitHub
parent 45b38c1caf
commit dac0dba019
14 changed files with 230 additions and 43 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ import { ErrorCode } from '@/errors/codes'
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'workspace id required',
hint: "pass --workspace or run 'difyctl use workspace <id>'",
hint: "pass --workspace-id or run 'difyctl use workspace <id>'",
})
```
+18
View File
@@ -148,10 +148,28 @@ it('a paused run is exit 0 with status suspended and the server hints', async ()
expect(await (await w.ctx.get(commands)).run()).toBe(0)
expect(JSON.parse(w.io.outBuf())).toMatchObject({
status: 'suspended',
run_id: 'wf-run-hitl-1',
hints: [{ op: 'submit.run.form', input: { form_token: 'ft-hitl-1', action: 'submit' } }],
})
})
it('a form submit prints the hint to follow the run', async () => {
const w = await world([
'submit',
'run',
'form',
'--input',
'{"app_id":"app-2","form_token":"ft-hitl-1","action":"submit","inputs":{"name":"Bob"}}',
])
expect(await (await w.ctx.get(commands)).run()).toBe(0)
expect(JSON.parse(w.io.outBuf()).hints).toEqual([
expect.objectContaining({
op: 'get.run.event',
input: { app_id: 'app-2', task_id: 'wf-run-hitl-1', continue_on_pause: false },
}),
])
})
it('a stale catalog is refreshed once and the op is retried', async () => {
const w = await world(['help'])
await (await w.ctx.get(commands)).run()
+15
View File
@@ -91,3 +91,18 @@ it('copies server hints from a pause and suspends; a pause without hints is stil
])
expect(bare.result).toEqual({ status: 'suspended', text: { answer: '' }, hints: [] })
})
it('keeps the run id from any event, even one it has no handler for', () => {
const { result, code } = run([
['workflow_started', { workflow_run_id: 'run-1', data: { id: 'run-1' } }],
['human_input_required', { workflow_run_id: 'run-1', hints: [] }],
['workflow_paused', { workflow_run_id: 'run-1' }],
])
expect(result).toMatchObject({ status: 'suspended', run_id: 'run-1' })
expect(code).toBe(0)
})
it('ignores a run id that is not a string', () => {
const { result } = run([['some_future_event', { workflow_run_id: 42 }]])
expect(result).not.toHaveProperty('run_id')
})
+3 -3
View File
@@ -1,15 +1,15 @@
import type { ExitCodeValue } from '@/errors/codes'
import type { FoldResult } from '@/protocol/fold'
import { ExitCode } from '@/errors/codes'
import { FOLD_STATUS, FOLD_TABLE } from '@/protocol/fold'
import { FOLD_STATUS, FOLD_TABLE, foldAnyEvent } from '@/protocol/fold'
export function newFoldResult(): FoldResult {
return { status: FOLD_STATUS.Incomplete, text: { answer: '' }, hints: [] }
}
export function foldEvent(result: FoldResult, name: string, event: Record<string, unknown>): void {
const handler = FOLD_TABLE[name]
if (handler !== undefined) handler(result, event)
foldAnyEvent(result, event)
FOLD_TABLE[name]?.(result, event)
}
export function finishFold(result: FoldResult): FoldResult {
+10 -2
View File
@@ -1,6 +1,6 @@
import type { Renderer } from './index'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { join, posix } from 'node:path'
import { unknownError } from '@/errors/base'
import { renderJson } from './object'
@@ -21,10 +21,18 @@ const EXTENSIONS: Readonly<Record<string, string>> = {
const QUOTED_FILENAME = /filename="([^"]+)"/
const BARE_FILENAME = /filename=([^;]+)/
const NOT_A_FILE_NAME: readonly string[] = ['', '.', '..']
// Only the last part of the server's name is used, so the file stays in the folder.
function baseName(raw: string | undefined): string | undefined {
if (raw === undefined) return undefined
const name = posix.basename(raw.replaceAll('\\', '/'))
return NOT_A_FILE_NAME.includes(name) ? undefined : name
}
function filenameFromContentDisposition(value: string | null): string | undefined {
if (value === null) return undefined
return QUOTED_FILENAME.exec(value)?.[1] ?? BARE_FILENAME.exec(value)?.[1]?.trim()
return baseName(QUOTED_FILENAME.exec(value)?.[1] ?? BARE_FILENAME.exec(value)?.[1]?.trim())
}
function extensionOf(contentType: string | null): string {
+29
View File
@@ -138,3 +138,32 @@ it('file names an unnamed download after the op and its content type', async ()
process.chdir(cwd)
}
})
it.each([
['../../escape.txt', 'escape.txt'],
['/etc/passwd', 'passwd'],
['..\\..\\win.txt', 'win.txt'],
['..', `${OP_ID}.txt`],
['dir/', 'dir'],
])('file keeps only the base name of the server filename %s', async (sent, kept) => {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-file-'))
const cwd = process.cwd()
process.chdir(dir)
const here = process.cwd()
try {
const r = render(
'file',
new Response('x', {
headers: {
'content-type': 'text/plain',
'content-disposition': `attachment; filename="${sent}"`,
},
}),
)
await r.code
expect(JSON.parse(r.buf.outBuf())).toMatchObject({ path: join(here, kept) })
expect(readFileSync(join(here, kept), 'utf8')).toBe('x')
} finally {
process.chdir(cwd)
}
})
+1 -1
View File
@@ -12,7 +12,7 @@ afterEach(async () => {
it('prints the envelope on stderr and returns the exit code', async () => {
w = await testContext({ login: false })
const code = await main(['nope'], w.overrides)
const code = await main(['versoin'], w.overrides)
expect(code).toBe(2)
expect(JSON.parse(w.io.errBuf()).error.code).toBe('usage_invalid_flag')
expect(w.io.outBuf()).toBe('')
+31 -10
View File
@@ -1,10 +1,12 @@
import type { TestWorld } from '@test/fixtures/kernel'
import type { BaseError } from '@/errors/base'
import type { CommandTree } from '@/plugins/commands/registry'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { testContext } from '@test/fixtures/kernel'
import { afterEach, expect, it } from 'vite-plus/test'
import { z } from 'zod'
import { ExitCode } from '@/errors/codes'
import { parseCatalog } from '@/plugins/catalog'
import { Command } from '@/plugins/commands/command'
import { propertiesOf } from '@/protocol/shape'
@@ -218,7 +220,7 @@ it('takes --verbose anywhere and keeps it out of the command input', async () =>
const b = await world(false, ['--verbose', 'version'])
expect(await (await b.ctx.get(commands)).run()).toBe(0)
expect(JSON.parse(b.io.outBuf()).client.version).toBe('0.0.0-test')
const c = await world(false, ['nope', '--verbose'])
const c = await world(false, ['versoin', '--verbose'])
await expect((await c.ctx.get(commands)).run()).rejects.toMatchObject({
code: 'usage_invalid_flag',
})
@@ -254,21 +256,32 @@ it('a command word may be spelled with dashes where the op id has underscores',
})
})
it('an unknown path refetches the catalog once before giving up', async () => {
it('an unknown path refetches the catalog once, then is unknown_op (exit 6)', async () => {
const w = await world(true, ['get', 'qqqq'])
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
code: 'usage_invalid_flag',
message: 'unknown command: get qqqq',
const miss = (await w.ctx.get(commands)).run()
await expect(miss).rejects.toMatchObject({
code: 'unknown_op',
message: 'no command or server operation named get qqqq',
hint: 'run difyctl help',
})
await expect(miss).rejects.toSatisfy((err: BaseError) => err.exit() === ExitCode.Catalog)
expect(w.mock.requestCount).toBe(2) // the cached catalog, then one refetch
const typo = await world(true, ['get', 'consol_app'])
await expect((await typo.ctx.get(commands)).run()).rejects.toMatchObject({
code: 'unknown_op',
hint: 'did you mean: get console_app, get console_app version',
})
})
it('a typo of a static command stays exit 2 once the catalog is loaded', async () => {
const w = await world(true, ['versoin'])
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
code: 'usage_invalid_flag',
hint: 'did you mean: version',
})
})
it('a known namespace and a help view answer from the cached catalog, with no refetch', async () => {
const a = await world(true, ['run'])
expect(await (await a.ctx.get(commands)).run()).toBe(0)
@@ -302,13 +315,21 @@ it('an unreachable catalog costs a notice, not the static suggestion', async ()
expect(w.io.errBuf()).toMatch(/^could not list server operations: failed to fetch the catalog: /)
})
it('without a login only statics resolve and the hint says to log in', async () => {
it('without a login a word no static claims is not_logged_in (exit 4)', async () => {
const w = await world(false, ['get', 'console_app'])
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
code: 'usage_invalid_flag',
message: 'unknown command: get console_app',
hint: 'log in to use server operations',
const miss = (await w.ctx.get(commands)).run()
await expect(miss).rejects.toMatchObject({
code: 'not_logged_in',
message: 'not logged in: get console_app may be a server operation; log in to load them',
hint: 'run difyctl help login',
})
await expect(miss).rejects.toSatisfy((err: BaseError) => err.exit() === ExitCode.Auth)
expect(w.mock.requestCount).toBe(0)
})
it('without a login a namespace still lists its static commands', async () => {
const w = await world(false, ['get'])
expect(await (await w.ctx.get(commands)).run()).toBe(0)
expect(w.mock.requestCount).toBe(0)
})
+47 -11
View File
@@ -42,7 +42,7 @@ const FULL_FLAG = '--full'
const ALL_FLAG = '--all'
const FLAG_PREFIX = '-'
const NO_SERVER_NOTICE = notLoggedInMessage(helpHint('login'))
const NO_SERVER_HINT = 'log in to use server operations'
const MAYBE_AN_OP = 'may be a server operation; log in to load them'
const OPS_UNAVAILABLE_PREFIX = 'could not list server operations: '
const NO_MATCH_NOTICE = `nothing matched; ${helpHint()} for the map`
const NO_REST: readonly string[] = []
@@ -96,22 +96,54 @@ async function fromOps<T>(
}
}
function unknownCommand(tree: CommandTree, words: readonly string[], fallback: string): BaseError {
function suggestionHint(tree: CommandTree, words: readonly string[]): string | undefined {
const suggestions = findSuggestions(tree, [...words])
return suggestions.length > 0 ? `did you mean: ${suggestions.join(', ')}` : undefined
}
function unknownCommand(words: readonly string[], hint: string): BaseError {
return new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `unknown command: ${words.join(COMMAND_SEPARATOR)}`,
hint: suggestions.length > 0 ? `did you mean: ${suggestions.join(', ')}` : fallback,
hint,
})
}
/** The error for words no command claimed, given the widest tree resolution got. */
type Miss = (tree: CommandTree, words: readonly string[]) => BaseError
const unresolved: Miss = (tree, words) =>
unknownCommand(words, suggestionHint(tree, words) ?? helpHint())
// A near miss of a static command is a typo (exit 2) whether or not ops could load.
function unlessStaticTypo(statics: CommandTree, miss: Miss): Miss {
return (tree, words) => {
const typo = suggestionHint(statics, words)
return typo === undefined ? miss(tree, words) : unknownCommand(words, typo)
}
}
const loggedOut: Miss = (_tree, words) =>
new BaseError({
code: ErrorCode.NotLoggedIn,
message: notLoggedInMessage(`${words.join(COMMAND_SEPARATOR)} ${MAYBE_AN_OP}`),
hint: helpHint('login'),
})
const unknownOp: Miss = (tree, words) =>
new BaseError({
code: ErrorCode.UnknownOp,
message: `no command or server operation named ${words.join(COMMAND_SEPARATOR)}`,
hint: suggestionHint(tree, words) ?? helpHint(),
})
type Discovery = Readonly<{
tree: CommandTree
path: readonly string[]
full: boolean
listing: CollectOptions
wantsHelp: boolean
missHint?: string
miss: Miss
ctx: CommandContext
streams: IOService
}>
@@ -140,7 +172,7 @@ async function discover(args: Discovery): Promise<number> {
await streams.document(listingView(under))
return ExitCode.Success
}
if (!args.wantsHelp) throw unknownCommand(tree, path, args.missHint ?? helpHint())
if (!args.wantsHelp) throw args.miss(tree, path)
const found = helpSearch(path, tree, listing)
if (found.total === 0) streams.notice(NO_MATCH_NOTICE)
@@ -151,8 +183,8 @@ async function discover(args: Discovery): Promise<number> {
type Resolution = {
readonly resolved: ResolvedCommand | undefined
readonly tree: CommandTree
/** Set only when a miss has a better explanation than the generic help hint. */
readonly missHint?: string
/** Set only when a miss has a better explanation than an unknown command. */
readonly miss?: Miss
}
type Walk = Readonly<{
@@ -181,9 +213,9 @@ async function resolveWithOps(walk: Walk): Promise<Resolution> {
const direct = resolveCommand(tree, path)
if (direct !== undefined) return { resolved: direct, tree }
// A help view explains a missing catalog on stderr; a plain miss gets the hint instead.
// A help view explains a missing catalog on stderr; a plain miss says to log in.
if (!walk.wantsHelp && (await (await ctx.get(session)).current()) === null)
return { resolved: undefined, tree, missHint: NO_SERVER_HINT }
return { resolved: undefined, tree, miss: unlessStaticTypo(tree, loggedOut) }
const known = await fromOps(ctx, streams, (service) => service.commands(EVERY_OP))
if (known === undefined) return { resolved: undefined, tree }
@@ -195,7 +227,11 @@ async function resolveWithOps(walk: Walk): Promise<Resolution> {
const refetched = await fromOps(ctx, streams, (service) => service.commands(EVERY_OP_FRESH))
if (refetched === undefined) return { resolved: undefined, tree: cached }
const fresh = mergeTrees(tree, refetched).tree
return { resolved: resolveCommand(fresh, path), tree: fresh }
return {
resolved: resolveCommand(fresh, path),
tree: fresh,
miss: unlessStaticTypo(tree, unknownOp),
}
}
export async function runPipeline(tree: CommandTree, ctx: CommandContext): Promise<number> {
@@ -225,7 +261,7 @@ export async function runPipeline(tree: CommandTree, ctx: CommandContext): Promi
full: argv.includes(FULL_FLAG),
listing: { includeHidden: argv.includes(ALL_FLAG) },
wantsHelp,
missHint: walked.missHint,
miss: walked.miss ?? unresolved,
ctx,
streams,
})
+33 -6
View File
@@ -2,7 +2,7 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startMock } from '@test/fixtures/dify-mock/server'
import { FORBIDDEN_HINT, startMock } from '@test/fixtures/dify-mock/server'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
import { Context } from '@/kernel/context'
import { catalog, CATALOG_PATH } from '@/plugins/catalog'
@@ -15,22 +15,20 @@ let mock: DifyMock
let dir: string
function writeTimeout(ms: number): void {
writeFileSync(join(dir, CONFIG_FILE_NAME), `schema_version: 1\nhttp:\n timeout: ${ms}\n`)
writeFileSync(join(dir, CONFIG_FILE_NAME), `http:\n timeout: ${ms}\n`)
}
beforeEach(async () => {
mock = await startMock()
dir = mkdtempSync(join(tmpdir(), 'difyctl-http-'))
Object.assign(process.env, {
DIFY_CONFIG_DIR: dir,
DIFY_CACHE_DIR: dir,
DIFY_SERVER: mock.url,
DIFY_TOKEN: 'dfoa_test',
})
})
afterEach(async () => {
await mock.stop()
for (const k of ['DIFY_CONFIG_DIR', 'DIFY_CACHE_DIR', 'DIFY_SERVER', 'DIFY_TOKEN'])
delete process.env[k]
for (const k of ['DIFY_CONFIG_DIR', 'DIFY_SERVER', 'DIFY_TOKEN']) delete process.env[k]
})
const account = () => ({ method: 'GET', path: '/openapi/v1/account' })
@@ -103,6 +101,35 @@ describe('http plugin', () => {
await expect(h.request(account)).rejects.toMatchObject({ code: 'rate_limited' })
})
it('a 401 says to log in again', async () => {
const h = await new Context().get(http)
mock.setScenario('auth-expired')
await expect(h.request(account)).rejects.toMatchObject({ hint: 'run difyctl login' })
})
it("a 403 keeps the server's hint and never says to log in", async () => {
const h = await new Context().get(http)
mock.setScenario('forbidden')
await expect(h.request(account)).rejects.toMatchObject({
code: 'access_denied',
message: 'you may not do this',
hint: FORBIDDEN_HINT,
})
})
it('a 403 without a server hint says permission denied', async () => {
process.env.DIFY_TOKEN = 'sk-test-resource'
const h = await new Context().get(http)
mock.setScenario('resource-token')
await expect(
h.request(() => ({ method: 'GET', path: '/openapi/v1/account' })),
).rejects.toMatchObject({
code: 'access_denied',
httpStatus: 403,
hint: expect.stringMatching(/^permission denied/),
})
})
it('surfaces 5xx as server_error with the server body and details', async () => {
const h = await new Context().get(http)
mock.setScenario('server-5xx')
@@ -166,7 +193,7 @@ describe('http plugin', () => {
await expect((await new Context().get(http)).request(account)).rejects.toMatchObject({
code: 'catalog_unavailable',
message: `failed to fetch the catalog: HTTP 404 from ${CATALOG_PATH}`,
hint: expect.stringContaining('refresh cache'),
hint: expect.stringContaining('server is reachable'),
})
})
+11 -5
View File
@@ -41,7 +41,9 @@ const HttpStatus = {
const CATALOG_STALE_CODE = 'catalog_stale'
const CATALOG_UNAVAILABLE_PREFIX = 'failed to fetch the catalog: '
const CATALOG_UNAVAILABLE_HINT = 'check the server URL or run difyctl refresh cache'
// `refresh cache` would only repeat the fetch that failed, so it is not suggested.
const CATALOG_UNAVAILABLE_HINT =
'check that the saved server URL is right and the server is reachable, then retry'
// The catalog fetch is the CLI's first request, so its failure has to name itself:
// a rejected status for the catalog route, or whatever the network said.
@@ -51,11 +53,15 @@ function catalogFailure(cause: unknown): string {
return errorMessage(cause)
}
type StatusMapping = Readonly<{ code: ErrorCodeValue; hint?: string }>
const PERMISSION_DENIED_HINT = 'permission denied: this account may not do this'
// `hint` replaces the server's hint; `fallbackHint` only fills in when it sent none.
type StatusMapping = Readonly<{ code: ErrorCodeValue; hint?: string; fallbackHint?: string }>
const STATUS_MAPPINGS: Readonly<Record<number, StatusMapping>> = {
[HttpStatus.Unauthorized]: { code: ErrorCode.AuthExpired, hint: LOGIN_HINT },
[HttpStatus.Forbidden]: { code: ErrorCode.AccessDenied, hint: LOGIN_HINT },
// Logging in again does not grant a permission, and `login` revokes the session first.
[HttpStatus.Forbidden]: { code: ErrorCode.AccessDenied, fallbackHint: PERMISSION_DENIED_HINT },
[HttpStatus.TooManyRequests]: { code: ErrorCode.RateLimited },
}
@@ -196,11 +202,11 @@ export const http = definePlugin({
const rawResponse = await res.text()
const serverError = parseServerErrorBody(rawResponse)
const { code, hint } = classifyStatus(res)
const { code, hint, fallbackHint } = classifyStatus(res)
throw new HttpClientError({
code,
message: serverError?.message ?? `HTTP ${res.status}`,
hint: hint ?? serverError?.hint,
hint: hint ?? serverError?.hint ?? fallbackHint,
httpStatus: res.status,
method: req.method,
url: url.toString(),
+7
View File
@@ -9,6 +9,7 @@ export type FoldResult = {
total_tokens?: number
message_id?: string
conversation_id?: string
run_id?: string
error?: { message: string; code?: string }
hints: Hint[]
}
@@ -108,6 +109,12 @@ function markError(result: FoldResult, event: Record<string, unknown>): void {
raise(result, FOLD_STATUS.Failed)
}
/** Runs for every event before its own handler: any workflow event names its run. */
export const foldAnyEvent: EventHandler = (result, event) => {
const runId = asString(event.workflow_run_id)
if (runId !== undefined) result.run_id = runId
}
export const FOLD_TABLE: Readonly<Record<string, EventHandler>> = Object.freeze({
message: appendAnswer,
agent_message: appendAnswer,
+1
View File
@@ -6,6 +6,7 @@ export type Scenario =
| 'denied'
| 'expired'
| 'auth-expired'
| 'forbidden'
| 'rate-limited'
| 'server-5xx'
| 'slow-down'
+23 -4
View File
@@ -53,6 +53,7 @@ const EMPTY_ERROR_PATH = '/empty-error'
const BAD_VERIFICATION_URI = 'ftp://elsewhere.example/device'
const HANG_MS = 30_000
const OAUTH_PREFIX = '/openapi/v1/oauth/'
export const FORBIDDEN_HINT = 'ask a workspace owner for the editor role'
const ServerErrorCode = {
Unauthorized: 'unauthorized',
@@ -71,6 +72,7 @@ const OpId = {
ChatRun: 'run.console_app.chat',
AdvancedChatRun: 'run.console_app.advanced_chat',
FormSubmit: 'submit.run.form',
RunEvents: 'get.run.event',
} as const
const CATALOG_STALE_MESSAGE =
@@ -195,7 +197,13 @@ function streamingRunResponse(
function hitlPauseResponse(appId: string): string {
return sseChunks([
{ event: 'workflow_started', data: { id: 'wf-run-hitl-1', workflow_id: 'wf-1' } },
{
event: 'workflow_started',
data: {
workflow_run_id: 'wf-run-hitl-1',
data: { id: 'wf-run-hitl-1', workflow_id: 'wf-1' },
},
},
{ event: 'node_started', data: { id: 'n1', title: 'First Node' } },
{
event: 'human_input_required',
@@ -435,6 +443,8 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
return c.json({ code: 'forbidden', message: 'resource_not_authorized', status: 403 }, 403)
const scenario = getScenario()
if (scenario === 'auth-expired') return unauthorized()
if (scenario === 'forbidden')
return errorResponse(ServerErrorCode.Forbidden, 'you may not do this', 403, FORBIDDEN_HINT)
await next()
})
@@ -747,9 +757,18 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
return c.json({ result: 'success' })
})
app.post('/openapi/v1/apps/:id/human-input-forms/:formToken:submit', (c) => {
return c.json({})
})
// Like the server: the submit names the run to follow, stopping at the next pause.
app.post('/openapi/v1/apps/:id/human-input-forms/:formToken:submit', (c) =>
c.json({
hints: [
{
summary: 'Follow the run',
op: OpId.RunEvents,
input: { app_id: c.req.param('id'), task_id: 'wf-run-hitl-1', continue_on_pause: false },
},
],
}),
)
app.get('/openapi/v1/apps/:id/tasks/:task_id/events', (_c) => sseResponse(hitlResumedResponse()))