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 00:46:59 -07:00 committed by GitHub
parent 1d74bff311
commit 084f122814
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 878 additions and 611 deletions

View File

@ -31,7 +31,7 @@ from controllers.openapi._models import (
AppDslExportQuery,
AppDslExportResponse,
AppDslImportPayload,
AppInfoResponse,
AppInfo,
AppListQuery,
AppListResponse,
AppListRow,
@ -62,7 +62,6 @@ from controllers.openapi._models import (
SessionListQuery,
SessionListResponse,
SessionRow,
TagItem,
TaskStopResponse,
UsageInfo,
WorkflowRunData,
@ -96,12 +95,11 @@ register_response_schema_models(
openapi_ns,
ErrorBody,
EventStreamResponse,
TagItem,
UsageInfo,
MessageMetadata,
AppListRow,
AppListResponse,
AppInfoResponse,
AppInfo,
AppDescribeInfo,
AppDescribeResponse,
AppDslExportResponse,

View File

@ -38,18 +38,12 @@ class PaginationEnvelope[T](BaseModel):
return cls(page=page, limit=limit, total=total, has_more=page * limit < total, data=items)
class TagItem(BaseModel):
name: str
class AppListRow(BaseModel):
id: str
name: str
description: str | None = None
mode: AppMode
tags: list[TagItem] = []
updated_at: str | None = None
created_by_name: str | None = None
workspace_id: str | None = None
workspace_name: str | None = None
@ -70,16 +64,14 @@ class PermittedExternalAppsListResponse(BaseModel):
data: list[AppListRow]
class AppInfoResponse(BaseModel):
class AppInfo(BaseModel):
id: str
name: str
description: str | None = None
mode: str
author: str | None = None
tags: list[TagItem] = []
class AppDescribeInfo(AppInfoResponse):
class AppDescribeInfo(AppInfo):
updated_at: str | None = None
service_api_enabled: bool
is_agent: bool = False
@ -294,7 +286,6 @@ class AppListQuery(BaseModel):
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
mode: AppMode | None = None
name: str | None = Field(None, max_length=200)
tag: str | None = Field(None, max_length=100)
class AppRunRequest(BaseModel):

View File

@ -19,7 +19,6 @@ from controllers.openapi._models import (
AppListQuery,
AppListResponse,
AppListRow,
TagItem,
)
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
@ -28,9 +27,9 @@ from core.app.app_config.common.parameters_mapping import get_parameters_from_fe
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import App
from models.model import AppMode
from services.account_service import TenantService
from services.app_service import AppListParams, AppService
from services.tag_service import TagService
_ALLOWED_DESCRIBE_FIELDS: frozenset[str] = frozenset({"info", "parameters", "input_schema"})
@ -84,6 +83,42 @@ def parameters_payload(app: App) -> dict:
return Parameters.model_validate(parameters).model_dump(mode="json")
def build_app_describe_response(app: App, fields: set[str] | None) -> AppDescribeResponse:
"""Public projection of an app (name / params / input schema) — never internal config."""
want_info = fields is None or "info" in fields
want_params = fields is None or "parameters" in fields
want_schema = fields is None or "input_schema" in fields
info = (
AppDescribeInfo(
id=str(app.id),
name=app.name,
mode=app.mode,
description=app.description,
updated_at=app.updated_at.isoformat() if app.updated_at else None,
service_api_enabled=bool(app.enable_api),
is_agent=app.mode in (AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT),
)
if want_info
else None
)
parameters: dict[str, Any] | None = None
input_schema: dict[str, Any] | None = None
if want_params:
try:
parameters = parameters_payload(app)
except AppUnavailableError:
parameters = dict(_EMPTY_PARAMETERS)
if want_schema:
try:
input_schema = build_input_schema(app)
except AppUnavailableError:
input_schema = dict(EMPTY_INPUT_SCHEMA)
return AppDescribeResponse(info=info, parameters=parameters, input_schema=input_schema)
@openapi_ns.route("/apps/<string:app_id>/describe")
class AppDescribeApi(AppReadResource):
@auth_router.guard(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
@ -92,46 +127,7 @@ class AppDescribeApi(AppReadResource):
def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery):
# describe is UUID-only (workspace_id query param dropped in #37212).
app = self._load(app_id)
requested = query.fields
want_info = requested is None or "info" in requested
want_params = requested is None or "parameters" in requested
want_schema = requested is None or "input_schema" in requested
info = (
AppDescribeInfo(
id=str(app.id),
name=app.name,
mode=app.mode,
description=app.description,
tags=[TagItem(name=t.name) for t in app.tags],
author=app.author_name,
updated_at=app.updated_at.isoformat() if app.updated_at else None,
service_api_enabled=bool(app.enable_api),
is_agent=app.mode in ("agent-chat", "advanced-chat"),
)
if want_info
else None
)
parameters: dict[str, Any] | None = None
input_schema: dict[str, Any] | None = None
if want_params:
try:
parameters = parameters_payload(app)
except AppUnavailableError:
parameters = dict(_EMPTY_PARAMETERS)
if want_schema:
try:
input_schema = build_input_schema(app)
except AppUnavailableError:
input_schema = dict(EMPTY_INPUT_SCHEMA)
return AppDescribeResponse(
info=info,
parameters=parameters,
input_schema=input_schema,
)
return build_app_describe_response(app, query.fields)
@openapi_ns.route("/apps")
@ -163,28 +159,18 @@ class AppListApi(Resource):
name=app.name,
description=app.description,
mode=app.mode,
tags=[TagItem(name=t.name) for t in app.tags],
updated_at=app.updated_at.isoformat() if app.updated_at else None,
created_by_name=getattr(app, "author_name", None),
workspace_id=str(workspace_id),
workspace_name=tenant_name,
)
env = AppListResponse(page=1, limit=1, total=1, has_more=False, data=[item])
return env
tag_ids: list[str] | None = None
if query.tag:
tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag, db.session)
if not tags:
return empty
tag_ids = [tag.id for tag in tags]
params = AppListParams(
page=query.page,
limit=query.limit,
mode=query.mode.value if query.mode else "all", # type:ignore
name=query.name,
tag_ids=tag_ids,
status="normal",
# Visibility gate pushed into the query — pagination.total stays
# consistent across pages because invisible rows never count.
@ -205,9 +191,7 @@ class AppListApi(Resource):
name=r.name,
description=r.description,
mode=r.mode,
tags=[TagItem(name=t.name) for t in r.tags],
updated_at=r.updated_at.isoformat() if r.updated_at else None,
created_by_name=getattr(r, "author_name", None),
workspace_id=str(workspace_id),
workspace_name=tenant_name,
)

View File

@ -8,14 +8,18 @@ EE blueprint chain so this module is unreachable there.
from __future__ import annotations
from flask_restx import Resource
from werkzeug.exceptions import NotFound
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._models import (
AppDescribeQuery,
AppDescribeResponse,
AppListRow,
PermittedExternalAppsListQuery,
PermittedExternalAppsListResponse,
)
from controllers.openapi.apps import build_app_describe_response
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, Edition
from extensions.ext_database import db
@ -67,9 +71,7 @@ class PermittedExternalAppsListApi(Resource):
name=app.name,
description=app.description,
mode=app.mode,
tags=[], # tenant-scoped; not surfaced cross-tenant
updated_at=app.updated_at.isoformat() if app.updated_at else None,
created_by_name=None, # cross-tenant author leak prevention
workspace_id=str(app.tenant_id),
workspace_name=tenant.name if tenant else None,
)
@ -82,3 +84,20 @@ class PermittedExternalAppsListApi(Resource):
data=items,
)
return env
@openapi_ns.route("/permitted-external-apps/<string:app_id>/describe")
class PermittedExternalAppDescribeApi(Resource):
@auth_router.guard(
scope=Scope.APPS_READ_PERMITTED_EXTERNAL,
allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}),
edition=frozenset({Edition.EE}),
)
@returns(200, AppDescribeResponse, description="Permitted external app description")
@accepts(query=AppDescribeQuery)
def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery):
# App already loaded and ACL-checked by the external_sso pipeline; project it.
app = auth_data.app
if app is None:
raise NotFound("app not found")
return build_app_describe_response(app, query.fields)

View File

@ -83,7 +83,6 @@ User-scoped operations
| mode | query | | No | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "channel", "chat", "completion", "rag-pipeline", "workflow" |
| name | query | | No | string |
| page | query | | No | integer, <br>**Default:** 1 |
| tag | query | | No | string |
| workspace_id | query | | Yes | string |
#### Responses
@ -331,6 +330,22 @@ Upload a file to use as an input variable when running the app
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /permitted-external-apps/{app_id}/describe
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| fields | query | | No | string |
| app_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Permitted external app description | **application/json**: [AppDescribeResponse](#appdescriberesponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /workspaces
#### Responses
@ -507,14 +522,12 @@ Upload a file to use as an input variable when running the app
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| author | string | | No |
| description | string | | No |
| id | string | | Yes |
| is_agent | boolean | | No |
| mode | string | | Yes |
| name | string | | Yes |
| service_api_enabled | boolean | | Yes |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
| updated_at | string | | No |
#### AppDescribeQuery
@ -568,16 +581,14 @@ Request body for POST /workspaces/<workspace_id>/apps/imports.
| yaml_content | string | Inline YAML DSL string (required when mode is yaml-content) | No |
| yaml_url | string | Remote URL to fetch YAML from (required when mode is yaml-url) | No |
#### AppInfoResponse
#### AppInfo
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| author | string | | No |
| description | string | | No |
| id | string | | Yes |
| mode | string | | Yes |
| name | string | | Yes |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
#### AppListQuery
@ -589,7 +600,6 @@ mode is a closed enum.
| mode | [AppMode](#appmode) | | No |
| name | string | | No |
| page | integer, <br>**Default:** 1 | | No |
| tag | string | | No |
| workspace_id | string | | Yes |
#### AppListResponse
@ -606,12 +616,10 @@ mode is a closed enum.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_by_name | string | | No |
| description | string | | No |
| id | string | | Yes |
| mode | [AppMode](#appmode) | | Yes |
| name | string | | Yes |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
| updated_at | string | | No |
| workspace_id | string | | No |
| workspace_name | string | | No |
@ -982,12 +990,6 @@ Pagination for GET /account/sessions. Strict (extra='forbid').
| last_used_at | string | | No |
| prefix | string | | Yes |
#### TagItem
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| name | string | | Yes |
#### TaskStopResponse
200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns

View File

@ -0,0 +1,73 @@
from types import SimpleNamespace
from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA
from controllers.openapi.apps import _EMPTY_PARAMETERS, build_app_describe_response
from controllers.service_api.app.error import AppUnavailableError
class _FakeApp(SimpleNamespace):
pass
def _app() -> _FakeApp:
from datetime import datetime
return _FakeApp(
id="11111111-1111-1111-1111-111111111111",
name="Demo",
mode="chat",
description="d",
tags=[],
author_name="me",
updated_at=datetime(2026, 1, 1),
enable_api=True,
)
def test_fields_none_returns_all_blocks(monkeypatch):
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
resp = build_app_describe_response(_app(), None)
assert resp.info is not None
assert resp.info.name == "Demo"
assert resp.parameters == {"k": "v"}
assert resp.input_schema == {"s": 1}
def test_fields_subset_limits_blocks(monkeypatch):
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
resp = build_app_describe_response(_app(), ["info"])
assert resp.info is not None
assert resp.parameters is None
assert resp.input_schema is None
def test_info_omits_author_and_tags(monkeypatch):
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {})
resp = build_app_describe_response(_app(), ["info"])
assert resp.info is not None
# Usage-face describe must not expose creator identity or tags (cross-tenant leak).
assert not hasattr(resp.info, "author")
assert not hasattr(resp.info, "tags")
def test_parameters_fallback_on_app_unavailable(monkeypatch):
def _raise(app):
raise AppUnavailableError()
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", _raise)
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
resp = build_app_describe_response(_app(), ["parameters"])
assert resp.parameters == dict(_EMPTY_PARAMETERS)
def test_input_schema_fallback_on_app_unavailable(monkeypatch):
def _raise(app):
raise AppUnavailableError()
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", _raise)
resp = build_app_describe_response(_app(), ["input_schema"])
assert resp.input_schema == dict(EMPTY_INPUT_SCHEMA)

View File

@ -5,7 +5,7 @@ Runs against the model directly, not the HTTP layer. Pins:
- workspace_id is required.
- numeric bounds enforced (page >= 1, limit in [1, MAX_PAGE_LIMIT]).
- mode validates against the AppMode enum.
- name and tag have length caps.
- name has a length cap.
"""
from __future__ import annotations
@ -24,7 +24,6 @@ def test_defaults():
assert q.limit == 20
assert q.mode is None
assert q.name is None
assert q.tag is None
def test_workspace_id_required():
@ -80,12 +79,6 @@ def test_name_length_capped():
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "name": "x" * 201})
def test_tag_length_capped():
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 100})
with pytest.raises(ValidationError):
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 101})
def test_all_fields_accept_valid_values():
"""Pin the happy-path acceptance for every field in one place."""
q = AppListQuery.model_validate(
@ -95,7 +88,6 @@ def test_all_fields_accept_valid_values():
"limit": 50,
"mode": "workflow",
"name": "search",
"tag": "prod",
}
)
assert q.workspace_id == "00000000-0000-0000-0000-000000000001"
@ -104,4 +96,3 @@ def test_all_fields_accept_valid_values():
assert q.mode is not None
assert q.mode.value == "workflow"
assert q.name == "search"
assert q.tag == "prod"

View File

@ -63,23 +63,19 @@ def test_envelope_uses_pep695_generics():
def test_app_info_response_dump_matches_spec():
from controllers.openapi._models import AppInfoResponse
from controllers.openapi._models import AppInfo
obj = AppInfoResponse(
obj = AppInfo(
id="app1",
name="X",
description="d",
mode="chat",
author="alice",
tags=[{"name": "prod"}],
)
assert obj.model_dump(mode="json") == {
"id": "app1",
"name": "X",
"description": "d",
"mode": "chat",
"author": "alice",
"tags": [{"name": "prod"}],
}
@ -91,8 +87,6 @@ def test_app_describe_response_nests_info_and_parameters():
name="X",
mode="chat",
description=None,
tags=[],
author=None,
updated_at="2026-05-05T00:00:00+00:00",
service_api_enabled=True,
)

View File

@ -137,6 +137,17 @@ export const commandTree: CommandTree = {
const verIdx = out.indexOf('Version')
expect(authIdx).toBeLessThan(verIdx)
})
it('quotes hyphenated keys and leaves plain identifier keys unquoted', () => {
const entries: CommandEntry[] = [
{ tokens: ['export', 'app'], identifier: 'ExportApp', importPath: '@/commands/export/app/index' },
{ tokens: ['export', 'studio-app'], identifier: 'ExportStudioApp', importPath: '@/commands/export/studio-app/index' },
]
const out = formatModule(entries, buildTree(entries))
expect(out).toContain(`'studio-app': { command: ExportStudioApp, subcommands: {} },`)
expect(out).toContain(`app: { command: ExportApp, subcommands: {} },`)
expect(out).not.toContain(`'app':`)
})
})
function makeFixture(): string {

View File

@ -141,13 +141,24 @@ function emitNode(node: TreeNode, indent: string): string {
return parts.join('\n')
}
function needsQuoting(key: string): boolean {
// A bare object key must be a valid JS identifier: the start class excludes digits
// (letter/_/$ only), so a leading digit fails the match and the key gets quoted.
return !/^[A-Z_$][\w$]*$/i.test(key)
}
function emitKey(key: string): string {
return needsQuoting(key) ? `'${key}'` : key
}
function emitEntry(key: string, node: TreeNode, indent: string): string {
const k = emitKey(key)
const isLeaf = node.subcommands.size === 0 && node.command !== undefined
if (isLeaf)
return `${indent}${key}: { command: ${node.command}, subcommands: {} },`
return `${indent}${k}: { command: ${node.command}, subcommands: {} },`
return [
`${indent}${key}: {`,
`${indent}${k}: {`,
emitNode(node, indent),
`${indent}},`,
].join('\n')

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

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
cli/src/api/app-reader.ts Normal file
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)
}

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 () => {

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,
},
})
}

View File

@ -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' } })
})
})

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 },
})
}
}

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,

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],
]

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}`)

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')
})
})

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(

View File

@ -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
`

View File

@ -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
}
}

View File

@ -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)

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(',')
}

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({

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/)
})
})

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]

View File

@ -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
`

View File

@ -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
}
}

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()
})
})

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

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 ?? {}) }
}

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()
})
})

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 {

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: {

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)

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>')

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)

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

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,

View File

@ -519,14 +519,14 @@ async function provisionApps(
async function importAppCli(filePath: string, wsId: string): Promise<string> {
const result = await run(
['import', 'app', '--from-file', filePath, '--workspace', wsId],
['import', 'studio-app', '--from-file', filePath, '--workspace', wsId],
{ configDir, timeout: 60_000 },
)
if (result.exitCode !== 0)
throw new Error(`import app failed (exit ${result.exitCode}): ${result.stderr}`)
throw new Error(`import studio-app failed (exit ${result.exitCode}): ${result.stderr}`)
const match = result.stderr.match(/app ([0-9a-f-]{36})/)
if (!match?.[1])
throw new Error(`import app: could not parse app_id: ${result.stderr}`)
throw new Error(`import studio-app: could not parse app_id: ${result.stderr}`)
return match[1]
}

View File

@ -288,7 +288,7 @@ describe('E2E / agent skill — get app -o json (auth required)', () => {
expect(line.trim()).not.toMatch(/\s/)
})
itWithSso('[P0] [SSO] dfoe_ get app → JSON error envelope (insufficient_scope)', async () => {
itWithSso('[P0] [SSO] dfoe_ get app -o json → permitted-apps list envelope', async () => {
const tc = await withTempConfig()
try {
const { mkdir, writeFile } = await import('node:fs/promises')
@ -296,12 +296,21 @@ describe('E2E / agent skill — get app -o json (auth required)', () => {
await mkdir(tc.configDir, { recursive: true })
await writeFile(
join(tc.configDir, 'hosts.yml'),
`${[`current_host: ${E.host}`, 'token_storage: file', 'tokens:', ` bearer: ${E.ssoToken}`].join('\n')}\n`,
`${[
`current_host: ${E.host}`,
'token_storage: file',
'tokens:',
` bearer: ${E.ssoToken}`,
'external_subject:',
' email: sso@example.com',
' issuer: https://issuer.example.com',
].join('\n')}\n`,
{ mode: 0o600 },
)
const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir })
expect(r.exitCode).not.toBe(0)
assertErrorEnvelope(r)
assertExitCode(r, 0)
const parsed = assertJson<{ data: unknown[] }>(r)
expect(Array.isArray(parsed.data), 'permitted-apps envelope has a data array').toBe(true)
}
finally { await tc.cleanup() }
})

View File

@ -57,6 +57,8 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
})
}
const itWithSso = optionalIt(Boolean(E.ssoToken))
// ── auth whoami — internal user ──────────────────────────────────────────────
it('[P0] internal user auth whoami outputs email', async () => {
@ -123,12 +125,12 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
expect(result.exitCode).not.toBe(0)
})
it('[P0] external user get app returns insufficient_scope error', async () => {
// Spec: external user get app returns insufficient_scope
itWithSso('[P0] external user can list permitted apps via SSO token', async () => {
// External users read apps via the permitted-external surface (no workspace scope).
await withSSOAuth()
const result = await r(['get', 'app'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/insufficient|scope|workspace|SSO/i)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i)
})
it('[P0] external user whoami outputs SSO email', async () => {
@ -138,8 +140,6 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
expect(result.stdout).toContain('sso-user@example.com')
})
const itWithSso = optionalIt(Boolean(E.ssoToken))
itWithSso('[P0] external user can execute run app using SSO token', async () => {
await injectSsoAuth(configDir, {
host: E.host,

View File

@ -67,12 +67,6 @@ describe('E2E / difyctl describe app', () => {
expect(result.stdout).toMatch(/Name:/i)
})
it('[P1] describe output contains Tags field', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/Tags:/i)
})
// ── Input schema ──────────────────────────────────────────────────────────
it('[P0] describe output contains Parameters section', async () => {
@ -172,8 +166,9 @@ describe('E2E / difyctl describe app', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user describe app returns insufficient_scope (3.86)', async () => {
// Spec 3.86: dfoe_ token → insufficient_scope, exit non-0.
itWithSso('[P0] external SSO user can describe a permitted app', async () => {
// A dfoe_ token resolves `describe app` via the permitted-external surface
// (not the account /apps surface), so a permitted app describes successfully.
// Uses DIFY_E2E_SSO_TOKEN; skipped when not configured.
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
@ -191,8 +186,10 @@ describe('E2E / difyctl describe app', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['describe', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user describe app should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/ID:/i)
expect(result.stdout).toContain(E.chatAppId)
expect(result.stdout).toMatch(/Mode:/i)
}
finally {
await ssoTmp.cleanup()
@ -225,16 +222,6 @@ describe('E2E / difyctl describe app', () => {
expect(result.stdout).toContain('e2e-test')
})
it('[P1] describe output contains Author field (3.67)', async () => {
// Spec 3.67: output includes Author field when app has an author.
const result = await withRetry(
() => fx.r(['describe', 'app', E.chatAppId]),
{ attempts: 3, delayMs: 2000 },
)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/Author:/i)
})
it('[P0] Inputs section shows parameter names (3.70)', async () => {
// Spec 3.70: Parameters/Inputs section displays variable names.
// workflow app has x, num, enum_var, paragraph.

View File

@ -61,7 +61,7 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => {
eeIt('[EE][P0] -o wide output contains WORKSPACE column and JSON has workspace_id (3.92)', async () => {
// Spec 3.92: WORKSPACE column (priority:1) appears only in -o wide mode.
// Default table shows priority:0 columns only (NAME/ID/MODE/TAGS/UPDATED).
// Default table shows priority:0 columns only (NAME/ID/MODE/UPDATED).
const wideResult = await withRetry(
() => fx.r(['get', 'app', '-A', '-o', 'wide']),
{ attempts: 3, delayMs: 2000 },
@ -151,15 +151,15 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user get app -A returns insufficient_scope error (3.103)', async () => {
// Spec 3.103: dfoe_ token on -A → insufficient_scope, exit non-0.
// Merged from two duplicate fake-token cases; now uses real DIFY_E2E_SSO_TOKEN.
itWithSso('[P0] external SSO user get app -A is rejected as an invalid flag', async () => {
// --all-workspaces is meaningless for external SSO users (no workspace
// scope), so the CLI rejects it client-side with usage_invalid_flag (exit 2).
// Uses real DIFY_E2E_SSO_TOKEN; skipped when not configured.
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const ssoTmp = await withTempConfig()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
// Use minimal SSO hosts.yml (no workspace) so CLI hits the scope/auth error path.
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
@ -171,8 +171,8 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user -A should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth|missing/i)
assertExitCode(result, 2)
expect(result.stderr).toMatch(/--all-workspaces is not available for external logins/)
}
finally {
await ssoTmp.cleanup()

View File

@ -8,7 +8,6 @@
* DIFY_E2E_WORKFLOW_APP_ID echo-workflow app
*/
import { Buffer } from 'node:buffer'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import {
assertErrorEnvelope,
@ -99,8 +98,8 @@ describe('E2E / difyctl get app (list)', () => {
it('[P1] -o wide outputs extended fields', async () => {
const result = await fx.r(['get', 'app', '-o', 'wide'])
assertExitCode(result, 0)
// wide adds AUTHOR and WORKSPACE columns
expect(result.stdout).toMatch(/AUTHOR|WORKSPACE/i)
// wide adds the WORKSPACE column
expect(result.stdout).toMatch(/WORKSPACE/i)
})
it('[P1] output is pipe-friendly in JSON mode', async () => {
@ -206,17 +205,15 @@ describe('E2E / difyctl get app (list)', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user get app returns insufficient_scope error (3.24 / 3.25)', async () => {
// Spec 3.24: dfoe_ token → insufficient_scope; Spec 3.25: exit code is 1.
itWithSso('[P0] external SSO user can list permitted apps', async () => {
// A dfoe_ token lists apps via the permitted-external surface
// (apps:read:permitted-external scope), with no workspace scoping.
// Uses DIFY_E2E_SSO_TOKEN (itWithSso skips when not configured).
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const ssoTmp = await withTempConfig()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
// SSO (dfoe_) users have apps:run scope only, not apps:list.
// Inject a minimal hosts.yml without workspace so the CLI reaches the
// scope-check path rather than resolving the workspace successfully.
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
@ -228,8 +225,8 @@ describe('E2E / difyctl get app (list)', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app'], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user get app should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i)
}
finally {
await ssoTmp.cleanup()
@ -348,114 +345,4 @@ describe('E2E / difyctl get app (list)', () => {
await networkTmp.cleanup()
}
})
it('[P1] --tag filter returns only apps that carry the specified tag (3.20)', async () => {
// Spec 3.20: --tag performs exact tag-name match.
//
// Before asserting: ensure echo-chat app has the 'e2e-test' tag.
// 1. GET /console/api/tags?type=app&keyword=e2e-test → find or confirm tag exists
// 2. POST /console/api/tags → create tag when absent
// 3. GET /console/api/apps/<id> → check existing bindings
// 4. POST /console/api/tag-bindings → bind when not yet bound
const base = E.host.replace(/\/$/, '')
// ── Console login: obtain cookie + CSRF (console API rejects dfoa_ Bearer) ──
const passwordB64 = Buffer.from(E.password, 'utf8').toString('base64')
const loginRes = await fetch(`${base}/console/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: E.email, password: passwordB64, remember_me: false }),
})
expect(loginRes.ok, `console login failed: ${loginRes.status}`).toBe(true)
// Helper: extract cookie string + csrf from Set-Cookie array
function parseCookies(res: Response): { cookieString: string, csrfToken: string } {
const setCookies = res.headers.getSetCookie?.() ?? []
const cookieString = setCookies.map(kv => kv.split(';')[0]).join('; ')
const csrfPair = setCookies.map(kv => kv.split(';')[0]).filter((p): p is string => typeof p === 'string' && p.includes('csrf_token='))[0]
const csrfToken = csrfPair !== undefined
? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length)
: ''
return { cookieString, csrfToken }
}
let { cookieString, csrfToken } = parseCookies(loginRes)
// ── Switch to the workspace that contains the test fixtures ──────────────
// E.workspaceId is resolved by global-setup; tag-bindings scope to the active workspace.
const switchRes = await fetch(`${base}/console/api/workspaces/switch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ tenant_id: E.workspaceId }),
})
// After workspace switch the server issues fresh cookies; use them for all subsequent calls.
if (switchRes.ok && switchRes.headers.getSetCookie?.().length) {
const switched = parseCookies(switchRes)
cookieString = switched.cookieString
csrfToken = switched.csrfToken
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cookie': cookieString,
'X-CSRF-Token': csrfToken,
}
// ── Step 1: find the 'e2e-test' app tag ──────────────────────────────────
const tagsRes = await fetch(`${base}/console/api/tags?type=app&keyword=e2e-test`, { headers })
expect(tagsRes.ok, `GET /tags failed: ${tagsRes.status}`).toBe(true)
const tagsList = await tagsRes.json() as Array<{ id: string, name: string }>
let tagId = tagsList.find(t => t.name === 'e2e-test')?.id
// ── Step 2: create the tag if it doesn't exist yet ───────────────────────
if (!tagId) {
const createRes = await fetch(`${base}/console/api/tags`, {
method: 'POST',
headers,
body: JSON.stringify({ name: 'e2e-test', type: 'app' }),
})
expect(createRes.ok, `POST /tags failed: ${createRes.status}`).toBe(true)
const created = await createRes.json() as { id: string, name: string }
tagId = created.id
}
expect(tagId, 'tag id must be resolved').toBeTruthy()
// ── Step 3 & 4: bind tag idempotently (tag-bindings is idempotent on duplicates) ──
const bindRes = await fetch(`${base}/console/api/tag-bindings`, {
method: 'POST',
headers,
body: JSON.stringify({
tag_ids: [tagId],
target_id: E.chatAppId,
type: 'app',
}),
})
// Accept 200 (bound) or 409/4xx if already bound — binding is idempotent
expect(
bindRes.ok || bindRes.status === 409,
`POST /tag-bindings failed unexpectedly: ${bindRes.status}`,
).toBe(true)
// ── Assertion: difyctl --tag e2e-test returns echo-chat ──────────────────
const result = await fx.r(['get', 'app', '--tag', 'e2e-test', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: Array<{ id: string, name: string, tags: Array<{ name: string }> }> }>(result)
// echo-chat must appear in the filtered list
const echoChatInResult = parsed.data.find(app => app.id === E.chatAppId)
expect(
echoChatInResult,
`echo-chat (id=${E.chatAppId}) should appear in --tag e2e-test results`,
).toBeDefined()
// Every returned app must carry the e2e-test tag
parsed.data.forEach(app =>
expect(
app.tags.some(t => t.name === 'e2e-test'),
`app "${app.name}" should carry the e2e-test tag`,
).toBe(true),
)
})
})

View File

@ -68,8 +68,9 @@ describe('E2E / difyctl get app <id> (single)', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user get app <id> returns insufficient_scope error (3.55)', async () => {
// Spec 3.55: dfoe_ token on get app <id> → insufficient_scope, exit 1.
itWithSso('[P0] external SSO user can get a permitted app by id', async () => {
// A dfoe_ token resolves get app <id> via the permitted-external describe
// surface (apps:read:permitted-external scope), so a permitted app is returned.
// Uses DIFY_E2E_SSO_TOKEN; skipped when not configured.
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
@ -87,8 +88,8 @@ describe('E2E / difyctl get app <id> (single)', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user get app <id> should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
assertExitCode(result, 0)
expect(result.stdout).toContain(E.chatAppId)
}
finally {
await ssoTmp.cleanup()
@ -153,13 +154,13 @@ describe('E2E / difyctl get app <id> (single)', () => {
})
it('[P1] get app <id> -o wide outputs extended columns (3.48)', async () => {
// Spec 3.48: -o wide → TAGS/UPDATED/AUTHOR columns, exit 0.
// Spec 3.48: -o wide → UPDATED/WORKSPACE columns, exit 0.
const result = await withRetry(
() => fx.r(['get', 'app', E.chatAppId, '-o', 'wide']),
{ attempts: 3, delayMs: 2000 },
)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/AUTHOR|UPDATED|TAGS/i)
expect(result.stdout).toMatch(/UPDATED|WORKSPACE/i)
})
it('[P1] get app <id> -o json is pipe-friendly with no ANSI (3.49)', async () => {

View File

@ -1,5 +1,5 @@
/**
* E2E: difyctl export app DSL export
* E2E: difyctl export studio-app DSL export
*
* Prerequisites (DIFY_E2E_* env vars):
* DIFY_E2E_WORKFLOW_APP_ID echo-workflow app (no model provider dependency)
@ -21,7 +21,7 @@ import { resolveEnv } from '../../setup/env.js'
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
describe('E2E / difyctl export app', () => {
describe('E2E / difyctl export studio-app', () => {
let fx: AuthFixture
beforeEach(async () => {
@ -34,37 +34,37 @@ describe('E2E / difyctl export app', () => {
// ── Basic export ──────────────────────────────────────────────────────────
it('[P0] exported DSL is non-empty YAML printed to stdout', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout.trim().length).toBeGreaterThan(0)
})
it('[P0] exported YAML contains kind: app', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^kind:\s*app/m)
})
it('[P0] exported YAML contains version field', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^version:/m)
})
it('[P0] exported YAML contains app section with mode', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^\s+mode:/m)
})
it('[P1] exported YAML ends with a newline (POSIX pipe convention)', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout.endsWith('\n')).toBe(true)
})
it('[P1] chat app export also succeeds and includes mode', async () => {
const result = await fx.r(['export', 'app', E.chatAppId])
const result = await fx.r(['export', 'studio-app', E.chatAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^kind:\s*app/m)
expect(result.stdout).toMatch(/^\s+mode:/m)
@ -76,7 +76,7 @@ describe('E2E / difyctl export app', () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-'))
const outPath = join(dir, 'exported.yaml')
try {
const result = await fx.r(['export', 'app', E.workflowAppId, '--output', outPath])
const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath])
assertExitCode(result, 0)
const content = await readFile(outPath, 'utf8')
expect(content).toMatch(/^kind:\s*app/m)
@ -92,8 +92,8 @@ describe('E2E / difyctl export app', () => {
const outPath = join(dir, 'exported.yaml')
try {
const [stdoutResult, fileResult] = await Promise.all([
fx.r(['export', 'app', E.workflowAppId]),
fx.r(['export', 'app', E.workflowAppId, '--output', outPath]).then(async (r) => {
fx.r(['export', 'studio-app', E.workflowAppId]),
fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath]).then(async (r) => {
const content = await readFile(outPath, 'utf8')
return { exitCode: r.exitCode, content }
}),
@ -113,12 +113,12 @@ describe('E2E / difyctl export app', () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-roundtrip-'))
const dslPath = join(dir, 'roundtrip.yaml')
try {
const exportResult = await fx.r(['export', 'app', E.workflowAppId, '--output', dslPath])
const exportResult = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', dslPath])
assertExitCode(exportResult, 0)
const importResult = await fx.r([
'import',
'app',
'studio-app',
'--from-file',
dslPath,
'--name',
@ -137,7 +137,7 @@ describe('E2E / difyctl export app', () => {
// ── Error scenarios ───────────────────────────────────────────────────────
it('[P0] non-existent app returns exit code 1 with error in stderr', async () => {
const result = await fx.r(['export', 'app', 'nonexistent-app-id-export-e2e'])
const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-export-e2e'])
expect(result.exitCode).toBe(1)
expect(result.stderr.length).toBeGreaterThan(0)
})
@ -145,7 +145,7 @@ describe('E2E / difyctl export app', () => {
it('[P0] unauthenticated export returns auth error (exit code 4)', async () => {
const unauthTmp = await withTempConfig()
try {
const result = await run(['export', 'app', E.workflowAppId], {
const result = await run(['export', 'studio-app', E.workflowAppId], {
configDir: unauthTmp.configDir,
})
assertExitCode(result, 4)
@ -156,13 +156,13 @@ describe('E2E / difyctl export app', () => {
})
it('[P1] export with missing app id argument exits non-zero', async () => {
const result = await fx.r(['export', 'app'])
const result = await fx.r(['export', 'studio-app'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument|required|app id/i)
})
it('[P1] malformed --workflow-id returns a 4xx, not a 5xx', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId, '--workflow-id', 'not-a-uuid'])
const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--workflow-id', 'not-a-uuid'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/http_status:\s*4\d\d/)
expect(result.stderr).not.toMatch(/http_status:\s*5\d\d/)
@ -171,7 +171,7 @@ describe('E2E / difyctl export app', () => {
it('[P1] non-existent --workflow-id returns 404, not a 5xx', async () => {
const result = await fx.r([
'export',
'app',
'studio-app',
E.workflowAppId,
'--workflow-id',
'00000000-0000-0000-0000-000000000000',
@ -184,7 +184,7 @@ describe('E2E / difyctl export app', () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-nofile-'))
const outPath = join(dir, 'should-not-exist.yaml')
try {
const result = await fx.r(['export', 'app', 'nonexistent-app-id-nofile-e2e', '--output', outPath])
const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-nofile-e2e', '--output', outPath])
expect(result.exitCode).not.toBe(0)
const exists = await readFile(outPath, 'utf8').then(() => true).catch(() => false)
expect(exists, 'output file must not be created on export failure').toBe(false)

View File

@ -82,10 +82,9 @@ describe('E2E / error message standards (spec 5.3)', () => {
// ── 5.63 dfoe_ token insufficient_scope ──────────────────────────────────
itWithSso('[P0] 5.63 dfoe_ SSO token with workspace returns insufficient_scope for management commands', async () => {
// Spec 5.63: an external SSO token (dfoe_) must not be able to access
// internal management APIs; the CLI must return an insufficient_scope
// error with exit 1.
itWithSso('[P0] dfoe_ SSO token is denied account-only management commands', async () => {
// A dfoe_ SSO token is rejected with a non-zero exit when it targets an
// account-only management command (`export studio-app`).
const { mkdir } = await import('node:fs/promises')
const ssoTmp = await withTempConfig()
try {
@ -95,16 +94,13 @@ describe('E2E / error message standards (spec 5.3)', () => {
`token_storage: file`,
`tokens:`,
` bearer: ${E.ssoToken}`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: member`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app'], { configDir: ssoTmp.configDir })
const result = await run(['export', 'studio-app', E.chatAppId], { configDir: ssoTmp.configDir })
assertNonZeroExit(result)
// In this environment ssoToken may be a dfoa_ token; the server returns
// either insufficient_scope or server_5xx — both are non-zero exits.
expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0)
}
finally {

View File

@ -41,7 +41,7 @@ import type { AuthFixture } from '../../helpers/cli.js'
import { afterEach, beforeEach, describe, expect, it, inject } from 'vitest'
import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js'
import { withAuthFixture } from '../../helpers/cli.js'
import { loadE2EEnv, resolveEnv } from '../../setup/env.js'
import { resolveEnv } from '../../setup/env.js'
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
@ -65,19 +65,18 @@ describe('E2E / table output — header and column format (spec 5.15.19)', ()
expect(result.stdout.trim().length).toBeGreaterThan(0)
})
it('[P0] 5.2 header row contains all five expected column names', async () => {
// Spec 5.2: header columns are NAME / ID / MODE / TAGS / UPDATED.
it('[P0] 5.2 header row contains all four expected column names', async () => {
// Spec 5.2: header columns are NAME / ID / MODE / UPDATED.
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
const header = result.stdout.split('\n')[0] ?? ''
expect(header).toMatch(/NAME/i)
expect(header).toMatch(/ID/i)
expect(header).toMatch(/MODE/i)
expect(header).toMatch(/TAGS/i)
expect(header).toMatch(/UPDATED/i)
})
it('[P0] 5.3 column order is NAME → ID → MODE → TAGS → UPDATED', async () => {
it('[P0] 5.3 column order is NAME → ID → MODE → UPDATED', async () => {
// Spec 5.3: columns appear in the defined order (as verified from actual CLI output).
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
@ -85,19 +84,16 @@ describe('E2E / table output — header and column format (spec 5.15.19)', ()
const nameIdx = header.indexOf('NAME')
const idIdx = header.indexOf('ID')
const modeIdx = header.indexOf('MODE')
const tagsIdx = header.indexOf('TAGS')
const updatedIdx = header.indexOf('UPDATED')
// All columns must be present
expect(nameIdx).toBeGreaterThanOrEqual(0)
expect(idIdx).toBeGreaterThanOrEqual(0)
expect(modeIdx).toBeGreaterThanOrEqual(0)
expect(tagsIdx).toBeGreaterThanOrEqual(0)
expect(updatedIdx).toBeGreaterThanOrEqual(0)
// Verify left-to-right order
expect(nameIdx).toBeLessThan(idIdx)
expect(idIdx).toBeLessThan(modeIdx)
expect(modeIdx).toBeLessThan(tagsIdx)
expect(tagsIdx).toBeLessThan(updatedIdx)
expect(modeIdx).toBeLessThan(updatedIdx)
})
it('[P0] 5.5 table displays multiple data rows when more than one app exists', async () => {
@ -153,32 +149,6 @@ describe('E2E / table output — header and column format (spec 5.15.19)', ()
expect(result.stdout).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/)
})
// ── 5.17 — Empty-field rendering ─────────────────────────────────────────
it('[P1] 5.17 empty TAGS field is rendered as blank — not as a dash (-)', async () => {
// Spec 5.17: empty fields show blank, not the `-` placeholder.
// Most apps in the fixture workspace have no tags.
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
const lines = result.stdout.trim().split('\n')
const header = lines[0] ?? ''
const tagsStart = header.indexOf('TAGS')
const updatedStart = header.indexOf('UPDATED')
// Check at least one data row: the TAGS slice should be blank, not '-'
const dataLines = lines.slice(1).filter(l => l.trim())
if (dataLines.length > 0 && tagsStart >= 0 && updatedStart > tagsStart) {
const tagsSlice = (dataLines[0] ?? '').substring(tagsStart, updatedStart).trim()
// If there are no tags, the slice should be empty (not contain a lone '-')
if (tagsSlice === '') {
expect(tagsSlice).toBe('')
}
else {
// Tags are present — just verify it's not the placeholder dash
expect(tagsSlice).not.toBe('-')
}
}
})
// ── 5.25 — Performance ────────────────────────────────────────────────────
it('[P1] 5.25 querying up to 100 apps completes without timeout', async () => {

View File

@ -269,8 +269,34 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
name: app.name,
description: app.description,
mode: app.mode,
author: app.author ?? '',
tags: app.tags,
updated_at: app.updated_at,
service_api_enabled: app.service_api_enabled ?? false,
is_agent: app.is_agent ?? false,
}
: null,
parameters: wantParams ? (app.parameters ?? null) : null,
input_schema: wantInputSchema ? (app.input_schema ?? null) : null,
})
})
app.get('/openapi/v1/permitted-external-apps/:id/describe', (c) => {
const id = c.req.param('id')
const fieldsRaw = c.req.query('fields') ?? ''
const fields = fieldsRaw === '' ? [] : fieldsRaw.split(',').map(s => s.trim()).filter(s => s !== '')
// External subjects have no workspace scope; the app is reachable across workspaces.
const app = APPS.find(a => a.id === id)
if (app === undefined)
return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 })
const wantInfo = fields.length === 0 || fields.includes('info')
const wantParams = fields.length === 0 || fields.includes('parameters')
const wantInputSchema = fields.length === 0 || fields.includes('input_schema')
return c.json({
info: wantInfo
? {
id: app.id,
name: app.name,
description: app.description,
mode: app.mode,
updated_at: app.updated_at,
service_api_enabled: app.service_api_enabled ?? false,
is_agent: app.is_agent ?? false,

View File

@ -30,6 +30,9 @@ import {
zGetHealthResponse,
zGetOauthDeviceLookupQuery,
zGetOauthDeviceLookupResponse,
zGetPermittedExternalAppsByAppIdDescribePath,
zGetPermittedExternalAppsByAppIdDescribeQuery,
zGetPermittedExternalAppsByAppIdDescribeResponse,
zGetPermittedExternalAppsQuery,
zGetPermittedExternalAppsResponse,
zGetVersionResponse,
@ -450,6 +453,30 @@ export const oauth = {
}
export const get12 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getPermittedExternalAppsByAppIdDescribe',
path: '/permitted-external-apps/{app_id}/describe',
tags: ['openapi'],
})
.input(
z.object({
params: zGetPermittedExternalAppsByAppIdDescribePath,
query: zGetPermittedExternalAppsByAppIdDescribeQuery.optional(),
}),
)
.output(zGetPermittedExternalAppsByAppIdDescribeResponse)
export const describe2 = {
get: get12,
}
export const byAppId2 = {
describe: describe2,
}
export const get13 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -461,7 +488,8 @@ export const get12 = oc
.output(zGetPermittedExternalAppsResponse)
export const permittedExternalApps = {
get: get12,
get: get13,
byAppId: byAppId2,
}
export const post9 = oc
@ -544,7 +572,7 @@ export const byMemberId = {
role,
}
export const get13 = oc
export const get14 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -578,7 +606,7 @@ export const post11 = oc
.output(zPostWorkspacesByWorkspaceIdMembersResponse)
export const members = {
get: get13,
get: get14,
post: post11,
byMemberId,
}
@ -598,7 +626,7 @@ export const switch_ = {
post: post12,
}
export const get14 = oc
export const get15 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -610,13 +638,13 @@ export const get14 = oc
.output(zGetWorkspacesByWorkspaceIdResponse)
export const byWorkspaceId = {
get: get14,
get: get15,
apps: apps2,
members,
switch: switch_,
}
export const get15 = oc
export const get16 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -627,7 +655,7 @@ export const get15 = oc
.output(zGetWorkspacesResponse)
export const workspaces = {
get: get15,
get: get16,
byWorkspaceId,
}

View File

@ -20,14 +20,12 @@ export type AccountResponse = {
}
export type AppDescribeInfo = {
author?: string | null
description?: string | null
id: string
is_agent?: boolean
mode: string
name: string
service_api_enabled: boolean
tags?: Array<TagItem>
updated_at?: string | null
}
@ -66,13 +64,11 @@ export type AppDslImportPayload = {
yaml_url?: string | null
}
export type AppInfoResponse = {
author?: string | null
export type AppInfo = {
description?: string | null
id: string
mode: string
name: string
tags?: Array<TagItem>
}
export type AppListQuery = {
@ -80,7 +76,6 @@ export type AppListQuery = {
mode?: AppMode | null
name?: string | null
page?: number
tag?: string | null
workspace_id: string
}
@ -93,12 +88,10 @@ export type AppListResponse = {
}
export type AppListRow = {
created_by_name?: string | null
description?: string | null
id: string
mode: AppMode
name: string
tags?: Array<TagItem>
updated_at?: string | null
workspace_id?: string | null
workspace_name?: string | null
@ -412,10 +405,6 @@ export type SessionRow = {
prefix: string
}
export type TagItem = {
name: string
}
export type TaskStopResponse = {
result: 'success'
}
@ -611,7 +600,6 @@ export type GetAppsData = {
| 'workflow'
name?: string
page?: number
tag?: string
workspace_id: string
}
url: '/apps'
@ -947,6 +935,32 @@ export type GetPermittedExternalAppsResponses = {
export type GetPermittedExternalAppsResponse
= GetPermittedExternalAppsResponses[keyof GetPermittedExternalAppsResponses]
export type GetPermittedExternalAppsByAppIdDescribeData = {
body?: never
path: {
app_id: string
}
query?: {
fields?: string
}
url: '/permitted-external-apps/{app_id}/describe'
}
export type GetPermittedExternalAppsByAppIdDescribeErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetPermittedExternalAppsByAppIdDescribeError
= GetPermittedExternalAppsByAppIdDescribeErrors[keyof GetPermittedExternalAppsByAppIdDescribeErrors]
export type GetPermittedExternalAppsByAppIdDescribeResponses = {
200: AppDescribeResponse
}
export type GetPermittedExternalAppsByAppIdDescribeResponse
= GetPermittedExternalAppsByAppIdDescribeResponses[keyof GetPermittedExternalAppsByAppIdDescribeResponses]
export type GetWorkspacesData = {
body?: never
path?: never

View File

@ -11,6 +11,19 @@ export const zAccountPayload = z.object({
name: z.string(),
})
/**
* AppDescribeInfo
*/
export const zAppDescribeInfo = z.object({
description: z.string().nullish(),
id: z.string(),
is_agent: z.boolean().optional().default(false),
mode: z.string(),
name: z.string(),
service_api_enabled: z.boolean(),
updated_at: z.string().nullish(),
})
/**
* AppDescribeQuery
*
@ -22,6 +35,15 @@ export const zAppDescribeQuery = z.object({
fields: z.string().optional(),
})
/**
* AppDescribeResponse
*/
export const zAppDescribeResponse = z.object({
info: zAppDescribeInfo.nullish(),
input_schema: z.record(z.string(), z.unknown()).nullish(),
parameters: z.record(z.string(), z.unknown()).nullish(),
})
/**
* AppDslExportQuery
*
@ -58,6 +80,16 @@ export const zAppDslImportPayload = z.object({
yaml_url: z.string().nullish(),
})
/**
* AppInfo
*/
export const zAppInfo = z.object({
description: z.string().nullish(),
id: z.string(),
mode: z.string(),
name: z.string(),
})
/**
* AppMode
*/
@ -82,10 +114,33 @@ export const zAppListQuery = z.object({
mode: zAppMode.nullish(),
name: z.string().max(200).nullish(),
page: z.int().gte(1).optional().default(1),
tag: z.string().max(100).nullish(),
workspace_id: z.string(),
})
/**
* AppListRow
*/
export const zAppListRow = z.object({
description: z.string().nullish(),
id: z.string(),
mode: zAppMode,
name: z.string(),
updated_at: z.string().nullish(),
workspace_id: z.string().nullish(),
workspace_name: z.string().nullish(),
})
/**
* AppListResponse
*/
export const zAppListResponse = z.object({
data: z.array(zAppListRow),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* AppRunRequest
*/
@ -409,6 +464,17 @@ export const zPermittedExternalAppsListQuery = z.object({
page: z.int().gte(1).optional().default(1),
})
/**
* PermittedExternalAppsListResponse
*/
export const zPermittedExternalAppsListResponse = z.object({
data: z.array(zAppListRow),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* RevokeResponse
*/
@ -460,86 +526,6 @@ export const zSessionListResponse = z.object({
total: z.int(),
})
/**
* TagItem
*/
export const zTagItem = z.object({
name: z.string(),
})
/**
* AppDescribeInfo
*/
export const zAppDescribeInfo = z.object({
author: z.string().nullish(),
description: z.string().nullish(),
id: z.string(),
is_agent: z.boolean().optional().default(false),
mode: z.string(),
name: z.string(),
service_api_enabled: z.boolean(),
tags: z.array(zTagItem).optional().default([]),
updated_at: z.string().nullish(),
})
/**
* AppDescribeResponse
*/
export const zAppDescribeResponse = z.object({
info: zAppDescribeInfo.nullish(),
input_schema: z.record(z.string(), z.unknown()).nullish(),
parameters: z.record(z.string(), z.unknown()).nullish(),
})
/**
* AppInfoResponse
*/
export const zAppInfoResponse = z.object({
author: z.string().nullish(),
description: z.string().nullish(),
id: z.string(),
mode: z.string(),
name: z.string(),
tags: z.array(zTagItem).optional().default([]),
})
/**
* AppListRow
*/
export const zAppListRow = z.object({
created_by_name: z.string().nullish(),
description: z.string().nullish(),
id: z.string(),
mode: zAppMode,
name: z.string(),
tags: z.array(zTagItem).optional().default([]),
updated_at: z.string().nullish(),
workspace_id: z.string().nullish(),
workspace_name: z.string().nullish(),
})
/**
* AppListResponse
*/
export const zAppListResponse = z.object({
data: z.array(zAppListRow),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* PermittedExternalAppsListResponse
*/
export const zPermittedExternalAppsListResponse = z.object({
data: z.array(zAppListRow),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* TaskStopResponse
*
@ -726,7 +712,6 @@ export const zGetAppsQuery = z.object({
.optional(),
name: z.string().max(200).optional(),
page: z.int().gte(1).optional().default(1),
tag: z.string().max(100).optional(),
workspace_id: z.string(),
})
@ -898,6 +883,19 @@ export const zGetPermittedExternalAppsQuery = z.object({
*/
export const zGetPermittedExternalAppsResponse = zPermittedExternalAppsListResponse
export const zGetPermittedExternalAppsByAppIdDescribePath = z.object({
app_id: z.string(),
})
export const zGetPermittedExternalAppsByAppIdDescribeQuery = z.object({
fields: z.string().optional(),
})
/**
* Permitted external app description
*/
export const zGetPermittedExternalAppsByAppIdDescribeResponse = zAppDescribeResponse
/**
* Workspace list
*/