mirror of
https://github.com/langgenius/dify.git
synced 2026-10-25 10:24:40 +08:00
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:
co-authored by
autofix-ci[bot]
parent
1d74bff311
commit
084f122814
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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' } })
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
-2
@@ -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,
|
||||
|
||||
@@ -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,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}`)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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(',')
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? {}) }
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user