diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 4bd436418f..fc7ee94c91 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -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", diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index d01f023cbf..a80ab63b42 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -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//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//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//form/human_input/. `extra='forbid'` pins `additionalProperties: false` so the generated contract is an exact `{}` rather diff --git a/api/controllers/openapi/app_dsl.py b/api/controllers/openapi/app_dsl.py new file mode 100644 index 0000000000..8a8c62f28c --- /dev/null +++ b/api/controllers/openapi/app_dsl.py @@ -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//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//apps/imports//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//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/`` + 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//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 diff --git a/api/openapi/markdown/openapi-swagger.md b/api/openapi/markdown/openapi-swagger.md index f04f23027c..7214bcaa94 100644 --- a/api/openapi/markdown/openapi-swagger.md +++ b/api/openapi/markdown/openapi-swagger.md @@ -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//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//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
*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)
[Marketplace](#marketplace)
[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 | diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index e13ed35180..e69cff6a29 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -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 diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index 85378bd84d..ff74ca3039 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -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(), diff --git a/cli/ARD.md b/cli/ARD.md index 11284ae577..cc0e2f3d66 100644 --- a/cli/ARD.md +++ b/cli/ARD.md @@ -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 })) } diff --git a/cli/src/api/app-dsl.test.ts b/cli/src/api/app-dsl.test.ts new file mode 100644 index 0000000000..66d4507317 --- /dev/null +++ b/cli/src/api/app-dsl.test.ts @@ -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([]) + }) +}) diff --git a/cli/src/api/app-dsl.ts b/cli/src/api/app-dsl.ts new file mode 100644 index 0000000000..acb10a9535 --- /dev/null +++ b/cli/src/api/app-dsl.ts @@ -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 { + return this.orpc.workspaces.byWorkspaceId.apps.imports.post({ + params: { workspace_id: workspaceId }, + body: payload, + }) + } + + async confirmImport(workspaceId: string, importId: string): Promise { + return this.orpc.workspaces.byWorkspaceId.apps.imports.byImportId.confirm.post({ + params: { workspace_id: workspaceId, import_id: importId }, + }) + } + + async exportDsl(appId: string, query?: ExportQuery): Promise { + 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": ""}; the + // contract generator marks it as loose because the backend annotation + // does not narrow the shape. Extract `data` directly. + const data = (resp as Record).data + if (typeof data !== 'string') + throw new Error('export response missing data field') + return data + } + + async checkDependencies(appId: string): Promise { + return this.orpc.apps.byAppId.checkDependencies.get({ + params: { app_id: appId }, + }) + } +} diff --git a/cli/src/commands/export/app/index.ts b/cli/src/commands/export/app/index.ts new file mode 100644 index 0000000000..7afd023498 --- /dev/null +++ b/cli/src/commands/export/app/index.ts @@ -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 ', + '<%= config.bin %> export app --output ./my-app.yaml', + '<%= config.bin %> export app --include-secret', + '<%= config.bin %> export app --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') + } + } +} diff --git a/cli/src/commands/export/app/run.test.ts b/cli/src/commands/export/app/run.test.ts new file mode 100644 index 0000000000..a0c2cd8023 --- /dev/null +++ b/cli/src/commands/export/app/run.test.ts @@ -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) + }) +}) diff --git a/cli/src/commands/export/app/run.ts b/cli/src/commands/export/app/run.ts new file mode 100644 index 0000000000..93abe2f8b4 --- /dev/null +++ b/cli/src/commands/export/app/run.ts @@ -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 { + 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 } +} diff --git a/cli/src/commands/import/app/index.ts b/cli/src/commands/import/app/index.ts new file mode 100644 index 0000000000..fddda88f44 --- /dev/null +++ b/cli/src/commands/import/app/index.ts @@ -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 ', + ] + + 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`) + } + } +} diff --git a/cli/src/commands/import/app/run.test.ts b/cli/src/commands/import/app/run.test.ts new file mode 100644 index 0000000000..562f2a207d --- /dev/null +++ b/cli/src/commands/import/app/run.test.ts @@ -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('') + }) +}) diff --git a/cli/src/commands/import/app/run.ts b/cli/src/commands/import/app/run.ts new file mode 100644 index 0000000000..ad5bde2420 --- /dev/null +++ b/cli/src/commands/import/app/run.ts @@ -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 { + 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 + 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 ?? '' +} diff --git a/cli/src/commands/tree.generated.ts b/cli/src/commands/tree.generated.ts index 966fd5b56d..03963865e5 100644 --- a/cli/src/commands/tree.generated.ts +++ b/cli/src/commands/tree.generated.ts @@ -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: {} }, diff --git a/cli/src/util/uuid.ts b/cli/src/util/uuid.ts new file mode 100644 index 0000000000..bd4d88e161 --- /dev/null +++ b/cli/src/util/uuid.ts @@ -0,0 +1 @@ +export const ZERO = '00000000-0000-0000-0000-000000000000' diff --git a/cli/test/e2e/setup/global-setup.ts b/cli/test/e2e/setup/global-setup.ts index 51f961bc37..35e171ecaf 100644 --- a/cli/test/e2e/setup/global-setup.ts +++ b/cli/test/e2e/setup/global-setup.ts @@ -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 { 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> { 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 { + 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 { 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 { - 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 { 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 } diff --git a/cli/test/e2e/suites/dsl/export-app.e2e.ts b/cli/test/e2e/suites/dsl/export-app.e2e.ts new file mode 100644 index 0000000000..f96fbd216a --- /dev/null +++ b/cli/test/e2e/suites/dsl/export-app.e2e.ts @@ -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 }) + } + }) +}) diff --git a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts index a884a9fa96..d5ff63d87e 100644 --- a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts +++ b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts @@ -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 " 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) diff --git a/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts b/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts index e8deb72729..f5e6bfff62 100644 --- a/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts +++ b/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts @@ -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', ]) diff --git a/cli/test/fixtures/dify-mock/scenarios.ts b/cli/test/fixtures/dify-mock/scenarios.ts index 0fa5d6d33f..8ee839381c 100644 --- a/cli/test/fixtures/dify-mock/scenarios.ts +++ b/cli/test/fixtures/dify-mock/scenarios.ts @@ -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', diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index 064dad071d..afc135e532 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -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 | 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 | null } const TOKEN_RE = /^Bearer\s+dfo[ae]_[\w-]+$/ @@ -106,6 +108,7 @@ function hitlResumedResponse(): string { export type MockState = { lastRunBody: Record | null uploadCallCount: number + lastImportBody: Record | 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 + 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 { 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 { }, get lastRunBody() { return state.lastRunBody }, get uploadCallCount() { return state.uploadCallCount }, + get lastImportBody() { return state.lastImportBody }, }) }) server.on('error', reject) diff --git a/cli/vitest.e2e.config.ts b/cli/vitest.e2e.config.ts index 7aa0f401ba..d9e87ed7e1 100644 --- a/cli/vitest.e2e.config.ts +++ b/cli/vitest.e2e.config.ts @@ -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', diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts index f909cdffe8..15375c33c1 100644 --- a/packages/contracts/generated/api/openapi/orpc.gen.ts +++ b/packages/contracts/generated/api/openapi/orpc.gen.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, } diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index a20ede5b59..b957d0ad9f 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -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 +} + 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: { diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 0b6a950be5..2d632814d0 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -22,6 +22,42 @@ export const zAppDescribeQuery = z.object({ fields: z.string().optional(), }) +/** + * AppDslExportQuery + * + * Query parameters for GET /apps//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//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(), })