diff --git a/cli/.gitignore b/cli/.gitignore index 9747e29156..d3249e1014 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -4,4 +4,8 @@ node_modules/ *.tsbuildinfo .vitest-cache/ docs/specs/ -context/ \ No newline at end of file +context/ +test/**/*.ts.map +test/**/*.js.map +test/**/*.js +test/**/*.d.ts diff --git a/cli/src/api/account-sessions.test.ts b/cli/src/api/account-sessions.test.ts index 6f5796a4b0..3af9bb641b 100644 --- a/cli/src/api/account-sessions.test.ts +++ b/cli/src/api/account-sessions.test.ts @@ -2,7 +2,7 @@ import type { StubServer } from '@test/fixtures/stub-server' import { testHttpClient } from '@test/fixtures/http-client' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { afterEach, describe, expect, it } from 'vitest' -import { isBaseError } from '@/errors/base' +import { isHttpClientError } from '@/errors/base' import { AccountSessionsClient } from './account-sessions.js' const LIST_BODY = { page: 1, limit: 100, total: 0, has_more: false, data: [] } @@ -70,7 +70,7 @@ describe('AccountSessionsClient.revoke', () => { jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap)) await expect(makeClient(stub.url).revoke('missing')).rejects.toSatisfy( - err => isBaseError(err) && err.httpStatus === 404, + err => isHttpClientError(err) && err.httpStatus === 404, ) }) diff --git a/cli/src/api/account.test.ts b/cli/src/api/account.test.ts index 9dc114db9f..f12b63a88c 100644 --- a/cli/src/api/account.test.ts +++ b/cli/src/api/account.test.ts @@ -2,7 +2,7 @@ import type { StubServer } from '@test/fixtures/stub-server' import { testHttpClient } from '@test/fixtures/http-client' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { afterEach, describe, expect, it } from 'vitest' -import { isBaseError } from '@/errors/base' +import { isHttpClientError } from '@/errors/base' import { AccountClient } from './account.js' function makeClient(host: string): AccountClient { @@ -35,7 +35,7 @@ describe('AccountClient.get', () => { stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap)) await expect(makeClient(stub.url).get()).rejects.toSatisfy( - err => isBaseError(err) && err.httpStatus === 401, + err => isHttpClientError(err) && err.httpStatus === 401, ) }) }) diff --git a/cli/src/api/apps.test.ts b/cli/src/api/apps.test.ts index 26e5e3ce26..e7d4da93e1 100644 --- a/cli/src/api/apps.test.ts +++ b/cli/src/api/apps.test.ts @@ -2,7 +2,7 @@ import type { StubServer } from '@test/fixtures/stub-server' import { testHttpClient } from '@test/fixtures/http-client' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { afterEach, describe, expect, it } from 'vitest' -import { isBaseError } from '@/errors/base' +import { isHttpClientError } from '@/errors/base' import { AppsClient } from './apps.js' const LIST_BODY = { page: 1, limit: 20, total: 0, has_more: false, data: [] } @@ -74,7 +74,7 @@ describe('AppsClient.list', () => { stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap)) await expect(makeClient(stub.url).list({ workspaceId: 'ws-1' })).rejects.toSatisfy( - err => isBaseError(err) && err.httpStatus === 403, + err => isHttpClientError(err) && err.httpStatus === 403, ) }) }) diff --git a/cli/src/api/file-upload.test.ts b/cli/src/api/file-upload.test.ts index ef19b78d9f..018389916b 100644 --- a/cli/src/api/file-upload.test.ts +++ b/cli/src/api/file-upload.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { testHttpClient } from '@test/fixtures/http-client' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { isBaseError } from '@/errors/base' +import { isHttpClientError } from '@/errors/base' import { FileUploadClient } from './file-upload.js' const UPLOADED = { @@ -70,7 +70,7 @@ describe('FileUploadClient.upload', () => { stub = await startStubServer(cap => jsonResponder(413, { error: 'file too large' }, cap)) await expect(makeClient(stub.url).upload('app-1', filePath)).rejects.toSatisfy( - err => isBaseError(err) && err.httpStatus === 413, + err => isHttpClientError(err) && err.httpStatus === 413, ) }) }) diff --git a/cli/src/api/members.test.ts b/cli/src/api/members.test.ts index 1d129bb44d..b4e01b76b2 100644 --- a/cli/src/api/members.test.ts +++ b/cli/src/api/members.test.ts @@ -2,7 +2,7 @@ import type { StubServer } from '@test/fixtures/stub-server' import { testHttpClient } from '@test/fixtures/http-client' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { afterEach, describe, expect, it } from 'vitest' -import { isBaseError } from '@/errors/base' +import { isHttpClientError } from '@/errors/base' import { MembersClient } from './members.js' import { WorkspacesClient } from './workspaces.js' @@ -62,7 +62,7 @@ describe('MembersClient.list', () => { stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap)) await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy( - err => isBaseError(err) && err.httpStatus === 403, + err => isHttpClientError(err) && err.httpStatus === 403, ) }) @@ -70,7 +70,7 @@ describe('MembersClient.list', () => { stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap)) await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy( - err => isBaseError(err) && err.httpStatus === 404, + err => isHttpClientError(err) && err.httpStatus === 404, ) }) }) @@ -117,7 +117,7 @@ describe('MembersClient.invite', () => { await expect( makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }), - ).rejects.toSatisfy(err => isBaseError(err) && err.httpStatus === 400) + ).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400) }) }) @@ -142,7 +142,7 @@ describe('MembersClient.remove', () => { stub = await startStubServer(cap => jsonResponder(400, { error: 'cannot operate self' }, cap)) await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy( - err => isBaseError(err) && err.httpStatus === 400, + err => isHttpClientError(err) && err.httpStatus === 400, ) }) }) @@ -170,7 +170,7 @@ describe('MembersClient.updateRole', () => { await expect( makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }), - ).rejects.toSatisfy(err => isBaseError(err) && err.httpStatus === 400) + ).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400) }) }) @@ -209,7 +209,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => { const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test')) await expect(client.switch('ws-x')).rejects.toSatisfy( - err => isBaseError(err) && err.httpStatus === 404, + err => isHttpClientError(err) && err.httpStatus === 404, ) }) }) diff --git a/cli/src/api/oauth-device.ts b/cli/src/api/oauth-device.ts index b3d8bfbad6..693a77d952 100644 --- a/cli/src/api/oauth-device.ts +++ b/cli/src/api/oauth-device.ts @@ -1,5 +1,5 @@ import type { HttpClient } from '@/http/types' -import { BaseError } from '@/errors/base' +import { BaseError, HttpClientError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' export const DEFAULT_CLIENT_ID = 'difyctl' @@ -80,7 +80,7 @@ export class DeviceFlowApi { if (res.status === 404) throw versionSkew() if (!res.ok) { - throw new BaseError({ + throw new HttpClientError({ code: ErrorCode.Server4xxOther, message: `device/code: HTTP ${res.status}`, httpStatus: res.status, @@ -133,8 +133,8 @@ export class DeviceFlowApi { } } -function versionSkew(): BaseError { - return new BaseError({ +function versionSkew(): HttpClientError { + return new HttpClientError({ code: ErrorCode.UnsupportedEndpoint, message: 'this Dify host does not implement the OAuth device flow', httpStatus: 404, diff --git a/cli/src/api/workspaces.test.ts b/cli/src/api/workspaces.test.ts index eefb9ccf08..d2cbd0da21 100644 --- a/cli/src/api/workspaces.test.ts +++ b/cli/src/api/workspaces.test.ts @@ -2,7 +2,7 @@ import type { StubServer } from '@test/fixtures/stub-server' import { testHttpClient } from '@test/fixtures/http-client' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { afterEach, describe, expect, it } from 'vitest' -import { isBaseError } from '@/errors/base' +import { isHttpClientError } from '@/errors/base' import { WorkspacesClient } from './workspaces.js' // WorkspacesClient.switch is covered in members.test.ts; this file covers list(). @@ -30,14 +30,14 @@ describe('WorkspacesClient.list', () => { expect(stub.captured.method).toBe('GET') expect(stub.captured.url).toBe('/openapi/v1/workspaces') - expect(res.workspaces[0].id).toBe('ws-1') + expect(res.workspaces[0]?.id).toBe('ws-1') }) it('maps 401 to a classified BaseError', async () => { stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap)) await expect(makeClient(stub.url).list()).rejects.toSatisfy( - err => isBaseError(err) && err.httpStatus === 401, + err => isHttpClientError(err) && err.httpStatus === 401, ) }) }) diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts index d0d4586577..92184590d2 100644 --- a/cli/src/commands/run/app/run.test.ts +++ b/cli/src/commands/run/app/run.test.ts @@ -357,14 +357,14 @@ describe('runApp', () => { // warm cache with successful run await runApp( { appId: 'app-1', message: 'hi' }, - { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(cache.get(mock.url, 'app-1')).toBeDefined() mock.setScenario('run-422-stale') const err = await runApp( { appId: 'app-1', message: 'hi' }, - { bundle: bundle(), http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache }, + { active: active(), http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache }, ).catch((e: unknown) => e) expect(err).toMatchObject({ code: 'server_4xx_other', httpStatus: 422 }) expect((err as { hint?: string }).hint).toMatch(/cache cleared/) diff --git a/cli/src/commands/run/app/run.ts b/cli/src/commands/run/app/run.ts index 6dae0e4143..bb06a6bdf3 100644 --- a/cli/src/commands/run/app/run.ts +++ b/cli/src/commands/run/app/run.ts @@ -7,7 +7,7 @@ import { AppRunClient } from '@/api/app-run' import { AppsClient } from '@/api/apps' import { FileUploadClient } from '@/api/file-upload' import { pickStrategy } from '@/commands/run/app/_strategies/index' -import { BaseError } from '@/errors/base' +import { BaseError, HttpClientError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { getEnv, processExit } from '@/sys/index' import { FieldInfo } from '@/types/app-meta' @@ -87,7 +87,7 @@ export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise { const err = decodeStreamError(enc.encode(JSON.stringify(env))) expect(err.message).toBe(inner.args.description) expect(err.code).toBe('server_4xx_other') - expect(err.httpStatus).toBe(400) + expect((err as HttpClientError).httpStatus).toBe(400) }) it('unwraps openapi-v1 invoke-error: falls back to inner.message when no args.description', () => { diff --git a/cli/src/commands/run/app/sse-collector.ts b/cli/src/commands/run/app/sse-collector.ts index 973a717746..ba329746e7 100644 --- a/cli/src/commands/run/app/sse-collector.ts +++ b/cli/src/commands/run/app/sse-collector.ts @@ -1,6 +1,6 @@ import type { BaseError } from '@/errors/base' import type { SseEvent } from '@/http/sse' -import { newError } from '@/errors/base' +import { HttpClientError, newError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { RUN_MODES } from './handlers' @@ -173,9 +173,9 @@ export function decodeStreamError(data: Uint8Array): BaseError { const code = env.status !== undefined && env.status > 0 && env.status < 500 ? ErrorCode.Server4xxOther : ErrorCode.Server5xx - let err = newError(code, message) + const err = newError(code, message) if (env.status !== undefined && env.status > 0) - err = err.withHttpStatus(env.status) + return HttpClientError.from(err).withHttpStatus(env.status) return err } diff --git a/cli/src/errors/base.test.ts b/cli/src/errors/base.test.ts index beb6a6296c..fe9dc30eea 100644 --- a/cli/src/errors/base.test.ts +++ b/cli/src/errors/base.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' -import { BaseError, isBaseError, newError, unknownError } from './base' +import { BaseError, HttpClientError, isBaseError, newError, unknownError } from './base' import { ErrorCode, ExitCode } from './codes' describe('BaseError', () => { it('captures code, message, optional fields', () => { - const err = new BaseError({ + const err = new HttpClientError({ code: ErrorCode.AuthExpired, message: 'session expired', hint: 'run difyctl auth login', @@ -30,7 +30,6 @@ describe('BaseError', () => { expect(newError(ErrorCode.AuthExpired, 'x').exit()).toBe(ExitCode.Auth) expect(newError(ErrorCode.UsageInvalidFlag, 'x').exit()).toBe(ExitCode.Usage) expect(newError(ErrorCode.VersionSkew, 'x').exit()).toBe(ExitCode.VersionCompat) - expect(newError(ErrorCode.NetworkDns, 'x').exit()).toBe(ExitCode.Generic) }) it('toString without hint formats ": "', () => { @@ -56,7 +55,7 @@ describe('BaseError', () => { it('withHttpStatus + withRequest + wrap chain immutably', () => { const cause = new Error('underlying') - const built = newError(ErrorCode.NetworkTimeout, 'timed out') + const built = HttpClientError.from(newError(ErrorCode.NetworkConnection, 'timed out')) .withHttpStatus(504) .withRequest('POST', 'https://x/y') .wrap(cause) @@ -68,7 +67,7 @@ describe('BaseError', () => { it('wrap exposes cause via standard Error.cause property', () => { const cause = new Error('underlying failure') - const wrapped = newError(ErrorCode.NetworkTimeout, 'timed out').wrap(cause) + const wrapped = newError(ErrorCode.NetworkConnection, 'timed out').wrap(cause) expect(wrapped.cause).toBe(cause) }) @@ -86,3 +85,56 @@ describe('BaseError', () => { expect(err.cause).toBe(cause) }) }) + +describe('error envelope', () => { + it('emits required fields only when minimal', () => { + const err = newError(ErrorCode.Unknown, 'boom') + expect(err.toEnvelope()).toEqual({ + error: { code: 'unknown', message: 'boom' }, + }) + }) + + it('includes hint / http_status / method / url when present', () => { + const err = HttpClientError.from(newError(ErrorCode.NetworkConnection, 'timed out')) + .withHint('check your network') + .withHttpStatus(504) + .withRequest('POST', 'https://api.dify.ai/v1/x') + expect(err.toEnvelope()).toEqual({ + error: { + code: 'network_connection', + message: 'timed out', + hint: 'check your network', + http_status: 504, + method: 'POST', + url: 'https://api.dify.ai/v1/x', + }, + }) + }) + + it('renderEnvelope returns a single-line JSON string', () => { + const err = newError(ErrorCode.AuthExpired, 'session expired') + .withHint('run difyctl auth login') + const out = JSON.stringify(err.toEnvelope()) + expect(out).toBe( + '{"error":{"code":"auth_expired","message":"session expired","hint":"run difyctl auth login"}}', + ) + expect(out).not.toContain('\n') + }) + + it('renderEnvelope output round-trips through JSON.parse to an ErrorEnvelope shape', () => { + const err = newError(ErrorCode.UsageInvalidFlag, 'bad flag').withHint('see --help') + const parsed = JSON.parse(JSON.stringify(err.toEnvelope())) + expect(parsed).toEqual({ + error: { code: 'usage_invalid_flag', message: 'bad flag', hint: 'see --help' }, + }) + }) + + it('omits undefined optional fields entirely (no `hint: null`)', () => { + const err = newError(ErrorCode.Server5xx, 'upstream broke') + const envelope = err.toEnvelope() + expect(envelope.error).not.toHaveProperty('hint') + expect(envelope.error).not.toHaveProperty('http_status') + expect(envelope.error).not.toHaveProperty('method') + expect(envelope.error).not.toHaveProperty('url') + }) +}) diff --git a/cli/src/errors/base.ts b/cli/src/errors/base.ts index 64baead260..0ad438a743 100644 --- a/cli/src/errors/base.ts +++ b/cli/src/errors/base.ts @@ -1,31 +1,24 @@ import type { ErrorCodeValue, ExitCodeValue } from './codes' +import type { ErrorEnvelope, PrintableError } from './format' import { ErrorCode, exitFor } from './codes' export type BaseErrorOptions = { readonly code: ErrorCodeValue readonly message: string readonly hint?: string - readonly httpStatus?: number - readonly method?: string - readonly url?: string readonly cause?: unknown } -export class BaseError extends Error { +export class BaseError extends Error implements PrintableError { readonly code: ErrorCodeValue readonly hint?: string - readonly httpStatus?: number - readonly method?: string - readonly url?: string constructor(opts: BaseErrorOptions) { super(opts.message, opts.cause === undefined ? undefined : { cause: opts.cause }) this.name = 'BaseError' this.code = opts.code this.hint = opts.hint - this.httpStatus = opts.httpStatus - this.method = opts.method - this.url = opts.url + Object.setPrototypeOf(this, new.target.prototype) } @@ -39,30 +32,31 @@ export class BaseError extends Error { : `${this.code}: ${this.message}` } - withHint(hint: string): BaseError { - return new BaseError({ ...this.snapshot(), hint }) + toEnvelope(): ErrorEnvelope { + const payload: ErrorEnvelope['error'] = { + code: this.code, + message: this.message, + } + if (this.hint !== undefined) + payload.hint = this.hint + return { error: payload } } - withHttpStatus(httpStatus: number): BaseError { - return new BaseError({ ...this.snapshot(), httpStatus }) + withHint(this: T, hint: string): T { + const Ctor = this.constructor as new (opts: BaseErrorOptions) => T + return new Ctor({ ...this.snapshot(), hint }) } - withRequest(method: string, url: string): BaseError { - return new BaseError({ ...this.snapshot(), method, url }) + wrap(this: T, cause: unknown): T { + const Ctor = this.constructor as new (opts: BaseErrorOptions) => T + return new Ctor({ ...this.snapshot(), cause }) } - wrap(cause: unknown): BaseError { - return new BaseError({ ...this.snapshot(), cause }) - } - - private snapshot(): BaseErrorOptions { + protected snapshot(): BaseErrorOptions { return { code: this.code, message: this.message, hint: this.hint, - httpStatus: this.httpStatus, - method: this.method, - url: this.url, cause: this.cause, } } @@ -76,6 +70,79 @@ export function isBaseError(value: unknown): value is BaseError { return value instanceof BaseError } +export function isHttpClientError(value: unknown): value is HttpClientError { + return value instanceof HttpClientError +} + export function unknownError(message: string, cause?: unknown): BaseError { return new BaseError({ code: ErrorCode.Unknown, message, cause }) } + +type HttpClientErrorOptions = BaseErrorOptions & { + readonly httpStatus?: number + readonly method?: string + readonly url?: string + readonly rawResponse?: string +} + +export class HttpClientError extends BaseError { + readonly httpStatus?: number + readonly method?: string + readonly url?: string + readonly rawResponse?: string + + constructor(opts: HttpClientErrorOptions) { + super(opts) + this.httpStatus = opts.httpStatus + this.method = opts.method + this.url = opts.url + this.rawResponse = opts.rawResponse + } + + override toEnvelope(): ErrorEnvelope { + const envelope = super.toEnvelope() + if (this.httpStatus !== undefined) + envelope.error.http_status = this.httpStatus + if (this.method !== undefined) + envelope.error.method = this.method + if (this.url !== undefined) + envelope.error.url = this.url + if (this.rawResponse !== undefined) + envelope.error.raw_response = this.rawResponse + return envelope + } + + protected override snapshot(): HttpClientErrorOptions { + return { + ...super.snapshot(), + httpStatus: this.httpStatus, + method: this.method, + url: this.url, + rawResponse: this.rawResponse, + } + } + + public static from(error: BaseError): HttpClientError { + return new HttpClientError({ + code: error.code, + message: error.message, + hint: error.hint, + cause: error.cause, + }) + } + + withHttpStatus(httpStatus: number): HttpClientError { + return new HttpClientError({ ...this.snapshot(), httpStatus }) + } + + withRequest(method: string, url: string): HttpClientError { + return new HttpClientError({ ...this.snapshot(), method, url }) + } + + withRawResponse(rawResponse: string): HttpClientError { + if (!rawResponse) { + return this + } + return new HttpClientError({ ...this.snapshot(), rawResponse }) + } +} diff --git a/cli/src/errors/codes.test.ts b/cli/src/errors/codes.test.ts index ac59a3dbf7..a29697f57a 100644 --- a/cli/src/errors/codes.test.ts +++ b/cli/src/errors/codes.test.ts @@ -42,8 +42,6 @@ describe('error codes', () => { [ErrorCode.UsageMissingArg, ExitCode.Usage], [ErrorCode.ConfigInvalidKey, ExitCode.Usage], [ErrorCode.ConfigInvalidValue, ExitCode.Usage], - [ErrorCode.NetworkTimeout, ExitCode.Generic], - [ErrorCode.NetworkDns, ExitCode.Generic], [ErrorCode.Server5xx, ExitCode.Generic], [ErrorCode.Server4xxOther, ExitCode.Generic], [ErrorCode.ClientError, ExitCode.Generic], diff --git a/cli/src/errors/codes.ts b/cli/src/errors/codes.ts index 0d157f90cb..e2e69e5cd2 100644 --- a/cli/src/errors/codes.ts +++ b/cli/src/errors/codes.ts @@ -11,8 +11,7 @@ export const ErrorCode = { UsageMissingArg: 'usage_missing_arg', ConfigInvalidKey: 'config_invalid_key', ConfigInvalidValue: 'config_invalid_value', - NetworkTimeout: 'network_timeout', - NetworkDns: 'network_dns', + NetworkConnection: 'network_connection', Server5xx: 'server_5xx', Server4xxOther: 'server_4xx_other', ClientError: 'client_error', @@ -45,8 +44,7 @@ const CODE_TO_EXIT: Readonly> = { usage_missing_arg: ExitCode.Usage, config_invalid_key: ExitCode.Usage, config_invalid_value: ExitCode.Usage, - network_timeout: ExitCode.Generic, - network_dns: ExitCode.Generic, + network_connection: ExitCode.Generic, server_5xx: ExitCode.Generic, server_4xx_other: ExitCode.Generic, client_error: ExitCode.Generic, diff --git a/cli/src/errors/envelope.test.ts b/cli/src/errors/envelope.test.ts deleted file mode 100644 index 6fb7b85f76..0000000000 --- a/cli/src/errors/envelope.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { newError } from './base' -import { ErrorCode } from './codes' -import { renderEnvelope, toEnvelope } from './envelope' - -describe('error envelope', () => { - it('emits required fields only when minimal', () => { - const err = newError(ErrorCode.Unknown, 'boom') - expect(toEnvelope(err)).toEqual({ - error: { code: 'unknown', message: 'boom' }, - }) - }) - - it('includes hint / http_status / method / url when present', () => { - const err = newError(ErrorCode.NetworkTimeout, 'timed out') - .withHint('check your network') - .withHttpStatus(504) - .withRequest('POST', 'https://api.dify.ai/v1/x') - expect(toEnvelope(err)).toEqual({ - error: { - code: 'network_timeout', - message: 'timed out', - hint: 'check your network', - http_status: 504, - method: 'POST', - url: 'https://api.dify.ai/v1/x', - }, - }) - }) - - it('renderEnvelope returns a single-line JSON string', () => { - const err = newError(ErrorCode.AuthExpired, 'session expired') - .withHint('run difyctl auth login') - const out = renderEnvelope(err) - expect(out).toBe( - '{"error":{"code":"auth_expired","message":"session expired","hint":"run difyctl auth login"}}', - ) - expect(out).not.toContain('\n') - }) - - it('renderEnvelope output round-trips through JSON.parse to an ErrorEnvelope shape', () => { - const err = newError(ErrorCode.UsageInvalidFlag, 'bad flag').withHint('see --help') - const parsed = JSON.parse(renderEnvelope(err)) - expect(parsed).toEqual({ - error: { code: 'usage_invalid_flag', message: 'bad flag', hint: 'see --help' }, - }) - }) - - it('omits undefined optional fields entirely (no `hint: null`)', () => { - const err = newError(ErrorCode.Server5xx, 'upstream broke') - const envelope = toEnvelope(err) - expect(envelope.error).not.toHaveProperty('hint') - expect(envelope.error).not.toHaveProperty('http_status') - expect(envelope.error).not.toHaveProperty('method') - expect(envelope.error).not.toHaveProperty('url') - }) -}) diff --git a/cli/src/errors/envelope.ts b/cli/src/errors/envelope.ts deleted file mode 100644 index b67e9d8945..0000000000 --- a/cli/src/errors/envelope.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { BaseError } from './base' - -export type ErrorEnvelope = { - error: { - code: string - message: string - hint?: string - http_status?: number - method?: string - url?: string - } -} - -export function toEnvelope(err: BaseError): ErrorEnvelope { - const payload: ErrorEnvelope['error'] = { - code: err.code, - message: err.message, - } - if (err.hint !== undefined) - payload.hint = err.hint - if (err.httpStatus !== undefined) - payload.http_status = err.httpStatus - if (err.method !== undefined) - payload.method = err.method - if (err.url !== undefined) - payload.url = err.url - return { error: payload } -} - -export function renderEnvelope(err: BaseError): string { - return JSON.stringify(toEnvelope(err)) -} diff --git a/cli/src/errors/format.ts b/cli/src/errors/format.ts index 65eb8d4b90..b8c3fe6cab 100644 --- a/cli/src/errors/format.ts +++ b/cli/src/errors/format.ts @@ -1,26 +1,58 @@ -import type { BaseError } from './base' +import { isVerbose } from '@/framework/context' +import { redactBearer } from '@/http/sanitize' import { colorEnabled, colorScheme } from '@/sys/io/color' -import { renderEnvelope } from './envelope' export type FormatErrorOptions = { readonly format?: string readonly isErrTTY?: boolean } -export function formatErrorForCli(err: BaseError, opts: FormatErrorOptions = {}): string { - if (opts.format === 'json') - return renderEnvelope(err) - return humanError(err, opts.isErrTTY ?? false) +export type ErrorEnvelope = { + error: { + code: string + message: string + hint?: string + http_status?: number + method?: string + url?: string + raw_response?: string + } } -function humanError(err: BaseError, isErrTTY: boolean): string { +export type PrintableError = { + toEnvelope: () => ErrorEnvelope +} + +export function formatErrorForCli(err: PrintableError, opts: FormatErrorOptions = {}): string { + const env = err.toEnvelope() + if (opts.format === 'json') + return renderEnvelope(env) + return renderHuman(env, opts.isErrTTY ?? false) +} + +function renderEnvelope(env: ErrorEnvelope): string { + const raw = env.error.raw_response + if (raw === undefined) + return JSON.stringify(env) + if (!isVerbose()) { + delete env.error.raw_response + return JSON.stringify(env) + } + env.error.raw_response = redactBearer(raw) + return JSON.stringify(env) +} + +function renderHuman(env: ErrorEnvelope, isErrTTY: boolean): string { const cs = colorScheme(colorEnabled(isErrTTY)) - const lines: string[] = [`${err.code}: ${err.message}`] - if (err.hint !== undefined) - lines.push(`${cs.magenta('hint:')} ${cs.cyan(err.hint)}`) - if (err.method !== undefined && err.url !== undefined) - lines.push(`request: ${err.method} ${err.url}`) - if (err.httpStatus !== undefined) - lines.push(`http_status: ${err.httpStatus}`) + const e = env.error + const lines: string[] = [`${e.code}: ${e.message}`] + if (e.hint !== undefined) + lines.push(`${cs.magenta('hint:')} ${cs.cyan(e.hint)}`) + if (e.method !== undefined && e.url !== undefined) + lines.push(`request: ${e.method} ${e.url}`) + if (e.http_status !== undefined) + lines.push(`http_status: ${e.http_status}`) + if (isVerbose() && e.raw_response) + lines.push(`raw_response: ${redactBearer(e.raw_response)}`) return lines.join('\n') } diff --git a/cli/src/framework/command.ts b/cli/src/framework/command.ts index ef898b966a..1b38df0074 100644 --- a/cli/src/framework/command.ts +++ b/cli/src/framework/command.ts @@ -1,6 +1,7 @@ import type { CommandOutput } from './output' import type { ArgDefinition, FlagDefinition, ICommand, InferArgs, InferFlags, OptionalArgValueType } from './types' -import { parseArgv } from './flags' +import { setVerbose } from './context' +import { hasBooleanFlag, parseArgv, VERBOSE_CHAR, VERBOSE_FLAG } from './flags' export type CommandConstructor = { new(): Command @@ -28,11 +29,16 @@ type ParseResult = { export abstract class Command implements ICommand { static description?: string static flags: Record> = {} + static args: Record> = {} static examples: string[] = [] abstract run(argv: string[]): Promise + processGlobalFlags(argv: readonly string[]): void { + setVerbose(hasBooleanFlag(argv, VERBOSE_FLAG, VERBOSE_CHAR)) + } + protected parse(ctor: C, argv: string[]): ParseResult { const meta = { flags: ctor.flags ?? {}, diff --git a/cli/src/framework/context.ts b/cli/src/framework/context.ts new file mode 100644 index 0000000000..fdb783dba3 --- /dev/null +++ b/cli/src/framework/context.ts @@ -0,0 +1,15 @@ +type CommandContext = { + verbose: boolean +} + +const commandContext: CommandContext = { + verbose: false, +} + +export function setVerbose(verbose: boolean): void { + commandContext.verbose = verbose +} + +export function isVerbose(): boolean { + return commandContext.verbose +} diff --git a/cli/src/framework/flags.ts b/cli/src/framework/flags.ts index d3db72fbbf..7ff0590e1a 100644 --- a/cli/src/framework/flags.ts +++ b/cli/src/framework/flags.ts @@ -1,6 +1,24 @@ import type { ArgDefinition, CommandMeta, FlagDefinition, ParsedArgs, ParsedFlags } from './types' import { UnsupportedArgValueError } from './errors' +export const VERBOSE_FLAG = 'verbose' +export const VERBOSE_CHAR = 'v' + +export const Flags = { + string: stringFlag, + stringArray: stringRepeatedFlag, + boolean: booleanFlag, + integer: integerFlag, + outputFormat: outputFormatFlag, +} + +const GLOBAL_FLAGS: Record = { + [VERBOSE_FLAG]: Flags.boolean({ + char: VERBOSE_CHAR, + description: 'enable verbose output', + }), +} + function stringFlag } -export const Flags = { - string: stringFlag, - stringArray: stringRepeatedFlag, - boolean: booleanFlag, - integer: integerFlag, - outputFormat: outputFormatFlag, -} - function stringArg( opts: Opts, ): ArgDefinition { @@ -99,8 +109,8 @@ function accumulateFlagValue(flags: ParsedFlags, name: string, value: string | b } } -function resolveByChar(char: string, meta: CommandMeta): [name: string, def: FlagDefinition] | undefined { - for (const [name, def] of Object.entries(meta.flags)) { +function resolveByChar(char: string, flags: Record): [name: string, def: FlagDefinition] | undefined { + for (const [name, def] of Object.entries(flags)) { if (def.char === char) return [name, def] } @@ -115,12 +125,12 @@ function validateFlagOptions(name: string, raw: string, def: FlagDefinition): vo type ResolvedFlag = { name: string, def: FlagDefinition, label: string, inlineRaw: string | undefined } -function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null { +function resolveToken(token: string, flags: Record): ResolvedFlag | null { if (token.startsWith('--')) { const eqIdx = token.indexOf('=') const name = eqIdx !== -1 ? token.slice(2, eqIdx) : token.slice(2) const inlineRaw = eqIdx !== -1 ? token.slice(eqIdx + 1) : undefined - const def = meta.flags[name] + const def = flags[name] if (!def) throw new Error(`unknown flag: --${name}`) return { name, def, label: `--${name}`, inlineRaw } @@ -128,7 +138,7 @@ function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null { if (token.length === 2 && token[1] !== undefined) { const char = token[1] - const resolved = resolveByChar(char, meta) + const resolved = resolveByChar(char, flags) if (!resolved) throw new Error(`unknown flag: -${char}`) const [name, def] = resolved @@ -138,6 +148,21 @@ function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null { return null } +// Scans argv for a boolean flag without throwing on unknown tokens, so it is safe +// to call before the command-specific flag set is known (e.g. global flags). +export function hasBooleanFlag(argv: readonly string[], name: string, char?: string): boolean { + for (const token of argv) { + if (token === '--') + break + if (token === `--${name}` || token === `--${name}=true` || token === `--${name}=1`) + return true + if (char !== undefined && token === `-${char}`) + return true + } + + return false +} + export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: ParsedArgs, flags: ParsedFlags } { const flags: ParsedFlags = {} const positional: string[] = [] @@ -159,7 +184,10 @@ export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: P continue } - const resolved = resolveToken(token, meta) + const resolved = resolveToken(token, { + ...meta.flags, + ...GLOBAL_FLAGS, // pass global flags to prevent unknown flag error + }) if (!resolved) { positional.push(token) continue diff --git a/cli/src/framework/run.test.ts b/cli/src/framework/run.test.ts index 5a67cc3044..0015913ec8 100644 --- a/cli/src/framework/run.test.ts +++ b/cli/src/framework/run.test.ts @@ -1,7 +1,7 @@ import type { CommandConstructor } from './command' import type { CommandTree } from './registry' import { describe, expect, it } from 'vitest' -import { BaseError, newError } from '@/errors/base' +import { BaseError, HttpClientError, newError } from '@/errors/base' import { ErrorCode, ExitCode } from '@/errors/codes' import { Command } from './command' import { run, sniffOutputFormat } from './run' @@ -171,7 +171,7 @@ describe('run() catch routing', () => { it('routes Server5xx error with http_status line and generic exit', async () => { class Throwing extends Command { async run(_argv: string[]) { - throw newError(ErrorCode.Server5xx, 'upstream boom').withHttpStatus(502) + throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom')).withHttpStatus(502) } } const result = await captureRun(makeTree(Throwing), ['cmd']) @@ -182,7 +182,7 @@ describe('run() catch routing', () => { it('renders request line and http_status when both are present', async () => { class Throwing extends Command { async run(_argv: string[]) { - throw newError(ErrorCode.Server5xx, 'upstream boom') + throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom')) .withRequest('GET', 'https://api.dify.ai/v1/me') .withHttpStatus(502) } @@ -197,7 +197,7 @@ describe('run() catch routing', () => { it('serializes method and url in JSON envelope', async () => { class Throwing extends Command { async run(_argv: string[]) { - throw newError(ErrorCode.Server4xxOther, 'not found') + throw HttpClientError.from(newError(ErrorCode.Server4xxOther, 'not found')) .withRequest('GET', 'https://api.dify.ai/v1/apps/x') .withHttpStatus(404) } diff --git a/cli/src/framework/run.ts b/cli/src/framework/run.ts index 4305c2dc4e..c078247241 100644 --- a/cli/src/framework/run.ts +++ b/cli/src/framework/run.ts @@ -45,7 +45,10 @@ export async function run(tree: CommandTree, argv: string[]): Promise { if (typeof Ctor.deprecated === 'string' && Ctor.deprecated.length > 0) process.stderr.write(`deprecated: ${Ctor.deprecated}\n`) const cmd = new Ctor() - const output = await cmd.run(argv.slice(resolved.path.length)) + const commandArgv = argv.slice(resolved.path.length) + cmd.processGlobalFlags(commandArgv) + + const output = await cmd.run(commandArgv) if (output !== undefined) process.stdout.write(stringifyOutput(output)) } diff --git a/cli/src/http/client.test.ts b/cli/src/http/client.test.ts index 9d27d36578..c7954e8bc2 100644 --- a/cli/src/http/client.test.ts +++ b/cli/src/http/client.test.ts @@ -3,7 +3,7 @@ import type { AddressInfo } from 'node:net' import * as http from 'node:http' import { startMock } from '@test/fixtures/dify-mock/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { isBaseError } from '@/errors/base' +import { isBaseError, isHttpClientError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { openAPIBase } from '@/util/host' import { createHttpClient } from './client.js' @@ -116,8 +116,8 @@ describe('http client', () => { await client.get('workspaces') } catch (err) { caught = err } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) { + expect(isHttpClientError(caught)).toBe(true) + if (isHttpClientError(caught)) { expect(caught.code).toBe(ErrorCode.AuthExpired) expect(caught.httpStatus).toBe(401) expect(caught.method).toBe('GET') @@ -138,8 +138,8 @@ describe('http client', () => { await client.get('workspaces') } catch (err) { caught = err } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) { + expect(isHttpClientError(caught)).toBe(true) + if (isHttpClientError(caught)) { expect(caught.code).toBe(ErrorCode.Server5xx) expect(caught.httpStatus).toBe(503) } @@ -187,8 +187,8 @@ describe('http client', () => { await client.get('apps/nope/describe') } catch (err) { caught = err } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) + expect(isHttpClientError(caught)).toBe(true) + if (isHttpClientError(caught)) expect(caught.code).toBe(ErrorCode.Server4xxOther) }) @@ -205,8 +205,8 @@ describe('http client', () => { await client.get('workspaces') } catch (err) { caught = err } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) + expect(isHttpClientError(caught)).toBe(true) + if (isHttpClientError(caught)) expect(caught.httpStatus).toBe(429) }) diff --git a/cli/src/http/client.ts b/cli/src/http/client.ts index 99459ea7b0..6143a519d6 100644 --- a/cli/src/http/client.ts +++ b/cli/src/http/client.ts @@ -9,6 +9,7 @@ import type { RequestOptions, ResolvedOptions, } from './types.js' +import { isVerbose } from '@/framework/context' import { userAgent as defaultUserAgent } from '@/version/info' import { buildBody } from './body.js' import { classifyResponse } from './error-mapper.js' @@ -133,11 +134,11 @@ async function dispatch(state: ClientState, path: string, opts: RequestOptions, await runHooks(state.hooks.onRequest, ctx) - // `dispatcher` is an undici extension to RequestInit, not in @types/node's fetch - // signature — hence the local type. Carries proxy routing when a proxy env var is set. - const init: RequestInit & { dispatcher?: unknown } = { signal } + const init: RequestInit & { dispatcher?: unknown, verbose?: boolean } = { signal } if (state.dispatcher !== undefined) init.dispatcher = state.dispatcher + if (isVerbose()) + init.verbose = true try { ctx.response = await fetch(ctx.request, init) diff --git a/cli/src/http/error-mapper.ts b/cli/src/http/error-mapper.ts index 825003c60f..cb0d03c206 100644 --- a/cli/src/http/error-mapper.ts +++ b/cli/src/http/error-mapper.ts @@ -1,5 +1,4 @@ -import type { BaseError } from '@/errors/base' -import { newError } from '@/errors/base' +import { BaseError, HttpClientError, newError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { redactBearer } from './sanitize' @@ -32,54 +31,50 @@ async function readBody(response: Response): Promise<{ raw: string, parsed?: Wir } export async function classifyResponse(request: Request, response: Response): Promise { - const { parsed } = await readBody(response.clone()) + const { parsed, raw } = await readBody(response.clone()) const wire: WireFields = parsed?.error ?? parsed ?? {} const status = response.status const url = redactBearer(response.url || request.url) const method = request.method if (status === 401) { - return newError( + return HttpClientError.from(newError( ErrorCode.AuthExpired, wire.message ?? 'session expired or revoked', - ) + )) .withHint(wire.hint ?? 'run \'difyctl auth login\' to sign in again') .withHttpStatus(status) .withRequest(method, url) } if (status >= 500) { - return newError( + return HttpClientError.from(newError( ErrorCode.Server5xx, wire.message ?? `server error (HTTP ${status})`, - ) + )) .withHttpStatus(status) .withRequest(method, url) + .withRawResponse(raw) } - const err = newError( + const err = HttpClientError.from(newError( ErrorCode.Server4xxOther, wire.message ?? `request failed (HTTP ${status})`, - ) + )) .withHttpStatus(status) .withRequest(method, url) + .withRawResponse(raw) return wire.hint !== undefined ? err.withHint(wire.hint) : err } export function classifyTransportError(err: unknown): BaseError { - const message = err instanceof Error ? err.message : String(err) - const sanitized = redactBearer(message) - - if (err instanceof Error && err.name === 'TimeoutError') - return newError(ErrorCode.NetworkTimeout, 'request timed out').wrap(err) - if (err instanceof Error && err.name === 'AbortError') - return newError(ErrorCode.NetworkTimeout, 'request aborted').wrap(err) - if (sanitized.toLowerCase().includes('econnrefused')) - return newError(ErrorCode.NetworkDns, 'connection refused').wrap(err) - if (sanitized.toLowerCase().includes('enotfound')) - return newError(ErrorCode.NetworkDns, 'host lookup failed').wrap(err) - if (sanitized.toLowerCase().includes('etimedout')) - return newError(ErrorCode.NetworkTimeout, 'connection timed out').wrap(err) - - return newError(ErrorCode.Unknown, sanitized).wrap(err) + if (err instanceof BaseError) { + return err + } + if (!(err instanceof Error)) { + return newError(ErrorCode.Unknown, String(err)).wrap(err) + } + const sanitized = redactBearer(err.message) + // there isn't a practical way to classify network errors reliably + return newError(ErrorCode.NetworkConnection, sanitized).wrap(err) } diff --git a/cli/tsconfig.json b/cli/tsconfig.json index d709023b13..96ea17a8da 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "@dify/tsconfig/node.json", "compilerOptions": { - "rootDir": "src", "moduleResolution": "bundler", "paths": { "@/*": [ @@ -12,12 +11,10 @@ ] }, "types": ["node"], - "declaration": true, - "declarationMap": true, - "noEmit": false, + "noEmit": true, // we already have bundlers to handle this. "outDir": "dist", "sourceMap": true }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "test", "node_modules", "**/*.test.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"], // tests must be included for typechecking + "exclude": ["node_modules", "dist"] }