feat: support import / export dsl in CLI (#37232)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: cheatofrom <85830867+cheatofrom@users.noreply.github.com>
Co-authored-by: Escape0707 <tothesong@gmail.com>
Co-authored-by: Rohit Gahlawat <personal.rg56@gmail.com>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
This commit is contained in:
Yunlu Wen 2026-06-10 17:51:40 +08:00 committed by GitHub
parent 534dd50d14
commit 0a051b598f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1830 additions and 61 deletions

View File

@ -25,6 +25,9 @@ from controllers.openapi._models import (
AppDescribeInfo,
AppDescribeQuery,
AppDescribeResponse,
AppDslExportQuery,
AppDslExportResponse,
AppDslImportPayload,
AppInfoResponse,
AppListQuery,
AppListResponse,
@ -64,10 +67,14 @@ from controllers.openapi._models import (
WorkspaceSummaryResponse,
)
from fields.file_fields import FileResponse
from services.app_dsl_service import Import
from services.entities.dsl_entities import CheckDependenciesResult
register_schema_models(
openapi_ns,
AppDescribeQuery,
AppDslImportPayload,
AppDslExportQuery,
AppListQuery,
AppRunRequest,
DeviceCodeRequest,
@ -90,6 +97,9 @@ register_response_schema_models(
AppInfoResponse,
AppDescribeInfo,
AppDescribeResponse,
AppDslExportResponse,
Import,
CheckDependenciesResult,
WorkflowRunData,
AccountPayload,
WorkspacePayload,
@ -118,6 +128,7 @@ register_response_schema_models(
from . import (
_meta,
account,
app_dsl,
app_run,
apps,
apps_permitted_external,
@ -135,6 +146,7 @@ from . import (
__all__ = [
"_meta",
"account",
"app_dsl",
"app_run",
"apps",
"apps_permitted_external",

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, uuid_value
from models.model import AppMode
@ -424,6 +424,45 @@ class TaskStopResponse(BaseModel):
result: Literal["success"]
class AppDslImportPayload(BaseModel):
"""Request body for POST /workspaces/<workspace_id>/apps/imports."""
model_config = ConfigDict(extra="forbid")
mode: Literal["yaml-content", "yaml-url"] = Field(..., description="Import mode: yaml-content or yaml-url")
yaml_content: str | None = Field(None, description="Inline YAML DSL string (required when mode is yaml-content)")
yaml_url: str | None = Field(None, description="Remote URL to fetch YAML from (required when mode is yaml-url)")
name: str | None = Field(None, description="Override the app name from the DSL")
description: str | None = Field(None, description="Override the app description from the DSL")
icon_type: str | None = Field(None)
icon: str | None = Field(None)
icon_background: str | None = Field(None)
app_id: str | None = Field(None, description="Existing app ID to overwrite (workflow/advanced-chat apps only)")
@model_validator(mode="after")
def _validate_source_by_mode(self) -> AppDslImportPayload:
if self.mode == "yaml-content" and not self.yaml_content:
raise ValueError("yaml_content is required when mode is 'yaml-content'")
if self.mode == "yaml-url" and not self.yaml_url:
raise ValueError("yaml_url is required when mode is 'yaml-url'")
return self
class AppDslExportQuery(BaseModel):
"""Query parameters for GET /apps/<app_id>/export."""
include_secret: bool = Field(False, description="Include encrypted secret values in the exported DSL")
workflow_id: UUIDStr | None = Field(
None, description="Export a specific workflow version instead of the current draft"
)
class AppDslExportResponse(BaseModel):
"""Export DSL response."""
data: str = Field(..., description="DSL YAML string")
class FormSubmitResponse(BaseModel):
"""Empty 200 body for POST /apps/<id>/form/human_input/<token>. `extra='forbid'`
pins `additionalProperties: false` so the generated contract is an exact `{}` rather

View File

@ -0,0 +1,167 @@
from __future__ import annotations
from typing import cast
from flask_restx import Resource
from sqlalchemy.orm import Session
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._models import AppDslExportQuery, AppDslExportResponse, AppDslImportPayload
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import Account, App
from models.account import TenantAccountRole
from services.app_dsl_service import AppDslService, Import
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.errors.app import WorkflowNotFoundError
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports")
class AppDslImportApi(Resource):
"""Import a DSL YAML string into the specified workspace.
Use ``mode=yaml-content`` with ``yaml_content`` for inline YAML, or
``mode=yaml-url`` with ``yaml_url`` for a remote URL. Provide ``app_id``
to overwrite an existing workflow or advanced-chat app; omit it to create
a new app.
Returns 202 when the DSL version requires an explicit confirmation step
(major version mismatch). Callers must then POST to the confirm endpoint.
Returns 400 when the import failed due to invalid DSL or a business error.
"""
@auth_router.guard_workspace(
scope=Scope.WORKSPACE_WRITE,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@returns(200, Import, "Import completed")
@returns(202, Import, "Import pending confirmation")
@returns(400, Import, "Import failed")
@accepts(body=AppDslImportPayload)
def post(self, workspace_id: str, *, auth_data: AuthData, body: AppDslImportPayload):
account = cast(Account, auth_data.caller)
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.import_app(
account=account,
import_mode=body.mode,
yaml_content=body.yaml_content,
yaml_url=body.yaml_url,
name=body.name,
description=body.description,
icon_type=body.icon_type,
icon=body.icon,
icon_background=body.icon_background,
app_id=body.app_id,
)
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
match result.status:
case ImportStatus.FAILED:
return result, 400
case ImportStatus.PENDING:
return result, 202
case _:
return result, 200
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports/<string:import_id>/confirm")
class AppDslImportConfirmApi(Resource):
"""Confirm a pending DSL import identified by ``import_id``.
Required only when the initial import returned 202 (major DSL version
mismatch that requires explicit acknowledgement). The pending state is
stored in Redis for 10 minutes; this call retrieves it and completes the
import under the given workspace.
Returns 400 when the pending data has expired or the import fails.
"""
@auth_router.guard_workspace(
scope=Scope.WORKSPACE_WRITE,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@returns(200, Import, "Import confirmed")
@returns(400, Import, "Import failed")
def post(self, workspace_id: str, import_id: str, *, auth_data: AuthData):
account = cast(Account, auth_data.caller)
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.confirm_import(import_id=import_id, account=account)
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
if result.status == ImportStatus.FAILED:
return result, 400
return result, 200
@openapi_ns.route("/apps/<string:app_id>/export")
class AppDslExportApi(Resource):
"""Export an app's current draft configuration as a DSL YAML string.
The auth pipeline resolves the app and its tenant from ``app_id``. Pass
``include_secret=true`` to embed encrypted credential values (e.g. tool
node secrets); omit it to produce a portable, sharable DSL safe to share.
Note: the pipeline enforces ``app.enable_api`` for all ``/apps/<app_id>``
routes in the openapi group. Apps with the service API disabled will
receive a 403; enable the API in the console first if needed.
"""
@auth_router.guard(
scope=Scope.APPS_READ,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@accepts(query=AppDslExportQuery)
@returns(200, AppDslExportResponse, "Export successful")
def get(self, app_id: str, *, auth_data: AuthData, query: AppDslExportQuery):
app = cast(App, auth_data.app)
try:
data = AppDslService.export_dsl(
app_model=app,
include_secret=query.include_secret,
workflow_id=query.workflow_id,
)
except WorkflowNotFoundError as exc:
return str(exc), 404
return AppDslExportResponse(data=data), 200
@openapi_ns.route("/apps/<string:app_id>/check-dependencies")
class AppDslCheckDependenciesApi(Resource):
"""Check for leaked plugin dependencies after a DSL import.
Call this after an import that reported ``COMPLETED_WITH_WARNINGS`` to
find which plugin dependencies referenced in the DSL are not yet installed
in the workspace. Returns an empty ``leaked_dependencies`` list when all
dependencies are satisfied.
"""
@auth_router.guard(
scope=Scope.APPS_READ,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@returns(200, CheckDependenciesResult, "Dependencies checked")
def get(self, app_id: str, *, auth_data: AuthData):
app = cast(App, auth_data.app)
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.check_dependencies(app_model=app)
return result, 200

View File

@ -103,6 +103,21 @@ User-scoped operations
| ---- | ----------- | ------ |
| 200 | App list | [AppListResponse](#applistresponse) |
### /apps/{app_id}/check-dependencies
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Dependencies checked | [CheckDependenciesResult](#checkdependenciesresult) |
### /apps/{app_id}/describe
#### GET
@ -119,6 +134,23 @@ User-scoped operations
| ---- | ----------- | ------ |
| 200 | App description | [AppDescribeResponse](#appdescriberesponse) |
### /apps/{app_id}/export
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| include_secret | query | Include encrypted secret values in the exported DSL | No | boolean |
| workflow_id | query | Export a specific workflow version instead of the current draft | No | string |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Export successful | [AppDslExportResponse](#appdslexportresponse) |
### /apps/{app_id}/files/upload
#### POST
@ -338,6 +370,41 @@ Upload a file to use as an input variable when running the app
| ---- | ----------- | ------ |
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
### /workspaces/{workspace_id}/apps/imports
#### POST
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
| payload | body | | Yes | [AppDslImportPayload](#appdslimportpayload) |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Import completed | [Import](#import) |
| 202 | Import pending confirmation | [Import](#import) |
| 400 | Import failed | [Import](#import) |
### /workspaces/{workspace_id}/apps/imports/{import_id}/confirm
#### POST
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| import_id | path | | Yes | string |
| workspace_id | path | | Yes | string |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Import confirmed | [Import](#import) |
| 400 | Import failed | [Import](#import) |
### /workspaces/{workspace_id}/members
#### GET
@ -471,6 +538,39 @@ Empty / omitted → all blocks. Unknown member → ValidationError → 422.
| input_schema | object | | No |
| parameters | object | | No |
#### AppDslExportQuery
Query parameters for GET /apps/<app_id>/export.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| include_secret | boolean | Include encrypted secret values in the exported DSL | No |
| workflow_id | string | Export a specific workflow version instead of the current draft | No |
#### AppDslExportResponse
Export DSL response.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | string | DSL YAML string | Yes |
#### AppDslImportPayload
Request body for POST /workspaces/<workspace_id>/apps/imports.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | Existing app ID to overwrite (workflow/advanced-chat apps only) | No |
| description | string | Override the app description from the DSL | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| mode | string | Import mode: yaml-content or yaml-url<br>*Enum:* `"yaml-content"`, `"yaml-url"` | Yes |
| name | string | Override the app name from the DSL | No |
| 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
| Name | Type | Description | Required |
@ -537,6 +637,12 @@ mode is a closed enum.
| workflow_id | string | | No |
| workspace_id | string | | No |
#### CheckDependenciesResult
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| leaked_dependencies | [ [PluginDependency](#plugindependency) ] | | No |
#### DeviceCodeRequest
| Name | Type | Description | Required |
@ -616,6 +722,15 @@ than an under-annotated open object.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
#### Github
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| github_plugin_unique_identifier | string | | Yes |
| package | string | | Yes |
| repo | string | | Yes |
| version | string | | Yes |
#### HealthResponse
Liveness payload for `GET /openapi/v1/_health` — no auth required.
@ -631,12 +746,37 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required.
| action | string | | Yes |
| inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes |
#### Import
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | | No |
| app_mode | string | | No |
| current_dsl_version | string | | No |
| error | string | | No |
| id | string | | Yes |
| imported_dsl_version | string | | No |
| status | [ImportStatus](#importstatus) | | Yes |
#### ImportStatus
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| ImportStatus | string | | |
#### JsonValue
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| JsonValue | | | |
#### Marketplace
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| marketplace_plugin_unique_identifier | string | | Yes |
| version | string | | No |
#### MemberActionResponse
| Name | Type | Description | Required |
@ -704,6 +844,13 @@ Strict (extra='forbid').
| retriever_resources | [ object ] | | No |
| usage | [UsageInfo](#usageinfo) | | No |
#### Package
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| plugin_unique_identifier | string | | Yes |
| version | string | | No |
#### PermittedExternalAppsListQuery
Strict (extra='forbid').
@ -725,6 +872,14 @@ Strict (extra='forbid').
| page | integer | | Yes |
| total | integer | | Yes |
#### PluginDependency
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| current_identifier | string | | No |
| type | [Type](#type) | | Yes |
| value | [Github](#github)<br>[Marketplace](#marketplace)<br>[Package](#package) | | Yes |
#### RevokeResponse
| Name | Type | Description | Required |
@ -787,6 +942,12 @@ types it as a required `'success'` rather than an optional field.
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### Type
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| Type | string | | |
#### UsageInfo
| Name | Type | Description | Required |

View File

@ -41,6 +41,7 @@ from models.model import AppModelConfig, AppModelConfigDict, IconType
from models.workflow import Workflow
from services.dsl_version import check_version_compatibility
from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus
from services.errors.app import WorkflowNotFoundError
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.workflow_draft_variable_service import WorkflowDraftVariableService
from services.workflow_service import WorkflowService
@ -557,7 +558,7 @@ class AppDslService:
workflow_service = WorkflowService()
workflow = workflow_service.get_draft_workflow(app_model, workflow_id)
if not workflow:
raise ValueError("Missing draft workflow configuration, please check.")
raise WorkflowNotFoundError("Missing draft workflow configuration, please check.")
workflow_dict = workflow.to_dict(include_secret=include_secret)
# TODO: refactor: we need a better way to filter workspace related data from nodes

View File

@ -38,6 +38,7 @@ from services.app_dsl_service import (
)
from services.app_service import AppService, CreateAppParams
from services.dsl_version import check_version_compatibility
from services.errors.app import WorkflowNotFoundError
from tests.test_containers_integration_tests.helpers import generate_valid_password
_DEFAULT_TENANT_ID = "00000000-0000-0000-0000-000000000001"
@ -1027,7 +1028,7 @@ class TestAppDslService:
mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.return_value = None
with pytest.raises(
ValueError,
WorkflowNotFoundError,
match="Missing draft workflow configuration, please check.",
):
AppDslService.export_dsl(app, include_secret=False, workflow_id=str(uuid4()))
@ -1139,7 +1140,7 @@ class TestAppDslService:
workflow_service.get_draft_workflow.return_value = None
monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service)
with pytest.raises(ValueError, match="Missing draft workflow configuration"):
with pytest.raises(WorkflowNotFoundError, match="Missing draft workflow configuration"):
AppDslService._append_workflow_export_data(
export_data={},
app_model=_app_stub(),

View File

@ -78,7 +78,7 @@ export default class MyCommand extends DifyCommand {
const { args, flags } = this.parse(MyCommand, argv)
// Authed: authedCtx() sets outputFormat + builds context
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format: flags.output })
const ctx = await this.authedCtx({ format: flags.output })
process.stdout.write(await runMyThing({ /* args */ }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io }))
}

113
cli/src/api/app-dsl.test.ts Normal file
View File

@ -0,0 +1,113 @@
import type { StubServer } from '@test/fixtures/stub-server'
import { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest'
import { isHttpClientError } from '@/errors/base'
import { AppDslClient } from './app-dsl.js'
const DSL_YAML = `app:\n mode: chat\n name: Test\nversion: '0.1.4'\n`
const COMPLETED_IMPORT = { id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }
function makeClient(host: string): AppDslClient {
return new AppDslClient(testHttpClient(host, 'dfoa_test'))
}
describe('AppDslClient.exportDsl', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('returns the data string from the response', async () => {
stub = await startStubServer(cap => jsonResponder(200, { data: DSL_YAML }, cap))
const yaml = await makeClient(stub.url).exportDsl('app-1')
expect(stub.captured.method).toBe('GET')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/export')
expect(yaml).toBe(DSL_YAML)
})
it('throws when response has no data field', async () => {
stub = await startStubServer(cap => jsonResponder(200, { wrong: 1 }, cap))
await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow('export response missing data field')
})
it('propagates 404 as a classified HttpClientError', async () => {
stub = await startStubServer(cap => jsonResponder(404, { error: 'not_found' }, cap))
await expect(makeClient(stub.url).exportDsl('missing')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 404,
)
})
})
describe('AppDslClient.importApp', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('POST to /workspaces/:id/apps/imports with body and returns Import', async () => {
stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
const result = await makeClient(stub.url).importApp('ws-1', {
mode: 'yaml-content',
yaml_content: DSL_YAML,
})
expect(stub.captured.method).toBe('POST')
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports')
expect(result.status).toBe('completed')
expect(result.app_id).toBe('app-1')
})
it('returns pending import on 202', async () => {
const pending = { id: 'imp-1', status: 'pending', current_dsl_version: '0.1.4', imported_dsl_version: '0.0.9' }
stub = await startStubServer(cap => jsonResponder(202, pending, cap))
const result = await makeClient(stub.url).importApp('ws-1', { mode: 'yaml-content', yaml_content: DSL_YAML })
expect(result.status).toBe('pending')
expect(result.id).toBe('imp-1')
})
})
describe('AppDslClient.confirmImport', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('POST to confirm URL and returns completed Import', async () => {
stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
const result = await makeClient(stub.url).confirmImport('ws-1', 'imp-1')
expect(stub.captured.method).toBe('POST')
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports/imp-1/confirm')
expect(result.status).toBe('completed')
})
})
describe('AppDslClient.checkDependencies', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('returns empty leaked_dependencies on healthy app', async () => {
stub = await startStubServer(cap => jsonResponder(200, { leaked_dependencies: [] }, cap))
const result = await makeClient(stub.url).checkDependencies('app-1')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/check-dependencies')
expect(result.leaked_dependencies).toEqual([])
})
})

59
cli/src/api/app-dsl.ts Normal file
View File

@ -0,0 +1,59 @@
import type {
AppDslImportPayload,
CheckDependenciesResult,
Import,
} from '@dify/contracts/api/openapi/types.gen'
import type { OpenApiClient } from '@/http/orpc'
import type { HttpClient } from '@/http/types'
import { createOpenApiClient } from '@/http/orpc'
export type ExportQuery = {
readonly includeSecret?: boolean
readonly workflowId?: string
}
export class AppDslClient {
private readonly orpc: OpenApiClient
constructor(http: HttpClient) {
this.orpc = createOpenApiClient(http)
}
async importApp(workspaceId: string, payload: AppDslImportPayload): Promise<Import> {
return this.orpc.workspaces.byWorkspaceId.apps.imports.post({
params: { workspace_id: workspaceId },
body: payload,
})
}
async confirmImport(workspaceId: string, importId: string): Promise<Import> {
return this.orpc.workspaces.byWorkspaceId.apps.imports.byImportId.confirm.post({
params: { workspace_id: workspaceId, import_id: importId },
})
}
async exportDsl(appId: string, query?: ExportQuery): Promise<string> {
const resp = await this.orpc.apps.byAppId.export.get({
params: { app_id: appId },
query: query !== undefined
? {
include_secret: query.includeSecret,
workflow_id: query.workflowId,
}
: undefined,
})
// The response schema is an open object {"data": "<yaml string>"}; the
// contract generator marks it as loose because the backend annotation
// does not narrow the shape. Extract `data` directly.
const data = (resp as Record<string, unknown>).data
if (typeof data !== 'string')
throw new Error('export response missing data field')
return data
}
async checkDependencies(appId: string): Promise<CheckDependenciesResult> {
return this.orpc.apps.byAppId.checkDependencies.get({
params: { app_id: appId },
})
}
}

View File

@ -0,0 +1,45 @@
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Args, Flags } from '@/framework/flags'
import { runExportApp } from './run'
export default class ExportApp extends DifyCommand {
static override description = 'Export an 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>',
]
static override args = {
id: Args.string({ description: 'app ID to export', required: true }),
}
static override flags = {
'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
'output': Flags.string({ description: 'write DSL YAML to this file path (prints to stdout if omitted)', char: 'o' }),
'include-secret': Flags.boolean({ description: 'include encrypted secret values in the exported DSL', default: false }),
'workflow-id': Flags.string({ description: 'export a specific workflow by ID (workflow apps only)' }),
'http-retry': httpRetryFlag,
}
async run(argv: string[]) {
const { args, flags } = this.parse(ExportApp, argv)
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
const result = await runExportApp({
appId: args.id,
workspace: flags.workspace,
output: flags.output,
includeSecret: flags['include-secret'],
workflowId: flags['workflow-id'],
}, { active: ctx.active, http: ctx.http, io: ctx.io })
if (result.writtenTo === undefined) {
ctx.io.out.write(result.yaml)
if (!result.yaml.endsWith('\n'))
ctx.io.out.write('\n')
}
}
}

View File

@ -0,0 +1,69 @@
import type { DifyMock } from '@test/fixtures/dify-mock/server'
import type { ActiveContext } from '@/auth/hosts'
import os from 'node:os'
import { join } from 'node:path'
import { DSL_YAML } from '@test/fixtures/dify-mock/scenarios'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { bufferStreams } from '@/sys/io/streams'
import { runExportApp } from './run.js'
const baseActive: ActiveContext = {
host: '127.0.0.1',
email: 'tester@dify.ai',
ctx: {
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
},
scheme: 'http',
}
describe('runExportApp', () => {
let mock: DifyMock
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
})
afterEach(async () => {
await mock.stop()
})
function http() {
return testHttpClient(mock.url, 'dfoa_test')
}
it('returns the DSL YAML string from the server', async () => {
const result = await runExportApp({ appId: 'app-1' }, { active: baseActive, http: http() })
expect(result.yaml).toBe(DSL_YAML)
expect(result.writtenTo).toBeUndefined()
})
it('writes to file when --output is given', async () => {
const tmpFile = join(os.tmpdir(), `difyctl-test-export-${Date.now()}.yaml`)
const result = await runExportApp(
{ appId: 'app-1', output: tmpFile },
{ active: baseActive, http: http() },
)
expect(result.writtenTo).toBe(tmpFile)
const { readFileSync } = await import('node:fs')
expect(readFileSync(tmpFile, 'utf8')).toBe(DSL_YAML)
})
it('err stream receives written-to path when --output is given', async () => {
const tmpFile = join(os.tmpdir(), `difyctl-test-export-${Date.now()}.yaml`)
const io = bufferStreams()
await runExportApp(
{ appId: 'app-1', output: tmpFile },
{ active: baseActive, http: http(), io },
)
expect(io.errBuf()).toContain(tmpFile)
})
})

View File

@ -0,0 +1,61 @@
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import fs from 'node:fs'
import { dirname } from 'node:path'
import { AppDslClient } from '@/api/app-dsl'
import { getEnv } from '@/sys/index'
import { runWithSpinner } from '@/sys/io/spinner'
import { nullStreams } from '@/sys/io/streams'
import { resolveWorkspaceId } from '@/workspace/resolver'
export type ExportAppOptions = {
readonly appId: string
readonly workspace?: string
readonly output?: string
readonly includeSecret?: boolean
readonly workflowId?: string
}
export type ExportAppDeps = {
readonly active: ActiveContext
readonly http: HttpClient
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
readonly dslFactory?: (http: HttpClient) => AppDslClient
}
export type ExportAppResult = {
readonly yaml: string
readonly writtenTo: string | undefined
}
export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps): Promise<ExportAppResult> {
const env = deps.envLookup ?? getEnv
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).
resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const client = dslFactory(deps.http)
const yaml = await runWithSpinner(
{ io, label: `Exporting DSL for app ${opts.appId}` },
() => client.exportDsl(opts.appId, {
includeSecret: opts.includeSecret,
workflowId: opts.workflowId,
}),
)
if (opts.output !== undefined && opts.output !== '') {
fs.mkdirSync(dirname(opts.output), { recursive: true })
fs.writeFileSync(opts.output, yaml, 'utf8')
io.err.write(`DSL written to ${opts.output}\n`)
return { yaml, writtenTo: opts.output }
}
return { yaml, writtenTo: undefined }
}

View File

@ -0,0 +1,60 @@
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Flags } from '@/framework/flags'
import { pluginDependencyLabel, runImportApp } from './run'
export default class ImportApp extends DifyCommand {
static override description = 'Import an 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>',
]
static override flags = {
'from-file': Flags.string({ description: 'import DSL from a local file (relative or absolute path)', char: 'f' }),
'from-url': Flags.string({ description: 'import DSL from an HTTP(S) URL' }),
'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
'name': Flags.string({ description: 'override the app name from the DSL' }),
'description': Flags.string({ description: 'override the app description from the DSL' }),
'app-id': Flags.string({ description: 'overwrite an existing app (workflow/advanced-chat only)' }),
'icon-type': Flags.string({ description: 'override icon type' }),
'icon': Flags.string({ description: 'override icon' }),
'icon-background': Flags.string({ description: 'override icon background colour' }),
'http-retry': httpRetryFlag,
}
async run(argv: string[]) {
const { flags } = this.parse(ImportApp, 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)
this.error('--from-file and --from-url are mutually exclusive', { exit: 1 })
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
const { result, leakedDependencies } = await runImportApp({
fromFile: flags['from-file'],
fromUrl: flags['from-url'],
workspace: flags.workspace,
name: flags.name,
description: flags.description,
appId: flags['app-id'],
iconType: flags['icon-type'],
icon: flags.icon,
iconBackground: flags['icon-background'],
}, { active: ctx.active, http: ctx.http, io: ctx.io })
const status = result.status === 'completed-with-warnings' ? 'completed (with warnings)' : result.status
ctx.io.err.write(`Import ${status}`)
if (result.app_id !== undefined && result.app_id !== null)
ctx.io.err.write(`: app ${result.app_id}`)
ctx.io.err.write('\n')
if (leakedDependencies.length > 0) {
ctx.io.err.write(`\nMissing plugin dependencies (${leakedDependencies.length}); install them before using the app:\n`)
for (const dep of leakedDependencies)
ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`)
}
}
}

View File

@ -0,0 +1,188 @@
import type { Import } from '@dify/contracts/api/openapi/types.gen'
import type { DifyMock } from '@test/fixtures/dify-mock/server'
import type { ActiveContext } from '@/auth/hosts'
import { writeFileSync } from 'node:fs'
import os from 'node:os'
import { join } from 'node:path'
import { DSL_YAML } from '@test/fixtures/dify-mock/scenarios'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AppDslClient } from '@/api/app-dsl'
import { bufferStreams } from '@/sys/io/streams'
import { ZERO } from '@/util/uuid.js'
import { pluginDependencyLabel, runImportApp } from './run.js'
const baseActive: ActiveContext = {
host: '127.0.0.1',
email: 'tester@dify.ai',
ctx: {
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
},
scheme: 'http',
}
describe('runImportApp', () => {
let mock: DifyMock
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
})
afterEach(async () => {
await mock.stop()
})
function http() {
return testHttpClient(mock.url, 'dfoa_test')
}
function tmpDslFile(): string {
const filePath = join(os.tmpdir(), `difyctl-import-test-${Date.now()}.yaml`)
writeFileSync(filePath, DSL_YAML, 'utf8')
return filePath
}
it('completes import from a local file path', async () => {
const dslFile = tmpDslFile()
const { result } = await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() })
expect(result.status).toBe('completed')
expect(result.app_id).toBe('app-1')
})
it('sends yaml_content in the request body', async () => {
const dslFile = tmpDslFile()
await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() })
expect(mock.lastImportBody?.mode).toBe('yaml-content')
expect(mock.lastImportBody?.yaml_content).toBe(DSL_YAML)
})
it('sends yaml_url when given --from-url', async () => {
await runImportApp(
{ fromUrl: 'https://example.com/app.yaml' },
{ active: baseActive, http: http() },
)
expect(mock.lastImportBody?.mode).toBe('yaml-url')
expect(mock.lastImportBody?.yaml_url).toBe('https://example.com/app.yaml')
})
it('forwards optional name and description overrides', async () => {
const dslFile = tmpDslFile()
await runImportApp(
{ fromFile: dslFile, name: 'My App', description: 'desc' },
{ active: baseActive, http: http() },
)
expect(mock.lastImportBody?.name).toBe('My App')
expect(mock.lastImportBody?.description).toBe('desc')
})
it('auto-confirms a pending (202) import and emits a note on err stream', async () => {
mock.setScenario('import-pending')
const io = bufferStreams()
const dslFile = tmpDslFile()
const { result } = await runImportApp(
{ fromFile: dslFile },
{ active: baseActive, http: http(), io },
)
expect(result.status).toBe('completed')
expect(io.errBuf()).toContain('confirming automatically')
})
it('throws on import-failed (400) response', async () => {
mock.setScenario('import-failed')
const dslFile = tmpDslFile()
await expect(
runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() }),
).rejects.toThrow('Import failed')
})
it('uses workspace from --workspace flag over context default', async () => {
const dslFile = tmpDslFile()
await runImportApp(
{ fromFile: dslFile, workspace: ZERO },
{ active: baseActive, http: http() },
)
expect(mock.lastImportBody).not.toBeNull()
})
it('throws UsageInvalidFlag when fromFile path does not exist', async () => {
await expect(
runImportApp({ fromFile: '/tmp/difyctl-no-such-file-ever.yaml' }, { active: baseActive, http: http() }),
).rejects.toThrow('file not found')
})
it('throws UsageInvalidFlag when both fromFile and fromUrl are given', async () => {
const dslFile = tmpDslFile()
await expect(
runImportApp({ fromFile: dslFile, fromUrl: 'https://example.com/app.yaml' }, { active: baseActive, http: http() }),
).rejects.toThrow('mutually exclusive')
})
it('throws UsageInvalidFlag when neither fromFile nor fromUrl is given', async () => {
await expect(
runImportApp({}, { active: baseActive, http: http() }),
).rejects.toThrow('required')
})
it('returns empty leakedDependencies when the app has no missing plugins', async () => {
const dslFile = tmpDslFile()
const { leakedDependencies } = await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() })
expect(leakedDependencies).toEqual([])
})
it('surfaces leaked dependencies reported by check-dependencies', async () => {
const dslFile = tmpDslFile()
const completed: Import = { id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }
const stub = Object.assign(Object.create(AppDslClient.prototype), {
importApp: async () => completed,
confirmImport: async () => completed,
checkDependencies: async () => ({
leaked_dependencies: [
{ type: 'marketplace', value: { marketplace_plugin_unique_identifier: 'langgenius/openai:0.0.1' } },
],
}),
}) as AppDslClient
const { leakedDependencies } = await runImportApp(
{ fromFile: dslFile },
{ active: baseActive, http: http(), dslFactory: () => stub },
)
expect(leakedDependencies).toHaveLength(1)
const [dep] = leakedDependencies
if (dep === undefined)
throw new Error('expected one leaked dependency')
expect(pluginDependencyLabel(dep)).toBe('langgenius/openai:0.0.1')
})
})
describe('pluginDependencyLabel', () => {
it('reads the github plugin identifier', () => {
const label = pluginDependencyLabel({
type: 'github',
value: { github_plugin_unique_identifier: 'owner/repo:1.0.0' },
})
expect(label).toBe('owner/repo:1.0.0')
})
it('reads the package plugin identifier', () => {
const label = pluginDependencyLabel({ type: 'package', value: { plugin_unique_identifier: 'pkg:2.0.0' } })
expect(label).toBe('pkg:2.0.0')
})
it('falls back to current_identifier then a placeholder', () => {
expect(pluginDependencyLabel({ type: 'package', value: {}, current_identifier: 'fallback' })).toBe('fallback')
expect(pluginDependencyLabel({ type: 'package', value: null })).toBe('<unknown>')
})
})

View File

@ -0,0 +1,138 @@
import type { Import, PluginDependency } from '@dify/contracts/api/openapi/types.gen'
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import fs from 'node:fs'
import { AppDslClient } from '@/api/app-dsl'
import { newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { getEnv } from '@/sys/index'
import { runWithSpinner } from '@/sys/io/spinner'
import { nullStreams } from '@/sys/io/streams'
import { resolveWorkspaceId } from '@/workspace/resolver'
export type ImportAppOptions = {
readonly fromFile?: string
readonly fromUrl?: string
readonly workspace?: string
readonly name?: string
readonly description?: string
readonly appId?: string
readonly iconType?: string
readonly icon?: string
readonly iconBackground?: string
}
export type ImportAppDeps = {
readonly active: ActiveContext
readonly http: HttpClient
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
readonly dslFactory?: (http: HttpClient) => AppDslClient
}
export type ImportAppResult = {
readonly result: Import
readonly leakedDependencies: readonly PluginDependency[]
}
export async function runImportApp(opts: ImportAppOptions, deps: ImportAppDeps): Promise<ImportAppResult> {
const env = deps.envLookup ?? getEnv
const io = deps.io ?? nullStreams()
const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h))
const workspaceId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const client = dslFactory(deps.http)
if (opts.fromFile !== undefined && opts.fromUrl !== undefined)
throw newError(ErrorCode.UsageInvalidFlag, '--from-file and --from-url are mutually exclusive')
let mode: 'yaml-content' | 'yaml-url'
let yamlContent: string | undefined
let yamlUrl: string | undefined
if (opts.fromFile !== undefined) {
mode = 'yaml-content'
try {
yamlContent = fs.readFileSync(opts.fromFile, 'utf8')
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'ENOENT')
throw newError(ErrorCode.UsageInvalidFlag, `--from-file: file not found: ${opts.fromFile}`)
throw err
}
}
else if (opts.fromUrl !== undefined) {
mode = 'yaml-url'
yamlUrl = opts.fromUrl
}
else {
throw newError(ErrorCode.UsageInvalidFlag, 'one of --from-file or --from-url is required')
}
let result = await runWithSpinner(
{ io, label: 'Importing app DSL' },
() => client.importApp(workspaceId, {
mode,
yaml_content: yamlContent,
yaml_url: yamlUrl,
name: opts.name,
description: opts.description,
app_id: opts.appId,
icon_type: opts.iconType,
icon: opts.icon,
icon_background: opts.iconBackground,
}),
)
if (result.status === 'failed') {
throw newError(
ErrorCode.Server4xxOther,
`Import failed: ${result.error !== '' ? result.error : 'unknown error'}`,
)
}
// DSL version mismatch: the server needs an explicit acknowledgement before
// finalising. Auto-confirm here so the user does not need a second command.
if (result.status === 'pending') {
io.err.write(`note: DSL version mismatch (imported ${result.imported_dsl_version ?? '?'}, current ${result.current_dsl_version ?? '?'}); confirming automatically\n`)
result = await runWithSpinner(
{ io, label: 'Confirming import' },
() => client.confirmImport(workspaceId, result.id),
)
}
if (result.status === 'failed') {
throw newError(
ErrorCode.Server4xxOther,
`Import failed after confirmation: ${result.error !== '' ? result.error : 'unknown error'}`,
)
}
const appId = result.app_id
if (appId === undefined || appId === null)
return { result, leakedDependencies: [] }
const { leaked_dependencies } = await runWithSpinner(
{ io, label: 'Checking plugin dependencies' },
() => client.checkDependencies(appId),
)
return { result, leakedDependencies: leaked_dependencies ?? [] }
}
// `value` is a loosely-typed wire object (Github | Marketplace | Package); narrow it here to
// surface a human-readable identifier without depending on which variant the server returned.
export function pluginDependencyLabel(dep: PluginDependency): string {
const value = dep.value
if (typeof value === 'object' && value !== null) {
const fields = value as Record<string, unknown>
const id = fields.marketplace_plugin_unique_identifier
?? fields.github_plugin_unique_identifier
?? fields.plugin_unique_identifier
if (typeof id === 'string' && id !== '')
return id
}
return dep.current_identifier ?? '<unknown>'
}

View File

@ -17,9 +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 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 ResumeApp from '@/commands/resume/app/index'
import RunApp from '@/commands/run/app/index'
import SetMember from '@/commands/set/member/index'
@ -73,6 +75,11 @@ export const commandTree: CommandTree = {
list: { command: EnvList, subcommands: {} },
},
},
export: {
subcommands: {
app: { command: ExportApp, subcommands: {} },
},
},
get: {
subcommands: {
app: { command: GetApp, subcommands: {} },
@ -80,6 +87,11 @@ export const commandTree: CommandTree = {
workspace: { command: GetWorkspace, subcommands: {} },
},
},
import: {
subcommands: {
app: { command: ImportApp, subcommands: {} },
},
},
resume: {
subcommands: {
app: { command: ResumeApp, subcommands: {} },

1
cli/src/util/uuid.ts Normal file
View File

@ -0,0 +1 @@
export const ZERO = '00000000-0000-0000-0000-000000000000'

View File

@ -27,9 +27,11 @@
import type { TestProject } from 'vitest/node'
import type { E2ECapabilities } from './env.js'
import { Buffer } from 'node:buffer'
import { readFile, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { injectAuth, run } from '../helpers/cli.js'
import { loadE2EEnv } from './env.js'
const TOKEN_MINT_APPROVE_ATTEMPTS = 5
@ -259,6 +261,10 @@ export async function setup(project: TestProject): Promise<void> {
secondaryWsId,
fixturesDir,
E.edition,
primaryToken,
apiBase,
E.email,
primaryWsName,
)
console.warn(`[E2E global-setup] Provisioned ${Object.keys(provisionedIds).length} fixture apps`)
}
@ -474,6 +480,10 @@ async function provisionApps(
secondaryWsId: string,
fixturesDir: string,
edition: 'ce' | 'ee',
token: string,
host: string,
email: string,
primaryWsName: string,
): Promise<Record<string, string>> {
const NEEDS_PUBLISH = new Set(['workflow', 'advanced-chat', 'agent-chat'])
@ -498,6 +508,28 @@ async function provisionApps(
: []),
]
const configDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-provision-'))
await injectAuth(configDir, {
host,
bearer: token,
email,
workspaceId: primaryWsId,
workspaceName: primaryWsName,
})
async function importAppCli(filePath: string, wsId: string): Promise<string> {
const result = await run(
['import', '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}`)
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}`)
return match[1]
}
async function switchWorkspace(wsId: string): Promise<void> {
const r = await fetch(`${consoleBase}/console/api/workspaces/switch`, {
method: 'POST',
@ -518,30 +550,6 @@ async function provisionApps(
return d.data?.find(a => a.name === name)?.id ?? null
}
async function importFromDsl(yamlContent: string): Promise<string> {
const r = await fetch(`${consoleBase}/console/api/apps/imports`, {
method: 'POST',
headers: mkHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ mode: 'yaml-content', yaml_content: yamlContent }),
signal: AbortSignal.timeout(30_000),
})
const d = await r.json() as { app_id?: string, import_id?: string, status?: string }
if (r.status === 202 && d.import_id) {
const cr = await fetch(`${consoleBase}/console/api/apps/imports/${d.import_id}/confirm`, {
method: 'POST',
headers: mkHeaders(),
signal: AbortSignal.timeout(15_000),
})
const c = await cr.json() as { app_id?: string }
if (!c.app_id)
throw new Error(`import confirm failed: HTTP ${cr.status}`)
return c.app_id
}
if (!d.app_id)
throw new Error(`import failed: HTTP ${r.status} ${JSON.stringify(d)}`)
return d.app_id
}
async function enableApi(appId: string): Promise<void> {
await fetch(`${consoleBase}/console/api/apps/${appId}/api-enable`, {
method: 'POST',
@ -603,7 +611,7 @@ async function provisionApps(
console.warn(`[E2E provision] ${dslFile}: exists in workspace id=${appId}; skip import`)
}
else {
appId = await importFromDsl(dsl)
appId = await importAppCli(join(fixturesDir, dslFile), wsId)
console.warn(`[E2E provision] ${dslFile}: imported id=${appId}`)
}
@ -619,6 +627,9 @@ async function provisionApps(
}
}
await rm(configDir, { recursive: true, force: true }).catch((err: unknown) =>
console.warn(`[E2E provision] failed to clean up configDir: ${err}`),
)
return results
}

View File

@ -0,0 +1,196 @@
/**
* E2E: difyctl export app DSL export
*
* Prerequisites (DIFY_E2E_* env vars):
* DIFY_E2E_WORKFLOW_APP_ID echo-workflow app (no model provider dependency)
* DIFY_E2E_CHAT_APP_ID echo-chat app
*/
import type { AuthFixture } from '../../helpers/cli.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import {
assertExitCode,
} from '../../helpers/assert.js'
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { resolveEnv } from '../../setup/env.js'
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
describe('E2E / difyctl export app', () => {
let fx: AuthFixture
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
// ── Basic export ──────────────────────────────────────────────────────────
it('[P0] exported DSL is non-empty YAML printed to stdout', async () => {
const result = await fx.r(['export', '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])
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])
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])
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])
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])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^kind:\s*app/m)
expect(result.stdout).toMatch(/^\s+mode:/m)
})
// ── --output flag ─────────────────────────────────────────────────────────
it('[P1] --output writes DSL to file and exits 0', async () => {
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])
assertExitCode(result, 0)
const content = await readFile(outPath, 'utf8')
expect(content).toMatch(/^kind:\s*app/m)
expect(content).toMatch(/^version:/m)
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
it('[P1] --output writes same content as stdout', async () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-cmp-'))
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) => {
const content = await readFile(outPath, 'utf8')
return { exitCode: r.exitCode, content }
}),
])
assertExitCode(stdoutResult, 0)
expect(fileResult.exitCode).toBe(0)
expect(fileResult.content.trim()).toBe(stdoutResult.stdout.trim())
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
// ── Roundtrip: export → import ────────────────────────────────────────────
it('[P1] roundtrip: exported DSL can be re-imported as a new app', async () => {
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])
assertExitCode(exportResult, 0)
const importResult = await fx.r([
'import',
'app',
'--from-file',
dslPath,
'--name',
'e2e-export-roundtrip',
])
assertExitCode(importResult, 0)
const match = importResult.stderr.match(/app ([0-9a-f-]{36})/)
expect(match?.[1], 'import stderr must contain the new app UUID').toBeTruthy()
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
// ── 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'])
expect(result.exitCode).toBe(1)
expect(result.stderr.length).toBeGreaterThan(0)
})
it('[P0] unauthenticated export returns auth error (exit code 4)', async () => {
const unauthTmp = await withTempConfig()
try {
const result = await run(['export', 'app', E.workflowAppId], {
configDir: unauthTmp.configDir,
})
assertExitCode(result, 4)
}
finally {
await unauthTmp.cleanup()
}
})
it('[P1] export with missing app id argument exits non-zero', async () => {
const result = await fx.r(['export', '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'])
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/)
})
it('[P1] non-existent --workflow-id returns 404, not a 5xx', async () => {
const result = await fx.r([
'export',
'app',
E.workflowAppId,
'--workflow-id',
'00000000-0000-0000-0000-000000000000',
])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/http_status:\s*404/)
})
it('[P1] non-existent app in --output mode leaves no file behind', async () => {
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])
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)
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
})

View File

@ -34,6 +34,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import { ZERO } from '@/util/uuid.js'
import {
assertErrorEnvelope,
assertNoAnsi,
@ -203,7 +204,7 @@ describe('E2E / error message standards (spec 5.3)', () => {
// Spec 5.83: without --debug the CLI must never print a stack trace.
// We trigger a server_5xx by querying a non-existent app id and verify
// that no "at <FunctionName>" stack-trace lines appear in stderr.
const result = await fx.r(['get', 'app', '00000000-0000-0000-0000-000000000000'])
const result = await fx.r(['get', 'app', ZERO])
assertNonZeroExit(result)
// Stack trace lines look like " at Object.xxx (/path/to/file.js:123:45)"
expect(result.stderr).not.toMatch(/^\s+at\s+\S/m)

View File

@ -31,16 +31,17 @@
* 5.117 panic output cannot reliably trigger panic
*/
import type { AuthFixture } from '../../helpers/cli.js'
import type { AuthFixture } from '@test/e2e/helpers/cli.js'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { assertExitCode, assertNonZeroExit } from '@test/e2e/helpers/assert.js'
import { run, withAuthFixture, withTempConfig } from '@test/e2e/helpers/cli.js'
import { resolveEnv } from '@test/e2e/setup/env.js'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import { assertExitCode, assertNonZeroExit } from '../../helpers/assert.js'
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { resolveEnv } from '../../setup/env.js'
import { ZERO } from '@/util/uuid.js'
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const caps = inject('e2eCapabilities') as import('@test/e2e/setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
describe('E2E / exit code standards (spec 5.4)', () => {
@ -85,7 +86,7 @@ describe('E2E / exit code standards (spec 5.4)', () => {
const result = await fx.r([
'get',
'app',
'00000000-0000-0000-0000-000000000000',
ZERO,
'-o',
'json',
])

View File

@ -14,6 +14,8 @@ export type Scenario
| 'server-version-empty'
| 'server-version-unsupported'
| 'run-422-stale'
| 'import-pending'
| 'import-failed'
export type AccountFixture = {
id: string
@ -141,6 +143,13 @@ export const APPS: AppFixture[] = [
},
]
export const DSL_YAML = `app:
description: A simple greeting bot
mode: chat
name: Greeter
version: '0.1.4'
`
export const SESSIONS: SessionFixture[] = [
{
id: 'tok-1',

View File

@ -2,7 +2,7 @@ import type { AddressInfo } from 'node:net'
import type { Scenario } from './scenarios.js'
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { ACCOUNT, APPS, SESSIONS, WORKSPACES } from './scenarios.js'
import { ACCOUNT, APPS, DSL_YAML, SESSIONS, WORKSPACES } from './scenarios.js'
export type DifyMockOptions = {
scenario?: Scenario
@ -19,6 +19,8 @@ export type DifyMock = {
lastRunBody: Record<string, unknown> | null
/** Number of times POST /apps/:id/files/upload was called */
uploadCallCount: number
/** Body of the most recent POST to /workspaces/:id/apps/imports */
lastImportBody: Record<string, unknown> | null
}
const TOKEN_RE = /^Bearer\s+dfo[ae]_[\w-]+$/
@ -106,6 +108,7 @@ function hitlResumedResponse(): string {
export type MockState = {
lastRunBody: Record<string, unknown> | null
uploadCallCount: number
lastImportBody: Record<string, unknown> | null
}
export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
@ -277,6 +280,38 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
})
})
app.get('/openapi/v1/apps/:id/export', (c) => {
const id = c.req.param('id')
const found = APPS.find(a => a.id === id)
if (found === undefined)
return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 })
return c.json({ data: DSL_YAML })
})
app.get('/openapi/v1/apps/:id/check-dependencies', (c) => {
const id = c.req.param('id')
const found = APPS.find(a => a.id === id)
if (found === undefined)
return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 })
return c.json({ leaked_dependencies: [] })
})
app.post('/openapi/v1/workspaces/:wsId/apps/imports', async (c) => {
const body = await c.req.json() as Record<string, unknown>
if (state !== undefined)
state.lastImportBody = body
const scenario = getScenario()
if (scenario === 'import-failed')
return c.json({ id: 'imp-1', status: 'failed', error: 'unsupported DSL version' }, { status: 200 })
if (scenario === 'import-pending')
return c.json({ id: 'imp-1', status: 'pending', current_dsl_version: '0.1.4', imported_dsl_version: '0.0.9' }, { status: 202 })
return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
})
app.post('/openapi/v1/workspaces/:wsId/apps/imports/:importId/confirm', (c) => {
return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
})
app.post('/openapi/v1/apps/:id/run', async (c) => {
const id = c.req.param('id')
const body = await c.req.json() as { query?: string, inputs?: unknown }
@ -393,7 +428,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
export function startMock(opts: DifyMockOptions = {}): Promise<DifyMock> {
let scenario: Scenario = opts.scenario ?? 'happy'
const state: MockState = { lastRunBody: null, uploadCallCount: 0 }
const state: MockState = { lastRunBody: null, uploadCallCount: 0, lastImportBody: null }
const app = buildApp(() => scenario, state)
return new Promise((resolve, reject) => {
const server = serve({
@ -416,6 +451,7 @@ export function startMock(opts: DifyMockOptions = {}): Promise<DifyMock> {
},
get lastRunBody() { return state.lastRunBody },
get uploadCallCount() { return state.uploadCallCount },
get lastImportBody() { return state.lastImportBody },
})
})
server.on('error', reject)

View File

@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite-plus'
import { resolveBuildInfo } from './scripts/lib/resolve-buildinfo.js'
@ -36,6 +37,12 @@ catch {
* Run: bun vitest --config vitest.e2e.config.ts
*/
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@test': fileURLToPath(new URL('./test', import.meta.url)),
},
},
pack: {
entry: ['src/index.ts'],
format: ['esm'],
@ -85,6 +92,8 @@ export default defineConfig({
'test/e2e/suites/framework/**/*.e2e.ts',
// discovery (get app / describe app)
'test/e2e/suites/discovery/**/*.e2e.ts',
// dsl (export / import)
'test/e2e/suites/dsl/**/*.e2e.ts',
// run tests (require valid token)
'test/e2e/suites/run/**/*.e2e.ts',
'test/e2e/suites/agent/**/*.e2e.ts',

View File

@ -12,9 +12,14 @@ import {
zGetAccountResponse,
zGetAccountSessionsQuery,
zGetAccountSessionsResponse,
zGetAppsByAppIdCheckDependenciesPath,
zGetAppsByAppIdCheckDependenciesResponse,
zGetAppsByAppIdDescribePath,
zGetAppsByAppIdDescribeQuery,
zGetAppsByAppIdDescribeResponse,
zGetAppsByAppIdExportPath,
zGetAppsByAppIdExportQuery,
zGetAppsByAppIdExportResponse,
zGetAppsByAppIdFormHumanInputByFormTokenPath,
zGetAppsByAppIdFormHumanInputByFormTokenResponse,
zGetAppsByAppIdTasksByTaskIdEventsPath,
@ -51,6 +56,11 @@ import {
zPostOauthDeviceDenyResponse,
zPostOauthDeviceTokenBody,
zPostOauthDeviceTokenResponse,
zPostWorkspacesByWorkspaceIdAppsImportsBody,
zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath,
zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse,
zPostWorkspacesByWorkspaceIdAppsImportsPath,
zPostWorkspacesByWorkspaceIdAppsImportsResponse,
zPostWorkspacesByWorkspaceIdMembersBody,
zPostWorkspacesByWorkspaceIdMembersPath,
zPostWorkspacesByWorkspaceIdMembersResponse,
@ -151,6 +161,21 @@ export const account = {
}
export const get5 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdCheckDependencies',
path: '/apps/{app_id}/check-dependencies',
tags: ['openapi'],
})
.input(z.object({ params: zGetAppsByAppIdCheckDependenciesPath }))
.output(zGetAppsByAppIdCheckDependenciesResponse)
export const checkDependencies = {
get: get5,
}
export const get6 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -167,7 +192,24 @@ export const get5 = oc
.output(zGetAppsByAppIdDescribeResponse)
export const describe = {
get: get5,
get: get6,
}
export const get7 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdExport',
path: '/apps/{app_id}/export',
tags: ['openapi'],
})
.input(
z.object({ params: zGetAppsByAppIdExportPath, query: zGetAppsByAppIdExportQuery.optional() }),
)
.output(zGetAppsByAppIdExportResponse)
export const export_ = {
get: get7,
}
/**
@ -199,7 +241,7 @@ export const files = {
*
* @deprecated
*/
export const get6 = oc
export const get8 = oc
.route({
deprecated: true,
description:
@ -230,7 +272,7 @@ export const post2 = oc
.output(zPostAppsByAppIdFormHumanInputByFormTokenResponse)
export const byFormToken = {
get: get6,
get: get8,
post: post2,
}
@ -270,7 +312,7 @@ export const run = {
*
* @deprecated
*/
export const get7 = oc
export const get9 = oc
.route({
deprecated: true,
description:
@ -285,7 +327,7 @@ export const get7 = oc
.output(zGetAppsByAppIdTasksByTaskIdEventsResponse)
export const events = {
get: get7,
get: get9,
}
export const post4 = oc
@ -313,14 +355,16 @@ export const tasks = {
}
export const byAppId = {
checkDependencies,
describe,
export: export_,
files,
form,
run,
tasks,
}
export const get8 = oc
export const get10 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -332,7 +376,7 @@ export const get8 = oc
.output(zGetAppsResponse)
export const apps = {
get: get8,
get: get10,
byAppId,
}
@ -381,7 +425,7 @@ export const deny = {
post: post7,
}
export const get9 = oc
export const get11 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -393,7 +437,7 @@ export const get9 = oc
.output(zGetOauthDeviceLookupResponse)
export const lookup = {
get: get9,
get: get11,
}
/**
@ -431,7 +475,7 @@ export const oauth = {
device,
}
export const get10 = oc
export const get12 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -443,7 +487,51 @@ export const get10 = oc
.output(zGetPermittedExternalAppsResponse)
export const permittedExternalApps = {
get: get10,
get: get12,
}
export const post9 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesByWorkspaceIdAppsImportsByImportIdConfirm',
path: '/workspaces/{workspace_id}/apps/imports/{import_id}/confirm',
tags: ['openapi'],
})
.input(z.object({ params: zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath }))
.output(zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse)
export const confirm = {
post: post9,
}
export const byImportId = {
confirm,
}
export const post10 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesByWorkspaceIdAppsImports',
path: '/workspaces/{workspace_id}/apps/imports',
tags: ['openapi'],
})
.input(
z.object({
body: zPostWorkspacesByWorkspaceIdAppsImportsBody,
params: zPostWorkspacesByWorkspaceIdAppsImportsPath,
}),
)
.output(zPostWorkspacesByWorkspaceIdAppsImportsResponse)
export const imports = {
post: post10,
byImportId,
}
export const apps2 = {
imports,
}
export const put = oc
@ -482,7 +570,7 @@ export const byMemberId = {
role,
}
export const get11 = oc
export const get13 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -498,7 +586,7 @@ export const get11 = oc
)
.output(zGetWorkspacesByWorkspaceIdMembersResponse)
export const post9 = oc
export const post11 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -516,12 +604,12 @@ export const post9 = oc
.output(zPostWorkspacesByWorkspaceIdMembersResponse)
export const members = {
get: get11,
post: post9,
get: get13,
post: post11,
byMemberId,
}
export const post10 = oc
export const post12 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -533,10 +621,10 @@ export const post10 = oc
.output(zPostWorkspacesByWorkspaceIdSwitchResponse)
export const switch_ = {
post: post10,
post: post12,
}
export const get12 = oc
export const get14 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -548,12 +636,13 @@ export const get12 = oc
.output(zGetWorkspacesByWorkspaceIdResponse)
export const byWorkspaceId = {
get: get12,
get: get14,
apps: apps2,
members,
switch: switch_,
}
export const get13 = oc
export const get15 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -564,7 +653,7 @@ export const get13 = oc
.output(zGetWorkspacesResponse)
export const workspaces = {
get: get13,
get: get15,
byWorkspaceId,
}

View File

@ -45,6 +45,27 @@ export type AppDescribeResponse = {
} | null
}
export type AppDslExportQuery = {
include_secret?: boolean
workflow_id?: string | null
}
export type AppDslExportResponse = {
data: string
}
export type AppDslImportPayload = {
app_id?: string | null
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
mode: 'yaml-content' | 'yaml-url'
name?: string | null
yaml_content?: string | null
yaml_url?: string | null
}
export type AppInfoResponse = {
author?: string | null
description?: string | null
@ -107,6 +128,10 @@ export type AppRunRequest = {
workspace_id?: string | null
}
export type CheckDependenciesResult = {
leaked_dependencies?: Array<PluginDependency>
}
export type DeviceCodeRequest = {
client_id: string
device_label: string
@ -165,6 +190,13 @@ export type FormSubmitResponse = {
[key: string]: never
}
export type Github = {
github_plugin_unique_identifier: string
package: string
repo: string
version: string
}
export type HealthResponse = {
ok: boolean
}
@ -176,8 +208,25 @@ export type HumanInputFormSubmitPayload = {
}
}
export type Import = {
app_id?: string | null
app_mode?: string | null
current_dsl_version?: string
error?: string
id: string
imported_dsl_version?: string
status: ImportStatus
}
export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | 'pending'
export type JsonValue = unknown
export type Marketplace = {
marketplace_plugin_unique_identifier: string
version?: string | null
}
export type MemberActionResponse = {
result?: string
}
@ -229,6 +278,11 @@ export type MessageMetadata = {
usage?: UsageInfo
}
export type Package = {
plugin_unique_identifier: string
version?: string | null
}
export type PermittedExternalAppsListQuery = {
limit?: number
mode?: AppMode
@ -244,6 +298,12 @@ export type PermittedExternalAppsListResponse = {
total: number
}
export type PluginDependency = {
current_identifier?: string | null
type: Type
value: unknown
}
export type RevokeResponse = {
status: string
}
@ -284,6 +344,8 @@ export type TaskStopResponse = {
result: string
}
export type Type = 'github' | 'marketplace' | 'package'
export type UsageInfo = {
completion_tokens?: number
prompt_tokens?: number
@ -438,6 +500,22 @@ export type GetAppsResponses = {
export type GetAppsResponse = GetAppsResponses[keyof GetAppsResponses]
export type GetAppsByAppIdCheckDependenciesData = {
body?: never
path: {
app_id: string
}
query?: never
url: '/apps/{app_id}/check-dependencies'
}
export type GetAppsByAppIdCheckDependenciesResponses = {
200: CheckDependenciesResult
}
export type GetAppsByAppIdCheckDependenciesResponse
= GetAppsByAppIdCheckDependenciesResponses[keyof GetAppsByAppIdCheckDependenciesResponses]
export type GetAppsByAppIdDescribeData = {
body?: never
path: {
@ -456,6 +534,25 @@ export type GetAppsByAppIdDescribeResponses = {
export type GetAppsByAppIdDescribeResponse
= GetAppsByAppIdDescribeResponses[keyof GetAppsByAppIdDescribeResponses]
export type GetAppsByAppIdExportData = {
body?: never
path: {
app_id: string
}
query?: {
include_secret?: boolean
workflow_id?: string
}
url: '/apps/{app_id}/export'
}
export type GetAppsByAppIdExportResponses = {
200: AppDslExportResponse
}
export type GetAppsByAppIdExportResponse
= GetAppsByAppIdExportResponses[keyof GetAppsByAppIdExportResponses]
export type PostAppsByAppIdFilesUploadData = {
body?: never
path: {
@ -702,6 +799,54 @@ export type GetWorkspacesByWorkspaceIdResponses = {
export type GetWorkspacesByWorkspaceIdResponse
= GetWorkspacesByWorkspaceIdResponses[keyof GetWorkspacesByWorkspaceIdResponses]
export type PostWorkspacesByWorkspaceIdAppsImportsData = {
body: AppDslImportPayload
path: {
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/apps/imports'
}
export type PostWorkspacesByWorkspaceIdAppsImportsErrors = {
400: Import
}
export type PostWorkspacesByWorkspaceIdAppsImportsError
= PostWorkspacesByWorkspaceIdAppsImportsErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsErrors]
export type PostWorkspacesByWorkspaceIdAppsImportsResponses = {
200: Import
202: Import
}
export type PostWorkspacesByWorkspaceIdAppsImportsResponse
= PostWorkspacesByWorkspaceIdAppsImportsResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsResponses]
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmData = {
body?: never
path: {
import_id: string
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/apps/imports/{import_id}/confirm'
}
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors = {
400: Import
}
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmError
= PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors]
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses = {
200: Import
}
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse
= PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses]
export type GetWorkspacesByWorkspaceIdMembersData = {
body?: never
path: {

View File

@ -22,6 +22,42 @@ export const zAppDescribeQuery = z.object({
fields: z.string().optional(),
})
/**
* AppDslExportQuery
*
* Query parameters for GET /apps/<app_id>/export.
*/
export const zAppDslExportQuery = z.object({
include_secret: z.boolean().optional().default(false),
workflow_id: z.string().nullish(),
})
/**
* AppDslExportResponse
*
* Export DSL response.
*/
export const zAppDslExportResponse = z.object({
data: z.string(),
})
/**
* AppDslImportPayload
*
* Request body for POST /workspaces/<workspace_id>/apps/imports.
*/
export const zAppDslImportPayload = z.object({
app_id: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
mode: z.enum(['yaml-content', 'yaml-url']),
name: z.string().nullish(),
yaml_content: z.string().nullish(),
yaml_url: z.string().nullish(),
})
/**
* AppMode
*/
@ -150,6 +186,16 @@ export const zFileResponse = z.object({
*/
export const zFormSubmitResponse = z.record(z.string(), z.never())
/**
* Github
*/
export const zGithub = z.object({
github_plugin_unique_identifier: z.string(),
package: z.string(),
repo: z.string(),
version: z.string(),
})
/**
* HealthResponse
*
@ -159,6 +205,24 @@ export const zHealthResponse = z.object({
ok: z.boolean(),
})
/**
* ImportStatus
*/
export const zImportStatus = z.enum(['completed', 'completed-with-warnings', 'failed', 'pending'])
/**
* Import
*/
export const zImport = z.object({
app_id: z.string().nullish(),
app_mode: z.string().nullish(),
current_dsl_version: z.string().optional().default('0.6.0'),
error: z.string().optional().default(''),
id: z.string(),
imported_dsl_version: z.string().optional().default(''),
status: zImportStatus,
})
export const zJsonValue = z.unknown()
/**
@ -169,6 +233,14 @@ export const zHumanInputFormSubmitPayload = z.object({
inputs: z.record(z.string(), zJsonValue),
})
/**
* Marketplace
*/
export const zMarketplace = z.object({
marketplace_plugin_unique_identifier: z.string(),
version: z.string().nullish(),
})
/**
* MemberActionResponse
*/
@ -236,6 +308,14 @@ export const zMemberRoleUpdatePayload = z.object({
role: z.enum(['admin', 'normal']),
})
/**
* Package
*/
export const zPackage = z.object({
plugin_unique_identifier: z.string(),
version: z.string().nullish(),
})
/**
* PermittedExternalAppsListQuery
*
@ -390,6 +470,27 @@ export const zTaskStopResponse = z.object({
result: z.string(),
})
/**
* Type
*/
export const zType = z.enum(['github', 'marketplace', 'package'])
/**
* PluginDependency
*/
export const zPluginDependency = z.object({
current_identifier: z.string().nullish(),
type: zType,
value: z.unknown(),
})
/**
* CheckDependenciesResult
*/
export const zCheckDependenciesResult = z.object({
leaked_dependencies: z.array(zPluginDependency).optional(),
})
/**
* UsageInfo
*/
@ -527,6 +628,15 @@ export const zGetAppsQuery = z.object({
*/
export const zGetAppsResponse = zAppListResponse
export const zGetAppsByAppIdCheckDependenciesPath = z.object({
app_id: z.string(),
})
/**
* Dependencies checked
*/
export const zGetAppsByAppIdCheckDependenciesResponse = zCheckDependenciesResult
export const zGetAppsByAppIdDescribePath = z.object({
app_id: z.string(),
})
@ -540,6 +650,20 @@ export const zGetAppsByAppIdDescribeQuery = z.object({
*/
export const zGetAppsByAppIdDescribeResponse = zAppDescribeResponse
export const zGetAppsByAppIdExportPath = z.object({
app_id: z.string(),
})
export const zGetAppsByAppIdExportQuery = z.object({
include_secret: z.boolean().optional().default(false),
workflow_id: z.string().optional(),
})
/**
* Export successful
*/
export const zGetAppsByAppIdExportResponse = zAppDslExportResponse
export const zPostAppsByAppIdFilesUploadPath = z.object({
app_id: z.string(),
})
@ -665,6 +789,27 @@ export const zGetWorkspacesByWorkspaceIdPath = z.object({
*/
export const zGetWorkspacesByWorkspaceIdResponse = zWorkspaceDetailResponse
export const zPostWorkspacesByWorkspaceIdAppsImportsBody = zAppDslImportPayload
export const zPostWorkspacesByWorkspaceIdAppsImportsPath = z.object({
workspace_id: z.string(),
})
/**
* Import completed
*/
export const zPostWorkspacesByWorkspaceIdAppsImportsResponse = zImport
export const zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath = z.object({
import_id: z.string(),
workspace_id: z.string(),
})
/**
* Import confirmed
*/
export const zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse = zImport
export const zGetWorkspacesByWorkspaceIdMembersPath = z.object({
workspace_id: z.string(),
})