feat(cli): difyctl v2 over the server catalog (#42608)
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
This commit is contained in:
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -9,3 +9,6 @@
|
||||
# Codegen output must stay byte-identical across platforms so
|
||||
# `pnpm tree:check` in CI does not trip on CRLF rewrites.
|
||||
*.generated.ts text eol=lf
|
||||
|
||||
# Skill scripts run on every platform, so keep LF line endings.
|
||||
/skills/** text=auto eol=lf
|
||||
|
||||
1
.github/workflows/cli-edge.yml
vendored
1
.github/workflows/cli-edge.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'packages/contracts/generated/api/openapi/**'
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
5
.github/workflows/cli-release.yml
vendored
5
.github/workflows/cli-release.yml
vendored
@@ -77,11 +77,6 @@ jobs:
|
||||
echo "dify_tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::target Dify release ${tag}"
|
||||
|
||||
- name: Compatibility check
|
||||
env:
|
||||
DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }}
|
||||
run: node scripts/release-naming.mjs compat-check "$DIFY_TAG"
|
||||
|
||||
release:
|
||||
name: build + attach standalone binaries (all targets)
|
||||
needs: validate
|
||||
|
||||
2
.github/workflows/cli-smoke.yml
vendored
2
.github/workflows/cli-smoke.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Run smoke against live dify
|
||||
working-directory: ./cli
|
||||
run: pnpm exec node scripts/run-smoke.ts --base-url http://localhost:5001
|
||||
run: DIFY_SERVER=http://localhost:5001 bun scripts/run-smoke.ts
|
||||
|
||||
- name: Dump dify logs on failure
|
||||
if: failure()
|
||||
|
||||
2
.github/workflows/main-ci.yml
vendored
2
.github/workflows/main-ci.yml
vendored
@@ -80,6 +80,7 @@ jobs:
|
||||
- 'docker/volumes/sandbox/conf/**'
|
||||
cli:
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'packages/contracts/**'
|
||||
- 'packages/tsconfig/**'
|
||||
- 'package.json'
|
||||
@@ -191,6 +192,7 @@ jobs:
|
||||
ts-common-style:
|
||||
- 'web/**'
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'e2e/**'
|
||||
- 'sdks/nodejs-client/**'
|
||||
- 'packages/**'
|
||||
|
||||
@@ -345,7 +345,7 @@ class RunPayloadBase(BaseModel):
|
||||
inputs: dict[str, Any] = Field(
|
||||
description=(
|
||||
"Variables declared by the app. The exact shape is per app: read `input_schema` from "
|
||||
"console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, "
|
||||
"describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, "
|
||||
"or a local file in `files`, not both."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ from services.entities.account_entities import AccountSnapshot
|
||||
@openapi_ns.route("/account")
|
||||
class AccountApi(Resource):
|
||||
@endpoint(
|
||||
op="account.get",
|
||||
op="describe.account",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Current account",
|
||||
examples=(Example(title="Show the logged-in account and its workspaces", input={}),),
|
||||
@@ -61,7 +61,7 @@ class AccountApi(Resource):
|
||||
@openapi_ns.route("/account/sessions/self")
|
||||
class AccountSessionsSelfApi(Resource):
|
||||
@endpoint(
|
||||
op="account.sessions.revoke_current",
|
||||
op="delete.account.current_session",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Revoke the session behind this token",
|
||||
examples=(Example(title="Log out the session behind this token", input={}),),
|
||||
@@ -76,7 +76,7 @@ class AccountSessionsSelfApi(Resource):
|
||||
@openapi_ns.route("/account/sessions")
|
||||
class AccountSessionsApi(Resource):
|
||||
@endpoint(
|
||||
op="account.sessions.list",
|
||||
op="get.account.session",
|
||||
kind=Kind.LIST,
|
||||
summary="List login sessions of the current account",
|
||||
examples=(Example(title="List login sessions, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -102,7 +102,7 @@ class AccountSessionsApi(Resource):
|
||||
@openapi_ns.route("/account/sessions/<string:session_id>")
|
||||
class AccountSessionByIdApi(Resource):
|
||||
@endpoint(
|
||||
op="account.sessions.revoke",
|
||||
op="delete.account.session",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Revoke one login session by id",
|
||||
examples=(Example(title="Log out one device by session id", input={"session_id": "<session_id>"}),),
|
||||
|
||||
@@ -66,7 +66,7 @@ class AppDslImportApi(Resource):
|
||||
|
||||
@endpoint(
|
||||
account_context=True,
|
||||
op="console_app.dsl.import",
|
||||
op="import.console_app.dsl",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Import an app from DSL text or URL",
|
||||
examples=(
|
||||
@@ -129,7 +129,7 @@ class AppDslImportConfirmApi(Resource):
|
||||
|
||||
@endpoint(
|
||||
account_context=True,
|
||||
op="console_app.dsl.import_confirm",
|
||||
op="confirm.console_app.dsl_import",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Confirm a pending DSL import",
|
||||
examples=(Example(title="Confirm an import that is pending confirmation", input={"import_id": "<import_id>"}),),
|
||||
@@ -167,7 +167,7 @@ class AppDslExportApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="console_app.dsl.export",
|
||||
op="export.console_app.dsl",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Export app DSL as YAML text inside a JSON object",
|
||||
examples=(
|
||||
@@ -213,7 +213,7 @@ class AppDslCheckDependenciesApi(Resource):
|
||||
|
||||
@endpoint(
|
||||
account_context=True,
|
||||
op="console_app.dependencies.check",
|
||||
op="check.console_app.dependency",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Check plugin dependencies of an app",
|
||||
examples=(Example(title="Check which plugins an app needs", input={"app_id": "<app_id>"}),),
|
||||
|
||||
@@ -237,7 +237,7 @@ _RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="WorkflowRunApi",
|
||||
segment="workflow",
|
||||
op="console_app.workflow.run",
|
||||
op="run.console_app.workflow",
|
||||
summary="Run a workflow app; streams workflow events",
|
||||
payload=WorkflowRunPayload,
|
||||
modes=(AppMode.WORKFLOW,),
|
||||
@@ -260,7 +260,7 @@ _RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="ChatRunApi",
|
||||
segment="chat",
|
||||
op="console_app.chat.run",
|
||||
op="run.console_app.chat",
|
||||
summary="Run a chat or agent app; streams message events",
|
||||
payload=ChatRunPayload,
|
||||
modes=(AppMode.CHAT, AppMode.AGENT_CHAT),
|
||||
@@ -293,7 +293,7 @@ _RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="AdvancedChatRunApi",
|
||||
segment="advanced-chat",
|
||||
op="console_app.advanced_chat.run",
|
||||
op="run.console_app.advanced_chat",
|
||||
summary="Run an advanced-chat (chatflow) app; streams message and workflow events",
|
||||
payload=AdvancedChatRunPayload,
|
||||
modes=(AppMode.ADVANCED_CHAT,),
|
||||
@@ -317,7 +317,7 @@ _RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="CompletionRunApi",
|
||||
segment="completion",
|
||||
op="console_app.completion.run",
|
||||
op="run.console_app.completion",
|
||||
summary="Run a completion app; streams message events",
|
||||
payload=CompletionRunPayload,
|
||||
modes=(AppMode.COMPLETION,),
|
||||
@@ -362,7 +362,7 @@ WorkflowRunApi, ChatRunApi, AdvancedChatRunApi, CompletionRunApi = (_run_api(rou
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>:stop")
|
||||
class AppRunTaskStopApi(Resource):
|
||||
@endpoint(
|
||||
op="run.stop",
|
||||
op="stop.run",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Stop a running task",
|
||||
examples=(Example(title="Stop a running task", input={"app_id": "<app_id>", "task_id": "<task_id>"}),),
|
||||
|
||||
@@ -105,7 +105,7 @@ def build_app_describe_response(app: App, fields: set[str] | None, *, session: S
|
||||
@openapi_ns.route("/apps/<string:app_id>")
|
||||
class AppDescribeApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.describe",
|
||||
op="describe.console_app",
|
||||
kind=Kind.OBJECT,
|
||||
summary="App detail, parameters and runtime input_schema",
|
||||
examples=(
|
||||
@@ -133,7 +133,7 @@ class AppDescribeApi(Resource):
|
||||
@openapi_ns.route("/apps")
|
||||
class AppListApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.list",
|
||||
op="get.console_app",
|
||||
kind=Kind.LIST,
|
||||
summary="List apps in a workspace",
|
||||
examples=(
|
||||
|
||||
@@ -43,7 +43,7 @@ _ENTERPRISE_ONLY = frozenset({DeploymentEdition.ENTERPRISE})
|
||||
@openapi_ns.route("/permitted-external-apps")
|
||||
class PermittedExternalAppsListApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.external.list",
|
||||
op="get.console_app.external",
|
||||
kind=Kind.LIST,
|
||||
summary="List apps an external SSO subject may run",
|
||||
examples=(Example(title="List the apps this SSO subject may run, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -101,7 +101,7 @@ class PermittedExternalAppsListApi(Resource):
|
||||
@openapi_ns.route("/permitted-external-apps/<string:app_id>")
|
||||
class PermittedExternalAppDescribeApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.external.describe",
|
||||
op="describe.console_app.external",
|
||||
kind=Kind.OBJECT,
|
||||
summary="External-subject app detail",
|
||||
examples=(Example(title="Describe a permitted app with its input_schema", input={"app_id": "<app_id>"}),),
|
||||
|
||||
@@ -35,7 +35,7 @@ class AppFileUploadApi(Resource):
|
||||
}
|
||||
)
|
||||
@endpoint(
|
||||
op="console_app.file.upload",
|
||||
op="upload.console_app.file",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Upload a file and get a file id for later runs",
|
||||
examples=(
|
||||
|
||||
@@ -92,7 +92,7 @@ def _ensure_form_belongs_to_app(form, app_model: App) -> None:
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>")
|
||||
class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@endpoint(
|
||||
op="run.form.get",
|
||||
op="describe.run.form",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Read a human-input form",
|
||||
examples=(
|
||||
@@ -126,7 +126,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>:submit")
|
||||
class OpenApiWorkflowHumanInputFormSubmitApi(Resource):
|
||||
@endpoint(
|
||||
op="run.form.submit",
|
||||
op="submit.run.form",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Submit a human-input form",
|
||||
examples=(
|
||||
|
||||
@@ -53,7 +53,7 @@ class WorkflowEventsQuery(BaseModel):
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>/events")
|
||||
class OpenApiWorkflowEventsApi(Resource):
|
||||
@endpoint(
|
||||
op="run.events",
|
||||
op="get.run.event",
|
||||
kind=Kind.SSE,
|
||||
summary="Stream the events of a workflow run",
|
||||
examples=(
|
||||
|
||||
@@ -89,7 +89,7 @@ def _check_member_invite_quota(tenant_id: str) -> None:
|
||||
@openapi_ns.route("/workspaces")
|
||||
class WorkspacesApi(Resource):
|
||||
@endpoint(
|
||||
op="workspace.list",
|
||||
op="get.workspace",
|
||||
kind=Kind.LIST,
|
||||
summary="List workspaces of the current account",
|
||||
examples=(Example(title="List my workspaces, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -105,7 +105,7 @@ class WorkspacesApi(Resource):
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>")
|
||||
class WorkspaceByIdApi(Resource):
|
||||
@endpoint(
|
||||
op="workspace.get",
|
||||
op="describe.workspace",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Workspace detail",
|
||||
examples=(
|
||||
@@ -134,7 +134,7 @@ class WorkspaceSwitchApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="workspace.switch",
|
||||
op="switch.workspace",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Server-side current workspace switch (shared with the web console)",
|
||||
examples=(Example(title="Make a workspace current on the server", input={"workspace_id": "<workspace_id>"}),),
|
||||
@@ -168,7 +168,7 @@ class WorkspaceMembersApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.list",
|
||||
op="get.workspace.member",
|
||||
kind=Kind.LIST,
|
||||
summary="List workspace members",
|
||||
examples=(Example(title="List members of the pinned workspace, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -185,7 +185,7 @@ class WorkspaceMembersApi(Resource):
|
||||
return MemberListResponse.page_of([_member_response(m) for m in members], query=query)
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.invite",
|
||||
op="invite.workspace.member",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Invite a member by email",
|
||||
examples=(
|
||||
@@ -252,7 +252,7 @@ class WorkspaceMemberApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.remove",
|
||||
op="delete.workspace.member",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Remove a member",
|
||||
examples=(Example(title="Remove a member by account id", input={"member_id": "<member_id>"}),),
|
||||
@@ -282,7 +282,7 @@ class WorkspaceMemberApi(Resource):
|
||||
return MemberActionResponse()
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.set_role",
|
||||
op="set.workspace.member_role",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Change a member's role",
|
||||
examples=(
|
||||
|
||||
@@ -25,7 +25,7 @@ class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC):
|
||||
def convert(
|
||||
cls, response: Union[AppBlockingResponse, Generator[AppStreamResponse, Any, None]], invoke_from: InvokeFrom
|
||||
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], Any, None]:
|
||||
if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.SERVICE_API}:
|
||||
if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.SERVICE_API, InvokeFrom.OPENAPI}:
|
||||
if isinstance(response, AppBlockingResponse):
|
||||
return cls.convert_blocking_full_response(cls._cast_blocking_response(response))
|
||||
else:
|
||||
|
||||
@@ -619,7 +619,7 @@ A chat run against an advanced-chat (chatflow) app, which can also pin a workflo
|
||||
| auto_generate_name | boolean, <br>**Default:** true | Let the server name a new conversation | No |
|
||||
| conversation_id | string | Continue an existing conversation | No |
|
||||
| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable | No |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| query | string | User message | Yes |
|
||||
| workflow_id | string | Pin a published workflow version | No |
|
||||
| workspace_id | string | Workspace that owns the app | No |
|
||||
@@ -762,7 +762,7 @@ mode is a closed enum of listable app types.
|
||||
| auto_generate_name | boolean, <br>**Default:** true | Let the server name a new conversation | No |
|
||||
| conversation_id | string | Continue an existing conversation | No |
|
||||
| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable | No |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| query | string | User message | Yes |
|
||||
| workspace_id | string | Workspace that owns the app | No |
|
||||
|
||||
@@ -778,7 +778,7 @@ mode is a closed enum of listable app types.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No |
|
||||
| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable | No |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| query | string | Prompt text; most completion apps take their input through `inputs` | No |
|
||||
| workspace_id | string | Workspace that owns the app | No |
|
||||
|
||||
@@ -1242,7 +1242,7 @@ types it as a required `'success'` rather than an optional field.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No |
|
||||
| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable | No |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| workflow_id | string | Pin a published workflow version | No |
|
||||
| workspace_id | string | Workspace that owns the app | No |
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ def test_chat_route_hints_the_reply_on_message_end(app: Flask, monkeypatch: pyte
|
||||
assert json.loads(chunks[1][len("data: ") :])["hints"] == [
|
||||
{
|
||||
"summary": "Reply in this conversation",
|
||||
"op": "console_app.chat.run",
|
||||
"op": "run.console_app.chat",
|
||||
"input": {"app_id": _TEST_APP_ID, "conversation_id": "c1", "query": None, "inputs": {}},
|
||||
}
|
||||
]
|
||||
|
||||
@@ -243,9 +243,9 @@ ADMIT_NO_MOUNT = Expect(
|
||||
_RUN_TRAITS = frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE})
|
||||
|
||||
ROUTES: tuple[Route, ...] = (
|
||||
Route("account.get", "GET", "/account", frozenset({Trait.ACCOUNT_PRIMARY})),
|
||||
Route("describe.account", "GET", "/account", frozenset({Trait.ACCOUNT_PRIMARY})),
|
||||
Route("account.sessions.revoke_self", "DELETE", "/account/sessions/self", frozenset({Trait.ACCOUNT_PRIMARY})),
|
||||
Route("account.sessions.list", "GET", "/account/sessions", frozenset({Trait.ACCOUNT_PRIMARY})),
|
||||
Route("get.account.session", "GET", "/account/sessions", frozenset({Trait.ACCOUNT_PRIMARY})),
|
||||
Route(
|
||||
"account.sessions.revoke_one",
|
||||
"DELETE",
|
||||
@@ -491,7 +491,7 @@ SCENARIOS: dict[Case, Scenario] = {
|
||||
}
|
||||
|
||||
|
||||
ROUTER_CASE_ROUTE = "account.get"
|
||||
ROUTER_CASE_ROUTE = "describe.account"
|
||||
ROUTER_CASES: dict[Case, Expect] = {
|
||||
Case.NO_BEARER: DENY_NO_BEARER,
|
||||
Case.LICENSE_INVALID: ADMIT_NO_LICENCE_GATE,
|
||||
@@ -564,7 +564,7 @@ _DUAL_SUBJECT_RUN: dict[Case, Expect] = {
|
||||
|
||||
|
||||
MATRIX: dict[str, dict[Case, Expect]] = {
|
||||
"account.get": dict(_ACCOUNT_ONLY_NO_WORKSPACE),
|
||||
"describe.account": dict(_ACCOUNT_ONLY_NO_WORKSPACE),
|
||||
"workspaces.list": dict(_ACCOUNT_ONLY_NO_WORKSPACE),
|
||||
"apps.list": dict(_ACCOUNT_MEMBER_NO_ROLE),
|
||||
"workspaces.switch": dict(_ACCOUNT_MEMBER_NO_ROLE),
|
||||
@@ -735,9 +735,9 @@ _REQ_EXTERNAL_DESCRIBE = (
|
||||
)
|
||||
|
||||
DECLARED: dict[str, tuple[Requirement, ...]] = {
|
||||
"account.get": _REQ_ACCOUNT_FULL,
|
||||
"describe.account": _REQ_ACCOUNT_FULL,
|
||||
"account.sessions.revoke_self": _REQ_ACCOUNT_FULL,
|
||||
"account.sessions.list": _REQ_ACCOUNT_FULL,
|
||||
"get.account.session": _REQ_ACCOUNT_FULL,
|
||||
"account.sessions.revoke_one": _REQ_ACCOUNT_FULL,
|
||||
"apps.describe": _REQ_APP_DESCRIBE,
|
||||
"apps.list": _REQ_ACCOUNT_APPS_READ_MEMBER,
|
||||
|
||||
@@ -92,12 +92,12 @@ def test_every_guarded_route_declares_catalog_meta_once(app: Flask, ops: dict[st
|
||||
streaming.add(meta.op)
|
||||
assert meta.kind is Kind.SSE, meta.op
|
||||
assert set(ops) <= set(seen)
|
||||
per_mode = {f"console_app.{mode}.run" for mode in ("workflow", "chat", "advanced_chat", "completion")}
|
||||
assert streaming == per_mode | {"run.events"}
|
||||
per_mode = {f"run.console_app.{mode}" for mode in ("workflow", "chat", "advanced_chat", "completion")}
|
||||
assert streaming == per_mode | {"get.run.event"}
|
||||
|
||||
|
||||
def test_run_entries_carry_path_bind_kind_and_flags(ops: dict[str, CatalogOp]) -> None:
|
||||
chat = ops["console_app.chat.run"]
|
||||
chat = ops["run.console_app.chat"]
|
||||
assert set(chat) == {"summary", "method", "path", "kind", "input", "bind", "internal", "deprecated", "examples"}
|
||||
assert (chat["method"], chat["path"], chat["kind"]) == (
|
||||
"POST",
|
||||
@@ -113,10 +113,10 @@ def test_run_entries_carry_path_bind_kind_and_flags(ops: dict[str, CatalogOp]) -
|
||||
"attachments": "file",
|
||||
}
|
||||
assert chat["deprecated"] is False
|
||||
assert ops["console_app.file.upload"]["bind"]["file"] == "file"
|
||||
assert ops["console_app.list"]["bind"]["page"] == "query"
|
||||
assert ops["run.events"]["bind"]["continue_on_pause"] == "query"
|
||||
assert ops["workspace.switch"]["internal"] is True
|
||||
assert ops["upload.console_app.file"]["bind"]["file"] == "file"
|
||||
assert ops["get.console_app"]["bind"]["page"] == "query"
|
||||
assert ops["get.run.event"]["bind"]["continue_on_pause"] == "query"
|
||||
assert ops["switch.workspace"]["internal"] is True
|
||||
|
||||
|
||||
def test_input_schemas_are_flat_shallow_and_described(ops: dict[str, CatalogOp]) -> None:
|
||||
@@ -140,8 +140,8 @@ def test_input_schemas_are_flat_shallow_and_described(ops: dict[str, CatalogOp])
|
||||
op for op, e in ops.items() if e["kind"] == "list" and not {"page", "limit"} <= set(e["input"]["properties"])
|
||||
] == []
|
||||
assert [op for op, e in ops.items() if set(Hinted.model_fields) & set(e["input"]["properties"])] == []
|
||||
desc = ops["console_app.chat.run"]["input"]["properties"]["inputs"]["description"]
|
||||
assert "console_app.describe" in desc
|
||||
desc = ops["run.console_app.chat"]["input"]["properties"]["inputs"]["description"]
|
||||
assert "describe.console_app" in desc
|
||||
assert "input_schema" in desc
|
||||
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ class TestFormHints:
|
||||
[out] = list(with_form_hints(iter([f"data: {json.dumps(event)}\n\n"]), app_id="a1"))
|
||||
body = json.loads(out[len("data: ") :])
|
||||
op = op_of(OpenApiWorkflowHumanInputFormSubmitApi.post)
|
||||
assert op == "run.form.submit"
|
||||
assert op == "submit.run.form"
|
||||
assert body["hints"] == [
|
||||
{
|
||||
"summary": "批准",
|
||||
|
||||
@@ -383,7 +383,7 @@ def test_members_list_next_page_hint_reaches_the_wire(admitted_bearer: AdmittedW
|
||||
assert body["hints"] == [
|
||||
{
|
||||
"summary": "Next page",
|
||||
"op": "workspace.members.list",
|
||||
"op": "get.workspace.member",
|
||||
"input": {"workspace_id": admitted_bearer.workspace_id, "page": 2, "limit": 2},
|
||||
"form": None,
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ This package is the ESM TypeScript implementation of `difyctl`. Use the Node.js
|
||||
|
||||
## Architecture Boundaries
|
||||
|
||||
- Every leaf command extends `DifyCommand`; command classes own framework parsing and delegate behavior to domain modules.
|
||||
- Each command folder keeps its framework shell in `index.ts`. Extract behavior into sibling modules such as `run.ts` and `handlers.ts` when it needs an independently testable owner; those modules receive typed dependencies and do not import `src/framework/`.
|
||||
- `src/http/` owns ky middleware and client construction; `src/api/` owns resource clients; `src/sys/io/` owns process streams and progress UI; `src/types/` remains a pure data and schema leaf.
|
||||
- Preserve flags, output, and exit codes during refactors. Do not add dependencies or compatibility shims unless the task explicitly requires them.
|
||||
- `ARD.md` owns CLI code structure. Keep wire behavior aligned with typed API clients and the real mock-server behavior tests.
|
||||
- Every command extends `Command` (`src/plugins/commands/command.ts`) and declares no `needs` of its own; it reads whatever plugin services it uses directly through `ctx.get(...)` inside `run(input, ctx)`.
|
||||
- Plugins under `src/plugins/` (`argv global-flags env config session token catalog http ops io commands`) own state. A plugin's `build()` closes over its own cache or loaded document; nothing outside a plugin reaches into another plugin's internals. Outside a plugin folder, import only `@/plugins/<name>` and use the service from `ctx.get(...)`; the command framework (`plugins/commands/command.ts`, `cancel.ts`, `registry.ts`) and the argv parser (`plugins/argv/parse.ts`) are the only modules imported directly.
|
||||
- `src/protocol/` and `src/call/` are pure modules: catalog-shape logic (kinds, bind, pins, fold) and the `call` flag table, request-building and rendering, with no op-specific branching anywhere under `src/call/` — kind and bind dispatch go through the tables in `src/protocol/` and `src/call/render/`, keyed by the catalog's own `kind`/`bind` values, never by operation id.
|
||||
- Preserve exit codes and the JSON output contract during refactors. Do not add dependencies or compatibility shims unless the task explicitly requires them.
|
||||
- `ARD.md` owns CLI code structure. Keep behavior aligned with the real mock-server (`test/fixtures/dify-mock/`) behavior tests.
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
268
cli/ARD.md
268
cli/ARD.md
@@ -4,90 +4,62 @@ Onboarding ref for `dify/cli/` contributors. Cover canonical patterns, layer con
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
commands/ one folder per command leaf
|
||||
api/ HTTP client wrappers (one file per resource)
|
||||
auth/ hosts.yml read/write
|
||||
cache/ app-info cache
|
||||
config/ config.yml read/write
|
||||
errors/ BaseError, ErrorCode, exit codes
|
||||
http/ ky client factory + middleware
|
||||
sys/io/ IOStreams, prompts, spinner, output rendering
|
||||
limit/ --limit flag parsing
|
||||
types/ shared TypeScript types
|
||||
util/ small pure helpers
|
||||
workspace/ workspace ID resolution
|
||||
kernel/ plugin + Context primitives — no CLI knowledge
|
||||
plugins/ argv global-flags env config session token catalog http ops output io commands — the 12 services, one folder each; index.ts is the plugin and the only file anything outside the folder imports
|
||||
argv/ the invocation's tokens as a context input, plus the argv parser (parse.ts, framework shared by global-flags and commands)
|
||||
global-flags/ flags any command accepts (`--verbose`, `--json`), parsed off argv before the command is resolved
|
||||
catalog/ the cached catalog document: types, parseCatalog, op lookup
|
||||
http/ the authenticated fetch wrapper with the catalog fingerprint header; refetches on 412, and on 404 when the catalog moved
|
||||
ops/ the catalog as a command tree (refetched on request); command.ts adapts one op to a Command, tree.ts builds their command tree
|
||||
output/ output mode per stream: text on a terminal, JSON in a pipe; `--json` or `DIFY_OUTPUT` forces one
|
||||
io/ the one way anything is printed
|
||||
commands/ command pipeline plus the command framework (command.ts, cancel.ts, registry.ts) every command imports; help.ts shapes the help views, text.ts renders them for a terminal
|
||||
protocol/ pure catalog-shape logic: kinds, bind, pins, fold, shape (a JSON Schema as one of ten shapes), op-id (dotted id and its spaced words) — no I/O
|
||||
discovery/ pure id tree and ranked search over commands and ops — no I/O
|
||||
call/ the `call` flag table; JSON and SSE response decoding; builds a request from a resolved op + validated input; renders the response by kind
|
||||
commands/ one folder per command leaf; index.ts is the only file the registry discovers
|
||||
net/ fetch init and proxy dispatcher, shared by the http plugin and the pre-login device flow
|
||||
store/ config/session/token file and keychain persistence
|
||||
sys/ the process edges — env, argv, exit, platform paths; io/ holds streams.ts, color.ts and view.ts (a JSON value paired with its text form, the mode picks one)
|
||||
skills/ reads skills from a GitHub folder or a local folder, and installs them
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kernel
|
||||
|
||||
A plugin is `{ name, needs, build(ctx) }`. `needs` is a list of the other plugin objects it reads through `ctx.get(...)` — an array of the actual plugin values, not strings or classes, so a plugin literally names its dependencies and TypeScript checks `build`'s `ctx` against that list (`src/kernel/plugin.ts`).
|
||||
|
||||
`Context` (`src/kernel/context.ts`) builds services lazily: `ctx.get(plugin)` runs `plugin.build(ctx)` the first time that plugin is requested, caches the result, and returns the cached service on every later call in the same invocation. A plugin never builds if nothing asks for it. `needs` is only the compile-time allow-list for that plugin's `ctx.get`; the kernel never walks it, so declaring `needs: BASE_PLUGINS` on the `commands` plugin costs nothing until a command actually calls `ctx.get` on one of them (`version` logged out builds six plugins, never `http`).
|
||||
|
||||
State stays with its owner. A plugin's `build()` closes over its own state (an in-memory cache, a loaded config document); nothing outside reaches into it — code that needs it calls `ctx.get(thatPlugin)` again and gets the same cached instance.
|
||||
|
||||
`ctx.defer(fn)` queues a cleanup to run after the command finishes (for example, aborting a streaming call's `AbortController` on `SIGINT`), instead of scattering `try`/`finally` through command bodies.
|
||||
|
||||
There is no event bus and no listener registration. Plugins that need to react to something call `ctx.get` on the plugin that has it; there is no subscribe/publish path to keep in sync.
|
||||
|
||||
The one `commands` plugin (`src/plugins/commands/index.ts`) needs `BASE_PLUGINS` (`global-flags env config session token catalog http ops io`) and its `build()` returns `{ run }`. `runPipeline` takes the argv left over by `global-flags`, resolves the typed `Command` from the generated tree, parses those tokens against that command's Zod `input` schema, and calls `run(input, ctx)` — the pipeline never branches on which command it is.
|
||||
|
||||
A plugin's folder is private. Code outside it imports only the plugin's `index.ts` (`@/plugins/<name>`) and reaches the plugin's behaviour through `ctx.get(plugin)`; a helper two plugins share is exposed on a service, and a helper only commands use lives outside `src/plugins/`. The two exceptions are contracts rather than services: the command framework in `plugins/commands/` (`Command`, `Outcome`, `runSignal`, the tree types) and the argv parser in `plugins/argv/parse.ts`.
|
||||
|
||||
Global flags are one Zod object in `src/plugins/global-flags/index.ts`. The plugin pulls the flags it knows out of the `argv` input, exposes their values as `flags` and the remaining tokens as `rest`; any plugin or command reads a global flag through `ctx.get(globalFlags)`, never by scanning argv. Which flag wins over an env var or a config key is decided in the plugin that owns the setting, not here.
|
||||
|
||||
Help is the row: `commandRow(Ctor, path)` (`src/plugins/commands/describe.ts`) is the one function that renders a command's `--help` output and its entry in `help --full` — there is no separate help-text template. Catalog operations are commands in the same tree, so `entriesOf(tree)` in `plugins/commands/help.ts` feeds the map, namespace listings and search (`src/discovery/`) from one list; words no command claims go to `discover` in the pipeline. `plugins/commands/text.ts` renders the same values for a terminal through a section table.
|
||||
|
||||
Tests substitute one plugin's service directly: `new Context([[plugin, mockService]])` seeds the cache so `ctx.get(plugin)` returns the mock without building the real one.
|
||||
|
||||
---
|
||||
|
||||
## New command scaffold
|
||||
|
||||
Recipe for adding command leaf. Follow order.
|
||||
|
||||
**1. Create folder**
|
||||
|
||||
```
|
||||
src/commands/<topic>/<verb>/
|
||||
```
|
||||
|
||||
Examples: `get/app/`, `auth/devices/revoke/`, `describe/app/`.
|
||||
|
||||
**2. Mandatory file**
|
||||
|
||||
| File | Responsibility |
|
||||
| ---------- | ------------------------------------------------------------------------------------ |
|
||||
| `index.ts` | `DifyCommand` subclass. Owns flag/arg parsing, framework output, and command wiring. |
|
||||
|
||||
**3. Optional files — add as needed**
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------ | ------------------------------------------------------------------ |
|
||||
| `run.ts` | Typed behavior owner when logic merits independent tests or reuse |
|
||||
| `handlers.ts` | Output types implementing `FormattedPrintable` or `TablePrintable` |
|
||||
| `payload-shape.ts` | Response type narrowing/transformation |
|
||||
| `run.test.ts` | Behavior tests against `run.ts` |
|
||||
| `guide.ts` | Agent onboarding text — exports `agentGuide` string |
|
||||
|
||||
**4. Checklist**
|
||||
|
||||
- [ ] `index.ts` extends `DifyCommand`
|
||||
- [ ] Authed command calls `this.authedCtx()`; non-authed skips
|
||||
- [ ] Let the command boundary handle `BaseError`; catch only when the command owns recovery
|
||||
- [ ] Keep framework parsing and output construction in `index.ts`
|
||||
- [ ] When present, `run.ts` returns typed behavior data or owns explicit streaming/interactive I/O and does not import `src/framework/`
|
||||
- [ ] HTTP client via factory dep, not direct
|
||||
- [ ] Add focused behavior tests when the command changes an observable contract
|
||||
- [ ] `pnpm tree:gen` run after adding command (updates `src/commands/tree.generated.ts`)
|
||||
- [ ] README command table updated by hand
|
||||
|
||||
---
|
||||
|
||||
## DifyCommand base class
|
||||
|
||||
All commands extend `DifyCommand`, not `Command`.
|
||||
|
||||
```typescript
|
||||
export default class MyCommand extends DifyCommand {
|
||||
async run(argv: string[]) {
|
||||
const { args, flags } = this.parse(MyCommand, argv)
|
||||
|
||||
const ctx = await this.authedCtx({ retryFlag: undefined, format: flags.output })
|
||||
const result = await runMyThing(
|
||||
{ id: args.id },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format: flags.output, data: result.data })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`authedCtx(opts)`** — wraps `buildAuthedContext` and returns the authenticated registry, account, HTTP, I/O, and optional cache dependencies. Pass the selected output format so authentication failures use the same serialization contract. Required for commands that need a bearer token.
|
||||
|
||||
The framework runner in `src/framework/run.ts` catches command errors, normalizes unknown failures, and serializes `BaseError` according to the selected output format. Catch inside a command only when that command owns a real recovery path.
|
||||
1. Create the folder: `src/commands/<topic>/<verb>/` (or `src/commands/<verb>/` for a top-level command like `login`).
|
||||
2. Write `index.ts`: a default-exported class extending `Command<typeof INPUT>` with static `summary`, `effect` (`'read' | 'write' | 'destructive'`, default `'read'`), `input` (a Zod object), optional `positional` and `examples`, and an instance `run(input, ctx)` that returns the value to print — or an `Outcome` when the command needs a non-zero exit or has already written its own output.
|
||||
3. Run `pnpm tree:gen` to add the command to `src/commands/tree.generated.ts`.
|
||||
4. Run `pnpm tree:check` (also gated in CI) to confirm the generated tree matches the command folders.
|
||||
|
||||
---
|
||||
|
||||
@@ -106,127 +78,48 @@ throw new BaseError({
|
||||
})
|
||||
```
|
||||
|
||||
`ErrorCode` is the exhaustive error-code object; do not scatter raw code strings. `exitFor(code)` maps it to a process exit code, and the framework runner calls `formatErrorForCli` so JSON/YAML consumers receive machine-readable errors.
|
||||
`ErrorCode` is the exhaustive error-code object; do not scatter raw code strings. `exitFor(code)` maps it to a process exit code, and `BaseError.toEnvelope()` serializes it as the one JSON error line on stderr.
|
||||
|
||||
| Exit | Meaning |
|
||||
| ---- | ----------------------------------------- |
|
||||
| ---- | ------------------------------------------------ |
|
||||
| 0 | Success |
|
||||
| 1 | Generic error |
|
||||
| 2 | Usage error (bad flag, missing arg) |
|
||||
| 4 | Auth error (not logged in, token expired) |
|
||||
| 6 | Version/compat error |
|
||||
| 2 | Usage error (bad flag, missing or invalid input) |
|
||||
| 4 | Auth error (not logged in, forbidden) |
|
||||
| 6 | Catalog error (unavailable, or unknown op id) |
|
||||
| 7 | Rate limited |
|
||||
|
||||
New error code: add to `ErrorCode` + map to `ExitCode` in `codes.ts`. Never scatter exit codes inline.
|
||||
New error code: add to `ErrorCode` and map it to an `ExitCode` in `codes.ts`. Never scatter exit codes inline.
|
||||
|
||||
---
|
||||
|
||||
## IOStreams
|
||||
## Output
|
||||
|
||||
I/O context passed through every layer. Carries stdout, stderr, stdin, TTY flags, `outputFormat`.
|
||||
Everything the CLI prints goes through the `io` plugin's service, never through a stream directly. Three writes cover every case; a new output format or a `--quiet` flag is a change inside this one plugin.
|
||||
|
||||
```typescript
|
||||
export type IOStreams = {
|
||||
out: NodeJS.WritableStream
|
||||
err: NodeJS.WritableStream
|
||||
in: NodeJS.ReadableStream
|
||||
isOutTTY: boolean
|
||||
isErrTTY: boolean
|
||||
outputFormat: string // 'json' | 'yaml' | 'name' | 'wide' | ''
|
||||
}
|
||||
export type IOService = Readonly<{
|
||||
document: (value: Printable | View) => Promise<void> // stdout: results, help
|
||||
emit: (value: Printable | View, channel: ChannelValue) => Promise<void> // either stream
|
||||
notice: (text: string) => void // one text line on stderr: warnings, progress
|
||||
raw: (chunk: string | Buffer) => Promise<void> // bytes as given: a text-kind body
|
||||
streams: IOStreams // stdin and the TTY flags
|
||||
}>
|
||||
```
|
||||
|
||||
| Factory | When |
|
||||
| --------------------- | --------------------------------- |
|
||||
| `realStreams(format)` | Production — wraps `process.std*` |
|
||||
| `bufferStreams()` | Tests — captures output in memory |
|
||||
| `nullStreams()` | When IO irrelevant |
|
||||
`Printable` (a JSON primitive, an array, or a plain object) is what a command may return and what `document` accepts. A `View` (`sys/io/view.ts`) is a `Printable` plus its text form; help and errors pass one, and the stream's mode picks which is written. Results and streams are JSON in every mode. Streamed events go through `raw`, one per line. stdout writes use the callback form so a closed pipe reaches `printEnvelope` as a rejection and the run ends at exit 0. `printEnvelope` emits the envelope through the same service on the err channel.
|
||||
|
||||
`outputFormat` set at construction. Do not mutate. Do not pass `format` as separate arg downstream — put in `IOStreams`, pass struct.
|
||||
|
||||
---
|
||||
|
||||
## Spinner
|
||||
|
||||
`runWithSpinner` wraps async call with animated spinner on stderr. Auto-disables for structured output — no manual `enabled:` flag needed.
|
||||
|
||||
```typescript
|
||||
const result = await runWithSpinner({ io, label: 'Fetching apps' }, () => client.list(params))
|
||||
```
|
||||
|
||||
`STRUCTURED_FORMATS = new Set(['json', 'yaml', 'name'])` drives disable check. New structured format = add to this set only — no other callsites change.
|
||||
|
||||
Only override `enabled` for intentional suppression (e.g., tests using `bufferStreams` already suppress via `isErrTTY: false`).
|
||||
|
||||
---
|
||||
|
||||
## Output protocol
|
||||
|
||||
Output rendering separated from data fetching via protocol objects.
|
||||
|
||||
- Data classes implement `TablePrintable` or `FormattedPrintable` from `src/framework/output`.
|
||||
- Streaming commands implement `StreamPrinter` from `src/framework/stream`.
|
||||
- `index.ts` wraps the result with `table({format, data})` or `formatted({format, data})` and returns it; `src/framework/run.ts` calls `stringifyOutput()`.
|
||||
- Commands that write incrementally (streaming) write directly from the strategy via `deps.io.out.write(stringifyOutput(...))`.
|
||||
|
||||
```typescript
|
||||
// handlers.ts — implement the protocol on the data object
|
||||
export class MyListOutput implements TablePrintable {
|
||||
tableColumns() {
|
||||
return COLUMNS
|
||||
}
|
||||
tableRows() {
|
||||
return this.rows.map((r) => r.tableRow())
|
||||
}
|
||||
json() {
|
||||
return { items: this.rows.map((r) => r.json()) }
|
||||
}
|
||||
}
|
||||
|
||||
// index.ts — wrap and return
|
||||
return table({ format: flags.output, data: result })
|
||||
```
|
||||
|
||||
New output format: add to `OutputFormat` in `framework/output.ts` and handle in `stringifyOutput`. Never add `if (format === 'json')` branches in `run.ts` or handlers.
|
||||
|
||||
---
|
||||
|
||||
## Strategy pattern (mode dispatch)
|
||||
|
||||
Singleton strategies + picker function. No switch ladders on discriminator.
|
||||
|
||||
```typescript
|
||||
export type RunStrategy = {
|
||||
execute: (ctx: RunContext) => Promise<void>
|
||||
}
|
||||
|
||||
const streamingText = new StreamingTextStrategy()
|
||||
const streamingStructured = new StreamingStructuredStrategy()
|
||||
|
||||
export function pickStrategy(isText: boolean, livePrint: boolean): RunStrategy {
|
||||
return isText && livePrint ? streamingText : streamingStructured
|
||||
}
|
||||
```
|
||||
|
||||
New mode = new class + one line in picker. Singletons avoid per-call allocation.
|
||||
|
||||
---
|
||||
|
||||
## HTTP clients
|
||||
|
||||
Keep resource clients under `src/api/`. They receive the shared `HttpClient` and call generated oRPC operations through `createOpenApiClient(...)` when the OpenAPI contract covers the endpoint. Reuse generated request and response types instead of duplicating wire shapes.
|
||||
|
||||
Pass `HttpClient` into behavior owners. Add a client or factory dependency only when it owns a real substitution or lifecycle boundary; behavior tests normally exercise the real client stack against `test/fixtures/dify-mock/`. Keep client construction out of `index.ts` so the command remains a framework and output boundary.
|
||||
`IOStreams` is the raw layer underneath: `realStreams()` wraps `process.std*` in production, `bufferStreams()` captures output in tests, and `ioService(streams)` wraps either.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Keep tests beside the owner as `*.test.ts`. When a command has a behavior module, test that public function directly for domain and protocol behavior. Test the command class or framework boundary when argument parsing, flags, help, output construction, or command wiring is the observable contract. Establish a failing case first when practical for behavior changes and bug fixes.
|
||||
Keep tests beside the owner as `*.test.ts`. When a command delegates to a pure module (`call/`, `protocol/`), test that module directly. Test the command class or the `commands` pipeline when argument parsing, help, or command wiring is the observable contract. Establish a failing case first when practical for behavior changes and bug fixes.
|
||||
|
||||
### dify-mock fixture server
|
||||
|
||||
`test/fixtures/dify-mock/server.ts` mirrors `/openapi/v1/*`. Each test starts isolated instance:
|
||||
`test/fixtures/dify-mock/server.ts` mirrors `/openapi/v1/*` and the catalog endpoint. Each test starts an isolated instance:
|
||||
|
||||
```typescript
|
||||
import { startMock } from '../../../test/fixtures/dify-mock/server.js'
|
||||
@@ -236,23 +129,16 @@ const mock = await startMock({ scenario: 'happy' })
|
||||
await mock.stop()
|
||||
```
|
||||
|
||||
| Scenario | Effect |
|
||||
| ----------------- | ----------------------------------------------------------------------------- |
|
||||
| `happy` (default) | Standard fixtures: 4 apps across 2 workspaces, 2 workspaces, 1 active session |
|
||||
| `sso` | `/workspaces` returns empty (external-SSO bearer model) |
|
||||
| `expired` | All authenticated routes return 401 `auth_expired` |
|
||||
| `pagination` | `/apps` honors `?page=` + `?limit=`, total > one page |
|
||||
| `slow` | Adds `Retry-After: 1` to GETs to test ky retry behavior |
|
||||
`happy` (default) is the standard fixture set: 4 apps across 2 workspaces, 2 workspaces, 1 active session. See `Scenario` in `scenarios.ts` for the full union — auth failures, rate limiting, catalog-fingerprint mismatches, HITL pauses, pending imports, and more.
|
||||
|
||||
New scenario: extend `Scenario` union in `scenarios.ts`, branch in relevant handler. No per-test mocks — one fixture surface keeps tests aligned with real API.
|
||||
New scenario: extend the `Scenario` union in `scenarios.ts`, branch in the relevant handler. No per-test mocks — one fixture surface keeps tests aligned with the real API.
|
||||
|
||||
### Assertions
|
||||
|
||||
Inline string/regex/JSON checks — no golden files.
|
||||
|
||||
```typescript
|
||||
expect(out).toMatch(/^ID\s+NAME\s+ROLE/)
|
||||
expect(JSON.parse(out).workspaces).toHaveLength(2)
|
||||
expect(JSON.parse(out).data).toHaveLength(2)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -270,6 +156,7 @@ expect(JSON.parse(out).workspaces).toHaveLength(2)
|
||||
| `pnpm tree:gen` | Regenerate `src/commands/tree.generated.ts` |
|
||||
| `pnpm tree:check` | Verify the generated tree matches the commands |
|
||||
| `pnpm build:bin` | Cross-compile standalone binaries via Bun (CI) |
|
||||
| `pnpm build:bin:local` | Same, pinned to the `dev` channel |
|
||||
|
||||
**`pnpm tree:gen` rule:** run after adding, removing, or renaming any command. The generated `tree.generated.ts` is the runtime command registry; a stale tree makes commands invisible at runtime. It also runs through `prebuild`, `predev`, and `pretest`.
|
||||
|
||||
@@ -296,7 +183,6 @@ Run `vp check --fix cli` from the repository root for scoped formatting, lint, a
|
||||
## PR conventions
|
||||
|
||||
- One feature, one PR. Bundle test + impl + doc update.
|
||||
- Branch off `feat/cli`. Never target `main`.
|
||||
- Commit style: `<type>(cli): <imperative subject>`. Types: `feat`, `fix`, `refactor`, `docs`, `chore`. Body explains why if non-obvious.
|
||||
- Plan/spec/superpowers files do not ship in CLI commits.
|
||||
- Verify diff before committing — `.local.json` and `.vitest-cache/` gitignored but check anyway.
|
||||
@@ -306,14 +192,10 @@ Run `vp check --fix cli` from the repository root for scoped formatting, lint, a
|
||||
## Anti-patterns
|
||||
|
||||
| Pattern | Do instead |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `if (format === 'json') { ... }` in `run.ts` | Printer handler per format |
|
||||
| `try { ... } catch (e) { if (isBaseError(e)) ... }` in every command | Throw `BaseError`; `src/framework/run.ts` normalizes and formats it |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Raw string error codes `'not_logged_in'` | `ErrorCode.NotLoggedIn` |
|
||||
| `enabled: !isHuman` in `runWithSpinner` | Set `outputFormat` on `IOStreams`; spinner auto-detects |
|
||||
| Long positional arg lists | Options struct |
|
||||
| `Record<string, Strategy>` dispatch map | Named singletons + picker function |
|
||||
| `src/framework/` import in `run.ts`, `api/`, or `auth/` | Framework imports belong in `index.ts`, `handlers.ts`, and strategies only |
|
||||
| `buildAuthedContext(this, opts)` in command body | `this.authedCtx(opts)` |
|
||||
| `console.log` in `src/` | Return `CommandOutput` from the command or use owned I/O for streaming |
|
||||
| Long positional arg lists | A Zod `input` object with named fields |
|
||||
| `if (kind === 'sse') { ... } else if (kind === 'list') ...` | A `Record<Kind, Renderer>` dispatch table (see `call/render/index.ts`) |
|
||||
| A command reaching into another plugin's closed-over state | `ctx.get(thatPlugin)` — the cached service, not its internals |
|
||||
| `console.log` in `src/` | Return the value from `run()`; the pipeline prints it |
|
||||
| New dependency without approval | Check first |
|
||||
|
||||
100
cli/README.md
100
cli/README.md
@@ -1,6 +1,6 @@
|
||||
# difyctl
|
||||
|
||||
CLI client for [Dify] platform. Browser device-flow signin, list/inspect apps, run with structured input, parse output as JSON, YAML, or human text.
|
||||
CLI client for the [Dify] platform. Browser device-flow sign-in, then a server-published catalog of operations you discover and call at run time, each one a command. Results are JSON; help and errors are text on a terminal, JSON in a pipe.
|
||||
|
||||
## Install (edge, internal)
|
||||
|
||||
@@ -33,47 +33,50 @@ Re-run to upgrade. For tagged `rc`/`stable` builds, use the GitHub installer (`i
|
||||
## Quickstart
|
||||
|
||||
```sh
|
||||
difyctl auth login # opens browser; paste the device code shown
|
||||
difyctl get app # list apps in default workspace
|
||||
difyctl describe app <app-id> # inspect parameters
|
||||
difyctl run app <app-id> "hello" # run, blocking
|
||||
difyctl run app <app-id> "hello" -o json | jq .answer # JSON output
|
||||
difyctl run app <app-id> --input name=world --input topic=cats # workflow inputs
|
||||
difyctl login --server https://dify.example.com # opens the browser; approve the device code shown
|
||||
difyctl help # the map: every command and operation namespace
|
||||
difyctl help chatbot # search commands and operations by plain words
|
||||
difyctl get workspace # workspaces visible to this account
|
||||
difyctl get console_app --limit 5 # list apps
|
||||
difyctl run console_app workflow --app-id <id> --inputs @vars.json --stream
|
||||
```
|
||||
|
||||
Background docs: `difyctl help account`, `difyctl help external`, `difyctl help environment`, `difyctl help agent`.
|
||||
difyctl has no built-in business commands. Every operation the server publishes is a command: `run.console_app.workflow` is `difyctl run console_app workflow`, and its input fields are flags (`--app-id`, `--inputs`). A field named like one of difyctl's own flags (`input`, `stream`, `only`, `output`, `verbose`, `json`, `help`) is passed through `--input`. The table lists the local commands; `difyctl help` lists the server's too. See [Agent skill] for the discovery flow.
|
||||
|
||||
## Commands
|
||||
|
||||
Run `difyctl --help` for the full list of commands.
|
||||
Run `difyctl <cmd> --help` for per-command reference.
|
||||
| Command | Purpose |
|
||||
| -------------------------- | --------------------------------------------------------------- |
|
||||
| `login` | Log in via the OAuth device flow |
|
||||
| `logout` | Log out of the current session |
|
||||
| `version` | Print the client and (if logged in) server versions |
|
||||
| `use workspace <id>` | Pin the local session to a workspace |
|
||||
| `get config [key]` | Print the local config, or one key |
|
||||
| `set config <key> <value>` | Set a local config value |
|
||||
| `unset config <key>` | Remove a local config value, restoring its default |
|
||||
| `refresh cache` | Refetch the server catalog and replace the local cache |
|
||||
| `delete cache` | Delete the local server catalog cache |
|
||||
| `get skills` | List the skills in the collection |
|
||||
| `install skills <dir>` | Write skills from the collection into a skills root (`--skill`) |
|
||||
|
||||
Use `difyctl help -o json --compact` to read the complete command map, then inspect the selected command with `difyctl help <path> -o json` before running it — that per-command descriptor is the only surface that carries args and flags.
|
||||
`login` blocks until the browser approval arrives. An agent runs it in the background, relays the `open <url>` and `code <code>` lines from its stderr to the user, and does not cancel the job. In a sandbox it adds `--no-keyring` and sets `DIFY_CONFIG_DIR` to persistent storage on every call, so the login survives the next session.
|
||||
|
||||
For agents (and scripting), start with `difyctl help agent` — the cross-command operating guide (output, discovery, auth, exit codes, errors, HITL, retry). Every help surface is also machine-readable: `difyctl help -o json` dumps the whole command tree plus the global contract (exit codes, output formats, error envelope, HITL protocol), and `difyctl help <path> -o json` returns one command's descriptor.
|
||||
`--verbose` is global: it may appear on any command, and it keeps the raw server
|
||||
response in the error envelope instead of dropping it.
|
||||
|
||||
## Agent skill
|
||||
`difyctl help` covers local commands and server operations as one tree. Bare, it prints the map. `difyctl help <namespace>` lists one namespace (the bare namespace, `difyctl get`, does the same), `difyctl help <words>` searches by plain words, and `difyctl help <id>` prints one descriptor, the same as `<command> --help`; the id may be dotted or spaced. `--all` adds hidden entries, `--full` on the bare `help` prints every descriptor. Help and errors are text on a terminal and JSON in a pipe; `--json` or `DIFY_OUTPUT=json` forces JSON. Results and streams are always JSON.
|
||||
|
||||
`difyctl skills install` installs a single, pure-delegation `SKILL.md` into your local agents so they auto-load it. The skill does not freeze the command set — it directs the agent through the live compact sitemap and per-command help, so it never drifts from your binary. It is embedded in the binary (version-stamped) rather than checked in.
|
||||
## Agent skills
|
||||
|
||||
- `difyctl skills install` — dry-run: detect installed agents (Claude Code, Codex, opencode, Cursor, pi) and print where the skill would land. Writes nothing.
|
||||
- `difyctl skills install --yes` — write to every detected agent, printing each path. `--agent claude-code[,cursor]` restricts to a subset; `<dir>` forces one explicit directory (handy when your agent isn't detected).
|
||||
- `difyctl skills install --stdout` — print the `SKILL.md` to stdout (for piping or self-install); writes nothing.
|
||||
difyctl installs agent skills from [`skills/`] at the repo root. It downloads them from GitHub at install time, from the commit the binary was built from; the binary holds no copy and needs no git. The basic skill, `difyctl`, teaches the discovery flow — `help` to see the map or search, `help <id>` to inspect one operation, then the operation's own command to run it — so it stays correct as the server's catalog grows. Scenario skills add guidance for one kind of task and open by reading the basic one.
|
||||
|
||||
Detection is by config-directory existence (`~/.claude`, `~/.codex`, `~/.config/opencode`, `~/.cursor`, `~/.pi`). If a copy ever looks stale, run `difyctl version` and re-run `difyctl skills install`.
|
||||
- `difyctl get skills` — the collection: each skill's name and description. Writes nothing.
|
||||
- `difyctl install skills <dir>` — write every skill into `<dir>`, the agent's skills root: the folder that holds one subfolder per skill, for example `~/.claude/skills` for Claude Code or `~/.codex/skills` for Codex. Each skill's folder is replaced whole. `--skill <name>` (repeatable) writes only those skills; a scenario skill brings `difyctl` along.
|
||||
- `--from` on both reads skills from elsewhere: any GitHub folder in the standard layout (`https://github.com/<owner>/<repo>/tree/<ref>/<folder>`), or a local folder when GitHub is blocked. Set `GITHUB_TOKEN` if GitHub's rate limit (60 requests an hour per IP) gets in the way.
|
||||
|
||||
## Output formats
|
||||
difyctl does not detect agents. You name the root, and re-running the same command refreshes the copies.
|
||||
|
||||
| Flag | Behavior |
|
||||
| --------- | ------------------------------------------------------ |
|
||||
| (none) | Human table, columns auto-sized to terminal. |
|
||||
| `-o wide` | Same as table, no column truncation. |
|
||||
| `-o json` | Pretty-printed JSON, machine-parseable, stable shape. |
|
||||
| `-o yaml` | YAML mirror of `-o json`. |
|
||||
| `-o name` | IDs only, newline-separated — pipes into `xargs`. |
|
||||
| `-o text` | kubectl-describe style human text (`describe`, `run`). |
|
||||
|
||||
Errors emit JSON envelope to stderr in `-o json` mode; else human message. Exit codes deterministic.
|
||||
The same files install through the Vercel skills installer: `npx skills add langgenius/dify --skill difyctl -g`. Always pass `--skill`; without it the installer also offers the contributor skills under `.agents/skills/`.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -83,30 +86,39 @@ Errors emit JSON envelope to stderr in `-o json` mode; else human message. Exit
|
||||
| macOS | `$HOME/.config/difyctl/` |
|
||||
| Windows | `%APPDATA%\difyctl\` |
|
||||
|
||||
Override with `DIFY_CONFIG_DIR=/some/path`. Files written `0600`, directory `0700`. Tokens use OS keychain by default, fall back to sealed file on hosts without one.
|
||||
`get config [key]` prints the whole local config or one key; `set config <key> <value>` and `unset config <key>` change it. The only config key today is `http.timeout` (request timeout in milliseconds).
|
||||
|
||||
For every env var `difyctl` reads, run `difyctl env list` (machine-readable) or `difyctl help environment` (narrative).
|
||||
Tokens use the OS keychain by default, falling back to `tokens.yml` on hosts without one; `login --no-keyring` chooses the file outright. Config and cache files are written `0600`, their directory `0700`.
|
||||
|
||||
| Env var | Effect |
|
||||
| ------------------- | ----------------------------------------------------------- |
|
||||
| `DIFY_SERVER` | Server base URL; with `DIFY_TOKEN`, skips interactive login |
|
||||
| `DIFY_TOKEN` | Bearer token; requires `DIFY_SERVER` |
|
||||
| `DIFY_WORKSPACE_ID` | Overrides the locally pinned workspace |
|
||||
| `DIFY_OUTPUT` | `json` or `text`; overrides the terminal/pipe detection |
|
||||
| `DIFY_CONFIG_DIR` | Overrides the config directory |
|
||||
| `DIFY_CACHE_DIR` | Overrides the catalog cache directory |
|
||||
|
||||
## Streaming
|
||||
|
||||
`run app` uses blocking transport by default. For long-running apps (likely exceed ~30s) pass `--stream`:
|
||||
Pass `--stream` on a streaming operation to print every event as one JSON line, instead of folding the whole run into a single result object:
|
||||
|
||||
```sh
|
||||
difyctl run app app-1 "tell me about cats" --stream
|
||||
difyctl run console_app chat --app-id … --query hello --inputs '{}' --stream
|
||||
```
|
||||
|
||||
Agent apps (`mode === 'agent-chat'` or `is_agent` flag set) stream regardless — Dify backend rejects blocking requests for agent mode. Combining `--stream` with `-o json` or `-o yaml` aggregates SSE events into same envelope shape as blocking response, so structured output identical regardless of transport.
|
||||
`--only <event>` filters the streamed events to the named event types; repeat it to keep more than one. Without `--stream`, a streaming operation's events fold into `{status, text, outputs?, message_id?, conversation_id?, error?, hints}`.
|
||||
|
||||
## HTTP retry
|
||||
## Exit codes
|
||||
|
||||
Idempotent requests (`GET`, `PUT`, `DELETE`) retry on transient network/DNS failures with exponential backoff. Default count: **3**. `POST` and `PATCH` never retry — side effects possible.
|
||||
|
||||
| Knob | Effect |
|
||||
| ------------------------ | ---------------------------------------------- |
|
||||
| `--http-retry <n>` | Per-invocation override. `0` disables retries. |
|
||||
| `DIFYCTL_HTTP_RETRY=<n>` | Process-level default. |
|
||||
|
||||
Resolution: flag → env → 3.
|
||||
| Code | Meaning |
|
||||
| ---- | -------------------------------------------- |
|
||||
| 0 | Success |
|
||||
| 1 | Error |
|
||||
| 2 | Usage or input error |
|
||||
| 4 | Not logged in, or forbidden |
|
||||
| 6 | Catalog unavailable, or unknown operation id |
|
||||
| 7 | Rate limited |
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -116,5 +128,7 @@ See [`ARD.md`] for architecture patterns, scaffolding recipe, dev workflow.
|
||||
|
||||
Apache-2.0.
|
||||
|
||||
[Agent skill]: #agent-skills
|
||||
[Dify]: https://dify.ai
|
||||
[`ARD.md`]: ARD.md
|
||||
[`skills/`]: ../skills
|
||||
|
||||
@@ -9,10 +9,6 @@ globalThis.__DIFYCTL_VERSION__ = info.version
|
||||
globalThis.__DIFYCTL_COMMIT__ = info.commit
|
||||
globalThis.__DIFYCTL_BUILD_DATE__ = info.buildDate
|
||||
globalThis.__DIFYCTL_CHANNEL__ = info.channel
|
||||
globalThis.__DIFYCTL_MIN_DIFY__ = info.minDify
|
||||
globalThis.__DIFYCTL_MAX_DIFY__ = info.maxDify
|
||||
|
||||
const { commandTree } = await import('../src/commands/tree.ts')
|
||||
const { run } = await import('../src/framework/run.ts')
|
||||
|
||||
await run(commandTree, process.argv.slice(2))
|
||||
const { main } = await import('../src/main.ts')
|
||||
process.exitCode = await main(process.argv.slice(2))
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
// Production entry compiled by `bun build --compile` (see scripts/release-build.sh).
|
||||
// Imports from src/ so the release pipeline doesn't need `pnpm build` (dist/).
|
||||
import { commandTree } from '../src/commands/tree.js'
|
||||
import { run } from '../src/framework/run.js'
|
||||
import { main } from '../src/main.js'
|
||||
import { cancel } from '../src/plugins/commands/cancel.js'
|
||||
|
||||
const EPIPE = 'EPIPE'
|
||||
|
||||
// SIGINT unwinds the run through the shared controller; the exit code is whatever
|
||||
// the command settles on, since 130 is outside the frozen taxonomy.
|
||||
process.once('SIGINT', cancel)
|
||||
|
||||
// A reader that closed stdout (`| head`) still emits on the stream after the write
|
||||
// callback has already reported it. Swallowing only EPIPE keeps that from turning a
|
||||
// finished run into an unhandled 'error' event.
|
||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code !== EPIPE) throw err
|
||||
})
|
||||
|
||||
// Wrapped instead of top-level await — `bun build --bytecode` doesn't support TLA.
|
||||
void (async () => {
|
||||
await run(commandTree, process.argv.slice(2))
|
||||
process.exitCode = await main(process.argv.slice(2))
|
||||
})()
|
||||
|
||||
@@ -31,24 +31,19 @@
|
||||
"pretest": "pnpm tree:gen",
|
||||
"ci": "pnpm tree:check && pnpm test:coverage && pnpm build",
|
||||
"clean": "rm -rf dist node_modules/.cache",
|
||||
"smoke": "bun scripts/run-smoke.ts",
|
||||
"version:info": "bun scripts/print-buildinfo.ts",
|
||||
"build:bin": "scripts/release-build.sh",
|
||||
"build:bin:local": "DIFYCTL_CHANNEL=dev scripts/release-build.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dify/contracts": "workspace:*",
|
||||
"@napi-rs/keyring": "catalog:",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
"@orpc/openapi-client": "catalog:",
|
||||
"cli-table3": "catalog:",
|
||||
"ajv": "catalog:",
|
||||
"eventsource-parser": "catalog:",
|
||||
"js-yaml": "catalog:",
|
||||
"lockfile": "catalog:",
|
||||
"open": "catalog:",
|
||||
"ora": "catalog:",
|
||||
"picocolors": "catalog:",
|
||||
"std-semver": "catalog:",
|
||||
"undici": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
@@ -77,10 +72,6 @@
|
||||
},
|
||||
"difyctl": {
|
||||
"channel": "stable",
|
||||
"compat": {
|
||||
"minDify": "1.16.0",
|
||||
"maxDify": "1.17.1"
|
||||
},
|
||||
"release": {
|
||||
"tagPrefix": "difyctl-v",
|
||||
"binName": "difyctl",
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('formatModule', () => {
|
||||
`// @generated by scripts/generate-command-tree.ts — DO NOT EDIT.
|
||||
// Regenerate via \`pnpm tree:gen\`. Drift gated by \`pnpm tree:check\` in CI.
|
||||
|
||||
import type { CommandTree } from '@/framework/registry'
|
||||
import type { CommandTree } from '@/plugins/commands/registry'
|
||||
import AuthDevicesList from '@/commands/auth/devices/list/index'
|
||||
import AuthLogin from '@/commands/auth/login/index'
|
||||
import Version from '@/commands/version/index'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { isExcludedCommandPath } from '../src/framework/command-fs.js'
|
||||
import { isExcludedCommandPath } from '../src/plugins/commands/command-fs.js'
|
||||
|
||||
const RESERVED_JS_KEYWORDS = new Set([
|
||||
'break',
|
||||
@@ -106,7 +106,7 @@ function compareStrings(a: string, b: string): number {
|
||||
|
||||
function emitImports(entries: readonly CommandEntry[]): string {
|
||||
const sorted = [...entries].sort((a, b) => compareStrings(a.importPath, b.importPath))
|
||||
const lines = [`import type { CommandTree } from '@/framework/registry'`]
|
||||
const lines = [`import type { CommandTree } from '@/plugins/commands/registry'`]
|
||||
for (const e of sorted) lines.push(`import ${e.identifier} from '${e.importPath}'`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -153,13 +153,17 @@ function emitEntry(key: string, node: TreeNode, indent: string): string {
|
||||
export function formatModule(entries: readonly CommandEntry[], tree: TreeNode): string {
|
||||
const importsBlock = emitImports(entries)
|
||||
const topKeys = [...tree.subcommands.keys()].sort()
|
||||
const literalParts = ['export const commandTree: CommandTree = {']
|
||||
for (const key of topKeys) {
|
||||
const child = tree.subcommands.get(key)!
|
||||
literalParts.push(emitEntry(key, child, ' '))
|
||||
}
|
||||
literalParts.push('}')
|
||||
return `${HEADER}\n${importsBlock}\n\n${literalParts.join('\n')}\n`
|
||||
// Empty object on one line, matching the repo formatter's own convention —
|
||||
// otherwise a fresh `tree:gen` and `tree:check` disagree right after a commit.
|
||||
const literal =
|
||||
topKeys.length === 0
|
||||
? 'export const commandTree: CommandTree = {}'
|
||||
: [
|
||||
'export const commandTree: CommandTree = {',
|
||||
...topKeys.map((key) => emitEntry(key, tree.subcommands.get(key)!, ' ')),
|
||||
'}',
|
||||
].join('\n')
|
||||
return `${HEADER}\n${importsBlock}\n\n${literal}\n`
|
||||
}
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
@@ -215,7 +219,8 @@ export async function discoverCommands(commandsDir: string): Promise<CommandEntr
|
||||
|
||||
entries.sort((a, b) => compareStrings(a.importPath, b.importPath))
|
||||
|
||||
if (entries.length === 0) throw new Error(`no commands found under ${commandsDir}`)
|
||||
if (entries.length === 0)
|
||||
throw new Error(`no commands found under ${commandsDir} — expected <verb>/index.ts folders`)
|
||||
|
||||
assertUniqueIdentifiers(entries)
|
||||
return entries
|
||||
|
||||
@@ -12,7 +12,6 @@ const MANIFEST = [
|
||||
' "version": "0.1.0-edge.2fd7b82",',
|
||||
' "commit": "abc1234",',
|
||||
' "buildDate": "2026-06-14T12:00:00Z",',
|
||||
' "compat": {"minDify":"1.14.0","maxDify":"1.15.0"},',
|
||||
' "baseUrl": "https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82",',
|
||||
' "targets": {',
|
||||
' "linux-x64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-linux-x64", "sha256": "deadbeef" },',
|
||||
|
||||
@@ -12,8 +12,6 @@ export type BuildInfo = {
|
||||
commit: string
|
||||
buildDate: string
|
||||
channel: BuildChannel
|
||||
minDify: string
|
||||
maxDify: string
|
||||
}
|
||||
|
||||
export type Env = Record<string, string | undefined>
|
||||
@@ -35,7 +33,6 @@ export const defaultGitProbe: GitProbe = (cmd) => {
|
||||
type PackageManifest = {
|
||||
difyctl?: {
|
||||
channel?: string
|
||||
compat?: { minDify?: string; maxDify?: string }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +72,6 @@ export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo {
|
||||
const commit = env.DIFYCTL_COMMIT ?? git('git rev-parse HEAD') ?? 'none'
|
||||
|
||||
const buildDate = env.DIFYCTL_BUILD_DATE ?? now().toISOString()
|
||||
const minDify = env.DIFYCTL_MIN_DIFY ?? pkg.difyctl?.compat?.minDify ?? '0.0.0'
|
||||
const maxDify = env.DIFYCTL_MAX_DIFY ?? pkg.difyctl?.compat?.maxDify ?? '0.0.0'
|
||||
|
||||
return { version, commit, buildDate, channel: channel as BuildChannel, minDify, maxDify }
|
||||
return { version, commit, buildDate, channel: channel as BuildChannel }
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
# Env (all optional; defaults derived from cli/package.json + git):
|
||||
# CLI_VERSION — package.json `version`
|
||||
# DIFYCTL_CHANNEL — package.json `difyctl.channel`
|
||||
# DIFYCTL_MIN_DIFY — package.json `difyctl.compat.minDify`; must be X.Y.Z
|
||||
# DIFYCTL_MAX_DIFY — package.json `difyctl.compat.maxDify`; must be X.Y.Z
|
||||
# DIFYCTL_COMMIT — `git rev-parse HEAD` (or "unknown")
|
||||
# DIFYCTL_BUILD_DATE — current UTC time
|
||||
#
|
||||
@@ -35,21 +33,12 @@ out_dir="${cli_root}/dist/bin"
|
||||
|
||||
read_pkg() { node -p "require('${cli_root}/package.json').$1" 2>/dev/null; }
|
||||
naming() { node "${_dir}/release-naming.mjs" "$@"; }
|
||||
require_bound() {
|
||||
[[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|
||||
|| die "$1 must be a plain X.Y.Z version, got '$2'"
|
||||
}
|
||||
|
||||
CLI_VERSION="${CLI_VERSION:-$(read_pkg version)}"
|
||||
DIFYCTL_CHANNEL="${DIFYCTL_CHANNEL:-$(read_pkg difyctl.channel)}"
|
||||
DIFYCTL_MIN_DIFY="${DIFYCTL_MIN_DIFY:-$(read_pkg difyctl.compat.minDify)}"
|
||||
DIFYCTL_MAX_DIFY="${DIFYCTL_MAX_DIFY:-$(read_pkg difyctl.compat.maxDify)}"
|
||||
DIFYCTL_COMMIT="${DIFYCTL_COMMIT:-$(git -C "$cli_root" rev-parse HEAD 2>/dev/null || echo unknown)}"
|
||||
DIFYCTL_BUILD_DATE="${DIFYCTL_BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
|
||||
|
||||
require_bound DIFYCTL_MIN_DIFY "$DIFYCTL_MIN_DIFY"
|
||||
require_bound DIFYCTL_MAX_DIFY "$DIFYCTL_MAX_DIFY"
|
||||
|
||||
[[ "$CLI_VERSION" != "undefined" ]] || die "CLI_VERSION could not be derived from package.json"
|
||||
|
||||
[[ -f "$entry" ]] || die "entry not found: $entry"
|
||||
@@ -62,8 +51,6 @@ mkdir -p "$out_dir"
|
||||
defines=(
|
||||
"--define" "__DIFYCTL_VERSION__=\"${CLI_VERSION}\""
|
||||
"--define" "__DIFYCTL_CHANNEL__=\"${DIFYCTL_CHANNEL}\""
|
||||
"--define" "__DIFYCTL_MIN_DIFY__=\"${DIFYCTL_MIN_DIFY}\""
|
||||
"--define" "__DIFYCTL_MAX_DIFY__=\"${DIFYCTL_MAX_DIFY}\""
|
||||
"--define" "__DIFYCTL_COMMIT__=\"${DIFYCTL_COMMIT}\""
|
||||
"--define" "__DIFYCTL_BUILD_DATE__=\"${DIFYCTL_BUILD_DATE}\""
|
||||
)
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
const posix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
const SCRIPTS_DIR = posix(fileURLToPath(new URL('.', import.meta.url))).replace(/\/$/, '')
|
||||
|
||||
const BUILD_SH = 'release-build.sh'
|
||||
|
||||
type Run = { code: number; stderr: string }
|
||||
|
||||
const STUB_BUN = ['#!/bin/sh', 'echo "release-guards: bun must not run" >&2', 'exit 90', ''].join(
|
||||
'\n',
|
||||
)
|
||||
|
||||
const FAKE_MANIFEST = {
|
||||
version: '7.7.7',
|
||||
difyctl: {
|
||||
channel: 'stable',
|
||||
compat: { minDify: '2.0.0', maxDify: '2.5.0' },
|
||||
release: {
|
||||
tagPrefix: 'difyctl-v',
|
||||
binName: 'difyctl',
|
||||
checksumsSuffix: '-checksums.txt',
|
||||
targets: [{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false }],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
return posix(mkdtempSync(join(tmpdir(), prefix)))
|
||||
}
|
||||
|
||||
function runScript(
|
||||
script: string,
|
||||
cliVersion?: string,
|
||||
extraEnv: Record<string, string | undefined> = {},
|
||||
): Run {
|
||||
const stubDir = tempDir('difyctl-stub-bun-')
|
||||
writeFileSync(`${stubDir}/bun`, STUB_BUN)
|
||||
chmodSync(`${stubDir}/bun`, 0o755)
|
||||
try {
|
||||
const merged: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
PATH: `${stubDir}:${process.env.PATH ?? ''}`,
|
||||
CLI_VERSION: cliVersion,
|
||||
...extraEnv,
|
||||
}
|
||||
const childEnv: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(merged)) {
|
||||
if (value !== undefined) childEnv[key] = value
|
||||
}
|
||||
const r = spawnSync('bash', [script], { encoding: 'utf8', env: childEnv })
|
||||
return { code: r.status ?? 1, stderr: r.stderr ?? '' }
|
||||
} finally {
|
||||
rmSync(stubDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function fakeCliRoot(scriptName: string): string {
|
||||
const root = tempDir('difyctl-release-guard-')
|
||||
mkdirSync(`${root}/scripts/lib`, { recursive: true })
|
||||
cpSync(`${SCRIPTS_DIR}/${scriptName}`, `${root}/scripts/${scriptName}`)
|
||||
cpSync(`${SCRIPTS_DIR}/lib/common.sh`, `${root}/scripts/lib/common.sh`)
|
||||
cpSync(`${SCRIPTS_DIR}/release-naming.mjs`, `${root}/scripts/release-naming.mjs`)
|
||||
writeFileSync(`${root}/package.json`, JSON.stringify(FAKE_MANIFEST))
|
||||
return root
|
||||
}
|
||||
|
||||
// Always against a throwaway root: a valid bound carries the script through to
|
||||
// `rm -rf "$out_dir"`, which against the real cli root deletes a developer's build.
|
||||
function runInFakeRoot(extraEnv: Record<string, string | undefined>): Run {
|
||||
const root = fakeCliRoot(BUILD_SH)
|
||||
try {
|
||||
return runScript(`${root}/scripts/${BUILD_SH}`, '2.4.0', extraEnv)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('release-build.sh compat bounds', () => {
|
||||
for (const bound of ['DIFYCTL_MIN_DIFY', 'DIFYCTL_MAX_DIFY']) {
|
||||
it.each(['undefined', '1.16'])(`rejects ${bound}=%s`, (bad) => {
|
||||
const r = runInFakeRoot({ [bound]: bad })
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain(bound)
|
||||
})
|
||||
}
|
||||
|
||||
it('does not wipe dist/bin when a bound guard fires', () => {
|
||||
const root = fakeCliRoot(BUILD_SH)
|
||||
const sentinel = `${root}/dist/bin/prior-build`
|
||||
try {
|
||||
mkdirSync(`${root}/dist/bin`, { recursive: true })
|
||||
writeFileSync(sentinel, 'output from an earlier build')
|
||||
mkdirSync(`${root}/bin`, { recursive: true })
|
||||
writeFileSync(`${root}/bin/run.ts`, 'export {}\n')
|
||||
|
||||
const r = runScript(`${root}/scripts/${BUILD_SH}`, '2.4.0', {
|
||||
DIFYCTL_MIN_DIFY: 'undefined',
|
||||
})
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(existsSync(sentinel)).toBe(true)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -15,19 +15,15 @@
|
||||
// prerelease <channel> -> "true" | "false"
|
||||
// github-env -> key=value lines (all fields CI needs) for $GITHUB_ENV
|
||||
// edge-version <sha> -> <package version core>-edge.<sha>
|
||||
// validate -> exit 1 if difyctl.release, version, channel, or
|
||||
// difyctl.compat is malformed
|
||||
// validate -> exit 1 if difyctl.release, version, or channel is malformed
|
||||
// validate-version <version> <channel>
|
||||
// -> exit 1 unless version matches the channel's form
|
||||
// compat-check <difyVer> -> exit 1 if difyVer outside compat.minDify..maxDify
|
||||
|
||||
import { readFileSync, realpathSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/
|
||||
const SEMVER_CORE_LEN = 3
|
||||
const SEMVER_CORE_RE = /^\d+\.\d+\.\d+$/
|
||||
const COMPAT_BOUNDS = ['minDify', 'maxDify']
|
||||
|
||||
// Add channels here: { name, prerelease, versionForm }.
|
||||
const CHANNELS = [
|
||||
@@ -40,14 +36,6 @@ const CHANNELS = [
|
||||
const channelByName = (name) => CHANNELS.find((c) => c.name === name)
|
||||
const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
|
||||
|
||||
function parsePrecedence(v) {
|
||||
const s = String(v).replace(/^v/, '').replace(/\+.*$/, '')
|
||||
const i = s.indexOf('-')
|
||||
const core = i === -1 ? s : s.slice(0, i)
|
||||
const pre = i === -1 ? '' : s.slice(i + 1)
|
||||
return { nums: core.split('.').map(Number), pre }
|
||||
}
|
||||
|
||||
function versionCore(v) {
|
||||
return String(v).replace(/^v/, '').replace(/\+.*$/, '').split('-')[0]
|
||||
}
|
||||
@@ -80,50 +68,15 @@ function validateVersionForChannel(version, channelName) {
|
||||
return `valid: ${version} is a ${channelName} version`
|
||||
}
|
||||
|
||||
function comparePre(a, b) {
|
||||
const aparts = a.split('.')
|
||||
const bparts = b.split('.')
|
||||
const len = Math.max(aparts.length, bparts.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (aparts[i] === undefined) return -1
|
||||
if (bparts[i] === undefined) return 1
|
||||
const an = /^\d+$/.test(aparts[i])
|
||||
const bn = /^\d+$/.test(bparts[i])
|
||||
if (an && bn) {
|
||||
const d = Number(aparts[i]) - Number(bparts[i])
|
||||
if (d !== 0) return d < 0 ? -1 : 1
|
||||
} else if (an !== bn) {
|
||||
return an ? -1 : 1
|
||||
} else if (aparts[i] !== bparts[i]) {
|
||||
return aparts[i] < bparts[i] ? -1 : 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function comparePrecedence(a, b) {
|
||||
const A = parsePrecedence(a)
|
||||
const B = parsePrecedence(b)
|
||||
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
|
||||
const x = A.nums[i] ?? 0
|
||||
const y = B.nums[i] ?? 0
|
||||
if (x !== y) return x < y ? -1 : 1
|
||||
}
|
||||
if (A.pre === B.pre) return 0
|
||||
if (A.pre === '') return 1
|
||||
if (B.pre === '') return -1
|
||||
return comparePre(A.pre, B.pre)
|
||||
}
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write(`release-naming: ${msg}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Tests point this at a fixture manifest so their assertions stay fixed while
|
||||
// the real version and compat window move with every release. The name is
|
||||
// mirrored in test/fixtures/pkg-manifest.ts rather than imported from here,
|
||||
// because this file's shebang breaks the Windows test runner.
|
||||
// the real version moves with every release. The name is mirrored in
|
||||
// test/fixtures/pkg-manifest.ts rather than imported from here, because this
|
||||
// file's shebang breaks the Windows test runner.
|
||||
const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH'
|
||||
|
||||
function loadPkg() {
|
||||
@@ -133,20 +86,17 @@ function loadPkg() {
|
||||
return {
|
||||
version: pkg.version,
|
||||
channel: pkg.difyctl.channel,
|
||||
compat: pkg.difyctl.compat ?? {},
|
||||
release: pkg.difyctl.release,
|
||||
}
|
||||
}
|
||||
|
||||
// Emits key=value lines for $GITHUB_ENV.
|
||||
function githubEnv() {
|
||||
const { version, channel, compat, release } = loadPkg()
|
||||
const { version, channel, release } = loadPkg()
|
||||
const fields = {
|
||||
version,
|
||||
channel,
|
||||
prerelease: channelByName(channel)?.prerelease ?? false,
|
||||
minDify: compat.minDify,
|
||||
maxDify: compat.maxDify,
|
||||
tagPrefix: release.tagPrefix,
|
||||
difyctlTag: `${release.tagPrefix}${version}`,
|
||||
}
|
||||
@@ -197,15 +147,6 @@ function validateVersionChannel(version, channel) {
|
||||
return problem ? [problem] : []
|
||||
}
|
||||
|
||||
function validateCompat(compat) {
|
||||
const problems = COMPAT_BOUNDS.filter((b) => !SEMVER_CORE_RE.test(compat[b] ?? '')).map(
|
||||
(b) => `difyctl.compat.${b} must be a plain X.Y.Z version, found ${compat[b] ?? '(missing)'}`,
|
||||
)
|
||||
if (problems.length === 0 && comparePrecedence(compat.minDify, compat.maxDify) > 0)
|
||||
problems.push(`difyctl.compat.minDify (${compat.minDify}) is above maxDify (${compat.maxDify})`)
|
||||
return problems
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const [cmd, ...rest] = argv
|
||||
switch (cmd) {
|
||||
@@ -231,35 +172,17 @@ function main(argv) {
|
||||
return CHANNELS.map((c) => c.name).join('\n')
|
||||
case 'github-env':
|
||||
return githubEnv()
|
||||
case 'compat-check': {
|
||||
const { compat } = loadPkg()
|
||||
const difyVersion = requireVersion(rest[0])
|
||||
if (!compat.minDify || !compat.maxDify)
|
||||
die('cli/package.json missing difyctl.compat.minDify/maxDify')
|
||||
if (
|
||||
comparePrecedence(difyVersion, compat.minDify) < 0 ||
|
||||
comparePrecedence(difyVersion, compat.maxDify) > 0
|
||||
)
|
||||
die(
|
||||
`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`,
|
||||
)
|
||||
return `compatible: Dify ${difyVersion} within ${compat.minDify}..${compat.maxDify}`
|
||||
}
|
||||
case 'prerelease': {
|
||||
const ch = channelByName(rest[0] ?? die('channel argument is required'))
|
||||
if (!ch) die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`)
|
||||
return String(ch.prerelease)
|
||||
}
|
||||
case 'validate': {
|
||||
const { version, channel, compat, release } = loadPkg()
|
||||
const problems = [
|
||||
...validateRelease(release),
|
||||
...validateCompat(compat),
|
||||
...validateVersionChannel(version, channel),
|
||||
]
|
||||
const { version, channel, release } = loadPkg()
|
||||
const problems = [...validateRelease(release), ...validateVersionChannel(version, channel)]
|
||||
if (problems.length > 0)
|
||||
die(`invalid difyctl release config:\n - ${problems.join('\n - ')}`)
|
||||
return `difyctl release valid: version=${version} channel=${channel} compat=${compat.minDify}..${compat.maxDify} targets=${release.targets.length}`
|
||||
return `difyctl release valid: version=${version} channel=${channel} targets=${release.targets.length}`
|
||||
}
|
||||
case 'edge-version':
|
||||
return edgeVersion(rest[0])
|
||||
|
||||
@@ -5,7 +5,6 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import {
|
||||
FIXTURE_CHANNEL,
|
||||
FIXTURE_COMPAT,
|
||||
FIXTURE_TAG_PREFIX,
|
||||
FIXTURE_VERSION,
|
||||
pkgManifestEnv,
|
||||
@@ -40,7 +39,7 @@ function runWith(overrides: PkgManifestOverrides, args: string[]): RunResult {
|
||||
|
||||
type FixtureManifest = {
|
||||
version?: string
|
||||
difyctl: { channel?: string; compat: { minDify?: string; maxDify?: string } }
|
||||
difyctl: { channel?: string }
|
||||
}
|
||||
|
||||
function runOnManifest(mutate: (manifest: FixtureManifest) => void, args: string[]): RunResult {
|
||||
@@ -62,48 +61,6 @@ function parseKeyValues(stdout: string): Record<string, string> {
|
||||
)
|
||||
}
|
||||
|
||||
describe('release-naming compat-check', () => {
|
||||
const { minDify, maxDify } = FIXTURE_COMPAT // 2.0.0 .. 2.5.0
|
||||
const compatCheck = (difyVersion?: string) =>
|
||||
run(difyVersion === undefined ? ['compat-check'] : ['compat-check', difyVersion]).code
|
||||
|
||||
it('accepts a version inside the window', () => {
|
||||
expect(compatCheck('2.3.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive lower bound', () => {
|
||||
expect(compatCheck(minDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive upper bound', () => {
|
||||
expect(compatCheck(maxDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts a v-prefixed tag', () => {
|
||||
expect(compatCheck('v2.3.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version below the lower bound', () => {
|
||||
expect(compatCheck('1.9.9')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version above the upper bound', () => {
|
||||
expect(compatCheck('2.5.1')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('treats a prerelease of the lower bound as below it', () => {
|
||||
expect(compatCheck(`${minDify}-rc1`)).not.toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata on the bound', () => {
|
||||
expect(compatCheck(`${maxDify}+build123`)).toBe(0)
|
||||
})
|
||||
|
||||
it('requires a version argument', () => {
|
||||
expect(compatCheck()).not.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming github-env', () => {
|
||||
it('emits every manifest field for $GITHUB_ENV, plus a composed difyctlTag', () => {
|
||||
const fields = parseKeyValues(run(['github-env']).stdout)
|
||||
@@ -111,8 +68,6 @@ describe('release-naming github-env', () => {
|
||||
version: FIXTURE_VERSION,
|
||||
channel: FIXTURE_CHANNEL,
|
||||
prerelease: 'false',
|
||||
minDify: FIXTURE_COMPAT.minDify,
|
||||
maxDify: FIXTURE_COMPAT.maxDify,
|
||||
tagPrefix: FIXTURE_TAG_PREFIX,
|
||||
difyctlTag: `${FIXTURE_TAG_PREFIX}${FIXTURE_VERSION}`,
|
||||
})
|
||||
@@ -179,35 +134,6 @@ describe('release-naming validate channel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming validate compat bounds', () => {
|
||||
const validateCompat = (minDify: string, maxDify: string) =>
|
||||
runWith({ compat: { minDify, maxDify } }, ['validate'])
|
||||
|
||||
it('accepts a well-formed window', () => {
|
||||
expect(validateCompat('1.16.0', '1.17.0').code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts equal bounds', () => {
|
||||
expect(validateCompat('1.17.0', '1.17.0').code).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects an inverted window', () => {
|
||||
const { code, stderr } = validateCompat('1.18.0', '1.17.0')
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('is above maxDify')
|
||||
})
|
||||
|
||||
it.each(['1.x', '1.16', '1.16.0-rc1', ''])('rejects %s as a bound', (bad) => {
|
||||
expect(validateCompat(bad, '2.9.0').code).not.toBe(0)
|
||||
expect(validateCompat('1.0.0', bad).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('names the offending bound', () => {
|
||||
expect(validateCompat('1.x', '1.17.0').stderr).toContain('difyctl.compat.minDify')
|
||||
expect(validateCompat('1.16.0', '1.x').stderr).toContain('difyctl.compat.maxDify')
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming validate-version', () => {
|
||||
it('accepts build metadata on a stable version', () => {
|
||||
expect(run(['validate-version', '1.16.1+r2', 'stable']).code).toBe(0)
|
||||
|
||||
@@ -41,7 +41,7 @@ function shaMap(checksumsPath) {
|
||||
function emitManifest(args) {
|
||||
requireArgs(args, ['channel', 'version', 'commit', 'build-date', 'base-url', 'checksums'])
|
||||
validateVersionForChannel(args.version, args.channel)
|
||||
const { release, compat } = loadPkg()
|
||||
const { release } = loadPkg()
|
||||
const shas = shaMap(args.checksums)
|
||||
|
||||
const targetLines = release.targets
|
||||
@@ -61,7 +61,6 @@ function emitManifest(args) {
|
||||
version: args.version,
|
||||
commit: args.commit,
|
||||
buildDate: args['build-date'],
|
||||
compat: { minDify: compat.minDify, maxDify: compat.maxDify },
|
||||
baseUrl: args['base-url'],
|
||||
}
|
||||
const headLines = Object.entries(head)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import { FIXTURE_COMPAT, FIXTURE_TARGET_IDS, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
|
||||
import { FIXTURE_TARGET_IDS, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url))
|
||||
|
||||
@@ -50,7 +50,6 @@ type ManifestJson = {
|
||||
version: string
|
||||
commit: string
|
||||
buildDate: string
|
||||
compat: { minDify: string; maxDify: string }
|
||||
baseUrl: string
|
||||
targets: Record<(typeof FIXTURE_TARGET_IDS)[number], { asset: string; sha256: string }>
|
||||
}
|
||||
@@ -111,11 +110,6 @@ describe('release-r2-edge manifest', () => {
|
||||
expect(json.baseUrl).toBe(BASE_URL)
|
||||
})
|
||||
|
||||
it('carries the compat window from package.json', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(json.compat).toEqual(FIXTURE_COMPAT)
|
||||
})
|
||||
|
||||
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(Object.keys(json.targets).sort()).toEqual([
|
||||
|
||||
@@ -1,45 +1,60 @@
|
||||
#!/usr/bin/env -S bun
|
||||
import { execSync } from 'node:child_process'
|
||||
// scripts/run-smoke.ts — five checks against a running Dify server (or mock),
|
||||
// through `bun bin/dev.js`. DIFY_SERVER, DIFY_TOKEN, DIFY_CONFIG_DIR, and
|
||||
// DIFY_CACHE_DIR are read from the environment and passed through unchanged.
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { ExitCode } from '../src/errors/codes.js'
|
||||
|
||||
type Check = { name: string; run: () => void }
|
||||
|
||||
const baseUrlIdx = process.argv.indexOf('--base-url')
|
||||
const baseUrl = baseUrlIdx > -1 ? process.argv[baseUrlIdx + 1] : 'http://localhost:5001'
|
||||
if (!baseUrl) {
|
||||
console.error('usage: run-smoke.ts --base-url <url>')
|
||||
process.exit(2)
|
||||
function cli(args: string[]): string {
|
||||
return execFileSync('bun', ['bin/dev.js', ...args], { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
const env = { ...process.env, DIFY_BASE_URL: baseUrl }
|
||||
function cliExitCode(args: string[]): number {
|
||||
return spawnSync('bun', ['bin/dev.js', ...args], { encoding: 'utf8' }).status ?? 1
|
||||
}
|
||||
|
||||
function cli(args: string): string {
|
||||
return execSync(`bun bin/dev.js ${args}`, { env, encoding: 'utf8' })
|
||||
function expectExit(args: string[], expected: number): void {
|
||||
const code = cliExitCode(args)
|
||||
if (code !== expected)
|
||||
throw new Error(`expected exit ${expected} for "${args.join(' ')}", got ${code}`)
|
||||
}
|
||||
|
||||
const checks: Check[] = [
|
||||
{
|
||||
name: 'config show',
|
||||
name: 'version prints .client.version',
|
||||
run: () => {
|
||||
cli('config show')
|
||||
const body = JSON.parse(cli(['version']))
|
||||
if (typeof body.client?.version !== 'string') throw new Error('no .client.version')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get workspace',
|
||||
name: 'help get.console_app carries .input.properties.workspace_id',
|
||||
run: () => {
|
||||
if (!cli('get workspace').includes('id')) throw new Error('no workspace listed')
|
||||
const body = JSON.parse(cli(['help', 'get.console_app', '--json']))
|
||||
if (body.input?.properties?.workspace_id === undefined)
|
||||
throw new Error('no .input.properties.workspace_id')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get apps',
|
||||
name: 'get console_app returns .data',
|
||||
run: () => {
|
||||
cli('get apps')
|
||||
const body = JSON.parse(cli(['get', 'console_app', '--input', '{"limit":1}']))
|
||||
if (body.data === undefined) throw new Error('no .data')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'difyctl version prints compat',
|
||||
run: () => {
|
||||
if (!cli('version').includes('compat:')) throw new Error('no compat line')
|
||||
name: '--stream on an object-kind op exits 2 (usage)',
|
||||
run: () =>
|
||||
expectExit(
|
||||
['describe', 'console_app', '--input', '{"app_id":"x"}', '--stream'],
|
||||
ExitCode.Usage,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'a missing required input exits 2 (usage)',
|
||||
run: () => expectExit(['describe', 'console_app', '--input', '{}'], ExitCode.Usage),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
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 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { AccountSessionsClient } from './account-sessions.js'
|
||||
|
||||
const LIST_BODY = { page: 1, limit: 100, total: 0, has_more: false, data: [] }
|
||||
|
||||
function makeClient(host: string): AccountSessionsClient {
|
||||
return new AccountSessionsClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
describe('AccountSessionsClient.list', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('GETs account/sessions with no query when paging is unset', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list()
|
||||
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
// page/limit are undefined → dropped, so no trailing query string at all.
|
||||
expect(stub.captured.url).toBe('/openapi/v1/account/sessions')
|
||||
})
|
||||
|
||||
it('forwards page/limit when supplied', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({ page: 2, limit: 25 })
|
||||
|
||||
const q = new URL(stub.captured.url ?? '', 'http://x').searchParams
|
||||
expect(q.get('page')).toBe('2')
|
||||
expect(q.get('limit')).toBe('25')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AccountSessionsClient.revoke', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('DELETEs the session by id and discards the JSON body without throwing', async () => {
|
||||
// The server replies 200 + {status:"revoked"}; revoke() returns void but the
|
||||
// typed client still parses the body — this guards against a regression where
|
||||
// a non-empty 200 body trips JSON handling.
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).revoke('sess-1')).resolves.toBeUndefined()
|
||||
expect(stub.captured.method).toBe('DELETE')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/account/sessions/sess-1')
|
||||
})
|
||||
|
||||
it('URL-encodes the session id', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
|
||||
await makeClient(stub.url).revoke('sess/1 2')
|
||||
|
||||
expect(stub.captured.url).toBe('/openapi/v1/account/sessions/sess%2F1%202')
|
||||
})
|
||||
|
||||
it('propagates 404 as a classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap),
|
||||
)
|
||||
|
||||
await expect(makeClient(stub.url).revoke('missing')).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
|
||||
it('revokeSelf DELETEs the self subresource', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).revokeSelf()).resolves.toBeUndefined()
|
||||
expect(stub.captured.method).toBe('DELETE')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/account/sessions/self')
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { SessionListResponse } 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 class AccountSessionsClient {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(q?: { page?: number; limit?: number }): Promise<SessionListResponse> {
|
||||
return this.orpc.account.sessions.get({ query: { page: q?.page, limit: q?.limit } })
|
||||
}
|
||||
|
||||
async revoke(sessionId: string): Promise<void> {
|
||||
await this.orpc.account.sessions.bySessionId.delete({ params: { session_id: sessionId } })
|
||||
}
|
||||
|
||||
async revokeSelf(): Promise<void> {
|
||||
await this.orpc.account.sessions.self.delete()
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { AccountClient } from './account.js'
|
||||
|
||||
function makeClient(host: string): AccountClient {
|
||||
return new AccountClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
describe('AccountClient.get', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('GETs account, sends the bearer, and returns the parsed payload', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
subject_type: 'account',
|
||||
account: { id: 'acct-1', email: 'a@e.com', name: 'A' },
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const res = await makeClient(stub.url).get()
|
||||
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/account')
|
||||
expect(stub.captured.headers?.authorization).toBe('Bearer dfoa_test')
|
||||
expect(res.account?.email).toBe('a@e.com')
|
||||
})
|
||||
|
||||
it('maps 401 to a classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).get()).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 401,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { AccountResponse } 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 class AccountClient {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async get(): Promise<AccountResponse> {
|
||||
return this.orpc.account.get()
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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 'vite-plus/test'
|
||||
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/dsl')
|
||||
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/dependencies:check')
|
||||
expect(result.leaked_dependencies).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
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.dsl.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.dependencies.check.get({
|
||||
params: { app_id: appId },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { dump, load } from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { loadAppInfoCache } from '@/cache/app-info'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_APP_INFO, cachePath, getCache } from '@/store/manager'
|
||||
import { FieldInfo, FieldParameters } from '@/types/app-meta'
|
||||
import { AppMetaClient } from './app-meta.js'
|
||||
import { AppsClient } from './apps.js'
|
||||
|
||||
describe('AppMetaClient', () => {
|
||||
let mock: DifyMock
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-meta-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
|
||||
else process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await mock.stop()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('cache miss → fetch → populate; warm hit skips network', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache })
|
||||
|
||||
const m1 = await client.get('app-1', [FieldInfo])
|
||||
expect(m1.info?.id).toBe('app-1')
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const m2 = await client.get('app-1', [FieldInfo])
|
||||
expect(m2.info?.id).toBe('app-1')
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('slim hit + full request triggers fresh fetch + merges', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache })
|
||||
|
||||
await client.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const full = await client.get('app-1', [FieldInfo, FieldParameters])
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
expect(full.coveredFields.has(FieldParameters)).toBe(true)
|
||||
})
|
||||
|
||||
it('expired cache entry refetches', async () => {
|
||||
const cache = await loadAppInfoCache({
|
||||
store: getCache(CACHE_APP_INFO),
|
||||
ttlMs: 100,
|
||||
now: () => new Date('2026-05-09T00:00:00Z'),
|
||||
})
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({
|
||||
apps,
|
||||
host: mock.url,
|
||||
cache,
|
||||
now: () => new Date('2026-05-09T00:00:00Z'),
|
||||
})
|
||||
|
||||
await client.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const client2 = new AppMetaClient({
|
||||
apps,
|
||||
host: mock.url,
|
||||
cache,
|
||||
now: () => new Date('2026-05-09T00:00:01Z'),
|
||||
})
|
||||
await client2.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('invalidate forces next get to fetch', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache })
|
||||
|
||||
await client.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
await client.invalidate('app-1')
|
||||
await client.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('no cache: each call hits network', async () => {
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url })
|
||||
|
||||
await client.get('app-1', [FieldInfo])
|
||||
await client.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('corrupt cache entry refetches; valid sibling stays cached; no throw', async () => {
|
||||
const path = cachePath(dir, CACHE_APP_INFO)
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
|
||||
// Seed a real, production-serialized entry by fetching app-1 once (this
|
||||
// calls cache.set → serialize, so we never hand-write the on-disk shape).
|
||||
const seed = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await new AppMetaClient({ apps, host: mock.url, cache: seed }).get('app-1', [FieldInfo])
|
||||
|
||||
// Reuse that serialized entry as a valid sibling; corrupt the app-1 slot.
|
||||
const file = load(await readFile(path, 'utf8')) as { entries: Record<string, unknown> }
|
||||
const validEntry = file.entries[`${mock.url}::app-1`]
|
||||
await writeFile(
|
||||
path,
|
||||
dump({
|
||||
entries: {
|
||||
[`${mock.url}::app-1`]: 'corrupted-string',
|
||||
[`${mock.url}::sibling`]: validEntry,
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
// Reload: app-1 dropped, sibling kept.
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache })
|
||||
|
||||
// app-1 corrupt → dropped → miss → refetched from the mock
|
||||
const a = await client.get('app-1', [FieldInfo])
|
||||
expect(a.info?.id).toBe('app-1')
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// sibling is the real serialized entry → served from cache, no network
|
||||
await client.get('sibling', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { AppReader } from './app-reader'
|
||||
import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { AppMeta, AppMetaFieldKey } from '@/types/app-meta'
|
||||
import { covers, fromDescribe, mergeMeta } from '@/types/app-meta'
|
||||
|
||||
export type AppMetaClientOptions = {
|
||||
readonly apps: AppReader
|
||||
readonly host: string
|
||||
readonly cache?: AppInfoCache
|
||||
readonly now?: () => Date
|
||||
}
|
||||
|
||||
export class AppMetaClient {
|
||||
private readonly apps: AppReader
|
||||
private readonly host: string
|
||||
private readonly cache: AppInfoCache | undefined
|
||||
private readonly now: () => Date
|
||||
|
||||
constructor(opts: AppMetaClientOptions) {
|
||||
this.apps = opts.apps
|
||||
this.host = opts.host
|
||||
this.cache = opts.cache
|
||||
this.now = opts.now ?? (() => new Date())
|
||||
}
|
||||
|
||||
async get(appId: string, fields: readonly AppMetaFieldKey[] = []): Promise<AppMeta> {
|
||||
const cached = this.cache?.get(this.host, appId)
|
||||
if (
|
||||
cached !== undefined &&
|
||||
this.cache?.isFresh(cached, this.now()) === true &&
|
||||
covers(cached.meta, fields)
|
||||
)
|
||||
return cached.meta
|
||||
|
||||
const resp = await this.apps.describe(appId, fields.length === 0 ? undefined : fields)
|
||||
const fresh = fromDescribe(resp, fields)
|
||||
const merged =
|
||||
cached !== undefined && this.cache?.isFresh(cached, this.now()) === true
|
||||
? mergeMeta(cached.meta, fresh)
|
||||
: fresh
|
||||
if (this.cache !== undefined) await this.cache.set(this.host, appId, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
async invalidate(appId: string): Promise<void> {
|
||||
if (this.cache !== undefined) await this.cache.delete(this.host, appId)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import { selectAppReader, SubjectKind, subjectOf } from './app-reader'
|
||||
import { AppsClient } from './apps'
|
||||
import { PermittedExternalAppsClient } from './permitted-external-apps'
|
||||
|
||||
const http = { baseURL: 'https://x', request: async () => new Response() } as unknown as HttpClient
|
||||
|
||||
function ctx(external: boolean): ActiveContext {
|
||||
return {
|
||||
host: 'h',
|
||||
email: 'e',
|
||||
ctx: {
|
||||
account: { id: 'a', email: 'e', name: 'n' },
|
||||
external_subject: external ? { email: 'e', issuer: 'i' } : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('selectAppReader', () => {
|
||||
it('account login → AppsClient', () => {
|
||||
expect(selectAppReader(ctx(false), http)).toBeInstanceOf(AppsClient)
|
||||
expect(subjectOf(ctx(false))).toBe(SubjectKind.Account)
|
||||
})
|
||||
it('external_subject present → PermittedExternalAppsClient', () => {
|
||||
expect(selectAppReader(ctx(true), http)).toBeInstanceOf(PermittedExternalAppsClient)
|
||||
expect(subjectOf(ctx(true))).toBe(SubjectKind.External)
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ListQuery } from './apps'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { AppsClient } from './apps'
|
||||
import { PermittedExternalAppsClient } from './permitted-external-apps'
|
||||
|
||||
export type AppReader = {
|
||||
list: (q: ListQuery) => Promise<AppListResponse>
|
||||
describe: (appId: string, fields?: readonly string[]) => Promise<AppDescribeResponse>
|
||||
}
|
||||
|
||||
// The auth subject behind an openapi bearer token. Each kind reads apps from its own surface.
|
||||
export const SubjectKind = {
|
||||
Account: 'account',
|
||||
External: 'external',
|
||||
} as const
|
||||
|
||||
export type SubjectKindValue = (typeof SubjectKind)[keyof typeof SubjectKind]
|
||||
|
||||
export function subjectOf(active: ActiveContext): SubjectKindValue {
|
||||
return active.ctx.external_subject !== undefined ? SubjectKind.External : SubjectKind.Account
|
||||
}
|
||||
|
||||
type AppReaderFactory = (http: HttpClient) => AppReader
|
||||
|
||||
// Maps each auth subject to the app reader for its surface.
|
||||
const APP_READER_BY_SUBJECT: Readonly<Record<SubjectKindValue, AppReaderFactory>> = {
|
||||
[SubjectKind.Account]: (http) => new AppsClient(http),
|
||||
[SubjectKind.External]: (http) => new PermittedExternalAppsClient(http),
|
||||
}
|
||||
|
||||
export function selectAppReader(active: ActiveContext, http: HttpClient): AppReader {
|
||||
return APP_READER_BY_SUBJECT[subjectOf(active)](http)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { AppRunClient, buildRunBody } from './app-run.js'
|
||||
|
||||
describe('buildRunBody', () => {
|
||||
it('does not include response_mode', () => {
|
||||
expect('response_mode' in buildRunBody({})).toBe(false)
|
||||
})
|
||||
|
||||
it('omits query when message empty', () => {
|
||||
expect('query' in buildRunBody({})).toBe(false)
|
||||
})
|
||||
|
||||
it('maps message → query', () => {
|
||||
expect(buildRunBody({ message: 'hi' }).query).toBe('hi')
|
||||
})
|
||||
|
||||
it('passes through inputs', () => {
|
||||
const body = buildRunBody({ inputs: { a: '1', b: 42 } })
|
||||
expect(body.inputs).toEqual({ a: '1', b: 42 })
|
||||
})
|
||||
|
||||
it('omits conversation_id when missing/empty', () => {
|
||||
expect('conversation_id' in buildRunBody({ conversationId: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('includes workspace_id when set', () => {
|
||||
expect(buildRunBody({ workspaceId: 'ws-1' }).workspace_id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('includes workflow_id when workflowId provided', () => {
|
||||
expect(buildRunBody({ workflowId: 'wf-abc' }).workflow_id).toBe('wf-abc')
|
||||
})
|
||||
|
||||
it('omits workflow_id when workflowId empty', () => {
|
||||
expect('workflow_id' in buildRunBody({ workflowId: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('includes files when provided and non-empty', () => {
|
||||
const files = [{ type: 'image', url: 'http://example.com/img.png' }]
|
||||
expect(buildRunBody({ files }).files).toEqual(files)
|
||||
})
|
||||
|
||||
it('omits files when empty array', () => {
|
||||
expect('files' in buildRunBody({ files: [] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppRunClient.runStream', () => {
|
||||
let mock: DifyMock
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
})
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('yields events for chat app', async () => {
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }))
|
||||
const dec = new TextDecoder()
|
||||
const names: string[] = []
|
||||
const datas: string[] = []
|
||||
for await (const ev of iter) {
|
||||
names.push(ev.name)
|
||||
datas.push(dec.decode(ev.data))
|
||||
}
|
||||
expect(names).toEqual(['message', 'message', 'message_end'])
|
||||
expect(datas[0]).toContain('"answer":"echo: "')
|
||||
expect(datas[1]).toContain('"answer":"hi"')
|
||||
})
|
||||
|
||||
it('throws typed BaseError on non-2xx open', async () => {
|
||||
mock.setScenario('server-5xx')
|
||||
const c = new AppRunClient(testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }))
|
||||
await expect(c.runStream('app-1', buildRunBody({ message: 'hi' }))).rejects.toMatchObject({
|
||||
code: 'server_5xx',
|
||||
})
|
||||
})
|
||||
|
||||
it('aborts when signal fires', async () => {
|
||||
expect.assertions(1)
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const ctrl = new AbortController()
|
||||
const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }), {
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
ctrl.abort()
|
||||
try {
|
||||
for await (const _ of iter) {
|
||||
/* drain */
|
||||
}
|
||||
} catch (e) {
|
||||
expect((e as Error).name).toBe('AbortError')
|
||||
}
|
||||
})
|
||||
|
||||
it('derives event name from JSON event field when SSE event line absent', async () => {
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const iter = await c.runStream('app-2', buildRunBody({ inputs: { x: '1' } }))
|
||||
const names: string[] = []
|
||||
for await (const ev of iter) names.push(ev.name)
|
||||
expect(names).toEqual([
|
||||
'workflow_started',
|
||||
'node_started',
|
||||
'node_finished',
|
||||
'workflow_finished',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppRunClient.stopTask', () => {
|
||||
let mock: DifyMock
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
})
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('resolves without error for known app and task', async () => {
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
await expect(c.stopTask('app-1', 'task-42')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppRunClient.submitHumanInput', () => {
|
||||
let mock: DifyMock
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
})
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('resolves without error', async () => {
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
await expect(
|
||||
c.submitHumanInput('app-1', 'tok-abc', 'approve', { comment: 'looks good' }),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { SseEvent } from '@/http/sse'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
import { parseSSE } from '@/http/sse'
|
||||
import { normalizeDifyStream } from '@/http/sse-dify'
|
||||
|
||||
export type RunBodyArgs = {
|
||||
readonly message?: string
|
||||
readonly inputs?: Readonly<Record<string, unknown>>
|
||||
readonly conversationId?: string
|
||||
readonly workspaceId?: string
|
||||
readonly workflowId?: string
|
||||
readonly files?: readonly Record<string, unknown>[]
|
||||
}
|
||||
|
||||
export function buildRunBody(args: RunBodyArgs): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
inputs: args.inputs ?? {},
|
||||
}
|
||||
if (args.message !== undefined && args.message !== '') body.query = args.message
|
||||
if (args.conversationId !== undefined && args.conversationId !== '')
|
||||
body.conversation_id = args.conversationId
|
||||
if (args.workspaceId !== undefined && args.workspaceId !== '')
|
||||
body.workspace_id = args.workspaceId
|
||||
if (args.workflowId !== undefined && args.workflowId !== '') body.workflow_id = args.workflowId
|
||||
if (args.files !== undefined && args.files.length > 0) body.files = args.files
|
||||
return body
|
||||
}
|
||||
|
||||
export type StreamOptions = {
|
||||
signal?: AbortSignal
|
||||
includeStateSnapshot?: boolean
|
||||
retryOnRateLimit?: boolean
|
||||
}
|
||||
|
||||
export class AppRunClient {
|
||||
private readonly http: HttpClient
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
// Mixed class (SPEC §4.4): runStream / reconnectStream are SSE and stay on the raw
|
||||
// `http.stream` facade; stopTask / submitHumanInput are plain JSON and go through the
|
||||
// generated oRPC contract. Both facades share this one transport.
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async runStream(
|
||||
appId: string,
|
||||
body: Record<string, unknown>,
|
||||
opts: StreamOptions = {},
|
||||
): Promise<AsyncIterable<SseEvent>> {
|
||||
const res = await this.http.stream(`apps/${encodeURIComponent(appId)}:run`, {
|
||||
method: 'POST',
|
||||
json: body,
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
signal: opts.signal,
|
||||
throwOnError: true,
|
||||
retryOnRateLimit: opts.retryOnRateLimit,
|
||||
})
|
||||
if (res.body === null) throw new Error('streaming response body missing')
|
||||
return normalizeDifyStream(parseSSE(res.body, opts.signal))
|
||||
}
|
||||
|
||||
async stopTask(appId: string, taskId: string): Promise<void> {
|
||||
await this.orpc.apps.byAppId.tasks.byTaskId.stop.post({
|
||||
params: { app_id: appId, task_id: taskId },
|
||||
})
|
||||
}
|
||||
|
||||
async submitHumanInput(
|
||||
appId: string,
|
||||
formToken: string,
|
||||
action: string,
|
||||
inputs: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.orpc.apps.byAppId.humanInputForms.byFormToken.submit.post({
|
||||
params: { app_id: appId, form_token: formToken },
|
||||
body: { action, inputs },
|
||||
})
|
||||
}
|
||||
|
||||
async reconnectStream(
|
||||
appId: string,
|
||||
workflowRunId: string,
|
||||
opts: StreamOptions = {},
|
||||
): Promise<AsyncIterable<SseEvent>> {
|
||||
const url = `apps/${encodeURIComponent(appId)}/tasks/${encodeURIComponent(workflowRunId)}/events`
|
||||
const res = await this.http.stream(url, {
|
||||
searchParams: {
|
||||
include_state_snapshot: opts.includeStateSnapshot === true ? 'true' : 'false',
|
||||
continue_on_pause: 'false',
|
||||
},
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
signal: opts.signal,
|
||||
throwOnError: true,
|
||||
})
|
||||
if (res.body === null) throw new Error('reconnect stream body missing')
|
||||
return normalizeDifyStream(parseSSE(res.body, opts.signal))
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { AppsClient } from './apps.js'
|
||||
|
||||
const LIST_BODY = { page: 1, limit: 20, total: 0, has_more: false, data: [] }
|
||||
const DESCRIBE_BODY = {
|
||||
info: { id: 'app-1', name: 'Demo', mode: 'chat', service_api_enabled: true },
|
||||
}
|
||||
|
||||
function makeClient(host: string): AppsClient {
|
||||
return new AppsClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
function queryOf(url: string | undefined): URLSearchParams {
|
||||
return new URL(url ?? '', 'http://x').searchParams
|
||||
}
|
||||
|
||||
describe('AppsClient.list', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('defaults page=1 & limit=20 and always sends workspace_id', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({ workspaceId: 'ws-1' })
|
||||
|
||||
const q = queryOf(stub.captured.url)
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
expect(q.get('workspace_id')).toBe('ws-1')
|
||||
expect(q.get('page')).toBe('1')
|
||||
expect(q.get('limit')).toBe('20')
|
||||
// Optional filters are omitted entirely when not supplied.
|
||||
expect(q.has('mode')).toBe(false)
|
||||
expect(q.has('name')).toBe(false)
|
||||
})
|
||||
|
||||
it('forwards explicit pagination and filters', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({
|
||||
workspaceId: 'ws-1',
|
||||
page: 3,
|
||||
limit: 50,
|
||||
mode: 'chat',
|
||||
name: 'support bot',
|
||||
})
|
||||
|
||||
const q = queryOf(stub.captured.url)
|
||||
expect(q.get('page')).toBe('3')
|
||||
expect(q.get('limit')).toBe('50')
|
||||
expect(q.get('mode')).toBe('chat')
|
||||
expect(q.get('name')).toBe('support bot')
|
||||
})
|
||||
|
||||
it('treats empty-string filters as absent (not blank query params)', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '' })
|
||||
|
||||
const q = queryOf(stub.captured.url)
|
||||
expect(q.has('mode')).toBe(false)
|
||||
expect(q.has('name')).toBe(false)
|
||||
})
|
||||
|
||||
it('propagates server 403 as a classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list({ workspaceId: 'ws-1' })).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 403,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppsClient.describe', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('hits /apps/<id>, omits workspace_id and fields when not given', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
|
||||
const res = await makeClient(stub.url).describe('app-1')
|
||||
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1')
|
||||
const q = queryOf(stub.captured.url)
|
||||
expect(q.has('workspace_id')).toBe(false)
|
||||
expect(q.has('fields')).toBe(false)
|
||||
expect(res.info?.id).toBe('app-1')
|
||||
})
|
||||
|
||||
it('joins fields with commas', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).describe('app-1', ['parameters', 'input_schema'])
|
||||
|
||||
expect(queryOf(stub.captured.url).get('fields')).toBe('parameters,input_schema')
|
||||
})
|
||||
|
||||
it('URL-encodes the app id', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).describe('app/with space')
|
||||
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app%2Fwith%20space')
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import type {
|
||||
AppDescribeResponse,
|
||||
AppListResponse,
|
||||
SupportedAppType,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppReader } from './app-reader'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
export type ListQuery = {
|
||||
readonly workspaceId: string
|
||||
readonly page?: number
|
||||
readonly limit?: number
|
||||
readonly mode?: SupportedAppType | ''
|
||||
readonly name?: string
|
||||
}
|
||||
|
||||
// An absent or empty mode filter means "any mode" — collapse both to undefined for the query.
|
||||
export function normalizeMode(
|
||||
mode: SupportedAppType | '' | undefined,
|
||||
): SupportedAppType | undefined {
|
||||
return mode !== undefined && mode !== '' ? mode : undefined
|
||||
}
|
||||
|
||||
export class AppsClient implements AppReader {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(q: ListQuery): Promise<AppListResponse> {
|
||||
return this.orpc.apps.get({
|
||||
query: {
|
||||
workspace_id: q.workspaceId,
|
||||
page: q.page ?? 1,
|
||||
limit: q.limit ?? 20,
|
||||
mode: normalizeMode(q.mode),
|
||||
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
|
||||
return this.orpc.apps.byAppId.get({
|
||||
params: { app_id: appId },
|
||||
query: {
|
||||
fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { CodeResponse } from './oauth-device.js'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import * as http from 'node:http'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { isBaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { DEFAULT_CLIENT_ID, DeviceFlowApi } from './oauth-device.js'
|
||||
|
||||
type StubServer = {
|
||||
url: string
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
function startStub(
|
||||
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
||||
): Promise<StubServer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer(handler)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address() as AddressInfo
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
stop: () =>
|
||||
new Promise<void>((res, rej) => server.close((err) => (err ? rej(err) : res()))),
|
||||
})
|
||||
})
|
||||
server.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function jsonStub(
|
||||
status: number,
|
||||
body: unknown,
|
||||
): (req: http.IncomingMessage, res: http.ServerResponse) => void {
|
||||
return (_req, res) => {
|
||||
const payload = JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(payload),
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function makeApi(mock: DifyMock): DeviceFlowApi {
|
||||
return new DeviceFlowApi(testHttpClient(mock.url))
|
||||
}
|
||||
|
||||
describe('DeviceFlowApi.requestCode', () => {
|
||||
let mock: DifyMock
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await startMock()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('POSTs to /openapi/v1/oauth/device/code with default client_id', async () => {
|
||||
const api = makeApi(mock)
|
||||
const out = await api.requestCode({ device_label: 'difyctl on host' })
|
||||
expect(out.user_code).toBe('ABCD-1234')
|
||||
expect(out.device_code).toBeDefined()
|
||||
expect(DEFAULT_CLIENT_ID).toBe('difyctl')
|
||||
})
|
||||
|
||||
it('strips trailing slash from host', async () => {
|
||||
const api = new DeviceFlowApi(testHttpClient(`${mock.url}/`))
|
||||
const out = await api.requestCode({ device_label: 'l' })
|
||||
expect(out.device_code).toBeDefined()
|
||||
})
|
||||
|
||||
it('throws BaseError(unsupported_endpoint) on 404', async () => {
|
||||
let stub: StubServer | undefined
|
||||
try {
|
||||
stub = await startStub(jsonStub(404, {}))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
let caught: unknown
|
||||
try {
|
||||
await api.requestCode({ device_label: 'l' })
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.UnsupportedEndpoint)
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty device_label', async () => {
|
||||
const api = makeApi(mock)
|
||||
await expect(api.requestCode({ device_label: '' })).rejects.toThrow(/device_label/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeviceFlowApi.pollOnce', () => {
|
||||
let mock: DifyMock
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await startMock()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('returns approved with token on 200', async () => {
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('approved')
|
||||
if (r.status === 'approved') expect(r.success.token).toBe('dfoa_test')
|
||||
})
|
||||
|
||||
it('maps authorization_pending to pending', async () => {
|
||||
let stub: StubServer | undefined
|
||||
try {
|
||||
stub = await startStub(jsonStub(400, { error: 'authorization_pending' }))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
const r = await api.pollOnce({ device_code: 'dc' })
|
||||
expect(r.status).toBe('pending')
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('maps slow_down to slow_down', async () => {
|
||||
mock.setScenario('slow-down')
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('slow_down')
|
||||
})
|
||||
|
||||
it('maps expired_token to expired', async () => {
|
||||
mock.setScenario('expired')
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('expired')
|
||||
})
|
||||
|
||||
it('maps access_denied to denied', async () => {
|
||||
mock.setScenario('denied')
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('denied')
|
||||
})
|
||||
|
||||
it('throws BaseError(unsupported_endpoint) on 404', async () => {
|
||||
let stub: StubServer | undefined
|
||||
try {
|
||||
stub = await startStub(jsonStub(404, {}))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/device flow/i)
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('signals retryable on 5xx', async () => {
|
||||
mock.setScenario('server-5xx')
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('retry_5xx')
|
||||
})
|
||||
|
||||
it('rejects 200 with empty body', async () => {
|
||||
let stub: StubServer | undefined
|
||||
try {
|
||||
stub = await startStub(jsonStub(200, {}))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/no OAuth envelope|token/i)
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown error code', async () => {
|
||||
let stub: StubServer | undefined
|
||||
try {
|
||||
stub = await startStub(jsonStub(400, { error: 'something_else' }))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/unknown poll error/)
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves dfoe_ token kind in approved branch', async () => {
|
||||
mock.setScenario('sso')
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('approved')
|
||||
if (r.status === 'approved') {
|
||||
expect(r.success.token).toBe('dfoe_test')
|
||||
expect(r.success.subject_type).toBe('external_sso')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeviceFlowApi types', () => {
|
||||
it('CodeResponse has required fields', () => {
|
||||
const r: CodeResponse = {
|
||||
device_code: 'd',
|
||||
user_code: 'u',
|
||||
verification_uri: 'v',
|
||||
expires_in: 1,
|
||||
interval: 1,
|
||||
}
|
||||
expect(r.device_code).toBe('d')
|
||||
})
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { StubServer } from '@test/fixtures/stub-server'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { FileUploadClient } from './file-upload.js'
|
||||
|
||||
const UPLOADED = {
|
||||
id: 'file-1',
|
||||
name: 'hello.png',
|
||||
size: 5,
|
||||
extension: 'png',
|
||||
mime_type: 'image/png',
|
||||
}
|
||||
|
||||
function makeClient(host: string): FileUploadClient {
|
||||
return new FileUploadClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
describe('FileUploadClient.upload', () => {
|
||||
let stub: StubServer
|
||||
let dir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-upload-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('POSTs multipart/form-data (boundary intact, no JSON content-type) and returns the parsed file', async () => {
|
||||
const filePath = join(dir, 'hello.png')
|
||||
await writeFile(filePath, 'hello')
|
||||
stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap))
|
||||
|
||||
const result = await makeClient(stub.url).upload('app-1', filePath)
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/apps/app-1/files')
|
||||
// The client must let fetch own the multipart Content-Type + boundary; it
|
||||
// must NOT coerce this to application/json the way a json body would.
|
||||
const contentType = stub.captured.headers?.['content-type'] ?? ''
|
||||
expect(contentType).toMatch(/^multipart\/form-data; boundary=/)
|
||||
// Bearer still applied on the upload path.
|
||||
expect(stub.captured.headers?.authorization).toBe('Bearer dfoa_test')
|
||||
// The multipart payload carries the filename and the file bytes.
|
||||
expect(stub.captured.body).toContain('filename="hello.png"')
|
||||
expect(stub.captured.body).toContain('hello')
|
||||
expect(result.id).toBe('file-1')
|
||||
})
|
||||
|
||||
it('encodes the app id in the path', async () => {
|
||||
const filePath = join(dir, 'a.txt')
|
||||
await writeFile(filePath, 'x')
|
||||
stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap))
|
||||
|
||||
await makeClient(stub.url).upload('app/with space', filePath)
|
||||
|
||||
expect(stub.captured.url).toBe('/openapi/v1/apps/app%2Fwith%20space/files')
|
||||
})
|
||||
|
||||
it('propagates a server 413 as a classified BaseError', async () => {
|
||||
const filePath = join(dir, 'big.bin')
|
||||
await writeFile(filePath, 'data')
|
||||
stub = await startStubServer((cap) => jsonResponder(413, { error: 'file too large' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).upload('app-1', filePath)).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 413,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, extname } from 'node:path'
|
||||
|
||||
export type UploadedFile = {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
extension: string | null
|
||||
mime_type: string | null
|
||||
}
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
wav: 'audio/wav',
|
||||
amr: 'audio/amr',
|
||||
mpga: 'audio/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
mpeg: 'video/mpeg',
|
||||
webm: 'video/webm',
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
markdown: 'text/markdown',
|
||||
mdx: 'text/markdown',
|
||||
csv: 'text/csv',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
xml: 'application/xml',
|
||||
epub: 'application/epub+zip',
|
||||
vtt: 'text/vtt',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
}
|
||||
|
||||
function mimeFromFilename(filename: string): string {
|
||||
const ext = extname(filename).replace(/^\./, '').toLowerCase()
|
||||
return MIME_MAP[ext] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
export class FileUploadClient {
|
||||
private readonly http: HttpClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
}
|
||||
|
||||
async upload(appId: string, filePath: string): Promise<UploadedFile> {
|
||||
const filename = basename(filePath)
|
||||
const content = await readFile(filePath)
|
||||
const blob = new Blob([content], { type: mimeFromFilename(filename) })
|
||||
const form = new FormData()
|
||||
form.append('file', blob, filename)
|
||||
|
||||
return this.http.post<UploadedFile>(`apps/${encodeURIComponent(appId)}/files`, {
|
||||
body: form,
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
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 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { MembersClient } from './members.js'
|
||||
import { WorkspacesClient } from './workspaces.js'
|
||||
|
||||
function makeClient(host: string): MembersClient {
|
||||
return new MembersClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
describe('MembersClient.list', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('GETs /workspaces/<id>/members and returns parsed envelope', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 1,
|
||||
has_more: false,
|
||||
data: [{ id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' }],
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const result = await makeClient(stub.url).list('ws-1')
|
||||
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members')
|
||||
expect(result.data[0]?.email).toBe('mia@e.com')
|
||||
})
|
||||
|
||||
it('URL-encodes workspace id', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap),
|
||||
)
|
||||
|
||||
await makeClient(stub.url).list('ws with space')
|
||||
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws%20with%20space/members')
|
||||
})
|
||||
|
||||
it('forwards page/limit as query params', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap),
|
||||
)
|
||||
|
||||
await makeClient(stub.url).list('ws-1', { page: 2, limit: 50 })
|
||||
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members?page=2&limit=50')
|
||||
})
|
||||
|
||||
it('propagates server 403 as classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 403,
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates 404 as classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MembersClient.invite', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('POSTs JSON body and returns parsed invite response', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
201,
|
||||
{
|
||||
result: 'success',
|
||||
email: 'new@e.com',
|
||||
role: 'normal',
|
||||
member_id: 'acct-9',
|
||||
invite_url: 'https://console.example.com/activate?email=new&token=tok',
|
||||
tenant_id: 'ws-1',
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const result = await makeClient(stub.url).invite('ws-1', {
|
||||
email: 'new@e.com',
|
||||
role: 'normal',
|
||||
})
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members')
|
||||
expect(JSON.parse(stub.captured.body ?? '{}')).toEqual({
|
||||
email: 'new@e.com',
|
||||
role: 'normal',
|
||||
})
|
||||
expect(result.member_id).toBe('acct-9')
|
||||
expect(result.invite_url).toContain('token=tok')
|
||||
})
|
||||
|
||||
it('propagates 400 (already in tenant) as classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(400, { error: 'already in tenant' }, cap))
|
||||
|
||||
await expect(
|
||||
makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }),
|
||||
).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MembersClient.remove', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('DELETEs member by id and returns success', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap))
|
||||
|
||||
const result = await makeClient(stub.url).remove('ws-1', 'm-1')
|
||||
|
||||
expect(stub.captured.method).toBe('DELETE')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1')
|
||||
expect(result.result).toBe('success')
|
||||
})
|
||||
|
||||
it('propagates 400 (cannot operate self / cannot remove owner)', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(400, { error: 'cannot operate self' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 400,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MembersClient.updateRole', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('PATCHes role payload to the member resource', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap))
|
||||
|
||||
const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' })
|
||||
|
||||
expect(stub.captured.method).toBe('PATCH')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1')
|
||||
expect(JSON.parse(stub.captured.body ?? '{}')).toEqual({ role: 'admin' })
|
||||
expect(result.result).toBe('success')
|
||||
})
|
||||
|
||||
it('propagates 400 (admin cannot demote owner)', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(400, { error: 'no permission' }, cap))
|
||||
|
||||
await expect(
|
||||
makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }),
|
||||
).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacesClient.switch (integration with stub)', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('POSTs /workspaces/<id>:switch and returns workspace detail', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
id: 'ws-1',
|
||||
name: 'Workspace 1',
|
||||
role: 'owner',
|
||||
status: 'normal',
|
||||
current: true,
|
||||
created_at: '2026-05-18T00:00:00Z',
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
|
||||
const result = await client.switch('ws-1')
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1:switch')
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates 404 (non-member)', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap))
|
||||
|
||||
const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
|
||||
await expect(client.switch('ws-x')).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import type {
|
||||
MemberActionResponse,
|
||||
MemberInvitePayload,
|
||||
MemberInviteResponse,
|
||||
MemberListResponse,
|
||||
MemberRoleUpdatePayload,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
/**
|
||||
* Thin client for /openapi/v1/workspaces/<id>/members, over the generated oRPC contract.
|
||||
*
|
||||
* Non-2xx (400/403/404/422) surface as BaseError — the oRPC client maps them at the transport
|
||||
* seam. The CLI's AuthedCommand base layer renders those for the user; clients never swallow codes.
|
||||
*/
|
||||
export class MembersClient {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(
|
||||
workspaceId: string,
|
||||
q?: { page?: number; limit?: number },
|
||||
): Promise<MemberListResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.members.get({
|
||||
params: { workspace_id: workspaceId },
|
||||
query: { page: q?.page, limit: q?.limit },
|
||||
})
|
||||
}
|
||||
|
||||
async invite(workspaceId: string, payload: MemberInvitePayload): Promise<MemberInviteResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.members.post({
|
||||
params: { workspace_id: workspaceId },
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
async remove(workspaceId: string, memberId: string): Promise<MemberActionResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.delete({
|
||||
params: { workspace_id: workspaceId, member_id: memberId },
|
||||
})
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
workspaceId: string,
|
||||
memberId: string,
|
||||
payload: MemberRoleUpdatePayload,
|
||||
): Promise<MemberActionResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.patch({
|
||||
params: { workspace_id: workspaceId, member_id: memberId },
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { MetaClient } from './meta.js'
|
||||
|
||||
describe('MetaClient', () => {
|
||||
let mock: DifyMock
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await startMock()
|
||||
})
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('fetches /openapi/v1/_version without a bearer token', async () => {
|
||||
const client = new MetaClient(testHttpClient(mock.url))
|
||||
const info = await client.serverVersion()
|
||||
|
||||
expect(info.version).toBe('1.6.4')
|
||||
expect(info.edition).toBe('CLOUD')
|
||||
})
|
||||
|
||||
it('honors the auth-expired scenario by allowing the unauthed endpoint anyway', async () => {
|
||||
mock.setScenario('auth-expired')
|
||||
const client = new MetaClient(testHttpClient(mock.url))
|
||||
const info = await client.serverVersion()
|
||||
|
||||
// The meta endpoint is exempt from auth middleware, so an auth-expired
|
||||
// session does not stop the version probe.
|
||||
expect(info.version).toBe('1.6.4')
|
||||
})
|
||||
|
||||
it('returns an empty version string when the server scenario forces it', async () => {
|
||||
mock.setScenario('server-version-empty')
|
||||
const client = new MetaClient(testHttpClient(mock.url))
|
||||
const info = await client.serverVersion()
|
||||
|
||||
expect(info.version).toBe('')
|
||||
expect(info.edition).toBe('COMMUNITY')
|
||||
})
|
||||
|
||||
it('throws when the host has no Dify on it', async () => {
|
||||
// Closed port — connection refused.
|
||||
const client = new MetaClient(testHttpClient('http://127.0.0.1:1'))
|
||||
await expect(client.serverVersion()).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
// Used by every /_version probe call site (the version command and the
|
||||
// per-command auto-nudge). Both must construct their HTTP client with this
|
||||
// timeout + retryAttempts: 0, otherwise the default 30s/3-retry budget kicks in.
|
||||
// The oRPC client below inherits that http instance's policy, so the probe timeout still holds.
|
||||
export const META_PROBE_TIMEOUT_MS = 2000
|
||||
|
||||
export class MetaClient {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async serverVersion(): Promise<ServerVersionResponse> {
|
||||
return this.orpc.version.get()
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { BaseError, HttpClientError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
|
||||
export const DEFAULT_CLIENT_ID = 'difyctl'
|
||||
|
||||
export type CodeRequest = {
|
||||
client_id?: string
|
||||
device_label: string
|
||||
}
|
||||
|
||||
export type CodeResponse = {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export type PollRequest = {
|
||||
client_id?: string
|
||||
device_code: string
|
||||
}
|
||||
|
||||
export type PollAccount = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type PollWorkspace = {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export type PollSuccess = {
|
||||
token: string
|
||||
expires_at?: string
|
||||
subject_type?: string
|
||||
subject_email?: string
|
||||
subject_issuer?: string
|
||||
account?: PollAccount | null
|
||||
workspaces?: readonly PollWorkspace[]
|
||||
default_workspace_id?: string
|
||||
token_id?: string
|
||||
}
|
||||
|
||||
export type PollResult =
|
||||
| { status: 'pending' }
|
||||
| { status: 'slow_down' }
|
||||
| { status: 'expired' }
|
||||
| { status: 'denied' }
|
||||
| { status: 'retry_5xx' }
|
||||
| { status: 'approved'; success: PollSuccess }
|
||||
|
||||
const POLL_ERROR_TO_STATUS: Record<string, PollResult['status']> = {
|
||||
authorization_pending: 'pending',
|
||||
slow_down: 'slow_down',
|
||||
expired_token: 'expired',
|
||||
access_denied: 'denied',
|
||||
}
|
||||
|
||||
export class DeviceFlowApi {
|
||||
private readonly http: HttpClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
}
|
||||
|
||||
async requestCode(req: CodeRequest): Promise<CodeResponse> {
|
||||
if (req.device_label === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'device_label is required',
|
||||
})
|
||||
}
|
||||
const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_label: req.device_label }
|
||||
const res = await this.http.fetch('oauth/device/code', { method: 'POST', json: body })
|
||||
if (res.status === 404) throw versionSkew()
|
||||
if (!res.ok) {
|
||||
throw new HttpClientError({
|
||||
code: ErrorCode.Server4xxOther,
|
||||
message: `device/code: HTTP ${res.status}`,
|
||||
httpStatus: res.status,
|
||||
})
|
||||
}
|
||||
return (await res.json()) as CodeResponse
|
||||
}
|
||||
|
||||
async pollOnce(req: PollRequest): Promise<PollResult> {
|
||||
if (req.device_code === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'device_code is required',
|
||||
})
|
||||
}
|
||||
const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_code: req.device_code }
|
||||
const res = await this.http.fetch('oauth/device/token', { method: 'POST', json: body })
|
||||
if (res.status === 404) throw versionSkew()
|
||||
if (res.status >= 500) return { status: 'retry_5xx' }
|
||||
let payload: { error?: string } & Partial<PollSuccess> = {}
|
||||
try {
|
||||
const text = await res.text()
|
||||
payload = text === '' ? {} : (JSON.parse(text) as typeof payload)
|
||||
} catch (err) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `decode poll response: ${(err as Error).message}`,
|
||||
})
|
||||
}
|
||||
if (typeof payload.error === 'string' && payload.error !== '') {
|
||||
const status = POLL_ERROR_TO_STATUS[payload.error]
|
||||
if (status === undefined) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `unknown poll error "${payload.error}"`,
|
||||
})
|
||||
}
|
||||
return { status } as PollResult
|
||||
}
|
||||
if (typeof payload.token !== 'string' || payload.token === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `poll: ${res.status} with no OAuth envelope`,
|
||||
})
|
||||
}
|
||||
return { status: 'approved', success: payload as PollSuccess }
|
||||
}
|
||||
}
|
||||
|
||||
function versionSkew(): HttpClientError {
|
||||
return new HttpClientError({
|
||||
code: ErrorCode.UnsupportedEndpoint,
|
||||
message: 'this Dify host does not implement the OAuth device flow',
|
||||
httpStatus: 404,
|
||||
})
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { PermittedExternalAppsClient } from './permitted-external-apps'
|
||||
|
||||
function fakeHttp() {
|
||||
return { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
|
||||
}
|
||||
|
||||
type WithOrpc = { orpc: unknown }
|
||||
|
||||
describe('PermittedExternalAppsClient', () => {
|
||||
it('list calls permittedExternalApps.get with paging/filter query', async () => {
|
||||
const c = new PermittedExternalAppsClient(fakeHttp())
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] })
|
||||
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { get: vi.fn() } } }
|
||||
await c.list({ workspaceId: '', page: 2, limit: 5, mode: undefined, name: 'a' })
|
||||
expect(get).toHaveBeenCalledWith({ query: { page: 2, limit: 5, mode: undefined, name: 'a' } })
|
||||
})
|
||||
|
||||
it('describe calls permittedExternalApps.byAppId.get with app_id + fields', async () => {
|
||||
const c = new PermittedExternalAppsClient(fakeHttp())
|
||||
const dget = vi.fn().mockResolvedValue({ info: null, parameters: null, input_schema: null })
|
||||
;(c as unknown as WithOrpc).orpc = {
|
||||
permittedExternalApps: { get: vi.fn(), byAppId: { get: dget } },
|
||||
}
|
||||
await c.describe('app-1', ['info'])
|
||||
expect(dget).toHaveBeenCalledWith({ params: { app_id: 'app-1' }, query: { fields: 'info' } })
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppReader } from './app-reader'
|
||||
import type { ListQuery } from './apps'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
import { normalizeMode } from './apps'
|
||||
|
||||
export class PermittedExternalAppsClient implements AppReader {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
// workspaceId is ignored: the external grant is not workspace-scoped.
|
||||
async list(q: ListQuery): Promise<AppListResponse> {
|
||||
return this.orpc.permittedExternalApps.get({
|
||||
query: {
|
||||
page: q.page ?? 1,
|
||||
limit: q.limit ?? 20,
|
||||
mode: normalizeMode(q.mode),
|
||||
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
|
||||
return this.orpc.permittedExternalApps.byAppId.get({
|
||||
params: { app_id: appId },
|
||||
query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { WorkspacesClient } from './workspaces.js'
|
||||
|
||||
// WorkspacesClient.switch is covered in members.test.ts; this file covers list(), which now
|
||||
// routes through the generated oRPC contract (OpenAPILink over http.request). The happy path
|
||||
// asserts the on-the-wire GET /openapi/v1/workspaces is unchanged; the 401 case asserts the
|
||||
// oRPC client surfaces it as the CLI's classified HttpClientError.
|
||||
|
||||
function makeClient(host: string): WorkspacesClient {
|
||||
return new WorkspacesClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
describe('WorkspacesClient.list', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('GETs /workspaces and returns the parsed list', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
page: 1,
|
||||
limit: 200,
|
||||
total: 1,
|
||||
has_more: false,
|
||||
data: [{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }],
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const res = await makeClient(stub.url).list()
|
||||
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces?page=1&limit=200')
|
||||
expect(res.data[0]?.id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('maps 401 to a classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list()).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 401,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import type {
|
||||
WorkspaceDetailResponse,
|
||||
WorkspaceListResponse,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
import { fetchAllPages, LIMIT_MAX } from '@/limit/limit'
|
||||
|
||||
export class WorkspacesClient {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
// oRPC client over the same transport (UA+bearer / retry / timeout / error-map) — SPEC §4.4:
|
||||
// one transport, a contract facade. Both methods are standard unary JSON, so both go through
|
||||
// the generated contract.
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
/** Every workspace the caller belongs to, all pages merged into one envelope. */
|
||||
async list(): Promise<WorkspaceListResponse> {
|
||||
const data = await fetchAllPages((page) =>
|
||||
this.orpc.workspaces.get({ query: { page, limit: LIMIT_MAX } }),
|
||||
)
|
||||
return { page: 1, limit: LIMIT_MAX, total: data.length, has_more: false, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side workspace switch via OpenAPI POST
|
||||
* `/workspaces/{id}:switch` — the bearer-authed equivalent of the
|
||||
* console's POST `/workspaces/switch`. The server updates the caller's
|
||||
* `current` tenant_account_join row. Callers MUST refresh their local
|
||||
* `hosts.yml` only after this resolves — never fall back to a local
|
||||
* write if the request fails, or `hosts.yml` will drift from the
|
||||
* server's state.
|
||||
*/
|
||||
async switch(workspaceId: string): Promise<WorkspaceDetailResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.switch.post({ params: { workspace_id: workspaceId } })
|
||||
}
|
||||
}
|
||||
121
cli/src/auth/device-api.test.ts
Normal file
121
cli/src/auth/device-api.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { createServer } from 'node:http'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { afterEach, expect, it } from 'vite-plus/test'
|
||||
import { deviceApi } from './device-api'
|
||||
|
||||
const mocks: DifyMock[] = []
|
||||
afterEach(async () => {
|
||||
for (const m of mocks.splice(0)) await m.stop()
|
||||
})
|
||||
|
||||
// A one-shot raw HTTP server for responses the shared dify-mock has no scenario
|
||||
// for (an RFC 8628 pending poll, a malformed code response).
|
||||
async function withServer<T>(
|
||||
handler: (res: ServerResponse) => void,
|
||||
fn: (baseUrl: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const server = createServer((_req, res) => handler(res))
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()))
|
||||
const { port } = server.address() as AddressInfo
|
||||
try {
|
||||
return await fn(`http://127.0.0.1:${port}`)
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('maps a 404 on the code endpoint to a server_error naming the missing feature', async () => {
|
||||
const mock = await startMock({ scenario: 'no-device-flow' })
|
||||
mocks.push(mock)
|
||||
const api = deviceApi(mock.url, { insecure: true })
|
||||
await expect(api.requestCode({ device_label: 'test' })).rejects.toMatchObject({
|
||||
code: 'server_error',
|
||||
message: 'this Dify host does not implement the OAuth device flow',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a non-2xx code response to a server_error carrying the raw body', async () => {
|
||||
const mock = await startMock({ scenario: 'server-5xx' })
|
||||
mocks.push(mock)
|
||||
const api = deviceApi(mock.url, { insecure: true })
|
||||
await expect(api.requestCode({ device_label: 'test' })).rejects.toMatchObject({
|
||||
code: 'server_error',
|
||||
rawResponse: expect.stringContaining('upstream broken'),
|
||||
})
|
||||
})
|
||||
|
||||
it('maps an unreachable server to network_connection', async () => {
|
||||
const mock = await startMock()
|
||||
const { port } = mock
|
||||
await mock.stop()
|
||||
const api = deviceApi(`http://127.0.0.1:${port}`, { insecure: true })
|
||||
await expect(api.requestCode({ device_label: 'test' })).rejects.toMatchObject({
|
||||
code: 'network_connection',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the RFC 8628 poll error codes to their statuses', async () => {
|
||||
const mock = await startMock({ scenario: 'denied' })
|
||||
mocks.push(mock)
|
||||
const api = deviceApi(mock.url, { insecure: true })
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).resolves.toEqual({ status: 'denied' })
|
||||
|
||||
mock.setScenario('expired')
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).resolves.toEqual({ status: 'expired' })
|
||||
|
||||
mock.setScenario('slow-down')
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).resolves.toEqual({ status: 'slow_down' })
|
||||
|
||||
mock.setScenario('happy')
|
||||
const approved = await api.pollOnce({ device_code: 'dc' })
|
||||
expect(approved.status).toBe('approved')
|
||||
})
|
||||
|
||||
it('reports authorization_pending as a pending poll', async () => {
|
||||
await withServer(
|
||||
(res) => {
|
||||
res.writeHead(400, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'authorization_pending' }))
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const api = deviceApi(baseUrl, { insecure: true })
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).resolves.toEqual({ status: 'pending' })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an incomplete code response without leaking a raw parse error', async () => {
|
||||
await withServer(
|
||||
(res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ device_code: 'dc' }))
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const api = deviceApi(baseUrl, { insecure: true })
|
||||
await expect(api.requestCode({ device_label: 'test' })).rejects.toMatchObject({
|
||||
code: 'server_error',
|
||||
message: expect.stringContaining('user_code'),
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a non-JSON code response without leaking a raw SyntaxError', async () => {
|
||||
await withServer(
|
||||
(res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' })
|
||||
res.end('not json')
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const api = deviceApi(baseUrl, { insecure: true })
|
||||
await expect(api.requestCode({ device_label: 'test' })).rejects.toMatchObject({
|
||||
code: 'server_error',
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
206
cli/src/auth/device-api.ts
Normal file
206
cli/src/auth/device-api.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { Dispatcher } from 'undici'
|
||||
import { BaseError, HttpClientError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { buildFetchInit } from '@/net/fetch-init'
|
||||
import { proxyDispatcher } from '@/net/proxy'
|
||||
|
||||
export const DEFAULT_CLIENT_ID = 'difyctl'
|
||||
|
||||
export type CodeRequest = {
|
||||
client_id?: string
|
||||
device_label: string
|
||||
}
|
||||
|
||||
export type CodeResponse = {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export type PollRequest = {
|
||||
client_id?: string
|
||||
device_code: string
|
||||
}
|
||||
|
||||
export type PollAccount = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type PollWorkspace = {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export type PollSuccess = {
|
||||
token: string
|
||||
expires_at?: string
|
||||
subject_type?: string
|
||||
subject_email?: string
|
||||
subject_issuer?: string
|
||||
account?: PollAccount | null
|
||||
workspaces?: readonly PollWorkspace[]
|
||||
default_workspace_id?: string
|
||||
token_id?: string
|
||||
}
|
||||
|
||||
export type PollResult =
|
||||
| { status: 'pending' }
|
||||
| { status: 'slow_down' }
|
||||
| { status: 'expired' }
|
||||
| { status: 'denied' }
|
||||
| { status: 'retry_5xx' }
|
||||
| { status: 'approved'; success: PollSuccess }
|
||||
|
||||
export type DeviceApi = {
|
||||
readonly requestCode: (req: CodeRequest) => Promise<CodeResponse>
|
||||
readonly pollOnce: (req: PollRequest) => Promise<PollResult>
|
||||
}
|
||||
|
||||
export type DeviceApiOptions = Readonly<{ insecure: boolean }>
|
||||
|
||||
const DEVICE_CODE_PATH = '/openapi/v1/oauth/device/code'
|
||||
const DEVICE_TOKEN_PATH = '/openapi/v1/oauth/device/token'
|
||||
const DEVICE_FLOW_UNSUPPORTED_MESSAGE = 'this Dify host does not implement the OAuth device flow'
|
||||
|
||||
const HttpStatus = { NotFound: 404, ServerErrorFloor: 500 } as const
|
||||
|
||||
// RFC 8628 error codes, mapped to the states awaitAuthorization polls on.
|
||||
const POLL_ERROR_STATUS: Readonly<Record<string, PollResult['status']>> = {
|
||||
authorization_pending: 'pending',
|
||||
slow_down: 'slow_down',
|
||||
expired_token: 'expired',
|
||||
access_denied: 'denied',
|
||||
}
|
||||
|
||||
type PollPayload = { error?: string } & Partial<PollSuccess>
|
||||
|
||||
const CODE_RESPONSE_REQUIRED_FIELDS = ['device_code', 'user_code', 'verification_uri'] as const
|
||||
|
||||
function parseCodeResponse(text: string): CodeResponse {
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = text === '' ? {} : (JSON.parse(text) as Record<string, unknown>)
|
||||
} catch (cause) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.ServerError,
|
||||
message: 'device/code response is not valid JSON',
|
||||
cause,
|
||||
})
|
||||
}
|
||||
const missing = CODE_RESPONSE_REQUIRED_FIELDS.filter(
|
||||
(field) => typeof payload[field] !== 'string' || payload[field] === '',
|
||||
)
|
||||
if (missing.length > 0) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.ServerError,
|
||||
message: `device/code response missing ${missing.join(', ')}`,
|
||||
})
|
||||
}
|
||||
return payload as unknown as CodeResponse
|
||||
}
|
||||
|
||||
function unsupportedDeviceFlow(method: string, url: URL): HttpClientError {
|
||||
return new HttpClientError({
|
||||
code: ErrorCode.ServerError,
|
||||
message: DEVICE_FLOW_UNSUPPORTED_MESSAGE,
|
||||
httpStatus: HttpStatus.NotFound,
|
||||
method,
|
||||
url: url.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
async function nonOkError(method: string, url: URL, res: Response): Promise<HttpClientError> {
|
||||
const rawResponse = await res.text()
|
||||
return new HttpClientError({
|
||||
code: ErrorCode.ServerError,
|
||||
message: `${method} ${url.pathname} failed: HTTP ${res.status}`,
|
||||
httpStatus: res.status,
|
||||
method,
|
||||
url: url.toString(),
|
||||
rawResponse,
|
||||
})
|
||||
}
|
||||
|
||||
function parsePollPayload(text: string): PollPayload {
|
||||
if (text === '') return {}
|
||||
try {
|
||||
return JSON.parse(text) as PollPayload
|
||||
} catch (cause) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `decode poll response: ${(cause as Error).message}`,
|
||||
cause,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function deviceApi(server: string, opts: DeviceApiOptions): DeviceApi {
|
||||
const dispatcher: Dispatcher | undefined = opts.insecure
|
||||
? proxyDispatcher({ insecure: true })
|
||||
: proxyDispatcher()
|
||||
|
||||
async function post(path: string, body: unknown): Promise<{ url: URL; res: Response }> {
|
||||
const url = new URL(path, server)
|
||||
const init = buildFetchInit(
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ insecure: opts.insecure, dispatcher },
|
||||
)
|
||||
try {
|
||||
return { url, res: await fetch(url, init) }
|
||||
} catch (cause) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NetworkConnection,
|
||||
message: `network error calling POST ${url}`,
|
||||
cause,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestCode: async (req) => {
|
||||
const { url, res } = await post(DEVICE_CODE_PATH, {
|
||||
client_id: req.client_id ?? DEFAULT_CLIENT_ID,
|
||||
device_label: req.device_label,
|
||||
})
|
||||
if (res.status === HttpStatus.NotFound) throw unsupportedDeviceFlow('POST', url)
|
||||
if (!res.ok) throw await nonOkError('POST', url, res)
|
||||
return parseCodeResponse(await res.text())
|
||||
},
|
||||
pollOnce: async (req) => {
|
||||
const { url, res } = await post(DEVICE_TOKEN_PATH, {
|
||||
client_id: req.client_id ?? DEFAULT_CLIENT_ID,
|
||||
device_code: req.device_code,
|
||||
})
|
||||
if (res.status === HttpStatus.NotFound) throw unsupportedDeviceFlow('POST', url)
|
||||
if (res.status >= HttpStatus.ServerErrorFloor) return { status: 'retry_5xx' }
|
||||
|
||||
const payload = parsePollPayload(await res.text())
|
||||
if (typeof payload.error === 'string' && payload.error !== '') {
|
||||
const status = POLL_ERROR_STATUS[payload.error]
|
||||
if (status === undefined) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `unknown poll error "${payload.error}"`,
|
||||
})
|
||||
}
|
||||
return { status } as PollResult
|
||||
}
|
||||
if (typeof payload.token !== 'string' || payload.token === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `poll: ${res.status} with no OAuth envelope`,
|
||||
})
|
||||
}
|
||||
return { status: 'approved', success: payload as PollSuccess }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { CodeResponse, PollRequest, PollResult, PollSuccess } from './device-api'
|
||||
import type { Clock } from './device-flow'
|
||||
import type { CodeResponse, PollRequest, PollResult, PollSuccess } from '@/api/oauth-device'
|
||||
import { describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { ErrorCode, ExitCode } from '@/errors/codes'
|
||||
import { isCancelled } from '@/plugins/commands/cancel'
|
||||
import {
|
||||
awaitAuthorization,
|
||||
DEFAULT_INTERVAL_MS,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
POLL_RETRY_ATTEMPTS,
|
||||
POLL_RETRY_CAP_MS,
|
||||
POLL_RETRY_INITIAL_MS,
|
||||
realClock,
|
||||
} from './device-flow'
|
||||
|
||||
const successPayload: PollSuccess = {
|
||||
@@ -133,7 +135,15 @@ describe('awaitAuthorization', () => {
|
||||
])
|
||||
const clock = new FakeClock()
|
||||
clock.cancelAt = 2
|
||||
await expect(awaitAuthorization(api, code, { clock })).rejects.toThrow(/expired|cancel/)
|
||||
// Cancelling is neither an expiry (exit 4) nor a usage mistake (exit 2).
|
||||
const err = await awaitAuthorization(api, code, { clock }).catch((e: unknown) => e)
|
||||
expect(err).toMatchObject({ code: 'unknown', message: 'cancelled' })
|
||||
expect((err as BaseError).exit()).toBe(ExitCode.Generic)
|
||||
})
|
||||
|
||||
it('realClock reports the run-wide cancellation flag, not a constant', async () => {
|
||||
expect(realClock().isCancelled).toBe(isCancelled)
|
||||
expect(realClock().isCancelled()).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes constants matching Go reference', () => {
|
||||
@@ -162,9 +172,7 @@ describe('awaitAuthorization', () => {
|
||||
const api = {
|
||||
pollOnce: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new BaseError({ code: ErrorCode.UnsupportedEndpoint, message: 'old server' }),
|
||||
),
|
||||
.mockRejectedValue(new BaseError({ code: ErrorCode.ServerError, message: 'old server' })),
|
||||
}
|
||||
const clock = new FakeClock()
|
||||
await expect(awaitAuthorization(api, code, { clock })).rejects.toThrow(/old server/)
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { CodeResponse, PollRequest, PollResult, PollSuccess } from '@/api/oauth-device'
|
||||
import { DEFAULT_CLIENT_ID } from '@/api/oauth-device'
|
||||
import type { CodeResponse, PollRequest, PollResult, PollSuccess } from './device-api'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { isCancelled } from '@/plugins/commands/cancel'
|
||||
import { DEFAULT_CLIENT_ID } from './device-api'
|
||||
|
||||
export const DEFAULT_INTERVAL_MS = 5_000
|
||||
export const MAX_INTERVAL_MS = 60_000
|
||||
@@ -9,6 +10,8 @@ export const POLL_RETRY_ATTEMPTS = 5
|
||||
export const POLL_RETRY_INITIAL_MS = 1_000
|
||||
export const POLL_RETRY_CAP_MS = 16_000
|
||||
|
||||
const CANCELLED_MESSAGE = 'cancelled'
|
||||
|
||||
export type Clock = {
|
||||
sleepMs: (ms: number) => Promise<void>
|
||||
isCancelled: () => boolean
|
||||
@@ -32,22 +35,38 @@ export async function awaitAuthorization(
|
||||
|
||||
const baseInterval = code.interval > 0 ? code.interval * 1000 : DEFAULT_INTERVAL_MS
|
||||
let interval = baseInterval
|
||||
const req: PollRequest = {
|
||||
device_code: code.device_code,
|
||||
client_id: opts.clientId ?? DEFAULT_CLIENT_ID,
|
||||
}
|
||||
const req = pollRequest(code.device_code, opts)
|
||||
|
||||
while (true) {
|
||||
if (opts.clock.isCancelled()) throw expired()
|
||||
if (opts.clock.isCancelled()) throw cancelledError()
|
||||
const result = await pollWithRetry(api, req, opts.clock)
|
||||
const success = settle(result)
|
||||
if (success !== undefined) return success
|
||||
if (result.status === 'slow_down') interval = Math.min(interval * 2, MAX_INTERVAL_MS)
|
||||
await opts.clock.sleepMs(interval)
|
||||
if (opts.clock.isCancelled()) throw cancelledError()
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollAuthorization(
|
||||
api: DeviceFlowApiSubset,
|
||||
deviceCode: string,
|
||||
opts: AwaitOptions,
|
||||
): Promise<PollSuccess | undefined> {
|
||||
return settle(await pollWithRetry(api, pollRequest(deviceCode, opts), opts.clock))
|
||||
}
|
||||
|
||||
function pollRequest(deviceCode: string, opts: AwaitOptions): PollRequest {
|
||||
return { device_code: deviceCode, client_id: opts.clientId ?? DEFAULT_CLIENT_ID }
|
||||
}
|
||||
|
||||
function settle(result: PollResult): PollSuccess | undefined {
|
||||
switch (result.status) {
|
||||
case 'approved':
|
||||
return result.success
|
||||
case 'pending':
|
||||
break
|
||||
case 'slow_down':
|
||||
interval = Math.min(interval * 2, MAX_INTERVAL_MS)
|
||||
break
|
||||
return undefined
|
||||
case 'expired':
|
||||
throw expired()
|
||||
case 'denied':
|
||||
@@ -57,13 +76,10 @@ export async function awaitAuthorization(
|
||||
})
|
||||
case 'retry_5xx':
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Server5xx,
|
||||
code: ErrorCode.ServerError,
|
||||
message: 'device-flow poll unavailable after retries',
|
||||
})
|
||||
}
|
||||
await opts.clock.sleepMs(interval)
|
||||
if (opts.clock.isCancelled()) throw expired()
|
||||
}
|
||||
}
|
||||
|
||||
async function pollWithRetry(
|
||||
@@ -84,19 +100,22 @@ async function pollWithRetry(
|
||||
|
||||
function expired(): BaseError {
|
||||
return new BaseError({
|
||||
code: ErrorCode.ExpiredToken,
|
||||
code: ErrorCode.AuthExpired,
|
||||
message: 'code expired before authorization',
|
||||
})
|
||||
}
|
||||
|
||||
// Ctrl-C during the wait is neither an expiry nor a usage mistake: the code is still
|
||||
// good, the operator just stopped waiting for it.
|
||||
function cancelledError(): BaseError {
|
||||
return new BaseError({ code: ErrorCode.Unknown, message: CANCELLED_MESSAGE })
|
||||
}
|
||||
|
||||
export function realClock(): Clock {
|
||||
const cancelled = false
|
||||
return {
|
||||
async sleepMs(ms) {
|
||||
await new Promise<void>((r) => setTimeout(r, ms))
|
||||
},
|
||||
isCancelled() {
|
||||
return cancelled
|
||||
},
|
||||
isCancelled,
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import type { AccountContext } from './hosts'
|
||||
import { useTempConfigDir } from '@test/fixtures/config-dir'
|
||||
import { MemStore } from '@test/fixtures/mem-store'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import { AccountContextSchema, notLoggedInError, Registry, RegistrySchema } from './hosts'
|
||||
|
||||
describe('RegistrySchema', () => {
|
||||
it('parses an empty registry with defaults', () => {
|
||||
const reg = RegistrySchema.parse({})
|
||||
expect(reg.token_storage).toBe('file')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.hosts).toEqual({})
|
||||
})
|
||||
|
||||
it('parses a populated multi-host registry', () => {
|
||||
const reg = RegistrySchema.parse({
|
||||
token_storage: 'keychain',
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'bob@corp.com',
|
||||
accounts: {
|
||||
'bob@corp.com': {
|
||||
account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' },
|
||||
workspace: { id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
token_id: 'tok_1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(reg.current_host).toBe('cloud.dify.ai')
|
||||
expect(reg.hosts['cloud.dify.ai']?.current_account).toBe('bob@corp.com')
|
||||
expect(reg.hosts['cloud.dify.ai']?.accounts['bob@corp.com']?.account.name).toBe('Bob')
|
||||
})
|
||||
|
||||
it('defaults a host entry accounts map to {}', () => {
|
||||
const reg = RegistrySchema.parse({ hosts: { h: { current_account: 'x' } } })
|
||||
expect(reg.hosts.h?.accounts).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects unknown token_storage values', () => {
|
||||
expect(() => RegistrySchema.parse({ token_storage: 'cloud' })).toThrow()
|
||||
})
|
||||
|
||||
it('AccountContextSchema keeps optional external_subject', () => {
|
||||
const ctx = AccountContextSchema.parse({
|
||||
account: { id: '', email: 'sso@x.io', name: '' },
|
||||
external_subject: { email: 'sso@x.io', issuer: 'https://issuer' },
|
||||
})
|
||||
expect(ctx.external_subject?.issuer).toBe('https://issuer')
|
||||
})
|
||||
|
||||
it('strips a stale available_workspaces field from legacy contexts', () => {
|
||||
const raw = {
|
||||
account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' },
|
||||
workspace: { id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
{ id: '00000000-0000-0000-0000-000000000002', name: 'Other', role: 'normal' },
|
||||
],
|
||||
} as unknown as Record<string, unknown>
|
||||
const ctx = AccountContextSchema.parse(raw)
|
||||
expect((ctx as Record<string, unknown>).available_workspaces).toBeUndefined()
|
||||
expect(ctx.workspace?.id).toBe('ws-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('notLoggedInError', () => {
|
||||
it('carries the default hint', () => {
|
||||
expect(notLoggedInError().toString()).toMatch(/auth login/)
|
||||
})
|
||||
it('accepts a custom hint', () => {
|
||||
expect(notLoggedInError("run 'difyctl use host'").toString()).toMatch(/use host/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry (pure)', () => {
|
||||
const baseReg = (): Registry => Registry.empty('file')
|
||||
const ctx = (email: string): AccountContext => ({
|
||||
account: { id: `id-${email}`, email, name: email },
|
||||
})
|
||||
|
||||
it('upsert creates host + account; remove drops them', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.upsert('h1', 'b@x', ctx('b@x'))
|
||||
expect(reg.hosts.h1?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(reg.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
reg.remove('h1', 'b@x')
|
||||
expect(reg.hosts.h1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setHost / setAccount set pointers', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
expect(reg.current_host).toBe('h1')
|
||||
expect(reg.hosts.h1?.current_account).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns the active context with scheme', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setScheme('h1', 'http')
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.host).toBe('h1')
|
||||
expect(active?.email).toBe('a@x')
|
||||
expect(active?.scheme).toBe('http')
|
||||
expect(active?.ctx.account.email).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns the active context with insecureTls', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setInsecureTls('h1', true)
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
expect(reg.resolveActive()?.insecureTls).toBe(true)
|
||||
})
|
||||
|
||||
it('setInsecureTls is a no-op for an unknown host', () => {
|
||||
const reg = baseReg()
|
||||
reg.setInsecureTls('missing', true)
|
||||
expect(reg.hosts.missing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolveActive returns undefined for each missing pointer', () => {
|
||||
const reg = baseReg()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('missing')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setHost('h1')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setAccount('missing@x')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('remove unsets pointers when removing the active account', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry.load / Registry.save', () => {
|
||||
useTempConfigDir('difyctl-reg-')
|
||||
|
||||
it('returns an empty registry when nothing saved', async () => {
|
||||
const reg = await Registry.load()
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(Object.keys(reg.hosts)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('round-trips a populated registry', async () => {
|
||||
const reg = Registry.empty('keychain')
|
||||
reg.upsert('cloud.dify.ai', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('a@x')
|
||||
await reg.save()
|
||||
const loaded = await Registry.load()
|
||||
expect(loaded?.current_host).toBe('cloud.dify.ai')
|
||||
expect(loaded?.hosts['cloud.dify.ai']?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry.forget', () => {
|
||||
useTempConfigDir('difyctl-forget-')
|
||||
|
||||
it('drops token + active context, keeps siblings, unsets pointers', async () => {
|
||||
const store = new MemStore()
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
await reg.save()
|
||||
await store.write('h1', 'a@x', 'dfoa_a')
|
||||
|
||||
const active = reg.resolveActive()!
|
||||
await reg.forget(active, store)
|
||||
|
||||
expect(await store.read('h1', 'a@x')).toBe('')
|
||||
const after = await Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { StorageMode } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import { z } from 'zod'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { getHostStore } from '@/store/manager'
|
||||
import { STORAGE_MODES } from '@/store/store'
|
||||
|
||||
const StorageModeSchema = z.enum(STORAGE_MODES)
|
||||
|
||||
export type { StorageMode } from '@/store/store'
|
||||
|
||||
export const AccountSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
email: z.string().default(''),
|
||||
name: z.string().default(''),
|
||||
})
|
||||
export type Account = z.infer<typeof AccountSchema>
|
||||
|
||||
export const WorkspaceSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
})
|
||||
export type Workspace = z.infer<typeof WorkspaceSchema>
|
||||
|
||||
export const ExternalSubjectSchema = z.object({
|
||||
email: z.string(),
|
||||
issuer: z.string(),
|
||||
})
|
||||
export type ExternalSubject = z.infer<typeof ExternalSubjectSchema>
|
||||
|
||||
export const AccountContextSchema = z.object({
|
||||
account: AccountSchema,
|
||||
workspace: WorkspaceSchema.optional(),
|
||||
token_id: z.string().optional(),
|
||||
token_expires_at: z.string().optional(),
|
||||
external_subject: ExternalSubjectSchema.optional(),
|
||||
})
|
||||
export type AccountContext = z.infer<typeof AccountContextSchema>
|
||||
|
||||
export const HostEntrySchema = z.object({
|
||||
scheme: z.string().optional(),
|
||||
insecure_tls: z.boolean().optional(),
|
||||
current_account: z.string().optional(),
|
||||
accounts: z.record(z.string(), AccountContextSchema).default({}),
|
||||
})
|
||||
export type HostEntry = z.infer<typeof HostEntrySchema>
|
||||
|
||||
export const RegistrySchema = z.object({
|
||||
token_storage: StorageModeSchema.default('file'),
|
||||
current_host: z.string().optional(),
|
||||
hosts: z.record(z.string(), HostEntrySchema).default({}),
|
||||
})
|
||||
export type RegistryData = z.infer<typeof RegistrySchema>
|
||||
|
||||
export type ActiveContext = {
|
||||
readonly host: string
|
||||
readonly email: string
|
||||
readonly ctx: AccountContext
|
||||
readonly scheme?: string
|
||||
readonly insecureTls?: boolean
|
||||
}
|
||||
|
||||
export function notLoggedInError(hint = "run 'difyctl auth login'"): BaseError {
|
||||
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
|
||||
}
|
||||
|
||||
export class Registry {
|
||||
private readonly data: RegistryData
|
||||
|
||||
private constructor(data: RegistryData) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
static async load(): Promise<Registry> {
|
||||
const raw = await getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null) return Registry.empty()
|
||||
return new Registry(RegistrySchema.parse(raw))
|
||||
}
|
||||
|
||||
static empty(mode: StorageMode = 'file'): Registry {
|
||||
return new Registry(RegistrySchema.parse({ token_storage: mode, hosts: {} }))
|
||||
}
|
||||
|
||||
static from(data: RegistryData): Registry {
|
||||
return new Registry(data)
|
||||
}
|
||||
|
||||
get hosts(): RegistryData['hosts'] {
|
||||
return this.data.hosts
|
||||
}
|
||||
get current_host(): string | undefined {
|
||||
return this.data.current_host
|
||||
}
|
||||
get token_storage(): StorageMode {
|
||||
return this.data.token_storage
|
||||
}
|
||||
set token_storage(mode: StorageMode) {
|
||||
this.data.token_storage = mode
|
||||
}
|
||||
|
||||
resolveActive(): ActiveContext | undefined {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined || host === '') return undefined
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined) return undefined
|
||||
const email = entry?.current_account
|
||||
if (!email) return undefined
|
||||
const ctx = entry.accounts[email]
|
||||
if (ctx === undefined) return undefined
|
||||
return { host, email, ctx, scheme: entry.scheme, insecureTls: entry.insecure_tls }
|
||||
}
|
||||
|
||||
requireActive(hint?: string): ActiveContext {
|
||||
const active = this.resolveActive()
|
||||
if (active === undefined) throw notLoggedInError(hint)
|
||||
return active
|
||||
}
|
||||
|
||||
upsert(host: string, email: string, ctx: AccountContext): void {
|
||||
const entry = this.data.hosts[host] ?? { accounts: {} }
|
||||
entry.accounts[email] = ctx
|
||||
this.data.hosts[host] = entry
|
||||
}
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined) return
|
||||
const wasActive = entry.current_account === email
|
||||
delete entry.accounts[email]
|
||||
if (wasActive) entry.current_account = undefined
|
||||
if (Object.keys(entry.accounts).length === 0) {
|
||||
delete this.data.hosts[host]
|
||||
if (this.data.current_host === host) this.data.current_host = undefined
|
||||
} else if (wasActive && this.data.current_host === host) {
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
}
|
||||
|
||||
setHost(host: string): void {
|
||||
this.data.current_host = host
|
||||
}
|
||||
|
||||
setAccount(email: string): void {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined) return
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined) entry.current_account = email
|
||||
}
|
||||
|
||||
setScheme(host: string, scheme: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined) entry.scheme = scheme
|
||||
}
|
||||
|
||||
setInsecureTls(host: string, insecure: boolean): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined) entry.insecure_tls = insecure
|
||||
}
|
||||
|
||||
activate(host: string, email: string, ctx: AccountContext): void {
|
||||
this.upsert(host, email, ctx)
|
||||
this.setHost(host)
|
||||
this.setAccount(email)
|
||||
}
|
||||
|
||||
// Teardown for "this credential is gone": drop the token, drop the context
|
||||
// (unsets pointers when active), persist. Logout + self-revoke share it.
|
||||
async forget(active: ActiveContext, store: TokenStore): Promise<void> {
|
||||
try {
|
||||
await store.remove(active.host, active.email)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
this.remove(active.host, active.email)
|
||||
await this.save()
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
await getHostStore().setTyped(RegistrySchema.parse(this.data))
|
||||
}
|
||||
}
|
||||
45
cli/src/auth/logout.ts
Normal file
45
cli/src/auth/logout.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { CommandContext } from '@/plugins/base'
|
||||
import type { Login } from '@/plugins/session'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { errorMessage } from '@/errors/message'
|
||||
import { catalog } from '@/plugins/catalog'
|
||||
import { http } from '@/plugins/http'
|
||||
import { io } from '@/plugins/io'
|
||||
import { session } from '@/plugins/session'
|
||||
import { token } from '@/plugins/token'
|
||||
|
||||
const SESSIONS_SELF_PATH = '/openapi/v1/account/sessions/self'
|
||||
const REVOKE_FAILED_PREFIX = 'server revoke failed: '
|
||||
|
||||
/** Throws when the current login comes from DIFY_SERVER/DIFY_TOKEN, which neither
|
||||
* login nor logout may touch — each passes its own explanation. */
|
||||
export function assertNotEnvLogin(fromEnv: boolean, message: string): void {
|
||||
if (fromEnv) throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message })
|
||||
}
|
||||
|
||||
// Shared by `login` (logging out a previous session before starting a new one)
|
||||
// and `logout` itself: best-effort server-side revoke, then unconditional local
|
||||
// cleanup of the token, the cached catalog and the session pin.
|
||||
//
|
||||
// catalog.clear() must run before session.clear(): the catalog plugin resolves
|
||||
// its cache-file path from session.current() the first time it is built, so
|
||||
// clearing the session first leaves it unable to find its own file.
|
||||
export async function revokeAndClearSession(ctx: CommandContext, login: Login): Promise<void> {
|
||||
const streams = await ctx.get(io)
|
||||
const httpService = await ctx.get(http)
|
||||
try {
|
||||
await httpService.request(() => ({ method: 'DELETE', path: SESSIONS_SELF_PATH }))
|
||||
} catch (err) {
|
||||
streams.notice(`${REVOKE_FAILED_PREFIX}${errorMessage(err)}`)
|
||||
}
|
||||
|
||||
const tokenService = await ctx.get(token)
|
||||
await tokenService.remove(login)
|
||||
|
||||
const catalogService = await ctx.get(catalog)
|
||||
await catalogService.clear()
|
||||
|
||||
const sessionService = await ctx.get(session)
|
||||
await sessionService.clear()
|
||||
}
|
||||
142
cli/src/cache/app-info.test.ts
vendored
142
cli/src/cache/app-info.test.ts
vendored
@@ -1,142 +0,0 @@
|
||||
import type { AppMeta } from '@/types/app-meta'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dump, load } from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_APP_INFO, cachePath, getCache } from '@/store/manager'
|
||||
import { platform } from '@/sys/index'
|
||||
import { FieldInfo, FieldParameters } from '@/types/app-meta'
|
||||
import { APP_INFO_TTL_MS, loadAppInfoCache } from './app-info'
|
||||
|
||||
function appInfoPath(dir: string): string {
|
||||
return cachePath(dir, CACHE_APP_INFO)
|
||||
}
|
||||
|
||||
function metaInfoOnly(id = 'app-1'): AppMeta {
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
name: 'Greeter',
|
||||
description: '',
|
||||
mode: 'chat',
|
||||
updated_at: undefined,
|
||||
service_api_enabled: false,
|
||||
is_agent: false,
|
||||
},
|
||||
parameters: null,
|
||||
inputSchema: null,
|
||||
coveredFields: new Set([FieldInfo]),
|
||||
}
|
||||
}
|
||||
|
||||
describe('app-info disk cache', () => {
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-cache-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
|
||||
else process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('round-trips an entry across reloads', async () => {
|
||||
const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await c1.set('http://localhost:9999', 'app-1', metaInfoOnly())
|
||||
|
||||
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const got = c2.get('http://localhost:9999', 'app-1')
|
||||
expect(got).toBeDefined()
|
||||
expect(got?.meta.info?.id).toBe('app-1')
|
||||
expect(got?.meta.coveredFields.has(FieldInfo)).toBe(true)
|
||||
})
|
||||
|
||||
it('isFresh respects TTL', async () => {
|
||||
const now = new Date('2026-05-09T00:00:00Z')
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), now: () => now })
|
||||
await c.set('h', 'app-1', metaInfoOnly())
|
||||
const r = c.get('h', 'app-1')
|
||||
expect(r).toBeDefined()
|
||||
expect(c.isFresh(r!, now)).toBe(true)
|
||||
expect(c.isFresh(r!, new Date(now.getTime() + APP_INFO_TTL_MS - 1))).toBe(true)
|
||||
expect(c.isFresh(r!, new Date(now.getTime() + APP_INFO_TTL_MS))).toBe(false)
|
||||
expect(c.isFresh(r!, new Date(now.getTime() + APP_INFO_TTL_MS + 60_000))).toBe(false)
|
||||
})
|
||||
|
||||
it('keys by (host, app_id) — different hosts isolate', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await c.set('h1', 'app-1', metaInfoOnly())
|
||||
expect(c.get('h2', 'app-1')).toBeUndefined()
|
||||
expect(c.get('h1', 'app-1')).toBeDefined()
|
||||
})
|
||||
|
||||
it('delete removes entry from disk', async () => {
|
||||
const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await c1.set('h', 'app-1', metaInfoOnly())
|
||||
await c1.delete('h', 'app-1')
|
||||
|
||||
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c2.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('writes file with 0600 permission', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await c.set('h', 'app-1', metaInfoOnly())
|
||||
const { stat } = await import('node:fs/promises')
|
||||
const s = await stat(appInfoPath(dir))
|
||||
if (platform() !== 'win32') expect(s.mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('missing cache file is not an error', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('corrupt cache file is treated as empty', async () => {
|
||||
await writeFile(appInfoPath(dir), ': : not valid yaml', 'utf8')
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a corrupt single entry but keeps valid siblings', async () => {
|
||||
// Seed a real serialized entry via set() — no hand-authored on-disk shape.
|
||||
const seed = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await seed.set('h', 'app-2', metaInfoOnly('app-2'))
|
||||
|
||||
// Inject a corrupt sibling alongside the real one.
|
||||
const file = load(await readFile(appInfoPath(dir), 'utf8')) as {
|
||||
entries: Record<string, unknown>
|
||||
}
|
||||
file.entries['h::app-1'] = 'corrupted-string-not-object'
|
||||
await writeFile(appInfoPath(dir), dump(file), 'utf8')
|
||||
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
expect(c.get('h', 'app-2')?.meta.info?.id).toBe('app-2')
|
||||
})
|
||||
|
||||
it('treats a non-object entries map as empty', async () => {
|
||||
await writeFile(appInfoPath(dir), dump({ entries: 'not-an-object' }), 'utf8')
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates same key in place (no growth)', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await c.set('h', 'app-1', metaInfoOnly())
|
||||
const slim: AppMeta = {
|
||||
...metaInfoOnly(),
|
||||
coveredFields: new Set([FieldInfo, FieldParameters]),
|
||||
parameters: { opening_statement: 'hi' },
|
||||
}
|
||||
await c.set('h', 'app-1', slim)
|
||||
const raw = await readFile(appInfoPath(dir), 'utf8')
|
||||
const parsed = load(raw) as { entries: Record<string, unknown> }
|
||||
expect(Object.keys(parsed.entries)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
129
cli/src/cache/app-info.ts
vendored
129
cli/src/cache/app-info.ts
vendored
@@ -1,129 +0,0 @@
|
||||
import type { Store } from '@/store/store'
|
||||
import type { AppMeta, AppMetaCacheRecord, AppMetaFieldKey } from '@/types/app-meta'
|
||||
import { CACHE_APP_INFO, getCache } from '@/store/manager'
|
||||
import { FieldInfo, FieldInputSchema, FieldParameters } from '@/types/app-meta'
|
||||
|
||||
export const APP_INFO_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
// All entries live under one top-level key; the inner record uses
|
||||
// `host::appId` composites that contain `::` (never `.`), so they're
|
||||
// safe as map keys without colliding with Store's dot-path semantics.
|
||||
const ENTRIES_KEY = { key: 'entries', default: {} as Record<string, DiskEntry> } as const
|
||||
|
||||
type DiskEntry = {
|
||||
meta: SerializedMeta
|
||||
fetched_at: string
|
||||
}
|
||||
|
||||
type SerializedMeta = {
|
||||
info: AppMeta['info']
|
||||
parameters: unknown
|
||||
input_schema: unknown
|
||||
covered_fields: AppMetaFieldKey[]
|
||||
}
|
||||
|
||||
export type AppInfoCache = {
|
||||
get: (host: string, appId: string) => AppMetaCacheRecord | undefined
|
||||
set: (host: string, appId: string, meta: AppMeta) => Promise<void>
|
||||
delete: (host: string, appId: string) => Promise<void>
|
||||
isFresh: (record: AppMetaCacheRecord, now?: Date) => boolean
|
||||
}
|
||||
|
||||
type State = {
|
||||
entries: Map<string, AppMetaCacheRecord>
|
||||
}
|
||||
|
||||
export type AppInfoCacheOptions = {
|
||||
readonly store?: Store
|
||||
readonly ttlMs?: number
|
||||
readonly now?: () => Date
|
||||
}
|
||||
|
||||
export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise<AppInfoCache> {
|
||||
const store = opts.store ?? getCache(CACHE_APP_INFO)
|
||||
const ttlMs = opts.ttlMs ?? APP_INFO_TTL_MS
|
||||
const state: State = { entries: await readEntries(store) }
|
||||
return {
|
||||
get: (host, appId) => state.entries.get(key(host, appId)),
|
||||
set: async (host, appId, meta) => {
|
||||
const record: AppMetaCacheRecord = {
|
||||
meta,
|
||||
fetchedAt: (opts.now ?? (() => new Date()))().toISOString(),
|
||||
}
|
||||
state.entries.set(key(host, appId), record)
|
||||
await writeEntries(store, state.entries)
|
||||
},
|
||||
delete: async (host, appId) => {
|
||||
state.entries.delete(key(host, appId))
|
||||
await writeEntries(store, state.entries)
|
||||
},
|
||||
isFresh: (record, now) => {
|
||||
const t = (now ?? new Date()).getTime() - new Date(record.fetchedAt).getTime()
|
||||
return t >= 0 && t < ttlMs
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function key(host: string, appId: string): string {
|
||||
return `${host}::${appId}`
|
||||
}
|
||||
|
||||
async function readEntries(store: Store): Promise<Map<string, AppMetaCacheRecord>> {
|
||||
const out = new Map<string, AppMetaCacheRecord>()
|
||||
let raw: Record<string, DiskEntry>
|
||||
try {
|
||||
raw = await store.get(ENTRIES_KEY)
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
// A scalar/array survives Object.entries as garbage rather than throwing.
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return out
|
||||
|
||||
for (const [k, e] of Object.entries(raw)) {
|
||||
try {
|
||||
out.set(k, deserialize(e))
|
||||
} catch {
|
||||
// Drop unreadable entry → becomes a cache miss → consumer refetches.
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function deserialize(e: DiskEntry): AppMetaCacheRecord {
|
||||
const covered = new Set<AppMetaFieldKey>(filterFields(e.meta.covered_fields))
|
||||
return {
|
||||
meta: {
|
||||
info: e.meta.info,
|
||||
parameters: e.meta.parameters,
|
||||
inputSchema: e.meta.input_schema,
|
||||
coveredFields: covered,
|
||||
},
|
||||
fetchedAt: e.fetched_at,
|
||||
}
|
||||
}
|
||||
|
||||
function filterFields(input: unknown): AppMetaFieldKey[] {
|
||||
if (!Array.isArray(input)) return []
|
||||
const valid = new Set<AppMetaFieldKey>([FieldInfo, FieldParameters, FieldInputSchema])
|
||||
return input.filter(
|
||||
(s): s is AppMetaFieldKey => typeof s === 'string' && valid.has(s as AppMetaFieldKey),
|
||||
)
|
||||
}
|
||||
|
||||
function serialize(record: AppMetaCacheRecord): DiskEntry {
|
||||
return {
|
||||
meta: {
|
||||
info: record.meta.info,
|
||||
parameters: record.meta.parameters,
|
||||
input_schema: record.meta.inputSchema,
|
||||
covered_fields: [...record.meta.coveredFields],
|
||||
},
|
||||
fetched_at: record.fetchedAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function writeEntries(store: Store, entries: Map<string, AppMetaCacheRecord>): Promise<void> {
|
||||
const out: Record<string, DiskEntry> = {}
|
||||
for (const [k, v] of entries) out[k] = serialize(v)
|
||||
await store.set(ENTRIES_KEY, out)
|
||||
}
|
||||
56
cli/src/cache/compat-store.test.ts
vendored
56
cli/src/cache/compat-store.test.ts
vendored
@@ -1,56 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_COMPAT, getCache } from '@/store/manager'
|
||||
import { loadCompatStore } from './compat-store'
|
||||
|
||||
const HOST = 'https://cloud.dify.ai'
|
||||
const NOW = new Date('2026-05-20T12:00:00.000Z')
|
||||
|
||||
describe('compat-store', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-compat-'))
|
||||
prev = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined) delete process.env[ENV_CACHE_DIR]
|
||||
else process.env[ENV_CACHE_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const store = (now: Date = NOW) =>
|
||||
loadCompatStore({ store: getCache(CACHE_COMPAT), now: () => now })
|
||||
|
||||
it('is not fresh before anything is marked', async () => {
|
||||
expect((await store()).isFreshCompatible(HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it('is fresh right after markCompatible, and persists across loads', async () => {
|
||||
await (await store()).markCompatible(HOST)
|
||||
expect((await store()).isFreshCompatible(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays fresh within the 1h TTL', async () => {
|
||||
const past = new Date(NOW.getTime() - 30 * 60 * 1000)
|
||||
await (await store(past)).markCompatible(HOST)
|
||||
expect((await store(NOW)).isFreshCompatible(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('expires after the 1h TTL', async () => {
|
||||
const past = new Date(NOW.getTime() - 61 * 60 * 1000)
|
||||
await (await store(past)).markCompatible(HOST)
|
||||
expect((await store(NOW)).isFreshCompatible(HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks hosts independently', async () => {
|
||||
const s = await store()
|
||||
await s.markCompatible(HOST)
|
||||
expect(s.isFreshCompatible('https://other.dify.ai')).toBe(false)
|
||||
})
|
||||
})
|
||||
67
cli/src/cache/compat-store.ts
vendored
67
cli/src/cache/compat-store.ts
vendored
@@ -1,67 +0,0 @@
|
||||
import type { Store } from '@/store/store'
|
||||
import { CACHE_COMPAT, getCache } from '@/store/manager'
|
||||
|
||||
// How long a host stays "known compatible" before difyctl re-probes /_version.
|
||||
export const COMPAT_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
// Only *positive* (compatible) verdicts are cached — never "too old". A host that
|
||||
// was too old is re-probed every time, so a just-upgraded server clears a previous
|
||||
// block immediately instead of staying locked out for the whole TTL.
|
||||
const COMPATIBLE_KEY = { key: 'compatible', default: {} as Record<string, string> } as const
|
||||
|
||||
export type CompatStore = {
|
||||
readonly isFreshCompatible: (host: string, now?: Date) => boolean
|
||||
readonly markCompatible: (host: string, now?: Date) => Promise<void>
|
||||
}
|
||||
|
||||
export type CompatStoreOptions = {
|
||||
readonly store?: Store
|
||||
readonly now?: () => Date
|
||||
readonly ttlMs?: number
|
||||
}
|
||||
|
||||
export async function loadCompatStore(opts: CompatStoreOptions = {}): Promise<CompatStore> {
|
||||
const store = opts.store ?? getCache(CACHE_COMPAT)
|
||||
const ttlMs = opts.ttlMs ?? COMPAT_TTL_MS
|
||||
const clock = opts.now ?? (() => new Date())
|
||||
const memory = await readCompatible(store)
|
||||
|
||||
return {
|
||||
isFreshCompatible: (host, now) => {
|
||||
const last = memory.get(host)
|
||||
if (last === undefined) return false
|
||||
const elapsed = Math.max(0, (now ?? clock()).getTime() - last)
|
||||
return elapsed < ttlMs
|
||||
},
|
||||
markCompatible: async (host, now) => {
|
||||
const stamp = (now ?? clock()).getTime()
|
||||
memory.set(host, stamp)
|
||||
// Re-read disk inside the write cycle so concurrent processes touching
|
||||
// different hosts don't clobber each other's stamps.
|
||||
const onDisk = await readCompatible(store)
|
||||
onDisk.set(host, stamp)
|
||||
await writeCompatible(store, onDisk)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readCompatible(store: Store): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>()
|
||||
let raw: Record<string, string>
|
||||
try {
|
||||
raw = await store.get(COMPATIBLE_KEY)
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
for (const [host, iso] of Object.entries(raw)) {
|
||||
const t = Date.parse(iso)
|
||||
if (!Number.isNaN(t)) out.set(host, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function writeCompatible(store: Store, state: Map<string, number>): Promise<void> {
|
||||
const compatible: Record<string, string> = {}
|
||||
for (const [host, t] of state) compatible[host] = new Date(t).toISOString()
|
||||
await store.set(COMPATIBLE_KEY, compatible)
|
||||
}
|
||||
98
cli/src/cache/nudge-store.test.ts
vendored
98
cli/src/cache/nudge-store.test.ts
vendored
@@ -1,98 +0,0 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { load } from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_NUDGE, cachePath, getCache } from '@/store/manager'
|
||||
import { loadNudgeStore, WARN_INTERVAL_MS } from './nudge-store'
|
||||
|
||||
function nudgeStorePath(dir: string): string {
|
||||
return cachePath(dir, CACHE_NUDGE)
|
||||
}
|
||||
|
||||
const HOST = 'https://cloud.dify.ai'
|
||||
|
||||
describe('NudgeStore', () => {
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
|
||||
else process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('canWarn=true when no prior record exists', async () => {
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) })
|
||||
expect(store.canWarn(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('canWarn=false within the silence window, true past it', async () => {
|
||||
const t0 = new Date('2026-05-19T12:00:00.000Z')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
await store.markWarned(HOST)
|
||||
expect(store.canWarn(HOST, new Date('2026-05-19T18:00:00.000Z'))).toBe(false)
|
||||
expect(store.canWarn(HOST, new Date('2026-05-20T12:00:00.000Z'))).toBe(true)
|
||||
})
|
||||
|
||||
it('canWarn clamps negative elapsed under clock skew (treats as still in window)', async () => {
|
||||
const t0 = new Date('2026-05-19T12:00:00.000Z')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
await store.markWarned(HOST)
|
||||
const pastClock = new Date('2026-05-19T11:00:00.000Z') // clock moved backwards 1h
|
||||
expect(store.canWarn(HOST, pastClock)).toBe(false)
|
||||
})
|
||||
|
||||
it('markWarned persists across store reloads', async () => {
|
||||
const t0 = new Date('2026-05-19T12:00:00.000Z')
|
||||
const s1 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
await s1.markWarned(HOST)
|
||||
const s2 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
expect(s2.canWarn(HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a corrupt cache file as empty', async () => {
|
||||
const path = nudgeStorePath(dir)
|
||||
await writeCacheFile(path, '{ not valid json')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) })
|
||||
expect(store.canWarn(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('writes ISO timestamps under warned/<host> on disk', async () => {
|
||||
const t = new Date('2026-05-19T12:00:00.000Z')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
await store.markWarned(HOST)
|
||||
const raw = await readFile(nudgeStorePath(dir), 'utf8')
|
||||
const parsed = load(raw) as Record<string, unknown>
|
||||
expect((parsed.warned as Record<string, string>)[HOST]).toBe(t.toISOString())
|
||||
})
|
||||
|
||||
it('concurrent writers across different hosts: both stamps survive (merge-on-write)', async () => {
|
||||
// Two stores independently loaded (simulating two CLI processes), each
|
||||
// warns about a different host. Without merge-on-write the second writer
|
||||
// would clobber the first.
|
||||
const t = new Date('2026-05-19T12:00:00.000Z')
|
||||
const a = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
const b = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
await a.markWarned('https://a.example')
|
||||
await b.markWarned('https://b.example')
|
||||
const reread = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
expect(reread.canWarn('https://a.example')).toBe(false)
|
||||
expect(reread.canWarn('https://b.example')).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes WARN_INTERVAL_MS as 24h', () => {
|
||||
expect(WARN_INTERVAL_MS).toBe(24 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
async function writeCacheFile(path: string, body: string): Promise<void> {
|
||||
const { mkdir } = await import('node:fs/promises')
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, body)
|
||||
}
|
||||
67
cli/src/cache/nudge-store.ts
vendored
67
cli/src/cache/nudge-store.ts
vendored
@@ -1,67 +0,0 @@
|
||||
import type { Store } from '@/store/store'
|
||||
import { CACHE_NUDGE, getCache } from '@/store/manager'
|
||||
|
||||
export const WARN_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
// Single top-level key holding host→ISO map. Hosts contain dots
|
||||
// (cloud.dify.ai), so we cannot use them as Store paths directly —
|
||||
// `doSet` would split on dots and create nested objects.
|
||||
const WARNED_KEY = { key: 'warned', default: {} as Record<string, string> } as const
|
||||
|
||||
export type NudgeStore = {
|
||||
readonly canWarn: (host: string, now?: Date) => boolean
|
||||
readonly markWarned: (host: string, now?: Date) => Promise<void>
|
||||
}
|
||||
|
||||
export type NudgeStoreOptions = {
|
||||
readonly store?: Store
|
||||
readonly now?: () => Date
|
||||
readonly intervalMs?: number
|
||||
}
|
||||
|
||||
export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise<NudgeStore> {
|
||||
const store = opts.store ?? getCache(CACHE_NUDGE)
|
||||
const intervalMs = opts.intervalMs ?? WARN_INTERVAL_MS
|
||||
const clock = opts.now ?? (() => new Date())
|
||||
const memory = await readWarned(store)
|
||||
|
||||
return {
|
||||
canWarn: (host, now) => {
|
||||
const last = memory.get(host)
|
||||
if (last === undefined) return true
|
||||
const elapsed = Math.max(0, (now ?? clock()).getTime() - last)
|
||||
return elapsed >= intervalMs
|
||||
},
|
||||
markWarned: async (host, now) => {
|
||||
const stamp = (now ?? clock()).getTime()
|
||||
memory.set(host, stamp)
|
||||
// Re-read disk inside the write cycle so concurrent processes touching
|
||||
// different hosts don't clobber each other's stamps. Same-host writers
|
||||
// converge on a near-identical timestamp, so order doesn't matter.
|
||||
const onDisk = await readWarned(store)
|
||||
onDisk.set(host, stamp)
|
||||
await writeWarned(store, onDisk)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readWarned(store: Store): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>()
|
||||
let raw: Record<string, string>
|
||||
try {
|
||||
raw = await store.get(WARNED_KEY)
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
for (const [host, iso] of Object.entries(raw)) {
|
||||
const t = Date.parse(iso)
|
||||
if (!Number.isNaN(t)) out.set(host, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function writeWarned(store: Store, state: Map<string, number>): Promise<void> {
|
||||
const warned: Record<string, string> = {}
|
||||
for (const [host, t] of state) warned[host] = new Date(t).toISOString()
|
||||
await store.set(WARNED_KEY, warned)
|
||||
}
|
||||
7
cli/src/call/errors.ts
Normal file
7
cli/src/call/errors.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
|
||||
/** Every rejection of caller-supplied operation input, from load through request building. */
|
||||
export function inputInvalid(message: string, cause?: unknown): BaseError {
|
||||
return new BaseError({ code: ErrorCode.InputInvalid, message, cause })
|
||||
}
|
||||
196
cli/src/call/execute.test.ts
Normal file
196
cli/src/call/execute.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { TestWorld } from '@test/fixtures/kernel'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { testContext } from '@test/fixtures/kernel'
|
||||
import { afterEach, expect, it } from 'vite-plus/test'
|
||||
import { commands } from '@/plugins/commands'
|
||||
|
||||
const worlds: TestWorld[] = []
|
||||
async function world(argv: string[]) {
|
||||
const w = await testContext({ login: true, argv })
|
||||
worlds.push(w)
|
||||
return w
|
||||
}
|
||||
afterEach(async () => {
|
||||
for (const w of worlds.splice(0)) await w.stop()
|
||||
})
|
||||
|
||||
it('fills the pin, validates, sends the fingerprint and prints a list with the server hint', async () => {
|
||||
const w = await world(['get', 'console_app', '--input', '{"limit":1}'])
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(w.mock.lastRequest?.path).toBe('/openapi/v1/apps?limit=1&workspace_id=ws-1')
|
||||
expect(w.mock.lastRequest?.headers['x-dify-catalog']).toHaveLength(64)
|
||||
expect(JSON.parse(w.io.outBuf()).hints[0]).toMatchObject({
|
||||
op: 'get.console_app',
|
||||
input: { workspace_id: 'ws-1', limit: 1, page: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid input with details and the schema, exit 2, before any op request', async () => {
|
||||
const w = await world(['describe', 'console_app', '--input', '{}'])
|
||||
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'input_invalid',
|
||||
details: [{ type: 'required', loc: ['app_id'] }],
|
||||
schema: expect.objectContaining({ type: 'object' }),
|
||||
})
|
||||
expect(w.mock.requestCount).toBe(1) // only the first-run catalog fetch
|
||||
})
|
||||
|
||||
it('refuses --stream on a non-sse op, exit 2', async () => {
|
||||
const w = await world(['describe', 'console_app', '--input', '{"app_id":"app-1"}', '--stream'])
|
||||
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'usage_invalid_flag',
|
||||
message: expect.stringContaining('--stream'),
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses --only without --stream, exit 2, before any op request', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'workflow',
|
||||
'--input',
|
||||
'{"app_id":"app-2","inputs":{}}',
|
||||
'--only',
|
||||
'workflow_finished',
|
||||
])
|
||||
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'usage_invalid_flag',
|
||||
message: '--only requires --stream',
|
||||
})
|
||||
expect(w.mock.requestCount).toBe(1) // only the first-run catalog fetch
|
||||
})
|
||||
|
||||
it('warns once on stderr for a deprecated op and still runs it', async () => {
|
||||
const w = await world([
|
||||
'legacy_run',
|
||||
'console_app',
|
||||
'--input',
|
||||
'{"app_id":"app-1","inputs":{},"query":"hi"}',
|
||||
])
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(w.io.errBuf().trim()).toBe('deprecated: legacy_run.console_app')
|
||||
expect(JSON.parse(w.io.outBuf())).toMatchObject({
|
||||
status: 'ended',
|
||||
text: { answer: 'echo: hi' },
|
||||
})
|
||||
})
|
||||
|
||||
it('folds a per-mode run and copies the reply hint', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'chat',
|
||||
'--input',
|
||||
'{"app_id":"app-1","inputs":{},"query":"hi"}',
|
||||
])
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(JSON.parse(w.io.outBuf())).toMatchObject({
|
||||
status: 'ended',
|
||||
text: { answer: 'echo: hi' },
|
||||
conversation_id: 'conv-1',
|
||||
hints: [{ op: 'run.console_app.chat', input: { conversation_id: 'conv-1', query: null } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('streams with --stream --only', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'workflow',
|
||||
'--input',
|
||||
'{"app_id":"app-2","inputs":{}}',
|
||||
'--stream',
|
||||
'--only',
|
||||
'workflow_finished',
|
||||
])
|
||||
await (await w.ctx.get(commands)).run()
|
||||
const lines = w.io
|
||||
.outBuf()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => JSON.parse(l))
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0].event).toBe('workflow_finished')
|
||||
})
|
||||
|
||||
it('sends multipart when files are given', async () => {
|
||||
const seed = await testContext({ login: true })
|
||||
worlds.push(seed)
|
||||
const tmp = join(seed.dir, 'r.pdf')
|
||||
writeFileSync(tmp, 'pdf')
|
||||
const w = await testContext({
|
||||
login: true,
|
||||
argv: [
|
||||
'run',
|
||||
'console_app',
|
||||
'workflow',
|
||||
'--input',
|
||||
JSON.stringify({ app_id: 'app-2', inputs: {}, files: { doc: tmp } }),
|
||||
],
|
||||
reuseDirOf: seed,
|
||||
})
|
||||
worlds.push(w)
|
||||
await (await w.ctx.get(commands)).run()
|
||||
expect(w.mock.lastRequest?.contentType).toMatch(/^multipart\/form-data/)
|
||||
expect(w.mock.lastRunParts?.['files[doc]']).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a paused run is exit 0 with status suspended and the server hints', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'workflow',
|
||||
'--input',
|
||||
'{"app_id":"app-2","inputs":{}}',
|
||||
])
|
||||
w.mock.setScenario('hitl-pause')
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(JSON.parse(w.io.outBuf())).toMatchObject({
|
||||
status: 'suspended',
|
||||
hints: [{ op: 'submit.run.form', input: { form_token: 'ft-hitl-1', action: 'submit' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('a stale catalog is refreshed once and the op is retried', async () => {
|
||||
const w = await world(['help'])
|
||||
await (await w.ctx.get(commands)).run()
|
||||
w.mock.setScenario('catalog-changed')
|
||||
const w2 = await testContext({
|
||||
login: true,
|
||||
argv: ['ping', 'workspace', '--input', '{"workspace_id":"ws-1"}'],
|
||||
reuseDirOf: w,
|
||||
})
|
||||
worlds.push(w2)
|
||||
expect(await (await w2.ctx.get(commands)).run()).toBe(0)
|
||||
})
|
||||
|
||||
it('a stream that dies mid-run folds as incomplete, exit 0, with one stderr notice', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'chat',
|
||||
'--input',
|
||||
'{"app_id":"app-1","inputs":{},"query":"hi"}',
|
||||
])
|
||||
w.mock.setScenario('broken-stream')
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(JSON.parse(w.io.outBuf())).toMatchObject({
|
||||
status: 'incomplete',
|
||||
text: { answer: 'partial' },
|
||||
})
|
||||
expect(w.io.errBuf().trim().split('\n')).toHaveLength(1)
|
||||
expect(w.io.errBuf()).toMatch(/^stream ended early: /)
|
||||
})
|
||||
|
||||
it('registers the stream abort as a deferred cleanup', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'workflow',
|
||||
'--input',
|
||||
'{"app_id":"app-2","inputs":{}}',
|
||||
])
|
||||
await (await w.ctx.get(commands)).run()
|
||||
expect(w.ctx.deferred).toHaveLength(1)
|
||||
})
|
||||
96
cli/src/call/execute.ts
Normal file
96
cli/src/call/execute.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { CallFlags } from '@/call/flags'
|
||||
import type { CommandContext } from '@/plugins/base'
|
||||
import type { CatalogOp } from '@/plugins/catalog'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { readLocalFile } from '@/call/files'
|
||||
import { unsupportedFlags } from '@/call/flags'
|
||||
import { applyPins, loadInput, resolveFileRefs } from '@/call/input'
|
||||
import { rendererFor } from '@/call/render/index'
|
||||
import { buildRequest } from '@/call/request'
|
||||
import { validateInput } from '@/call/validate'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { runSignal } from '@/plugins/commands/cancel'
|
||||
import { helpHint, Outcome } from '@/plugins/commands/command'
|
||||
import { http } from '@/plugins/http'
|
||||
import { io } from '@/plugins/io'
|
||||
import { session } from '@/plugins/session'
|
||||
import { KIND } from '@/protocol/kinds'
|
||||
import { spacedId } from '@/protocol/op-id'
|
||||
import { PIN } from '@/protocol/pins'
|
||||
|
||||
/** One catalog operation, its call flags, and the fields typed as flags of their own. */
|
||||
export type CallRequest = Readonly<{
|
||||
id: string
|
||||
op: CatalogOp
|
||||
flags: CallFlags
|
||||
fields: Record<string, unknown>
|
||||
}>
|
||||
|
||||
const FILE_ENCODING = 'utf8'
|
||||
const DEPRECATED_NOTICE = 'deprecated'
|
||||
const ONLY_NEEDS_STREAM = '--only requires --stream'
|
||||
|
||||
function assertSupportedFlags(kind: string, flags: CallFlags): void {
|
||||
const [refused] = unsupportedFlags(kind, flags)
|
||||
if (refused !== undefined) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `--${refused} is not supported by ${kind} operations`,
|
||||
})
|
||||
}
|
||||
// --only filters the events --stream emits; on its own it filters nothing.
|
||||
if (flags.only !== undefined && flags.stream !== true)
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: ONLY_NEEDS_STREAM })
|
||||
}
|
||||
|
||||
async function readAll(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
stream.setEncoding(FILE_ENCODING)
|
||||
let text = ''
|
||||
for await (const chunk of stream) text += chunk
|
||||
return text
|
||||
}
|
||||
|
||||
function assertValidInput(op: CatalogOp, opId: string, input: Record<string, unknown>): void {
|
||||
const details = validateInput(op.input, input)
|
||||
if (details.length === 0) return
|
||||
throw new BaseError({
|
||||
code: ErrorCode.InputInvalid,
|
||||
message: `invalid input for "${opId}"`,
|
||||
hint: helpHint(spacedId(opId)),
|
||||
details: [...details],
|
||||
schema: op.input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function executeCall(req: CallRequest, ctx: CommandContext): Promise<Outcome> {
|
||||
assertSupportedFlags(req.op.kind, req.flags)
|
||||
|
||||
const out = await ctx.get(io)
|
||||
const source = {
|
||||
stdin: () => readAll(out.streams.in),
|
||||
readFile: (path: string) => readFile(path, FILE_ENCODING),
|
||||
}
|
||||
const whole = await loadInput({ ...source, raw: req.flags.input })
|
||||
const fields = await resolveFileRefs(req.fields, req.op.input, source)
|
||||
const workspaceId = await (await ctx.get(session)).workspaceId()
|
||||
const body = applyPins({ ...whole, ...fields }, req.op.input, { [PIN.Workspace]: workspaceId })
|
||||
assertValidInput(req.op, req.id, body)
|
||||
|
||||
if (req.op.deprecated) out.notice(`${DEPRECATED_NOTICE}: ${req.id}`)
|
||||
|
||||
// SIGINT cancels the whole invocation; this controller ends only this call's stream.
|
||||
const call = new AbortController()
|
||||
if (req.op.kind === KIND.Sse) ctx.defer(() => call.abort())
|
||||
const signal = AbortSignal.any([runSignal(), call.signal])
|
||||
|
||||
const res = await (
|
||||
await ctx.get(http)
|
||||
).request(async (cat) => ({
|
||||
...(await buildRequest(cat.opOrThrow(req.id), body, { readFile: readLocalFile })),
|
||||
signal,
|
||||
}))
|
||||
|
||||
const code = await rendererFor(req.op.kind, out)(res, out, req.flags, req.id)
|
||||
return new Outcome(code)
|
||||
}
|
||||
8
cli/src/call/files.ts
Normal file
8
cli/src/call/files.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { FileReader } from './request'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
|
||||
export const readLocalFile: FileReader = async (path) => ({
|
||||
bytes: await readFile(path),
|
||||
name: basename(path),
|
||||
})
|
||||
10
cli/src/call/flags.test.ts
Normal file
10
cli/src/call/flags.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { expect, it } from 'vite-plus/test'
|
||||
import { unsupportedFlags } from './flags'
|
||||
|
||||
it('refuses the flags a kind does not accept and treats an unknown kind as an object', () => {
|
||||
const all = { input: '{}', stream: true, only: ['a'], output: 'f' }
|
||||
expect(unsupportedFlags('sse', all)).toEqual(['output'])
|
||||
expect(unsupportedFlags('file', all)).toEqual(['stream', 'only'])
|
||||
expect(unsupportedFlags('hologram', { stream: true })).toEqual(['stream'])
|
||||
expect(unsupportedFlags('object', { input: '{}' })).toEqual([])
|
||||
})
|
||||
74
cli/src/call/flags.ts
Normal file
74
cli/src/call/flags.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { JsonSchema } from '@/plugins/catalog'
|
||||
import type { Kind } from '@/protocol/kinds'
|
||||
import { z } from 'zod'
|
||||
import { inputSchema } from '@/plugins/argv/parse'
|
||||
import { isKind, KIND, KINDS } from '@/protocol/kinds'
|
||||
|
||||
export const CALL_FLAG = {
|
||||
Input: 'input',
|
||||
Stream: 'stream',
|
||||
Only: 'only',
|
||||
Output: 'output',
|
||||
} as const
|
||||
|
||||
export type CallFlag = (typeof CALL_FLAG)[keyof typeof CALL_FLAG]
|
||||
|
||||
type FlagSpec<S extends z.ZodType> = Readonly<{ schema: S; kinds: readonly Kind[] }>
|
||||
|
||||
function flag<S extends z.ZodType>(schema: S, kinds: readonly Kind[]): FlagSpec<S> {
|
||||
return { schema, kinds }
|
||||
}
|
||||
|
||||
export const CALL_FLAGS = {
|
||||
[CALL_FLAG.Input]: flag(
|
||||
z.string().optional().describe('Operation input as JSON, @file or @- for stdin'),
|
||||
KINDS,
|
||||
),
|
||||
[CALL_FLAG.Stream]: flag(z.boolean().optional().describe('Print each event as it arrives'), [
|
||||
KIND.Sse,
|
||||
]),
|
||||
[CALL_FLAG.Only]: flag(
|
||||
z.array(z.string()).optional().describe('Keep only these streamed events'),
|
||||
[KIND.Sse],
|
||||
),
|
||||
[CALL_FLAG.Output]: flag(z.string().optional().describe('Write the downloaded file here'), [
|
||||
KIND.File,
|
||||
]),
|
||||
} as const
|
||||
|
||||
type SchemasOf<T extends Record<string, FlagSpec<z.ZodType>>> = { [K in keyof T]: T[K]['schema'] }
|
||||
|
||||
function schemasOf<T extends Record<string, FlagSpec<z.ZodType>>>(table: T): SchemasOf<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(table).map(([name, spec]) => [name, spec.schema]),
|
||||
) as SchemasOf<T>
|
||||
}
|
||||
|
||||
export const CALL_FLAG_SCHEMA = z.object(schemasOf(CALL_FLAGS))
|
||||
|
||||
export type CallFlags = z.infer<typeof CALL_FLAG_SCHEMA>
|
||||
|
||||
const CALL_FLAG_NAMES = Object.keys(CALL_FLAGS) as readonly CallFlag[]
|
||||
|
||||
// An unknown kind is rendered — and so flagged — as an object.
|
||||
function renderedKind(kind: string): Kind {
|
||||
return isKind(kind) ? kind : KIND.Object
|
||||
}
|
||||
|
||||
export function unsupportedFlags(kind: string, flags: CallFlags): CallFlag[] {
|
||||
const renderedAs = renderedKind(kind)
|
||||
return CALL_FLAG_NAMES.filter(
|
||||
(name) => flags[name] !== undefined && !CALL_FLAGS[name].kinds.includes(renderedAs),
|
||||
)
|
||||
}
|
||||
|
||||
/** The call flags an operation of this kind accepts, as a JSON Schema. */
|
||||
export function callOptionsSchema(kind: string): JsonSchema {
|
||||
const renderedAs = renderedKind(kind)
|
||||
const shape = Object.fromEntries(
|
||||
Object.entries(CALL_FLAGS)
|
||||
.filter(([, spec]) => spec.kinds.includes(renderedAs))
|
||||
.map(([name, spec]) => [name, spec.schema]),
|
||||
)
|
||||
return inputSchema(z.object(shape))
|
||||
}
|
||||
93
cli/src/call/fold.test.ts
Normal file
93
cli/src/call/fold.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { expect, it } from 'vite-plus/test'
|
||||
import { exitCodeFor, finishFold, foldEvent, newFoldResult } from './fold'
|
||||
|
||||
function run(events: [string, Record<string, unknown>][]) {
|
||||
const r = newFoldResult()
|
||||
for (const [n, e] of events) foldEvent(r, n, e)
|
||||
return { result: finishFold(r), code: exitCodeFor(r) }
|
||||
}
|
||||
|
||||
it('folds a chat run and copies the reply hint from message_end', () => {
|
||||
const hint = {
|
||||
summary: 'Reply in this conversation',
|
||||
op: 'run.console_app.chat',
|
||||
input: { app_id: 'a', conversation_id: 'c1', query: null, inputs: {} },
|
||||
}
|
||||
const { result, code } = run([
|
||||
['message', { answer: 'a' }],
|
||||
['message', { answer: 'b' }],
|
||||
['message_end', { message_id: 'm1', conversation_id: 'c1', hints: [hint] }],
|
||||
])
|
||||
expect(result).toEqual({
|
||||
status: 'ended',
|
||||
text: { answer: 'ab' },
|
||||
message_id: 'm1',
|
||||
conversation_id: 'c1',
|
||||
hints: [hint],
|
||||
})
|
||||
expect(code).toBe(0)
|
||||
})
|
||||
|
||||
it('folds a failed workflow without an error event', () => {
|
||||
const { result, code } = run([
|
||||
[
|
||||
'workflow_finished',
|
||||
{ data: { status: 'failed', outputs: {}, total_tokens: 0, error: 'inputs.q is required' } },
|
||||
],
|
||||
])
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toEqual({ message: 'inputs.q is required' })
|
||||
expect(code).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps text_chunk previews only when incomplete', () => {
|
||||
const incomplete = run([
|
||||
['text_chunk', { data: { text: 'so far', from_variable_selector: ['llm_1', 'text'] } }],
|
||||
])
|
||||
expect(incomplete.result).toEqual({
|
||||
status: 'incomplete',
|
||||
text: { answer: '', by_source: { 'llm_1.text': 'so far' } },
|
||||
hints: [],
|
||||
})
|
||||
const ended = run([
|
||||
['text_chunk', { data: { text: 'x', from_variable_selector: ['n', 't'] } }],
|
||||
['workflow_finished', { data: { status: 'succeeded', outputs: { r: 1 } } }],
|
||||
])
|
||||
expect(ended.result.text).toEqual({ answer: '' })
|
||||
expect(ended.result.outputs).toEqual({ r: 1 })
|
||||
})
|
||||
|
||||
it('a late error still wins and unknown events are ignored', () => {
|
||||
expect(
|
||||
run([
|
||||
['message_end', {}],
|
||||
['error', { message: 'late', code: 'x' }],
|
||||
]).result.status,
|
||||
).toBe('failed')
|
||||
expect(run([['node_started', { id: 'n1' }]]).result.status).toBe('incomplete')
|
||||
})
|
||||
|
||||
it('copies server hints from a pause and suspends; a pause without hints is still suspended', () => {
|
||||
const hint = {
|
||||
summary: 'Submit',
|
||||
op: 'submit.run.form',
|
||||
input: { app_id: 'a', form_token: 'ft', action: 'submit', inputs: { name: null } },
|
||||
form: [{ output_variable_name: 'name' }],
|
||||
}
|
||||
const paused = run([
|
||||
['message', { answer: 'x' }],
|
||||
[
|
||||
'human_input_required',
|
||||
{ data: { form_token: 'ft', actions: [{ id: 'submit' }] }, hints: [hint] },
|
||||
],
|
||||
])
|
||||
expect(paused.result).toEqual({ status: 'suspended', text: { answer: 'x' }, hints: [hint] })
|
||||
expect(paused.code).toBe(0)
|
||||
const bare = run([
|
||||
[
|
||||
'human_input_required',
|
||||
{ data: { form_token: 'ft', actions: [], approval_channels: ['email'] } },
|
||||
],
|
||||
])
|
||||
expect(bare.result).toEqual({ status: 'suspended', text: { answer: '' }, hints: [] })
|
||||
})
|
||||
22
cli/src/call/fold.ts
Normal file
22
cli/src/call/fold.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ExitCodeValue } from '@/errors/codes'
|
||||
import type { FoldResult } from '@/protocol/fold'
|
||||
import { ExitCode } from '@/errors/codes'
|
||||
import { FOLD_STATUS, FOLD_TABLE } from '@/protocol/fold'
|
||||
|
||||
export function newFoldResult(): FoldResult {
|
||||
return { status: FOLD_STATUS.Incomplete, text: { answer: '' }, hints: [] }
|
||||
}
|
||||
|
||||
export function foldEvent(result: FoldResult, name: string, event: Record<string, unknown>): void {
|
||||
const handler = FOLD_TABLE[name]
|
||||
if (handler !== undefined) handler(result, event)
|
||||
}
|
||||
|
||||
export function finishFold(result: FoldResult): FoldResult {
|
||||
if (result.status !== FOLD_STATUS.Incomplete) delete result.text.by_source
|
||||
return result
|
||||
}
|
||||
|
||||
export function exitCodeFor(result: FoldResult): ExitCodeValue {
|
||||
return result.status === FOLD_STATUS.Failed ? ExitCode.Generic : ExitCode.Success
|
||||
}
|
||||
102
cli/src/call/input.test.ts
Normal file
102
cli/src/call/input.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { expect, it } from 'vite-plus/test'
|
||||
import { applyPins, loadInput, resolveFileRefs } from './input'
|
||||
|
||||
const src = (raw: string | undefined, stdin = '', files: Record<string, string> = {}) => ({
|
||||
raw,
|
||||
stdin: async () => stdin,
|
||||
readFile: async (p: string) => {
|
||||
const f = files[p]
|
||||
if (f === undefined) throw new Error('ENOENT')
|
||||
return f
|
||||
},
|
||||
})
|
||||
|
||||
it('parses inline json, @file and @- and defaults to {}', async () => {
|
||||
expect(await loadInput(src('{"a":1}'))).toEqual({ a: 1 })
|
||||
expect(await loadInput(src('@in.json', '', { 'in.json': '{"b":2}' }))).toEqual({ b: 2 })
|
||||
expect(await loadInput(src('@-', '{"c":3}'))).toEqual({ c: 3 })
|
||||
expect(await loadInput(src(undefined))).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects non-object json and unreadable files with input_invalid', async () => {
|
||||
await expect(loadInput(src('[1]'))).rejects.toMatchObject({ code: 'input_invalid' })
|
||||
await expect(loadInput(src('nope'))).rejects.toMatchObject({ code: 'input_invalid' })
|
||||
await expect(loadInput(src('@missing.json'))).rejects.toMatchObject({
|
||||
code: 'input_invalid',
|
||||
message: expect.stringContaining('missing.json'),
|
||||
})
|
||||
})
|
||||
|
||||
const REF_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
inputs: { type: 'object', properties: {} },
|
||||
files: { type: 'array', items: { type: 'object', properties: {} } },
|
||||
query: { type: 'string' },
|
||||
},
|
||||
}
|
||||
|
||||
const refs = (stdin: string, files: Record<string, string> = {}) => ({
|
||||
stdin: async () => stdin,
|
||||
readFile: async (p: string) => {
|
||||
const f = files[p]
|
||||
if (f === undefined) throw new Error('ENOENT')
|
||||
return f
|
||||
},
|
||||
})
|
||||
|
||||
it('resolves @file and @- only on non-scalar declared fields', async () => {
|
||||
expect(
|
||||
await resolveFileRefs(
|
||||
{ inputs: '@in.json', files: '@-', query: '@keep', other: '@untouched' },
|
||||
REF_SCHEMA,
|
||||
refs('[{"b":2}]', { 'in.json': '{"a":1}' }),
|
||||
),
|
||||
).toEqual({ inputs: { a: 1 }, files: [{ b: 2 }], query: '@keep', other: '@untouched' })
|
||||
})
|
||||
|
||||
it('an array field takes an array from its file', async () => {
|
||||
expect(
|
||||
await resolveFileRefs(
|
||||
{ files: '@files.json' },
|
||||
REF_SCHEMA,
|
||||
refs('', { 'files.json': '[{"c":3}]' }),
|
||||
),
|
||||
).toEqual({ files: [{ c: 3 }] })
|
||||
})
|
||||
|
||||
it('reports an unreadable reference as input_invalid', async () => {
|
||||
await expect(
|
||||
resolveFileRefs({ inputs: '@missing.json' }, REF_SCHEMA, refs('')),
|
||||
).rejects.toMatchObject({
|
||||
code: 'input_invalid',
|
||||
message: expect.stringContaining('missing.json'),
|
||||
})
|
||||
})
|
||||
|
||||
it('names the field flag when a reference is not valid JSON', async () => {
|
||||
await expect(
|
||||
resolveFileRefs({ files: '@files.json' }, REF_SCHEMA, refs('', { 'files.json': 'nope' })),
|
||||
).rejects.toMatchObject({
|
||||
code: 'input_invalid',
|
||||
message: expect.stringContaining('--files'),
|
||||
})
|
||||
})
|
||||
|
||||
it('fills pins only for declared, absent fields', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: { workspace_id: { type: 'string' }, page: { type: 'integer' } },
|
||||
}
|
||||
expect(applyPins({ page: 2 }, schema, { workspace_id: 'ws-1' })).toEqual({
|
||||
page: 2,
|
||||
workspace_id: 'ws-1',
|
||||
})
|
||||
expect(applyPins({ workspace_id: 'mine' }, schema, { workspace_id: 'ws-1' })).toEqual({
|
||||
workspace_id: 'mine',
|
||||
})
|
||||
expect(
|
||||
applyPins({}, { type: 'object', properties: { page: {} } }, { workspace_id: 'ws-1' }),
|
||||
).toEqual({})
|
||||
expect(applyPins({}, schema, { workspace_id: null })).toEqual({})
|
||||
})
|
||||
91
cli/src/call/input.ts
Normal file
91
cli/src/call/input.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { JsonSchema } from '@/plugins/catalog'
|
||||
import { inputInvalid } from '@/call/errors'
|
||||
import { errorMessage } from '@/errors/message'
|
||||
import { isScalar, propertiesOf, shapeOf } from '@/protocol/shape'
|
||||
import { isRecord } from '@/util/is-record'
|
||||
|
||||
export type InputSource = {
|
||||
readonly raw: string | undefined
|
||||
readonly stdin: () => Promise<string>
|
||||
readonly readFile: (p: string) => Promise<string>
|
||||
}
|
||||
|
||||
export type FileRefSource = Pick<InputSource, 'stdin' | 'readFile'>
|
||||
|
||||
/** Text read from an `@…` reference, with the name errors about it should use. */
|
||||
type Referenced = Readonly<{ text: string; source: string }>
|
||||
|
||||
const FILE_PREFIX = '@'
|
||||
const STDIN_MARKER = '-'
|
||||
const STDIN_SOURCE = `${FILE_PREFIX}${STDIN_MARKER}`
|
||||
const FLAG_PREFIX = '--'
|
||||
const INLINE_SOURCE = '--input'
|
||||
const STDIN_LABEL = 'stdin'
|
||||
|
||||
export function parseJsonValue(raw: string, source: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch (cause) {
|
||||
throw inputInvalid(`${source}: not valid JSON (${errorMessage(cause)})`, cause)
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonObject(raw: string, source: string): Record<string, unknown> {
|
||||
const parsed = parseJsonValue(raw, source)
|
||||
if (!isRecord(parsed)) throw inputInvalid(`${INLINE_SOURCE} must be a JSON object`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function readRef(ref: string, src: FileRefSource): Promise<Referenced> {
|
||||
if (ref === STDIN_SOURCE) return { text: await src.stdin(), source: STDIN_LABEL }
|
||||
const path = ref.slice(FILE_PREFIX.length)
|
||||
try {
|
||||
return { text: await src.readFile(path), source: path }
|
||||
} catch (cause) {
|
||||
throw inputInvalid(`cannot read input file "${path}"`, cause)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadInput(src: InputSource): Promise<Record<string, unknown>> {
|
||||
if (src.raw === undefined) return {}
|
||||
if (!src.raw.startsWith(FILE_PREFIX)) return parseJsonObject(src.raw, INLINE_SOURCE)
|
||||
const { text, source } = await readRef(src.raw, src)
|
||||
return parseJsonObject(text, source)
|
||||
}
|
||||
|
||||
// A non-scalar field typed as a flag keeps its raw `@…` string through the parser,
|
||||
// which cannot read files; it becomes the referenced JSON only here. Any JSON value will
|
||||
// do — the field's own schema, not this function, says what shape it must have.
|
||||
export async function resolveFileRefs(
|
||||
fields: Record<string, unknown>,
|
||||
schema: JsonSchema,
|
||||
src: FileRefSource,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const properties = propertiesOf(schema)
|
||||
const resolved = { ...fields }
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
const property = properties[name]
|
||||
if (property === undefined) continue
|
||||
if (typeof value !== 'string' || !value.startsWith(FILE_PREFIX)) continue
|
||||
if (isScalar(shapeOf(property))) continue
|
||||
const { text } = await readRef(value, src)
|
||||
resolved[name] = parseJsonValue(text, `${FLAG_PREFIX}${name}`)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function applyPins(
|
||||
input: Record<string, unknown>,
|
||||
schema: JsonSchema,
|
||||
pins: Readonly<Record<string, string | null>>,
|
||||
): Record<string, unknown> {
|
||||
const properties = propertiesOf(schema)
|
||||
const result = { ...input }
|
||||
for (const [key, pin] of Object.entries(pins)) {
|
||||
if (pin === null) continue
|
||||
if (!(key in properties)) continue
|
||||
if (key in result) continue
|
||||
result[key] = pin
|
||||
}
|
||||
return result
|
||||
}
|
||||
25
cli/src/call/json-body.ts
Normal file
25
cli/src/call/json-body.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Printable } from '@/sys/io/view'
|
||||
import { HttpClientError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
|
||||
const CONTENT_TYPE_HEADER = 'content-type'
|
||||
const NON_JSON_BODY_PREVIEW_LENGTH = 200
|
||||
|
||||
/** The one guard for a response the catalog promised would be JSON. */
|
||||
export async function parseJsonBody(res: Response): Promise<Printable> {
|
||||
const text = await res.text()
|
||||
try {
|
||||
return JSON.parse(text) as Printable
|
||||
} catch (cause) {
|
||||
const contentType = res.headers.get(CONTENT_TYPE_HEADER)
|
||||
throw new HttpClientError({
|
||||
code: ErrorCode.ServerError,
|
||||
message:
|
||||
contentType !== null
|
||||
? `response is not JSON (content-type: ${contentType})`
|
||||
: 'response is not JSON',
|
||||
cause,
|
||||
rawResponse: text.slice(0, NON_JSON_BODY_PREVIEW_LENGTH),
|
||||
})
|
||||
}
|
||||
}
|
||||
53
cli/src/call/render/file.ts
Normal file
53
cli/src/call/render/file.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Renderer } from './index'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { unknownError } from '@/errors/base'
|
||||
import { renderJson } from './object'
|
||||
|
||||
const CONTENT_DISPOSITION_HEADER = 'content-disposition'
|
||||
const CONTENT_TYPE_HEADER = 'content-type'
|
||||
const UNKNOWN_EXTENSION = 'bin'
|
||||
|
||||
const EXTENSIONS: Readonly<Record<string, string>> = {
|
||||
'application/json': 'json',
|
||||
'application/x-yaml': 'yaml',
|
||||
'application/yaml': 'yaml',
|
||||
'text/yaml': 'yaml',
|
||||
'text/csv': 'csv',
|
||||
'text/plain': 'txt',
|
||||
'application/zip': 'zip',
|
||||
'application/pdf': 'pdf',
|
||||
}
|
||||
|
||||
const QUOTED_FILENAME = /filename="([^"]+)"/
|
||||
const BARE_FILENAME = /filename=([^;]+)/
|
||||
|
||||
function filenameFromContentDisposition(value: string | null): string | undefined {
|
||||
if (value === null) return undefined
|
||||
return QUOTED_FILENAME.exec(value)?.[1] ?? BARE_FILENAME.exec(value)?.[1]?.trim()
|
||||
}
|
||||
|
||||
function extensionOf(contentType: string | null): string {
|
||||
const mediaType = contentType?.split(';')[0]?.trim().toLowerCase() ?? ''
|
||||
return EXTENSIONS[mediaType] ?? UNKNOWN_EXTENSION
|
||||
}
|
||||
|
||||
export const fileRenderer: Renderer = async (res, io, options, opId) => {
|
||||
const bytes = new Uint8Array(await res.arrayBuffer())
|
||||
const contentType = res.headers.get(CONTENT_TYPE_HEADER)
|
||||
const filename =
|
||||
filenameFromContentDisposition(res.headers.get(CONTENT_DISPOSITION_HEADER)) ??
|
||||
`${opId}.${extensionOf(contentType)}`
|
||||
const path = options.output ?? join(process.cwd(), filename)
|
||||
|
||||
try {
|
||||
await writeFile(path, bytes)
|
||||
} catch (cause) {
|
||||
throw unknownError(`failed to write file: ${path}`, cause)
|
||||
}
|
||||
|
||||
// A content type the server never sent is absent, not empty.
|
||||
const receipt: Record<string, unknown> = { path, size: bytes.byteLength }
|
||||
if (contentType !== null) receipt.content_type = contentType
|
||||
return renderJson(io, receipt)
|
||||
}
|
||||
31
cli/src/call/render/index.ts
Normal file
31
cli/src/call/render/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { CallFlags } from '@/call/flags'
|
||||
import type { ExitCodeValue } from '@/errors/codes'
|
||||
import type { IOService } from '@/plugins/io'
|
||||
import type { Kind } from '@/protocol/kinds'
|
||||
import { isKind, KIND } from '@/protocol/kinds'
|
||||
import { fileRenderer } from './file'
|
||||
import { listRenderer } from './list'
|
||||
import { objectRenderer } from './object'
|
||||
import { sseRenderer } from './sse'
|
||||
import { textRenderer } from './text'
|
||||
|
||||
export type Renderer = (
|
||||
res: Response,
|
||||
io: IOService,
|
||||
flags: CallFlags,
|
||||
opId: string,
|
||||
) => Promise<ExitCodeValue>
|
||||
|
||||
const RENDERERS: Readonly<Record<Kind, Renderer>> = {
|
||||
[KIND.Object]: objectRenderer,
|
||||
[KIND.List]: listRenderer,
|
||||
[KIND.Sse]: sseRenderer,
|
||||
[KIND.Text]: textRenderer,
|
||||
[KIND.File]: fileRenderer,
|
||||
}
|
||||
|
||||
export function rendererFor(kind: string, io: IOService): Renderer {
|
||||
if (isKind(kind)) return RENDERERS[kind]
|
||||
io.notice(`unknown kind ${kind}, printing as object`)
|
||||
return objectRenderer
|
||||
}
|
||||
6
cli/src/call/render/list.ts
Normal file
6
cli/src/call/render/list.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Renderer } from './index'
|
||||
import { objectRenderer } from './object'
|
||||
|
||||
// A list body (page/limit/total/has_more/data/hints) prints exactly like an
|
||||
// object body: the parsed JSON re-serialised, indented.
|
||||
export const listRenderer: Renderer = objectRenderer
|
||||
13
cli/src/call/render/object.ts
Normal file
13
cli/src/call/render/object.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Renderer } from './index'
|
||||
import type { ExitCodeValue } from '@/errors/codes'
|
||||
import type { IOService } from '@/plugins/io'
|
||||
import type { Printable } from '@/sys/io/view'
|
||||
import { parseJsonBody } from '@/call/json-body'
|
||||
import { ExitCode } from '@/errors/codes'
|
||||
|
||||
export async function renderJson(io: IOService, value: Printable): Promise<ExitCodeValue> {
|
||||
await io.document(value)
|
||||
return ExitCode.Success
|
||||
}
|
||||
|
||||
export const objectRenderer: Renderer = async (res, io) => renderJson(io, await parseJsonBody(res))
|
||||
140
cli/src/call/render/render.test.ts
Normal file
140
cli/src/call/render/render.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { CallFlags } from '@/call/flags'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vite-plus/test'
|
||||
import { ioService } from '@/plugins/io'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { rendererFor } from './index'
|
||||
|
||||
const sse = (events: object[]) =>
|
||||
new Response(events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(''), {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
})
|
||||
|
||||
const OP_ID = 'thing.export'
|
||||
|
||||
function render(kind: string, res: Response, flags: CallFlags = {}) {
|
||||
const buf = bufferStreams()
|
||||
const io = ioService(buf)
|
||||
return { buf, code: rendererFor(kind, io)(res, io, flags, OP_ID) }
|
||||
}
|
||||
|
||||
it('object and list print the body unchanged, hints included', async () => {
|
||||
const a = render('object', Response.json({ a: 1 }))
|
||||
expect(await a.code).toBe(0)
|
||||
expect(JSON.parse(a.buf.outBuf())).toEqual({ a: 1 })
|
||||
const body = {
|
||||
page: 1,
|
||||
limit: 2,
|
||||
total: 5,
|
||||
has_more: true,
|
||||
data: [],
|
||||
hints: [{ summary: 'Next page', op: 'x.y', input: { page: 2, limit: 2 } }],
|
||||
}
|
||||
const b = render('list', Response.json(body))
|
||||
await b.code
|
||||
expect(JSON.parse(b.buf.outBuf())).toEqual(body)
|
||||
})
|
||||
|
||||
it('object rejects a non-JSON body as a server error and writes nothing', async () => {
|
||||
const res = new Response('<html>', { headers: { 'content-type': 'text/html' } })
|
||||
const r = render('object', res)
|
||||
await expect(r.code).rejects.toMatchObject({ code: 'server_error' })
|
||||
expect(r.buf.outBuf()).toBe('')
|
||||
})
|
||||
|
||||
it('sse folds by default and streams with --stream --only', async () => {
|
||||
const events = [
|
||||
{ event: 'message', answer: 'hi' },
|
||||
{ event: 'node_finished', data: { id: 'n' } },
|
||||
{ event: 'message_end', message_id: 'm' },
|
||||
]
|
||||
const a = render('sse', sse(events))
|
||||
expect(await a.code).toBe(0)
|
||||
expect(JSON.parse(a.buf.outBuf())).toMatchObject({
|
||||
status: 'ended',
|
||||
text: { answer: 'hi' },
|
||||
message_id: 'm',
|
||||
})
|
||||
const b = render('sse', sse(events), { stream: true, only: ['node_finished'] })
|
||||
await b.code
|
||||
expect(
|
||||
b.buf
|
||||
.outBuf()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => JSON.parse(l)),
|
||||
).toEqual([{ event: 'node_finished', data: { id: 'n' } }])
|
||||
})
|
||||
|
||||
it('sse exit code follows the fold in both modes', async () => {
|
||||
const events = [{ event: 'error', message: 'boom', code: 'x' }]
|
||||
expect(await render('sse', sse(events)).code).toBe(1)
|
||||
expect(await render('sse', sse(events), { stream: true }).code).toBe(1)
|
||||
})
|
||||
|
||||
it('unknown kind renders as object with one warning line', async () => {
|
||||
const r = render('hologram', Response.json({ z: 1 }))
|
||||
expect(await r.code).toBe(0)
|
||||
expect(r.buf.errBuf()).toMatch(/unknown kind hologram/)
|
||||
})
|
||||
|
||||
it('text writes raw; file writes to --output and prints the receipt', async () => {
|
||||
const t = render('text', new Response('a: 1\n', { headers: { 'content-type': 'text/plain' } }))
|
||||
await t.code
|
||||
expect(t.buf.outBuf()).toBe('a: 1\n')
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-file-'))
|
||||
const out = join(dir, 'x.bin')
|
||||
const f = render(
|
||||
'file',
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
headers: {
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-disposition': 'attachment; filename="r.bin"',
|
||||
},
|
||||
}),
|
||||
{ output: out },
|
||||
)
|
||||
await f.code
|
||||
expect(readFileSync(out)).toEqual(Buffer.from([1, 2, 3]))
|
||||
expect(JSON.parse(f.buf.outBuf())).toEqual({
|
||||
path: out,
|
||||
size: 3,
|
||||
content_type: 'application/octet-stream',
|
||||
})
|
||||
})
|
||||
|
||||
it('file omits content_type when the server sent none', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-file-'))
|
||||
const out = join(dir, 'y.bin')
|
||||
const res = new Response(new Uint8Array([7]))
|
||||
res.headers.delete('content-type')
|
||||
const r = render('file', res, { output: out })
|
||||
await r.code
|
||||
expect(JSON.parse(r.buf.outBuf())).toEqual({ path: out, size: 1 })
|
||||
})
|
||||
|
||||
it('file names an unnamed download after the op and its content type', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-file-'))
|
||||
const cwd = process.cwd()
|
||||
process.chdir(dir)
|
||||
const here = process.cwd()
|
||||
try {
|
||||
const yaml = render(
|
||||
'file',
|
||||
new Response('a: 1', { headers: { 'content-type': 'application/x-yaml' } }),
|
||||
)
|
||||
await yaml.code
|
||||
expect(JSON.parse(yaml.buf.outBuf())).toMatchObject({ path: join(here, `${OP_ID}.yaml`) })
|
||||
const blob = render(
|
||||
'file',
|
||||
new Response(new Uint8Array([1]), { headers: { 'content-type': 'application/vnd.unknown' } }),
|
||||
)
|
||||
await blob.code
|
||||
expect(JSON.parse(blob.buf.outBuf())).toMatchObject({ path: join(here, `${OP_ID}.bin`) })
|
||||
} finally {
|
||||
process.chdir(cwd)
|
||||
}
|
||||
})
|
||||
46
cli/src/call/render/sse.ts
Normal file
46
cli/src/call/render/sse.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Renderer } from './index'
|
||||
import { exitCodeFor, finishFold, foldEvent, newFoldResult } from '@/call/fold'
|
||||
import { parseSSE } from '@/call/sse'
|
||||
import { normalizeDifyStream } from '@/call/sse-dify'
|
||||
import { isBrokenPipe } from '@/errors/envelope'
|
||||
import { errorMessage } from '@/errors/message'
|
||||
import { isRecord } from '@/util/is-record'
|
||||
|
||||
const dec = new TextDecoder()
|
||||
const STREAM_ENDED_EARLY_PREFIX = 'stream ended early: '
|
||||
|
||||
function parseEventData(data: Uint8Array): Record<string, unknown> {
|
||||
try {
|
||||
const obj = JSON.parse(dec.decode(data)) as unknown
|
||||
return isRecord(obj) ? obj : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function matchesOnly(name: string, only: readonly string[] | undefined): boolean {
|
||||
return only === undefined || only.length === 0 || only.includes(name)
|
||||
}
|
||||
|
||||
// A run that dies mid-stream still folds to whatever arrived: the events already
|
||||
// seen are real, so they are reported as an `incomplete` result rather than thrown
|
||||
// away behind a network error.
|
||||
export const sseRenderer: Renderer = async (res, io, options) => {
|
||||
const result = newFoldResult()
|
||||
const stream = options.stream ?? false
|
||||
|
||||
if (res.body !== null) {
|
||||
try {
|
||||
for await (const ev of normalizeDifyStream(parseSSE(res.body))) {
|
||||
foldEvent(result, ev.name, parseEventData(ev.data))
|
||||
if (stream && matchesOnly(ev.name, options.only)) await io.raw(`${dec.decode(ev.data)}\n`)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBrokenPipe(err)) throw err
|
||||
io.notice(`${STREAM_ENDED_EARLY_PREFIX}${errorMessage(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!stream) await io.document(finishFold(result))
|
||||
return exitCodeFor(result)
|
||||
}
|
||||
9
cli/src/call/render/text.ts
Normal file
9
cli/src/call/render/text.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Renderer } from './index'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { ExitCode } from '@/errors/codes'
|
||||
|
||||
export const textRenderer: Renderer = async (res, io) => {
|
||||
const bytes = await res.arrayBuffer()
|
||||
await io.raw(Buffer.from(bytes))
|
||||
return ExitCode.Success
|
||||
}
|
||||
63
cli/src/call/request.test.ts
Normal file
63
cli/src/call/request.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { CatalogOp } from '@/plugins/catalog'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vite-plus/test'
|
||||
import { parseCatalog } from '@/plugins/catalog'
|
||||
import { buildRequest } from './request'
|
||||
|
||||
const doc = parseCatalog(readFileSync(join(__dirname, '../../test/fixtures/catalog.json')))
|
||||
const run = doc.ops['run.console_app.workflow']!
|
||||
const list = doc.ops['get.console_app']!
|
||||
const readFile = async (p: string) => ({
|
||||
bytes: new TextEncoder().encode(`bytes-of-${p}`),
|
||||
name: p.split('/').pop() ?? p,
|
||||
})
|
||||
const deps = { readFile }
|
||||
|
||||
it('splits path, query and body', async () => {
|
||||
const req = await buildRequest(list, { workspace_id: 'ws 1', page: 2 }, deps)
|
||||
expect(req).toMatchObject({
|
||||
method: 'GET',
|
||||
path: '/openapi/v1/apps',
|
||||
query: { workspace_id: 'ws 1', page: '2' },
|
||||
})
|
||||
expect(req.json).toBeUndefined()
|
||||
const post = await buildRequest(
|
||||
run,
|
||||
{ app_id: 'a/1', inputs: { q: 'hi' }, workflow_id: 'w' },
|
||||
deps,
|
||||
)
|
||||
expect(post.path).toBe('/openapi/v1/apps/a%2F1/workflow:run')
|
||||
expect(post.json).toEqual({ inputs: { q: 'hi' }, workflow_id: 'w' })
|
||||
expect(post.form).toBeUndefined()
|
||||
})
|
||||
|
||||
it('switches to multipart when a file field is present', async () => {
|
||||
const req = await buildRequest(
|
||||
run,
|
||||
{
|
||||
app_id: 'a1',
|
||||
inputs: { q: 'x' },
|
||||
files: { doc: './r.pdf', pages: ['./1.png', './2.png'] },
|
||||
attachments: ['./att.txt'],
|
||||
},
|
||||
deps,
|
||||
)
|
||||
expect(req.json).toBeUndefined()
|
||||
const form = req.form!
|
||||
expect(form.get('inputs')).toBe('{"q":"x"}')
|
||||
expect((form.get('files[doc]') as File).name).toBe('r.pdf')
|
||||
expect(form.getAll('files[pages][]').map((f) => (f as File).name)).toEqual(['1.png', '2.png'])
|
||||
expect((form.get('attachments[]') as File).name).toBe('att.txt')
|
||||
})
|
||||
|
||||
it('refuses unknown bind values and non-path file values before sending', async () => {
|
||||
const weird: CatalogOp = { ...run, bind: { ...run.bind, inputs: 'header' } }
|
||||
await expect(buildRequest(weird, { app_id: 'a', inputs: {} }, deps)).rejects.toMatchObject({
|
||||
code: 'input_invalid',
|
||||
message: expect.stringContaining('header'),
|
||||
})
|
||||
await expect(
|
||||
buildRequest(run, { app_id: 'a', inputs: {}, files: { doc: 42 } }, deps),
|
||||
).rejects.toMatchObject({ code: 'input_invalid' })
|
||||
})
|
||||
182
cli/src/call/request.ts
Normal file
182
cli/src/call/request.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { CatalogOp } from '@/plugins/catalog'
|
||||
import type { HttpRequest } from '@/plugins/http'
|
||||
import type { Bind } from '@/protocol/bind'
|
||||
import { inputInvalid } from '@/call/errors'
|
||||
import { BIND, isBind } from '@/protocol/bind'
|
||||
import { isRecord } from '@/util/is-record'
|
||||
|
||||
export type FileReader = (
|
||||
path: string,
|
||||
) => Promise<{ readonly bytes: Uint8Array; readonly name: string }>
|
||||
|
||||
const LIST_SUFFIX = '[]'
|
||||
|
||||
const HttpMethod = { Get: 'GET', Delete: 'DELETE' } as const
|
||||
const BODYLESS_METHODS: ReadonlySet<string> = new Set(Object.values(HttpMethod))
|
||||
|
||||
const Placement = { Path: 'path', Query: 'query', Body: 'body', File: 'file' } as const
|
||||
type PlacementValue = (typeof Placement)[keyof typeof Placement]
|
||||
|
||||
const BIND_PLACEMENT: Readonly<Record<Bind, PlacementValue>> = {
|
||||
[BIND.Path]: Placement.Path,
|
||||
[BIND.Query]: Placement.Query,
|
||||
[BIND.Body]: Placement.Body,
|
||||
[BIND.File]: Placement.File,
|
||||
}
|
||||
|
||||
type Buckets = Readonly<Record<PlacementValue, Map<string, unknown>>>
|
||||
|
||||
function defaultPlacement(method: string): PlacementValue {
|
||||
return BODYLESS_METHODS.has(method) ? Placement.Query : Placement.Body
|
||||
}
|
||||
|
||||
// Every input key is placed before anything else is built: an unsupported bind
|
||||
// value must fail before a byte of the request exists.
|
||||
function classify(op: CatalogOp, input: Record<string, unknown>): Buckets {
|
||||
const buckets: Buckets = {
|
||||
[Placement.Path]: new Map(),
|
||||
[Placement.Query]: new Map(),
|
||||
[Placement.Body]: new Map(),
|
||||
[Placement.File]: new Map(),
|
||||
}
|
||||
const fallback = defaultPlacement(op.method)
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
const bindValue = op.bind[key]
|
||||
if (bindValue === undefined) {
|
||||
buckets[fallback].set(key, value)
|
||||
continue
|
||||
}
|
||||
if (!isBind(bindValue))
|
||||
throw inputInvalid(`bind value ${bindValue} for ${key} is not supported by this difyctl`)
|
||||
buckets[BIND_PLACEMENT[bindValue]].set(key, value)
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
function substitutePath(path: string, values: Map<string, unknown>): string {
|
||||
return path.replace(/\{([^{}]+)\}/g, (placeholder, name: string) =>
|
||||
values.has(name) ? encodeURIComponent(String(values.get(name))) : placeholder,
|
||||
)
|
||||
}
|
||||
|
||||
function toQueryValue(value: unknown): string | readonly string[] | undefined {
|
||||
if (value === null || value === undefined) return undefined
|
||||
if (Array.isArray(value)) return value.map((item) => String(item))
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function buildQuery(values: Map<string, unknown>): HttpRequest['query'] {
|
||||
const query: Record<string, string | readonly string[]> = {}
|
||||
for (const [key, value] of values) {
|
||||
const converted = toQueryValue(value)
|
||||
if (converted !== undefined) query[key] = converted
|
||||
}
|
||||
return Object.keys(query).length === 0 ? undefined : query
|
||||
}
|
||||
|
||||
function buildBodyObject(values: Map<string, unknown>): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {}
|
||||
for (const [key, value] of values) {
|
||||
if (value === undefined) continue
|
||||
body[key] = value
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function partName(field: string, key: string | undefined, many: boolean): string {
|
||||
const base = key === undefined ? field : `${field}[${key}]`
|
||||
return many ? `${base}${LIST_SUFFIX}` : base
|
||||
}
|
||||
|
||||
async function readAsFile(path: string, readFile: FileReader): Promise<File> {
|
||||
let resolved: { bytes: Uint8Array; name: string }
|
||||
try {
|
||||
resolved = await readFile(path)
|
||||
} catch (cause) {
|
||||
throw inputInvalid(`cannot read file "${path}"`, cause)
|
||||
}
|
||||
return new File([Uint8Array.from(resolved.bytes)], resolved.name)
|
||||
}
|
||||
|
||||
function assertPathString(value: unknown, field: string): asserts value is string {
|
||||
if (typeof value !== 'string')
|
||||
throw inputInvalid(
|
||||
`file field "${field}" must be a path string, an array of paths, or an object mapping names to paths`,
|
||||
)
|
||||
}
|
||||
|
||||
async function appendFileParts(
|
||||
form: FormData,
|
||||
name: string,
|
||||
value: unknown,
|
||||
field: string,
|
||||
readFile: FileReader,
|
||||
): Promise<void> {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
assertPathString(item, field)
|
||||
form.append(name, await readAsFile(item, readFile))
|
||||
}
|
||||
return
|
||||
}
|
||||
assertPathString(value, field)
|
||||
form.append(name, await readAsFile(value, readFile))
|
||||
}
|
||||
|
||||
async function appendFileField(
|
||||
form: FormData,
|
||||
field: string,
|
||||
value: unknown,
|
||||
readFile: FileReader,
|
||||
): Promise<void> {
|
||||
if (value === null || value === undefined) return
|
||||
if (isRecord(value)) {
|
||||
for (const [key, item] of Object.entries(value))
|
||||
await appendFileParts(
|
||||
form,
|
||||
partName(field, key, Array.isArray(item)),
|
||||
item,
|
||||
`${field}.${key}`,
|
||||
readFile,
|
||||
)
|
||||
return
|
||||
}
|
||||
await appendFileParts(
|
||||
form,
|
||||
partName(field, undefined, Array.isArray(value)),
|
||||
value,
|
||||
field,
|
||||
readFile,
|
||||
)
|
||||
}
|
||||
|
||||
function hasFileValue(values: Map<string, unknown>): boolean {
|
||||
return [...values.values()].some((value) => value !== null && value !== undefined)
|
||||
}
|
||||
|
||||
export async function buildRequest(
|
||||
op: CatalogOp,
|
||||
input: Record<string, unknown>,
|
||||
deps: { readonly readFile: FileReader },
|
||||
): Promise<HttpRequest> {
|
||||
const buckets = classify(op, input)
|
||||
const path = substitutePath(op.path, buckets[Placement.Path])
|
||||
const query = buildQuery(buckets[Placement.Query])
|
||||
|
||||
if (!hasFileValue(buckets[Placement.File])) {
|
||||
const bodyObj = buildBodyObject(buckets[Placement.Body])
|
||||
const isEmptyBodylessRequest =
|
||||
BODYLESS_METHODS.has(op.method) && Object.keys(bodyObj).length === 0
|
||||
return { method: op.method, path, query, json: isEmptyBodylessRequest ? undefined : bodyObj }
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
for (const [key, value] of buckets[Placement.Body]) {
|
||||
if (value === undefined) continue
|
||||
form.append(key, JSON.stringify(value))
|
||||
}
|
||||
for (const [field, value] of buckets[Placement.File])
|
||||
await appendFileField(form, field, value, deps.readFile)
|
||||
|
||||
return { method: op.method, path, query, form }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user