refactor(openapi/cli): split app usage-face from studio-app build-face (#37641)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Xiyuan Chen
2026-06-22 07:46:59 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 1d74bff311
commit 084f122814
59 changed files with 878 additions and 611 deletions
+3 -3
View File
@@ -1,17 +1,17 @@
import type { AppsClient } from './apps'
import type { AppReader } from './app-reader'
import type { AppInfoCache } from '@/cache/app-info'
import type { AppMeta, AppMetaFieldKey } from '@/types/app-meta'
import { covers, fromDescribe, mergeMeta } from '@/types/app-meta'
export type AppMetaClientOptions = {
readonly apps: AppsClient
readonly apps: AppReader
readonly host: string
readonly cache?: AppInfoCache
readonly now?: () => Date
}
export class AppMetaClient {
private readonly apps: AppsClient
private readonly apps: AppReader
private readonly host: string
private readonly cache: AppInfoCache | undefined
private readonly now: () => Date
+30
View File
@@ -0,0 +1,30 @@
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import { describe, expect, it } from 'vitest'
import { selectAppReader, SubjectKind, subjectOf } from './app-reader'
import { AppsClient } from './apps'
import { PermittedExternalAppsClient } from './permitted-external-apps'
const http = { baseURL: 'https://x', request: async () => new Response() } as unknown as HttpClient
function ctx(external: boolean): ActiveContext {
return {
host: 'h',
email: 'e',
ctx: {
account: { id: 'a', email: 'e', name: 'n' },
external_subject: external ? { email: 'e', issuer: 'i' } : undefined,
},
}
}
describe('selectAppReader', () => {
it('account login → AppsClient', () => {
expect(selectAppReader(ctx(false), http)).toBeInstanceOf(AppsClient)
expect(subjectOf(ctx(false))).toBe(SubjectKind.Account)
})
it('external_subject present → PermittedExternalAppsClient', () => {
expect(selectAppReader(ctx(true), http)).toBeInstanceOf(PermittedExternalAppsClient)
expect(subjectOf(ctx(true))).toBe(SubjectKind.External)
})
})
+35
View File
@@ -0,0 +1,35 @@
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
import type { ListQuery } from './apps'
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import { AppsClient } from './apps'
import { PermittedExternalAppsClient } from './permitted-external-apps'
export type AppReader = {
list: (q: ListQuery) => Promise<AppListResponse>
describe: (appId: string, fields?: readonly string[]) => Promise<AppDescribeResponse>
}
// The auth subject behind an openapi bearer token. Each kind reads apps from its own surface.
export const SubjectKind = {
Account: 'account',
External: 'external',
} as const
export type SubjectKindValue = (typeof SubjectKind)[keyof typeof SubjectKind]
export function subjectOf(active: ActiveContext): SubjectKindValue {
return active.ctx.external_subject !== undefined ? SubjectKind.External : SubjectKind.Account
}
type AppReaderFactory = (http: HttpClient) => AppReader
// Maps each auth subject to the app reader for its surface.
const APP_READER_BY_SUBJECT: Readonly<Record<SubjectKindValue, AppReaderFactory>> = {
[SubjectKind.Account]: http => new AppsClient(http),
[SubjectKind.External]: http => new PermittedExternalAppsClient(http),
}
export function selectAppReader(active: ActiveContext, http: HttpClient): AppReader {
return APP_READER_BY_SUBJECT[subjectOf(active)](http)
}
+1 -5
View File
@@ -36,7 +36,6 @@ describe('AppsClient.list', () => {
// Optional filters are omitted entirely when not supplied.
expect(q.has('mode')).toBe(false)
expect(q.has('name')).toBe(false)
expect(q.has('tag')).toBe(false)
})
it('forwards explicit pagination and filters', async () => {
@@ -48,7 +47,6 @@ describe('AppsClient.list', () => {
limit: 50,
mode: 'chat',
name: 'support bot',
tag: 'prod',
})
const q = queryOf(stub.captured.url)
@@ -56,18 +54,16 @@ describe('AppsClient.list', () => {
expect(q.get('limit')).toBe('50')
expect(q.get('mode')).toBe('chat')
expect(q.get('name')).toBe('support bot')
expect(q.get('tag')).toBe('prod')
})
it('treats empty-string filters as absent (not blank query params)', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '', tag: '' })
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '' })
const q = queryOf(stub.captured.url)
expect(q.has('mode')).toBe(false)
expect(q.has('name')).toBe(false)
expect(q.has('tag')).toBe(false)
})
it('propagates server 403 as a classified BaseError', async () => {
+8 -4
View File
@@ -1,4 +1,5 @@
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
import type { AppReader } from './app-reader'
import type { OpenApiClient } from '@/http/orpc'
import type { HttpClient } from '@/http/types'
import { createOpenApiClient } from '@/http/orpc'
@@ -9,10 +10,14 @@ export type ListQuery = {
readonly limit?: number
readonly mode?: AppMode | ''
readonly name?: string
readonly tag?: string
}
export class AppsClient {
// An absent or empty mode filter means "any mode" — collapse both to undefined for the query.
export function normalizeMode(mode: AppMode | '' | undefined): AppMode | undefined {
return mode !== undefined && mode !== '' ? mode : undefined
}
export class AppsClient implements AppReader {
private readonly orpc: OpenApiClient
constructor(http: HttpClient) {
@@ -25,9 +30,8 @@ export class AppsClient {
workspace_id: q.workspaceId,
page: q.page ?? 1,
limit: q.limit ?? 20,
mode: q.mode !== undefined && q.mode !== '' ? q.mode : undefined,
mode: normalizeMode(q.mode),
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
tag: q.tag !== undefined && q.tag !== '' ? q.tag : undefined,
},
})
}
@@ -0,0 +1,27 @@
import type { HttpClient } from '@/http/types'
import { describe, expect, it, vi } from 'vitest'
import { PermittedExternalAppsClient } from './permitted-external-apps'
function fakeHttp() {
return { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
}
type WithOrpc = { orpc: unknown }
describe('PermittedExternalAppsClient', () => {
it('list calls permittedExternalApps.get with paging/filter query', async () => {
const c = new PermittedExternalAppsClient(fakeHttp())
const get = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] })
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { describe: { get: vi.fn() } } } }
await c.list({ workspaceId: '', page: 2, limit: 5, mode: undefined, name: 'a' })
expect(get).toHaveBeenCalledWith({ query: { page: 2, limit: 5, mode: undefined, name: 'a' } })
})
it('describe calls permittedExternalApps.byAppId.describe.get with app_id + fields', async () => {
const c = new PermittedExternalAppsClient(fakeHttp())
const dget = vi.fn().mockResolvedValue({ info: null, parameters: null, input_schema: null })
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get: vi.fn(), byAppId: { describe: { get: dget } } } }
await c.describe('app-1', ['info'])
expect(dget).toHaveBeenCalledWith({ params: { app_id: 'app-1' }, query: { fields: 'info' } })
})
})
+34
View File
@@ -0,0 +1,34 @@
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
import type { AppReader } from './app-reader'
import type { ListQuery } from './apps'
import type { OpenApiClient } from '@/http/orpc'
import type { HttpClient } from '@/http/types'
import { createOpenApiClient } from '@/http/orpc'
import { normalizeMode } from './apps'
export class PermittedExternalAppsClient implements AppReader {
private readonly orpc: OpenApiClient
constructor(http: HttpClient) {
this.orpc = createOpenApiClient(http)
}
// workspaceId is ignored: the external grant is not workspace-scoped.
async list(q: ListQuery): Promise<AppListResponse> {
return this.orpc.permittedExternalApps.get({
query: {
page: q.page ?? 1,
limit: q.limit ?? 20,
mode: normalizeMode(q.mode),
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
},
})
}
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
return this.orpc.permittedExternalApps.byAppId.describe.get({
params: { app_id: appId },
query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined },
})
}
}
-2
View File
@@ -21,8 +21,6 @@ function metaInfoOnly(): AppMeta {
name: 'Greeter',
description: '',
mode: 'chat',
author: 'tester',
tags: [],
updated_at: undefined,
service_api_enabled: false,
is_agent: false,
+4
View File
@@ -2,7 +2,9 @@ import type { CommandConstructor } from '@/framework/command'
import { describe, expect, it } from 'vitest'
import Login from '@/commands/auth/login/index'
import DescribeApp from '@/commands/describe/app/index'
import ExportStudioApp from '@/commands/export/studio-app/index'
import GetApp from '@/commands/get/app/index'
import ImportStudioApp from '@/commands/import/studio-app/index'
import ResumeApp from '@/commands/resume/app/index'
import RunApp from '@/commands/run/app/index'
@@ -13,6 +15,8 @@ const GUIDED_COMMANDS: ReadonlyArray<readonly [string, CommandConstructor]> = [
['resume app', ResumeApp],
['describe app', DescribeApp],
['get app', GetApp],
['export studio-app', ExportStudioApp],
['import studio-app', ImportStudioApp],
['auth login', Login],
]
+1 -9
View File
@@ -1,4 +1,4 @@
import type { AppDescribeInfo, TagItem } from '@dify/contracts/api/openapi/types.gen'
import type { AppDescribeInfo } from '@dify/contracts/api/openapi/types.gen'
import type { AppMeta } from '@/types/app-meta'
export const APP_DESCRIBE_MODE_KEY = 'app-describe'
@@ -28,10 +28,8 @@ export class AppDescribeOutput {
['Name', info.name],
['ID', info.id],
['Mode', info.mode],
['Author', info.author ?? ''],
['Updated', info.updated_at ?? ''],
['Service API', info.service_api_enabled ? 'true' : 'false'],
['Tags', joinTags(info.tags ?? [])],
]
if (info.description !== '' && info.description !== undefined)
rows.push(['Description', info.description ?? ''])
@@ -55,12 +53,6 @@ export class AppDescribeOutput {
}
}
function joinTags(tags: readonly TagItem[]): string {
if (tags.length === 0)
return '<none>'
return tags.map(t => t.name).join(',')
}
function alignedRows(rows: readonly [string, string][]): string[] {
const widest = rows.reduce((m, [k]) => Math.max(m, k.length), 0)
return rows.map(([k, v]) => `${`${k}:`.padEnd(widest + 2)}${v}`)
+11 -3
View File
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadAppInfoCache } from '@/cache/app-info'
import { formatted, stringifyOutput } from '@/framework/output'
import { ENV_CACHE_DIR } from '@/store/dir'
@@ -34,6 +34,7 @@ describe('runDescribeApp', () => {
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
vi.restoreAllMocks()
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
@@ -60,8 +61,6 @@ describe('runDescribeApp', () => {
expect(out).toContain('Mode:')
expect(out).toContain('chat')
expect(out).toContain('Service API:')
expect(out).toContain('Tags:')
expect(out).toContain('demo')
expect(out).toContain('Description:')
expect(out).toContain('Parameters:')
})
@@ -115,4 +114,13 @@ describe('runDescribeApp', () => {
},
)).rejects.toThrow()
})
it('external login resolves describe via the permitted-external route', async () => {
const activeExt: ActiveContext = { host: mock.url, email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
const out = await runDescribeApp(
{ appId: 'app-1' },
{ active: activeExt, http: testHttpClient(mock.url, 'dfoe_test'), host: mock.url },
)
expect(out.payload.info?.id).toBe('app-1')
})
})
+2 -2
View File
@@ -3,7 +3,7 @@ import type { AppInfoCache } from '@/cache/app-info'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import { AppMetaClient } from '@/api/app-meta'
import { AppsClient } from '@/api/apps'
import { selectAppReader } from '@/api/app-reader'
import { runWithSpinner } from '@/sys/io/spinner'
import { nullStreams } from '@/sys/io/streams'
import { FieldInfo, FieldInputSchema, FieldParameters } from '@/types/app-meta'
@@ -26,7 +26,7 @@ export type DescribeAppDeps = {
}
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
const apps = new AppsClient(deps.http)
const apps = selectAppReader(deps.active, deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
const io = deps.io ?? nullStreams()
const result = await runWithSpinner(
@@ -0,0 +1,12 @@
export const agentGuide = `
WHEN TO USE
A studio app is what you build and edit in Studio on the web console,
inside a workspace — the app's source definition, not the published app
that 'run app' invokes. Export pulls that definition as YAML to back it
up, diff it, or recreate the app elsewhere with 'import studio-app'. To
run or inspect an app instead, use the 'app' noun.
ERROR RECOVERY
app not found (404) difyctl get app
not logged in (exit 4) difyctl auth login
`
@@ -1,16 +1,17 @@
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Args, Flags } from '@/framework/flags'
import { agentGuide } from './guide'
import { runExportApp } from './run'
export default class ExportApp extends DifyCommand {
static override description = 'Export an app\'s DSL configuration as YAML'
export default class ExportStudioApp extends DifyCommand {
static override description = 'Export a studio app\'s DSL configuration as YAML'
static override examples = [
'<%= config.bin %> export app <app-id>',
'<%= config.bin %> export app <app-id> --output ./my-app.yaml',
'<%= config.bin %> export app <app-id> --include-secret',
'<%= config.bin %> export app <app-id> --workflow-id <workflow-id>',
'<%= config.bin %> export studio-app <app-id>',
'<%= config.bin %> export studio-app <app-id> --output ./my-app.yaml',
'<%= config.bin %> export studio-app <app-id> --include-secret',
'<%= config.bin %> export studio-app <app-id> --workflow-id <workflow-id>',
]
static override args = {
@@ -26,7 +27,7 @@ export default class ExportApp extends DifyCommand {
}
async run(argv: string[]) {
const { args, flags } = this.parse(ExportApp, argv)
const { args, flags } = this.parse(ExportStudioApp, argv)
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
const result = await runExportApp({
appId: args.id,
@@ -42,4 +43,8 @@ export default class ExportApp extends DifyCommand {
ctx.io.out.write('\n')
}
}
override agentGuide(): string {
return agentGuide
}
}
@@ -35,9 +35,8 @@ export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps):
const io = deps.io ?? nullStreams()
const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h))
// workspace is needed to satisfy the auth pipeline; resolving it here
// mirrors what other commands do even though the export endpoint does not
// take workspace_id as a query parameter (it loads tenant from app).
// workspace is resolved to satisfy the auth pipeline; the export endpoint itself
// takes no workspace_id query parameter (it loads tenant from the app).
resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const client = dslFactory(deps.http)
+1 -9
View File
@@ -1,4 +1,4 @@
import type { AppListResponse, AppListRow, TagItem } from '@dify/contracts/api/openapi/types.gen'
import type { AppListResponse, AppListRow } from '@dify/contracts/api/openapi/types.gen'
import type { TableCell, TableColumn } from '@/framework/output'
export const APP_MODE_KEY = 'app'
@@ -7,9 +7,7 @@ export const APP_COLUMNS: readonly TableColumn[] = [
{ name: 'NAME', priority: 0 },
{ name: 'ID', priority: 0 },
{ name: 'MODE', priority: 0 },
{ name: 'TAGS', priority: 0 },
{ name: 'UPDATED', priority: 0 },
{ name: 'AUTHOR', priority: 1 },
{ name: 'WORKSPACE', priority: 1 },
]
@@ -25,9 +23,7 @@ export class AppRow {
this.data.name,
this.data.id,
this.data.mode,
joinTags(this.data.tags ?? []),
this.data.updated_at ?? '',
this.data.created_by_name ?? '',
this.data.workspace_name ?? '',
]
}
@@ -70,7 +66,3 @@ export class AppListOutput {
return this.envelope
}
}
function joinTags(tags: readonly TagItem[]): string {
return tags.map(t => t.name).join(',')
}
-2
View File
@@ -42,7 +42,6 @@ export default class GetApp extends DifyCommand {
'limit': Flags.string({ description: 'page size [1..200]' }),
'mode': Flags.string({ description: 'filter by app mode', options: APP_MODE_VALUES }),
'name': Flags.string({ description: 'filter by app name (server-side substring)' }),
'tag': Flags.string({ description: 'filter by tag name (server-side exact match)' }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
}
@@ -59,7 +58,6 @@ export default class GetApp extends DifyCommand {
limitRaw: flags.limit,
mode: flags.mode as AppMode | undefined,
name: flags.name,
tag: flags.tag,
format,
}, { active: ctx.active, http: ctx.http, io: ctx.io })
return table({
+28 -15
View File
@@ -1,8 +1,9 @@
import type { DifyMock } from '@test/fixtures/dify-mock/server'
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { stringifyOutput, table } from '@/framework/output'
import { AppListOutput } from './handlers.js'
import { runGetApp } from './run.js'
@@ -25,6 +26,7 @@ describe('runGetApp', () => {
})
afterEach(async () => {
vi.restoreAllMocks()
await mock.stop()
})
@@ -40,13 +42,12 @@ describe('runGetApp', () => {
}))
}
it('list (no id, default format) renders table with NAME ID MODE TAGS UPDATED', async () => {
it('list (no id, default format) renders table with NAME ID MODE UPDATED', async () => {
const out = await render()
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED/)
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED/)
expect(out).toContain('Greeter')
expect(out).toContain('app-1')
expect(out).toContain('chat')
expect(out).toContain('demo')
expect(out).toContain('Workflow')
expect(out).not.toContain('app-3')
})
@@ -56,9 +57,7 @@ describe('runGetApp', () => {
'NAME',
'ID',
'MODE',
'TAGS',
'UPDATED',
'AUTHOR',
'WORKSPACE',
])
})
@@ -76,12 +75,6 @@ describe('runGetApp', () => {
expect(out).not.toContain('Greeter')
})
it('--tag filters server-side', async () => {
const out = await render({ tag: 'demo' })
expect(out).toContain('Greeter')
expect(out).not.toContain('Workflow')
})
it('-A all-workspaces aggregates across workspaces sorted by id', async () => {
const out = await render({ allWorkspaces: true })
expect(out).toContain('app-1')
@@ -110,10 +103,9 @@ describe('runGetApp', () => {
expect(out.trim().split('\n').sort()).toEqual(['app-1', 'app-2'])
})
it('-o wide includes AUTHOR and WORKSPACE columns', async () => {
it('-o wide includes the WORKSPACE column', async () => {
const out = await render({ format: 'wide' })
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED\s+AUTHOR\s+WORKSPACE/)
expect(out).toContain('tester')
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED\s+WORKSPACE/)
expect(out).toContain('Default')
})
@@ -138,4 +130,25 @@ describe('runGetApp', () => {
}
await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/)
})
it('external login lists via permitted-external client without workspace', async () => {
const list = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 1, has_more: false, data: [{ id: 'x', name: 'X', description: null, mode: 'chat', updated_at: null, workspace_id: 'w', workspace_name: 'W' }] })
const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps')
vi.spyOn(PermittedExternalAppsClient.prototype, 'list').mockImplementation(list)
const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
const http = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
const res = await runGetApp({}, { active, http })
expect(list).toHaveBeenCalled()
const firstCallArg = list.mock.calls[0]![0] as { workspaceId: string }
expect(firstCallArg.workspaceId).toBe('')
expect(res.data).toBeDefined()
})
it('--all-workspaces throws UsageInvalidFlag for external logins', async () => {
const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
const httpClient = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
await expect(runGetApp({ allWorkspaces: true }, { active, http: httpClient }))
.rejects
.toThrow(/--all-workspaces is not available for external logins/)
})
})
+14 -12
View File
@@ -1,9 +1,12 @@
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
import type { AppReader } from '@/api/app-reader'
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import { AppsClient } from '@/api/apps'
import { selectAppReader, SubjectKind, subjectOf } from '@/api/app-reader'
import { WorkspacesClient } from '@/api/workspaces'
import { newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { LIMIT_DEFAULT, parseLimit } from '@/limit/limit'
import { getEnv } from '@/sys/index'
import { runWithSpinner } from '@/sys/io/spinner'
@@ -19,7 +22,6 @@ export type GetAppOptions = {
readonly limitRaw?: string
readonly mode?: AppMode
readonly name?: string
readonly tag?: string
readonly format?: string
}
@@ -28,7 +30,6 @@ export type GetAppDeps = {
readonly http: HttpClient
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
readonly appsFactory?: (http: HttpClient) => AppsClient
readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient
}
@@ -40,10 +41,10 @@ export type GetAppResult = {
export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<GetAppResult> {
const env = deps.envLookup ?? getEnv
const appsFactory = deps.appsFactory ?? ((h: HttpClient) => new AppsClient(h))
const wsFactory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h))
const apps = appsFactory(deps.http)
const external = subjectOf(deps.active) === SubjectKind.External
const apps = selectAppReader(deps.active, deps.http)
const pageSize = resolveLimit(opts.limitRaw, env)
const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page
const label = opts.appId !== undefined && opts.appId !== '' ? 'Fetching app' : 'Fetching apps'
@@ -53,15 +54,20 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
{ io, label },
async (): Promise<AppListResponse> => {
if (opts.allWorkspaces === true) {
if (external)
throw newError(ErrorCode.UsageInvalidFlag, '--all-workspaces is not available for external logins')
const ws = wsFactory(deps.http)
return runAllWorkspaces(apps, ws, opts, page, pageSize)
}
if (opts.appId !== undefined && opts.appId !== '') {
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const wsName = workspaceNameForId(deps.active, wsId)
const wsId = external ? '' : resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const wsName = external ? '' : workspaceNameForId(deps.active, wsId)
const desc = await apps.describe(opts.appId, ['info'])
return describeToEnvelope(desc, wsId, wsName)
}
if (external) {
return apps.list({ workspaceId: '', page, limit: pageSize, mode: opts.mode, name: opts.name })
}
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
return apps.list({
workspaceId: wsId,
@@ -69,7 +75,6 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
limit: pageSize,
mode: opts.mode,
name: opts.name,
tag: opts.tag,
})
},
)
@@ -102,9 +107,7 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
name: desc.info.name,
description: desc.info.description,
mode: desc.info.mode as AppMode,
tags: desc.info.tags,
updated_at: desc.info.updated_at,
created_by_name: desc.info.author === '' ? undefined : desc.info.author,
workspace_id: wsId,
workspace_name: wsName === '' ? undefined : wsName,
}],
@@ -118,7 +121,7 @@ function workspaceNameForId(active: ActiveContext, id: string): string {
}
async function runAllWorkspaces(
apps: AppsClient,
apps: AppReader,
ws: WorkspacesClient,
opts: GetAppOptions,
page: number,
@@ -139,7 +142,6 @@ async function runAllWorkspaces(
limit,
mode: opts.mode,
name: opts.name,
tag: opts.tag,
})
merged.total += env.total
merged.data = [...merged.data, ...env.data]
@@ -0,0 +1,17 @@
export const agentGuide = `
WHEN TO USE
A studio app is what you build and edit in Studio on the web console,
inside a workspace — the app's source definition. Import materialises a
DSL YAML into a new (or existing) studio app; pair it with
'export studio-app' to move an app between workspaces or instances. To
run or inspect the result, switch to the 'app' noun.
BEHAVIOUR
A DSL version mismatch is auto-confirmed; no second command needed.
Missing plugin dependencies are listed on stderr — install them before
running the app.
ERROR RECOVERY
workspace required difyctl get workspace
not logged in (exit 4) difyctl auth login
`
@@ -1,16 +1,17 @@
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Flags } from '@/framework/flags'
import { agentGuide } from './guide'
import { pluginDependencyLabel, runImportApp } from './run'
export default class ImportApp extends DifyCommand {
static override description = 'Import an app from a DSL YAML file or URL'
export default class ImportStudioApp extends DifyCommand {
static override description = 'Import a studio app from a DSL YAML file or URL'
static override examples = [
'<%= config.bin %> import app --from-file ./app.yaml',
'<%= config.bin %> import app --from-file /path/to/app.yaml --name "My App"',
'<%= config.bin %> import app --from-url https://example.com/my-app.yaml',
'<%= config.bin %> import app --from-file ./app.yaml --app-id <existing-app-id>',
'<%= config.bin %> import studio-app --from-file ./app.yaml',
'<%= config.bin %> import studio-app --from-file /path/to/app.yaml --name "My App"',
'<%= config.bin %> import studio-app --from-url https://example.com/my-app.yaml',
'<%= config.bin %> import studio-app --from-file ./app.yaml --app-id <existing-app-id>',
]
static override flags = {
@@ -27,7 +28,7 @@ export default class ImportApp extends DifyCommand {
}
async run(argv: string[]) {
const { flags } = this.parse(ImportApp, argv)
const { flags } = this.parse(ImportStudioApp, argv)
if (flags['from-file'] === undefined && flags['from-url'] === undefined)
this.error('one of --from-file or --from-url is required', { exit: 1 })
if (flags['from-file'] !== undefined && flags['from-url'] !== undefined)
@@ -57,4 +58,8 @@ export default class ImportApp extends DifyCommand {
ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`)
}
}
override agentGuide(): string {
return agentGuide
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { ActiveContext } from '@/auth/hosts'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppRunClient } from '@/api/app-run'
import { AppsClient } from '@/api/apps'
import { PermittedExternalAppsClient } from '@/api/permitted-external-apps'
import { bufferStreams } from '@/sys/io/streams'
import { resumeApp } from './run.js'
const DESCRIBE_RESULT = {
info: { id: 'app-2', name: 'X', mode: 'workflow', description: '', updated_at: null, service_api_enabled: true, is_agent: false },
parameters: null,
input_schema: null,
}
const FORM_RESP = { user_actions: [{ id: 'submit' }] }
function makeExternalActive(): ActiveContext {
return {
host: 'http://localhost',
email: 'sso@x.io',
ctx: {
account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' },
external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' },
},
} as unknown as ActiveContext
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('resumeApp pre-flight subject strategy', () => {
it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => {
const externalDescribe = vi.fn().mockResolvedValue(DESCRIBE_RESULT)
const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe)
const accountSpy = vi.spyOn(AppsClient.prototype, 'describe')
vi.spyOn(AppRunClient.prototype, 'submitHumanInput').mockResolvedValue(undefined as never)
const io = bufferStreams()
const http = {
baseURL: 'http://localhost',
request: vi.fn().mockImplementation((opts: { path: string }) => {
if (typeof opts.path === 'string' && opts.path.includes('form/human_input')) {
return Promise.resolve(FORM_RESP)
}
// reconnect stream — return an async iterable that ends immediately
const iter: AsyncIterable<never> = { [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ done: true, value: undefined as never }) }) }
return Promise.resolve(iter)
}),
} as unknown as import('@/http/types').HttpClient
try {
await resumeApp(
{ appId: 'app-2', formToken: 'ft-1', workflowRunId: 'wf-run-1', action: 'submit', inputs: {} },
{ active: makeExternalActive(), http, host: 'http://localhost', io },
)
}
catch {
// run may fail after pre-flight due to stream mock; we only check which describe was called
}
expect(externalSpy).toHaveBeenCalled()
expect(accountSpy).not.toHaveBeenCalled()
})
})
+3 -39
View File
@@ -4,10 +4,11 @@ import type { RunContext } from '@/commands/run/app/_strategies/index'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import { AppMetaClient } from '@/api/app-meta'
import { selectAppReader } from '@/api/app-reader'
import { AppRunClient } from '@/api/app-run'
import { AppsClient } from '@/api/apps'
import { pickStrategy } from '@/commands/run/app/_strategies/index'
import { RUN_MODES } from '@/commands/run/app/handlers'
import { resolveInputs, TEXT_FORMATS } from '@/commands/run/app/input-flags'
import { processExit } from '@/sys/index'
import { colorEnabled, colorScheme } from '@/sys/io/color'
import { FieldInfo } from '@/types/app-meta'
@@ -37,45 +38,8 @@ export type ResumeAppDeps = {
readonly exit?: (code: number) => never
}
const TEXT_FORMATS = new Set(['', 'text'])
async function resolveInputs(
inputsJson: string | undefined,
inputsFile: string | undefined,
directInputs: Readonly<Record<string, unknown>> | undefined,
): Promise<Record<string, unknown>> {
if (inputsJson !== undefined && inputsFile !== undefined)
throw new Error('--inputs and --inputs-file are mutually exclusive')
if (inputsJson !== undefined) {
let parsed: unknown
try {
parsed = JSON.parse(inputsJson)
}
catch {
throw new Error('--inputs must be valid JSON')
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error('--inputs must be a JSON object')
return parsed as Record<string, unknown>
}
if (inputsFile !== undefined) {
const { readFile } = await import('node:fs/promises')
let parsed: unknown
try {
parsed = JSON.parse(await readFile(inputsFile, 'utf8'))
}
catch {
throw new Error('--inputs-file must contain valid JSON')
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error('--inputs-file must be a JSON object')
return parsed as Record<string, unknown>
}
return { ...(directInputs ?? {}) }
}
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
const apps = new AppsClient(deps.http)
const apps = selectAppReader(deps.active, deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
const m = await meta.get(opts.appId, [FieldInfo])
const mode = m.info?.mode ?? RUN_MODES.Workflow
+42
View File
@@ -0,0 +1,42 @@
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
// Output formats that render the run/resume result as plain text rather than JSON/YAML.
export const TEXT_FORMATS = new Set(['', 'text'])
// Shared by `run app` and `resume app`: --inputs (inline JSON) / --inputs-file (JSON file) /
// direct inputs are mutually exclusive ways to supply the run's variable map.
export async function resolveInputs(
inputsJson: string | undefined,
inputsFile: string | undefined,
directInputs: Readonly<Record<string, unknown>> | undefined,
): Promise<Record<string, unknown>> {
if (inputsJson !== undefined && inputsFile !== undefined)
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' })
if (inputsJson !== undefined) {
let parsed: unknown
try {
parsed = JSON.parse(inputsJson)
}
catch {
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' })
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' })
return parsed as Record<string, unknown>
}
if (inputsFile !== undefined) {
const { readFile } = await import('node:fs/promises')
let parsed: unknown
try {
parsed = JSON.parse(await readFile(inputsFile, 'utf8'))
}
catch {
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' })
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' })
return parsed as Record<string, unknown>
}
return { ...(directInputs ?? {}) }
}
+33 -1
View File
@@ -1,11 +1,12 @@
import type { DifyMock } from '@test/fixtures/dify-mock/server'
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadAppInfoCache } from '@/cache/app-info'
import { resumeApp } from '@/commands/resume/app/run'
import { ENV_CACHE_DIR } from '@/store/dir'
@@ -418,4 +419,35 @@ describe('runApp', () => {
expect(docInput.transfer_method).toBe('remote_url')
expect(docInput.url).toBe('https://example.com/override.pdf')
})
it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => {
const describeResult = { info: { id: 'app-1', name: 'X', mode: 'chat', description: '', updated_at: null, service_api_enabled: true, is_agent: false }, parameters: null, input_schema: null }
const externalDescribe = vi.fn().mockResolvedValue(describeResult)
const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps')
const { AppsClient } = await import('@/api/apps')
const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe)
const accountSpy = vi.spyOn(AppsClient.prototype, 'describe')
const io = bufferStreams()
const http = { baseURL: mock.url, request: vi.fn().mockResolvedValue({ answer: 'echo: hi', conversation_id: 'conv-1', message_id: 'msg-1', mode: 'chat', metadata: {} }) } as unknown as HttpClient
const activeExt: ActiveContext = {
host: mock.url,
email: 'sso@x.io',
ctx: {
account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' },
external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' },
},
}
try {
await runApp(
{ appId: 'app-1', message: 'hi' },
{ active: activeExt, http, host: mock.url, io },
)
}
catch {
// run may fail due to mocked http; we only care about which describe was called
}
expect(externalSpy).toHaveBeenCalled()
expect(accountSpy).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
})
+3 -39
View File
@@ -3,8 +3,8 @@ import type { AppInfoCache } from '@/cache/app-info'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import { AppMetaClient } from '@/api/app-meta'
import { selectAppReader } from '@/api/app-reader'
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, HttpClientError } from '@/errors/base'
@@ -13,6 +13,7 @@ import { processExit } from '@/sys/index'
import { FieldInfo } from '@/types/app-meta'
import { resolveFileInputs } from './file-flags.js'
import { RUN_MODES } from './handlers.js'
import { resolveInputs, TEXT_FORMATS } from './input-flags.js'
export type RunAppOptions = {
readonly appId: string
@@ -40,45 +41,8 @@ export type RunAppDeps = {
readonly exit?: (code: number) => never
}
const TEXT_FORMATS = new Set(['', 'text'])
async function resolveInputs(
inputsJson: string | undefined,
inputsFile: string | undefined,
directInputs: Readonly<Record<string, unknown>> | undefined,
): Promise<Record<string, unknown>> {
if (inputsJson !== undefined && inputsFile !== undefined)
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' })
if (inputsJson !== undefined) {
let parsed: unknown
try {
parsed = JSON.parse(inputsJson)
}
catch {
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' })
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' })
return parsed as Record<string, unknown>
}
if (inputsFile !== undefined) {
const { readFile } = await import('node:fs/promises')
let parsed: unknown
try {
parsed = JSON.parse(await readFile(inputsFile, 'utf8'))
}
catch {
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' })
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' })
return parsed as Record<string, unknown>
}
return { ...(directInputs ?? {}) }
}
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
const apps = new AppsClient(deps.http)
const apps = selectAppReader(deps.active, deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
try {
+4 -4
View File
@@ -17,11 +17,11 @@ import CreateMember from '@/commands/create/member/index'
import DeleteMember from '@/commands/delete/member/index'
import DescribeApp from '@/commands/describe/app/index'
import EnvList from '@/commands/env/list/index'
import ExportApp from '@/commands/export/app/index'
import ExportStudioApp from '@/commands/export/studio-app/index'
import GetApp from '@/commands/get/app/index'
import GetMember from '@/commands/get/member/index'
import GetWorkspace from '@/commands/get/workspace/index'
import ImportApp from '@/commands/import/app/index'
import ImportStudioApp from '@/commands/import/studio-app/index'
import ResumeApp from '@/commands/resume/app/index'
import RunApp from '@/commands/run/app/index'
import SetMember from '@/commands/set/member/index'
@@ -77,7 +77,7 @@ export const commandTree: CommandTree = {
},
export: {
subcommands: {
app: { command: ExportApp, subcommands: {} },
'studio-app': { command: ExportStudioApp, subcommands: {} },
},
},
get: {
@@ -89,7 +89,7 @@ export const commandTree: CommandTree = {
},
import: {
subcommands: {
app: { command: ImportApp, subcommands: {} },
'studio-app': { command: ImportStudioApp, subcommands: {} },
},
},
resume: {
+13
View File
@@ -22,6 +22,9 @@ const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding
difyctl run app <id> "hello" -o json
Tips:
* Two app nouns: 'studio-app' is what you build and edit in Studio on the
web console inside a workspace (its source definition — export or move it);
'app' is a published app you run and inspect.
* 'difyctl auth list' shows your authenticated contexts; 'difyctl use host'
and 'difyctl use account' switch between them.
* Pass --workspace <id> to target a non-default workspace.
@@ -74,6 +77,16 @@ OUTPUT
Pass -o json (or -o yaml) on every command — the JSON shape is stable and
documented. Without it you get human tables meant for a terminal.
APP vs STUDIO-APP
Two nouns, two faces of the same app:
studio-app what you build and edit in Studio on the web console,
inside a workspace — the app's source definition.
app a published app, live and runnable.
Use 'studio-app' to work with the definition you manage on the website
(export it, move it between workspaces or instances); use 'app' to run
and inspect a published one. The COMMANDS list shows the verbs each
noun supports.
DISCOVERY
difyctl help -o json full command tree + this contract, machine-readable
difyctl get app -o json list apps (ids + modes)
+19
View File
@@ -72,6 +72,25 @@ describe('classifyResponse — canonical ErrorBody', () => {
})
})
describe('classifyResponse 403', () => {
it('maps 403 to AccessDenied (exit 4 bucket)', async () => {
const req403 = new Request('https://x/openapi/v1/apps/abc/export')
const res403 = new Response(
JSON.stringify({ code: 'unsupported_token_type', message: 'unsupported_token_type', status: 403 }),
{ status: 403, headers: { 'content-type': 'application/json' } },
)
const err = await classifyResponse(req403, res403)
expect(err.code).toBe(ErrorCode.AccessDenied)
expect(err.message).toBe('unsupported_token_type')
})
it('403 with no parseable ErrorBody falls back to generic denied message', async () => {
const err = await classified(403, 'not json')
expect(err.code).toBe(ErrorCode.AccessDenied)
expect(err.message).toBe('not permitted')
})
})
describe('classifyResponse — non-conforming bodies (no fallback by design)', () => {
it('non-JSON body yields no serverError, classification by status', async () => {
const err = await classified(502, '<html>bad gateway</html>')
+8
View File
@@ -44,9 +44,17 @@ const RATE_LIMITED_CLASS: StatusClass = {
includeRaw: false,
}
const ACCESS_DENIED_CLASS: StatusClass = {
code: ErrorCode.AccessDenied,
fallbackMessage: () => 'not permitted',
includeRaw: false,
}
function statusClass(status: number): StatusClass {
if (status === 401)
return AUTH_EXPIRED_CLASS
if (status === 403)
return ACCESS_DENIED_CLASS
if (status === 429)
return RATE_LIMITED_CLASS
if (status >= 500)
+2 -2
View File
@@ -44,10 +44,10 @@ describe('createOpenApiClient error mapping', () => {
}
it('recovers Dify message from a canonical ErrorBody 4xx response', async () => {
const caught = await classifiedError(403, { code: 'access_denied', message: 'no access', status: 403 })
const caught = await classifiedError(422, { code: 'invalid_param', message: 'no access', status: 422 })
expect(caught.code).toBe(ErrorCode.Server4xxOther)
expect(caught.httpStatus).toBe(403)
expect(caught.httpStatus).toBe(422)
expect(caught.message).toBe('no access')
// Parity with the transport path: the migrated endpoint's error keeps the request
// method/url and the raw body, so formatted errors still print the `request:` line
-2
View File
@@ -9,8 +9,6 @@ function describeResp(): AppDescribeResponse {
name: 'Greeter',
description: '',
mode: 'chat',
author: 'tester',
tags: [],
updated_at: undefined,
service_api_enabled: false,
is_agent: false,