mirror of
https://github.com/langgenius/dify.git
synced 2026-06-10 18:24:09 +08:00
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:
parent
534dd50d14
commit
0a051b598f
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
167
api/controllers/openapi/app_dsl.py
Normal file
167
api/controllers/openapi/app_dsl.py
Normal 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
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
113
cli/src/api/app-dsl.test.ts
Normal 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
59
cli/src/api/app-dsl.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
45
cli/src/commands/export/app/index.ts
Normal file
45
cli/src/commands/export/app/index.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
69
cli/src/commands/export/app/run.test.ts
Normal file
69
cli/src/commands/export/app/run.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
61
cli/src/commands/export/app/run.ts
Normal file
61
cli/src/commands/export/app/run.ts
Normal 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 }
|
||||
}
|
||||
60
cli/src/commands/import/app/index.ts
Normal file
60
cli/src/commands/import/app/index.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
188
cli/src/commands/import/app/run.test.ts
Normal file
188
cli/src/commands/import/app/run.test.ts
Normal 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>')
|
||||
})
|
||||
})
|
||||
138
cli/src/commands/import/app/run.ts
Normal file
138
cli/src/commands/import/app/run.ts
Normal 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>'
|
||||
}
|
||||
@ -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
1
cli/src/util/uuid.ts
Normal file
@ -0,0 +1 @@
|
||||
export const ZERO = '00000000-0000-0000-0000-000000000000'
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
196
cli/test/e2e/suites/dsl/export-app.e2e.ts
Normal file
196
cli/test/e2e/suites/dsl/export-app.e2e.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
])
|
||||
|
||||
9
cli/test/fixtures/dify-mock/scenarios.ts
vendored
9
cli/test/fixtures/dify-mock/scenarios.ts
vendored
@ -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',
|
||||
|
||||
40
cli/test/fixtures/dify-mock/server.ts
vendored
40
cli/test/fixtures/dify-mock/server.ts
vendored
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user