mirror of
https://github.com/langgenius/dify.git
synced 2026-06-23 04:11:09 +08:00
refactor(openapi/cli): split app usage-face from studio-app build-face
Squash of PR #37641 (worktree-fix+app-abstraction-noun). Introduces two app nouns: - app: usage face (run/get/describe/resume), dual-subject account + external-SSO - studio-app: build face (export/import), account-only Backend: split read routes by subject with token-type-restricted guards; shared public projection builder (build_app_describe_response); drop author/tags from describe to prevent cross-tenant identity leak. CLI: selectAppReader subject dispatch (account vs permitted-external), AppReader strategy, studio-app export/import, refreshed help/guides.
This commit is contained in:
parent
c62276d7de
commit
4111751bdf
@ -31,7 +31,7 @@ from controllers.openapi._models import (
|
||||
AppDslExportQuery,
|
||||
AppDslExportResponse,
|
||||
AppDslImportPayload,
|
||||
AppInfoResponse,
|
||||
AppInfo,
|
||||
AppListQuery,
|
||||
AppListResponse,
|
||||
AppListRow,
|
||||
@ -101,7 +101,7 @@ register_response_schema_models(
|
||||
MessageMetadata,
|
||||
AppListRow,
|
||||
AppListResponse,
|
||||
AppInfoResponse,
|
||||
AppInfo,
|
||||
AppDescribeInfo,
|
||||
AppDescribeResponse,
|
||||
AppDslExportResponse,
|
||||
|
||||
@ -70,16 +70,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
|
||||
|
||||
@ -28,6 +28,7 @@ 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
|
||||
@ -84,6 +85,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 +129,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")
|
||||
|
||||
@ -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
|
||||
@ -82,3 +86,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)
|
||||
|
||||
@ -331,6 +331,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 +523,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 +582,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
|
||||
|
||||
|
||||
@ -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)
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import type { AppsClient } from './apps'
|
||||
import type { AppReader } from './app-reader'
|
||||
import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { AppMeta, AppMetaFieldKey } from '@/types/app-meta'
|
||||
import { covers, fromDescribe, mergeMeta } from '@/types/app-meta'
|
||||
|
||||
export type AppMetaClientOptions = {
|
||||
readonly apps: AppsClient
|
||||
readonly apps: AppReader
|
||||
readonly host: string
|
||||
readonly cache?: AppInfoCache
|
||||
readonly now?: () => Date
|
||||
}
|
||||
|
||||
export class AppMetaClient {
|
||||
private readonly apps: AppsClient
|
||||
private readonly apps: AppReader
|
||||
private readonly host: string
|
||||
private readonly cache: AppInfoCache | undefined
|
||||
private readonly now: () => Date
|
||||
|
||||
30
cli/src/api/app-reader.test.ts
Normal file
30
cli/src/api/app-reader.test.ts
Normal 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
35
cli/src/api/app-reader.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ListQuery } from './apps'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { AppsClient } from './apps'
|
||||
import { PermittedExternalAppsClient } from './permitted-external-apps'
|
||||
|
||||
export type AppReader = {
|
||||
list: (q: ListQuery) => Promise<AppListResponse>
|
||||
describe: (appId: string, fields?: readonly string[]) => Promise<AppDescribeResponse>
|
||||
}
|
||||
|
||||
// The auth subject behind an openapi bearer token. Each kind reads apps from its own surface.
|
||||
export const SubjectKind = {
|
||||
Account: 'account',
|
||||
External: 'external',
|
||||
} as const
|
||||
|
||||
export type SubjectKindValue = (typeof SubjectKind)[keyof typeof SubjectKind]
|
||||
|
||||
export function subjectOf(active: ActiveContext): SubjectKindValue {
|
||||
return active.ctx.external_subject !== undefined ? SubjectKind.External : SubjectKind.Account
|
||||
}
|
||||
|
||||
type AppReaderFactory = (http: HttpClient) => AppReader
|
||||
|
||||
// Maps each auth subject to the app reader for its surface.
|
||||
const APP_READER_BY_SUBJECT: Readonly<Record<SubjectKindValue, AppReaderFactory>> = {
|
||||
[SubjectKind.Account]: http => new AppsClient(http),
|
||||
[SubjectKind.External]: http => new PermittedExternalAppsClient(http),
|
||||
}
|
||||
|
||||
export function selectAppReader(active: ActiveContext, http: HttpClient): AppReader {
|
||||
return APP_READER_BY_SUBJECT[subjectOf(active)](http)
|
||||
}
|
||||
@ -1,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'
|
||||
@ -12,7 +13,12 @@ export type ListQuery = {
|
||||
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,7 +31,7 @@ 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,
|
||||
},
|
||||
|
||||
27
cli/src/api/permitted-external-apps.test.ts
Normal file
27
cli/src/api/permitted-external-apps.test.ts
Normal 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' } })
|
||||
})
|
||||
})
|
||||
34
cli/src/api/permitted-external-apps.ts
Normal file
34
cli/src/api/permitted-external-apps.ts
Normal 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/tag are ignored: the external grant is not workspace-scoped.
|
||||
async list(q: ListQuery): Promise<AppListResponse> {
|
||||
return this.orpc.permittedExternalApps.get({
|
||||
query: {
|
||||
page: q.page ?? 1,
|
||||
limit: q.limit ?? 20,
|
||||
mode: normalizeMode(q.mode),
|
||||
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
|
||||
return this.orpc.permittedExternalApps.byAppId.describe.get({
|
||||
params: { app_id: appId },
|
||||
query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined },
|
||||
})
|
||||
}
|
||||
}
|
||||
2
cli/src/cache/app-info.test.ts
vendored
2
cli/src/cache/app-info.test.ts
vendored
@ -21,8 +21,6 @@ function metaInfoOnly(): AppMeta {
|
||||
name: 'Greeter',
|
||||
description: '',
|
||||
mode: 'chat',
|
||||
author: 'tester',
|
||||
tags: [],
|
||||
updated_at: undefined,
|
||||
service_api_enabled: false,
|
||||
is_agent: false,
|
||||
|
||||
@ -2,7 +2,9 @@ import type { CommandConstructor } from '@/framework/command'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Login from '@/commands/auth/login/index'
|
||||
import DescribeApp from '@/commands/describe/app/index'
|
||||
import ExportStudioApp from '@/commands/export/studio-app/index'
|
||||
import GetApp from '@/commands/get/app/index'
|
||||
import ImportStudioApp from '@/commands/import/studio-app/index'
|
||||
import ResumeApp from '@/commands/resume/app/index'
|
||||
import RunApp from '@/commands/run/app/index'
|
||||
|
||||
@ -13,6 +15,8 @@ const GUIDED_COMMANDS: ReadonlyArray<readonly [string, CommandConstructor]> = [
|
||||
['resume app', ResumeApp],
|
||||
['describe app', DescribeApp],
|
||||
['get app', GetApp],
|
||||
['export studio-app', ExportStudioApp],
|
||||
['import studio-app', ImportStudioApp],
|
||||
['auth login', Login],
|
||||
]
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { AppDescribeInfo, TagItem } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppDescribeInfo } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppMeta } from '@/types/app-meta'
|
||||
|
||||
export const APP_DESCRIBE_MODE_KEY = 'app-describe'
|
||||
@ -28,10 +28,8 @@ export class AppDescribeOutput {
|
||||
['Name', info.name],
|
||||
['ID', info.id],
|
||||
['Mode', info.mode],
|
||||
['Author', info.author ?? ''],
|
||||
['Updated', info.updated_at ?? ''],
|
||||
['Service API', info.service_api_enabled ? 'true' : 'false'],
|
||||
['Tags', joinTags(info.tags ?? [])],
|
||||
]
|
||||
if (info.description !== '' && info.description !== undefined)
|
||||
rows.push(['Description', info.description ?? ''])
|
||||
@ -55,12 +53,6 @@ export class AppDescribeOutput {
|
||||
}
|
||||
}
|
||||
|
||||
function joinTags(tags: readonly TagItem[]): string {
|
||||
if (tags.length === 0)
|
||||
return '<none>'
|
||||
return tags.map(t => t.name).join(',')
|
||||
}
|
||||
|
||||
function alignedRows(rows: readonly [string, string][]): string[] {
|
||||
const widest = rows.reduce((m, [k]) => Math.max(m, k.length), 0)
|
||||
return rows.map(([k, v]) => `${`${k}:`.padEnd(widest + 2)}${v}`)
|
||||
|
||||
@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadAppInfoCache } from '@/cache/app-info'
|
||||
import { formatted, stringifyOutput } from '@/framework/output'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
@ -34,6 +34,7 @@ describe('runDescribeApp', () => {
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
@ -60,8 +61,6 @@ describe('runDescribeApp', () => {
|
||||
expect(out).toContain('Mode:')
|
||||
expect(out).toContain('chat')
|
||||
expect(out).toContain('Service API:')
|
||||
expect(out).toContain('Tags:')
|
||||
expect(out).toContain('demo')
|
||||
expect(out).toContain('Description:')
|
||||
expect(out).toContain('Parameters:')
|
||||
})
|
||||
@ -115,4 +114,13 @@ describe('runDescribeApp', () => {
|
||||
},
|
||||
)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('external login resolves describe via the permitted-external route', async () => {
|
||||
const activeExt: ActiveContext = { host: mock.url, email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
|
||||
const out = await runDescribeApp(
|
||||
{ appId: 'app-1' },
|
||||
{ active: activeExt, http: testHttpClient(mock.url, 'dfoe_test'), host: mock.url },
|
||||
)
|
||||
expect(out.payload.info?.id).toBe('app-1')
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,7 +3,7 @@ import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AppMetaClient } from '@/api/app-meta'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { selectAppReader } from '@/api/app-reader'
|
||||
import { runWithSpinner } from '@/sys/io/spinner'
|
||||
import { nullStreams } from '@/sys/io/streams'
|
||||
import { FieldInfo, FieldInputSchema, FieldParameters } from '@/types/app-meta'
|
||||
@ -26,7 +26,7 @@ export type DescribeAppDeps = {
|
||||
}
|
||||
|
||||
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
|
||||
const apps = new AppsClient(deps.http)
|
||||
const apps = selectAppReader(deps.active, deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const io = deps.io ?? nullStreams()
|
||||
const result = await runWithSpinner(
|
||||
|
||||
12
cli/src/commands/export/studio-app/guide.ts
Normal file
12
cli/src/commands/export/studio-app/guide.ts
Normal 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
|
||||
`
|
||||
@ -1,16 +1,17 @@
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { httpRetryFlag } from '@/commands/_shared/global-flags'
|
||||
import { Args, Flags } from '@/framework/flags'
|
||||
import { agentGuide } from './guide'
|
||||
import { runExportApp } from './run'
|
||||
|
||||
export default class ExportApp extends DifyCommand {
|
||||
static override description = 'Export an app\'s DSL configuration as YAML'
|
||||
export default class ExportStudioApp extends DifyCommand {
|
||||
static override description = 'Export a studio app\'s DSL configuration as YAML'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> export app <app-id>',
|
||||
'<%= config.bin %> export app <app-id> --output ./my-app.yaml',
|
||||
'<%= config.bin %> export app <app-id> --include-secret',
|
||||
'<%= config.bin %> export app <app-id> --workflow-id <workflow-id>',
|
||||
'<%= config.bin %> export studio-app <app-id>',
|
||||
'<%= config.bin %> export studio-app <app-id> --output ./my-app.yaml',
|
||||
'<%= config.bin %> export studio-app <app-id> --include-secret',
|
||||
'<%= config.bin %> export studio-app <app-id> --workflow-id <workflow-id>',
|
||||
]
|
||||
|
||||
static override args = {
|
||||
@ -26,7 +27,7 @@ export default class ExportApp extends DifyCommand {
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
const { args, flags } = this.parse(ExportApp, argv)
|
||||
const { args, flags } = this.parse(ExportStudioApp, argv)
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
const result = await runExportApp({
|
||||
appId: args.id,
|
||||
@ -42,4 +43,8 @@ export default class ExportApp extends DifyCommand {
|
||||
ctx.io.out.write('\n')
|
||||
}
|
||||
}
|
||||
|
||||
override agentGuide(): string {
|
||||
return agentGuide
|
||||
}
|
||||
}
|
||||
@ -35,9 +35,8 @@ export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps):
|
||||
const io = deps.io ?? nullStreams()
|
||||
const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h))
|
||||
|
||||
// workspace is needed to satisfy the auth pipeline; resolving it here
|
||||
// mirrors what other commands do even though the export endpoint does not
|
||||
// take workspace_id as a query parameter (it loads tenant from app).
|
||||
// workspace is resolved to satisfy the auth pipeline; the export endpoint itself
|
||||
// takes no workspace_id query parameter (it loads tenant from the app).
|
||||
resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
|
||||
const client = dslFactory(deps.http)
|
||||
@ -1,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()
|
||||
})
|
||||
|
||||
@ -138,4 +140,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', tags: [], updated_at: null, created_by_name: null, workspace_id: 'w', workspace_name: 'W' }] })
|
||||
const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps')
|
||||
vi.spyOn(PermittedExternalAppsClient.prototype, 'list').mockImplementation(list)
|
||||
const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
|
||||
const http = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
|
||||
const res = await runGetApp({}, { active, http })
|
||||
expect(list).toHaveBeenCalled()
|
||||
const firstCallArg = list.mock.calls[0]![0] as { workspaceId: string }
|
||||
expect(firstCallArg.workspaceId).toBe('')
|
||||
expect(res.data).toBeDefined()
|
||||
})
|
||||
|
||||
it('--all-workspaces throws UsageInvalidFlag for external logins', async () => {
|
||||
const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
|
||||
const httpClient = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
|
||||
await expect(runGetApp({ allWorkspaces: true }, { active, http: httpClient }))
|
||||
.rejects
|
||||
.toThrow(/--all-workspaces is not available for external logins/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppReader } from '@/api/app-reader'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { selectAppReader, SubjectKind, subjectOf } from '@/api/app-reader'
|
||||
import { WorkspacesClient } from '@/api/workspaces'
|
||||
import { newError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { LIMIT_DEFAULT, parseLimit } from '@/limit/limit'
|
||||
import { getEnv } from '@/sys/index'
|
||||
import { runWithSpinner } from '@/sys/io/spinner'
|
||||
@ -28,7 +31,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 +42,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 +55,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,
|
||||
@ -102,9 +109,9 @@ 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,
|
||||
tags: [],
|
||||
updated_at: desc.info.updated_at,
|
||||
created_by_name: desc.info.author === '' ? undefined : desc.info.author,
|
||||
created_by_name: undefined,
|
||||
workspace_id: wsId,
|
||||
workspace_name: wsName === '' ? undefined : wsName,
|
||||
}],
|
||||
@ -118,7 +125,7 @@ function workspaceNameForId(active: ActiveContext, id: string): string {
|
||||
}
|
||||
|
||||
async function runAllWorkspaces(
|
||||
apps: AppsClient,
|
||||
apps: AppReader,
|
||||
ws: WorkspacesClient,
|
||||
opts: GetAppOptions,
|
||||
page: number,
|
||||
|
||||
17
cli/src/commands/import/studio-app/guide.ts
Normal file
17
cli/src/commands/import/studio-app/guide.ts
Normal 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
|
||||
`
|
||||
@ -1,16 +1,17 @@
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { httpRetryFlag } from '@/commands/_shared/global-flags'
|
||||
import { Flags } from '@/framework/flags'
|
||||
import { agentGuide } from './guide'
|
||||
import { pluginDependencyLabel, runImportApp } from './run'
|
||||
|
||||
export default class ImportApp extends DifyCommand {
|
||||
static override description = 'Import an app from a DSL YAML file or URL'
|
||||
export default class ImportStudioApp extends DifyCommand {
|
||||
static override description = 'Import a studio app from a DSL YAML file or URL'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> import app --from-file ./app.yaml',
|
||||
'<%= config.bin %> import app --from-file /path/to/app.yaml --name "My App"',
|
||||
'<%= config.bin %> import app --from-url https://example.com/my-app.yaml',
|
||||
'<%= config.bin %> import app --from-file ./app.yaml --app-id <existing-app-id>',
|
||||
'<%= config.bin %> import studio-app --from-file ./app.yaml',
|
||||
'<%= config.bin %> import studio-app --from-file /path/to/app.yaml --name "My App"',
|
||||
'<%= config.bin %> import studio-app --from-url https://example.com/my-app.yaml',
|
||||
'<%= config.bin %> import studio-app --from-file ./app.yaml --app-id <existing-app-id>',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
@ -27,7 +28,7 @@ export default class ImportApp extends DifyCommand {
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
const { flags } = this.parse(ImportApp, argv)
|
||||
const { flags } = this.parse(ImportStudioApp, argv)
|
||||
if (flags['from-file'] === undefined && flags['from-url'] === undefined)
|
||||
this.error('one of --from-file or --from-url is required', { exit: 1 })
|
||||
if (flags['from-file'] !== undefined && flags['from-url'] !== undefined)
|
||||
@ -57,4 +58,8 @@ export default class ImportApp extends DifyCommand {
|
||||
ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
override agentGuide(): string {
|
||||
return agentGuide
|
||||
}
|
||||
}
|
||||
66
cli/src/commands/resume/app/run.test.ts
Normal file
66
cli/src/commands/resume/app/run.test.ts
Normal 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: '', tags: [], author: '', updated_at: null, service_api_enabled: true, is_agent: false },
|
||||
parameters: null,
|
||||
input_schema: null,
|
||||
}
|
||||
|
||||
const FORM_RESP = { user_actions: [{ id: 'submit' }] }
|
||||
|
||||
function makeExternalActive(): ActiveContext {
|
||||
return {
|
||||
host: 'http://localhost',
|
||||
email: 'sso@x.io',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' },
|
||||
external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' },
|
||||
},
|
||||
} as unknown as ActiveContext
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('resumeApp pre-flight subject strategy', () => {
|
||||
it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => {
|
||||
const externalDescribe = vi.fn().mockResolvedValue(DESCRIBE_RESULT)
|
||||
const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe)
|
||||
const accountSpy = vi.spyOn(AppsClient.prototype, 'describe')
|
||||
|
||||
vi.spyOn(AppRunClient.prototype, 'submitHumanInput').mockResolvedValue(undefined as never)
|
||||
|
||||
const io = bufferStreams()
|
||||
const http = {
|
||||
baseURL: 'http://localhost',
|
||||
request: vi.fn().mockImplementation((opts: { path: string }) => {
|
||||
if (typeof opts.path === 'string' && opts.path.includes('form/human_input')) {
|
||||
return Promise.resolve(FORM_RESP)
|
||||
}
|
||||
// reconnect stream — return an async iterable that ends immediately
|
||||
const iter: AsyncIterable<never> = { [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ done: true, value: undefined as never }) }) }
|
||||
return Promise.resolve(iter)
|
||||
}),
|
||||
} as unknown as import('@/http/types').HttpClient
|
||||
|
||||
try {
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-1', workflowRunId: 'wf-run-1', action: 'submit', inputs: {} },
|
||||
{ active: makeExternalActive(), http, host: 'http://localhost', io },
|
||||
)
|
||||
}
|
||||
catch {
|
||||
// run may fail after pre-flight due to stream mock; we only check which describe was called
|
||||
}
|
||||
|
||||
expect(externalSpy).toHaveBeenCalled()
|
||||
expect(accountSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -4,10 +4,11 @@ import type { RunContext } from '@/commands/run/app/_strategies/index'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AppMetaClient } from '@/api/app-meta'
|
||||
import { selectAppReader } from '@/api/app-reader'
|
||||
import { AppRunClient } from '@/api/app-run'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { pickStrategy } from '@/commands/run/app/_strategies/index'
|
||||
import { RUN_MODES } from '@/commands/run/app/handlers'
|
||||
import { resolveInputs, TEXT_FORMATS } from '@/commands/run/app/input-flags'
|
||||
import { processExit } from '@/sys/index'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { FieldInfo } from '@/types/app-meta'
|
||||
@ -37,45 +38,8 @@ export type ResumeAppDeps = {
|
||||
readonly exit?: (code: number) => never
|
||||
}
|
||||
|
||||
const TEXT_FORMATS = new Set(['', 'text'])
|
||||
|
||||
async function resolveInputs(
|
||||
inputsJson: string | undefined,
|
||||
inputsFile: string | undefined,
|
||||
directInputs: Readonly<Record<string, unknown>> | undefined,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (inputsJson !== undefined && inputsFile !== undefined)
|
||||
throw new Error('--inputs and --inputs-file are mutually exclusive')
|
||||
if (inputsJson !== undefined) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(inputsJson)
|
||||
}
|
||||
catch {
|
||||
throw new Error('--inputs must be valid JSON')
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new Error('--inputs must be a JSON object')
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
if (inputsFile !== undefined) {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(inputsFile, 'utf8'))
|
||||
}
|
||||
catch {
|
||||
throw new Error('--inputs-file must contain valid JSON')
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new Error('--inputs-file must be a JSON object')
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
return { ...(directInputs ?? {}) }
|
||||
}
|
||||
|
||||
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
|
||||
const apps = new AppsClient(deps.http)
|
||||
const apps = selectAppReader(deps.active, deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const m = await meta.get(opts.appId, [FieldInfo])
|
||||
const mode = m.info?.mode ?? RUN_MODES.Workflow
|
||||
|
||||
42
cli/src/commands/run/app/input-flags.ts
Normal file
42
cli/src/commands/run/app/input-flags.ts
Normal 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 ?? {}) }
|
||||
}
|
||||
@ -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'
|
||||
@ -381,4 +382,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: '', tags: [], author: '', updated_at: null, service_api_enabled: true, is_agent: false }, parameters: null, input_schema: null }
|
||||
const externalDescribe = vi.fn().mockResolvedValue(describeResult)
|
||||
const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps')
|
||||
const { AppsClient } = await import('@/api/apps')
|
||||
const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe)
|
||||
const accountSpy = vi.spyOn(AppsClient.prototype, 'describe')
|
||||
const io = bufferStreams()
|
||||
const http = { baseURL: mock.url, request: vi.fn().mockResolvedValue({ answer: 'echo: hi', conversation_id: 'conv-1', message_id: 'msg-1', mode: 'chat', metadata: {} }) } as unknown as HttpClient
|
||||
const activeExt: ActiveContext = {
|
||||
host: mock.url,
|
||||
email: 'sso@x.io',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' },
|
||||
external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' },
|
||||
},
|
||||
}
|
||||
try {
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi' },
|
||||
{ active: activeExt, http, host: mock.url, io },
|
||||
)
|
||||
}
|
||||
catch {
|
||||
// run may fail due to mocked http; we only care about which describe was called
|
||||
}
|
||||
expect(externalSpy).toHaveBeenCalled()
|
||||
expect(accountSpy).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,8 +3,8 @@ import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AppMetaClient } from '@/api/app-meta'
|
||||
import { selectAppReader } from '@/api/app-reader'
|
||||
import { AppRunClient } from '@/api/app-run'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { FileUploadClient } from '@/api/file-upload'
|
||||
import { pickStrategy } from '@/commands/run/app/_strategies/index'
|
||||
import { BaseError, HttpClientError } from '@/errors/base'
|
||||
@ -13,6 +13,7 @@ import { processExit } from '@/sys/index'
|
||||
import { FieldInfo } from '@/types/app-meta'
|
||||
import { resolveFileInputs } from './file-flags.js'
|
||||
import { RUN_MODES } from './handlers.js'
|
||||
import { resolveInputs, TEXT_FORMATS } from './input-flags.js'
|
||||
|
||||
export type RunAppOptions = {
|
||||
readonly appId: string
|
||||
@ -40,45 +41,8 @@ export type RunAppDeps = {
|
||||
readonly exit?: (code: number) => never
|
||||
}
|
||||
|
||||
const TEXT_FORMATS = new Set(['', 'text'])
|
||||
|
||||
async function resolveInputs(
|
||||
inputsJson: string | undefined,
|
||||
inputsFile: string | undefined,
|
||||
directInputs: Readonly<Record<string, unknown>> | undefined,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (inputsJson !== undefined && inputsFile !== undefined)
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' })
|
||||
if (inputsJson !== undefined) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(inputsJson)
|
||||
}
|
||||
catch {
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' })
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' })
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
if (inputsFile !== undefined) {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(inputsFile, 'utf8'))
|
||||
}
|
||||
catch {
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' })
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' })
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
return { ...(directInputs ?? {}) }
|
||||
}
|
||||
|
||||
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
|
||||
const apps = new AppsClient(deps.http)
|
||||
const apps = selectAppReader(deps.active, deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
|
||||
try {
|
||||
|
||||
@ -17,11 +17,11 @@ import CreateMember from '@/commands/create/member/index'
|
||||
import DeleteMember from '@/commands/delete/member/index'
|
||||
import DescribeApp from '@/commands/describe/app/index'
|
||||
import EnvList from '@/commands/env/list/index'
|
||||
import ExportApp from '@/commands/export/app/index'
|
||||
import ExportStudioApp from '@/commands/export/studio-app/index'
|
||||
import GetApp from '@/commands/get/app/index'
|
||||
import GetMember from '@/commands/get/member/index'
|
||||
import GetWorkspace from '@/commands/get/workspace/index'
|
||||
import ImportApp from '@/commands/import/app/index'
|
||||
import ImportStudioApp from '@/commands/import/studio-app/index'
|
||||
import ResumeApp from '@/commands/resume/app/index'
|
||||
import RunApp from '@/commands/run/app/index'
|
||||
import SetMember from '@/commands/set/member/index'
|
||||
@ -77,7 +77,7 @@ export const commandTree: CommandTree = {
|
||||
},
|
||||
export: {
|
||||
subcommands: {
|
||||
app: { command: ExportApp, subcommands: {} },
|
||||
'studio-app': { command: ExportStudioApp, subcommands: {} },
|
||||
},
|
||||
},
|
||||
get: {
|
||||
@ -89,7 +89,7 @@ export const commandTree: CommandTree = {
|
||||
},
|
||||
import: {
|
||||
subcommands: {
|
||||
app: { command: ImportApp, subcommands: {} },
|
||||
'studio-app': { command: ImportStudioApp, subcommands: {} },
|
||||
},
|
||||
},
|
||||
resume: {
|
||||
|
||||
@ -22,6 +22,9 @@ const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding
|
||||
difyctl run app <id> "hello" -o json
|
||||
|
||||
Tips:
|
||||
* Two app nouns: 'studio-app' is what you build and edit in Studio on the
|
||||
web console inside a workspace (its source definition — export or move it);
|
||||
'app' is a published app you run and inspect.
|
||||
* 'difyctl auth list' shows your authenticated contexts; 'difyctl use host'
|
||||
and 'difyctl use account' switch between them.
|
||||
* Pass --workspace <id> to target a non-default workspace.
|
||||
@ -74,6 +77,16 @@ OUTPUT
|
||||
Pass -o json (or -o yaml) on every command — the JSON shape is stable and
|
||||
documented. Without it you get human tables meant for a terminal.
|
||||
|
||||
APP vs STUDIO-APP
|
||||
Two nouns, two faces of the same app:
|
||||
studio-app what you build and edit in Studio on the web console,
|
||||
inside a workspace — the app's source definition.
|
||||
app a published app, live and runnable.
|
||||
Use 'studio-app' to work with the definition you manage on the website
|
||||
(export it, move it between workspaces or instances); use 'app' to run
|
||||
and inspect a published one. The COMMANDS list shows the verbs each
|
||||
noun supports.
|
||||
|
||||
DISCOVERY
|
||||
difyctl help -o json full command tree + this contract, machine-readable
|
||||
difyctl get app -o json list apps (ids + modes)
|
||||
|
||||
@ -72,6 +72,25 @@ describe('classifyResponse — canonical ErrorBody', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyResponse 403', () => {
|
||||
it('maps 403 to AccessDenied (exit 4 bucket)', async () => {
|
||||
const req403 = new Request('https://x/openapi/v1/apps/abc/export')
|
||||
const res403 = new Response(
|
||||
JSON.stringify({ code: 'unsupported_token_type', message: 'unsupported_token_type', status: 403 }),
|
||||
{ status: 403, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
const err = await classifyResponse(req403, res403)
|
||||
expect(err.code).toBe(ErrorCode.AccessDenied)
|
||||
expect(err.message).toBe('unsupported_token_type')
|
||||
})
|
||||
|
||||
it('403 with no parseable ErrorBody falls back to generic denied message', async () => {
|
||||
const err = await classified(403, 'not json')
|
||||
expect(err.code).toBe(ErrorCode.AccessDenied)
|
||||
expect(err.message).toBe('not permitted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyResponse — non-conforming bodies (no fallback by design)', () => {
|
||||
it('non-JSON body yields no serverError, classification by status', async () => {
|
||||
const err = await classified(502, '<html>bad gateway</html>')
|
||||
|
||||
@ -44,9 +44,17 @@ const RATE_LIMITED_CLASS: StatusClass = {
|
||||
includeRaw: false,
|
||||
}
|
||||
|
||||
const ACCESS_DENIED_CLASS: StatusClass = {
|
||||
code: ErrorCode.AccessDenied,
|
||||
fallbackMessage: () => 'not permitted',
|
||||
includeRaw: false,
|
||||
}
|
||||
|
||||
function statusClass(status: number): StatusClass {
|
||||
if (status === 401)
|
||||
return AUTH_EXPIRED_CLASS
|
||||
if (status === 403)
|
||||
return ACCESS_DENIED_CLASS
|
||||
if (status === 429)
|
||||
return RATE_LIMITED_CLASS
|
||||
if (status >= 500)
|
||||
|
||||
@ -44,10 +44,10 @@ describe('createOpenApiClient error mapping', () => {
|
||||
}
|
||||
|
||||
it('recovers Dify message from a canonical ErrorBody 4xx response', async () => {
|
||||
const caught = await classifiedError(403, { code: 'access_denied', message: 'no access', status: 403 })
|
||||
const caught = await classifiedError(422, { code: 'invalid_param', message: 'no access', status: 422 })
|
||||
|
||||
expect(caught.code).toBe(ErrorCode.Server4xxOther)
|
||||
expect(caught.httpStatus).toBe(403)
|
||||
expect(caught.httpStatus).toBe(422)
|
||||
expect(caught.message).toBe('no access')
|
||||
// Parity with the transport path: the migrated endpoint's error keeps the request
|
||||
// method/url and the raw body, so formatted errors still print the `request:` line
|
||||
|
||||
@ -9,8 +9,6 @@ function describeResp(): AppDescribeResponse {
|
||||
name: 'Greeter',
|
||||
description: '',
|
||||
mode: 'chat',
|
||||
author: 'tester',
|
||||
tags: [],
|
||||
updated_at: undefined,
|
||||
service_api_enabled: false,
|
||||
is_agent: false,
|
||||
|
||||
@ -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]
|
||||
}
|
||||
|
||||
|
||||
@ -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() }
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -206,17 +206,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 +226,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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
@ -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 {
|
||||
|
||||
30
cli/test/fixtures/dify-mock/server.ts
vendored
30
cli/test/fixtures/dify-mock/server.ts
vendored
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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 = {
|
||||
@ -945,6 +941,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
|
||||
|
||||
@ -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
|
||||
*/
|
||||
@ -465,42 +497,6 @@ 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
|
||||
*/
|
||||
@ -896,6 +892,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
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user