From a4524c215f299989e6488b8b3dc2c9c1b199489e Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:58:58 +0000 Subject: [PATCH] feat(cli): difyctl v2 over the server catalog (#42608) Co-authored-by: yunlu.wen --- .gitattributes | 3 + .github/workflows/cli-edge.yml | 1 + .github/workflows/cli-release.yml | 5 - .github/workflows/cli-smoke.yml | 2 +- .github/workflows/main-ci.yml | 2 + api/controllers/openapi/_models.py | 2 +- api/controllers/openapi/account.py | 8 +- api/controllers/openapi/app_dsl.py | 8 +- api/controllers/openapi/app_run.py | 10 +- api/controllers/openapi/apps.py | 4 +- .../openapi/apps_permitted_external.py | 4 +- api/controllers/openapi/files.py | 2 +- api/controllers/openapi/human_input_form.py | 4 +- api/controllers/openapi/workflow_events.py | 2 +- api/controllers/openapi/workspaces.py | 14 +- .../base_app_generate_response_converter.py | 2 +- api/openapi/markdown/openapi-openapi.md | 8 +- .../openapi/test_app_run_streaming.py | 2 +- .../controllers/openapi/test_auth_matrix.py | 12 +- .../controllers/openapi/test_catalog.py | 18 +- .../openapi/test_human_input_form.py | 2 +- .../openapi/test_workspaces_members.py | 2 +- cli/AGENTS.md | 10 +- cli/ARD.md | 282 +--- cli/README.md | 100 +- cli/bin/dev.js | 8 +- cli/bin/run.ts | 22 +- cli/package.json | 13 +- cli/scripts/generate-command-tree.test.ts | 2 +- cli/scripts/generate-command-tree.ts | 25 +- cli/scripts/install-r2.test.ts | 1 - cli/scripts/lib/resolve-buildinfo.ts | 7 +- cli/scripts/release-build.sh | 13 - cli/scripts/release-guards.test.ts | 120 -- cli/scripts/release-naming.mjs | 93 +- cli/scripts/release-naming.test.ts | 76 +- cli/scripts/release-r2-edge.mjs | 3 +- cli/scripts/release-r2-edge.test.ts | 8 +- cli/scripts/run-smoke.ts | 53 +- cli/src/api/account-sessions.test.ts | 85 - cli/src/api/account-sessions.ts | 24 - cli/src/api/account.test.ts | 46 - cli/src/api/account.ts | 16 - cli/src/api/app-dsl.test.ts | 123 -- cli/src/api/app-dsl.ts | 59 - cli/src/api/app-meta.test.ts | 151 -- cli/src/api/app-meta.ts | 48 - cli/src/api/app-reader.test.ts | 30 - cli/src/api/app-reader.ts | 35 - cli/src/api/app-run.test.ts | 144 -- cli/src/api/app-run.ts | 102 -- cli/src/api/apps.test.ts | 114 -- cli/src/api/apps.ts | 53 - cli/src/api/device-flow.test.ts | 216 --- cli/src/api/file-upload.test.ts | 76 - cli/src/api/file-upload.ts | 72 - cli/src/api/members.test.ts | 218 --- cli/src/api/members.ts | 58 - cli/src/api/meta.test.ts | 49 - cli/src/api/meta.ts | 22 - cli/src/api/oauth-device.ts | 138 -- cli/src/api/permitted-external-apps.test.ts | 31 - cli/src/api/permitted-external-apps.ts | 34 - cli/src/api/workspaces.test.ts | 53 - cli/src/api/workspaces.ts | 40 - cli/src/auth/device-api.test.ts | 121 ++ cli/src/auth/device-api.ts | 206 +++ .../auth/login => auth}/device-flow.test.ts | 20 +- .../auth/login => auth}/device-flow.ts | 87 +- cli/src/auth/hosts.test.ts | 199 --- cli/src/auth/hosts.ts | 183 --- cli/src/auth/logout.ts | 45 + cli/src/cache/app-info.test.ts | 142 -- cli/src/cache/app-info.ts | 129 -- cli/src/cache/compat-store.test.ts | 56 - cli/src/cache/compat-store.ts | 67 - cli/src/cache/nudge-store.test.ts | 98 -- cli/src/cache/nudge-store.ts | 67 - cli/src/call/errors.ts | 7 + cli/src/call/execute.test.ts | 196 +++ cli/src/call/execute.ts | 96 ++ cli/src/call/files.ts | 8 + cli/src/call/flags.test.ts | 10 + cli/src/call/flags.ts | 74 + cli/src/call/fold.test.ts | 93 ++ cli/src/call/fold.ts | 22 + cli/src/call/input.test.ts | 102 ++ cli/src/call/input.ts | 91 ++ cli/src/call/json-body.ts | 25 + cli/src/call/render/file.ts | 53 + cli/src/call/render/index.ts | 31 + cli/src/call/render/list.ts | 6 + cli/src/call/render/object.ts | 13 + cli/src/call/render/render.test.ts | 140 ++ cli/src/call/render/sse.ts | 46 + cli/src/call/render/text.ts | 9 + cli/src/call/request.test.ts | 63 + cli/src/call/request.ts | 182 +++ cli/src/{http => call}/sse-dify.test.ts | 0 cli/src/{http => call}/sse-dify.ts | 0 cli/src/{http => call}/sse.test.ts | 0 cli/src/{http => call}/sse.ts | 0 cli/src/call/validate.test.ts | 38 + cli/src/call/validate.ts | 41 + cli/src/commands/AGENTS.md | 86 +- cli/src/commands/_shared/authed-command.ts | 102 -- cli/src/commands/_shared/dify-command.ts | 9 - cli/src/commands/_shared/global-flags.test.ts | 42 - cli/src/commands/_shared/global-flags.ts | 30 - cli/src/commands/agent-guides.test.ts | 27 - .../auth/devices/_shared/devices.test.ts | 265 ---- .../commands/auth/devices/_shared/devices.ts | 166 -- cli/src/commands/auth/devices/list/index.ts | 35 - cli/src/commands/auth/devices/revoke/index.ts | 44 - cli/src/commands/auth/list/handlers.ts | 65 - cli/src/commands/auth/list/index.ts | 29 - cli/src/commands/auth/list/list.test.ts | 111 -- cli/src/commands/auth/list/list.ts | 13 - cli/src/commands/auth/login/guide.ts | 18 - cli/src/commands/auth/login/index.ts | 51 - cli/src/commands/auth/login/login.test.ts | 207 --- cli/src/commands/auth/login/login.ts | 232 --- cli/src/commands/auth/logout/index.ts | 43 - cli/src/commands/auth/logout/logout.test.ts | 55 - cli/src/commands/auth/logout/logout.ts | 49 - cli/src/commands/auth/whoami/index.ts | 24 - cli/src/commands/auth/whoami/whoami.test.ts | 98 -- cli/src/commands/auth/whoami/whoami.ts | 35 - cli/src/commands/config/get/index.ts | 20 - cli/src/commands/config/get/run.test.ts | 44 - cli/src/commands/config/get/run.ts | 16 - cli/src/commands/config/path/index.ts | 16 - cli/src/commands/config/set/index.ts | 29 - cli/src/commands/config/set/run.test.ts | 94 -- cli/src/commands/config/set/run.ts | 20 - cli/src/commands/config/unset/index.ts | 23 - cli/src/commands/config/unset/run.test.ts | 46 - cli/src/commands/config/unset/run.ts | 19 - cli/src/commands/config/view/index.ts | 23 - cli/src/commands/config/view/run.test.ts | 57 - cli/src/commands/config/view/run.ts | 42 - cli/src/commands/coverage.test.ts | 21 - cli/src/commands/create/member/handlers.ts | 23 - cli/src/commands/create/member/index.ts | 46 - cli/src/commands/create/member/run.test.ts | 113 -- cli/src/commands/create/member/run.ts | 74 - cli/src/commands/delete/cache/index.test.ts | 31 + cli/src/commands/delete/cache/index.ts | 18 + cli/src/commands/delete/member/handlers.ts | 26 - cli/src/commands/delete/member/index.ts | 46 - cli/src/commands/delete/member/run.test.ts | 75 - cli/src/commands/delete/member/run.ts | 77 - cli/src/commands/describe/app/guide.ts | 15 - cli/src/commands/describe/app/handlers.ts | 58 - cli/src/commands/describe/app/index.ts | 60 - cli/src/commands/describe/app/run.test.ts | 135 -- cli/src/commands/describe/app/run.ts | 40 - cli/src/commands/env/list/index.ts | 19 - cli/src/commands/env/list/run-list.test.ts | 70 - cli/src/commands/env/list/run-list.ts | 67 - cli/src/commands/export/studio-app/guide.ts | 12 - cli/src/commands/export/studio-app/index.ts | 62 - .../commands/export/studio-app/run.test.ts | 70 - cli/src/commands/export/studio-app/run.ts | 62 - cli/src/commands/get/app/guide.ts | 15 - cli/src/commands/get/app/handlers.ts | 68 - cli/src/commands/get/app/index.ts | 74 - .../commands/get/app/mode-whitelist.test.ts | 17 - cli/src/commands/get/app/run.test.ts | 184 --- cli/src/commands/get/app/run.ts | 184 --- cli/src/commands/get/config/index.test.ts | 35 + cli/src/commands/get/config/index.ts | 17 + cli/src/commands/get/member/handlers.ts | 88 -- cli/src/commands/get/member/index.ts | 47 - cli/src/commands/get/member/run.test.ts | 168 -- cli/src/commands/get/member/run.ts | 62 - cli/src/commands/get/skills/index.ts | 16 + .../commands/get/workspace/handlers.test.ts | 52 - cli/src/commands/get/workspace/handlers.ts | 76 - cli/src/commands/get/workspace/index.ts | 38 - cli/src/commands/get/workspace/run.test.ts | 128 -- cli/src/commands/get/workspace/run.ts | 54 - cli/src/commands/import/studio-app/guide.ts | 17 - cli/src/commands/import/studio-app/index.ts | 80 - .../commands/import/studio-app/run.test.ts | 208 --- cli/src/commands/import/studio-app/run.ts | 147 -- cli/src/commands/install/skills/index.test.ts | 27 + cli/src/commands/install/skills/index.ts | 35 + cli/src/commands/login/index.test.ts | 97 ++ cli/src/commands/login/index.ts | 189 +++ cli/src/commands/logout/index.test.ts | 56 + cli/src/commands/logout/index.ts | 25 + cli/src/commands/refresh/cache/index.test.ts | 25 + cli/src/commands/refresh/cache/index.ts | 25 + cli/src/commands/resume/app/guide.ts | 17 - cli/src/commands/resume/app/index.ts | 87 -- cli/src/commands/resume/app/run.test.ts | 85 - cli/src/commands/resume/app/run.ts | 115 -- cli/src/commands/run/app/_strategies/index.ts | 27 - .../app/_strategies/streaming-structured.ts | 133 -- .../run/app/_strategies/streaming-text.ts | 69 - cli/src/commands/run/app/agent-guide.test.ts | 20 - cli/src/commands/run/app/file-flags.test.ts | 132 -- cli/src/commands/run/app/file-flags.ts | 117 -- cli/src/commands/run/app/guide.ts | 31 - cli/src/commands/run/app/handlers.ts | 84 - cli/src/commands/run/app/hitl-render.test.ts | 75 - cli/src/commands/run/app/hitl-render.ts | 148 -- cli/src/commands/run/app/index.ts | 101 -- cli/src/commands/run/app/input-flags.ts | 55 - cli/src/commands/run/app/run.test.ts | 626 -------- cli/src/commands/run/app/run.ts | 104 -- .../commands/run/app/sse-collector.test.ts | 351 ----- cli/src/commands/run/app/sse-collector.ts | 264 ---- .../commands/run/app/stream-handlers.test.ts | 340 ---- cli/src/commands/run/app/stream-handlers.ts | 189 --- cli/src/commands/set/config/index.test.ts | 25 + cli/src/commands/set/config/index.ts | 19 + cli/src/commands/set/member/handlers.ts | 26 - cli/src/commands/set/member/index.ts | 49 - cli/src/commands/set/member/run.test.ts | 95 -- cli/src/commands/set/member/run.ts | 77 - cli/src/commands/skills/install/index.test.ts | 65 - cli/src/commands/skills/install/index.ts | 78 - .../commands/skills/install/registry.test.ts | 76 - cli/src/commands/skills/install/registry.ts | 58 - cli/src/commands/skills/install/run.test.ts | 156 -- cli/src/commands/skills/install/run.ts | 125 -- cli/src/commands/tree.generated.ts | 110 +- cli/src/commands/unset/config/index.test.ts | 26 + cli/src/commands/unset/config/index.ts | 19 + cli/src/commands/use/account/index.ts | 29 - .../commands/use/account/use-account.test.ts | 47 - cli/src/commands/use/account/use-account.ts | 79 - cli/src/commands/use/host/index.ts | 29 - cli/src/commands/use/host/use-host.test.ts | 41 - cli/src/commands/use/host/use-host.ts | 58 - cli/src/commands/use/workspace/index.test.ts | 52 + cli/src/commands/use/workspace/index.ts | 61 +- cli/src/commands/use/workspace/use.test.ts | 200 --- cli/src/commands/use/workspace/use.ts | 86 - cli/src/commands/version/index.test.ts | 27 + cli/src/commands/version/index.ts | 89 +- cli/src/commands/version/version.test.ts | 169 -- .../commands/workspace/_list/index.test.ts | 36 + cli/src/commands/workspace/_list/index.ts | 47 + cli/src/config/config-loader.test.ts | 87 -- cli/src/config/config-loader.ts | 37 - cli/src/config/keys.test.ts | 137 -- cli/src/config/keys.ts | 93 -- cli/src/config/schema.test.ts | 55 - cli/src/config/schema.ts | 31 - cli/src/discovery/search.test.ts | 26 + cli/src/discovery/search.ts | 205 +++ cli/src/discovery/tree.ts | 37 + cli/src/env/registry.test.ts | 89 -- cli/src/env/registry.ts | 66 - cli/src/errors/base.test.ts | 4 +- cli/src/errors/base.ts | 62 +- cli/src/errors/codes.test.ts | 64 +- cli/src/errors/codes.ts | 35 +- cli/src/errors/envelope.test.ts | 93 ++ cli/src/errors/envelope.ts | 53 + cli/src/errors/format.test.ts | 210 +-- cli/src/errors/format.ts | 63 +- cli/src/errors/message.ts | 4 + cli/src/{http => errors}/sanitize.ts | 0 cli/src/framework/command.ts | 75 - cli/src/framework/context.ts | 15 - cli/src/framework/errors.test.ts | 49 - cli/src/framework/errors.ts | 23 - cli/src/framework/flags.test.ts | 231 --- cli/src/framework/flags.ts | 249 --- cli/src/framework/help.test.ts | 235 --- cli/src/framework/help.ts | 279 ---- cli/src/framework/output.test.ts | 209 --- cli/src/framework/output.ts | 206 --- cli/src/framework/run.test.ts | 600 ------- cli/src/framework/run.ts | 152 -- cli/src/framework/stream.ts | 6 - cli/src/framework/types.ts | 44 - cli/src/help/contract.test.ts | 26 - cli/src/help/contract.ts | 70 - cli/src/help/skill-template.ts | 43 - cli/src/help/skill.test.ts | 65 - cli/src/help/skill.ts | 9 - cli/src/help/topics.test.ts | 86 - cli/src/help/topics.ts | 143 -- cli/src/http/body.test.ts | 69 - cli/src/http/body.ts | 53 - cli/src/http/client-tls.test.ts | 70 - cli/src/http/client.test.ts | 1021 ------------ cli/src/http/client.ts | 392 ----- cli/src/http/error-mapper.test.ts | 141 -- cli/src/http/error-mapper.ts | 119 -- cli/src/http/hooks.ts | 51 - cli/src/http/orpc.test.ts | 97 -- cli/src/http/orpc.ts | 39 - cli/src/http/rate-limit.test.ts | 99 -- cli/src/http/rate-limit.ts | 88 -- cli/src/http/retry.test.ts | 85 - cli/src/http/retry.ts | 34 - cli/src/http/types.ts | 104 -- cli/src/http/url.test.ts | 75 - cli/src/http/url.ts | 37 - cli/src/index.ts | 2 - cli/src/kernel/context.test.ts | 80 + cli/src/kernel/context.ts | 40 + cli/src/kernel/plugin.ts | 25 + cli/src/kernel/types.test.ts | 21 + cli/src/limit/limit.test.ts | 61 - cli/src/limit/limit.ts | 39 - cli/src/main.test.ts | 63 + cli/src/main.ts | 32 + cli/src/net/fetch-init.ts | 20 + cli/src/{http => net}/proxy.test.ts | 0 cli/src/{http => net}/proxy.ts | 0 cli/src/plugins/argv/index.ts | 10 + cli/src/plugins/argv/parse.test.ts | 110 ++ cli/src/plugins/argv/parse.ts | 131 ++ cli/src/plugins/base.ts | 27 + cli/src/plugins/catalog/index.test.ts | 123 ++ cli/src/plugins/catalog/index.ts | 142 ++ cli/src/plugins/catalog/types.test.ts | 41 + cli/src/plugins/catalog/types.ts | 62 + cli/src/plugins/commands/cancel.ts | 19 + .../commands}/command-fs.test.ts | 0 .../commands}/command-fs.ts | 0 cli/src/plugins/commands/command.ts | 103 ++ cli/src/plugins/commands/describe.test.ts | 54 + cli/src/plugins/commands/describe.ts | 44 + cli/src/plugins/commands/help.test.ts | 102 ++ cli/src/plugins/commands/help.ts | 127 ++ cli/src/plugins/commands/index.test.ts | 333 ++++ cli/src/plugins/commands/index.ts | 254 +++ .../commands}/registry.test.ts | 54 +- .../commands}/registry.ts | 99 +- cli/src/plugins/commands/text.test.ts | 250 +++ cli/src/plugins/commands/text.ts | 243 +++ cli/src/plugins/config/index.test.ts | 46 + cli/src/plugins/config/index.ts | 156 ++ cli/src/plugins/env/index.test.ts | 29 + cli/src/plugins/env/index.ts | 67 + cli/src/plugins/global-flags/index.test.ts | 20 + cli/src/plugins/global-flags/index.ts | 41 + cli/src/plugins/http/index.test.ts | 182 +++ cli/src/plugins/http/index.ts | 251 +++ cli/src/plugins/io/index.test.ts | 35 + cli/src/plugins/io/index.ts | 82 + cli/src/plugins/ops/command.test.ts | 142 ++ cli/src/plugins/ops/command.ts | 106 ++ cli/src/plugins/ops/index.ts | 48 + cli/src/plugins/ops/pins.ts | 16 + cli/src/plugins/ops/tree.test.ts | 47 + cli/src/plugins/ops/tree.ts | 22 + cli/src/plugins/output/index.test.ts | 44 + cli/src/plugins/output/index.ts | 51 + cli/src/plugins/session/index.test.ts | 67 + cli/src/plugins/session/index.ts | 133 ++ cli/src/plugins/token/index.test.ts | 55 + cli/src/plugins/token/index.ts | 54 + cli/src/protocol/bind.ts | 14 + cli/src/protocol/fold.ts | 125 ++ cli/src/protocol/kinds.ts | 15 + cli/src/protocol/op-id.ts | 11 + cli/src/protocol/pins.ts | 6 + cli/src/protocol/protocol.test.ts | 9 + cli/src/protocol/shape.test.ts | 42 + cli/src/protocol/shape.ts | 141 ++ cli/src/skills/collection.ts | 76 + cli/src/skills/frontmatter.ts | 19 + cli/src/skills/github.ts | 112 ++ cli/src/skills/install.test.ts | 65 + cli/src/skills/install.ts | 37 + cli/src/skills/source.ts | 76 + cli/src/store/config-writer.test.ts | 61 - cli/src/store/config-writer.ts | 8 - cli/src/store/errors.ts | 4 +- cli/src/store/manager.test.ts | 13 + cli/src/store/manager.ts | 31 +- cli/src/store/store.test.ts | 11 + cli/src/store/store.ts | 1 + cli/src/sys/index.ts | 4 + cli/src/sys/io/color.ts | 16 +- cli/src/sys/io/prompt.test.ts | 147 -- cli/src/sys/io/prompt.ts | 102 -- cli/src/sys/io/reasoning.test.ts | 129 -- cli/src/sys/io/reasoning.ts | 91 -- cli/src/sys/io/select.test.ts | 103 -- cli/src/sys/io/select.ts | 150 -- cli/src/sys/io/spinner.ts | 97 -- cli/src/sys/io/streams.ts | 9 +- cli/src/sys/io/think-filter.test.ts | 238 --- cli/src/sys/io/think-filter.ts | 104 -- cli/src/sys/io/view.ts | 27 + cli/src/types/app-meta.test.ts | 61 - cli/src/types/app-meta.ts | 65 - cli/src/util/edit-distance.ts | 16 + cli/src/util/flag-name.ts | 12 + cli/src/util/host.ts | 20 - cli/src/util/is-record.ts | 4 + cli/src/util/uuid.ts | 1 - cli/src/version/build-info.d.ts | 7 +- cli/src/version/compat.test.ts | 102 -- cli/src/version/compat.ts | 75 - cli/src/version/enforce.test.ts | 88 -- cli/src/version/enforce.ts | 75 - cli/src/version/info.test.ts | 4 +- cli/src/version/info.ts | 8 +- cli/src/version/nudge.test.ts | 191 --- cli/src/version/nudge.ts | 64 - cli/src/version/probe.test.ts | 206 --- cli/src/version/probe.ts | 147 -- cli/src/version/render.test.ts | 200 --- cli/src/version/render.ts | 63 - cli/src/workspace/resolver.test.ts | 38 - cli/src/workspace/resolver.ts | 54 - cli/test/e2e/helpers/cli.ts | 5 +- cli/test/e2e/setup/global-setup.ts | 2 +- .../suites/agent/agent-skill-workflow.e2e.ts | 717 --------- cli/test/e2e/suites/auth/devices.e2e.ts | 420 ----- cli/test/e2e/suites/auth/login.e2e.ts | 254 --- cli/test/e2e/suites/auth/logout.e2e.ts | 272 ---- cli/test/e2e/suites/auth/status.e2e.ts | 184 --- cli/test/e2e/suites/auth/use.e2e.ts | 196 --- cli/test/e2e/suites/auth/whoami.e2e.ts | 185 --- .../e2e/suites/discovery/describe-app.e2e.ts | 342 ---- .../discovery/get-app-all-workspaces.e2e.ts | 280 ---- .../e2e/suites/discovery/get-app-list.e2e.ts | 356 ----- .../suites/discovery/get-app-single.e2e.ts | 242 --- .../e2e/suites/dsl/export-studio-app.e2e.ts | 209 --- .../error-handling/error-messages.e2e.ts | 520 ------ .../suites/error-handling/exit-codes.e2e.ts | 182 --- .../e2e/suites/framework/global-flags.e2e.ts | 288 ---- cli/test/e2e/suites/framework/help.e2e.ts | 301 ---- .../e2e/suites/output/json-yaml-output.e2e.ts | 275 ---- .../e2e/suites/output/table-output.e2e.ts | 214 --- cli/test/e2e/suites/run/run-app-basic.e2e.ts | 534 ------- .../suites/run/run-app-conversation.e2e.ts | 546 ------- cli/test/e2e/suites/run/run-app-file.e2e.ts | 429 ----- cli/test/e2e/suites/run/run-app-hitl.e2e.ts | 598 ------- .../e2e/suites/run/run-app-reasoning.e2e.ts | 94 -- .../e2e/suites/run/run-app-streaming.e2e.ts | 359 ----- cli/test/fixtures/catalog-v2.json | 1389 +++++++++++++++++ cli/test/fixtures/catalog.json | 922 +++++++++++ cli/test/fixtures/dify-mock/scenarios.ts | 28 +- cli/test/fixtures/dify-mock/server.test.ts | 255 ++- cli/test/fixtures/dify-mock/server.ts | 577 ++++--- cli/test/fixtures/http-client.ts | 13 - cli/test/fixtures/kernel.ts | 111 ++ cli/test/fixtures/mem-store.ts | 27 - cli/test/fixtures/pkg-manifest.ts | 8 +- cli/test/fixtures/stub-server.ts | 66 - cli/test/scripts/resolve-buildinfo.test.ts | 89 +- cli/test/setup.ts | 2 - cli/vite.config.ts | 6 +- cli/vitest.e2e.config.ts | 4 +- oxlint-suppressions.json | 5 - pnpm-lock.yaml | 145 +- pnpm-workspace.yaml | 3 +- skills/README.md | 26 + skills/difyctl/SKILL.md | 59 + 462 files changed, 11886 insertions(+), 31432 deletions(-) delete mode 100644 cli/scripts/release-guards.test.ts delete mode 100644 cli/src/api/account-sessions.test.ts delete mode 100644 cli/src/api/account-sessions.ts delete mode 100644 cli/src/api/account.test.ts delete mode 100644 cli/src/api/account.ts delete mode 100644 cli/src/api/app-dsl.test.ts delete mode 100644 cli/src/api/app-dsl.ts delete mode 100644 cli/src/api/app-meta.test.ts delete mode 100644 cli/src/api/app-meta.ts delete mode 100644 cli/src/api/app-reader.test.ts delete mode 100644 cli/src/api/app-reader.ts delete mode 100644 cli/src/api/app-run.test.ts delete mode 100644 cli/src/api/app-run.ts delete mode 100644 cli/src/api/apps.test.ts delete mode 100644 cli/src/api/apps.ts delete mode 100644 cli/src/api/device-flow.test.ts delete mode 100644 cli/src/api/file-upload.test.ts delete mode 100644 cli/src/api/file-upload.ts delete mode 100644 cli/src/api/members.test.ts delete mode 100644 cli/src/api/members.ts delete mode 100644 cli/src/api/meta.test.ts delete mode 100644 cli/src/api/meta.ts delete mode 100644 cli/src/api/oauth-device.ts delete mode 100644 cli/src/api/permitted-external-apps.test.ts delete mode 100644 cli/src/api/permitted-external-apps.ts delete mode 100644 cli/src/api/workspaces.test.ts delete mode 100644 cli/src/api/workspaces.ts create mode 100644 cli/src/auth/device-api.test.ts create mode 100644 cli/src/auth/device-api.ts rename cli/src/{commands/auth/login => auth}/device-flow.test.ts (87%) rename cli/src/{commands/auth/login => auth}/device-flow.ts (50%) delete mode 100644 cli/src/auth/hosts.test.ts delete mode 100644 cli/src/auth/hosts.ts create mode 100644 cli/src/auth/logout.ts delete mode 100644 cli/src/cache/app-info.test.ts delete mode 100644 cli/src/cache/app-info.ts delete mode 100644 cli/src/cache/compat-store.test.ts delete mode 100644 cli/src/cache/compat-store.ts delete mode 100644 cli/src/cache/nudge-store.test.ts delete mode 100644 cli/src/cache/nudge-store.ts create mode 100644 cli/src/call/errors.ts create mode 100644 cli/src/call/execute.test.ts create mode 100644 cli/src/call/execute.ts create mode 100644 cli/src/call/files.ts create mode 100644 cli/src/call/flags.test.ts create mode 100644 cli/src/call/flags.ts create mode 100644 cli/src/call/fold.test.ts create mode 100644 cli/src/call/fold.ts create mode 100644 cli/src/call/input.test.ts create mode 100644 cli/src/call/input.ts create mode 100644 cli/src/call/json-body.ts create mode 100644 cli/src/call/render/file.ts create mode 100644 cli/src/call/render/index.ts create mode 100644 cli/src/call/render/list.ts create mode 100644 cli/src/call/render/object.ts create mode 100644 cli/src/call/render/render.test.ts create mode 100644 cli/src/call/render/sse.ts create mode 100644 cli/src/call/render/text.ts create mode 100644 cli/src/call/request.test.ts create mode 100644 cli/src/call/request.ts rename cli/src/{http => call}/sse-dify.test.ts (100%) rename cli/src/{http => call}/sse-dify.ts (100%) rename cli/src/{http => call}/sse.test.ts (100%) rename cli/src/{http => call}/sse.ts (100%) create mode 100644 cli/src/call/validate.test.ts create mode 100644 cli/src/call/validate.ts delete mode 100644 cli/src/commands/_shared/authed-command.ts delete mode 100644 cli/src/commands/_shared/dify-command.ts delete mode 100644 cli/src/commands/_shared/global-flags.test.ts delete mode 100644 cli/src/commands/_shared/global-flags.ts delete mode 100644 cli/src/commands/agent-guides.test.ts delete mode 100644 cli/src/commands/auth/devices/_shared/devices.test.ts delete mode 100644 cli/src/commands/auth/devices/_shared/devices.ts delete mode 100644 cli/src/commands/auth/devices/list/index.ts delete mode 100644 cli/src/commands/auth/devices/revoke/index.ts delete mode 100644 cli/src/commands/auth/list/handlers.ts delete mode 100644 cli/src/commands/auth/list/index.ts delete mode 100644 cli/src/commands/auth/list/list.test.ts delete mode 100644 cli/src/commands/auth/list/list.ts delete mode 100644 cli/src/commands/auth/login/guide.ts delete mode 100644 cli/src/commands/auth/login/index.ts delete mode 100644 cli/src/commands/auth/login/login.test.ts delete mode 100644 cli/src/commands/auth/login/login.ts delete mode 100644 cli/src/commands/auth/logout/index.ts delete mode 100644 cli/src/commands/auth/logout/logout.test.ts delete mode 100644 cli/src/commands/auth/logout/logout.ts delete mode 100644 cli/src/commands/auth/whoami/index.ts delete mode 100644 cli/src/commands/auth/whoami/whoami.test.ts delete mode 100644 cli/src/commands/auth/whoami/whoami.ts delete mode 100644 cli/src/commands/config/get/index.ts delete mode 100644 cli/src/commands/config/get/run.test.ts delete mode 100644 cli/src/commands/config/get/run.ts delete mode 100644 cli/src/commands/config/path/index.ts delete mode 100644 cli/src/commands/config/set/index.ts delete mode 100644 cli/src/commands/config/set/run.test.ts delete mode 100644 cli/src/commands/config/set/run.ts delete mode 100644 cli/src/commands/config/unset/index.ts delete mode 100644 cli/src/commands/config/unset/run.test.ts delete mode 100644 cli/src/commands/config/unset/run.ts delete mode 100644 cli/src/commands/config/view/index.ts delete mode 100644 cli/src/commands/config/view/run.test.ts delete mode 100644 cli/src/commands/config/view/run.ts delete mode 100644 cli/src/commands/coverage.test.ts delete mode 100644 cli/src/commands/create/member/handlers.ts delete mode 100644 cli/src/commands/create/member/index.ts delete mode 100644 cli/src/commands/create/member/run.test.ts delete mode 100644 cli/src/commands/create/member/run.ts create mode 100644 cli/src/commands/delete/cache/index.test.ts create mode 100644 cli/src/commands/delete/cache/index.ts delete mode 100644 cli/src/commands/delete/member/handlers.ts delete mode 100644 cli/src/commands/delete/member/index.ts delete mode 100644 cli/src/commands/delete/member/run.test.ts delete mode 100644 cli/src/commands/delete/member/run.ts delete mode 100644 cli/src/commands/describe/app/guide.ts delete mode 100644 cli/src/commands/describe/app/handlers.ts delete mode 100644 cli/src/commands/describe/app/index.ts delete mode 100644 cli/src/commands/describe/app/run.test.ts delete mode 100644 cli/src/commands/describe/app/run.ts delete mode 100644 cli/src/commands/env/list/index.ts delete mode 100644 cli/src/commands/env/list/run-list.test.ts delete mode 100644 cli/src/commands/env/list/run-list.ts delete mode 100644 cli/src/commands/export/studio-app/guide.ts delete mode 100644 cli/src/commands/export/studio-app/index.ts delete mode 100644 cli/src/commands/export/studio-app/run.test.ts delete mode 100644 cli/src/commands/export/studio-app/run.ts delete mode 100644 cli/src/commands/get/app/guide.ts delete mode 100644 cli/src/commands/get/app/handlers.ts delete mode 100644 cli/src/commands/get/app/index.ts delete mode 100644 cli/src/commands/get/app/mode-whitelist.test.ts delete mode 100644 cli/src/commands/get/app/run.test.ts delete mode 100644 cli/src/commands/get/app/run.ts create mode 100644 cli/src/commands/get/config/index.test.ts create mode 100644 cli/src/commands/get/config/index.ts delete mode 100644 cli/src/commands/get/member/handlers.ts delete mode 100644 cli/src/commands/get/member/index.ts delete mode 100644 cli/src/commands/get/member/run.test.ts delete mode 100644 cli/src/commands/get/member/run.ts create mode 100644 cli/src/commands/get/skills/index.ts delete mode 100644 cli/src/commands/get/workspace/handlers.test.ts delete mode 100644 cli/src/commands/get/workspace/handlers.ts delete mode 100644 cli/src/commands/get/workspace/index.ts delete mode 100644 cli/src/commands/get/workspace/run.test.ts delete mode 100644 cli/src/commands/get/workspace/run.ts delete mode 100644 cli/src/commands/import/studio-app/guide.ts delete mode 100644 cli/src/commands/import/studio-app/index.ts delete mode 100644 cli/src/commands/import/studio-app/run.test.ts delete mode 100644 cli/src/commands/import/studio-app/run.ts create mode 100644 cli/src/commands/install/skills/index.test.ts create mode 100644 cli/src/commands/install/skills/index.ts create mode 100644 cli/src/commands/login/index.test.ts create mode 100644 cli/src/commands/login/index.ts create mode 100644 cli/src/commands/logout/index.test.ts create mode 100644 cli/src/commands/logout/index.ts create mode 100644 cli/src/commands/refresh/cache/index.test.ts create mode 100644 cli/src/commands/refresh/cache/index.ts delete mode 100644 cli/src/commands/resume/app/guide.ts delete mode 100644 cli/src/commands/resume/app/index.ts delete mode 100644 cli/src/commands/resume/app/run.test.ts delete mode 100644 cli/src/commands/resume/app/run.ts delete mode 100644 cli/src/commands/run/app/_strategies/index.ts delete mode 100644 cli/src/commands/run/app/_strategies/streaming-structured.ts delete mode 100644 cli/src/commands/run/app/_strategies/streaming-text.ts delete mode 100644 cli/src/commands/run/app/agent-guide.test.ts delete mode 100644 cli/src/commands/run/app/file-flags.test.ts delete mode 100644 cli/src/commands/run/app/file-flags.ts delete mode 100644 cli/src/commands/run/app/guide.ts delete mode 100644 cli/src/commands/run/app/handlers.ts delete mode 100644 cli/src/commands/run/app/hitl-render.test.ts delete mode 100644 cli/src/commands/run/app/hitl-render.ts delete mode 100644 cli/src/commands/run/app/index.ts delete mode 100644 cli/src/commands/run/app/input-flags.ts delete mode 100644 cli/src/commands/run/app/run.test.ts delete mode 100644 cli/src/commands/run/app/run.ts delete mode 100644 cli/src/commands/run/app/sse-collector.test.ts delete mode 100644 cli/src/commands/run/app/sse-collector.ts delete mode 100644 cli/src/commands/run/app/stream-handlers.test.ts delete mode 100644 cli/src/commands/run/app/stream-handlers.ts create mode 100644 cli/src/commands/set/config/index.test.ts create mode 100644 cli/src/commands/set/config/index.ts delete mode 100644 cli/src/commands/set/member/handlers.ts delete mode 100644 cli/src/commands/set/member/index.ts delete mode 100644 cli/src/commands/set/member/run.test.ts delete mode 100644 cli/src/commands/set/member/run.ts delete mode 100644 cli/src/commands/skills/install/index.test.ts delete mode 100644 cli/src/commands/skills/install/index.ts delete mode 100644 cli/src/commands/skills/install/registry.test.ts delete mode 100644 cli/src/commands/skills/install/registry.ts delete mode 100644 cli/src/commands/skills/install/run.test.ts delete mode 100644 cli/src/commands/skills/install/run.ts create mode 100644 cli/src/commands/unset/config/index.test.ts create mode 100644 cli/src/commands/unset/config/index.ts delete mode 100644 cli/src/commands/use/account/index.ts delete mode 100644 cli/src/commands/use/account/use-account.test.ts delete mode 100644 cli/src/commands/use/account/use-account.ts delete mode 100644 cli/src/commands/use/host/index.ts delete mode 100644 cli/src/commands/use/host/use-host.test.ts delete mode 100644 cli/src/commands/use/host/use-host.ts create mode 100644 cli/src/commands/use/workspace/index.test.ts delete mode 100644 cli/src/commands/use/workspace/use.test.ts delete mode 100644 cli/src/commands/use/workspace/use.ts create mode 100644 cli/src/commands/version/index.test.ts delete mode 100644 cli/src/commands/version/version.test.ts create mode 100644 cli/src/commands/workspace/_list/index.test.ts create mode 100644 cli/src/commands/workspace/_list/index.ts delete mode 100644 cli/src/config/config-loader.test.ts delete mode 100644 cli/src/config/config-loader.ts delete mode 100644 cli/src/config/keys.test.ts delete mode 100644 cli/src/config/keys.ts delete mode 100644 cli/src/config/schema.test.ts delete mode 100644 cli/src/config/schema.ts create mode 100644 cli/src/discovery/search.test.ts create mode 100644 cli/src/discovery/search.ts create mode 100644 cli/src/discovery/tree.ts delete mode 100644 cli/src/env/registry.test.ts delete mode 100644 cli/src/env/registry.ts create mode 100644 cli/src/errors/envelope.test.ts create mode 100644 cli/src/errors/envelope.ts create mode 100644 cli/src/errors/message.ts rename cli/src/{http => errors}/sanitize.ts (100%) delete mode 100644 cli/src/framework/command.ts delete mode 100644 cli/src/framework/context.ts delete mode 100644 cli/src/framework/errors.test.ts delete mode 100644 cli/src/framework/errors.ts delete mode 100644 cli/src/framework/flags.test.ts delete mode 100644 cli/src/framework/flags.ts delete mode 100644 cli/src/framework/help.test.ts delete mode 100644 cli/src/framework/help.ts delete mode 100644 cli/src/framework/output.test.ts delete mode 100644 cli/src/framework/output.ts delete mode 100644 cli/src/framework/run.test.ts delete mode 100644 cli/src/framework/run.ts delete mode 100644 cli/src/framework/stream.ts delete mode 100644 cli/src/framework/types.ts delete mode 100644 cli/src/help/contract.test.ts delete mode 100644 cli/src/help/contract.ts delete mode 100644 cli/src/help/skill-template.ts delete mode 100644 cli/src/help/skill.test.ts delete mode 100644 cli/src/help/skill.ts delete mode 100644 cli/src/help/topics.test.ts delete mode 100644 cli/src/help/topics.ts delete mode 100644 cli/src/http/body.test.ts delete mode 100644 cli/src/http/body.ts delete mode 100644 cli/src/http/client-tls.test.ts delete mode 100644 cli/src/http/client.test.ts delete mode 100644 cli/src/http/client.ts delete mode 100644 cli/src/http/error-mapper.test.ts delete mode 100644 cli/src/http/error-mapper.ts delete mode 100644 cli/src/http/hooks.ts delete mode 100644 cli/src/http/orpc.test.ts delete mode 100644 cli/src/http/orpc.ts delete mode 100644 cli/src/http/rate-limit.test.ts delete mode 100644 cli/src/http/rate-limit.ts delete mode 100644 cli/src/http/retry.test.ts delete mode 100644 cli/src/http/retry.ts delete mode 100644 cli/src/http/types.ts delete mode 100644 cli/src/http/url.test.ts delete mode 100644 cli/src/http/url.ts delete mode 100644 cli/src/index.ts create mode 100644 cli/src/kernel/context.test.ts create mode 100644 cli/src/kernel/context.ts create mode 100644 cli/src/kernel/plugin.ts create mode 100644 cli/src/kernel/types.test.ts delete mode 100644 cli/src/limit/limit.test.ts delete mode 100644 cli/src/limit/limit.ts create mode 100644 cli/src/main.test.ts create mode 100644 cli/src/main.ts create mode 100644 cli/src/net/fetch-init.ts rename cli/src/{http => net}/proxy.test.ts (100%) rename cli/src/{http => net}/proxy.ts (100%) create mode 100644 cli/src/plugins/argv/index.ts create mode 100644 cli/src/plugins/argv/parse.test.ts create mode 100644 cli/src/plugins/argv/parse.ts create mode 100644 cli/src/plugins/base.ts create mode 100644 cli/src/plugins/catalog/index.test.ts create mode 100644 cli/src/plugins/catalog/index.ts create mode 100644 cli/src/plugins/catalog/types.test.ts create mode 100644 cli/src/plugins/catalog/types.ts create mode 100644 cli/src/plugins/commands/cancel.ts rename cli/src/{framework => plugins/commands}/command-fs.test.ts (100%) rename cli/src/{framework => plugins/commands}/command-fs.ts (100%) create mode 100644 cli/src/plugins/commands/command.ts create mode 100644 cli/src/plugins/commands/describe.test.ts create mode 100644 cli/src/plugins/commands/describe.ts create mode 100644 cli/src/plugins/commands/help.test.ts create mode 100644 cli/src/plugins/commands/help.ts create mode 100644 cli/src/plugins/commands/index.test.ts create mode 100644 cli/src/plugins/commands/index.ts rename cli/src/{framework => plugins/commands}/registry.test.ts (88%) rename cli/src/{framework => plugins/commands}/registry.ts (64%) create mode 100644 cli/src/plugins/commands/text.test.ts create mode 100644 cli/src/plugins/commands/text.ts create mode 100644 cli/src/plugins/config/index.test.ts create mode 100644 cli/src/plugins/config/index.ts create mode 100644 cli/src/plugins/env/index.test.ts create mode 100644 cli/src/plugins/env/index.ts create mode 100644 cli/src/plugins/global-flags/index.test.ts create mode 100644 cli/src/plugins/global-flags/index.ts create mode 100644 cli/src/plugins/http/index.test.ts create mode 100644 cli/src/plugins/http/index.ts create mode 100644 cli/src/plugins/io/index.test.ts create mode 100644 cli/src/plugins/io/index.ts create mode 100644 cli/src/plugins/ops/command.test.ts create mode 100644 cli/src/plugins/ops/command.ts create mode 100644 cli/src/plugins/ops/index.ts create mode 100644 cli/src/plugins/ops/pins.ts create mode 100644 cli/src/plugins/ops/tree.test.ts create mode 100644 cli/src/plugins/ops/tree.ts create mode 100644 cli/src/plugins/output/index.test.ts create mode 100644 cli/src/plugins/output/index.ts create mode 100644 cli/src/plugins/session/index.test.ts create mode 100644 cli/src/plugins/session/index.ts create mode 100644 cli/src/plugins/token/index.test.ts create mode 100644 cli/src/plugins/token/index.ts create mode 100644 cli/src/protocol/bind.ts create mode 100644 cli/src/protocol/fold.ts create mode 100644 cli/src/protocol/kinds.ts create mode 100644 cli/src/protocol/op-id.ts create mode 100644 cli/src/protocol/pins.ts create mode 100644 cli/src/protocol/protocol.test.ts create mode 100644 cli/src/protocol/shape.test.ts create mode 100644 cli/src/protocol/shape.ts create mode 100644 cli/src/skills/collection.ts create mode 100644 cli/src/skills/frontmatter.ts create mode 100644 cli/src/skills/github.ts create mode 100644 cli/src/skills/install.test.ts create mode 100644 cli/src/skills/install.ts create mode 100644 cli/src/skills/source.ts delete mode 100644 cli/src/store/config-writer.test.ts delete mode 100644 cli/src/store/config-writer.ts delete mode 100644 cli/src/sys/io/prompt.test.ts delete mode 100644 cli/src/sys/io/prompt.ts delete mode 100644 cli/src/sys/io/reasoning.test.ts delete mode 100644 cli/src/sys/io/reasoning.ts delete mode 100644 cli/src/sys/io/select.test.ts delete mode 100644 cli/src/sys/io/select.ts delete mode 100644 cli/src/sys/io/spinner.ts delete mode 100644 cli/src/sys/io/think-filter.test.ts delete mode 100644 cli/src/sys/io/think-filter.ts create mode 100644 cli/src/sys/io/view.ts delete mode 100644 cli/src/types/app-meta.test.ts delete mode 100644 cli/src/types/app-meta.ts create mode 100644 cli/src/util/edit-distance.ts create mode 100644 cli/src/util/flag-name.ts create mode 100644 cli/src/util/is-record.ts delete mode 100644 cli/src/util/uuid.ts delete mode 100644 cli/src/version/compat.test.ts delete mode 100644 cli/src/version/compat.ts delete mode 100644 cli/src/version/enforce.test.ts delete mode 100644 cli/src/version/enforce.ts delete mode 100644 cli/src/version/nudge.test.ts delete mode 100644 cli/src/version/nudge.ts delete mode 100644 cli/src/version/probe.test.ts delete mode 100644 cli/src/version/probe.ts delete mode 100644 cli/src/version/render.test.ts delete mode 100644 cli/src/version/render.ts delete mode 100644 cli/src/workspace/resolver.test.ts delete mode 100644 cli/src/workspace/resolver.ts delete mode 100644 cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts delete mode 100644 cli/test/e2e/suites/auth/devices.e2e.ts delete mode 100644 cli/test/e2e/suites/auth/login.e2e.ts delete mode 100644 cli/test/e2e/suites/auth/logout.e2e.ts delete mode 100644 cli/test/e2e/suites/auth/status.e2e.ts delete mode 100644 cli/test/e2e/suites/auth/use.e2e.ts delete mode 100644 cli/test/e2e/suites/auth/whoami.e2e.ts delete mode 100644 cli/test/e2e/suites/discovery/describe-app.e2e.ts delete mode 100644 cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts delete mode 100644 cli/test/e2e/suites/discovery/get-app-list.e2e.ts delete mode 100644 cli/test/e2e/suites/discovery/get-app-single.e2e.ts delete mode 100644 cli/test/e2e/suites/dsl/export-studio-app.e2e.ts delete mode 100644 cli/test/e2e/suites/error-handling/error-messages.e2e.ts delete mode 100644 cli/test/e2e/suites/error-handling/exit-codes.e2e.ts delete mode 100644 cli/test/e2e/suites/framework/global-flags.e2e.ts delete mode 100644 cli/test/e2e/suites/framework/help.e2e.ts delete mode 100644 cli/test/e2e/suites/output/json-yaml-output.e2e.ts delete mode 100644 cli/test/e2e/suites/output/table-output.e2e.ts delete mode 100644 cli/test/e2e/suites/run/run-app-basic.e2e.ts delete mode 100644 cli/test/e2e/suites/run/run-app-conversation.e2e.ts delete mode 100644 cli/test/e2e/suites/run/run-app-file.e2e.ts delete mode 100644 cli/test/e2e/suites/run/run-app-hitl.e2e.ts delete mode 100644 cli/test/e2e/suites/run/run-app-reasoning.e2e.ts delete mode 100644 cli/test/e2e/suites/run/run-app-streaming.e2e.ts create mode 100644 cli/test/fixtures/catalog-v2.json create mode 100644 cli/test/fixtures/catalog.json delete mode 100644 cli/test/fixtures/http-client.ts create mode 100644 cli/test/fixtures/kernel.ts delete mode 100644 cli/test/fixtures/mem-store.ts delete mode 100644 cli/test/fixtures/stub-server.ts create mode 100644 skills/README.md create mode 100644 skills/difyctl/SKILL.md diff --git a/.gitattributes b/.gitattributes index fcce5d26703..b0473b13881 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/cli-edge.yml b/.github/workflows/cli-edge.yml index 7d8c5290581..8df4bc3bac2 100644 --- a/.github/workflows/cli-edge.yml +++ b/.github/workflows/cli-edge.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'cli/**' + - 'skills/**' - 'packages/contracts/generated/api/openapi/**' workflow_dispatch: diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 172ff0e8cf4..d87b21dbe6b 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -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 diff --git a/.github/workflows/cli-smoke.yml b/.github/workflows/cli-smoke.yml index 13ed79cac23..fd6a48d6e92 100644 --- a/.github/workflows/cli-smoke.yml +++ b/.github/workflows/cli-smoke.yml @@ -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() diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 7e1e34967dd..9eb0003fc4e 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -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/**' diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 56bed1a8600..299602328a4 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -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." ) ) diff --git a/api/controllers/openapi/account.py b/api/controllers/openapi/account.py index 70f32086057..de168034983 100644 --- a/api/controllers/openapi/account.py +++ b/api/controllers/openapi/account.py @@ -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/") 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": ""}),), diff --git a/api/controllers/openapi/app_dsl.py b/api/controllers/openapi/app_dsl.py index ee62b583a76..2be74f15fc0 100644 --- a/api/controllers/openapi/app_dsl.py +++ b/api/controllers/openapi/app_dsl.py @@ -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": ""}),), @@ -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": ""}),), diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index 7b5d8d3acfe..6a32b062c64 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -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//tasks/: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": "", "task_id": ""}),), diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index 099f19ca785..531971a98cc 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -105,7 +105,7 @@ def build_app_describe_response(app: App, fields: set[str] | None, *, session: S @openapi_ns.route("/apps/") 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=( diff --git a/api/controllers/openapi/apps_permitted_external.py b/api/controllers/openapi/apps_permitted_external.py index a94dfc46808..3c9f8c581eb 100644 --- a/api/controllers/openapi/apps_permitted_external.py +++ b/api/controllers/openapi/apps_permitted_external.py @@ -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/") 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": ""}),), diff --git a/api/controllers/openapi/files.py b/api/controllers/openapi/files.py index 69ba44c7414..f38dba816a0 100644 --- a/api/controllers/openapi/files.py +++ b/api/controllers/openapi/files.py @@ -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=( diff --git a/api/controllers/openapi/human_input_form.py b/api/controllers/openapi/human_input_form.py index e4fdff91108..3d2162b50f4 100644 --- a/api/controllers/openapi/human_input_form.py +++ b/api/controllers/openapi/human_input_form.py @@ -92,7 +92,7 @@ def _ensure_form_belongs_to_app(form, app_model: App) -> None: @openapi_ns.route("/apps//human-input-forms/") 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//human-input-forms/:submit") class OpenApiWorkflowHumanInputFormSubmitApi(Resource): @endpoint( - op="run.form.submit", + op="submit.run.form", kind=Kind.OBJECT, summary="Submit a human-input form", examples=( diff --git a/api/controllers/openapi/workflow_events.py b/api/controllers/openapi/workflow_events.py index 154d7fe13d7..6e2be3d15d0 100644 --- a/api/controllers/openapi/workflow_events.py +++ b/api/controllers/openapi/workflow_events.py @@ -53,7 +53,7 @@ class WorkflowEventsQuery(BaseModel): @openapi_ns.route("/apps//tasks//events") class OpenApiWorkflowEventsApi(Resource): @endpoint( - op="run.events", + op="get.run.event", kind=Kind.SSE, summary="Stream the events of a workflow run", examples=( diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index 55c3f02b289..e1c010ef66a 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -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/") 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": ""}),), @@ -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": ""}),), @@ -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=( diff --git a/api/core/app/apps/base_app_generate_response_converter.py b/api/core/app/apps/base_app_generate_response_converter.py index 0576bd48318..4b7efad3892 100644 --- a/api/core/app/apps/base_app_generate_response_converter.py +++ b/api/core/app/apps/base_app_generate_response_converter.py @@ -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: diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index 389467b5199..9235bb60527 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -619,7 +619,7 @@ A chat run against an advanced-chat (chatflow) app, which can also pin a workflo | auto_generate_name | boolean,
**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[]`. Send a list (part name `files[][]`) 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,
**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[]`. Send a list (part name `files[][]`) 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[]`. Send a list (part name `files[][]`) 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[]`. Send a list (part name `files[][]`) 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 | diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py index 0915ae2acb4..9bccd650ade 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py @@ -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": {}}, } ] diff --git a/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py b/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py index c3c9370c654..4137703e33c 100644 --- a/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py +++ b/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py @@ -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, diff --git a/api/tests/unit_tests/controllers/openapi/test_catalog.py b/api/tests/unit_tests/controllers/openapi/test_catalog.py index b007f64090c..87551838cb4 100644 --- a/api/tests/unit_tests/controllers/openapi/test_catalog.py +++ b/api/tests/unit_tests/controllers/openapi/test_catalog.py @@ -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 diff --git a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py index 5f5aca795a8..a882b9dcbae 100644 --- a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py @@ -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": "批准", diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py index c31581b8669..ce2fcbb3d91 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -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, } diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 22b1f2cb29e..c40e4b8309c 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -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/` 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 diff --git a/cli/ARD.md b/cli/ARD.md index e485e74be60..92ee95ca6df 100644 --- a/cli/ARD.md +++ b/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/`) 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/// -``` - -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///` (or `src/commands//` for a top-level command like `login`). +2. Write `index.ts`: a default-exported class extending `Command` 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 | -| 7 | Rate limited | +| Exit | Meaning | +| ---- | ------------------------------------------------ | +| 0 | Success | +| 1 | Generic 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 // stdout: results, help + emit: (value: Printable | View, channel: ChannelValue) => Promise // either stream + notice: (text: string) => void // one text line on stderr: warnings, progress + raw: (chunk: string | Buffer) => Promise // 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 -} - -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: `(cli): `. 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. @@ -305,15 +191,11 @@ 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` 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 | -| New dependency without approval | Check first | +| Pattern | Do instead | +| ----------------------------------------------------------- | ---------------------------------------------------------------------- | +| Raw string error codes `'not_logged_in'` | `ErrorCode.NotLoggedIn` | +| Long positional arg lists | A Zod `input` object with named fields | +| `if (kind === 'sse') { ... } else if (kind === 'list') ...` | A `Record` 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 | diff --git a/cli/README.md b/cli/README.md index b9de444b980..9b6c5c6525f 100644 --- a/cli/README.md +++ b/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 # inspect parameters -difyctl run app "hello" # run, blocking -difyctl run app "hello" -o json | jq .answer # JSON output -difyctl run app --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 --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 --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 ` | Pin the local session to a workspace | +| `get config [key]` | Print the local config, or one key | +| `set config ` | Set a local config value | +| `unset config ` | 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 ` | 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 -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 ` and `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 -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 ` lists one namespace (the bare namespace, `difyctl get`, does the same), `difyctl help ` searches by plain words, and `difyctl help ` prints one descriptor, the same as ` --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; `` 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 ` 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 ` — write every skill into ``, 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 ` (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///tree//`), 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 ` and `unset config ` 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 ` 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 ` | Per-invocation override. `0` disables retries. | -| `DIFYCTL_HTTP_RETRY=` | 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 diff --git a/cli/bin/dev.js b/cli/bin/dev.js index 0a071a1764e..fb6a702ba22 100755 --- a/cli/bin/dev.js +++ b/cli/bin/dev.js @@ -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)) diff --git a/cli/bin/run.ts b/cli/bin/run.ts index 27b99776be4..d26f151cd19 100755 --- a/cli/bin/run.ts +++ b/cli/bin/run.ts @@ -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)) })() diff --git a/cli/package.json b/cli/package.json index 08cba5f7f61..c6da8e4e4d6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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", diff --git a/cli/scripts/generate-command-tree.test.ts b/cli/scripts/generate-command-tree.test.ts index 217fab98bc1..3de18f28226 100644 --- a/cli/scripts/generate-command-tree.test.ts +++ b/cli/scripts/generate-command-tree.test.ts @@ -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' diff --git a/cli/scripts/generate-command-tree.ts b/cli/scripts/generate-command-tree.ts index 7508625c18a..408666a25b3 100644 --- a/cli/scripts/generate-command-tree.ts +++ b/cli/scripts/generate-command-tree.ts @@ -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 { @@ -215,7 +219,8 @@ export async function discoverCommands(commandsDir: string): Promise 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 /index.ts folders`) assertUniqueIdentifiers(entries) return entries diff --git a/cli/scripts/install-r2.test.ts b/cli/scripts/install-r2.test.ts index a43f046b86a..d6875524433 100644 --- a/cli/scripts/install-r2.test.ts +++ b/cli/scripts/install-r2.test.ts @@ -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" },', diff --git a/cli/scripts/lib/resolve-buildinfo.ts b/cli/scripts/lib/resolve-buildinfo.ts index 7e5b6e84700..4d382c41d0c 100644 --- a/cli/scripts/lib/resolve-buildinfo.ts +++ b/cli/scripts/lib/resolve-buildinfo.ts @@ -12,8 +12,6 @@ export type BuildInfo = { commit: string buildDate: string channel: BuildChannel - minDify: string - maxDify: string } export type Env = Record @@ -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 } } diff --git a/cli/scripts/release-build.sh b/cli/scripts/release-build.sh index 16dd9a89081..31e22c2526f 100755 --- a/cli/scripts/release-build.sh +++ b/cli/scripts/release-build.sh @@ -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}\"" ) diff --git a/cli/scripts/release-guards.test.ts b/cli/scripts/release-guards.test.ts deleted file mode 100644 index a6fea0f9e45..00000000000 --- a/cli/scripts/release-guards.test.ts +++ /dev/null @@ -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 = {}, -): Run { - const stubDir = tempDir('difyctl-stub-bun-') - writeFileSync(`${stubDir}/bun`, STUB_BUN) - chmodSync(`${stubDir}/bun`, 0o755) - try { - const merged: Record = { - ...process.env, - PATH: `${stubDir}:${process.env.PATH ?? ''}`, - CLI_VERSION: cliVersion, - ...extraEnv, - } - const childEnv: Record = {} - 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): 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 }) - } - }) -}) diff --git a/cli/scripts/release-naming.mjs b/cli/scripts/release-naming.mjs index 2af839b1f2e..f43187e653f 100644 --- a/cli/scripts/release-naming.mjs +++ b/cli/scripts/release-naming.mjs @@ -15,19 +15,15 @@ // prerelease -> "true" | "false" // github-env -> key=value lines (all fields CI needs) for $GITHUB_ENV // edge-version -> -edge. -// 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 // -> exit 1 unless version matches the channel's form -// compat-check -> 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]) diff --git a/cli/scripts/release-naming.test.ts b/cli/scripts/release-naming.test.ts index 547fe55f1e4..e3b84d93bd5 100644 --- a/cli/scripts/release-naming.test.ts +++ b/cli/scripts/release-naming.test.ts @@ -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 { ) } -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) diff --git a/cli/scripts/release-r2-edge.mjs b/cli/scripts/release-r2-edge.mjs index cd1d3a5d7f9..8840d0104a4 100644 --- a/cli/scripts/release-r2-edge.mjs +++ b/cli/scripts/release-r2-edge.mjs @@ -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) diff --git a/cli/scripts/release-r2-edge.test.ts b/cli/scripts/release-r2-edge.test.ts index c4b981af008..2ec4b2d4384 100644 --- a/cli/scripts/release-r2-edge.test.ts +++ b/cli/scripts/release-r2-edge.test.ts @@ -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([ diff --git a/cli/scripts/run-smoke.ts b/cli/scripts/run-smoke.ts index 8346e42f21f..958c9fe3153 100644 --- a/cli/scripts/run-smoke.ts +++ b/cli/scripts/run-smoke.ts @@ -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 ') - 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), }, ] diff --git a/cli/src/api/account-sessions.test.ts b/cli/src/api/account-sessions.test.ts deleted file mode 100644 index b58ef4b82ea..00000000000 --- a/cli/src/api/account-sessions.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/cli/src/api/account-sessions.ts b/cli/src/api/account-sessions.ts deleted file mode 100644 index e2eec413b11..00000000000 --- a/cli/src/api/account-sessions.ts +++ /dev/null @@ -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 { - return this.orpc.account.sessions.get({ query: { page: q?.page, limit: q?.limit } }) - } - - async revoke(sessionId: string): Promise { - await this.orpc.account.sessions.bySessionId.delete({ params: { session_id: sessionId } }) - } - - async revokeSelf(): Promise { - await this.orpc.account.sessions.self.delete() - } -} diff --git a/cli/src/api/account.test.ts b/cli/src/api/account.test.ts deleted file mode 100644 index f629bdcf5f8..00000000000 --- a/cli/src/api/account.test.ts +++ /dev/null @@ -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, - ) - }) -}) diff --git a/cli/src/api/account.ts b/cli/src/api/account.ts deleted file mode 100644 index 39f61d63bc8..00000000000 --- a/cli/src/api/account.ts +++ /dev/null @@ -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 { - return this.orpc.account.get() - } -} diff --git a/cli/src/api/app-dsl.test.ts b/cli/src/api/app-dsl.test.ts deleted file mode 100644 index 6f30af6ce5b..00000000000 --- a/cli/src/api/app-dsl.test.ts +++ /dev/null @@ -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([]) - }) -}) diff --git a/cli/src/api/app-dsl.ts b/cli/src/api/app-dsl.ts deleted file mode 100644 index ccbcbc613e6..00000000000 --- a/cli/src/api/app-dsl.ts +++ /dev/null @@ -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 { - return this.orpc.workspaces.byWorkspaceId.apps.imports.post({ - params: { workspace_id: workspaceId }, - body: payload, - }) - } - - async confirmImport(workspaceId: string, importId: string): Promise { - return this.orpc.workspaces.byWorkspaceId.apps.imports.byImportId.confirm.post({ - params: { workspace_id: workspaceId, import_id: importId }, - }) - } - - async exportDsl(appId: string, query?: ExportQuery): Promise { - const resp = await this.orpc.apps.byAppId.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": ""}; the - // contract generator marks it as loose because the backend annotation - // does not narrow the shape. Extract `data` directly. - const data = (resp as Record).data - if (typeof data !== 'string') throw new Error('export response missing data field') - return data - } - - async checkDependencies(appId: string): Promise { - return this.orpc.apps.byAppId.dependencies.check.get({ - params: { app_id: appId }, - }) - } -} diff --git a/cli/src/api/app-meta.test.ts b/cli/src/api/app-meta.test.ts deleted file mode 100644 index 57e65d7776a..00000000000 --- a/cli/src/api/app-meta.test.ts +++ /dev/null @@ -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 } - 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) - }) -}) diff --git a/cli/src/api/app-meta.ts b/cli/src/api/app-meta.ts deleted file mode 100644 index 73472ca3af7..00000000000 --- a/cli/src/api/app-meta.ts +++ /dev/null @@ -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 { - 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 { - if (this.cache !== undefined) await this.cache.delete(this.host, appId) - } -} diff --git a/cli/src/api/app-reader.test.ts b/cli/src/api/app-reader.test.ts deleted file mode 100644 index 1a5568860b6..00000000000 --- a/cli/src/api/app-reader.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/cli/src/api/app-reader.ts b/cli/src/api/app-reader.ts deleted file mode 100644 index 713ec64bbd1..00000000000 --- a/cli/src/api/app-reader.ts +++ /dev/null @@ -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 - describe: (appId: string, fields?: readonly string[]) => Promise -} - -// 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> = { - [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) -} diff --git a/cli/src/api/app-run.test.ts b/cli/src/api/app-run.test.ts deleted file mode 100644 index 9c6c9d7064d..00000000000 --- a/cli/src/api/app-run.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/cli/src/api/app-run.ts b/cli/src/api/app-run.ts deleted file mode 100644 index 0cbc607b3fc..00000000000 --- a/cli/src/api/app-run.ts +++ /dev/null @@ -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> - readonly conversationId?: string - readonly workspaceId?: string - readonly workflowId?: string - readonly files?: readonly Record[] -} - -export function buildRunBody(args: RunBodyArgs): Record { - const body: Record = { - 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, - opts: StreamOptions = {}, - ): Promise> { - 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 { - 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, - ): Promise { - 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> { - 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)) - } -} diff --git a/cli/src/api/apps.test.ts b/cli/src/api/apps.test.ts deleted file mode 100644 index 8d5facca3d5..00000000000 --- a/cli/src/api/apps.test.ts +++ /dev/null @@ -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/, 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') - }) -}) diff --git a/cli/src/api/apps.ts b/cli/src/api/apps.ts deleted file mode 100644 index ec8471993fe..00000000000 --- a/cli/src/api/apps.ts +++ /dev/null @@ -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 { - 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 { - return this.orpc.apps.byAppId.get({ - params: { app_id: appId }, - query: { - fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined, - }, - }) - } -} diff --git a/cli/src/api/device-flow.test.ts b/cli/src/api/device-flow.test.ts deleted file mode 100644 index f7bc6042edf..00000000000 --- a/cli/src/api/device-flow.test.ts +++ /dev/null @@ -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 -} - -function startStub( - handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, -): Promise { - 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((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') - }) -}) diff --git a/cli/src/api/file-upload.test.ts b/cli/src/api/file-upload.test.ts deleted file mode 100644 index d27d6701782..00000000000 --- a/cli/src/api/file-upload.test.ts +++ /dev/null @@ -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, - ) - }) -}) diff --git a/cli/src/api/file-upload.ts b/cli/src/api/file-upload.ts deleted file mode 100644 index 9989de7f84a..00000000000 --- a/cli/src/api/file-upload.ts +++ /dev/null @@ -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 = { - 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 { - 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(`apps/${encodeURIComponent(appId)}/files`, { - body: form, - timeoutMs: 60_000, - }) - } -} diff --git a/cli/src/api/members.test.ts b/cli/src/api/members.test.ts deleted file mode 100644 index deeb9636821..00000000000 --- a/cli/src/api/members.test.ts +++ /dev/null @@ -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//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/: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, - ) - }) -}) diff --git a/cli/src/api/members.ts b/cli/src/api/members.ts deleted file mode 100644 index 930972e55f1..00000000000 --- a/cli/src/api/members.ts +++ /dev/null @@ -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//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 { - 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 { - return this.orpc.workspaces.byWorkspaceId.members.post({ - params: { workspace_id: workspaceId }, - body: payload, - }) - } - - async remove(workspaceId: string, memberId: string): Promise { - return this.orpc.workspaces.byWorkspaceId.members.byMemberId.delete({ - params: { workspace_id: workspaceId, member_id: memberId }, - }) - } - - async updateRole( - workspaceId: string, - memberId: string, - payload: MemberRoleUpdatePayload, - ): Promise { - return this.orpc.workspaces.byWorkspaceId.members.byMemberId.patch({ - params: { workspace_id: workspaceId, member_id: memberId }, - body: payload, - }) - } -} diff --git a/cli/src/api/meta.test.ts b/cli/src/api/meta.test.ts deleted file mode 100644 index fc4ab25c7a3..00000000000 --- a/cli/src/api/meta.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/cli/src/api/meta.ts b/cli/src/api/meta.ts deleted file mode 100644 index 6e9fdfdf208..00000000000 --- a/cli/src/api/meta.ts +++ /dev/null @@ -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 { - return this.orpc.version.get() - } -} diff --git a/cli/src/api/oauth-device.ts b/cli/src/api/oauth-device.ts deleted file mode 100644 index 955bb3d0169..00000000000 --- a/cli/src/api/oauth-device.ts +++ /dev/null @@ -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 = { - 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 { - 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 { - 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 = {} - 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, - }) -} diff --git a/cli/src/api/permitted-external-apps.test.ts b/cli/src/api/permitted-external-apps.test.ts deleted file mode 100644 index 16ad260d40e..00000000000 --- a/cli/src/api/permitted-external-apps.test.ts +++ /dev/null @@ -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' } }) - }) -}) diff --git a/cli/src/api/permitted-external-apps.ts b/cli/src/api/permitted-external-apps.ts deleted file mode 100644 index c0164d3c536..00000000000 --- a/cli/src/api/permitted-external-apps.ts +++ /dev/null @@ -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 { - 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 { - return this.orpc.permittedExternalApps.byAppId.get({ - params: { app_id: appId }, - query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined }, - }) - } -} diff --git a/cli/src/api/workspaces.test.ts b/cli/src/api/workspaces.test.ts deleted file mode 100644 index 4e7b4b11ba8..00000000000 --- a/cli/src/api/workspaces.test.ts +++ /dev/null @@ -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, - ) - }) -}) diff --git a/cli/src/api/workspaces.ts b/cli/src/api/workspaces.ts deleted file mode 100644 index c846a81ca9e..00000000000 --- a/cli/src/api/workspaces.ts +++ /dev/null @@ -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 { - 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 { - return this.orpc.workspaces.byWorkspaceId.switch.post({ params: { workspace_id: workspaceId } }) - } -} diff --git a/cli/src/auth/device-api.test.ts b/cli/src/auth/device-api.test.ts new file mode 100644 index 00000000000..c5ac159968f --- /dev/null +++ b/cli/src/auth/device-api.test.ts @@ -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( + handler: (res: ServerResponse) => void, + fn: (baseUrl: string) => Promise, +): Promise { + const server = createServer((_req, res) => handler(res)) + await new Promise((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((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', + }) + }, + ) +}) diff --git a/cli/src/auth/device-api.ts b/cli/src/auth/device-api.ts new file mode 100644 index 00000000000..0206798f9df --- /dev/null +++ b/cli/src/auth/device-api.ts @@ -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 + readonly pollOnce: (req: PollRequest) => Promise +} + +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> = { + authorization_pending: 'pending', + slow_down: 'slow_down', + expired_token: 'expired', + access_denied: 'denied', +} + +type PollPayload = { error?: string } & Partial + +const CODE_RESPONSE_REQUIRED_FIELDS = ['device_code', 'user_code', 'verification_uri'] as const + +function parseCodeResponse(text: string): CodeResponse { + let payload: Record + try { + payload = text === '' ? {} : (JSON.parse(text) as Record) + } 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 { + 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 } + }, + } +} diff --git a/cli/src/commands/auth/login/device-flow.test.ts b/cli/src/auth/device-flow.test.ts similarity index 87% rename from cli/src/commands/auth/login/device-flow.test.ts rename to cli/src/auth/device-flow.test.ts index 616e4f9695e..5bc16bfc23d 100644 --- a/cli/src/commands/auth/login/device-flow.test.ts +++ b/cli/src/auth/device-flow.test.ts @@ -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/) diff --git a/cli/src/commands/auth/login/device-flow.ts b/cli/src/auth/device-flow.ts similarity index 50% rename from cli/src/commands/auth/login/device-flow.ts rename to cli/src/auth/device-flow.ts index eb1b2b5bad9..f8b0f6cca33 100644 --- a/cli/src/commands/auth/login/device-flow.ts +++ b/cli/src/auth/device-flow.ts @@ -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 isCancelled: () => boolean @@ -32,37 +35,50 @@ 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) - switch (result.status) { - case 'approved': - return result.success - case 'pending': - break - case 'slow_down': - interval = Math.min(interval * 2, MAX_INTERVAL_MS) - break - case 'expired': - throw expired() - case 'denied': - throw new BaseError({ - code: ErrorCode.AccessDenied, - message: 'authorization denied', - }) - case 'retry_5xx': - throw new BaseError({ - code: ErrorCode.Server5xx, - message: 'device-flow poll unavailable after retries', - }) - } + 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 expired() + if (opts.clock.isCancelled()) throw cancelledError() + } +} + +export async function pollAuthorization( + api: DeviceFlowApiSubset, + deviceCode: string, + opts: AwaitOptions, +): Promise { + 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': + case 'slow_down': + return undefined + case 'expired': + throw expired() + case 'denied': + throw new BaseError({ + code: ErrorCode.AccessDenied, + message: 'authorization denied', + }) + case 'retry_5xx': + throw new BaseError({ + code: ErrorCode.ServerError, + message: 'device-flow poll unavailable after retries', + }) } } @@ -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((r) => setTimeout(r, ms)) }, - isCancelled() { - return cancelled - }, + isCancelled, } } diff --git a/cli/src/auth/hosts.test.ts b/cli/src/auth/hosts.test.ts deleted file mode 100644 index a46c5740d31..00000000000 --- a/cli/src/auth/hosts.test.ts +++ /dev/null @@ -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 - const ctx = AccountContextSchema.parse(raw) - expect((ctx as Record).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() - }) -}) diff --git a/cli/src/auth/hosts.ts b/cli/src/auth/hosts.ts deleted file mode 100644 index 7796e05e0e4..00000000000 --- a/cli/src/auth/hosts.ts +++ /dev/null @@ -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 - -export const WorkspaceSchema = z.object({ - id: z.string(), - name: z.string(), - role: z.string(), -}) -export type Workspace = z.infer - -export const ExternalSubjectSchema = z.object({ - email: z.string(), - issuer: z.string(), -}) -export type ExternalSubject = z.infer - -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 - -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 - -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 - -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 { - const raw = await getHostStore().getTyped>() - 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 { - try { - await store.remove(active.host, active.email) - } catch { - /* best-effort */ - } - this.remove(active.host, active.email) - await this.save() - } - - async save(): Promise { - await getHostStore().setTyped(RegistrySchema.parse(this.data)) - } -} diff --git a/cli/src/auth/logout.ts b/cli/src/auth/logout.ts new file mode 100644 index 00000000000..e6b2c6f00b2 --- /dev/null +++ b/cli/src/auth/logout.ts @@ -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 { + 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() +} diff --git a/cli/src/cache/app-info.test.ts b/cli/src/cache/app-info.test.ts deleted file mode 100644 index 589df109fdc..00000000000 --- a/cli/src/cache/app-info.test.ts +++ /dev/null @@ -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 - } - 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 } - expect(Object.keys(parsed.entries)).toHaveLength(1) - }) -}) diff --git a/cli/src/cache/app-info.ts b/cli/src/cache/app-info.ts deleted file mode 100644 index 2d0d033cb20..00000000000 --- a/cli/src/cache/app-info.ts +++ /dev/null @@ -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 } 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 - delete: (host: string, appId: string) => Promise - isFresh: (record: AppMetaCacheRecord, now?: Date) => boolean -} - -type State = { - entries: Map -} - -export type AppInfoCacheOptions = { - readonly store?: Store - readonly ttlMs?: number - readonly now?: () => Date -} - -export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise { - 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> { - const out = new Map() - let raw: Record - 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(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([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): Promise { - const out: Record = {} - for (const [k, v] of entries) out[k] = serialize(v) - await store.set(ENTRIES_KEY, out) -} diff --git a/cli/src/cache/compat-store.test.ts b/cli/src/cache/compat-store.test.ts deleted file mode 100644 index ef163fe90f4..00000000000 --- a/cli/src/cache/compat-store.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/cli/src/cache/compat-store.ts b/cli/src/cache/compat-store.ts deleted file mode 100644 index 97d51db11d4..00000000000 --- a/cli/src/cache/compat-store.ts +++ /dev/null @@ -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 } as const - -export type CompatStore = { - readonly isFreshCompatible: (host: string, now?: Date) => boolean - readonly markCompatible: (host: string, now?: Date) => Promise -} - -export type CompatStoreOptions = { - readonly store?: Store - readonly now?: () => Date - readonly ttlMs?: number -} - -export async function loadCompatStore(opts: CompatStoreOptions = {}): Promise { - 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> { - const out = new Map() - let raw: Record - 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): Promise { - const compatible: Record = {} - for (const [host, t] of state) compatible[host] = new Date(t).toISOString() - await store.set(COMPATIBLE_KEY, compatible) -} diff --git a/cli/src/cache/nudge-store.test.ts b/cli/src/cache/nudge-store.test.ts deleted file mode 100644 index 893a4cbab0a..00000000000 --- a/cli/src/cache/nudge-store.test.ts +++ /dev/null @@ -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/ 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 - expect((parsed.warned as Record)[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 { - const { mkdir } = await import('node:fs/promises') - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, body) -} diff --git a/cli/src/cache/nudge-store.ts b/cli/src/cache/nudge-store.ts deleted file mode 100644 index 344b6fb3b10..00000000000 --- a/cli/src/cache/nudge-store.ts +++ /dev/null @@ -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 } as const - -export type NudgeStore = { - readonly canWarn: (host: string, now?: Date) => boolean - readonly markWarned: (host: string, now?: Date) => Promise -} - -export type NudgeStoreOptions = { - readonly store?: Store - readonly now?: () => Date - readonly intervalMs?: number -} - -export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise { - 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> { - const out = new Map() - let raw: Record - 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): Promise { - const warned: Record = {} - for (const [host, t] of state) warned[host] = new Date(t).toISOString() - await store.set(WARNED_KEY, warned) -} diff --git a/cli/src/call/errors.ts b/cli/src/call/errors.ts new file mode 100644 index 00000000000..78d7122f74f --- /dev/null +++ b/cli/src/call/errors.ts @@ -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 }) +} diff --git a/cli/src/call/execute.test.ts b/cli/src/call/execute.test.ts new file mode 100644 index 00000000000..130ecaac1d6 --- /dev/null +++ b/cli/src/call/execute.test.ts @@ -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) +}) diff --git a/cli/src/call/execute.ts b/cli/src/call/execute.ts new file mode 100644 index 00000000000..d8eeea35205 --- /dev/null +++ b/cli/src/call/execute.ts @@ -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 +}> + +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 { + stream.setEncoding(FILE_ENCODING) + let text = '' + for await (const chunk of stream) text += chunk + return text +} + +function assertValidInput(op: CatalogOp, opId: string, input: Record): 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 { + 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) +} diff --git a/cli/src/call/files.ts b/cli/src/call/files.ts new file mode 100644 index 00000000000..cbd1354c2a0 --- /dev/null +++ b/cli/src/call/files.ts @@ -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), +}) diff --git a/cli/src/call/flags.test.ts b/cli/src/call/flags.test.ts new file mode 100644 index 00000000000..3a0a350d81c --- /dev/null +++ b/cli/src/call/flags.test.ts @@ -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([]) +}) diff --git a/cli/src/call/flags.ts b/cli/src/call/flags.ts new file mode 100644 index 00000000000..cf41f774168 --- /dev/null +++ b/cli/src/call/flags.ts @@ -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 = Readonly<{ schema: S; kinds: readonly Kind[] }> + +function flag(schema: S, kinds: readonly Kind[]): FlagSpec { + 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>> = { [K in keyof T]: T[K]['schema'] } + +function schemasOf>>(table: T): SchemasOf { + return Object.fromEntries( + Object.entries(table).map(([name, spec]) => [name, spec.schema]), + ) as SchemasOf +} + +export const CALL_FLAG_SCHEMA = z.object(schemasOf(CALL_FLAGS)) + +export type CallFlags = z.infer + +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)) +} diff --git a/cli/src/call/fold.test.ts b/cli/src/call/fold.test.ts new file mode 100644 index 00000000000..d0aac2c0e07 --- /dev/null +++ b/cli/src/call/fold.test.ts @@ -0,0 +1,93 @@ +import { expect, it } from 'vite-plus/test' +import { exitCodeFor, finishFold, foldEvent, newFoldResult } from './fold' + +function run(events: [string, Record][]) { + 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: [] }) +}) diff --git a/cli/src/call/fold.ts b/cli/src/call/fold.ts new file mode 100644 index 00000000000..2c1a359ad1a --- /dev/null +++ b/cli/src/call/fold.ts @@ -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): 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 +} diff --git a/cli/src/call/input.test.ts b/cli/src/call/input.test.ts new file mode 100644 index 00000000000..e8bb26ecaef --- /dev/null +++ b/cli/src/call/input.test.ts @@ -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 = {}) => ({ + 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 = {}) => ({ + 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({}) +}) diff --git a/cli/src/call/input.ts b/cli/src/call/input.ts new file mode 100644 index 00000000000..607fe9a7626 --- /dev/null +++ b/cli/src/call/input.ts @@ -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 + readonly readFile: (p: string) => Promise +} + +export type FileRefSource = Pick + +/** 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 { + 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 { + 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> { + 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, + schema: JsonSchema, + src: FileRefSource, +): Promise> { + 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, + schema: JsonSchema, + pins: Readonly>, +): Record { + 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 +} diff --git a/cli/src/call/json-body.ts b/cli/src/call/json-body.ts new file mode 100644 index 00000000000..07016910912 --- /dev/null +++ b/cli/src/call/json-body.ts @@ -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 { + 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), + }) + } +} diff --git a/cli/src/call/render/file.ts b/cli/src/call/render/file.ts new file mode 100644 index 00000000000..a23e7e4e812 --- /dev/null +++ b/cli/src/call/render/file.ts @@ -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> = { + '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 = { path, size: bytes.byteLength } + if (contentType !== null) receipt.content_type = contentType + return renderJson(io, receipt) +} diff --git a/cli/src/call/render/index.ts b/cli/src/call/render/index.ts new file mode 100644 index 00000000000..9f37032a649 --- /dev/null +++ b/cli/src/call/render/index.ts @@ -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 + +const RENDERERS: Readonly> = { + [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 +} diff --git a/cli/src/call/render/list.ts b/cli/src/call/render/list.ts new file mode 100644 index 00000000000..dcd7264f951 --- /dev/null +++ b/cli/src/call/render/list.ts @@ -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 diff --git a/cli/src/call/render/object.ts b/cli/src/call/render/object.ts new file mode 100644 index 00000000000..1f915ce1a21 --- /dev/null +++ b/cli/src/call/render/object.ts @@ -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 { + await io.document(value) + return ExitCode.Success +} + +export const objectRenderer: Renderer = async (res, io) => renderJson(io, await parseJsonBody(res)) diff --git a/cli/src/call/render/render.test.ts b/cli/src/call/render/render.test.ts new file mode 100644 index 00000000000..40fa8f253ab --- /dev/null +++ b/cli/src/call/render/render.test.ts @@ -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('', { 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) + } +}) diff --git a/cli/src/call/render/sse.ts b/cli/src/call/render/sse.ts new file mode 100644 index 00000000000..6fae864b565 --- /dev/null +++ b/cli/src/call/render/sse.ts @@ -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 { + 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) +} diff --git a/cli/src/call/render/text.ts b/cli/src/call/render/text.ts new file mode 100644 index 00000000000..d7416a71ebb --- /dev/null +++ b/cli/src/call/render/text.ts @@ -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 +} diff --git a/cli/src/call/request.test.ts b/cli/src/call/request.test.ts new file mode 100644 index 00000000000..bb16f11c4ce --- /dev/null +++ b/cli/src/call/request.test.ts @@ -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' }) +}) diff --git a/cli/src/call/request.ts b/cli/src/call/request.ts new file mode 100644 index 00000000000..7650395a1b7 --- /dev/null +++ b/cli/src/call/request.ts @@ -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 = 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> = { + [BIND.Path]: Placement.Path, + [BIND.Query]: Placement.Query, + [BIND.Body]: Placement.Body, + [BIND.File]: Placement.File, +} + +type Buckets = Readonly>> + +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): 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 { + 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): HttpRequest['query'] { + const query: Record = {} + 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): Record { + const body: Record = {} + 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 { + 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 { + 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 { + 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): boolean { + return [...values.values()].some((value) => value !== null && value !== undefined) +} + +export async function buildRequest( + op: CatalogOp, + input: Record, + deps: { readonly readFile: FileReader }, +): Promise { + 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 } +} diff --git a/cli/src/http/sse-dify.test.ts b/cli/src/call/sse-dify.test.ts similarity index 100% rename from cli/src/http/sse-dify.test.ts rename to cli/src/call/sse-dify.test.ts diff --git a/cli/src/http/sse-dify.ts b/cli/src/call/sse-dify.ts similarity index 100% rename from cli/src/http/sse-dify.ts rename to cli/src/call/sse-dify.ts diff --git a/cli/src/http/sse.test.ts b/cli/src/call/sse.test.ts similarity index 100% rename from cli/src/http/sse.test.ts rename to cli/src/call/sse.test.ts diff --git a/cli/src/http/sse.ts b/cli/src/call/sse.ts similarity index 100% rename from cli/src/http/sse.ts rename to cli/src/call/sse.ts diff --git a/cli/src/call/validate.test.ts b/cli/src/call/validate.test.ts new file mode 100644 index 00000000000..8f00ae750cc --- /dev/null +++ b/cli/src/call/validate.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, it } from 'vite-plus/test' +import { parseCatalog } from '@/plugins/catalog' +import { validateInput } from './validate' + +const doc = parseCatalog(readFileSync(join(__dirname, '../../test/fixtures/catalog.json'))) +const run = doc.ops['run.console_app.workflow']!.input +const list = doc.ops['get.console_app']!.input + +it('returns no details for valid input against the real schemas', () => { + expect( + validateInput(run, { app_id: 'a', inputs: {}, files: { doc: './r.pdf', pages: ['1.png'] } }), + ).toEqual([]) + expect(validateInput(list, { workspace_id: 'not-a-uuid', limit: 5 })).toEqual([]) // formats are the server's job +}) + +it('reports missing required, wrong types and bad enums in the 422 detail shape', () => { + expect(validateInput(run, { inputs: 'x' })).toContainEqual({ + type: 'required', + loc: ['app_id'], + msg: expect.stringContaining('required'), + }) + expect(validateInput(run, { app_id: 'a', inputs: 'x' })).toContainEqual({ + type: 'type', + loc: ['inputs'], + msg: expect.stringContaining('object'), + }) + expect(validateInput(list, { workspace_id: 'w', mode: 'agent' })).toContainEqual({ + type: 'enum', + loc: ['mode'], + msg: expect.stringContaining('allowed'), + }) +}) + +it('lets unknown keys through to the server', () => { + expect(validateInput(run, { app_id: 'a', inputs: {}, extra: 1 })).toEqual([]) +}) diff --git a/cli/src/call/validate.ts b/cli/src/call/validate.ts new file mode 100644 index 00000000000..0036e8274f8 --- /dev/null +++ b/cli/src/call/validate.ts @@ -0,0 +1,41 @@ +import type { ErrorObject, ValidateFunction } from 'ajv/dist/2020' +import type { JsonSchema } from '@/plugins/catalog' +import Ajv2020 from 'ajv/dist/2020' + +export type ValidationDetail = { + readonly type: string + readonly loc: (string | number)[] + readonly msg: string +} + +// One shared Ajv instance: schemas come from a trusted, already-parsed catalog, so +// $data is never enabled and formats are left to the server (validateFormats: false). +const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false }) +const compiled = new WeakMap() + +function compile(schema: JsonSchema): ValidateFunction { + const cached = compiled.get(schema) + if (cached !== undefined) return cached + const validate = ajv.compile(schema) + compiled.set(schema, validate) + return validate +} + +function locFor(error: ErrorObject): (string | number)[] { + const loc = error.instancePath.split('/').filter(Boolean) + if (error.keyword === 'required') { + const missingProperty = (error.params as { missingProperty?: string }).missingProperty + if (missingProperty !== undefined) loc.push(missingProperty) + } + return loc +} + +function toDetail(error: ErrorObject): ValidationDetail { + return { type: error.keyword, loc: locFor(error), msg: error.message ?? error.keyword } +} + +export function validateInput(schema: JsonSchema, input: unknown): readonly ValidationDetail[] { + const validate = compile(schema) + if (validate(input) || validate.errors == null) return [] + return validate.errors.map(toDetail) +} diff --git a/cli/src/commands/AGENTS.md b/cli/src/commands/AGENTS.md index 6d675ef70a9..4922c2da0b8 100644 --- a/cli/src/commands/AGENTS.md +++ b/cli/src/commands/AGENTS.md @@ -5,19 +5,17 @@ Per-command agent-optimized usage and structure guide. ## Command folder convention Every command is a folder. `index.ts` is the command class file. All related -code — business logic, helpers, tests, and optional agent guide — colocates inside -the folder. Subcommands are subfolders. +code — business logic, helpers, tests — colocates inside the folder. +Subcommands are subfolders. ``` src/commands/ / / - index.ts ← command class (extends DifyCommand; the ONLY file the registry discovers) - run.ts ← optional behavior owner (not a command, invisible to the registry) - handlers.ts ← helpers - guide.ts ← agent guide string (optional) + index.ts ← command class (extends Command; the ONLY file the registry discovers) + helpers.ts ← optional behavior owner (not a command, invisible to the registry) *.test.ts ← tests - / ← subcommand (e.g. auth/devices/list/) + / ← subcommand (e.g. ops/describe/) index.ts _shared/ ← intra-topic shared code (only when needed by 2+ siblings) .ts @@ -26,45 +24,51 @@ src/commands/ The registry generator (`pnpm tree:gen` → `src/commands/tree.generated.ts`) discovers commands only via `**/index.+(js|cjs|mjs|ts)`. All other files in command folders are invisible to the registry — add freely without glob exclusions. -Folders prefixed with `_` (e.g. `_shared/`, `_strategies/`) are excluded from -registry discovery and from coverage checks. +Folders prefixed with `_` are excluded from registry discovery. + +The command class extends `Command` (`src/plugins/commands/command.ts`): + +```ts +export default class MyCmd extends Command { + static override summary = 'One-line description' + static override effect = 'write' as const // 'read' (default) | 'write' | 'destructive' + static override input = INPUT // a Zod object; also the argv parser's schema + static override positional = ['id'] as const // optional; maps positional argv to input keys + static override examples = [{ title: '...', input: { id: '...' } }] // optional + static override hidden = true // optional; keeps the command out of help, not out of the tree + + async run(input: z.infer, ctx: CommandContext) { + // read plugin services with ctx.get(...); the return value is printed as + // indented JSON. Return an Outcome for a non-zero exit or output already + // written through ctx.get(io); notices go to ctx.get(io).notice(...). + } +} +``` + +`schema()` is the JSON Schema help documents and `finalize` validates, derived from +`input`; `flags()` is what the argv parser accepts (default: `schema()`); +`facets()` is the op half of the descriptor (default: none); `finalize(input, path)` +is the one input gate, throwing `input_invalid` and then applying the Zod defaults. +Only the catalog-op adapter overrides them. + +`run`'s return value is the JSON value printed to stdout; there is no separate +output-formatting step and no `agentGuide` — `--help` on any command prints the +same descriptor (`summary`, `effect`, `input`, `positional`, `examples`, plus +`kind`, `bind`, `pins` for a catalog operation) that `difyctl help ` prints. ## Adding a new command -1. Create `src/commands///index.ts` extending `DifyCommand`. -1. Keep small owner-local behavior in `index.ts`; extract sibling modules such as `run.ts` or `handlers.ts` when logic needs independent tests, reuse, or a clearer owner. +1. Create `src/commands///index.ts` extending `Command`. +1. Keep small owner-local behavior in `index.ts`; extract a sibling module when logic needs independent tests, reuse, or a clearer owner. 1. Run `pnpm tree:gen` to regenerate the command tree (also runs implicitly via `prebuild`/`predev`/`pretest`). 1. Run `pnpm test` to verify coverage. -## Adding an agent guide - - -1. Create `src/commands///guide.ts` exporting a plain string: - ```ts - export const agentGuide = ` - WORKFLOW - 1. ... - - ERROR RECOVERY - ... - ` - ``` -1. Import and assign in `index.ts`: - ```ts - import { agentGuide } from './guide.js' - - export default class MyCmd extends DifyCommand { - override agentGuide(): string { - return agentGuide - } - } - ``` -1. The guide appears at the bottom of `difyctl --help` automatically. -1. Agents call `difyctl --help` to read both structural help and workflow guidance. - ## Shared utilities -Code used by two or more commands lives in `src//` (e.g. `src/auth/`, -`src/api/`, `src/errors/`). Do not put broadly shared code inside a command folder. -Intra-topic shared code (used only within one topic's commands) uses `_shared/` -within that topic folder. +Code used by two or more commands lives in a top-level domain folder (e.g. +`src/call/`, `src/errors/`) or on a plugin service, never inside a +command folder. One command never imports from another command's folder. + +A command imports a plugin through `@/plugins/` and reads it with +`ctx.get(...)`. The framework files are the exception: +`plugins/commands/{command,cancel,registry}.ts` and `plugins/argv/parse.ts`. diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts deleted file mode 100644 index ce25d082359..00000000000 --- a/cli/src/commands/_shared/authed-command.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { AppInfoCache } from '@/cache/app-info' -import type { Command } from '@/framework/command' -import type { HttpClient } from '@/http/types' -import type { TokenStore } from '@/store/token-store' -import type { IOStreams } from '@/sys/io/streams' -import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta' -import { notLoggedInError, Registry } from '@/auth/hosts' -import { loadAppInfoCache } from '@/cache/app-info' -import { loadNudgeStore } from '@/cache/nudge-store' -import { getEnv } from '@/env/registry' -import { formatErrorForCli } from '@/errors/format' -import { createHttpClient } from '@/http/client' -import { getTokenStore } from '@/store/manager' -import { realStreams } from '@/sys/io/streams' -import { activeHostInfo, openAPIBase } from '@/util/host' -import { enforceDifyVersion } from '@/version/enforce' -import { versionInfo } from '@/version/info' -import { maybeNudgeCompat } from '@/version/nudge' -import { resolveRetryAttempts } from './global-flags.js' - -export type AuthedContext = { - readonly reg: Registry - readonly active: ActiveContext - readonly store: TokenStore - readonly http: HttpClient - readonly host: string - readonly io: IOStreams - readonly cache?: AppInfoCache -} - -export type AuthedContextOptions = { - readonly retryFlag: number | undefined - readonly withCache?: boolean - readonly format?: string -} - -export async function buildAuthedContext( - cmd: Pick, - opts: AuthedContextOptions, -): Promise { - const io = realStreams(opts.format ?? '') - const reg = await Registry.load() - const active = reg.resolveActive() - if (active === undefined) fail(cmd, opts, io) - - const store = getTokenStore(reg.token_storage) - const bearer = await store.read(active.host, active.email) - if (bearer === '') fail(cmd, opts, io) - - const { host, insecure } = activeHostInfo(active) - const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv }) - const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts, insecure }) - - const cache = opts.withCache === true ? await loadAppInfoCache() : undefined - - // Hard gate: refuse a server too old for this difyctl (throws → exit 6). - // Cached per host (1h) so most commands don't re-probe. Then the soft nudge - // handles the "server too new" direction. - await enforceDifyVersion(host, { insecure }) - await runCompatNudge({ host, insecure, io }) - - return { reg, active, store, http, host, io, cache } -} - -function fail(cmd: Pick, opts: AuthedContextOptions, io: IOStreams): never { - const err = notLoggedInError() - cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { - exit: err.exit(), - }) -} - -// Best-effort nudge: never throws, never blocks. Lives here so every authed -// command flows through it without per-command wiring. -async function runCompatNudge(opts: { - readonly host: string - readonly insecure: boolean - readonly io: IOStreams -}): Promise { - try { - const store = await loadNudgeStore() - await maybeNudgeCompat(opts.host, { - store, - probe: async (host) => { - const http = createHttpClient({ - baseURL: openAPIBase(host), - timeoutMs: META_PROBE_TIMEOUT_MS, - retryAttempts: 0, - insecure: opts.insecure, - }) - return new MetaClient(http).serverVersion() - }, - emit: (line) => opts.io.err.write(line), - isTty: opts.io.isOutTTY, - format: opts.io.outputFormat, - clientVersion: versionInfo.version, - color: opts.io.isErrTTY, - }) - } catch { - // already swallowed inside maybeNudgeCompat; this is belt-and-braces - } -} diff --git a/cli/src/commands/_shared/dify-command.ts b/cli/src/commands/_shared/dify-command.ts deleted file mode 100644 index cfde145f805..00000000000 --- a/cli/src/commands/_shared/dify-command.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AuthedContext, AuthedContextOptions } from './authed-command' -import { Command } from '@/framework/command' -import { buildAuthedContext } from './authed-command' - -export abstract class DifyCommand extends Command { - protected async authedCtx(opts: AuthedContextOptions): Promise { - return buildAuthedContext(this, opts) - } -} diff --git a/cli/src/commands/_shared/global-flags.test.ts b/cli/src/commands/_shared/global-flags.test.ts deleted file mode 100644 index 8e8c68f8346..00000000000 --- a/cli/src/commands/_shared/global-flags.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { resolveRetryAttempts } from './global-flags' - -describe('resolveRetryAttempts', () => { - it('returns flag value when given', () => { - expect(resolveRetryAttempts({ flag: 1, env: () => undefined })).toBe(1) - }) - - it('returns 0 when flag is 0', () => { - expect(resolveRetryAttempts({ flag: 0, env: () => undefined })).toBe(0) - }) - - it('falls back to DIFYCTL_HTTP_RETRY env when flag missing', () => { - expect(resolveRetryAttempts({ flag: undefined, env: () => '5' })).toBe(5) - }) - - it('falls back to default 3 when flag and env missing', () => { - expect(resolveRetryAttempts({ flag: undefined, env: () => undefined })).toBe(3) - }) - - it('throws typed BaseError with UsageInvalidFlag on non-numeric env', () => { - let caught: unknown - try { - resolveRetryAttempts({ flag: undefined, env: () => 'foo' }) - } catch (e) { - caught = e - } - expect((caught as { code: string }).code).toBe('usage_invalid_flag') - expect((caught as Error).message).toMatch(/DIFYCTL_HTTP_RETRY/) - }) - - it('throws typed BaseError with UsageInvalidFlag on negative env', () => { - let caught: unknown - try { - resolveRetryAttempts({ flag: undefined, env: () => '-1' }) - } catch (e) { - caught = e - } - expect((caught as { code: string }).code).toBe('usage_invalid_flag') - expect((caught as Error).message).toMatch(/DIFYCTL_HTTP_RETRY/) - }) -}) diff --git a/cli/src/commands/_shared/global-flags.ts b/cli/src/commands/_shared/global-flags.ts deleted file mode 100644 index d03bdc8256d..00000000000 --- a/cli/src/commands/_shared/global-flags.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { Flags } from '@/framework/flags' - -export const HTTP_RETRY_DEFAULT = 3 - -export const httpRetryFlag = Flags.integer({ - description: - 'HTTP retry attempts for GET/PUT/DELETE on transient errors. 0 disables. Overrides DIFYCTL_HTTP_RETRY.', - helpGroup: 'GLOBAL', -}) - -export type ResolveRetryAttemptsOpts = { - flag: number | undefined - env: (k: string) => string | undefined -} - -export function resolveRetryAttempts(opts: ResolveRetryAttemptsOpts): number { - if (opts.flag !== undefined) return opts.flag - const raw = opts.env('DIFYCTL_HTTP_RETRY') - if (raw === undefined || raw === '') return HTTP_RETRY_DEFAULT - if (!/^-?\d+$/.test(raw)) - throw newError( - ErrorCode.UsageInvalidFlag, - `DIFYCTL_HTTP_RETRY: ${JSON.stringify(raw)} is not a non-negative integer`, - ) - const n = Number(raw) - if (n < 0) throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${n} is negative`) - return n -} diff --git a/cli/src/commands/agent-guides.test.ts b/cli/src/commands/agent-guides.test.ts deleted file mode 100644 index db918765ca7..00000000000 --- a/cli/src/commands/agent-guides.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { CommandConstructor } from '@/framework/command' -import { describe, expect, it } from 'vite-plus/test' -import Login from '@/commands/auth/login/index' -import DescribeApp from '@/commands/describe/app/index' -import ExportStudioApp from '@/commands/export/studio-app/index' -import GetApp from '@/commands/get/app/index' -import ImportStudioApp from '@/commands/import/studio-app/index' -import ResumeApp from '@/commands/resume/app/index' -import RunApp from '@/commands/run/app/index' - -// Commands an agent chains through; each must expose a non-empty agentGuide -// so the wiring (index.ts override + guide.ts) is never silently dropped. -const GUIDED_COMMANDS: ReadonlyArray = [ - ['run app', RunApp], - ['resume app', ResumeApp], - ['describe app', DescribeApp], - ['get app', GetApp], - ['export studio-app', ExportStudioApp], - ['import studio-app', ImportStudioApp], - ['auth login', Login], -] - -describe('agent guides', () => { - it.each(GUIDED_COMMANDS)('%s exposes a non-empty agentGuide', (_name, Ctor) => { - expect(new Ctor().agentGuide().length).toBeGreaterThan(0) - }) -}) diff --git a/cli/src/commands/auth/devices/_shared/devices.test.ts b/cli/src/commands/auth/devices/_shared/devices.test.ts deleted file mode 100644 index 9d68c4e9c64..00000000000 --- a/cli/src/commands/auth/devices/_shared/devices.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openapi/types.gen' -import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { AccountSessionsClient } from '@/api/account-sessions' -import type { ActiveContext } from '@/auth/hosts' -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { startMock } from '@test/fixtures/dify-mock/server' -import { testHttpClient } from '@test/fixtures/http-client' -import { MemStore } from '@test/fixtures/mem-store' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { Registry } from '@/auth/hosts' -import { bufferStreams } from '@/sys/io/streams' -import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js' - -function buildRegistry( - host: string, - email: string, - tokenId: string, -): { reg: Registry; active: ActiveContext } { - const reg = Registry.empty('file') - reg.upsert(host, email, { - account: { id: 'acct-1', email, name: 'Test Tester' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - token_id: tokenId, - }) - reg.setHost(host) - reg.setAccount(email) - const active = reg.resolveActive()! - return { reg, active } -} - -describe('runDevicesList', () => { - let mock: DifyMock - beforeEach(async () => { - mock = await startMock({ scenario: 'happy' }) - }) - afterEach(async () => { - await mock.stop() - }) - - it('table: marks current with *', async () => { - const io = bufferStreams() - const http = testHttpClient(mock.url, 'dfoa_test') - await runDevicesList({ io, tokenId: 'tok-1', http }) - const out = io.outBuf() - expect(out).toContain('DEVICE') - expect(out).toContain('difyctl on laptop') - expect(out).toContain('difyctl on desktop') - const lines = out.trim().split('\n') - const laptopLine = lines.find((l) => l.includes('difyctl on laptop'))! - expect(laptopLine).toMatch(/\*\s*$/) - }) - - it('json: emits PaginationEnvelope unchanged', async () => { - const io = bufferStreams() - const http = testHttpClient(mock.url, 'dfoa_test') - await runDevicesList({ io, tokenId: 'tok-1', http, json: true }) - const parsed = JSON.parse(io.outBuf()) as Record - expect(parsed.page).toBe(1) - expect(Array.isArray(parsed.data)).toBe(true) - expect((parsed.data as unknown[]).length).toBe(3) - }) -}) - -describe('runDevicesRevoke', () => { - let mock: DifyMock - useTempConfigDir('difyctl-devrevoke-') - beforeEach(async () => { - mock = await startMock({ scenario: 'happy' }) - }) - afterEach(async () => { - await mock.stop() - }) - - it('exact device_label: revokes one + leaves local creds', async () => { - const io = bufferStreams() - const store = new MemStore() - const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - await store.write(mock.url, 'tester@dify.ai', 'dfoa_test') - await reg.save() - const http = testHttpClient(mock.url, 'dfoa_test') - - await runDevicesRevoke({ - io, - reg, - active, - store, - http, - target: 'difyctl on desktop', - all: false, - }) - expect(io.outBuf()).toContain('Revoked 1 session(s)') - expect(store.entries.size).toBe(1) - }) - - it('exact id: revokes one', async () => { - const io = bufferStreams() - const store = new MemStore() - const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - const http = testHttpClient(mock.url, 'dfoa_test') - - await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false }) - expect(io.outBuf()).toContain('Revoked 1 session(s)') - }) - - it('substring: unique match revokes', async () => { - const io = bufferStreams() - const store = new MemStore() - const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - const http = testHttpClient(mock.url, 'dfoa_test') - - await runDevicesRevoke({ io, reg, active, store, http, target: 'web', all: false }) - expect(io.outBuf()).toContain('Revoked 1 session(s)') - }) - - it('substring: ambiguous throws', async () => { - const io = bufferStreams() - const store = new MemStore() - const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - const http = testHttpClient(mock.url, 'dfoa_test') - - await expect( - runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }), - ).rejects.toThrow(/matches multiple/) - }) - - it('no match throws', async () => { - const io = bufferStreams() - const store = new MemStore() - const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - const http = testHttpClient(mock.url, 'dfoa_test') - - await expect( - runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }), - ).rejects.toThrow(/no session matches/) - }) - - it('--all: revokes everything except current', async () => { - const io = bufferStreams() - const store = new MemStore() - const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - const http = testHttpClient(mock.url, 'dfoa_test') - - await runDevicesRevoke({ io, reg, active, store, http, all: true }) - expect(io.outBuf()).toContain('Revoked 2 session(s)') - }) - - it('revoking current session clears token and removes context from registry', async () => { - const io = bufferStreams() - const store = new MemStore() - const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - await store.write(mock.url, 'tester@dify.ai', 'dfoa_test') - await reg.save() - const http = testHttpClient(mock.url, 'dfoa_test') - - await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false }) - expect(store.entries.size).toBe(0) - const saved = await Registry.load() - expect(saved?.hosts[mock.url]).toBeUndefined() - }) - - it('TTY without --yes: prompts and aborts on decline (no revoke)', async () => { - const base = bufferStreams('n\n') - const io = { ...base, isErrTTY: true } - const store = new MemStore() - const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - const http = testHttpClient(mock.url, 'dfoa_test') - - await expect(runDevicesRevoke({ io, reg, active, store, http, all: true })).rejects.toThrow( - /aborted by user/, - ) - expect(base.errBuf()).toContain('Revoke 2 session(s)? [y/N]') - expect(base.outBuf()).not.toContain('Revoked') - }) - - it('TTY without --yes: proceeds on accept', async () => { - const base = bufferStreams('y\n') - const io = { ...base, isErrTTY: true } - const store = new MemStore() - const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - const http = testHttpClient(mock.url, 'dfoa_test') - - await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false }) - expect(base.outBuf()).toContain('Revoked 1 session(s)') - }) - - it('TTY with --yes: skips prompt entirely', async () => { - const base = bufferStreams() - const io = { ...base, isErrTTY: true } - const store = new MemStore() - const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - const http = testHttpClient(mock.url, 'dfoa_test') - - await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false, yes: true }) - expect(base.errBuf()).not.toContain('[y/N]') - expect(base.outBuf()).toContain('Revoked 1 session(s)') - }) - - it('no target + no --all: throws UsageMissingArg', async () => { - const io = bufferStreams() - const store = new MemStore() - const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') - const http = testHttpClient(mock.url, 'dfoa_test') - await expect(runDevicesRevoke({ io, reg, active, store, http, all: false })).rejects.toThrow( - /specify a device label/, - ) - }) -}) - -describe('listAllSessions', () => { - const row = (id: string, label = `dev-${id}`): SessionRow => ({ - id, - prefix: 'dfoa_xxx', - client_id: 'difyctl', - device_label: label, - created_at: null, - last_used_at: null, - expires_at: null, - }) - - function stubClient(pages: readonly SessionListResponse[]): { - client: AccountSessionsClient - list: ReturnType - } { - const list = vi.fn(async (q?: { page?: number; limit?: number }) => { - const page = q?.page ?? 1 - const env = pages[page - 1] - if (env === undefined) throw new Error(`stub: no page ${page}`) - return env - }) - return { client: { list } as unknown as AccountSessionsClient, list } - } - - it('exhausts pages until has_more=false', async () => { - const { client, list } = stubClient([ - { - page: 1, - limit: 200, - total: 250, - has_more: true, - data: Array.from({ length: 200 }, (_, i) => row(`s-${i}`)), - }, - { - page: 2, - limit: 200, - total: 250, - has_more: false, - data: Array.from({ length: 50 }, (_, i) => row(`s-${200 + i}`)), - }, - ]) - const all = await listAllSessions(client) - expect(all.length).toBe(250) - expect(list).toHaveBeenCalledTimes(2) - expect(list).toHaveBeenNthCalledWith(1, { page: 1, limit: 200 }) - expect(list).toHaveBeenNthCalledWith(2, { page: 2, limit: 200 }) - }) - - it('single page (has_more=false): one call', async () => { - const { client, list } = stubClient([ - { page: 1, limit: 200, total: 3, has_more: false, data: [row('a'), row('b'), row('c')] }, - ]) - const all = await listAllSessions(client) - expect(all.length).toBe(3) - expect(list).toHaveBeenCalledTimes(1) - }) -}) diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts deleted file mode 100644 index 43c8557cb96..00000000000 --- a/cli/src/commands/auth/devices/_shared/devices.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { SessionRow } from '@dify/contracts/api/openapi/types.gen' -import type { ActiveContext, Registry } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import type { TokenStore } from '@/store/token-store' -import type { IOStreams } from '@/sys/io/streams' -import { AccountSessionsClient } from '@/api/account-sessions' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { fetchAllPages, LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '@/limit/limit' -import { colorEnabled, colorScheme } from '@/sys/io/color' -import { promptConfirm } from '@/sys/io/prompt' -import { runWithSpinner } from '@/sys/io/spinner' - -export type DevicesListOptions = { - readonly io: IOStreams - readonly tokenId: string - readonly http: HttpClient - readonly json?: boolean - readonly page?: number - readonly limitRaw?: string - readonly envLookup?: (k: string) => string | undefined -} - -export async function runDevicesList(opts: DevicesListOptions): Promise { - const sessions = new AccountSessionsClient(opts.http) - const env = opts.envLookup ?? ((k: string) => process.env[k]) - const limit = resolveLimit(opts.limitRaw, env) - const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page - const envelope = await runWithSpinner({ io: opts.io, label: 'Fetching devices' }, () => - sessions.list({ page, limit }), - ) - - if (opts.json === true) { - opts.io.out.write(`${JSON.stringify(envelope)}\n`) - return - } - - opts.io.out.write(renderTable(envelope.data, opts.tokenId)) -} - -function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number { - if (raw !== undefined && raw !== '') return parseLimit(raw, '--limit') - const envValue = env('DIFY_LIMIT') - if (envValue !== undefined && envValue !== '') return parseLimit(envValue, 'DIFY_LIMIT') - return LIMIT_DEFAULT -} - -/** - * Fetches every session across all pages. Used by revoke paths so that a - * session sitting on page 2+ is still findable / revocable. Uses the max - * page size (LIMIT_MAX) to minimize round-trips. - */ -export async function listAllSessions( - client: AccountSessionsClient, -): Promise { - return fetchAllPages((page) => client.list({ page, limit: LIMIT_MAX })) -} - -export type DevicesRevokeOptions = { - readonly io: IOStreams - readonly reg: Registry - readonly active: ActiveContext - readonly store: TokenStore - readonly http: HttpClient - readonly target?: string - readonly all: boolean - readonly yes?: boolean -} - -export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise { - const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) - if (!opts.all && (opts.target === undefined || opts.target === '')) { - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: 'specify a device label / id, or pass --all', - hint: "see 'difyctl auth devices list'", - }) - } - - const sessions = new AccountSessionsClient(opts.http) - const rows = await listAllSessions(sessions) - const { ids, selfHit } = pickTargets(rows, opts, opts.active.ctx.token_id ?? '') - if (ids.length === 0) { - opts.io.out.write('no sessions to revoke\n') - return - } - - if (opts.yes !== true && opts.io.isErrTTY) { - const confirmed = await promptConfirm(opts.io, `Revoke ${ids.length} session(s)? [y/N] `) - if (!confirmed) { - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: 'aborted by user', - hint: 'pass --yes to skip confirmation', - }) - } - } - - for (const id of ids) await sessions.revoke(id) - - if (selfHit) await opts.reg.forget(opts.active, opts.store) - - opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`) -} - -export type PickResult = { - ids: readonly string[] - selfHit: boolean -} - -export function pickTargets( - rows: readonly SessionRow[], - opts: { target?: string; all: boolean }, - currentId: string, -): PickResult { - if (opts.all) { - const ids = rows.filter((r) => r.id !== currentId).map((r) => r.id) - return { ids, selfHit: false } - } - const target = opts.target ?? '' - const byLabel = rows.filter((r) => r.device_label === target) - if (byLabel.length > 1) throw ambiguous(target, byLabel) - const onlyLabel = byLabel[0] - if (onlyLabel !== undefined) return { ids: [onlyLabel.id], selfHit: onlyLabel.id === currentId } - - const byId = rows.find((r) => r.id === target) - if (byId !== undefined) return { ids: [byId.id], selfHit: byId.id === currentId } - - const needle = target.toLowerCase() - const bySub = rows.filter((r) => r.device_label.toLowerCase().includes(needle)) - if (bySub.length > 1) throw ambiguous(target, bySub) - const onlySub = bySub[0] - if (onlySub !== undefined) return { ids: [onlySub.id], selfHit: onlySub.id === currentId } - - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: `no session matches "${target}"`, - }) -} - -function ambiguous(target: string, rows: readonly SessionRow[]): BaseError { - const labels = rows.map((r) => `${r.device_label} (${r.id})`).join(', ') - return new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: `"${target}" matches multiple sessions: ${labels}; pass an exact id to disambiguate`, - }) -} - -function renderTable(rows: readonly SessionRow[], currentId: string): string { - const header = ['DEVICE', 'CREATED', 'LAST USED', 'CURRENT'] - const body = rows.map((r) => [ - r.device_label !== '' ? r.device_label : r.id, - r.created_at ?? '', - r.last_used_at ?? '', - r.id === currentId ? '*' : '', - ]) - const widths = header.map((h, i) => - Math.max(h.length, ...body.map((row) => (row[i] ?? '').length)), - ) - const fmt = (cells: readonly string[]): string => - cells - .map((c, i) => c.padEnd(widths[i] ?? 0)) - .join(' ') - .trimEnd() - return body.length === 0 ? `${fmt(header)}\n` : `${[fmt(header), ...body.map(fmt)].join('\n')}\n` -} diff --git a/cli/src/commands/auth/devices/list/index.ts b/cli/src/commands/auth/devices/list/index.ts deleted file mode 100644 index 31254c666f5..00000000000 --- a/cli/src/commands/auth/devices/list/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { runDevicesList } from '@/commands/auth/devices/_shared/devices' -import { Flags } from '@/framework/flags' - -export default class DevicesList extends DifyCommand { - static override description = 'List active sessions for the current bearer' - - static override examples = [ - '<%= config.bin %> auth devices list', - '<%= config.bin %> auth devices list --json', - '<%= config.bin %> auth devices list --page 2 --limit 50', - ] - - static override flags = { - 'http-retry': httpRetryFlag, - json: Flags.boolean({ description: 'emit JSON', default: false }), - page: Flags.integer({ description: 'page number', default: 1 }), - limit: Flags.string({ description: 'page size [1..200]' }), - } - - async run(argv: string[]): Promise { - const { flags } = this.parse(DevicesList, argv) - const format = flags.json ? 'json' : '' - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) - await runDevicesList({ - io: ctx.io, - tokenId: ctx.active.ctx.token_id ?? '', - http: ctx.http, - json: flags.json, - page: flags.page, - limitRaw: flags.limit, - }) - } -} diff --git a/cli/src/commands/auth/devices/revoke/index.ts b/cli/src/commands/auth/devices/revoke/index.ts deleted file mode 100644 index cac89287070..00000000000 --- a/cli/src/commands/auth/devices/revoke/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { runDevicesRevoke } from '@/commands/auth/devices/_shared/devices' -import { Args, Flags } from '@/framework/flags' - -export default class DevicesRevoke extends DifyCommand { - static override description = 'Revoke one or all session devices' - - static override effect: CommandEffect = 'destructive' - - static override examples = [ - '<%= config.bin %> auth devices revoke "difyctl on laptop"', - '<%= config.bin %> auth devices revoke --all', - ] - - static override args = { - target: Args.string({ description: 'device label / id to revoke', required: false }), - } - - static override flags = { - all: Flags.boolean({ - description: 'revoke every session except the current one', - default: false, - }), - 'http-retry': httpRetryFlag, - yes: Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }), - } - - async run(argv: string[]): Promise { - const { args, flags } = this.parse(DevicesRevoke, argv) - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) - await runDevicesRevoke({ - io: ctx.io, - reg: ctx.reg, - active: ctx.active, - store: ctx.store, - http: ctx.http, - target: args.target, - all: flags.all, - yes: flags.yes, - }) - } -} diff --git a/cli/src/commands/auth/list/handlers.ts b/cli/src/commands/auth/list/handlers.ts deleted file mode 100644 index 253efd8dd15..00000000000 --- a/cli/src/commands/auth/list/handlers.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { TableCell, TableColumn } from '@/framework/output' - -const ACTIVE_MARKER = '*' - -const LIST_COLUMNS: readonly TableColumn[] = [ - { name: 'HOST', priority: 0 }, - { name: 'ACCOUNT', priority: 0 }, - { name: 'ACTIVE', priority: 0 }, -] - -export class ContextRow { - readonly host: string - readonly account: string - readonly displayName: string - readonly active: boolean - - constructor(host: string, account: string, displayName: string, active: boolean) { - this.host = host - this.account = account - this.displayName = displayName - this.active = active - } - - tableRow(): readonly TableCell[] { - const accountCell = this.displayName ? `${this.account} (${this.displayName})` : this.account - return [this.host, accountCell, this.active ? ACTIVE_MARKER : ''] - } - - name(): string { - return this.account - } - - json() { - return { - host: this.host, - account: this.account, - name: this.displayName, - active: this.active, - } - } -} - -export class ContextListOutput { - readonly rows: readonly ContextRow[] - - constructor(rows: readonly ContextRow[]) { - this.rows = rows - } - - tableColumns(): readonly TableColumn[] { - return LIST_COLUMNS - } - - tableRows(): readonly (readonly TableCell[])[] { - return this.rows.map((r) => r.tableRow()) - } - - name(): string { - return this.rows.map((r) => r.name()).join('\n') - } - - json() { - return { contexts: this.rows.map((r) => r.json()) } - } -} diff --git a/cli/src/commands/auth/list/index.ts b/cli/src/commands/auth/list/index.ts deleted file mode 100644 index 3ac980b06fa..00000000000 --- a/cli/src/commands/auth/list/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Registry } from '@/auth/hosts' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Flags } from '@/framework/flags' -import { OutputFormat, table } from '@/framework/output' -import { runAuthList } from './list' - -export default class AuthList extends DifyCommand { - static override description = 'List all authenticated contexts (host + account pairs)' - - static override examples = [ - '<%= config.bin %> auth list', - '<%= config.bin %> auth list -o json', - '<%= config.bin %> auth list -o name', - ] - - static override flags = { - output: Flags.outputFormat({ - options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME], - default: '', - }), - } - - async run(argv: string[]) { - const { flags } = this.parse(AuthList, argv) - const reg = await Registry.load() - const result = runAuthList(reg) - return table({ format: flags.output, data: result }) - } -} diff --git a/cli/src/commands/auth/list/list.test.ts b/cli/src/commands/auth/list/list.test.ts deleted file mode 100644 index 0a9477800c0..00000000000 --- a/cli/src/commands/auth/list/list.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { Registry } from '@/auth/hosts' -import { stringifyOutput, table } from '@/framework/output' -import { runAuthList } from './list' - -function twoHostReg(): Registry { - return Registry.from({ - token_storage: 'keychain', - current_host: 'cloud.dify.ai', - hosts: { - 'cloud.dify.ai': { - current_account: 'alice@corp.com', - accounts: { - 'alice@corp.com': { - account: { id: 'acct-1', email: 'alice@corp.com', name: 'Alice' }, - }, - 'bob@corp.com': { - account: { id: 'acct-2', email: 'bob@corp.com', name: 'Bob' }, - }, - }, - }, - 'other.dify.ai': { - current_account: 'admin@other.com', - accounts: { - 'admin@other.com': { - account: { id: 'acct-3', email: 'admin@other.com', name: 'Admin' }, - }, - }, - }, - }, - }) -} - -describe('runAuthList', () => { - it('returns all host+account pairs', () => { - const result = runAuthList(twoHostReg()) - expect(result.rows).toHaveLength(3) - }) - - it('marks only the active context', () => { - const result = runAuthList(twoHostReg()) - const active = result.rows.filter((r) => r.active) - expect(active).toHaveLength(1) - expect(active[0]!.host).toBe('cloud.dify.ai') - expect(active[0]!.account).toBe('alice@corp.com') - }) - - it('table: renders HOST ACCOUNT ACTIVE header', () => { - const out = stringifyOutput(table({ format: '', data: runAuthList(twoHostReg()) })) - expect(out).toMatch(/HOST\s+ACCOUNT\s+ACTIVE/) - expect(out).toContain('cloud.dify.ai') - expect(out).toContain('alice@corp.com') - expect(out).toContain('other.dify.ai') - }) - - it('table: marks active row with *', () => { - const out = stringifyOutput(table({ format: '', data: runAuthList(twoHostReg()) })) - const lines = out.trim().split('\n') - const activeLine = lines.find((l) => l.includes('alice@corp.com'))! - expect(activeLine).toContain('*') - const inactiveLine = lines.find((l) => l.includes('bob@corp.com'))! - expect(inactiveLine).not.toContain('*') - }) - - it('json: emits { contexts: [...] }', () => { - const out = stringifyOutput(table({ format: 'json', data: runAuthList(twoHostReg()) })) - const parsed = JSON.parse(out) as { - contexts: Array<{ host: string; account: string; active: boolean }> - } - expect(parsed.contexts).toHaveLength(3) - const activeCtx = parsed.contexts.find((c) => c.active)! - expect(activeCtx.host).toBe('cloud.dify.ai') - expect(activeCtx.account).toBe('alice@corp.com') - }) - - it('name: emits account emails one per line', () => { - const out = stringifyOutput(table({ format: 'name', data: runAuthList(twoHostReg()) })) - const lines = out.trim().split('\n').sort() - expect(lines).toContain('alice@corp.com') - expect(lines).toContain('admin@other.com') - expect(lines).toContain('bob@corp.com') - }) - - it('table: shows email (Name) when display name present', () => { - const out = stringifyOutput(table({ format: '', data: runAuthList(twoHostReg()) })) - expect(out).toContain('alice@corp.com (Alice)') - }) - - it('table: shows email only when display name absent', () => { - const reg = Registry.from({ - token_storage: 'file', - current_host: 'cloud.dify.ai', - hosts: { - 'cloud.dify.ai': { - current_account: 'anon@corp.com', - accounts: { - 'anon@corp.com': { account: { id: 'x', email: 'anon@corp.com', name: '' } }, - }, - }, - }, - }) - const out = stringifyOutput(table({ format: '', data: runAuthList(reg) })) - expect(out).toContain('anon@corp.com') - expect(out).not.toContain('anon@corp.com (') - }) - - it('empty registry: returns zero rows', () => { - const result = runAuthList(Registry.empty()) - expect(result.rows).toHaveLength(0) - }) -}) diff --git a/cli/src/commands/auth/list/list.ts b/cli/src/commands/auth/list/list.ts deleted file mode 100644 index 7aa0654f896..00000000000 --- a/cli/src/commands/auth/list/list.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Registry } from '@/auth/hosts' -import { ContextListOutput, ContextRow } from './handlers' - -export function runAuthList(reg: Registry): ContextListOutput { - const rows: ContextRow[] = [] - for (const [host, entry] of Object.entries(reg.hosts)) { - for (const [email, ctx] of Object.entries(entry.accounts)) { - const isActive = reg.current_host === host && entry.current_account === email - rows.push(new ContextRow(host, email, ctx.account.name, isActive)) - } - } - return new ContextListOutput(rows) -} diff --git a/cli/src/commands/auth/login/guide.ts b/cli/src/commands/auth/login/guide.ts deleted file mode 100644 index 1f117dc737c..00000000000 --- a/cli/src/commands/auth/login/guide.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const agentGuide = ` -WHEN TO USE - Establish a session before any authenticated command. Interactive browser - device flow: - difyctl auth login - difyctl auth login --host https://cloud.dify.ai - difyctl auth login --no-browser print the code/URL instead of opening - -NON-INTERACTIVE - Agents without a browser can supply a bearer token via the DIFY_TOKEN env - var instead of logging in; difyctl reads it on every command. See - 'difyctl help account' and 'difyctl help external'. - -AFTER LOGIN - difyctl auth whoami check the active session - difyctl auth list list every authenticated context - difyctl get app list apps -` diff --git a/cli/src/commands/auth/login/index.ts b/cli/src/commands/auth/login/index.ts deleted file mode 100644 index ae051cef1c3..00000000000 --- a/cli/src/commands/auth/login/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Flags } from '@/framework/flags' -import { realStreams } from '@/sys/io/streams' -import { enforceDifyVersion } from '@/version/enforce' -import { agentGuide } from './guide' -import { runLogin } from './login' - -export default class Login extends DifyCommand { - static override description = 'Sign in to Dify via OAuth device flow' - - static override effect: CommandEffect = 'write' - - static override examples = [ - '<%= config.bin %> auth login', - '<%= config.bin %> auth login --host https://cloud.dify.ai', - '<%= config.bin %> auth login --no-browser', - ] - - static override flags = { - host: Flags.string({ - description: 'Dify host URL', - default: '', - }), - 'no-browser': Flags.boolean({ - description: 'do not auto-open the browser', - default: false, - }), - insecure: Flags.boolean({ - description: 'allow http:// hosts and skip TLS certificate verification (local-dev only)', - default: false, - }), - } - - async run(argv: string[]): Promise { - const { flags } = this.parse(Login, argv) - await runLogin({ - io: realStreams(), - host: flags.host, - noBrowser: flags['no-browser'], - insecure: flags.insecure, - verifyServer: async (host) => { - await enforceDifyVersion(host, { forceFresh: true, insecure: flags.insecure }) - }, - }) - } - - override agentGuide(): string { - return agentGuide - } -} diff --git a/cli/src/commands/auth/login/login.test.ts b/cli/src/commands/auth/login/login.test.ts deleted file mode 100644 index bc224cc4082..00000000000 --- a/cli/src/commands/auth/login/login.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { Clock } from './device-flow.js' -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { startMock } from '@test/fixtures/dify-mock/server' -import { testHttpClient } from '@test/fixtures/http-client' -import { MemStore } from '@test/fixtures/mem-store' -import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' -import { DeviceFlowApi } from '@/api/oauth-device' -import { createHttpClient } from '@/http/client' -import { bufferStreams } from '@/sys/io/streams' -import { openAPIBase } from '@/util/host' -import { runLogin } from './login.js' - -const noopClock: Clock = { - sleepMs: async () => { - /* immediate */ - }, - isCancelled: () => false, -} - -const noopBrowser = async (): Promise => { - /* skip OS open */ -} - -describe('runLogin', () => { - let mock: DifyMock - const configDir = useTempConfigDir('difyctl-login-') - - beforeEach(async () => { - mock = await startMock({ scenario: 'happy' }) - }) - - afterEach(async () => { - await mock.stop() - }) - - it('happy: stores bearer + writes hosts.yml + greets account user', async () => { - const io = bufferStreams() - const store = new MemStore() - const reg = await runLogin({ - io, - host: mock.url, - noBrowser: true, - insecure: true, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(testHttpClient(mock.url)), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - }) - const active = reg.resolveActive() - expect(active?.ctx.account.email).toBe('tester@dify.ai') - expect(active?.ctx.workspace?.id).toBe('550e8400-e29b-41d4-a716-446655440000') - expect(await store.read(active!.host, 'tester@dify.ai')).toBe('dfoa_test') - - const hostsRaw = await readFile(join(configDir(), 'hosts.yml'), 'utf8') - expect(hostsRaw).toContain('current_host:') - expect(hostsRaw).toContain('tester@dify.ai') - expect(hostsRaw).not.toContain('dfoa_test') - expect(hostsRaw).not.toContain('bearer') - - expect(io.outBuf()).toContain('Logged in to') - expect(io.outBuf()).toContain('tester@dify.ai') - expect(io.outBuf()).toContain('Default') - expect(io.errBuf()).toContain('ABCD-1234') - }) - - it('sso: stores dfoe_ token + greets external SSO subject (no account)', async () => { - mock.setScenario('sso') - const io = bufferStreams() - const store = new MemStore() - const reg = await runLogin({ - io, - host: mock.url, - noBrowser: true, - insecure: true, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(testHttpClient(mock.url)), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - }) - const active = reg.resolveActive() - expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai') - expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example') - expect(active?.ctx.account.email).toBe('') - expect(await store.read(active!.host, 'sso@dify.ai')).toBe('dfoe_test') - expect(io.outBuf()).toContain('external SSO') - expect(io.outBuf()).toContain('sso@dify.ai') - }) - - it('denied: throws DeviceFlowError + leaves config dir empty', async () => { - mock.setScenario('denied') - const io = bufferStreams() - const store = new MemStore() - await expect( - runLogin({ - io, - host: mock.url, - noBrowser: true, - insecure: true, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(testHttpClient(mock.url)), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - }), - ).rejects.toThrow(/denied/) - expect(store.entries.size).toBe(0) - await expect(readFile(join(configDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/) - }) - - it('expired: throws DeviceFlowError', async () => { - mock.setScenario('expired') - const io = bufferStreams() - const store = new MemStore() - await expect( - runLogin({ - io, - host: mock.url, - noBrowser: true, - insecure: true, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(testHttpClient(mock.url)), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - }), - ).rejects.toThrow(/expired/) - }) - - it('rejects login when the account has no email', async () => { - mock.setScenario('no-email') - const io = bufferStreams() - const store = new MemStore() - await expect( - runLogin({ - io, - host: mock.url, - noBrowser: true, - insecure: true, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(mock.url) })), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - }), - ).rejects.toThrow(/no email/i) - expect(store.entries.size).toBe(0) - }) - - it('rejects http:// host without --insecure', async () => { - const io = bufferStreams() - const store = new MemStore() - await expect( - runLogin({ - io, - host: mock.url, - noBrowser: true, - insecure: false, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(testHttpClient(mock.url)), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - }), - ).rejects.toThrow(/https:\/\//) - }) - - it('emits skip-reason to stderr when --no-browser', async () => { - const io = bufferStreams() - const store = new MemStore() - await runLogin({ - io, - host: mock.url, - noBrowser: true, - insecure: true, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(testHttpClient(mock.url)), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - }) - expect(io.errBuf()).toContain('--no-browser requested') - }) - - it('TTY: prompts for host when --host omitted, uses typed URL', async () => { - const io = bufferStreams(`${mock.url}\n`) - ;(io as { isErrTTY: boolean }).isErrTTY = true - const store = new MemStore() - const reg = await runLogin({ - io, - noBrowser: true, - insecure: true, - deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(testHttpClient(mock.url)), - store: { store, mode: 'file' }, - clock: noopClock, - browserOpener: noopBrowser, - }) - expect(reg.resolveActive()?.ctx.account.email).toBe('tester@dify.ai') - expect(io.errBuf()).toContain('Enter Dify host URL') - expect(io.errBuf()).toContain('[default: https://cloud.dify.ai]') - }) -}) diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts deleted file mode 100644 index aa3baec0a4e..00000000000 --- a/cli/src/commands/auth/login/login.ts +++ /dev/null @@ -1,232 +0,0 @@ -import type { Clock } from './device-flow.js' -import type { CodeResponse, PollSuccess } from '@/api/oauth-device' -import type { AccountContext } from '@/auth/hosts' -import type { StorageMode } from '@/store/store' -import type { TokenStore } from '@/store/token-store' -import type { ParseResult } from '@/sys/io/prompt' -import type { IOStreams } from '@/sys/io/streams' -import type { BrowserEnv, BrowserOpener } from '@/util/browser' -import * as os from 'node:os' -import { DeviceFlowApi } from '@/api/oauth-device' -import { Registry } from '@/auth/hosts' -import { BaseError, isBaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { createHttpClient } from '@/http/client' -import { detectTokenStore } from '@/store/manager' -import { colorEnabled, colorScheme } from '@/sys/io/color' -import { promptText } from '@/sys/io/prompt' -import { startSpinner } from '@/sys/io/spinner' -import { decideOpen, OpenDecision, openUrl, realEnv } from '@/util/browser' -import { - bareHost, - DEFAULT_HOST, - openAPIBase, - resolveHost, - validateVerificationURI, -} from '@/util/host' -import { awaitAuthorization, realClock } from './device-flow.js' - -export type LoginOptions = { - readonly io: IOStreams - readonly host?: string - readonly noBrowser?: boolean - readonly insecure?: boolean - readonly deviceLabel?: string - readonly store?: { readonly store: TokenStore; readonly mode: StorageMode } - readonly api?: DeviceFlowApi - readonly browserEnv?: BrowserEnv - readonly browserOpener?: BrowserOpener - readonly clock?: Clock - // Version guard for the freshly-authenticated host; wired to enforceDifyVersion - // at the command boundary. Runs before the session is persisted so we never - // save credentials for a server too old for this difyctl. Defaults to a no-op. - readonly verifyServer?: (host: string) => Promise -} - -export async function runLogin(opts: LoginOptions): Promise { - const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) - const insecure = opts.insecure ?? false - - const host = await resolveLoginHost(opts, insecure) - const label = opts.deviceLabel ?? defaultDeviceLabel() - - const api = - opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host), insecure })) - const code = await api.requestCode({ device_label: label }) - - renderCodePrompt(opts.io.err, cs, code) - validateVerificationURI(code.verification_uri, insecure) - - const env = opts.browserEnv ?? realEnv() - const decision = decideOpen(env, opts.noBrowser ?? false) - if (decision === OpenDecision.Auto) { - const opener = opts.browserOpener ?? openUrl - try { - await opener(code.verification_uri) - } catch (err) { - opts.io.err.write( - `${cs.warningIcon()} couldn't open browser (${(err as Error).message}); open the URL above manually\n`, - ) - } - } else { - opts.io.err.write(`${cs.warningIcon()} ${decision} — open the URL above manually\n`) - } - - const spinner = startSpinner({ io: opts.io, label: 'Waiting for authorization', style: 'dify' }) - let success: PollSuccess - try { - success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() }) - } finally { - spinner.stop() - } - - // Refuse to persist a session to a server too old for this difyctl. - await (opts.verifyServer ?? (async () => {}))(host) - - const storeBundle = opts.store ?? (await detectTokenStore()) - const display = bareHost(host) - const email = accountEmail(success) - const ctx = contextFromSuccess(success) - - await storeBundle.store.write(display, email, success.token) - - const reg = await Registry.load() - reg.token_storage = storeBundle.mode - reg.activate(display, email, ctx) - applyScheme(reg, display, host) - reg.setInsecureTls(display, insecure) - await reg.save() - - renderLoggedIn(opts.io.out, cs, host, success) - return reg -} - -async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise { - const raw = opts.host?.trim() ?? '' - if (raw !== '') return resolveHost({ raw, insecure }) - if (!opts.io.isErrTTY) { - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: '--host is required (no TTY)', - hint: "pass the host explicitly, e.g. 'difyctl auth login --host cloud.dify.ai'", - }) - } - return promptHost(opts.io, insecure) -} - -function makeHostParser(insecure: boolean): (raw: string) => ParseResult { - return (raw: string) => { - try { - return { ok: true, value: resolveHost({ raw, insecure }) } - } catch (err) { - if (isBaseError(err)) { - const msg = err.hint !== undefined ? `${err.message} — ${err.hint}` : err.message - return { ok: false, error: msg } - } - return { ok: false, error: String(err) } - } - } -} - -async function promptHost(io: IOStreams, insecure: boolean): Promise { - return promptText({ - io, - label: 'Enter Dify host URL', - hint: insecure - ? 'e.g. https://cloud.dify.ai or http://localhost' - : 'e.g. https://your-dify.com', - default: DEFAULT_HOST, - acceptAsDefault: (raw) => /^y(?:es)?$/i.test(raw.trim()), - parse: makeHostParser(insecure), - }) -} - -function defaultDeviceLabel(): string { - const host = os.hostname() - return `difyctl on ${host !== '' ? host : 'unknown-host'}` -} - -function renderCodePrompt( - w: NodeJS.WritableStream, - cs: ReturnType, - code: CodeResponse, -): void { - w.write(`${cs.warningIcon()} Copy this one-time code: ${cs.bold(code.user_code)}\n`) - w.write(` Open: ${code.verification_uri}\n`) -} - -function renderLoggedIn( - w: NodeJS.WritableStream, - cs: ReturnType, - host: string, - s: PollSuccess, -): void { - const display = bareHost(host) - if (s.account && s.account.email !== '') { - w.write( - `${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`, - ) - const ws = findDefaultWorkspace(s) - if (ws !== undefined) w.write(` Workspace: ${ws.name}\n`) - return - } - if (s.subject_email !== undefined && s.subject_email !== '') { - if (s.subject_issuer !== undefined && s.subject_issuer !== '') - w.write( - `${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.subject_email)} (external SSO, issuer: ${s.subject_issuer})\n`, - ) - else - w.write( - `${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.subject_email)} (external SSO)\n`, - ) - return - } - w.write(`${cs.successIcon()} Logged in to ${display}\n`) -} - -function findDefaultWorkspace( - s: PollSuccess, -): { id: string; name: string; role: string } | undefined { - if (s.default_workspace_id === undefined || s.default_workspace_id === '') return undefined - return s.workspaces?.find((w) => w.id === s.default_workspace_id) -} - -function accountEmail(s: PollSuccess): string { - const email = (s.account?.email ?? '') !== '' ? s.account!.email : (s.subject_email ?? '') - if (email === '') { - throw new BaseError({ - code: ErrorCode.NotLoggedIn, - message: 'account has no email; cannot store credential', - hint: 'this Dify instance returned no email for the signed-in subject', - }) - } - return email -} - -function contextFromSuccess(s: PollSuccess): AccountContext { - const ctx: AccountContext = { - account: s.account - ? { id: s.account.id, email: s.account.email, name: s.account.name } - : { id: '', email: '', name: '' }, - token_id: s.token_id, - } - if ( - s.subject_email !== undefined && - s.subject_email !== '' && - (!s.account || s.account.id === '') - ) { - ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' } - } - const def = findDefaultWorkspace(s) - if (def !== undefined) ctx.workspace = def - return ctx -} - -function applyScheme(reg: Registry, display: string, host: string): void { - try { - const u = new URL(host) - if (u.protocol !== 'https:') reg.setScheme(display, u.protocol.replace(':', '')) - } catch { - /* keep scheme unset */ - } -} diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts deleted file mode 100644 index 04a6e5545cc..00000000000 --- a/cli/src/commands/auth/logout/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import type { HttpClient } from '@/http/types' -import { Registry } from '@/auth/hosts' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { createHttpClient } from '@/http/client' -import { getTokenStore } from '@/store/manager' -import { runWithSpinner } from '@/sys/io/spinner' -import { realStreams } from '@/sys/io/streams' -import { activeHostInfo, openAPIBase } from '@/util/host' -import { runLogout } from './logout.js' - -export default class Logout extends DifyCommand { - static override description = 'Log out of the active Dify host' - - static override effect: CommandEffect = 'write' - - static override examples = ['<%= config.bin %> auth logout'] - - async run(argv: string[]): Promise { - this.parse(Logout, argv) - const io = realStreams() - const reg = await Registry.load() - const active = reg.resolveActive() - - let http: HttpClient | undefined - if (active !== undefined) { - let bearer = '' - try { - bearer = await getTokenStore(reg.token_storage).read(active.host, active.email) - } catch { - /* keyring locked — skip remote revocation, local cleanup still runs */ - } - if (bearer !== '') { - const { host, insecure } = activeHostInfo(active) - http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts: 0, insecure }) - } - } - - await runWithSpinner({ io, label: 'Signing out', enabled: true, style: 'dify-dim' }, () => - runLogout({ io, reg, http }), - ) - } -} diff --git a/cli/src/commands/auth/logout/logout.test.ts b/cli/src/commands/auth/logout/logout.test.ts deleted file mode 100644 index fd17b77259f..00000000000 --- a/cli/src/commands/auth/logout/logout.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { MemStore } from '@test/fixtures/mem-store' -import { describe, expect, it } from 'vite-plus/test' -import { Registry } from '@/auth/hosts' -import { bufferStreams } from '@/sys/io/streams' -import { runLogout } from './logout.js' - -describe('runLogout', () => { - const dir = useTempConfigDir('difyctl-logout-') - - async function seed(store: 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') - await store.write('h1', 'b@x', 'dfoa_b') - } - - it('removes only the active context, keeps others, unsets pointers, file survives', async () => { - const store = new MemStore() - await seed(store) - await runLogout({ io: bufferStreams(), reg: await Registry.load(), store }) - 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() - expect(await store.read('h1', 'a@x')).toBe('') - expect(await store.read('h1', 'b@x')).toBe('dfoa_b') - const raw = await readFile(join(dir(), 'hosts.yml'), 'utf8') - expect(raw).toContain('b@x') - }) - - it('clears local credentials even when the store.read throws (e.g. keyring locked)', async () => { - const store = new MemStore() - await seed(store) - store.read = () => { - throw new Error('keyring locked') - } - await runLogout({ io: bufferStreams(), reg: await Registry.load(), store }) - const after = await Registry.load() - expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined() - }) - - it('throws NotLoggedIn when no active context', async () => { - await Registry.empty('file').save() - await expect( - runLogout({ io: bufferStreams(), reg: await Registry.load(), store: new MemStore() }), - ).rejects.toThrow(/not logged in/i) - }) -}) diff --git a/cli/src/commands/auth/logout/logout.ts b/cli/src/commands/auth/logout/logout.ts deleted file mode 100644 index e78f8be5342..00000000000 --- a/cli/src/commands/auth/logout/logout.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Registry } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import type { TokenStore } from '@/store/token-store' -import type { IOStreams } from '@/sys/io/streams' -import { AccountSessionsClient } from '@/api/account-sessions' -import { getTokenStore } from '@/store/manager' -import { colorEnabled, colorScheme } from '@/sys/io/color' - -export type LogoutOptions = { - readonly io: IOStreams - readonly reg: Registry - readonly http?: HttpClient - /** Optional override for tests; production resolves via `getTokenStore`. */ - readonly store?: TokenStore -} - -const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const - -export async function runLogout(opts: LogoutOptions): Promise { - const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) - const reg = opts.reg - const active = reg.requireActive() - - const store = opts.store ?? getTokenStore(reg.token_storage) - let bearer = '' - try { - bearer = await store.read(active.host, active.email) - } catch { - /* keyring locked — skip remote revocation, local cleanup still runs */ - } - - let revokeWarning = '' - if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) { - try { - await new AccountSessionsClient(opts.http).revokeSelf() - } catch (err) { - revokeWarning = `${cs.warningIcon()} server revoke failed (${(err as Error).message}); local credentials cleared anyway\n` - } - } - - await reg.forget(active, store) - - if (revokeWarning !== '') opts.io.err.write(revokeWarning) - opts.io.out.write(`${cs.successIcon()} Logged out of ${active.host}\n`) -} - -function revokeAllowed(bearer: string): boolean { - return REVOCABLE_PREFIXES.some((p) => bearer.startsWith(p)) -} diff --git a/cli/src/commands/auth/whoami/index.ts b/cli/src/commands/auth/whoami/index.ts deleted file mode 100644 index 4649a7594cd..00000000000 --- a/cli/src/commands/auth/whoami/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Registry } from '@/auth/hosts' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Flags } from '@/framework/flags' -import { realStreams } from '@/sys/io/streams' -import { runWhoami } from './whoami' - -export default class Whoami extends DifyCommand { - static override description = "Print the active subject's identity" - - static override examples = [ - '<%= config.bin %> auth whoami', - '<%= config.bin %> auth whoami --json', - ] - - static override flags = { - json: Flags.boolean({ description: 'emit JSON', default: false }), - } - - async run(argv: string[]): Promise { - const { flags } = this.parse(Whoami, argv) - const reg = await Registry.load() - await runWhoami({ io: realStreams(), reg, json: flags.json }) - } -} diff --git a/cli/src/commands/auth/whoami/whoami.test.ts b/cli/src/commands/auth/whoami/whoami.test.ts deleted file mode 100644 index 0de85e6e483..00000000000 --- a/cli/src/commands/auth/whoami/whoami.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { Registry } from '@/auth/hosts' -import { bufferStreams } from '@/sys/io/streams' -import { runWhoami } from './whoami' - -function accountReg(): Registry { - return Registry.from({ - token_storage: 'file', - current_host: 'cloud.dify.ai', - hosts: { - 'cloud.dify.ai': { - current_account: 'a@b.c', - accounts: { - 'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } }, - }, - }, - }, - }) -} - -function ssoReg(): Registry { - return Registry.from({ - token_storage: 'file', - current_host: 'cloud.dify.ai', - hosts: { - 'cloud.dify.ai': { - current_account: 'sso@dify.ai', - accounts: { - 'sso@dify.ai': { - account: { email: 'sso@dify.ai', name: '' }, - external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' }, - }, - }, - }, - }, - }) -} - -describe('runWhoami', () => { - it('throws NotLoggedIn when no active context', async () => { - await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow( - /not logged in/i, - ) - }) - - it('prints email + name for an account', async () => { - const io = bufferStreams() - await runWhoami({ io, reg: accountReg() }) - expect(io.outBuf()).toContain('a@b.c') - expect(io.outBuf()).toContain('Ann') - }) - - it('account human: emits "email (name)"', async () => { - const io = bufferStreams() - await runWhoami({ io, reg: accountReg() }) - expect(io.outBuf()).toBe('a@b.c (Ann)\n') - }) - - it('account human, no name: emits email only', async () => { - const io = bufferStreams() - const reg = accountReg() - reg.hosts['cloud.dify.ai']!.accounts['a@b.c']!.account.name = '' - await runWhoami({ io, reg }) - expect(io.outBuf()).toBe('a@b.c\n') - }) - - it('emits JSON when --json', async () => { - const io = bufferStreams() - await runWhoami({ io, reg: accountReg(), json: true }) - expect(JSON.parse(io.outBuf())).toMatchObject({ email: 'a@b.c', id: 'acct-1' }) - }) - - it('account json: emits {id, email, name}', async () => { - const io = bufferStreams() - await runWhoami({ io, reg: accountReg(), json: true }) - expect(JSON.parse(io.outBuf())).toEqual({ - id: 'acct-1', - email: 'a@b.c', - name: 'Ann', - }) - }) - - it('sso human: emits email + issuer', async () => { - const io = bufferStreams() - await runWhoami({ io, reg: ssoReg() }) - expect(io.outBuf()).toBe('sso@dify.ai (external SSO, issuer: https://issuer.example)\n') - }) - - it('sso json: emits {subject_type, email, issuer}', async () => { - const io = bufferStreams() - await runWhoami({ io, reg: ssoReg(), json: true }) - expect(JSON.parse(io.outBuf())).toEqual({ - subject_type: 'external_sso', - email: 'sso@dify.ai', - issuer: 'https://issuer.example', - }) - }) -}) diff --git a/cli/src/commands/auth/whoami/whoami.ts b/cli/src/commands/auth/whoami/whoami.ts deleted file mode 100644 index 514283a2570..00000000000 --- a/cli/src/commands/auth/whoami/whoami.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Registry } from '@/auth/hosts' -import type { IOStreams } from '@/sys/io/streams' - -export type WhoamiOptions = { - readonly io: IOStreams - readonly reg: Registry - readonly json?: boolean -} - -export async function runWhoami(opts: WhoamiOptions): Promise { - const active = opts.reg.requireActive() - - const sub = active.ctx.external_subject - if (sub !== undefined) { - if (opts.json === true) { - opts.io.out.write( - `${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`, - ) - return - } - opts.io.out.write( - sub.issuer !== '' - ? `${sub.email} (external SSO, issuer: ${sub.issuer})\n` - : `${sub.email} (external SSO)\n`, - ) - return - } - - const acc = active.ctx.account - if (opts.json === true) { - opts.io.out.write(`${JSON.stringify({ id: acc.id ?? '', email: acc.email, name: acc.name })}\n`) - return - } - opts.io.out.write(acc.name !== '' ? `${acc.email} (${acc.name})\n` : `${acc.email}\n`) -} diff --git a/cli/src/commands/config/get/index.ts b/cli/src/commands/config/get/index.ts deleted file mode 100644 index 45a122d648e..00000000000 --- a/cli/src/commands/config/get/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Args } from '@/framework/flags' -import { raw } from '@/framework/output' -import { getConfigurationStore } from '@/store/manager' -import { runConfigGet } from './run' - -export default class ConfigGet extends DifyCommand { - static override description = "Print one config key's value" - - static override examples = ['<%= config.bin %> config get defaults.format'] - - static override args = { - key: Args.string({ description: 'config key', required: true }), - } - - async run(argv: string[]) { - const { args } = this.parse(ConfigGet, argv) - return raw(await runConfigGet({ store: getConfigurationStore(), key: args.key })) - } -} diff --git a/cli/src/commands/config/get/run.test.ts b/cli/src/commands/config/get/run.test.ts deleted file mode 100644 index 37a7300140b..00000000000 --- a/cli/src/commands/config/get/run.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { describe, expect, it } from 'vite-plus/test' -import { isBaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { getConfigurationStore } from '@/store/manager' -import { runConfigGet } from './run' - -describe('runConfigGet', () => { - useTempConfigDir('difyctl-get-') - - it('returns set value with trailing newline', async () => { - await getConfigurationStore().setTyped({ - schema_version: 1, - defaults: { format: 'yaml' }, - }) - const out = await runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' }) - expect(out).toBe('yaml\n') - }) - - it('returns empty line when key is unset (matches Go fmt.Fprintln)', async () => { - const out = await runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' }) - expect(out).toBe('\n') - }) - - it('throws BaseError(config_invalid_key) on unknown key', async () => { - let caught: unknown - try { - await runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' }) - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) - }) - - it('returns numeric limit as string', async () => { - await getConfigurationStore().setTyped({ - schema_version: 1, - defaults: { limit: 75 }, - }) - const out = await runConfigGet({ store: getConfigurationStore(), key: 'defaults.limit' }) - expect(out).toBe('75\n') - }) -}) diff --git a/cli/src/commands/config/get/run.ts b/cli/src/commands/config/get/run.ts deleted file mode 100644 index b0e7e93b9f5..00000000000 --- a/cli/src/commands/config/get/run.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ConfigFile } from '@/config/schema' -import type { YamlStore } from '@/store/store' -import { loadConfig } from '@/config/config-loader' -import { getKey } from '@/config/keys' -import { emptyConfig } from '@/config/schema' - -export type RunConfigGetOptions = { - readonly key: string - readonly store: YamlStore -} - -export async function runConfigGet(opts: RunConfigGetOptions): Promise { - const loaded = await loadConfig(opts.store) - const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() - return `${getKey(config, opts.key)}\n` -} diff --git a/cli/src/commands/config/path/index.ts b/cli/src/commands/config/path/index.ts deleted file mode 100644 index caa99208790..00000000000 --- a/cli/src/commands/config/path/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { join } from 'node:path' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { raw } from '@/framework/output' -import { resolveConfigDir } from '@/store/dir' -import { CONFIG_FILE_NAME } from '@/store/manager' - -export default class ConfigPath extends DifyCommand { - static override description = 'Print the resolved config.yml path' - - static override examples = ['<%= config.bin %> config path'] - - async run(argv: string[]) { - this.parse(ConfigPath, argv) - return raw(join(resolveConfigDir(), CONFIG_FILE_NAME)) - } -} diff --git a/cli/src/commands/config/set/index.ts b/cli/src/commands/config/set/index.ts deleted file mode 100644 index 21ff9ce7f48..00000000000 --- a/cli/src/commands/config/set/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Args } from '@/framework/flags' -import { raw } from '@/framework/output' -import { getConfigurationStore } from '@/store/manager' -import { runConfigSet } from './run' - -export default class ConfigSet extends DifyCommand { - static override description = 'Set a config key (validates value)' - - static override effect: CommandEffect = 'write' - - static override examples = [ - '<%= config.bin %> config set defaults.format json', - '<%= config.bin %> config set defaults.limit 50', - ] - - static override args = { - key: Args.string({ description: 'config key', required: true }), - value: Args.string({ description: 'config value', required: true }), - } - - async run(argv: string[]) { - const { args } = this.parse(ConfigSet, argv) - return raw( - await runConfigSet({ store: getConfigurationStore(), key: args.key, value: args.value }), - ) - } -} diff --git a/cli/src/commands/config/set/run.test.ts b/cli/src/commands/config/set/run.test.ts deleted file mode 100644 index 8c7f65266ba..00000000000 --- a/cli/src/commands/config/set/run.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { describe, expect, it } from 'vite-plus/test' -import { loadConfig } from '@/config/config-loader' -import { isBaseError } from '@/errors/base' -import { ErrorCode, ExitCode } from '@/errors/codes' -import { getConfigurationStore } from '@/store/manager' -import { runConfigSet } from './run' - -describe('runConfigSet', () => { - useTempConfigDir('difyctl-set-') - - it('persists the value and returns "set k = v\\n"', async () => { - const out = await runConfigSet({ - store: getConfigurationStore(), - key: 'defaults.format', - value: 'json', - }) - expect(out).toBe('set defaults.format = json\n') - - const r = await loadConfig(getConfigurationStore()) - expect(r.found).toBe(true) - if (r.found) expect(r.config.defaults.format).toBe('json') - }) - - it('rejects invalid format value with config_invalid_value', async () => { - let caught: unknown - try { - await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' }) - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) - }) - - it('rejects unknown key with config_invalid_key', async () => { - let caught: unknown - try { - await runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' }) - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) - }) - - it('preserves prior keys when setting a new one', async () => { - await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'yaml' }) - await runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: '40' }) - - const r = await loadConfig(getConfigurationStore()) - expect(r.found).toBe(true) - if (r.found) { - expect(r.config.defaults.format).toBe('yaml') - expect(r.config.defaults.limit).toBe(40) - } - }) - - it('exit code for invalid value is Usage (2)', async () => { - let caught: unknown - try { - await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' }) - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) expect(caught.exit()).toBe(ExitCode.Usage) - }) - - it('exit code for unknown key is Usage (2)', async () => { - let caught: unknown - try { - await runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' }) - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) expect(caught.exit()).toBe(ExitCode.Usage) - }) - - it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', async () => { - let caught: unknown - try { - await runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' }) - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) { - expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) - expect(caught.exit()).toBe(ExitCode.Usage) - } - }) -}) diff --git a/cli/src/commands/config/set/run.ts b/cli/src/commands/config/set/run.ts deleted file mode 100644 index 47789ae9818..00000000000 --- a/cli/src/commands/config/set/run.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ConfigFile } from '@/config/schema' -import type { YamlStore } from '@/store/store' -import { loadConfig } from '@/config/config-loader' -import { setKey } from '@/config/keys' -import { emptyConfig } from '@/config/schema' -import { saveConfig } from '@/store/config-writer' - -export type RunConfigSetOptions = { - readonly key: string - readonly value: string - readonly store: YamlStore -} - -export async function runConfigSet(opts: RunConfigSetOptions): Promise { - const loaded = await loadConfig(opts.store) - const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() - const next = setKey(config, opts.key, opts.value) - await saveConfig(opts.store, next) - return `set ${opts.key} = ${opts.value}\n` -} diff --git a/cli/src/commands/config/unset/index.ts b/cli/src/commands/config/unset/index.ts deleted file mode 100644 index 507c73eda95..00000000000 --- a/cli/src/commands/config/unset/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Args } from '@/framework/flags' -import { raw } from '@/framework/output' -import { getConfigurationStore } from '@/store/manager' -import { runConfigUnset } from './run' - -export default class ConfigUnset extends DifyCommand { - static override description = 'Reset a config key to its zero value' - - static override effect: CommandEffect = 'write' - - static override examples = ['<%= config.bin %> config unset defaults.format'] - - static override args = { - key: Args.string({ description: 'config key', required: true }), - } - - async run(argv: string[]) { - const { args } = this.parse(ConfigUnset, argv) - return raw(await runConfigUnset({ store: getConfigurationStore(), key: args.key })) - } -} diff --git a/cli/src/commands/config/unset/run.test.ts b/cli/src/commands/config/unset/run.test.ts deleted file mode 100644 index 77ff1ef24d1..00000000000 --- a/cli/src/commands/config/unset/run.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { describe, expect, it } from 'vite-plus/test' -import { loadConfig } from '@/config/config-loader' -import { isBaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { getConfigurationStore } from '@/store/manager' -import { runConfigUnset } from './run' - -describe('runConfigUnset', () => { - useTempConfigDir('difyctl-unset-') - - it('clears the requested key, leaves others intact', async () => { - await getConfigurationStore().setTyped({ - schema_version: 1, - defaults: { format: 'json', limit: 25 }, - }) - const out = await runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' }) - expect(out).toBe('unset defaults.format\n') - - const r = await loadConfig(getConfigurationStore()) - expect(r.found).toBe(true) - if (r.found) { - expect(r.config.defaults.format).not.toBe('json') - expect(r.config.defaults.limit).toBe(25) - } - }) - - it('is a no-op (writes empty config) when key was already unset', async () => { - const out = await runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' }) - expect(out).toBe('unset defaults.format\n') - const r = await loadConfig(getConfigurationStore()) - expect(r.found).toBe(true) - if (r.found) expect(r.config.schema_version).toBe(1) - }) - - it('rejects unknown key', async () => { - let caught: unknown - try { - await runConfigUnset({ store: getConfigurationStore(), key: 'bogus' }) - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) - }) -}) diff --git a/cli/src/commands/config/unset/run.ts b/cli/src/commands/config/unset/run.ts deleted file mode 100644 index acb9a473bfc..00000000000 --- a/cli/src/commands/config/unset/run.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ConfigFile } from '@/config/schema' -import type { YamlStore } from '@/store/store' -import { loadConfig } from '@/config/config-loader' -import { unsetKey } from '@/config/keys' -import { emptyConfig } from '@/config/schema' -import { saveConfig } from '@/store/config-writer' - -export type RunConfigUnsetOptions = { - readonly key: string - readonly store: YamlStore -} - -export async function runConfigUnset(opts: RunConfigUnsetOptions): Promise { - const loaded = await loadConfig(opts.store) - const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() - const next = unsetKey(config, opts.key) - await saveConfig(opts.store, next) - return `unset ${opts.key}\n` -} diff --git a/cli/src/commands/config/view/index.ts b/cli/src/commands/config/view/index.ts deleted file mode 100644 index d6b43e8336a..00000000000 --- a/cli/src/commands/config/view/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Flags } from '@/framework/flags' -import { raw } from '@/framework/output' -import { getConfigurationStore } from '@/store/manager' -import { runConfigView } from './run' - -export default class ConfigView extends DifyCommand { - static override description = 'Print the resolved config' - - static override examples = [ - '<%= config.bin %> config view', - '<%= config.bin %> config view --json', - ] - - static override flags = { - json: Flags.boolean({ description: 'emit JSON', default: false }), - } - - async run(argv: string[]) { - const { flags } = this.parse(ConfigView, argv) - return raw(await runConfigView({ store: getConfigurationStore(), json: flags.json })) - } -} diff --git a/cli/src/commands/config/view/run.test.ts b/cli/src/commands/config/view/run.test.ts deleted file mode 100644 index 7019106b7f6..00000000000 --- a/cli/src/commands/config/view/run.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { describe, expect, it } from 'vite-plus/test' -import { getConfigurationStore } from '@/store/manager' -import { runConfigView } from './run' - -describe('runConfigView', () => { - useTempConfigDir('difyctl-view-') - - it('text format: empty config returns empty string', async () => { - const out = await runConfigView({ store: getConfigurationStore() }) - expect(out).toBe('') - }) - - it('text format: emits "key = value" lines for set keys only', async () => { - await getConfigurationStore().setTyped({ - schema_version: 1, - defaults: { format: 'json', limit: 50 }, - state: { current_app: 'app-1' }, - }) - const out = await runConfigView({ store: getConfigurationStore() }) - expect(out).toBe('defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n') - }) - - it('text format: skips unset keys', async () => { - await getConfigurationStore().setTyped({ - schema_version: 1, - defaults: { format: 'yaml' }, - }) - const out = await runConfigView({ store: getConfigurationStore() }) - expect(out).toBe('defaults.format = yaml\n') - expect(out).not.toContain('defaults.limit') - expect(out).not.toContain('state.current_app') - }) - - it('json format: empty config returns "{}\\n"', async () => { - const out = await runConfigView({ store: getConfigurationStore(), json: true }) - expect(out).toBe('{}\n') - }) - - it('json format: defaults.limit is numeric, others are strings', async () => { - await getConfigurationStore().setTyped({ - schema_version: 1, - defaults: { format: 'table', limit: 100 }, - state: { current_app: 'app-x' }, - }) - const out = await runConfigView({ store: getConfigurationStore(), json: true }) - const parsed = JSON.parse(out) as Record - expect(parsed['defaults.format']).toBe('table') - expect(parsed['defaults.limit']).toBe(100) - expect(parsed['state.current_app']).toBe('app-x') - }) - - it('json format: trailing newline matches Go encoder.Encode', async () => { - const out = await runConfigView({ store: getConfigurationStore(), json: true }) - expect(out.endsWith('\n')).toBe(true) - }) -}) diff --git a/cli/src/commands/config/view/run.ts b/cli/src/commands/config/view/run.ts deleted file mode 100644 index a3d79466061..00000000000 --- a/cli/src/commands/config/view/run.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ConfigFile } from '@/config/schema' -import type { YamlStore } from '@/store/store' -import { loadConfig } from '@/config/config-loader' -import { knownKeyNames, lookupKey } from '@/config/keys' -import { emptyConfig } from '@/config/schema' - -export type RunConfigViewOptions = { - readonly json?: boolean - readonly store: YamlStore -} - -type ViewOut = Record - -export async function runConfigView(opts: RunConfigViewOptions): Promise { - const loaded = await loadConfig(opts.store) - const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() - const out = collect(config) - if (opts.json) return `${JSON.stringify(out, null, 2)}\n` - let text = '' - for (const k of knownKeyNames()) { - if (!(k in out)) continue - text += `${k} = ${out[k]}\n` - } - return text -} - -function collect(config: ConfigFile): ViewOut { - const out: ViewOut = {} - for (const k of knownKeyNames()) { - const spec = lookupKey(k) - if (spec === undefined) continue - const v = spec.get(config) - if (v === '') continue - if (k === 'defaults.limit') { - const n = Number.parseInt(v, 10) - if (Number.isFinite(n)) out[k] = n - continue - } - out[k] = v - } - return out -} diff --git a/cli/src/commands/coverage.test.ts b/cli/src/commands/coverage.test.ts deleted file mode 100644 index e18656061e9..00000000000 --- a/cli/src/commands/coverage.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -/// -import { describe, expect, it } from 'vite-plus/test' -import { isExcludedCommandPath } from '@/framework/command-fs' - -const INDEX_MODULES = import.meta.glob<{ default?: unknown }>('./**/index.ts', { eager: true }) - -const COMMAND_MODULES = Object.fromEntries( - Object.entries(INDEX_MODULES).filter(([path]) => !isExcludedCommandPath(path)), -) - -describe('command folder coverage', () => { - it('discovers at least one command index', () => { - expect(Object.keys(COMMAND_MODULES).length).toBeGreaterThan(0) - }) - - describe.each(Object.entries(COMMAND_MODULES))('%s', (path, mod) => { - it('default export exists', () => { - expect(mod.default, `${path}: missing default export`).toBeDefined() - }) - }) -}) diff --git a/cli/src/commands/create/member/handlers.ts b/cli/src/commands/create/member/handlers.ts deleted file mode 100644 index c2e43577e75..00000000000 --- a/cli/src/commands/create/member/handlers.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { MemberInviteResponse } from '@dify/contracts/api/openapi/types.gen' - -export class InviteOutput { - readonly response: MemberInviteResponse - readonly textLine: string - - constructor(response: MemberInviteResponse, textLine: string) { - this.response = response - this.textLine = textLine - } - - text(): string { - return this.textLine - } - - json(): MemberInviteResponse { - return this.response - } - - name(): string { - return this.response.member_id - } -} diff --git a/cli/src/commands/create/member/index.ts b/cli/src/commands/create/member/index.ts deleted file mode 100644 index 50778bb9d92..00000000000 --- a/cli/src/commands/create/member/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { Flags } from '@/framework/flags' -import { formatted, OutputFormat } from '@/framework/output' -import { runCreateMember } from './run' - -export default class CreateMember extends DifyCommand { - static override description = 'Invite a member to the active (or specified) workspace by email' - - static override effect: CommandEffect = 'write' - - static override examples = [ - '<%= config.bin %> create member --email user@example.com --role normal', - '<%= config.bin %> create member --email user@example.com --role admin -w ws-1', - '<%= config.bin %> create member --email user@example.com --role normal -o json', - ] - - static override flags = { - email: Flags.string({ description: 'invitee email address', required: true }), - role: Flags.string({ - description: 'role to assign (normal|admin); owner is not assignable here', - required: true, - }), - workspace: Flags.string({ - char: 'w', - description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', - }), - 'http-retry': httpRetryFlag, - output: Flags.outputFormat({ - options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], - default: '', - }), - } - - async run(argv: string[]) { - const { flags } = this.parse(CreateMember, argv) - const format = flags.output - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) - const result = await runCreateMember( - { email: flags.email, role: flags.role, workspace: flags.workspace, format }, - { active: ctx.active, http: ctx.http, io: ctx.io }, - ) - return formatted({ format, data: result.data }) - } -} diff --git a/cli/src/commands/create/member/run.test.ts b/cli/src/commands/create/member/run.test.ts deleted file mode 100644 index f1fb60adc0b..00000000000 --- a/cli/src/commands/create/member/run.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import { describe, expect, it, vi } from 'vite-plus/test' -import { bufferStreams } from '@/sys/io/streams' -import { runCreateMember } from './run.js' - -function active(): ActiveContext { - return { - host: 'cloud.dify.ai', - email: 'inviter@example.com', - ctx: { - account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' }, - workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - }, - } -} - -function fakeClient() { - return { - invite: vi.fn((_ws: string, body: { email: string; role: string }) => - Promise.resolve({ - result: 'success' as const, - email: body.email.toLowerCase(), - role: body.role, - member_id: 'acct-new', - invite_url: 'https://console.example.com/activate?email=x&token=tok', - tenant_id: '550e8400-e29b-41d4-a716-446655440000', - }), - ), - } -} - -describe('runCreateMember', () => { - it('happy path: POSTs invite, returns InviteOutput with text/json/name', async () => { - const client = fakeClient() - const result = await runCreateMember( - { email: 'new@example.com', role: 'normal' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ) - expect(client.invite).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000', { - email: 'new@example.com', - role: 'normal', - }) - expect(result.data.text()).toMatch(/Invited new@example\.com as normal/) - expect(result.data.name()).toBe('acct-new') - expect(result.data.json()).toMatchObject({ - email: 'new@example.com', - role: 'normal', - member_id: 'acct-new', - invite_url: 'https://console.example.com/activate?email=x&token=tok', - tenant_id: '550e8400-e29b-41d4-a716-446655440000', - }) - expect(result.workspaceId).toBe('550e8400-e29b-41d4-a716-446655440000') - }) - - it('rejects unknown role before any HTTP call', async () => { - const client = fakeClient() - await expect( - runCreateMember( - { email: 'new@example.com', role: 'owner' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ), - ).rejects.toThrow(/invalid --role/) - expect(client.invite).not.toHaveBeenCalled() - }) - - it('rejects empty email', async () => { - const client = fakeClient() - await expect( - runCreateMember( - { email: '', role: 'normal' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ), - ).rejects.toThrow(/--email is required/) - expect(client.invite).not.toHaveBeenCalled() - }) - - it('-w flag overrides resolved workspace', async () => { - const client = fakeClient() - await runCreateMember( - { - email: 'new@example.com', - role: 'admin', - workspace: '550e8400-e29b-41d4-a716-446655440008', - }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ) - expect(client.invite).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440008', { - email: 'new@example.com', - role: 'admin', - }) - }) -}) diff --git a/cli/src/commands/create/member/run.ts b/cli/src/commands/create/member/run.ts deleted file mode 100644 index 44b41fdd42b..00000000000 --- a/cli/src/commands/create/member/run.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import { MembersClient } from '@/api/members' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { colorEnabled, colorScheme } from '@/sys/io/color' -import { runWithSpinner } from '@/sys/io/spinner' -import { nullStreams } from '@/sys/io/streams' -import { resolveWorkspaceId } from '@/workspace/resolver' -import { InviteOutput } from './handlers.js' - -export type CreateMemberOptions = { - readonly email: string - readonly role: string - readonly workspace?: string - readonly format?: string -} - -export type CreateMemberDeps = { - readonly active: ActiveContext - readonly http: HttpClient - readonly io?: IOStreams - readonly envLookup?: (k: string) => string | undefined - readonly membersFactory?: (http: HttpClient) => MembersClient -} - -export type CreateMemberResult = { - readonly data: InviteOutput - readonly workspaceId: string -} - -// `owner` is intentionally absent — ownership transfer is console-only. -const ASSIGNABLE_ROLES = new Set(['normal', 'admin']) - -export async function runCreateMember( - opts: CreateMemberOptions, - deps: CreateMemberDeps, -): Promise { - if (opts.email === undefined || opts.email === '') { - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: '--email is required', - }) - } - if (!ASSIGNABLE_ROLES.has(opts.role)) { - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: `invalid --role "${opts.role}"`, - hint: 'expected: normal | admin (ownership transfer is console-only)', - }) - } - - const env = deps.envLookup ?? ((k: string) => process.env[k]) - const factory = deps.membersFactory ?? ((h: HttpClient) => new MembersClient(h)) - const io = deps.io ?? nullStreams() - const cs = colorScheme(colorEnabled(io.isErrTTY)) - - const wsId = resolveWorkspaceId({ - flag: opts.workspace, - env: env('DIFY_WORKSPACE_ID'), - active: deps.active, - }) - - const response = await runWithSpinner({ io, label: `Inviting ${opts.email}` }, () => - factory(deps.http).invite(wsId, { - email: opts.email, - role: opts.role as 'normal' | 'admin', - }), - ) - - const textLine = `${cs.successIcon()} Invited ${response.email} as ${response.role}\n` - return { data: new InviteOutput(response, textLine), workspaceId: wsId } -} diff --git a/cli/src/commands/delete/cache/index.test.ts b/cli/src/commands/delete/cache/index.test.ts new file mode 100644 index 00000000000..c42a84b28e4 --- /dev/null +++ b/cli/src/commands/delete/cache/index.test.ts @@ -0,0 +1,31 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { catalog } from '@/plugins/catalog' +import { commands } from '@/plugins/commands' + +const worlds: TestWorld[] = [] +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +it('deletes the cached catalog file', async () => { + const w = await testContext({ login: true, argv: ['refresh', 'cache'] }) + worlds.push(w) + await (await w.ctx.get(commands)).run() + expect((await w.ctx.get(catalog)).loaded).toBe(true) + + const w2 = await testContext({ login: true, argv: ['delete', 'cache'], reuseDirOf: w }) + worlds.push(w2) + expect(await (await w2.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(w2.io.outBuf())).toEqual({ cleared: true }) + expect((await w2.ctx.get(catalog)).loaded).toBe(false) +}) + +it('rejects with exit 4 when not logged in', async () => { + const w = await testContext({ login: false, argv: ['delete', 'cache'] }) + worlds.push(w) + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'not_logged_in', + }) +}) diff --git a/cli/src/commands/delete/cache/index.ts b/cli/src/commands/delete/cache/index.ts new file mode 100644 index 00000000000..d11f753aef7 --- /dev/null +++ b/cli/src/commands/delete/cache/index.ts @@ -0,0 +1,18 @@ +import type { CommandContext } from '@/plugins/base' +import { z } from 'zod' +import { catalog } from '@/plugins/catalog' +import { Command } from '@/plugins/commands/command' + +const INPUT = z.object({}) + +export default class CacheClear extends Command { + static override summary = 'Delete the local server catalog cache' + static override effect = 'write' as const + static override input = INPUT + + async run(_input: z.infer, ctx: CommandContext) { + const catalogService = await ctx.get(catalog) + await catalogService.clear() + return { cleared: true } + } +} diff --git a/cli/src/commands/delete/member/handlers.ts b/cli/src/commands/delete/member/handlers.ts deleted file mode 100644 index 1e88ee419e9..00000000000 --- a/cli/src/commands/delete/member/handlers.ts +++ /dev/null @@ -1,26 +0,0 @@ -export type DeletedMemberPayload = { - readonly id: string - readonly deleted: true -} - -export class DeleteMemberOutput { - readonly payload: DeletedMemberPayload - readonly textLine: string - - constructor(memberId: string, textLine: string) { - this.payload = { id: memberId, deleted: true } - this.textLine = textLine - } - - text(): string { - return this.textLine - } - - json(): DeletedMemberPayload { - return this.payload - } - - name(): string { - return this.payload.id - } -} diff --git a/cli/src/commands/delete/member/index.ts b/cli/src/commands/delete/member/index.ts deleted file mode 100644 index 878f8c46593..00000000000 --- a/cli/src/commands/delete/member/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { Args, Flags } from '@/framework/flags' -import { formatted, OutputFormat } from '@/framework/output' -import { runDeleteMember } from './run' - -export default class DeleteMember extends DifyCommand { - static override description = 'Remove a member from the active (or specified) workspace' - - static override effect: CommandEffect = 'destructive' - - static override examples = [ - '<%= config.bin %> delete member acct-1', - '<%= config.bin %> delete member acct-1 -w ws-1', - '<%= config.bin %> delete member acct-1 -o json', - ] - - static override args = { - memberId: Args.string({ description: 'account id of the member to remove', required: true }), - } - - static override flags = { - workspace: Flags.string({ - char: 'w', - description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', - }), - 'http-retry': httpRetryFlag, - output: Flags.outputFormat({ - options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], - default: '', - }), - yes: Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }), - } - - async run(argv: string[]) { - const { args, flags } = this.parse(DeleteMember, argv) - const format = flags.output - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) - const result = await runDeleteMember( - { memberId: args.memberId, workspace: flags.workspace, format, yes: flags.yes }, - { active: ctx.active, http: ctx.http, io: ctx.io }, - ) - return formatted({ format, data: result.data }) - } -} diff --git a/cli/src/commands/delete/member/run.test.ts b/cli/src/commands/delete/member/run.test.ts deleted file mode 100644 index 95fdb35871a..00000000000 --- a/cli/src/commands/delete/member/run.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import { describe, expect, it, vi } from 'vite-plus/test' -import { bufferStreams } from '@/sys/io/streams' -import { runDeleteMember } from './run.js' - -function active(): ActiveContext { - return { - host: 'cloud.dify.ai', - email: 'me@example.com', - ctx: { - account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, - workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - }, - } -} - -function fakeClient() { - return { - remove: vi.fn(() => Promise.resolve({ result: 'success' as const })), - } -} - -describe('runDeleteMember', () => { - it('happy path: DELETE, returns DeleteMemberOutput with text/json/name', async () => { - const client = fakeClient() - const result = await runDeleteMember( - { memberId: 'acct-2' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ) - expect(client.remove).toHaveBeenCalledExactlyOnceWith( - '550e8400-e29b-41d4-a716-446655440000', - 'acct-2', - ) - expect(result.data.text()).toMatch(/Removed acct-2/) - expect(result.data.name()).toBe('acct-2') - expect(result.data.json()).toEqual({ id: 'acct-2', deleted: true }) - expect(result.workspaceId).toBe('550e8400-e29b-41d4-a716-446655440000') - }) - - it('-w flag overrides resolved workspace', async () => { - const client = fakeClient() - await runDeleteMember( - { memberId: 'acct-2', workspace: '550e8400-e29b-41d4-a716-446655440008' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ) - expect(client.remove).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440008', 'acct-2') - }) - - it('rejects empty member id before any HTTP call', async () => { - const client = fakeClient() - await expect( - runDeleteMember( - { memberId: '' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ), - ).rejects.toThrow(/member id is required/) - expect(client.remove).not.toHaveBeenCalled() - }) -}) diff --git a/cli/src/commands/delete/member/run.ts b/cli/src/commands/delete/member/run.ts deleted file mode 100644 index 6796e1ab2ce..00000000000 --- a/cli/src/commands/delete/member/run.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import { MembersClient } from '@/api/members' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { colorEnabled, colorScheme } from '@/sys/io/color' -import { promptConfirm } from '@/sys/io/prompt' -import { runWithSpinner } from '@/sys/io/spinner' -import { nullStreams } from '@/sys/io/streams' -import { resolveWorkspaceId } from '@/workspace/resolver' -import { DeleteMemberOutput } from './handlers.js' - -export type DeleteMemberOptions = { - readonly memberId: string - readonly workspace?: string - readonly format?: string - readonly yes?: boolean -} - -export type DeleteMemberDeps = { - readonly active: ActiveContext - readonly http: HttpClient - readonly io?: IOStreams - readonly envLookup?: (k: string) => string | undefined - readonly membersFactory?: (http: HttpClient) => MembersClient -} - -export type DeleteMemberResult = { - readonly data: DeleteMemberOutput - readonly workspaceId: string -} - -export async function runDeleteMember( - opts: DeleteMemberOptions, - deps: DeleteMemberDeps, -): Promise { - if (opts.memberId === undefined || opts.memberId === '') { - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: 'member id is required', - hint: 'pass it positionally: difyctl delete member ', - }) - } - - const env = deps.envLookup ?? ((k: string) => process.env[k]) - const factory = deps.membersFactory ?? ((h: HttpClient) => new MembersClient(h)) - const io = deps.io ?? nullStreams() - const cs = colorScheme(colorEnabled(io.isErrTTY)) - - const wsId = resolveWorkspaceId({ - flag: opts.workspace, - env: env('DIFY_WORKSPACE_ID'), - active: deps.active, - }) - - if (!opts.yes && io.isErrTTY) { - const confirmed = await promptConfirm(io, `Remove member ${opts.memberId}? [y/N] `) - if (!confirmed) { - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: 'aborted by user', - hint: 'pass --yes to skip confirmation', - }) - } - } - - await runWithSpinner({ io, label: `Removing ${opts.memberId}` }, () => - factory(deps.http).remove(wsId, opts.memberId), - ) - - const textLine = `${cs.successIcon()} Removed ${opts.memberId}\n` - return { - data: new DeleteMemberOutput(opts.memberId, textLine), - workspaceId: wsId, - } -} diff --git a/cli/src/commands/describe/app/guide.ts b/cli/src/commands/describe/app/guide.ts deleted file mode 100644 index 6d29e2ef093..00000000000 --- a/cli/src/commands/describe/app/guide.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const agentGuide = ` -WHEN TO USE - Inspect one app before running it — reveals its mode and the input - variables / parameters it expects: - difyctl describe app -o json - difyctl describe app -o json | jq '.info.mode' - -NEXT - Feed the discovered inputs to run: - difyctl run app --inputs '{"key":"value"}' -o json - -ERROR RECOVERY - app not found (404) difyctl get app - not logged in (exit 4) difyctl auth login -` diff --git a/cli/src/commands/describe/app/handlers.ts b/cli/src/commands/describe/app/handlers.ts deleted file mode 100644 index 79fa372e263..00000000000 --- a/cli/src/commands/describe/app/handlers.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { AppDescribeInfo } from '@dify/contracts/api/openapi/types.gen' -import type { AppMeta } from '@/types/app-meta' - -export const APP_DESCRIBE_MODE_KEY = 'app-describe' - -export type AppDescribePayload = { - info: AppDescribeInfo | null - parameters: unknown - input_schema: unknown -} - -export class AppDescribeOutput { - readonly payload: AppDescribePayload - - constructor(meta: AppMeta) { - this.payload = { - info: meta.info, - parameters: meta.parameters, - input_schema: meta.inputSchema, - } - } - - text(): string { - const lines: string[] = [] - if (this.payload.info !== null && this.payload.info !== undefined) { - const info = this.payload.info - const rows: [string, string][] = [ - ['Name', info.name], - ['ID', info.id], - ['Mode', info.mode], - ['Updated', info.updated_at ?? ''], - ['Service API', info.service_api_enabled ? 'true' : 'false'], - ] - if (info.description !== '' && info.description !== undefined) - rows.push(['Description', info.description ?? '']) - if (info.is_agent) rows.push(['Agent', 'true']) - lines.push(...alignedRows(rows)) - } - if (this.payload.parameters !== null && this.payload.parameters !== undefined) { - lines.push('Parameters:') - const indented = JSON.stringify(this.payload.parameters, null, 2) - .split('\n') - .map((l) => ` ${l}`) - .join('\n') - lines.push(indented) - } - return `${lines.join('\n')}\n` - } - - json(): AppDescribePayload { - return this.payload - } -} - -function alignedRows(rows: readonly [string, string][]): string[] { - const widest = rows.reduce((m, [k]) => Math.max(m, k.length), 0) - return rows.map(([k, v]) => `${`${k}:`.padEnd(widest + 2)}${v}`) -} diff --git a/cli/src/commands/describe/app/index.ts b/cli/src/commands/describe/app/index.ts deleted file mode 100644 index 133a29f6b41..00000000000 --- a/cli/src/commands/describe/app/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { Args, Flags } from '@/framework/flags' -import { formatted, OutputFormat } from '@/framework/output' -import { isValidUuid } from '@/workspace/resolver' -import { agentGuide } from './guide' -import { runDescribeApp } from './run' - -export default class DescribeApp extends DifyCommand { - static override description = 'Describe a single app (kubectl-describe-style)' - - static override examples = [ - '<%= config.bin %> describe app ', - '<%= config.bin %> describe app -o json', - '<%= config.bin %> describe app --refresh', - ] - - static override args = { - id: Args.string({ description: 'app UUID', required: true }), - } - - static override flags = { - workspace: Flags.string({ - description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', - }), - 'http-retry': httpRetryFlag, - output: Flags.outputFormat({ - options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], - default: '', - }), - refresh: Flags.boolean({ - description: 'bypass app-info cache and fetch fresh', - default: false, - }), - } - - async run(argv: string[]) { - const { args, flags } = this.parse(DescribeApp, argv) - if (!isValidUuid(args.id)) - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: `${JSON.stringify(args.id)} is not a valid app UUID`, - }) - const format = flags.output - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], withCache: true, format }) - return formatted({ - format, - data: await runDescribeApp( - { appId: args.id, workspace: flags.workspace, format, refresh: flags.refresh }, - { active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, - ), - }) - } - - override agentGuide(): string { - return agentGuide - } -} diff --git a/cli/src/commands/describe/app/run.test.ts b/cli/src/commands/describe/app/run.test.ts deleted file mode 100644 index 24ed79238a3..00000000000 --- a/cli/src/commands/describe/app/run.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { ActiveContext } from '@/auth/hosts' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { startMock } from '@test/fixtures/dify-mock/server' -import { testHttpClient } from '@test/fixtures/http-client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { loadAppInfoCache } from '@/cache/app-info' -import { formatted, stringifyOutput } from '@/framework/output' -import { ENV_CACHE_DIR } from '@/store/dir' -import { CACHE_APP_INFO, getCache } from '@/store/manager' -import { runDescribeApp } from './run.js' - -function active(): ActiveContext { - return { - host: 'http://localhost', - email: 't@d.ai', - ctx: { - account: { id: 'acct-1', email: 't@d.ai', name: 'T' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - }, - } -} - -describe('runDescribeApp', () => { - let mock: DifyMock - let dir: string - let prevCacheDir: string | undefined - beforeEach(async () => { - mock = await startMock({ scenario: 'happy' }) - dir = await mkdtemp(join(tmpdir(), 'difyctl-desc-')) - prevCacheDir = process.env[ENV_CACHE_DIR] - process.env[ENV_CACHE_DIR] = dir - }) - afterEach(async () => { - vi.restoreAllMocks() - 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 }) - }) - - async function render(opts: Parameters[0]): Promise { - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - const data = await runDescribeApp(opts, { - active: active(), - http: testHttpClient(mock.url, 'dfoa_test'), - host: mock.url, - cache, - }) - return stringifyOutput(formatted({ format: opts.format ?? '', data })) - } - - it('text: renders kubectl-describe-style for chat app', async () => { - const out = await render({ appId: 'app-1' }) - expect(out).toContain('Name:') - expect(out).toContain('Greeter') - expect(out).toContain('ID:') - expect(out).toContain('app-1') - expect(out).toContain('Mode:') - expect(out).toContain('chat') - expect(out).toContain('Service API:') - expect(out).toContain('Description:') - expect(out).toContain('Parameters:') - }) - - it('text: agent app shows Agent: true', async () => { - const out = await render({ appId: 'app-4', workspace: '00000000-0000-0000-0000-000000000002' }) - expect(out).toContain('Agent:') - expect(out).toContain('true') - }) - - it('json: passes through DescribeResponse-shaped meta', async () => { - const out = await render({ appId: 'app-1', format: 'json' }) - const parsed = JSON.parse(out) as { info: { id: string }; parameters: unknown } - expect(parsed.info.id).toBe('app-1') - expect(parsed.parameters).toBeDefined() - }) - - it('yaml: renders YAML', async () => { - const out = await render({ appId: 'app-1', format: 'yaml' }) - expect(out).toContain('info:') - expect(out).toContain('id: app-1') - }) - - it('refresh: bypasses cache', async () => { - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runDescribeApp( - { appId: 'app-1' }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, - ) - const before = cache.get(mock.url, 'app-1') - expect(before).toBeDefined() - await runDescribeApp( - { appId: 'app-1', refresh: true }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, - ) - const after = cache.get(mock.url, 'app-1') - expect(after?.fetchedAt).not.toBe(before?.fetchedAt ?? '') - }) - - it('rejects unknown format', async () => { - await expect(render({ appId: 'app-1', format: 'bogus' })).rejects.toThrow(/not supported/) - }) - - it('unknown app id surfaces as error', async () => { - await expect( - runDescribeApp( - { appId: 'nope' }, - { - active: active(), - http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), - host: mock.url, - }, - ), - ).rejects.toThrow() - }) - - it('external login resolves describe via the permitted-external route', async () => { - const activeExt: ActiveContext = { - host: mock.url, - email: 'e', - ctx: { - account: { id: 'a', email: 'e', name: 'n' }, - external_subject: { email: 'e', issuer: 'i' }, - }, - } - const out = await runDescribeApp( - { appId: 'app-1' }, - { active: activeExt, http: testHttpClient(mock.url, 'dfoe_test'), host: mock.url }, - ) - expect(out.payload.info?.id).toBe('app-1') - }) -}) diff --git a/cli/src/commands/describe/app/run.ts b/cli/src/commands/describe/app/run.ts deleted file mode 100644 index 5e2eb381db3..00000000000 --- a/cli/src/commands/describe/app/run.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { AppInfoCache } from '@/cache/app-info' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import { AppMetaClient } from '@/api/app-meta' -import { selectAppReader } from '@/api/app-reader' -import { runWithSpinner } from '@/sys/io/spinner' -import { nullStreams } from '@/sys/io/streams' -import { FieldInfo, FieldInputSchema, FieldParameters } from '@/types/app-meta' -import { AppDescribeOutput } from './handlers.js' - -export type DescribeAppOptions = { - readonly appId: string - readonly workspace?: string - readonly format?: string - readonly refresh?: boolean -} - -export type DescribeAppDeps = { - readonly active: ActiveContext - readonly http: HttpClient - readonly host: string - readonly io?: IOStreams - readonly cache?: AppInfoCache - readonly envLookup?: (k: string) => string | undefined -} - -export async function runDescribeApp( - opts: DescribeAppOptions, - deps: DescribeAppDeps, -): Promise { - const apps = selectAppReader(deps.active, deps.http) - const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) - const io = deps.io ?? nullStreams() - const result = await runWithSpinner({ io, label: 'Fetching app details' }, async () => { - if (opts.refresh === true) await meta.invalidate(opts.appId) - return meta.get(opts.appId, [FieldInfo, FieldParameters, FieldInputSchema]) - }) - return new AppDescribeOutput(result) -} diff --git a/cli/src/commands/env/list/index.ts b/cli/src/commands/env/list/index.ts deleted file mode 100644 index 47622d187b5..00000000000 --- a/cli/src/commands/env/list/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Flags } from '@/framework/flags' -import { raw } from '@/framework/output' -import { runEnvList } from './run-list' - -export default class EnvList extends DifyCommand { - static override description = 'Show every DIFY_* env var difyctl reads' - - static override examples = ['<%= config.bin %> env list', '<%= config.bin %> env list --json'] - - static override flags = { - json: Flags.boolean({ description: 'emit JSON', default: false }), - } - - async run(argv: string[]) { - const { flags } = this.parse(EnvList, argv) - return raw(runEnvList({ json: flags.json })) - } -} diff --git a/cli/src/commands/env/list/run-list.test.ts b/cli/src/commands/env/list/run-list.test.ts deleted file mode 100644 index 407764ebe9d..00000000000 --- a/cli/src/commands/env/list/run-list.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { runEnvList } from './run-list' - -const stub = - (overrides: Record = {}) => - (name: string) => - overrides[name] - -describe('runEnvList', () => { - it('text: header is NAME VALUE DESCRIPTION', () => { - const out = runEnvList({ lookup: stub() }) - expect(out.split('\n')[0]).toMatch(/^NAME\s+VALUE\s+DESCRIPTION$/) - }) - - it('text: for unset non-sensitive var', () => { - const out = runEnvList({ lookup: stub() }) - const hostLine = out.split('\n').find((l) => l.startsWith('DIFY_HOST'))! - expect(hostLine).toContain('') - }) - - it('text: prints actual value for set non-sensitive var', () => { - const out = runEnvList({ lookup: stub({ DIFY_HOST: 'https://acme' }) }) - const hostLine = out.split('\n').find((l) => l.startsWith('DIFY_HOST'))! - expect(hostLine).toContain('https://acme') - }) - - it('text: for set sensitive var (token never echoed)', () => { - const out = runEnvList({ lookup: stub({ DIFY_TOKEN: 'dfoa_secret' }) }) - const tokLine = out.split('\n').find((l) => l.startsWith('DIFY_TOKEN'))! - expect(tokLine).toContain('') - expect(tokLine).not.toContain('dfoa_secret') - }) - - it('text: for unset sensitive var', () => { - const out = runEnvList({ lookup: stub() }) - const tokLine = out.split('\n').find((l) => l.startsWith('DIFY_TOKEN'))! - expect(tokLine).toContain('') - }) - - it('text: rows are sorted alphabetically by name', () => { - const out = runEnvList({ lookup: stub() }) - const lines = out - .trim() - .split('\n') - .slice(1) - .map((l) => l.split(/\s+/)[0]) - const sorted = [...lines].sort() - expect(lines).toEqual(sorted) - }) - - it('json: emits array with name/description/sensitive/value fields', () => { - const out = runEnvList({ - json: true, - lookup: stub({ DIFY_HOST: 'https://acme', DIFY_TOKEN: 'dfoa_x' }), - }) - const parsed = JSON.parse(out) as Array<{ name: string; sensitive: boolean; value: string }> - expect(parsed.length).toBeGreaterThan(0) - const host = parsed.find((r) => r.name === 'DIFY_HOST')! - expect(host.sensitive).toBe(false) - expect(host.value).toBe('https://acme') - const tok = parsed.find((r) => r.name === 'DIFY_TOKEN')! - expect(tok.sensitive).toBe(true) - expect(tok.value).toBe('') - }) - - it('json: trailing newline matches Go encoder.Encode', () => { - const out = runEnvList({ json: true, lookup: stub() }) - expect(out.endsWith('\n')).toBe(true) - }) -}) diff --git a/cli/src/commands/env/list/run-list.ts b/cli/src/commands/env/list/run-list.ts deleted file mode 100644 index a45ea1b807d..00000000000 --- a/cli/src/commands/env/list/run-list.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ENV_REGISTRY } from '@/env/registry' -import { getEnv } from '@/sys/index' - -export type EnvLookup = (name: string) => string | undefined - -export type RunEnvListOptions = { - readonly json?: boolean - readonly lookup?: EnvLookup -} - -export type EnvListJsonRow = { - name: string - description: string - sensitive: boolean - value: string -} - -const COLUMN_PADDING = 2 - -export function runEnvList(opts: RunEnvListOptions = {}): string { - const lookup = opts.lookup ?? getEnv - if (opts.json) { - const rows: EnvListJsonRow[] = ENV_REGISTRY.map((v) => ({ - name: v.name, - description: v.description, - sensitive: v.sensitive ?? false, - value: displayValue(v.name, v.sensitive ?? false, lookup), - })) - return `${JSON.stringify(rows, null, 2)}\n` - } - const header: readonly string[] = ['NAME', 'VALUE', 'DESCRIPTION'] - const dataRows = ENV_REGISTRY.map((v) => [ - v.name, - displayValue(v.name, v.sensitive ?? false, lookup), - v.description, - ]) - return renderTable([header, ...dataRows]) -} - -function displayValue(name: string, sensitive: boolean, lookup: EnvLookup): string { - const raw = lookup(name) ?? '' - if (sensitive) return raw === '' ? '' : '' - return raw === '' ? '' : raw -} - -function renderTable(rows: readonly (readonly string[])[]): string { - if (rows.length === 0) return '' - const cols = rows[0]?.length ?? 0 - const widths: number[] = Array.from({ length: cols }, () => 0) - for (const row of rows) { - for (let i = 0; i < cols; i++) { - const cell = row[i] ?? '' - if (cell.length > (widths[i] ?? 0)) widths[i] = cell.length - } - } - let out = '' - for (const row of rows) { - const parts: string[] = [] - for (let i = 0; i < cols; i++) { - const cell = row[i] ?? '' - const pad = i === cols - 1 ? '' : ' '.repeat((widths[i] ?? 0) - cell.length + COLUMN_PADDING) - parts.push(`${cell}${pad}`) - } - out += `${parts.join('').trimEnd()}\n` - } - return out -} diff --git a/cli/src/commands/export/studio-app/guide.ts b/cli/src/commands/export/studio-app/guide.ts deleted file mode 100644 index d41b502b191..00000000000 --- a/cli/src/commands/export/studio-app/guide.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const agentGuide = ` -WHEN TO USE - A studio app is what you build and edit in Studio on the web console, - inside a workspace — the app's source definition, not the published app - that 'run app' invokes. Export pulls that definition as YAML to back it - up, diff it, or recreate the app elsewhere with 'import studio-app'. To - run or inspect an app instead, use the 'app' noun. - -ERROR RECOVERY - app not found (404) difyctl get app - not logged in (exit 4) difyctl auth login -` diff --git a/cli/src/commands/export/studio-app/index.ts b/cli/src/commands/export/studio-app/index.ts deleted file mode 100644 index 50530e2c2ac..00000000000 --- a/cli/src/commands/export/studio-app/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { Args, Flags } from '@/framework/flags' -import { agentGuide } from './guide' -import { runExportApp } from './run' - -export default class ExportStudioApp extends DifyCommand { - static override description = "Export a studio app's DSL configuration as YAML" - - static override examples = [ - '<%= config.bin %> export studio-app ', - '<%= config.bin %> export studio-app --output ./my-app.yaml', - '<%= config.bin %> export studio-app --include-secret', - '<%= config.bin %> export studio-app --workflow-id ', - ] - - static override args = { - id: Args.string({ description: 'app ID to export', required: true }), - } - - static override flags = { - workspace: Flags.string({ - description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', - }), - output: Flags.string({ - description: 'write DSL YAML to this file path (prints to stdout if omitted)', - char: 'o', - }), - 'include-secret': Flags.boolean({ - description: 'include encrypted secret values in the exported DSL', - default: false, - }), - 'workflow-id': Flags.string({ - description: 'export a specific workflow by ID (workflow apps only)', - }), - 'http-retry': httpRetryFlag, - } - - async run(argv: string[]) { - const { args, flags } = this.parse(ExportStudioApp, argv) - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) - const result = await runExportApp( - { - appId: args.id, - workspace: flags.workspace, - output: flags.output, - includeSecret: flags['include-secret'], - workflowId: flags['workflow-id'], - }, - { active: ctx.active, http: ctx.http, io: ctx.io }, - ) - - if (result.writtenTo === undefined) { - ctx.io.out.write(result.yaml) - if (!result.yaml.endsWith('\n')) ctx.io.out.write('\n') - } - } - - override agentGuide(): string { - return agentGuide - } -} diff --git a/cli/src/commands/export/studio-app/run.test.ts b/cli/src/commands/export/studio-app/run.test.ts deleted file mode 100644 index ddf6a3c9553..00000000000 --- a/cli/src/commands/export/studio-app/run.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { ActiveContext } from '@/auth/hosts' -import os from 'node:os' -import { join } from 'node:path' -import { DSL_YAML } from '@test/fixtures/dify-mock/scenarios' -import { startMock } from '@test/fixtures/dify-mock/server' -import { testHttpClient } from '@test/fixtures/http-client' -import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' -import { bufferStreams } from '@/sys/io/streams' -import { runExportApp } from './run.js' - -const WS_ID = 'aaaaaaaa-0000-0000-0000-000000000001' - -const baseActive: ActiveContext = { - host: '127.0.0.1', - email: 'tester@dify.ai', - ctx: { - account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, - workspace: { id: WS_ID, name: 'Default', role: 'owner' }, - }, - scheme: 'http', -} - -describe('runExportApp', () => { - let mock: DifyMock - - beforeEach(async () => { - mock = await startMock({ scenario: 'happy' }) - }) - - afterEach(async () => { - await mock.stop() - }) - - function http() { - return testHttpClient(mock.url, 'dfoa_test') - } - - it('returns the DSL YAML string from the server', async () => { - const result = await runExportApp({ appId: 'app-1' }, { active: baseActive, http: http() }) - - expect(result.yaml).toBe(DSL_YAML) - expect(result.writtenTo).toBeUndefined() - }) - - it('writes to file when --output is given', async () => { - const tmpFile = join(os.tmpdir(), `difyctl-test-export-${Date.now()}.yaml`) - - const result = await runExportApp( - { appId: 'app-1', output: tmpFile }, - { active: baseActive, http: http() }, - ) - - expect(result.writtenTo).toBe(tmpFile) - const { readFileSync } = await import('node:fs') - expect(readFileSync(tmpFile, 'utf8')).toBe(DSL_YAML) - }) - - it('err stream receives written-to path when --output is given', async () => { - const tmpFile = join(os.tmpdir(), `difyctl-test-export-${Date.now()}.yaml`) - const io = bufferStreams() - - await runExportApp( - { appId: 'app-1', output: tmpFile }, - { active: baseActive, http: http(), io }, - ) - - expect(io.errBuf()).toContain(tmpFile) - }) -}) diff --git a/cli/src/commands/export/studio-app/run.ts b/cli/src/commands/export/studio-app/run.ts deleted file mode 100644 index 30c7ad70145..00000000000 --- a/cli/src/commands/export/studio-app/run.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import fs from 'node:fs' -import { dirname } from 'node:path' -import { AppDslClient } from '@/api/app-dsl' -import { getEnv } from '@/sys/index' -import { runWithSpinner } from '@/sys/io/spinner' -import { nullStreams } from '@/sys/io/streams' -import { resolveWorkspaceId } from '@/workspace/resolver' - -export type ExportAppOptions = { - readonly appId: string - readonly workspace?: string - readonly output?: string - readonly includeSecret?: boolean - readonly workflowId?: string -} - -export type ExportAppDeps = { - readonly active: ActiveContext - readonly http: HttpClient - readonly io?: IOStreams - readonly envLookup?: (k: string) => string | undefined - readonly dslFactory?: (http: HttpClient) => AppDslClient -} - -export type ExportAppResult = { - readonly yaml: string - readonly writtenTo: string | undefined -} - -export async function runExportApp( - opts: ExportAppOptions, - deps: ExportAppDeps, -): Promise { - const env = deps.envLookup ?? getEnv - const io = deps.io ?? nullStreams() - const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h)) - - // workspace is resolved to satisfy the auth pipeline; the export endpoint itself - // takes no workspace_id query parameter (it loads tenant from the app). - resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) - - const client = dslFactory(deps.http) - - const yaml = await runWithSpinner({ io, label: `Exporting DSL for app ${opts.appId}` }, () => - client.exportDsl(opts.appId, { - includeSecret: opts.includeSecret, - workflowId: opts.workflowId, - }), - ) - - if (opts.output !== undefined && opts.output !== '') { - fs.mkdirSync(dirname(opts.output), { recursive: true }) - fs.writeFileSync(opts.output, yaml, 'utf8') - io.err.write(`DSL written to ${opts.output}\n`) - return { yaml, writtenTo: opts.output } - } - - return { yaml, writtenTo: undefined } -} diff --git a/cli/src/commands/get/app/guide.ts b/cli/src/commands/get/app/guide.ts deleted file mode 100644 index fb0b1e31c75..00000000000 --- a/cli/src/commands/get/app/guide.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const agentGuide = ` -DISCOVERY - List apps to find their ids and modes before running one: - difyctl get app -o json all apps in the default workspace - difyctl get app -A -o json across every workspace you can see - difyctl get app -o json one app's basic info - - Each app's "mode" (chat / advanced-chat / completion / workflow / agent-chat) - decides how you call run app. Use 'difyctl describe app ' for the full - input schema. - -ERROR RECOVERY - not logged in (exit 4) difyctl auth login - empty list wrong workspace — try -A or --workspace -` diff --git a/cli/src/commands/get/app/handlers.ts b/cli/src/commands/get/app/handlers.ts deleted file mode 100644 index c4d7526d1f0..00000000000 --- a/cli/src/commands/get/app/handlers.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { AppListResponse, AppListRow } from '@dify/contracts/api/openapi/types.gen' -import type { TableCell, TableColumn } from '@/framework/output' - -export const APP_MODE_KEY = 'app' - -export const APP_COLUMNS: readonly TableColumn[] = [ - { name: 'NAME', priority: 0 }, - { name: 'ID', priority: 0 }, - { name: 'MODE', priority: 0 }, - { name: 'UPDATED', priority: 0 }, - { name: 'WORKSPACE', priority: 1 }, -] - -export class AppRow { - readonly data: AppListRow - - constructor(data: AppListRow) { - this.data = data - } - - tableRow(): readonly TableCell[] { - return [ - this.data.name, - this.data.id, - this.data.mode, - this.data.updated_at ?? '', - this.data.workspace_name ?? '', - ] - } - - name(): string { - return this.data.id - } - - json(): AppListRow { - return this.data - } -} - -export class AppListOutput { - readonly rows: readonly AppRow[] - readonly envelope: AppListResponse - - constructor(rows: readonly AppRow[], envelope: AppListResponse) { - this.rows = rows - this.envelope = envelope - } - - static tableColumns(): readonly TableColumn[] { - return APP_COLUMNS - } - - tableColumns(): readonly TableColumn[] { - return AppListOutput.tableColumns() - } - - tableRows(): readonly (readonly TableCell[])[] { - return this.rows.map((row) => row.tableRow()) - } - - name(): string { - return this.rows.map((row) => row.name()).join('\n') - } - - json(): AppListResponse { - return this.envelope - } -} diff --git a/cli/src/commands/get/app/index.ts b/cli/src/commands/get/app/index.ts deleted file mode 100644 index ee0222b8972..00000000000 --- a/cli/src/commands/get/app/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { SupportedAppType } from '@dify/contracts/api/openapi/types.gen' -import { zSupportedAppType } from '@dify/contracts/api/openapi/zod.gen' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { Args, Flags } from '@/framework/flags' -import { OutputFormat, table } from '@/framework/output' -import { agentGuide } from './guide' -import { runGetApp } from './run' - -// Single source: derived from the backend's listable app types (openapi codegen). -// Adding/removing a listable type is a backend-only change that flows here on regen. -const APP_MODE_VALUES: readonly SupportedAppType[] = zSupportedAppType.options - -export default class GetApp extends DifyCommand { - static override description = "List apps or describe one app's basic info" - - static override examples = [ - '<%= config.bin %> get app', - '<%= config.bin %> get app app-1', - '<%= config.bin %> get app -o json', - '<%= config.bin %> get app -A', - ] - - static override args = { - id: Args.string({ description: 'app id', required: false }), - } - - static override flags = { - workspace: Flags.string({ - description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', - }), - 'all-workspaces': Flags.boolean({ - char: 'A', - description: 'list apps across every workspace the bearer can see', - default: false, - }), - page: Flags.integer({ description: 'page number', default: 1 }), - limit: Flags.string({ description: 'page size [1..200]' }), - mode: Flags.string({ description: 'filter by app mode', options: APP_MODE_VALUES }), - name: Flags.string({ description: 'filter by app name (server-side substring)' }), - 'http-retry': httpRetryFlag, - output: Flags.outputFormat({ - options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], - default: '', - }), - } - - async run(argv: string[]) { - const { args, flags } = this.parse(GetApp, argv) - const format = flags.output - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) - const result = await runGetApp( - { - appId: args.id, - workspace: flags.workspace, - allWorkspaces: flags['all-workspaces'], - page: flags.page, - limitRaw: flags.limit, - mode: flags.mode as SupportedAppType | undefined, - name: flags.name, - format, - }, - { active: ctx.active, http: ctx.http, io: ctx.io }, - ) - return table({ - format, - data: result.data, - }) - } - - override agentGuide(): string { - return agentGuide - } -} diff --git a/cli/src/commands/get/app/mode-whitelist.test.ts b/cli/src/commands/get/app/mode-whitelist.test.ts deleted file mode 100644 index 944820967a0..00000000000 --- a/cli/src/commands/get/app/mode-whitelist.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { zSupportedAppType } from '@dify/contracts/api/openapi/zod.gen' -import { describe, expect, it } from 'vite-plus/test' - -// The `get app --mode` whitelist is derived from this generated enum (see index.ts). -// These pins guard the original bug: the CLI must not advertise modes the backend -// rejects (rag-pipeline, channel) or modes that aren't listable here (agent). -describe('get app --mode whitelist', () => { - it('is exactly the listable app types', () => { - expect([...zSupportedAppType.options].sort()).toEqual([ - 'advanced-chat', - 'agent-chat', - 'chat', - 'completion', - 'workflow', - ]) - }) -}) diff --git a/cli/src/commands/get/app/run.test.ts b/cli/src/commands/get/app/run.test.ts deleted file mode 100644 index 42e0b0a5925..00000000000 --- a/cli/src/commands/get/app/run.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import { startMock } from '@test/fixtures/dify-mock/server' -import { testHttpClient } from '@test/fixtures/http-client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { stringifyOutput, table } from '@/framework/output' -import { AppListOutput } from './handlers.js' -import { runGetApp } from './run.js' - -const baseActive: ActiveContext = { - host: '127.0.0.1', - email: 'tester@dify.ai', - ctx: { - account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, - workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - }, - scheme: 'http', -} - -describe('runGetApp', () => { - let mock: DifyMock - - beforeEach(async () => { - mock = await startMock({ scenario: 'happy' }) - }) - - afterEach(async () => { - vi.restoreAllMocks() - await mock.stop() - }) - - function http() { - return testHttpClient(mock.url, 'dfoa_test') - } - - async function render(opts: Parameters[0] = {}): Promise { - const result = await runGetApp(opts, { active: baseActive, http: http() }) - return stringifyOutput( - table({ - format: opts.format ?? '', - data: result.data, - }), - ) - } - - it('list (no id, default format) renders table with NAME ID MODE UPDATED', async () => { - const out = await render() - expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED/) - expect(out).toContain('Greeter') - expect(out).toContain('app-1') - expect(out).toContain('chat') - expect(out).toContain('Workflow') - expect(out).not.toContain('app-3') - }) - - it('defines table headers on the output class', () => { - expect(AppListOutput.tableColumns().map((column) => column.name)).toEqual([ - 'NAME', - 'ID', - 'MODE', - 'UPDATED', - 'WORKSPACE', - ]) - }) - - it('by-id (single) renders 1-row table', async () => { - const out = await render({ appId: 'app-1' }) - expect(out).toContain('Greeter') - expect(out).toContain('app-1') - expect(out).not.toContain('Workflow') - }) - - it('--mode filters server-side', async () => { - const out = await render({ mode: 'workflow' }) - expect(out).toContain('Workflow') - expect(out).not.toContain('Greeter') - }) - - it('-A all-workspaces aggregates across workspaces sorted by id', async () => { - const out = await render({ allWorkspaces: true }) - expect(out).toContain('app-1') - expect(out).toContain('app-2') - expect(out).toContain('app-3') - const idxApp1 = out.indexOf('app-1') - const idxApp3 = out.indexOf('app-3') - expect(idxApp1).toBeLessThan(idxApp3) - }) - - it('-o json emits parseable JSON envelope', async () => { - const out = await render({ format: 'json' }) - const parsed = JSON.parse(out) as { data: Array<{ id: string }>; total: number } - expect(parsed.data).toHaveLength(2) - expect(parsed.data.map((r) => r.id).sort()).toEqual(['app-1', 'app-2']) - }) - - it('-o yaml emits YAML envelope', async () => { - const out = await render({ format: 'yaml' }) - expect(out).toContain('data:') - expect(out).toContain('id: app-1') - }) - - it('-o name emits ids one per line', async () => { - const out = await render({ format: 'name' }) - expect(out.trim().split('\n').sort()).toEqual(['app-1', 'app-2']) - }) - - it('-o wide includes the WORKSPACE column', async () => { - const out = await render({ format: 'wide' }) - expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED\s+WORKSPACE/) - expect(out).toContain('Default') - }) - - it('rejects unknown format', async () => { - await expect(render({ format: 'bogus' })).rejects.toThrow(/not supported/) - }) - - it('--workspace flag overrides bundle default', async () => { - const out = await render({ workspace: '550e8400-e29b-41d4-a716-446655440001' }) - expect(out).toContain('app-3') - expect(out).toContain('OtherWS Bot') - expect(out).not.toContain('Greeter') - }) - - it('throws NotLoggedIn-equivalent when no workspace can be resolved', async () => { - const minimal: ActiveContext = { - host: 'h', - email: 'x@x.com', - ctx: { account: { email: 'x@x.com', name: 'X' } }, - } - await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/) - }) - - it('external login lists via permitted-external client without workspace', async () => { - const list = vi.fn().mockResolvedValue({ - page: 1, - limit: 20, - total: 1, - has_more: false, - data: [ - { - id: 'x', - name: 'X', - description: null, - mode: 'chat', - updated_at: null, - workspace_id: 'w', - workspace_name: 'W', - }, - ], - }) - const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps') - vi.spyOn(PermittedExternalAppsClient.prototype, 'list').mockImplementation(list) - const active: ActiveContext = { - host: 'h', - email: 'e', - ctx: { - account: { id: 'a', email: 'e', name: 'n' }, - external_subject: { email: 'e', issuer: 'i' }, - }, - } - const http = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient - const res = await runGetApp({}, { active, http }) - expect(list).toHaveBeenCalled() - const firstCallArg = list.mock.calls[0]![0] as { workspaceId: string } - expect(firstCallArg.workspaceId).toBe('') - expect(res.data).toBeDefined() - }) - - it('--all-workspaces throws UsageInvalidFlag for external logins', async () => { - const active: ActiveContext = { - host: 'h', - email: 'e', - ctx: { - account: { id: 'a', email: 'e', name: 'n' }, - external_subject: { email: 'e', issuer: 'i' }, - }, - } - const httpClient = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient - await expect(runGetApp({ allWorkspaces: true }, { active, http: httpClient })).rejects.toThrow( - /--all-workspaces is not available for external logins/, - ) - }) -}) diff --git a/cli/src/commands/get/app/run.ts b/cli/src/commands/get/app/run.ts deleted file mode 100644 index 72fcbaac68f..00000000000 --- a/cli/src/commands/get/app/run.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { - AppDescribeResponse, - AppListResponse, - AppMode, - SupportedAppType, -} from '@dify/contracts/api/openapi/types.gen' -import type { AppReader } from '@/api/app-reader' -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import { selectAppReader, SubjectKind, subjectOf } from '@/api/app-reader' -import { WorkspacesClient } from '@/api/workspaces' -import { newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { LIMIT_DEFAULT, parseLimit } from '@/limit/limit' -import { getEnv } from '@/sys/index' -import { runWithSpinner } from '@/sys/io/spinner' -import { nullStreams } from '@/sys/io/streams' -import { resolveWorkspaceId } from '@/workspace/resolver' -import { AppListOutput, AppRow } from './handlers.js' - -export type GetAppOptions = { - readonly appId?: string - readonly workspace?: string - readonly allWorkspaces?: boolean - readonly page?: number - readonly limitRaw?: string - readonly mode?: SupportedAppType - readonly name?: string - readonly format?: string -} - -export type GetAppDeps = { - readonly active: ActiveContext - readonly http: HttpClient - readonly io?: IOStreams - readonly envLookup?: (k: string) => string | undefined - readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient -} - -const ALL_WORKSPACES_CONCURRENCY = 4 - -export type GetAppResult = { - readonly data: AppListOutput -} - -export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise { - const env = deps.envLookup ?? getEnv - const wsFactory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h)) - - const external = subjectOf(deps.active) === SubjectKind.External - const apps = selectAppReader(deps.active, deps.http) - const pageSize = resolveLimit(opts.limitRaw, env) - const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page - const label = opts.appId !== undefined && opts.appId !== '' ? 'Fetching app' : 'Fetching apps' - const io = deps.io ?? nullStreams() - - const envelope = await runWithSpinner({ io, label }, async (): Promise => { - if (opts.allWorkspaces === true) { - if (external) - throw newError( - ErrorCode.UsageInvalidFlag, - '--all-workspaces is not available for external logins', - ) - const ws = wsFactory(deps.http) - return runAllWorkspaces(apps, ws, opts, page, pageSize) - } - if (opts.appId !== undefined && opts.appId !== '') { - const wsId = external - ? '' - : resolveWorkspaceId({ - flag: opts.workspace, - env: env('DIFY_WORKSPACE_ID'), - active: deps.active, - }) - const wsName = external ? '' : workspaceNameForId(deps.active, wsId) - const desc = await apps.describe(opts.appId, ['info']) - return describeToEnvelope(desc, wsId, wsName) - } - if (external) { - return apps.list({ workspaceId: '', page, limit: pageSize, mode: opts.mode, name: opts.name }) - } - const wsId = resolveWorkspaceId({ - flag: opts.workspace, - env: env('DIFY_WORKSPACE_ID'), - active: deps.active, - }) - return apps.list({ - workspaceId: wsId, - page, - limit: pageSize, - mode: opts.mode, - name: opts.name, - }) - }) - - return { - data: new AppListOutput( - envelope.data.map((row) => new AppRow(row)), - envelope, - ), - } -} - -function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number { - if (raw !== undefined && raw !== '') return parseLimit(raw, '--limit') - const envValue = env('DIFY_LIMIT') - if (envValue !== undefined && envValue !== '') return parseLimit(envValue, 'DIFY_LIMIT') - return LIMIT_DEFAULT -} - -function describeToEnvelope( - desc: AppDescribeResponse, - wsId: string, - wsName: string, -): AppListResponse { - if (desc.info === null || desc.info === undefined) { - return { page: 1, limit: 1, total: 0, has_more: false, data: [] } - } - return { - page: 1, - limit: 1, - total: 1, - has_more: false, - data: [ - { - id: desc.info.id, - name: desc.info.name, - description: desc.info.description, - mode: desc.info.mode as AppMode, - updated_at: desc.info.updated_at, - workspace_id: wsId, - workspace_name: wsName === '' ? undefined : wsName, - }, - ], - } -} - -function workspaceNameForId(active: ActiveContext, id: string): string { - if (id === '') return '' - return active.ctx.workspace?.id === id ? active.ctx.workspace.name : '' -} - -async function runAllWorkspaces( - apps: AppReader, - ws: WorkspacesClient, - opts: GetAppOptions, - page: number, - limit: number, -): Promise { - const wsResp = await ws.list() - if (wsResp.data.length === 0) return { page: 1, limit, total: 0, has_more: false, data: [] } - - const merged: AppListResponse = { page: 1, limit, total: 0, has_more: false, data: [] } - const queue = [...wsResp.data] - const workers: Promise[] = [] - - const fetchOne = async (wsId: string): Promise => { - const env = await apps.list({ - workspaceId: wsId, - page, - limit, - mode: opts.mode, - name: opts.name, - }) - merged.total += env.total - merged.data = [...merged.data, ...env.data] - } - - const runner = async (): Promise => { - while (true) { - const next = queue.shift() - if (next === undefined) return - await fetchOne(next.id) - } - } - - const N = Math.min(ALL_WORKSPACES_CONCURRENCY, wsResp.data.length) - for (let i = 0; i < N; i++) workers.push(runner()) - await Promise.all(workers) - - merged.data = [...merged.data].sort((a, b) => a.id.localeCompare(b.id)) - return merged -} diff --git a/cli/src/commands/get/config/index.test.ts b/cli/src/commands/get/config/index.test.ts new file mode 100644 index 00000000000..b32ee2ca5bc --- /dev/null +++ b/cli/src/commands/get/config/index.test.ts @@ -0,0 +1,35 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { commands } from '@/plugins/commands' +import { config } from '@/plugins/config' + +const worlds: TestWorld[] = [] +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +it('reads a value written directly through the config service', async () => { + const w = await testContext({ login: false, argv: ['get', 'config', 'http.timeout'] }) + worlds.push(w) + await (await w.ctx.get(config)).set('http.timeout', '5000') + expect(await (await w.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(w.io.outBuf())).toBe(5000) +}) + +it('prints the whole doc with its path when no key is given', async () => { + const w = await testContext({ login: false, argv: ['get', 'config'] }) + worlds.push(w) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + const body = JSON.parse(w.io.outBuf()) as { path: string; http: { timeout: number } } + expect(body.http.timeout).toBe(30_000) + expect(body.path).toContain('config.yml') +}) + +it('rejects an unknown key with exit 2', async () => { + const w = await testContext({ login: false, argv: ['get', 'config', 'nope'] }) + worlds.push(w) + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'config_invalid_key', + }) +}) diff --git a/cli/src/commands/get/config/index.ts b/cli/src/commands/get/config/index.ts new file mode 100644 index 00000000000..5c573dbe996 --- /dev/null +++ b/cli/src/commands/get/config/index.ts @@ -0,0 +1,17 @@ +import type { CommandContext } from '@/plugins/base' +import { z } from 'zod' +import { Command } from '@/plugins/commands/command' +import { config } from '@/plugins/config' + +const INPUT = z.object({ key: z.string().optional() }) + +export default class ConfigGet extends Command { + static override summary = 'Print the local config, or one key' + static override input = INPUT + static override positional = ['key'] as const + + async run(input: z.infer, ctx: CommandContext) { + const configService = await ctx.get(config) + return configService.get(input.key) + } +} diff --git a/cli/src/commands/get/member/handlers.ts b/cli/src/commands/get/member/handlers.ts deleted file mode 100644 index 29398646b9f..00000000000 --- a/cli/src/commands/get/member/handlers.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { MemberListResponse, MemberResponse } from '@dify/contracts/api/openapi/types.gen' -import type { TableCell, TableColumn } from '@/framework/output' - -export const MEMBER_MODE_KEY = 'member' -const CURRENT_MARKER = '*' - -export const MEMBER_COLUMNS: readonly TableColumn[] = [ - { name: 'ID', priority: 0 }, - { name: 'NAME', priority: 0 }, - { name: 'EMAIL', priority: 0 }, - { name: 'ROLE', priority: 0 }, - { name: 'STATUS', priority: 0 }, - { name: 'CURRENT', priority: 0 }, -] - -export class MemberRow { - readonly id: string - readonly displayName: string - readonly email: string - readonly role: string - readonly status: string - readonly current: boolean - - constructor(member: MemberResponse, current: boolean) { - this.id = member.id - this.displayName = member.name - this.email = member.email - this.role = member.role - this.status = member.status - this.current = current - } - - tableRow(): readonly TableCell[] { - return [ - this.id, - this.displayName, - this.email, - this.role, - this.status, - this.current ? CURRENT_MARKER : '', - ] - } - - name(): string { - return this.id - } - - json() { - return { - id: this.id, - name: this.displayName, - email: this.email, - role: this.role, - status: this.status, - current: this.current, - } - } -} - -export class MemberListOutput { - readonly rows: readonly MemberRow[] - readonly envelope: MemberListResponse - - constructor(rows: readonly MemberRow[], envelope: MemberListResponse) { - this.rows = rows - this.envelope = envelope - } - - static tableColumns(): readonly TableColumn[] { - return MEMBER_COLUMNS - } - - tableColumns(): readonly TableColumn[] { - return MemberListOutput.tableColumns() - } - - tableRows(): readonly (readonly TableCell[])[] { - return this.rows.map((row) => row.tableRow()) - } - - name(): string { - return this.rows.map((row) => row.name()).join('\n') - } - - json(): MemberListResponse { - return this.envelope - } -} diff --git a/cli/src/commands/get/member/index.ts b/cli/src/commands/get/member/index.ts deleted file mode 100644 index 0dd66de9f5f..00000000000 --- a/cli/src/commands/get/member/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { Flags } from '@/framework/flags' -import { OutputFormat, table } from '@/framework/output' -import { runGetMember } from './run' - -export default class GetMember extends DifyCommand { - static override description = 'List members of the active (or specified) workspace' - - static override examples = [ - '<%= config.bin %> get member', - '<%= config.bin %> get member -w ws-1', - '<%= config.bin %> get member --page 2 --limit 50', - '<%= config.bin %> get member -o json', - '<%= config.bin %> get member -o name', - ] - - static override flags = { - workspace: Flags.string({ - char: 'w', - description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', - }), - page: Flags.integer({ description: 'page number', default: 1 }), - limit: Flags.string({ description: 'page size [1..200]' }), - 'http-retry': httpRetryFlag, - output: Flags.outputFormat({ - options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], - default: '', - }), - } - - async run(argv: string[]) { - const { flags } = this.parse(GetMember, argv) - const format = flags.output - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) - const result = await runGetMember( - { - workspace: flags.workspace, - page: flags.page, - limitRaw: flags.limit, - format, - }, - { active: ctx.active, http: ctx.http, io: ctx.io }, - ) - return table({ format, data: result.data }) - } -} diff --git a/cli/src/commands/get/member/run.test.ts b/cli/src/commands/get/member/run.test.ts deleted file mode 100644 index f3670b69c7b..00000000000 --- a/cli/src/commands/get/member/run.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen' -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import { describe, expect, it, vi } from 'vite-plus/test' -import { bufferStreams } from '@/sys/io/streams' -import { runGetMember } from './run.js' - -function active(): ActiveContext { - return { - host: 'cloud.dify.ai', - email: 'me@example.com', - ctx: { - account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, - workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - }, - } -} - -function fakeClient(envelope: MemberListResponse) { - return { list: vi.fn(() => Promise.resolve(envelope)) } -} - -describe('runGetMember', () => { - const env: MemberListResponse = { - page: 1, - limit: 20, - total: 2, - has_more: false, - data: [ - { id: 'acct-1', name: 'Me', email: 'me@example.com', role: 'owner', status: 'active' }, - { id: 'acct-2', name: 'Mate', email: 'mate@example.com', role: 'admin', status: 'active' }, - ], - } - - it('lists members and marks the calling account with current=true', async () => { - const client = fakeClient(env) - const r = await runGetMember( - {}, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ) - expect(client.list).toHaveBeenCalledExactlyOnceWith('550e8400-e29b-41d4-a716-446655440000', { - page: 1, - limit: 20, - }) - expect(r.workspaceId).toBe('550e8400-e29b-41d4-a716-446655440000') - expect(r.data.rows.map((row) => row.current)).toEqual([true, false]) - expect(r.data.rows.map((row) => row.id)).toEqual(['acct-1', 'acct-2']) - }) - - it('-w flag overrides resolved workspace', async () => { - const client = fakeClient(env) - const r = await runGetMember( - { workspace: '550e8400-e29b-41d4-a716-446655440008' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ) - expect(client.list).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440008', { - page: 1, - limit: 20, - }) - expect(r.workspaceId).toBe('550e8400-e29b-41d4-a716-446655440008') - }) - - it('--page/--limit are forwarded to the client', async () => { - const client = fakeClient(env) - await runGetMember( - { page: 3, limitRaw: '50' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ) - expect(client.list).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000', { - page: 3, - limit: 50, - }) - }) - - it('marks no row when active context has no account id', async () => { - const client = fakeClient(env) - const a: ActiveContext = { - host: 'cloud.dify.ai', - email: 'me@example.com', - ctx: { - account: { id: '', email: '', name: '' }, - workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - }, - } - const r = await runGetMember( - {}, - { - active: a, - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ) - expect(r.data.rows.every((row) => !row.current)).toBe(true) - }) - - it('throws when no workspace can be resolved', async () => { - const client = fakeClient(env) - const noWs: ActiveContext = { - host: 'cloud.dify.ai', - email: 'me@example.com', - ctx: { account: { id: 'acct-1', email: 'me@example.com', name: 'Me' } }, - } - await expect( - runGetMember( - {}, - { - active: noWs, - http: {} as HttpClient, - io: bufferStreams(), - envLookup: () => undefined, - membersFactory: () => client as never, - }, - ), - ).rejects.toThrow(/no workspace selected/) - expect(client.list).not.toHaveBeenCalled() - }) -}) - -describe('MemberListOutput shape', () => { - it('builds table with CURRENT marker column', async () => { - const env: MemberListResponse = { - page: 1, - limit: 20, - total: 1, - has_more: false, - data: [ - { id: 'acct-1', name: 'Me', email: 'me@example.com', role: 'owner', status: 'active' }, - ], - } - const client = fakeClient(env) - const r = await runGetMember( - {}, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ) - expect(r.data.tableColumns().map((c) => c.name)).toEqual([ - 'ID', - 'NAME', - 'EMAIL', - 'ROLE', - 'STATUS', - 'CURRENT', - ]) - expect(r.data.tableRows()[0]?.[5]).toBe('*') - expect(r.data.name()).toBe('acct-1') - expect(r.data.json().data[0]?.email).toBe('me@example.com') - }) -}) diff --git a/cli/src/commands/get/member/run.ts b/cli/src/commands/get/member/run.ts deleted file mode 100644 index 4da1f4cb129..00000000000 --- a/cli/src/commands/get/member/run.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import { MembersClient } from '@/api/members' -import { LIMIT_DEFAULT, parseLimit } from '@/limit/limit' -import { runWithSpinner } from '@/sys/io/spinner' -import { nullStreams } from '@/sys/io/streams' -import { resolveWorkspaceId } from '@/workspace/resolver' -import { MemberListOutput, MemberRow } from './handlers.js' - -export type GetMemberOptions = { - readonly workspace?: string - readonly page?: number - readonly limitRaw?: string - readonly format?: string -} - -export type GetMemberDeps = { - readonly active: ActiveContext - readonly http: HttpClient - readonly io?: IOStreams - readonly envLookup?: (k: string) => string | undefined - readonly membersFactory?: (http: HttpClient) => MembersClient -} - -export type GetMemberResult = { - readonly data: MemberListOutput - readonly workspaceId: string -} - -export async function runGetMember( - opts: GetMemberOptions, - deps: GetMemberDeps, -): Promise { - const env = deps.envLookup ?? ((k: string) => process.env[k]) - const factory = deps.membersFactory ?? ((h: HttpClient) => new MembersClient(h)) - const io = deps.io ?? nullStreams() - - const wsId = resolveWorkspaceId({ - flag: opts.workspace, - env: env('DIFY_WORKSPACE_ID'), - active: deps.active, - }) - - const limit = resolveLimit(opts.limitRaw, env) - const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page - - const envelope = await runWithSpinner({ io, label: 'Fetching members' }, () => - factory(deps.http).list(wsId, { page, limit }), - ) - - const callerId = deps.active.ctx.account?.id ?? '' - const rows = envelope.data.map((m) => new MemberRow(m, callerId !== '' && m.id === callerId)) - return { data: new MemberListOutput(rows, envelope), workspaceId: wsId } -} - -function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number { - if (raw !== undefined && raw !== '') return parseLimit(raw, '--limit') - const envValue = env('DIFY_LIMIT') - if (envValue !== undefined && envValue !== '') return parseLimit(envValue, 'DIFY_LIMIT') - return LIMIT_DEFAULT -} diff --git a/cli/src/commands/get/skills/index.ts b/cli/src/commands/get/skills/index.ts new file mode 100644 index 00000000000..2fc0c8f27f4 --- /dev/null +++ b/cli/src/commands/get/skills/index.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' +import { Command } from '@/plugins/commands/command' +import { loadSkills } from '@/skills/collection' +import { FROM_FIELD, openSource } from '@/skills/source' + +const INPUT = z.object({ from: FROM_FIELD }) + +export default class SkillsList extends Command { + static override summary = 'List the skills in the collection' + static override input = INPUT + + async run(input: z.infer) { + const skills = await loadSkills(await openSource(input.from)) + return { skills: skills.map(({ name, description }) => ({ name, description })) } + } +} diff --git a/cli/src/commands/get/workspace/handlers.test.ts b/cli/src/commands/get/workspace/handlers.test.ts deleted file mode 100644 index d761ddbc589..00000000000 --- a/cli/src/commands/get/workspace/handlers.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen' -import { describe, expect, it } from 'vite-plus/test' -import { WorkspaceListOutput, WorkspaceRow } from './handlers' - -function env(): WorkspaceListResponse { - return { - page: 1, - limit: 20, - total: 2, - has_more: false, - data: [ - { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, - { - id: '00000000-0000-0000-0000-000000000002', - name: 'Other', - role: 'normal', - status: 'normal', - current: false, - }, - ], - } -} - -describe('get/workspace handlers', () => { - it('WorkspaceRow defines table, name, and json print shapes', () => { - const row = new WorkspaceRow('ws-1', 'Default', 'owner', 'normal', true) - expect(row.tableRow()).toEqual(['ws-1', 'Default', 'owner', 'normal', '*']) - expect(row.name()).toBe('ws-1') - expect(row.json()).toEqual({ - id: 'ws-1', - name: 'Default', - role: 'owner', - status: 'normal', - current: true, - }) - }) - - it('WorkspaceListOutput defines cohesive print behavior', () => { - const row = new WorkspaceRow('ws-1', 'Default', 'owner', 'normal', true) - const output = new WorkspaceListOutput([row], env()) - expect(output.tableColumns().map((column) => column.name)).toEqual([ - 'ID', - 'NAME', - 'ROLE', - 'STATUS', - 'CURRENT', - ]) - expect(output.tableRows()).toEqual([['ws-1', 'Default', 'owner', 'normal', '*']]) - expect(output.name()).toBe('ws-1') - expect(output.json().data).toHaveLength(2) - }) -}) diff --git a/cli/src/commands/get/workspace/handlers.ts b/cli/src/commands/get/workspace/handlers.ts deleted file mode 100644 index 9a2e1cdbbc1..00000000000 --- a/cli/src/commands/get/workspace/handlers.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen' -import type { TableCell, TableColumn } from '@/framework/output' - -const CURRENT_MARKER = '*' - -export const WORKSPACE_COLUMNS: readonly TableColumn[] = [ - { name: 'ID', priority: 0 }, - { name: 'NAME', priority: 0 }, - { name: 'ROLE', priority: 0 }, - { name: 'STATUS', priority: 0 }, - { name: 'CURRENT', priority: 0 }, -] - -export class WorkspaceRow { - readonly id: string - readonly displayName: string - readonly role: string - readonly status: string - readonly current: boolean - - constructor(id: string, displayName: string, role: string, status: string, current: boolean) { - this.id = id - this.displayName = displayName - this.role = role - this.status = status - this.current = current - } - - tableRow(): readonly TableCell[] { - return [this.id, this.displayName, this.role, this.status, this.current ? CURRENT_MARKER : ''] - } - - name(): string { - return this.id - } - - json() { - return { - id: this.id, - name: this.displayName, - role: this.role, - status: this.status, - current: this.current, - } - } -} - -export class WorkspaceListOutput { - readonly rows: readonly WorkspaceRow[] - readonly envelope: WorkspaceListResponse - - constructor(rows: readonly WorkspaceRow[], envelope: WorkspaceListResponse) { - this.rows = rows - this.envelope = envelope - } - - static tableColumns(): readonly TableColumn[] { - return WORKSPACE_COLUMNS - } - - tableColumns(): readonly TableColumn[] { - return WorkspaceListOutput.tableColumns() - } - - tableRows(): readonly (readonly TableCell[])[] { - return this.rows.map((row) => row.tableRow()) - } - - name(): string { - return this.rows.map((row) => row.name()).join('\n') - } - - json(): WorkspaceListResponse { - return this.envelope - } -} diff --git a/cli/src/commands/get/workspace/index.ts b/cli/src/commands/get/workspace/index.ts deleted file mode 100644 index 4f6e1398815..00000000000 --- a/cli/src/commands/get/workspace/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { Flags } from '@/framework/flags' -import { OutputFormat, raw, table } from '@/framework/output' -import { runGetWorkspace } from './run' - -export default class GetWorkspace extends DifyCommand { - static override description = 'List workspaces visible to the current bearer' - - static override examples = [ - '<%= config.bin %> get workspace', - '<%= config.bin %> get workspace -o json', - '<%= config.bin %> get workspace -o name', - ] - - static override flags = { - 'http-retry': httpRetryFlag, - output: Flags.outputFormat({ - options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], - default: '', - }), - } - - async run(argv: string[]) { - const { flags } = this.parse(GetWorkspace, argv) - const format = flags.output - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) - const result = await runGetWorkspace( - { format }, - { active: ctx.active, http: ctx.http, io: ctx.io }, - ) - if (result.kind === 'empty') return raw(result.message) - return table({ - format, - data: result.data, - }) - } -} diff --git a/cli/src/commands/get/workspace/run.test.ts b/cli/src/commands/get/workspace/run.test.ts deleted file mode 100644 index 2e1529b1d0d..00000000000 --- a/cli/src/commands/get/workspace/run.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { ActiveContext } from '@/auth/hosts' -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 { stringifyOutput, table } from '@/framework/output' -import { WorkspaceListOutput } from './handlers.js' -import { EMPTY_WORKSPACES_MESSAGE, runGetWorkspace } from './run.js' - -const baseActive: ActiveContext = { - host: '127.0.0.1', - email: 'tester@dify.ai', - ctx: { - account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, - workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - }, - scheme: 'http', -} - -describe('runGetWorkspace', () => { - let mock: DifyMock - - beforeEach(async () => { - mock = await startMock({ scenario: 'happy' }) - }) - - afterEach(async () => { - await mock.stop() - }) - - function http() { - return testHttpClient(mock.url, 'dfoa_test') - } - - async function render(format = '', activeCtx = baseActive): Promise { - const result = await runGetWorkspace({ format }, { active: activeCtx, http: http() }) - if (result.kind === 'empty') return result.message - return stringifyOutput( - table({ - format, - data: result.data, - }), - ) - } - - it('default format renders ID NAME ROLE STATUS CURRENT table', async () => { - const out = await render() - expect(out).toMatch(/^ID\s+NAME\s+ROLE\s+STATUS\s+CURRENT/) - expect(out).toContain('550e8400-e29b-41d4-a716-446655440000') - expect(out).toContain('550e8400-e29b-41d4-a716-446655440001') - expect(out).toContain('Default') - expect(out).toContain('owner') - expect(out).toContain('normal') - }) - - it('defines table headers on the output class', () => { - expect(WorkspaceListOutput.tableColumns().map((column) => column.name)).toEqual([ - 'ID', - 'NAME', - 'ROLE', - 'STATUS', - 'CURRENT', - ]) - }) - - it('marks the current workspace with *', async () => { - const out = await render() - for (const line of out.split('\n')) { - if (line.includes('550e8400-e29b-41d4-a716-446655440000')) expect(line).toContain('*') - else if (line.includes('550e8400-e29b-41d4-a716-446655440001')) - expect(line).not.toContain('*') - } - }) - - it('falls back to active context workspace.id when server current=false', async () => { - const overridden: ActiveContext = { - ...baseActive, - ctx: { - ...baseActive.ctx, - workspace: { id: '550e8400-e29b-41d4-a716-446655440001', name: 'Other', role: 'normal' }, - }, - } - const out = await render('', overridden) - for (const line of out.split('\n')) { - if (line.includes('550e8400-e29b-41d4-a716-446655440001')) expect(line).toContain('*') - } - }) - - it('-o json emits a parseable workspaces envelope', async () => { - const out = await render('json') - const parsed = JSON.parse(out) as { - data: Array<{ id: string; status: string; current: boolean }> - } - expect(parsed.data).toHaveLength(2) - expect(parsed.data.map((w) => w.id).sort()).toEqual([ - '550e8400-e29b-41d4-a716-446655440000', - '550e8400-e29b-41d4-a716-446655440001', - ]) - expect(parsed.data[0]?.status).toBe('normal') - expect(parsed.data[0]?.current).toBe(true) - }) - - it('-o yaml emits "data:" header', async () => { - const out = await render('yaml') - expect(out).toContain('data:') - expect(out).toContain('550e8400-e29b-41d4-a716-446655440000') - }) - - it('-o name emits ids joined by newline', async () => { - const out = await render('name') - expect(out.trim().split('\n').sort()).toEqual([ - '550e8400-e29b-41d4-a716-446655440000', - '550e8400-e29b-41d4-a716-446655440001', - ]) - }) - - it('empty workspaces (sso scenario) prints external-SSO message regardless of format', async () => { - mock.setScenario('sso') - const out = await render() - expect(out).toBe(EMPTY_WORKSPACES_MESSAGE) - const jsonOut = await render('json') - expect(jsonOut).toBe(EMPTY_WORKSPACES_MESSAGE) - }) - - it('rejects unknown -o format', async () => { - await expect(render('csv')).rejects.toThrow(/csv|not supported|format/i) - }) -}) diff --git a/cli/src/commands/get/workspace/run.ts b/cli/src/commands/get/workspace/run.ts deleted file mode 100644 index 0c9de9ca663..00000000000 --- a/cli/src/commands/get/workspace/run.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import { WorkspacesClient } from '@/api/workspaces' -import { runWithSpinner } from '@/sys/io/spinner' -import { nullStreams } from '@/sys/io/streams' -import { WorkspaceListOutput, WorkspaceRow } from './handlers.js' - -export const EMPTY_WORKSPACES_MESSAGE = - 'No workspaces visible to this bearer (external-SSO subjects see empty data).\n' - -export type GetWorkspaceOptions = { - readonly format?: string -} - -export type GetWorkspaceDeps = { - readonly active: ActiveContext - readonly http: HttpClient - readonly io?: IOStreams - readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient -} - -export type GetWorkspaceResult = - | { readonly kind: 'empty'; readonly message: string } - | { readonly kind: 'output'; readonly data: WorkspaceListOutput } - -export async function runGetWorkspace( - opts: GetWorkspaceOptions, - deps: GetWorkspaceDeps, -): Promise { - const wsFactory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h)) - const io = deps.io ?? nullStreams() - const env = await runWithSpinner({ io, label: 'Fetching workspaces' }, () => - wsFactory(deps.http).list(), - ) - if (env.data.length === 0) return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE } - const currentId = deps.active.ctx.workspace?.id ?? '' - return { - kind: 'output', - data: new WorkspaceListOutput( - env.data.map( - (w) => - new WorkspaceRow( - w.id, - w.name, - w.role, - w.status, - w.current || (currentId !== '' && w.id === currentId), - ), - ), - env, - ), - } -} diff --git a/cli/src/commands/import/studio-app/guide.ts b/cli/src/commands/import/studio-app/guide.ts deleted file mode 100644 index 66ecdda9aa5..00000000000 --- a/cli/src/commands/import/studio-app/guide.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const agentGuide = ` -WHEN TO USE - A studio app is what you build and edit in Studio on the web console, - inside a workspace — the app's source definition. Import materialises a - DSL YAML into a new (or existing) studio app; pair it with - 'export studio-app' to move an app between workspaces or instances. To - run or inspect the result, switch to the 'app' noun. - -BEHAVIOUR - A DSL version mismatch is auto-confirmed; no second command needed. - Missing plugin dependencies are listed on stderr — install them before - running the app. - -ERROR RECOVERY - workspace required difyctl get workspace - not logged in (exit 4) difyctl auth login -` diff --git a/cli/src/commands/import/studio-app/index.ts b/cli/src/commands/import/studio-app/index.ts deleted file mode 100644 index 2615322807d..00000000000 --- a/cli/src/commands/import/studio-app/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { Flags } from '@/framework/flags' -import { agentGuide } from './guide' -import { pluginDependencyLabel, runImportApp } from './run' - -export default class ImportStudioApp extends DifyCommand { - static override description = 'Import a studio app from a DSL YAML file or URL' - - static override effect: CommandEffect = 'write' - - static override examples = [ - '<%= config.bin %> import studio-app --from-file ./app.yaml', - '<%= config.bin %> import studio-app --from-file /path/to/app.yaml --name "My App"', - '<%= config.bin %> import studio-app --from-url https://example.com/my-app.yaml', - '<%= config.bin %> import studio-app --from-file ./app.yaml --app-id ', - ] - - static override flags = { - 'from-file': Flags.string({ - description: 'import DSL from a local file (relative or absolute path)', - char: 'f', - }), - 'from-url': Flags.string({ description: 'import DSL from an HTTP(S) URL' }), - workspace: Flags.string({ - description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', - }), - name: Flags.string({ description: 'override the app name from the DSL' }), - description: Flags.string({ description: 'override the app description from the DSL' }), - 'app-id': Flags.string({ - description: 'overwrite an existing app (workflow/advanced-chat only)', - }), - 'icon-type': Flags.string({ description: 'override icon type' }), - icon: Flags.string({ description: 'override icon' }), - 'icon-background': Flags.string({ description: 'override icon background colour' }), - 'http-retry': httpRetryFlag, - } - - async run(argv: string[]) { - const { flags } = this.parse(ImportStudioApp, argv) - if (flags['from-file'] === undefined && flags['from-url'] === undefined) - this.error('one of --from-file or --from-url is required', { exit: 1 }) - if (flags['from-file'] !== undefined && flags['from-url'] !== undefined) - this.error('--from-file and --from-url are mutually exclusive', { exit: 1 }) - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) - const { result, leakedDependencies } = await runImportApp( - { - fromFile: flags['from-file'], - fromUrl: flags['from-url'], - workspace: flags.workspace, - name: flags.name, - description: flags.description, - appId: flags['app-id'], - iconType: flags['icon-type'], - icon: flags.icon, - iconBackground: flags['icon-background'], - }, - { active: ctx.active, http: ctx.http, io: ctx.io }, - ) - - const status = - result.status === 'completed-with-warnings' ? 'completed (with warnings)' : result.status - ctx.io.err.write(`Import ${status}`) - if (result.app_id !== undefined && result.app_id !== null) - ctx.io.err.write(`: app ${result.app_id}`) - ctx.io.err.write('\n') - - if (leakedDependencies.length > 0) { - ctx.io.err.write( - `\nMissing plugin dependencies (${leakedDependencies.length}); install them before using the app:\n`, - ) - for (const dep of leakedDependencies) ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`) - } - } - - override agentGuide(): string { - return agentGuide - } -} diff --git a/cli/src/commands/import/studio-app/run.test.ts b/cli/src/commands/import/studio-app/run.test.ts deleted file mode 100644 index bf29959401c..00000000000 --- a/cli/src/commands/import/studio-app/run.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { Import } from '@dify/contracts/api/openapi/types.gen' -import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { ActiveContext } from '@/auth/hosts' -import { writeFileSync } from 'node:fs' -import os from 'node:os' -import { join } from 'node:path' -import { DSL_YAML } from '@test/fixtures/dify-mock/scenarios' -import { startMock } from '@test/fixtures/dify-mock/server' -import { testHttpClient } from '@test/fixtures/http-client' -import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' -import { AppDslClient } from '@/api/app-dsl' -import { bufferStreams } from '@/sys/io/streams' -import { ZERO } from '@/util/uuid.js' -import { pluginDependencyLabel, runImportApp } from './run.js' - -const WS_ID = 'aaaaaaaa-0000-0000-0000-000000000001' - -const baseActive: ActiveContext = { - host: '127.0.0.1', - email: 'tester@dify.ai', - ctx: { - account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, - workspace: { id: WS_ID, name: 'Default', role: 'owner' }, - }, - scheme: 'http', -} - -describe('runImportApp', () => { - let mock: DifyMock - - beforeEach(async () => { - mock = await startMock({ scenario: 'happy' }) - }) - - afterEach(async () => { - await mock.stop() - }) - - function http() { - return testHttpClient(mock.url, 'dfoa_test') - } - - function tmpDslFile(): string { - const filePath = join(os.tmpdir(), `difyctl-import-test-${Date.now()}.yaml`) - writeFileSync(filePath, DSL_YAML, 'utf8') - return filePath - } - - it('completes import from a local file path', async () => { - const dslFile = tmpDslFile() - const { result } = await runImportApp( - { fromFile: dslFile }, - { active: baseActive, http: http() }, - ) - - expect(result.status).toBe('completed') - expect(result.app_id).toBe('app-1') - }) - - it('sends yaml_content in the request body', async () => { - const dslFile = tmpDslFile() - await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() }) - - expect(mock.lastImportBody?.mode).toBe('yaml-content') - expect(mock.lastImportBody?.yaml_content).toBe(DSL_YAML) - }) - - it('sends yaml_url when given --from-url', async () => { - await runImportApp( - { fromUrl: 'https://example.com/app.yaml' }, - { active: baseActive, http: http() }, - ) - - expect(mock.lastImportBody?.mode).toBe('yaml-url') - expect(mock.lastImportBody?.yaml_url).toBe('https://example.com/app.yaml') - }) - - it('forwards optional name and description overrides', async () => { - const dslFile = tmpDslFile() - await runImportApp( - { fromFile: dslFile, name: 'My App', description: 'desc' }, - { active: baseActive, http: http() }, - ) - - expect(mock.lastImportBody?.name).toBe('My App') - expect(mock.lastImportBody?.description).toBe('desc') - }) - - it('auto-confirms a pending (202) import and emits a note on err stream', async () => { - mock.setScenario('import-pending') - const io = bufferStreams() - const dslFile = tmpDslFile() - - const { result } = await runImportApp( - { fromFile: dslFile }, - { active: baseActive, http: http(), io }, - ) - - expect(result.status).toBe('completed') - expect(io.errBuf()).toContain('confirming automatically') - }) - - it('throws on import-failed (400) response', async () => { - mock.setScenario('import-failed') - const dslFile = tmpDslFile() - - await expect( - runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() }), - ).rejects.toThrow('Import failed') - }) - - it('uses workspace from --workspace flag over context default', async () => { - const dslFile = tmpDslFile() - await runImportApp({ fromFile: dslFile, workspace: ZERO }, { active: baseActive, http: http() }) - - expect(mock.lastImportBody).not.toBeNull() - }) - - it('throws UsageInvalidFlag when fromFile path does not exist', async () => { - await expect( - runImportApp( - { fromFile: '/tmp/difyctl-no-such-file-ever.yaml' }, - { active: baseActive, http: http() }, - ), - ).rejects.toThrow('file not found') - }) - - it('throws UsageInvalidFlag when both fromFile and fromUrl are given', async () => { - const dslFile = tmpDslFile() - await expect( - runImportApp( - { fromFile: dslFile, fromUrl: 'https://example.com/app.yaml' }, - { active: baseActive, http: http() }, - ), - ).rejects.toThrow('mutually exclusive') - }) - - it('throws UsageInvalidFlag when neither fromFile nor fromUrl is given', async () => { - await expect(runImportApp({}, { active: baseActive, http: http() })).rejects.toThrow('required') - }) - - it('returns empty leakedDependencies when the app has no missing plugins', async () => { - const dslFile = tmpDslFile() - const { leakedDependencies } = await runImportApp( - { fromFile: dslFile }, - { active: baseActive, http: http() }, - ) - - expect(leakedDependencies).toEqual([]) - }) - - it('surfaces leaked dependencies reported by check-dependencies', async () => { - const dslFile = tmpDslFile() - const completed: Import = { - id: 'imp-1', - status: 'completed', - app_id: 'app-1', - app_mode: 'chat', - } - const stub = Object.assign(Object.create(AppDslClient.prototype), { - importApp: async () => completed, - confirmImport: async () => completed, - checkDependencies: async () => ({ - leaked_dependencies: [ - { - type: 'marketplace', - value: { marketplace_plugin_unique_identifier: 'langgenius/openai:0.0.1' }, - }, - ], - }), - }) as AppDslClient - - const { leakedDependencies } = await runImportApp( - { fromFile: dslFile }, - { active: baseActive, http: http(), dslFactory: () => stub }, - ) - - expect(leakedDependencies).toHaveLength(1) - const [dep] = leakedDependencies - if (dep === undefined) throw new Error('expected one leaked dependency') - expect(pluginDependencyLabel(dep)).toBe('langgenius/openai:0.0.1') - }) -}) - -describe('pluginDependencyLabel', () => { - it('reads the github plugin identifier', () => { - const label = pluginDependencyLabel({ - type: 'github', - value: { github_plugin_unique_identifier: 'owner/repo:1.0.0' }, - }) - expect(label).toBe('owner/repo:1.0.0') - }) - - it('reads the package plugin identifier', () => { - const label = pluginDependencyLabel({ - type: 'package', - value: { plugin_unique_identifier: 'pkg:2.0.0' }, - }) - expect(label).toBe('pkg:2.0.0') - }) - - it('falls back to current_identifier then a placeholder', () => { - expect( - pluginDependencyLabel({ type: 'package', value: {}, current_identifier: 'fallback' }), - ).toBe('fallback') - expect(pluginDependencyLabel({ type: 'package', value: null })).toBe('') - }) -}) diff --git a/cli/src/commands/import/studio-app/run.ts b/cli/src/commands/import/studio-app/run.ts deleted file mode 100644 index fe0f9b7163d..00000000000 --- a/cli/src/commands/import/studio-app/run.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { Import, PluginDependency } from '@dify/contracts/api/openapi/types.gen' -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import fs from 'node:fs' -import { AppDslClient } from '@/api/app-dsl' -import { newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { getEnv } from '@/sys/index' -import { runWithSpinner } from '@/sys/io/spinner' -import { nullStreams } from '@/sys/io/streams' -import { resolveWorkspaceId } from '@/workspace/resolver' - -export type ImportAppOptions = { - readonly fromFile?: string - readonly fromUrl?: string - readonly workspace?: string - readonly name?: string - readonly description?: string - readonly appId?: string - readonly iconType?: string - readonly icon?: string - readonly iconBackground?: string -} - -export type ImportAppDeps = { - readonly active: ActiveContext - readonly http: HttpClient - readonly io?: IOStreams - readonly envLookup?: (k: string) => string | undefined - readonly dslFactory?: (http: HttpClient) => AppDslClient -} - -export type ImportAppResult = { - readonly result: Import - readonly leakedDependencies: readonly PluginDependency[] -} - -type PluginDependencyLabelInput = { - readonly current_identifier?: string | null - readonly type?: PluginDependency['type'] - readonly value?: unknown -} - -export async function runImportApp( - opts: ImportAppOptions, - deps: ImportAppDeps, -): Promise { - const env = deps.envLookup ?? getEnv - const io = deps.io ?? nullStreams() - const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h)) - - const workspaceId = resolveWorkspaceId({ - flag: opts.workspace, - env: env('DIFY_WORKSPACE_ID'), - active: deps.active, - }) - const client = dslFactory(deps.http) - - if (opts.fromFile !== undefined && opts.fromUrl !== undefined) - throw newError(ErrorCode.UsageInvalidFlag, '--from-file and --from-url are mutually exclusive') - - let mode: 'yaml-content' | 'yaml-url' - let yamlContent: string | undefined - let yamlUrl: string | undefined - - if (opts.fromFile !== undefined) { - mode = 'yaml-content' - try { - yamlContent = fs.readFileSync(opts.fromFile, 'utf8') - } catch (err) { - const code = (err as NodeJS.ErrnoException).code - if (code === 'ENOENT') - throw newError(ErrorCode.UsageInvalidFlag, `--from-file: file not found: ${opts.fromFile}`) - throw err - } - } else if (opts.fromUrl !== undefined) { - mode = 'yaml-url' - yamlUrl = opts.fromUrl - } else { - throw newError(ErrorCode.UsageInvalidFlag, 'one of --from-file or --from-url is required') - } - - let result = await runWithSpinner({ io, label: 'Importing app DSL' }, () => - client.importApp(workspaceId, { - mode, - yaml_content: yamlContent, - yaml_url: yamlUrl, - name: opts.name, - description: opts.description, - app_id: opts.appId, - icon_type: opts.iconType, - icon: opts.icon, - icon_background: opts.iconBackground, - }), - ) - - if (result.status === 'failed') { - throw newError( - ErrorCode.Server4xxOther, - `Import failed: ${result.error !== '' ? result.error : 'unknown error'}`, - ) - } - - // DSL version mismatch: the server needs an explicit acknowledgement before - // finalising. Auto-confirm here so the user does not need a second command. - if (result.status === 'pending') { - io.err.write( - `note: DSL version mismatch (imported ${result.imported_dsl_version ?? '?'}, current ${result.current_dsl_version ?? '?'}); confirming automatically\n`, - ) - result = await runWithSpinner({ io, label: 'Confirming import' }, () => - client.confirmImport(workspaceId, result.id), - ) - } - - if (result.status === 'failed') { - throw newError( - ErrorCode.Server4xxOther, - `Import failed after confirmation: ${result.error !== '' ? result.error : 'unknown error'}`, - ) - } - - const appId = result.app_id - if (appId === undefined || appId === null) return { result, leakedDependencies: [] } - - const { leaked_dependencies } = await runWithSpinner( - { io, label: 'Checking plugin dependencies' }, - () => client.checkDependencies(appId), - ) - - return { result, leakedDependencies: leaked_dependencies ?? [] } -} - -// `value` is a loosely-typed wire object (Github | Marketplace | Package); narrow it here to -// surface a human-readable identifier without depending on which variant the server returned. -export function pluginDependencyLabel(dep: PluginDependencyLabelInput): string { - const value = dep.value - if (typeof value === 'object' && value !== null) { - const fields = value as Record - const id = - fields.marketplace_plugin_unique_identifier ?? - fields.github_plugin_unique_identifier ?? - fields.plugin_unique_identifier - if (typeof id === 'string' && id !== '') return id - } - return dep.current_identifier ?? '' -} diff --git a/cli/src/commands/install/skills/index.test.ts b/cli/src/commands/install/skills/index.test.ts new file mode 100644 index 00000000000..17cf0630cd5 --- /dev/null +++ b/cli/src/commands/install/skills/index.test.ts @@ -0,0 +1,27 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { commands } from '@/plugins/commands' + +const REPO_SKILLS = fileURLToPath(new URL('../../../../../skills', import.meta.url)) + +const worlds: TestWorld[] = [] +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +it('writes the repo skills into the given skills root', async () => { + const seed = await testContext({ login: false }) + worlds.push(seed) + const dir = join(seed.dir, 'target') + const argv = ['install', 'skills', dir, '--from', REPO_SKILLS] + const w = await testContext({ login: false, argv, reuseDirOf: seed }) + worlds.push(w) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + const body = JSON.parse(w.io.outBuf()) as { wrote: string[] } + expect(body.wrote).toContain(join(dir, 'difyctl', 'SKILL.md')) + expect(await readFile(join(dir, 'difyctl', 'SKILL.md'), 'utf8')).toContain('name: difyctl') +}) diff --git a/cli/src/commands/install/skills/index.ts b/cli/src/commands/install/skills/index.ts new file mode 100644 index 00000000000..cb9a62ee6eb --- /dev/null +++ b/cli/src/commands/install/skills/index.ts @@ -0,0 +1,35 @@ +import { resolve } from 'node:path' +import { z } from 'zod' +import { Command } from '@/plugins/commands/command' +import { loadSkills } from '@/skills/collection' +import { installSkills, selectSkills } from '@/skills/install' +import { FROM_FIELD, openSource } from '@/skills/source' + +const INPUT = z.object({ + dir: z + .string() + .min(1) + .describe("The agent's skills root: the folder that holds one subfolder per skill"), + skill: z + .array(z.string()) + .default([]) + .describe('Skill to install (repeatable); default: the whole collection'), + from: FROM_FIELD, +}) + +export default class SkillsInstall extends Command { + static override summary = 'Write skills from the collection into a skills root' + static override effect = 'write' as const + static override input = INPUT + static override positional = ['dir'] as const + static override examples = [ + { title: 'Install every skill for Claude Code', input: { dir: '~/.claude/skills' } }, + { title: 'Install one skill for Codex', input: { dir: '~/.codex/skills', skill: ['difyctl'] } }, + ] + + async run(input: z.infer) { + const source = await openSource(input.from) + const skills = selectSkills(await loadSkills(source), input.skill) + return { wrote: await installSkills(resolve(input.dir), skills, source) } + } +} diff --git a/cli/src/commands/login/index.test.ts b/cli/src/commands/login/index.test.ts new file mode 100644 index 00000000000..46f2bc32966 --- /dev/null +++ b/cli/src/commands/login/index.test.ts @@ -0,0 +1,97 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { commands } from '@/plugins/commands' +import { session } from '@/plugins/session' +import { token } from '@/plugins/token' + +const worlds: TestWorld[] = [] +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +it('logs the previous login out, runs the device flow, saves the pin and the token', async () => { + const seed = await testContext({ login: true }) + worlds.push(seed) + const w = await testContext({ + login: true, + argv: ['login', '--server', seed.mock.url, '--no-browser', '--no-keyring', '--insecure'], + reuseDirOf: seed, + }) + worlds.push(w) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + expect(w.io.errBuf()).toMatch(/logged out me@x from/) + expect(w.io.errBuf()).toMatch(/code ABCD-1234/) + const login = await (await w.ctx.get(session)).current() + expect(login?.workspaceId).toBe('ws-1') + expect(login?.tokenStorage).toBe('file') + expect(await (await w.ctx.get(token)).get()).toBe('dfoa_test') + expect(JSON.parse(w.io.outBuf())).toMatchObject({ server: w.mock.url, workspace_id: 'ws-1' }) +}) + +it('refuses under an env login', async () => { + const seed = await testContext({ login: false, env: true }) + worlds.push(seed) + const w = await testContext({ + login: false, + env: true, + argv: ['login', '--server', seed.mock.url], + reuseDirOf: seed, + }) + worlds.push(w) + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'usage_invalid_flag', + message: 'unset DIFY_TOKEN to log in interactively', + }) +}) + +it('checks the verification uri before inviting the operator to open it', async () => { + const seed = await testContext({ login: false }) + worlds.push(seed) + const w = await testContext({ + login: false, + argv: ['login', '--server', seed.mock.url, '--no-browser', '--no-keyring', '--insecure'], + reuseDirOf: seed, + }) + worlds.push(w) + w.mock.setScenario('bad-verification-uri') + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'unknown', + message: expect.stringContaining('unsupported scheme "ftp"'), + }) + expect(w.io.errBuf()).not.toMatch(/open /) + expect(w.io.errBuf()).not.toMatch(/code ABCD-1234/) +}) + +it('rejects a login response with no usable email and saves nothing', async () => { + const seed = await testContext({ login: false }) + worlds.push(seed) + const w = await testContext({ + login: false, + argv: ['login', '--server', seed.mock.url, '--no-browser', '--no-keyring', '--insecure'], + reuseDirOf: seed, + }) + worlds.push(w) + w.mock.setScenario('no-email') + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'server_error', + message: 'login response carries no account or subject email', + }) + expect(await (await w.ctx.get(session)).current()).toBeNull() +}) + +it('falls back to the external-SSO subject email when there is no account', async () => { + const seed = await testContext({ login: false }) + worlds.push(seed) + const w = await testContext({ + login: false, + argv: ['login', '--server', seed.mock.url, '--no-browser', '--no-keyring', '--insecure'], + reuseDirOf: seed, + }) + worlds.push(w) + w.mock.setScenario('sso') + expect(await (await w.ctx.get(commands)).run()).toBe(0) + const login = await (await w.ctx.get(session)).current() + expect(login?.email).toBe('sso@dify.ai') + expect(login?.account).toBeNull() +}) diff --git a/cli/src/commands/login/index.ts b/cli/src/commands/login/index.ts new file mode 100644 index 00000000000..f061a734177 --- /dev/null +++ b/cli/src/commands/login/index.ts @@ -0,0 +1,189 @@ +import type { PollSuccess } from '@/auth/device-api' +import type { CommandContext } from '@/plugins/base' +import { hostname } from 'node:os' +import { join } from 'node:path' +import { z } from 'zod' +import { deviceApi } from '@/auth/device-api' +import { awaitAuthorization, pollAuthorization, realClock } from '@/auth/device-flow' +import { assertNotEnvLogin, revokeAndClearSession } from '@/auth/logout' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { Command } from '@/plugins/commands/command' +import { env } from '@/plugins/env' +import { io } from '@/plugins/io' +import { session } from '@/plugins/session' +import { token } from '@/plugins/token' +import { YamlStore } from '@/store/store' +import { decideOpen, OpenDecision, openUrl, realEnv } from '@/util/browser' +import { DEFAULT_HOST, resolveHost, validateVerificationURI } from '@/util/host' + +const INPUT = z.object({ + server: z.string().default(DEFAULT_HOST).describe('Dify server base URL'), + no_browser: z + .boolean() + .default(false) + .describe('Do not open a browser; print the URL and code for the user'), + no_keyring: z + .boolean() + .default(false) + .describe( + 'Keep the token in tokens.yml under the config dir instead of the OS keyring. Use in a sandbox, with DIFY_CONFIG_DIR on persistent storage', + ), + insecure: z + .boolean() + .default(false) + .describe('Skip TLS verification and allow http:// (local dev only)'), + no_wait: z + .boolean() + .default(false) + .describe('Print the URL and code, save the pending login, and exit without waiting'), + resume: z + .boolean() + .default(false) + .describe( + 'Check a login started with --no-wait once. Prints status pending until the user approves', + ), +}) + +const PENDING_FILE_NAME = 'login-pending.yml' +const PENDING_STATUS = 'pending' +const PENDING_SCHEMA = z.object({ + server: z.string(), + insecure: z.boolean(), + no_keyring: z.boolean(), + device_code: z.string(), +}) +type Pending = z.infer +const NO_PENDING_MESSAGE = 'no pending login; start one with login --no-wait' +const FINAL_POLL_ERRORS: readonly string[] = [ErrorCode.AuthExpired, ErrorCode.AccessDenied] + +const ENV_LOGIN_MESSAGE = 'unset DIFY_TOKEN to log in interactively' +const NO_EMAIL_MESSAGE = 'login response carries no account or subject email' + +// A present-but-empty email is the same as absent: neither account.email nor +// subject_email is meaningful until it has a value. +function meaningfulEmail(value: string | undefined): string | undefined { + return value === undefined || value === '' ? undefined : value +} + +export default class Login extends Command { + static override summary = + 'Log in via the OAuth device flow. Blocks until approval: run it in the background, never cancel it' + static override effect = 'write' as const + static override input = INPUT + static override examples = [ + { + title: 'Agent: run in the background, relay the url and code to the user, do not cancel', + input: { server: 'https://dify.example.com', no_browser: true }, + }, + { + title: 'Agent in a sandbox: add --no-keyring and point DIFY_CONFIG_DIR at persistent storage', + input: { server: 'https://dify.example.com', no_browser: true, no_keyring: true }, + }, + { + title: 'Agent that cannot keep a process alive: start without waiting', + input: { server: 'https://dify.example.com', no_browser: true, no_wait: true }, + }, + { + title: 'Then, after the user approves, finish the login (repeat while status is pending)', + input: { resume: true }, + }, + ] + + async run(input: z.infer, ctx: CommandContext) { + const sessionService = await ctx.get(session) + assertNotEnvLogin(sessionService.fromEnv, ENV_LOGIN_MESSAGE) + + const { configDir } = await ctx.get(env) + const pendingStore = new YamlStore(join(configDir, PENDING_FILE_NAME)) + if (input.resume) return resume(ctx, pendingStore) + + const server = resolveHost({ raw: input.server, insecure: input.insecure }) + const streams = await ctx.get(io) + + const current = await sessionService.current() + if (current !== null) { + await revokeAndClearSession(ctx, current) + streams.notice(`logged out ${current.email} from ${current.server}`) + } + + const api = deviceApi(server, { insecure: input.insecure }) + const code = await api.requestCode({ device_label: hostname() }) + // Checked before it is printed: a URI difyctl would refuse to open is one the + // operator should not be invited to paste either. + validateVerificationURI(code.verification_uri, input.insecure) + streams.notice(`open ${code.verification_uri}`) + streams.notice(`code ${code.user_code}`) + + const decision = decideOpen(realEnv(), input.no_browser) + if (decision === OpenDecision.Auto) await openUrl(code.verification_uri) + + const pending: Pending = { + server, + insecure: input.insecure, + no_keyring: input.no_keyring, + device_code: code.device_code, + } + if (input.no_wait) { + await pendingStore.setTyped(pending) + return { + status: PENDING_STATUS, + verification_uri: code.verification_uri, + user_code: code.user_code, + expires_in: code.expires_in, + } + } + + const success = await awaitAuthorization(api, code, { clock: realClock() }) + return finish(ctx, pending, success) + } +} + +async function resume(ctx: CommandContext, pendingStore: YamlStore) { + const parsed = PENDING_SCHEMA.safeParse(await pendingStore.getTyped()) + if (!parsed.success) { + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: NO_PENDING_MESSAGE }) + } + const pending = parsed.data + const api = deviceApi(pending.server, { insecure: pending.insecure }) + let success: PollSuccess | undefined + try { + success = await pollAuthorization(api, pending.device_code, { clock: realClock() }) + } catch (err) { + if (err instanceof BaseError && FINAL_POLL_ERRORS.includes(err.code)) await pendingStore.rm() + throw err + } + if (success === undefined) return { status: PENDING_STATUS } + const result = await finish(ctx, pending, success) + await pendingStore.rm() + return result +} + +async function finish(ctx: CommandContext, pending: Pending, success: PollSuccess) { + const sessionService = await ctx.get(session) + const tokenService = await ctx.get(token) + const tokenStorage = await tokenService.detect({ skipKeyring: pending.no_keyring }) + const email = meaningfulEmail(success.account?.email) ?? meaningfulEmail(success.subject_email) + if (email === undefined) { + throw new BaseError({ code: ErrorCode.ServerError, message: NO_EMAIL_MESSAGE }) + } + + const login = { + server: pending.server, + email, + account: success.account ?? null, + workspaceId: success.default_workspace_id ?? null, + tokenStorage, + insecure: pending.insecure, + } + + await sessionService.save(login) + await tokenService.write(login, success.token) + + return { + server: pending.server, + email, + account: login.account, + workspace_id: login.workspaceId, + } +} diff --git a/cli/src/commands/logout/index.test.ts b/cli/src/commands/logout/index.test.ts new file mode 100644 index 00000000000..7f33606f9de --- /dev/null +++ b/cli/src/commands/logout/index.test.ts @@ -0,0 +1,56 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import { existsSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { Context } from '@/kernel/context' +import { commands } from '@/plugins/commands' +import { session } from '@/plugins/session' +import { token } from '@/plugins/token' + +const worlds: TestWorld[] = [] +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +function catalogFiles(dir: string): string[] { + const catalogDir = join(dir, 'catalog') + return existsSync(catalogDir) ? readdirSync(catalogDir) : [] +} + +it('revokes the session on the server and clears local state', async () => { + const w = await testContext({ login: true, argv: ['logout'] }) + worlds.push(w) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(w.io.outBuf())).toEqual({ logged_out: true, server: w.mock.url }) + expect(w.io.errBuf()).toBe('') + + expect(await (await w.ctx.get(session)).current()).toBeNull() + expect(catalogFiles(w.dir)).toEqual([]) + await expect((await new Context().get(token)).get()).rejects.toMatchObject({ + code: 'not_logged_in', + }) +}) + +it('still clears local state and reports a failed server revoke', async () => { + const w = await testContext({ login: true, argv: ['logout'] }) + worlds.push(w) + w.mock.setScenario('server-5xx') + expect(await (await w.ctx.get(commands)).run()).toBe(0) + expect(w.io.errBuf()).toMatch(/^server revoke failed:/) + + expect(await (await w.ctx.get(session)).current()).toBeNull() + expect(catalogFiles(w.dir)).toEqual([]) + await expect((await new Context().get(token)).get()).rejects.toMatchObject({ + code: 'not_logged_in', + }) +}) + +it('refuses under an env login', async () => { + const w = await testContext({ login: false, env: true, argv: ['logout'] }) + worlds.push(w) + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'usage_invalid_flag', + message: 'nothing to log out: the login comes from DIFY_TOKEN', + }) +}) diff --git a/cli/src/commands/logout/index.ts b/cli/src/commands/logout/index.ts new file mode 100644 index 00000000000..74d8691524b --- /dev/null +++ b/cli/src/commands/logout/index.ts @@ -0,0 +1,25 @@ +import type { CommandContext } from '@/plugins/base' +import { z } from 'zod' +import { assertNotEnvLogin, revokeAndClearSession } from '@/auth/logout' +import { Command } from '@/plugins/commands/command' +import { session } from '@/plugins/session' + +const INPUT = z.object({}) + +const ENV_LOGOUT_MESSAGE = 'nothing to log out: the login comes from DIFY_TOKEN' + +export default class Logout extends Command { + static override summary = 'Log out of the current Dify session' + static override effect = 'write' as const + static override input = INPUT + + async run(_input: z.infer, ctx: CommandContext) { + const sessionService = await ctx.get(session) + const login = await sessionService.require() + assertNotEnvLogin(sessionService.fromEnv, ENV_LOGOUT_MESSAGE) + + await revokeAndClearSession(ctx, login) + + return { logged_out: true, server: login.server } + } +} diff --git a/cli/src/commands/refresh/cache/index.test.ts b/cli/src/commands/refresh/cache/index.test.ts new file mode 100644 index 00000000000..fef5bddef47 --- /dev/null +++ b/cli/src/commands/refresh/cache/index.test.ts @@ -0,0 +1,25 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import { createHash } from 'node:crypto' +import { readFileSync } 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 FIXTURE = readFileSync(join(__dirname, '../../../../test/fixtures/catalog.json')) +const FINGERPRINT = createHash('sha256').update(FIXTURE).digest('hex') + +const worlds: TestWorld[] = [] +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +it('writes the catalog file and reports the op count and fingerprint', async () => { + const w = await testContext({ login: true, argv: ['refresh', 'cache'] }) + worlds.push(w) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + const body = JSON.parse(w.io.outBuf()) as { ops: number; fingerprint: string; path: string } + expect(body.fingerprint).toBe(FINGERPRINT) + expect(body.ops).toBeGreaterThan(20) + expect(readFileSync(body.path)).toEqual(FIXTURE) +}) diff --git a/cli/src/commands/refresh/cache/index.ts b/cli/src/commands/refresh/cache/index.ts new file mode 100644 index 00000000000..774f4994a68 --- /dev/null +++ b/cli/src/commands/refresh/cache/index.ts @@ -0,0 +1,25 @@ +import type { CommandContext } from '@/plugins/base' +import { z } from 'zod' +import { catalog } from '@/plugins/catalog' +import { Command } from '@/plugins/commands/command' +import { http } from '@/plugins/http' + +const INPUT = z.object({}) + +export default class CacheRefresh extends Command { + static override summary = 'Refetch the server catalog and replace the local cache' + static override effect = 'write' as const + static override input = INPUT + + async run(_input: z.infer, ctx: CommandContext) { + const httpService = await ctx.get(http) + const catalogService = await ctx.get(catalog) + await catalogService.replace(await httpService.fetchCatalog()) + + return { + ops: Object.keys(catalogService.ops()).length, + fingerprint: catalogService.fingerprint, + path: catalogService.path, + } + } +} diff --git a/cli/src/commands/resume/app/guide.ts b/cli/src/commands/resume/app/guide.ts deleted file mode 100644 index 1483d40b9d4..00000000000 --- a/cli/src/commands/resume/app/guide.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const agentGuide = ` -WHEN TO USE - Continue a workflow that paused for human input. run app (or a prior - resume app) exits 0 and prints a JSON object with status "paused", - form_token, workflow_run_id and resolved_default_values. Resume with: - difyctl resume app --workflow-run-id \\ - --inputs '{"name":"Alice"}' -o json - -LOOP - A resume can pause again (exit 0 with a new form_token and status - "paused"). Repeat until the output is no longer a pause. Pass --stream - to print events live. - -ERROR RECOVERY - not logged in (exit 4) difyctl auth login - stale form/run id re-run the app to get a fresh pause token -` diff --git a/cli/src/commands/resume/app/index.ts b/cli/src/commands/resume/app/index.ts deleted file mode 100644 index e697a2d070d..00000000000 --- a/cli/src/commands/resume/app/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { Args, Flags } from '@/framework/flags' -import { OutputFormat } from '@/framework/output' -import { agentGuide } from './guide' -import { resumeApp } from './run' - -export default class ResumeApp extends DifyCommand { - static override description = 'Resume a paused workflow app after submitting a human input form' - - static override effect: CommandEffect = 'write' - - static override examples = [ - '<%= config.bin %> resume app app-1 ft-abc --workflow-run-id wf-run-1 --action submit --inputs \'{"name":"Alice"}\'', - '<%= config.bin %> resume app app-1 ft-abc --workflow-run-id wf-run-1 --inputs-file form.json', - ] - - static override args = { - id: Args.string({ description: 'app id', required: true }), - formToken: Args.string({ description: 'form token from the HITL pause JSON', required: true }), - } - - static override flags = { - 'workflow-run-id': Flags.string({ - description: 'workflow_run_id from the HITL pause JSON', - required: true, - }), - action: Flags.string({ - description: 'user action id (auto-selected when form has exactly one action)', - }), - inputs: Flags.string({ - description: - 'Input variables as a JSON object, e.g. --inputs \'{"key":"value"}\'. Mutually exclusive with --inputs-file.', - }), - 'inputs-file': Flags.string({ - description: - 'Path to a JSON file containing the inputs object. Mutually exclusive with --inputs.', - }), - workspace: Flags.string({ description: 'workspace id override' }), - 'with-history': Flags.boolean({ - description: 'Replay executed-node history before attaching to live stream.', - default: false, - }), - stream: Flags.boolean({ - description: 'Print output live as tokens/events arrive. Default: collect and print at end.', - default: false, - }), - think: Flags.boolean({ - description: - 'Show model thinking/reasoning when available — both inline ... blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', - default: false, - }), - output: Flags.outputFormat({ - options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], - default: '', - }), - 'http-retry': httpRetryFlag, - } - - async run(argv: string[]): Promise { - const { args, flags } = this.parse(ResumeApp, argv) - const format = flags.output - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], withCache: true, format }) - - await resumeApp( - { - appId: args.id, - formToken: args.formToken, - workflowRunId: flags['workflow-run-id'], - action: flags.action, - inputsJson: flags.inputs, - inputsFile: flags['inputs-file'], - format, - workspace: flags.workspace, - withHistory: flags['with-history'], - stream: flags.stream, - think: flags.think, - }, - { active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, - ) - } - - override agentGuide(): string { - return agentGuide - } -} diff --git a/cli/src/commands/resume/app/run.test.ts b/cli/src/commands/resume/app/run.test.ts deleted file mode 100644 index 5907ce9025f..00000000000 --- a/cli/src/commands/resume/app/run.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import { afterEach, describe, expect, it, vi } from 'vite-plus/test' -import { AppRunClient } from '@/api/app-run' -import { AppsClient } from '@/api/apps' -import { PermittedExternalAppsClient } from '@/api/permitted-external-apps' -import { bufferStreams } from '@/sys/io/streams' -import { resumeApp } from './run.js' - -const DESCRIBE_RESULT = { - info: { - id: 'app-2', - name: 'X', - mode: 'workflow', - description: '', - updated_at: null, - service_api_enabled: true, - is_agent: false, - }, - parameters: null, - input_schema: null, -} - -const FORM_RESP = { user_actions: [{ id: 'submit' }] } - -function makeExternalActive(): ActiveContext { - return { - host: 'http://localhost', - email: 'sso@x.io', - ctx: { - account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' }, - external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' }, - }, - } as unknown as ActiveContext -} - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('resumeApp pre-flight subject strategy', () => { - it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => { - const externalDescribe = vi.fn().mockResolvedValue(DESCRIBE_RESULT) - const externalSpy = vi - .spyOn(PermittedExternalAppsClient.prototype, 'describe') - .mockImplementation(externalDescribe) - const accountSpy = vi.spyOn(AppsClient.prototype, 'describe') - - vi.spyOn(AppRunClient.prototype, 'submitHumanInput').mockResolvedValue(undefined as never) - - const io = bufferStreams() - const http = { - baseURL: 'http://localhost', - request: vi.fn().mockImplementation((opts: { path: string }) => { - if (typeof opts.path === 'string' && opts.path.includes('human-input-forms')) { - return Promise.resolve(FORM_RESP) - } - // reconnect stream — return an async iterable that ends immediately - const iter: AsyncIterable = { - [Symbol.asyncIterator]: () => ({ - next: () => Promise.resolve({ done: true, value: undefined as never }), - }), - } - return Promise.resolve(iter) - }), - } as unknown as import('@/http/types').HttpClient - - try { - await resumeApp( - { - appId: 'app-2', - formToken: 'ft-1', - workflowRunId: 'wf-run-1', - action: 'submit', - inputs: {}, - }, - { active: makeExternalActive(), http, host: 'http://localhost', io }, - ) - } catch { - // run may fail after pre-flight due to stream mock; we only check which describe was called - } - - expect(externalSpy).toHaveBeenCalled() - expect(accountSpy).not.toHaveBeenCalled() - }) -}) diff --git a/cli/src/commands/resume/app/run.ts b/cli/src/commands/resume/app/run.ts deleted file mode 100644 index eda64152891..00000000000 --- a/cli/src/commands/resume/app/run.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { AppInfoCache } from '@/cache/app-info' -import type { RunContext } from '@/commands/run/app/_strategies/index' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import { AppMetaClient } from '@/api/app-meta' -import { selectAppReader } from '@/api/app-reader' -import { AppRunClient } from '@/api/app-run' -import { pickStrategy } from '@/commands/run/app/_strategies/index' -import { RUN_MODES } from '@/commands/run/app/handlers' -import { resolveInputs, TEXT_FORMATS } from '@/commands/run/app/input-flags' -import { processExit } from '@/sys/index' -import { colorEnabled, colorScheme } from '@/sys/io/color' -import { FieldInfo } from '@/types/app-meta' - -export type ResumeAppOptions = { - readonly appId: string - readonly formToken: string - readonly workflowRunId: string - readonly action?: string - readonly inputs?: Readonly> - readonly inputsJson?: string - readonly inputsFile?: string - readonly format?: string - readonly workspace?: string - readonly withHistory?: boolean - readonly stream?: boolean - readonly think?: boolean -} - -export type ResumeAppDeps = { - readonly active: ActiveContext - readonly http: HttpClient - readonly host: string - readonly io: IOStreams - readonly cache?: AppInfoCache - readonly envLookup?: (k: string) => string | undefined - readonly exit?: (code: number) => never -} - -export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise { - const apps = selectAppReader(deps.active, deps.http) - const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) - const m = await meta.get(opts.appId, [FieldInfo]) - const mode = m.info?.mode ?? RUN_MODES.Workflow - - const runClient = new AppRunClient(deps.http) - const exit = deps.exit ?? processExit - - let action = opts.action - if (action === undefined) { - const formResp = await deps.http.get<{ user_actions: { id: string }[] }>( - `apps/${encodeURIComponent(opts.appId)}/human-input-forms/${encodeURIComponent(opts.formToken)}`, - ) - if (formResp.user_actions.length === 1) { - action = formResp.user_actions[0]?.id ?? '' - } else if (formResp.user_actions.length === 0) { - action = '' - } else { - throw new Error('--action required: form has multiple user actions') - } - } - - const inputs = await resolveInputs(opts.inputsJson, opts.inputsFile, opts.inputs) - await runClient.submitHumanInput(opts.appId, opts.formToken, action, inputs) - - const format = opts.format ?? '' - const isText = TEXT_FORMATS.has(format) - - if (isText) { - const cs = colorScheme(colorEnabled(deps.io.isErrTTY)) - deps.io.err.write(`${cs.successIcon()} ${cs.bold('form submitted')}\n`) - deps.io.err.write(` ${cs.dim('workflow execution resumed')}\n`) - } - const livePrint = opts.stream === true - - const adaptedRunClient = { - runStream: (_appId: string, _body: unknown, streamOpts?: { signal?: AbortSignal }) => - runClient.reconnectStream(opts.appId, opts.workflowRunId, { - signal: streamOpts?.signal, - includeStateSnapshot: opts.withHistory === true, - }), - stopTask: (appId: string, taskId: string) => runClient.stopTask(appId, taskId), - submitHumanInput: runClient.submitHumanInput.bind(runClient), - reconnectStream: runClient.reconnectStream.bind(runClient), - } - - const runCtx: RunContext = { - opts: { - appId: opts.appId, - inputs: inputs as Record, - conversationId: undefined, - workflowId: undefined, - workspace: opts.workspace, - format, - stream: opts.stream, - think: opts.think, - }, - deps, - mode, - format, - isText, - livePrint, - runClient: adaptedRunClient as unknown as AppRunClient, - exit, - think: opts.think ?? false, - } - - await pickStrategy(isText, livePrint).execute(runCtx) - - if (isText) { - const cs = colorScheme(colorEnabled(deps.io.isErrTTY)) - deps.io.err.write(`${cs.successIcon()} ${cs.bold('workflow finished')}\n`) - } -} diff --git a/cli/src/commands/run/app/_strategies/index.ts b/cli/src/commands/run/app/_strategies/index.ts deleted file mode 100644 index f9a1f4a5446..00000000000 --- a/cli/src/commands/run/app/_strategies/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { AppRunClient } from '@/api/app-run' -import type { RunAppDeps, RunAppOptions } from '@/commands/run/app/run' -import { StreamingStructuredStrategy } from './streaming-structured' -import { StreamingTextStrategy } from './streaming-text' - -export type RunContext = { - readonly opts: RunAppOptions & { inputs: Record } - readonly deps: RunAppDeps - readonly mode: string - readonly format: string - readonly isText: boolean - readonly livePrint: boolean - readonly runClient: AppRunClient - readonly exit: (code: number) => never - readonly think: boolean -} - -export type RunStrategy = { - execute: (ctx: RunContext) => Promise -} - -const streamingText = new StreamingTextStrategy() -const streamingStructured = new StreamingStructuredStrategy() - -export function pickStrategy(isText: boolean, livePrint: boolean): RunStrategy { - return isText && livePrint ? streamingText : streamingStructured -} diff --git a/cli/src/commands/run/app/_strategies/streaming-structured.ts b/cli/src/commands/run/app/_strategies/streaming-structured.ts deleted file mode 100644 index ba85ff9e090..00000000000 --- a/cli/src/commands/run/app/_strategies/streaming-structured.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { RunContext, RunStrategy } from './index' -import type { SseEvent } from '@/http/sse' -import { buildRunBody } from '@/api/app-run' -import { - CHAT_MODES, - chatConversationHint, - newAppRunObject, - RUN_MODES, -} from '@/commands/run/app/handlers' -import { renderHitlHint, renderHitlOutput } from '@/commands/run/app/hitl-render' -import { collect, HitlPauseError } from '@/commands/run/app/sse-collector' -import { formatted, stringifyOutput } from '@/framework/output' -import { handle, unhandle } from '@/sys/index' -import { colorEnabled, colorScheme } from '@/sys/io/color' -import { reasoningBlocksFromMetadata } from '@/sys/io/reasoning' -import { startSpinner } from '@/sys/io/spinner' -import { extractThinkBlocks, filterThinkInOutputs, stripThinkBlocks } from '@/sys/io/think-filter' - -async function* captureTaskId( - iter: AsyncIterable, - onCapture: (id: string) => void, -): AsyncIterable { - const dec = new TextDecoder() - for await (const ev of iter) { - if (ev.data.byteLength > 0) { - try { - const parsed = JSON.parse(dec.decode(ev.data)) as Record - if (typeof parsed.task_id === 'string' && parsed.task_id !== '') onCapture(parsed.task_id) - } catch { - /* ignore parse errors */ - } - } - yield ev - } -} - -export class StreamingStructuredStrategy implements RunStrategy { - async execute(ctx: RunContext): Promise { - const { opts, deps, mode, format, isText, exit } = ctx - const ctrl = new AbortController() - const body = buildRunBody({ - message: opts.message, - inputs: opts.inputs as Record, - conversationId: opts.conversationId, - workspaceId: opts.workspace, - workflowId: opts.workflowId, - }) - - const spinner = startSpinner({ - io: deps.io, - label: 'running', - enabled: ctx.isText && !ctx.livePrint, - }) - - let taskId: string | undefined - const cleanup = () => { - spinner.stop() - if (taskId !== undefined) void ctx.runClient.stopTask(opts.appId, taskId).catch(() => {}) - ctrl.abort() - exit(1) - } - handle('SIGINT', cleanup) - - let resp: Record - try { - const events = await ctx.runClient.runStream(opts.appId, body, { - signal: ctrl.signal, - retryOnRateLimit: opts.retryOnRateLimit, - }) - const wrappedEvents = captureTaskId(events, (id) => { - taskId = id - }) - resp = await collect(wrappedEvents, mode) - } catch (err) { - ctrl.abort() - if (err instanceof HitlPauseError) { - spinner.stop() - deps.io.out.write(renderHitlOutput(opts.appId, err.pausePayload, isText, deps.io.isOutTTY)) - deps.io.err.write(renderHitlHint(opts.appId, err.pausePayload, deps.io.isErrTTY)) - exit(0) - } - throw err - } finally { - spinner.stop() - unhandle('SIGINT', cleanup) - } - let processedResp = resp - if (typeof processedResp.answer === 'string') { - if (ctx.think) { - const { clean, thinking } = extractThinkBlocks(processedResp.answer) - if (thinking !== '') deps.io.err.write(`${thinking}\n`) - processedResp = { ...processedResp, answer: clean } - } else { - processedResp = { ...processedResp, answer: stripThinkBlocks(processedResp.answer) } - } - } else if (mode === RUN_MODES.Workflow) { - const data = processedResp.data - if (data !== null && typeof data === 'object' && 'outputs' in data) { - const raw = (data as { outputs: unknown }).outputs - if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) { - const { outputs, thinking } = filterThinkInOutputs( - raw as Record, - ctx.think, - ) - if (ctx.think && thinking !== '') deps.io.err.write(`${thinking}\n`) - processedResp = { - ...processedResp, - data: { ...(data as Record), outputs }, - } - } - } - } - - // Surface separated-mode reasoning (carried in message_end metadata) to stderr under --think. - if (ctx.think) { - const reasoningBlocks = reasoningBlocksFromMetadata(processedResp.metadata) - if (reasoningBlocks !== '') deps.io.err.write(`${reasoningBlocks}\n`) - } - - const respMode = - typeof processedResp.mode === 'string' && processedResp.mode !== '' - ? processedResp.mode - : mode - deps.io.out.write( - stringifyOutput(formatted({ format, data: newAppRunObject(respMode, processedResp) })), - ) - if (isText && CHAT_MODES.has(respMode)) { - const cs = colorScheme(colorEnabled(deps.io.isErrTTY)) - const hint = chatConversationHint(processedResp, cs) - if (hint !== undefined) deps.io.err.write(hint) - } - } -} diff --git a/cli/src/commands/run/app/_strategies/streaming-text.ts b/cli/src/commands/run/app/_strategies/streaming-text.ts deleted file mode 100644 index c5e7c7bc15a..00000000000 --- a/cli/src/commands/run/app/_strategies/streaming-text.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { RunContext, RunStrategy } from './index' -import { buildRunBody } from '@/api/app-run' -import { renderHitlHint, renderHitlOutput } from '@/commands/run/app/hitl-render' -import { decodeStreamError, HitlPauseError } from '@/commands/run/app/sse-collector' -import { streamPrinterFor } from '@/commands/run/app/stream-handlers' -import { handle, unhandle } from '@/sys/index' - -export class StreamingTextStrategy implements RunStrategy { - async execute(ctx: RunContext): Promise { - const { opts, deps, mode, exit } = ctx - const ctrl = new AbortController() - const body = buildRunBody({ - message: opts.message, - inputs: opts.inputs as Record, - conversationId: opts.conversationId, - workspaceId: opts.workspace, - workflowId: opts.workflowId, - }) - - let taskId: string | undefined - const cleanup = () => { - if (taskId !== undefined) void ctx.runClient.stopTask(opts.appId, taskId).catch(() => {}) - ctrl.abort() - exit(1) - } - - handle('SIGINT', cleanup) - - try { - const events = await ctx.runClient.runStream(opts.appId, body, { - signal: ctrl.signal, - retryOnRateLimit: opts.retryOnRateLimit, - }) - const sp = streamPrinterFor(mode, ctx.think, deps.io.isErrTTY) - const dec = new TextDecoder() - for await (const ev of events) { - if (ev.name === 'ping') continue - if (ev.name === 'error') throw decodeStreamError(ev.data) - if (ev.data.byteLength > 0) { - try { - const parsed = JSON.parse(dec.decode(ev.data)) as Record - if (typeof parsed.task_id === 'string' && parsed.task_id !== '' && taskId === undefined) - taskId = parsed.task_id - } catch { - /* ignore */ - } - } - try { - sp.onEvent(deps.io.out, deps.io.err, ev) - } catch (err) { - if (err instanceof HitlPauseError) { - deps.io.out.write( - renderHitlOutput(opts.appId, err.pausePayload, ctx.isText, deps.io.isOutTTY), - ) - deps.io.err.write(renderHitlHint(opts.appId, err.pausePayload, deps.io.isErrTTY)) - exit(0) - } - throw err - } - } - sp.onEnd(deps.io.out, deps.io.err) - } catch (err) { - ctrl.abort() - throw err - } finally { - unhandle('SIGINT', cleanup) - } - } -} diff --git a/cli/src/commands/run/app/agent-guide.test.ts b/cli/src/commands/run/app/agent-guide.test.ts deleted file mode 100644 index 5d4534adf76..00000000000 --- a/cli/src/commands/run/app/agent-guide.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import RunApp from './index' - -describe('run app agentGuide', () => { - it('exposes non-empty agentGuide string', () => { - const guide = new RunApp().agentGuide() - expect(typeof guide).toBe('string') - expect(guide.length).toBeGreaterThan(0) - }) - - it('agentGuide mentions WORKFLOW section', () => { - const guide = new RunApp().agentGuide() - expect(guide).toContain('WORKFLOW') - }) - - it('agentGuide mentions ERROR RECOVERY section', () => { - const guide = new RunApp().agentGuide() - expect(guide).toContain('ERROR RECOVERY') - }) -}) diff --git a/cli/src/commands/run/app/file-flags.test.ts b/cli/src/commands/run/app/file-flags.test.ts deleted file mode 100644 index 7515e82002b..00000000000 --- a/cli/src/commands/run/app/file-flags.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { ParsedFileFlag } from './file-flags' -import { describe, expect, it, vi } from 'vite-plus/test' -import { difyFileType, parseFileFlag, resolveFileInputs } from './file-flags' - -describe('parseFileFlag', () => { - it('parses local file with @ prefix', () => { - expect(parseFileFlag('doc=@/tmp/report.pdf')).toEqual({ - varname: 'doc', - kind: 'local', - path: '/tmp/report.pdf', - }) - }) - - it('parses remote https URL', () => { - expect(parseFileFlag('img=https://cdn.example.com/logo.png')).toEqual({ - varname: 'img', - kind: 'remote', - url: 'https://cdn.example.com/logo.png', - }) - }) - - it('parses remote http URL', () => { - expect(parseFileFlag('f=http://host/a.pdf')).toEqual({ - varname: 'f', - kind: 'remote', - url: 'http://host/a.pdf', - }) - }) - - it('throws on missing = separator', () => { - expect(() => parseFileFlag('noequalssign')).toThrow( - '--file must be key=@path or key=https://url', - ) - }) - - it('throws on value that is neither @ nor URL', () => { - expect(() => parseFileFlag('doc=justaplainstring')).toThrow( - '--file value must start with @ (local file) or http(s):// (remote URL)', - ) - }) - - it('throws on empty varname', () => { - expect(() => parseFileFlag('=@/path')).toThrow('--file varname must not be empty') - }) -}) - -describe('difyFileType', () => { - it('detects image', () => { - expect(difyFileType('photo.jpg')).toBe('image') - expect(difyFileType('logo.PNG')).toBe('image') - expect(difyFileType('icon.svg')).toBe('image') - }) - - it('detects audio', () => { - expect(difyFileType('clip.mp3')).toBe('audio') - expect(difyFileType('sound.WAV')).toBe('audio') - }) - - it('detects video', () => { - expect(difyFileType('video.mp4')).toBe('video') - expect(difyFileType('clip.MOV')).toBe('video') - }) - - it('returns document for known doc extensions', () => { - expect(difyFileType('report.pdf')).toBe('document') - expect(difyFileType('notes.md')).toBe('document') - }) - - it('returns document for unknown extension', () => { - expect(difyFileType('data.xyz')).toBe('document') - }) - - it('returns custom for no extension', () => { - expect(difyFileType('noext')).toBe('custom') - }) -}) - -describe('resolveFileInputs', () => { - it('remote URL: injects remote_url object without calling upload', async () => { - const upload = vi.fn() - const result = await resolveFileInputs('app-1', ['doc=https://example.com/report.pdf'], upload) - expect(upload).not.toHaveBeenCalled() - expect(result).toEqual({ - doc: { - type: 'document', - transfer_method: 'remote_url', - url: 'https://example.com/report.pdf', - }, - }) - }) - - it('remote URL with query string: extracts correct extension', async () => { - const upload = vi.fn() - const result = await resolveFileInputs( - 'app-1', - ['img=https://cdn.example.com/photo.jpg?token=abc'], - upload, - ) - expect(result.img).toMatchObject({ type: 'image', transfer_method: 'remote_url' }) - }) - - it('local file: calls upload and injects local_file object', async () => { - const upload = vi.fn().mockResolvedValue({ id: 'file-uuid-1' }) - const result = await resolveFileInputs('app-1', ['doc=@/tmp/report.pdf'], upload) - expect(upload).toHaveBeenCalledWith('app-1', '/tmp/report.pdf') - expect(result).toEqual({ - doc: { type: 'document', transfer_method: 'local_file', upload_file_id: 'file-uuid-1' }, - }) - }) - - it('multiple flags: produces multiple entries keyed by varname', async () => { - const upload = vi.fn().mockResolvedValue({ id: 'file-uuid-2' }) - const result = await resolveFileInputs( - 'app-1', - ['img=https://x.com/logo.png', 'doc=@/tmp/file.pdf'], - upload, - ) - expect(Object.keys(result)).toHaveLength(2) - expect(result.img).toMatchObject({ transfer_method: 'remote_url' }) - expect(result.doc).toMatchObject({ - transfer_method: 'local_file', - upload_file_id: 'file-uuid-2', - }) - }) - - it('upload failure: throws with context including varname and path', async () => { - const upload = vi.fn().mockRejectedValue(new Error('413 File too large')) - await expect(resolveFileInputs('app-1', ['doc=@/tmp/big.pdf'], upload)).rejects.toThrow( - '--file doc: upload of /tmp/big.pdf failed', - ) - }) -}) diff --git a/cli/src/commands/run/app/file-flags.ts b/cli/src/commands/run/app/file-flags.ts deleted file mode 100644 index 2b2edabefdc..00000000000 --- a/cli/src/commands/run/app/file-flags.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { basename } from 'node:path' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' - -export type ParsedFileFlag = - | { varname: string; kind: 'local'; path: string } - | { varname: string; kind: 'remote'; url: string } - -export function parseFileFlag(raw: string): ParsedFileFlag { - const eqIdx = raw.indexOf('=') - if (eqIdx === -1) - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: '--file must be key=@path or key=https://url', - }) - - const varname = raw.slice(0, eqIdx) - const value = raw.slice(eqIdx + 1) - - if (varname === '') - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: '--file varname must not be empty', - }) - - if (value.startsWith('@')) return { varname, kind: 'local', path: value.slice(1) } - - if (value.startsWith('http://') || value.startsWith('https://')) - return { varname, kind: 'remote', url: value } - - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: '--file value must start with @ (local file) or http(s):// (remote URL)', - }) -} - -const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg']) -const AUDIO_EXTS = new Set(['mp3', 'm4a', 'wav', 'amr', 'mpga']) -const VIDEO_EXTS = new Set(['mp4', 'mov', 'mpeg', 'webm']) -// Matches graphon/file/constants.py DOCUMENT_EXTENSIONS (Unstructured ETL config) -const DOCUMENT_EXTS = new Set([ - 'txt', - 'markdown', - 'md', - 'mdx', - 'pdf', - 'html', - 'htm', - 'xlsx', - 'xls', - 'vtt', - 'properties', - 'doc', - 'docx', - 'csv', - 'eml', - 'msg', - 'ppt', - 'pptx', - 'xml', - 'epub', -]) - -export type DifyFileType = 'image' | 'audio' | 'video' | 'document' | 'custom' - -export function difyFileType(filename: string): DifyFileType { - const dotIdx = filename.lastIndexOf('.') - if (dotIdx <= 0 || dotIdx === filename.length - 1) return 'custom' - const ext = filename.slice(dotIdx + 1).toLowerCase() - if (IMAGE_EXTS.has(ext)) return 'image' - if (AUDIO_EXTS.has(ext)) return 'audio' - if (VIDEO_EXTS.has(ext)) return 'video' - if (DOCUMENT_EXTS.has(ext)) return 'document' - return 'document' -} - -export type UploadCallback = (appId: string, path: string) => Promise<{ id: string }> - -export async function resolveFileInputs( - appId: string, - rawFlags: readonly string[], - upload: UploadCallback, -): Promise> { - const result: Record = {} - - for (const raw of rawFlags) { - const parsed = parseFileFlag(raw) - - if (parsed.kind === 'remote') { - const filename = new URL(parsed.url).pathname.split('/').pop() ?? '' - result[parsed.varname] = { - type: difyFileType(filename), - transfer_method: 'remote_url', - url: parsed.url, - } - } else { - const filename = basename(parsed.path) - let uploaded: { id: string } - try { - uploaded = await upload(appId, parsed.path) - } catch (err) { - throw new BaseError({ - code: ErrorCode.Unknown, - message: `--file ${parsed.varname}: upload of ${parsed.path} failed: ${(err as Error).message}`, - cause: err, - }) - } - result[parsed.varname] = { - type: difyFileType(filename), - transfer_method: 'local_file', - upload_file_id: uploaded.id, - } - } - } - - return result -} diff --git a/cli/src/commands/run/app/guide.ts b/cli/src/commands/run/app/guide.ts deleted file mode 100644 index 66403c0cbf1..00000000000 --- a/cli/src/commands/run/app/guide.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const agentGuide = ` -WORKFLOW - 1. Discover app id and mode: - difyctl get app -o json - difyctl describe app -o json | jq '.info.mode' - - 2. Run the app: - difyctl run app "your message" - difyctl run app --inputs '{"key":"value"}' -o json - -APP MODES - chat / agent-chat / Conversational. Accept --conversation to - advanced-chat resume an existing thread. agent-chat adds - autonomous tool use. - completion Single-turn. Ignores --conversation. - workflow Multi-step graph. Pass all input variables as a - JSON object via --inputs. - -HITL PAUSE (exit code 0 — success-with-pending) - When a workflow pauses for human input, stdout receives a JSON object - with status "paused", form_token, workflow_run_id, and resolved_default_values. - Resume with: - difyctl resume app --workflow-run-id - You can supply form values by: - difyctl resume app --workflow-run-id --inputs '{"name":"Alice"}' - -ERROR RECOVERY - not logged in difyctl auth login - app not found (404) difyctl get app - workspace required difyctl get workspace -` diff --git a/cli/src/commands/run/app/handlers.ts b/cli/src/commands/run/app/handlers.ts deleted file mode 100644 index 93ea70305a0..00000000000 --- a/cli/src/commands/run/app/handlers.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { FormattedPrintable } from '@/framework/output' -import type { ColorScheme } from '@/sys/io/color' - -export const RUN_MODES = { - Chat: 'chat', - AgentChat: 'agent-chat', - AdvancedChat: 'advanced-chat', - Completion: 'completion', - Workflow: 'workflow', -} as const - -export type RunMode = (typeof RUN_MODES)[keyof typeof RUN_MODES] - -export const CHAT_MODES: ReadonlySet = new Set([ - RUN_MODES.Chat, - RUN_MODES.AgentChat, - RUN_MODES.AdvancedChat, -]) - -export type AppRunObject = FormattedPrintable - -export function newAppRunObject(mode: string, resp: Record): AppRunObject { - const filled = resp.mode === undefined || resp.mode === '' ? { ...resp, mode } : resp - return { - text: () => textForMode(mode, filled), - json: () => filled, - } -} - -function textForMode(mode: string, raw: Record): string { - switch (mode) { - case RUN_MODES.Chat: - case RUN_MODES.AgentChat: - case RUN_MODES.AdvancedChat: - return renderChat(raw) - case RUN_MODES.Completion: - return renderCompletion(raw) - case RUN_MODES.Workflow: - return renderWorkflow(raw) - default: - return `${JSON.stringify(raw)}\n` - } -} - -function renderChat(raw: Record): string { - const out: string[] = [] - const answer = pickString(raw, 'answer') - if (answer !== undefined) out.push(answer) - out.push('') - return out.join('\n') -} - -function renderCompletion(raw: Record): string { - return `${pickString(raw, 'answer') ?? ''}\n` -} - -function renderWorkflow(raw: Record): string { - const data = raw.data - if (data !== null && typeof data === 'object' && 'outputs' in data) { - const { outputs } = data as { outputs: unknown } - if (outputs !== undefined) { - if (typeof outputs === 'object' && outputs !== null) { - const entries = Object.entries(outputs as Record) - if (entries.length === 1 && typeof entries[0]![1] === 'string') return `${entries[0]![1]}\n` - } - return `${JSON.stringify(outputs)}\n` - } - } - return `${JSON.stringify(raw)}\n` -} - -export function chatConversationHint( - resp: Record, - cs: ColorScheme, -): string | undefined { - const cid = pickString(resp, 'conversation_id') - if (cid === undefined || cid === '') return undefined - return `${cs.magenta('hint:')} continue this conversation with --conversation ${cs.cyan(cid)}\n` -} - -function pickString(o: Record, key: string): string | undefined { - const v = o[key] - return typeof v === 'string' ? v : undefined -} diff --git a/cli/src/commands/run/app/hitl-render.test.ts b/cli/src/commands/run/app/hitl-render.test.ts deleted file mode 100644 index b8ef44287bd..00000000000 --- a/cli/src/commands/run/app/hitl-render.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { HitlPauseData, HitlPausePayload } from './sse-collector' -import { describe, expect, it } from 'vite-plus/test' -import { buildHitlExitObject, renderHitlHint } from './hitl-render' - -function payload(overrides: Partial = {}): HitlPausePayload { - return { - event: 'human_input_required', - task_id: 'task-1', - workflow_run_id: 'run-1', - data: { - form_id: 'form-1', - node_id: 'node-1', - node_title: 'Approve', - form_content: 'Please approve', - inputs: [], - actions: [], - display_in_ui: false, - form_token: null, - approval_channels: ['email'], - resolved_default_values: {}, - expiration_time: 0, - ...overrides, - }, - } -} - -describe('renderHitlHint — non-resumable form (form_token null)', () => { - it.each<[string[], string]>([ - [['email'], 'form delivered via email — resume only from that channel'], - [['console'], 'form delivered via the console — resume only from that channel'], - [['web_app'], 'form delivered via the web app — resume only from that channel'], - [ - ['console', 'email'], - 'form delivered via the console or email — resume only from those channels', - ], - [[], 'form delivered via another channel — resume only from that channel'], - ])('renders %j as the channel note', (channels, expected) => { - const out = renderHitlHint('app-1', payload({ approval_channels: channels }), false) - expect(out).toBe(`hint: workflow paused — ${expected}\n`) - expect(out).not.toContain('difyctl resume') - }) - - it('falls back to a generic note when approval_channels is absent (older server)', () => { - const p = payload() - delete p.data.approval_channels - const out = renderHitlHint('app-1', p, false) - expect(out).toContain('another channel') - }) -}) - -describe('renderHitlHint — resumable form (form_token present)', () => { - it('renders the resume command and ignores approval_channels', () => { - const out = renderHitlHint( - 'app-1', - payload({ form_token: 'tok-123', approval_channels: [] }), - false, - ) - expect(out).toContain('difyctl resume app app-1 tok-123 --workflow-run-id run-1') - expect(out).not.toContain('delivered via') - }) -}) - -describe('buildHitlExitObject', () => { - it('carries approval_channels into the JSON exit object', () => { - const obj = buildHitlExitObject('app-1', payload({ approval_channels: ['email'] })) - expect(obj.approval_channels).toEqual(['email']) - expect(obj.form_token).toBeNull() - }) - - it('defaults approval_channels to [] when absent', () => { - const p = payload({ form_token: 'tok' }) - delete p.data.approval_channels - expect(buildHitlExitObject('app-1', p).approval_channels).toEqual([]) - }) -}) diff --git a/cli/src/commands/run/app/hitl-render.ts b/cli/src/commands/run/app/hitl-render.ts deleted file mode 100644 index ff7032226fc..00000000000 --- a/cli/src/commands/run/app/hitl-render.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { HitlPausePayload } from './sse-collector' -import { colorEnabled, colorScheme } from '@/sys/io/color' - -export type HitlExitObject = { - status: 'paused' - app_id: string - task_id: string - workflow_run_id: string - form_id: string - node_id: string - node_title: string - form_token: string | null - approval_channels: string[] - form_content: string - inputs: unknown[] - actions: unknown[] - display_in_ui: boolean - resolved_default_values: Record - expiration_time: number -} - -export function buildHitlExitObject(appId: string, payload: HitlPausePayload): HitlExitObject { - const d = payload.data - return { - status: 'paused', - app_id: appId, - task_id: payload.task_id, - workflow_run_id: payload.workflow_run_id, - form_id: d.form_id, - node_id: d.node_id, - node_title: d.node_title, - form_token: d.form_token, - approval_channels: d.approval_channels ?? [], - form_content: d.form_content, - inputs: d.inputs, - actions: d.actions, - display_in_ui: d.display_in_ui, - resolved_default_values: d.resolved_default_values, - expiration_time: d.expiration_time, - } -} - -export function renderHitlExit(obj: HitlExitObject): string { - return JSON.stringify(obj, null, 2) -} - -type ActionRecord = { id: string; title?: string; button_style?: string } -type InputRecord = { - output_variable_name?: string - label?: string - type?: string - required?: boolean -} - -export function renderHitlBlock(_appId: string, payload: HitlPausePayload, isTTY: boolean): string { - const d = payload.data - const cs = colorScheme(colorEnabled(isTTY)) - const lines: string[] = [] - lines.push(`${cs.warningIcon()} ${cs.bold('Workflow paused')} ${cs.dim('— input required')}`) - lines.push(` ${cs.dim('Node:')} ${d.node_title}`) - const msgLines = d.form_content.split('\n') - if (msgLines.length === 1) { - lines.push(` ${cs.dim('Message:')} ${d.form_content}`) - } else { - lines.push(` ${cs.dim('Message:')}`) - for (const ml of msgLines) lines.push(` ${ml}`) - } - - const actions = (Array.isArray(d.actions) ? d.actions : []) as ActionRecord[] - if (actions.length > 0) { - const inline = actions - .map((a) => { - const title = a.title ?? '' - return `${cs.cyan(`[${a.id}]`)} ${title}` - }) - .join(' ') - lines.push(` ${cs.dim('Actions:')} ${inline}`) - } - - const inputs = (Array.isArray(d.inputs) ? d.inputs : []) as InputRecord[] - if (inputs.length > 0) { - const inline = inputs - .map((inp) => { - const name = inp.output_variable_name ?? '?' - const label = - typeof inp.label === 'string' && inp.label !== '' ? ` ${cs.dim(`— ${inp.label}`)}` : '' - const req = inp.required === true ? ` ${cs.yellow('*')}` : '' - return `- ${cs.cyan(name)}${req}${label}` - }) - .join(' ') - lines.push(` ${cs.dim('Inputs:')} ${inline}`) - } - - lines.push('') - return `${lines.join('\n')}\n` -} - -export function renderHitlOutput( - appId: string, - payload: HitlPausePayload, - isText: boolean, - isOutTTY: boolean, -): string { - if (isText) return renderHitlBlock(appId, payload, isOutTTY) - const obj = buildHitlExitObject(appId, payload) - return `${renderHitlExit(obj)}\n` -} - -// Server approval-channel labels → human wording for the pause hint. -const APPROVAL_CHANNEL_LABELS: Record = { - email: 'email', - console: 'the console', - web_app: 'the web app', -} - -function describeApprovalChannels(channels: string[]): string { - const labels = channels.map((c) => APPROVAL_CHANNEL_LABELS[c] ?? c) - if (labels.length <= 1) return labels[0] ?? 'another channel' - if (labels.length === 2) return `${labels[0]} or ${labels[1]}` - return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}` -} - -function externalChannelNote(channels: string[]): string { - const where = channels.length > 1 ? 'those channels' : 'that channel' - return `form delivered via ${describeApprovalChannels(channels)} — resume only from ${where}` -} - -export function renderHitlHint( - appId: string, - payload: HitlPausePayload, - isErrTTY: boolean, -): string { - const d = payload.data - const cs = colorScheme(colorEnabled(isErrTTY)) - if (d.form_token === null) { - const note = externalChannelNote(d.approval_channels ?? []) - if (!isErrTTY) return `hint: workflow paused — ${note}\n` - return `${cs.warningIcon()} ${cs.bold('workflow paused')} — ${cs.dim(note)}\n` - } - const actions = (d.actions ?? []) as { id: string }[] - let cmd = `difyctl resume app ${appId} ${d.form_token} --workflow-run-id ${payload.workflow_run_id}` - if (actions.length > 1) { - const firstAction = actions[0]?.id - if (firstAction !== undefined) cmd += ` --action ${firstAction}` - } - if (!isErrTTY) return `hint: workflow paused — resume with: ${cmd}\n` - return `${cs.warningIcon()} ${cs.bold('workflow paused')} — resume with:\n ${cs.cyan(cmd)}\n` -} diff --git a/cli/src/commands/run/app/index.ts b/cli/src/commands/run/app/index.ts deleted file mode 100644 index 7f50a1bc090..00000000000 --- a/cli/src/commands/run/app/index.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { Args, Flags } from '@/framework/flags' -import { OutputFormat } from '@/framework/output' -import { agentGuide } from './guide' -import { CHAT_MODES } from './handlers' -import { runApp } from './run' - -export default class RunApp extends DifyCommand { - static override description = 'Run an app and print the response' - - static override effect: CommandEffect = 'write' - - static override examples = [ - '<%= config.bin %> run app app-1 "hello"', - '<%= config.bin %> run app app-1 --inputs \'{"name":"world"}\'', - '<%= config.bin %> run app app-1 --inputs-file inputs.json', - '<%= config.bin %> run app app-1 --stream', - '<%= config.bin %> run app app-1 -o json', - '<%= config.bin %> run app app-1 --file doc=@./report.pdf', - '<%= config.bin %> run app app-1 --file img=https://cdn.example.com/logo.png', - ] - - static override args = { - id: Args.string({ description: 'app id', required: true }), - message: Args.string({ - description: 'user message (chat/agent-chat/advanced-chat/completion)', - required: false, - }), - } - - static override flags = { - inputs: Flags.string({ - description: - 'Input variables as a JSON object, e.g. --inputs \'{"key":"value"}\'. Mutually exclusive with --inputs-file.', - }), - 'inputs-file': Flags.string({ - description: - 'Path to a JSON file containing the inputs object. Mutually exclusive with --inputs.', - }), - file: Flags.stringArray({ - description: - 'Named file input: --file key=@path for a local file or --file key=https://url for a remote URL. Repeatable.', - default: [], - }), - conversation: Flags.string({ - description: `Resume a chat conversation by id (${[...CHAT_MODES].join('/')} only)`, - }), - 'workflow-id': Flags.string({ description: 'Pin to a specific published workflow version' }), - workspace: Flags.string({ - description: 'Workspace id (overrides DIFY_WORKSPACE_ID and stored default)', - }), - stream: Flags.boolean({ - description: 'Print output live as tokens/events arrive (default: collect and print at end)', - default: false, - }), - think: Flags.boolean({ - description: - 'Show model thinking/reasoning when available — both inline ... blocks and separated reasoning streams. Hidden by default; with --think, thinking is printed to stderr.', - default: false, - }), - 'retry-on-limit': Flags.boolean({ - description: - 'On a 429 rate limit, wait and retry this POST (bounded) instead of failing immediately. Off by default since running an app is not idempotent.', - default: false, - }), - 'http-retry': httpRetryFlag, - output: Flags.outputFormat({ - options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], - default: '', - }), - } - - async run(argv: string[]): Promise { - const { args, flags } = this.parse(RunApp, argv) - const format = flags.output - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], withCache: true, format }) - await runApp( - { - appId: args.id, - message: args.message, - inputsJson: flags.inputs, - inputsFile: flags['inputs-file'], - files: flags.file, - conversationId: flags.conversation, - workflowId: flags['workflow-id'], - workspace: flags.workspace, - format, - stream: flags.stream, - think: flags.think, - retryOnRateLimit: flags['retry-on-limit'], - }, - { active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, - ) - } - - override agentGuide(): string { - return agentGuide - } -} diff --git a/cli/src/commands/run/app/input-flags.ts b/cli/src/commands/run/app/input-flags.ts deleted file mode 100644 index 551325d219a..00000000000 --- a/cli/src/commands/run/app/input-flags.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' - -// Output formats that render the run/resume result as plain text rather than JSON/YAML. -export const TEXT_FORMATS = new Set(['', 'text']) - -// Shared by `run app` and `resume app`: --inputs (inline JSON) / --inputs-file (JSON file) / -// direct inputs are mutually exclusive ways to supply the run's variable map. -export async function resolveInputs( - inputsJson: string | undefined, - inputsFile: string | undefined, - directInputs: Readonly> | undefined, -): Promise> { - if (inputsJson !== undefined && inputsFile !== undefined) - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: '--inputs and --inputs-file are mutually exclusive', - }) - if (inputsJson !== undefined) { - let parsed: unknown - try { - parsed = JSON.parse(inputsJson) - } catch { - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: '--inputs must be valid JSON', - }) - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: '--inputs must be a JSON object', - }) - return parsed as Record - } - if (inputsFile !== undefined) { - const { readFile } = await import('node:fs/promises') - let parsed: unknown - try { - parsed = JSON.parse(await readFile(inputsFile, 'utf8')) - } catch { - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: '--inputs-file must contain valid JSON', - }) - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: '--inputs-file must be a JSON object', - }) - return parsed as Record - } - return { ...(directInputs ?? {}) } -} diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts deleted file mode 100644 index 2ca7ffe06f3..00000000000 --- a/cli/src/commands/run/app/run.test.ts +++ /dev/null @@ -1,626 +0,0 @@ -import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { startMock } from '@test/fixtures/dify-mock/server' -import { testHttpClient } from '@test/fixtures/http-client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { loadAppInfoCache } from '@/cache/app-info' -import { resumeApp } from '@/commands/resume/app/run' -import { ENV_CACHE_DIR } from '@/store/dir' -import { CACHE_APP_INFO, getCache } from '@/store/manager' -import { bufferStreams } from '@/sys/io/streams' -import { runApp } from './run.js' - -function active(): ActiveContext { - return { - host: 'http://localhost', - email: 't@d.ai', - ctx: { - account: { id: 'acct-1', email: 't@d.ai', name: 'T' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - }, - } -} - -describe('runApp', () => { - let mock: DifyMock - let dir: string - let prevCacheDir: string | undefined - beforeEach(async () => { - mock = await startMock({ scenario: 'happy' }) - dir = await mkdtemp(join(tmpdir(), 'difyctl-runapp-')) - 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('chat: prints answer + conversation hint to stderr', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-1', message: 'hi' }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toBe('echo: hi\n') - expect(io.errBuf()).toContain('--conversation conv-1') - }) - - it('workflow: rejects positional message with usage error', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await expect( - runApp( - { appId: 'app-2', message: 'hi' }, - { - active: active(), - http: testHttpClient(mock.url, 'dfoa_test'), - host: mock.url, - io, - cache, - }, - ), - ).rejects.toMatchObject({ code: 'usage_invalid_flag' }) - }) - - it('workflow: prints single-string output as plain text', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-2', inputs: { x: '1' } }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toBe('echo: \n') - }) - - it('json: passes through full envelope', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-1', message: 'hi', format: 'json' }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - const parsed = JSON.parse(io.outBuf()) as { mode: string; answer: string } - expect(parsed.mode).toBe('chat') - expect(parsed.answer).toBe('echo: hi') - }) - - it('rejects unknown format', async () => { - const io = bufferStreams() - await expect( - runApp( - { appId: 'app-1', format: 'bogus' }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, - ), - ).rejects.toThrow(/not supported/) - }) - - it('unknown app id surfaces as error', async () => { - const io = bufferStreams() - await expect( - runApp( - { appId: 'nope', message: 'hi' }, - { - active: active(), - http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), - host: mock.url, - io, - }, - ), - ).rejects.toThrow() - }) - - it('--stream chat: streams answer to stdout and hint to stderr', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-1', message: 'hi', stream: true }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toContain('echo: ') - expect(io.outBuf()).toContain('hi') - expect(io.errBuf()).toContain('--conversation conv-1') - }) - - it('--stream -o json chat: aggregates into blocking-shape envelope', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-1', message: 'hi', stream: true, format: 'json' }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - const parsed = JSON.parse(io.outBuf()) as { - mode: string - answer: string - conversation_id: string - } - expect(parsed.mode).toBe('chat') - expect(parsed.answer).toBe('echo: hi') - expect(parsed.conversation_id).toBe('conv-1') - }) - - it('agent-chat without --stream: collects and prints answer', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-4', workspace: '00000000-0000-0000-0000-000000000002', message: 'do research' }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toContain('do research') - expect(io.errBuf()).toContain('--conversation conv-1') - }) - - it('agent-chat with --stream: live-prints answer and thoughts to stderr', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { - appId: 'app-4', - workspace: '00000000-0000-0000-0000-000000000002', - message: 'go', - stream: true, - }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toContain('go') - expect(io.errBuf()).toContain('thought:') - }) - - it('--stream workflow -o json: aggregates from workflow_finished', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - const parsed = JSON.parse(io.outBuf()) as { mode: string; data: { status: string } } - expect(parsed.mode).toBe('workflow') - expect(parsed.data.status).toBe('succeeded') - }) - - it('workflow: strips from outputs by default', async () => { - mock.setScenario('workflow-think') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-2', inputs: { x: '1' } }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toBe('final answer\n') - expect(io.errBuf()).not.toContain('secret reasoning') - }) - - it('workflow --think: routes to stderr, clean stdout', async () => { - mock.setScenario('workflow-think') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-2', inputs: { x: '1' }, think: true }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toBe('final answer\n') - expect(io.errBuf()).toContain('secret reasoning') - }) - - it('--stream workflow -o json --think: strips outputs and routes thinking to stderr', async () => { - mock.setScenario('workflow-think') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json', think: true }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - const parsed = JSON.parse(io.outBuf()) as { data: { outputs: { result: string } } } - expect(parsed.data.outputs.result).toBe('final answer') - expect(io.errBuf()).toContain('secret reasoning') - }) - - it('--stream chat --think: routes separated reasoning to stderr, clean answer to stdout', async () => { - mock.setScenario('chat-reasoning') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-1', message: 'hi', stream: true, think: true }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toContain('final answer') - expect(io.outBuf()).not.toContain('secret reasoning') - expect(io.errBuf()).toContain('') - expect(io.errBuf()).toContain('secret reasoning') - }) - - it('--stream chat without --think: separated reasoning stays hidden', async () => { - mock.setScenario('chat-reasoning') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-1', message: 'hi', stream: true }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toContain('final answer') - expect(io.errBuf()).not.toContain('secret reasoning') - }) - - it('chat -o json --think: echoes separated reasoning to stderr, persists it in metadata', async () => { - mock.setScenario('chat-reasoning') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-1', message: 'hi', format: 'json', think: true }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.errBuf()).toContain('secret reasoning') - const parsed = JSON.parse(io.outBuf()) as { - answer: string - metadata: { reasoning: Record } - } - expect(parsed.answer).toBe('final answer') - expect(parsed.metadata.reasoning).toEqual({ 'llm-1': 'secret reasoning' }) - }) - - it('--stream workflow --think: routes separated reasoning to stderr, clean outputs to stdout', async () => { - mock.setScenario('workflow-reasoning') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-2', inputs: { x: '1' }, stream: true, think: true }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.errBuf()).toContain('') - expect(io.errBuf()).toContain('secret reasoning') - expect(io.outBuf()).toContain('final answer') - expect(io.outBuf()).not.toContain('secret reasoning') - }) - - it('workflow -o json --think: echoes reasoning to stderr, accumulates into metadata.reasoning', async () => { - mock.setScenario('workflow-reasoning') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-2', inputs: { x: '1' }, format: 'json', think: true }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.errBuf()).toContain('secret reasoning') - const parsed = JSON.parse(io.outBuf()) as { metadata: { reasoning: Record } } - expect(parsed.metadata.reasoning).toEqual({ 'llm-1': 'secret reasoning' }) - }) - - it('--stream workflow without --think: reasoning stays hidden', async () => { - mock.setScenario('workflow-reasoning') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-2', inputs: { x: '1' }, stream: true }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toContain('final answer') - expect(io.errBuf()).not.toContain('secret reasoning') - }) - - it('stream-error scenario: error event surfaces typed BaseError', async () => { - mock.setScenario('stream-error') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await expect( - runApp( - { appId: 'app-1', message: 'hi', stream: true }, - { - active: active(), - http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), - host: mock.url, - io, - cache, - }, - ), - ).rejects.toMatchObject({ code: 'server_5xx' }) - }) - - it('--inputs-file: reads inputs from file', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - const inputsFile = join(dir, 'inputs.json') - const { writeFile } = await import('node:fs/promises') - await writeFile(inputsFile, JSON.stringify({ x: 'from-file' })) - await runApp( - { appId: 'app-2', inputsFile }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toBe('echo: \n') - }) - - it('--inputs-file: rejects non-object JSON', async () => { - const io = bufferStreams() - const { writeFile } = await import('node:fs/promises') - const inputsFile = join(dir, 'bad.json') - await writeFile(inputsFile, JSON.stringify([1, 2, 3])) - await expect( - runApp( - { appId: 'app-2', inputsFile }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, - ), - ).rejects.toThrow(/must be a JSON object/) - }) - - it('--inputs: accepts JSON object string', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-2', inputsJson: '{"x":"hello"}' }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toBe('echo: \n') - }) - - it('--inputs and --inputs-file are mutually exclusive', async () => { - const io = bufferStreams() - const { writeFile } = await import('node:fs/promises') - const inputsFile = join(dir, 'f.json') - await writeFile(inputsFile, '{}') - await expect( - runApp( - { appId: 'app-2', inputsJson: '{}', inputsFile }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, - ), - ).rejects.toThrow(/mutually exclusive/) - }) - - it('hitl pause (text): writes readable block to stdout, hint to stderr, exits 0', async () => { - mock.setScenario('hitl-pause') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - let exitCode = -1 - await expect( - runApp( - { appId: 'app-2', inputs: {} }, - { - active: active(), - http: testHttpClient(mock.url, 'dfoa_test'), - host: mock.url, - io, - cache, - exit: (code) => { - exitCode = code - throw new Error(`exit:${code}`) - }, - }, - ), - ).rejects.toThrow('exit:0') - expect(exitCode).toBe(0) - const out = io.outBuf() - expect(out).toContain('Workflow paused') - expect(out).toContain('First Node') - expect(out).toContain('Please provide input') - expect(out).toContain('[submit]') - expect(io.errBuf()).toContain('difyctl resume app') - expect(io.errBuf()).toContain('ft-hitl-1') - }) - - it('hitl pause (json): writes JSON envelope to stdout, exits 0', async () => { - mock.setScenario('hitl-pause') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - let exitCode = -1 - await expect( - runApp( - { appId: 'app-2', inputs: {}, format: 'json' }, - { - active: active(), - http: testHttpClient(mock.url, 'dfoa_test'), - host: mock.url, - io, - cache, - exit: (code) => { - exitCode = code - throw new Error(`exit:${code}`) - }, - }, - ), - ).rejects.toThrow('exit:0') - expect(exitCode).toBe(0) - const payload = JSON.parse(io.outBuf()) as { - status: string - form_token: string - workflow_run_id: string - } - expect(payload.status).toBe('paused') - expect(payload.form_token).toBe('ft-hitl-1') - expect(payload.workflow_run_id).toBe('wf-run-hitl-1') - }) - - it('resume: withHistory: false completes successfully', async () => { - mock.setScenario('hitl-resume') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await resumeApp( - { - appId: 'app-2', - formToken: 'ft-hitl-1', - workflowRunId: 'wf-run-hitl-1', - action: 'submit', - inputs: {}, - withHistory: false, - }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toBe('echo: resumed\n') - }) - - it('resume: submits form and streams workflow to completion', async () => { - mock.setScenario('hitl-resume') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await resumeApp( - { - appId: 'app-2', - formToken: 'ft-hitl-1', - workflowRunId: 'wf-run-hitl-1', - action: 'submit', - inputs: {}, - }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toBe('echo: resumed\n') - }) - - it('resume --stream: live-prints workflow node progress to stderr', async () => { - mock.setScenario('hitl-resume') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await resumeApp( - { - appId: 'app-2', - formToken: 'ft-hitl-1', - workflowRunId: 'wf-run-hitl-1', - action: 'submit', - inputs: {}, - stream: true, - }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - // stream mode for workflow: node_started → "→ " on stderr - expect(io.errBuf()).toContain('After Resume') - }) - - it('workflow: --file remote URL is passed as remote_url input variable', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { appId: 'app-2', files: ['doc=https://example.com/report.pdf'] }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toBe('echo: \n') - expect(mock.uploadCallCount).toBe(0) - const runInputs = mock.lastRunBody?.inputs as Record<string, unknown> - expect(runInputs).toBeDefined() - expect(runInputs.doc).toMatchObject({ - type: 'document', - transfer_method: 'remote_url', - url: 'https://example.com/report.pdf', - }) - }) - - it('workflow: --file @path uploads file and passes local_file input variable', async () => { - const { writeFile } = await import('node:fs/promises') - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - const filePath = join(dir, 'test.pdf') - await writeFile(filePath, 'fake pdf content') - await runApp( - { appId: 'app-2', files: [`doc=@${filePath}`] }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toBe('echo: \n') - expect(mock.uploadCallCount).toBe(1) - const runInputs = mock.lastRunBody?.inputs as Record<string, unknown> - expect(runInputs).toBeDefined() - expect(runInputs.doc).toMatchObject({ - transfer_method: 'local_file', - upload_file_id: 'upload-file-1', - }) - }) - - it('422 on run: invalidates cache and adds republish hint', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - // warm cache with successful run - await runApp( - { appId: 'app-1', message: 'hi' }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(cache.get(mock.url, 'app-1')).toBeDefined() - - mock.setScenario('run-422-stale') - const err = await runApp( - { appId: 'app-1', message: 'hi' }, - { - active: active(), - http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), - host: mock.url, - io, - cache, - }, - ).catch((e: unknown) => e) - expect(err).toMatchObject({ code: 'server_4xx_other', httpStatus: 422 }) - expect((err as { hint?: string }).hint).toMatch(/cache cleared/) - expect(cache.get(mock.url, 'app-1')).toBeUndefined() - }) - - it('workflow: --file overrides same-named key from --inputs (file wins)', async () => { - const io = bufferStreams() - const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - await runApp( - { - appId: 'app-2', - inputs: { doc: 'old-value' }, - files: ['doc=https://example.com/override.pdf'], - }, - { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, - ) - expect(io.outBuf()).toBe('echo: \n') - const runInputs = mock.lastRunBody?.inputs as Record<string, unknown> - expect(runInputs).toBeDefined() - const docInput = runInputs.doc as Record<string, unknown> - expect(docInput.transfer_method).toBe('remote_url') - expect(docInput.url).toBe('https://example.com/override.pdf') - }) - - it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => { - const describeResult = { - info: { - id: 'app-1', - name: 'X', - mode: 'chat', - description: '', - updated_at: null, - service_api_enabled: true, - is_agent: false, - }, - parameters: null, - input_schema: null, - } - const externalDescribe = vi.fn().mockResolvedValue(describeResult) - const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps') - const { AppsClient } = await import('@/api/apps') - const externalSpy = vi - .spyOn(PermittedExternalAppsClient.prototype, 'describe') - .mockImplementation(externalDescribe) - const accountSpy = vi.spyOn(AppsClient.prototype, 'describe') - const io = bufferStreams() - const http = { - baseURL: mock.url, - request: vi.fn().mockResolvedValue({ - answer: 'echo: hi', - conversation_id: 'conv-1', - message_id: 'msg-1', - mode: 'chat', - metadata: {}, - }), - } as unknown as HttpClient - const activeExt: ActiveContext = { - host: mock.url, - email: 'sso@x.io', - ctx: { - account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' }, - external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' }, - }, - } - try { - await runApp( - { appId: 'app-1', message: 'hi' }, - { active: activeExt, http, host: mock.url, io }, - ) - } catch { - // run may fail due to mocked http; we only care about which describe was called - } - expect(externalSpy).toHaveBeenCalled() - expect(accountSpy).not.toHaveBeenCalled() - vi.restoreAllMocks() - }) -}) diff --git a/cli/src/commands/run/app/run.ts b/cli/src/commands/run/app/run.ts deleted file mode 100644 index f535e091706..00000000000 --- a/cli/src/commands/run/app/run.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { AppInfoCache } from '@/cache/app-info' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import { AppMetaClient } from '@/api/app-meta' -import { selectAppReader } from '@/api/app-reader' -import { AppRunClient } from '@/api/app-run' -import { FileUploadClient } from '@/api/file-upload' -import { pickStrategy } from '@/commands/run/app/_strategies/index' -import { BaseError, HttpClientError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { processExit } from '@/sys/index' -import { FieldInfo } from '@/types/app-meta' -import { resolveFileInputs } from './file-flags.js' -import { RUN_MODES } from './handlers.js' -import { resolveInputs, TEXT_FORMATS } from './input-flags.js' - -export type RunAppOptions = { - readonly appId: string - readonly message?: string - readonly inputs?: Readonly<Record<string, unknown>> - readonly inputsJson?: string - readonly inputsFile?: string - readonly files?: readonly string[] - readonly conversationId?: string - readonly workflowId?: string - readonly workspace?: string - readonly format?: string - readonly stream?: boolean - readonly think?: boolean - readonly retryOnRateLimit?: boolean -} - -export type RunAppDeps = { - readonly active: ActiveContext - readonly http: HttpClient - readonly host: string - readonly io: IOStreams - readonly cache?: AppInfoCache - readonly envLookup?: (k: string) => string | undefined - readonly exit?: (code: number) => never -} - -export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> { - const apps = selectAppReader(deps.active, deps.http) - const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) - - try { - await executeRun(opts, deps, meta) - } catch (err) { - if (err instanceof HttpClientError && err.httpStatus === 422) { - await meta.invalidate(opts.appId) - throw err.withHint( - 'app metadata cache cleared — if the app was recently republished, run the command again', - ) - } - throw err - } -} - -async function executeRun( - opts: RunAppOptions, - deps: RunAppDeps, - meta: AppMetaClient, -): Promise<void> { - const m = await meta.get(opts.appId, [FieldInfo]) - const mode = m.info?.mode ?? '' - if (mode === '') throw new Error(`app ${opts.appId}: mode missing from app metadata`) - - if (mode === RUN_MODES.Workflow && opts.message !== undefined && opts.message !== '') { - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: 'workflow apps do not accept a positional message', - hint: 'pass workflow inputs via --inputs \'{"key":"value"}\'', - }) - } - - const inputs = await resolveInputs(opts.inputsJson, opts.inputsFile, opts.inputs) - if (opts.files !== undefined && opts.files.length > 0) { - const uploadClient = new FileUploadClient(deps.http) - const fileInputs = await resolveFileInputs(opts.appId, opts.files, (appId, path) => - uploadClient.upload(appId, path), - ) - Object.assign(inputs, fileInputs) - } - const format = opts.format ?? '' - const isText = TEXT_FORMATS.has(format) - const livePrint = opts.stream === true - const runClient = new AppRunClient(deps.http) - - const exit = deps.exit ?? processExit - const ctx = { - opts: { ...opts, inputs }, - deps, - mode, - format, - isText, - livePrint, - runClient, - exit, - think: opts.think ?? false, - } - await pickStrategy(isText, livePrint).execute(ctx) -} diff --git a/cli/src/commands/run/app/sse-collector.test.ts b/cli/src/commands/run/app/sse-collector.test.ts deleted file mode 100644 index 514b0e71072..00000000000 --- a/cli/src/commands/run/app/sse-collector.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import type { HttpClientError } from '@/errors/base' -import type { SseEvent } from '@/http/sse' -import { describe, expect, it } from 'vite-plus/test' -import { collect, collectorFor, decodeStreamError, HitlPauseError } from './sse-collector' - -const enc = new TextEncoder() -function ev(name: string, data: object): SseEvent { - return { name, data: enc.encode(JSON.stringify(data)) } -} - -async function* iterOf(...evs: SseEvent[]): AsyncIterable<SseEvent> { - for (const e of evs) yield e -} - -describe('collectorFor', () => { - it('throws for unknown mode', () => { - expect(() => collectorFor('whatever')).toThrow() - }) - - it.each(['chat', 'advanced-chat', 'agent-chat', 'completion', 'workflow'])( - 'returns collector for %s', - (m) => { - expect(collectorFor(m)).toBeDefined() - }, - ) -}) - -describe('collect — chat', () => { - it('aggregates message + message_end into blocking shape', async () => { - const got = await collect( - iterOf( - ev('message', { conversation_id: 'c1', message_id: 'm1', mode: 'chat', answer: 'hello ' }), - ev('message', { answer: 'world' }), - ev('message_end', { metadata: { usage: { tokens: 5 } } }), - ), - 'chat', - ) - expect(got).toMatchObject({ - mode: 'chat', - answer: 'hello world', - conversation_id: 'c1', - message_id: 'm1', - metadata: { usage: { tokens: 5 } }, - }) - }) - - it('drops ping events', async () => { - const got = await collect( - iterOf(ev('ping', {}), ev('message', { answer: 'x' }), ev('ping', {})), - 'chat', - ) - expect(got.answer).toBe('x') - }) - - it('ignores unknown event names', async () => { - const got = await collect( - iterOf(ev('weird_future_event', { whatever: true }), ev('message', { answer: 'x' })), - 'chat', - ) - expect(got.answer).toBe('x') - }) -}) - -describe('collect — chat separated reasoning', () => { - function reasoningEvent(reasoning: string, isFinal: boolean) { - return ev('reasoning_chunk', { - data: { message_id: 'm1', reasoning, node_id: 'llm-1', is_final: isFinal }, - }) - } - - it('backfills metadata.reasoning from live deltas when the server omits it', async () => { - const got = await collect( - iterOf( - reasoningEvent('pon', false), - reasoningEvent('dering', true), - ev('message', { message_id: 'm1', answer: 'answer' }), - ev('message_end', { metadata: { usage: { tokens: 3 } } }), - ), - 'advanced-chat', - ) - expect(got.answer).toBe('answer') - expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'pondering' }) - expect((got.metadata as { usage?: unknown }).usage).toEqual({ tokens: 3 }) - }) - - it('keeps the server-persisted reasoning over live deltas', async () => { - const got = await collect( - iterOf( - reasoningEvent('live', true), - ev('message', { answer: 'a' }), - ev('message_end', { metadata: { reasoning: { 'llm-1': 'persisted' } } }), - ), - 'advanced-chat', - ) - expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'persisted' }) - }) - - it('leaves metadata untouched when there is no reasoning at all', async () => { - const got = await collect( - iterOf( - ev('message', { answer: 'a' }), - ev('message_end', { metadata: { usage: { tokens: 1 } } }), - ), - 'advanced-chat', - ) - expect((got.metadata as { reasoning?: unknown }).reasoning).toBeUndefined() - }) -}) - -describe('collect — agent-chat', () => { - it('captures agent_thoughts', async () => { - const got = await collect( - iterOf( - ev('agent_thought', { thought: 'first' }), - ev('agent_message', { answer: 'a' }), - ev('agent_thought', { thought: 'second' }), - ev('agent_message', { answer: 'b' }), - ), - 'agent-chat', - ) - expect(got.answer).toBe('ab') - expect(Array.isArray(got.agent_thoughts)).toBe(true) - expect((got.agent_thoughts as unknown[]).length).toBe(2) - }) -}) - -describe('collect — completion', () => { - it('aggregates message events into answer', async () => { - const got = await collect( - iterOf( - ev('message', { mode: 'completion', message_id: 'm1', answer: 'foo' }), - ev('message', { answer: 'bar' }), - ev('message_end', { metadata: {} }), - ), - 'completion', - ) - expect(got).toMatchObject({ mode: 'completion', answer: 'foobar', message_id: 'm1' }) - }) -}) - -describe('collect — workflow', () => { - it('captures only workflow_finished payload', async () => { - const got = await collect( - iterOf( - ev('workflow_started', { id: 'wf' }), - ev('node_started', { id: 'n1' }), - ev('node_finished', { id: 'n1', status: 'succeeded' }), - ev('workflow_finished', { data: { status: 'succeeded', outputs: { x: 1 } } }), - ), - 'workflow', - ) - expect(got.mode).toBe('workflow') - expect((got.data as { outputs: { x: number } }).outputs.x).toBe(1) - }) -}) - -describe('collect — workflow separated reasoning', () => { - function wfReasoning(reasoning: string, nodeId: string, isFinal: boolean) { - return ev('reasoning_chunk', { data: { reasoning, node_id: nodeId, is_final: isFinal } }) - } - - it('accumulates reasoning_chunk per node into metadata.reasoning', async () => { - const got = await collect( - iterOf( - ev('node_started', { id: 'llm-1' }), - wfReasoning('pon', 'llm-1', false), - wfReasoning('dering', 'llm-1', true), - ev('workflow_finished', { data: { status: 'succeeded', outputs: { result: 'clean' } } }), - ), - 'workflow', - ) - expect((got.data as { outputs: { result: string } }).outputs.result).toBe('clean') - expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'pondering' }) - }) - - it('keys reasoning by node, leaves metadata absent when there is none', async () => { - const got = await collect( - iterOf( - ev('workflow_finished', { data: { status: 'succeeded', outputs: { result: 'clean' } } }), - ), - 'workflow', - ) - expect((got.metadata as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined() - }) - - it('merges reasoning into metadata already carried by workflow_finished', async () => { - const got = await collect( - iterOf( - wfReasoning('think', 'llm-1', true), - ev('workflow_finished', { - data: { status: 'succeeded' }, - metadata: { usage: { tokens: 7 } }, - }), - ), - 'workflow', - ) - expect((got.metadata as { reasoning?: unknown }).reasoning).toEqual({ 'llm-1': 'think' }) - expect((got.metadata as { usage?: unknown }).usage).toEqual({ tokens: 7 }) - }) -}) - -describe('collect — error event', () => { - it('throws BaseError when error event arrives', async () => { - await expect( - collect(iterOf(ev('error', { message: 'boom', status: 503 })), 'chat'), - ).rejects.toMatchObject({ code: 'server_5xx', message: 'boom' }) - }) -}) - -describe('decodeStreamError', () => { - it('maps status >= 500 to Server5xx', () => { - const err = decodeStreamError(enc.encode(JSON.stringify({ message: 'x', status: 500 }))) - expect(err.code).toBe('server_5xx') - }) - - it('maps status < 500 to Server4xxOther', () => { - const err = decodeStreamError(enc.encode(JSON.stringify({ message: 'x', status: 400 }))) - expect(err.code).toBe('server_4xx_other') - }) - - it('falls back to default message on empty data', () => { - const err = decodeStreamError(new Uint8Array()) - expect(err.message).toMatch(/error event/i) - }) - - it('unwraps openapi-v1 invoke-error: prefers args.description', () => { - const inner = { - args: { - description: - '[models] Error: API request failed with status code 402: Insufficient Balance', - }, - error_type: 'InvokeError', - message: 'fallback message', - } - const env = { message: JSON.stringify(inner), status: 400 } - const err = decodeStreamError(enc.encode(JSON.stringify(env))) - expect(err.message).toBe(inner.args.description) - expect(err.code).toBe('server_4xx_other') - expect((err as HttpClientError).httpStatus).toBe(400) - }) - - it('unwraps openapi-v1 invoke-error: falls back to inner.message when no args.description', () => { - const inner = { error_type: 'InvokeError', message: 'inner only' } - const env = { message: JSON.stringify(inner), status: 500 } - const err = decodeStreamError(enc.encode(JSON.stringify(env))) - expect(err.message).toBe('inner only') - expect(err.code).toBe('server_5xx') - }) - - it('leaves message untouched when env.message is plain text', () => { - const env = { message: 'plain text error', status: 400 } - const err = decodeStreamError(enc.encode(JSON.stringify(env))) - expect(err.message).toBe('plain text error') - }) - - it('leaves message untouched when nested JSON lacks error_type', () => { - const env = { message: JSON.stringify({ foo: 'bar' }), status: 400 } - const err = decodeStreamError(enc.encode(JSON.stringify(env))) - expect(err.message).toBe(JSON.stringify({ foo: 'bar' })) - }) - - it('leaves message untouched on malformed nested JSON starting with {', () => { - const env = { message: '{not valid json', status: 400 } - const err = decodeStreamError(enc.encode(JSON.stringify(env))) - expect(err.message).toBe('{not valid json') - }) -}) - -describe('collect — human_input_required', () => { - it('throws HitlPauseError when human_input_required arrives', async () => { - const hitlData = { - task_id: 'task-1', - workflow_run_id: 'wf-run-1', - data: { - form_id: 'form-1', - node_id: 'n1', - node_title: 'First', - form_content: 'Please fill in', - inputs: [], - actions: [{ id: 'submit', title: 'Submit' }], - display_in_ui: false, - form_token: 'ft-1', - resolved_default_values: {}, - expiration_time: 9999999999, - }, - } - await expect( - collect(iterOf(ev('workflow_started', {}), ev('human_input_required', hitlData)), 'workflow'), - ).rejects.toBeInstanceOf(HitlPauseError) - }) - - it('HitlPauseError carries the pause payload', async () => { - const hitlData = { - task_id: 'task-1', - workflow_run_id: 'wf-run-1', - data: { - form_id: 'form-1', - node_id: 'n1', - node_title: 'First', - form_content: 'form', - inputs: [], - actions: [], - display_in_ui: false, - form_token: 'ft-abc', - resolved_default_values: { name: 'Alice' }, - expiration_time: 9999999999, - }, - } - let caught: HitlPauseError | undefined - try { - await collect(iterOf(ev('human_input_required', hitlData)), 'workflow') - } catch (e) { - if (e instanceof HitlPauseError) caught = e - } - expect(caught).toBeDefined() - expect(caught!.pausePayload.data.form_token).toBe('ft-abc') - expect(caught!.pausePayload.data.resolved_default_values).toEqual({ name: 'Alice' }) - }) -}) - -describe('collect — silent events', () => { - it('silently ignores iteration_started and loop_started', async () => { - const got = await collect( - iterOf( - ev('iteration_started', { id: 'iter-1' }), - ev('loop_started', { id: 'loop-1' }), - ev('node_started', {}), - ev('message', { answer: 'x' }), - ), - 'chat', - ) - expect(got.answer).toBe('x') - }) - - it('silently ignores node_retry', async () => { - const got = await collect( - iterOf(ev('node_retry', { id: 'n1', retry: 1 }), ev('message', { answer: 'ok' })), - 'chat', - ) - expect(got.answer).toBe('ok') - }) - - it('workflow_paused without prior HITL throws a plain error', async () => { - await expect( - collect( - iterOf(ev('workflow_started', {}), ev('workflow_paused', { reasons: [] })), - 'workflow', - ), - ).rejects.toThrow(/paused/) - }) -}) diff --git a/cli/src/commands/run/app/sse-collector.ts b/cli/src/commands/run/app/sse-collector.ts deleted file mode 100644 index 395fff5b098..00000000000 --- a/cli/src/commands/run/app/sse-collector.ts +++ /dev/null @@ -1,264 +0,0 @@ -import type { BaseError } from '@/errors/base' -import type { SseEvent } from '@/http/sse' -import { HttpClientError, newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { accumulateReasoning, parseReasoningChunk } from '@/sys/io/reasoning' -import { RUN_MODES } from './handlers' - -export type HitlPauseData = { - form_id: string - node_id: string - node_title: string - form_content: string - inputs: unknown[] - actions: unknown[] - display_in_ui: boolean - form_token: string | null - // Channels where the form can be approved when it is not CLI-resumable, e.g. ['email']. - approval_channels?: string[] - resolved_default_values: Record<string, string> - expiration_time: number -} - -export type HitlPausePayload = { - event: 'human_input_required' - task_id: string - workflow_run_id: string - data: HitlPauseData -} - -export class HitlPauseError extends Error { - readonly pausePayload: HitlPausePayload - constructor(payload: HitlPausePayload) { - super('workflow paused for human input') - this.name = 'HitlPauseError' - this.pausePayload = payload - } -} - -export type Collector = { - consume: (ev: SseEvent) => void - finalize: () => Record<string, unknown> -} - -const dec = new TextDecoder() - -function parseJson(data: Uint8Array): Record<string, unknown> { - if (data.byteLength === 0) return {} - try { - return JSON.parse(dec.decode(data)) as Record<string, unknown> - } catch (e) { - throw newError(ErrorCode.Unknown, `decode SSE event: ${(e as Error).message}`) - } -} - -function copyScalar( - dst: Record<string, unknown>, - src: Record<string, unknown>, - keys: readonly string[], -): void { - for (const k of keys) { - if (k in dst) continue - if (k in src) dst[k] = src[k] - } -} - -class ChatCollector implements Collector { - private answer = '' - private base: Record<string, unknown> = {} - private metadata: Record<string, unknown> | undefined - private thoughts: unknown[] = [] - private readonly reasoning: Record<string, string> = {} - private readonly mode: string - private readonly isAgent: boolean - constructor(mode: string, isAgent: boolean) { - this.mode = mode - this.isAgent = isAgent - } - - consume(ev: SseEvent): void { - const c = parseJson(ev.data) - switch (ev.name) { - case 'message': - case 'agent_message': { - if (typeof c.answer === 'string') this.answer += c.answer - copyScalar(this.base, c, ['id', 'conversation_id', 'message_id', 'task_id', 'created_at']) - return - } - // Accumulate separated-mode reasoning deltas per LLM node. - case 'reasoning_chunk': { - const chunk = parseReasoningChunk(c) - if (chunk !== undefined) accumulateReasoning(this.reasoning, chunk) - return - } - case 'agent_thought': - this.thoughts.push(c) - return - case 'message_end': - if (c.metadata !== undefined && typeof c.metadata === 'object' && c.metadata !== null) - this.metadata = c.metadata as Record<string, unknown> - copyScalar(this.base, c, ['id', 'conversation_id', 'message_id', 'task_id', 'created_at']) - } - } - - finalize(): Record<string, unknown> { - const out: Record<string, unknown> = { mode: this.mode, answer: this.answer, ...this.base } - if (this.metadata !== undefined) out.metadata = this.metadata - // Fall back to live deltas only when the server didn't persist reasoning in metadata. - if (Object.keys(this.reasoning).length > 0 && !hasReasoning(this.metadata)) - out.metadata = { ...(this.metadata ?? {}), reasoning: this.reasoning } - if (this.isAgent || this.thoughts.length > 0) out.agent_thoughts = this.thoughts - return out - } -} - -function hasReasoning(metadata: Record<string, unknown> | undefined): boolean { - const reasoning = metadata?.reasoning - return ( - reasoning !== null && - typeof reasoning === 'object' && - !Array.isArray(reasoning) && - Object.keys(reasoning as object).length > 0 - ) -} - -class CompletionCollector implements Collector { - private answer = '' - private base: Record<string, unknown> = {} - private metadata: Record<string, unknown> | undefined - consume(ev: SseEvent): void { - const c = parseJson(ev.data) - switch (ev.name) { - case 'message': - if (typeof c.answer === 'string') this.answer += c.answer - copyScalar(this.base, c, ['id', 'message_id', 'task_id', 'created_at']) - return - case 'message_end': - if (c.metadata !== undefined && typeof c.metadata === 'object' && c.metadata !== null) - this.metadata = c.metadata as Record<string, unknown> - copyScalar(this.base, c, ['id', 'message_id', 'task_id', 'created_at']) - } - } - - finalize(): Record<string, unknown> { - const out: Record<string, unknown> = { - mode: RUN_MODES.Completion, - answer: this.answer, - ...this.base, - } - if (this.metadata !== undefined) out.metadata = this.metadata - return out - } -} - -class WorkflowCollector implements Collector { - private final: Record<string, unknown> | undefined - private readonly reasoning: Record<string, string> = {} - consume(ev: SseEvent): void { - if (ev.name === 'reasoning_chunk') { - const chunk = parseReasoningChunk(parseJson(ev.data)) - if (chunk !== undefined) accumulateReasoning(this.reasoning, chunk) - return - } - if (ev.name !== 'workflow_finished') return - this.final = parseJson(ev.data) - } - - finalize(): Record<string, unknown> { - const out: Record<string, unknown> = { mode: RUN_MODES.Workflow, ...(this.final ?? {}) } - // Workflow runs don't persist reasoning; surface live deltas under metadata.reasoning. - if (Object.keys(this.reasoning).length > 0) { - const existing = - out.metadata !== null && typeof out.metadata === 'object' && !Array.isArray(out.metadata) - ? (out.metadata as Record<string, unknown>) - : undefined - out.metadata = { ...(existing ?? {}), reasoning: this.reasoning } - } - return out - } -} - -const FACTORIES: Record<string, () => Collector> = { - [RUN_MODES.Chat]: () => new ChatCollector(RUN_MODES.Chat, false), - [RUN_MODES.AdvancedChat]: () => new ChatCollector(RUN_MODES.AdvancedChat, false), - [RUN_MODES.AgentChat]: () => new ChatCollector(RUN_MODES.AgentChat, true), - [RUN_MODES.Completion]: () => new CompletionCollector(), - [RUN_MODES.Workflow]: () => new WorkflowCollector(), -} - -export function collectorFor(mode: string): Collector { - const f = FACTORIES[mode] - if (f === undefined) throw newError(ErrorCode.Unknown, `unsupported streaming mode "${mode}"`) - return f() -} - -export function decodeStreamError(data: Uint8Array): BaseError { - type Env = { message?: string; code?: string; status?: number } - let env: Env = {} - if (data.byteLength > 0) { - try { - env = JSON.parse(dec.decode(data)) as Env - } catch {} - } - const rawMessage = - env.message !== undefined && env.message !== '' - ? env.message - : 'stream terminated by error event' - const message = unwrapInvokeErrorMessage(rawMessage) - const code = - env.status !== undefined && env.status > 0 && env.status < 500 - ? ErrorCode.Server4xxOther - : ErrorCode.Server5xx - const err = newError(code, message) - if (env.status !== undefined && env.status > 0) - return HttpClientError.from(err).withHttpStatus(env.status) - return err -} - -function unwrapInvokeErrorMessage(raw: string): string { - if (!raw.startsWith('{')) return raw - type InvokeErrorEnv = { - error_type?: string - args?: { description?: string } - message?: string - } - try { - const inner = JSON.parse(raw) as InvokeErrorEnv - if (inner.error_type === undefined) return raw - return inner.args?.description ?? inner.message ?? raw - } catch { - return raw - } -} - -const SILENT_EVENTS = new Set([ - 'node_retry', - 'iteration_started', - 'iteration_next', - 'iteration_completed', - 'loop_started', - 'loop_next', - 'loop_completed', -]) - -export async function collect( - iter: AsyncIterable<SseEvent>, - mode: string, -): Promise<Record<string, unknown>> { - const c = collectorFor(mode) - for await (const ev of iter) { - if (ev.name === 'ping' || SILENT_EVENTS.has(ev.name)) continue - if (ev.name === 'error') throw decodeStreamError(ev.data) - if (ev.name === 'human_input_required') { - throw new HitlPauseError(parseJson(ev.data) as unknown as HitlPausePayload) - } - if (ev.name === 'workflow_paused') { - throw newError( - ErrorCode.Unknown, - 'workflow paused (non-interactive pause; check server logs)', - ) - } - c.consume(ev) - } - return c.finalize() -} diff --git a/cli/src/commands/run/app/stream-handlers.test.ts b/cli/src/commands/run/app/stream-handlers.test.ts deleted file mode 100644 index dab0d9c419b..00000000000 --- a/cli/src/commands/run/app/stream-handlers.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import type { SseEvent } from '@/http/sse' -import { Buffer } from 'node:buffer' -import { PassThrough, Writable } from 'node:stream' -import { describe, expect, it } from 'vite-plus/test' -import { HitlPauseError } from './sse-collector' -import { streamPrinterFor } from './stream-handlers' - -const enc = new TextEncoder() -function ev(name: string, data: object): SseEvent { - return { name, data: enc.encode(JSON.stringify(data)) } -} - -function captures(): { - out: PassThrough - err: PassThrough - outBuf: () => string - errBuf: () => string -} { - const out = new PassThrough() - const err = new PassThrough() - const oc: Buffer[] = [] - out.on('data', (d) => oc.push(d as Buffer)) - const ec: Buffer[] = [] - err.on('data', (d) => ec.push(d as Buffer)) - return { - out, - err, - outBuf: () => Buffer.concat(oc).toString('utf-8'), - errBuf: () => Buffer.concat(ec).toString('utf-8'), - } -} - -describe('streamPrinterFor — chat', () => { - it('prints answer chunks live and conversation hint on end', () => { - const sp = streamPrinterFor('chat') - const cap = captures() - sp.onEvent(cap.out, cap.err, ev('message', { conversation_id: 'c1', answer: 'hello ' })) - sp.onEvent(cap.out, cap.err, ev('message', { answer: 'world' })) - sp.onEnd(cap.out, cap.err) - expect(cap.outBuf()).toBe('hello world\n') - expect(cap.errBuf()).toContain('--conversation c1') - }) -}) - -function reasoningEvent(reasoning: string, isFinal: boolean) { - return ev('reasoning_chunk', { - data: { message_id: 'm1', reasoning, node_id: 'llm-1', is_final: isFinal }, - }) -} - -describe('streamPrinterFor — chat separated reasoning', () => { - it('think: true frames reasoning_chunk deltas to stderr, answer stays clean on stdout', () => { - const sp = streamPrinterFor('advanced-chat', true) - const cap = captures() - sp.onEvent(cap.out, cap.err, reasoningEvent('pon', false)) - sp.onEvent(cap.out, cap.err, reasoningEvent('dering', false)) - sp.onEvent(cap.out, cap.err, reasoningEvent('', true)) - sp.onEvent(cap.out, cap.err, ev('message', { conversation_id: 'c1', answer: 'final answer' })) - sp.onEnd(cap.out, cap.err) - expect(cap.errBuf()).toContain('<think> [llm-1]\npondering</think>') - expect(cap.outBuf()).toBe('final answer\n') - }) - - it('think: false ignores reasoning_chunk entirely', () => { - const sp = streamPrinterFor('advanced-chat', false) - const cap = captures() - sp.onEvent(cap.out, cap.err, reasoningEvent('secret', true)) - sp.onEvent(cap.out, cap.err, ev('message', { answer: 'hi' })) - sp.onEnd(cap.out, cap.err) - expect(cap.errBuf()).not.toContain('secret') - expect(cap.outBuf()).toBe('hi\n') - }) - - it('closes an unterminated reasoning block on stream end', () => { - const sp = streamPrinterFor('advanced-chat', true) - const cap = captures() - sp.onEvent(cap.out, cap.err, reasoningEvent('thinking', false)) - sp.onEnd(cap.out, cap.err) - expect(cap.errBuf()).toContain('<think> [llm-1]\nthinking</think>') - }) -}) - -describe('streamPrinterFor — agent-chat', () => { - it('writes agent_thought to stderr', () => { - const sp = streamPrinterFor('agent-chat') - const cap = captures() - sp.onEvent(cap.out, cap.err, ev('agent_thought', { thought: 'thinking' })) - sp.onEvent(cap.out, cap.err, ev('agent_message', { answer: 'done' })) - sp.onEnd(cap.out, cap.err) - expect(cap.errBuf()).toContain('thought: thinking') - expect(cap.outBuf()).toContain('done') - }) -}) - -describe('streamPrinterFor — completion', () => { - it('prints answers + trailing newline', () => { - const sp = streamPrinterFor('completion') - const cap = captures() - sp.onEvent(cap.out, cap.err, ev('message', { answer: 'foo' })) - sp.onEvent(cap.out, cap.err, ev('message', { answer: 'bar' })) - sp.onEnd(cap.out, cap.err) - expect(cap.outBuf()).toBe('foobar\n') - }) -}) - -describe('streamPrinterFor — workflow', () => { - it('streams node titles to stderr and outputs JSON on end', () => { - const sp = streamPrinterFor('workflow') - const cap = captures() - sp.onEvent(cap.out, cap.err, ev('node_started', { title: 'A' })) - sp.onEvent(cap.out, cap.err, ev('node_finished', { id: 'a', status: 'succeeded' })) - sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: { x: 1 } } })) - sp.onEnd(cap.out, cap.err) - expect(cap.errBuf()).toContain('→ A') - const parsed = JSON.parse(cap.outBuf().trim()) as { x: number } - expect(parsed.x).toBe(1) - }) -}) - -describe('streamPrinterFor — workflow think filtering', () => { - it('think: false (default) strips <think> from string outputs, nothing to stderr', () => { - const sp = streamPrinterFor('workflow') - const cap = captures() - sp.onEvent( - cap.out, - cap.err, - ev('workflow_finished', { data: { outputs: { text: '<think>hidden</think>\nresult' } } }), - ) - sp.onEnd(cap.out, cap.err) - const parsed = JSON.parse(cap.outBuf().trim()) as { text: string } - expect(parsed.text).toBe('result') - expect(cap.errBuf()).toBe('') - }) - - it('think: true strips <think> from string outputs and routes thinking to stderr', () => { - const sp = streamPrinterFor('workflow', true) - const cap = captures() - sp.onEvent( - cap.out, - cap.err, - ev('workflow_finished', { data: { outputs: { text: '<think>reasoning</think>\nresult' } } }), - ) - sp.onEnd(cap.out, cap.err) - const parsed = JSON.parse(cap.outBuf().trim()) as { text: string } - expect(parsed.text).toBe('result') - expect(cap.errBuf()).toContain('<think>') - expect(cap.errBuf()).toContain('reasoning') - }) - - it('array outputs pass through unchanged (not reshaped into an object)', () => { - const sp = streamPrinterFor('workflow', true) - const cap = captures() - sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: ['a', 'b'] } })) - sp.onEnd(cap.out, cap.err) - expect(cap.outBuf().trim()).toBe('["a","b"]') - }) -}) - -// Workflow reasoning_chunk events carry no message_id (workflow runs have no message). -function wfReasoning(reasoning: string, nodeId: string, isFinal: boolean) { - return ev('reasoning_chunk', { data: { reasoning, node_id: nodeId, is_final: isFinal } }) -} - -describe('streamPrinterFor — workflow separated reasoning', () => { - it('think: true frames reasoning_chunk to stderr, outputs stay clean on stdout', () => { - const sp = streamPrinterFor('workflow', true) - const cap = captures() - sp.onEvent(cap.out, cap.err, ev('node_started', { id: 'llm-1', title: 'LLM' })) - sp.onEvent(cap.out, cap.err, wfReasoning('pon', 'llm-1', false)) - sp.onEvent(cap.out, cap.err, wfReasoning('dering', 'llm-1', false)) - sp.onEvent(cap.out, cap.err, wfReasoning('', 'llm-1', true)) - sp.onEvent( - cap.out, - cap.err, - ev('workflow_finished', { data: { outputs: { result: 'clean answer' } } }), - ) - sp.onEnd(cap.out, cap.err) - // node title precedes the reasoning block for attribution - expect(cap.errBuf()).toContain('→ LLM') - expect(cap.errBuf()).toContain('<think> [llm-1]\npondering</think>') - const parsed = JSON.parse(cap.outBuf().trim()) as { result: string } - expect(parsed.result).toBe('clean answer') - }) - - it('think: false drops reasoning_chunk entirely', () => { - const sp = streamPrinterFor('workflow', false) - const cap = captures() - sp.onEvent(cap.out, cap.err, wfReasoning('secret', 'llm-1', true)) - sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: { result: 'ok' } } })) - sp.onEnd(cap.out, cap.err) - expect(cap.errBuf()).not.toContain('secret') - const parsed = JSON.parse(cap.outBuf().trim()) as { result: string } - expect(parsed.result).toBe('ok') - }) - - it('closes an unterminated reasoning block on stream end', () => { - const sp = streamPrinterFor('workflow', true) - const cap = captures() - sp.onEvent(cap.out, cap.err, wfReasoning('thinking', 'llm-1', false)) - sp.onEnd(cap.out, cap.err) - expect(cap.errBuf()).toContain('<think> [llm-1]\nthinking</think>') - }) - - it('keeps interleaved parallel-node reasoning in separate node-tagged blocks', () => { - const sp = streamPrinterFor('workflow', true) - const cap = captures() - sp.onEvent(cap.out, cap.err, wfReasoning('a1', 'llm-1', false)) - sp.onEvent(cap.out, cap.err, wfReasoning('b1', 'llm-2', false)) - sp.onEvent(cap.out, cap.err, wfReasoning('a2', 'llm-1', true)) - sp.onEvent(cap.out, cap.err, wfReasoning('b2', 'llm-2', true)) - sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: { result: 'ok' } } })) - sp.onEnd(cap.out, cap.err) - expect(cap.errBuf()).toBe( - '<think> [llm-1]\na1</think>\n<think> [llm-2]\nb1</think>\n<think> [llm-1]\na2</think>\n<think> [llm-2]\nb2</think>\n', - ) - }) -}) - -describe('streamPrinterFor — unknown mode', () => { - it('throws', () => { - expect(() => streamPrinterFor('whatever')).toThrow() - }) -}) - -function capture(): { stream: Writable; buf: () => string } { - const chunks: Buffer[] = [] - const stream = new Writable({ - write(chunk, _enc, cb) { - chunks.push(Buffer.from(chunk as ArrayBuffer)) - cb() - }, - }) - return { stream, buf: () => Buffer.concat(chunks).toString() } -} - -describe('streamPrinterFor — HITL events', () => { - it('throws HitlPauseError on human_input_required', () => { - const sp = streamPrinterFor('workflow') - const { stream } = capture() - const hitl = { - task_id: 't-1', - workflow_run_id: 'wf-1', - data: { - form_id: 'form-1', - node_id: 'n1', - node_title: 'First', - form_content: 'fill', - inputs: [], - actions: [], - display_in_ui: false, - form_token: 'ft-1', - resolved_default_values: {}, - expiration_time: 999, - }, - } - expect(() => sp.onEvent(stream, stream, ev('human_input_required', hitl))).toThrow( - HitlPauseError, - ) - }) -}) - -describe('streamPrinterFor — silent events', () => { - it('silently ignores iteration_started', () => { - const sp = streamPrinterFor('workflow') - const { stream } = capture() - expect(() => sp.onEvent(stream, stream, ev('iteration_started', { id: 'i-1' }))).not.toThrow() - }) - - it('silently ignores node_retry', () => { - const sp = streamPrinterFor('chat') - const { stream } = capture() - expect(() => sp.onEvent(stream, stream, ev('node_retry', { id: 'n1' }))).not.toThrow() - }) -}) - -describe('streamPrinterFor — think: false strips think blocks from streamed answer', () => { - it('chat: strips think block from live answer chunk', () => { - const sp = streamPrinterFor('chat', false) - const cap = captures() - sp.onEvent( - cap.out, - cap.err, - ev('message', { conversation_id: 'c1', answer: '<think>reasoning</think>\nhello' }), - ) - sp.onEnd(cap.out, cap.err) - expect(cap.outBuf()).toBe('hello\n') - expect(cap.errBuf()).not.toContain('reasoning') - }) - - it('chat: strips think block split across two events', () => { - const sp = streamPrinterFor('chat', false) - const cap = captures() - sp.onEvent(cap.out, cap.err, ev('message', { answer: '<think>sec' })) - sp.onEvent(cap.out, cap.err, ev('message', { answer: 'ret</think>\nfinal' })) - sp.onEnd(cap.out, cap.err) - expect(cap.outBuf()).toBe('final\n') - }) - - it('completion: strips think block', () => { - const sp = streamPrinterFor('completion', false) - const cap = captures() - sp.onEvent(cap.out, cap.err, ev('message', { answer: '<think>hidden</think>\nresult' })) - sp.onEnd(cap.out, cap.err) - expect(cap.outBuf()).toBe('result\n') - }) -}) - -describe('streamPrinterFor — think: true routes thinking to stderr', () => { - it('chat: routes think block to stderr', () => { - const sp = streamPrinterFor('chat', true) - const cap = captures() - sp.onEvent( - cap.out, - cap.err, - ev('message', { answer: '<think>my reasoning</think>\nanswer text' }), - ) - sp.onEnd(cap.out, cap.err) - expect(cap.outBuf()).toBe('answer text\n') - expect(cap.errBuf()).toContain('my reasoning') - }) - - it('completion: routes think block to stderr', () => { - const sp = streamPrinterFor('completion', true) - const cap = captures() - sp.onEvent(cap.out, cap.err, ev('message', { answer: '<think>thought</think>\nout' })) - sp.onEnd(cap.out, cap.err) - expect(cap.outBuf()).toBe('out\n') - expect(cap.errBuf()).toContain('thought') - }) -}) - -describe('streamPrinterFor — no-think param = backward compat (strips by default)', () => { - it('existing call without think param still strips', () => { - const sp = streamPrinterFor('chat') - const cap = captures() - sp.onEvent(cap.out, cap.err, ev('message', { answer: '<think>x</think>\nok' })) - sp.onEnd(cap.out, cap.err) - expect(cap.outBuf()).toBe('ok\n') - }) -}) diff --git a/cli/src/commands/run/app/stream-handlers.ts b/cli/src/commands/run/app/stream-handlers.ts deleted file mode 100644 index b7f33e7bc0d..00000000000 --- a/cli/src/commands/run/app/stream-handlers.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { HitlPausePayload } from './sse-collector' -import type { StreamPrinter } from '@/framework/stream' -import type { SseEvent } from '@/http/sse' -import { newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { colorEnabled, colorScheme } from '@/sys/io/color' -import { parseReasoningChunk, ReasoningChunkRenderer } from '@/sys/io/reasoning' -import { filterThinkInOutputs, ThinkChunkFilter } from '@/sys/io/think-filter' -import { RUN_MODES } from './handlers' -import { HitlPauseError } from './sse-collector' - -const dec = new TextDecoder() - -function parseJson(data: Uint8Array): Record<string, unknown> { - if (data.byteLength === 0) return {} - try { - return JSON.parse(dec.decode(data)) as Record<string, unknown> - } catch { - return {} - } -} - -const SILENT_EVENTS = new Set([ - 'node_retry', - 'iteration_started', - 'iteration_next', - 'iteration_completed', - 'loop_started', - 'loop_next', - 'loop_completed', -]) - -function handleCommonEvents(ev: SseEvent): boolean { - if (SILENT_EVENTS.has(ev.name)) return true - if (ev.name === 'human_input_required') { - throw new HitlPauseError(parseJson(ev.data) as unknown as HitlPausePayload) - } - return false -} - -class ChatStreamPrinter implements StreamPrinter { - private convoId = '' - private readonly filter: ThinkChunkFilter - private readonly reasoning = new ReasoningChunkRenderer() - private readonly think: boolean - private readonly isTTY: boolean - constructor(think: boolean, isTTY = false) { - this.filter = new ThinkChunkFilter(think) - this.think = think - this.isTTY = isTTY - } - - onEvent(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream, ev: SseEvent): void { - if (handleCommonEvents(ev)) return - const c = parseJson(ev.data) - switch (ev.name) { - case 'message': - case 'agent_message': { - if (typeof c.answer === 'string') this.filter.push(c.answer, out, errOut) - if (typeof c.conversation_id === 'string' && c.conversation_id !== '') - this.convoId = c.conversation_id - return - } - // Stream separated-mode reasoning to stderr under --think. - case 'reasoning_chunk': { - if (!this.think) return - const chunk = parseReasoningChunk(c) - if (chunk !== undefined) this.reasoning.push(chunk, errOut) - return - } - case 'agent_thought': - if (typeof c.thought === 'string' && c.thought !== '') - errOut.write(`thought: ${c.thought}\n`) - return - case 'message_end': - if (typeof c.conversation_id === 'string' && c.conversation_id !== '') - this.convoId = c.conversation_id - } - } - - onEnd(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream): void { - this.reasoning.flush(errOut) - this.filter.flush(out, errOut) - out.write('\n') - if (this.convoId !== '') { - const cs = colorScheme(colorEnabled(this.isTTY)) - errOut.write( - `${cs.magenta('hint:')} continue this conversation with --conversation ${cs.cyan(this.convoId)}\n`, - ) - } - } -} - -class CompletionStreamPrinter implements StreamPrinter { - private readonly filter: ThinkChunkFilter - constructor(think: boolean) { - this.filter = new ThinkChunkFilter(think) - } - - onEvent(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream, ev: SseEvent): void { - if (handleCommonEvents(ev)) return - if (ev.name !== 'message') return - const c = parseJson(ev.data) - if (typeof c.answer === 'string') this.filter.push(c.answer, out, errOut) - } - - onEnd(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream): void { - this.filter.flush(out, errOut) - out.write('\n') - } -} - -class WorkflowStreamPrinter implements StreamPrinter { - private final: Record<string, unknown> | undefined - private readonly reasoning = new ReasoningChunkRenderer() - private readonly think: boolean - constructor(think: boolean) { - this.think = think - } - - onEvent(_out: NodeJS.WritableStream, errOut: NodeJS.WritableStream, ev: SseEvent): void { - if (handleCommonEvents(ev)) return - const c = parseJson(ev.data) - switch (ev.name) { - case 'node_started': { - const title = - typeof c.title === 'string' && c.title !== '' - ? c.title - : typeof c.id === 'string' - ? c.id - : '' - if (title !== '') errOut.write(`→ ${title}\n`) - return - } - // Stream separated-mode reasoning to stderr under --think; the prior → title attributes the node. - case 'reasoning_chunk': { - if (!this.think) return - const chunk = parseReasoningChunk(c) - if (chunk !== undefined) this.reasoning.push(chunk, errOut) - return - } - case 'node_finished': { - const status = typeof c.status === 'string' ? c.status : '' - if (status !== '' && status !== 'succeeded') { - const id = typeof c.id === 'string' ? c.id : '' - errOut.write(` [${status}] ${id}\n`) - } - return - } - case 'workflow_finished': - this.final = c - } - } - - onEnd(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream): void { - this.reasoning.flush(errOut) - if (this.final === undefined) return - const data = this.final.data - if (data !== null && typeof data === 'object' && 'outputs' in data) { - const raw = (data as { outputs: unknown }).outputs - if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) { - const { outputs, thinking } = filterThinkInOutputs( - raw as Record<string, unknown>, - this.think, - ) - if (this.think && thinking !== '') errOut.write(`${thinking}\n`) - out.write(`${JSON.stringify(outputs)}\n`) - return - } - out.write(`${JSON.stringify(raw)}\n`) - return - } - out.write(`${JSON.stringify(this.final)}\n`) - } -} - -const FACTORIES: Record<string, (think: boolean, isTTY: boolean) => StreamPrinter> = { - [RUN_MODES.Chat]: (think, isTTY) => new ChatStreamPrinter(think, isTTY), - [RUN_MODES.AdvancedChat]: (think, isTTY) => new ChatStreamPrinter(think, isTTY), - [RUN_MODES.AgentChat]: (think, isTTY) => new ChatStreamPrinter(think, isTTY), - [RUN_MODES.Completion]: (think, _isTTY) => new CompletionStreamPrinter(think), - [RUN_MODES.Workflow]: (think, _isTTY) => new WorkflowStreamPrinter(think), -} - -export function streamPrinterFor(mode: string, think = false, isTTY = false): StreamPrinter { - const f = FACTORIES[mode] - if (f === undefined) throw newError(ErrorCode.Unknown, `unsupported streaming mode "${mode}"`) - return f(think, isTTY) -} diff --git a/cli/src/commands/set/config/index.test.ts b/cli/src/commands/set/config/index.test.ts new file mode 100644 index 00000000000..6e11f9e010a --- /dev/null +++ b/cli/src/commands/set/config/index.test.ts @@ -0,0 +1,25 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { commands } from '@/plugins/commands' +import { config } from '@/plugins/config' + +const worlds: TestWorld[] = [] +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +it('writes a value that the config service can read back', async () => { + const w = await testContext({ login: false, argv: ['set', 'config', 'http.timeout', '5000'] }) + worlds.push(w) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + expect(await (await w.ctx.get(config)).get('http.timeout')).toBe(5000) +}) + +it('rejects an unknown key with exit 2', async () => { + const w = await testContext({ login: false, argv: ['set', 'config', 'nope', '1'] }) + worlds.push(w) + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'config_invalid_key', + }) +}) diff --git a/cli/src/commands/set/config/index.ts b/cli/src/commands/set/config/index.ts new file mode 100644 index 00000000000..d2cc82d815c --- /dev/null +++ b/cli/src/commands/set/config/index.ts @@ -0,0 +1,19 @@ +import type { CommandContext } from '@/plugins/base' +import { z } from 'zod' +import { Command } from '@/plugins/commands/command' +import { config } from '@/plugins/config' + +const INPUT = z.object({ key: z.string(), value: z.string() }) + +export default class ConfigSet extends Command<typeof INPUT> { + static override summary = 'Set a local config value' + static override effect = 'write' as const + static override input = INPUT + static override positional = ['key', 'value'] as const + + async run(input: z.infer<typeof INPUT>, ctx: CommandContext) { + const configService = await ctx.get(config) + await configService.set(input.key, input.value) + return undefined + } +} diff --git a/cli/src/commands/set/member/handlers.ts b/cli/src/commands/set/member/handlers.ts deleted file mode 100644 index 23bd04c5218..00000000000 --- a/cli/src/commands/set/member/handlers.ts +++ /dev/null @@ -1,26 +0,0 @@ -export type SetMemberPayload = { - readonly id: string - readonly role: 'normal' | 'admin' -} - -export class SetMemberOutput { - readonly payload: SetMemberPayload - readonly textLine: string - - constructor(payload: SetMemberPayload, textLine: string) { - this.payload = payload - this.textLine = textLine - } - - text(): string { - return this.textLine - } - - json(): SetMemberPayload { - return this.payload - } - - name(): string { - return this.payload.id - } -} diff --git a/cli/src/commands/set/member/index.ts b/cli/src/commands/set/member/index.ts deleted file mode 100644 index 8ef3c7e2545..00000000000 --- a/cli/src/commands/set/member/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { Args, Flags } from '@/framework/flags' -import { formatted, OutputFormat } from '@/framework/output' -import { runSetMember } from './run' - -export default class SetMember extends DifyCommand { - static override description = "Change a member's role in the active (or specified) workspace" - - static override effect: CommandEffect = 'write' - - static override examples = [ - '<%= config.bin %> set member acct-1 --role admin', - '<%= config.bin %> set member acct-1 --role normal -w ws-1', - '<%= config.bin %> set member acct-1 --role admin -o json', - ] - - static override args = { - memberId: Args.string({ description: 'account id of the member to update', required: true }), - } - - static override flags = { - role: Flags.string({ - description: 'new role (normal|admin); owner is not assignable here', - required: true, - }), - workspace: Flags.string({ - char: 'w', - description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)', - }), - 'http-retry': httpRetryFlag, - output: Flags.outputFormat({ - options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], - default: '', - }), - } - - async run(argv: string[]) { - const { args, flags } = this.parse(SetMember, argv) - const format = flags.output - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format }) - const result = await runSetMember( - { memberId: args.memberId, role: flags.role, workspace: flags.workspace, format }, - { active: ctx.active, http: ctx.http, io: ctx.io }, - ) - return formatted({ format, data: result.data }) - } -} diff --git a/cli/src/commands/set/member/run.test.ts b/cli/src/commands/set/member/run.test.ts deleted file mode 100644 index e2c0e2c0ebb..00000000000 --- a/cli/src/commands/set/member/run.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import { describe, expect, it, vi } from 'vite-plus/test' -import { bufferStreams } from '@/sys/io/streams' -import { runSetMember } from './run.js' - -function active(): ActiveContext { - return { - host: 'cloud.dify.ai', - email: 'me@example.com', - ctx: { - account: { id: 'acct-1', email: 'me@example.com', name: 'Me' }, - workspace: { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Default', role: 'owner' }, - }, - } -} - -function fakeClient() { - return { - updateRole: vi.fn(() => Promise.resolve({ result: 'success' as const })), - } -} - -describe('runSetMember', () => { - it('happy path: PUT new role, returns SetMemberOutput with text/json/name', async () => { - const client = fakeClient() - const result = await runSetMember( - { memberId: 'acct-2', role: 'admin' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ) - expect(client.updateRole).toHaveBeenCalledExactlyOnceWith( - '550e8400-e29b-41d4-a716-446655440000', - 'acct-2', - { role: 'admin' }, - ) - expect(result.data.text()).toMatch(/Set acct-2 role to admin/) - expect(result.data.name()).toBe('acct-2') - expect(result.data.json()).toEqual({ id: 'acct-2', role: 'admin' }) - expect(result.workspaceId).toBe('550e8400-e29b-41d4-a716-446655440000') - }) - - it('rejects unknown role before any HTTP call', async () => { - const client = fakeClient() - await expect( - runSetMember( - { memberId: 'acct-2', role: 'owner' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ), - ).rejects.toThrow(/invalid --role/) - expect(client.updateRole).not.toHaveBeenCalled() - }) - - it('rejects empty member id', async () => { - const client = fakeClient() - await expect( - runSetMember( - { memberId: '', role: 'admin' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ), - ).rejects.toThrow(/member id is required/) - }) - - it('-w flag overrides resolved workspace', async () => { - const client = fakeClient() - await runSetMember( - { memberId: 'acct-2', role: 'normal', workspace: '550e8400-e29b-41d4-a716-446655440008' }, - { - active: active(), - http: {} as HttpClient, - io: bufferStreams(), - membersFactory: () => client as never, - }, - ) - expect(client.updateRole).toHaveBeenCalledWith( - '550e8400-e29b-41d4-a716-446655440008', - 'acct-2', - { role: 'normal' }, - ) - }) -}) diff --git a/cli/src/commands/set/member/run.ts b/cli/src/commands/set/member/run.ts deleted file mode 100644 index 0089dce2a4a..00000000000 --- a/cli/src/commands/set/member/run.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import { MembersClient } from '@/api/members' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { colorEnabled, colorScheme } from '@/sys/io/color' -import { runWithSpinner } from '@/sys/io/spinner' -import { nullStreams } from '@/sys/io/streams' -import { resolveWorkspaceId } from '@/workspace/resolver' -import { SetMemberOutput } from './handlers.js' - -export type SetMemberOptions = { - readonly memberId: string - readonly role: string - readonly workspace?: string - readonly format?: string -} - -export type SetMemberDeps = { - readonly active: ActiveContext - readonly http: HttpClient - readonly io?: IOStreams - readonly envLookup?: (k: string) => string | undefined - readonly membersFactory?: (http: HttpClient) => MembersClient -} - -export type SetMemberResult = { - readonly data: SetMemberOutput - readonly workspaceId: string -} - -const ASSIGNABLE_ROLES = new Set(['normal', 'admin']) - -export async function runSetMember( - opts: SetMemberOptions, - deps: SetMemberDeps, -): Promise<SetMemberResult> { - if (opts.memberId === undefined || opts.memberId === '') { - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: 'member id is required', - hint: 'pass it positionally: difyctl set member <member-id> --role <role>', - }) - } - if (!ASSIGNABLE_ROLES.has(opts.role)) { - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: `invalid --role "${opts.role}"`, - hint: 'expected: normal | admin (ownership transfer is console-only)', - }) - } - - const env = deps.envLookup ?? ((k: string) => process.env[k]) - const factory = deps.membersFactory ?? ((h: HttpClient) => new MembersClient(h)) - const io = deps.io ?? nullStreams() - const cs = colorScheme(colorEnabled(io.isErrTTY)) - - const wsId = resolveWorkspaceId({ - flag: opts.workspace, - env: env('DIFY_WORKSPACE_ID'), - active: deps.active, - }) - - await runWithSpinner({ io, label: `Updating role for ${opts.memberId}` }, () => - factory(deps.http).updateRole(wsId, opts.memberId, { - role: opts.role as 'normal' | 'admin', - }), - ) - - const role = opts.role as 'normal' | 'admin' - const textLine = `${cs.successIcon()} Set ${opts.memberId} role to ${role}\n` - return { - data: new SetMemberOutput({ id: opts.memberId, role }, textLine), - workspaceId: wsId, - } -} diff --git a/cli/src/commands/skills/install/index.test.ts b/cli/src/commands/skills/install/index.test.ts deleted file mode 100644 index bc6fb3a40b1..00000000000 --- a/cli/src/commands/skills/install/index.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { CommandTree } from '@/framework/registry' -import { describe, expect, it } from 'vite-plus/test' -import { ExitCode } from '@/errors/codes' -import { run } from '@/framework/run' -import { renderSkill } from '@/help/skill' -import { versionInfo } from '@/version/info' -import SkillsInstall from './index' - -const tree: CommandTree = { - skills: { subcommands: { install: { command: SkillsInstall, subcommands: {} } } }, -} - -type Captured = { stdout: string; stderr: string; exit: number | undefined } - -async function captureRun(argv: string[]): Promise<Captured> { - const captured: Captured = { stdout: '', stderr: '', exit: undefined } - const origStdout = process.stdout.write.bind(process.stdout) - const origStderr = process.stderr.write.bind(process.stderr) - const origExit = process.exit.bind(process) - - process.stdout.write = ((chunk: string | Uint8Array) => { - captured.stdout += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk) - return true - }) as typeof process.stdout.write - process.stderr.write = ((chunk: string | Uint8Array) => { - captured.stderr += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk) - return true - }) as typeof process.stderr.write - process.exit = ((code?: number) => { - captured.exit = code - }) as typeof process.exit - - try { - await run(tree, argv) - } finally { - process.stdout.write = origStdout - process.stderr.write = origStderr - process.exit = origExit - } - return captured -} - -describe('skills install command', () => { - it('prints the skill verbatim under --stdout and exits 0', async () => { - const result = await captureRun(['skills', 'install', '--stdout']) - expect(result.stdout).toBe(renderSkill({ version: versionInfo.version })) - expect(result.exit).toBeUndefined() - }) - - it('rejects --stdout combined with --yes (exit 2, no output)', async () => { - const result = await captureRun(['skills', 'install', '--stdout', '--yes']) - expect(result.exit).toBe(ExitCode.Usage) - expect(result.stdout).toBe('') - }) - - it('rejects --stdout combined with a directory (exit 2)', async () => { - const result = await captureRun(['skills', 'install', './out', '--stdout']) - expect(result.exit).toBe(ExitCode.Usage) - }) - - it('rejects a directory combined with --agent (exit 2)', async () => { - const result = await captureRun(['skills', 'install', './out', '--agent', 'claude-code']) - expect(result.exit).toBe(ExitCode.Usage) - }) -}) diff --git a/cli/src/commands/skills/install/index.ts b/cli/src/commands/skills/install/index.ts deleted file mode 100644 index e223c105ac5..00000000000 --- a/cli/src/commands/skills/install/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import type { CommandOutput } from '@/framework/output' -import { newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { Command } from '@/framework/command' -import { Args, Flags } from '@/framework/flags' -import { raw } from '@/framework/output' -import { versionInfo } from '@/version/info' -import { runSkillsInstall } from './run' - -export default class SkillsInstall extends Command { - static override description = 'Install the difyctl agent skill (SKILL.md) into detected agents' - - static override effect: CommandEffect = 'write' - - static override examples = [ - '<%= config.bin %> skills install', - '<%= config.bin %> skills install --yes', - '<%= config.bin %> skills install --yes --agent claude-code', - '<%= config.bin %> skills install ./my-skills/difyctl --yes', - '<%= config.bin %> skills install --stdout', - ] - - static override args = { - dir: Args.string({ - description: 'force install into a single explicit directory (bypasses detection)', - }), - } - - static override flags = { - yes: Flags.boolean({ - char: 'y', - description: 'write the skill (otherwise dry-run)', - default: false, - }), - agent: Flags.stringArray({ - description: 'restrict to specific detected agents (repeatable or comma-separated)', - default: [], - multiple: true, - }), - stdout: Flags.boolean({ - description: 'print SKILL.md to stdout and write nothing', - default: false, - }), - } - - async run(argv: string[]): Promise<CommandOutput> { - const { args, flags } = this.parse(SkillsInstall, argv) - - const dir = args.dir - const hasDir = dir !== undefined && dir !== '' - const agents = flags.agent - .flatMap((a) => a.split(',')) - .map((s) => s.trim()) - .filter((s) => s.length > 0) - - if (flags.stdout && (flags.yes || hasDir || agents.length > 0)) - throw newError( - ErrorCode.IllegalArgumentError, - '--stdout writes nothing; do not combine it with --yes, --agent, or [dir]', - ) - - if (hasDir && agents.length > 0) - throw newError(ErrorCode.IllegalArgumentError, 'pass either [dir] or --agent, not both') - - const result = await runSkillsInstall({ - version: versionInfo.version, - write: flags.yes, - stdout: flags.stdout, - dir, - agents, - }) - - if (result.kind === 'usage') throw newError(ErrorCode.IllegalArgumentError, result.message) - - return raw(result.text) - } -} diff --git a/cli/src/commands/skills/install/registry.test.ts b/cli/src/commands/skills/install/registry.test.ts deleted file mode 100644 index ab10202fc0a..00000000000 --- a/cli/src/commands/skills/install/registry.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { mkdir, 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 { AGENTS, detectAgents } from './registry' - -describe('detectAgents', () => { - let home: string - - beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'difyctl-detect-')) - }) - - afterEach(async () => { - await rm(home, { recursive: true, force: true }) - }) - - it('detects nothing in an empty home', () => { - expect(detectAgents(home)).toEqual([]) - }) - - it('detects an agent once its config dir exists', async () => { - await mkdir(join(home, '.codex')) - expect(detectAgents(home).map((a) => a.name)).toEqual(['codex']) - }) - - it('detects every agent whose config dir exists, in registry order', async () => { - await mkdir(join(home, '.claude')) - await mkdir(join(home, '.codex')) - await mkdir(join(home, '.config', 'opencode'), { recursive: true }) - await mkdir(join(home, '.cursor')) - await mkdir(join(home, '.pi')) - expect(detectAgents(home).map((a) => a.name)).toEqual([ - 'claude-code', - 'codex', - 'opencode', - 'cursor', - 'pi', - ]) - }) - - it('detects cursor and pi by their config dirs', async () => { - await mkdir(join(home, '.cursor')) - await mkdir(join(home, '.pi')) - expect(detectAgents(home).map((a) => a.name)).toEqual(['cursor', 'pi']) - }) -}) - -describe('agent registry paths', () => { - const home = join('home', 'dev') - - it('installs Codex skills under ~/.agents/skills, detected via ~/.codex', () => { - const codex = AGENTS.find((a) => a.name === 'codex') - expect(codex?.probeDir(home)).toBe(join(home, '.codex')) - expect(codex?.skillDir(home)).toBe(join(home, '.agents', 'skills', 'difyctl')) - }) - - it('installs Claude Code and opencode skills under their native dirs', () => { - const claude = AGENTS.find((a) => a.name === 'claude-code') - const opencode = AGENTS.find((a) => a.name === 'opencode') - expect(claude?.skillDir(home)).toBe(join(home, '.claude', 'skills', 'difyctl')) - expect(opencode?.skillDir(home)).toBe(join(home, '.config', 'opencode', 'skills', 'difyctl')) - }) - - it('installs Cursor under ~/.cursor/skills, detected via ~/.cursor', () => { - const cursor = AGENTS.find((a) => a.name === 'cursor') - expect(cursor?.probeDir(home)).toBe(join(home, '.cursor')) - expect(cursor?.skillDir(home)).toBe(join(home, '.cursor', 'skills', 'difyctl')) - }) - - it('installs pi under ~/.pi/agent/skills, detected via ~/.pi', () => { - const pi = AGENTS.find((a) => a.name === 'pi') - expect(pi?.probeDir(home)).toBe(join(home, '.pi')) - expect(pi?.skillDir(home)).toBe(join(home, '.pi', 'agent', 'skills', 'difyctl')) - }) -}) diff --git a/cli/src/commands/skills/install/registry.ts b/cli/src/commands/skills/install/registry.ts deleted file mode 100644 index c7ccce8403c..00000000000 --- a/cli/src/commands/skills/install/registry.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { existsSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' - -// The agents difyctl knows how to install its skill into: how to tell each one -// is in use on this machine, and where it reads user-level skills. -// -// Detection is config-DIRECTORY existence only — no PATH probe, no subprocess. -// difyctl spawns nothing at runtime, and a tool's config dir (`~/.claude`, -// `~/.codex`, `~/.config/opencode`, `~/.cursor`, `~/.pi`) is the reliable "this -// agent is set up here" signal; agents themselves discover each other's skills -// by reading these dirs, not by locating executables. The narrow "installed but -// never launched, so no config dir yet" case is served by `skills install <dir>`. -// -// `probeDir` (the detection signal) and `skillDir` (the install target) are kept -// separate because they diverge for some agents: Codex is configured under -// `~/.codex` but reads user skills from `~/.agents/skills`, and pi is configured -// under `~/.pi` but reads them from `~/.pi/agent/skills` (each tool's documented -// location). Adding an agent is one entry; paths are verified against its docs. -export type AgentEntry = { - readonly name: string - readonly probeDir: (home: string) => string - readonly skillDir: (home: string) => string -} - -export const AGENTS: readonly AgentEntry[] = [ - { - name: 'claude-code', - probeDir: (home) => join(home, '.claude'), - skillDir: (home) => join(home, '.claude', 'skills', 'difyctl'), - }, - { - name: 'codex', - probeDir: (home) => join(home, '.codex'), - skillDir: (home) => join(home, '.agents', 'skills', 'difyctl'), - }, - { - name: 'opencode', - probeDir: (home) => join(home, '.config', 'opencode'), - skillDir: (home) => join(home, '.config', 'opencode', 'skills', 'difyctl'), - }, - { - name: 'cursor', - probeDir: (home) => join(home, '.cursor'), - skillDir: (home) => join(home, '.cursor', 'skills', 'difyctl'), - }, - { - name: 'pi', - probeDir: (home) => join(home, '.pi'), - skillDir: (home) => join(home, '.pi', 'agent', 'skills', 'difyctl'), - }, -] - -// Agents whose config dir exists under `home`. `home` is injectable so tests can -// point at a temp dir instead of the real home. -export function detectAgents(home: string = homedir()): readonly AgentEntry[] { - return AGENTS.filter((agent) => existsSync(agent.probeDir(home))) -} diff --git a/cli/src/commands/skills/install/run.test.ts b/cli/src/commands/skills/install/run.test.ts deleted file mode 100644 index bfa45dcb9ec..00000000000 --- a/cli/src/commands/skills/install/run.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { SkillsInstallOptions } from './run' -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, rm, writeFile } 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 { renderSkill } from '@/help/skill' -import { runSkillsInstall } from './run' - -const VERSION = '9.9.9-test' -const SKILL = renderSkill({ version: VERSION }) - -describe('runSkillsInstall', () => { - let home: string - - beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'difyctl-skills-')) - }) - - afterEach(async () => { - await rm(home, { recursive: true, force: true }) - }) - - const opts = (over: Partial<SkillsInstallOptions>): SkillsInstallOptions => ({ - version: VERSION, - write: false, - stdout: false, - agents: [], - home, - ...over, - }) - - const claudeTarget = () => join(home, '.claude', 'skills', 'difyctl', 'SKILL.md') - - it('--stdout returns the skill verbatim and writes nothing', async () => { - expect(await runSkillsInstall(opts({ stdout: true }))).toEqual({ - kind: 'ok', - text: SKILL, - wrote: [], - }) - }) - - it('dry-run lists detected agents and writes nothing', async () => { - await mkdir(join(home, '.claude')) - const result = await runSkillsInstall(opts({})) - expect(result.kind).toBe('ok') - if (result.kind !== 'ok') return - expect(result.text).toContain('Detected 1 agent: claude-code') - expect(result.text).toContain(`would write to claude-code: ${claudeTarget()}`) - expect(result.text).toContain('Re-run with --yes') - // A single detected agent: hint the manual-directory escape hatch, but not - // the subset selector (nothing to subset). - expect(result.text).toContain('skills install <dir>') - expect(result.text).not.toContain('--agent') - expect(result.wrote).toEqual([]) - expect(existsSync(claudeTarget())).toBe(false) - }) - - it('dry-run summarizes detected agents and enumerates --agent names', async () => { - await mkdir(join(home, '.claude')) - await mkdir(join(home, '.codex')) - const result = await runSkillsInstall(opts({})) - expect(result.kind).toBe('ok') - if (result.kind !== 'ok') return - // The detection summary lists the selectable names; the footer just shows the flag. - expect(result.text).toContain('Detected 2 agents: claude-code, codex') - expect(result.text).toContain('--agent <name> to write only some') - expect(result.text).toContain('skills install <dir>') - expect(result.wrote).toEqual([]) - }) - - it('--yes writes the skill and reports the actual path', async () => { - await mkdir(join(home, '.claude')) - const result = await runSkillsInstall(opts({ write: true })) - expect(result).toEqual({ - kind: 'ok', - text: `wrote ${claudeTarget()}\n`, - wrote: [claudeTarget()], - }) - expect(await readFile(claudeTarget(), 'utf8')).toBe(SKILL) - }) - - it('overwrites an existing skill in place', async () => { - await mkdir(join(home, '.claude', 'skills', 'difyctl'), { recursive: true }) - await writeFile(claudeTarget(), 'stale skill', 'utf8') - await runSkillsInstall(opts({ write: true })) - expect(await readFile(claudeTarget(), 'utf8')).toBe(SKILL) - }) - - it('guides the user and writes nothing when no agents are detected', async () => { - const result = await runSkillsInstall(opts({ write: true })) - expect(result.kind).toBe('ok') - if (result.kind !== 'ok') return - expect(result.text).toContain('No agents detected') - expect(result.wrote).toEqual([]) - }) - - it('rejects an --agent name that is not detected', async () => { - await mkdir(join(home, '.claude')) - const result = await runSkillsInstall(opts({ write: true, agents: ['bogus'] })) - expect(result.kind).toBe('usage') - if (result.kind !== 'usage') return - expect(result.message).toContain('bogus') - }) - - it('rejects --agent for an agent that is not present (none detected)', async () => { - const result = await runSkillsInstall(opts({ write: true, agents: ['claude-code'] })) - expect(result.kind).toBe('usage') - }) - - it('[dir] forces a single directory, bypassing detection', async () => { - const dest = join(home, 'forced') - const target = join(dest, 'SKILL.md') - const result = await runSkillsInstall(opts({ write: true, dir: dest })) - expect(result).toEqual({ kind: 'ok', text: `wrote ${target}\n`, wrote: [target] }) - expect(await readFile(target, 'utf8')).toBe(SKILL) - }) - - it('writes one copy per detected agent', async () => { - await mkdir(join(home, '.claude')) - await mkdir(join(home, '.codex')) - await mkdir(join(home, '.config', 'opencode'), { recursive: true }) - const result = await runSkillsInstall(opts({ write: true })) - expect(result.kind).toBe('ok') - if (result.kind !== 'ok') return - expect(result.wrote).toEqual([ - join(home, '.claude', 'skills', 'difyctl', 'SKILL.md'), - join(home, '.agents', 'skills', 'difyctl', 'SKILL.md'), - join(home, '.config', 'opencode', 'skills', 'difyctl', 'SKILL.md'), - ]) - }) - - it('narrows writes to the named agent subset', async () => { - await mkdir(join(home, '.claude')) - await mkdir(join(home, '.codex')) - const result = await runSkillsInstall(opts({ write: true, agents: ['codex'] })) - expect(result.kind).toBe('ok') - if (result.kind !== 'ok') return - expect(result.wrote).toEqual([join(home, '.agents', 'skills', 'difyctl', 'SKILL.md')]) - }) - - it('writes cursor and pi to their documented dirs (pi under agent/skills)', async () => { - await mkdir(join(home, '.cursor')) - await mkdir(join(home, '.pi')) - const result = await runSkillsInstall(opts({ write: true })) - expect(result.kind).toBe('ok') - if (result.kind !== 'ok') return - expect(result.wrote).toEqual([ - join(home, '.cursor', 'skills', 'difyctl', 'SKILL.md'), - join(home, '.pi', 'agent', 'skills', 'difyctl', 'SKILL.md'), - ]) - expect( - await readFile(join(home, '.pi', 'agent', 'skills', 'difyctl', 'SKILL.md'), 'utf8'), - ).toBe(SKILL) - }) -}) diff --git a/cli/src/commands/skills/install/run.ts b/cli/src/commands/skills/install/run.ts deleted file mode 100644 index 8dcafb6b220..00000000000 --- a/cli/src/commands/skills/install/run.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { AgentEntry } from './registry' -import { mkdir, rename, writeFile } from 'node:fs/promises' -import { homedir } from 'node:os' -import { dirname, join, resolve } from 'node:path' -import { renderSkill } from '@/help/skill' -import { AGENTS, detectAgents } from './registry' - -export type SkillsInstallOptions = { - readonly version: string - // Write to disk (true) or just preview the targets (false / dry-run). - readonly write: boolean - // Print the skill to stdout and write nothing. - readonly stdout: boolean - // Force a single explicit directory, bypassing agent detection. - readonly dir?: string - // Restrict to these detected agents (by name); empty = all detected. - readonly agents: readonly string[] - // Injectable home dir (defaults to os.homedir()); tests pass a temp dir. - readonly home?: string -} - -export type SkillsInstallResult = - | { readonly kind: 'ok'; readonly text: string; readonly wrote: readonly string[] } - | { readonly kind: 'usage'; readonly message: string } - -type InstallTarget = { - readonly name: string - readonly path: string -} - -// Atomic write: temp file in the destination dir, then rename over any existing -// SKILL.md. Mirrors the store/skill-init pattern; the unique temp name makes -// concurrent installs safe. -async function writeSkill(content: string, target: string): Promise<void> { - await mkdir(dirname(target), { recursive: true }) - const tmp = `${target}.tmp-${process.pid}-${process.hrtime.bigint()}` - await writeFile(tmp, content, 'utf8') - await rename(tmp, target) -} - -function resolveTargets( - opts: SkillsInstallOptions, - home: string, -): InstallTarget[] | SkillsInstallResult { - // Explicit directory: skip detection entirely. - if (opts.dir !== undefined && opts.dir !== '') - return [{ name: opts.dir, path: join(resolve(opts.dir), 'SKILL.md') }] - - const detected = detectAgents(home) - const target = (a: AgentEntry): InstallTarget => ({ - name: a.name, - path: join(a.skillDir(home), 'SKILL.md'), - }) - - // An explicit --agent must name agents that are actually detected. This is - // checked before the zero-detected guidance below: naming an agent that is - // not present (including when none are present) is a usage error, per spec. - if (opts.agents.length > 0) { - const known = new Set(detected.map((a) => a.name)) - const unknown = opts.agents.filter((name) => !known.has(name)) - if (unknown.length > 0) { - return { - kind: 'usage', - message: `unknown or undetected agent(s): ${unknown.join(', ')} (detected: ${[...known].join(', ') || 'none'})`, - } - } - return detected.filter((a) => opts.agents.includes(a.name)).map(target) - } - - // No --agent and nothing detected: not an error — guide the user, write nothing. - if (detected.length === 0) { - const lookedFor = AGENTS.map((a) => a.probeDir(home).replace(home, '~')).join(', ') - return { - kind: 'ok', - text: - `No agents detected (looked for ${lookedFor}).\n` + - 'Install into a directory manually with `difyctl skills install <dir>`, or\n' + - 'print the skill with `difyctl skills install --stdout`.\n', - wrote: [], - } - } - - return detected.map(target) -} - -export async function runSkillsInstall(opts: SkillsInstallOptions): Promise<SkillsInstallResult> { - const home = opts.home ?? homedir() - const content = renderSkill({ version: opts.version }) - - // --stdout: emit the skill, write nothing. - if (opts.stdout) return { kind: 'ok', text: content, wrote: [] } - - const targets = resolveTargets(opts, home) - // resolveTargets short-circuits to a terminal result (zero detected / usage). - if (!Array.isArray(targets)) return targets - - // Dry-run: list where the skill would land, write nothing. - if (!opts.write) { - const lines = targets.map((t) => `would write to ${t.name}: ${t.path}`).join('\n') - - // Explicit <dir>: no detection happened, so no agent summary / selectors. - if (opts.dir !== undefined && opts.dir !== '') - return { kind: 'ok', text: `${lines}\n\nRe-run with --yes to write.\n`, wrote: [] } - - const names = targets.map((t) => t.name) - const selected = opts.agents.length > 0 - const header = `${selected ? 'Selected' : 'Detected'} ${names.length} agent${names.length === 1 ? '' : 's'}: ${names.join(', ')}` - // Only suggest --agent when the user hasn't already used it and there is more - // than one to choose from. The selectable names are the ones listed above, so - // the hint just shows the flag, not the (already-visible) name list. - const pick = - !selected && names.length > 1 - ? 'Re-run with --yes to write all, or --agent <name> to write only some.' - : 'Re-run with --yes to write.' - const footer = `${pick}\nAgent not listed? Install into its directory with \`difyctl skills install <dir>\`.` - return { kind: 'ok', text: `${header}\n\n${lines}\n\n${footer}\n`, wrote: [] } - } - - const wrote: string[] = [] - for (const target of targets) { - await writeSkill(content, target.path) - wrote.push(target.path) - } - return { kind: 'ok', text: `${wrote.map((p) => `wrote ${p}`).join('\n')}\n`, wrote } -} diff --git a/cli/src/commands/tree.generated.ts b/cli/src/commands/tree.generated.ts index fdaf8f269ed..53e89bf67a0 100644 --- a/cli/src/commands/tree.generated.ts +++ b/cli/src/commands/tree.generated.ts @@ -1,121 +1,55 @@ // @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 AuthDevicesList from '@/commands/auth/devices/list/index' -import AuthDevicesRevoke from '@/commands/auth/devices/revoke/index' -import AuthList from '@/commands/auth/list/index' -import AuthLogin from '@/commands/auth/login/index' -import AuthLogout from '@/commands/auth/logout/index' -import AuthWhoami from '@/commands/auth/whoami/index' -import ConfigGet from '@/commands/config/get/index' -import ConfigPath from '@/commands/config/path/index' -import ConfigSet from '@/commands/config/set/index' -import ConfigUnset from '@/commands/config/unset/index' -import ConfigView from '@/commands/config/view/index' -import CreateMember from '@/commands/create/member/index' -import DeleteMember from '@/commands/delete/member/index' -import DescribeApp from '@/commands/describe/app/index' -import EnvList from '@/commands/env/list/index' -import ExportStudioApp from '@/commands/export/studio-app/index' -import GetApp from '@/commands/get/app/index' -import GetMember from '@/commands/get/member/index' -import GetWorkspace from '@/commands/get/workspace/index' -import ImportStudioApp from '@/commands/import/studio-app/index' -import ResumeApp from '@/commands/resume/app/index' -import RunApp from '@/commands/run/app/index' -import SetMember from '@/commands/set/member/index' -import SkillsInstall from '@/commands/skills/install/index' -import UseAccount from '@/commands/use/account/index' -import UseHost from '@/commands/use/host/index' +import type { CommandTree } from '@/plugins/commands/registry' +import DeleteCache from '@/commands/delete/cache/index' +import GetConfig from '@/commands/get/config/index' +import GetSkills from '@/commands/get/skills/index' +import InstallSkills from '@/commands/install/skills/index' +import Login from '@/commands/login/index' +import Logout from '@/commands/logout/index' +import RefreshCache from '@/commands/refresh/cache/index' +import SetConfig from '@/commands/set/config/index' +import UnsetConfig from '@/commands/unset/config/index' import UseWorkspace from '@/commands/use/workspace/index' import Version from '@/commands/version/index' export const commandTree: CommandTree = { - auth: { - subcommands: { - devices: { - subcommands: { - list: { command: AuthDevicesList, subcommands: {} }, - revoke: { command: AuthDevicesRevoke, subcommands: {} }, - }, - }, - list: { command: AuthList, subcommands: {} }, - login: { command: AuthLogin, subcommands: {} }, - logout: { command: AuthLogout, subcommands: {} }, - whoami: { command: AuthWhoami, subcommands: {} }, - }, - }, - config: { - subcommands: { - get: { command: ConfigGet, subcommands: {} }, - path: { command: ConfigPath, subcommands: {} }, - set: { command: ConfigSet, subcommands: {} }, - unset: { command: ConfigUnset, subcommands: {} }, - view: { command: ConfigView, subcommands: {} }, - }, - }, - create: { - subcommands: { - member: { command: CreateMember, subcommands: {} }, - }, - }, delete: { subcommands: { - member: { command: DeleteMember, subcommands: {} }, - }, - }, - describe: { - subcommands: { - app: { command: DescribeApp, subcommands: {} }, - }, - }, - env: { - subcommands: { - list: { command: EnvList, subcommands: {} }, - }, - }, - export: { - subcommands: { - 'studio-app': { command: ExportStudioApp, subcommands: {} }, + cache: { command: DeleteCache, subcommands: {} }, }, }, get: { subcommands: { - app: { command: GetApp, subcommands: {} }, - member: { command: GetMember, subcommands: {} }, - workspace: { command: GetWorkspace, subcommands: {} }, + config: { command: GetConfig, subcommands: {} }, + skills: { command: GetSkills, subcommands: {} }, }, }, - import: { + install: { subcommands: { - 'studio-app': { command: ImportStudioApp, subcommands: {} }, + skills: { command: InstallSkills, subcommands: {} }, }, }, - resume: { + login: { command: Login, subcommands: {} }, + logout: { command: Logout, subcommands: {} }, + refresh: { subcommands: { - app: { command: ResumeApp, subcommands: {} }, - }, - }, - run: { - subcommands: { - app: { command: RunApp, subcommands: {} }, + cache: { command: RefreshCache, subcommands: {} }, }, }, set: { subcommands: { - member: { command: SetMember, subcommands: {} }, + config: { command: SetConfig, subcommands: {} }, }, }, - skills: { + unset: { subcommands: { - install: { command: SkillsInstall, subcommands: {} }, + config: { command: UnsetConfig, subcommands: {} }, }, }, use: { subcommands: { - account: { command: UseAccount, subcommands: {} }, - host: { command: UseHost, subcommands: {} }, workspace: { command: UseWorkspace, subcommands: {} }, }, }, diff --git a/cli/src/commands/unset/config/index.test.ts b/cli/src/commands/unset/config/index.test.ts new file mode 100644 index 00000000000..92fbeb418e1 --- /dev/null +++ b/cli/src/commands/unset/config/index.test.ts @@ -0,0 +1,26 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { commands } from '@/plugins/commands' +import { config } from '@/plugins/config' + +const worlds: TestWorld[] = [] +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +it('removes a value, restoring its default', async () => { + const w = await testContext({ login: false, argv: ['unset', 'config', 'http.timeout'] }) + worlds.push(w) + await (await w.ctx.get(config)).set('http.timeout', '5000') + expect(await (await w.ctx.get(commands)).run()).toBe(0) + expect(await (await w.ctx.get(config)).get('http.timeout')).toBe(30_000) +}) + +it('rejects an unknown key with exit 2', async () => { + const w = await testContext({ login: false, argv: ['unset', 'config', 'nope'] }) + worlds.push(w) + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'config_invalid_key', + }) +}) diff --git a/cli/src/commands/unset/config/index.ts b/cli/src/commands/unset/config/index.ts new file mode 100644 index 00000000000..14bb96ef4d7 --- /dev/null +++ b/cli/src/commands/unset/config/index.ts @@ -0,0 +1,19 @@ +import type { CommandContext } from '@/plugins/base' +import { z } from 'zod' +import { Command } from '@/plugins/commands/command' +import { config } from '@/plugins/config' + +const INPUT = z.object({ key: z.string() }) + +export default class ConfigUnset extends Command<typeof INPUT> { + static override summary = 'Remove a local config value, restoring its default' + static override effect = 'write' as const + static override input = INPUT + static override positional = ['key'] as const + + async run(input: z.infer<typeof INPUT>, ctx: CommandContext) { + const configService = await ctx.get(config) + await configService.unset(input.key) + return undefined + } +} diff --git a/cli/src/commands/use/account/index.ts b/cli/src/commands/use/account/index.ts deleted file mode 100644 index 2ecd1b1556a..00000000000 --- a/cli/src/commands/use/account/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Flags } from '@/framework/flags' -import { realStreams } from '@/sys/io/streams' -import { runUseAccount } from './use-account' - -export default class UseAccount extends DifyCommand { - static override description = 'Switch the active account on the current host' - - static override effect: CommandEffect = 'write' - - static override examples = [ - '<%= config.bin %> use account', - '<%= config.bin %> use account --email bob@corp.com', - ] - - static override flags = { - email: Flags.string({ - description: - 'email of the account to switch to (interactive picker shown when omitted in TTY)', - default: '', - }), - } - - async run(argv: string[]): Promise<void> { - const { flags } = this.parse(UseAccount, argv) - await runUseAccount({ io: realStreams(), email: flags.email !== '' ? flags.email : undefined }) - } -} diff --git a/cli/src/commands/use/account/use-account.test.ts b/cli/src/commands/use/account/use-account.test.ts deleted file mode 100644 index 9f9cbe8b41e..00000000000 --- a/cli/src/commands/use/account/use-account.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { MemStore } from '@test/fixtures/mem-store' -import { beforeEach, describe, expect, it } from 'vite-plus/test' -import { Registry } from '@/auth/hosts' -import { bufferStreams } from '@/sys/io/streams' -import { runUseAccount } from './use-account' - -describe('runUseAccount', () => { - useTempConfigDir('difyctl-useacct-') - beforeEach(async () => { - 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() - }) - - it('switches current_account when email valid + token present', async () => { - await runUseAccount({ - io: bufferStreams(), - email: 'b@x', - store: new MemStore({ 'h1 b@x': 'dfoa_b' }), - }) - expect((await Registry.load()).hosts.h1?.current_account).toBe('b@x') - }) - - it('errors when the account has no stored token', async () => { - await expect( - runUseAccount({ io: bufferStreams(), email: 'b@x', store: new MemStore({}) }), - ).rejects.toThrow(/log in|no credential/i) - }) - - it('errors when the email is unknown on the current host', async () => { - await expect( - runUseAccount({ io: bufferStreams(), email: 'z@x', store: new MemStore({ 'h1 z@x': 'x' }) }), - ).rejects.toThrow(/unknown account|no account/i) - }) - - it('errors in non-TTY when email omitted', async () => { - const io = bufferStreams() - ;(io as { isErrTTY: boolean }).isErrTTY = false - await expect(runUseAccount({ io, email: undefined, store: new MemStore({}) })).rejects.toThrow( - /--email/i, - ) - }) -}) diff --git a/cli/src/commands/use/account/use-account.ts b/cli/src/commands/use/account/use-account.ts deleted file mode 100644 index bac310bc3cd..00000000000 --- a/cli/src/commands/use/account/use-account.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { HostEntry } from '@/auth/hosts' -import type { TokenStore } from '@/store/token-store' -import type { IOStreams } from '@/sys/io/streams' -import { notLoggedInError, Registry } from '@/auth/hosts' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { getTokenStore } from '@/store/manager' -import { colorEnabled, colorScheme } from '@/sys/io/color' -import { selectFromList } from '@/sys/io/select' - -export type UseAccountOptions = { - readonly io: IOStreams - readonly email: string | undefined - /** Optional override for tests; production resolves via `getTokenStore`. */ - readonly store?: TokenStore -} - -type AccountChoice = { email: string; name: string; sso: boolean; active: boolean } - -const USE_HOST_HINT = "run 'difyctl use host' or 'difyctl auth login'" - -export async function runUseAccount(opts: UseAccountOptions): Promise<void> { - const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) - const reg = await Registry.load() - if (reg.current_host === undefined) throw notLoggedInError(USE_HOST_HINT) - const host = reg.current_host - const entry = reg.hosts[host] - if (entry === undefined) throw notLoggedInError(USE_HOST_HINT) - - const emails = Object.keys(entry.accounts) - const target = opts.email ?? (await pickAccount(opts, entry, host)) - if (!emails.includes(target)) { - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: `unknown account "${target}" on ${host}; known: ${emails.join(', ')}`, - }) - } - - const store = opts.store ?? getTokenStore(reg.token_storage) - if ((await store.read(host, target)) === '') { - throw new BaseError({ - code: ErrorCode.NotLoggedIn, - message: `no credential stored for ${target} on ${host}`, - hint: `run 'difyctl auth login --host ${host}'`, - }) - } - - reg.setAccount(target) - await reg.save() - opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`) -} - -async function pickAccount( - opts: UseAccountOptions, - entry: HostEntry, - host: string, -): Promise<string> { - const emails = Object.keys(entry.accounts) - if (!opts.io.isErrTTY) { - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: `--email is required (no TTY); known accounts on ${host}: ${emails.join(', ')}`, - }) - } - const choices: AccountChoice[] = Object.entries(entry.accounts).map(([email, ctx]) => ({ - email, - name: ctx.account.name, - sso: ctx.external_subject !== undefined, - active: entry.current_account === email, - })) - const picked = await selectFromList<AccountChoice>({ - io: opts.io, - items: choices, - header: `Select an account on ${host}`, - render: (c) => - `${c.active ? '* ' : ' '}${c.email} ${c.sso ? '(SSO)' : c.name !== '' ? `(${c.name})` : ''}`.trimEnd(), - }) - return picked.email -} diff --git a/cli/src/commands/use/host/index.ts b/cli/src/commands/use/host/index.ts deleted file mode 100644 index 7118fbbe7b7..00000000000 --- a/cli/src/commands/use/host/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Flags } from '@/framework/flags' -import { realStreams } from '@/sys/io/streams' -import { runUseHost } from './use-host' - -export default class UseHost extends DifyCommand { - static override description = 'Switch the active Dify host' - - static override effect: CommandEffect = 'write' - - static override examples = [ - '<%= config.bin %> use host', - '<%= config.bin %> use host --domain cloud.dify.ai', - ] - - static override flags = { - domain: Flags.string({ - description: - 'host domain to switch to, e.g. cloud.dify.ai (interactive picker shown when omitted in TTY)', - default: '', - }), - } - - async run(argv: string[]): Promise<void> { - const { flags } = this.parse(UseHost, argv) - await runUseHost({ io: realStreams(), host: flags.domain !== '' ? flags.domain : undefined }) - } -} diff --git a/cli/src/commands/use/host/use-host.test.ts b/cli/src/commands/use/host/use-host.test.ts deleted file mode 100644 index a23aa9b8efd..00000000000 --- a/cli/src/commands/use/host/use-host.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { beforeEach, describe, expect, it } from 'vite-plus/test' -import { Registry } from '@/auth/hosts' -import { bufferStreams } from '@/sys/io/streams' -import { runUseHost } from './use-host' - -describe('runUseHost', () => { - useTempConfigDir('difyctl-usehost-') - beforeEach(async () => { - const reg = Registry.empty('file') - reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } }) - reg.upsert('h2', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } }) - reg.setHost('h1') - reg.setAccount('a@x') - await reg.save() - }) - - it('switches current_host when host is valid', async () => { - await runUseHost({ io: bufferStreams(), host: 'h2' }) - expect((await Registry.load()).current_host).toBe('h2') - }) - - it('errors when host is unknown, listing valid hosts', async () => { - await expect(runUseHost({ io: bufferStreams(), host: 'nope' })).rejects.toThrow( - /h1.*h2|unknown host/i, - ) - }) - - it('errors in non-TTY when host omitted', async () => { - const io = bufferStreams() - ;(io as { isErrTTY: boolean }).isErrTTY = false - await expect(runUseHost({ io, host: undefined })).rejects.toThrow(/--domain/i) - }) - - it('errors when no hosts exist', async () => { - await Registry.empty('file').save() - await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow( - /no hosts|not logged in/i, - ) - }) -}) diff --git a/cli/src/commands/use/host/use-host.ts b/cli/src/commands/use/host/use-host.ts deleted file mode 100644 index d0308200554..00000000000 --- a/cli/src/commands/use/host/use-host.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { IOStreams } from '@/sys/io/streams' -import { notLoggedInError, Registry } from '@/auth/hosts' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { colorEnabled, colorScheme } from '@/sys/io/color' -import { selectFromList } from '@/sys/io/select' - -export type UseHostOptions = { - readonly io: IOStreams - readonly host: string | undefined -} - -type HostChoice = { host: string; accounts: number; active: boolean } - -export async function runUseHost(opts: UseHostOptions): Promise<void> { - const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) - const reg = await Registry.load() - const hosts = Object.keys(reg.hosts) - if (hosts.length === 0) throw notLoggedInError() - - const target = opts.host ?? (await pickHost(opts, reg, hosts)) - if (!hosts.includes(target)) { - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: `unknown host "${target}"; known hosts: ${hosts.join(', ')}`, - }) - } - - reg.setHost(target) - await reg.save() - opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`) -} - -async function pickHost( - opts: UseHostOptions, - reg: Registry, - hosts: readonly string[], -): Promise<string> { - if (!opts.io.isErrTTY) { - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: `--domain is required (no TTY); known hosts: ${hosts.join(', ')}`, - }) - } - const choices: HostChoice[] = hosts.map((h) => ({ - host: h, - accounts: Object.keys(reg.hosts[h]?.accounts ?? {}).length, - active: reg.current_host === h, - })) - const picked = await selectFromList<HostChoice>({ - io: opts.io, - items: choices, - header: 'Select a host', - render: (c) => - `${c.active ? '* ' : ' '}${c.host} (${c.accounts} account${c.accounts === 1 ? '' : 's'})`, - }) - return picked.host -} diff --git a/cli/src/commands/use/workspace/index.test.ts b/cli/src/commands/use/workspace/index.test.ts new file mode 100644 index 00000000000..c12953eadb1 --- /dev/null +++ b/cli/src/commands/use/workspace/index.test.ts @@ -0,0 +1,52 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { commands } from '@/plugins/commands' +import { ENV } from '@/plugins/env' +import { session } from '@/plugins/session' + +const worlds: TestWorld[] = [] +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +it('pins the workspace locally without a server call', async () => { + const w = await testContext({ login: true, argv: ['use', 'workspace', 'ws-9'] }) + worlds.push(w) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(w.io.outBuf())).toEqual({ workspace_id: 'ws-9' }) + expect(await (await w.ctx.get(session)).workspaceId()).toBe('ws-9') + expect(w.mock.requestCount).toBe(0) +}) + +it('refuses under an env login and without a login', async () => { + const a = await testContext({ login: false, env: true, argv: ['use', 'workspace', 'ws-9'] }) + worlds.push(a) + await expect((await a.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'usage_invalid_flag', + message: 'the login comes from DIFY_TOKEN; set DIFY_WORKSPACE_ID instead', + }) + const b = await testContext({ login: false, argv: ['use', 'workspace', 'ws-9'] }) + worlds.push(b) + await expect((await b.ctx.get(commands)).run()).rejects.toMatchObject({ code: 'not_logged_in' }) +}) + +it('refuses an empty workspace id, exit 2, and changes nothing', async () => { + const w = await testContext({ login: true, argv: ['use', 'workspace', ''] }) + worlds.push(w) + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'input_invalid', + details: [expect.objectContaining({ loc: ['workspace_id'] })], + }) + expect(await (await w.ctx.get(session)).workspaceId()).toBe('ws-1') +}) + +it('refuses when DIFY_WORKSPACE_ID overrides the pin', async () => { + const w = await testContext({ login: true, argv: ['use', 'workspace', 'ws-9'] }) + worlds.push(w) + process.env[ENV.WorkspaceId] = 'ws-9' + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'usage_invalid_flag', + message: 'DIFY_WORKSPACE_ID is set; unset it to pin locally', + }) +}) diff --git a/cli/src/commands/use/workspace/index.ts b/cli/src/commands/use/workspace/index.ts index 38acc3f39aa..4a09d4fb18f 100644 --- a/cli/src/commands/use/workspace/index.ts +++ b/cli/src/commands/use/workspace/index.ts @@ -1,42 +1,35 @@ -import type { CommandEffect } from '@/framework/command' -import { DifyCommand } from '@/commands/_shared/dify-command' -import { httpRetryFlag } from '@/commands/_shared/global-flags' -import { Args } from '@/framework/flags' -import { runUseWorkspace } from './use' +import type { CommandContext } from '@/plugins/base' +import { z } from 'zod' +import { assertNotEnvLogin } from '@/auth/logout' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { Command } from '@/plugins/commands/command' +import { env } from '@/plugins/env' +import { session } from '@/plugins/session' -export default class UseWorkspace extends DifyCommand { - static override description = - 'Switch the active workspace on the server (omit the id to pick interactively)' +const INPUT = z.object({ workspace_id: z.string().min(1) }) - static override effect: CommandEffect = 'write' +const ENV_WORKSPACE_ID_MESSAGE = 'DIFY_WORKSPACE_ID is set; unset it to pin locally' +const ENV_LOGIN_MESSAGE = 'the login comes from DIFY_TOKEN; set DIFY_WORKSPACE_ID instead' - static override examples = [ - '<%= config.bin %> use workspace ws-abc123', - '<%= config.bin %> use workspace', - ] +export default class WorkspaceUse extends Command<typeof INPUT> { + static override summary = 'Pin the local session to a workspace' + static override effect = 'write' as const + static override input = INPUT + static override positional = ['workspace_id'] as const - static override args = { - workspaceId: Args.string({ - description: 'workspace id to switch to (omit to pick interactively)', - required: false, - }), - } + async run(input: z.infer<typeof INPUT>, ctx: CommandContext) { + const envService = await ctx.get(env) + if (envService.workspaceId !== undefined) { + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: ENV_WORKSPACE_ID_MESSAGE }) + } - static override flags = { - 'http-retry': httpRetryFlag, - } + const sessionService = await ctx.get(session) + const login = await sessionService.require() + assertNotEnvLogin(sessionService.fromEnv, ENV_LOGIN_MESSAGE) - async run(argv: string[]): Promise<void> { - const { args, flags } = this.parse(UseWorkspace, argv) - const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) - await runUseWorkspace( - { workspaceId: args.workspaceId }, - { - reg: ctx.reg, - active: ctx.active, - http: ctx.http, - io: ctx.io, - }, - ) + await sessionService.save({ ...login, workspaceId: input.workspace_id }) + + return { workspace_id: input.workspace_id } } } diff --git a/cli/src/commands/use/workspace/use.test.ts b/cli/src/commands/use/workspace/use.test.ts deleted file mode 100644 index f09ddd2e607..00000000000 --- a/cli/src/commands/use/workspace/use.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { - WorkspaceDetailResponse, - WorkspaceListResponse, -} from '@dify/contracts/api/openapi/types.gen' -import type { ActiveContext } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { Registry } from '@/auth/hosts' -import { selectFromList } from '@/sys/io/select' -import { bufferStreams } from '@/sys/io/streams' -import { runUseWorkspace } from './use.js' - -vi.mock('@/sys/io/select', () => ({ - selectFromList: vi.fn(), -})) - -const selectFromListMock = vi.mocked(selectFromList) - -function makeRegistry(): Registry { - const reg = Registry.empty('file') - reg.upsert('cloud.dify.ai', 'tester@dify.ai', { - account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' }, - workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, - }) - reg.setHost('cloud.dify.ai') - reg.setAccount('tester@dify.ai') - return reg -} - -function makeActive(reg: Registry): ActiveContext { - const active = reg.resolveActive() - if (active === undefined) throw new Error('resolveActive returned undefined in test setup') - return active -} - -function makeDetail(over: Partial<WorkspaceDetailResponse> = {}): WorkspaceDetailResponse { - return { - id: '00000000-0000-0000-0000-000000000002', - name: 'Two', - role: 'owner', - status: 'normal', - current: true, - created_at: '2026-05-18T00:00:00Z', - ...over, - } -} - -function fakeClient(opts: { - switch?: () => Promise<WorkspaceDetailResponse> - list?: () => Promise<WorkspaceListResponse> -}) { - return { - switch: vi.fn(opts.switch ?? (() => Promise.resolve(makeDetail()))), - list: vi.fn( - opts.list ?? - (() => - Promise.resolve({ - page: 1, - limit: 20, - total: 2, - has_more: false, - data: [ - { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, - { - id: '00000000-0000-0000-0000-000000000002', - name: 'Two', - role: 'owner', - status: 'normal', - current: false, - }, - ], - })), - ), - } -} - -describe('runUseWorkspace', () => { - useTempConfigDir('difyctl-use-workspace-') - - beforeEach(() => { - selectFromListMock.mockReset() - }) - - it('arg path: switches directly without listing and persists only the active workspace', async () => { - const io = bufferStreams() - const reg = makeRegistry() - await reg.save() - const active = makeActive(reg) - const client = fakeClient({}) - - const next = await runUseWorkspace( - { workspaceId: '00000000-0000-0000-0000-000000000002' }, - { - reg, - active, - http: {} as HttpClient, - io, - workspacesFactory: () => client as never, - }, - ) - - expect(client.switch).toHaveBeenCalledExactlyOnceWith('00000000-0000-0000-0000-000000000002') - expect(client.list).not.toHaveBeenCalled() - - const activeCtx = next.resolveActive() - expect(activeCtx?.ctx.workspace).toEqual({ - id: '00000000-0000-0000-0000-000000000002', - name: 'Two', - role: 'owner', - }) - expect( - (activeCtx?.ctx as Record<string, unknown> | undefined)?.available_workspaces, - ).toBeUndefined() - - const reloaded = await Registry.load() - const reloadedActive = reloaded?.resolveActive() - expect(reloadedActive?.ctx.workspace?.id).toBe('00000000-0000-0000-0000-000000000002') - expect(reloadedActive?.ctx.workspace?.name).toBe('Two') - expect( - (reloadedActive?.ctx as Record<string, unknown> | undefined)?.available_workspaces, - ).toBeUndefined() - - expect(io.outBuf()).toMatch(/Switched to Two \(00000000-0000-0000-0000-000000000002\)/) - }) - - it('no-arg + no-TTY: rejects with usage_missing_arg and never switches', async () => { - const io = bufferStreams() - io.isErrTTY = false - const reg = makeRegistry() - await reg.save() - const active = makeActive(reg) - const client = fakeClient({}) - - await expect( - runUseWorkspace( - { workspaceId: undefined }, - { reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never }, - ), - ).rejects.toMatchObject({ code: 'usage_missing_arg' }) - - expect(client.switch).not.toHaveBeenCalled() - expect(client.list).not.toHaveBeenCalled() - }) - - it('switch failure: rejects and leaves the active workspace untouched', async () => { - const io = bufferStreams() - const reg = makeRegistry() - await reg.save() - const active = makeActive(reg) - const before = await Registry.load() - - const client = fakeClient({ - switch: () => Promise.reject(new Error('forbidden')), - }) - - await expect( - runUseWorkspace( - { workspaceId: '00000000-0000-0000-0000-000000000002' }, - { reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never }, - ), - ).rejects.toThrow(/forbidden/) - - const after = await Registry.load() - expect(after).toEqual(before) - expect(after?.resolveActive()?.ctx.workspace?.id).toBe('ws-1') - }) - - it('picker path (TTY): lists live workspaces and switches to the selected one', async () => { - const io = bufferStreams() - io.isErrTTY = true - const reg = makeRegistry() - await reg.save() - const active = makeActive(reg) - const client = fakeClient({}) - - selectFromListMock.mockResolvedValue({ - id: '00000000-0000-0000-0000-000000000002', - name: 'Two', - role: 'owner', - }) - - await runUseWorkspace( - { workspaceId: undefined }, - { reg, active, http: {} as HttpClient, io, workspacesFactory: () => client as never }, - ) - - expect(client.list).toHaveBeenCalledOnce() - expect(selectFromListMock).toHaveBeenCalledOnce() - const passed = selectFromListMock.mock.calls[0]![0] - expect(passed.items).toEqual([ - { id: 'ws-1', name: 'Default', role: 'owner' }, - { id: '00000000-0000-0000-0000-000000000002', name: 'Two', role: 'owner' }, - ]) - expect(client.switch).toHaveBeenCalledExactlyOnceWith('00000000-0000-0000-0000-000000000002') - - const reloadedActive = (await Registry.load())?.resolveActive() - expect(reloadedActive?.ctx.workspace?.id).toBe('00000000-0000-0000-0000-000000000002') - }) -}) diff --git a/cli/src/commands/use/workspace/use.ts b/cli/src/commands/use/workspace/use.ts deleted file mode 100644 index 7003482e25f..00000000000 --- a/cli/src/commands/use/workspace/use.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { ActiveContext, Registry, Workspace } from '@/auth/hosts' -import type { HttpClient } from '@/http/types' -import type { IOStreams } from '@/sys/io/streams' -import { WorkspacesClient } from '@/api/workspaces' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { colorEnabled, colorScheme } from '@/sys/io/color' -import { selectFromList } from '@/sys/io/select' -import { runWithSpinner } from '@/sys/io/spinner' - -export type UseWorkspaceOptions = { - readonly workspaceId?: string -} - -export type UseWorkspaceDeps = { - readonly reg: Registry - readonly active: ActiveContext - readonly http: HttpClient - readonly io: IOStreams - readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient -} - -/** - * Switch the caller's active workspace. - * - * With an explicit id we switch directly; with no id we fetch the live - * workspace list and let the caller pick one interactively (TTY only). - * - * The server-side switch is the source of truth: if POST - * `/workspaces/<id>:switch` fails we abort before touching `hosts.yml`, so - * local state never diverges from the server. - */ -export async function runUseWorkspace( - opts: UseWorkspaceOptions, - deps: UseWorkspaceDeps, -): Promise<Registry> { - const cs = colorScheme(colorEnabled(deps.io.isErrTTY)) - const factory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h)) - const client = factory(deps.http) - - const argId = opts.workspaceId?.trim() ?? '' - const id = argId !== '' ? argId : await pickWorkspaceId(client, deps) - - const detail = await runWithSpinner({ io: deps.io, label: `Switching to ${id}` }, () => - client.switch(id), - ) - - const nextCtx = { - ...deps.active.ctx, - workspace: { id: detail.id, name: detail.name, role: detail.role }, - } - deps.reg.upsert(deps.active.host, deps.active.email, nextCtx) - await deps.reg.save() - deps.io.out.write(`${cs.successIcon()} Switched to ${detail.name} (${detail.id})\n`) - return deps.reg -} - -async function pickWorkspaceId(client: WorkspacesClient, deps: UseWorkspaceDeps): Promise<string> { - if (!deps.io.isErrTTY) { - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: 'a workspace id is required (no TTY)', - hint: "pass the id: 'difyctl use workspace <id>'", - }) - } - - const list = await runWithSpinner({ io: deps.io, label: 'Loading workspaces' }, () => - client.list(), - ) - const items = list.data.map<Workspace>((w) => ({ id: w.id, name: w.name, role: w.role })) - if (items.length === 0) { - throw new BaseError({ - code: ErrorCode.AccessDenied, - message: 'no workspaces available to switch to', - }) - } - - const activeId = deps.active.ctx.workspace?.id - const picked = await selectFromList<Workspace>({ - io: deps.io, - items, - header: 'Select a workspace', - render: (w) => `${w.id === activeId ? '* ' : ' '}${w.name} (${w.role})`, - }) - return picked.id -} diff --git a/cli/src/commands/version/index.test.ts b/cli/src/commands/version/index.test.ts new file mode 100644 index 00000000000..bb70b16a84d --- /dev/null +++ b/cli/src/commands/version/index.test.ts @@ -0,0 +1,27 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { commands } from '@/plugins/commands' + +const worlds: TestWorld[] = [] +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +it('prints the client version without a login and adds the server with one', async () => { + const a = await testContext({ login: false, argv: ['version'] }) + worlds.push(a) + expect(await (await a.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(a.io.outBuf())).toEqual({ + client: { + version: '0.0.0-test', + commit: '0000000', + channel: 'dev', + build_date: '1970-01-01T00:00:00.000Z', + }, + }) + const b = await testContext({ login: true, argv: ['version'] }) + worlds.push(b) + await (await b.ctx.get(commands)).run() + expect(JSON.parse(b.io.outBuf()).server).toEqual({ version: '1.6.4', edition: 'CLOUD' }) +}) diff --git a/cli/src/commands/version/index.ts b/cli/src/commands/version/index.ts index 345929f8d6c..984458ddfec 100644 --- a/cli/src/commands/version/index.ts +++ b/cli/src/commands/version/index.ts @@ -1,61 +1,46 @@ -import { DifyCommand } from '@/commands/_shared/dify-command' -import { Flags } from '@/framework/flags' -import { formatted, OutputFormat, raw, stringifyOutput } from '@/framework/output' -import { colorEnabled } from '@/sys/io/color' -import { realStreams } from '@/sys/io/streams' +import type { CommandContext } from '@/plugins/base' +import { z } from 'zod' +import { parseJsonBody } from '@/call/json-body' +import { errorMessage } from '@/errors/message' +import { Command } from '@/plugins/commands/command' +import { http } from '@/plugins/http' +import { io } from '@/plugins/io' +import { session } from '@/plugins/session' import { versionInfo } from '@/version/info' -import { runVersionProbe } from '@/version/probe' -import { renderVersionText } from '@/version/render' -export const COMPAT_FAIL_EXIT_CODE = 64 +const INPUT = z.object({}) +const SERVER_VERSION_PATH = '/openapi/v1/_version' +const SERVER_VERSION_UNAVAILABLE_PREFIX = 'server version unavailable: ' -export default class Version extends DifyCommand { - static override description = 'Show difyctl version, probe server, and report compatibility' - static override examples = [ - '<%= config.bin %> version', - '<%= config.bin %> version --short', - '<%= config.bin %> version --client', - '<%= config.bin %> version -o json', - '<%= config.bin %> version --check-compat', - ] +type ServerVersion = { version: string; edition: string } - static override flags = { - output: Flags.outputFormat({ - options: [OutputFormat.TEXT, OutputFormat.JSON, OutputFormat.YAML], - default: '', - }), - client: Flags.boolean({ description: 'skip server probe' }), - short: Flags.boolean({ description: 'print only the client semver' }), - 'check-compat': Flags.boolean({ - description: `exit ${COMPAT_FAIL_EXIT_CODE} if server is not 'compatible'`, - }), - } +export default class Version extends Command<typeof INPUT> { + static override summary = 'Print the client and server versions' + static override input = INPUT - async run(argv: string[]) { - const { flags } = this.parse(Version, argv) - - if (flags.short) return raw(`${versionInfo.version}\n`) - - const report = await runVersionProbe({ skipServer: flags.client }) - - const io = realStreams(flags.output) - const useColor = colorEnabled(io.isOutTTY) - const output = formatted({ - format: flags.output, - data: { - text: () => renderVersionText(report, { color: useColor }), - json: () => report, - }, - }) - - if (flags['check-compat'] && report.compat.status !== 'compatible') { - // Emit the full report first so `difyctl version -o json --check-compat | jq` - // works exactly like the success path: stdout gets the canonical envelope, - // stderr gets the one-line failure reason, exit code signals the verdict. - io.out.write(stringifyOutput(output)) - this.error(report.compat.detail, { exit: COMPAT_FAIL_EXIT_CODE }) + async run(_input: z.infer<typeof INPUT>, ctx: CommandContext) { + const client = { + version: versionInfo.version, + commit: versionInfo.commit, + channel: versionInfo.channel, + build_date: versionInfo.buildDate, } + const login = await (await ctx.get(session)).current() + if (login === null) return { client } - return output + const streams = await ctx.get(io) + const httpService = await ctx.get(http) + try { + const res = await httpService.request(() => ({ + method: 'GET', + path: SERVER_VERSION_PATH, + auth: false, + })) + const body = (await parseJsonBody(res)) as ServerVersion + return { client, server: { version: body.version, edition: body.edition } } + } catch (err) { + streams.notice(`${SERVER_VERSION_UNAVAILABLE_PREFIX}${errorMessage(err)}`) + return { client, server: null } + } } } diff --git a/cli/src/commands/version/version.test.ts b/cli/src/commands/version/version.test.ts deleted file mode 100644 index f491b726397..00000000000 --- a/cli/src/commands/version/version.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import * as info from '@/version/info' -import * as probe from '@/version/probe' -import Version, { COMPAT_FAIL_EXIT_CODE } from './index' - -function fakeReport( - overrides: { - channel?: probe.VersionReport['client']['channel'] - reachable?: boolean - status?: probe.VersionReport['compat']['status'] - } = {}, -): probe.VersionReport { - return { - client: { - version: '0.1.0-rc.1', - commit: '2fd7b82970abcdef', - buildDate: '2026-05-18T00:00:00Z', - channel: overrides.channel ?? 'stable', - platform: 'darwin', - arch: 'arm64', - }, - server: - overrides.reachable === false - ? { endpoint: '', reachable: false } - : { - endpoint: 'https://cloud.dify.ai', - reachable: true, - version: '1.6.4', - edition: 'CLOUD', - }, - compat: { - minDify: '1.6.0', - maxDify: '1.7.0', - status: overrides.status ?? 'compatible', - detail: 'server 1.6.4 in [1.6.0, 1.7.0]', - }, - } -} - -describe('Version command', () => { - beforeEach(() => { - vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport()) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('emits formatted text output by default with three blocks', async () => { - const output = await new Version().run([]) - expect(output?.kind).toBe('formatted') - if (output?.kind !== 'formatted') throw new Error('expected formatted output') - - const text = output.data.text() - expect(text).toContain('Client:') - expect(text).toContain('Server:') - expect(text).toContain('Compatibility: ok') - }) - - it('emits the canonical envelope when -o json is passed', async () => { - const output = await new Version().run(['-o', 'json']) - expect(output?.kind).toBe('formatted') - if (output?.kind !== 'formatted') throw new Error('expected formatted output') - - const payload = output.data.json() as probe.VersionReport - expect(payload).toHaveProperty('client') - expect(payload).toHaveProperty('server') - expect(payload).toHaveProperty('compat') - expect(payload.compat).toHaveProperty('minDify') - expect(payload.compat).toHaveProperty('maxDify') - expect(payload.compat).toHaveProperty('status') - expect(payload.server.reachable).toBe(true) - }) - - it('threads -o yaml through formatted output (envelope, not text)', async () => { - const output = await new Version().run(['-o', 'yaml']) - expect(output?.kind).toBe('formatted') - if (output?.kind !== 'formatted') throw new Error('expected formatted output') - expect(output.format).toBe('yaml') - // The same envelope drives json + yaml — assert the shape via the json - // facet (stringifyOutput uses js-yaml.dump on this object). - const payload = output.data.json() as probe.VersionReport - expect(payload.compat.status).toBe('compatible') - expect(payload.server.version).toBe('1.6.4') - }) - - it('--short returns a raw single-line semver output', async () => { - const orig = info.versionInfo.version - Object.assign(info.versionInfo, { version: '0.2.0' }) - try { - const output = await new Version().run(['--short']) - expect(output?.kind).toBe('raw') - if (output?.kind !== 'raw') throw new Error('expected raw output') - - expect(output.data).toBe('0.2.0\n') - } finally { - Object.assign(info.versionInfo, { version: orig }) - } - }) - - it('passes skipServer=true to the probe when --client is set', async () => { - const spy = vi - .spyOn(probe, 'runVersionProbe') - .mockResolvedValue(fakeReport({ reachable: false, status: 'unknown' })) - await new Version().run(['--client']) - expect(spy).toHaveBeenCalledWith({ skipServer: true }) - }) - - function stubProcessExit() { - const impl = (() => { - throw new Error('__exit__') - }) as never - return vi.spyOn(process, 'exit').mockImplementation(impl) - } - - it('--check-compat exits with COMPAT_FAIL_EXIT_CODE when compat is unsupported', async () => { - vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'too_new' })) - const exitSpy = stubProcessExit() - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - await expect(new Version().run(['--check-compat'])).rejects.toThrow('__exit__') - expect(exitSpy).toHaveBeenCalledWith(COMPAT_FAIL_EXIT_CODE) - expect(stderrSpy).toHaveBeenCalled() - }) - - it('--check-compat -o json emits the JSON envelope on stdout before exiting', async () => { - vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'too_new' })) - const exitSpy = stubProcessExit() - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - await expect(new Version().run(['--check-compat', '-o', 'json'])).rejects.toThrow('__exit__') - - // stdout must receive a parseable JSON envelope so pipelines like - // `difyctl version -o json --check-compat | jq` still work on failure. - expect(stdoutSpy).toHaveBeenCalled() - const written = stdoutSpy.mock.calls.map((c) => String(c[0])).join('') - const parsed = JSON.parse(written) as { compat: { status: string } } - expect(parsed.compat.status).toBe('too_new') - expect(exitSpy).toHaveBeenCalledWith(COMPAT_FAIL_EXIT_CODE) - }) - - it('--check-compat exits 64 when compat is unknown (no server)', async () => { - vi.spyOn(probe, 'runVersionProbe').mockResolvedValue( - fakeReport({ reachable: false, status: 'unknown' }), - ) - const exitSpy = stubProcessExit() - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - await expect(new Version().run(['--check-compat'])).rejects.toThrow('__exit__') - expect(exitSpy).toHaveBeenCalledWith(COMPAT_FAIL_EXIT_CODE) - }) - - it('--check-compat does not exit when compat is compatible', async () => { - const exitSpy = stubProcessExit() - const output = await new Version().run(['--check-compat']) - expect(exitSpy).not.toHaveBeenCalled() - expect(output?.kind).toBe('formatted') - }) - - it('renders RC warning in text output when channel is rc', async () => { - vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ channel: 'rc' })) - const output = await new Version().run([]) - if (output?.kind !== 'formatted') throw new Error('expected formatted output') - - expect(output.data.text()).toContain('WARNING: This build is a(n) rc release') - }) -}) diff --git a/cli/src/commands/workspace/_list/index.test.ts b/cli/src/commands/workspace/_list/index.test.ts new file mode 100644 index 00000000000..97e2b3ce568 --- /dev/null +++ b/cli/src/commands/workspace/_list/index.test.ts @@ -0,0 +1,36 @@ +// Skipped: `get workspace` moved out of the tree; see docs/superpowers/specs/2026-09-22-difyctl-v2-human-output-design.md +import type { TestWorld } from '@test/fixtures/kernel' +import type { BaseError } from '@/errors/base' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { ExitCode } from '@/errors/codes' +import { commands } from '@/plugins/commands' + +const worlds: TestWorld[] = [] +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +it.skip('marks the pinned workspace, exactly one true', async () => { + const w = await testContext({ login: true, argv: ['get', 'workspace'] }) + worlds.push(w) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + const body = JSON.parse(w.io.outBuf()) as { data: Array<{ id: string; pinned: boolean }> } + expect(body.data.length).toBeGreaterThan(0) + const pinned = body.data.filter((row) => row.pinned) + expect(pinned).toHaveLength(1) + expect(pinned[0]?.id).toBe('ws-1') +}) + +it.skip('a response with no data array is a server error, exit 1', async () => { + const w = await testContext({ login: true, argv: ['get', 'workspace'] }) + worlds.push(w) + w.mock.setScenario('workspaces-malformed') + const err = await (await w.ctx.get(commands)).run().catch((e: unknown) => e) + expect(err).toMatchObject({ + code: 'server_error', + message: 'get workspace response has no data array', + }) + expect((err as BaseError).exit()).toBe(ExitCode.Generic) + expect(w.io.outBuf()).toBe('') +}) diff --git a/cli/src/commands/workspace/_list/index.ts b/cli/src/commands/workspace/_list/index.ts new file mode 100644 index 00000000000..98db0d47695 --- /dev/null +++ b/cli/src/commands/workspace/_list/index.ts @@ -0,0 +1,47 @@ +import type { CommandContext } from '@/plugins/base' +import { z } from 'zod' +import { readLocalFile } from '@/call/files' +import { parseJsonBody } from '@/call/json-body' +import { buildRequest } from '@/call/request' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { Command } from '@/plugins/commands/command' +import { http } from '@/plugins/http' +import { session } from '@/plugins/session' +import { isRecord } from '@/util/is-record' + +const INPUT = z.object({}) + +const WORKSPACE_LIST_OP = 'get.workspace' +const NO_DATA_ARRAY_MESSAGE = 'get workspace response has no data array' + +type WorkspaceListEnvelope = { + data: readonly Record<string, unknown>[] +} & Record<string, unknown> + +function asEnvelope(body: unknown): WorkspaceListEnvelope { + if (!isRecord(body) || !Array.isArray(body.data)) + throw new BaseError({ code: ErrorCode.ServerError, message: NO_DATA_ARRAY_MESSAGE }) + return body as WorkspaceListEnvelope +} + +export default class WorkspaceList extends Command<typeof INPUT> { + static override summary = 'List the workspaces visible to the current account' + static override input = INPUT + + async run(_input: z.infer<typeof INPUT>, ctx: CommandContext) { + const httpService = await ctx.get(http) + const res = await httpService.request(async (cat) => + buildRequest(cat.opOrThrow(WORKSPACE_LIST_OP), {}, { readFile: readLocalFile }), + ) + const envelope = asEnvelope(await parseJsonBody(res)) + + const sessionService = await ctx.get(session) + const pinnedId = await sessionService.workspaceId() + + return { + ...envelope, + data: envelope.data.map((row) => ({ ...row, pinned: row.id === pinnedId })), + } + } +} diff --git a/cli/src/config/config-loader.test.ts b/cli/src/config/config-loader.test.ts deleted file mode 100644 index 732865088c8..00000000000 --- a/cli/src/config/config-loader.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { YamlStore } from '@/store/store' -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { describe, expect, it } from 'vite-plus/test' -import { isBaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { getConfigurationStore } from '@/store/manager' -import { loadConfig } from './config-loader' - -describe('loadConfig', () => { - useTempConfigDir('difyctl-cfg-') - - it('returns found:false when config is missing', async () => { - const r = await loadConfig(getConfigurationStore()) - expect(r.found).toBe(false) - }) - - it('parses a minimal valid config', async () => { - await getConfigurationStore().setTyped({ schema_version: 1 }) - const r = await loadConfig(getConfigurationStore()) - expect(r.found).toBe(true) - if (r.found) expect(r.config.schema_version).toBe(1) - }) - - it('parses defaults + state', async () => { - await getConfigurationStore().setTyped({ - schema_version: 1, - defaults: { format: 'json', limit: 100 }, - state: { current_app: 'app-1' }, - }) - const r = await loadConfig(getConfigurationStore()) - expect(r.found).toBe(true) - if (r.found) { - expect(r.config.defaults.format).toBe('json') - expect(r.config.defaults.limit).toBe(100) - expect(r.config.state.current_app).toBe('app-1') - } - }) - - it('throws BaseError(config_schema_unsupported) when the store fails to parse the file', async () => { - // Simulate a corrupt on-disk file via a fake store; loadConfig must wrap - // the underlying error as ConfigSchemaUnsupported. - const throwingStore = { - getTyped: () => { - throw new Error('YAML parse failure') - }, - } as unknown as YamlStore - let caught: unknown - try { - await loadConfig(throwingStore) - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) { - expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported) - expect(caught.hint).toMatch(/not valid YAML/) - } - }) - - it('throws BaseError(config_schema_unsupported) when zod validation fails', async () => { - await getConfigurationStore().setTyped({ defaults: { limit: 9999 } }) - let caught: unknown - try { - await loadConfig(getConfigurationStore()) - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported) - }) - - it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', async () => { - await getConfigurationStore().setTyped({ schema_version: 2 }) - let caught: unknown - try { - await loadConfig(getConfigurationStore()) - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) { - expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported) - expect(caught.message).toMatch(/schema_version=2/) - expect(caught.hint).toMatch(/upgrade difyctl/) - } - }) -}) diff --git a/cli/src/config/config-loader.ts b/cli/src/config/config-loader.ts deleted file mode 100644 index 1ae3f825311..00000000000 --- a/cli/src/config/config-loader.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ConfigFile } from './schema' -import type { YamlStore } from '@/store/store' -import { newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { ConfigFileSchema, CURRENT_SCHEMA_VERSION } from './schema' - -export type LoadResult = { found: false } | { found: true; config: ConfigFile } - -export async function loadConfig(store: YamlStore): Promise<LoadResult> { - let raw: Record<string, unknown> | null - try { - raw = await store.getTyped<Record<string, unknown>>() - } catch (err) { - throw newError(ErrorCode.ConfigSchemaUnsupported, `parse config.yml: ${(err as Error).message}`) - .wrap(err) - .withHint('config.yml is not valid YAML') - } - - if (raw === null) return { found: false } - - const result = ConfigFileSchema.safeParse(raw) - if (!result.success) { - throw newError( - ErrorCode.ConfigSchemaUnsupported, - `validate config.yml: ${result.error.issues.map((i) => i.message).join('; ')}`, - ).withHint('config.yml does not match the v1 schema') - } - - if (result.data.schema_version > CURRENT_SCHEMA_VERSION) { - throw newError( - ErrorCode.ConfigSchemaUnsupported, - `config.yml schema_version=${result.data.schema_version} is newer than this binary supports (max=${CURRENT_SCHEMA_VERSION})`, - ).withHint('upgrade difyctl, or remove config.yml') - } - - return { found: true, config: result.data } -} diff --git a/cli/src/config/keys.test.ts b/cli/src/config/keys.test.ts deleted file mode 100644 index 14cb4d92d82..00000000000 --- a/cli/src/config/keys.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { isBaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { getKey, knownKeyNames, knownKeys, lookupKey, setKey, unsetKey } from './keys' -import { emptyConfig } from './schema' - -describe('config keys', () => { - it('exposes the v1.0 key set: defaults.format, defaults.limit, state.current_app', () => { - expect([...knownKeyNames()].sort()).toEqual([ - 'defaults.format', - 'defaults.limit', - 'state.current_app', - ]) - }) - - it('knownKeys is alphabetically sorted', () => { - const names = knownKeys().map((k) => k.name) - const sorted = [...names].sort() - expect(names).toEqual(sorted) - }) - - it('lookupKey returns the spec by name', () => { - expect(lookupKey('defaults.format')?.description).toMatch(/format/i) - expect(lookupKey('nope')).toBeUndefined() - }) - - describe('getKey', () => { - it('returns empty string for unset values', () => { - const cfg = emptyConfig() - expect(getKey(cfg, 'defaults.format')).toBe('') - expect(getKey(cfg, 'defaults.limit')).toBe('') - expect(getKey(cfg, 'state.current_app')).toBe('') - }) - - it('throws config_invalid_key for unknown keys', () => { - let caught: unknown - try { - getKey(emptyConfig(), 'nope') - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) - }) - }) - - describe('setKey', () => { - it('sets defaults.format when value is in the allowed enum', () => { - const updated = setKey(emptyConfig(), 'defaults.format', 'json') - expect(updated.defaults.format).toBe('json') - }) - - it('throws config_invalid_value for unknown format', () => { - let caught: unknown - try { - setKey(emptyConfig(), 'defaults.format', 'csv') - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) { - expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) - expect(caught.message).toMatch(/csv/) - } - }) - - it('sets defaults.limit when value is 1..200', () => { - const updated = setKey(emptyConfig(), 'defaults.limit', '50') - expect(updated.defaults.limit).toBe(50) - }) - - it('throws config_invalid_value for limit outside 1..200', () => { - let caught: unknown - try { - setKey(emptyConfig(), 'defaults.limit', '999') - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) - }) - - it('throws config_invalid_value for non-numeric limit', () => { - let caught: unknown - try { - setKey(emptyConfig(), 'defaults.limit', 'abc') - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidValue) - }) - - it('sets state.current_app to any string', () => { - const updated = setKey(emptyConfig(), 'state.current_app', 'app-123') - expect(updated.state.current_app).toBe('app-123') - }) - - it('returns a new config object (does not mutate the original)', () => { - const original = emptyConfig() - const updated = setKey(original, 'defaults.format', 'yaml') - expect(original.defaults.format).toBeUndefined() - expect(updated.defaults.format).toBe('yaml') - }) - }) - - describe('unsetKey', () => { - it('clears a previously-set defaults.format', () => { - const set = setKey(emptyConfig(), 'defaults.format', 'json') - const unset = unsetKey(set, 'defaults.format') - expect(unset.defaults.format).toBeUndefined() - }) - - it('clears a previously-set defaults.limit', () => { - const set = setKey(emptyConfig(), 'defaults.limit', '99') - const unset = unsetKey(set, 'defaults.limit') - expect(unset.defaults.limit).toBeUndefined() - }) - - it('clears state.current_app', () => { - const set = setKey(emptyConfig(), 'state.current_app', 'app-1') - const unset = unsetKey(set, 'state.current_app') - expect(unset.state.current_app).toBeUndefined() - }) - - it('throws config_invalid_key for unknown keys', () => { - let caught: unknown - try { - unsetKey(emptyConfig(), 'nope') - } catch (err) { - caught = err - } - expect(isBaseError(caught)).toBe(true) - if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey) - }) - }) -}) diff --git a/cli/src/config/keys.ts b/cli/src/config/keys.ts deleted file mode 100644 index c5523bf06d8..00000000000 --- a/cli/src/config/keys.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { AllowedFormat, ConfigFile } from './schema' -import { newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { parseLimit } from '@/limit/limit' -import { ALLOWED_FORMATS } from './schema' - -export type KeySpec = { - readonly name: string - readonly description: string - get: (config: ConfigFile) => string - set: (config: ConfigFile, value: string) => ConfigFile - unset: (config: ConfigFile) => ConfigFile -} - -const KEYS: readonly KeySpec[] = [ - { - name: 'defaults.format', - description: `Default output format used when -o is not passed (${ALLOWED_FORMATS.join('|')}).`, - get: (c) => c.defaults.format ?? '', - set: (c, v) => { - if (!(ALLOWED_FORMATS as readonly string[]).includes(v)) { - throw newError( - ErrorCode.ConfigInvalidValue, - `defaults.format: ${JSON.stringify(v)} is not one of ${ALLOWED_FORMATS.join('|')}`, - ) - } - return { ...c, defaults: { ...c.defaults, format: v as AllowedFormat } } - }, - unset: (c) => ({ ...c, defaults: { ...c.defaults, format: undefined } }), - }, - { - name: 'defaults.limit', - description: 'Default page size for list commands (1..200).', - get: (c) => (c.defaults.limit === undefined ? '' : String(c.defaults.limit)), - set: (c, v) => { - try { - const n = parseLimit(v, 'defaults.limit') - return { ...c, defaults: { ...c.defaults, limit: n } } - } catch (err) { - throw newError(ErrorCode.ConfigInvalidValue, (err as Error).message).wrap(err) - } - }, - unset: (c) => ({ ...c, defaults: { ...c.defaults, limit: undefined } }), - }, - { - name: 'state.current_app', - description: - 'App ID used when commands need an app context but no positional argument is given.', - get: (c) => c.state.current_app ?? '', - set: (c, v) => ({ ...c, state: { ...c.state, current_app: v } }), - unset: (c) => ({ ...c, state: { ...c.state, current_app: undefined } }), - }, -] - -const SORTED: readonly KeySpec[] = [...KEYS].sort((a, b) => a.name.localeCompare(b.name)) -const BY_NAME = new Map(SORTED.map((k) => [k.name, k])) - -export function knownKeys(): readonly KeySpec[] { - return SORTED -} - -export function knownKeyNames(): readonly string[] { - return SORTED.map((k) => k.name) -} - -export function lookupKey(name: string): KeySpec | undefined { - return BY_NAME.get(name) -} - -export function getKey(config: ConfigFile, name: string): string { - const spec = lookupKey(name) - if (spec === undefined) throw unknownKey(name) - return spec.get(config) -} - -export function setKey(config: ConfigFile, name: string, value: string): ConfigFile { - const spec = lookupKey(name) - if (spec === undefined) throw unknownKey(name) - return spec.set(config, value) -} - -export function unsetKey(config: ConfigFile, name: string): ConfigFile { - const spec = lookupKey(name) - if (spec === undefined) throw unknownKey(name) - return spec.unset(config) -} - -function unknownKey(name: string): Error { - return newError( - ErrorCode.ConfigInvalidKey, - `unknown config key ${JSON.stringify(name)} (known: ${knownKeyNames().join(', ')})`, - ) -} diff --git a/cli/src/config/schema.test.ts b/cli/src/config/schema.test.ts deleted file mode 100644 index a97e0c135f7..00000000000 --- a/cli/src/config/schema.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { CONFIG_FILE_NAME } from '@/store/manager' -import { ALLOWED_FORMATS, ConfigFileSchema, CURRENT_SCHEMA_VERSION, emptyConfig } from './schema' - -describe('config schema', () => { - it('CURRENT_SCHEMA_VERSION is 1', () => { - expect(CURRENT_SCHEMA_VERSION).toBe(1) - }) - - it('CONFIG_FILE_NAME is config.yml', () => { - expect(CONFIG_FILE_NAME).toBe('config.yml') - }) - - it('ALLOWED_FORMATS matches Go set (json/yaml/table/wide/name/text)', () => { - expect([...ALLOWED_FORMATS].sort()).toEqual(['json', 'name', 'table', 'text', 'wide', 'yaml']) - }) - - it('emptyConfig fills defaults + state with empty objects', () => { - const cfg = emptyConfig() - expect(cfg.schema_version).toBe(0) - expect(cfg.defaults).toEqual({}) - expect(cfg.state).toEqual({}) - }) - - it('rejects defaults.limit out of bounds', () => { - expect(ConfigFileSchema.safeParse({ defaults: { limit: 0 } }).success).toBe(false) - expect(ConfigFileSchema.safeParse({ defaults: { limit: 201 } }).success).toBe(false) - expect(ConfigFileSchema.safeParse({ defaults: { limit: 50 } }).success).toBe(true) - }) - - it('rejects defaults.format outside the enum', () => { - expect(ConfigFileSchema.safeParse({ defaults: { format: 'csv' } }).success).toBe(false) - expect(ConfigFileSchema.safeParse({ defaults: { format: 'json' } }).success).toBe(true) - }) - - it('accepts the full v1 shape', () => { - const r = ConfigFileSchema.safeParse({ - schema_version: 1, - defaults: { format: 'yaml', limit: 100 }, - state: { current_app: 'app-123' }, - }) - expect(r.success).toBe(true) - if (r.success) { - expect(r.data.defaults.format).toBe('yaml') - expect(r.data.defaults.limit).toBe(100) - expect(r.data.state.current_app).toBe('app-123') - } - }) - - it('parses an empty object into emptyConfig() shape', () => { - const r = ConfigFileSchema.safeParse({}) - expect(r.success).toBe(true) - if (r.success) expect(r.data).toEqual(emptyConfig()) - }) -}) diff --git a/cli/src/config/schema.ts b/cli/src/config/schema.ts deleted file mode 100644 index 28eed53d419..00000000000 --- a/cli/src/config/schema.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from 'zod' - -export const CURRENT_SCHEMA_VERSION = 1 - -export const ALLOWED_FORMATS = ['json', 'yaml', 'table', 'wide', 'name', 'text'] as const -export type AllowedFormat = (typeof ALLOWED_FORMATS)[number] - -export const DefaultsSchema = z - .object({ - format: z.enum(ALLOWED_FORMATS).optional(), - limit: z.number().int().min(1).max(200).optional(), - }) - .default({}) - -export const StateSchema = z - .object({ - current_app: z.string().optional(), - }) - .default({}) - -export const ConfigFileSchema = z.object({ - schema_version: z.number().int().nonnegative().default(0), - defaults: DefaultsSchema, - state: StateSchema, -}) - -export type ConfigFile = z.infer<typeof ConfigFileSchema> - -export function emptyConfig(): ConfigFile { - return ConfigFileSchema.parse({}) -} diff --git a/cli/src/discovery/search.test.ts b/cli/src/discovery/search.test.ts new file mode 100644 index 00000000000..8f4ae48c8fd --- /dev/null +++ b/cli/src/discovery/search.test.ts @@ -0,0 +1,26 @@ +import type { Describable, SearchDoc } from './search' +import { expect, it } from 'vite-plus/test' +import { search, searchDoc } from './search' + +function doc(id: string, source: Partial<Describable>, deprecated = false): SearchDoc { + return searchDoc(id, { summary: '', input: {}, examples: [], ...source }, deprecated) +} + +const DOCS = [ + doc('run.console_app.chat', { summary: 'Run a chat or agent app' }), + doc('run.console_app.workflow', { summary: 'Run a workflow app' }), + doc('get.console_app', { input: { properties: { page: { description: 'Page number' } } } }), + doc('console_app.run', { summary: 'Deprecated: use the per-mode run' }, true), +] +const ids = (query: string) => search(DOCS, query, { limit: 20 }).hits.map((hit) => hit.id) + +it('ranks id tokens first, reaches schema text, takes compounds and typos, keeps deprecated last', () => { + expect(ids('workflow')[0]).toBe('run.console_app.workflow') + expect(ids('agents nonsense')).toEqual(['run.console_app.chat']) + expect(ids('page')).toEqual(['get.console_app']) + expect(ids('chatbot')[0]).toBe('run.console_app.chat') + expect(ids('worflow')[0]).toBe('run.console_app.workflow') + expect(ids('running').at(-1)).toBe('console_app.run') + expect(search(DOCS, 'app', { limit: 1 })).toMatchObject({ total: 4 }) + expect(ids('the zebra')).toEqual([]) +}) diff --git a/cli/src/discovery/search.ts b/cli/src/discovery/search.ts new file mode 100644 index 00000000000..cda39e868a0 --- /dev/null +++ b/cli/src/discovery/search.ts @@ -0,0 +1,205 @@ +import type { Example, JsonSchema } from '@/plugins/catalog' +import { editDistance } from '@/util/edit-distance' +import { isRecord } from '@/util/is-record' + +export const FIELD = { + Id: 'id', + Summary: 'summary', + Example: 'example', + Input: 'input', +} as const +export type Field = (typeof FIELD)[keyof typeof FIELD] + +const WEIGHT: Readonly<Record<Field, number>> = { + [FIELD.Id]: 5, + [FIELD.Summary]: 3, + [FIELD.Example]: 2, + [FIELD.Input]: 1, +} +const FIELDS = Object.values(FIELD) + +export type Describable = Readonly<{ + summary: string + input: JsonSchema + examples: readonly Example[] +}> + +export type SearchDoc = Readonly<{ + id: string + deprecated: boolean + fields: Readonly<Record<Field, readonly string[]>> +}> + +export type SearchHit = Readonly<{ id: string; score: number }> +export type SearchResult = Readonly<{ hits: readonly SearchHit[]; total: number }> + +const TOKEN_BREAK = /[^a-z0-9]+/ +const STOPWORDS: ReadonlySet<string> = new Set([ + 'a', + 'an', + 'and', + 'as', + 'at', + 'be', + 'by', + 'for', + 'from', + 'in', + 'is', + 'it', + 'its', + 'of', + 'on', + 'or', + 'the', + 'this', + 'to', + 'with', +]) + +const SCHEMA_KEYS_WITH_TEXT = ['description', 'title'] as const +const SCHEMA_DEPTH_LIMIT = 4 + +const RUNG_EXACT = 1 +const RUNG_TERM_PREFIX = 0.6 +const RUNG_TOKEN_PREFIX = 0.5 +const RUNG_TYPO = 0.4 +const RUNG_NONE = 0 +const TERM_PREFIX_MIN = 3 +const TOKEN_PREFIX_MIN = 4 +const TYPO_MIN = 5 +const TYPO_DISTANCE = 1 + +const K1 = 1.2 +const B = 0.75 + +function stem(token: string): string { + if (token.length > 4 && token.endsWith('ies')) return `${token.slice(0, -3)}y` + if (token.length > 5 && token.endsWith('ing')) return undouble(token.slice(0, -3)) + if (token.length > 4 && token.endsWith('ed')) return undouble(token.slice(0, -2)) + if (token.length > 3 && token.endsWith('s') && !token.endsWith('ss')) return token.slice(0, -1) + return token +} + +function undouble(token: string): string { + const last = token.at(-1) + if (last === undefined || last !== token.at(-2) || 'lsz'.includes(last)) return token + return token.slice(0, -1) +} + +export function tokenize(text: string): string[] { + return text + .toLowerCase() + .split(TOKEN_BREAK) + .filter((token) => token !== '' && !STOPWORDS.has(token)) + .map(stem) +} + +function schemaText(schema: unknown, depth: number, out: string[]): void { + if (!isRecord(schema) || depth > SCHEMA_DEPTH_LIMIT) return + for (const key of SCHEMA_KEYS_WITH_TEXT) { + const value = schema[key] + if (typeof value === 'string') out.push(value) + } + const properties = schema.properties + if (isRecord(properties)) { + for (const [name, child] of Object.entries(properties)) { + out.push(name) + schemaText(child, depth + 1, out) + } + } + schemaText(schema.items, depth + 1, out) +} + +export function searchDoc(id: string, source: Describable, deprecated: boolean): SearchDoc { + const input: string[] = [] + schemaText(source.input, 0, input) + return { + id, + deprecated, + fields: { + [FIELD.Id]: tokenize(id), + [FIELD.Summary]: tokenize(source.summary), + [FIELD.Example]: tokenize(source.examples.map((example) => example.title).join(' ')), + [FIELD.Input]: tokenize(input.join(' ')), + }, + } +} + +function rung(term: string, token: string): number { + if (term === token) return RUNG_EXACT + if (term.length >= TERM_PREFIX_MIN && token.startsWith(term)) return RUNG_TERM_PREFIX + if (token.length >= TOKEN_PREFIX_MIN && term.startsWith(token)) return RUNG_TOKEN_PREFIX + if (term.length >= TYPO_MIN && editDistance(term, token) <= TYPO_DISTANCE) return RUNG_TYPO + return RUNG_NONE +} + +function frequency(term: string, tokens: readonly string[]): number { + let total = 0 + for (const token of tokens) total += rung(term, token) + return total +} + +function averageLengths(docs: readonly SearchDoc[]): Record<Field, number> { + const out = {} as Record<Field, number> + for (const field of FIELDS) { + const sum = docs.reduce((acc, doc) => acc + doc.fields[field].length, 0) + out[field] = docs.length === 0 ? 0 : sum / docs.length + } + return out +} + +function saturate(tf: number, length: number, average: number): number { + const norm = average === 0 ? 1 : 1 - B + (B * length) / average + return (tf * (K1 + 1)) / (tf + K1 * norm) +} + +function idf(matching: number, total: number): number { + return Math.log(1 + (total - matching + 0.5) / (matching + 0.5)) +} + +function compare(a: SearchDoc & SearchHit, b: SearchDoc & SearchHit): number { + if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1 + if (a.score !== b.score) return b.score - a.score + return a.id < b.id ? -1 : 1 +} + +export function search( + docs: readonly SearchDoc[], + query: string, + opts: Readonly<{ limit: number }>, +): SearchResult { + const terms = [...new Set(tokenize(query))] + const average = averageLengths(docs) + const scores = new Map<string, number>(docs.map((doc) => [doc.id, 0])) + + for (const term of terms) { + const perDoc = docs.map((doc) => { + const tf = {} as Record<Field, number> + for (const field of FIELDS) tf[field] = frequency(term, doc.fields[field]) + return tf + }) + const matching = perDoc.filter((tf) => FIELDS.some((field) => tf[field] > 0)).length + if (matching === 0) continue + const weightOfTerm = idf(matching, docs.length) + docs.forEach((doc, at) => { + const tf = perDoc[at] + if (tf === undefined) return + let score = 0 + for (const field of FIELDS) { + if (tf[field] === 0) continue + score += WEIGHT[field] * saturate(tf[field], doc.fields[field].length, average[field]) + } + scores.set(doc.id, (scores.get(doc.id) ?? 0) + weightOfTerm * score) + }) + } + + const ranked = docs + .map((doc) => ({ ...doc, score: scores.get(doc.id) ?? 0 })) + .filter((doc) => doc.score > 0) + .sort(compare) + return { + hits: ranked.slice(0, opts.limit).map(({ id, score }) => ({ id, score })), + total: ranked.length, + } +} diff --git a/cli/src/discovery/tree.ts b/cli/src/discovery/tree.ts new file mode 100644 index 00000000000..c2cad35acd2 --- /dev/null +++ b/cli/src/discovery/tree.ts @@ -0,0 +1,37 @@ +export function namespacesOf(ids: readonly string[], sep: string): ReadonlySet<string> { + const out = new Set<string>() + for (const id of ids) { + const parts = id.split(sep) + for (let depth = 1; depth < parts.length; depth++) out.add(parts.slice(0, depth).join(sep)) + } + return out +} + +export function under(namespace: string, ids: readonly string[], sep: string): string[] { + const prefix = `${namespace}${sep}` + return ids.filter((id) => id.startsWith(prefix)) +} + +export function byHead( + ids: readonly string[], + sep: string, +): ReadonlyMap<string, readonly string[]> { + const out = new Map<string, string[]>() + for (const id of [...ids].sort()) { + const at = id.indexOf(sep) + const head = at === -1 ? id : id.slice(0, at) + const rest = out.get(head) ?? [] + if (at !== -1) rest.push(id.slice(at + 1)) + out.set(head, rest) + } + return out +} + +export function groupsOf(remainders: readonly string[], sep: string): string[] { + const groups = new Set<string>() + for (const rest of remainders) { + const at = rest.indexOf(sep) + if (at !== -1) groups.add(rest.slice(0, at)) + } + return [...groups] +} diff --git a/cli/src/env/registry.test.ts b/cli/src/env/registry.test.ts deleted file mode 100644 index a584f69a0a2..00000000000 --- a/cli/src/env/registry.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' -import { ENV_REGISTRY, getEnv, lookupEnv, resolveEnv } from './registry' - -describe('env registry', () => { - it('contains every DIFY_* var from the v1.0 spec', () => { - const names = ENV_REGISTRY.map((e) => e.name) - expect(names).toContain('DIFY_TOKEN') - expect(names).toContain('DIFY_HOST') - expect(names).toContain('DIFY_WORKSPACE_ID') - expect(names).toContain('DIFY_CONFIG_DIR') - expect(names).toContain('DIFY_LIMIT') - expect(names).toContain('DIFY_FORMAT') - expect(names).toContain('DIFY_NO_PROGRESS') - expect(names).toContain('DIFY_PLAIN') - }) - - it('is sorted alphabetically (matches Go init() ordering)', () => { - const names = ENV_REGISTRY.map((e) => e.name) - const sorted = [...names].sort() - expect(names).toEqual(sorted) - }) - - it('marks DIFY_TOKEN as sensitive', () => { - expect(lookupEnv('DIFY_TOKEN')?.sensitive).toBe(true) - }) - - it('does not mark non-secret vars as sensitive', () => { - expect(lookupEnv('DIFY_HOST')?.sensitive).toBeFalsy() - expect(lookupEnv('DIFY_LIMIT')?.sensitive).toBeFalsy() - }) - - it('lookupEnv returns undefined for unknown name', () => { - expect(lookupEnv('DIFY_NOPE')).toBeUndefined() - }) - - it('lookupEnv finds the registry entry by name', () => { - expect(lookupEnv('DIFY_HOST')?.description).toMatch(/host/i) - }) - - describe('process.env reads', () => { - const originals: Record<string, string | undefined> = {} - beforeEach(() => { - originals.DIFY_LIMIT = process.env.DIFY_LIMIT - originals.DIFY_HOST = process.env.DIFY_HOST - originals.DIFY_TEST_NONEXISTENT = process.env.DIFY_TEST_NONEXISTENT - delete process.env.DIFY_LIMIT - delete process.env.DIFY_HOST - delete process.env.DIFY_TEST_NONEXISTENT - }) - afterEach(() => { - for (const [k, v] of Object.entries(originals)) { - if (v === undefined) delete process.env[k] - else process.env[k] = v - } - }) - - it('getEnv returns undefined for unset var', () => { - expect(getEnv('DIFY_TEST_NONEXISTENT')).toBeUndefined() - }) - - it('getEnv returns the literal string for a set var', () => { - process.env.DIFY_HOST = 'https://cloud.dify.ai' - expect(getEnv('DIFY_HOST')).toBe('https://cloud.dify.ai') - }) - - it('resolveEnv returns parsed value for DIFY_LIMIT (uses parseLimit)', () => { - process.env.DIFY_LIMIT = '42' - expect(resolveEnv('DIFY_LIMIT')).toBe(42) - }) - - it('resolveEnv returns the raw string for vars with no parser', () => { - process.env.DIFY_HOST = 'https://example.dify.ai' - expect(resolveEnv('DIFY_HOST')).toBe('https://example.dify.ai') - }) - - it('resolveEnv returns undefined when var is unset and no default', () => { - expect(resolveEnv('DIFY_HOST')).toBeUndefined() - }) - - it('resolveEnv propagates parser errors', () => { - process.env.DIFY_LIMIT = '999' - expect(() => resolveEnv('DIFY_LIMIT')).toThrow(/out of range/) - }) - - it('resolveEnv accepts unknown var name and returns undefined (no throw)', () => { - expect(resolveEnv('DIFY_NOPE')).toBeUndefined() - }) - }) -}) diff --git a/cli/src/env/registry.ts b/cli/src/env/registry.ts deleted file mode 100644 index 7e6cf28e39e..00000000000 --- a/cli/src/env/registry.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { parseLimit } from '@/limit/limit' -import { getEnv } from '@/sys/index' - -export type EnvVar = { - readonly name: string - readonly description: string - readonly default?: string - readonly sensitive?: boolean - readonly parse?: (raw: string) => unknown -} - -const REGISTRY_UNSORTED: readonly EnvVar[] = [ - { - name: 'DIFY_CONFIG_DIR', - description: 'Override the config-dir resolution (precedes XDG_CONFIG_HOME on Linux).', - }, - { - name: 'DIFY_FORMAT', - description: 'Default output format for list commands (table | json | yaml | wide | name).', - }, - { - name: 'DIFY_HOST', - description: 'Default Dify host (overridden by --host).', - }, - { - name: 'DIFY_LIMIT', - description: 'Default page size for list commands (1..200).', - parse: (raw: string) => parseLimit(raw, 'DIFY_LIMIT'), - }, - { - name: 'DIFY_NO_PROGRESS', - description: 'Suppress progress spinners. Truthy values: 1, true, yes.', - }, - { - name: 'DIFY_PLAIN', - description: 'Disable ANSI colors and decorative output. Truthy values: 1, true, yes.', - }, - { - name: 'DIFY_TOKEN', - description: 'Bearer token for non-interactive auth.', - sensitive: true, - }, - { - name: 'DIFY_WORKSPACE_ID', - description: 'Workspace ID used when no --workspace flag is set.', - }, -] - -export const ENV_REGISTRY: readonly EnvVar[] = [...REGISTRY_UNSORTED].sort((a, b) => - a.name.localeCompare(b.name), -) - -const BY_NAME = new Map(ENV_REGISTRY.map((e) => [e.name, e])) - -export function lookupEnv(name: string): EnvVar | undefined { - return BY_NAME.get(name) -} - -export { getEnv } - -export function resolveEnv(name: string): unknown { - const entry = lookupEnv(name) - const raw = getEnv(name) ?? entry?.default - if (raw === undefined) return undefined - return entry?.parse ? entry.parse(raw) : raw -} diff --git a/cli/src/errors/base.test.ts b/cli/src/errors/base.test.ts index c2b37c99f21..86839606444 100644 --- a/cli/src/errors/base.test.ts +++ b/cli/src/errors/base.test.ts @@ -29,7 +29,7 @@ describe('BaseError', () => { it('exit() routes via code map', () => { expect(newError(ErrorCode.AuthExpired, 'x').exit()).toBe(ExitCode.Auth) expect(newError(ErrorCode.UsageInvalidFlag, 'x').exit()).toBe(ExitCode.Usage) - expect(newError(ErrorCode.VersionSkew, 'x').exit()).toBe(ExitCode.VersionCompat) + expect(newError(ErrorCode.CatalogUnavailable, 'x').exit()).toBe(ExitCode.Catalog) }) it('toString without hint formats "<code>: <message>"', () => { @@ -130,7 +130,7 @@ describe('error envelope', () => { }) it('omits undefined optional fields entirely (no `hint: null`)', () => { - const err = newError(ErrorCode.Server5xx, 'upstream broke') + const err = newError(ErrorCode.ServerError, 'upstream broke') const envelope = err.toEnvelope() expect(envelope.error).not.toHaveProperty('hint') expect(envelope.error).not.toHaveProperty('http_status') diff --git a/cli/src/errors/base.ts b/cli/src/errors/base.ts index 8850f0febaa..18f313a7245 100644 --- a/cli/src/errors/base.ts +++ b/cli/src/errors/base.ts @@ -1,24 +1,58 @@ -import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' import type { ErrorCodeValue, ExitCodeValue } from './codes' -import type { ErrorEnvelope, PrintableError } from './format' +import { BINARY } from '@/version/info' import { ErrorCode, exitFor } from './codes' +export type ServerErrorDetail = { + readonly type: string + readonly loc?: (string | number)[] + readonly msg: string +} + +export type ServerErrorBody = { + readonly code?: string + readonly message: string + readonly status?: number + readonly hint?: string + readonly details?: ServerErrorDetail[] +} + +export type ErrorEnvelope = { + error: { + code: string + message: string + hint?: string + details?: ServerErrorDetail[] + schema?: unknown + http_status?: number + method?: string + url?: string + raw_response?: string + server?: ServerErrorBody + } +} + export type BaseErrorOptions = { readonly code: ErrorCodeValue readonly message: string readonly hint?: string readonly cause?: unknown + readonly details?: ServerErrorDetail[] + readonly schema?: unknown } -export class BaseError extends Error implements PrintableError { +export class BaseError extends Error { readonly code: ErrorCodeValue readonly hint?: string + readonly details?: ServerErrorDetail[] + readonly schema?: unknown constructor(opts: BaseErrorOptions) { super(opts.message, opts.cause === undefined ? undefined : { cause: opts.cause }) this.name = 'BaseError' this.code = opts.code this.hint = opts.hint + this.details = opts.details + this.schema = opts.schema Object.setPrototypeOf(this, new.target.prototype) } @@ -39,6 +73,8 @@ export class BaseError extends Error implements PrintableError { message: this.message, } if (this.hint !== undefined) payload.hint = this.hint + if (this.details !== undefined) payload.details = this.details + if (this.schema !== undefined) payload.schema = this.schema return { error: payload } } @@ -58,6 +94,8 @@ export class BaseError extends Error implements PrintableError { message: this.message, hint: this.hint, cause: this.cause, + details: this.details, + schema: this.schema, } } } @@ -78,12 +116,18 @@ export function unknownError(message: string, cause?: unknown): BaseError { return new BaseError({ code: ErrorCode.Unknown, message, cause }) } +export const LOGIN_HINT = `run ${BINARY} login` + +export function notLoggedIn(): BaseError { + return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint: LOGIN_HINT }) +} + type HttpClientErrorOptions = BaseErrorOptions & { readonly httpStatus?: number readonly method?: string readonly url?: string readonly rawResponse?: string - readonly serverError?: ErrorBody + readonly serverError?: ServerErrorBody } export class HttpClientError extends BaseError { @@ -91,7 +135,7 @@ export class HttpClientError extends BaseError { readonly method?: string readonly url?: string readonly rawResponse?: string - readonly serverError?: ErrorBody + readonly serverError?: ServerErrorBody constructor(opts: HttpClientErrorOptions) { super(opts) @@ -109,6 +153,10 @@ export class HttpClientError extends BaseError { if (this.url !== undefined) envelope.error.url = this.url if (this.rawResponse !== undefined) envelope.error.raw_response = this.rawResponse if (this.serverError !== undefined) envelope.error.server = this.serverError + // One details[] surface for both client- and server-authored rejections: the + // server's own details fill in only when this error didn't already have its own. + if (envelope.error.details === undefined && this.serverError?.details !== undefined) + envelope.error.details = this.serverError.details return envelope } @@ -129,6 +177,8 @@ export class HttpClientError extends BaseError { message: error.message, hint: error.hint, cause: error.cause, + details: error.details, + schema: error.schema, }) } @@ -147,7 +197,7 @@ export class HttpClientError extends BaseError { return new HttpClientError({ ...this.snapshot(), rawResponse }) } - withServerError(serverError: ErrorBody): HttpClientError { + withServerError(serverError: ServerErrorBody): HttpClientError { return new HttpClientError({ ...this.snapshot(), serverError }) } } diff --git a/cli/src/errors/codes.test.ts b/cli/src/errors/codes.test.ts index 6634613dbb9..df3531e0247 100644 --- a/cli/src/errors/codes.test.ts +++ b/cli/src/errors/codes.test.ts @@ -1,51 +1,17 @@ -import { describe, expect, it } from 'vite-plus/test' -import { ALL_ERROR_CODES, CODE_TO_EXIT_MAP, ErrorCode, ExitCode, exitFor } from './codes' +import { expect, it } from 'vite-plus/test' +import { ALL_ERROR_CODES, ErrorCode, ExitCode, exitFor } from './codes' -describe('error codes', () => { - it('has correct number codes (parity with internal/api/errors)', () => { - expect(ALL_ERROR_CODES).toHaveLength(Object.keys(CODE_TO_EXIT_MAP).length) - }) - - it('has the expected ExitCode buckets', () => { - expect(ExitCode.Success).toBe(0) - expect(ExitCode.Generic).toBe(1) - expect(ExitCode.Usage).toBe(2) - expect(ExitCode.Auth).toBe(4) - expect(ExitCode.VersionCompat).toBe(6) - expect(ExitCode.RateLimited).toBe(7) - }) - - it('every code maps to an exit', () => { - for (const code of ALL_ERROR_CODES) expect(CODE_TO_EXIT_MAP[code]).toBeDefined() - }) - - it('CODE_TO_EXIT_MAP entry count == ALL_ERROR_CODES length (drift guard)', () => { - expect(Object.keys(CODE_TO_EXIT_MAP)).toHaveLength(ALL_ERROR_CODES.length) - }) - - it.each([ - [ErrorCode.NotLoggedIn, ExitCode.Auth], - [ErrorCode.AuthExpired, ExitCode.Auth], - [ErrorCode.TokenExpired, ExitCode.Auth], - [ErrorCode.AccessDenied, ExitCode.Auth], - [ErrorCode.ExpiredToken, ExitCode.Auth], - [ErrorCode.VersionSkew, ExitCode.VersionCompat], - [ErrorCode.UnsupportedEndpoint, ExitCode.VersionCompat], - [ErrorCode.ConfigSchemaUnsupported, ExitCode.VersionCompat], - [ErrorCode.UsageInvalidFlag, ExitCode.Usage], - [ErrorCode.UsageMissingArg, ExitCode.Usage], - [ErrorCode.ConfigInvalidKey, ExitCode.Usage], - [ErrorCode.ConfigInvalidValue, ExitCode.Usage], - [ErrorCode.Server5xx, ExitCode.Generic], - [ErrorCode.Server4xxOther, ExitCode.Generic], - [ErrorCode.ClientError, ExitCode.Generic], - [ErrorCode.Unknown, ExitCode.Generic], - [ErrorCode.RateLimited, ExitCode.RateLimited], - ])('exitFor(%s) -> %d', (code, want) => { - expect(exitFor(code)).toBe(want) - }) - - it('exitFor returns ExitCode.Generic for unknown code (conservative default)', () => { - expect(exitFor('no_such_code')).toBe(ExitCode.Generic) - }) +it('maps every code to the frozen exit taxonomy', () => { + expect(exitFor(ErrorCode.NotLoggedIn)).toBe(ExitCode.Auth) + expect(exitFor(ErrorCode.AccessDenied)).toBe(ExitCode.Auth) + expect(exitFor(ErrorCode.InputInvalid)).toBe(ExitCode.Usage) + expect(exitFor(ErrorCode.UsageInvalidFlag)).toBe(ExitCode.Usage) + expect(exitFor(ErrorCode.CatalogUnavailable)).toBe(ExitCode.Catalog) + expect(exitFor(ErrorCode.UnknownOp)).toBe(ExitCode.Catalog) + expect(exitFor(ErrorCode.ConfigSchemaUnsupported)).toBe(ExitCode.Catalog) + expect(exitFor(ErrorCode.RateLimited)).toBe(ExitCode.RateLimited) + expect(exitFor(ErrorCode.ServerError)).toBe(ExitCode.Generic) + expect(exitFor('something_from_the_future')).toBe(ExitCode.Generic) + expect(new Set(Object.values(ExitCode))).toEqual(new Set([0, 1, 2, 4, 6, 7])) + for (const code of ALL_ERROR_CODES) expect([0, 1, 2, 4, 6, 7]).toContain(exitFor(code)) }) diff --git a/cli/src/errors/codes.ts b/cli/src/errors/codes.ts index e2b16cb3619..75aa9353e29 100644 --- a/cli/src/errors/codes.ts +++ b/cli/src/errors/codes.ts @@ -1,24 +1,20 @@ export const ErrorCode = { NotLoggedIn: 'not_logged_in', AuthExpired: 'auth_expired', - TokenExpired: 'token_expired', AccessDenied: 'access_denied', - ExpiredToken: 'expired_token', - VersionSkew: 'version_skew', - UnsupportedEndpoint: 'unsupported_endpoint', - ConfigSchemaUnsupported: 'config_schema_unsupported', UsageInvalidFlag: 'usage_invalid_flag', UsageMissingArg: 'usage_missing_arg', + InputInvalid: 'input_invalid', ConfigInvalidKey: 'config_invalid_key', ConfigInvalidValue: 'config_invalid_value', + ConfigSchemaUnsupported: 'config_schema_unsupported', + CatalogUnavailable: 'catalog_unavailable', + UnknownOp: 'unknown_op', NetworkConnection: 'network_connection', RateLimited: 'rate_limited', - Server5xx: 'server_5xx', - Server4xxOther: 'server_4xx_other', - ClientError: 'client_error', - Unknown: 'unknown', - IllegalArgumentError: 'illegal_argument', + ServerError: 'server_error', KeyringUnavailable: 'keyring_unavailable', + Unknown: 'unknown', } as const export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode] @@ -28,7 +24,7 @@ export const ExitCode = { Generic: 1, Usage: 2, Auth: 4, - VersionCompat: 6, + Catalog: 6, // Distinct from Generic so wrappers can tell "rate limited, retry later" from a hard failure. RateLimited: 7, } as const @@ -38,24 +34,20 @@ export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode] const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = { not_logged_in: ExitCode.Auth, auth_expired: ExitCode.Auth, - token_expired: ExitCode.Auth, access_denied: ExitCode.Auth, - expired_token: ExitCode.Auth, - version_skew: ExitCode.VersionCompat, - unsupported_endpoint: ExitCode.VersionCompat, - config_schema_unsupported: ExitCode.VersionCompat, usage_invalid_flag: ExitCode.Usage, usage_missing_arg: ExitCode.Usage, + input_invalid: ExitCode.Usage, config_invalid_key: ExitCode.Usage, config_invalid_value: ExitCode.Usage, + config_schema_unsupported: ExitCode.Catalog, + catalog_unavailable: ExitCode.Catalog, + unknown_op: ExitCode.Catalog, network_connection: ExitCode.Generic, rate_limited: ExitCode.RateLimited, - server_5xx: ExitCode.Generic, - server_4xx_other: ExitCode.Generic, - client_error: ExitCode.Generic, - unknown: ExitCode.Generic, - illegal_argument: ExitCode.Usage, + server_error: ExitCode.Generic, keyring_unavailable: ExitCode.Generic, + unknown: ExitCode.Generic, } export function exitFor(code: string): ExitCodeValue { @@ -63,4 +55,3 @@ export function exitFor(code: string): ExitCodeValue { } export const ALL_ERROR_CODES: readonly ErrorCodeValue[] = Object.values(ErrorCode) -export const CODE_TO_EXIT_MAP: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = CODE_TO_EXIT diff --git a/cli/src/errors/envelope.test.ts b/cli/src/errors/envelope.test.ts new file mode 100644 index 00000000000..9fbe92075d0 --- /dev/null +++ b/cli/src/errors/envelope.test.ts @@ -0,0 +1,93 @@ +import { expect, it } from 'vite-plus/test' +import { ioService } from '@/plugins/io' +import { OutputMode } from '@/plugins/output' +import { bufferStreams } from '@/sys/io/streams' +import { BaseError, HttpClientError } from './base' +import { ErrorCode } from './codes' +import { printEnvelope } from './envelope' + +it('prints one JSON line with details and schema and returns the exit code', async () => { + const streams = bufferStreams() + const code = await printEnvelope( + new BaseError({ + code: ErrorCode.InputInvalid, + message: 'bad', + details: [{ type: 'required', loc: ['inputs'], msg: 'missing' }], + schema: { type: 'object' }, + }), + ioService(streams, { out: OutputMode.Json, err: OutputMode.Json }), + { verbose: false }, + ) + expect(code).toBe(2) + expect(JSON.parse(streams.errBuf())).toEqual({ + error: { + code: 'input_invalid', + message: 'bad', + details: [{ type: 'required', loc: ['inputs'], msg: 'missing' }], + schema: { type: 'object' }, + }, + }) +}) + +it('hides raw_response unless verbose and redacts the bearer', async () => { + const err = new HttpClientError({ + code: ErrorCode.ServerError, + message: 'x', + httpStatus: 500, + rawResponse: 'Bearer abc', + }) + const quiet = bufferStreams() + await printEnvelope(err, ioService(quiet, { out: OutputMode.Json, err: OutputMode.Json }), { + verbose: false, + }) + expect(JSON.parse(quiet.errBuf()).error.raw_response).toBeUndefined() + const loud = bufferStreams() + await printEnvelope(err, ioService(loud, { out: OutputMode.Json, err: OutputMode.Json }), { + verbose: true, + }) + expect(JSON.parse(loud.errBuf()).error.raw_response).toBe('Bearer [redacted]') +}) + +it('offers --verbose in text when the raw response is hidden, and shows it redacted when asked', async () => { + const err = new HttpClientError({ + code: ErrorCode.ServerError, + message: 'boom', + httpStatus: 500, + rawResponse: 'Bearer abc body', + }) + const quiet = bufferStreams() + await printEnvelope(err, ioService(quiet, { out: OutputMode.Json, err: OutputMode.Text }), { + verbose: false, + }) + expect(quiet.errBuf()).toContain('hint: run again with --verbose to see the raw server response') + expect(quiet.errBuf()).not.toContain('raw_response:') + + const loud = bufferStreams() + await printEnvelope(err, ioService(loud, { out: OutputMode.Json, err: OutputMode.Text }), { + verbose: true, + }) + expect(loud.errBuf()).toContain('raw_response: Bearer [redacted] body') + expect(loud.errBuf()).not.toContain('--verbose to see') +}) + +it('wraps unknown errors as unknown with exit 1', async () => { + const streams = bufferStreams() + const code = await printEnvelope( + new Error('boom'), + ioService(streams, { out: OutputMode.Json, err: OutputMode.Json }), + { verbose: false }, + ) + expect(code).toBe(1) + expect(JSON.parse(streams.errBuf()).error.code).toBe('unknown') +}) + +it('renders the text form on stderr when the err channel is text, exit code unchanged', async () => { + const streams = bufferStreams() + const code = await printEnvelope( + new BaseError({ code: ErrorCode.InputInvalid, message: 'bad' }), + ioService(streams, { out: OutputMode.Json, err: OutputMode.Text }), + { verbose: false }, + ) + expect(code).toBe(2) + expect(streams.errBuf()).toBe('input_invalid: bad\n') +}) diff --git a/cli/src/errors/envelope.ts b/cli/src/errors/envelope.ts new file mode 100644 index 00000000000..1da15ed2b2e --- /dev/null +++ b/cli/src/errors/envelope.ts @@ -0,0 +1,53 @@ +import type { ErrorEnvelope } from './base' +import type { IOService } from '@/plugins/io' +import { redactBearer } from '@/errors/sanitize' +import { Channel } from '@/plugins/io' +import { view } from '@/sys/io/view' +import { BaseError } from './base' +import { ErrorCode, ExitCode, exitFor } from './codes' +import { renderEnvelope } from './format' +import { errorMessage } from './message' + +const EPIPE = 'EPIPE' + +/** A reader that closed stdout (`| head`) ends a run; it never fails it. */ +export function isBrokenPipe(err: unknown): boolean { + return err instanceof Error && (err as NodeJS.ErrnoException).code === EPIPE +} + +function envelopeFor(err: unknown): ErrorEnvelope { + if (err instanceof BaseError) return err.toEnvelope() + return { + error: { + code: ErrorCode.Unknown, + message: errorMessage(err), + }, + } +} + +// The text form reads the whole envelope: a hidden raw response is what makes it offer +// --verbose. Only the JSON form drops the body, and both see it redacted. +function jsonForm(envelope: ErrorEnvelope, verbose: boolean): ErrorEnvelope { + if (verbose) return envelope + const error = { ...envelope.error } + delete error.raw_response + return { error } +} + +export async function printEnvelope( + err: unknown, + io: IOService, + opts: { readonly verbose: boolean }, +): Promise<number> { + if (isBrokenPipe(err)) return ExitCode.Success + + const envelope = envelopeFor(err) + const raw = envelope.error.raw_response + if (raw !== undefined) envelope.error.raw_response = redactBearer(raw) + + await io.emit( + view(jsonForm(envelope, opts.verbose), (style) => renderEnvelope(envelope, style, opts)), + Channel.Err, + ) + return exitFor(envelope.error.code) +} diff --git a/cli/src/errors/format.test.ts b/cli/src/errors/format.test.ts index 8b14ececa6e..022127ba6e5 100644 --- a/cli/src/errors/format.test.ts +++ b/cli/src/errors/format.test.ts @@ -1,177 +1,45 @@ -import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' -import { afterEach, describe, expect, it } from 'vite-plus/test' -import { setVerbose } from '@/framework/context' -import { HttpClientError } from './base' -import { ErrorCode } from './codes' -import { formatErrorForCli } from './format' +import { expect, it } from 'vite-plus/test' +import { colorScheme } from '@/sys/io/color' +import { renderEnvelope } from './format' -type ValidationErrorOverrides = { - readonly cliHint?: string - readonly serverHint?: string - readonly details?: ErrorBody['details'] -} +const plain = colorScheme(false) -function validationError(overrides: ValidationErrorOverrides = {}): HttpClientError { - const details = overrides.details ?? [ - { type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }, - { type: 'missing', loc: ['inputs', 'query'], msg: 'field required' }, - ] - return new HttpClientError({ - code: ErrorCode.Server4xxOther, - message: 'Request validation failed', - httpStatus: 422, - hint: overrides.cliHint, - serverError: { - code: 'invalid_param', - message: 'Request validation failed', - status: 422, - hint: overrides.serverHint, - details, +it('renders code, message, details, hint, request and status in v1 order', () => { + const text = renderEnvelope( + { + error: { + code: 'input_invalid', + message: 'invalid input for "get console_app"', + details: [{ type: 'maximum', loc: ['limit'], msg: 'must be <= 100' }], + hint: 'run difyctl help get console_app', + http_status: 422, + method: 'GET', + url: 'https://x/apps', + }, }, - }) -} - -afterEach(() => { - setVerbose(false) + plain, + { verbose: false }, + ) + expect(text.split('\n')).toEqual([ + 'input_invalid: invalid input for "get console_app"', + ' - limit: must be <= 100 (maximum)', + 'hint: run difyctl help get console_app', + 'request: GET https://x/apps', + 'http_status: 422', + ]) }) -describe('formatErrorForCli — human', () => { - it('prints server code, message, and details without verbose', () => { - const out = formatErrorForCli(validationError({ serverHint: 'check the page parameter' }), { - isErrTTY: false, - }) - - expect(out).toContain('invalid_param: Request validation failed') - expect(out).toContain('- page: must be >= 1 (int_parsing)') - expect(out).toContain('- inputs.query: field required (missing)') - expect(out).toContain('check the page parameter') - expect(out).not.toContain('raw_response') - }) - - it('falls back to cli code when no server code', () => { - const err = new HttpClientError({ - code: ErrorCode.Server5xx, - message: 'server error (HTTP 502)', - httpStatus: 502, - }) - - const out = formatErrorForCli(err, { isErrTTY: false }) - - expect(out).toContain('server_5xx: server error (HTTP 502)') - }) - - it('cli hint wins over server hint; server hint fills when cli sent none', () => { - const withBothHints = validationError({ - cliHint: 'cli local hint', - serverHint: 'check the page parameter', - details: [], - }) - expect(formatErrorForCli(withBothHints, { isErrTTY: false })).toContain('cli local hint') - expect(formatErrorForCli(withBothHints, { isErrTTY: false })).not.toContain( - 'check the page parameter', - ) - - // no cli hint → server hint shown - const noCliHint = validationError({ serverHint: 'check the page parameter', details: [] }) - expect(formatErrorForCli(noCliHint, { isErrTTY: false })).toContain('check the page parameter') - - // no server hint → cli hint shown - const noServerHint = new HttpClientError({ - code: ErrorCode.AuthExpired, - message: 'session expired', - hint: 'run difyctl auth login', - }) - expect(formatErrorForCli(noServerHint, { isErrTTY: false })).toContain('run difyctl auth login') - }) - - it('omits the loc prefix when a detail has no loc', () => { - const out = formatErrorForCli( - validationError({ details: [{ type: 'invalid', loc: [], msg: 'body required' }] }), - { isErrTTY: false }, - ) - - expect(out).toContain('- body required (invalid)') - expect(out).not.toContain('- : body required') - }) - - it('hints at -v when a raw response exists but is hidden', () => { - const err = new HttpClientError({ - code: ErrorCode.Server4xxOther, - message: 'request failed (HTTP 400)', - httpStatus: 400, - rawResponse: '<html>not json</html>', - }) - - const out = formatErrorForCli(err, { isErrTTY: false }) - - expect(out).toContain('run again with -v to see the raw server response') - expect(out).not.toContain('raw_response') - }) - - it('no -v hint when the server body parsed', () => { - const err = new HttpClientError({ - code: ErrorCode.Server4xxOther, - message: 'Request validation failed', - httpStatus: 422, - rawResponse: '{"code":"invalid_param","message":"Request validation failed","status":422}', - serverError: { code: 'invalid_param', message: 'Request validation failed', status: 422 }, - }) - - const out = formatErrorForCli(err, { isErrTTY: false }) - - expect(out).not.toContain('run again with -v') - }) - - it('existing hints win over the -v hint', () => { - const err = new HttpClientError({ - code: ErrorCode.Server4xxOther, - message: 'request failed (HTTP 400)', - httpStatus: 400, - hint: 'cli hint', - rawResponse: '<html>not json</html>', - }) - - const out = formatErrorForCli(err, { isErrTTY: false }) - - expect(out).toContain('cli hint') - expect(out).not.toContain('run again with -v') - }) - - it('shows raw_response instead of the -v hint when verbose', () => { - setVerbose(true) - const err = new HttpClientError({ - code: ErrorCode.Server4xxOther, - message: 'request failed (HTTP 400)', - httpStatus: 400, - rawResponse: '<html>not json</html>', - }) - - const out = formatErrorForCli(err, { isErrTTY: false }) - - expect(out).toContain('raw_response: <html>not json</html>') - expect(out).not.toContain('run again with -v') - }) - - it('renders request and http_status lines', () => { - const err = new HttpClientError({ - code: ErrorCode.Server5xx, - message: 'upstream boom', - httpStatus: 502, - method: 'GET', - url: 'https://api.dify.ai/v1/me', - }) - const out = formatErrorForCli(err, { isErrTTY: false }) - expect(out).toContain('request: GET https://api.dify.ai/v1/me') - expect(out).toContain('http_status: 502') - }) -}) - -describe('formatErrorForCli — json', () => { - it('envelope nests the whole server error', () => { - const out = JSON.parse(formatErrorForCli(validationError(), { format: 'json' })) - - expect(out.error.server.code).toBe('invalid_param') - expect(out.error.server.details).toHaveLength(2) - expect(out.error.code).toBe('server_4xx_other') - }) +it('drops schema in text and shows raw_response only when verbose', () => { + const env = { + error: { + code: 'server_error', + message: 'boom', + schema: { type: 'object' }, + raw_response: 'Bearer abc body', + }, + } + expect(renderEnvelope(env, plain, { verbose: false })).toBe( + 'server_error: boom\nhint: run again with --verbose to see the raw server response', + ) + expect(renderEnvelope(env, plain, { verbose: true })).toMatch(/raw_response: Bearer \[redacted\]/) }) diff --git a/cli/src/errors/format.ts b/cli/src/errors/format.ts index 656431325c7..55e9fea0f42 100644 --- a/cli/src/errors/format.ts +++ b/cli/src/errors/format.ts @@ -1,72 +1,33 @@ -import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' -import { isVerbose } from '@/framework/context' -import { redactBearer } from '@/http/sanitize' -import { colorEnabled, colorScheme } from '@/sys/io/color' +import type { ErrorEnvelope } from './base' +import type { Style } from '@/sys/io/color' +import { redactBearer } from '@/errors/sanitize' -const RAW_RESPONSE_HINT = 'run again with -v to see the raw server response' +const RAW_RESPONSE_HINT = 'run again with --verbose to see the raw server response' -export type FormatErrorOptions = { - readonly format?: string - readonly isErrTTY?: boolean -} - -export type ErrorEnvelope = { - error: { - code: string - message: string - hint?: string - http_status?: number - method?: string - url?: string - raw_response?: string - server?: ErrorBody - } -} - -export type PrintableError = { - toEnvelope: () => ErrorEnvelope -} - -export function formatErrorForCli(err: PrintableError, opts: FormatErrorOptions = {}): string { - const env = err.toEnvelope() - if (opts.format === 'json') return renderEnvelope(env) - return renderHuman(env, opts.isErrTTY ?? false) -} - -function renderEnvelope(env: ErrorEnvelope): string { - const raw = env.error.raw_response - if (raw === undefined) return JSON.stringify(env) - if (!isVerbose()) { - delete env.error.raw_response - return JSON.stringify(env) - } - env.error.raw_response = redactBearer(raw) - return JSON.stringify(env) -} +type RenderOptions = { readonly verbose: boolean } // CLI-authored hint wins: it knows local remediation (e.g. which command to // run); the server hint fills in when the CLI has nothing for this error. -function resolveHint(e: ErrorEnvelope['error']): string | undefined { +function resolveHint(e: ErrorEnvelope['error'], opts: RenderOptions): string | undefined { if (e.hint !== undefined) return e.hint if (e.server?.hint != null) return e.server.hint - const rawHiddenAndUnparsed = e.server === undefined && Boolean(e.raw_response) && !isVerbose() + const rawHiddenAndUnparsed = e.server === undefined && Boolean(e.raw_response) && !opts.verbose return rawHiddenAndUnparsed ? RAW_RESPONSE_HINT : undefined } -function renderHuman(env: ErrorEnvelope, isErrTTY: boolean): string { - const cs = colorScheme(colorEnabled(isErrTTY)) +export function renderEnvelope(env: ErrorEnvelope, style: Style, opts: RenderOptions): string { const e = env.error const server = e.server const headerCode = server?.code ?? e.code const lines: string[] = [`${headerCode}: ${e.message}`] - for (const d of server?.details ?? []) { + for (const d of e.details ?? server?.details ?? []) { const loc = (d.loc ?? []).join('.') lines.push(` - ${loc ? `${loc}: ` : ''}${d.msg} (${d.type})`) } - const hint = resolveHint(e) - if (hint !== undefined) lines.push(`${cs.magenta('hint:')} ${cs.cyan(hint)}`) + const hint = resolveHint(e, opts) + if (hint !== undefined) lines.push(`${style.magenta('hint:')} ${style.cyan(hint)}`) if (e.method !== undefined && e.url !== undefined) lines.push(`request: ${e.method} ${e.url}`) if (e.http_status !== undefined) lines.push(`http_status: ${e.http_status}`) - if (isVerbose() && e.raw_response) lines.push(`raw_response: ${redactBearer(e.raw_response)}`) + if (opts.verbose && e.raw_response) lines.push(`raw_response: ${redactBearer(e.raw_response)}`) return lines.join('\n') } diff --git a/cli/src/errors/message.ts b/cli/src/errors/message.ts new file mode 100644 index 00000000000..22ec15534e9 --- /dev/null +++ b/cli/src/errors/message.ts @@ -0,0 +1,4 @@ +/** The message to put in a one-line notice for a value caught from anywhere. */ +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} diff --git a/cli/src/http/sanitize.ts b/cli/src/errors/sanitize.ts similarity index 100% rename from cli/src/http/sanitize.ts rename to cli/src/errors/sanitize.ts diff --git a/cli/src/framework/command.ts b/cli/src/framework/command.ts deleted file mode 100644 index 7401ab90ea2..00000000000 --- a/cli/src/framework/command.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { CommandOutput } from './output' -import type { - ArgDefinition, - FlagDefinition, - ICommand, - InferArgs, - InferFlags, - OptionalArgValueType, -} from './types' -import { setVerbose } from './context' -import { hasBooleanFlag, parseArgv, VERBOSE_CHAR, VERBOSE_FLAG } from './flags' - -// What invoking a command does to remote/persistent state. Drives the skill's -// safety section and the `effect` bit in machine-readable help. Defaults to -// `read`; write/destructive commands must opt in explicitly. -export type CommandEffect = 'read' | 'write' | 'destructive' - -export type CommandConstructor = { - new (): Command - description?: string - flags?: Record<string, FlagDefinition<OptionalArgValueType>> - args?: Record<string, ArgDefinition<string | undefined>> - examples?: string[] - hidden?: boolean - deprecated?: string - effect?: CommandEffect -} - -type InferCommandArgs<C extends CommandConstructor> = - C['args'] extends Record<string, ArgDefinition<string | undefined>> - ? InferArgs<C['args']> - : Record<string, string | undefined> - -type InferCommandFlags<C extends CommandConstructor> = - C['flags'] extends Record<string, FlagDefinition<OptionalArgValueType>> - ? InferFlags<C['flags']> - : Record<string, OptionalArgValueType> - -type ParseResult<C extends CommandConstructor> = { - args: InferCommandArgs<C> - flags: InferCommandFlags<C> -} - -export abstract class Command implements ICommand { - static description?: string - static flags: Record<string, FlagDefinition<OptionalArgValueType>> = {} - - static args: Record<string, ArgDefinition<string | undefined>> = {} - static examples: string[] = [] - static effect: CommandEffect = 'read' - - abstract run(argv: string[]): Promise<CommandOutput | void> - - processGlobalFlags(argv: readonly string[]): void { - setVerbose(hasBooleanFlag(argv, VERBOSE_FLAG, VERBOSE_CHAR)) - } - - protected parse<C extends CommandConstructor>(ctor: C, argv: string[]): ParseResult<C> { - const meta = { - flags: ctor.flags ?? {}, - args: ctor.args ?? {}, - } - - return parseArgv(argv, meta) as ParseResult<C> - } - - error(message: string, opts?: { exit?: number }): never { - process.stderr.write(`${message}\n`) - process.exit(opts?.exit ?? 1) - } - - agentGuide(): string { - return '' - } -} diff --git a/cli/src/framework/context.ts b/cli/src/framework/context.ts deleted file mode 100644 index fdb783dba3e..00000000000 --- a/cli/src/framework/context.ts +++ /dev/null @@ -1,15 +0,0 @@ -type CommandContext = { - verbose: boolean -} - -const commandContext: CommandContext = { - verbose: false, -} - -export function setVerbose(verbose: boolean): void { - commandContext.verbose = verbose -} - -export function isVerbose(): boolean { - return commandContext.verbose -} diff --git a/cli/src/framework/errors.test.ts b/cli/src/framework/errors.test.ts deleted file mode 100644 index 8256ef04e96..00000000000 --- a/cli/src/framework/errors.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { FlagDefinition } from './types' -import { describe, expect, it } from 'vite-plus/test' -import { OutputFormatNotSupportedError, UnsupportedArgValueError } from './errors' - -describe('OutputFormatNotSupportedError', () => { - it('states the offending format in the message', () => { - const err = new OutputFormatNotSupportedError('csv') - expect(err.message).toBe('format csv is not supported by this command') - }) -}) - -describe('UnsupportedArgValueError', () => { - it('includes both long and short option labels when a char exists', () => { - const def: FlagDefinition = { - type: 'string', - description: 'output', - char: 'o', - options: ['json', 'yaml'], - } - const err = new UnsupportedArgValueError('output', def, 'csv') - expect(err.message).toBe('illegal value csv for flag --output / -o') - }) - - it('omits the short option label when the flag has no char', () => { - const def: FlagDefinition = { - type: 'string', - description: 'app mode', - options: ['chat', 'workflow'], - } - const err = new UnsupportedArgValueError('mode', def, 'chatbot') - expect(err.message).toBe('illegal value chatbot for flag --mode') - }) - - it('lists supported values in the hint', () => { - const def: FlagDefinition = { - type: 'string', - description: 'app mode', - options: ['chat', 'workflow'], - } - expect(new UnsupportedArgValueError('mode', def, 'chatbot').hint).toBe( - 'supported value: chat, workflow', - ) - }) - - it('leaves the hint empty when the flag declares no options', () => { - const def: FlagDefinition = { type: 'string', description: 'app mode' } - expect(new UnsupportedArgValueError('mode', def, 'chatbot').hint).toBe('') - }) -}) diff --git a/cli/src/framework/errors.ts b/cli/src/framework/errors.ts deleted file mode 100644 index 4f8a4c1c48e..00000000000 --- a/cli/src/framework/errors.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { FlagDefinition } from './types' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' - -export class OutputFormatNotSupportedError extends BaseError { - constructor(format: string) { - super({ - code: ErrorCode.IllegalArgumentError, - message: `format ${format} is not supported by this command`, - }) - } -} - -export class UnsupportedArgValueError extends BaseError { - constructor(flagName: string, flagDef: FlagDefinition, givenValue: string) { - const flagLabel = flagDef.char ? `--${flagName} / -${flagDef.char}` : `--${flagName}` - super({ - code: ErrorCode.IllegalArgumentError, - message: `illegal value ${givenValue} for flag ${flagLabel}`, - hint: flagDef.options ? `supported value: ${flagDef.options.join(', ')}` : '', - }) - } -} diff --git a/cli/src/framework/flags.test.ts b/cli/src/framework/flags.test.ts deleted file mode 100644 index 25895c1d1ce..00000000000 --- a/cli/src/framework/flags.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { UnsupportedArgValueError } from './errors' -import { Args, Flags, parseArgv } from './flags' - -const meta = { - flags: { - output: Flags.string({ description: 'output format', char: 'o' }), - verbose: Flags.boolean({ description: 'verbose mode', char: 'v' }), - count: Flags.integer({ description: 'count', default: 5 }), - format: Flags.string({ description: 'format', default: 'text' }), - }, - args: { - name: Args.string({ description: 'name', required: true }), - extra: Args.string({ description: 'extra' }), - }, -} - -describe('parseArgv', () => { - describe('positional args', () => { - it('parses required arg', () => { - const { args } = parseArgv(['alice'], meta) - expect(args.name).toBe('alice') - }) - - it('parses optional arg when provided', () => { - const { args } = parseArgv(['alice', 'bonus'], meta) - expect(args.name).toBe('alice') - expect(args.extra).toBe('bonus') - }) - - it('leaves optional arg undefined when absent', () => { - const { args } = parseArgv(['alice'], meta) - expect(args.extra).toBeUndefined() - }) - - it('throws on missing required arg', () => { - expect(() => parseArgv([], meta)).toThrow('missing required argument: name') - }) - }) - - describe('long flags (--flag value)', () => { - it('parses string flag with space separator', () => { - const { flags } = parseArgv(['alice', '--output', 'json'], meta) - expect(flags.output).toBe('json') - }) - - it('parses string flag with = separator', () => { - const { flags } = parseArgv(['alice', '--output=yaml'], meta) - expect(flags.output).toBe('yaml') - }) - - it('parses boolean flag as true when bare', () => { - const { flags } = parseArgv(['alice', '--verbose'], meta) - expect(flags.verbose).toBe(true) - }) - - it('parses boolean flag with =true', () => { - const { flags } = parseArgv(['alice', '--verbose=true'], meta) - expect(flags.verbose).toBe(true) - }) - - it('parses boolean flag with =false', () => { - const { flags } = parseArgv(['alice', '--verbose=false'], meta) - expect(flags.verbose).toBe(false) - }) - - it('parses integer flag', () => { - const { flags } = parseArgv(['alice', '--count', '10'], meta) - expect(flags.count).toBe(10) - }) - - it('parses integer flag with = separator', () => { - const { flags } = parseArgv(['alice', '--count=42'], meta) - expect(flags.count).toBe(42) - }) - - it('throws on non-integer value for integer flag', () => { - expect(() => parseArgv(['alice', '--count', 'abc'], meta)).toThrow( - 'expected integer, got "abc"', - ) - }) - - it('throws on invalid boolean value', () => { - expect(() => parseArgv(['alice', '--verbose=maybe'], meta)).toThrow( - 'expected boolean, got "maybe"', - ) - }) - - it('throws when non-boolean flag has no value', () => { - expect(() => parseArgv(['alice', '--output'], meta)).toThrow('flag --output expects a value') - }) - - it('throws on unknown long flag', () => { - expect(() => parseArgv(['alice', '--unknown', 'x'], meta)).toThrow('unknown flag: --unknown') - }) - }) - - describe('short flags (-x)', () => { - it('parses short boolean flag', () => { - const { flags } = parseArgv(['alice', '-v'], meta) - expect(flags.verbose).toBe(true) - }) - - it('parses short string flag with space-separated value', () => { - const { flags } = parseArgv(['alice', '-o', 'json'], meta) - expect(flags.output).toBe('json') - }) - - it('throws when short non-boolean flag has no value', () => { - expect(() => parseArgv(['alice', '-o'], meta)).toThrow('flag -o expects a value') - }) - - it('throws on unknown short flag', () => { - expect(() => parseArgv(['alice', '-z'], meta)).toThrow('unknown flag: -z') - }) - }) - - describe('multiple: true', () => { - const multipleMeta = { - flags: { - label: Flags.stringArray({ description: 'labels' }), - output: Flags.string({ description: 'output', char: 'o' }), - }, - args: {}, - } - - it('collects repeated long flags into an array', () => { - const { flags } = parseArgv(['--label', 'foo', '--label', 'bar'], multipleMeta) - expect(flags.label).toEqual(['foo', 'bar']) - }) - - it('collects repeated long flags with = separator', () => { - const { flags } = parseArgv(['--label=foo', '--label=bar', '--label=baz'], multipleMeta) - expect(flags.label).toEqual(['foo', 'bar', 'baz']) - }) - - it('collects repeated short flags into an array', () => { - const multipleShortMeta = { - flags: { label: Flags.string({ description: 'labels', multiple: true, char: 'l' }) }, - args: {}, - } - const { flags } = parseArgv(['-l', 'foo', '-l', 'bar'], multipleShortMeta) - expect(flags.label).toEqual(['foo', 'bar']) - }) - - it('single occurrence still produces array with one element', () => { - const { flags } = parseArgv(['--label', 'only'], multipleMeta) - expect(flags.label).toEqual(['only']) - }) - - it('absent multiple flag is undefined', () => { - const { flags } = parseArgv([], multipleMeta) - expect(flags.label).toBeUndefined() - }) - - it('non-multiple flag is not affected', () => { - const { flags } = parseArgv(['--output', 'json'], multipleMeta) - expect(flags.output).toBe('json') - }) - }) - - describe('double-dash (--) separator', () => { - it('treats tokens after -- as positional args', () => { - const { args, flags } = parseArgv(['alice', '--', '--output', 'json'], meta) - expect(flags.output).toBeUndefined() - expect(args.extra).toBe('--output') - }) - }) - - describe('defaults', () => { - it('applies flag default when flag is absent', () => { - const { flags } = parseArgv(['alice'], meta) - expect(flags.count).toBe(5) - expect(flags.format).toBe('text') - }) - - it('does not apply default when flag is provided', () => { - const { flags } = parseArgv(['alice', '--count', '99'], meta) - expect(flags.count).toBe(99) - }) - }) - - describe('options validation', () => { - const metaWithOptions = { - flags: { - mode: Flags.string({ - description: 'app mode', - options: ['chat', 'workflow', 'completion'], - }), - }, - args: {}, - } - - it('accepts a valid option value', () => { - const { flags } = parseArgv(['--mode', 'chat'], metaWithOptions) - expect(flags.mode).toBe('chat') - }) - - it('rejects an invalid option value (space form)', () => { - expect(() => parseArgv(['--mode', 'chatbot'], metaWithOptions)).toThrow( - UnsupportedArgValueError, - ) - }) - - it('rejects an invalid option value (= form)', () => { - expect(() => parseArgv(['--mode=chatbot'], metaWithOptions)).toThrow(UnsupportedArgValueError) - }) - }) - - describe('Flags and Args factory', () => { - it('Flags.string produces string type definition', () => { - const def = Flags.string({ description: 'test' }) - expect(def.type).toBe('string') - }) - - it('Flags.boolean produces boolean type definition', () => { - const def = Flags.boolean({ description: 'test' }) - expect(def.type).toBe('boolean') - }) - - it('Flags.integer produces integer type definition', () => { - const def = Flags.integer({ description: 'test' }) - expect(def.type).toBe('integer') - }) - - it('Args.string produces an arg definition with required when set', () => { - const def = Args.string({ description: 'test', required: true }) - expect(def.required).toBe(true) - }) - }) -}) diff --git a/cli/src/framework/flags.ts b/cli/src/framework/flags.ts deleted file mode 100644 index f9af9541196..00000000000 --- a/cli/src/framework/flags.ts +++ /dev/null @@ -1,249 +0,0 @@ -import type { ArgDefinition, CommandMeta, FlagDefinition, ParsedArgs, ParsedFlags } from './types' -import { UnsupportedArgValueError } from './errors' - -export const VERBOSE_FLAG = 'verbose' -export const VERBOSE_CHAR = 'v' - -export const Flags = { - string: stringFlag, - stringArray: stringRepeatedFlag, - boolean: booleanFlag, - integer: integerFlag, - outputFormat: outputFormatFlag, -} - -const GLOBAL_FLAGS: Record<string, FlagDefinition> = { - [VERBOSE_FLAG]: Flags.boolean({ - char: VERBOSE_CHAR, - description: 'enable verbose output', - helpGroup: 'GLOBAL', - }), -} - -function stringFlag< - const Opts extends { - description: string - char?: string - default?: string - options?: readonly string[] - }, ->(opts: Opts): FlagDefinition<string> { - return { - type: 'string', - multiple: false, - ...opts, - } -} - -function outputFormatFlag<const Opts extends { options: readonly string[]; default?: string }>( - opts: Opts, -): FlagDefinition<string> { - return { - type: 'string', - description: `output format (${opts.options.join('|')})`, - char: 'o', - multiple: false, - ...opts, - } -} - -function stringRepeatedFlag< - const Opts extends { description: string; char?: string; default?: string[]; multiple?: boolean }, ->(opts: Opts): FlagDefinition<string[]> { - return { - type: 'string', - multiple: true, - ...opts, - } -} - -function booleanFlag(opts: { - description: string - char?: string - default?: boolean - helpGroup?: 'GLOBAL' -}): FlagDefinition<boolean> { - return { type: 'boolean', ...opts } -} - -function integerFlag<const Opts extends { description: string; char?: string; default?: number }>( - opts: Opts, -): FlagDefinition<Opts extends { default: number } ? number : number | undefined> { - return { type: 'integer', ...opts } as FlagDefinition< - Opts extends { default: number } ? number : number | undefined - > -} - -function stringArg<const Opts extends { description: string; required?: boolean }>( - opts: Opts, -): ArgDefinition<Opts extends { required: true } ? string : string | undefined> { - return opts as ArgDefinition<Opts extends { required: true } ? string : string | undefined> -} - -export const Args = { - string: stringArg, -} - -function coerceFlagValue(raw: string, def: FlagDefinition): string | boolean | number { - switch (def.type) { - case 'integer': { - const n = Number(raw) - if (Number.isNaN(n)) throw new Error(`expected integer, got ${JSON.stringify(raw)}`) - - return n - } - case 'boolean': { - if (raw === 'true' || raw === '1') return true - - if (raw === 'false' || raw === '0') return false - - throw new Error(`expected boolean, got ${JSON.stringify(raw)}`) - } - default: - return raw - } -} - -function accumulateFlagValue( - flags: ParsedFlags, - name: string, - value: string | boolean | number, - def: FlagDefinition, -): void { - if (def.multiple === true) { - const existing = flags[name] - flags[name] = Array.isArray(existing) ? [...existing, String(value)] : [String(value)] - } else { - flags[name] = value - } -} - -function resolveByChar( - char: string, - flags: Record<string, FlagDefinition>, -): [name: string, def: FlagDefinition] | undefined { - for (const [name, def] of Object.entries(flags)) { - if (def.char === char) return [name, def] - } - - return undefined -} - -function validateFlagOptions(name: string, raw: string, def: FlagDefinition): void { - if (def.options !== undefined && !def.options.includes(raw)) - throw new UnsupportedArgValueError(name, def, raw) -} - -type ResolvedFlag = { - name: string - def: FlagDefinition - label: string - inlineRaw: string | undefined -} - -function resolveToken(token: string, flags: Record<string, FlagDefinition>): ResolvedFlag | null { - if (token.startsWith('--')) { - const eqIdx = token.indexOf('=') - const name = eqIdx !== -1 ? token.slice(2, eqIdx) : token.slice(2) - const inlineRaw = eqIdx !== -1 ? token.slice(eqIdx + 1) : undefined - const def = flags[name] - if (!def) throw new Error(`unknown flag: --${name}`) - return { name, def, label: `--${name}`, inlineRaw } - } - - if (token.length === 2 && token[1] !== undefined) { - const char = token[1] - const resolved = resolveByChar(char, flags) - if (!resolved) throw new Error(`unknown flag: -${char}`) - const [name, def] = resolved - return { name, def, label: `-${char}`, inlineRaw: undefined } - } - - return null -} - -// Scans argv for a boolean flag without throwing on unknown tokens, so it is safe -// to call before the command-specific flag set is known (e.g. global flags). -export function hasBooleanFlag(argv: readonly string[], name: string, char?: string): boolean { - for (const token of argv) { - if (token === '--') break - if (token === `--${name}` || token === `--${name}=true` || token === `--${name}=1`) return true - if (char !== undefined && token === `-${char}`) return true - } - - return false -} - -export function parseArgv( - argv: readonly string[], - meta: CommandMeta, -): { args: ParsedArgs; flags: ParsedFlags } { - const flags: ParsedFlags = {} - const positional: string[] = [] - const argDefs = Object.entries(meta.args) - let pastDoubleDash = false - - for (let i = 0; i < argv.length; i++) { - const token = argv[i] - if (token === undefined) break - - if (!pastDoubleDash && token === '--') { - pastDoubleDash = true - continue - } - - if (pastDoubleDash || !token.startsWith('-')) { - positional.push(token) - continue - } - - const resolved = resolveToken(token, { - ...meta.flags, - ...GLOBAL_FLAGS, // pass global flags to prevent unknown flag error - }) - if (!resolved) { - positional.push(token) - continue - } - - const { name, def, label, inlineRaw } = resolved - - if (def.type === 'boolean') { - flags[name] = inlineRaw === undefined ? true : coerceFlagValue(inlineRaw, def) - continue - } - - let raw: string - if (inlineRaw !== undefined) { - raw = inlineRaw - } else { - i++ - const next = i < argv.length ? argv[i] : undefined - if (next === undefined || next.startsWith('-')) - throw new Error(`flag ${label} expects a value`) - raw = next - } - - validateFlagOptions(name, raw, def) - accumulateFlagValue(flags, name, coerceFlagValue(raw, def), def) - } - - const args: ParsedArgs = {} - for (let j = 0; j < argDefs.length; j++) { - const entry = argDefs[j] - if (!entry) continue - - const [argName, argDef] = entry - if (j < positional.length) { - args[argName] = positional[j] - } else if (argDef.required) { - throw new Error(`missing required argument: ${argName}`) - } - } - - for (const [name, def] of Object.entries(meta.flags)) { - if (!(name in flags) && def.default !== undefined) flags[name] = def.default - } - - return { args, flags } -} diff --git a/cli/src/framework/help.test.ts b/cli/src/framework/help.test.ts deleted file mode 100644 index 3fef2a830f1..00000000000 --- a/cli/src/framework/help.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import type { CommandConstructor } from './command' -import type { CommandTree } from './registry' -import { describe, expect, it } from 'vite-plus/test' -import { Args, Flags } from './flags' -import { formatHelp, formatTopLevelHelp } from './help' - -function makeCmd(opts: { - description?: string - flags?: CommandConstructor['flags'] - args?: CommandConstructor['args'] - examples?: string[] - agentGuide?: string - effect?: CommandConstructor['effect'] -}): CommandConstructor { - class Cmd { - static description = opts.description - static flags = opts.flags ?? {} - static args = opts.args ?? {} - static examples = opts.examples ?? [] - static effect = opts.effect - static agentGuide = opts.agentGuide - - async run(_argv: string[]) {} - - agentGuide(): string { - return opts.agentGuide ?? '' - } - } - return Cmd as unknown as CommandConstructor -} - -describe('formatHelp', () => { - it('includes description when present', () => { - const ctor = makeCmd({ description: 'Lists all apps' }) - expect(formatHelp(ctor, 'get app')).toContain('Lists all apps') - }) - - it('omits description section when absent', () => { - const ctor = makeCmd({}) - const out = formatHelp(ctor, 'get app') - expect(out).not.toContain('undefined') - }) - - it('includes USAGE line with path and bin', () => { - const ctor = makeCmd({}) - expect(formatHelp(ctor, 'get app')).toContain('$ difyctl get app') - }) - - it('includes [FLAGS] suffix when flags exist', () => { - const ctor = makeCmd({ flags: { output: Flags.string({ description: 'format' }) } }) - expect(formatHelp(ctor, 'get app')).toContain('[FLAGS]') - }) - - it('omits [FLAGS] when no flags', () => { - const ctor = makeCmd({}) - expect(formatHelp(ctor, 'get app')).not.toContain('[FLAGS]') - }) - - it('includes [ARGS] suffix when args exist', () => { - const ctor = makeCmd({ args: { id: Args.string({ description: 'app id', required: true }) } }) - expect(formatHelp(ctor, 'get app')).toContain('[ARGS]') - }) - - it('omits [ARGS] when no args', () => { - const ctor = makeCmd({}) - expect(formatHelp(ctor, 'get app')).not.toContain('[ARGS]') - }) - - it('includes FLAGS section with flag description', () => { - const ctor = makeCmd({ - flags: { output: Flags.string({ description: 'output format', char: 'o' }) }, - }) - const out = formatHelp(ctor, 'get app') - expect(out).toContain('FLAGS') - expect(out).toContain('--output') - expect(out).toContain('-o') - expect(out).toContain('output format') - }) - - it('includes default value in flag description', () => { - const ctor = makeCmd({ - flags: { format: Flags.string({ description: 'fmt', default: 'text' }) }, - }) - expect(formatHelp(ctor, 'get app')).toContain('[default: "text"]') - }) - - it('renders boolean flag without <type> placeholder', () => { - const ctor = makeCmd({ - flags: { verbose: Flags.boolean({ description: 'verbose' }) }, - }) - const out = formatHelp(ctor, 'get app') - expect(out).toContain('--verbose') - expect(out).not.toContain('<boolean>') - }) - - it('includes ARGUMENTS section with arg description and required marker', () => { - const ctor = makeCmd({ - args: { id: Args.string({ description: 'app id', required: true }) }, - }) - const out = formatHelp(ctor, 'get app') - expect(out).toContain('ARGUMENTS') - expect(out).toContain('id') - expect(out).toContain('(required)') - expect(out).toContain('app id') - }) - - it('omits (required) for optional args', () => { - const ctor = makeCmd({ - args: { id: Args.string({ description: 'app id' }) }, - }) - expect(formatHelp(ctor, 'get app')).not.toContain('(required)') - }) - - it('includes EXAMPLES section', () => { - const ctor = makeCmd({ examples: ['difyctl get app my-id'] }) - const out = formatHelp(ctor, 'get app') - expect(out).toContain('EXAMPLES') - expect(out).toContain('difyctl get app my-id') - }) - - it('replaces <%= config.bin %> with difyctl in examples', () => { - const ctor = makeCmd({ examples: ['<%= config.bin %> run app my-id'] }) - const out = formatHelp(ctor, 'run app') - expect(out).toContain('$ difyctl run app my-id') - expect(out).not.toContain('<%= config.bin %>') - }) - - it('appends agentGuide string at the end', () => { - const ctor = makeCmd({ agentGuide: 'WORKFLOW\n 1. do thing\n' }) - const out = formatHelp(ctor, 'run app') - expect(out).toContain('WORKFLOW') - expect(out).toContain('1. do thing') - }) - - it('omits agentGuide when absent', () => { - const ctor = makeCmd({}) - expect(formatHelp(ctor, 'run app')).not.toContain('WORKFLOW') - }) - - it('renders aliases comma-separated and the type after a space', () => { - const ctor = makeCmd({ - flags: { output: Flags.string({ description: 'fmt', char: 'o' }) }, - }) - const out = formatHelp(ctor, 'get app') - expect(out).toContain('-o, --output <string>') - expect(out).not.toContain('--output, <string>') - }) -}) - -describe('formatHelp structured output', () => { - it('emits a JSON descriptor under json format', () => { - const ctor = makeCmd({ - description: 'Lists apps', - flags: { - output: Flags.outputFormat({ options: ['json', 'yaml', 'name', 'wide'], default: '' }), - }, - args: { id: Args.string({ description: 'app id', required: true }) }, - examples: ['<%= config.bin %> get app'], - agentGuide: 'WORKFLOW', - }) - const obj = JSON.parse(formatHelp(ctor, 'get app', 'json')) - expect(obj.command).toBe('get app') - expect(obj.description).toBe('Lists apps') - expect(obj.flags[0]).toMatchObject({ name: 'output', char: 'o', type: 'string' }) - expect(obj.flags[0].options).toEqual(['json', 'yaml', 'name', 'wide']) - expect(obj.args[0]).toMatchObject({ name: 'id', required: true }) - expect(obj.examples).toEqual(['difyctl get app']) - expect(obj.agentGuide).toBe('WORKFLOW') - }) - - it('sets a flag options to null when the flag has no enum constraint', () => { - const ctor = makeCmd({ - flags: { name: Flags.string({ description: 'a name' }) }, - }) - const obj = JSON.parse(formatHelp(ctor, 'get app', 'json')) - expect(obj.flags[0].options).toBeNull() - }) - - it('sets agentGuide to null when absent', () => { - const obj = JSON.parse(formatHelp(makeCmd({}), 'get app', 'json')) - expect(obj.agentGuide).toBeNull() - }) - - it('defaults effect to read when unset', () => { - const obj = JSON.parse(formatHelp(makeCmd({}), 'get app', 'json')) - expect(obj.effect).toBe('read') - }) - - it('carries an explicit effect through to the descriptor', () => { - const obj = JSON.parse(formatHelp(makeCmd({ effect: 'destructive' }), 'delete member', 'json')) - expect(obj.effect).toBe('destructive') - }) -}) - -describe('formatTopLevelHelp', () => { - it('emits bin, contract, commands and topics as a JSON site map', () => { - const tree: CommandTree = { - get: { subcommands: { app: { command: makeCmd({ description: 'apps' }), subcommands: {} } } }, - } - const obj = JSON.parse(formatTopLevelHelp(tree, 'json')) - expect(obj.bin).toBe('difyctl') - expect(obj.contract.exitCodes['0']).toBeDefined() - expect(obj.contract.outputFormats).toContain('json') - expect(obj.commands.some((c: { command: string }) => c.command === 'get app')).toBe(true) - expect(obj.commands.every((c: { effect?: string }) => typeof c.effect === 'string')).toBe(true) - expect(obj.topics.map((t: { name: string }) => t.name)).toContain('account') - }) - - it('emits only command, description, and effect when compact', () => { - const tree: CommandTree = { - get: { - subcommands: { - app: { - command: makeCmd({ - description: 'apps', - effect: 'read', - flags: { output: Flags.string({ description: 'format' }) }, - }), - subcommands: {}, - }, - }, - }, - } - const obj = JSON.parse(formatTopLevelHelp(tree, 'json', { compact: true })) as { - commands: Array<Record<string, unknown>> - bin?: unknown - contract?: unknown - } - expect(obj).toEqual({ - commands: [{ command: 'get app', description: 'apps', effect: 'read' }], - }) - expect(obj.bin).toBeUndefined() - expect(obj.contract).toBeUndefined() - }) -}) diff --git a/cli/src/framework/help.ts b/cli/src/framework/help.ts deleted file mode 100644 index e8fea63c522..00000000000 --- a/cli/src/framework/help.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { CommandConstructor, CommandEffect } from './command' -import type { CommandTree } from './registry' -import type { ArgValueType, FlagDefinition } from './types' -import type { HelpTopic } from '@/help/topics' -import { dump } from 'js-yaml' -import { CONTRACT, GLOBAL_FLAG_HELP } from '@/help/contract' -import { TOPICS } from '@/help/topics' -import { collectCommands } from './registry' - -const BIN = 'difyctl' - -export type FlagDescriptor = { - name: string - char: string | null - type: string - default: ArgValueType | null - multiple: boolean - options: readonly string[] | null - description: string -} - -export type ArgDescriptor = { - name: string - required: boolean - description: string -} - -export type CommandDescriptor = { - command: string - description: string | null - effect: CommandEffect - args: ArgDescriptor[] - flags: FlagDescriptor[] - examples: string[] - agentGuide: string | null -} - -export function isStructured(format: string): boolean { - return format === 'json' || format === 'yaml' -} - -function serialize(value: unknown, format: string): string { - if (format === 'yaml') return dump(value, { indent: 2, lineWidth: -1 }) - return `${JSON.stringify(value, null, 2)}\n` -} - -function flagLabel(name: string, def: FlagDefinition): string { - const aliases: string[] = [] - - if (def.char) aliases.push(`-${def.char}`) - - aliases.push(`--${name}`) - - const label = aliases.join(', ') - - return def.type === 'boolean' ? label : `${label} <${def.type}>` -} - -function flagDefault(def: FlagDefinition): string { - if (def.default === undefined) return '' - - return ` [default: ${JSON.stringify(def.default)}]` -} - -function renderExamples(ctor: CommandConstructor): string[] { - return (ctor.examples ?? []).map((ex) => ex.replace('<%= config.bin %>', BIN)) -} - -function agentGuideOf(ctor: CommandConstructor): string { - const C = ctor - return new C().agentGuide() -} - -function summarizeCommand( - ctor: CommandConstructor, - path: string, -): Pick<CommandDescriptor, 'command' | 'description' | 'effect'> { - return { - command: path, - description: ctor.description ?? null, - effect: ctor.effect ?? 'read', - } -} - -export function describeCommand(ctor: CommandConstructor, path: string): CommandDescriptor { - const guide = agentGuideOf(ctor) - - return { - ...summarizeCommand(ctor, path), - args: Object.entries(ctor.args ?? {}).map(([name, def]) => ({ - name, - required: def.required ?? false, - description: def.description, - })), - flags: Object.entries(ctor.flags ?? {}).map(([name, def]) => ({ - name, - char: def.char ?? null, - type: def.type, - default: def.default ?? null, - multiple: def.multiple ?? false, - options: def.options ?? null, - description: def.description, - })), - examples: renderExamples(ctor), - agentGuide: guide.length > 0 ? guide : null, - } -} - -function formatHelpText(ctor: CommandConstructor, path: string): string { - const lines: string[] = [] - - if (ctor.description) lines.push(ctor.description, '') - - lines.push( - 'USAGE', - ` $ ${BIN} ${path}${ctor.args && Object.keys(ctor.args).length > 0 ? ' [ARGS]' : ''}${ctor.flags && Object.keys(ctor.flags).length > 0 ? ' [FLAGS]' : ''}`, - '', - ) - - if (ctor.args && Object.keys(ctor.args).length > 0) { - lines.push('ARGUMENTS') - - for (const [name, def] of Object.entries(ctor.args)) { - const required = def.required ? ' (required)' : '' - lines.push(` ${name}${required} ${def.description}`) - } - - lines.push('') - } - - if (ctor.flags && Object.keys(ctor.flags).length > 0) { - lines.push('FLAGS') - - for (const [name, def] of Object.entries(ctor.flags)) { - lines.push(` ${flagLabel(name, def)} ${def.description}${flagDefault(def)}`) - } - - lines.push('') - } - - if (ctor.examples && ctor.examples.length > 0) { - lines.push('EXAMPLES') - - for (const ex of renderExamples(ctor)) { - lines.push(` $ ${ex}`) - } - - lines.push('') - } - - const guide = agentGuideOf(ctor) - - if (guide.length > 0) lines.push(guide) - - return lines.join('\n') -} - -export function formatHelp(ctor: CommandConstructor, path: string, format = ''): string { - if (isStructured(format)) return serialize(describeCommand(ctor, path), format) - - return formatHelpText(ctor, path) -} - -export function formatTopic(topic: HelpTopic, format = ''): string { - if (isStructured(format)) - return serialize({ name: topic.name, summary: topic.summary, body: topic.render() }, format) - - return topic.render() -} - -// Renders a list of commands as aligned `path description` rows, grouped by -// their first path segment (a blank line between groups). Shared by the -// top-level overview and namespace drill-in (`difyctl <group> --help`) so both -// derive from the same full-depth `collectCommands` walk and the canonical -// space-joined command path. -export function renderCommandRows( - commands: Array<{ command: CommandConstructor; path: string[] }>, -): string { - const rows = commands.map(({ command, path }) => ({ - label: path.join(' '), - desc: command.description ?? '', - group: path[0] ?? '', - })) - - const width = rows.reduce((max, r) => Math.max(max, r.label.length), 0) + 2 - const lines: string[] = [] - let prevGroup: string | undefined - - for (const r of rows) { - if (prevGroup !== undefined && r.group !== prevGroup) lines.push('') - - prevGroup = r.group - lines.push(r.desc ? ` ${r.label.padEnd(width)}${r.desc}` : ` ${r.label}`) - } - - return lines.join('\n') -} - -// Renders a command list (a namespace subtree for `<group> --help`) in the -// requested format: structured formats serialize per-command descriptors — the -// same shape as the top-level site map's `commands` — while text renders the -// aligned rows. Keeps `<group> --help -o json` machine-readable like every -// other help surface. -export function formatCommandList( - commands: Array<{ command: CommandConstructor; path: string[] }>, - format: string, -): string { - if (isStructured(format)) - return serialize( - { commands: commands.map(({ command, path }) => describeCommand(command, path.join(' '))) }, - format, - ) - - return `COMMANDS\n${renderCommandRows(commands)}\n` -} - -// Curated onboarding examples for the top-level overview (gh-style): the -// shortest path from zero to a structured app run. Editorial, not an exhaustive -// dump — per-command examples live in each command's own `--help`. -const ROOT_EXAMPLES = [`${BIN} auth login`, `${BIN} get app`, `${BIN} run app <id> "hello" -o json`] - -function renderTopicRows(): string { - const width = TOPICS.reduce((max, t) => Math.max(max, t.name.length), 0) + 2 - return TOPICS.map((t) => ` ${t.name.padEnd(width)}${t.summary}`).join('\n') -} - -function renderGlobalFlagRows(): string { - const width = GLOBAL_FLAG_HELP.reduce((max, f) => Math.max(max, f.label.length), 0) + 2 - return GLOBAL_FLAG_HELP.map((f) => ` ${f.label.padEnd(width)}${f.description}`).join('\n') -} - -function formatTopLevelHelpText(tree: CommandTree): string { - const sections = [ - `${BIN} — Dify command-line interface`, - `USAGE\n ${BIN} <command> <subcommand> [flags]`, - `COMMANDS\n${renderCommandRows(collectCommands(tree))}`, - `EXAMPLES\n${ROOT_EXAMPLES.map((ex) => ` $ ${ex}`).join('\n')}`, - `GLOBAL FLAGS\n${renderGlobalFlagRows()}`, - `GUIDES\n${renderTopicRows()}`, - `LEARN MORE\n` + - ` Use \`${BIN} <command> --help\` for details on a command.\n` + - ` New here? Run \`${BIN} help account\`. Agents: \`${BIN} help agent\` or \`${BIN} help -o json --compact\`.`, - ] - - return `${sections.join('\n\n')}\n` -} - -export function formatTopLevelHelp( - tree: CommandTree, - format: string, - options: { compact?: boolean } = {}, -): string { - if (options.compact && isStructured(format)) { - return serialize( - { - commands: collectCommands(tree).map(({ command, path }) => - summarizeCommand(command, path.join(' ')), - ), - }, - format, - ) - } - - if (isStructured(format)) { - return serialize( - { - bin: BIN, - contract: CONTRACT, - commands: collectCommands(tree).map(({ command, path }) => - describeCommand(command, path.join(' ')), - ), - topics: TOPICS.map((t) => ({ name: t.name, summary: t.summary })), - }, - format, - ) - } - - return formatTopLevelHelpText(tree) -} diff --git a/cli/src/framework/output.test.ts b/cli/src/framework/output.test.ts deleted file mode 100644 index d4c15466db2..00000000000 --- a/cli/src/framework/output.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { FormattedPrintable, NamePrintable, TablePrintable } from './output' -import { describe, expect, it } from 'vite-plus/test' -import { OutputFormatNotSupportedError } from './errors' -import { formatted, raw, stringifyOutput, table } from './output' - -function makeFormatted(opts: { - text?: string - json?: unknown - name?: string -}): FormattedPrintable & NamePrintable { - return { - text: () => opts.text ?? 'hello', - json: () => opts.json ?? { msg: 'hello' }, - name: () => opts.name ?? 'my-name', - } -} - -function makeTable(opts: { - columns?: Array<{ name: string; priority: number }> - rows?: Array<Array<string | number | boolean | null | undefined>> - json?: unknown - name?: string -}): TablePrintable & NamePrintable { - return { - tableColumns: () => - opts.columns ?? [ - { name: 'NAME', priority: 0 }, - { name: 'STATUS', priority: 0 }, - ], - tableRows: () => opts.rows ?? [['alice', 'active']], - json: () => opts.json ?? [{ name: 'alice', status: 'active' }], - name: () => opts.name ?? 'table-name', - } -} - -describe('raw', () => { - it('creates RawOutput with kind=raw', () => { - const out = raw('hello\n') - expect(out.kind).toBe('raw') - expect(out.data).toBe('hello\n') - }) -}) - -describe('table', () => { - it('creates TableOutput with kind=table', () => { - const data = makeTable({}) - const out = table({ format: 'json', data }) - expect(out.kind).toBe('table') - expect(out.format).toBe('json') - expect(out.data).toBe(data) - }) -}) - -describe('formatted', () => { - it('creates FormattedOutput with kind=formatted', () => { - const data = makeFormatted({}) - const out = formatted({ format: 'text', data }) - expect(out.kind).toBe('formatted') - expect(out.format).toBe('text') - expect(out.data).toBe(data) - }) -}) - -describe('stringifyOutput — raw', () => { - it('returns raw data as-is', () => { - expect(stringifyOutput(raw('abc\n'))).toBe('abc\n') - }) -}) - -describe('stringifyOutput — formatted', () => { - it('text: calls data.text()', () => { - const out = formatted({ format: 'text', data: makeFormatted({ text: 'plain text\n' }) }) - expect(stringifyOutput(out)).toBe('plain text\n') - }) - - it('empty format: calls data.text()', () => { - const out = formatted({ format: '', data: makeFormatted({ text: 'default\n' }) }) - expect(stringifyOutput(out)).toBe('default\n') - }) - - it('json: serializes data.json() with 2-space indent + newline', () => { - const out = formatted({ format: 'json', data: makeFormatted({ json: { x: 1 } }) }) - expect(stringifyOutput(out)).toBe(`${JSON.stringify({ x: 1 }, null, 2)}\n`) - }) - - it('yaml: renders YAML of data.json()', () => { - const out = formatted({ format: 'yaml', data: makeFormatted({ json: { x: 1 } }) }) - const result = stringifyOutput(out) - expect(result).toContain('x: 1') - }) - - it('name: returns data.name() + newline', () => { - const out = formatted({ format: 'name', data: makeFormatted({ name: 'my-app' }) }) - expect(stringifyOutput(out)).toBe('my-app\n') - }) - - it('name: throws when data has no name()', () => { - const noName: FormattedPrintable = { - text: () => 'txt', - json: () => ({}), - } - const out = formatted({ format: 'name', data: noName }) - expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError) - }) - - it('unknown format: throws with allowed list', () => { - const out = formatted({ format: 'csv', data: makeFormatted({}) }) - expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError) - }) -}) - -describe('stringifyOutput — table', () => { - it('default format: renders tabular text with header row', () => { - const out = table({ format: '', data: makeTable({}) }) - const result = stringifyOutput(out) - expect(result).toContain('NAME') - expect(result).toContain('STATUS') - expect(result).toContain('alice') - expect(result).toContain('active') - }) - - it('wide: includes all columns', () => { - const data = makeTable({ - columns: [ - { name: 'NAME', priority: 0 }, - { name: 'EXTRA', priority: 1 }, - ], - rows: [['bob', 'hidden']], - }) - const wide = table({ format: 'wide', data }) - const narrow = table({ format: '', data }) - expect(stringifyOutput(wide)).toContain('EXTRA') - expect(stringifyOutput(narrow)).not.toContain('EXTRA') - }) - - it('aligns columns correctly when cells contain CJK double-width characters', () => { - const data = makeTable({ - columns: [ - { name: 'NAME', priority: 0 }, - { name: 'ID', priority: 0 }, - ], - rows: [ - ['hello', 'aaa'], - ['猜谜', 'bbb'], // 猜谜 = 2 CJK chars, display width 4 - ], - }) - const result = stringifyOutput(table({ format: '', data })) - const lines = result.split('\n').filter((l) => l.length > 0) - // 'hello' display width 5, '猜谜' display width 4 — column width=5 - // padding after 'hello': 5-5+2=2 spaces → 'hello aaa' - // padding after '猜谜': 5-4+2=3 spaces → '猜谜 bbb' - expect(lines[1]).toBe('hello aaa') - expect(lines[2]).toBe('猜谜 bbb') - }) - - it('json: serializes data.json() + newline', () => { - const out = table({ format: 'json', data: makeTable({ json: [{ id: 1 }] }) }) - expect(stringifyOutput(out)).toBe(`${JSON.stringify([{ id: 1 }], null, 2)}\n`) - }) - - it('yaml: renders YAML of data.json()', () => { - const out = table({ format: 'yaml', data: makeTable({ json: [{ id: 1 }] }) }) - expect(stringifyOutput(out)).toContain('id: 1') - }) - - it('name: returns data.name() + newline', () => { - const out = table({ format: 'name', data: makeTable({ name: 'row-name' }) }) - expect(stringifyOutput(out)).toBe('row-name\n') - }) - - it('name: throws when data has no name()', () => { - const noName: TablePrintable = { - tableColumns: () => [], - tableRows: () => [], - json: () => [], - } - const out = table({ format: 'name', data: noName }) - expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError) - }) - - it('unknown format: throws with allowed list', () => { - const out = table({ format: 'csv', data: makeTable({}) }) - expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError) - }) - - it('table renders column padding correctly', () => { - const data = makeTable({ - columns: [ - { name: 'NAME', priority: 0 }, - { name: 'ID', priority: 0 }, - ], - rows: [ - ['alice-longname', '1'], - ['bob', '2'], - ], - }) - const result = stringifyOutput(table({ format: '', data })) - const lines = result.split('\n').filter(Boolean) - expect(lines).toHaveLength(3) - const headerParts = lines[0]!.split(/\s{2,}/) - expect(headerParts[0]).toBe('NAME') - expect(headerParts[1]).toBe('ID') - }) - - it('empty columns produces only a newline', () => { - const data = makeTable({ columns: [], rows: [] }) - expect(stringifyOutput(table({ format: '', data }))).toBe('\n') - }) -}) diff --git a/cli/src/framework/output.ts b/cli/src/framework/output.ts deleted file mode 100644 index c07a66b6fe2..00000000000 --- a/cli/src/framework/output.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { dump } from 'js-yaml' -import { OutputFormatNotSupportedError } from './errors' - -export type RawOutput = { - readonly kind: 'raw' - readonly data: string -} - -export type TableCell = string | number | boolean | null | undefined - -export type TableColumn = { - readonly name: string - readonly priority: number -} - -export type TablePrintable = { - readonly tableColumns: () => readonly TableColumn[] - readonly tableRows: () => readonly (readonly TableCell[])[] - readonly json: () => unknown -} - -export type FormattedPrintable = { - readonly text: () => string - readonly json: () => unknown -} - -export type NamePrintable = { - readonly name: () => string -} - -export type JsonPrintable = { - readonly json: () => unknown -} - -export const OutputFormat = { - NAME: 'name', - JSON: 'json', - YAML: 'yaml', - TEXT: 'text', - WIDE: 'wide', -} as const - -export type TableOutput<TRow extends TablePrintable> = { - readonly kind: 'table' - readonly format: string - readonly data: TRow -} - -export type FormattedOutput<TData extends FormattedPrintable> = { - readonly kind: 'formatted' - readonly format: string - readonly data: TData -} - -export type CommandOutput = - | RawOutput - | TableOutput<TablePrintable> - | FormattedOutput<FormattedPrintable> - -export function raw(data: string): RawOutput { - return { kind: 'raw', data } -} - -export function table<TRow extends TablePrintable>(opts: { - readonly format: string - readonly data: TRow -}): TableOutput<TRow> { - return { kind: 'table', ...opts } -} - -export function formatted<TData extends FormattedPrintable>(opts: { - readonly format: string - readonly data: TData -}): FormattedOutput<TData> { - return { kind: 'formatted', ...opts } -} - -export function stringifyOutput(output: CommandOutput): string { - switch (output.kind) { - case 'raw': - return output.data - case 'table': - return stringifyTableOutput(output) - case 'formatted': - return stringifyFormattedOutput(output) - } -} - -function stringifyFormattedOutput(output: FormattedOutput<FormattedPrintable>): string { - switch (output.format) { - case '': - case OutputFormat.TEXT: - return output.data.text() - case OutputFormat.JSON: - return `${JSON.stringify(output.data.json(), null, 2)}\n` - case OutputFormat.YAML: - return dump(output.data.json(), { indent: 2, lineWidth: -1 }) - case OutputFormat.NAME: - return `${toName(output.data)}\n` - default: - throw new OutputFormatNotSupportedError(output.format) - } -} - -function stringifyTableOutput(output: TableOutput<TablePrintable>): string { - switch (output.format) { - case '': - case OutputFormat.WIDE: - return renderTable(output) - case OutputFormat.JSON: - return `${JSON.stringify(output.data.json(), null, 2)}\n` - case OutputFormat.YAML: - return dump(output.data.json(), { indent: 2, lineWidth: -1 }) - case OutputFormat.NAME: - return `${toName(output.data)}\n` - default: - throw new OutputFormatNotSupportedError(output.format) - } -} - -function renderTable(output: TableOutput<TablePrintable>): string { - const wide = output.format === 'wide' - const columns = output.data.tableColumns() - const keep: number[] = [] - for (let i = 0; i < columns.length; i++) { - const column = columns[i] - if (column !== undefined && (column.priority === 0 || wide)) keep.push(i) - } - - const rows = [ - keep.map((i) => columns[i]?.name ?? ''), - ...output.data.tableRows().map((row) => - keep.map((idx) => { - const cell = row[idx] - return cell === null || cell === undefined ? '' : String(cell) - }), - ), - ] - return formatTable(rows) -} - -function isWideCodePoint(cp: number): boolean { - return ( - (cp >= 0x1100 && cp <= 0x115f) || - cp === 0x2329 || - cp === 0x232a || - (cp >= 0x2e80 && cp <= 0x3247) || - (cp >= 0x3250 && cp <= 0x4dbf) || - (cp >= 0x4e00 && cp <= 0xa4c6) || - (cp >= 0xa960 && cp <= 0xa97c) || - (cp >= 0xac00 && cp <= 0xd7a3) || - (cp >= 0xf900 && cp <= 0xfaff) || - (cp >= 0xfe10 && cp <= 0xfe19) || - (cp >= 0xfe30 && cp <= 0xfe6b) || - (cp >= 0xff01 && cp <= 0xff60) || - (cp >= 0xffe0 && cp <= 0xffe6) || - (cp >= 0x1b000 && cp <= 0x1b001) || - (cp >= 0x1f200 && cp <= 0x1f251) || - (cp >= 0x20000 && cp <= 0x3fffd) - ) -} - -function displayWidth(s: string): number { - let w = 0 - for (const ch of s) w += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1 - return w -} - -function formatTable(rows: readonly (readonly string[])[]): string { - if (rows.length === 0) return '' - const colCount = rows[0]?.length ?? 0 - const widths: number[] = Array.from({ length: colCount }, () => 0) - for (const row of rows) { - for (let i = 0; i < colCount; i++) { - const cell = row[i] ?? '' - const w = displayWidth(cell) - if (w > (widths[i] ?? 0)) widths[i] = w - } - } - const lines = rows.map((row) => { - const cells: string[] = [] - for (let i = 0; i < colCount; i++) { - const cell = row[i] ?? '' - const isLast = i === colCount - 1 - if (isLast) { - cells.push(cell) - } else { - const pad = (widths[i] ?? 0) - displayWidth(cell) + 2 - cells.push(cell + ' '.repeat(pad)) - } - } - return cells.join('') - }) - return `${lines.join('\n')}\n` -} - -function toName(data: TablePrintable | FormattedPrintable): string { - if (!isNamePrintable(data)) throw new OutputFormatNotSupportedError('name') - return data.name() -} - -function isNamePrintable( - data: TablePrintable | FormattedPrintable, -): data is (TablePrintable | FormattedPrintable) & NamePrintable { - return typeof (data as { name?: unknown }).name === 'function' -} diff --git a/cli/src/framework/run.test.ts b/cli/src/framework/run.test.ts deleted file mode 100644 index 9ce0df7f45e..00000000000 --- a/cli/src/framework/run.test.ts +++ /dev/null @@ -1,600 +0,0 @@ -import type { CommandConstructor } from './command' -import type { CommandTree } from './registry' -import { describe, expect, it } from 'vite-plus/test' -import { BaseError, HttpClientError, newError } from '@/errors/base' -import { ErrorCode, ExitCode } from '@/errors/codes' -import { CONTRACT } from '@/help/contract' -import { Command } from './command' -import { run, sniffOutputFormat } from './run' - -describe('sniffOutputFormat', () => { - it('returns empty for empty argv', () => { - expect(sniffOutputFormat([])).toBe('') - }) - - it('returns empty when no output flag present', () => { - expect(sniffOutputFormat(['cmd'])).toBe('') - expect(sniffOutputFormat(['cmd', 'pos', '--flag'])).toBe('') - }) - - it('parses --output=value', () => { - expect(sniffOutputFormat(['cmd', '--output=json'])).toBe('json') - }) - - it('parses --output value (space form)', () => { - expect(sniffOutputFormat(['cmd', '--output', 'json'])).toBe('json') - }) - - it('parses -o value (space form)', () => { - expect(sniffOutputFormat(['cmd', '-o', 'yaml'])).toBe('yaml') - }) - - it('parses -o=value', () => { - expect(sniffOutputFormat(['cmd', '-o=text'])).toBe('text') - }) - - it('returns empty when next token after space-form is itself a flag', () => { - expect(sniffOutputFormat(['cmd', '-o', '--other'])).toBe('') - expect(sniffOutputFormat(['cmd', '--output', '--other'])).toBe('') - }) - - it('stops at end-of-flags marker --', () => { - expect(sniffOutputFormat(['cmd', '--', '-o', 'json'])).toBe('') - expect(sniffOutputFormat(['cmd', '--', '--output=json'])).toBe('') - }) - - it('first occurrence wins on duplicate flags', () => { - expect(sniffOutputFormat(['cmd', '--output=json', '--output=yaml'])).toBe('json') - }) - - it('does NOT support concatenated short form -o<val>', () => { - expect(sniffOutputFormat(['cmd', '-ojson'])).toBe('') - }) -}) - -type Captured = { - stdout: string - stderr: string - exit: number | undefined -} - -async function captureRun(tree: CommandTree, argv: string[]): Promise<Captured> { - const captured: Captured = { stdout: '', stderr: '', exit: undefined } - const origStdout = process.stdout.write.bind(process.stdout) - const origStderr = process.stderr.write.bind(process.stderr) - const origExit = process.exit.bind(process) - - process.stdout.write = ((chunk: string | Uint8Array) => { - captured.stdout += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk) - return true - }) as typeof process.stdout.write - - process.stderr.write = ((chunk: string | Uint8Array) => { - captured.stderr += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk) - return true - }) as typeof process.stderr.write - - process.exit = ((code?: number) => { - captured.exit = code - // do not throw; the catch block should `return` after exit - }) as typeof process.exit - - try { - await run(tree, argv) - } finally { - process.stdout.write = origStdout - process.stderr.write = origStderr - process.exit = origExit - } - return captured -} - -function makeTree(cmd: CommandConstructor): CommandTree { - return { cmd: { command: cmd, subcommands: {} } } -} - -describe('run() catch routing', () => { - it('routes BaseError to human format with semantic exit code', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw new BaseError({ - code: ErrorCode.NotLoggedIn, - message: 'not logged in', - hint: 'run `difyctl auth login` to authenticate', - }) - } - } - const result = await captureRun(makeTree(Throwing), ['cmd']) - expect(result.stderr).toBe( - 'not_logged_in: not logged in\nhint: run `difyctl auth login` to authenticate\n', - ) - expect(result.exit).toBe(ExitCode.Auth) - expect(result.stdout).toBe('') - }) - - it('routes BaseError to JSON envelope with --output=json', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw new BaseError({ - code: ErrorCode.NotLoggedIn, - message: 'not logged in', - hint: 'run `difyctl auth login` to authenticate', - }) - } - } - const result = await captureRun(makeTree(Throwing), ['cmd', '--output=json']) - expect(result.stderr).toBe( - `${JSON.stringify({ - error: { - code: 'not_logged_in', - message: 'not logged in', - hint: 'run `difyctl auth login` to authenticate', - }, - })}\n`, - ) - expect(result.exit).toBe(ExitCode.Auth) - }) - - it('routes BaseError to JSON envelope with -o json (space form)', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw newError(ErrorCode.NotLoggedIn, 'not logged in') - } - } - const result = await captureRun(makeTree(Throwing), ['cmd', '-o', 'json']) - expect(result.stderr).toContain('"code":"not_logged_in"') - expect(result.stderr.startsWith('{')).toBe(true) - expect(result.exit).toBe(ExitCode.Auth) - }) - - it('keeps human format when -- separates positional from --output', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw newError(ErrorCode.NotLoggedIn, 'not logged in') - } - } - const result = await captureRun(makeTree(Throwing), ['cmd', '--', '--output=json']) - expect(result.stderr.startsWith('not_logged_in:')).toBe(true) - }) - - it('routes Usage error to exit code 2 with code prefix', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw newError(ErrorCode.UsageInvalidFlag, 'bad flag') - } - } - const result = await captureRun(makeTree(Throwing), ['cmd']) - expect(result.stderr).toBe('usage_invalid_flag: bad flag\n') - expect(result.exit).toBe(ExitCode.Usage) - }) - - it('routes Server5xx error with http_status line and generic exit', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom')).withHttpStatus( - 502, - ) - } - } - const result = await captureRun(makeTree(Throwing), ['cmd']) - expect(result.stderr).toBe('server_5xx: upstream boom\nhttp_status: 502\n') - expect(result.exit).toBe(ExitCode.Generic) - }) - - it('renders request line and http_status when both are present', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom')) - .withRequest('GET', 'https://api.dify.ai/v1/me') - .withHttpStatus(502) - } - } - const result = await captureRun(makeTree(Throwing), ['cmd']) - expect(result.stderr).toBe( - 'server_5xx: upstream boom\nrequest: GET https://api.dify.ai/v1/me\nhttp_status: 502\n', - ) - expect(result.exit).toBe(ExitCode.Generic) - }) - - it('serializes method and url in JSON envelope', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw HttpClientError.from(newError(ErrorCode.Server4xxOther, 'not found')) - .withRequest('GET', 'https://api.dify.ai/v1/apps/x') - .withHttpStatus(404) - } - } - const result = await captureRun(makeTree(Throwing), ['cmd', '--output=json']) - const envelope = JSON.parse(result.stderr.trim()) - expect(envelope.error.method).toBe('GET') - expect(envelope.error.url).toBe('https://api.dify.ai/v1/apps/x') - expect(envelope.error.http_status).toBe(404) - expect(envelope.error.code).toBe('server_4xx_other') - expect(result.exit).toBe(ExitCode.Generic) - }) - - it('routes non-BaseError to JSON envelope with -o json (exit 1)', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw new Error('boom') - } - } - const result = await captureRun(makeTree(Throwing), ['cmd', '-o', 'json']) - expect(result.stderr).toBe( - `${JSON.stringify({ error: { code: 'unknown', message: 'boom' } })}\n`, - ) - expect(result.exit).toBe(ExitCode.Generic) - expect(result.stdout).toBe('') - }) - - it('wraps a generic Error into the human unknown form and exits 1', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw new Error('oops') - } - } - const result = await captureRun(makeTree(Throwing), ['cmd']) - expect(result.stderr).toBe('unknown: oops\n') - expect(result.exit).toBe(ExitCode.Generic) - }) - - it('wraps a non-Error throw via String() coercion into unknown form', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - // oxlint-disable-next-line no-throw-literal - throw 'plain string' - } - } - const result = await captureRun(makeTree(Throwing), ['cmd']) - expect(result.stderr).toBe('unknown: plain string\n') - expect(result.exit).toBe(ExitCode.Generic) - }) - - it('exits 0 on EPIPE without writing an error envelope', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw Object.assign(new Error('broken pipe'), { code: 'EPIPE' }) - } - } - // process.exit is typed `never`; stub it to halt (throw) like the real call, - // so the EPIPE early-exit doesn't fall through to the envelope path. - let exitCode: number | undefined - let stderr = '' - const origExit = process.exit.bind(process) - const origStderr = process.stderr.write.bind(process.stderr) - process.exit = ((code?: number) => { - exitCode = code - throw new Error('__exit__') - }) as typeof process.exit - process.stderr.write = ((chunk: string | Uint8Array) => { - stderr += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk) - return true - }) as typeof process.stderr.write - try { - await run(makeTree(Throwing), ['cmd', '-o', 'json']) - } catch (e) { - expect((e as Error).message).toBe('__exit__') - } finally { - process.exit = origExit - process.stderr.write = origStderr - } - expect(exitCode).toBe(0) - expect(stderr).toBe('') - }) - - it('preserves RateLimited semantic exit code through the collapsed catch', async () => { - class Throwing extends Command { - async run(_argv: string[]) { - throw newError(ErrorCode.RateLimited, 'slow down') - } - } - const result = await captureRun(makeTree(Throwing), ['cmd']) - expect(result.exit).toBe(ExitCode.RateLimited) - }) - - it('does not call process.exit when command runs successfully', async () => { - class Ok extends Command { - async run(_argv: string[]) { - // returning void → run() does not write to stdout - } - } - const result = await captureRun({ cmd: { command: Ok, subcommands: {} } }, ['cmd']) - expect(result.stderr).toBe('') - expect(result.exit).toBeUndefined() - }) - - it('routes BaseError thrown from constructor through catch with JSON envelope', async () => { - class CtorBang extends Command { - constructor() { - super() - throw newError(ErrorCode.Unknown, 'ctor-bang') - } - - async run(_argv: string[]) {} - } - const result = await captureRun({ cmd: { command: CtorBang, subcommands: {} } }, [ - 'cmd', - '--output=json', - ]) - expect(result.stderr).toContain('"code":"unknown"') - expect(result.stderr).toContain('"message":"ctor-bang"') - expect(result.exit).toBe(ExitCode.Generic) - }) -}) - -describe('hidden commands', () => { - it('omits a hidden top-level command from printTopLevelHelp', async () => { - class Visible extends Command { - static override description = 'visible cmd' - async run() {} - } - class Hidden extends Command { - static override description = 'hidden cmd' - static hidden = true - async run() {} - } - const tree: CommandTree = { - visible: { command: Visible, subcommands: {} }, - 'secret-debug': { command: Hidden, subcommands: {} }, - } - const result = await captureRun(tree, []) - expect(result.stdout).toContain('visible') - expect(result.stdout).not.toContain('secret-debug') - }) - - it('omits a hidden subcommand from its topic listing', async () => { - class Public extends Command { - static override description = 'visible sub' - async run() {} - } - class HiddenSub extends Command { - static override description = 'hidden sub' - static hidden = true - async run() {} - } - const tree: CommandTree = { - topic: { - subcommands: { - public: { command: Public, subcommands: {} }, - 'debug-only': { command: HiddenSub, subcommands: {} }, - }, - }, - } - const result = await captureRun(tree, []) - expect(result.stdout).toContain('public') - expect(result.stdout).not.toContain('debug-only') - }) - - it('still resolves and executes a hidden command when invoked directly', async () => { - let ran = false - class Hidden extends Command { - static hidden = true - async run() { - ran = true - } - } - const tree: CommandTree = { - 'secret-debug': { command: Hidden, subcommands: {} }, - } - await captureRun(tree, ['secret-debug']) - expect(ran).toBe(true) - }) -}) - -describe('deprecated commands', () => { - it('prints a deprecation warning to stderr before running', async () => { - class Old extends Command { - static deprecated = 'use `difyctl run app` instead; removal in 2.0' - async run() { - process.stdout.write('old-ran\n') - } - } - const tree: CommandTree = { - old: { command: Old, subcommands: {} }, - } - const result = await captureRun(tree, ['old']) - expect(result.stderr).toBe('deprecated: use `difyctl run app` instead; removal in 2.0\n') - expect(result.stdout).toBe('old-ran\n') - expect(result.exit).toBeUndefined() - }) - - it('does not print a warning when deprecated is unset', async () => { - class Fresh extends Command { - async run() {} - } - const tree: CommandTree = { - fresh: { command: Fresh, subcommands: {} }, - } - const result = await captureRun(tree, ['fresh']) - expect(result.stderr).toBe('') - }) -}) - -describe('run() help routing', () => { - class GetApp extends Command { - static override description = 'List or get apps' - async run() {} - } - - const tree: CommandTree = { - get: { subcommands: { app: { command: GetApp, subcommands: {} } } }, - } - - it('renders a concept guide for `help <topic>`', async () => { - const result = await captureRun(tree, ['help', 'account']) - expect(result.stdout).toContain('account-bearer onboarding') - expect(result.stdout).toContain('difyctl auth login') - expect(result.stdout).not.toContain('COMMANDS') - expect(result.exit).toBeUndefined() - }) - - it('renders the environment guide for `help environment`', async () => { - const result = await captureRun(tree, ['help', 'environment']) - expect(result.stdout).toContain('ENVIRONMENT VARIABLES') - }) - - it('renders per-command help for `help <cmd...>`', async () => { - const result = await captureRun(tree, ['help', 'get', 'app']) - expect(result.stdout).toContain('List or get apps') - expect(result.stdout).toContain('USAGE') - expect(result.exit).toBeUndefined() - }) - - it('suggests and exits non-zero for an unknown help topic', async () => { - const result = await captureRun(tree, ['help', 'xyz']) - expect(result.stderr).toContain('unknown help topic: xyz') - expect(result.exit).toBe(1) - }) - - it('lists USAGE, COMMANDS, EXAMPLES, GLOBAL FLAGS, GUIDES and LEARN MORE in the top-level overview', async () => { - const result = await captureRun(tree, ['help']) - expect(result.stdout).toContain('USAGE') - expect(result.stdout).toContain('COMMANDS') - expect(result.stdout).toContain('EXAMPLES') - expect(result.stdout).toContain('GLOBAL FLAGS') - expect(result.stdout).toContain('GUIDES') - expect(result.stdout).toContain('LEARN MORE') - expect(result.stdout).toContain('account') - expect(result.stdout).toContain('environment') - expect(result.stdout).toContain('external') - }) - - it('derives the GLOBAL FLAGS output format list from the contract', async () => { - const result = await captureRun(tree, ['help']) - expect(result.stdout).toContain('-o, --output') - expect(result.stdout).toContain(`Output format: ${CONTRACT.outputFormats.join('|')}`) - expect(result.stdout).toContain('--http-retry') - }) - - it('does not print trailing whitespace on group rows', async () => { - const groupTree: CommandTree = { - auth: { - subcommands: { devices: { subcommands: { list: { command: GetApp, subcommands: {} } } } }, - }, - } - const result = await captureRun(groupTree, ['help']) - expect(result.stdout).not.toMatch(/ \n/) - }) - - it('emits a JSON site map for `help -o json`', async () => { - const result = await captureRun(tree, ['help', '-o', 'json']) - const obj = JSON.parse(result.stdout) - expect(obj.bin).toBe('difyctl') - expect(obj.contract.exitCodes).toBeDefined() - expect(obj.commands.some((c: { command: string }) => c.command === 'get app')).toBe(true) - expect(obj.topics.some((t: { name: string }) => t.name === 'account')).toBe(true) - expect(result.exit).toBeUndefined() - }) - - it('routes `--compact` to the site map instead of the full tree', async () => { - const result = await captureRun(tree, ['help', '-o', 'json', '--compact']) - const obj = JSON.parse(result.stdout) as { bin?: unknown; contract?: unknown } - expect(result.exit).toBeUndefined() - expect(obj.bin).toBeUndefined() - expect(obj.contract).toBeUndefined() - }) - - it('accepts every boolean spelling of `--compact`', async () => { - const canonical = await captureRun(tree, ['help', '-o', 'json', '--compact']) - for (const flag of ['--compact=true', '--compact=1']) { - const result = await captureRun(tree, ['help', '-o', 'json', flag]) - expect(result.stdout, flag).toBe(canonical.stdout) - } - }) - - it('rejects `--compact` without a structured format', async () => { - const result = await captureRun(tree, ['help', '--compact']) - expect(result.exit).toBe(ExitCode.Usage) - expect(result.stderr).toContain('--compact requires -o json or -o yaml') - }) - - it('rejects `--compact` on per-command help', async () => { - const result = await captureRun(tree, ['help', 'get', 'app', '-o', 'json', '--compact']) - expect(result.exit).toBe(ExitCode.Usage) - expect(JSON.parse(result.stderr)).toEqual({ - error: { - code: 'usage_invalid_flag', - message: '--compact is only valid on top-level help', - }, - }) - }) - - it('emits a JSON descriptor for `help <cmd> -o json`', async () => { - const result = await captureRun(tree, ['help', 'get', 'app', '-o', 'json']) - const obj = JSON.parse(result.stdout) - expect(obj.command).toBe('get app') - expect(Array.isArray(obj.flags)).toBe(true) - }) - - class Login extends Command { - static override description = 'Sign in' - async run() {} - } - - class DevicesList extends Command { - static override description = 'List sessions' - async run() {} - } - - class DevicesRevoke extends Command { - static override description = 'Revoke a session' - async run() {} - } - - const groupTree: CommandTree = { - auth: { - subcommands: { - login: { command: Login, subcommands: {} }, - devices: { - subcommands: { - list: { command: DevicesList, subcommands: {} }, - revoke: { command: DevicesRevoke, subcommands: {} }, - }, - }, - }, - }, - } - - it('drills into a namespace node for `<group> --help` instead of erroring', async () => { - const result = await captureRun(groupTree, ['auth', '--help']) - expect(result.stdout).toContain('auth login') - expect(result.stdout).toContain('auth devices list') - expect(result.stdout).toContain('auth devices revoke') - expect(result.stderr).not.toContain('unknown help topic') - expect(result.exit).toBeUndefined() - }) - - it('drills into a nested namespace node for `<group> <sub> --help`', async () => { - const result = await captureRun(groupTree, ['auth', 'devices', '--help']) - expect(result.stdout).toContain('auth devices list') - expect(result.stdout).toContain('auth devices revoke') - expect(result.stdout).not.toContain('auth login') - expect(result.stderr).not.toContain('unknown help topic') - expect(result.exit).toBeUndefined() - }) - - it('still errors for an unknown namespace-looking path', async () => { - const result = await captureRun(groupTree, ['auth', 'nope', '--help']) - expect(result.stderr).toContain('unknown help topic: auth nope') - expect(result.exit).toBe(1) - }) - - it('serializes the subtree as JSON for `<group> --help -o json`', async () => { - const result = await captureRun(groupTree, ['auth', '--help', '-o', 'json']) - expect(result.exit).toBeUndefined() - expect(result.stdout).not.toContain('COMMANDS') - const parsed = JSON.parse(result.stdout) as { commands: Array<{ command: string }> } - expect(parsed.commands.map((c) => c.command)).toEqual([ - 'auth login', - 'auth devices list', - 'auth devices revoke', - ]) - }) - - it('renders full-depth leaves at the top level (third-level commands visible)', async () => { - const result = await captureRun(groupTree, []) - expect(result.stdout).toContain('auth login') - expect(result.stdout).toContain('auth devices list') - expect(result.stdout).toContain('auth devices revoke') - }) -}) diff --git a/cli/src/framework/run.ts b/cli/src/framework/run.ts deleted file mode 100644 index 5c567940c12..00000000000 --- a/cli/src/framework/run.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { CommandTree } from './registry' -import { BaseError, newError, unknownError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { formatErrorForCli } from '@/errors/format' -import { findTopic } from '@/help/topics' -import { hasBooleanFlag } from './flags' -import { - formatCommandList, - formatHelp, - formatTopic, - formatTopLevelHelp, - isStructured, -} from './help' -import { stringifyOutput } from './output' -import { collectCommands, findSuggestions, resolveCommand } from './registry' - -function exitWithUsageError(message: string, format: string): void { - const error = newError(ErrorCode.UsageInvalidFlag, message) - process.stderr.write(`${formatErrorForCli(error, { format, isErrTTY: process.stderr.isTTY })}\n`) - process.exit(error.exit()) -} - -export async function run(tree: CommandTree, argv: string[]): Promise<void> { - if (argv.length === 0 || argv[0] === 'help' || argv.includes('--help') || argv.includes('-h')) { - const format = sniffOutputFormat(argv) - const compact = hasBooleanFlag(argv, 'compact') - // The command/topic path is the leading positional run; stop at the first - // flag so output flags like `-o json` never leak into resolution. - const helpArgv: string[] = [] - - for (const a of argv) { - if (a === 'help' || a === '--help' || a === '-h') continue - if (a.startsWith('-')) break - helpArgv.push(a) - } - - if (compact && helpArgv.length > 0) { - exitWithUsageError('--compact is only valid on top-level help', format) - return - } - - if (compact && !isStructured(format)) { - exitWithUsageError('--compact requires -o json or -o yaml', format) - return - } - - if (helpArgv.length > 0) { - const resolved = resolveCommand(tree, helpArgv) - - if (resolved) { - const out = formatHelp(resolved.command, resolved.path.join(' '), format) - process.stdout.write(isStructured(format) ? out : `${out}\n`) - - return - } - - const first = helpArgv[0] - - if (helpArgv.length === 1 && first !== undefined) { - const topic = findTopic(first) - - if (topic) { - process.stdout.write(formatTopic(topic, format)) - - return - } - } - - // Namespace drill-in: `difyctl auth --help` / `difyctl auth devices --help`. - // Group nodes have no command of their own (no index.ts), so resolveCommand - // misses them; surface their subtree instead of erroring. A strict-prefix - // match over the full-depth command walk keeps this purely derived. - const subtree = collectCommands(tree).filter( - (c) => c.path.length > helpArgv.length && helpArgv.every((token, i) => c.path[i] === token), - ) - - if (subtree.length > 0) { - process.stdout.write(formatCommandList(subtree, format)) - - return - } - - process.stderr.write(`unknown help topic: ${helpArgv.join(' ')}\n`) - const suggestions = findSuggestions(tree, helpArgv) - - if (suggestions.length > 0) { - process.stderr.write('\nDid you mean:\n') - - for (const s of suggestions.slice(0, 5)) process.stderr.write(` ${s}\n`) - } - - process.exit(1) - } - - process.stdout.write(formatTopLevelHelp(tree, format, { compact })) - - return - } - - const resolved = resolveCommand(tree, argv) - - if (!resolved) { - process.stderr.write(`unknown command: ${argv.join(' ')}\n`) - const suggestions = findSuggestions(tree, argv) - - if (suggestions.length > 0) { - process.stderr.write('\nDid you mean:\n') - - for (const s of suggestions.slice(0, 5)) process.stderr.write(` ${s}\n`) - } - - process.exit(1) - } - - try { - const Ctor = resolved.command - if (typeof Ctor.deprecated === 'string' && Ctor.deprecated.length > 0) - process.stderr.write(`deprecated: ${Ctor.deprecated}\n`) - const cmd = new Ctor() - const commandArgv = argv.slice(resolved.path.length) - cmd.processGlobalFlags(commandArgv) - - const output = await cmd.run(commandArgv) - if (output !== undefined) process.stdout.write(stringifyOutput(output)) - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'EPIPE') process.exit(0) - const e = - err instanceof BaseError - ? err - : unknownError(err instanceof Error ? err.message : String(err), err) - const format = sniffOutputFormat(argv) - process.stderr.write(`${formatErrorForCli(e, { format, isErrTTY: process.stderr.isTTY })}\n`) - process.exit(e.exit()) - } -} - -export function sniffOutputFormat(argv: readonly string[]): string { - for (let i = 0; i < argv.length; i++) { - const t = argv[i] - if (t === undefined) continue - if (t === '--') return '' - - if (t === '--output' || t === '-o') { - const next = argv[i + 1] - if (next !== undefined && !next.startsWith('-')) return next - continue - } - if (t.startsWith('--output=')) return t.slice('--output='.length) - if (t.startsWith('-o=')) return t.slice('-o='.length) - } - return '' -} diff --git a/cli/src/framework/stream.ts b/cli/src/framework/stream.ts deleted file mode 100644 index 613c6c6104a..00000000000 --- a/cli/src/framework/stream.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { SseEvent } from '@/http/sse' - -export type StreamPrinter = { - onEvent: (out: NodeJS.WritableStream, errOut: NodeJS.WritableStream, ev: SseEvent) => void - onEnd: (out: NodeJS.WritableStream, errOut: NodeJS.WritableStream) => void -} diff --git a/cli/src/framework/types.ts b/cli/src/framework/types.ts deleted file mode 100644 index 242fc280a1d..00000000000 --- a/cli/src/framework/types.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { CommandOutput } from './output' - -export type ArgValueType = string | boolean | number | string[] -export type OptionalArgValueType = ArgValueType | undefined - -export type FlagDefinition<T extends OptionalArgValueType = OptionalArgValueType> = { - readonly type: 'string' | 'boolean' | 'integer' - readonly description: string - readonly char?: string - readonly default?: ArgValueType - readonly multiple?: boolean - readonly options?: readonly string[] - // Marks a flag that applies across commands; surfaced once in the top-level - // GLOBAL FLAGS section rather than per command. - readonly helpGroup?: 'GLOBAL' - readonly _flagValue?: T -} - -export type ArgDefinition<T extends string | undefined = string | undefined> = { - readonly description: string - readonly required?: boolean - readonly _argValue?: T -} - -export type InferArgs<TArgs extends Record<string, ArgDefinition<string | undefined>>> = { - readonly [K in keyof TArgs]: TArgs[K] extends ArgDefinition<infer V> ? V : never -} - -export type InferFlags<TFlags extends Record<string, FlagDefinition<OptionalArgValueType>>> = { - readonly [K in keyof TFlags]: TFlags[K] extends FlagDefinition<infer V> ? V : never -} - -export type ParsedFlags = Record<string, OptionalArgValueType> - -export type ParsedArgs = Record<string, string | undefined> - -export type CommandMeta = { - readonly flags: Record<string, FlagDefinition> - readonly args: Record<string, ArgDefinition> -} - -export type ICommand = { - readonly run: (argv: string[]) => Promise<CommandOutput | void> -} diff --git a/cli/src/help/contract.test.ts b/cli/src/help/contract.test.ts deleted file mode 100644 index a97488c8c29..00000000000 --- a/cli/src/help/contract.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' -import { describe, expect, it } from 'vite-plus/test' -import { HttpClientError, newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { CONTRACT } from './contract' - -describe('errorEnvelope contract', () => { - // Guard against the documented shape drifting from the real envelope: build an - // error with every optional field populated and assert each JSON key it emits - // is named in the contract string. Adding/removing an envelope field without - // updating the doc fails here. - it('documents every key the real JSON envelope can emit', () => { - const server: ErrorBody = { code: 'app_unavailable', message: 'gone', status: 404 } - const err = HttpClientError.from(newError(ErrorCode.Server4xxOther, 'boom')) - .withHint('do x') - .withRequest('GET', 'https://api.dify.ai/v1/me') - .withHttpStatus(404) - .withRawResponse('{"x":1}') - .withServerError(server) - - const env = err.toEnvelope() - - for (const key of Object.keys(env.error)) - expect(CONTRACT.errorEnvelope.shape).toContain(`"${key}"`) - }) -}) diff --git a/cli/src/help/contract.ts b/cli/src/help/contract.ts deleted file mode 100644 index 407beed6638..00000000000 --- a/cli/src/help/contract.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { ExitCode } from '@/errors/codes' - -export type Contract = { - readonly exitCodes: Readonly<Record<string, string>> - readonly outputFormats: readonly string[] - readonly errorEnvelope: { - readonly description: string - readonly shape: string - } - readonly hitl: { - readonly description: string - readonly resume: string - } -} - -const EXIT_CODE_DESCRIPTIONS: Readonly<Record<number, string>> = { - [ExitCode.Success]: - 'success (also a workflow paused for human input — check stdout for status "paused")', - [ExitCode.Generic]: 'generic error', - [ExitCode.Usage]: 'usage error (bad flag / missing arg)', - [ExitCode.Auth]: 'auth error (not logged in / token expired)', - [ExitCode.VersionCompat]: 'version / compatibility error', -} - -function buildExitCodes(): Record<string, string> { - const out: Record<string, string> = {} - for (const code of Object.values(ExitCode)) - out[String(code)] = EXIT_CODE_DESCRIPTIONS[code] ?? 'see docs' - return out -} - -// Single machine-readable source for the cross-command contract an agent -// needs to drive difyctl: exit semantics, output formats, the stderr error -// envelope, and the human-in-the-loop pause protocol. -export const CONTRACT: Contract = { - exitCodes: buildExitCodes(), - outputFormats: ['json', 'yaml', 'name', 'wide', 'text'], - errorEnvelope: { - description: - 'On failure the error goes to stderr. Under -o json/yaml it is a structured envelope; otherwise a human line.', - shape: - '{ "error": { "code": string, "message": string, "hint"?: string, "http_status"?: number, "method"?: string, "url"?: string, "raw_response"?: string, "server"?: object } }', - }, - hitl: { - description: - 'When a workflow pauses for human input, `run app` exits 0 (success-with-pending) and writes a JSON object to stdout with status "paused", form_token, workflow_run_id and resolved_default_values.', - resume: - 'difyctl resume app <app_id> <form_token> --workflow-run-id <id> [--inputs \'{"key":"value"}\']', - }, -} - -// Single source for the top-level GLOBAL FLAGS section: flags the framework -// parses before any command sees argv. `-o` is parsed globally (see -// sniffOutputFormat); its accepted values come straight from -// CONTRACT.outputFormats so the two can never drift. -export const GLOBAL_FLAG_HELP: ReadonlyArray<{ label: string; description: string }> = [ - { - label: '-o, --output <format>', - description: `Output format: ${CONTRACT.outputFormats.join('|')}`, - }, - { - label: '--compact', - description: 'Command map with only command/description/effect (top-level help, json|yaml)', - }, - { label: '-v, --verbose', description: 'Enable verbose logging' }, - { - label: '--http-retry <n>', - description: 'Retry idempotent GET/PUT/DELETE on transient errors (0 disables)', - }, -] diff --git a/cli/src/help/skill-template.ts b/cli/src/help/skill-template.ts deleted file mode 100644 index 6315b10c9fc..00000000000 --- a/cli/src/help/skill-template.ts +++ /dev/null @@ -1,43 +0,0 @@ -export const SKILL_TEMPLATE = `--- -name: difyctl -description: Drive the difyctl CLI to manage Dify apps, workspaces, members and runs. Use when the task involves difyctl or operating a Dify instance from the command line. ---- - -# difyctl - -difyctl is self-describing — do not guess commands. - -## Discover the command surface -1. Read the complete compact sitemap: - \`difyctl help -o json --compact\` - Each entry contains only \`command\`, \`description\`, and \`effect\`. -2. Decide whether the request maps to one command, needs clarification, or is - unsupported. -3. When one command is selected, inspect it: - \`difyctl help <path> -o json\` - Expand a multi-token path as separate shell arguments; never quote the - entire returned path. - That JSON is the source of truth for args, flags, examples, \`effect\`, - and \`agentGuide\`. Never skip it — the sitemap carries no args or flags, - so any flag you name without this step is a guess. - -When the task depends on global exit codes, error envelopes, HITL, or retry -semantics, read \`difyctl help agent\`. - -Do not read the full command tree. Report unsupported only after checking the -compact sitemap. Clarify only when more than one command could satisfy the -request. - -## The one non-obvious thing: HITL pauses are not failures -A run can pause for human input. It exits with **code 0** and emits a -\`paused\` JSON payload — this is success-with-pending, NOT a crash. -Resume as the payload instructs (see \`difyctl resume app --help\`). - -## Before any write/destructive action -Treat the inspected command's \`effect\` as the confirmation gate before -running it. Never execute a write/destructive command without confirmation. - ---- -difyctl skill v{{VERSION}} — if \`difyctl version\` differs, re-run -\`difyctl skills install\` to refresh. -` diff --git a/cli/src/help/skill.test.ts b/cli/src/help/skill.test.ts deleted file mode 100644 index a6edf40cf71..00000000000 --- a/cli/src/help/skill.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { commandTree } from '@/commands/tree.generated' -import { collectCommands } from '@/framework/registry' -import { versionInfo } from '@/version/info' -import { renderSkill } from './skill' -import { SKILL_TEMPLATE } from './skill-template' - -const SELF_REFERENCES = new Set(['resume app', 'skills install', 'version']) - -describe('renderSkill', () => { - it('substitutes the version stamp and changes nothing else', () => { - expect(renderSkill({ version: '9.9.9-test' })).toBe( - SKILL_TEMPLATE.replaceAll('{{VERSION}}', '9.9.9-test'), - ) - }) - - it('stamps the running binary version (deterministic under test setup)', () => { - expect(versionInfo.version).toBe('0.0.0-test') - expect(renderSkill({ version: versionInfo.version })).toContain('difyctl skill v0.0.0-test') - }) - - it('leaves no unfilled template tokens', () => { - expect(renderSkill({ version: versionInfo.version })).not.toContain('{{') - }) - - it('uses the compact sitemap before per-command help', () => { - const skill = renderSkill({ version: versionInfo.version }) - expect(skill).toContain('difyctl help -o json --compact') - expect(skill).toContain('difyctl help <path> -o json') - expect(skill).toContain('difyctl help agent') - expect(skill.indexOf('difyctl help -o json --compact')).toBeLessThan( - skill.indexOf('difyctl help <path> -o json'), - ) - }) - - it('enumerates no command from the tree (zero drift surface)', () => { - const skill = renderSkill({ version: versionInfo.version }) - for (const { path } of collectCommands(commandTree)) { - const command = path.join(' ') - if (SELF_REFERENCES.has(command)) continue - expect(skill, `skill must not enumerate command "${command}"`).not.toContain(command) - } - }) - - it('carries none of the old enumerated sections', () => { - const skill = renderSkill({ version: versionInfo.version }) - for (const marker of [ - '## Safety', - '## Core workflow', - '## Reference', - 'OUTPUT FORMATS', - 'EXIT CODES', - 'reference/', - ]) - expect(skill).not.toContain(marker) - }) - - it('inlines no command-specific flags (only the --help pointer)', () => { - const skill = renderSkill({ version: versionInfo.version }) - const longFlags = skill.match(/--[a-z][\w-]+/g) ?? [] - const allowed = new Set(['--help', '--compact']) - for (const flag of longFlags) - expect(allowed.has(flag), `unexpected flag in skill: ${flag}`).toBe(true) - }) -}) diff --git a/cli/src/help/skill.ts b/cli/src/help/skill.ts deleted file mode 100644 index 5f31c1d72f1..00000000000 --- a/cli/src/help/skill.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { SKILL_TEMPLATE } from './skill-template' - -export type RenderSkillOptions = { - readonly version: string -} - -export function renderSkill(opts: RenderSkillOptions): string { - return SKILL_TEMPLATE.replaceAll('{{VERSION}}', opts.version) -} diff --git a/cli/src/help/topics.test.ts b/cli/src/help/topics.test.ts deleted file mode 100644 index a3dc4af54fc..00000000000 --- a/cli/src/help/topics.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { ENV_REGISTRY } from '@/env/registry' -import { findTopic, TOPICS } from './topics' - -function render(name: string): string { - const topic = findTopic(name) - if (!topic) throw new Error(`topic not found: ${name}`) - return topic.render() -} - -describe('topic registry', () => { - it('registers account, agent, environment and external', () => { - expect(TOPICS.map((t) => t.name)).toEqual(['account', 'agent', 'environment', 'external']) - }) - - it('findTopic returns undefined for unknown names', () => { - expect(findTopic('nope')).toBeUndefined() - }) -}) - -describe('account topic', () => { - it('mentions auth login device flow', () => { - expect(render('account')).toContain('difyctl auth login') - }) - - it('mentions get/describe/run app commands', () => { - const out = render('account') - expect(out).toContain('difyctl get app') - expect(out).toContain('difyctl describe app') - expect(out).toContain('difyctl run app') - }) - - it('mentions --workspace and env list pointers', () => { - const out = render('account') - expect(out).toContain('--workspace') - expect(out).toContain('difyctl env list') - }) -}) - -describe('agent topic', () => { - it('covers output, exit codes, auth, errors and HITL', () => { - const out = render('agent') - expect(out).toContain('EXIT CODES') - expect(out).toContain('DIFY_TOKEN') - expect(out).toContain('difyctl help <path> -o json') - expect(out).toContain('difyctl help -o json --compact') - expect(out).toMatch(/difyctl help -o json\s+full command tree/) - expect(out).toContain('difyctl get app -o json') - expect(out).toContain('HUMAN-IN-THE-LOOP') - }) -}) - -describe('external topic', () => { - it('mentions external bearer prefix and DIFY_TOKEN onboarding', () => { - const out = render('external') - expect(out).toContain('dfoe_') - expect(out).toContain('export DIFY_TOKEN') - expect(out).toContain('DIFY_TOKEN') - }) - - it('explains workspace empty-list expectation', () => { - expect(render('external')).toContain('get workspace') - }) -}) - -describe('environment topic', () => { - it('starts with the ENVIRONMENT VARIABLES header', () => { - expect(render('environment').startsWith('ENVIRONMENT VARIABLES\n\n')).toBe(true) - }) - - it('lists every var from ENV_REGISTRY with its description', () => { - const out = render('environment') - for (const v of ENV_REGISTRY) { - expect(out).toContain(v.name) - expect(out).toContain(v.description) - } - }) - - it('marks sensitive vars with a never-echoed notice', () => { - const out = render('environment') - expect(out).toContain('(treat as secret; never echoed)') - const sensitiveCount = ENV_REGISTRY.filter((v) => v.sensitive).length - const noticeCount = (out.match(/treat as secret/g) ?? []).length - expect(noticeCount).toBe(sensitiveCount) - }) -}) diff --git a/cli/src/help/topics.ts b/cli/src/help/topics.ts deleted file mode 100644 index 59e75d34e0e..00000000000 --- a/cli/src/help/topics.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { ENV_REGISTRY } from '@/env/registry' -import { CONTRACT } from './contract' - -export type HelpTopic = { - readonly name: string - readonly summary: string - readonly render: () => string -} - -const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding - - 1. Sign in interactively (browser device flow): - difyctl auth login - - 2. List accessible apps in your default workspace: - difyctl get app - - 3. Describe one app to see its parameters: - difyctl describe app <id> - - 4. Run an app and capture structured output: - difyctl run app <id> "hello" -o json - -Tips: - * Two app nouns: 'studio-app' is what you build and edit in Studio on the - web console inside a workspace (its source definition — export or move it); - 'app' is a published app you run and inspect. - * 'difyctl auth list' shows your authenticated contexts; 'difyctl use host' - and 'difyctl use account' switch between them. - * Pass --workspace <id> to target a non-default workspace. - * Pass --stream to 'difyctl run app' for live token/event output. - * 'difyctl env list' shows every env var difyctl reads. -` - -const EXTERNAL_HELP_TEXT = `difyctl: external-SSO bearer onboarding - - Most agents authenticate as a human account (see 'difyctl help account'). - External-SSO bearers (dfoe_) skip the human flow and exchange an upstream - identity for a Dify token. The CLI surfaces the same commands but a - smaller dataset: - - 1. Acquire a token through your SSO provider (out of band). - 2. Hand it to the CLI via the DIFY_TOKEN environment variable: - export DIFY_TOKEN="<your-token>" - - 3. List apps your subject is permitted to invoke: - difyctl get app - - 4. Run an app: - difyctl run app <id> "hello" -o json - -Notes: - * 'difyctl get workspace' returns an empty list for external bearers — that - is expected; external subjects have no workspace membership. - * Tokens are best stored in DIFY_TOKEN; difyctl reads it on every command. -` - -function renderEnvironment(): string { - let out = 'ENVIRONMENT VARIABLES\n\n' - for (const v of ENV_REGISTRY) { - out += ` ${v.name}\n ${v.description}\n` - if (v.sensitive) out += ' (treat as secret; never echoed)\n' - out += '\n' - } - return out -} - -function renderAgent(): string { - const exitCodes = Object.entries(CONTRACT.exitCodes) - .map(([code, desc]) => ` ${code} ${desc}`) - .join('\n') - - return `difyctl: agent operating guide - -OUTPUT - Pass -o json (or -o yaml) on every command — the JSON shape is stable and - documented. Without it you get human tables meant for a terminal. - -APP vs STUDIO-APP - Two nouns, two faces of the same app: - studio-app what you build and edit in Studio on the web console, - inside a workspace — the app's source definition. - app a published app, live and runnable. - Use 'studio-app' to work with the definition you manage on the website - (export it, move it between workspaces or instances); use 'app' to run - and inspect a published one. The COMMANDS list shows the verbs each - noun supports. - -DISCOVERY - difyctl help -o json --compact complete command map for discovery - difyctl help <path> -o json inspect the selected command; the only source of flags - difyctl help -o json full command tree + contract for troubleshooting - difyctl get app -o json list apps (ids + modes) - difyctl describe app <id> one app's mode and input schema - -AUTH - Interactive: difyctl auth login (browser device flow) - Non-interactive: export DIFY_TOKEN=<bearer> (read on every command) - Details: difyctl help account / difyctl help external - -EXIT CODES -${exitCodes} - -ERRORS - Under -o json/yaml a failure writes a structured envelope to stderr: - ${CONTRACT.errorEnvelope.shape} - -HUMAN-IN-THE-LOOP - ${CONTRACT.hitl.description} - Resume: ${CONTRACT.hitl.resume} - -RETRY - Idempotent GET/PUT/DELETE retry on transient errors (default 3); POST/PATCH - never. Override with --http-retry <n> or DIFYCTL_HTTP_RETRY. -` -} - -export const TOPICS: readonly HelpTopic[] = [ - { - name: 'account', - summary: 'Agent-onboarding text for account bearers (dfoa_)', - render: () => ACCOUNT_HELP_TEXT, - }, - { - name: 'agent', - summary: 'Cross-command contract for agents driving difyctl', - render: renderAgent, - }, - { - name: 'environment', - summary: 'Long-form documentation for every DIFY_* env var', - render: renderEnvironment, - }, - { - name: 'external', - summary: 'Agent-onboarding text for external-SSO bearers (dfoe_)', - render: () => EXTERNAL_HELP_TEXT, - }, -] - -export function findTopic(name: string): HelpTopic | undefined { - return TOPICS.find((t) => t.name === name) -} diff --git a/cli/src/http/body.test.ts b/cli/src/http/body.test.ts deleted file mode 100644 index 5a5fc898473..00000000000 --- a/cli/src/http/body.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { buildBody, isJSONSerializable } from './body.js' - -describe('isJSONSerializable', () => { - it('rejects undefined', () => { - expect(isJSONSerializable(undefined)).toBe(false) - }) - - it('accepts null and primitives', () => { - expect(isJSONSerializable(null)).toBe(true) - expect(isJSONSerializable('')).toBe(true) - expect(isJSONSerializable(0)).toBe(true) - expect(isJSONSerializable(true)).toBe(true) - }) - - it('accepts plain objects and arrays', () => { - expect(isJSONSerializable({ a: 1 })).toBe(true) - expect(isJSONSerializable([])).toBe(true) - }) - - it('rejects FormData and URLSearchParams', () => { - expect(isJSONSerializable(new FormData())).toBe(false) - expect(isJSONSerializable(new URLSearchParams())).toBe(false) - }) - - it('accepts objects with a toJSON method', () => { - expect(isJSONSerializable({ toJSON: () => ({}) })).toBe(true) - }) - - it('rejects buffer-like objects', () => { - expect(isJSONSerializable({ buffer: new ArrayBuffer(1) })).toBe(false) - }) -}) - -describe('buildBody', () => { - it('returns no body for GET regardless of json/body input', () => { - expect(buildBody({ method: 'GET', json: { a: 1 } })).toEqual({ - body: undefined, - contentType: undefined, - }) - expect(buildBody({ method: 'GET', body: 'x' })).toEqual({ - body: undefined, - contentType: undefined, - }) - }) - - it('serializes json and sets Content-Type on payload methods', () => { - const { body, contentType } = buildBody({ method: 'POST', json: { a: 1 } }) - expect(body).toBe('{"a":1}') - expect(contentType).toBe('application/json') - }) - - it('passes raw body through without Content-Type injection', () => { - const form = new FormData() - const { body, contentType } = buildBody({ method: 'POST', body: form }) - expect(body).toBe(form) - expect(contentType).toBeUndefined() - }) - - it('prefers json over body when both are supplied', () => { - const { body, contentType } = buildBody({ method: 'POST', json: { a: 1 }, body: 'raw' }) - expect(body).toBe('{"a":1}') - expect(contentType).toBe('application/json') - }) - - it('returns null body when neither json nor body is supplied', () => { - expect(buildBody({ method: 'POST' })).toEqual({ body: undefined, contentType: undefined }) - }) -}) diff --git a/cli/src/http/body.ts b/cli/src/http/body.ts deleted file mode 100644 index 0a7bade678d..00000000000 --- a/cli/src/http/body.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { BodyInit } from './types.js' - -// Reports whether a value should be JSON-stringified for the wire: primitives, -// plain objects, arrays, and anything with a `toJSON` method — but not typed -// arrays/buffers, FormData, or URLSearchParams, which are sent as-is. -export function isJSONSerializable(value: unknown): boolean { - if (value === undefined) return false - if (value === null) return true - const t = typeof value - if (t === 'string' || t === 'number' || t === 'boolean') return true - if (t !== 'object') return false - if (Array.isArray(value)) return true - const obj = value as { buffer?: unknown; constructor?: { name?: string }; toJSON?: unknown } - if (obj.buffer !== undefined) return false - if (value instanceof FormData || value instanceof URLSearchParams) return false - if (obj.constructor?.name === 'Object') return true - return typeof obj.toJSON === 'function' -} - -export type BuildBodyInput = { - readonly json?: unknown - readonly body?: BodyInit - readonly method: string -} - -export type BuildBodyResult = { - readonly body: BodyInit | undefined - readonly contentType: string | undefined -} - -// Decide the wire body and whether Content-Type should be injected. -// json wins over body when both are provided; tests assert single-source-of-truth via type system, -// but at runtime we still prefer json explicitly. -export function buildBody(input: BuildBodyInput): BuildBodyResult { - const { json, body, method } = input - const isPayloadMethod = method !== 'GET' && method !== 'HEAD' - - if (json !== undefined) { - if (!isPayloadMethod) return { body: undefined, contentType: undefined } - if (isJSONSerializable(json)) - return { body: JSON.stringify(json), contentType: 'application/json' } - return { body: json as BodyInit, contentType: undefined } - } - - // A raw `body` is passed through untouched. This is replay-safe only for buffered - // payloads (string, Blob, FormData, typed arrays) — a single-shot ReadableStream - // would be consumed on the first attempt and replay empty on retry. Safe today - // because the only stream/multipart caller (file-upload) uses POST, which is not - // in RETRY_METHODS; revisit if a ReadableStream body is ever sent over GET/PUT/DELETE. - if (body !== undefined && isPayloadMethod) return { body, contentType: undefined } - - return { body: undefined, contentType: undefined } -} diff --git a/cli/src/http/client-tls.test.ts b/cli/src/http/client-tls.test.ts deleted file mode 100644 index befe1894f41..00000000000 --- a/cli/src/http/client-tls.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Buffer } from 'node:buffer' -import type { AddressInfo } from 'node:net' -import { execFileSync } from 'node:child_process' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import * as https from 'node:https' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test' -import { createHttpClient } from './client.js' - -function generateSelfSignedCert(dir: string): { key: Buffer; cert: Buffer } { - const keyPath = join(dir, 'key.pem') - const certPath = join(dir, 'cert.pem') - execFileSync( - 'openssl', - [ - 'req', - '-x509', - '-newkey', - 'rsa:2048', - '-nodes', - '-keyout', - keyPath, - '-out', - certPath, - '-days', - '1', - '-subj', - '/CN=localhost', - ], - { stdio: ['ignore', 'ignore', 'pipe'] }, - ) - return { key: readFileSync(keyPath), cert: readFileSync(certPath) } -} - -// A real server, not a fetch mock, so this also covers Bun's native `tls` -// fetch option (ignored by Node, which only reads undici's `dispatcher`). -describe('createHttpClient against a real self-signed TLS server', () => { - let server: https.Server - let baseURL: string - let certDir: string - - beforeAll(async () => { - certDir = mkdtempSync(join(tmpdir(), 'difyctl-tls-test-')) - const { key, cert } = generateSelfSignedCert(certDir) - server = https.createServer({ key, cert }, (_req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ ok: true })) - }) - await new Promise<void>((resolve) => server.listen(0, resolve)) - const port = (server.address() as AddressInfo).port - baseURL = `https://localhost:${port}/` - }) - - afterAll(async () => { - await new Promise<void>((resolve) => server.close(() => resolve())) - rmSync(certDir, { recursive: true, force: true }) - }) - - it('rejects the self-signed cert by default', async () => { - const http = createHttpClient({ baseURL, retryAttempts: 0 }) - await expect(http.get('')).rejects.toBeDefined() - }) - - it('accepts the self-signed cert when insecure: true', async () => { - const http = createHttpClient({ baseURL, retryAttempts: 0, insecure: true }) - const res = await http.get<{ ok: boolean }>('') - expect(res.ok).toBe(true) - }) -}) diff --git a/cli/src/http/client.test.ts b/cli/src/http/client.test.ts deleted file mode 100644 index 6f981418203..00000000000 --- a/cli/src/http/client.test.ts +++ /dev/null @@ -1,1021 +0,0 @@ -import type { DifyMock } from '@test/fixtures/dify-mock/server' -import type { AddressInfo } from 'node:net' -import * as http from 'node:http' -import { startMock } from '@test/fixtures/dify-mock/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { isBaseError, isHttpClientError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { openAPIBase } from '@/util/host' -import { createHttpClient } from './client.js' - -function base(mockUrl: string): string { - return openAPIBase(mockUrl) -} - -type Stub = { url: string; stop: () => Promise<void> } - -function startStub( - handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, -): Promise<Stub> { - 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) - }) -} - -describe('http client', () => { - let mock: DifyMock - - beforeEach(async () => { - mock = await startMock() - }) - - afterEach(async () => { - await mock.stop() - }) - - it('GET returns parsed JSON when bearer is valid', async () => { - const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test' }) - const body = await client.get<{ data: unknown[] }>('workspaces') - expect(body.data).toHaveLength(2) - }) - - it('omits Authorization header when bearer is undefined', async () => { - let captured: string | null = null - const client = createHttpClient({ - baseURL: base(mock.url), - logger: () => undefined, - bearer: undefined, - hooks: { - onRequest: ({ request }) => { - captured = request.headers.get('authorization') - }, - }, - }) - try { - await client.get('workspaces') - } catch { - // 401 expected because no bearer - } - expect(captured).toBeNull() - }) - - it('sets Authorization header when bearer is provided', async () => { - let captured: string | null = null - const client = createHttpClient({ - baseURL: base(mock.url), - bearer: 'dfoa_test', - hooks: { - onRequest: ({ request }) => { - captured = request.headers.get('authorization') - }, - }, - }) - await client.get('workspaces') - expect(captured).toBe('Bearer dfoa_test') - }) - - it('sets a User-Agent header in the difyctl format', async () => { - let captured: string | null = null - const client = createHttpClient({ - baseURL: base(mock.url), - bearer: 'dfoa_test', - userAgent: 'difyctl/0.0.0-test (test; arm64; dev)', - hooks: { - onRequest: ({ request }) => { - captured = request.headers.get('user-agent') - }, - }, - }) - await client.get('workspaces') - expect(captured).toBe('difyctl/0.0.0-test (test; arm64; dev)') - }) - - // Regression guard for F-2: every production createHttpClient call site omits - // `userAgent`, so the client itself must pin a difyctl-shaped default. Without - // it, requests leak out with Node's default UA and server-side telemetry / WAF - // rules lose the CLI version signal. - it('falls back to the difyctl default User-Agent when none is supplied', async () => { - let captured: string | null = null - const client = createHttpClient({ - baseURL: base(mock.url), - bearer: 'dfoa_test', - hooks: { - onRequest: ({ request }) => { - captured = request.headers.get('user-agent') - }, - }, - }) - await client.get('workspaces') - expect(captured).toMatch(/^difyctl\/\S+ \(.+; .+; .+\)$/) - }) - - it('maps 401 to BaseError(auth_expired)', async () => { - mock.setScenario('auth-expired') - const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test' }) - let caught: unknown - try { - await client.get('workspaces') - } catch (err) { - caught = err - } - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.code).toBe(ErrorCode.AuthExpired) - expect(caught.httpStatus).toBe(401) - expect(caught.method).toBe('GET') - expect(caught.url).toMatch(/workspaces$/) - } - }) - - it('maps 5xx to BaseError(server_5xx) after retries', async () => { - mock.setScenario('server-5xx') - const client = createHttpClient({ - baseURL: base(mock.url), - bearer: 'dfoa_test', - retryAttempts: 1, - timeoutMs: 5_000, - }) - let caught: unknown - try { - await client.get('workspaces') - } catch (err) { - caught = err - } - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.code).toBe(ErrorCode.Server5xx) - expect(caught.httpStatus).toBe(503) - } - }) - - it('maps DNS failure to BaseError(network_dns)', async () => { - const client = createHttpClient({ - baseURL: base('http://nonexistent-host-12345.invalid'), - bearer: 'dfoa_test', - retryAttempts: 0, - timeoutMs: 3_000, - }) - let caught: unknown - try { - await client.get('workspaces') - } catch (err) { - caught = err - } - expect(isBaseError(caught) || caught instanceof Error).toBe(true) - }) - - it('logger fires twice per successful request (request + response phases)', async () => { - const events: { phase: string; status?: number }[] = [] - const client = createHttpClient({ - baseURL: base(mock.url), - bearer: 'dfoa_test', - logger: (e) => events.push({ phase: e.phase, status: e.status }), - }) - await client.get('workspaces') - expect(events).toHaveLength(2) - expect(events[0]?.phase).toBe('request') - expect(events[1]?.phase).toBe('response') - expect(events[1]?.status).toBe(200) - }) - - it('respects insecure URL trim (trailing slash on baseURL is normalized)', async () => { - const client = createHttpClient({ baseURL: openAPIBase(`${mock.url}/`), bearer: 'dfoa_test' }) - const body = await client.get<{ data: unknown[] }>('workspaces') - expect(body.data).toHaveLength(2) - }) - - it('preserves error envelope hint when server returns one', async () => { - const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test' }) - let caught: unknown - try { - await client.get('apps/nope') - } catch (err) { - caught = err - } - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) expect(caught.code).toBe(ErrorCode.Server4xxOther) - }) - - it('surfaces a 429 as a rate-limit error (dedicated exit code), no retry when budget is 0', async () => { - mock.setScenario('rate-limited') - const client = createHttpClient({ - baseURL: base(mock.url), - bearer: 'dfoa_test', - retryAttempts: 0, - timeoutMs: 5_000, - }) - let caught: unknown - try { - await client.get('workspaces') - } catch (err) { - caught = err - } - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.httpStatus).toBe(429) - expect(caught.code).toBe(ErrorCode.RateLimited) - expect(caught.exit()).toBe(7) - expect(caught.serverError?.code).toBe('too_many_requests') - } - }) - - it('retries an idempotent GET on a throttle 429, then succeeds', async () => { - let calls = 0 - const stub = await startStub((_req, res) => { - calls++ - if (calls === 1) { - res.writeHead(429, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) - return - } - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ workspaces: [] })) - }) - const events: { phase: string; status?: number; delayMs?: number }[] = [] - try { - const client = createHttpClient({ - baseURL: base(stub.url), - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 5_000, - logger: (e) => events.push({ phase: e.phase, status: e.status, delayMs: e.delayMs }), - }) - const body = await client.get<{ workspaces: unknown[] }>('workspaces') - expect(body.workspaces).toEqual([]) - } finally { - await stub.stop() - } - expect(calls).toBe(2) - const retry = events.find((e) => e.phase === 'retry') - expect(retry?.status).toBe(429) - expect(retry?.delayMs).toBeGreaterThan(0) - }) - - it('does not retry a quota 429 (rate_limit_error) — surfaces immediately', async () => { - let requests = 0 - const stub = await startStub((_req, res) => { - res.writeHead(429, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ code: 'rate_limit_error', message: 'quota exhausted', status: 429 })) - }) - let caught: unknown - try { - const client = createHttpClient({ - baseURL: base(stub.url), - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 5_000, - logger: (e) => { - if (e.phase === 'request') requests++ - }, - }) - try { - await client.get('workspaces') - } catch (err) { - caught = err - } - } finally { - await stub.stop() - } - expect(requests).toBe(1) - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.code).toBe(ErrorCode.RateLimited) - expect(caught.serverError?.code).toBe('rate_limit_error') - } - }) - - it('does not retry a POST 429 by default; retries with retry-on-limit', async () => { - let postDefault = 0 - const stubDefault = await startStub((_req, res) => { - postDefault++ - res.writeHead(429, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) - }) - try { - const client = createHttpClient({ - baseURL: base(stubDefault.url), - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 5_000, - }) - await expect(client.post('apps/app-1/run', { json: { inputs: {} } })).rejects.toBeDefined() - } finally { - await stubDefault.stop() - } - expect(postDefault).toBe(1) - - let calls = 0 - const stubOptIn = await startStub((_req, res) => { - calls++ - if (calls === 1) { - res.writeHead(429, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) - return - } - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ ok: true })) - }) - try { - const client = createHttpClient({ - baseURL: base(stubOptIn.url), - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 5_000, - }) - const body = await client.post<{ ok: boolean }>('apps/app-1/run', { - json: { inputs: {} }, - retryOnRateLimit: true, - }) - expect(body.ok).toBe(true) - } finally { - await stubOptIn.stop() - } - expect(calls).toBe(2) - }) - - it('still never retries a POST 5xx even with retry-on-limit (idempotency guard)', async () => { - let requests = 0 - const stub = await startStub((_req, res) => { - requests++ - res.writeHead(503, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ code: 'internal_server_error', message: 'boom', status: 503 })) - }) - try { - const client = createHttpClient({ - baseURL: base(stub.url), - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 5_000, - }) - await expect( - client.post('apps/app-1/run', { json: { inputs: {} }, retryOnRateLimit: true }), - ).rejects.toBeDefined() - } finally { - await stub.stop() - } - expect(requests).toBe(1) - }) - - it('surfaces a RateLimited error after exhausting 429 retries on GET', async () => { - let requests = 0 - let retries = 0 - const stub = await startStub((_req, res) => { - res.writeHead(429, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) - }) - let caught: unknown - try { - const client = createHttpClient({ - baseURL: base(stub.url), - bearer: 'dfoa_test', - retryAttempts: 2, - timeoutMs: 5_000, - logger: (e) => { - if (e.phase === 'request') requests++ - else if (e.phase === 'retry') retries++ - }, - }) - try { - await client.get('workspaces') - } catch (err) { - caught = err - } - } finally { - await stub.stop() - } - expect(requests).toBe(3) - expect(retries).toBe(2) - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) expect(caught.code).toBe(ErrorCode.RateLimited) - }) - - it('surfaces a throttle 429 whose Retry-After exceeds the honored cap (no retry)', async () => { - let requests = 0 - const stub = await startStub((_req, res) => { - // 120s advised wait > MAX_HONORED_WAIT_MS (60s) — surface rather than park for minutes. - res.writeHead(429, { 'content-type': 'application/json', 'retry-after': '120' }) - res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) - }) - let caught: unknown - try { - const client = createHttpClient({ - baseURL: base(stub.url), - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 5_000, - logger: (e) => { - if (e.phase === 'request') requests++ - }, - }) - try { - await client.get('workspaces') - } catch (err) { - caught = err - } - } finally { - await stub.stop() - } - expect(requests).toBe(1) - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) expect(caught.code).toBe(ErrorCode.RateLimited) - }) - - it('stream GET surfaces a 429 (returns the Response, no throw, no retry)', async () => { - let calls = 0 - const stub = await startStub((_req, res) => { - calls++ - res.writeHead(429, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) - }) - try { - const client = createHttpClient({ - baseURL: base(stub.url), - bearer: 'dfoa_test', - timeoutMs: 5_000, - }) - const res = await client.stream('workspaces') - expect(res.status).toBe(429) - await res.body?.cancel() - } finally { - await stub.stop() - } - expect(calls).toBe(1) - }) - - it('stream POST retries a throttle 429 when retry-on-limit is set', async () => { - let calls = 0 - const stub = await startStub((_req, res) => { - calls++ - if (calls === 1) { - res.writeHead(429, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ code: 'too_many_requests', message: 'slow down', status: 429 })) - return - } - res.writeHead(200, { 'content-type': 'text/event-stream' }) - res.end('data: {}\n\n') - }) - try { - const client = createHttpClient({ - baseURL: base(stub.url), - bearer: 'dfoa_test', - timeoutMs: 5_000, - }) - const res = await client.stream('apps/app-1/run', { - method: 'POST', - json: {}, - retryOnRateLimit: true, - }) - expect(res.status).toBe(200) - await res.body?.cancel() - } finally { - await stub.stop() - } - expect(calls).toBe(2) - }) - - it('does not retry POST on 503', async () => { - mock.setScenario('server-5xx') - let attempts = 0 - const client = createHttpClient({ - baseURL: base(mock.url), - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 5_000, - logger: (e) => { - if (e.phase === 'request' || e.phase === 'retry') attempts++ - }, - }) - await expect( - client.post('apps/app-1/run', { json: { inputs: {}, response_mode: 'blocking' } }), - ).rejects.toBeDefined() - expect(attempts).toBe(1) - }) - - it('does not retry POST on network error (method allowlist gates retry)', async () => { - let attempts = 0 - const client = createHttpClient({ - baseURL: base('http://nonexistent-host-12345.invalid'), - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 3_000, - logger: (e) => { - if (e.phase === 'request' || e.phase === 'retry') attempts++ - }, - }) - await expect( - client.post('apps/app-1/run', { json: { inputs: {}, response_mode: 'blocking' } }), - ).rejects.toBeDefined() - expect(attempts).toBe(1) - }) - - it('retries GET on network error up to retryAttempts', async () => { - // New semantics: every attempt emits a 'request' event (logRequest runs in onRequest - // on each recursive dispatch), plus one 'retry' event per retry decision. - // retryAttempts=2 => 3 attempts => 3 requests + 2 retries = 5 events. - let requests = 0 - let retries = 0 - const client = createHttpClient({ - baseURL: base('http://nonexistent-host-12345.invalid'), - bearer: 'dfoa_test', - retryAttempts: 2, - timeoutMs: 3_000, - logger: (e) => { - if (e.phase === 'request') requests++ - else if (e.phase === 'retry') retries++ - }, - }) - await expect(client.get('workspaces')).rejects.toBeDefined() - expect(requests).toBe(3) - expect(retries).toBe(2) - }, 30_000) - - it('does not retry PATCH on network error (method allowlist gates retry)', async () => { - let attempts = 0 - const client = createHttpClient({ - baseURL: base('http://nonexistent-host-12345.invalid'), - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 3_000, - logger: (e) => { - if (e.phase === 'request' || e.phase === 'retry') attempts++ - }, - }) - await expect(client.patch('workspaces', { json: {} })).rejects.toBeDefined() - expect(attempts).toBe(1) - }) -}) - -describe('empty / No-Content bodies', () => { - it('204 response resolves to undefined instead of throwing', async () => { - const stub = await startStub((_req, res) => { - res.writeHead(204).end() - }) - try { - const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test' }) - await expect(client.delete('account/sessions/abc')).resolves.toBeUndefined() - } finally { - await stub.stop() - } - }) - - it('empty 200 body resolves to undefined instead of throwing', async () => { - const stub = await startStub((_req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }).end() - }) - try { - const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test' }) - await expect(client.post('apps/app-1/tasks/t-1:stop', { json: {} })).resolves.toBeUndefined() - } finally { - await stub.stop() - } - }) -}) - -describe('classifyResponse internals', () => { - it('strips Bearer from logged URLs', async () => { - const mock = await startMock() - try { - const logger = vi.fn() - const client = createHttpClient({ - baseURL: openAPIBase(mock.url), - bearer: 'dfoa_should_not_log', - logger, - }) - await client.get('workspaces') - const calls = logger.mock.calls.map((c) => c[0]) - for (const event of calls) expect(JSON.stringify(event)).not.toContain('dfoa_should_not_log') - } finally { - await mock.stop() - } - }) -}) - -describe('extend()', () => { - let mock: DifyMock - beforeEach(async () => { - mock = await startMock() - }) - afterEach(async () => { - await mock.stop() - }) - - it('inherits bearer, userAgent, and logger from parent', async () => { - const events: { phase: string }[] = [] - const parent = createHttpClient({ - baseURL: openAPIBase(mock.url), - bearer: 'dfoa_test', - userAgent: 'difyctl/parent', - logger: (e) => events.push({ phase: e.phase }), - }) - let captured: { auth?: string | null; ua?: string | null } = {} - const child = parent.extend({ - hooks: { - onRequest: ({ request }) => { - captured = { - auth: request.headers.get('authorization'), - ua: request.headers.get('user-agent'), - } - }, - }, - }) - await child.get('workspaces') - expect(captured.auth).toBe('Bearer dfoa_test') - expect(captured.ua).toBe('difyctl/parent') - expect(events.length).toBeGreaterThan(0) - }) - - it('drops log hooks when extended with logger: undefined', async () => { - const events: unknown[] = [] - const parent = createHttpClient({ - baseURL: openAPIBase(mock.url), - bearer: 'dfoa_test', - logger: (e) => events.push(e), - }) - const silent = parent.extend({ logger: undefined }) - await silent.get('workspaces') - expect(events).toHaveLength(0) - }) - - it('per-call timeoutMs override beats client default', async () => { - const parent = createHttpClient({ - baseURL: openAPIBase(mock.url), - bearer: 'dfoa_test', - timeoutMs: 1, - }) - // 1ms client default would always fail the mock GET; per-call override of 5s lets it succeed. - const body = await parent.get<{ data: unknown[] }>('workspaces', { timeoutMs: 5_000 }) - expect(body.data.length).toBeGreaterThan(0) - }) -}) - -describe('fetch() and stream()', () => { - let mock: DifyMock - beforeEach(async () => { - mock = await startMock() - }) - afterEach(async () => { - await mock.stop() - }) - - it('fetch() returns raw Response and does NOT throw on 4xx', async () => { - mock.setScenario('auth-expired') - const client = createHttpClient({ baseURL: openAPIBase(mock.url), bearer: 'dfoa_test' }) - const res = await client.fetch('workspaces') - expect(res.status).toBe(401) - expect(res.ok).toBe(false) - }) - - it('throwOnError: true on .fetch() opts in to classification', async () => { - mock.setScenario('auth-expired') - const client = createHttpClient({ baseURL: openAPIBase(mock.url), bearer: 'dfoa_test' }) - await expect(client.fetch('workspaces', { throwOnError: true })).rejects.toBeDefined() - }) - - it('stream() bypasses the client-default timeout so SSE bodies stay open', async () => { - let stub: Stub | undefined - try { - stub = await startStub((_req, res) => { - setTimeout(() => { - res.writeHead(200, { 'content-type': 'text/event-stream' }) - res.end('data: ok\n\n') - }, 200) - }) - const client = createHttpClient({ - baseURL: openAPIBase(stub.url), - bearer: 'dfoa_test', - timeoutMs: 50, // would abort .get(); stream() must ignore it - retryAttempts: 0, - }) - const res = await client.stream('apps/app-1/run', { method: 'POST', json: {} }) - expect(res.ok).toBe(true) - } finally { - await stub?.stop() - } - }) - - it('stream() forces retryAttempts=0 even when client default would allow retries', async () => { - let attempts = 0 - const client = createHttpClient({ - baseURL: openAPIBase('http://nonexistent-host-12345.invalid'), - bearer: 'dfoa_test', - retryAttempts: 5, - timeoutMs: 0, - logger: (e) => { - if (e.phase === 'request' || e.phase === 'retry') attempts++ - }, - }) - await expect(client.stream('workspaces')).rejects.toBeDefined() - expect(attempts).toBe(1) - }) -}) - -describe('timeout + abort retry policy', () => { - it('does not retry POST on timeout (method allowlist gates timeout retries)', async () => { - let attempts = 0 - let stub: Stub | undefined - try { - stub = await startStub(() => { - attempts++ - // Never respond — let the client timeout abort. - }) - const client = createHttpClient({ - baseURL: openAPIBase(stub.url), - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 100, - }) - await expect(client.post('apps/app-1/run', { json: {} })).rejects.toBeDefined() - expect(attempts).toBe(1) - } finally { - await stub?.stop() - } - }) - - it('retries GET on timeout up to retryAttempts', async () => { - let attempts = 0 - let stub: Stub | undefined - try { - stub = await startStub(() => { - attempts++ - // Never respond. - }) - const client = createHttpClient({ - baseURL: openAPIBase(stub.url), - bearer: 'dfoa_test', - retryAttempts: 2, - timeoutMs: 100, - }) - await expect(client.get('workspaces')).rejects.toBeDefined() - expect(attempts).toBe(3) // initial + 2 retries - } finally { - await stub?.stop() - } - }) - - it('does not retry GET on user-initiated abort', async () => { - let attempts = 0 - let stub: Stub | undefined - try { - stub = await startStub(() => { - attempts++ - // Never respond — caller will abort. - }) - const ac = new AbortController() - const client = createHttpClient({ - baseURL: openAPIBase(stub.url), - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 5_000, - }) - const pending = client.get('workspaces', { signal: ac.signal }) - setTimeout(() => ac.abort(), 50) - await expect(pending).rejects.toBeDefined() - expect(attempts).toBe(1) - } finally { - await stub?.stop() - } - }) -}) - -describe('hook semantics', () => { - let mock: DifyMock - beforeEach(async () => { - mock = await startMock() - }) - afterEach(async () => { - await mock.stop() - }) - - it("onRequest hooks see each other's mutations via shared ctx.request", async () => { - let observed: string | null = null - const client = createHttpClient({ - baseURL: openAPIBase(mock.url), - bearer: 'dfoa_test', - hooks: { - onRequest: [ - ({ request }) => { - request.headers.set('x-trace', 'hooked') - }, - ({ request }) => { - observed = request.headers.get('x-trace') - }, - ], - }, - }) - await client.get('workspaces') - expect(observed).toBe('hooked') - }) - - it('onResponse hook throw propagates immediately and does NOT enter onResponseError', async () => { - let onResponseErrorRan = false - const client = createHttpClient({ - baseURL: openAPIBase(mock.url), - bearer: 'dfoa_test', - hooks: { - onResponse: () => { - throw new Error('hook boom') - }, - onResponseError: () => { - onResponseErrorRan = true - }, - }, - }) - await expect(client.get('workspaces')).rejects.toThrow('hook boom') - expect(onResponseErrorRan).toBe(false) - }) - - // Symmetric to the onResponse-throws test above: a throw inside an - // onResponseError hook (the !res.ok branch) must propagate out of dispatch - // verbatim, replacing the classified BaseError. dispatch has no try/catch - // around the hook chain; this pins that intent so a future "swallow hook - // errors" change can't slip through. - it('onResponseError hook throw propagates and replaces the classified BaseError', async () => { - mock.setScenario('auth-expired') - const client = createHttpClient({ - baseURL: openAPIBase(mock.url), - bearer: 'dfoa_test', - retryAttempts: 0, - hooks: { - onResponseError: () => { - throw new Error('hook boom on error') - }, - }, - }) - await expect(client.get('workspaces')).rejects.toThrow('hook boom on error') - }) -}) - -// The low-level request() entrypoint: oRPC's OpenAPILink builds an absolute-URL Request and -// calls a fetch-shaped fn. These tests pin the three pitfalls (clone-on-retry / signal merge / -// skip-baseURL) plus UA+bearer injection and the raw-Response-on-error contract oRPC relies on. -describe('request() entrypoint (oRPC OpenAPILink socket)', () => { - it('sends to the Request URL verbatim, skipping the client baseURL', async () => { - const mock = await startMock() - try { - // Deliberately wrong baseURL: if request() re-joined it via joinURL, this would miss. - const client = createHttpClient({ - baseURL: 'http://wrong.invalid/openapi/v1/', - bearer: 'dfoa_test', - }) - const res = await client.request(new Request(`${base(mock.url)}workspaces`)) - expect(res.status).toBe(200) - const body = (await res.json()) as { data: unknown[] } - expect(body.data).toHaveLength(2) - } finally { - await mock.stop() - } - }) - - it('injects UA + bearer that the pre-built Request did not carry', async () => { - const mock = await startMock() - try { - let auth: string | null = null - let ua: string | null = null - const client = createHttpClient({ - baseURL: base(mock.url), - bearer: 'dfoa_test', - userAgent: 'difyctl/req-test', - hooks: { - onRequest: ({ request }) => { - auth = request.headers.get('authorization') - ua = request.headers.get('user-agent') - }, - }, - }) - await client.request(new Request(`${base(mock.url)}workspaces`)) - expect(auth).toBe('Bearer dfoa_test') - expect(ua).toBe('difyctl/req-test') - } finally { - await mock.stop() - } - }) - - it('returns the raw Response on 4xx and does NOT throw (oRPC reads the body + maps errors)', async () => { - const mock = await startMock() - try { - mock.setScenario('auth-expired') - const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test' }) - const res = await client.request(new Request(`${base(mock.url)}workspaces`)) - expect(res.ok).toBe(false) - expect(res.status).toBe(401) - } finally { - await mock.stop() - } - }) - - it('clones the Request per attempt so a retried body replays intact', async () => { - let hits = 0 - let secondBody = '' - const stub = await startStub((req, res) => { - hits++ - let raw = '' - req.on('data', (c) => { - raw += String(c) - }) - req.on('end', () => { - if (hits === 1) { - res.writeHead(503).end() - return - } - secondBody = raw - res.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}') - }) - }) - try { - const client = createHttpClient({ - baseURL: stub.url, - bearer: 'dfoa_test', - retryAttempts: 2, - timeoutMs: 5_000, - }) - // PUT is in RETRY_METHODS and carries a body — proves clone-on-retry replays it. - const res = await client.request( - new Request(`${stub.url}/x`, { - method: 'PUT', - body: JSON.stringify({ a: 1 }), - headers: { 'content-type': 'application/json' }, - }), - ) - expect(res.status).toBe(200) - expect(hits).toBe(2) - expect(secondBody).toBe('{"a":1}') - } finally { - await stub.stop() - } - }) - - it('applies the client-default timeout on top of the Request signal', async () => { - const stub = await startStub(() => { - /* never responds */ - }) - try { - const client = createHttpClient({ - baseURL: stub.url, - bearer: 'dfoa_test', - timeoutMs: 100, - retryAttempts: 0, - }) - const ac = new AbortController() // oRPC's own signal, never aborted - await expect( - client.request(new Request(`${stub.url}/x`, { signal: ac.signal })), - ).rejects.toBeDefined() - } finally { - await stub.stop() - } - }) - - it('does not retry when the Request (oRPC/user) signal aborts', async () => { - let hits = 0 - const stub = await startStub(() => { - hits++ // never responds - }) - try { - const ac = new AbortController() - const client = createHttpClient({ - baseURL: stub.url, - bearer: 'dfoa_test', - retryAttempts: 3, - timeoutMs: 5_000, - }) - const pending = client.request( - new Request(`${stub.url}/x`, { method: 'GET', signal: ac.signal }), - ) - setTimeout(() => ac.abort(), 50) - await expect(pending).rejects.toBeDefined() - expect(hits).toBe(1) - } finally { - await stub.stop() - } - }) - - it('gives each retried attempt a fresh timeout budget', async () => { - let hits = 0 - const stub = await startStub(() => { - hits++ // never responds - }) - try { - const client = createHttpClient({ - baseURL: stub.url, - bearer: 'dfoa_test', - timeoutMs: 100, - retryAttempts: 2, - }) - // GET is retryable; each attempt must mint its own 100ms timeout, so all 3 fire. If the - // timeout signal were hoisted out of the retry loop, attempt 0's expired signal would - // short-circuit attempts 1-2 and hits would be 1. - await expect(client.request(new Request(`${stub.url}/x`))).rejects.toBeDefined() - expect(hits).toBe(3) - } finally { - await stub.stop() - } - }, 30_000) -}) diff --git a/cli/src/http/client.ts b/cli/src/http/client.ts deleted file mode 100644 index 197b23148dd..00000000000 --- a/cli/src/http/client.ts +++ /dev/null @@ -1,392 +0,0 @@ -import type { - ClientOptions, - FetchContext, - HeadersInit, - Hook, - HttpClient, - HttpLogger, - HttpMethod, - RequestOptions, - ResolvedOptions, -} from './types.js' -import { isVerbose } from '@/framework/context' -import { userAgent as defaultUserAgent } from '@/version/info' -import { buildBody } from './body.js' -import { classifyResponse } from './error-mapper.js' -import { classifyTransport, logRequest, logResponse, setBearer, setUserAgent } from './hooks.js' -import { proxyDispatcher } from './proxy.js' -import { - classifyRateLimit, - MAX_HONORED_WAIT_MS, - RATE_LIMIT_MAX_ATTEMPTS, - rateLimitDelayMs, -} from './rate-limit.js' -import { backoffDelay, isIdempotentRetryMethod, shouldRetry } from './retry.js' -import { redactBearer } from './sanitize.js' -import { appendSearchParams, joinURL } from './url.js' - -export const DEFAULT_TIMEOUT_MS = 30_000 -export const DEFAULT_RETRY_ATTEMPTS = 3 - -type ResolvedHooks = { - readonly onRequest: Hook[] - readonly onResponse: Hook[] - readonly onRequestError: Hook[] - readonly onResponseError: Hook[] -} - -type ClientState = { - readonly baseURL: string - readonly defaultTimeoutMs: number | undefined - readonly defaultRetryAttempts: number - readonly hooks: ResolvedHooks - readonly logger: HttpLogger | undefined - readonly originalOptions: ClientOptions - readonly dispatcher: ReturnType<typeof proxyDispatcher> - readonly insecure: boolean -} - -function toArray<T>(value: T | T[] | undefined): T[] { - if (value === undefined) return [] - return Array.isArray(value) ? value : [value] -} - -function compileState(opts: ClientOptions): ClientState { - const onRequest: Hook[] = [] - const onResponse: Hook[] = [] - const onRequestError: Hook[] = [classifyTransport] - const onResponseError: Hook[] = [] - - // Always pin a difyctl-shaped UA so server logs / WAF rules see the CLI's - // version + platform. Callers can override by passing `userAgent` explicitly. - onRequest.push(setUserAgent(opts.userAgent ?? defaultUserAgent())) - if (opts.bearer !== undefined && opts.bearer !== '') onRequest.push(setBearer(opts.bearer)) - if (opts.logger !== undefined) { - onRequest.push(logRequest(opts.logger)) - onResponse.push(logResponse(opts.logger)) - } - - onRequest.push(...toArray(opts.hooks?.onRequest)) - onResponse.push(...toArray(opts.hooks?.onResponse)) - onRequestError.push(...toArray(opts.hooks?.onRequestError)) - onResponseError.push(...toArray(opts.hooks?.onResponseError)) - - return { - baseURL: opts.baseURL, - defaultTimeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, - defaultRetryAttempts: opts.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS, - hooks: { onRequest, onResponse, onRequestError, onResponseError }, - logger: opts.logger, - originalOptions: opts, - dispatcher: proxyDispatcher({ insecure: opts.insecure }), - insecure: opts.insecure ?? false, - } -} - -async function runHooks(hooks: readonly Hook[], ctx: FetchContext): Promise<void> { - for (const hook of hooks) await hook(ctx) -} - -// Merge a fresh per-attempt timeout signal with the persistent user/oRPC signal. -// Called once per attempt inside execute() so every retry gets its own timeout budget. -function mergeSignal( - userSignal: AbortSignal | undefined, - effectiveTimeoutMs: number | undefined, -): AbortSignal | undefined { - const timeoutSignal = - effectiveTimeoutMs !== undefined && effectiveTimeoutMs > 0 - ? AbortSignal.timeout(effectiveTimeoutMs) - : undefined - - if (timeoutSignal === undefined) return userSignal - if (userSignal === undefined) return timeoutSignal - return AbortSignal.any([timeoutSignal, userSignal]) -} - -function mergeHeaders(input: HeadersInit | undefined, contentType: string | undefined): Headers { - const headers = new Headers(input ?? {}) - if (contentType !== undefined && !headers.has('content-type')) - headers.set('content-type', contentType) - return headers -} - -type BuiltRequest = { - readonly request: Request - readonly resolved: ResolvedOptions - readonly effectiveTimeoutMs: number | undefined - readonly userSignal: AbortSignal | undefined -} - -// Path-keyed constructor: turns (path, opts) into a Request. The Request is built -// WITHOUT a signal — execute() supplies a fresh per-attempt signal via fetch's -// init.signal (which overrides any signal carried on the Request). -function buildRequest( - state: ClientState, - path: string, - opts: RequestOptions, - throwOnErrorDefault: boolean, -): BuiltRequest { - const method: HttpMethod = opts.method ?? 'GET' - const effectiveTimeoutMs = - opts.timeoutMs !== undefined - ? opts.timeoutMs > 0 - ? opts.timeoutMs - : undefined - : state.defaultTimeoutMs - const effectiveRetryAttempts = opts.retryAttempts ?? state.defaultRetryAttempts - const throwOnError = opts.throwOnError ?? throwOnErrorDefault - - const { body, contentType } = buildBody({ json: opts.json, body: opts.body, method }) - const headers = mergeHeaders(opts.headers, contentType) - const url = appendSearchParams(joinURL(state.baseURL, path), opts.searchParams) - - const request = new Request(url, { method, headers, body }) - const resolved: ResolvedOptions = { - method, - headers, - body, - timeoutMs: effectiveTimeoutMs, - retryAttempts: effectiveRetryAttempts, - throwOnError, - retryOnRateLimit: opts.retryOnRateLimit ?? false, - } - return { request, resolved, effectiveTimeoutMs, userSignal: opts.signal } -} - -// The shared transport engine: hooks -> fetch -> retry -> error-map. Accepts an -// already-built Request, so both the path entrypoints (via buildRequest) and the -// low-level `request` entrypoint (oRPC's pre-built Request) share one retry/timeout -// /error policy. -async function execute( - state: ClientState, - request: Request, - resolved: ResolvedOptions, - effectiveTimeoutMs: number | undefined, - userSignal: AbortSignal | undefined, -): Promise<Response> { - const { method, retryAttempts: effectiveRetryAttempts, throwOnError } = resolved - - for (let attempt = 0; ; attempt++) { - // Fresh timeout budget per attempt, merged with the persistent user/oRPC signal. - // Mirrors the old recursion that re-ran buildSignal() on every retry. Keep this - // INSIDE the loop — hoisting it would leak attempt 0's timeout into later attempts. - const signal = mergeSignal(userSignal, effectiveTimeoutMs) - - // clone-on-retry: the first fetch consumes the Request body, so any attempt that - // may still be retried sends a clone and keeps `request` as the pristine replay copy. - const sendable = attempt < effectiveRetryAttempts ? request.clone() : request - - const ctx: FetchContext = { - request: sendable, - options: resolved, - attempt, - meta: new Map(), - } - - await runHooks(state.hooks.onRequest, ctx) - - // Two runtimes, two options: Node's fetch reads undici's `dispatcher` (used - // below for TLS-skip + proxy routing); Bun's native fetch — what the compiled - // difyctl binary actually runs on — ignores `dispatcher` entirely and instead - // needs its own `tls` option. Set both; each runtime ignores the one it - // doesn't understand. - const init: RequestInit & { - dispatcher?: unknown - tls?: { rejectUnauthorized: boolean } - verbose?: boolean - } = { signal } - if (state.dispatcher !== undefined) init.dispatcher = state.dispatcher - if (state.insecure) init.tls = { rejectUnauthorized: false } - if (isVerbose()) init.verbose = true - - try { - ctx.response = await fetch(ctx.request, init) - } catch (err) { - ctx.error = err - // Snapshot the abort cause before onRequestError hooks rewrite ctx.error into BaseError. - const userAborted = userSignal?.aborted === true - await runHooks(state.hooks.onRequestError, ctx) - - // User aborts (ctrl+C) must never retry. Timeouts and other transport errors fall - // through to shouldRetry, which enforces the method allowlist. - if (!userAborted && attempt < effectiveRetryAttempts && shouldRetry(ctx.error, ctx)) { - state.logger?.({ - phase: 'retry', - method, - url: redactBearer(ctx.request.url), - attempt: attempt + 1, - }) - const delay = backoffDelay(attempt + 1) - if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)) - continue - } - - const finalErr = ctx.error - if (finalErr instanceof Error && typeof Error.captureStackTrace === 'function') - Error.captureStackTrace(finalErr, execute) - throw finalErr - } - - await runHooks(state.hooks.onResponse, ctx) - - const res = ctx.response - if (!res.ok) { - // 429 has its own policy. The server self-describes via the ErrorBody `code`: a - // "too_many_requests" throttle waits-and-retries on idempotent methods (or opted-in POSTs) - // honoring Retry-After; quota / unrecognized 429s surface immediately rather than burning - // retries. Surfacing reuses the shared classifyResponse so the body parses to ErrorBody. - if (res.status === 429) { - const decision = await classifyRateLimit(res.clone()) - const canRetry = - decision.retryable && - attempt < effectiveRetryAttempts && - (decision.retryAfterMs === undefined || decision.retryAfterMs <= MAX_HONORED_WAIT_MS) && - (isIdempotentRetryMethod(method) || (method === 'POST' && resolved.retryOnRateLimit)) - if (canRetry) { - const delay = rateLimitDelayMs(decision, attempt + 1) - state.logger?.({ - phase: 'retry', - method, - url: redactBearer(ctx.request.url), - status: 429, - attempt: attempt + 1, - delayMs: delay, - }) - await res.body?.cancel().catch(() => {}) - if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)) - continue - } - - ctx.error = await classifyResponse(ctx.request, res) - await runHooks(state.hooks.onResponseError, ctx) - if (throwOnError) { - const finalErr = ctx.error - if (finalErr instanceof Error && typeof Error.captureStackTrace === 'function') - Error.captureStackTrace(finalErr, execute) - throw finalErr - } - return res - } - - if (attempt < effectiveRetryAttempts && shouldRetry(res, ctx)) { - state.logger?.({ - phase: 'retry', - method, - url: redactBearer(ctx.request.url), - attempt: attempt + 1, - }) - // Drain the discarded error body so undici can release the socket back to its - // pool instead of holding the connection open until keep-alive timeout / GC. - await res.body?.cancel().catch(() => {}) - const delay = backoffDelay(attempt + 1) - if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)) - continue - } - - ctx.error = await classifyResponse(ctx.request, res) - await runHooks(state.hooks.onResponseError, ctx) - - if (throwOnError) { - const finalErr = ctx.error - if (finalErr instanceof Error && typeof Error.captureStackTrace === 'function') - Error.captureStackTrace(finalErr, execute) - throw finalErr - } - } - - return res - } -} - -// 204/205 and empty 2xx bodies carry no JSON. Resolve to `undefined` instead of -// letting `res.json()` throw an unclassified SyntaxError, so void-returning callers -// (revoke, stopTask, …) stay safe when a server replies with No Content. -async function parseJsonBody<T>(res: Response): Promise<T> { - if (res.status === 204 || res.status === 205) return undefined as T - const text = await res.text() - return (text === '' ? undefined : JSON.parse(text)) as T -} - -export function createHttpClient(opts: ClientOptions): HttpClient { - const state = compileState(opts) - - const typedCall = async <T>( - method: HttpMethod, - path: string, - callOpts?: RequestOptions, - ): Promise<T> => { - const finalOpts: RequestOptions = { ...callOpts, method } - const built = buildRequest(state, path, finalOpts, true) - const res = await execute( - state, - built.request, - built.resolved, - built.effectiveTimeoutMs, - built.userSignal, - ) - return parseJsonBody<T>(res) - } - - const rawFetch = (path: string, callOpts?: RequestOptions): Promise<Response> => { - const finalOpts: RequestOptions = { ...callOpts, method: callOpts?.method ?? 'GET' } - const built = buildRequest(state, path, finalOpts, false) - return execute(state, built.request, built.resolved, built.effectiveTimeoutMs, built.userSignal) - } - - const streamFetch = (path: string, callOpts?: RequestOptions): Promise<Response> => { - // SSE bodies must not be aborted by a request-level timeout — `0` is the buildRequest - // sentinel for "no timeout" and also overrides the client default. - // - // A stream normally never retries (a mid-stream replay would double-send). When the caller - // opts into 429 retry, allow a bounded budget: the 429 admission rejection arrives as a plain - // body before the stream opens, and execute()'s 429 branch is the only path that fires for a - // POST — shouldRetry still rejects POST for transport / 5xx, so nothing else replays. - const retryAttempts = - callOpts?.retryOnRateLimit === true ? (callOpts.retryAttempts ?? RATE_LIMIT_MAX_ATTEMPTS) : 0 - const finalOpts: RequestOptions = { - ...callOpts, - method: callOpts?.method ?? 'GET', - retryAttempts, - timeoutMs: 0, - } - const built = buildRequest(state, path, finalOpts, false) - return execute(state, built.request, built.resolved, built.effectiveTimeoutMs, built.userSignal) - } - - // Low-level entrypoint for oRPC's OpenAPILink: executes an already-built, absolute-URL - // Request through the same transport (UA+bearer hooks, retry, timeout, error-map) while - // skipping joinURL. Policy comes from the client instance defaults — there is no per-call - // override, so this stays a drop-in for OpenAPILink's `(req, init) => Promise<Response>`. - // Returns the raw Response for every status; the oRPC fetch wrapper (orpc.ts) inspects the - // status and raises classifyResponse for non-2xx, so error mapping stays in one place. - const requestFetch = (req: Request, init?: RequestInit): Promise<Response> => { - const method = req.method.toUpperCase() as HttpMethod - const resolved: ResolvedOptions = { - method, - headers: req.headers, - body: undefined, - timeoutMs: state.defaultTimeoutMs, - retryAttempts: state.defaultRetryAttempts, - throwOnError: false, - retryOnRateLimit: false, - } - const userSignal = init?.signal ?? req.signal - return execute(state, req, resolved, state.defaultTimeoutMs, userSignal) - } - - const extend = (overrides: Partial<ClientOptions>): HttpClient => - createHttpClient({ ...state.originalOptions, ...overrides }) - - return { - baseURL: state.baseURL, - get: <T>(p: string, o?: RequestOptions) => typedCall<T>('GET', p, o), - post: <T>(p: string, o?: RequestOptions) => typedCall<T>('POST', p, o), - put: <T>(p: string, o?: RequestOptions) => typedCall<T>('PUT', p, o), - patch: <T>(p: string, o?: RequestOptions) => typedCall<T>('PATCH', p, o), - delete: <T>(p: string, o?: RequestOptions) => typedCall<T>('DELETE', p, o), - fetch: rawFetch, - stream: streamFetch, - request: requestFetch, - extend, - } -} diff --git a/cli/src/http/error-mapper.test.ts b/cli/src/http/error-mapper.test.ts deleted file mode 100644 index 362d2e3d91d..00000000000 --- a/cli/src/http/error-mapper.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { HttpClientError } from '@/errors/base' -import { describe, expect, it } from 'vite-plus/test' -import { ErrorCode } from '@/errors/codes' -import { classifyResponse } from './error-mapper' - -function res(status: number, body: unknown): Response { - return new Response(typeof body === 'string' ? body : JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) -} - -const req = new Request('https://dify.test/openapi/v1/apps') - -function classified(status: number, body: unknown): Promise<HttpClientError> { - return classifyResponse(req, res(status, body)) -} - -describe('classifyResponse — canonical ErrorBody', () => { - it('attaches the parsed body whole as serverError', async () => { - const body = { - code: 'invalid_param', - message: 'Request validation failed', - status: 422, - hint: 'check the page parameter', - details: [{ type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }], - } - - const err = await classified(422, body) - - expect(err.serverError).toEqual(body) - expect(err.message).toBe('Request validation failed') - expect(err.code).toBe(ErrorCode.Server4xxOther) - }) - - it('401 classifies by status as AuthExpired with CLI login hint', async () => { - const err = await classified(401, { - code: 'unauthorized', - message: 'session expired or revoked', - status: 401, - }) - - expect(err.code).toBe(ErrorCode.AuthExpired) - expect(err.hint).toBe("run 'difyctl auth login' to sign in again") - }) - - it('unknown future server code is data, not behavior — status bucket decides', async () => { - const err = await classified(409, { - code: 'some_future_code', - message: 'nope', - status: 409, - }) - - expect(err.code).toBe(ErrorCode.Server4xxOther) - expect(err.serverError?.code).toBe('some_future_code') - }) - - it('429 classifies as RateLimited (dedicated exit code) and keeps the server code', async () => { - const err = await classified(429, { - code: 'too_many_requests', - message: 'slow down', - status: 429, - }) - - expect(err.code).toBe(ErrorCode.RateLimited) - expect(err.exit()).toBe(7) - expect(err.serverError?.code).toBe('too_many_requests') - }) - - it('429 with no parseable ErrorBody falls back to a generic rate-limit message', async () => { - const err = await classified(429, 'not json') - - expect(err.code).toBe(ErrorCode.RateLimited) - expect(err.serverError).toBeUndefined() - expect(err.message).toBe('too many requests') - }) -}) - -describe('classifyResponse 403', () => { - it('maps 403 to AccessDenied (exit 4 bucket)', async () => { - const req403 = new Request('https://x/openapi/v1/apps/abc/dsl') - const res403 = new Response( - JSON.stringify({ - code: 'unsupported_token_type', - message: 'unsupported_token_type', - status: 403, - }), - { status: 403, headers: { 'content-type': 'application/json' } }, - ) - const err = await classifyResponse(req403, res403) - expect(err.code).toBe(ErrorCode.AccessDenied) - expect(err.message).toBe('unsupported_token_type') - }) - - it('403 with no parseable ErrorBody falls back to generic denied message', async () => { - const err = await classified(403, 'not json') - expect(err.code).toBe(ErrorCode.AccessDenied) - expect(err.message).toBe('not permitted') - }) -}) - -describe('classifyResponse 426', () => { - it('maps 426 to VersionSkew (exit 6) and surfaces the server upgrade message', async () => { - const body = { - code: 'upgrade_required', - message: 'difyctl 0.1.0 is no longer supported; upgrade to >= 0.2.0.', - status: 426, - hint: 'Upgrade difyctl: https://docs.dify.ai/en/cli/install', - } - - const err = await classified(426, body) - - expect(err.code).toBe(ErrorCode.VersionSkew) - expect(err.exit()).toBe(6) - expect(err.message).toBe('difyctl 0.1.0 is no longer supported; upgrade to >= 0.2.0.') - expect(err.serverError?.code).toBe('upgrade_required') - }) - - it('426 with no parseable ErrorBody falls back to a version message', async () => { - const err = await classified(426, 'not json') - - expect(err.code).toBe(ErrorCode.VersionSkew) - expect(err.message).toBe('client version no longer supported by the server') - }) -}) - -describe('classifyResponse — non-conforming bodies (no fallback by design)', () => { - it('non-JSON body yields no serverError, classification by status', async () => { - const err = await classified(502, '<html>bad gateway</html>') - - expect(err.code).toBe(ErrorCode.Server5xx) - expect(err.serverError).toBeUndefined() - }) - - it('RFC 8628 string error field yields no serverError and a generic message', async () => { - const err = await classified(400, { error: 'slow_down' }) - - expect(err.message).toBe('request failed (HTTP 400)') - expect(err.serverError).toBeUndefined() - }) -}) diff --git a/cli/src/http/error-mapper.ts b/cli/src/http/error-mapper.ts deleted file mode 100644 index 0c0d1a8a7e7..00000000000 --- a/cli/src/http/error-mapper.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' -import type { ErrorCodeValue } from '@/errors/codes' -import { zErrorBody } from '@dify/contracts/api/openapi/zod.gen' -import { BaseError, HttpClientError, newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { redactBearer } from './sanitize' - -const AUTH_EXPIRED_MESSAGE = 'session expired or revoked' -const AUTH_LOGIN_HINT = "run 'difyctl auth login' to sign in again" - -// How one HTTP status bucket classifies: CLI code, message fallback when the -// body is not a canonical ErrorBody, optional CLI hint, raw-body retention. -type StatusClass = { - readonly code: ErrorCodeValue - readonly fallbackMessage: (status: number) => string - readonly hint?: string - readonly includeRaw: boolean -} - -const AUTH_EXPIRED_CLASS: StatusClass = { - code: ErrorCode.AuthExpired, - fallbackMessage: () => AUTH_EXPIRED_MESSAGE, - hint: AUTH_LOGIN_HINT, - includeRaw: false, -} - -const SERVER_5XX_CLASS: StatusClass = { - code: ErrorCode.Server5xx, - fallbackMessage: (status) => `server error (HTTP ${status})`, - includeRaw: true, -} - -const SERVER_4XX_CLASS: StatusClass = { - code: ErrorCode.Server4xxOther, - fallbackMessage: (status) => `request failed (HTTP ${status})`, - includeRaw: true, -} - -// 429 gets a dedicated CLI code (its own exit code) so wrappers can tell a rate limit from a hard -// failure. The serverError.code ("too_many_requests" / "rate_limit_error") still rides along. -const RATE_LIMITED_CLASS: StatusClass = { - code: ErrorCode.RateLimited, - fallbackMessage: () => 'too many requests', - includeRaw: false, -} - -const ACCESS_DENIED_CLASS: StatusClass = { - code: ErrorCode.AccessDenied, - fallbackMessage: () => 'not permitted', - includeRaw: false, -} - -// 426 Upgrade Required: the server rejected this difyctl as too old. Give it the -// version-compat exit code so scripts can tell it apart from a generic failure. -// The server's ErrorBody.code ("upgrade_required") + message still ride along. -const VERSION_COMPAT_CLASS: StatusClass = { - code: ErrorCode.VersionSkew, - fallbackMessage: () => 'client version no longer supported by the server', - includeRaw: false, -} - -function statusClass(status: number): StatusClass { - if (status === 401) return AUTH_EXPIRED_CLASS - if (status === 403) return ACCESS_DENIED_CLASS - if (status === 426) return VERSION_COMPAT_CLASS - if (status === 429) return RATE_LIMITED_CLASS - if (status >= 500) return SERVER_5XX_CLASS - return SERVER_4XX_CLASS -} - -function parseServerError(raw: string): ErrorBody | undefined { - if (raw === '') return undefined - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - return undefined - } - const result = zErrorBody.safeParse(parsed) - return result.success ? result.data : undefined -} - -export async function classifyResponse( - request: Request, - response: Response, -): Promise<HttpClientError> { - let raw = '' - try { - raw = await response.clone().text() - } catch { - // ignore read errors; raw stays '' - } - - const serverError = parseServerError(raw) - const status = response.status - const c = statusClass(status) - return new HttpClientError({ - code: c.code, - message: serverError?.message ?? c.fallbackMessage(status), - hint: c.hint, - httpStatus: status, - method: request.method, - url: redactBearer(response.url || request.url), - rawResponse: c.includeRaw && raw !== '' ? raw : undefined, - serverError, - }) -} - -export function classifyTransportError(err: unknown): BaseError { - if (err instanceof BaseError) { - return err - } - if (!(err instanceof Error)) { - return newError(ErrorCode.Unknown, String(err)).wrap(err) - } - const sanitized = redactBearer(err.message) - // there isn't a practical way to classify network errors reliably - return newError(ErrorCode.NetworkConnection, sanitized).wrap(err) -} diff --git a/cli/src/http/hooks.ts b/cli/src/http/hooks.ts deleted file mode 100644 index 4b502dddf1d..00000000000 --- a/cli/src/http/hooks.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Hook, HttpLogger } from './types.js' -import { BaseError } from '@/errors/base' -import { classifyTransportError } from './error-mapper.js' -import { redactBearer } from './sanitize.js' - -export const HTTP_START_SYM = Symbol('difyctl-http-start') - -export function setBearer(token: string): Hook { - return ({ request }) => { - if (!request.headers.has('authorization')) - request.headers.set('authorization', `Bearer ${token}`) - } -} - -export function setUserAgent(ua: string): Hook { - return ({ request }) => { - if (!request.headers.has('user-agent')) request.headers.set('user-agent', ua) - } -} - -export function logRequest(logger: HttpLogger): Hook { - return ({ request, meta }) => { - meta.set(HTTP_START_SYM, performance.now()) - logger({ - phase: 'request', - method: request.method, - url: redactBearer(request.url), - }) - } -} - -export function logResponse(logger: HttpLogger): Hook { - return ({ request, response, meta }) => { - if (response === undefined) return - const start = meta.get(HTTP_START_SYM) - const durationMs = typeof start === 'number' ? performance.now() - start : undefined - logger({ - phase: 'response', - method: request.method, - url: redactBearer(request.url), - status: response.status, - durationMs, - }) - } -} - -export const classifyTransport: Hook = (ctx) => { - if (ctx.error === undefined) return - if (ctx.error instanceof BaseError) return - ctx.error = classifyTransportError(ctx.error) -} diff --git a/cli/src/http/orpc.test.ts b/cli/src/http/orpc.test.ts deleted file mode 100644 index 8e45c57c7c8..00000000000 --- a/cli/src/http/orpc.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { StubServer } from '@test/fixtures/stub-server' -import type { HttpClientError } from '@/errors/base' -import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' -import { afterEach, describe, expect, it } from 'vite-plus/test' -import { isHttpClientError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { openAPIBase } from '@/util/host' -import { createHttpClient } from './client.js' -import { createOpenApiClient } from './orpc.js' - -// createOpenApiClient maps errors at the transport seams, so a migrated endpoint surfaces the -// SAME classified HttpClientError as the `this.http.*` path — Dify's message/hint, the request -// method/url, and the raw body — straight from a plain `orpc.x()` call, with no per-call wrapper. -function orpcClient(host: string) { - // retryAttempts: 0 so the 5xx case fails fast instead of burning the backoff budget. - const http = createHttpClient({ - baseURL: openAPIBase(host), - bearer: 'dfoa_test', - retryAttempts: 0, - }) - return createOpenApiClient(http) -} - -async function catchErr(run: () => Promise<unknown>): Promise<unknown> { - try { - await run() - return undefined - } catch (err) { - return err - } -} - -describe('createOpenApiClient error mapping', () => { - let stub: StubServer - - afterEach(async () => { - await stub?.stop() - }) - - async function classifiedError(status: number, body: unknown): Promise<HttpClientError> { - stub = await startStubServer((cap) => jsonResponder(status, body, cap)) - const orpc = orpcClient(stub.url) - const caught = await catchErr(() => orpc.account.get()) - if (!isHttpClientError(caught)) - throw new Error(`expected HttpClientError, got: ${String(caught)}`) - return caught - } - - it('recovers Dify message from a canonical ErrorBody 4xx response', async () => { - const caught = await classifiedError(422, { - code: 'invalid_param', - message: 'no access', - status: 422, - }) - - expect(caught.code).toBe(ErrorCode.Server4xxOther) - expect(caught.httpStatus).toBe(422) - expect(caught.message).toBe('no access') - // Parity with the transport path: the migrated endpoint's error keeps the request - // method/url and the raw body, so formatted errors still print the `request:` line - // and the raw-response dump (not just message/hint). - expect(caught.method).toBe('GET') - expect(caught.url).toContain('/account') - expect(caught.rawResponse).toContain('no access') - }) - - it('reads server message from canonical ErrorBody on 401 and keeps the auth code', async () => { - const caught = await classifiedError(401, { - code: 'unauthorized', - message: 'expired', - status: 401, - }) - - expect(caught.code).toBe(ErrorCode.AuthExpired) - expect(caught.httpStatus).toBe(401) - expect(caught.message).toBe('expired') - }) - - it('uses CLI default auth-login hint for non-conforming 401 body', async () => { - const caught = await classifiedError(401, { error: 'expired' }) - - expect(caught.code).toBe(ErrorCode.AuthExpired) - expect(caught.hint).toContain('difyctl auth login') - }) - - it('maps 5xx to Server5xx with message from canonical ErrorBody', async () => { - const caught = await classifiedError(503, { - code: 'service_unavailable', - message: 'down for maintenance', - status: 503, - }) - - expect(caught.code).toBe(ErrorCode.Server5xx) - expect(caught.httpStatus).toBe(503) - expect(caught.message).toBe('down for maintenance') - }) -}) diff --git a/cli/src/http/orpc.ts b/cli/src/http/orpc.ts deleted file mode 100644 index 704fd9ca583..00000000000 --- a/cli/src/http/orpc.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { ContractRouterClient } from '@orpc/contract' -import type { JsonifiedClient } from '@orpc/openapi-client' -import type { HttpClient } from './types.js' -import { contract } from '@dify/contracts/api/openapi/orpc.gen' -import { createORPCClient } from '@orpc/client' -import { OpenAPILink } from '@orpc/openapi-client/fetch' -import { isBaseError, unknownError } from '@/errors/base' -import { classifyResponse } from './error-mapper.js' - -// Contract-typed oRPC client for the public OpenAPI surface. `JsonifiedClient` reshapes the -// contract types to what survives JSON transport (e.g. Date -> string), matching the wire. -export type OpenApiClient = JsonifiedClient<ContractRouterClient<typeof contract>> - -// An oRPC client routed through the CLI's HttpClient, so every call reuses the one transport -// policy (UA+bearer, retry, timeout). Errors become the CLI's model at the two transport seams, -// so call sites stay plain `this.orpc.x.y(input)` with no per-method try/catch: -// - fetch wrapper: non-2xx -> classifyResponse (identical to the `this.http.*` path), leaving -// oRPC to decode only 2xx responses; -// - link wrapper: the one residual throw (a 2xx body oRPC can't decode) -> mapOrpcError. -export function createOpenApiClient(http: HttpClient): OpenApiClient { - const link = new OpenAPILink(contract, { - url: http.baseURL, - fetch: async (req, init) => { - const res = await http.request(req, init) - if (!res.ok) throw await classifyResponse(req, res) - return res - }, - }) - return createORPCClient<OpenApiClient>({ - call: (path, input, options) => link.call(path, input, options).catch(mapOrpcError), - }) -} - -// Non-2xx and transport failures already arrive as BaseError (from the fetch wrapper / transport) -// and re-throw unchanged; the only residual is a 2xx body oRPC failed to decode. -function mapOrpcError(err: unknown): never { - if (isBaseError(err)) throw err - throw unknownError(err instanceof Error ? err.message : String(err), err) -} diff --git a/cli/src/http/rate-limit.test.ts b/cli/src/http/rate-limit.test.ts deleted file mode 100644 index ea77092ae95..00000000000 --- a/cli/src/http/rate-limit.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { - classifyRateLimit, - MAX_HONORED_WAIT_MS, - parseRetryAfterMs, - RATE_LIMIT_MAX_ATTEMPTS, - rateLimitDelayMs, -} from './rate-limit.js' - -function res429(body: unknown, headers?: Record<string, string>): Response { - return new Response(typeof body === 'string' ? body : JSON.stringify(body), { - status: 429, - headers: { 'content-type': 'application/json', ...headers }, - }) -} - -function headers(init?: Record<string, string>): Headers { - return new Headers(init) -} - -describe('classifyRateLimit', () => { - it('throttle (code too_many_requests) is retryable and reads the Retry-After header', async () => { - const d = await classifyRateLimit( - res429({ code: 'too_many_requests', status: 429 }, { 'retry-after': '2' }), - ) - expect(d).toEqual({ retryable: true, retryAfterMs: 2000 }) - }) - - it('throttle without Retry-After is retryable with no advised wait', async () => { - const d = await classifyRateLimit(res429({ code: 'too_many_requests', status: 429 })) - expect(d).toEqual({ retryable: true, retryAfterMs: undefined }) - }) - - it('quota (code rate_limit_error) is not retryable', async () => { - const d = await classifyRateLimit( - res429({ code: 'rate_limit_error', status: 429 }, { 'retry-after': '5' }), - ) - expect(d.retryable).toBe(false) - expect(d.retryAfterMs).toBeUndefined() - }) - - it.each([ - ['unknown code', { code: 'mystery' }], - ['no code', { message: 'nope' }], - ['non-JSON body', 'not json'], - ])('unrecognized 429 (%s) is conservatively non-retryable', async (_label, body) => { - const d = await classifyRateLimit(res429(body)) - expect(d.retryable).toBe(false) - }) - - it('reads the body off a clone (response stays consumable)', async () => { - const r = res429({ code: 'too_many_requests', status: 429 }) - await classifyRateLimit(r) - await expect(r.text()).resolves.toContain('too_many_requests') - }) -}) - -describe('parseRetryAfterMs', () => { - it('reads integer-seconds Retry-After as ms', () => { - expect(parseRetryAfterMs(headers({ 'retry-after': '3' }))).toBe(3000) - }) - - it('reads an HTTP-date relative to the injected now, clamped at 0', () => { - const now = Date.parse('2026-06-11T00:00:00Z') - expect( - parseRetryAfterMs(headers({ 'retry-after': 'Thu, 11 Jun 2026 00:00:05 GMT' }), now), - ).toBe(5000) - expect( - parseRetryAfterMs(headers({ 'retry-after': 'Thu, 11 Jun 2026 00:00:00 GMT' }), now + 10_000), - ).toBe(0) - }) - - it('returns undefined when absent or unparseable', () => { - expect(parseRetryAfterMs(headers())).toBeUndefined() - expect(parseRetryAfterMs(headers({ 'retry-after': 'soon' }))).toBeUndefined() - }) -}) - -describe('rateLimitDelayMs', () => { - it('returns the advised wait as-is (the caller already declined over-cap waits)', () => { - expect(rateLimitDelayMs({ retryAfterMs: 800 }, 1)).toBe(800) - expect(rateLimitDelayMs({ retryAfterMs: 5000 }, 1)).toBe(5000) - }) - - it('falls back to equal-jitter backoff when no wait is advised (rng pinned)', () => { - // attempt 1 => backoffDelay 300; equal jitter => [150, 300]. - expect(rateLimitDelayMs({}, 1, { rng: () => 0 })).toBe(150) - expect(rateLimitDelayMs({}, 1, { rng: () => 1 })).toBe(300) - }) - - it('returns 0 when there is neither an advised wait nor any backoff (attempt 0)', () => { - expect(rateLimitDelayMs({}, 0, { rng: () => 0.5 })).toBe(0) - }) - - it('exposes sane retry constants', () => { - expect(MAX_HONORED_WAIT_MS).toBe(60_000) - expect(RATE_LIMIT_MAX_ATTEMPTS).toBe(3) - }) -}) diff --git a/cli/src/http/rate-limit.ts b/cli/src/http/rate-limit.ts deleted file mode 100644 index c0eb4a5a7f6..00000000000 --- a/cli/src/http/rate-limit.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { backoffDelay } from './retry.js' - -// Stateless handling for the server's 429s: react when one arrives, never predict or store limits. -// The server is self-describing via the unified ErrorBody `code`: -// "too_many_requests" → throttle, waiting helps (retryable) -// "rate_limit_error" → quota, waiting within the window does not (not retryable) -// anything else / unparsable → conservative: not retryable. - -export type RateLimitDecision = { - readonly retryable: boolean - // The advised wait, from the Retry-After header (only meaningful for a retryable throttle). - readonly retryAfterMs?: number -} - -// The longest server-advised wait we'll honor by retrying. If Retry-After is larger, the 429 -// branch surfaces immediately instead of parking the process for minutes (better to let the -// caller decide than to sleep through several capped retries that will just 429 again). -export const MAX_HONORED_WAIT_MS = 60_000 - -export const RATE_LIMIT_MAX_ATTEMPTS = 3 - -function bodyCode(raw: string): string | undefined { - try { - const parsed = JSON.parse(raw) as unknown - if (typeof parsed === 'object' && parsed !== null) { - const code = (parsed as Record<string, unknown>).code - return typeof code === 'string' ? code : undefined - } - } catch { - // not JSON - } - return undefined -} - -// Read a 429 response into a retry decision. Reads the ErrorBody `code` for retryability and -// the Retry-After header for the wait; both off a clone so the body stays consumable downstream. -export async function classifyRateLimit(response: Response): Promise<RateLimitDecision> { - let raw = '' - try { - raw = await response.clone().text() - } catch { - // ignore read errors; raw stays '' - } - const retryable = bodyCode(raw) === 'too_many_requests' - return { retryable, retryAfterMs: retryable ? parseRetryAfterMs(response.headers) : undefined } -} - -// Parse the Retry-After header to ms: integer seconds, or an HTTP-date relative to `now` -// (injectable for deterministic tests). The unified ErrorBody carries no wait field of its own. -export function parseRetryAfterMs(headers: Headers, now: number = Date.now()): number | undefined { - const header = headers.get('retry-after') - if (header === null) { - return undefined - } - const trimmed = header.trim() - if (/^\d+$/.test(trimmed)) { - return Number(trimmed) * 1000 - } - const dateMs = Date.parse(trimmed) - if (!Number.isNaN(dateMs)) { - return Math.max(0, dateMs - now) - } - return undefined -} - -// Equal-jitter backoff around the exponential base: half fixed + half random. Avoids both the -// thundering-herd of a fixed delay and the near-zero spikes of full jitter. -function jitter(baseMs: number, rng: () => number): number { - if (baseMs <= 0) { - return 0 - } - const half = baseMs / 2 - return Math.round(half + rng() * half) -} - -// How long to wait before the next 429 retry: a known server wait takes precedence (the caller -// has already declined to retry waits beyond MAX_HONORED_WAIT_MS), otherwise jittered exponential -// backoff for sources that advise none (e.g. app concurrency). -export function rateLimitDelayMs( - decision: Pick<RateLimitDecision, 'retryAfterMs'>, - attempt: number, - opts: { rng?: () => number } = {}, -): number { - if (decision.retryAfterMs !== undefined) { - return decision.retryAfterMs - } - return jitter(backoffDelay(attempt), opts.rng ?? Math.random) -} diff --git a/cli/src/http/retry.test.ts b/cli/src/http/retry.test.ts deleted file mode 100644 index 82dfdc568cf..00000000000 --- a/cli/src/http/retry.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { FetchContext, HttpMethod, ResolvedOptions } from './types.js' -import { describe, expect, it } from 'vite-plus/test' -import { backoffDelay, isIdempotentRetryMethod, shouldRetry } from './retry.js' - -function ctxFor(method: HttpMethod): FetchContext { - const options: ResolvedOptions = { - method, - headers: new Headers(), - body: undefined, - timeoutMs: undefined, - retryAttempts: 0, - throwOnError: true, - retryOnRateLimit: false, - } - return { - request: new Request('https://x/y', { method }), - options, - attempt: 0, - meta: new Map(), - } -} - -describe('shouldRetry', () => { - it('retries retryable status codes on GET', () => { - const res = new Response(null, { status: 503 }) - expect(shouldRetry(res, ctxFor('GET'))).toBe(true) - }) - - it('does not retry non-retryable status codes on GET', () => { - const res = new Response(null, { status: 404 }) - expect(shouldRetry(res, ctxFor('GET'))).toBe(false) - }) - - it('no longer retries 429 here (it has a dedicated branch in execute())', () => { - const res = new Response(null, { status: 429 }) - expect(shouldRetry(res, ctxFor('GET'))).toBe(false) - }) - - it('does not retry POST regardless of status', () => { - const res = new Response(null, { status: 503 }) - expect(shouldRetry(res, ctxFor('POST'))).toBe(false) - }) - - it('does not retry PATCH regardless of status', () => { - const res = new Response(null, { status: 503 }) - expect(shouldRetry(res, ctxFor('PATCH'))).toBe(false) - }) - - it('retries transport errors on retryable methods', () => { - expect(shouldRetry(new Error('econnreset'), ctxFor('GET'))).toBe(true) - expect(shouldRetry(new Error('econnreset'), ctxFor('PUT'))).toBe(true) - }) - - it('does not retry transport errors on non-retryable methods', () => { - expect(shouldRetry(new Error('econnreset'), ctxFor('POST'))).toBe(false) - }) -}) - -describe('isIdempotentRetryMethod', () => { - it('is true for GET/PUT/DELETE and false for POST/PATCH', () => { - expect(isIdempotentRetryMethod('GET')).toBe(true) - expect(isIdempotentRetryMethod('PUT')).toBe(true) - expect(isIdempotentRetryMethod('DELETE')).toBe(true) - expect(isIdempotentRetryMethod('POST')).toBe(false) - expect(isIdempotentRetryMethod('PATCH')).toBe(false) - }) -}) - -describe('backoffDelay', () => { - it('returns 0 for attempts <= 0', () => { - expect(backoffDelay(0)).toBe(0) - expect(backoffDelay(-1)).toBe(0) - }) - - it('grows exponentially from a 300 ms base', () => { - expect(backoffDelay(1)).toBe(300) - expect(backoffDelay(2)).toBe(600) - expect(backoffDelay(3)).toBe(1200) - expect(backoffDelay(4)).toBe(2400) - }) - - it('caps at 30 s', () => { - expect(backoffDelay(20)).toBe(30_000) - }) -}) diff --git a/cli/src/http/retry.ts b/cli/src/http/retry.ts deleted file mode 100644 index c585c08770e..00000000000 --- a/cli/src/http/retry.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { FetchContext } from './types.js' - -export const RETRY_METHODS = ['GET', 'PUT', 'DELETE'] as const -// 429 is intentionally absent — it has a dedicated branch in execute(). shouldRetry covers -// transport errors / 408 / 413 / 5xx only. -export const RETRY_STATUS_CODES = [408, 413, 500, 502, 503, 504] as const - -const RETRY_METHODS_SET: ReadonlySet<string> = new Set(RETRY_METHODS) -const RETRY_STATUS_SET: ReadonlySet<number> = new Set(RETRY_STATUS_CODES) - -// GET/PUT/DELETE are idempotent — safe to auto-retry. The 429 branch reuses this to decide which -// methods may wait-and-retry a throttle without risking a double-run. -export function isIdempotentRetryMethod(method: string): boolean { - return RETRY_METHODS_SET.has(method) -} - -export function shouldRetry(target: Response | unknown, ctx: FetchContext): boolean { - if (!RETRY_METHODS_SET.has(ctx.options.method)) return false - if (target instanceof Response) return RETRY_STATUS_SET.has(target.status) - // Any other transport error on a retryable method retries. User aborts are filtered - // out earlier in dispatch (before this hook ever runs), so they never reach here. - return true -} - -// Exponential backoff: 300ms base, doubling each attempt, capped at 30s -// (300ms, 600ms, 1.2s, ...). -const BACKOFF_BASE_MS = 300 -const BACKOFF_CAP_MS = 30_000 - -export function backoffDelay(attempt: number): number { - if (attempt <= 0) return 0 - const exp = BACKOFF_BASE_MS * 2 ** (attempt - 1) - return Math.min(exp, BACKOFF_CAP_MS) -} diff --git a/cli/src/http/types.ts b/cli/src/http/types.ts deleted file mode 100644 index d346e30f4cd..00000000000 --- a/cli/src/http/types.ts +++ /dev/null @@ -1,104 +0,0 @@ -export type HttpLogPhase = 'request' | 'response' | 'retry' - -export type HttpLogEvent = { - readonly phase: HttpLogPhase - readonly method: string - readonly url: string - readonly status?: number - readonly attempt?: number - readonly durationMs?: number - // Set on a 429 retry decision so --verbose can explain how long we waited. - readonly delayMs?: number -} - -export type HttpLogger = (event: HttpLogEvent) => void - -export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' - -export type SearchParamValue = string | number | boolean | undefined - -// Local equivalents of the DOM-named union types — globals exist (Request, Response, Headers, Blob, …) -// but the union aliases BodyInit / HeadersInit are not exposed by @types/node. Matching the shapes -// the global Request/Response constructors accept; ArrayBufferView is omitted because @types/node's -// RequestInit is stricter and only accepts DataView, which `Uint8Array` covers for our byte-buffer -// callers. -export type HeadersInit = Headers | [string, string][] | Record<string, string> -export type BodyInit = - | string - | Blob - | ArrayBuffer - | FormData - | URLSearchParams - | ReadableStream<Uint8Array> - | Uint8Array - -export type FetchContext = { - request: Request - readonly options: ResolvedOptions - response?: Response - error?: unknown - attempt: number - readonly meta: Map<string | symbol, unknown> -} - -export type Hook = (ctx: FetchContext) => void | Promise<void> - -export type Hooks = { - readonly onRequest?: Hook | Hook[] - readonly onResponse?: Hook | Hook[] - readonly onRequestError?: Hook | Hook[] - readonly onResponseError?: Hook | Hook[] -} - -export type RequestOptions = { - readonly method?: HttpMethod - readonly headers?: HeadersInit - readonly json?: unknown - readonly body?: BodyInit - readonly searchParams?: Record<string, SearchParamValue> - readonly timeoutMs?: number - readonly retryAttempts?: number - readonly signal?: AbortSignal - readonly throwOnError?: boolean - // Opt a non-idempotent POST into bounded wait-and-retry on a 429 throttle. - readonly retryOnRateLimit?: boolean -} - -export type ResolvedOptions = { - readonly method: HttpMethod - readonly headers: Headers - readonly body: BodyInit | undefined - readonly timeoutMs: number | undefined - readonly retryAttempts: number - readonly throwOnError: boolean - readonly retryOnRateLimit: boolean -} - -export type ClientOptions = { - readonly baseURL: string - readonly bearer?: string - readonly userAgent?: string - readonly timeoutMs?: number - readonly retryAttempts?: number - readonly logger?: HttpLogger - readonly hooks?: Hooks - // Skip TLS certificate verification (local-dev only, self-signed hosts). - readonly insecure?: boolean -} - -export type HttpClient = { - // The resolved base URL this client was created with (e.g. openAPIBase(host)). Exposed so - // callers can build a contract/oRPC facade over the same transport without re-deriving it. - readonly baseURL: string - readonly get: <T>(path: string, opts?: RequestOptions) => Promise<T> - readonly post: <T>(path: string, opts?: RequestOptions) => Promise<T> - readonly put: <T>(path: string, opts?: RequestOptions) => Promise<T> - readonly patch: <T>(path: string, opts?: RequestOptions) => Promise<T> - readonly delete: <T>(path: string, opts?: RequestOptions) => Promise<T> - readonly fetch: (path: string, opts?: RequestOptions) => Promise<Response> - readonly stream: (path: string, opts?: RequestOptions) => Promise<Response> - // Low-level entrypoint for oRPC's OpenAPILink: runs a pre-built, absolute-URL Request - // through the transport (UA+bearer, retry, timeout, error-map) without re-joining baseURL. - readonly request: (req: Request, init?: RequestInit) => Promise<Response> - readonly extend: (overrides: Partial<ClientOptions>) => HttpClient -} diff --git a/cli/src/http/url.test.ts b/cli/src/http/url.test.ts deleted file mode 100644 index c5762281607..00000000000 --- a/cli/src/http/url.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { appendSearchParams, joinURL } from './url.js' - -describe('joinURL', () => { - it('joins base and path with single slash', () => { - expect(joinURL('https://api.example.com/openapi/v1', 'workspaces')).toBe( - 'https://api.example.com/openapi/v1/workspaces', - ) - }) - - it('collapses double slash when base has trailing and path has leading', () => { - expect(joinURL('https://api.example.com/openapi/v1/', '/workspaces')).toBe( - 'https://api.example.com/openapi/v1/workspaces', - ) - }) - - it('inserts slash when neither side has one', () => { - expect(joinURL('https://api.example.com', 'workspaces')).toBe( - 'https://api.example.com/workspaces', - ) - }) - - it('preserves trailing-only or leading-only slash without adding another', () => { - expect(joinURL('https://api.example.com/', 'workspaces')).toBe( - 'https://api.example.com/workspaces', - ) - expect(joinURL('https://api.example.com', '/workspaces')).toBe( - 'https://api.example.com/workspaces', - ) - }) - - it('returns base when path is empty or root', () => { - expect(joinURL('https://api.example.com', '')).toBe('https://api.example.com') - expect(joinURL('https://api.example.com', '/')).toBe('https://api.example.com') - }) - - it('returns path when base is empty or root', () => { - expect(joinURL('', 'workspaces')).toBe('workspaces') - expect(joinURL('/', 'workspaces')).toBe('workspaces') - }) -}) - -describe('appendSearchParams', () => { - it('returns the URL unchanged when params is undefined', () => { - expect(appendSearchParams('https://x/y', undefined)).toBe('https://x/y') - }) - - it('returns the URL unchanged when every value is undefined', () => { - expect(appendSearchParams('https://x/y', { a: undefined, b: undefined })).toBe('https://x/y') - }) - - it('omits undefined values and coerces primitives', () => { - const url = appendSearchParams('https://x/y', { - page: 2, - active: true, - name: 'foo', - skip: undefined, - }) - expect(url).toBe('https://x/y?page=2&active=true&name=foo') - }) - - it('uses & when the URL already has a query string', () => { - expect(appendSearchParams('https://x/y?a=1', { b: 2 })).toBe('https://x/y?a=1&b=2') - }) - - // Pins the convention documented on `appendSearchParams`: callers that mean - // "absent" pass `undefined`; an explicit empty string travels as `?key=`. - // API-client callers collapse empties to `undefined` upstream — this is the - // backstop that catches a future "let's just skip empties here too" change. - it('keeps empty-string values on the wire (only undefined is skipped)', () => { - expect(appendSearchParams('https://x/y', { name: '', tag: undefined })).toBe( - 'https://x/y?name=', - ) - }) -}) diff --git a/cli/src/http/url.ts b/cli/src/http/url.ts deleted file mode 100644 index a5233dd1318..00000000000 --- a/cli/src/http/url.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { SearchParamValue } from './types.js' - -// Joins a base URL and a path, collapsing/inserting a single slash at the seam. -export function joinURL(base: string, path: string): string { - if (base === '' || base === '/') return path === '' ? '/' : path - if (path === '' || path === '/') return base - - const baseHasTrailing = base.endsWith('/') - const pathHasLeading = path.startsWith('/') - - if (baseHasTrailing && pathHasLeading) return base + path.slice(1) - if (!baseHasTrailing && !pathHasLeading) return `${base}/${path}` - return base + path -} - -// Only `undefined` is treated as "absent". Empty strings, 0, and false coerce -// through `String(...)` and land on the wire — e.g. `{ name: '' }` becomes -// `?name=`. API-client callers (see `apps.ts`, `account-sessions.ts`) collapse -// empty-string filters to `undefined` at their own layer; this helper does NOT -// do that for them, on purpose, so a caller that wants to send an explicit -// empty value still can. -export function appendSearchParams( - url: string, - params: Record<string, SearchParamValue> | undefined, -): string { - if (params === undefined) return url - - const search = new URLSearchParams() - for (const [key, value] of Object.entries(params)) { - if (value === undefined) continue - search.append(key, String(value)) - } - - const qs = search.toString() - if (qs === '') return url - return url.includes('?') ? `${url}&${qs}` : `${url}?${qs}` -} diff --git a/cli/src/index.ts b/cli/src/index.ts deleted file mode 100644 index 65d86b25994..00000000000 --- a/cli/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { longVersion, shortVersion, userAgent, versionInfo } from '@/version/info' -export type { Channel, VersionInfo } from '@/version/info' diff --git a/cli/src/kernel/context.test.ts b/cli/src/kernel/context.test.ts new file mode 100644 index 00000000000..5d0763e042b --- /dev/null +++ b/cli/src/kernel/context.test.ts @@ -0,0 +1,80 @@ +import { expect, it } from 'vite-plus/test' +import { Context } from './context' +import { definePlugin } from './plugin' + +it('builds lazily, once, and memoises by plugin object', async () => { + let builds = 0 + const a = definePlugin({ name: 'a', needs: [], build: () => ({ n: ++builds }) }) + const ctx = new Context() + expect(builds).toBe(0) + const first = await ctx.get(a) + const second = await ctx.get(a) + expect(first).toBe(second) + expect(builds).toBe(1) +}) + +it('builds needs on demand from inside build', async () => { + const order: string[] = [] + const a = definePlugin({ + name: 'a', + needs: [], + build: () => { + order.push('a') + return { v: 1 } + }, + }) + const b = definePlugin({ + name: 'b', + needs: [a], + build: async (ctx) => { + order.push('b:start') + const { v } = await ctx.get(a) + order.push('b:end') + return { v: v + 1 } + }, + }) + const ctx = new Context() + expect((await ctx.get(b)).v).toBe(2) + expect(order).toEqual(['b:start', 'a', 'b:end']) +}) + +it('returns an override without building the plugin', async () => { + let built = false + const a = definePlugin({ + name: 'a', + needs: [], + build: () => { + built = true + return { real: true } + }, + }) + const ctx = new Context([[a, { real: false }]]) + expect(await ctx.get(a)).toEqual({ real: false }) + expect(built).toBe(false) +}) + +it('reports a runtime cycle instead of hanging', async () => { + // Types forbid this; the cast is the only way in, so the kernel still refuses it at run time. + const a = definePlugin({ + name: 'a', + needs: [], + build: async (ctx): Promise<unknown> => + (ctx as { get: (p: unknown) => Promise<unknown> }).get(a), + }) + await expect(new Context().get(a)).rejects.toThrow(/kernel: cycle while building a/) +}) + +it('records defers in registration order', async () => { + const a = definePlugin({ + name: 'a', + needs: [], + build: (ctx) => { + ctx.defer(() => {}) + ctx.defer(() => {}) + return {} + }, + }) + const ctx = new Context() + await ctx.get(a) + expect(ctx.deferred).toHaveLength(2) +}) diff --git a/cli/src/kernel/context.ts b/cli/src/kernel/context.ts new file mode 100644 index 00000000000..29299327ebe --- /dev/null +++ b/cli/src/kernel/context.ts @@ -0,0 +1,40 @@ +import type { AnyPlugin, Deferred, ServiceOf } from './plugin' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' + +export type Override = readonly [AnyPlugin, unknown] + +export class Context { + private readonly cache = new Map<AnyPlugin, unknown>() + private readonly pending = new Set<AnyPlugin>() + private readonly defers: Deferred[] = [] + + constructor(overrides: Iterable<Override> = []) { + for (const [plugin, service] of overrides) this.cache.set(plugin, service) + } + + async get<P extends AnyPlugin>(plugin: P): Promise<ServiceOf<P>> { + if (this.cache.has(plugin)) return this.cache.get(plugin) as ServiceOf<P> + if (this.pending.has(plugin)) + throw new BaseError({ + code: ErrorCode.Unknown, + message: `kernel: cycle while building ${plugin.name}`, + }) + this.pending.add(plugin) + try { + const service = await (plugin.build as (ctx: Context) => unknown)(this) + this.cache.set(plugin, service) + return service as ServiceOf<P> + } finally { + this.pending.delete(plugin) + } + } + + defer(fn: Deferred): void { + this.defers.push(fn) + } + + get deferred(): readonly Deferred[] { + return this.defers + } +} diff --git a/cli/src/kernel/plugin.ts b/cli/src/kernel/plugin.ts new file mode 100644 index 00000000000..d87cca6fee4 --- /dev/null +++ b/cli/src/kernel/plugin.ts @@ -0,0 +1,25 @@ +export type Deferred = () => void | Promise<void> + +export type Plugin<Name extends string, T, N extends readonly AnyPlugin[]> = { + readonly name: Name + readonly needs: N + readonly build: (ctx: BuildContext<N[number]>) => T | Promise<T> +} + +export type AnyPlugin = { + readonly name: string + readonly needs: readonly AnyPlugin[] + readonly build: (ctx: never) => unknown +} +export type ServiceOf<P extends AnyPlugin> = Awaited<ReturnType<P['build']>> + +export type BuildContext<Allowed extends AnyPlugin> = { + readonly get: <P extends Allowed>(plugin: P) => Promise<ServiceOf<P>> + readonly defer: (fn: Deferred) => void +} + +export function definePlugin<const Name extends string, T, const N extends readonly AnyPlugin[]>( + spec: Plugin<Name, T, N>, +): Plugin<Name, T, N> { + return spec +} diff --git a/cli/src/kernel/types.test.ts b/cli/src/kernel/types.test.ts new file mode 100644 index 00000000000..ffa5a5f6c9c --- /dev/null +++ b/cli/src/kernel/types.test.ts @@ -0,0 +1,21 @@ +import { it } from 'vite-plus/test' +import { definePlugin } from './plugin' + +const env = definePlugin({ name: 'env', needs: [], build: () => ({ home: '/tmp' }) }) +const session = definePlugin({ + name: 'session', + needs: [env], + build: async (ctx) => ({ dir: (await ctx.get(env)).home }), +}) +definePlugin({ + name: 'token', + needs: [session], + build: async (ctx) => { + const s = await ctx.get(session) + // @ts-expect-error env is not in needs + await ctx.get(env) + return { where: s.dir } + }, +}) + +it('compiles', () => {}) diff --git a/cli/src/limit/limit.test.ts b/cli/src/limit/limit.test.ts deleted file mode 100644 index e0d4ae2156e..00000000000 --- a/cli/src/limit/limit.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { isBaseError } from '@/errors/base' -import { ExitCode } from '@/errors/codes' -import { LIMIT_DEFAULT, LIMIT_MAX, LIMIT_MIN, parseLimit } from './limit' - -describe('limit', () => { - it('constants match Go original', () => { - expect(LIMIT_MIN).toBe(1) - expect(LIMIT_MAX).toBe(200) - expect(LIMIT_DEFAULT).toBe(20) - }) - - it.each([1, 20, 50, 200])('accepts %d', (n) => { - expect(parseLimit(String(n), '--limit')).toBe(n) - }) - - it.each([0, -1, 201, 1000])('rejects %d as out of range', (n) => { - let err: unknown - try { - parseLimit(String(n), '--limit') - } catch (e) { - err = e - } - expect(isBaseError(err)).toBe(true) - expect((err as { code: string }).code).toBe('usage_invalid_flag') - expect((err as { exit: () => number }).exit()).toBe(ExitCode.Usage) - expect((err as Error).message).toMatch(/out of range/) - }) - - it('rejects non-numeric with typed UsageInvalidFlag', () => { - let err: unknown - try { - parseLimit('abc', '--limit') - } catch (e) { - err = e - } - expect(isBaseError(err)).toBe(true) - expect((err as { code: string }).code).toBe('usage_invalid_flag') - expect((err as Error).message).toMatch(/not a number/) - }) - - it('rejects empty string with typed UsageInvalidFlag', () => { - let err: unknown - try { - parseLimit('', '--limit') - } catch (e) { - err = e - } - expect(isBaseError(err)).toBe(true) - expect((err as { code: string }).code).toBe('usage_invalid_flag') - }) - - it('rejects floats (mirroring Go strconv.Atoi behaviour)', () => { - expect(() => parseLimit('1.5', '--limit')).toThrow(/not a number/) - }) - - it('error message names the source knob', () => { - expect(() => parseLimit('999', 'DIFY_LIMIT')).toThrow(/DIFY_LIMIT/) - expect(() => parseLimit('999', 'defaults.limit')).toThrow(/defaults\.limit/) - }) -}) diff --git a/cli/src/limit/limit.ts b/cli/src/limit/limit.ts deleted file mode 100644 index 0684496f078..00000000000 --- a/cli/src/limit/limit.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' - -export const LIMIT_MIN = 1 -export const LIMIT_MAX = 200 -export const LIMIT_DEFAULT = 20 - -const INTEGER_PATTERN = /^-?\d+$/ - -export function parseLimit(raw: string, source: string): number { - if (!INTEGER_PATTERN.test(raw)) { - throw newError(ErrorCode.UsageInvalidFlag, `${source}: ${JSON.stringify(raw)} is not a number`) - } - const n = Number(raw) - if (n < LIMIT_MIN || n > LIMIT_MAX) { - throw newError( - ErrorCode.UsageInvalidFlag, - `${source}: ${n} out of range [${LIMIT_MIN}..${LIMIT_MAX}]`, - ) - } - return n -} - -const MAX_PAGES = 100 - -export type Page<T> = { readonly data: readonly T[]; readonly has_more: boolean } - -/** Walks pages from 1 until has_more is false; MAX_PAGES caps a server that never says so. */ -export async function fetchAllPages<T>( - fetchPage: (page: number) => Promise<Page<T>>, -): Promise<T[]> { - const out: T[] = [] - for (let page = 1; page <= MAX_PAGES; page++) { - const env = await fetchPage(page) - out.push(...env.data) - if (!env.has_more) break - } - return out -} diff --git a/cli/src/main.test.ts b/cli/src/main.test.ts new file mode 100644 index 00000000000..ec38375e6a2 --- /dev/null +++ b/cli/src/main.test.ts @@ -0,0 +1,63 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { Context } from '@/kernel/context' +import { commands } from '@/plugins/commands' +import { main } from './main' + +let w: TestWorld +afterEach(async () => { + await w.stop() +}) + +it('prints the envelope on stderr and returns the exit code', async () => { + w = await testContext({ login: false }) + const code = await main(['nope'], w.overrides) + expect(code).toBe(2) + expect(JSON.parse(w.io.errBuf()).error.code).toBe('usage_invalid_flag') + expect(w.io.outBuf()).toBe('') +}) + +it('a thrown non-BaseError becomes the unknown envelope, exit 1', async () => { + w = await testContext({ login: false }) + const code = await main( + [], + [ + ...w.overrides, + [ + commands, + { + run: async () => { + throw new Error('boom') + }, + }, + ], + ], + ) + expect(code).toBe(1) + expect(JSON.parse(w.io.errBuf()).error).toEqual({ code: 'unknown', message: 'boom' }) +}) + +it('runs deferred cleanups in reverse, after failure too', async () => { + w = await testContext({ login: false }) + const order: string[] = [] + const ctx: Context = new Context([ + ...w.overrides, + [ + commands, + { + run: async () => { + ctx.defer(() => { + order.push('first') + }) + ctx.defer(() => { + order.push('second') + }) + throw new Error('boom') + }, + }, + ], + ]) + expect(await main([], [], ctx)).toBe(1) + expect(order).toEqual(['second', 'first']) +}) diff --git a/cli/src/main.ts b/cli/src/main.ts new file mode 100644 index 00000000000..03a059f1ee8 --- /dev/null +++ b/cli/src/main.ts @@ -0,0 +1,32 @@ +import type { Override } from '@/kernel/context' +import { printEnvelope } from '@/errors/envelope' +import { Context } from '@/kernel/context' +import { argv } from '@/plugins/argv' +import { commands } from '@/plugins/commands' +import { globalFlags } from '@/plugins/global-flags' +import { io } from '@/plugins/io' + +export async function main( + args: readonly string[], + overrides: Iterable<Override> = [], + ctx = new Context([...overrides, [argv, args]]), +): Promise<number> { + const ioService = await ctx.get(io) + let code: number + let verbose = false + try { + verbose = (await ctx.get(globalFlags)).flags.verbose + code = await (await ctx.get(commands)).run() + } catch (err) { + code = await printEnvelope(err, ioService, { verbose }) + } finally { + for (const fn of [...ctx.deferred].reverse()) { + try { + await fn() + } catch { + /* cleanup never masks the command's outcome */ + } + } + } + return code +} diff --git a/cli/src/net/fetch-init.ts b/cli/src/net/fetch-init.ts new file mode 100644 index 00000000000..08115238807 --- /dev/null +++ b/cli/src/net/fetch-init.ts @@ -0,0 +1,20 @@ +import type { Dispatcher } from 'undici' + +export type FetchInit = RequestInit & { + dispatcher?: Dispatcher + tls?: { rejectUnauthorized: boolean } +} + +export type FetchInitOptions = Readonly<{ + insecure: boolean + dispatcher?: Dispatcher +}> + +// Shared by src/plugins/http.ts and src/auth/device-api.ts: both send requests +// that may need TLS verification skipped and/or routed through a proxy dispatcher. +export function buildFetchInit(base: RequestInit, opts: FetchInitOptions): FetchInit { + const init: FetchInit = { ...base } + if (opts.insecure) init.tls = { rejectUnauthorized: false } + if (opts.dispatcher !== undefined) init.dispatcher = opts.dispatcher + return init +} diff --git a/cli/src/http/proxy.test.ts b/cli/src/net/proxy.test.ts similarity index 100% rename from cli/src/http/proxy.test.ts rename to cli/src/net/proxy.test.ts diff --git a/cli/src/http/proxy.ts b/cli/src/net/proxy.ts similarity index 100% rename from cli/src/http/proxy.ts rename to cli/src/net/proxy.ts diff --git a/cli/src/plugins/argv/index.ts b/cli/src/plugins/argv/index.ts new file mode 100644 index 00000000000..04b4005ec5a --- /dev/null +++ b/cli/src/plugins/argv/index.ts @@ -0,0 +1,10 @@ +import { definePlugin } from '@/kernel/plugin' +import { argv as processArgv } from '@/sys' + +export type ArgvService = readonly string[] + +export const argv = definePlugin({ + name: 'argv', + needs: [], + build: (): ArgvService => processArgv(), +}) diff --git a/cli/src/plugins/argv/parse.test.ts b/cli/src/plugins/argv/parse.test.ts new file mode 100644 index 00000000000..6da0de10021 --- /dev/null +++ b/cli/src/plugins/argv/parse.test.ts @@ -0,0 +1,110 @@ +import { expect, it } from 'vite-plus/test' +import { parseArgv } from './parse' + +const spec = { + positional: ['op_id'], + schema: { + type: 'object', + properties: { + op_id: { type: 'string' }, + input: { type: 'string' }, + stream: { type: 'boolean' }, + only: { type: 'array', items: { type: 'string' } }, + limit: { type: 'integer' }, + no_browser: { type: 'boolean' }, + }, + }, +} + +it('fills positionals, flags, booleans, arrays and numbers by schema type', () => { + expect( + parseArgv( + [ + 'a.b', + '--input', + '{}', + '--stream', + '--only', + 'x', + '--only=y', + '--limit', + '3', + '--no-browser', + ], + spec, + ), + ).toEqual({ + op_id: 'a.b', + input: '{}', + stream: true, + only: ['x', 'y'], + limit: 3, + no_browser: true, + }) +}) + +it('rejects unknown flags and extra positionals with exit 2', () => { + expect(() => parseArgv(['a.b', '--nope'], spec)).toThrow( + expect.objectContaining({ + code: 'usage_invalid_flag', + message: expect.stringContaining('--nope'), + }), + ) + expect(() => parseArgv(['a.b', 'extra'], spec)).toThrow( + expect.objectContaining({ code: 'usage_invalid_flag' }), + ) +}) + +it('leaves a bad number for validation to report', () => { + expect(parseArgv(['--limit', 'many'], { positional: [], schema: spec.schema })).toEqual({ + limit: 'many', + }) +}) + +it('an empty number value stays the string it was typed as, never 0', () => { + const bare = { positional: [], schema: spec.schema } + expect(parseArgv(['--limit='], bare)).toEqual({ limit: '' }) + expect(parseArgv(['--limit', ''], bare)).toEqual({ limit: '' }) + expect(parseArgv(['--limit', ' '], bare)).toEqual({ limit: ' ' }) +}) + +it('a nullable boolean (anyOf [boolean, null]) is a switch, not a value-taking flag', () => { + const nullableBooleanSpec = { + positional: [], + schema: { + type: 'object', + properties: { + verbose: { anyOf: [{ type: 'boolean' }, { type: 'null' }], default: null }, + }, + }, + } + expect(parseArgv(['--verbose'], nullableBooleanSpec)).toEqual({ verbose: true }) +}) + +it('a map property coerces its JSON value to an object', () => { + const mapSpec = { + positional: [], + schema: { + type: 'object', + properties: { + inputs: { type: 'object', additionalProperties: { type: 'string' } }, + }, + }, + } + expect(parseArgv(['--inputs', '{"a":1}'], mapSpec)).toEqual({ inputs: { a: 1 } }) +}) + +it('repeated flags on an array of binary files collect into a string list', () => { + const attachmentsSpec = { + positional: [], + schema: { + type: 'object', + properties: { + attachments: { type: 'array', items: { type: 'string', format: 'binary' } }, + }, + }, + } + expect(parseArgv(['--attachments', 'a', '--attachments', 'b'], attachmentsSpec)).toEqual({ + attachments: ['a', 'b'], + }) +}) diff --git a/cli/src/plugins/argv/parse.ts b/cli/src/plugins/argv/parse.ts new file mode 100644 index 00000000000..41ea19b044e --- /dev/null +++ b/cli/src/plugins/argv/parse.ts @@ -0,0 +1,131 @@ +import type { JsonSchema } from '@/plugins/catalog' +import type { ShapeNode } from '@/protocol/shape' +import { z } from 'zod' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { coerceFor, isRepeatable, propertiesOf, SHAPE, shapeOf, takesValue } from '@/protocol/shape' +import { propertyFor } from '@/util/flag-name' + +export type ArgvSpec = { + readonly positional: readonly string[] + readonly schema: JsonSchema +} + +export type ArgvPartition = { + readonly matched: string[] + readonly rest: string[] +} + +const LONG_PREFIX = '--' +const SHORT_PREFIX = '-' +const END_OF_FLAGS = '--' +const VALUE_SEPARATOR = '=' + +// A positional whose name has no matching schema property keeps the raw string — +// there is no declared type to coerce it against. +const UNKNOWN_PROPERTY_SHAPE: ShapeNode = { shape: SHAPE.String } + +function invalidFlag(message: string): BaseError { + return new BaseError({ code: ErrorCode.UsageInvalidFlag, message }) +} + +function assign( + target: Record<string, unknown>, + key: string, + property: JsonSchema | undefined, + raw: string, +): void { + const node = property === undefined ? UNKNOWN_PROPERTY_SHAPE : shapeOf(property) + const value = coerceFor(node)(raw) + if (isRepeatable(node)) { + const existing = target[key] + target[key] = [...(Array.isArray(existing) ? existing : []), value] + return + } + target[key] = value +} + +function splitFlag(token: string): { name: string; value: string | undefined } { + const body = token.slice(LONG_PREFIX.length) + const at = body.indexOf(VALUE_SEPARATOR) + if (at === -1) return { name: body, value: undefined } + return { name: body.slice(0, at), value: body.slice(at + 1) } +} + +// Both help and validation describe what a caller may pass, so a defaulted property +// must stay optional — that is the schema's 'input' view, not its output view. +export function inputSchema(input: z.ZodObject): JsonSchema { + return z.toJSONSchema(input, { io: 'input' }) as JsonSchema +} + +export function partitionArgv(argv: readonly string[], schema: JsonSchema): ArgvPartition { + const properties = propertiesOf(schema) + const matched: string[] = [] + const rest: string[] = [] + + for (let index = 0; index < argv.length; index++) { + const token = argv[index] as string + if (token === END_OF_FLAGS) { + rest.push(...argv.slice(index)) + break + } + const property = token.startsWith(LONG_PREFIX) + ? properties[propertyFor(splitFlag(token).name)] + : undefined + if (property === undefined) { + rest.push(token) + continue + } + matched.push(token) + const needsValue = takesValue(shapeOf(property)) && splitFlag(token).value === undefined + if (needsValue && index + 1 < argv.length) matched.push(argv[++index] as string) + } + + return { matched, rest } +} + +export function parseArgv(argv: readonly string[], spec: ArgvSpec): Record<string, unknown> { + const properties = propertiesOf(spec.schema) + const parsed: Record<string, unknown> = {} + let positionals = 0 + let flagsEnded = false + + for (let index = 0; index < argv.length; index++) { + const token = argv[index] as string + + if (!flagsEnded && token === END_OF_FLAGS) { + flagsEnded = true + continue + } + + if (!flagsEnded && token.startsWith(SHORT_PREFIX) && token.length > SHORT_PREFIX.length) { + if (!token.startsWith(LONG_PREFIX)) throw invalidFlag(`unknown flag "${token}"`) + const { name, value } = splitFlag(token) + const key = propertyFor(name) + const property = properties[key] + if (property === undefined) throw invalidFlag(`unknown flag "${token}"`) + + const node = shapeOf(property) + if (!takesValue(node)) { + parsed[key] = value === undefined ? true : coerceFor(node)(value) + continue + } + + let raw = value + if (raw === undefined) { + index += 1 + raw = argv[index] + if (raw === undefined) throw invalidFlag(`flag "${token}" needs a value`) + } + assign(parsed, key, property, raw) + continue + } + + const name = spec.positional[positionals] + if (name === undefined) throw invalidFlag(`unexpected argument "${token}"`) + positionals += 1 + assign(parsed, name, properties[name], token) + } + + return parsed +} diff --git a/cli/src/plugins/base.ts b/cli/src/plugins/base.ts new file mode 100644 index 00000000000..bb68a4975a0 --- /dev/null +++ b/cli/src/plugins/base.ts @@ -0,0 +1,27 @@ +import type { BuildContext } from '@/kernel/plugin' +import { catalog } from './catalog' +import { config } from './config' +import { env } from './env' +import { globalFlags } from './global-flags' +import { http } from './http' +import { io } from './io' +import { ops } from './ops' +import { session } from './session' +import { token } from './token' + +// What a command may reach through `ctx.get`. `needs` is a compile-time allow-list, +// never a load list: a plugin builds only when something asks for it, so a command +// pays for exactly the plugins it calls. A new plugin commands should see goes here. +export const BASE_PLUGINS = [ + globalFlags, + env, + config, + session, + token, + catalog, + http, + ops, + io, +] as const + +export type CommandContext = BuildContext<(typeof BASE_PLUGINS)[number]> diff --git a/cli/src/plugins/catalog/index.test.ts b/cli/src/plugins/catalog/index.test.ts new file mode 100644 index 00000000000..00ddea739bb --- /dev/null +++ b/cli/src/plugins/catalog/index.test.ts @@ -0,0 +1,123 @@ +import type { Login } from '@/plugins/session' +import type { BufferStreams } from '@/sys/io/streams' +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vite-plus/test' +import { Context } from '@/kernel/context' +import { io, ioService } from '@/plugins/io' +import { session } from '@/plugins/session' +import { platform } from '@/sys' +import { bufferStreams } from '@/sys/io/streams' +import { catalog, CATALOG_DIR_NAME } from './index' + +const FIXTURE = readFileSync(join(__dirname, '../../../test/fixtures/catalog.json')) +const LOGIN: Login = { + server: 'https://d.example', + email: 'e', + account: { id: 'a', email: 'e', name: 'n' }, + workspaceId: null, + tokenStorage: 'file', + insecure: false, +} +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'difyctl-cat-')) + process.env.DIFY_CONFIG_DIR = dir + process.env.DIFY_CACHE_DIR = dir +}) +afterEach(() => { + delete process.env.DIFY_CONFIG_DIR + delete process.env.DIFY_CACHE_DIR +}) + +const CACHE_FILE = 'd.example.json' + +async function build() { + const ctx = new Context() + await (await ctx.get(session)).save(LOGIN) + return ctx.get(catalog) +} + +async function buildWithStreams(): Promise<{ streams: BufferStreams; ctx: Context }> { + const streams = bufferStreams() + const ctx = new Context([[io, ioService(streams)]]) + await (await ctx.get(session)).save(LOGIN) + return { streams, ctx } +} + +function writeCorruptCache(): string { + const dir_ = join(dir, CATALOG_DIR_NAME) + mkdirSync(dir_, { recursive: true }) + const path = join(dir_, CACHE_FILE) + writeFileSync(path, '{ not json') + return path +} + +it('knows nothing before a catalog is loaded', async () => { + const c = await build() + expect(c.loaded).toBe(false) + expect(c.ops()).toEqual({}) + expect(c.op('get.console_app')).toBeUndefined() + expect(c.fingerprint).toBe('') +}) + +it('replace writes the raw bytes; the fingerprint is the sha256 of those bytes', async () => { + const c = await build() + await c.replace(FIXTURE) + expect(c.loaded).toBe(true) + expect(readFileSync(join(dir, 'catalog', 'd.example.json'))).toEqual(FIXTURE) + expect(c.fingerprint).toBe(createHash('sha256').update(FIXTURE).digest('hex')) + const again = await build() + expect(again.loaded).toBe(true) + expect(again.op('run.console_app.workflow')?.kind).toBe('sse') +}) + +it('clear() forgets everything', async () => { + const c = await build() + await c.replace(FIXTURE) + await c.clear() + expect(c.loaded).toBe(false) + expect(c.ops()).toEqual({}) +}) + +it('clear() without a login rejects like path and replace do', async () => { + const c = await new Context().get(catalog) + await expect(c.clear()).rejects.toMatchObject({ code: 'not_logged_in' }) +}) + +it('a cache file it cannot parse is a miss with one notice, and a refresh still works', async () => { + const path = writeCorruptCache() + const { streams, ctx } = await buildWithStreams() + const c = await ctx.get(catalog) + expect(c.loaded).toBe(false) + expect(c.fingerprint).toBe('') + expect(streams.errBuf().trim().split('\n')).toHaveLength(1) + expect(streams.errBuf()).toBe( + `ignoring unreadable catalog cache ${path}: catalog document is not valid JSON\n`, + ) + + await c.replace(FIXTURE) + expect(c.loaded).toBe(true) + expect(readFileSync(path)).toEqual(FIXTURE) +}) + +it.skipIf(platform() === 'win32')('writes the cache 0600 under a 0700 directory', async () => { + const c = await build() + await c.replace(FIXTURE) + const path = join(dir, CATALOG_DIR_NAME, CACHE_FILE) + expect(statSync(path).mode & 0o777).toBe(0o600) + expect(statSync(join(dir, CATALOG_DIR_NAME)).mode & 0o777).toBe(0o700) +}) + +it('a cache it cannot write is an error naming the path', async () => { + const c = await build() + // A file where the catalog directory belongs: mkdir cannot make the parent. + writeFileSync(join(dir, CATALOG_DIR_NAME), 'not a directory') + await expect(c.replace(FIXTURE)).rejects.toMatchObject({ + code: 'unknown', + message: `failed to write the catalog cache ${join(dir, CATALOG_DIR_NAME, CACHE_FILE)}`, + }) + expect(c.loaded).toBe(false) +}) diff --git a/cli/src/plugins/catalog/index.ts b/cli/src/plugins/catalog/index.ts new file mode 100644 index 00000000000..ed59776757a --- /dev/null +++ b/cli/src/plugins/catalog/index.ts @@ -0,0 +1,142 @@ +import type { CatalogDoc, CatalogOp } from './types' +import { createHash } from 'node:crypto' +import { promises as fsp } from 'node:fs' +import { dirname, join } from 'node:path' +import { BaseError, notLoggedIn } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { errorMessage } from '@/errors/message' +import { definePlugin } from '@/kernel/plugin' +import { env } from '@/plugins/env' +import { io } from '@/plugins/io' +import { session } from '@/plugins/session' +import { pid, resolvePlatform } from '@/sys' +import { bareHost } from '@/util/host' +import { BINARY } from '@/version/info' +import { parseCatalog } from './types' + +export const CATALOG_DIR_NAME = 'catalog' +const HOST_SEPARATOR_REPLACEMENT = '_' +const FILE_MODE = 0o600 +const DIR_MODE = 0o700 +const UNREADABLE_CACHE_PREFIX = 'ignoring unreadable catalog cache ' +const ENOENT = 'ENOENT' + +export type { CatalogDoc, CatalogOp, Example, JsonSchema } from './types' +export { CATALOG_HEADER, CATALOG_PATH, parseCatalog } from './types' + +export type CatalogService = { + readonly path: string + readonly loaded: boolean + readonly fingerprint: string + readonly op: (id: string) => CatalogOp | undefined + readonly opOrThrow: (id: string) => CatalogOp + readonly ops: () => Readonly<Record<string, CatalogOp>> + readonly replace: (bytes: Uint8Array) => Promise<void> + readonly clear: () => Promise<void> +} + +export function fingerprintOf(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function cachePath(cacheDir: string, server: string): string { + const key = bareHost(server).split(':').join(HOST_SEPARATOR_REPLACEMENT) + return join(cacheDir, CATALOG_DIR_NAME, `${key}.json`) +} + +function isMissingFile(err: unknown): boolean { + return (err as NodeJS.ErrnoException).code === ENOENT +} + +// The cache is a file two processes may race on, so it is replaced rather than +// rewritten in place: a reader either sees the whole old document or the whole new one. +async function writeAtomically(path: string, bytes: Uint8Array): Promise<void> { + const tmp = `${path}.tmp.${pid()}.${Date.now()}` + try { + await fsp.mkdir(dirname(path), { recursive: true, mode: DIR_MODE }) + await fsp.writeFile(tmp, bytes, { mode: FILE_MODE }) + resolvePlatform().atomicReplace(tmp, path) + } catch (cause) { + try { + await fsp.unlink(tmp) + } catch { + /* tmp may never have been created */ + } + throw new BaseError({ + code: ErrorCode.Unknown, + message: `failed to write the catalog cache ${path}`, + cause, + }) + } +} + +export const catalog = definePlugin({ + name: 'catalog', + needs: [env, session, io], + build: async (ctx): Promise<CatalogService> => { + const { cacheDir } = await ctx.get(env) + const sessionService = await ctx.get(session) + const login = await sessionService.current() + const path = login === null ? undefined : cachePath(cacheDir, login.server) + + let bytes: Uint8Array | undefined + let doc: CatalogDoc | undefined + + if (path !== undefined) { + try { + bytes = await fsp.readFile(path) + doc = parseCatalog(bytes) + } catch (err) { + // A cache this build cannot read is a miss, not a dead end: the next + // fetch replaces it. Only a file that was never there stays silent. + bytes = undefined + doc = undefined + if (!isMissingFile(err)) { + const out = await ctx.get(io) + out.notice(`${UNREADABLE_CACHE_PREFIX}${path}: ${errorMessage(err)}`) + } + } + } + + return { + get path(): string { + if (path === undefined) throw notLoggedIn() + return path + }, + get loaded(): boolean { + return doc !== undefined + }, + get fingerprint(): string { + return bytes === undefined ? '' : fingerprintOf(bytes) + }, + op: (id: string) => doc?.ops[id], + opOrThrow: (id: string) => { + const op = doc?.ops[id] + if (op !== undefined) return op + throw new BaseError({ + code: ErrorCode.UnknownOp, + message: `unknown op "${id}"`, + hint: `run ${BINARY} ops`, + }) + }, + ops: () => doc?.ops ?? {}, + replace: async (newBytes: Uint8Array) => { + if (path === undefined) throw notLoggedIn() + const parsed = parseCatalog(newBytes) + await writeAtomically(path, newBytes) + bytes = newBytes + doc = parsed + }, + clear: async () => { + if (path === undefined) throw notLoggedIn() + try { + await fsp.unlink(path) + } catch (err) { + if (!isMissingFile(err)) throw err + } + bytes = undefined + doc = undefined + }, + } + }, +}) diff --git a/cli/src/plugins/catalog/types.test.ts b/cli/src/plugins/catalog/types.test.ts new file mode 100644 index 00000000000..1286436fbff --- /dev/null +++ b/cli/src/plugins/catalog/types.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, it } from 'vite-plus/test' +import { parseCatalog } from './types' + +const FIXTURE = readFileSync(join(__dirname, '../../../test/fixtures/catalog.json')) + +it('parses the real catalog and defaults examples to []', () => { + const doc = parseCatalog(FIXTURE) + expect(Object.keys(doc.ops).length).toBeGreaterThan(20) + expect(doc.ops['run.console_app.workflow']?.kind).toBe('sse') + expect(doc.ops['run.console_app.workflow']?.examples).toEqual([]) + expect(doc.ops['legacy_run.console_app']?.deprecated).toBe(true) + expect(doc.ops['switch.workspace']?.internal).toBe(true) +}) + +it('accepts kinds and binds it has never heard of', () => { + const bytes = new TextEncoder().encode( + JSON.stringify({ + ops: { + 'x.y': { + summary: '', + method: 'GET', + path: '/x', + kind: 'hologram', + input: { type: 'object' }, + bind: { a: 'header' }, + internal: false, + deprecated: false, + }, + }, + }), + ) + expect(parseCatalog(bytes).ops['x.y']?.kind).toBe('hologram') +}) + +it('rejects a document without ops as catalog_unavailable', () => { + expect(() => parseCatalog(new TextEncoder().encode('{"nope":1}'))).toThrow( + expect.objectContaining({ code: 'catalog_unavailable' }), + ) +}) diff --git a/cli/src/plugins/catalog/types.ts b/cli/src/plugins/catalog/types.ts new file mode 100644 index 00000000000..e154372415f --- /dev/null +++ b/cli/src/plugins/catalog/types.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' + +export const CATALOG_PATH = '/openapi/v1/_catalog' +export const CATALOG_HEADER = 'X-Dify-Catalog' + +export type JsonSchema = Record<string, unknown> +export type Example = Readonly<{ title: string; input: Record<string, unknown> }> +export type CatalogOp = Readonly<{ + summary: string + method: string + path: string + kind: string + input: JsonSchema + bind: Readonly<Record<string, string>> + internal: boolean + deprecated: boolean + examples: readonly Example[] +}> +export type CatalogDoc = Readonly<{ ops: Readonly<Record<string, CatalogOp>> }> + +const EXAMPLE_SCHEMA = z.object({ title: z.string(), input: z.record(z.string(), z.unknown()) }) + +// kind and bind values are unrestricted here: unknown kinds/binds are a forward-compat +// contract, not a parse error. +const CATALOG_OP_SCHEMA = z + .object({ + summary: z.string(), + method: z.string(), + path: z.string(), + kind: z.string(), + input: z.record(z.string(), z.unknown()), + bind: z.record(z.string(), z.string()), + internal: z.boolean(), + deprecated: z.boolean(), + examples: z.array(EXAMPLE_SCHEMA).default([]), + }) + .catchall(z.unknown()) + +const CATALOG_DOC_SCHEMA = z.object({ ops: z.record(z.string(), CATALOG_OP_SCHEMA) }) + +export function parseCatalog(bytes: Uint8Array): CatalogDoc { + let raw: unknown + try { + raw = JSON.parse(new TextDecoder().decode(bytes)) + } catch (cause) { + throw new BaseError({ + code: ErrorCode.CatalogUnavailable, + message: 'catalog document is not valid JSON', + cause, + }) + } + const result = CATALOG_DOC_SCHEMA.safeParse(raw) + if (!result.success) { + throw new BaseError({ + code: ErrorCode.CatalogUnavailable, + message: `catalog document failed validation: ${result.error.issues.map((issue) => issue.message).join('; ')}`, + }) + } + return result.data as CatalogDoc +} diff --git a/cli/src/plugins/commands/cancel.ts b/cli/src/plugins/commands/cancel.ts new file mode 100644 index 00000000000..e4a4701f717 --- /dev/null +++ b/cli/src/plugins/commands/cancel.ts @@ -0,0 +1,19 @@ +// One controller for the whole invocation: SIGINT aborts it and every in-flight +// request built by `call` carries its signal. It lives here rather than in main.ts +// so a command can import it without pulling the entry module into a cycle. +const runController = new AbortController() +let cancelled = false + +export function cancel(): void { + cancelled = true + runController.abort() +} + +/** For the waits that poll rather than carry a signal, such as the device flow. */ +export function isCancelled(): boolean { + return cancelled +} + +export function runSignal(): AbortSignal { + return runController.signal +} diff --git a/cli/src/framework/command-fs.test.ts b/cli/src/plugins/commands/command-fs.test.ts similarity index 100% rename from cli/src/framework/command-fs.test.ts rename to cli/src/plugins/commands/command-fs.test.ts diff --git a/cli/src/framework/command-fs.ts b/cli/src/plugins/commands/command-fs.ts similarity index 100% rename from cli/src/framework/command-fs.ts rename to cli/src/plugins/commands/command-fs.ts diff --git a/cli/src/plugins/commands/command.ts b/cli/src/plugins/commands/command.ts new file mode 100644 index 00000000000..b590fde155d --- /dev/null +++ b/cli/src/plugins/commands/command.ts @@ -0,0 +1,103 @@ +import type { Descriptor, OpFacets } from './describe' +import type { ExitCodeValue } from '@/errors/codes' +import type { CommandContext } from '@/plugins/base' +import type { Example, JsonSchema } from '@/plugins/catalog' +import type { Printable } from '@/sys/io/view' +import { z } from 'zod' +import { validateInput } from '@/call/validate' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { inputSchema } from '@/plugins/argv/parse' +import { COMMAND_SEPARATOR } from '@/protocol/op-id' +import { BINARY } from '@/version/info' +import { commandRow } from './describe' + +export type { Descriptor, OpFacets } from './describe' + +export type CommandEffect = 'read' | 'write' | 'destructive' + +export const HELP_WORD = 'help' + +/** Points at the help for `words`, or at the map when nothing is named. */ +export function helpHint(...words: readonly string[]): string { + return ['run', BINARY, HELP_WORD, ...words].join(COMMAND_SEPARATOR) +} + +/** Returned by a command that already wrote its output or needs a non-zero exit. */ +export class Outcome { + readonly code: ExitCodeValue + readonly body?: Printable + + constructor(code: ExitCodeValue, body?: Printable) { + this.code = code + this.body = body + } +} + +export type CommandResult = Printable | Outcome | undefined + +export type HelpArgs = { + readonly path: readonly string[] + readonly rest: readonly string[] + readonly ctx: CommandContext +} + +export abstract class Command<S extends z.ZodObject = z.ZodObject> { + static summary = '' + static effect: CommandEffect | undefined = 'read' + static input: z.ZodObject = z.object({}) + static positional: readonly string[] = [] + static examples: readonly Example[] = [] + static hidden?: boolean + + static schema(this: CommandConstructor): JsonSchema { + return inputSchema(this.input) + } + + static flags(this: CommandConstructor): JsonSchema { + return this.schema() + } + + static facets(): Partial<OpFacets> { + return {} + } + + // The one input gate. parseArgv only coerces what was typed; zod owns the declared + // defaults, so reaching its parse means a zod rejection is a schema/parser contract bug. + static finalize( + this: CommandConstructor, + input: Record<string, unknown>, + path: readonly string[], + ): Record<string, unknown> { + const schema = this.schema() + const details = validateInput(schema, input) + if (details.length > 0) { + throw new BaseError({ + code: ErrorCode.InputInvalid, + message: `invalid input for "${path.join(COMMAND_SEPARATOR)}"`, + hint: helpHint(...path), + details: [...details], + schema, + }) + } + const parsed = this.input.safeParse(input) + if (!parsed.success) { + throw new BaseError({ + code: ErrorCode.Unknown, + message: parsed.error.issues.map((issue) => issue.message).join('; '), + cause: parsed.error, + }) + } + return parsed.data + } + + static async help(this: CommandConstructor, args: HelpArgs): Promise<Descriptor> { + return { ...commandRow(this, args.path), ...this.facets() } + } + + abstract run(input: z.infer<S>, ctx: CommandContext): Promise<CommandResult> +} + +// `Omit` drops the abstract construct signature (and the generic `S` with it), so the +// registry can hold and instantiate any concrete subclass while keeping the statics. +export type CommandConstructor = Omit<typeof Command, 'prototype'> & (new () => Command) diff --git a/cli/src/plugins/commands/describe.test.ts b/cli/src/plugins/commands/describe.test.ts new file mode 100644 index 00000000000..1a8b392606b --- /dev/null +++ b/cli/src/plugins/commands/describe.test.ts @@ -0,0 +1,54 @@ +import { expect, it } from 'vite-plus/test' +import { z } from 'zod' +import { Command } from './command' +import { commandRow } from './describe' + +const INPUT = z.object({ id: z.string().describe('The id'), dry_run: z.boolean().default(false) }) + +class Fake extends Command<typeof INPUT> { + static override summary = 'Do a thing' + static override effect = 'write' as const + static override input = INPUT + static override positional = ['id'] as const + static override examples = [{ title: 'Basic', input: { id: 'x' } }] + async run() { + return undefined + } +} + +it('renders the row from the statics with a JSON Schema input', () => { + const row = commandRow(Fake, ['fake', 'thing']) + expect(row).toMatchObject({ + id: 'fake thing', + usage: 'difyctl fake thing <id> [flags]', + summary: 'Do a thing', + effect: 'write', + positional: ['id'], + examples: [{ title: 'Basic', input: { id: 'x' } }], + }) + expect(row.input).toMatchObject({ + type: 'object', + required: ['id'], + properties: { + id: { type: 'string', description: 'The id' }, + dry_run: { type: 'boolean', default: false }, + }, + }) +}) + +class OverridesSchema extends Command { + static override summary = 'Custom schema' + static override schema() { + return { type: 'object', properties: { x: { type: 'string' } } } + } + + async run() { + return undefined + } +} + +it('renders the row from a static schema() override', () => { + const row = commandRow(OverridesSchema, ['custom']) + expect(row.input).toEqual({ type: 'object', properties: { x: { type: 'string' } } }) + expect(row.usage).toMatch(/\[flags\]$/) +}) diff --git a/cli/src/plugins/commands/describe.ts b/cli/src/plugins/commands/describe.ts new file mode 100644 index 00000000000..f274878e30d --- /dev/null +++ b/cli/src/plugins/commands/describe.ts @@ -0,0 +1,44 @@ +import type { CommandConstructor, CommandEffect } from './command' +import type { Example, JsonSchema } from '@/plugins/catalog' +import { BINARY } from '@/version/info' + +export type CommandRow = { + id: string + usage: string + summary: string + effect?: CommandEffect + input: JsonSchema + positional: readonly string[] + examples: readonly Example[] +} + +// The op side of a descriptor: present once a command wraps a catalog op instead of a +// hand-written zod schema. +export type OpFacets = { + op: string + method: string + path: string + kind: string + bind: Readonly<Record<string, string>> + deprecated: boolean + options?: JsonSchema + pins?: Record<string, string | null> +} + +export type Descriptor = CommandRow & Partial<OpFacets> + +const FLAGS = '[flags]' + +export function commandRow(ctor: CommandConstructor, path: readonly string[]): CommandRow { + const id = path.join(' ') + const args = ctor.positional.map((name) => `<${name}>`) + const row: CommandRow = { + id, + usage: [BINARY, id, ...args, FLAGS].join(' '), + summary: ctor.summary, + input: ctor.schema(), + positional: ctor.positional, + examples: ctor.examples, + } + return ctor.effect === undefined ? row : { ...row, effect: ctor.effect } +} diff --git a/cli/src/plugins/commands/help.test.ts b/cli/src/plugins/commands/help.test.ts new file mode 100644 index 00000000000..fcac90ac680 --- /dev/null +++ b/cli/src/plugins/commands/help.test.ts @@ -0,0 +1,102 @@ +import type { CommandTree } from './registry' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, it } from 'vite-plus/test' +import { commandTree } from '@/commands/tree' +import { parseCatalog } from '@/plugins/catalog' +import { opsTree } from '@/plugins/ops/tree' +import { colorScheme } from '@/sys/io/color' +import { entriesOf, helpListing, helpMap, helpSearch, pointer, spacedWords } from './help' +import { mergeTrees } from './registry' + +const OPS = parseCatalog(readFileSync(join(__dirname, '../../../test/fixtures/catalog.json'))).ops +const tree: CommandTree = mergeTrees(commandTree, opsTree(OPS)).tree +const entries = entriesOf(tree) +const ids = entries.map((entry) => entry.id) +const plain = colorScheme(false) + +it('entries are one list of statics and ops, hidden commands left out', () => { + expect(ids).toContain('use workspace') + expect(ids).toContain('run console_app workflow') + expect(ids).not.toContain('switch workspace') // internal + expect(entries.find((entry) => entry.id === 'run console_app chat')).toEqual({ + id: 'run console_app chat', + summary: 'Run a chat or agent app; streams message events', + effect: 'write', + kind: 'sse', + deprecated: false, + }) + expect(entries.find((entry) => entry.id === 'version')).toEqual({ + id: 'version', + summary: expect.any(String), + effect: 'read', + }) +}) + +it('the map is one tree: leaves carry a summary, groups a count and sub-groups', () => { + const map = helpMap(entries) + expect(map.login).toMatchObject({ summary: expect.any(String), count: 1, groups: [] }) + expect(map.run).toMatchObject({ + count: expect.any(Number), + groups: expect.arrayContaining(['console_app']), + }) + expect(map.run?.summary).toBeUndefined() + expect(map.get?.groups).toEqual(expect.arrayContaining(['workspace'])) + expect(map.get?.count).toBe(ids.filter((id) => id.startsWith('get ')).length) + expect(map.call).toBeUndefined() + expect(map.ops).toBeUndefined() +}) + +it('a listing works on a namespace of any depth and nowhere else', () => { + expect(helpListing('run console_app', entries)?.entries.map((entry) => entry.id)).toEqual([ + 'run console_app advanced_chat', + 'run console_app chat', + 'run console_app completion', + 'run console_app workflow', + ]) + const exports = helpListing('export', entries) + expect(exports?.entries.map((entry) => entry.id)).toContain('export console_app dsl') + expect(exports?.total).toBe(exports?.entries.length) + expect(helpListing('version', entries)).toBeUndefined() // a leaf is not a namespace + expect(helpListing('zebra', entries)).toBeUndefined() +}) + +it('every command the tree hides comes back with includeHidden', () => { + const all = entriesOf(tree, { includeHidden: true }).map((entry) => entry.id) + expect(all).toContain('switch workspace') // internal op + expect(all).toContain('use workspace') // and everything the default already listed +}) + +it('search ranks the plain words against ids, summaries, fields and examples', () => { + expect(helpSearch(['chatbot'], tree).entries[0]).toMatchObject({ + id: 'run console_app chat', + kind: 'sse', + }) + // `attachments` is a field name only: it appears in no id, summary or example title. + const byField = helpSearch(['attachments'], tree).entries.map((entry) => entry.id) + expect(byField).toContain('run console_app workflow') + expect(helpSearch(['zebra'], tree)).toEqual({ entries: [], total: 0 }) + expect(helpSearch(['switch'], tree).entries.map((entry) => entry.id)).not.toContain( + 'switch workspace', + ) + expect( + helpSearch(['switch'], tree, { includeHidden: true }).entries.map((entry) => entry.id), + ).toContain('switch workspace') +}) + +it('a single dotted word is the spaced path typed as one word', () => { + expect(spacedWords(['run.console_app.workflow'])).toEqual(['run', 'console_app', 'workflow']) + expect(spacedWords(['get', 'console_app'])).toEqual(['get', 'console_app']) + expect(spacedWords(['upload.file', 'now'])).toEqual(['upload.file', 'now']) + expect(spacedWords([])).toEqual([]) +}) + +it('the pointer names help and the skill in both forms', () => { + const pointed = pointer() + expect(pointed.json).toEqual({ + message: 'difyctl has no built-in business commands; every server operation is a command', + help: 'difyctl help', + skill: 'difyctl install skills <dir>', + }) + expect(pointed.text(plain).split('\n')).toEqual(Object.values(pointed.json)) +}) diff --git a/cli/src/plugins/commands/help.ts b/cli/src/plugins/commands/help.ts new file mode 100644 index 00000000000..cc82074f654 --- /dev/null +++ b/cli/src/plugins/commands/help.ts @@ -0,0 +1,127 @@ +import type { CommandConstructor, CommandEffect } from './command' +import type { CollectOptions, CommandTree } from './registry' +import type { SearchDoc } from '@/discovery/search' +import type { View } from '@/sys/io/view' +import { search, searchDoc } from '@/discovery/search' +import { byHead, groupsOf, namespacesOf, under } from '@/discovery/tree' +import { COMMAND_SEPARATOR, OP_SEPARATOR, spacedId } from '@/protocol/op-id' +import { view } from '@/sys/io/view' +import { BINARY } from '@/version/info' +import { HELP_WORD } from './command' +import { collectCommands } from './registry' + +const SEARCH_LIMIT = 20 +const NEWLINE = '\n' +const SKILL_WORDS = 'install skills <dir>' +const POINTER_MESSAGE = `${BINARY} has no built-in business commands; every server operation is a command` + +export type HelpEntry = { + id: string + summary: string + effect?: CommandEffect + kind?: string + deprecated?: boolean +} + +export type HelpListing = { entries: HelpEntry[]; total: number } +export type HelpHead = { summary?: string; count: number; groups: string[] } +export type HelpMap = Record<string, HelpHead> + +/** Bare `difyctl`: where to look, without a request. */ +export function pointer(): View<Record<string, string>> { + const json = { + message: POINTER_MESSAGE, + help: [BINARY, HELP_WORD].join(COMMAND_SEPARATOR), + skill: [BINARY, SKILL_WORDS].join(COMMAND_SEPARATOR), + } + return view(json, () => Object.values(json).join(NEWLINE)) +} + +/** `help run.console_app.workflow` names the command its spaced words name. */ +export function spacedWords(words: readonly string[]): readonly string[] { + const [only] = words + if (words.length !== 1 || only === undefined || !only.includes(OP_SEPARATOR)) return words + return spacedId(only).split(COMMAND_SEPARATOR) +} + +function entryOf(ctor: CommandConstructor, id: string): HelpEntry { + const entry: HelpEntry = { id, summary: ctor.summary } + if (ctor.effect !== undefined) entry.effect = ctor.effect + const { deprecated, kind } = ctor.facets() + if (kind !== undefined) entry.kind = kind + if (deprecated !== undefined) entry.deprecated = deprecated + return entry +} + +// `schema()` is the documented input, so an adapter is indexed on the op's own fields +// rather than on the parse schema, which carries call options every op would match. +function docOf(ctor: CommandConstructor, id: string): SearchDoc { + return searchDoc( + id, + { summary: ctor.summary, input: ctor.schema(), examples: ctor.examples }, + ctor.facets().deprecated === true, + ) +} + +/** Every command the tree lists, static or catalog op, as one flat list. */ +export function entriesOf(tree: CommandTree, opts?: CollectOptions): HelpEntry[] { + return collectCommands(tree, opts).map(({ command, path }) => + entryOf(command, path.join(COMMAND_SEPARATOR)), + ) +} + +function byId(a: HelpEntry, b: HelpEntry): number { + return a.id < b.id ? -1 : 1 +} + +export function helpMap(entries: readonly HelpEntry[]): HelpMap { + const leaves = new Map(entries.map((entry) => [entry.id, entry])) + const map: HelpMap = {} + for (const [head, rest] of byHead( + entries.map((entry) => entry.id), + COMMAND_SEPARATOR, + )) { + const self = leaves.get(head) + const count = rest.length + (self === undefined ? 0 : 1) + map[head] = + self !== undefined && count === 1 + ? { summary: self.summary, count, groups: [] } + : { count, groups: groupsOf(rest, COMMAND_SEPARATOR) } + } + return map +} + +export function helpListing( + namespace: string, + entries: readonly HelpEntry[], +): HelpListing | undefined { + const ids = entries.map((entry) => entry.id) + if (!namespacesOf(ids, COMMAND_SEPARATOR).has(namespace)) return undefined + const wanted = new Set(under(namespace, ids, COMMAND_SEPARATOR)) + const found = entries.filter((entry) => wanted.has(entry.id)).sort(byId) + return { entries: found, total: found.length } +} + +// Ranked over everything a command declares — id, summary, field names and descriptions, +// example titles — so the words a task is described in reach the fields that take them. +export function helpSearch( + words: readonly string[], + tree: CommandTree, + opts?: CollectOptions, +): HelpListing { + const byKey = new Map<string, HelpEntry>() + const docs: SearchDoc[] = [] + for (const { command, path } of collectCommands(tree, opts)) { + const id = path.join(COMMAND_SEPARATOR) + byKey.set(id, entryOf(command, id)) + docs.push(docOf(command, id)) + } + const result = search(docs, words.join(COMMAND_SEPARATOR), { limit: SEARCH_LIMIT }) + return { + entries: result.hits.flatMap((hit) => { + const entry = byKey.get(hit.id) + return entry === undefined ? [] : [entry] + }), + total: result.total, + } +} diff --git a/cli/src/plugins/commands/index.test.ts b/cli/src/plugins/commands/index.test.ts new file mode 100644 index 00000000000..e1fec9546b2 --- /dev/null +++ b/cli/src/plugins/commands/index.test.ts @@ -0,0 +1,333 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import type { CommandTree } from '@/plugins/commands/registry' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { z } from 'zod' +import { parseCatalog } from '@/plugins/catalog' +import { Command } from '@/plugins/commands/command' +import { propertiesOf } from '@/protocol/shape' +import { commands, runPipeline } from './index' + +// What the mock serves by default, so an op's descriptor and its errors can be compared. +const FIXTURE_OPS = parseCatalog( + readFileSync(join(__dirname, '../../../test/fixtures/catalog.json')), +).ops + +const worlds: TestWorld[] = [] +async function world(login: boolean, argv: string[]) { + const w = await testContext({ login, argv }) + worlds.push(w) + return w +} +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +it('bare difyctl prints a pointer to help and the skill, and makes no request', async () => { + const w = await world(true, []) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(w.io.outBuf())).toEqual({ + message: 'difyctl has no built-in business commands; every server operation is a command', + help: 'difyctl help', + skill: 'difyctl install skills <dir>', + }) + expect(w.mock.requestCount).toBe(0) +}) + +it('the map is one tree: leaves carry a summary, groups a count and sub-groups', async () => { + const w = await world(true, ['help']) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + const map = JSON.parse(w.io.outBuf()) + expect(map.login).toMatchObject({ summary: expect.any(String), count: 1, groups: [] }) + expect(map.run).toMatchObject({ + count: expect.any(Number), + groups: expect.arrayContaining(['console_app']), + }) + expect(map.get.groups).toEqual(expect.arrayContaining(['workspace'])) + expect(map.call).toBeUndefined() + expect(map.ops).toBeUndefined() + expect(w.io.outBuf()).toMatch(/\n {2}"login"/) + expect(w.mock.requestCount).toBe(1) // the catalog fetch, nothing else +}) + +it('on a terminal the map is text, not JSON', async () => { + const w = await testContext({ login: true, argv: ['help'], tty: true }) + worlds.push(w) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + const out = w.io.outBuf() + expect(out).toContain('console_app') + expect(out).not.toMatch(/[{}]/) +}) + +it('the map with no server known is the static half and says so on stderr', async () => { + const w = await world(false, ['help']) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + const map = JSON.parse(w.io.outBuf()) + expect(map.version).toMatchObject({ count: 1 }) + expect(map.console_app).toBeUndefined() + expect(w.io.errBuf()).toMatch(/log in to list server operations/) + expect(w.mock.requestCount).toBe(0) +}) + +it('the map still prints the static half when the catalog cannot be fetched', async () => { + const w = await world(true, ['help']) + w.mock.setScenario('no-catalog') + expect(await (await w.ctx.get(commands)).run()).toBe(0) + const map = JSON.parse(w.io.outBuf()) + expect(map.version).toMatchObject({ count: 1 }) + expect(map.console_app).toBeUndefined() + expect(w.io.errBuf().trim().split('\n')).toHaveLength(1) + expect(w.io.errBuf()).toMatch(/^could not list server operations: failed to fetch the catalog: /) +}) + +it('help accepts a dotted id and prints the same descriptor as the spaced path', async () => { + const a = await world(true, ['help', 'run.console_app.workflow']) + const b = await world(true, ['run', 'console_app', 'workflow', '--help']) + await (await a.ctx.get(commands)).run() + await (await b.ctx.get(commands)).run() + expect(JSON.parse(a.io.outBuf())).toEqual(JSON.parse(b.io.outBuf())) + expect(JSON.parse(a.io.outBuf())).toMatchObject({ + id: 'run console_app workflow', + op: 'run.console_app.workflow', + kind: 'sse', + }) +}) + +it('a namespace without a command lists what is under it, with or without the help word', async () => { + const a = await world(true, ['run']) + expect(await (await a.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(a.io.outBuf()).entries.map((e: { id: string }) => e.id)).toContain( + 'run console_app workflow', + ) + + const b = await world(true, ['help', 'run', 'console_app']) + expect(await (await b.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(b.io.outBuf()).entries.map((e: { id: string }) => e.id)).toEqual([ + 'run console_app advanced_chat', + 'run console_app chat', + 'run console_app completion', + 'run console_app workflow', + ]) +}) + +it('an internal op stays out of the listing, comes back under --all, and always resolves', async () => { + const a = await world(true, ['help', 'workspace']) + expect(await (await a.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(a.io.outBuf()).entries.map((e: { id: string }) => e.id)).not.toContain( + 'switch workspace', + ) + + const b = await world(true, ['help', 'workspace', '--all']) + expect(await (await b.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(b.io.outBuf()).entries.map((e: { id: string }) => e.id)).toContain( + 'switch workspace', + ) + + const c = await world(true, ['switch', 'workspace', '--help']) + expect(await (await c.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(c.io.outBuf())).toMatchObject({ + id: 'switch workspace', + op: 'switch.workspace', + }) +}) + +it('search reaches a word that only a field name carries', async () => { + const w = await world(true, ['help', 'attachments']) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + const found = JSON.parse(w.io.outBuf()) + expect(found.total).toBeGreaterThan(0) + expect(found.entries.map((e: { id: string }) => e.id)).toContain('run console_app workflow') +}) + +it('help routes a word to a namespace listing or a search, and --full is the flat list', async () => { + const c = await world(true, ['help', 'get']) + await (await c.ctx.get(commands)).run() + const ids = JSON.parse(c.io.outBuf()).entries.map((e: { id: string }) => e.id) + expect(ids).toContain('get skills') + expect(ids).toContain('get workspace member') + expect(ids).toEqual([...ids].sort()) + + const d = await world(true, ['help', 'chatbot']) + await (await d.ctx.get(commands)).run() + expect(JSON.parse(d.io.outBuf()).entries[0]).toMatchObject({ + id: 'run console_app chat', + kind: 'sse', + }) + const e = await world(true, ['help', 'zebra']) + await (await e.ctx.get(commands)).run() + expect(JSON.parse(e.io.outBuf())).toEqual({ entries: [], total: 0 }) + expect(e.io.errBuf().trim()).toBe('nothing matched; run difyctl help for the map') + + const f = await world(true, ['help', '--full']) + await (await f.ctx.get(commands)).run() + const full = JSON.parse(f.io.outBuf()) + expect(full.commands[0]).toHaveProperty('usage') + expect(full.commands.map((row: { id: string }) => row.id)).toContain('run console_app workflow') +}) + +it('help on a command prints its row; --help anywhere and the help word both work', async () => { + const a = await world(false, ['version', '--help']) + await (await a.ctx.get(commands)).run() + expect(JSON.parse(a.io.outBuf())).toMatchObject({ + id: 'version', + usage: expect.stringContaining('difyctl version'), + }) + const b = await world(false, ['help', 'version']) + await (await b.ctx.get(commands)).run() + expect(JSON.parse(b.io.outBuf()).id).toBe('version') +}) + +it('unknown command is exit 2 with a suggestion', async () => { + const w = await world(false, ['versoin']) + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'usage_invalid_flag', + hint: expect.stringContaining('version'), + }) +}) + +it('an unknown flag is exit 2', async () => { + const w = await world(false, ['version', '--bogus']) + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'usage_invalid_flag', + }) +}) + +const FAKE_INPUT = z.object({ name: z.string().optional(), dry_run: z.boolean().default(false) }) + +class Fake extends Command<typeof FAKE_INPUT> { + static override input = FAKE_INPUT + async run(input: z.infer<typeof FAKE_INPUT>) { + return input + } +} + +const fakeTree: CommandTree = { fake: { command: Fake, subcommands: {} } } + +it('applies the schema defaults before run and prints the result indented', async () => { + const w = await world(false, ['fake']) + expect(await runPipeline(fakeTree, w.ctx)).toBe(0) + expect(w.io.outBuf()).toBe('{\n "dry_run": false\n}\n') +}) + +it('takes --verbose anywhere and keeps it out of the command input', async () => { + const a = await world(false, ['version', '--verbose']) + expect(await (await a.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(a.io.outBuf()).client.version).toBe('0.0.0-test') + const b = await world(false, ['--verbose', 'version']) + expect(await (await b.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(b.io.outBuf()).client.version).toBe('0.0.0-test') + const c = await world(false, ['nope', '--verbose']) + await expect((await c.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'usage_invalid_flag', + }) +}) + +it('the map reuses a cached catalog instead of fetching again', async () => { + const w = await world(true, ['help']) + await (await w.ctx.get(commands)).run() + expect(w.mock.requestCount).toBe(1) + const reusing = await testContext({ login: true, argv: ['help'], reuseDirOf: w }) + worlds.push(reusing) + await (await reusing.ctx.get(commands)).run() + expect(JSON.parse(reusing.io.outBuf()).get.count).toBeGreaterThan(3) + expect(reusing.mock.requestCount).toBe(1) +}) + +it('a catalog op resolves as a spaced command and takes its fields as flags', async () => { + const w = await world(true, ['get', 'console_app', '--limit', '2']) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + expect(w.mock.lastRequest?.path).toContain('limit=2') +}) + +it('a command word may be spelled with dashes where the op id has underscores', async () => { + const w = await world(true, ['get', 'console-app']) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + expect(w.mock.lastRequest?.path).toBe('/openapi/v1/apps?workspace_id=ws-1') + + const help = await world(true, ['get', 'console-app', '--help']) + expect(await (await help.ctx.get(commands)).run()).toBe(0) + expect(JSON.parse(help.io.outBuf())).toMatchObject({ + id: 'get console_app', + usage: 'difyctl get console_app [flags]', + }) +}) + +it('an unknown path refetches the catalog once before giving up', async () => { + const w = await world(true, ['get', 'nope']) + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'usage_invalid_flag', + message: 'unknown command: get nope', + hint: 'run difyctl help', + }) + expect(w.mock.requestCount).toBe(2) // the cached catalog, then one refetch + + const typo = await world(true, ['get', 'consol_app']) + await expect((await typo.ctx.get(commands)).run()).rejects.toMatchObject({ + hint: 'did you mean: get console_app', + }) +}) + +it('a known namespace and a help view answer from the cached catalog, with no refetch', async () => { + const a = await world(true, ['run']) + expect(await (await a.ctx.get(commands)).run()).toBe(0) + expect(a.mock.requestCount).toBe(1) + + const b = await world(true, ['help', 'workspace']) + expect(await (await b.ctx.get(commands)).run()).toBe(0) + expect(b.mock.requestCount).toBe(1) +}) + +it("an op's invalid input carries the op's own schema, not the parse-only one", async () => { + const w = await world(true, ['get', 'console_app', '--limit', '500']) + const expected = FIXTURE_OPS['get.console_app']?.input + expect(expected).toMatchObject({ required: ['workspace_id'] }) + expect(propertiesOf(expected ?? {})).not.toHaveProperty('input') + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'input_invalid', + hint: 'run difyctl help get console_app', + schema: expected, + }) +}) + +it('an unreachable catalog costs a notice, not the static suggestion', async () => { + const w = await world(true, ['versoin']) + w.mock.setScenario('no-catalog') + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'usage_invalid_flag', + hint: 'did you mean: version', + }) + expect(w.io.errBuf().trim().split('\n')).toHaveLength(1) + expect(w.io.errBuf()).toMatch(/^could not list server operations: failed to fetch the catalog: /) +}) + +it('without a login only statics resolve and the hint says to log in', async () => { + const w = await world(false, ['get', 'console_app']) + await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'usage_invalid_flag', + message: 'unknown command: get console_app', + hint: 'log in to use server operations', + }) + expect(w.mock.requestCount).toBe(0) +}) + +it('a static command resolves without loading the catalog', async () => { + const w = await world(true, ['version']) + expect(await (await w.ctx.get(commands)).run()).toBe(0) + expect(w.mock.requestCount).toBe(1) + expect(w.mock.lastRequest?.path).toBe('/openapi/v1/_version') +}) + +it('invalid input points at the help for the path that failed', async () => { + const a = await world(false, ['use', 'workspace']) + await expect((await a.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'input_invalid', + hint: 'run difyctl help use workspace', + }) + const b = await world(true, ['describe', 'console_app']) + await expect((await b.ctx.get(commands)).run()).rejects.toMatchObject({ + code: 'input_invalid', + hint: 'run difyctl help describe console_app', + }) +}) diff --git a/cli/src/plugins/commands/index.ts b/cli/src/plugins/commands/index.ts new file mode 100644 index 00000000000..f6abcbf5431 --- /dev/null +++ b/cli/src/plugins/commands/index.ts @@ -0,0 +1,254 @@ +import type { Descriptor } from './describe' +import type { CommandContext } from '@/plugins/base' +import type { CollectOptions, CommandTree, ResolvedCommand } from '@/plugins/commands/registry' +import type { IOService } from '@/plugins/io' +import type { OpsService, TreeOptions } from '@/plugins/ops' +import { commandTree } from '@/commands/tree' +import { BaseError } from '@/errors/base' +import { ErrorCode, ExitCode } from '@/errors/codes' +import { errorMessage } from '@/errors/message' +import { definePlugin } from '@/kernel/plugin' +import { parseArgv } from '@/plugins/argv/parse' +import { BASE_PLUGINS } from '@/plugins/base' +import { HELP_WORD, helpHint, Outcome } from '@/plugins/commands/command' +import { + entriesOf, + helpListing, + helpMap, + helpSearch, + pointer, + spacedWords, +} from '@/plugins/commands/help' +import { + collectCommands, + findSuggestions, + mergeTrees, + nodeAt, + resolveCommand, +} from '@/plugins/commands/registry' +import { descriptorsView, descriptorView, listingView, mapView } from '@/plugins/commands/text' +import { globalFlags } from '@/plugins/global-flags' +import { io } from '@/plugins/io' +import { ops } from '@/plugins/ops' +import { session } from '@/plugins/session' +import { COMMAND_SEPARATOR } from '@/protocol/op-id' + +export type CommandsService = { + readonly run: () => Promise<number> +} + +const HELP_FLAGS: readonly string[] = ['--help', '-h'] +const FULL_FLAG = '--full' +const ALL_FLAG = '--all' +const FLAG_PREFIX = '-' +const NO_SERVER_NOTICE = 'log in to list server operations' +const NO_SERVER_HINT = 'log in to use server operations' +const OPS_UNAVAILABLE_PREFIX = 'could not list server operations: ' +const NO_MATCH_NOTICE = `nothing matched; ${helpHint()} for the map` +const NO_REST: readonly string[] = [] +// Resolution walks every op the server publishes; `hidden` keeps the internal ones +// out of listings, not out of the tree. +const EVERY_OP: TreeOptions = { includeInternal: true, fresh: false } +const EVERY_OP_FRESH: TreeOptions = { ...EVERY_OP, fresh: true } + +function isWord(token: string): boolean { + return !token.startsWith(FLAG_PREFIX) +} + +function withoutHelpWord(argv: readonly string[]): readonly string[] { + const at = argv.findIndex(isWord) + return at === -1 ? argv : [...argv.slice(0, at), ...argv.slice(at + 1)] +} + +// Everything after the last word the tree matched — by position, so a flag written +// before the command words does not shift the slice. +function restAfterPath(tokens: readonly string[], pathLength: number): readonly string[] { + let matched = 0 + for (const [index, token] of tokens.entries()) { + if (!isWord(token)) continue + matched += 1 + if (matched === pathLength) return tokens.slice(index + 1) + } + return [] +} + +function leadingWords(tokens: readonly string[]): number { + const at = tokens.findIndex((token) => !isWord(token)) + return at === -1 ? tokens.length : at +} + +// An unreachable server must not cost the caller the static half of help. +async function fromOps<T>( + ctx: CommandContext, + streams: IOService, + load: (service: OpsService) => Promise<T>, +): Promise<T | undefined> { + const login = await (await ctx.get(session)).current() + if (login === null) { + streams.notice(NO_SERVER_NOTICE) + return undefined + } + try { + return await load(await ctx.get(ops)) + } catch (err) { + streams.notice(`${OPS_UNAVAILABLE_PREFIX}${errorMessage(err)}`) + return undefined + } +} + +function unknownCommand(tree: CommandTree, words: readonly string[], fallback: string): BaseError { + const suggestions = findSuggestions(tree, [...words]) + return new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `unknown command: ${words.join(COMMAND_SEPARATOR)}`, + hint: suggestions.length > 0 ? `did you mean: ${suggestions.join(', ')}` : fallback, + }) +} + +type Discovery = Readonly<{ + tree: CommandTree + path: readonly string[] + full: boolean + listing: CollectOptions + wantsHelp: boolean + missHint?: string + ctx: CommandContext + streams: IOService +}> + +// Words no command claimed: the map, a namespace listing, a search — or, when help +// was not asked for, the error that names the miss. +async function discover(args: Discovery): Promise<number> { + const { ctx, listing, path, streams, tree } = args + if (path.length === 0 && args.full) { + // One at a time: a descriptor may read the session, and that file takes a lock. + const rows: Descriptor[] = [] + for (const found of collectCommands(tree, listing)) + rows.push(await found.command.help({ path: found.path, rest: NO_REST, ctx })) + await streams.document(descriptorsView(rows)) + return ExitCode.Success + } + + const entries = entriesOf(tree, listing) + if (path.length === 0) { + await streams.document(mapView(helpMap(entries))) + return ExitCode.Success + } + + const under = helpListing(path.join(COMMAND_SEPARATOR), entries) + if (under !== undefined) { + await streams.document(listingView(under)) + return ExitCode.Success + } + if (!args.wantsHelp) throw unknownCommand(tree, path, args.missHint ?? helpHint()) + + const found = helpSearch(path, tree, listing) + if (found.total === 0) streams.notice(NO_MATCH_NOTICE) + await streams.document(listingView(found)) + return ExitCode.Success +} + +type Resolution = { + readonly resolved: ResolvedCommand | undefined + readonly tree: CommandTree + /** Set only when a miss has a better explanation than the generic help hint. */ + readonly missHint?: string +} + +type Walk = Readonly<{ + tree: CommandTree + path: readonly string[] + wantsHelp: boolean + ctx: CommandContext + streams: IOService +}> + +// The refetch exists for one case: a path the cached tree does not know at all may name +// an op the server added since. A known group, the bare map and every help view are +// answered from the cache, so they cost one request. +function answerable(walk: Walk, cached: CommandTree): boolean { + return walk.wantsHelp || walk.path.length === 0 || nodeAt(cached, walk.path) !== undefined +} + +// Statics resolve without touching the catalog. A miss walks the server's ops as +// commands too: the cached catalog first, then one refetch in case the op is newer +// than the cache. A server that cannot answer costs the caller nothing but a notice — +// resolution falls back to the widest tree it did get, which is also the tree help +// lists, so the catalog is loaded at most once per run. +async function resolveWithOps(walk: Walk): Promise<Resolution> { + const { ctx, streams, tree } = walk + const path = [...walk.path] + const direct = resolveCommand(tree, path) + if (direct !== undefined) return { resolved: direct, tree } + + // A help view explains a missing catalog on stderr; a plain miss gets the hint instead. + if (!walk.wantsHelp && (await (await ctx.get(session)).current()) === null) + return { resolved: undefined, tree, missHint: NO_SERVER_HINT } + + const known = await fromOps(ctx, streams, (service) => service.commands(EVERY_OP)) + if (known === undefined) return { resolved: undefined, tree } + const cached = mergeTrees(tree, known).tree + const fromCache = resolveCommand(cached, path) + if (fromCache !== undefined || answerable(walk, cached)) + return { resolved: fromCache, tree: cached } + + const refetched = await fromOps(ctx, streams, (service) => service.commands(EVERY_OP_FRESH)) + if (refetched === undefined) return { resolved: undefined, tree: cached } + const fresh = mergeTrees(tree, refetched).tree + return { resolved: resolveCommand(fresh, path), tree: fresh } +} + +export async function runPipeline(tree: CommandTree, ctx: CommandContext): Promise<number> { + const streams = await ctx.get(io) + const argv = (await ctx.get(globalFlags)).rest + if (argv.length === 0) { + await streams.document(pointer()) + return ExitCode.Success + } + + const words = argv.filter(isWord) + const byHelpWord = words[0] === HELP_WORD + const tokens = byHelpWord ? withoutHelpWord(argv) : argv + const path = byHelpWord ? spacedWords(words.slice(1)) : words + const wantsHelp = + words.length === 0 || byHelpWord || argv.some((token) => HELP_FLAGS.includes(token)) + const walked = await resolveWithOps({ tree, path, wantsHelp, ctx, streams }) + const resolved = walked.resolved + const rest = resolved === undefined ? [] : restAfterPath(tokens, resolved.path.length) + if ( + resolved === undefined || + (wantsHelp && leadingWords(rest) > resolved.command.positional.length) + ) + return discover({ + tree: walked.tree, + path, + full: argv.includes(FULL_FLAG), + listing: { includeHidden: argv.includes(ALL_FLAG) }, + wantsHelp, + missHint: walked.missHint, + ctx, + streams, + }) + + const Ctor = resolved.command + if (wantsHelp) { + await streams.document(descriptorView(await Ctor.help({ path: resolved.path, rest, ctx }))) + return ExitCode.Success + } + + const input = parseArgv(rest, { positional: Ctor.positional, schema: Ctor.flags() }) + const out = await new Ctor().run(Ctor.finalize(input, resolved.path), ctx) + if (out instanceof Outcome) { + if (out.body !== undefined) await streams.document(out.body) + return out.code + } + if (out === undefined) return ExitCode.Success + await streams.document(out) + return ExitCode.Success +} + +export const commands = definePlugin({ + name: 'commands', + needs: BASE_PLUGINS, + build: (ctx): CommandsService => ({ run: () => runPipeline(commandTree, ctx) }), +}) diff --git a/cli/src/framework/registry.test.ts b/cli/src/plugins/commands/registry.test.ts similarity index 88% rename from cli/src/framework/registry.test.ts rename to cli/src/plugins/commands/registry.test.ts index 49002259d7a..477f67a1387 100644 --- a/cli/src/framework/registry.test.ts +++ b/cli/src/plugins/commands/registry.test.ts @@ -1,19 +1,27 @@ import type { CommandTree } from './registry' import { describe, expect, it } from 'vite-plus/test' import { Command } from './command' -import { findSuggestions, resolveCommand } from './registry' +import { findSuggestions, mergeTrees, resolveCommand } from './registry' class FooCmd extends Command { - async run(_argv: string[]) {} + async run() { + return undefined + } } class FooBarCmd extends Command { - async run(_argv: string[]) {} + async run() { + return undefined + } } class FooBazCmd extends Command { - async run(_argv: string[]) {} + async run() { + return undefined + } } class TopLevelCmd extends Command { - async run(_argv: string[]) {} + async run() { + return undefined + } } const tree: CommandTree = { @@ -93,6 +101,32 @@ describe('resolveCommand', () => { }) }) +describe('mergeTrees', () => { + const added: CommandTree = { + foo: { + subcommands: { + bar: { command: FooCmd, subcommands: {} }, + extra: { command: FooCmd, subcommands: {} }, + }, + }, + fresh: { command: FooCmd, subcommands: {} }, + } + + it('adds what is new, keeps the static on a collision and reports it', () => { + const { tree: merged, shadowed } = mergeTrees(tree, added) + expect(merged.foo?.subcommands.bar?.command).toBe(FooBarCmd) + expect(merged.foo?.subcommands.extra?.command).toBe(FooCmd) + expect(merged.fresh?.command).toBe(FooCmd) + expect(shadowed).toEqual(['foo.bar']) + }) + + it('leaves the base tree untouched', () => { + mergeTrees(tree, added) + expect(Object.keys(tree.foo?.subcommands ?? {})).toEqual(['bar', 'baz']) + expect(tree.fresh).toBeUndefined() + }) +}) + describe('findSuggestions', () => { it('returns empty for token with edit distance > 1 to all commands', () => { const suggestions = findSuggestions(tree, ['xyz']) @@ -233,11 +267,15 @@ describe('findSuggestions — cross-namespace fallback', () => { describe('findSuggestions — hidden commands', () => { class Visible extends Command { - async run(_argv: string[]) {} + async run() { + return undefined + } } class Hidden extends Command { - static hidden = true - async run(_argv: string[]) {} + static override hidden = true + async run() { + return undefined + } } const hiddenTree: CommandTree = { status: { command: Visible, subcommands: {} }, diff --git a/cli/src/framework/registry.ts b/cli/src/plugins/commands/registry.ts similarity index 64% rename from cli/src/framework/registry.ts rename to cli/src/plugins/commands/registry.ts index 19897d83b66..aee8a4bc5cb 100644 --- a/cli/src/framework/registry.ts +++ b/cli/src/plugins/commands/registry.ts @@ -1,4 +1,7 @@ import type { CommandConstructor } from './command' +import { dottedId } from '@/protocol/op-id' +import { editDistance } from '@/util/edit-distance' +import { propertyFor } from '@/util/flag-name' export type CommandNode = { readonly command?: CommandConstructor @@ -7,32 +10,55 @@ export type CommandNode = { export type CommandTree = Record<string, CommandNode> +export type ResolvedCommand = { command: CommandConstructor; path: string[] } + +export type MergedTree = { tree: CommandTree; shadowed: string[] } + +/** `hidden` keeps a command out of listings; a help view may ask for it back. */ +export type CollectOptions = Readonly<{ includeHidden: boolean }> + +const LISTED_ONLY: CollectOptions = { includeHidden: false } + function buildPath(parts: string[]): string { return parts.join(' ') } -export function resolveCommand( - tree: CommandTree, - argv: string[], -): { command: CommandConstructor; path: string[] } | undefined { +// A command word may be typed with dashes where the id spells underscores; the path +// keeps the key it matched, so what is echoed back is the canonical spelling. +function childOf( + level: Record<string, CommandNode>, + token: string, +): { key: string; node: CommandNode } | undefined { + for (const key of [token, propertyFor(token)]) { + const node = level[key] + if (node !== undefined) return { key, node } + } + return undefined +} + +export function resolveCommand(tree: CommandTree, argv: string[]): ResolvedCommand | undefined { const path: string[] = [] let node: CommandNode | undefined - let lastMatch: { command: CommandConstructor; path: string[] } | undefined + let lastMatch: ResolvedCommand | undefined for (let i = 0; i < argv.length; i++) { const token = argv[i] if (token === undefined || token.startsWith('-')) break - const next = path.length === 0 ? tree[token] : node?.subcommands[token] - if (!next) break + const next = childOf(path.length === 0 ? tree : (node?.subcommands ?? {}), token) + if (next === undefined) break - node = next - path.push(token) + node = next.node + path.push(next.key) if (node.command) { lastMatch = { command: node.command, path: [...path] } const nextToken = argv[i + 1] - if (nextToken === undefined || nextToken.startsWith('-') || !(nextToken in node.subcommands)) + if ( + nextToken === undefined || + nextToken.startsWith('-') || + childOf(node.subcommands, nextToken) === undefined + ) return lastMatch } } @@ -40,30 +66,26 @@ export function resolveCommand( return lastMatch } -function editDistance(a: string, b: string): number { - const m = a.length - const n = b.length - let prev = Array.from({ length: n + 1 }, (_, j) => j) - for (let i = 1; i <= m; i++) { - const curr: number[] = [i] - for (let j = 1; j <= n; j++) { - curr[j] = - a[i - 1] === b[j - 1] - ? (prev[j - 1] ?? 0) - : 1 + Math.min(prev[j] ?? 0, curr[j - 1] ?? 0, prev[j - 1] ?? 0) - } - prev = curr +/** The node these words name, command or bare group; undefined when the tree has none. */ +export function nodeAt(tree: CommandTree, path: readonly string[]): CommandNode | undefined { + let node: CommandNode | undefined + for (const token of path) { + const next = childOf(node === undefined ? tree : node.subcommands, token) + if (next === undefined) return undefined + node = next.node } - return prev[n] ?? 0 + return node } export function collectCommands( tree: CommandTree, + opts: CollectOptions = LISTED_ONLY, ): Array<{ command: CommandConstructor; path: string[] }> { const results: Array<{ command: CommandConstructor; path: string[] }> = [] function walk(node: CommandNode, path: string[]): void { - if (node.command && node.command.hidden !== true) results.push({ command: node.command, path }) + if (node.command && (opts.includeHidden || node.command.hidden !== true)) + results.push({ command: node.command, path }) for (const [key, child] of Object.entries(node.subcommands)) walk(child, [...path, key]) } @@ -72,6 +94,33 @@ export function collectCommands( return results } +// Static wins: the op stays reachable through `call <id>` and is reported so a +// snapshot test can flag the collision. +export function mergeTrees(statics: CommandTree, added: CommandTree): MergedTree { + const shadowed: string[] = [] + + function fold(base: CommandTree, extra: CommandTree, path: readonly string[]): CommandTree { + const merged: Record<string, CommandNode> = { ...base } + for (const [key, node] of Object.entries(extra)) { + const here = [...path, key] + const existing = merged[key] + if (existing === undefined) { + merged[key] = node + continue + } + if (existing.command !== undefined && node.command !== undefined) + shadowed.push(dottedId(here)) + merged[key] = { + command: existing.command ?? node.command, + subcommands: fold(existing.subcommands, node.subcommands, here), + } + } + return merged + } + + return { tree: fold(statics, added, []), shadowed } +} + // Below MAX_SCORE a score decomposes uniquely into (integer spelling cost, // 1.5 × omitted namespaces), so scoreFallback's ambiguity guard can read // `spelling === 0` as "exact leaf, only the namespace was omitted". diff --git a/cli/src/plugins/commands/text.test.ts b/cli/src/plugins/commands/text.test.ts new file mode 100644 index 00000000000..fb4567a6248 --- /dev/null +++ b/cli/src/plugins/commands/text.test.ts @@ -0,0 +1,250 @@ +import type { Descriptor } from './describe' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, it } from 'vite-plus/test' +import { callOptionsSchema } from '@/call/flags' +import SkillsInstall from '@/commands/install/skills/index' +import { parseCatalog } from '@/plugins/catalog' +import { colorScheme } from '@/sys/io/color' +import { commandRow } from './describe' +import { helpMap } from './help' +import { descriptorView, exampleLine, fieldRows, listingView, mapView, SECTIONS } from './text' + +const OPS = parseCatalog(readFileSync(join(__dirname, '../../../test/fixtures/catalog.json'))).ops +const plain = colorScheme(false) + +function opDescriptor(id: string, path: readonly string[]): Descriptor { + const op = OPS[id] + if (op === undefined) throw new Error(`the catalog fixture has no op ${id}`) + return { + id: path.join(' '), + usage: `difyctl ${path.join(' ')} [flags]`, + summary: op.summary, + effect: 'write', + input: op.input, + positional: [], + examples: op.examples, + op: id, + method: op.method, + path: op.path, + kind: op.kind, + bind: op.bind, + deprecated: op.deprecated, + options: callOptionsSchema(op.kind), + pins: { workspace_id: 'ws-1' }, + } +} + +const workflowRun = opDescriptor('run.console_app.workflow', ['run', 'console_app', 'workflow']) +const skillsInstall: Descriptor = commandRow(SkillsInstall, ['install', 'skills']) + +it('sections are a table in one order, each skipped when it has no lines', () => { + expect(SECTIONS.map((section) => section.title)).toEqual([ + 'Usage', + 'Arguments', + 'Flags', + 'Options', + 'Global', + 'Examples', + 'Pins', + ]) + expect(descriptorView(workflowRun).text(plain)).not.toContain('Arguments') +}) + +it('renders an op descriptor with input, options, global and examples', () => { + expect(descriptorView(workflowRun).text(plain)).toMatchInlineSnapshot(` + "run console_app workflow Run a workflow app; streams workflow events write sse + + Usage + difyctl run console_app workflow [flags] + + Flags + --app-id string required + --attachments file[] optional Local files attached to the run itself (the app's + \`sys.files\`), not to a variable + --files map<string, json> optional 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 + --inputs map<string, json> required 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. + --workflow-id string optional Pin a published workflow version + --workspace-id string optional Workspace that owns the app + + Options + --input string optional Operation input as JSON, @file or @- for stdin + --stream boolean optional Print each event as it arrives + --only string[] optional Keep only these streamed events + + Global + --verbose boolean false Keep the raw server response in error envelopes + --json boolean false Print JSON even on a terminal + + Examples + # Required fields + difyctl run console_app workflow --app-id <app_id> --inputs <inputs> + + Pins + workspace_id ws-1" + `) +}) + +it('renders a static descriptor with arguments and flags', () => { + expect(descriptorView(skillsInstall).text(plain)).toMatchInlineSnapshot(` + "install skills Write skills from the collection into a skills root write + + Usage + difyctl install skills <dir> [flags] + + Arguments + <dir> string required The agent's skills root: the folder that holds one subfolder + per skill + + Flags + --skill string[] [] Skill to install (repeatable); default: the whole collection + --from string "https://github.com/langgenius/dify/tree/0000000/skills" Where the skills come from: a GitHub folder URL or a local + folder + + Global + --verbose boolean false Keep the raw server response in error envelopes + --json boolean false Print JSON even on a terminal + + Examples + # Install every skill for Claude Code + difyctl install skills ~/.claude/skills + # Install one skill for Codex + difyctl install skills ~/.codex/skills --skill difyctl" + `) +}) + +it('example lines put positionals first, scalars as --flag value, non-scalars as json', () => { + expect( + exampleLine(workflowRun, { title: 't', input: { app_id: '<app_id>', inputs: { a: 1 } } }), + ).toBe(`difyctl run console_app workflow --app-id <app_id> --inputs '{"a":1}'`) + expect( + exampleLine(skillsInstall, { + title: 't', + input: { dir: '~/.claude/skills', skill: ['difyctl'] }, + }), + ).toBe('difyctl install skills ~/.claude/skills --skill difyctl') +}) + +it('a missing positional prints as itself and a non-scalar one as json', () => { + const two: Descriptor = { + id: 'thing do', + usage: 'difyctl thing do <first> <second> [flags]', + summary: 'Do a thing', + input: { + type: 'object', + properties: { first: { type: 'string' }, second: { type: 'object' } }, + }, + positional: ['first', 'second'], + examples: [], + } + expect(exampleLine(two, { title: 't', input: { second: { a: 1 } } })).toBe( + `difyctl thing do <first> '{"a":1}'`, + ) +}) + +it('a tagless header carries no empty styled cell', () => { + const color = colorScheme(true) + const bare: Descriptor = { + id: 'thing', + usage: 'difyctl thing [flags]', + summary: 'Do a thing', + input: {}, + positional: [], + examples: [], + } + expect(descriptorView(bare).text(color).split('\n')[0]).toBe(`${color.bold('thing')} Do a thing`) +}) + +it('a boolean field is typed as its presence, not as a value', () => { + const login: Descriptor = { + id: 'login', + usage: 'difyctl login [flags]', + summary: 'Log in', + input: { type: 'object', properties: { no_browser: { type: 'boolean', default: false } } }, + positional: [], + examples: [], + } + expect(exampleLine(login, { title: 't', input: { no_browser: true } })).toBe( + 'difyctl login --no-browser', + ) + expect(exampleLine(login, { title: 't', input: { no_browser: false } })).toBe( + 'difyctl login --no-browser=false', + ) +}) + +it('a description prints its first line only', () => { + const schema = { + type: 'object', + required: ['note'], + properties: { + note: { type: 'string', description: 'What it does\n\nArgs:\n note: a leaked docstring' }, + tries: { type: 'integer', default: 3, description: 'How many times' }, + }, + } + expect(fieldRows(schema, [])).toEqual([ + { + name: 'note', + label: 'string', + presence: 'required', + description: 'What it does', + positional: false, + }, + { + name: 'tries', + label: 'integer', + presence: '3', + description: 'How many times', + positional: false, + }, + ]) +}) + +it('an op without examples prints a skeleton of its required fields', () => { + expect(workflowRun.examples).toEqual([]) + expect(descriptorView(workflowRun).text(plain)).toContain( + 'difyctl run console_app workflow --app-id <app_id> --inputs <inputs>', + ) +}) + +it('the map names each head, leaves by summary and groups by count', () => { + const map = helpMap([ + { id: 'login', summary: 'Log in to a Dify server', effect: 'write' }, + { id: 'run console_app', summary: 'Run an app', kind: 'sse', deprecated: false }, + { id: 'run console_app chat', summary: 'Run a chat app', kind: 'sse', deprecated: false }, + ]) + expect(mapView(map).text(plain)).toMatchInlineSnapshot(` + "login Log in to a Dify server + run 2 commands: console_app" + `) +}) + +it('a listing is one aligned row per entry', () => { + const listing = { + entries: [ + { + id: 'get console_app', + summary: 'List apps', + effect: 'read' as const, + kind: 'list', + deprecated: false, + }, + { + id: 'legacy_run console_app', + summary: 'Deprecated: use the mode op', + effect: 'write' as const, + kind: 'sse', + deprecated: true, + }, + ], + total: 2, + } + expect(listingView(listing).text(plain)).toMatchInlineSnapshot(` + "get console_app List apps read list + legacy_run console_app Deprecated: use the mode op write sse deprecated" + `) +}) diff --git a/cli/src/plugins/commands/text.ts b/cli/src/plugins/commands/text.ts new file mode 100644 index 00000000000..da2971863e5 --- /dev/null +++ b/cli/src/plugins/commands/text.ts @@ -0,0 +1,243 @@ +import type { CommandEffect } from './command' +import type { Descriptor } from './describe' +import type { HelpEntry, HelpHead, HelpListing, HelpMap } from './help' +import type { Example, JsonSchema } from '@/plugins/catalog' +import type { Style } from '@/sys/io/color' +import type { View } from '@/sys/io/view' +import { inputSchema } from '@/plugins/argv/parse' +import { GLOBAL_INPUT } from '@/plugins/global-flags' +import { COMMAND_SEPARATOR } from '@/protocol/op-id' +import { isRepeatable, isScalar, labelOf, propertiesOf, shapeOf } from '@/protocol/shape' +import { view } from '@/sys/io/view' +import { flagFor } from '@/util/flag-name' +import { BINARY } from '@/version/info' + +export type FieldRow = Readonly<{ + name: string + label: string + presence: string + description: string + positional: boolean +}> + +type Section = Readonly<{ title: string; lines: (d: Descriptor, style: Style) => string[] }> +type Tagged = Readonly<{ effect?: CommandEffect; kind?: string; deprecated?: boolean }> + +const PRESENCE = { Required: 'required', Optional: 'optional' } as const +const FIRST_LINE = /\r?\n/ +const PLACEHOLDER = /^<[^<>]*>$/ +const GAP = ' ' +const MAX_COLUMN_WIDTH = 60 +const WORD_SEPARATOR = ' ' +const ESCAPE = '\u001B' +const INDENT = ' ' +const NEWLINE = '\n' +const BLOCK_BREAK = '\n\n' +const LIST_SEPARATOR = ', ' +const COMMAND_NOUN = { one: 'command', many: 'commands' } as const +const DEPRECATED = 'deprecated' +const NO_PIN = '(none)' +const SKELETON_TITLE = 'Required fields' +const COMMENT = '#' +const NO_POSITIONAL: readonly string[] = [] + +function wrapped(cell: string): string[] { + if (cell.length <= MAX_COLUMN_WIDTH || cell.includes(ESCAPE)) return [cell] + const lines: string[] = [] + let line = '' + for (const word of cell.split(WORD_SEPARATOR)) { + if (line !== '' && line.length + WORD_SEPARATOR.length + word.length > MAX_COLUMN_WIDTH) { + lines.push(line) + line = word + } else { + line = line === '' ? word : `${line}${WORD_SEPARATOR}${word}` + } + } + lines.push(line) + return lines +} + +/** Pads every column but leaves the last one free, so a styled cell keeps its width. */ +function aligned(rows: readonly (readonly string[])[]): string[] { + const wrappedRows = rows.map((cells) => cells.map(wrapped)) + const widths: number[] = [] + for (const cells of wrappedRows) + cells.forEach((lines, at) => { + widths[at] = Math.max(widths[at] ?? 0, ...lines.map((line) => line.length)) + }) + return wrappedRows.flatMap((cells) => + Array.from({ length: Math.max(...cells.map((lines) => lines.length)) }, (_, row) => + cells + .map((lines, at) => (lines[row] ?? '').padEnd(widths[at] ?? 0)) + .join(GAP) + .trimEnd(), + ), + ) +} + +function firstLine(text: unknown): string { + return typeof text === 'string' ? (text.split(FIRST_LINE)[0] ?? '') : '' +} + +function presenceOf(name: string, schema: JsonSchema, property: JsonSchema): string { + if (Array.isArray(schema.required) && schema.required.includes(name)) return PRESENCE.Required + return property.default !== undefined && property.default !== null + ? JSON.stringify(property.default) + : PRESENCE.Optional +} + +export function fieldRows(schema: JsonSchema, positional: readonly string[]): FieldRow[] { + const rows = Object.entries(propertiesOf(schema)).map((entry) => ({ + name: entry[0], + label: labelOf(shapeOf(entry[1])), + presence: presenceOf(entry[0], schema, entry[1]), + description: firstLine(entry[1].description), + positional: positional.includes(entry[0]), + })) + return [...rows.filter((row) => row.positional), ...rows.filter((row) => !row.positional)] +} + +function rowsToLines(rows: readonly FieldRow[]): string[] { + return aligned( + rows.map((row) => [ + row.positional ? `<${row.name}>` : `--${flagFor(row.name)}`, + row.label, + row.presence, + row.description, + ]), + ) +} + +// A placeholder stands for a value the caller fills in, whatever the field's shape. +function valueToken(property: JsonSchema | undefined, value: unknown): string { + const placeholder = typeof value === 'string' && PLACEHOLDER.test(value) + const scalar = property !== undefined && isScalar(shapeOf(property)) + return placeholder || scalar ? String(value) : `'${JSON.stringify(value)}'` +} + +// How the field is typed: a boolean as its presence, a repeatable list as the flag +// once per item, anything else as one value. +function flagTokens(name: string, property: JsonSchema | undefined, value: unknown): string[] { + const flag = `--${flagFor(name)}` + if (value === true) return [flag] + if (value === false) return [`${flag}=false`] + if (property !== undefined && isRepeatable(shapeOf(property)) && Array.isArray(value)) + return value.flatMap((item) => [flag, String(item)]) + return [flag, valueToken(property, value)] +} + +// Positionals keep their declared order, so an example that skips one does not shift the +// rest along: the one it skipped prints as itself. +export function exampleLine(d: Descriptor, example: Example): string { + const properties = propertiesOf(d.input) + const flags = Object.keys(example.input).filter((name) => !d.positional.includes(name)) + return [ + BINARY, + d.id, + ...d.positional.map((name) => + name in example.input ? valueToken(properties[name], example.input[name]) : `<${name}>`, + ), + ...flags.flatMap((name) => flagTokens(name, properties[name], example.input[name])), + ].join(COMMAND_SEPARATOR) +} + +// A server that ships no example still gets a runnable line: the required fields, +// each standing for itself. +function examplesOf(d: Descriptor): readonly Example[] { + if (d.examples.length > 0) return d.examples + const required = Array.isArray(d.input.required) ? d.input.required.map(String) : [] + return [{ title: SKELETON_TITLE, input: Object.fromEntries(required.map((n) => [n, `<${n}>`])) }] +} + +export const SECTIONS: readonly Section[] = [ + { title: 'Usage', lines: (d) => [d.usage] }, + { + title: 'Arguments', + lines: (d) => rowsToLines(fieldRows(d.input, d.positional).filter((row) => row.positional)), + }, + { + title: 'Flags', + lines: (d) => rowsToLines(fieldRows(d.input, d.positional).filter((row) => !row.positional)), + }, + { + title: 'Options', + lines: (d) => (d.options === undefined ? [] : rowsToLines(fieldRows(d.options, NO_POSITIONAL))), + }, + { + title: 'Global', + lines: () => rowsToLines(fieldRows(inputSchema(GLOBAL_INPUT), NO_POSITIONAL)), + }, + { + title: 'Examples', + lines: (d, style) => + examplesOf(d).flatMap((example) => [ + style.dim(`${COMMENT} ${example.title}`), + exampleLine(d, example), + ]), + }, + { + title: 'Pins', + lines: (d) => aligned(Object.entries(d.pins ?? {}).map(([pin, at]) => [pin, at ?? NO_PIN])), + }, +] + +function tagsOf(tagged: Tagged): string[] { + const tags = [tagged.effect, tagged.kind, tagged.deprecated === true ? DEPRECATED : undefined] + return tags.filter((tag): tag is string => tag !== undefined) +} + +// Empty is no cell at all: a styled empty string is escape codes, not nothing. +function tagCell(tagged: Tagged, style: Style): string[] { + const tags = tagsOf(tagged) + return tags.length === 0 ? [] : [style.dim(tags.join(COMMAND_SEPARATOR))] +} + +function descriptorText(d: Descriptor, style: Style): string { + const header = [style.bold(d.id), d.summary, ...tagCell(d, style)] + const blocks = [header.join(GAP)] + for (const section of SECTIONS) { + const lines = section.lines(d, style) + if (lines.length === 0) continue + blocks.push([style.bold(section.title), ...lines.map((line) => INDENT + line)].join(NEWLINE)) + } + return blocks.join(BLOCK_BREAK) +} + +export function descriptorView(d: Descriptor): View<Descriptor> { + return view(d, (style) => descriptorText(d, style)) +} + +/** `help --full`: every descriptor, one after another. */ +export function descriptorsView(rows: readonly Descriptor[]): View<{ commands: Descriptor[] }> { + return view({ commands: [...rows] }, (style) => + rows.map((row) => descriptorText(row, style)).join(BLOCK_BREAK), + ) +} + +function headText(head: HelpHead | undefined, style: Style): string { + if (head === undefined) return '' + if (head.summary !== undefined) return head.summary + const noun = head.count === 1 ? COMMAND_NOUN.one : COMMAND_NOUN.many + const groups = head.groups.length === 0 ? '' : `: ${head.groups.join(LIST_SEPARATOR)}` + return style.dim(`${head.count} ${noun}${groups}`) +} + +export function mapView(map: HelpMap): View<HelpMap> { + return view(map, (style) => + aligned( + Object.keys(map) + .sort() + .map((head) => [head, headText(map[head], style)]), + ).join(NEWLINE), + ) +} + +function listingRow(entry: HelpEntry, style: Style): string[] { + return [entry.id, entry.summary, ...tagCell(entry, style)] +} + +export function listingView(listing: HelpListing): View<HelpListing> { + return view(listing, (style) => + aligned(listing.entries.map((entry) => listingRow(entry, style))).join(NEWLINE), + ) +} diff --git a/cli/src/plugins/config/index.test.ts b/cli/src/plugins/config/index.test.ts new file mode 100644 index 00000000000..216aa9b55fe --- /dev/null +++ b/cli/src/plugins/config/index.test.ts @@ -0,0 +1,46 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vite-plus/test' +import { Context } from '@/kernel/context' +import { config } from './index' + +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'difyctl-cfg-')) + process.env.DIFY_CONFIG_DIR = dir +}) +afterEach(() => { + delete process.env.DIFY_CONFIG_DIR +}) + +it('returns defaults and the file path when nothing is on disk', async () => { + const c = await new Context().get(config) + expect(await c.get()).toEqual({ + schema_version: 1, + http: { timeout: 30_000 }, + path: join(dir, 'config.yml'), + }) +}) + +it('set / get / unset round-trip through yaml', async () => { + const c = await new Context().get(config) + await c.set('http.timeout', '5000') + expect(await c.get('http.timeout')).toBe(5000) + await c.unset('http.timeout') + expect(await c.get('http.timeout')).toBe(30_000) +}) + +it('rejects unknown keys and wrong types with exit 2', async () => { + const c = await new Context().get(config) + await expect(c.set('nope', '1')).rejects.toMatchObject({ code: 'config_invalid_key' }) + await expect(c.set('http.timeout', 'fast')).rejects.toMatchObject({ + code: 'config_invalid_value', + }) +}) + +it('rejects a newer schema_version with exit 6', async () => { + writeFileSync(join(dir, 'config.yml'), 'schema_version: 2\n') + const c = await new Context().get(config) + await expect(c.get()).rejects.toMatchObject({ code: 'config_schema_unsupported' }) +}) diff --git a/cli/src/plugins/config/index.ts b/cli/src/plugins/config/index.ts new file mode 100644 index 00000000000..f857d3cbd34 --- /dev/null +++ b/cli/src/plugins/config/index.ts @@ -0,0 +1,156 @@ +import type { Printable } from '@/sys/io/view' +import { join } from 'node:path' +import { z } from 'zod' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { definePlugin } from '@/kernel/plugin' +import { env } from '@/plugins/env' +import { YamlStore } from '@/store/store' +import { isRecord } from '@/util/is-record' + +export const CONFIG_FILE_NAME = 'config.yml' +export const CONFIG_SCHEMA_VERSION = 1 + +export const CONFIG_SCHEMA = z + .object({ + schema_version: z.literal(1).default(1), + http: z.object({ timeout: z.number().int().positive().default(30_000) }).prefault({}), + }) + .strict() + +export type ConfigDoc = z.infer<typeof CONFIG_SCHEMA> + +export const CONFIG_KEYS = ['http.timeout'] as const +export type ConfigKey = (typeof CONFIG_KEYS)[number] + +export type ConfigService = { + readonly path: string + readonly doc: () => Promise<ConfigDoc> + readonly get: (key?: string) => Promise<Printable | undefined> + readonly set: (key: string, raw: string) => Promise<void> + readonly unset: (key: string) => Promise<void> +} + +function isConfigKey(key: string): key is ConfigKey { + return (CONFIG_KEYS as readonly string[]).includes(key) +} + +function requireConfigKey(key: string): ConfigKey { + if (!isConfigKey(key)) { + throw new BaseError({ + code: ErrorCode.ConfigInvalidKey, + message: `unknown config key "${key}"`, + hint: `known keys: ${CONFIG_KEYS.join(', ')}`, + }) + } + return key +} + +function readPath(doc: ConfigDoc, dotted: string): Printable | undefined { + return dotted.split('.').reduce<unknown>((acc, part) => { + return isRecord(acc) ? acc[part] : undefined + }, doc) as Printable | undefined +} + +function writePath(target: Record<string, unknown>, dotted: string, value: unknown): void { + const parts = dotted.split('.') + const lastKey = parts.pop() as string + let current = target + for (const part of parts) { + const next = current[part] + if (next === null || typeof next !== 'object') current[part] = {} + current = current[part] as Record<string, unknown> + } + current[lastKey] = value +} + +function deletePath(target: Record<string, unknown>, dotted: string): void { + const parts = dotted.split('.') + const lastKey = parts.pop() as string + let current: Record<string, unknown> = target + for (const part of parts) { + const next = current[part] + if (next === null || typeof next !== 'object') return + current = next as Record<string, unknown> + } + delete current[lastKey] +} + +function parseRawValue(raw: string): unknown { + try { + return JSON.parse(raw) + } catch { + return raw + } +} + +function checkSchemaVersion(raw: unknown): void { + if (!isRecord(raw)) return + const version = raw.schema_version + if (typeof version === 'number' && version > CONFIG_SCHEMA_VERSION) { + throw new BaseError({ + code: ErrorCode.ConfigSchemaUnsupported, + message: `config schema_version ${version} is newer than the supported version ${CONFIG_SCHEMA_VERSION}`, + hint: 'upgrade difyctl to read this config file', + }) + } +} + +function validateDoc(raw: unknown): ConfigDoc { + checkSchemaVersion(raw) + const result = CONFIG_SCHEMA.safeParse(raw) + if (!result.success) { + throw new BaseError({ + code: ErrorCode.ConfigInvalidValue, + message: result.error.issues.map((issue) => issue.message).join('; '), + }) + } + return result.data +} + +export const config = definePlugin({ + name: 'config', + needs: [env], + build: async (ctx): Promise<ConfigService> => { + const { configDir } = await ctx.get(env) + const path = join(configDir, CONFIG_FILE_NAME) + const store = new YamlStore(path) + + let cached: ConfigDoc | undefined + + async function doc(): Promise<ConfigDoc> { + if (cached === undefined) { + const raw = await store.getTyped<Record<string, unknown>>() + cached = validateDoc(raw ?? {}) + } + return cached + } + + async function persist(mutate: (target: Record<string, unknown>) => void): Promise<void> { + const current = structuredClone(await doc()) as Record<string, unknown> + mutate(current) + const validated = validateDoc(current) + await store.setTyped(validated) + cached = validated + } + + return { + path, + doc, + get: async (key?: string) => { + const current = await doc() + if (key === undefined) return { ...current, path } + return readPath(current, requireConfigKey(key)) + }, + set: async (key: string, raw: string) => { + const configKey = requireConfigKey(key) + const value = parseRawValue(raw) + await persist((target) => writePath(target, configKey, value)) + }, + unset: async (key: string) => { + const configKey = requireConfigKey(key) + await persist((target) => deletePath(target, configKey)) + }, + } + }, +}) diff --git a/cli/src/plugins/env/index.test.ts b/cli/src/plugins/env/index.test.ts new file mode 100644 index 00000000000..493e0150d39 --- /dev/null +++ b/cli/src/plugins/env/index.test.ts @@ -0,0 +1,29 @@ +import { afterEach, expect, it } from 'vite-plus/test' +import { Context } from '@/kernel/context' +import { env } from './index' + +const saved = { ...process.env } +afterEach(() => { + process.env = { ...saved } +}) + +it('reads and validates DIFY_* variables into a frozen object', async () => { + process.env.DIFY_SERVER = 'https://dify.example' + process.env.DIFY_TOKEN = 'dfoa_x' + process.env.DIFY_CONFIG_DIR = '/tmp/cfg' + const e = await new Context().get(env) + expect(e.server).toBe('https://dify.example') + expect(e.configDir).toBe('/tmp/cfg') + expect(Object.isFrozen(e)).toBe(true) +}) + +it('rejects a token without a server with exit 2', async () => { + delete process.env.DIFY_SERVER + process.env.DIFY_TOKEN = 'dfoa_x' + await expect(new Context().get(env)).rejects.toMatchObject({ code: 'usage_invalid_flag' }) +}) + +it('rejects a non-http server url', async () => { + process.env.DIFY_SERVER = 'ftp://x' + await expect(new Context().get(env)).rejects.toMatchObject({ code: 'usage_invalid_flag' }) +}) diff --git a/cli/src/plugins/env/index.ts b/cli/src/plugins/env/index.ts new file mode 100644 index 00000000000..7c21d43ba8e --- /dev/null +++ b/cli/src/plugins/env/index.ts @@ -0,0 +1,67 @@ +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { definePlugin } from '@/kernel/plugin' +import { resolveCacheDir, resolveConfigDir } from '@/store/dir' +import { getEnv } from '@/sys' + +export const ENV = { + Server: 'DIFY_SERVER', + Token: 'DIFY_TOKEN', + WorkspaceId: 'DIFY_WORKSPACE_ID', + ConfigDir: 'DIFY_CONFIG_DIR', + CacheDir: 'DIFY_CACHE_DIR', + Output: 'DIFY_OUTPUT', +} as const + +export type EnvService = Readonly<{ + server?: string + token?: string + workspaceId?: string + configDir: string + cacheDir: string +}> + +const HTTP_SCHEMES: readonly string[] = ['http:', 'https:'] + +function validateServer(server: string | undefined): void { + if (server === undefined) return + let url: URL + try { + url = new URL(server) + } catch (cause) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `${ENV.Server} must be a valid http(s) URL, got "${server}"`, + cause, + }) + } + if (!HTTP_SCHEMES.includes(url.protocol)) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `${ENV.Server} must use http or https, got "${server}"`, + }) + } +} + +export const env = definePlugin({ + name: 'env', + needs: [], + build: (): EnvService => { + const server = getEnv(ENV.Server) + const token = getEnv(ENV.Token) + validateServer(server) + if (server === undefined && token !== undefined) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `${ENV.Token} requires ${ENV.Server} to be set`, + }) + } + return Object.freeze({ + server, + token, + workspaceId: getEnv(ENV.WorkspaceId), + configDir: resolveConfigDir(), + cacheDir: resolveCacheDir(), + }) + }, +}) diff --git a/cli/src/plugins/global-flags/index.test.ts b/cli/src/plugins/global-flags/index.test.ts new file mode 100644 index 00000000000..ce087435440 --- /dev/null +++ b/cli/src/plugins/global-flags/index.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vite-plus/test' +import { Context } from '@/kernel/context' +import { argv } from '@/plugins/argv' +import { globalFlags } from './index' + +async function parse(tokens: readonly string[]) { + return new Context([[argv, tokens]]).get(globalFlags) +} + +it('takes --verbose anywhere, hands the command the rest, and leaves -- alone', async () => { + expect(await parse(['--verbose', 'call', 'a.b', '--input', '{}'])).toEqual({ + flags: { verbose: true, json: false }, + rest: ['call', 'a.b', '--input', '{}'], + }) + expect(await parse(['call', 'a.b', '--', '--verbose'])).toEqual({ + flags: { verbose: false, json: false }, + rest: ['call', 'a.b', '--', '--verbose'], + }) + await expect(parse(['--verbose=maybe'])).rejects.toMatchObject({ code: 'usage_invalid_flag' }) +}) diff --git a/cli/src/plugins/global-flags/index.ts b/cli/src/plugins/global-flags/index.ts new file mode 100644 index 00000000000..57a61f1faa0 --- /dev/null +++ b/cli/src/plugins/global-flags/index.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { definePlugin } from '@/kernel/plugin' +import { argv } from '@/plugins/argv' +import { inputSchema, parseArgv, partitionArgv } from '@/plugins/argv/parse' + +export const GLOBAL_INPUT = z.object({ + verbose: z.boolean().default(false).describe('Keep the raw server response in error envelopes'), + json: z.boolean().default(false).describe('Print JSON even on a terminal'), +}) + +export type GlobalFlags = z.infer<typeof GLOBAL_INPUT> + +export type GlobalFlagsService = Readonly<{ + flags: GlobalFlags + rest: readonly string[] +}> + +const NO_POSITIONALS: readonly string[] = [] + +function invalidGlobalFlags(error: z.ZodError): BaseError { + const message = error.issues + .map((issue) => `--${issue.path.join('.')}: ${issue.message}`) + .join('; ') + return new BaseError({ code: ErrorCode.UsageInvalidFlag, message, cause: error }) +} + +export const globalFlags = definePlugin({ + name: 'global-flags', + needs: [argv], + build: async (ctx): Promise<GlobalFlagsService> => { + const schema = inputSchema(GLOBAL_INPUT) + const { matched, rest } = partitionArgv(await ctx.get(argv), schema) + const parsed = GLOBAL_INPUT.safeParse( + parseArgv(matched, { positional: NO_POSITIONALS, schema }), + ) + if (!parsed.success) throw invalidGlobalFlags(parsed.error) + return Object.freeze({ flags: parsed.data, rest }) + }, +}) diff --git a/cli/src/plugins/http/index.test.ts b/cli/src/plugins/http/index.test.ts new file mode 100644 index 00000000000..b3195ee5b0f --- /dev/null +++ b/cli/src/plugins/http/index.test.ts @@ -0,0 +1,182 @@ +import type { DifyMock } from '@test/fixtures/dify-mock/server' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { startMock } from '@test/fixtures/dify-mock/server' +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' +import { Context } from '@/kernel/context' +import { catalog, CATALOG_PATH } from '@/plugins/catalog' +import { CONFIG_FILE_NAME } from '@/plugins/config' +import { http } from './index' + +const SHORT_TIMEOUT_MS = 50 + +let mock: DifyMock +let dir: string + +function writeTimeout(ms: number): void { + writeFileSync(join(dir, CONFIG_FILE_NAME), `schema_version: 1\nhttp:\n timeout: ${ms}\n`) +} +beforeEach(async () => { + mock = await startMock() + dir = mkdtempSync(join(tmpdir(), 'difyctl-http-')) + Object.assign(process.env, { + DIFY_CONFIG_DIR: dir, + DIFY_CACHE_DIR: dir, + DIFY_SERVER: mock.url, + DIFY_TOKEN: 'dfoa_test', + }) +}) +afterEach(async () => { + await mock.stop() + for (const k of ['DIFY_CONFIG_DIR', 'DIFY_CACHE_DIR', 'DIFY_SERVER', 'DIFY_TOKEN']) + delete process.env[k] +}) + +const account = () => ({ method: 'GET', path: '/openapi/v1/account' }) + +describe('http plugin', () => { + it('fetches the catalog on first use and sends bearer, fingerprint and user agent', async () => { + const ctx = new Context() + const res = await (await ctx.get(http)).request(account) + expect(res.status).toBe(200) + const c = await ctx.get(catalog) + expect(c.loaded).toBe(true) + expect(mock.lastRequest?.headers.authorization).toBe('Bearer dfoa_test') + expect(mock.lastRequest?.headers['x-dify-catalog']).toBe(c.fingerprint) + expect(mock.lastRequest?.headers['user-agent']).toMatch(/^difyctl\//) + }) + + it('on 412 refetches the catalog, rebuilds once and resends', async () => { + const ctx = new Context() + const h = await ctx.get(http) + await h.request(account) + const before = (await ctx.get(catalog)).fingerprint + mock.setScenario('catalog-changed') + let builds = 0 + const res = await h.request((cat) => { + builds++ + return { + method: 'GET', + path: '/openapi/v1/account', + headers: { 'x-build': String(cat.op('ping.workspace') !== undefined) }, + } + }) + expect(res.status).toBe(200) + expect(builds).toBe(2) + expect(mock.lastRequest?.headers['x-build']).toBe('true') + expect((await ctx.get(catalog)).fingerprint).not.toBe(before) + expect(mock.requestCount).toBe(5) // catalog, account, account(412), catalog, account + }) + + it('a second 412 is a server error, exit 1, carrying the server hint', async () => { + const h = await new Context().get(http) + await h.request(account) + mock.setScenario('always-stale') + await expect(h.request(account)).rejects.toMatchObject({ + code: 'server_error', + httpStatus: 412, + hint: expect.stringContaining('X-Dify-Catalog'), + }) + }) + + it('a 404 refetches the catalog only when the server catalog moved', async () => { + const ctx = new Context() + const h = await ctx.get(http) + await h.request(account) + const gone = () => ({ method: 'GET', path: '/openapi/v1/gone' }) + const before = (await ctx.get(catalog)).fingerprint + await expect(h.request(gone)).rejects.toMatchObject({ httpStatus: 404 }) + expect(mock.requestCount).toBe(4) // catalog, account, gone, catalog (unchanged, no resend) + expect((await ctx.get(catalog)).fingerprint).toBe(before) + mock.setScenario('catalog-changed') + await expect(h.request(gone)).rejects.toMatchObject({ httpStatus: 404 }) + expect(mock.requestCount).toBe(7) // + gone, catalog, gone + expect((await ctx.get(catalog)).fingerprint).not.toBe(before) + }) + + it('maps 401 to auth_expired and 429 to rate_limited', async () => { + const h = await new Context().get(http) + mock.setScenario('auth-expired') + await expect(h.request(account)).rejects.toMatchObject({ code: 'auth_expired' }) + mock.setScenario('rate-limited') + await expect(h.request(account)).rejects.toMatchObject({ code: 'rate_limited' }) + }) + + it('surfaces 5xx as server_error with the server body and details', async () => { + const h = await new Context().get(http) + mock.setScenario('server-5xx') + await expect(h.request(account)).rejects.toMatchObject({ + code: 'server_error', + httpStatus: 503, + serverError: { code: 'server_5xx' }, + }) + }) + + it('fails with catalog_unavailable when the catalog route is missing', async () => { + mock.setScenario('no-catalog') + await expect((await new Context().get(http)).request(account)).rejects.toMatchObject({ + code: 'catalog_unavailable', + }) + }) + + it('an auth:false request needs no catalog and sends no bearer', async () => { + const ctx = new Context() + const res = await ( + await ctx.get(http) + ).request(() => ({ method: 'GET', path: '/openapi/v1/_version', auth: false })) + expect(res.status).toBe(200) + expect((await ctx.get(catalog)).loaded).toBe(false) + expect(mock.lastRequest?.headers.authorization).toBeUndefined() + }) + + it('http.timeout bounds the headers, not the stream: a slow body still arrives whole', async () => { + writeTimeout(SHORT_TIMEOUT_MS) + mock.setScenario('slow-stream') + const res = await ( + await new Context().get(http) + ).request(() => ({ + method: 'POST', + path: '/openapi/v1/apps/app-1/chat:run', + json: { app_id: 'app-1', inputs: {}, query: 'hi' }, + })) + expect(res.status).toBe(200) + // The gap between the two writes is four times the deadline. + const body = await res.text() + expect(body).toContain('"event":"message_end"') + expect(body).toContain('"answer":"hi"') + }) + + it('a server that never sends headers within the timeout is network_connection', async () => { + writeTimeout(SHORT_TIMEOUT_MS) + await expect( + (await new Context().get(http)).request(() => ({ + method: 'GET', + path: '/hang', + auth: false, + })), + ).rejects.toMatchObject({ + code: 'network_connection', + message: expect.stringContaining(`timed out after ${SHORT_TIMEOUT_MS}ms`), + }) + }) + + it('a catalog fetch failure names the status and the catalog path', async () => { + mock.setScenario('no-catalog') + await expect((await new Context().get(http)).request(account)).rejects.toMatchObject({ + code: 'catalog_unavailable', + message: `failed to fetch the catalog: HTTP 404 from ${CATALOG_PATH}`, + hint: expect.stringContaining('refresh cache'), + }) + }) + + it('an error with no body is named by its status', async () => { + await expect( + (await new Context().get(http)).request(() => ({ + method: 'GET', + path: '/empty-error', + auth: false, + })), + ).rejects.toMatchObject({ code: 'server_error', message: 'HTTP 503' }) + }) +}) diff --git a/cli/src/plugins/http/index.ts b/cli/src/plugins/http/index.ts new file mode 100644 index 00000000000..1b19931889a --- /dev/null +++ b/cli/src/plugins/http/index.ts @@ -0,0 +1,251 @@ +import type { ServerErrorBody } from '@/errors/base' +import type { ErrorCodeValue } from '@/errors/codes' +import type { CatalogService } from '@/plugins/catalog' +import { BaseError, HttpClientError, LOGIN_HINT, unknownError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { errorMessage } from '@/errors/message' +import { definePlugin } from '@/kernel/plugin' +import { buildFetchInit } from '@/net/fetch-init' +import { proxyDispatcher } from '@/net/proxy' +import { catalog, CATALOG_HEADER, CATALOG_PATH, fingerprintOf } from '@/plugins/catalog' +import { config } from '@/plugins/config' +import { env } from '@/plugins/env' +import { session } from '@/plugins/session' +import { token } from '@/plugins/token' +import { userAgent } from '@/version/info' + +export type HttpRequest = Readonly<{ + method: string + path: string + query?: Readonly<Record<string, string | readonly string[]>> + json?: unknown + form?: FormData + headers?: Readonly<Record<string, string>> + signal?: AbortSignal + auth?: boolean +}> +export type RequestBuilder = (catalog: CatalogService) => HttpRequest | Promise<HttpRequest> +export type HttpService = { + readonly request: (build: RequestBuilder) => Promise<Response> + readonly fetchCatalog: () => Promise<Uint8Array> +} + +const HttpStatus = { + Unauthorized: 401, + Forbidden: 403, + TooManyRequests: 429, + NotFound: 404, + PreconditionFailed: 412, +} as const + +const CATALOG_STALE_CODE = 'catalog_stale' +const CATALOG_UNAVAILABLE_PREFIX = 'failed to fetch the catalog: ' +const CATALOG_UNAVAILABLE_HINT = 'check the server URL or run difyctl refresh cache' + +// The catalog fetch is the CLI's first request, so its failure has to name itself: +// a rejected status for the catalog route, or whatever the network said. +function catalogFailure(cause: unknown): string { + if (cause instanceof HttpClientError && cause.httpStatus !== undefined) + return `HTTP ${cause.httpStatus} from ${CATALOG_PATH}` + return errorMessage(cause) +} + +type StatusMapping = Readonly<{ code: ErrorCodeValue; hint?: string }> + +const STATUS_MAPPINGS: Readonly<Record<number, StatusMapping>> = { + [HttpStatus.Unauthorized]: { code: ErrorCode.AuthExpired, hint: LOGIN_HINT }, + [HttpStatus.Forbidden]: { code: ErrorCode.AccessDenied, hint: LOGIN_HINT }, + [HttpStatus.TooManyRequests]: { code: ErrorCode.RateLimited }, +} + +function classifyStatus(res: Response): StatusMapping { + return STATUS_MAPPINGS[res.status] ?? { code: ErrorCode.ServerError } +} + +function parseServerErrorBody(raw: string): ServerErrorBody | undefined { + if (raw === '') return undefined + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return undefined + } + if (parsed === null || typeof parsed !== 'object') return undefined + const record = parsed as Record<string, unknown> + if (record.code !== undefined && typeof record.code !== 'string') return undefined + const message = typeof record.message === 'string' ? record.message : record.error + if (typeof message !== 'string') return undefined + return { ...record, message } as ServerErrorBody +} + +function appendQuery(url: URL, query: HttpRequest['query']): void { + if (query === undefined) return + for (const [key, value] of Object.entries(query)) { + for (const v of Array.isArray(value) ? value : [value]) url.searchParams.append(key, v) + } +} + +function bodyAndContentType(req: HttpRequest): { + body?: RequestInit['body'] + contentType?: string +} { + if (req.form !== undefined) return { body: req.form } + if (req.json !== undefined) + return { body: JSON.stringify(req.json), contentType: 'application/json' } + return {} +} + +function isCatalogStale(err: unknown): err is HttpClientError { + return ( + err instanceof HttpClientError && + err.httpStatus === HttpStatus.PreconditionFailed && + err.serverError?.code === CATALOG_STALE_CODE + ) +} + +// A route the server dropped answers 404 before any catalog guard runs, so a 404 is +// the one rejection that can hide a stale catalog. +function isRouteMissing(err: unknown): err is HttpClientError { + return err instanceof HttpClientError && err.httpStatus === HttpStatus.NotFound +} + +// Resolves the request to send for a not-yet-loaded catalog: `build` runs once to see +// whether the caller needs auth at all (auth:false skips the catalog fetch entirely). +// A builder that resolves an op id against the empty catalog raises UnknownOp on that +// first run; that is expected here, so the catalog is fetched and `build` runs again. +async function resolveRequest( + build: RequestBuilder, + catalogService: CatalogService, + fetchCatalog: () => Promise<Uint8Array>, +): Promise<HttpRequest> { + if (catalogService.loaded) return build(catalogService) + try { + const first = await build(catalogService) + if (first.auth === false) return first + } catch (err) { + if (!(err instanceof BaseError) || err.code !== ErrorCode.UnknownOp) throw err + } + await catalogService.replace(await fetchCatalog()) + return build(catalogService) +} + +export const http = definePlugin({ + name: 'http', + needs: [env, config, session, token, catalog], + build: async (ctx): Promise<HttpService> => { + const configService = await ctx.get(config) + const sessionService = await ctx.get(session) + const tokenService = await ctx.get(token) + const catalogService = await ctx.get(catalog) + + const login = await sessionService.require() + const dispatcher = login.insecure ? proxyDispatcher({ insecure: true }) : proxyDispatcher() + if (dispatcher !== undefined) ctx.defer(() => dispatcher.close()) + + async function send(req: HttpRequest): Promise<Response> { + const url = new URL(req.path, login.server) + appendQuery(url, req.query) + + const headers = new Headers(req.headers) + headers.set('User-Agent', userAgent()) + headers.set('Accept', 'application/json') + if (req.auth !== false) { + headers.set('Authorization', `Bearer ${await tokenService.get()}`) + headers.set(CATALOG_HEADER, catalogService.fingerprint) + } + const { body, contentType } = bodyAndContentType(req) + if (contentType !== undefined) headers.set('Content-Type', contentType) + + // http.timeout is a deadline for the response headers, not for the whole + // exchange: an SSE run streams for as long as the server keeps it open, so the + // timer is cleared the moment `fetch` resolves and only `req.signal` still + // governs the body read. + const timeoutMs = (await configService.doc()).http.timeout + const deadline = new AbortController() + let deadlineExpired = false + const timer = setTimeout(() => { + deadlineExpired = true + deadline.abort() + }, timeoutMs) + const signal = AbortSignal.any( + req.signal === undefined ? [deadline.signal] : [deadline.signal, req.signal], + ) + + const init = buildFetchInit( + { method: req.method, headers, body, signal }, + { insecure: login.insecure, dispatcher }, + ) + + let res: Response + try { + res = await fetch(url, init) + } catch (cause) { + throw new BaseError({ + code: ErrorCode.NetworkConnection, + message: deadlineExpired + ? `${req.method} ${url} timed out after ${timeoutMs}ms` + : `network error calling ${req.method} ${url}`, + cause, + }) + } finally { + clearTimeout(timer) + } + if (res.ok) return res + + const rawResponse = await res.text() + const serverError = parseServerErrorBody(rawResponse) + const { code, hint } = classifyStatus(res) + throw new HttpClientError({ + code, + message: serverError?.message ?? `HTTP ${res.status}`, + hint: hint ?? serverError?.hint, + httpStatus: res.status, + method: req.method, + url: url.toString(), + rawResponse, + serverError, + }) + } + + const fetchCatalog: HttpService['fetchCatalog'] = async () => { + try { + const res = await send({ method: 'GET', path: CATALOG_PATH, auth: false }) + const bytes = new Uint8Array(await res.arrayBuffer()) + if (bytes.length === 0) throw unknownError('catalog response was empty') + return bytes + } catch (cause) { + throw new BaseError({ + code: ErrorCode.CatalogUnavailable, + message: `${CATALOG_UNAVAILABLE_PREFIX}${catalogFailure(cause)}`, + hint: CATALOG_UNAVAILABLE_HINT, + cause, + }) + } + } + + const catalogMoved = async (): Promise<boolean> => { + let bytes: Uint8Array + try { + bytes = await fetchCatalog() + } catch { + return false + } + if (fingerprintOf(bytes) === catalogService.fingerprint) return false + await catalogService.replace(bytes) + return true + } + + const request: HttpService['request'] = async (build) => { + const req = await resolveRequest(build, catalogService, fetchCatalog) + try { + return await send(req) + } catch (err) { + if (isCatalogStale(err)) await catalogService.replace(await fetchCatalog()) + else if (!(isRouteMissing(err) && (await catalogMoved()))) throw err + return await send(await build(catalogService)) + } + } + + return { request, fetchCatalog } + }, +}) diff --git a/cli/src/plugins/io/index.test.ts b/cli/src/plugins/io/index.test.ts new file mode 100644 index 00000000000..460cfcaf722 --- /dev/null +++ b/cli/src/plugins/io/index.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from 'vite-plus/test' +import { OutputMode } from '@/plugins/output' +import { bufferStreams } from '@/sys/io/streams' +import { view } from '@/sys/io/view' +import { Channel, ioService } from './index' + +const both = (mode: 'json' | 'text') => ({ out: mode, err: mode }) as const + +it('emits the text form on a text channel and json elsewhere', async () => { + const text = ioService(bufferStreams(), both(OutputMode.Text)) + await text.emit( + view({ a: 1 }, () => 'A is one'), + Channel.Out, + ) + expect(text.streams.outBuf()).toBe('A is one\n') + + const json = ioService(bufferStreams(), both(OutputMode.Json)) + await json.emit( + view({ a: 1 }, () => 'A is one'), + Channel.Out, + ) + expect(JSON.parse(json.streams.outBuf())).toEqual({ a: 1 }) +}) + +it('a plain printable is json in every mode', async () => { + const text = ioService(bufferStreams(), both(OutputMode.Text)) + await text.emit({ a: 1 }, Channel.Err) + expect(JSON.parse(text.streams.errBuf())).toEqual({ a: 1 }) +}) + +it('document is emit on stdout', async () => { + const text = ioService(bufferStreams(), both(OutputMode.Text)) + await text.document(view([1], () => 'one')) + expect(text.streams.outBuf()).toBe('one\n') +}) diff --git a/cli/src/plugins/io/index.ts b/cli/src/plugins/io/index.ts new file mode 100644 index 00000000000..b94ae77258f --- /dev/null +++ b/cli/src/plugins/io/index.ts @@ -0,0 +1,82 @@ +import type { Buffer } from 'node:buffer' +import type { OutputService } from '@/plugins/output' +import type { Style } from '@/sys/io/color' +import type { IOStreams } from '@/sys/io/streams' +import type { Printable, View } from '@/sys/io/view' +import { definePlugin } from '@/kernel/plugin' +import { output, OutputMode } from '@/plugins/output' +import { env } from '@/sys' +import { colorEnabled, colorScheme } from '@/sys/io/color' +import { realStreams } from '@/sys/io/streams' +import { isView } from '@/sys/io/view' + +export const Channel = { Out: 'out', Err: 'err' } as const +export type ChannelValue = (typeof Channel)[keyof typeof Channel] + +export type IOService<S extends IOStreams = IOStreams> = Readonly<{ + document: (value: Printable | View) => Promise<void> + emit: (value: Printable | View, channel: ChannelValue) => Promise<void> + notice: (text: string) => void + raw: (chunk: string | Buffer) => Promise<void> + streams: S +}> + +const JSON_INDENT = 2 +const NEWLINE = '\n' + +// Every mode field defaults to json so existing single-argument callers (results, +// streams) keep their pre-view behaviour: a plain Printable is always json anyway. +const JSON_ONLY: OutputService = Object.freeze({ out: OutputMode.Json, err: OutputMode.Json }) + +const CHANNEL_STREAM: Readonly<Record<ChannelValue, (s: IOStreams) => NodeJS.WritableStream>> = { + [Channel.Out]: (s) => s.out, + [Channel.Err]: (s) => s.err, +} +const CHANNEL_TTY: Readonly<Record<ChannelValue, (s: IOStreams) => boolean>> = { + [Channel.Out]: (s) => s.isOutTTY, + [Channel.Err]: (s) => s.isErrTTY, +} + +// The callback form reports a reader that closed the pipe as a rejection, so +// `printEnvelope` can end the run at exit 0 instead of the write failing silently. +function writeChunk(stream: NodeJS.WritableStream, chunk: string | Buffer): Promise<void> { + return new Promise((resolve, reject) => { + stream.write(chunk, (err) => (err ? reject(err) : resolve())) + }) +} + +export function ioService<S extends IOStreams>( + streams: S, + mode: OutputService = JSON_ONLY, +): IOService<S> { + const styles: Readonly<Record<ChannelValue, Style>> = { + [Channel.Out]: colorScheme( + mode.out === OutputMode.Text && colorEnabled(CHANNEL_TTY[Channel.Out](streams), env()), + ), + [Channel.Err]: colorScheme( + mode.err === OutputMode.Text && colorEnabled(CHANNEL_TTY[Channel.Err](streams), env()), + ), + } + const emit: IOService['emit'] = (value, channel) => { + const stream = CHANNEL_STREAM[channel](streams) + if (mode[channel] === OutputMode.Text && isView(value)) + return writeChunk(stream, `${value.text(styles[channel])}${NEWLINE}`) + const json = isView(value) ? value.json : value + return writeChunk(stream, `${JSON.stringify(json, null, JSON_INDENT)}${NEWLINE}`) + } + return Object.freeze({ + document: (value) => emit(value, Channel.Out), + emit, + notice: (text) => { + streams.err.write(`${text}${NEWLINE}`) + }, + raw: (chunk) => writeChunk(streams.out, chunk), + streams, + }) +} + +export const io = definePlugin({ + name: 'io', + needs: [output], + build: async (ctx): Promise<IOService> => ioService(realStreams(), await ctx.get(output)), +}) diff --git a/cli/src/plugins/ops/command.test.ts b/cli/src/plugins/ops/command.test.ts new file mode 100644 index 00000000000..dc03a638f66 --- /dev/null +++ b/cli/src/plugins/ops/command.test.ts @@ -0,0 +1,142 @@ +import type { TestWorld } from '@test/fixtures/kernel' +import type { CatalogOp } from '@/plugins/catalog' +import type { CommandEffect } from '@/plugins/commands/command' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { testContext } from '@test/fixtures/kernel' +import { afterEach, expect, it } from 'vite-plus/test' +import { callOptionsSchema } from '@/call/flags' +import { parseArgv } from '@/plugins/argv/parse' +import { parseCatalog } from '@/plugins/catalog' +import { propertiesOf } from '@/protocol/shape' +import { effectOf, opCommand } from './command' + +const OPS = parseCatalog( + readFileSync(join(__dirname, '../../../test/fixtures/catalog-v2.json')), +).ops + +function fixtureOp(id: string): CatalogOp { + const op = OPS[id] + if (op === undefined) throw new Error(`catalog-v2 has no op ${id}`) + return op +} + +const list = fixtureOp('get.console_app') + +const worlds: TestWorld[] = [] +async function world() { + const w = await testContext({ login: true }) + worlds.push(w) + return w +} +afterEach(async () => { + for (const w of worlds.splice(0)) await w.stop() +}) + +const NO_PATH: readonly string[] = [] + +it('presents an op through the command interface', () => { + const Ctor = opCommand('get.console_app', list) + expect(Ctor.summary).toBe(list.summary) + expect(Ctor.effect).toBe('read') + expect(Ctor.positional).toEqual([]) + expect(Ctor.examples).toEqual(list.examples) + expect(Ctor.schema()).toBe(list.input) + expect(Ctor.facets()).toMatchObject({ + op: 'get.console_app', + method: 'GET', + path: '/openapi/v1/apps', + kind: 'list', + deprecated: false, + }) + const flags = Ctor.flags() + expect(Object.keys(flags.properties as object)).toEqual( + expect.arrayContaining(['limit', 'mode', 'input']), + ) + expect(flags.required).toBeUndefined() + expect(Ctor.finalize({ limit: 5 }, NO_PATH)).toEqual({ limit: 5 }) +}) + +it('a reserved name in the catalog is dropped from the flag schema', () => { + const Ctor = opCommand('x.y', { + ...list, + input: { type: 'object', properties: { input: { type: 'string' }, ok: { type: 'string' } } }, + }) + expect(Object.keys(Ctor.flags().properties as object)).toEqual(['ok', 'input']) +}) + +it('a reserved name reaches the request body through --input', async () => { + const w = await world() + const id = 'run.console_app.workflow' + const run = fixtureOp(id) + const Ctor = opCommand(id, { + ...run, + input: { + ...run.input, + properties: { ...propertiesOf(run.input), input: { type: 'string' } }, + }, + }) + // The only `input` the parser knows is the CLI's own flag; the op's field is not there. + expect(propertiesOf(Ctor.flags()).input).toEqual(propertiesOf(callOptionsSchema(run.kind)).input) + + const typed = parseArgv(['--input', '{"app_id":"app-2","inputs":{},"input":"kept"}'], { + positional: Ctor.positional, + schema: Ctor.flags(), + }) + await new Ctor().run(Ctor.finalize(typed, NO_PATH), w.ctx) + expect(w.mock.lastRunBody).toMatchObject({ input: 'kept' }) +}) + +const EFFECTS: readonly [string, CommandEffect | undefined][] = [ + ['GET', 'read'], + ['DELETE', 'destructive'], + ['POST', 'write'], + ['patch', 'write'], + ['BREW', undefined], +] + +it.each(EFFECTS)('effect of %s is %s', (method, effect) => { + expect(effectOf(method)).toBe(effect) +}) + +it('help shows the business schema, the op facets, the call options and the pin', async () => { + const w = await world() + const row = await opCommand('get.console_app', list).help({ + path: ['get', 'console_app'], + rest: [], + ctx: w.ctx, + }) + expect(row).toMatchObject({ + id: 'get console_app', + usage: 'difyctl get console_app [flags]', + summary: list.summary, + effect: 'read', + op: 'get.console_app', + method: 'GET', + path: '/openapi/v1/apps', + kind: 'list', + deprecated: false, + pins: { workspace_id: 'ws-1' }, + }) + expect(row.input).toMatchObject({ required: ['workspace_id'] }) + expect(Object.keys((row.options as { properties: object }).properties)).toEqual(['input']) +}) + +it('runs the op from its own flags, pinning the workspace', async () => { + const w = await world() + const Ctor = opCommand('get.console_app', list) + const input = parseArgv(['--limit', '2'], { positional: Ctor.positional, schema: Ctor.flags() }) + const out = await new Ctor().run(Ctor.finalize(input, NO_PATH), w.ctx) + expect(out).toMatchObject({ code: 0 }) + expect(w.mock.lastRequest?.path).toBe('/openapi/v1/apps?limit=2&workspace_id=ws-1') +}) + +it('help omits pins for an op whose schema takes no workspace_id', async () => { + const w = await world() + const row = await opCommand('describe.account', fixtureOp('describe.account')).help({ + path: ['describe', 'account'], + rest: [], + ctx: w.ctx, + }) + expect(row).not.toHaveProperty('pins') +}) diff --git a/cli/src/plugins/ops/command.ts b/cli/src/plugins/ops/command.ts new file mode 100644 index 00000000000..9420ded7c8f --- /dev/null +++ b/cli/src/plugins/ops/command.ts @@ -0,0 +1,106 @@ +import type { CommandContext } from '@/plugins/base' +import type { CatalogOp, JsonSchema } from '@/plugins/catalog' +import type { + CommandConstructor, + CommandEffect, + Descriptor, + HelpArgs, + OpFacets, +} from '@/plugins/commands/command' +import { executeCall } from '@/call/execute' +import { CALL_FLAG, CALL_FLAG_SCHEMA, CALL_FLAGS, callOptionsSchema } from '@/call/flags' +import { Command, HELP_WORD } from '@/plugins/commands/command' +import { GLOBAL_INPUT } from '@/plugins/global-flags' +import { propertiesOf } from '@/protocol/shape' +import { pinsFor } from './pins' + +const OBJECT_SCHEMA_TYPE = 'object' + +/** + * Flag names the CLI owns: an op field by one of these names cannot be typed. The help + * word is one of them — the pipeline answers it before a command ever runs. + */ +export const RESERVED_FLAGS: readonly string[] = [ + ...Object.values(CALL_FLAG), + ...Object.keys(GLOBAL_INPUT.shape), + HELP_WORD, +] + +const METHOD_EFFECT: Readonly<Record<string, CommandEffect>> = { + GET: 'read', + HEAD: 'read', + POST: 'write', + PUT: 'write', + PATCH: 'write', + DELETE: 'destructive', +} + +export function effectOf(method: string): CommandEffect | undefined { + return METHOD_EFFECT[method.toUpperCase()] +} + +// Parse-only: the op's own fields minus the reserved names, plus the call options its +// kind accepts. `required` is left out on purpose — executeCall validates the assembled +// body against the op's real schema, which the fields alone do not have to satisfy. +function flagSchema(op: CatalogOp, options: JsonSchema): JsonSchema { + const fields = Object.entries(propertiesOf(op.input)).filter( + ([name]) => !RESERVED_FLAGS.includes(name), + ) + return { + type: OBJECT_SCHEMA_TYPE, + properties: { ...Object.fromEntries(fields), ...propertiesOf(options) }, + } +} + +export function opCommand(id: string, op: CatalogOp): CommandConstructor { + const options = callOptionsSchema(op.kind) + const parseSchema = flagSchema(op, options) + return class OpCommand extends Command { + static override summary = op.summary + static override effect = effectOf(op.method) + static override positional = [] as const + static override examples = op.examples + static override hidden = op.internal + + static override schema(): JsonSchema { + return op.input + } + + static override flags(): JsonSchema { + return parseSchema + } + + static override facets(): Partial<OpFacets> { + return { + op: id, + method: op.method, + path: op.path, + kind: op.kind, + bind: op.bind, + deprecated: op.deprecated, + options, + } + } + + // Every declared default belongs to the server's schema, so what was typed stands. + static override finalize(input: Record<string, unknown>): Record<string, unknown> { + return input + } + + static override async help(args: HelpArgs): Promise<Descriptor> { + const row = await super.help(args) + const pins = await pinsFor(op, args.ctx) + return pins === undefined ? row : { ...row, pins } + } + + // The parser only accepts what `parseSchema` declares, so the input splits cleanly: + // the CLI's own flags on one side, this op's fields on the other. + async run(input: Record<string, unknown>, ctx: CommandContext) { + const flags = CALL_FLAG_SCHEMA.parse(input) + const fields = Object.fromEntries( + Object.entries(input).filter(([key]) => !(key in CALL_FLAGS)), + ) + return executeCall({ id, op, flags, fields }, ctx) + } + } +} diff --git a/cli/src/plugins/ops/index.ts b/cli/src/plugins/ops/index.ts new file mode 100644 index 00000000000..d18efdffb20 --- /dev/null +++ b/cli/src/plugins/ops/index.ts @@ -0,0 +1,48 @@ +import type { CatalogOp, CatalogService } from '@/plugins/catalog' +import type { CommandTree } from '@/plugins/commands/registry' +import { definePlugin } from '@/kernel/plugin' +import { catalog } from '@/plugins/catalog' +import { http } from '@/plugins/http' +import { session } from '@/plugins/session' +import { opsTree } from './tree' + +export type ListOptions = Readonly<{ includeInternal: boolean }> + +export type TreeOptions = ListOptions & Readonly<{ fresh: boolean }> + +export type OpsService = { + readonly commands: (opts: TreeOptions) => Promise<CommandTree> +} + +function shown( + ops: Readonly<Record<string, CatalogOp>>, + opts: ListOptions, +): Readonly<Record<string, CatalogOp>> { + return Object.fromEntries( + Object.entries(ops).filter(([, op]) => opts.includeInternal || !op.internal), + ) +} + +export const ops = definePlugin({ + name: 'ops', + needs: [catalog, http, session], + build: async (ctx): Promise<OpsService> => { + const catalogService = await ctx.get(catalog) + const httpService = await ctx.get(http) + + async function loaded(): Promise<CatalogService> { + if (!catalogService.loaded) await catalogService.replace(await httpService.fetchCatalog()) + return catalogService + } + + async function refresh(): Promise<Readonly<Record<string, CatalogOp>>> { + await catalogService.replace(await httpService.fetchCatalog()) + return catalogService.ops() + } + + const commands: OpsService['commands'] = async (opts) => + opsTree(shown(opts.fresh ? await refresh() : (await loaded()).ops(), opts)) + + return { commands } + }, +}) diff --git a/cli/src/plugins/ops/pins.ts b/cli/src/plugins/ops/pins.ts new file mode 100644 index 00000000000..f80bcd05f5c --- /dev/null +++ b/cli/src/plugins/ops/pins.ts @@ -0,0 +1,16 @@ +import type { BuildContext } from '@/kernel/plugin' +import type { CatalogOp } from '@/plugins/catalog' +import { session } from '@/plugins/session' +import { PIN } from '@/protocol/pins' +import { propertiesOf } from '@/protocol/shape' + +type PinValues = Record<string, string | null> + +/** What the CLI would fill in from the local session, or undefined if the op takes none. */ +export async function pinsFor( + op: CatalogOp, + ctx: BuildContext<typeof session>, +): Promise<PinValues | undefined> { + if (!(PIN.Workspace in propertiesOf(op.input))) return undefined + return { [PIN.Workspace]: await (await ctx.get(session)).workspaceId() } +} diff --git a/cli/src/plugins/ops/tree.test.ts b/cli/src/plugins/ops/tree.test.ts new file mode 100644 index 00000000000..8ff95082248 --- /dev/null +++ b/cli/src/plugins/ops/tree.test.ts @@ -0,0 +1,47 @@ +import type { CatalogOp } from '@/plugins/catalog' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, it } from 'vite-plus/test' +import { commandTree } from '@/commands/tree' +import { parseCatalog } from '@/plugins/catalog' +import { mergeTrees } from '@/plugins/commands/registry' +import { spacedId } from '@/protocol/op-id' +import { opsTree } from './tree' + +const OPS = parseCatalog(readFileSync(join(__dirname, '../../../test/fixtures/catalog.json'))).ops + +function fixtureOp(id: string): CatalogOp { + const op = OPS[id] + if (op === undefined) throw new Error(`the catalog fixture has no op ${id}`) + return op +} + +it('builds a tree from dotted ids, one command per leaf', () => { + const tree = opsTree({ + 'get.workspace': fixtureOp('get.workspace'), + 'invite.workspace.member': fixtureOp('invite.workspace.member'), + }) + expect(tree.get?.subcommands.workspace?.command?.facets().op).toBe('get.workspace') + expect(tree.invite?.subcommands.workspace?.command).toBeUndefined() + expect(tree.invite?.subcommands.workspace?.subcommands.member?.command?.facets().op).toBe( + 'invite.workspace.member', + ) +}) + +// Collisions are silent at run time — the static wins and the op is only reachable by id — +// so the published catalog is checked against the real command tree here. +it('no op the catalog publishes is shadowed by a static command', () => { + const published = parseCatalog( + readFileSync(join(__dirname, '../../../test/fixtures/catalog-v2.json')), + ).ops + expect(mergeTrees(commandTree, opsTree(published)).shadowed).toEqual([]) +}) + +it('an internal op is in the tree but stays out of listings', () => { + const tree = opsTree({ 'switch.workspace': fixtureOp('switch.workspace') }) + expect(tree.switch?.subcommands.workspace?.command?.hidden).toBe(true) +}) + +it('spaces a dotted id into the words it is typed as', () => { + expect(spacedId('run.console_app.workflow')).toBe('run console_app workflow') +}) diff --git a/cli/src/plugins/ops/tree.ts b/cli/src/plugins/ops/tree.ts new file mode 100644 index 00000000000..791533dc809 --- /dev/null +++ b/cli/src/plugins/ops/tree.ts @@ -0,0 +1,22 @@ +import type { CatalogOp } from '@/plugins/catalog' +import type { CommandConstructor } from '@/plugins/commands/command' +import type { CommandTree } from '@/plugins/commands/registry' +import { OP_SEPARATOR } from '@/protocol/op-id' +import { opCommand } from './command' + +// A node is readonly once it is in a tree; building one needs a mutable view of it. +type BuildingNode = { command?: CommandConstructor; subcommands: Record<string, BuildingNode> } + +export function opsTree(ops: Readonly<Record<string, CatalogOp>>): CommandTree { + const tree: Record<string, BuildingNode> = {} + for (const [id, op] of Object.entries(ops)) { + const segments = id.split(OP_SEPARATOR) + let level = tree + for (const [index, segment] of segments.entries()) { + const node = (level[segment] ??= { subcommands: {} }) + if (index === segments.length - 1) node.command = opCommand(id, op) + level = node.subcommands + } + } + return tree +} diff --git a/cli/src/plugins/output/index.test.ts b/cli/src/plugins/output/index.test.ts new file mode 100644 index 00000000000..a1648af48a7 --- /dev/null +++ b/cli/src/plugins/output/index.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from 'vite-plus/test' +import { OutputMode, resolveOutput } from './index' + +const tty = { isOutTTY: true, isErrTTY: true } +const pipe = { isOutTTY: false, isErrTTY: false } + +it('is text on a tty and json in a pipe, per channel', () => { + expect(resolveOutput({ argv: [], env: {}, streams: tty })).toEqual({ + out: OutputMode.Text, + err: OutputMode.Text, + }) + expect(resolveOutput({ argv: [], env: {}, streams: pipe })).toEqual({ + out: OutputMode.Json, + err: OutputMode.Json, + }) + expect( + resolveOutput({ argv: [], env: {}, streams: { isOutTTY: false, isErrTTY: true } }), + ).toEqual({ out: OutputMode.Json, err: OutputMode.Text }) +}) + +it('--json and DIFY_OUTPUT=json force json on a tty; the flag wins over env', () => { + expect(resolveOutput({ argv: ['--json'], env: {}, streams: tty }).out).toBe(OutputMode.Json) + expect(resolveOutput({ argv: [], env: { DIFY_OUTPUT: 'json' }, streams: tty }).out).toBe( + OutputMode.Json, + ) + expect(resolveOutput({ argv: ['--json'], env: { DIFY_OUTPUT: 'text' }, streams: pipe }).out).toBe( + OutputMode.Json, + ) +}) + +it('an unknown DIFY_OUTPUT value is ignored, not guessed', () => { + expect(resolveOutput({ argv: [], env: { DIFY_OUTPUT: 'yaml' }, streams: pipe }).out).toBe( + OutputMode.Json, + ) + expect(resolveOutput({ argv: [], env: { DIFY_OUTPUT: 'yaml' }, streams: tty }).out).toBe( + OutputMode.Text, + ) +}) + +it('--json is only a flag before --, and honors an explicit =value', () => { + expect(resolveOutput({ argv: ['--', '--json'], env: {}, streams: tty }).out).toBe(OutputMode.Text) + expect(resolveOutput({ argv: ['--json=false'], env: {}, streams: tty }).out).toBe(OutputMode.Text) + expect(resolveOutput({ argv: ['--json=true'], env: {}, streams: pipe }).out).toBe(OutputMode.Json) +}) diff --git a/cli/src/plugins/output/index.ts b/cli/src/plugins/output/index.ts new file mode 100644 index 00000000000..89df19f286b --- /dev/null +++ b/cli/src/plugins/output/index.ts @@ -0,0 +1,51 @@ +import { definePlugin } from '@/kernel/plugin' +import { argv } from '@/plugins/argv' +import { inputSchema, parseArgv, partitionArgv } from '@/plugins/argv/parse' +import { ENV } from '@/plugins/env' +import { GLOBAL_INPUT } from '@/plugins/global-flags' +import { env as processEnv, io as processIo } from '@/sys' + +export const OutputMode = { Json: 'json', Text: 'text' } as const +export type OutputModeValue = (typeof OutputMode)[keyof typeof OutputMode] +export type OutputService = Readonly<{ out: OutputModeValue; err: OutputModeValue }> + +type TTYFlags = Readonly<{ isOutTTY: boolean; isErrTTY: boolean }> +type ResolveInput = Readonly<{ argv: readonly string[]; env: NodeJS.ProcessEnv; streams: TTYFlags }> + +const MODES: readonly string[] = Object.values(OutputMode) + +// Only the `json` field of GLOBAL_INPUT, so --json is recognized exactly as +// global-flags recognizes it (honors --, accepts --json=false/=true). +const JSON_FLAG_SCHEMA = inputSchema(GLOBAL_INPUT.pick({ json: true })) + +function jsonFlag(tokens: readonly string[]): boolean { + const { matched } = partitionArgv(tokens, JSON_FLAG_SCHEMA) + return parseArgv(matched, { positional: [], schema: JSON_FLAG_SCHEMA }).json === true +} + +function forced(input: ResolveInput): OutputModeValue | undefined { + if (jsonFlag(input.argv)) return OutputMode.Json + const fromEnv = input.env[ENV.Output] + return fromEnv !== undefined && MODES.includes(fromEnv) ? (fromEnv as OutputModeValue) : undefined +} + +function fromTTY(isTTY: boolean): OutputModeValue { + return isTTY ? OutputMode.Text : OutputMode.Json +} + +export function resolveOutput(input: ResolveInput): OutputService { + const fixed = forced(input) + return Object.freeze({ + out: fixed ?? fromTTY(input.streams.isOutTTY), + err: fixed ?? fromTTY(input.streams.isErrTTY), + }) +} + +// Reads argv tolerantly on purpose: this plugin must never throw, because io +// depends on it and io is what prints a flag error. +export const output = definePlugin({ + name: 'output', + needs: [argv], + build: async (ctx): Promise<OutputService> => + resolveOutput({ argv: await ctx.get(argv), env: processEnv(), streams: processIo() }), +}) diff --git a/cli/src/plugins/session/index.test.ts b/cli/src/plugins/session/index.test.ts new file mode 100644 index 00000000000..4e5cae8888c --- /dev/null +++ b/cli/src/plugins/session/index.test.ts @@ -0,0 +1,67 @@ +import type { Login } from './index' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vite-plus/test' +import { Context } from '@/kernel/context' +import { session } from './index' + +const LOGIN: Login = { + server: 'https://d.example', + email: 'me@x', + account: { id: 'a1', email: 'me@x', name: 'Me' }, + workspaceId: 'ws-1', + tokenStorage: 'file', + insecure: false, +} +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'difyctl-sess-')) + process.env.DIFY_CONFIG_DIR = dir +}) +afterEach(() => { + for (const k of ['DIFY_CONFIG_DIR', 'DIFY_SERVER', 'DIFY_TOKEN', 'DIFY_WORKSPACE_ID']) + delete process.env[k] +}) + +it('is null before login and require() exits 4', async () => { + const s = await new Context().get(session) + expect(await s.current()).toBeNull() + await expect(s.require()).rejects.toMatchObject({ code: 'not_logged_in' }) +}) + +it('save / current / clear round-trip', async () => { + const s = await new Context().get(session) + await s.save(LOGIN) + expect(await s.current()).toEqual(LOGIN) + await s.save({ ...LOGIN, workspaceId: 'ws-2' }) + expect(await s.workspaceId()).toBe('ws-2') + await s.clear() + expect(await s.current()).toBeNull() +}) + +it('env login wins, never touches disk, and refuses save', async () => { + process.env.DIFY_SERVER = 'https://env.example' + process.env.DIFY_TOKEN = 'dfoa_env' + process.env.DIFY_WORKSPACE_ID = 'ws-env' + const s = await new Context().get(session) + expect(s.fromEnv).toBe(true) + expect((await s.require()).server).toBe('https://env.example') + expect(await s.workspaceId()).toBe('ws-env') + await expect(s.save(LOGIN)).rejects.toMatchObject({ code: 'usage_invalid_flag' }) +}) + +it('DIFY_SERVER without a token is not a login', async () => { + process.env.DIFY_SERVER = 'https://env.example' + const s = await new Context().get(session) + expect(s.fromEnv).toBe(false) + expect(await s.current()).toBeNull() + await s.save(LOGIN) + expect(await s.current()).toEqual(LOGIN) +}) + +it('DIFY_WORKSPACE_ID overrides the pin', async () => { + await (await new Context().get(session)).save(LOGIN) + process.env.DIFY_WORKSPACE_ID = 'ws-env' + expect(await (await new Context().get(session)).workspaceId()).toBe('ws-env') +}) diff --git a/cli/src/plugins/session/index.ts b/cli/src/plugins/session/index.ts new file mode 100644 index 00000000000..ae49873108f --- /dev/null +++ b/cli/src/plugins/session/index.ts @@ -0,0 +1,133 @@ +import type { StorageMode } from '@/store/store' +import { join } from 'node:path' +import { z } from 'zod' +import { BaseError, notLoggedIn } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { definePlugin } from '@/kernel/plugin' +import { env } from '@/plugins/env' +import { STORAGE_MODES, YamlStore } from '@/store/store' + +export const LOGIN_FILE_NAME = 'login.yml' +export const LOGIN_SCHEMA_VERSION = 1 +export const ENV_LOGIN_EMAIL = 'env' + +const ACCOUNT_SCHEMA = z.object({ id: z.string(), email: z.string(), name: z.string() }) + +const LOGIN_SCHEMA = z + .object({ + schema_version: z.literal(LOGIN_SCHEMA_VERSION), + server: z.string(), + email: z.string(), + account: ACCOUNT_SCHEMA.nullable(), + workspace_id: z.string().nullable(), + token_storage: z.enum(STORAGE_MODES), + insecure: z.boolean(), + }) + .strict() + +export type Login = Readonly<{ + server: string + email: string + account: Readonly<{ id: string; email: string; name: string }> | null + workspaceId: string | null + tokenStorage: StorageMode + insecure: boolean +}> + +export type SessionService = { + readonly current: () => Promise<Login | null> + readonly require: () => Promise<Login> + readonly save: (login: Login) => Promise<void> + readonly clear: () => Promise<void> + readonly workspaceId: () => Promise<string | null> + readonly fromEnv: boolean +} + +function loginToDoc(login: Login): z.infer<typeof LOGIN_SCHEMA> { + return { + schema_version: LOGIN_SCHEMA_VERSION, + server: login.server, + email: login.email, + account: login.account, + workspace_id: login.workspaceId, + token_storage: login.tokenStorage, + insecure: login.insecure, + } +} + +function docToLogin(doc: z.infer<typeof LOGIN_SCHEMA>): Login { + return { + server: doc.server, + email: doc.email, + account: doc.account, + workspaceId: doc.workspace_id, + tokenStorage: doc.token_storage, + insecure: doc.insecure, + } +} + +export const session = definePlugin({ + name: 'session', + needs: [env], + build: async (ctx): Promise<SessionService> => { + const { server, token: envToken, workspaceId: envWorkspaceId, configDir } = await ctx.get(env) + // An env login is the pair: a server on its own only says where to log in. + const fromEnv = server !== undefined && envToken !== undefined + const path = join(configDir, LOGIN_FILE_NAME) + const store = new YamlStore(path) + + const envLogin: Login | null = fromEnv + ? { + server, + email: ENV_LOGIN_EMAIL, + account: null, + workspaceId: envWorkspaceId ?? null, + tokenStorage: 'file', + insecure: false, + } + : null + + async function readLogin(): Promise<Login | null> { + const raw = await store.getTyped<unknown>() + if (raw === null) return null + const result = LOGIN_SCHEMA.safeParse(raw) + if (!result.success) { + throw new BaseError({ + code: ErrorCode.Unknown, + message: `${path} failed validation: ${result.error.issues.map((issue) => issue.message).join('; ')}`, + }) + } + return docToLogin(result.data) + } + + const current = async (): Promise<Login | null> => envLogin ?? readLogin() + + return { + current, + require: async () => { + const login = await current() + if (login === null) throw notLoggedIn() + return login + }, + save: async (login: Login) => { + if (fromEnv) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: 'cannot save a login while DIFY_SERVER/DIFY_TOKEN are set', + }) + } + await store.setTyped(loginToDoc(login)) + }, + clear: async () => { + if (fromEnv) return + await store.rm() + }, + workspaceId: async () => { + if (envWorkspaceId !== undefined) return envWorkspaceId + const login = await current() + return login?.workspaceId ?? null + }, + fromEnv, + } + }, +}) diff --git a/cli/src/plugins/token/index.test.ts b/cli/src/plugins/token/index.test.ts new file mode 100644 index 00000000000..acfe4140725 --- /dev/null +++ b/cli/src/plugins/token/index.test.ts @@ -0,0 +1,55 @@ +import type { Login } from '@/plugins/session' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vite-plus/test' +import { Context } from '@/kernel/context' +import { session } from '@/plugins/session' +import { token } from './index' + +const LOGIN: Login = { + server: 'https://d.example', + email: 'me@x', + account: { id: 'a1', email: 'me@x', name: 'Me' }, + workspaceId: null, + tokenStorage: 'file', + insecure: false, +} +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'difyctl-tok-')) + process.env.DIFY_CONFIG_DIR = dir +}) +afterEach(() => { + for (const k of ['DIFY_CONFIG_DIR', 'DIFY_SERVER', 'DIFY_TOKEN']) delete process.env[k] +}) + +it('writes, reads and removes through the file store', async () => { + const ctx = new Context() + await (await ctx.get(session)).save(LOGIN) + const t = await ctx.get(token) + await t.write(LOGIN, 'dfoa_secret') + expect(await t.get()).toBe('dfoa_secret') + await t.remove(LOGIN) + await expect((await new Context().get(token)).get()).rejects.toMatchObject({ + code: 'not_logged_in', + }) +}) + +it('returns the env token without touching the store', async () => { + process.env.DIFY_SERVER = 'https://env.example' + process.env.DIFY_TOKEN = 'dfoa_env' + expect(await (await new Context().get(token)).get()).toBe('dfoa_env') +}) + +it('write and remove keep the cached bearer in sync on the same Context', async () => { + const ctx = new Context() + await (await ctx.get(session)).save(LOGIN) + const t = await ctx.get(token) + await t.write(LOGIN, 'dfoa_first') + expect(await t.get()).toBe('dfoa_first') + await t.write(LOGIN, 'dfoa_second') + expect(await t.get()).toBe('dfoa_second') + await t.remove(LOGIN) + await expect(t.get()).rejects.toMatchObject({ code: 'not_logged_in' }) +}) diff --git a/cli/src/plugins/token/index.ts b/cli/src/plugins/token/index.ts new file mode 100644 index 00000000000..e8fdb874be1 --- /dev/null +++ b/cli/src/plugins/token/index.ts @@ -0,0 +1,54 @@ +import type { Login } from '@/plugins/session' +import type { StorageMode } from '@/store/store' +import { notLoggedIn } from '@/errors/base' +import { definePlugin } from '@/kernel/plugin' +import { env } from '@/plugins/env' +import { session } from '@/plugins/session' +import { detectTokenStore, getTokenStore } from '@/store/manager' +import { bareHost } from '@/util/host' + +export type DetectOptions = Readonly<{ skipKeyring: boolean }> + +export type TokenService = { + readonly get: () => Promise<string> + readonly write: (login: Login, bearer: string) => Promise<void> + readonly remove: (login: Login) => Promise<void> + readonly detect: (opts: DetectOptions) => Promise<StorageMode> +} + +export const token = definePlugin({ + name: 'token', + needs: [env, session], + build: async (ctx): Promise<TokenService> => { + const { token: envToken } = await ctx.get(env) + const sessionService = await ctx.get(session) + + let cached: string | undefined + + async function readFromStore(): Promise<string> { + const login = await sessionService.require() + const store = getTokenStore(login.tokenStorage) + return store.read(bareHost(login.server), login.email) + } + + return { + get: async () => { + if (envToken !== undefined) return envToken + if (cached === undefined) cached = await readFromStore() + if (cached === '') throw notLoggedIn() + return cached + }, + write: async (login: Login, bearer: string) => { + const store = getTokenStore(login.tokenStorage) + await store.write(bareHost(login.server), login.email, bearer) + cached = bearer + }, + remove: async (login: Login) => { + const store = getTokenStore(login.tokenStorage) + await store.remove(bareHost(login.server), login.email) + cached = '' + }, + detect: async (opts) => (await detectTokenStore(opts)).mode, + } + }, +}) diff --git a/cli/src/protocol/bind.ts b/cli/src/protocol/bind.ts new file mode 100644 index 00000000000..31f8b10717d --- /dev/null +++ b/cli/src/protocol/bind.ts @@ -0,0 +1,14 @@ +export const BIND = { + Path: 'path', + Query: 'query', + Body: 'body', + File: 'file', +} as const + +export type Bind = (typeof BIND)[keyof typeof BIND] + +const BINDS: readonly Bind[] = Object.values(BIND) + +export function isBind(b: string): b is Bind { + return (BINDS as readonly string[]).includes(b) +} diff --git a/cli/src/protocol/fold.ts b/cli/src/protocol/fold.ts new file mode 100644 index 00000000000..225f582ce2d --- /dev/null +++ b/cli/src/protocol/fold.ts @@ -0,0 +1,125 @@ +import { isRecord } from '@/util/is-record' + +export type Hint = { + summary: string + op: string + input: Record<string, unknown> + form?: unknown +} + +export type FoldResult = { + status: FoldStatus + text: { answer: string; by_source?: Record<string, string> } + outputs?: Record<string, unknown> + total_tokens?: number + message_id?: string + conversation_id?: string + error?: { message: string; code?: string } + hints: Hint[] +} + +export type EventHandler = (result: FoldResult, event: Record<string, unknown>) => void + +export const FOLD_STATUS = { + Incomplete: 'incomplete', + Ended: 'ended', + Suspended: 'suspended', + Failed: 'failed', +} as const + +type FoldStatus = (typeof FOLD_STATUS)[keyof typeof FOLD_STATUS] + +// Highest precedence first: a handler only ever raises status toward this end. +const STATUS_PRECEDENCE: readonly FoldStatus[] = [ + FOLD_STATUS.Failed, + FOLD_STATUS.Suspended, + FOLD_STATUS.Ended, + FOLD_STATUS.Incomplete, +] + +function raise(result: FoldResult, status: FoldStatus): void { + if (STATUS_PRECEDENCE.indexOf(status) < STATUS_PRECEDENCE.indexOf(result.status)) + result.status = status +} + +function asRecord(value: unknown): Record<string, unknown> | undefined { + return isRecord(value) ? value : undefined +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function appendHints(result: FoldResult, hints: unknown): void { + if (Array.isArray(hints)) result.hints.push(...(hints as Hint[])) +} + +function appendAnswer(result: FoldResult, event: Record<string, unknown>): void { + const answer = asString(event.answer) + if (answer !== undefined) result.text.answer += answer +} + +function replaceAnswer(result: FoldResult, event: Record<string, unknown>): void { + const answer = asString(event.answer) + if (answer !== undefined) result.text.answer = answer +} + +function appendTextChunk(result: FoldResult, event: Record<string, unknown>): void { + const data = asRecord(event.data) + if (data === undefined) return + const selector = data.from_variable_selector + if (!Array.isArray(selector)) return + const key = selector.map((part) => String(part)).join('.') + const text = asString(data.text) ?? '' + result.text.by_source ??= {} + result.text.by_source[key] = (result.text.by_source[key] ?? '') + text +} + +function endMessage(result: FoldResult, event: Record<string, unknown>): void { + const messageId = asString(event.message_id) + if (messageId !== undefined) result.message_id = messageId + const conversationId = asString(event.conversation_id) + if (conversationId !== undefined) result.conversation_id = conversationId + appendHints(result, event.hints) + raise(result, FOLD_STATUS.Ended) +} + +const WORKFLOW_FAILURE_STATUSES: readonly string[] = ['failed', 'stopped'] + +function finishWorkflow(result: FoldResult, event: Record<string, unknown>): void { + const data = asRecord(event.data) + if (data === undefined) return + const outputs = asRecord(data.outputs) + if (outputs !== undefined) result.outputs = outputs + if (typeof data.total_tokens === 'number') result.total_tokens = data.total_tokens + const status = asString(data.status) + if (status !== undefined && WORKFLOW_FAILURE_STATUSES.includes(status)) { + result.error = { message: asString(data.error) ?? '' } + raise(result, FOLD_STATUS.Failed) + return + } + raise(result, FOLD_STATUS.Ended) +} + +function requireHumanInput(result: FoldResult, event: Record<string, unknown>): void { + appendHints(result, event.hints) + raise(result, FOLD_STATUS.Suspended) +} + +function markError(result: FoldResult, event: Record<string, unknown>): void { + const message = asString(event.message) ?? '' + const code = asString(event.code) + result.error = code !== undefined ? { message, code } : { message } + raise(result, FOLD_STATUS.Failed) +} + +export const FOLD_TABLE: Readonly<Record<string, EventHandler>> = Object.freeze({ + message: appendAnswer, + agent_message: appendAnswer, + message_replace: replaceAnswer, + text_chunk: appendTextChunk, + message_end: endMessage, + workflow_finished: finishWorkflow, + human_input_required: requireHumanInput, + error: markError, +}) diff --git a/cli/src/protocol/kinds.ts b/cli/src/protocol/kinds.ts new file mode 100644 index 00000000000..b4456f7db56 --- /dev/null +++ b/cli/src/protocol/kinds.ts @@ -0,0 +1,15 @@ +export const KIND = { + Object: 'object', + List: 'list', + Sse: 'sse', + Text: 'text', + File: 'file', +} as const + +export type Kind = (typeof KIND)[keyof typeof KIND] + +export const KINDS: readonly Kind[] = Object.values(KIND) + +export function isKind(k: string): k is Kind { + return (KINDS as readonly string[]).includes(k) +} diff --git a/cli/src/protocol/op-id.ts b/cli/src/protocol/op-id.ts new file mode 100644 index 00000000000..308621a81f9 --- /dev/null +++ b/cli/src/protocol/op-id.ts @@ -0,0 +1,11 @@ +export const OP_SEPARATOR = '.' +export const COMMAND_SEPARATOR = ' ' + +/** An op id as the words it is typed as: `get.console_app` -> `get console_app`. */ +export function spacedId(id: string): string { + return id.split(OP_SEPARATOR).join(COMMAND_SEPARATOR) +} + +export function dottedId(path: readonly string[]): string { + return path.join(OP_SEPARATOR) +} diff --git a/cli/src/protocol/pins.ts b/cli/src/protocol/pins.ts new file mode 100644 index 00000000000..b233e1479a1 --- /dev/null +++ b/cli/src/protocol/pins.ts @@ -0,0 +1,6 @@ +// Input properties the CLI fills from the local session when the caller leaves them out. +export const PIN = { + Workspace: 'workspace_id', +} as const + +export type Pin = (typeof PIN)[keyof typeof PIN] diff --git a/cli/src/protocol/protocol.test.ts b/cli/src/protocol/protocol.test.ts new file mode 100644 index 00000000000..819ce198327 --- /dev/null +++ b/cli/src/protocol/protocol.test.ts @@ -0,0 +1,9 @@ +import { expect, it } from 'vite-plus/test' +import { isBind } from './bind' +import { isKind, KINDS } from './kinds' + +it('names the frozen protocol', () => { + expect(KINDS).toEqual(['object', 'list', 'sse', 'text', 'file']) + expect(isKind('hologram')).toBe(false) + expect(isBind('header')).toBe(false) +}) diff --git a/cli/src/protocol/shape.test.ts b/cli/src/protocol/shape.test.ts new file mode 100644 index 00000000000..99847da1ff0 --- /dev/null +++ b/cli/src/protocol/shape.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from 'vite-plus/test' +import { coerceFor, isRepeatable, labelOf, SHAPE, shapeOf } from './shape' + +const binary = { type: 'string', format: 'binary' } +const nullable = (inner: object) => ({ anyOf: [inner, { type: 'null' }], default: null }) + +it.each([ + [{ type: 'string' }, SHAPE.String, 'string'], + [{ type: 'integer' }, SHAPE.Integer, 'integer'], + [{ type: 'boolean' }, SHAPE.Boolean, 'boolean'], + [{ type: 'string', enum: ['a', 'b'] }, SHAPE.Enum, 'a | b'], + [binary, SHAPE.File, 'file'], + [{ type: 'array', items: binary }, SHAPE.List, 'file[]'], + [{ type: 'array', items: { type: 'string' } }, SHAPE.List, 'string[]'], + [{ type: 'object', additionalProperties: binary }, SHAPE.Map, 'map<string, file>'], + [{ type: 'object', additionalProperties: {} }, SHAPE.Map, 'map<string, json>'], + [{ type: 'object', properties: { a: { type: 'string' } } }, SHAPE.Object, 'object'], + [nullable({ type: 'string', enum: ['x'] }), SHAPE.Enum, 'x'], + [{ enum: [] }, SHAPE.Json, 'json'], + [{ type: 'string', enum: [] }, SHAPE.Json, 'json'], + [{ oneOf: [{ type: 'string' }, { type: 'integer' }] }, SHAPE.Json, 'json'], + [{ someFutureKeyword: true }, SHAPE.Json, 'json'], +])('%j classifies as %s labelled %s', (schema, shape, label) => { + const node = shapeOf(schema) + expect(node.shape).toBe(shape) + expect(labelOf(node)).toBe(label) +}) + +it('coerces by shape and leaves misfits raw for validation', () => { + expect(coerceFor(shapeOf({ type: 'integer' }))('5')).toBe(5) + expect(coerceFor(shapeOf({ type: 'integer' }))('x')).toBe('x') + expect(coerceFor(shapeOf({ type: 'boolean' }))('false')).toBe(false) + expect(coerceFor(shapeOf({ type: 'object' }))('{"a":1}')).toEqual({ a: 1 }) + expect(coerceFor(shapeOf({ type: 'object' }))('{oops')).toBe('{oops') + expect(coerceFor(shapeOf({ type: 'object' }))('@vars.json')).toBe('@vars.json') +}) + +it('a list of scalars or files repeats; a list of objects takes json', () => { + expect(isRepeatable(shapeOf({ type: 'array', items: { type: 'string' } }))).toBe(true) + expect(isRepeatable(shapeOf({ type: 'array', items: binary }))).toBe(true) + expect(isRepeatable(shapeOf({ type: 'array', items: { type: 'object' } }))).toBe(false) +}) diff --git a/cli/src/protocol/shape.ts b/cli/src/protocol/shape.ts new file mode 100644 index 00000000000..325f06e47e9 --- /dev/null +++ b/cli/src/protocol/shape.ts @@ -0,0 +1,141 @@ +import type { JsonSchema } from '@/plugins/catalog' +import { isRecord } from '@/util/is-record' + +export const SHAPE = { + String: 'string', + Integer: 'integer', + Number: 'number', + Boolean: 'boolean', + Enum: 'enum', + File: 'file', + List: 'list', + Map: 'map', + Object: 'object', + Json: 'json', +} as const +export type Shape = (typeof SHAPE)[keyof typeof SHAPE] +export type ShapeNode = Readonly<{ shape: Shape; item?: ShapeNode; values?: readonly string[] }> + +type Coercion = (raw: string) => unknown + +const NULL_TYPE = 'null' +const BINARY_FORMAT = 'binary' +const ARRAY_TYPE = 'array' +const OBJECT_TYPE = 'object' +const STRING_TYPE = 'string' +const FILE_REF_PREFIX = '@' +const ENUM_SEPARATOR = ' | ' +const BOOLEAN_LITERALS: Readonly<Record<string, boolean>> = { true: true, false: false } + +const keepRaw: Coercion = (raw) => raw +const toNumber: Coercion = (raw) => { + if (raw.trim() === '') return raw + const n = Number(raw) + return Number.isFinite(n) ? n : raw +} +const toBoolean: Coercion = (raw) => BOOLEAN_LITERALS[raw] ?? raw +// An @file reference is resolved later by the caller that can read files. +const toJson: Coercion = (raw) => { + if (raw.startsWith(FILE_REF_PREFIX)) return raw + try { + return JSON.parse(raw) as unknown + } catch { + return raw + } +} + +const JSON_NODE: ShapeNode = { shape: SHAPE.Json } +const SCALAR_TYPES: Readonly<Record<string, Shape>> = { + string: SHAPE.String, + integer: SHAPE.Integer, + number: SHAPE.Number, + boolean: SHAPE.Boolean, +} + +type ShapeInfo = Readonly<{ + label: (node: ShapeNode) => string + coerce: (node: ShapeNode) => Coercion + scalar: boolean +}> + +const SHAPE_INFO: Readonly<Record<Shape, ShapeInfo>> = { + [SHAPE.String]: { label: () => SHAPE.String, coerce: () => keepRaw, scalar: true }, + [SHAPE.Integer]: { label: () => SHAPE.Integer, coerce: () => toNumber, scalar: true }, + [SHAPE.Number]: { label: () => SHAPE.Number, coerce: () => toNumber, scalar: true }, + [SHAPE.Boolean]: { label: () => SHAPE.Boolean, coerce: () => toBoolean, scalar: true }, + [SHAPE.Enum]: { + label: (n) => (n.values ?? []).join(ENUM_SEPARATOR), + coerce: () => keepRaw, + scalar: true, + }, + [SHAPE.File]: { label: () => SHAPE.File, coerce: () => keepRaw, scalar: true }, + [SHAPE.List]: { + label: (n) => `${labelOf(n.item ?? JSON_NODE)}[]`, + coerce: (n) => (isRepeatable(n) ? coerceFor(n.item ?? JSON_NODE) : toJson), + scalar: false, + }, + [SHAPE.Map]: { + label: (n) => `map<string, ${labelOf(n.item ?? JSON_NODE)}>`, + coerce: () => toJson, + scalar: false, + }, + [SHAPE.Object]: { label: () => SHAPE.Object, coerce: () => toJson, scalar: false }, + [SHAPE.Json]: { label: () => SHAPE.Json, coerce: () => toJson, scalar: false }, +} + +// pydantic optional: anyOf [T, null]. Anything else with anyOf is json. +function unwrapNullable(schema: JsonSchema): JsonSchema | undefined { + const branches = schema.anyOf + if (!Array.isArray(branches) || branches.length !== 2) return undefined + const real = branches.filter((b) => !(isRecord(b) && b.type === NULL_TYPE)) + return real.length === 1 && isRecord(real[0]) ? (real[0] as JsonSchema) : undefined +} + +export function shapeOf(schema: JsonSchema): ShapeNode { + const inner = unwrapNullable(schema) + if (inner !== undefined) return shapeOf(inner) + const type = typeof schema.type === 'string' ? schema.type : undefined + // An enum with no members names no value at all, so it degrades to json rather than + // labelling as the empty string. + if (Array.isArray(schema.enum)) + return schema.enum.length === 0 + ? JSON_NODE + : { shape: SHAPE.Enum, values: schema.enum.map(String) } + if (type === STRING_TYPE && schema.format === BINARY_FORMAT) return { shape: SHAPE.File } + if (type !== undefined && type in SCALAR_TYPES) return { shape: SCALAR_TYPES[type] as Shape } + if (type === ARRAY_TYPE) + return { + shape: SHAPE.List, + item: isRecord(schema.items) ? shapeOf(schema.items as JsonSchema) : JSON_NODE, + } + if (type === OBJECT_TYPE && isRecord(schema.properties)) return { shape: SHAPE.Object } + if (type === OBJECT_TYPE) { + const extra = schema.additionalProperties + return { + shape: SHAPE.Map, + item: + isRecord(extra) && Object.keys(extra).length > 0 ? shapeOf(extra as JsonSchema) : JSON_NODE, + } + } + return JSON_NODE +} + +export function propertiesOf(schema: JsonSchema): Record<string, JsonSchema> { + return isRecord(schema.properties) ? (schema.properties as Record<string, JsonSchema>) : {} +} + +export function labelOf(node: ShapeNode): string { + return SHAPE_INFO[node.shape].label(node) +} +export function coerceFor(node: ShapeNode): Coercion { + return SHAPE_INFO[node.shape].coerce(node) +} +export function isScalar(node: ShapeNode): boolean { + return SHAPE_INFO[node.shape].scalar +} +export function isRepeatable(node: ShapeNode): boolean { + return node.shape === SHAPE.List && node.item !== undefined && isScalar(node.item) +} +export function takesValue(node: ShapeNode): boolean { + return node.shape !== SHAPE.Boolean +} diff --git a/cli/src/skills/collection.ts b/cli/src/skills/collection.ts new file mode 100644 index 00000000000..88bf0918ba3 --- /dev/null +++ b/cli/src/skills/collection.ts @@ -0,0 +1,76 @@ +import type { SkillEntry, SkillSource } from './source' +import { posix } from 'node:path' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { parseFrontmatter } from './frontmatter' +import { SKILL_FILE } from './source' + +export const BASIC_SKILL = 'difyctl' +const ROOT_DIR = '.' +const SKILL_NAME_RE = /^[a-z0-9-]{1,64}$/ + +export type Skill = { + readonly name: string + readonly description: string + readonly dir: string + readonly files: readonly SkillEntry[] +} + +function skillDirs(entries: readonly SkillEntry[]): string[] { + const dirs = entries + .filter( + (entry) => entry.path.split('/').length <= 2 && posix.basename(entry.path) === SKILL_FILE, + ) + .map((entry) => posix.dirname(entry.path)) + return dirs.includes(ROOT_DIR) ? [ROOT_DIR] : dirs +} + +function filesIn(entries: readonly SkillEntry[], dir: string): SkillEntry[] { + return entries + .filter((entry) => dir === ROOT_DIR || entry.path.startsWith(`${dir}/`)) + .map((entry) => ({ + ...entry, + path: dir === ROOT_DIR ? entry.path : entry.path.slice(dir.length + 1), + })) +} + +async function loadSkill(source: SkillSource, dir: string): Promise<Skill> { + const path = posix.join(dir, SKILL_FILE) + const meta = parseFrontmatter(new TextDecoder().decode(await source.read(path))) + const { name, description } = meta + if (typeof name !== 'string' || !SKILL_NAME_RE.test(name) || typeof description !== 'string') { + throw new BaseError({ + code: ErrorCode.ServerError, + message: `${path}: frontmatter needs a name (lowercase letters, digits, dashes) and a description`, + }) + } + return { name, description, dir, files: filesIn(source.entries, dir) } +} + +export async function loadSkills(source: SkillSource): Promise<Skill[]> { + const skills = await Promise.all(skillDirs(source.entries).map((dir) => loadSkill(source, dir))) + if (skills.length === 0) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: 'no skills found', + hint: 'point --from at a folder of skills, or at one skill folder', + }) + } + return skills +} + +export function isScenario(skill: Skill): boolean { + return skill.name !== BASIC_SKILL +} + +export function findSkill(skills: readonly Skill[], name: string): Skill { + const skill = skills.find((candidate) => candidate.name === name) + if (skill === undefined) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `unknown skill "${name}"`, + hint: `known skills: ${skills.map((candidate) => candidate.name).join(', ')}`, + }) + } + return skill +} diff --git a/cli/src/skills/frontmatter.ts b/cli/src/skills/frontmatter.ts new file mode 100644 index 00000000000..adcdc0885f7 --- /dev/null +++ b/cli/src/skills/frontmatter.ts @@ -0,0 +1,19 @@ +import { load } from 'js-yaml' +import { isRecord } from '@/util/is-record' + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/ + +export type Frontmatter = { readonly head: string; readonly body: string } + +export function splitFrontmatter(text: string): Frontmatter | undefined { + const match = FRONTMATTER_RE.exec(text) + if (match === null) return undefined + return { head: match[1] as string, body: text.slice(match[0].length) } +} + +export function parseFrontmatter(text: string): Record<string, unknown> { + const split = splitFrontmatter(text) + if (split === undefined) return {} + const doc: unknown = load(split.head) + return isRecord(doc) ? doc : {} +} diff --git a/cli/src/skills/github.ts b/cli/src/skills/github.ts new file mode 100644 index 00000000000..6a5c9901dd9 --- /dev/null +++ b/cli/src/skills/github.ts @@ -0,0 +1,112 @@ +import type { SkillSource } from './source' +import { createHash } from 'node:crypto' +import { z } from 'zod' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { buildFetchInit } from '@/net/fetch-init' +import { proxyDispatcher } from '@/net/proxy' +import { getEnv } from '@/sys' + +export const GITHUB_ORIGIN = 'https://github.com' +const API_ORIGIN = 'https://api.github.com' +const RAW_ORIGIN = 'https://raw.githubusercontent.com' +const TREE_SEGMENT = 'tree' +const DEFAULT_REF = 'HEAD' +const TOKEN_ENV = 'GITHUB_TOKEN' +const RATE_LIMIT_HEADER = 'x-ratelimit-remaining' +const TOO_MANY_REQUESTS = 429 +const EXECUTABLE_MODE = '100755' +const FILE_MODES: readonly string[] = ['100644', EXECUTABLE_MODE] +const FROM_HINT = 'check --from, or download the skills folder and pass --from <folder>' + +const TREE_SCHEMA = z.object({ + truncated: z.boolean(), + tree: z.array(z.object({ path: z.string(), mode: z.string(), sha: z.string() })), +}) + +export type GitHubFolder = { + readonly owner: string + readonly repo: string + readonly ref: string + readonly path: string +} + +export function parseGitHubUrl(url: string): GitHubFolder { + const [owner, repo, segment, ref, ...rest] = new URL(url).pathname.split('/').filter(Boolean) + const tree = segment === undefined || (segment === TREE_SEGMENT && ref !== undefined) + if (owner === undefined || repo === undefined || !tree) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `unsupported GitHub URL "${url}"`, + hint: `use ${GITHUB_ORIGIN}/<owner>/<repo>/tree/<ref>/<folder>`, + }) + } + return { owner, repo, ref: ref ?? DEFAULT_REF, path: rest.join('/') } +} + +async function get(url: string, token?: string): Promise<Response> { + const headers = token === undefined ? undefined : { authorization: `Bearer ${token}` } + let res: Response + try { + res = await fetch( + url, + buildFetchInit({ headers }, { insecure: false, dispatcher: proxyDispatcher() }), + ) + } catch (cause) { + throw new BaseError({ + code: ErrorCode.NetworkConnection, + message: `GET ${url} failed`, + hint: FROM_HINT, + cause, + }) + } + if (res.ok) return res + const limited = res.status === TOO_MANY_REQUESTS || res.headers.get(RATE_LIMIT_HEADER) === '0' + throw new BaseError({ + code: limited ? ErrorCode.RateLimited : ErrorCode.ServerError, + message: `GET ${url} returned ${res.status}`, + hint: limited ? `set ${TOKEN_ENV} to raise GitHub's rate limit, or ${FROM_HINT}` : FROM_HINT, + }) +} + +function gitBlobSha(bytes: Uint8Array): string { + return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex') +} + +export async function openGitHub(url: string): Promise<SkillSource> { + const { owner, repo, ref, path } = parseGitHubUrl(url) + const treeUrl = `${API_ORIGIN}/repos/${owner}/${repo}/git/trees/${encodeURIComponent(`${ref}:${path}`)}?recursive=1` + const parsed = TREE_SCHEMA.safeParse(await (await get(treeUrl, getEnv(TOKEN_ENV))).json()) + if (!parsed.success) + throw new BaseError({ + code: ErrorCode.ServerError, + message: `unexpected response from ${treeUrl}`, + }) + if (parsed.data.truncated) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `${url} holds too many files to list`, + hint: 'point --from at the skills folder', + }) + } + const files = parsed.data.tree.filter((entry) => FILE_MODES.includes(entry.mode)) + const shas = new Map(files.map((entry) => [entry.path, entry.sha])) + return { + entries: files.map((entry) => ({ + path: entry.path, + executable: entry.mode === EXECUTABLE_MODE, + })), + read: async (file) => { + const rawUrl = [RAW_ORIGIN, owner, repo, ref, path, file].filter(Boolean).join('/') + const bytes = new Uint8Array(await (await get(rawUrl)).arrayBuffer()) + if (gitBlobSha(bytes) !== shas.get(file)) { + throw new BaseError({ + code: ErrorCode.ServerError, + message: `${rawUrl} changed during the install`, + hint: 'retry in a few minutes', + }) + } + return bytes + }, + } +} diff --git a/cli/src/skills/install.test.ts b/cli/src/skills/install.test.ts new file mode 100644 index 00000000000..0f04c9a2e94 --- /dev/null +++ b/cli/src/skills/install.test.ts @@ -0,0 +1,65 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' +import { ErrorCode } from '@/errors/codes' +import { BASIC_SKILL, loadSkills } from './collection' +import { installSkills, selectSkills } from './install' +import { openSource } from './source' + +const SCENARIO = 'difyctl-demo' + +let tmp: string +let from: string +let root: string + +async function put(path: string, text: string): Promise<void> { + const abs = join(from, path) + await mkdir(dirname(abs), { recursive: true }) + await writeFile(abs, text) +} + +function skillMd(name: string): string { + return `---\nname: ${name}\ndescription: ${name} skill\n---\n\n# ${name}\n` +} + +beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'difyctl-skills-')) + from = join(tmp, 'source') + root = join(tmp, 'root') + await put(`${BASIC_SKILL}/SKILL.md`, skillMd(BASIC_SKILL)) + await put(`${SCENARIO}/SKILL.md`, skillMd(SCENARIO)) +}) + +afterEach(async () => { + await rm(tmp, { recursive: true, force: true }) +}) + +describe('selectSkills', () => { + it('selects the whole collection when no names are given', async () => { + const skills = await loadSkills(await openSource(from)) + expect(selectSkills(skills, [])).toEqual(skills) + }) + + it('selects the named skills', async () => { + const skills = await loadSkills(await openSource(from)) + expect(selectSkills(skills, [BASIC_SKILL]).map((s) => s.name)).toEqual([BASIC_SKILL]) + }) + + it('rejects an unknown name as a usage error', async () => { + const skills = await loadSkills(await openSource(from)) + expect(() => selectSkills(skills, ['nope'])).toThrow( + expect.objectContaining({ code: ErrorCode.UsageInvalidFlag }), + ) + }) +}) + +describe('installSkills', () => { + it('writes <root>/<skill>/<file> for every file of every skill', async () => { + const source = await openSource(from) + const basic = selectSkills(await loadSkills(source), [BASIC_SKILL]) + const target = join(root, BASIC_SKILL, 'SKILL.md') + expect(await installSkills(root, basic, source)).toEqual([target]) + expect(await readFile(target, 'utf8')).toBe(skillMd(BASIC_SKILL)) + }) +}) diff --git a/cli/src/skills/install.ts b/cli/src/skills/install.ts new file mode 100644 index 00000000000..858be55d546 --- /dev/null +++ b/cli/src/skills/install.ts @@ -0,0 +1,37 @@ +import type { Skill } from './collection' +import type { SkillSource } from './source' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { dirname, join, posix } from 'node:path' +import { BASIC_SKILL, findSkill, isScenario } from './collection' + +const FILE_MODE = 0o644 +const EXECUTABLE_MODE = 0o755 + +export function selectSkills(skills: readonly Skill[], names: readonly string[]): Skill[] { + if (names.length === 0) return [...skills] + const chosen = new Map(names.map((name) => [name, findSkill(skills, name)])) + const basic = skills.find((skill) => skill.name === BASIC_SKILL) + if (basic !== undefined && [...chosen.values()].some(isScenario)) chosen.set(BASIC_SKILL, basic) + return [...chosen.values()] +} + +export async function installSkills( + root: string, + skills: readonly Skill[], + source: SkillSource, +): Promise<string[]> { + const downloads = [] + for (const skill of skills) { + for (const file of skill.files) + downloads.push({ skill, file, bytes: await source.read(posix.join(skill.dir, file.path)) }) + } + for (const skill of skills) await rm(join(root, skill.name), { recursive: true, force: true }) + const wrote: string[] = [] + for (const { skill, file, bytes } of downloads) { + const path = join(root, skill.name, file.path) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, bytes, { mode: file.executable ? EXECUTABLE_MODE : FILE_MODE }) + wrote.push(path) + } + return wrote +} diff --git a/cli/src/skills/source.ts b/cli/src/skills/source.ts new file mode 100644 index 00000000000..2dc2c33ca9f --- /dev/null +++ b/cli/src/skills/source.ts @@ -0,0 +1,76 @@ +import type { Dirent } from 'node:fs' +import { readdir, readFile, stat } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' +import { z } from 'zod' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { versionInfo } from '@/version/info' +import { GITHUB_ORIGIN, openGitHub } from './github' + +export const SKILL_FILE = 'SKILL.md' +const EXECUTABLE_BITS = 0o111 +const URL_RE = /^https?:\/\// + +export type SkillEntry = { readonly path: string; readonly executable: boolean } + +export type SkillSource = { + readonly entries: readonly SkillEntry[] + readonly read: (path: string) => Promise<Uint8Array> +} + +export const DEFAULT_SOURCE = `${GITHUB_ORIGIN}/langgenius/dify/tree/${versionInfo.commit}/skills` + +export const FROM_FIELD = z + .string() + .min(1) + .default(DEFAULT_SOURCE) + .describe('Where the skills come from: a GitHub folder URL or a local folder') + +async function hasSkillFile(dir: string): Promise<boolean> { + return stat(join(dir, SKILL_FILE)).then( + () => true, + () => false, + ) +} + +async function walk(root: string, dir: string): Promise<SkillEntry[]> { + const entries: SkillEntry[] = [] + for (const entry of await readdir(dir, { recursive: true, withFileTypes: true })) { + if (!entry.isFile()) continue + const abs = join(entry.parentPath, entry.name) + const executable = ((await stat(abs)).mode & EXECUTABLE_BITS) !== 0 + entries.push({ path: relative(root, abs).split(sep).join('/'), executable }) + } + return entries +} + +async function openDir(root: string): Promise<SkillSource> { + let children: Dirent[] + try { + children = await readdir(root, { withFileTypes: true }) + } catch (cause) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `cannot read folder "${root}"`, + cause, + }) + } + const dirs = (await hasSkillFile(root)) + ? [root] + : children.filter((child) => child.isDirectory()).map((child) => join(root, child.name)) + const entries: SkillEntry[] = [] + for (const dir of dirs) if (await hasSkillFile(dir)) entries.push(...(await walk(root, dir))) + return { entries, read: (path) => readFile(join(root, path)) } +} + +export function openSource(from: string): Promise<SkillSource> { + if (from.startsWith(`${GITHUB_ORIGIN}/`)) return openGitHub(from) + if (URL_RE.test(from)) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: `unsupported --from URL "${from}"`, + hint: `use a ${GITHUB_ORIGIN} folder URL or a local folder`, + }) + } + return openDir(from) +} diff --git a/cli/src/store/config-writer.test.ts b/cli/src/store/config-writer.test.ts deleted file mode 100644 index c75f7f39258..00000000000 --- a/cli/src/store/config-writer.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useTempConfigDir } from '@test/fixtures/config-dir' -import { describe, expect, it } from 'vite-plus/test' -import { loadConfig } from '@/config/config-loader' -import { emptyConfig } from '@/config/schema' -import { saveConfig } from './config-writer' -import { getConfigurationStore } from './manager' - -describe('saveConfig', () => { - useTempConfigDir('difyctl-w-') - - it('stamps schema_version=1 even if caller passed 0', async () => { - await saveConfig(getConfigurationStore(), { ...emptyConfig() }) - const r = await loadConfig(getConfigurationStore()) - expect(r.found).toBe(true) - if (r.found) expect(r.config.schema_version).toBe(1) - }) - - it('overrides a stale schema_version on save', async () => { - await saveConfig(getConfigurationStore(), { - ...emptyConfig(), - schema_version: 999 as never, - }) - const r = await loadConfig(getConfigurationStore()) - expect(r.found).toBe(true) - if (r.found) expect(r.config.schema_version).toBe(1) - }) - - it('round-trips defaults + state', async () => { - await saveConfig(getConfigurationStore(), { - schema_version: 1, - defaults: { format: 'wide', limit: 75 }, - state: { current_app: 'app-xyz' }, - }) - const r = await loadConfig(getConfigurationStore()) - expect(r.found).toBe(true) - if (r.found) { - expect(r.config.defaults.format).toBe('wide') - expect(r.config.defaults.limit).toBe(75) - expect(r.config.state.current_app).toBe('app-xyz') - } - }) - - it('overwrites the previous config on resave', async () => { - await saveConfig(getConfigurationStore(), { - schema_version: 1, - defaults: { format: 'json' }, - state: {}, - }) - await saveConfig(getConfigurationStore(), { - schema_version: 1, - defaults: { format: 'table' }, - state: { current_app: 'app-2' }, - }) - const r = await loadConfig(getConfigurationStore()) - expect(r.found).toBe(true) - if (r.found) { - expect(r.config.defaults.format).toBe('table') - expect(r.config.state.current_app).toBe('app-2') - } - }) -}) diff --git a/cli/src/store/config-writer.ts b/cli/src/store/config-writer.ts deleted file mode 100644 index fd86f597148..00000000000 --- a/cli/src/store/config-writer.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { YamlStore } from './store' -import type { ConfigFile } from '@/config/schema' -import { CURRENT_SCHEMA_VERSION } from '@/config/schema' - -export async function saveConfig(store: YamlStore, config: ConfigFile): Promise<void> { - const stamped: ConfigFile = { ...config, schema_version: CURRENT_SCHEMA_VERSION } - await store.setTyped(stamped) -} diff --git a/cli/src/store/errors.ts b/cli/src/store/errors.ts index 4f038ed6797..b71a9fc82fc 100644 --- a/cli/src/store/errors.ts +++ b/cli/src/store/errors.ts @@ -6,7 +6,7 @@ export class ConcurrentAccessError extends BaseError { const msg = `Another process is modifying the file ${filePath}. remove ${filePath}.lock to reset lock.` super({ - code: ErrorCode.ClientError, + code: ErrorCode.Unknown, message: msg, hint: `remove ${filePath}.lock to reset lock.`, }) @@ -35,7 +35,7 @@ export class BadYamlFormatError extends BaseError { const body = snippet ? `\n\n${snippet}` : '' super({ - code: ErrorCode.ClientError, + code: ErrorCode.Unknown, message: `${header}${body}`, hint: `Fix the YAML syntax in ${path} or remove the file to reset it.`, }) diff --git a/cli/src/store/manager.test.ts b/cli/src/store/manager.test.ts index f70c3cb0aa6..43d534dbfba 100644 --- a/cli/src/store/manager.test.ts +++ b/cli/src/store/manager.test.ts @@ -20,6 +20,19 @@ function memStore(label: string): TokenStore & { _label: string } { } describe('detectTokenStore', () => { + it('skips the probe and returns the file store when asked', async () => { + const k = memStore('keyring') + const f = memStore('file') + const writeSpy = vi.spyOn(k, 'write') + const result = await detectTokenStore({ + skipKeyring: true, + factory: { keyring: () => k, file: () => f }, + }) + expect(writeSpy).not.toHaveBeenCalled() + expect(result.mode).toBe('file') + expect(result.store).toBe(f) + }) + it('returns keychain store when probe succeeds', async () => { const k = memStore('keyring') const f = memStore('file') diff --git a/cli/src/store/manager.ts b/cli/src/store/manager.ts index 0ae105d5a0f..86b78736a4a 100644 --- a/cli/src/store/manager.ts +++ b/cli/src/store/manager.ts @@ -1,14 +1,10 @@ -import type { StorageMode, Store } from './store' +import type { StorageMode } from './store' import type { TokenStore } from './token-store' import { join } from 'node:path' -import { resolveCacheDir, resolveConfigDir } from './dir' +import { resolveConfigDir } from './dir' import { YamlStore } from './store' import { FileTokenStore, KeychainTokenStore } from './token-store' -export const CACHE_APP_INFO = 'app-info' -export const CACHE_NUDGE = 'nudge' -export const CACHE_COMPAT = 'compat' -const HOSTS_FILE = 'hosts.yml' const TOKENS_FILE = 'tokens.yml' export const CONFIG_FILE_NAME = 'config.yml' @@ -18,22 +14,10 @@ function getStore(filePath: string): YamlStore { return new YamlStore(filePath) } -export function cachePath(cacheDir: string, name: string): string { - return join(cacheDir, `${name}.yml`) -} - export function getConfigurationStore(): YamlStore { return getStore(join(resolveConfigDir(), CONFIG_FILE_NAME)) } -export function getCache(cacheName: string): Store { - return getStore(cachePath(resolveCacheDir(), cacheName)) -} - -export function getHostStore(): YamlStore { - return getStore(join(resolveConfigDir(), HOSTS_FILE)) -} - const PROBE_HOST = '__difyctl_probe__' const PROBE_EMAIL = '__difyctl_probe__' const PROBE_VALUE = 'probe-v1' @@ -45,6 +29,10 @@ export type GetTokenStoreOptions = { } } +export type DetectTokenStoreOptions = GetTokenStoreOptions & { + readonly skipKeyring?: boolean +} + const TOKEN_STORE_OPENERS: Record<StorageMode, (opts: GetTokenStoreOptions) => TokenStore> = { file: (opts) => opts.factory?.file?.() ?? new FileTokenStore(join(resolveConfigDir(), TOKENS_FILE)), @@ -57,12 +45,9 @@ const TOKEN_STORE_OPENERS: Record<StorageMode, (opts: GetTokenStoreOptions) => T * only where a credential is about to be written anyway (login). */ export async function detectTokenStore( - opts: GetTokenStoreOptions = {}, + opts: DetectTokenStoreOptions = {}, ): Promise<{ store: TokenStore; mode: StorageMode }> { - // DIFY_E2E_NO_KEYRING=1 forces file-based storage in E2E tests to avoid - // macOS keychain UI prompts blocking child processes spawned by vitest. - if (process.env.DIFY_E2E_NO_KEYRING === '1') - return { store: TOKEN_STORE_OPENERS.file(opts), mode: 'file' } + if (opts.skipKeyring === true) return { store: TOKEN_STORE_OPENERS.file(opts), mode: 'file' } try { const k = TOKEN_STORE_OPENERS.keychain(opts) await k.write(PROBE_HOST, PROBE_EMAIL, PROBE_VALUE) diff --git a/cli/src/store/store.test.ts b/cli/src/store/store.test.ts index 060ef44df45..0bfaf7e8588 100644 --- a/cli/src/store/store.test.ts +++ b/cli/src/store/store.test.ts @@ -254,6 +254,17 @@ describe('YamlStore persistence', () => { expect(s3.doGet({ key: 'y', default: '' })).toBe('second') }) + it('forgets its cached content once the file is deleted and reloaded', async () => { + const path = join(dir, 'config.yml') + await writeFile(path, 'key: value\n') + + const store = new YamlStore(path) + expect(await store.getTyped<{ key: string }>()).toEqual({ key: 'value' }) + + await rm(path) + expect(await store.getTyped<{ key: string }>()).toBeNull() + }) + it('load → doSet → flush writes the value to disk', async () => { const path = join(dir, 'config.yml') await writeFile(path, 'existing: value\n') diff --git a/cli/src/store/store.ts b/cli/src/store/store.ts index f5051a2398c..f692ed55474 100644 --- a/cli/src/store/store.ts +++ b/cli/src/store/store.ts @@ -129,6 +129,7 @@ abstract class FileBasedStore implements Store { if (code !== 'ENOENT') { throw err } + this.rawContent = undefined } } diff --git a/cli/src/sys/index.ts b/cli/src/sys/index.ts index 27e3b48dcfa..a49e252f0a7 100644 --- a/cli/src/sys/index.ts +++ b/cli/src/sys/index.ts @@ -10,6 +10,10 @@ export function env(): NodeJS.ProcessEnv { return process.env } +export function argv(): readonly string[] { + return process.argv.slice(2) +} + export function processExit(code: number): never { return process.exit(code) as never } diff --git a/cli/src/sys/io/color.ts b/cli/src/sys/io/color.ts index 6cfda7a7e77..f5599e3c254 100644 --- a/cli/src/sys/io/color.ts +++ b/cli/src/sys/io/color.ts @@ -1,4 +1,4 @@ -import pc from 'picocolors' +import { createColors } from 'picocolors' export type ColorScheme = { bold: (s: string) => string @@ -12,6 +12,8 @@ export type ColorScheme = { failureIcon: () => string } +export type Style = ColorScheme + const identity = (s: string): string => s export function colorScheme(enabled: boolean): ColorScheme { @@ -28,6 +30,9 @@ export function colorScheme(enabled: boolean): ColorScheme { failureIcon: () => '✗', } } + // createColors(true) forces ANSI output regardless of TTY/env so this module + // never reads the environment itself; callers decide enablement via colorEnabled. + const pc = createColors(true) return { bold: (s) => pc.bold(s), dim: (s) => pc.dim(s), @@ -41,9 +46,8 @@ export function colorScheme(enabled: boolean): ColorScheme { } } -export function colorEnabled(isTTY: boolean): boolean { - if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false - if (process.env.DIFYCTL_NO_COLOR !== undefined && process.env.DIFYCTL_NO_COLOR !== '') - return false - return isTTY +export const NO_COLOR_ENV = 'NO_COLOR' + +export function colorEnabled(isTTY: boolean, env: NodeJS.ProcessEnv): boolean { + return isTTY && env[NO_COLOR_ENV] === undefined } diff --git a/cli/src/sys/io/prompt.test.ts b/cli/src/sys/io/prompt.test.ts deleted file mode 100644 index a3cb387044f..00000000000 --- a/cli/src/sys/io/prompt.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { PassThrough } from 'node:stream' -import { describe, expect, it } from 'vite-plus/test' -import { promptText } from './prompt' -import { bufferStreams } from './streams' - -function ttyStreams(input: string): ReturnType<typeof bufferStreams> { - const io = bufferStreams(input) - ;(io as { isErrTTY: boolean }).isErrTTY = true - return io -} - -function eofStreams(): ReturnType<typeof bufferStreams> { - const io = bufferStreams() - const ended = new PassThrough() - ended.end() - ;(io as { in: NodeJS.ReadableStream }).in = ended - ;(io as { isErrTTY: boolean }).isErrTTY = true - return io -} - -const parseString = (raw: string) => - raw === 'hello' - ? { ok: true as const, value: raw } - : { ok: false as const, error: `expected "hello", got "${raw}"` } - -describe('promptText (non-TTY)', () => { - it('writes label to stderr and returns parsed value', async () => { - const io = bufferStreams('hello\n') - const result = await promptText({ io, label: 'Enter name', parse: parseString }) - expect(result).toBe('hello') - expect(io.errBuf()).toContain('Enter name: ') - }) - - it('includes hint and default in prompt when provided', async () => { - const io = bufferStreams('hello\n') - await promptText({ - io, - label: 'Enter name', - hint: 'e.g. world', - default: 'world', - parse: parseString, - }) - expect(io.errBuf()).toBe('Enter name (e.g. world) [default: world]: ') - }) - - it('substitutes default on empty Enter', async () => { - const io = bufferStreams('\n') - const result = await promptText({ - io, - label: 'Enter name', - default: 'hello', - parse: parseString, - }) - expect(result).toBe('hello') - }) - - it('throws on invalid input (no retry in non-TTY)', async () => { - const io = bufferStreams('bad\n') - await expect(promptText({ io, label: 'Enter name', parse: parseString })).rejects.toThrow( - /expected "hello"/, - ) - }) - - it('throws UsageMissingArg on EOF before reading input', async () => { - const io = bufferStreams() - const ended = new PassThrough() - ended.end() - ;(io as { in: NodeJS.ReadableStream }).in = ended - await expect(promptText({ io, label: 'Enter name', parse: parseString })).rejects.toThrow( - /input closed/, - ) - }) - - it('acceptAsDefault: treats matching input as default', async () => { - const io = bufferStreams('y\n') - const result = await promptText({ - io, - label: 'Enter URL', - default: 'https://example.com', - acceptAsDefault: (raw) => /^y(?:es)?$/i.test(raw), - parse: (raw) => ({ ok: true, value: raw }), - }) - expect(result).toBe('https://example.com') - }) -}) - -describe('promptText (TTY)', () => { - it('returns parsed value on first valid input', async () => { - const io = ttyStreams('hello\n') - const result = await promptText({ io, label: 'Enter name', parse: parseString }) - expect(result).toBe('hello') - expect(io.errBuf()).toContain('Enter name: ') - }) - - it('prints error and re-prompts on invalid input, accepts next valid', async () => { - const io = ttyStreams('bad\nhello\n') - const result = await promptText({ io, label: 'Enter name', parse: parseString }) - expect(result).toBe('hello') - const err = io.errBuf() - expect(err).toContain('expected "hello", got "bad"') - expect(err.split('Enter name: ').length - 1).toBe(2) - }) - - it('substitutes default on empty Enter', async () => { - const io = ttyStreams('\n') - const result = await promptText({ - io, - label: 'Enter name', - default: 'hello', - parse: parseString, - }) - expect(result).toBe('hello') - }) - - it('acceptAsDefault: treats matching input as empty → default', async () => { - const io = ttyStreams('y\n') - const result = await promptText({ - io, - label: 'Enter URL', - default: 'https://example.com', - acceptAsDefault: (raw) => /^y(?:es)?$/i.test(raw), - parse: (raw) => ({ ok: true, value: raw }), - }) - expect(result).toBe('https://example.com') - }) - - it('acceptAsDefault: case-insensitive — YES and Yes also map to default', async () => { - for (const input of ['YES\n', 'Yes\n']) { - const io = ttyStreams(input) - const result = await promptText({ - io, - label: 'Enter URL', - default: 'https://example.com', - acceptAsDefault: (raw) => /^y(?:es)?$/i.test(raw), - parse: (raw) => ({ ok: true, value: raw }), - }) - expect(result).toBe('https://example.com') - } - }) - - it('throws UsageMissingArg on EOF before valid input is given', async () => { - const io = eofStreams() - await expect(promptText({ io, label: 'Enter name', parse: parseString })).rejects.toThrow( - /input closed/, - ) - }) -}) diff --git a/cli/src/sys/io/prompt.ts b/cli/src/sys/io/prompt.ts deleted file mode 100644 index 28cb19bce66..00000000000 --- a/cli/src/sys/io/prompt.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { IOStreams } from './streams' -import * as readline from 'node:readline' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { colorEnabled, colorScheme } from './color' - -export type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string } - -export type PromptTextOptions<T> = { - readonly io: IOStreams - readonly label: string - readonly hint?: string - readonly default?: string - readonly acceptAsDefault?: (raw: string) => boolean - readonly parse: (raw: string) => ParseResult<T> -} - -function buildPromptLine( - opts: Pick<PromptTextOptions<unknown>, 'label' | 'hint' | 'default'>, -): string { - let line = opts.label - if (opts.hint !== undefined) line += ` (${opts.hint})` - if (opts.default !== undefined) line += ` [default: ${opts.default}]` - return `${line}: ` -} - -function normalize( - raw: string, - opts: Pick<PromptTextOptions<unknown>, 'default' | 'acceptAsDefault'>, -): string { - const trimmed = raw.trim() - if (trimmed === '' || opts.acceptAsDefault?.(trimmed)) return opts.default ?? '' - return trimmed -} - -export async function promptConfirm(io: IOStreams, message: string): Promise<boolean> { - io.err.write(message) - const rl = readline.createInterface({ input: io.in, output: io.err, terminal: false }) - try { - const line = await new Promise<string>((resolve) => rl.once('line', resolve)) - return line.trim().toLowerCase() === 'y' - } finally { - rl.close() - } -} - -export async function promptText<T>(opts: PromptTextOptions<T>): Promise<T> { - const prompt = buildPromptLine(opts) - const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) - - return new Promise<T>((resolve, reject) => { - let settled = false - const settle = (fn: () => void): void => { - if (settled) return - settled = true - fn() - } - - const rl = readline.createInterface({ input: opts.io.in, output: opts.io.err, terminal: false }) - - rl.on('close', () => { - settle(() => - reject( - new BaseError({ - code: ErrorCode.UsageMissingArg, - message: 'input closed before a valid value was provided', - }), - ), - ) - }) - - const onLine = (raw: string): void => { - const value = normalize(raw, opts) - - if (!opts.io.isErrTTY) { - const result = opts.parse(value) - rl.off('line', onLine) - settle(() => { - rl.close() - if (result.ok) resolve(result.value) - else reject(new BaseError({ code: ErrorCode.UsageInvalidFlag, message: result.error })) - }) - return - } - - const result = opts.parse(value) - if (result.ok) { - rl.off('line', onLine) - settle(() => { - rl.close() - resolve(result.value) - }) - } else { - opts.io.err.write(` ${cs.failureIcon()} ${result.error}\n`) - opts.io.err.write(prompt) - } - } - - opts.io.err.write(prompt) - rl.on('line', onLine) - }) -} diff --git a/cli/src/sys/io/reasoning.test.ts b/cli/src/sys/io/reasoning.test.ts deleted file mode 100644 index ed4642bcd60..00000000000 --- a/cli/src/sys/io/reasoning.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Buffer } from 'node:buffer' -import { PassThrough } from 'node:stream' -import { describe, expect, it } from 'vite-plus/test' -import { - accumulateReasoning, - formatReasoningBlocks, - parseReasoningChunk, - reasoningBlocksFromMetadata, - ReasoningChunkRenderer, -} from './reasoning' - -function capture(): { err: PassThrough; errBuf: () => string } { - const err = new PassThrough() - const ec: Buffer[] = [] - err.on('data', (d) => ec.push(d as Buffer)) - return { err, errBuf: () => Buffer.concat(ec).toString('utf-8') } -} - -describe('parseReasoningChunk', () => { - it('reads the payload nested under data', () => { - expect( - parseReasoningChunk({ data: { reasoning: 'hi', node_id: 'llm-1', is_final: true } }), - ).toEqual({ reasoning: 'hi', nodeId: 'llm-1', isFinal: true }) - }) - - it('defaults missing/wrong-typed fields', () => { - expect(parseReasoningChunk({ data: {} })).toEqual({ reasoning: '', nodeId: '', isFinal: false }) - }) - - it('returns undefined when data is absent or not an object', () => { - expect(parseReasoningChunk({})).toBeUndefined() - expect(parseReasoningChunk({ data: null })).toBeUndefined() - expect(parseReasoningChunk({ data: ['x'] })).toBeUndefined() - }) -}) - -describe('ReasoningChunkRenderer', () => { - it('frames streamed deltas with a node-tagged <think> open/close on the terminal marker', () => { - const cap = capture() - const r = new ReasoningChunkRenderer() - r.push({ reasoning: 'pon', nodeId: 'llm-1', isFinal: false }, cap.err) - r.push({ reasoning: 'dering', nodeId: 'llm-1', isFinal: false }, cap.err) - r.push({ reasoning: '', nodeId: 'llm-1', isFinal: true }, cap.err) - expect(cap.errBuf()).toBe('<think> [llm-1]\npondering</think>\n') - }) - - it('emits separate node-tagged blocks per node', () => { - const cap = capture() - const r = new ReasoningChunkRenderer() - r.push({ reasoning: 'a', nodeId: 'n1', isFinal: true }, cap.err) - r.push({ reasoning: 'b', nodeId: 'n2', isFinal: true }, cap.err) - expect(cap.errBuf()).toBe('<think> [n1]\na</think>\n<think> [n2]\nb</think>\n') - }) - - it('tags each block with its node id so interleaved fragments stay distinguishable', () => { - const cap = capture() - const r = new ReasoningChunkRenderer() - r.push({ reasoning: 'a1', nodeId: 'n1', isFinal: false }, cap.err) - r.push({ reasoning: 'b1', nodeId: 'n2', isFinal: false }, cap.err) - r.push({ reasoning: 'a2', nodeId: 'n1', isFinal: true }, cap.err) - r.push({ reasoning: 'b2', nodeId: 'n2', isFinal: true }, cap.err) - expect(cap.errBuf()).toBe( - '<think> [n1]\na1</think>\n<think> [n2]\nb1</think>\n<think> [n1]\na2</think>\n<think> [n2]\nb2</think>\n', - ) - }) - - it('omits the tag when the chunk carries no node id', () => { - const cap = capture() - const r = new ReasoningChunkRenderer() - r.push({ reasoning: 'plain', nodeId: '', isFinal: true }, cap.err) - expect(cap.errBuf()).toBe('<think>\nplain</think>\n') - }) - - it('flush closes a block left open by a truncated stream', () => { - const cap = capture() - const r = new ReasoningChunkRenderer() - r.push({ reasoning: 'half', nodeId: 'n1', isFinal: false }, cap.err) - r.flush(cap.err) - expect(cap.errBuf()).toBe('<think> [n1]\nhalf</think>\n') - }) - - it('a lone terminal marker with no reasoning emits nothing', () => { - const cap = capture() - const r = new ReasoningChunkRenderer() - r.push({ reasoning: '', nodeId: 'n1', isFinal: true }, cap.err) - expect(cap.errBuf()).toBe('') - }) -}) - -describe('accumulateReasoning', () => { - it('appends deltas per node, falling back to "_" for a missing nodeId', () => { - const acc: Record<string, string> = {} - accumulateReasoning(acc, { reasoning: 'a', nodeId: 'n1', isFinal: false }) - accumulateReasoning(acc, { reasoning: 'b', nodeId: 'n1', isFinal: false }) - accumulateReasoning(acc, { reasoning: 'x', nodeId: '', isFinal: false }) - expect(acc).toEqual({ n1: 'ab', _: 'x' }) - }) - - it('ignores empty reasoning', () => { - const acc: Record<string, string> = {} - accumulateReasoning(acc, { reasoning: '', nodeId: 'n1', isFinal: true }) - expect(acc).toEqual({}) - }) -}) - -describe('formatReasoningBlocks', () => { - it('frames and trims each node, joined by a separator', () => { - expect(formatReasoningBlocks({ n1: ' one ', n2: 'two' })).toBe( - '<think>\none\n</think>\n---\n<think>\ntwo\n</think>', - ) - }) - - it('skips empty entries and returns empty for no reasoning', () => { - expect(formatReasoningBlocks({ n1: ' ' })).toBe('') - expect(formatReasoningBlocks({})).toBe('') - }) -}) - -describe('reasoningBlocksFromMetadata', () => { - it('extracts reasoning from a metadata object', () => { - expect(reasoningBlocksFromMetadata({ reasoning: { n1: 'why' } })).toBe('<think>\nwhy\n</think>') - }) - - it('returns empty for tagged mode (empty reasoning) and malformed input', () => { - expect(reasoningBlocksFromMetadata({ reasoning: {} })).toBe('') - expect(reasoningBlocksFromMetadata(undefined)).toBe('') - expect(reasoningBlocksFromMetadata({ usage: { tokens: 1 } })).toBe('') - }) -}) diff --git a/cli/src/sys/io/reasoning.ts b/cli/src/sys/io/reasoning.ts deleted file mode 100644 index 87c385b0e61..00000000000 --- a/cli/src/sys/io/reasoning.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Renders "separated"-mode reasoning (streamed on its own `reasoning_chunk` SSE -// channel) to stderr, so --think matches inline <think> (see think-filter.ts). - -const THINK_OPEN = '<think>' -const THINK_CLOSE = '</think>' - -export type ReasoningChunk = { - reasoning: string - nodeId: string - isFinal: boolean -} - -// reasoning_chunk nests its payload under `data` (not top-level like `message`). -export function parseReasoningChunk(parsed: Record<string, unknown>): ReasoningChunk | undefined { - const data = parsed.data - if (data === null || typeof data !== 'object' || Array.isArray(data)) return undefined - const rec = data as Record<string, unknown> - return { - reasoning: typeof rec.reasoning === 'string' ? rec.reasoning : '', - nodeId: typeof rec.node_id === 'string' ? rec.node_id : '', - isFinal: rec.is_final === true, - } -} - -// Bucket key for a chunk; falls back to a single bucket so live rendering and -// buffered collection key reasoning the same way. -export function reasoningKey(chunk: ReasoningChunk): string { - return chunk.nodeId !== '' ? chunk.nodeId : '_' -} - -// Appends a reasoning delta to a per-node accumulator. -export function accumulateReasoning(acc: Record<string, string>, chunk: ReasoningChunk): void { - if (chunk.reasoning === '') return - const key = reasoningKey(chunk) - acc[key] = (acc[key] ?? '') + chunk.reasoning -} - -// Frames a live reasoning stream into stderr: <think> on the first delta, -// raw deltas thereafter, </think> on is_final. Parallel branches can interleave -// chunks from different nodes on one stream, so it keeps at most one block open, -// switches blocks on node change, and tags each block with its node id so the -// interleaved fragments stay distinguishable. -export class ReasoningChunkRenderer { - private openNode: string | undefined - - push(chunk: ReasoningChunk, errOut: NodeJS.WritableStream): void { - const key = reasoningKey(chunk) - if (chunk.reasoning !== '') { - if (this.openNode !== key) { - this.closeActive(errOut) - errOut.write(chunk.nodeId !== '' ? `${THINK_OPEN} [${chunk.nodeId}]\n` : `${THINK_OPEN}\n`) - this.openNode = key - } - errOut.write(chunk.reasoning) - } - if (chunk.isFinal && this.openNode === key) this.closeActive(errOut) - } - - // Close a block left open by a truncated stream. - flush(errOut: NodeJS.WritableStream): void { - this.closeActive(errOut) - } - - private closeActive(errOut: NodeJS.WritableStream): void { - if (this.openNode === undefined) return - errOut.write(`${THINK_CLOSE}\n`) - this.openNode = undefined - } -} - -// Frames fully-buffered reasoning (one entry per LLM node id) into <think> blocks. -export function formatReasoningBlocks(reasoning: Record<string, string>): string { - const blocks: string[] = [] - for (const text of Object.values(reasoning)) { - const trimmed = text.trim() - if (trimmed !== '') blocks.push(`${THINK_OPEN}\n${trimmed}\n${THINK_CLOSE}`) - } - return blocks.join('\n---\n') -} - -// Frames per-node reasoning from a message_end `metadata` object; '' when absent. -export function reasoningBlocksFromMetadata(metadata: unknown): string { - if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) return '' - const reasoning = (metadata as Record<string, unknown>).reasoning - if (reasoning === null || typeof reasoning !== 'object' || Array.isArray(reasoning)) return '' - const map: Record<string, string> = {} - for (const [key, value] of Object.entries(reasoning as Record<string, unknown>)) { - if (typeof value === 'string') map[key] = value - } - return formatReasoningBlocks(map) -} diff --git a/cli/src/sys/io/select.test.ts b/cli/src/sys/io/select.test.ts deleted file mode 100644 index 888baf79a8c..00000000000 --- a/cli/src/sys/io/select.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { PassThrough } from 'node:stream' -import { describe, expect, it } from 'vite-plus/test' -import { selectFromList } from './select' -import { bufferStreams } from './streams' - -type Row = { id: string; label: string } -const rows: Row[] = [ - { id: '1', label: 'alpha' }, - { id: '2', label: 'beta' }, - { id: '3', label: 'gamma' }, -] - -const SHOW_CURSOR = '\x1B[?25h' - -type FakeTTYIn = PassThrough & { - isTTY: boolean - isRaw: boolean - setRawMode: (mode: boolean) => unknown -} - -function ttyInput(opts: { failRawMode?: boolean } = {}): FakeTTYIn { - const stream = new PassThrough() as unknown as FakeTTYIn - stream.isTTY = true - stream.isRaw = false - stream.setRawMode = (mode: boolean): unknown => { - if (opts.failRawMode === true && mode) throw new Error('raw mode unavailable') - stream.isRaw = mode - return stream - } - return stream -} - -function ttyStreams(input: FakeTTYIn): ReturnType<typeof bufferStreams> { - const io = bufferStreams() - ;(io as { in: NodeJS.ReadableStream }).in = input - ;(io as { isErrTTY: boolean }).isErrTTY = true - return io -} - -describe('selectFromList (non-TTY numbered fallback)', () => { - it('returns the item matching the typed number', async () => { - const io = bufferStreams('2\n') - ;(io as { isErrTTY: boolean }).isErrTTY = false - const picked = await selectFromList({ - io, - items: rows, - header: 'Pick one', - render: (r) => r.label, - }) - expect(picked.id).toBe('2') - expect(io.errBuf()).toContain('1) alpha') - expect(io.errBuf()).toContain('Pick one') - }) - - it('rejects an out-of-range selection', async () => { - const io = bufferStreams('9\n') - ;(io as { isErrTTY: boolean }).isErrTTY = false - await expect( - selectFromList({ io, items: rows, header: 'Pick', render: (r) => r.label }), - ).rejects.toThrow(/invalid selection/i) - }) - - it('throws when the list is empty', async () => { - const io = bufferStreams('1\n') - ;(io as { isErrTTY: boolean }).isErrTTY = false - await expect( - selectFromList({ io, items: [] as Row[], header: 'Pick', render: (r) => (r as Row).label }), - ).rejects.toThrow(/nothing to select/i) - }) -}) - -describe('selectFromList (interactive TTY picker)', () => { - it('moves with arrow keys and resolves on enter, restoring raw mode', async () => { - const input = ttyInput() - const io = ttyStreams(input) - const pick = selectFromList({ io, items: rows, header: 'Pick', render: (r) => r.label }) - input.write('\x1B[B') - input.write('\r') - const picked = await pick - expect(picked.id).toBe('2') - expect(input.isRaw).toBe(false) - expect(io.errBuf()).toContain(SHOW_CURSOR) - }) - - it('cancels on escape', async () => { - const input = ttyInput() - const io = ttyStreams(input) - const pick = selectFromList({ io, items: rows, header: 'Pick', render: (r) => r.label }) - input.write('\x1B') - await expect(pick).rejects.toThrow(/cancelled/i) - expect(input.isRaw).toBe(false) - }) - - it('rejects and restores the terminal when raw-mode setup fails', async () => { - const input = ttyInput({ failRawMode: true }) - const io = ttyStreams(input) - await expect( - selectFromList({ io, items: rows, header: 'Pick', render: (r) => r.label }), - ).rejects.toThrow(/raw mode unavailable/i) - expect(input.isRaw).toBe(false) - expect(io.errBuf()).toContain(SHOW_CURSOR) - }) -}) diff --git a/cli/src/sys/io/select.ts b/cli/src/sys/io/select.ts deleted file mode 100644 index 4c9797ab991..00000000000 --- a/cli/src/sys/io/select.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { Key } from 'node:readline' -import type { IOStreams } from './streams' -import * as readline from 'node:readline' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { colorEnabled, colorScheme } from './color' - -export type SelectOptions<T> = { - readonly io: IOStreams - readonly items: readonly T[] - readonly header: string - /** Single rich line shown per option. */ - readonly render: (item: T) => string - /** Optional second line shown only for the focused option in the TTY picker. */ - readonly describe?: (item: T) => string -} - -const HIDE_CURSOR = '\x1B[?25l' -const SHOW_CURSOR = '\x1B[?25h' -const CLEAR_DOWN = '\x1B[0J' -const cursorUp = (n: number): string => `\x1B[${n}A` - -export async function selectFromList<T>(opts: SelectOptions<T>): Promise<T> { - if (opts.items.length === 0) - throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'nothing to select' }) - return opts.io.isErrTTY ? pickInteractive(opts) : pickNumbered(opts) -} - -/** - * Arrow-key picker built on Node's readline keypress events — no third-party - * prompt library, so it bundles cleanly into the compiled binary. Renders to - * the err stream, redrawing in place on each keystroke and erasing itself on - * exit so the caller's own output starts on a clean row. - */ -async function pickInteractive<T>(opts: SelectOptions<T>): Promise<T> { - const input = opts.io.in as NodeJS.ReadStream - const out = opts.io.err - const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) - const count = opts.items.length - - return new Promise<T>((resolve, reject) => { - let active = 0 - let rendered = 0 - - const frame = (): readonly string[] => { - const lines = [opts.header] - opts.items.forEach((item, i) => { - const focused = i === active - const pointer = focused ? cs.cyan('❯') : ' ' - const label = focused ? cs.bold(opts.render(item)) : opts.render(item) - lines.push(`${pointer} ${label}`) - }) - const desc = opts.describe?.(opts.items[active] as T) - if (desc !== undefined && desc !== '') lines.push(cs.dim(` ${desc}`)) - return lines - } - - const render = (): void => { - if (rendered > 0) out.write(cursorUp(rendered)) - const lines = frame() - out.write(`${CLEAR_DOWN}${lines.join('\n')}\n`) - rendered = lines.length - } - - const wasRaw = input.isTTY ? input.isRaw : false - const cleanup = (): void => { - input.off('keypress', onKey) - if (input.isTTY) input.setRawMode(wasRaw) - input.pause() - if (rendered > 0) out.write(`${cursorUp(rendered)}${CLEAR_DOWN}`) - out.write(SHOW_CURSOR) - } - - function onKey(_str: string | undefined, key: Key): void { - if (key.ctrl && key.name === 'c') { - cleanup() - reject(cancelled()) - return - } - switch (key.name) { - case 'up': - case 'k': - active = (active - 1 + count) % count - render() - break - case 'down': - case 'j': - active = (active + 1) % count - render() - break - case 'return': - case 'enter': { - const chosen = opts.items[active] - cleanup() - if (chosen === undefined) - reject( - new BaseError({ code: ErrorCode.UsageInvalidFlag, message: 'invalid selection' }), - ) - else resolve(chosen) - break - } - case 'escape': - cleanup() - reject(cancelled()) - break - default: - break - } - } - - try { - readline.emitKeypressEvents(input) - if (input.isTTY) input.setRawMode(true) - out.write(HIDE_CURSOR) - input.on('keypress', onKey) - input.resume() - render() - } catch (err) { - cleanup() - reject(err) - } - }) -} - -function cancelled(): BaseError { - return new BaseError({ code: ErrorCode.UsageMissingArg, message: 'selection cancelled' }) -} - -async function pickNumbered<T>(opts: SelectOptions<T>): Promise<T> { - opts.io.err.write(`${opts.header}\n`) - opts.items.forEach((item, idx) => { - opts.io.err.write(` ${idx + 1}) ${opts.render(item)}\n`) - }) - opts.io.err.write('Enter number: ') - - const rl = readline.createInterface({ input: opts.io.in, output: opts.io.err, terminal: false }) - try { - const line: string = await new Promise((resolve) => rl.once('line', resolve)) - const n = Number(line.trim()) - const chosen = Number.isInteger(n) ? opts.items[n - 1] : undefined - if (chosen === undefined) - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: `invalid selection: ${line.trim()}`, - }) - return chosen - } finally { - rl.close() - } -} diff --git a/cli/src/sys/io/spinner.ts b/cli/src/sys/io/spinner.ts deleted file mode 100644 index 89df41eb581..00000000000 --- a/cli/src/sys/io/spinner.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { IOStreams } from './streams' -import oraImport from 'ora' - -const DIFY_FRAMES = ['Dify', 'dIfy', 'diFy', 'difY', 'diFy', 'dIfy'] -const DIFY_BLUE_RGB = '\x1B[38;2;0;51;255m' -const DIFY_BLUE_256 = '\x1B[38;5;27m' -const DIM = '\x1B[2m' -const ANSI_RESET = '\x1B[0m' - -export type SpinnerStyle = 'dify' | 'dify-dim' - -function colorize(s: string, style: SpinnerStyle, truecolor: boolean): string { - if (style === 'dify-dim') return `${DIM}${s}${ANSI_RESET}` - return `${truecolor ? DIFY_BLUE_RGB : DIFY_BLUE_256}${s}${ANSI_RESET}` -} - -function detectTruecolor(env: NodeJS.ProcessEnv): boolean { - const v = env.COLORTERM ?? '' - return v === 'truecolor' || v === '24bit' -} - -const STRUCTURED_FORMATS = new Set(['json', 'yaml', 'name']) - -export type SpinnerOptions = { - readonly io: IOStreams - readonly label: string - readonly enabled?: boolean - readonly style?: SpinnerStyle - readonly minDisplayMs?: number - readonly env?: NodeJS.ProcessEnv -} - -const DEFAULT_MIN_DISPLAY_MS = 600 - -function sleep(ms: number): Promise<void> { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -export type ActiveSpinner = { stop: () => void } - -const NOOP_SPINNER: ActiveSpinner = { stop: () => {} } - -function buildOraSpinner(opts: SpinnerOptions) { - const env = opts.env ?? process.env - const truecolor = detectTruecolor(env) - const style = opts.style ?? 'dify' - const frames = DIFY_FRAMES.map((f) => colorize(f, style, truecolor)) - return oraImport({ - text: opts.label, - stream: opts.io.err as NodeJS.WriteStream, - spinner: { frames, interval: 140 }, - discardStdin: false, - }) -} - -function isActive(opts: SpinnerOptions): boolean { - const spinnerEnabled = opts.enabled ?? !STRUCTURED_FORMATS.has(opts.io.outputFormat) - return spinnerEnabled && opts.io.isErrTTY -} - -export function startSpinner(opts: SpinnerOptions): ActiveSpinner { - if (!isActive(opts)) return NOOP_SPINNER - const ora = buildOraSpinner(opts).start() - let stopped = false - return { - stop: () => { - if (!stopped) { - stopped = true - ora.stop() - } - }, - } -} - -export async function runWithSpinner<T>(opts: SpinnerOptions, fn: () => Promise<T>): Promise<T> { - if (!isActive(opts)) return fn() - - const minMs = opts.minDisplayMs ?? DEFAULT_MIN_DISPLAY_MS - const start = Date.now() - const spinner = buildOraSpinner(opts).start() - - const enforceMin = async () => { - const remaining = minMs - (Date.now() - start) - if (remaining > 0) await sleep(remaining) - } - - try { - const result = await fn() - await enforceMin() - spinner.succeed(opts.label) - return result - } catch (err) { - await enforceMin() - spinner.fail(opts.label) - throw err - } -} diff --git a/cli/src/sys/io/streams.ts b/cli/src/sys/io/streams.ts index b5deebc4b21..f976e87f646 100644 --- a/cli/src/sys/io/streams.ts +++ b/cli/src/sys/io/streams.ts @@ -8,18 +8,14 @@ export type IOStreams = { in: NodeJS.ReadableStream isOutTTY: boolean isErrTTY: boolean - outputFormat: string } export function nullStreams(): IOStreams { return bufferStreams() } -export function realStreams(outputFormat = ''): IOStreams { - return { - outputFormat, - ...io(), - } +export function realStreams(): IOStreams { + return io() } export type BufferStreams = IOStreams & { @@ -49,7 +45,6 @@ export function bufferStreams(stdin = ''): BufferStreams { in: inStream, isOutTTY: false, isErrTTY: false, - outputFormat: '', outBuf: () => Buffer.concat(outChunks).toString('utf8'), errBuf: () => Buffer.concat(errChunks).toString('utf8'), } diff --git a/cli/src/sys/io/think-filter.test.ts b/cli/src/sys/io/think-filter.test.ts deleted file mode 100644 index 5943e52010b..00000000000 --- a/cli/src/sys/io/think-filter.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { Buffer } from 'node:buffer' -import { PassThrough } from 'node:stream' -import { describe, expect, it } from 'vite-plus/test' -import { - extractThinkBlocks, - filterThinkInOutputs, - stripThinkBlocks, - ThinkChunkFilter, -} from './think-filter' - -function captures() { - const out = new PassThrough() - const err = new PassThrough() - const oc: Buffer[] = [] - out.on('data', (d: Buffer) => oc.push(d)) - const ec: Buffer[] = [] - err.on('data', (d: Buffer) => ec.push(d)) - return { - out, - err, - outBuf: () => Buffer.concat(oc).toString('utf-8'), - errBuf: () => Buffer.concat(ec).toString('utf-8'), - } -} - -// --- bulk helpers --- - -describe('stripThinkBlocks', () => { - it('no think block — unchanged', () => { - expect(stripThinkBlocks('hello world')).toBe('hello world') - }) - - it('strips single think block at start', () => { - expect(stripThinkBlocks('<think>reasoning</think>\nhello')).toBe('hello') - }) - - it('strips multiple think blocks', () => { - expect(stripThinkBlocks('<think>a</think>\nfoo<think>b</think>\nbar')).toBe('foobar') - }) - - it('strips multi-line think block', () => { - const s = '<think>\nline1\nline2\n</think>\nanswer' - expect(stripThinkBlocks(s)).toBe('answer') - }) - - it('empty string unchanged', () => { - expect(stripThinkBlocks('')).toBe('') - }) -}) - -describe('extractThinkBlocks', () => { - it('no think block — clean equals input, thinking empty', () => { - const r = extractThinkBlocks('hello') - expect(r.clean).toBe('hello') - expect(r.thinking).toBe('') - }) - - it('single block — separates thinking and clean', () => { - const r = extractThinkBlocks('<think>step 1</think>\nfinal answer') - expect(r.clean).toBe('final answer') - expect(r.thinking).toBe('<think>\nstep 1\n</think>') - }) - - it('multiple blocks — thinking joined with separator', () => { - const r = extractThinkBlocks('<think>a</think>\nfoo<think>b</think>\nbar') - expect(r.clean).toBe('foobar') - expect(r.thinking).toBe('<think>\na\n</think>\n---\n<think>\nb\n</think>') - }) -}) - -// --- workflow outputs helper --- - -describe('filterThinkInOutputs', () => { - it('no think block — outputs unchanged, thinking empty', () => { - const r = filterThinkInOutputs({ text: 'hello' }, true) - expect(r.outputs).toEqual({ text: 'hello' }) - expect(r.thinking).toBe('') - }) - - it('showThink: false — strips from string field, thinking empty', () => { - const r = filterThinkInOutputs({ text: '<think>reasoning</think>\nanswer' }, false) - expect(r.outputs).toEqual({ text: 'answer' }) - expect(r.thinking).toBe('') - }) - - it('showThink: true — strips from string field, captures thinking', () => { - const r = filterThinkInOutputs({ text: '<think>step 1</think>\nfinal' }, true) - expect(r.outputs).toEqual({ text: 'final' }) - expect(r.thinking).toBe('<think>\nstep 1\n</think>') - }) - - it('multiple string fields — thinking joined with separator', () => { - const r = filterThinkInOutputs({ a: '<think>x</think>\nfoo', b: '<think>y</think>\nbar' }, true) - expect(r.outputs).toEqual({ a: 'foo', b: 'bar' }) - expect(r.thinking).toBe('<think>\nx\n</think>\n---\n<think>\ny\n</think>') - }) - - it('non-string values pass through untouched', () => { - const outputs = { - n: 42, - flag: true, - nested: { k: '<think>v</think>\nx' }, - arr: ['a'], - nil: null, - } - const r = filterThinkInOutputs(outputs, true) - expect(r.outputs).toEqual(outputs) - expect(r.thinking).toBe('') - }) - - it('empty outputs — empty result', () => { - const r = filterThinkInOutputs({}, true) - expect(r.outputs).toEqual({}) - expect(r.thinking).toBe('') - }) -}) - -// --- streaming chunk filter --- - -describe('ThinkChunkFilter — showThink: false (strip)', () => { - it('passes normal text through to out', () => { - const cap = captures() - const f = new ThinkChunkFilter(false) - f.push('hello world', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.outBuf()).toBe('hello world') - expect(cap.errBuf()).toBe('') - }) - - it('strips think block in single chunk', () => { - const cap = captures() - const f = new ThinkChunkFilter(false) - f.push('<think>secret</think>\nvisible', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.outBuf()).toBe('visible') - expect(cap.errBuf()).toBe('') - }) - - it('strips think block split across two chunks', () => { - const cap = captures() - const f = new ThinkChunkFilter(false) - f.push('<think>sec', cap.out, cap.err) - f.push('ret</think>\nvisible', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.outBuf()).toBe('visible') - expect(cap.errBuf()).toBe('') - }) - - it('strips when tag boundary splits mid-<think>', () => { - const cap = captures() - const f = new ThinkChunkFilter(false) - f.push('pre<thi', cap.out, cap.err) - f.push('nk>hidden</think>\nafter', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.outBuf()).toBe('preafter') - expect(cap.errBuf()).toBe('') - }) - - it('strips when close tag boundary splits mid-</think>', () => { - const cap = captures() - const f = new ThinkChunkFilter(false) - f.push('<think>think</thi', cap.out, cap.err) - f.push('nk>\nafter', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.outBuf()).toBe('after') - expect(cap.errBuf()).toBe('') - }) - - it('strips multiple think blocks across chunks', () => { - const cap = captures() - const f = new ThinkChunkFilter(false) - f.push('<think>a</think>\nfoo', cap.out, cap.err) - f.push('<think>b</think>\nbar', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.outBuf()).toBe('foobar') - }) - - it('flush emits buffered normal text', () => { - const cap = captures() - const f = new ThinkChunkFilter(false) - f.push('hel', cap.out, cap.err) - f.push('lo', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.outBuf()).toBe('hello') - }) -}) - -describe('ThinkChunkFilter — showThink: true (route to stderr)', () => { - it('routes think content to stderr with tags, normal text to stdout', () => { - const cap = captures() - const f = new ThinkChunkFilter(true) - f.push('<think>reasoning</think>\nanswer', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.outBuf()).toBe('answer') - expect(cap.errBuf()).toBe('<think>\nreasoning</think>\n') - }) - - it('routes multi-chunk think content to stderr with tags', () => { - const cap = captures() - const f = new ThinkChunkFilter(true) - f.push('<think>part1 ', cap.out, cap.err) - f.push('part2</think>\nans', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.errBuf()).toBe('<think>\npart1 part2</think>\n') - expect(cap.outBuf()).toBe('ans') - }) - - it('flush emits remaining inThink buffer to stderr', () => { - const cap = captures() - const f = new ThinkChunkFilter(true) - f.push('<think>incomplete', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.errBuf()).toContain('<think>') - expect(cap.errBuf()).toContain('incomplete') - expect(cap.outBuf()).toBe('') - }) - - it('split close-tag boundary routes partial think to stderr with tags', () => { - const cap = captures() - const f = new ThinkChunkFilter(true) - f.push('<think>content</thi', cap.out, cap.err) - f.push('nk>\nafter', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.errBuf()).toBe('<think>\ncontent</think>\n') - expect(cap.outBuf()).toBe('after') - }) -}) - -describe('ThinkChunkFilter — flush with inThink=false drops nothing', () => { - it('showThink=false + unclosed think: flush emits nothing to either stream', () => { - const cap = captures() - const f = new ThinkChunkFilter(false) - f.push('<think>secret content', cap.out, cap.err) - f.flush(cap.out, cap.err) - expect(cap.outBuf()).toBe('') - expect(cap.errBuf()).toBe('') - }) -}) diff --git a/cli/src/sys/io/think-filter.ts b/cli/src/sys/io/think-filter.ts deleted file mode 100644 index a6206f7b113..00000000000 --- a/cli/src/sys/io/think-filter.ts +++ /dev/null @@ -1,104 +0,0 @@ -const OPEN = '<think>' -const CLOSE = '</think>' - -export function stripThinkBlocks(s: string): string { - return s.replace(/<think>[\s\S]*?<\/think>\r?\n?/g, '') -} - -export function extractThinkBlocks(s: string): { clean: string; thinking: string } { - const parts: string[] = [] - const clean = s.replace(/<think>([\s\S]*?)<\/think>\r?\n?/g, (_, content: string) => { - parts.push(`<think>\n${content.trim()}\n</think>`) - return '' - }) - return { clean, thinking: parts.join('\n---\n') } -} - -// Workflow outputs carry their answer text in top-level string fields rather than -// a single `answer`, so think filtering navigates the outputs object. Nested -// strings (inside arrays/objects) are left untouched. -export function filterThinkInOutputs( - outputs: Record<string, unknown>, - showThink: boolean, -): { outputs: Record<string, unknown>; thinking: string } { - const thoughts: string[] = [] - const clean: Record<string, unknown> = {} - for (const [key, value] of Object.entries(outputs)) { - if (typeof value !== 'string') { - clean[key] = value - continue - } - const extracted = extractThinkBlocks(value) - clean[key] = extracted.clean - if (showThink && extracted.thinking !== '') thoughts.push(extracted.thinking) - } - return { outputs: clean, thinking: thoughts.join('\n---\n') } -} - -function splitAtPotentialTag(s: string, tag: string): [string, string] { - const maxHold = tag.length - 1 - for (let len = Math.min(maxHold, s.length); len > 0; len--) { - if (tag.startsWith(s.slice(-len))) { - return [s.slice(0, -len), s.slice(-len)] - } - } - return [s, ''] -} - -export class ThinkChunkFilter { - private buf = '' - private inThink = false - private readonly showThink: boolean - - constructor(showThink: boolean) { - this.showThink = showThink - } - - push(chunk: string, out: NodeJS.WritableStream, errOut: NodeJS.WritableStream): void { - let s = this.buf + chunk - this.buf = '' - - while (s.length > 0) { - if (!this.inThink) { - const idx = s.indexOf(OPEN) - if (idx === -1) { - const [safe, held] = splitAtPotentialTag(s, OPEN) - if (safe) out.write(safe) - this.buf = held - return - } - if (idx > 0) out.write(s.slice(0, idx)) - s = s.slice(idx + OPEN.length) - this.inThink = true - if (this.showThink) errOut.write(`${OPEN}\n`) - continue - } - - // inThink = true - const idx = s.indexOf(CLOSE) - if (idx === -1) { - const [safe, held] = splitAtPotentialTag(s, CLOSE) - if (safe && this.showThink) errOut.write(safe) - this.buf = held - return - } - if (idx > 0 && this.showThink) errOut.write(s.slice(0, idx)) - if (this.showThink) errOut.write(`${CLOSE}\n`) - s = s.slice(idx + CLOSE.length) - this.inThink = false - if (s.startsWith('\r\n')) s = s.slice(2) - else if (s.startsWith('\n')) s = s.slice(1) - } - } - - flush(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream): void { - if (this.buf === '') return - if (this.inThink) { - if (this.showThink) errOut.write(this.buf) - } else { - out.write(this.buf) - } - this.buf = '' - this.inThink = false - } -} diff --git a/cli/src/sys/io/view.ts b/cli/src/sys/io/view.ts new file mode 100644 index 00000000000..b90f908d7a7 --- /dev/null +++ b/cli/src/sys/io/view.ts @@ -0,0 +1,27 @@ +import type { Style } from './color' + +type JsonPrimitive = string | number | boolean | null + +export type Printable = JsonPrimitive | readonly unknown[] | Record<string, unknown> + +export type View<T extends Printable = Printable> = Readonly<{ + json: T + text: (style: Style) => string +}> + +const TEXT_KEY = 'text' +const JSON_KEY = 'json' + +export function view<T extends Printable>(json: T, text: View<T>['text']): View<T> { + return Object.freeze({ json, text }) +} + +export function isView(value: unknown): value is View { + return ( + typeof value === 'object' && + value !== null && + JSON_KEY in value && + TEXT_KEY in value && + typeof (value as View).text === 'function' + ) +} diff --git a/cli/src/types/app-meta.test.ts b/cli/src/types/app-meta.test.ts deleted file mode 100644 index d82ebb014bb..00000000000 --- a/cli/src/types/app-meta.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { AppDescribeResponse } from '@dify/contracts/api/openapi/types.gen' -import { describe, expect, it } from 'vite-plus/test' -import { - covers, - FieldInfo, - FieldInputSchema, - FieldParameters, - fromDescribe, - mergeMeta, -} from './app-meta' - -function describeResp(): AppDescribeResponse { - return { - info: { - id: 'app-1', - name: 'Greeter', - description: '', - mode: 'chat', - updated_at: undefined, - service_api_enabled: false, - is_agent: false, - }, - parameters: { opening_statement: 'hi' }, - input_schema: undefined, - } -} - -describe('app-meta', () => { - it('fromDescribe with requested=[info] only marks info covered', () => { - const m = fromDescribe(describeResp(), [FieldInfo]) - expect(m.coveredFields.has(FieldInfo)).toBe(true) - expect(m.coveredFields.has(FieldParameters)).toBe(false) - expect(covers(m, [FieldInfo])).toBe(true) - expect(covers(m, [FieldParameters])).toBe(false) - }) - - it('fromDescribe with no fields marks all covered', () => { - const m = fromDescribe(describeResp(), []) - expect(m.coveredFields.has(FieldInfo)).toBe(true) - expect(m.coveredFields.has(FieldParameters)).toBe(true) - expect(m.coveredFields.has(FieldInputSchema)).toBe(true) - expect(covers(m, [])).toBe(true) - }) - - it('mergeMeta unions covered fields and prefers next for covered keys', () => { - const slim = fromDescribe(describeResp(), [FieldInfo]) - const full = fromDescribe(describeResp(), [FieldInfo, FieldParameters, FieldInputSchema]) - const merged = mergeMeta(slim, full) - expect(covers(merged, [FieldInfo, FieldParameters, FieldInputSchema])).toBe(true) - }) - - it('mergeMeta with prev=undefined returns next', () => { - const next = fromDescribe(describeResp(), [FieldInfo]) - expect(mergeMeta(undefined, next)).toBe(next) - }) - - it('covers([]) requires all three slots populated', () => { - const partial = fromDescribe(describeResp(), [FieldInfo, FieldParameters]) - expect(covers(partial, [])).toBe(false) - }) -}) diff --git a/cli/src/types/app-meta.ts b/cli/src/types/app-meta.ts deleted file mode 100644 index 8adb1c6b38c..00000000000 --- a/cli/src/types/app-meta.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { AppDescribeInfo, AppDescribeResponse } from '@dify/contracts/api/openapi/types.gen' - -export const FieldInfo = 'info' -export const FieldParameters = 'parameters' -export const FieldInputSchema = 'input_schema' - -export type AppMetaFieldKey = typeof FieldInfo | typeof FieldParameters | typeof FieldInputSchema - -export type AppMeta = { - info: AppDescribeInfo | null - parameters: unknown - inputSchema: unknown - coveredFields: ReadonlySet<AppMetaFieldKey> -} - -export type AppMetaCacheRecord = { - meta: AppMeta - fetchedAt: string -} - -export function fromDescribe( - resp: AppDescribeResponse, - requested: readonly AppMetaFieldKey[], -): AppMeta { - const covered = new Set<AppMetaFieldKey>() - if (requested.length === 0) { - covered.add(FieldInfo) - covered.add(FieldParameters) - covered.add(FieldInputSchema) - } else { - for (const f of requested) covered.add(f) - } - return { - info: resp.info ?? null, - parameters: resp.parameters, - inputSchema: resp.input_schema, - coveredFields: covered, - } -} - -export function mergeMeta(prev: AppMeta | undefined, next: AppMeta): AppMeta { - if (prev === undefined) return next - const merged = new Set<AppMetaFieldKey>(prev.coveredFields) - for (const f of next.coveredFields) merged.add(f) - return { - info: next.coveredFields.has(FieldInfo) ? next.info : prev.info, - parameters: next.coveredFields.has(FieldParameters) ? next.parameters : prev.parameters, - inputSchema: next.coveredFields.has(FieldInputSchema) ? next.inputSchema : prev.inputSchema, - coveredFields: merged, - } -} - -export function covers(meta: AppMeta, fields: readonly AppMetaFieldKey[]): boolean { - if (fields.length === 0) { - return ( - meta.coveredFields.has(FieldInfo) && - meta.coveredFields.has(FieldParameters) && - meta.coveredFields.has(FieldInputSchema) - ) - } - for (const f of fields) { - if (!meta.coveredFields.has(f)) return false - } - return true -} diff --git a/cli/src/util/edit-distance.ts b/cli/src/util/edit-distance.ts new file mode 100644 index 00000000000..d5db3fe6ff0 --- /dev/null +++ b/cli/src/util/edit-distance.ts @@ -0,0 +1,16 @@ +export function editDistance(a: string, b: string): number { + const m = a.length + const n = b.length + let prev = Array.from({ length: n + 1 }, (_, j) => j) + for (let i = 1; i <= m; i++) { + const curr: number[] = [i] + for (let j = 1; j <= n; j++) { + curr[j] = + a[i - 1] === b[j - 1] + ? (prev[j - 1] ?? 0) + : 1 + Math.min(prev[j] ?? 0, curr[j - 1] ?? 0, prev[j - 1] ?? 0) + } + prev = curr + } + return prev[n] ?? 0 +} diff --git a/cli/src/util/flag-name.ts b/cli/src/util/flag-name.ts new file mode 100644 index 00000000000..f398273298d --- /dev/null +++ b/cli/src/util/flag-name.ts @@ -0,0 +1,12 @@ +const FLAG_WORD_SEPARATOR = '-' +const PROPERTY_WORD_SEPARATOR = '_' + +/** The name a typed word stands for: dashes are an alias for the underscores ids use. */ +export function propertyFor(name: string): string { + return name.split(FLAG_WORD_SEPARATOR).join(PROPERTY_WORD_SEPARATOR) +} + +/** The flag a property name is typed as: ids spell underscores, flags spell dashes. */ +export function flagFor(name: string): string { + return name.split(PROPERTY_WORD_SEPARATOR).join(FLAG_WORD_SEPARATOR) +} diff --git a/cli/src/util/host.ts b/cli/src/util/host.ts index 638f357f36f..6d8a35a8ed9 100644 --- a/cli/src/util/host.ts +++ b/cli/src/util/host.ts @@ -1,4 +1,3 @@ -import type { ActiveContext } from '@/auth/hosts' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' @@ -38,25 +37,6 @@ export function resolveHost(opts: ResolveHostOptions): string { return out.endsWith('/') ? out.slice(0, -1) : out } -export function hostWithScheme(host: string, scheme: string | undefined): string { - if (host.includes('://')) return host - const proto = scheme === undefined || scheme === '' ? 'https' : scheme - return `${proto}://${host}` -} - -// Every call site that builds an HTTP client for the active host needs both its -// scheme-qualified host and whether TLS verification is disabled for it — derive -// them together so neither is forgotten independently. -export function activeHostInfo(active: Pick<ActiveContext, 'host' | 'scheme' | 'insecureTls'>): { - host: string - insecure: boolean -} { - return { - host: hostWithScheme(active.host, active.scheme), - insecure: active.insecureTls === true, - } -} - export function bareHost(raw: string): string { try { const u = new URL(raw) diff --git a/cli/src/util/is-record.ts b/cli/src/util/is-record.ts new file mode 100644 index 00000000000..a48c3837e55 --- /dev/null +++ b/cli/src/util/is-record.ts @@ -0,0 +1,4 @@ +/** A JSON object: not null, not an array. The one shape guard the whole CLI uses. */ +export function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/cli/src/util/uuid.ts b/cli/src/util/uuid.ts deleted file mode 100644 index bd4d88e1614..00000000000 --- a/cli/src/util/uuid.ts +++ /dev/null @@ -1 +0,0 @@ -export const ZERO = '00000000-0000-0000-0000-000000000000' diff --git a/cli/src/version/build-info.d.ts b/cli/src/version/build-info.d.ts index caa37c23e2a..886954c0dfc 100644 --- a/cli/src/version/build-info.d.ts +++ b/cli/src/version/build-info.d.ts @@ -1,9 +1,6 @@ -// Build-time globals injected by vite-plus. Single source of truth — both -// info.ts (client identity) and compat.ts (supported dify range) read from -// here. Values are computed in scripts/lib/resolve-buildinfo.ts. +// Build-time globals injected by vite-plus. Single source of truth for client +// identity in info.ts. Values are computed in scripts/lib/resolve-buildinfo.ts. declare const __DIFYCTL_VERSION__: string declare const __DIFYCTL_COMMIT__: string declare const __DIFYCTL_BUILD_DATE__: string declare const __DIFYCTL_CHANNEL__: string -declare const __DIFYCTL_MIN_DIFY__: string -declare const __DIFYCTL_MAX_DIFY__: string diff --git a/cli/src/version/compat.test.ts b/cli/src/version/compat.test.ts deleted file mode 100644 index 33b0b3e6be6..00000000000 --- a/cli/src/version/compat.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test' -import { compatString, difyCompat, evaluateCompat } from './compat' - -describe('difyCompat', () => { - it('exposes minDify and maxDify as readonly strings', () => { - expect(typeof difyCompat.minDify).toBe('string') - expect(typeof difyCompat.maxDify).toBe('string') - }) -}) - -describe('compatString', () => { - it('formats as "dify >=min, <=max"', () => { - expect(compatString()).toMatch(/^dify >=\d+\.\d+\.\d+(-[\w.]+)?, <=\d+\.\d+\.\d+(-[\w.]+)?$/) - }) -}) - -describe('evaluateCompat', () => { - const range = { minDify: '1.6.0', maxDify: '1.7.0' } - - it('returns compatible when server version is in range', () => { - expect(evaluateCompat('1.6.4', range)).toEqual({ - status: 'compatible', - detail: 'server 1.6.4 in [1.6.0, 1.7.0]', - }) - }) - - it('returns compatible at the lower bound', () => { - expect(evaluateCompat('1.6.0', range).status).toBe('compatible') - }) - - it('returns compatible at the upper bound (inclusive)', () => { - expect(evaluateCompat('1.7.0', range).status).toBe('compatible') - }) - - it('returns too_old when server is below minimum', () => { - const v = evaluateCompat('1.5.9', range) - expect(v.status).toBe('too_old') - expect(v.detail).toContain('1.5.9') - }) - - it('returns too_new when server is above maximum', () => { - expect(evaluateCompat('2.0.0', range).status).toBe('too_new') - }) - - describe('ignores pre-release/channel suffixes (numeric-core comparison)', () => { - it('treats a pre-release of the upper bound as compatible', () => { - // 1.7.0-rc.1 has core 1.7.0 == maxDify; a suffix-sensitive range would push - // it out of [1.6.0, 1.7.0], but its numeric core is in range. - expect(evaluateCompat('1.7.0-rc.1', range).status).toBe('compatible') - }) - - it('treats a pre-release of the lower bound as compatible', () => { - expect(evaluateCompat('1.6.0-alpha', range).status).toBe('compatible') - }) - - it('still flags a pre-release whose core is below the minimum as too_old', () => { - expect(evaluateCompat('1.5.9-rc.1', range).status).toBe('too_old') - }) - - it('still flags a pre-release whose core is above the maximum as too_new', () => { - expect(evaluateCompat('2.0.0-alpha', range).status).toBe('too_new') - }) - - it('strips suffixes on the range bounds too', () => { - expect(evaluateCompat('1.6.5', { minDify: '1.6.0-alpha', maxDify: '1.7.0-rc' }).status).toBe( - 'compatible', - ) - }) - }) - - it('returns unknown when server version is empty', () => { - expect(evaluateCompat('', range).status).toBe('unknown') - expect(evaluateCompat(undefined, range).status).toBe('unknown') - }) - - it('returns unknown when server version is not valid semver', () => { - const v = evaluateCompat('totally-not-semver', range) - expect(v.status).toBe('unknown') - expect(v.detail).toContain('not valid semver') - }) - - it('clamps malformed server versions to 80 chars in the detail string', () => { - const malicious = 'x'.repeat(10_000) - const v = evaluateCompat(malicious, range) - expect(v.status).toBe('unknown') - // detail = `server version "<=80 chars + ellipsis>" is not valid semver`; - // a bit of leeway for the surrounding text, but nowhere near 10k. - expect(v.detail.length).toBeLessThan(150) - expect(v.detail).toContain('…') - }) - - it('returns unknown when compat range itself is not valid semver', () => { - const v = evaluateCompat('1.6.4', { minDify: 'foo', maxDify: 'bar' }) - expect(v.status).toBe('unknown') - }) - - it('uses the bundled difyCompat range by default', () => { - // Build-info range comes from package.json#difyctl.compat (or env at build - // time); a server version equal to the lower bound must be compatible. - expect(evaluateCompat(difyCompat.minDify).status).toBe('compatible') - }) -}) diff --git a/cli/src/version/compat.ts b/cli/src/version/compat.ts deleted file mode 100644 index bf343540b87..00000000000 --- a/cli/src/version/compat.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { SemVer } from 'std-semver' -import { compare, tryParse } from 'std-semver' - -export type DifyCompat = { - readonly minDify: string - readonly maxDify: string -} - -export const difyCompat: DifyCompat = { - minDify: __DIFYCTL_MIN_DIFY__, - maxDify: __DIFYCTL_MAX_DIFY__, -} - -export function compatString(): string { - return `dify >=${difyCompat.minDify}, <=${difyCompat.maxDify}` -} - -export type CompatStatus = 'compatible' | 'too_old' | 'too_new' | 'unknown' - -export type CompatVerdict = { - readonly status: CompatStatus - readonly detail: string -} - -const DETAIL_MAX_LEN = 80 - -function clamp(s: string): string { - return s.length > DETAIL_MAX_LEN ? `${s.slice(0, DETAIL_MAX_LEN)}…` : s -} - -// Numeric core (major.minor.patch) with pre-release/build stripped, so ordering -// ignores channel suffixes: a 0.2.0-rc.1 build compares equal to the 0.2.0 floor. -function core(v: SemVer): SemVer { - return { major: v.major, minor: v.minor, patch: v.patch, prerelease: [], build: [] } -} - -export function evaluateCompat( - serverVersion: string | undefined, - range: DifyCompat = difyCompat, -): CompatVerdict { - if (serverVersion === undefined || serverVersion === '') - return { status: 'unknown', detail: 'server version unknown' } - - const server = tryParse(serverVersion) - if (server === undefined) - return { - status: 'unknown', - detail: `server version ${JSON.stringify(clamp(serverVersion))} is not valid semver`, - } - - const min = tryParse(range.minDify) - const max = tryParse(range.maxDify) - if (min === undefined || max === undefined) - return { - status: 'unknown', - detail: `compat range ${JSON.stringify(`>=${range.minDify} <=${range.maxDify}`)} is not valid semver`, - } - - if (compare(core(server), core(min)) < 0) - return { - status: 'too_old', - detail: `server ${serverVersion} is older than the minimum ${range.minDify}`, - } - - if (compare(core(server), core(max)) > 0) - return { - status: 'too_new', - detail: `server ${serverVersion} is newer than the tested maximum ${range.maxDify}`, - } - - return { - status: 'compatible', - detail: `server ${serverVersion} in [${range.minDify}, ${range.maxDify}]`, - } -} diff --git a/cli/src/version/enforce.test.ts b/cli/src/version/enforce.test.ts deleted file mode 100644 index ff86aeabe7e..00000000000 --- a/cli/src/version/enforce.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { CompatStore } from '@/cache/compat-store' -import { describe, expect, it, vi } from 'vite-plus/test' -import { ErrorCode } from '@/errors/codes' -import { enforceDifyVersion } from './enforce' - -// Injected build range in tests is __DIFYCTL_MIN_DIFY__=1.6.0 / MAX=1.7.0 (test/setup.ts): -// 1.5.0 → too_old, 1.6.4 → compatible, 99.0.0 → too_new, '' → unknown. -const HOST = 'https://cloud.dify.ai' - -function fakeStore(fresh = false): CompatStore & { readonly marked: string[] } { - const marked: string[] = [] - return { - marked, - isFreshCompatible: () => fresh, - markCompatible: async (host) => { - marked.push(host) - }, - } -} - -const server = (version: string): ServerVersionResponse => ({ version, edition: 'COMMUNITY' }) - -describe('enforceDifyVersion', () => { - it('throws version_skew (exit 6) when the server is too old, and never caches it', async () => { - const store = fakeStore() - const probe = vi.fn(async () => server('1.5.0')) - - await expect(enforceDifyVersion(HOST, { store, probe })).rejects.toMatchObject({ - code: ErrorCode.VersionSkew, - }) - expect(store.marked).toHaveLength(0) - }) - - it('passes and caches when the server is compatible', async () => { - const store = fakeStore() - const probe = vi.fn(async () => server('1.6.4')) - - const res = await enforceDifyVersion(HOST, { store, probe }) - - expect(res?.version).toBe('1.6.4') - expect(store.marked).toEqual([HOST]) - }) - - it('passes (soft, no throw) and caches when the server is too new', async () => { - const store = fakeStore() - const probe = vi.fn(async () => server('99.0.0')) - - await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeDefined() - expect(store.marked).toEqual([HOST]) - }) - - it('skips the probe entirely when the host is fresh-compatible', async () => { - const store = fakeStore(true) - const probe = vi.fn(async () => server('1.5.0')) // would throw if it ran - - await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeUndefined() - expect(probe).not.toHaveBeenCalled() - }) - - it('re-probes despite a fresh cache when forceFresh is set', async () => { - const store = fakeStore(true) - const probe = vi.fn(async () => server('1.5.0')) - - await expect( - enforceDifyVersion(HOST, { store, probe, forceFresh: true }), - ).rejects.toMatchObject({ code: ErrorCode.VersionSkew }) - expect(probe).toHaveBeenCalledOnce() - }) - - it('fails open (never blocks, never caches) when the probe errors', async () => { - const store = fakeStore() - const probe = vi.fn(async () => { - throw new Error('net down') - }) - - await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeUndefined() - expect(store.marked).toHaveLength(0) - }) - - it('does not block or cache on an unknown server version', async () => { - const store = fakeStore() - const probe = vi.fn(async () => server('')) - - await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeDefined() - expect(store.marked).toHaveLength(0) - }) -}) diff --git a/cli/src/version/enforce.ts b/cli/src/version/enforce.ts deleted file mode 100644 index 63865d7832d..00000000000 --- a/cli/src/version/enforce.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { CompatStore } from '@/cache/compat-store' -import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta' -import { loadCompatStore } from '@/cache/compat-store' -import { newError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' -import { createHttpClient } from '@/http/client' -import { openAPIBase } from '@/util/host' -import { difyCompat, evaluateCompat } from './compat' -import { versionInfo } from './info' - -export type ServerVersionProbe = (host: string) => Promise<ServerVersionResponse> - -const UPGRADE_HINT = - `upgrade the Dify server to >= ${difyCompat.minDify} ` + - '(https://docs.dify.ai/en/getting-started/install-self-hosted)' - -// /_version is unauthenticated; same timeout/no-retry budget as the auto-nudge probe. -function buildDefaultProbe(insecure: boolean): ServerVersionProbe { - return async (host) => { - const http = createHttpClient({ - baseURL: openAPIBase(host), - timeoutMs: META_PROBE_TIMEOUT_MS, - retryAttempts: 0, - insecure, - }) - return new MetaClient(http).serverVersion() - } -} - -export type EnforceOptions = { - readonly probe?: ServerVersionProbe - readonly store?: CompatStore - readonly forceFresh?: boolean - readonly insecure?: boolean -} - -/** - * Hard version gate for the client → server direction: refuse a Dify server older - * than this difyctl requires (its removed paths would only 404 otherwise). - * - * Cached: a host recently confirmed compatible is not re-probed for COMPAT_TTL_MS. - * Only "compatible" is cached, so a just-upgraded server clears a previous block at - * once. Fails open on any probe error — a flaky network never blocks a command. - * Returns the probed server version when it actually probed (skipped/failed → undefined), - * so the caller can reuse it. - */ -export async function enforceDifyVersion( - host: string, - opts: EnforceOptions = {}, -): Promise<ServerVersionResponse | undefined> { - const store = opts.store ?? (await loadCompatStore()) - if (opts.forceFresh !== true && store.isFreshCompatible(host)) return undefined - - const probe = opts.probe ?? buildDefaultProbe(opts.insecure === true) - let server: ServerVersionResponse - try { - server = await probe(host) - } catch { - return undefined - } - - const verdict = evaluateCompat(server.version) - if (verdict.status === 'too_old') { - throw newError( - ErrorCode.VersionSkew, - `Dify server ${server.version} is too old for difyctl ${versionInfo.version}: ${verdict.detail}`, - ).withHint(UPGRADE_HINT) - } - - if (verdict.status === 'compatible' || verdict.status === 'too_new') - await store.markCompatible(host) - - return server -} diff --git a/cli/src/version/info.test.ts b/cli/src/version/info.test.ts index f3fd064e924..639a02c073c 100644 --- a/cli/src/version/info.test.ts +++ b/cli/src/version/info.test.ts @@ -6,14 +6,12 @@ describe('version info', () => { expect(shortVersion()).toMatch(/^\d+\.\d+\.\d+(-[\w.]+)?$/) }) - it('longVersion includes commit, build date, channel, and compat range', () => { + it('longVersion includes commit, build date, and channel', () => { const out = longVersion() expect(out).toMatch(/^difyctl /) expect(out).toContain('commit') expect(out).toContain('built') expect(out).toContain('channel') - expect(out).toContain('compat:') - expect(out).toMatch(/dify >=\d+\.\d+\.\d.*, <=\d+\.\d+\.\d+/) }) it('userAgent is well-formed', () => { diff --git a/cli/src/version/info.ts b/cli/src/version/info.ts index f807fa56644..fe582c18b2c 100644 --- a/cli/src/version/info.ts +++ b/cli/src/version/info.ts @@ -1,5 +1,4 @@ import { arch, platform } from '@/sys/index' -import { compatString } from './compat' export type Channel = 'dev' | 'alpha' | 'edge' | 'rc' | 'stable' @@ -23,12 +22,11 @@ export function shortVersion(): string { export function longVersion(): string { const { version, commit, buildDate, channel } = versionInfo - return ( - `difyctl ${version} (commit ${commit.slice(0, 7)}, built ${buildDate}, channel ${channel})\n` + - `compat: ${compatString()}` - ) + return `difyctl ${version} (commit ${commit.slice(0, 7)}, built ${buildDate}, channel ${channel})` } export function userAgent(): string { return `difyctl/${versionInfo.version} (${platform()}; ${arch()}; ${versionInfo.channel})` } + +export const BINARY = 'difyctl' diff --git a/cli/src/version/nudge.test.ts b/cli/src/version/nudge.test.ts deleted file mode 100644 index 14de0020977..00000000000 --- a/cli/src/version/nudge.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { NudgeStore } from '@/cache/nudge-store' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { loadNudgeStore } from '@/cache/nudge-store' -import { ENV_CACHE_DIR } from '@/store/dir' -import { CACHE_NUDGE, getCache } from '@/store/manager' -import { maybeNudgeCompat } from './nudge' - -const HOST = 'https://cloud.dify.ai' -const NOW = new Date('2026-05-20T12:00:00.000Z') -const fixedNow = () => NOW - -type Probe = (host: string) => Promise<ServerVersionResponse> - -const UNSUPPORTED: ServerVersionResponse = { version: '99.0.0', edition: 'COMMUNITY' } -const COMPATIBLE: ServerVersionResponse = { version: '1.6.4', edition: 'CLOUD' } - -function emitterSpy() { - const lines: string[] = [] - return { emit: (line: string) => lines.push(line), lines } -} - -function baseDeps( - overrides: Partial<{ - store: NudgeStore - probe: Probe - emit: (line: string) => void - isTty: boolean - format: string - clientVersion: string - }> & { store: NudgeStore } & { probe: Probe } & { emit: (line: string) => void }, -) { - return { - isTty: true, - format: '', - clientVersion: '0.1.0', - now: fixedNow, - ...overrides, - } -} - -describe('maybeNudgeCompat', () => { - let dir: string - let store: NudgeStore - - 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 - store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow }) - }) - 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('probes + warns when server is unsupported (TTY, text format, never warned)', async () => { - const probe = vi.fn(async () => UNSUPPORTED) - const { emit, lines } = emitterSpy() - - await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit })) - - expect(probe).toHaveBeenCalledOnce() - expect(lines).toHaveLength(1) - expect(lines[0]).toContain('warning:') - expect(lines[0]).toContain('99.0.0') - expect(store.canWarn(HOST)).toBe(false) - }) - - it('does not probe nor warn when throttled (lastWarnedAt within 24h)', async () => { - await store.markWarned(HOST) - const probe = vi.fn(async () => UNSUPPORTED) - const { emit, lines } = emitterSpy() - - await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit })) - - expect(probe).not.toHaveBeenCalled() - expect(lines).toHaveLength(0) - }) - - it('warns again after the silence window has elapsed', async () => { - const yesterday = new Date(NOW.getTime() - 25 * 60 * 60 * 1000) - const tStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => yesterday }) - await tStore.markWarned(HOST) - const probe = vi.fn(async () => UNSUPPORTED) - const { emit, lines } = emitterSpy() - - const freshStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow }) - await maybeNudgeCompat(HOST, baseDeps({ store: freshStore, probe, emit })) - - expect(probe).toHaveBeenCalledOnce() - expect(lines).toHaveLength(1) - }) - - it('does nothing when probe rejects (no warn, no markWarned)', async () => { - const probe: Probe = async () => { - throw new Error('net down') - } - const { emit, lines } = emitterSpy() - - await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit })) - - expect(lines).toHaveLength(0) - expect(store.canWarn(HOST)).toBe(true) - }) - - it('does not warn when server is compatible', async () => { - const probe = vi.fn(async () => COMPATIBLE) - const { emit, lines } = emitterSpy() - - await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit })) - - expect(probe).toHaveBeenCalledOnce() - expect(lines).toHaveLength(0) - expect(store.canWarn(HOST)).toBe(true) - }) - - it('does not warn when server version yields unknown verdict', async () => { - const probe = vi.fn( - async () => ({ version: '', edition: 'COMMUNITY' }) as ServerVersionResponse, - ) - const { emit, lines } = emitterSpy() - - await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit })) - - expect(lines).toHaveLength(0) - expect(store.canWarn(HOST)).toBe(true) - }) - - it.each(['json', 'yaml', 'name'])('skips probe + banner when format=%s', async (format) => { - const probe = vi.fn(async () => UNSUPPORTED) - const { emit, lines } = emitterSpy() - - await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit, format })) - - expect(probe).not.toHaveBeenCalled() - expect(lines).toHaveLength(0) - }) - - it('skips probe + banner when stdout is not a TTY', async () => { - const probe = vi.fn(async () => UNSUPPORTED) - const { emit, lines } = emitterSpy() - - await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit, isTty: false })) - - expect(probe).not.toHaveBeenCalled() - expect(lines).toHaveLength(0) - }) - - it('formats the banner with the injected clientVersion (not a global)', async () => { - const probe = vi.fn(async () => UNSUPPORTED) - const { emit, lines } = emitterSpy() - - await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit, clientVersion: '9.9.9-test' })) - - expect(lines[0]).toContain('difyctl 9.9.9-test') - }) - - it('never throws even when every dependency explodes', async () => { - const explodingStore: NudgeStore = { - canWarn: () => { - throw new Error('canWarn boom') - }, - markWarned: async () => { - throw new Error('markWarned boom') - }, - } - const probe: Probe = async () => { - throw new Error('probe boom') - } - const emit = () => { - throw new Error('emit boom') - } - - await expect( - maybeNudgeCompat( - HOST, - baseDeps({ - store: explodingStore, - probe, - emit, - }), - ), - ).resolves.toBeUndefined() - }) -}) diff --git a/cli/src/version/nudge.ts b/cli/src/version/nudge.ts deleted file mode 100644 index 7eecf63e350..00000000000 --- a/cli/src/version/nudge.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { NudgeStore } from '@/cache/nudge-store' -import { colorScheme } from '@/sys/io/color' -import { difyCompat, evaluateCompat } from './compat' - -// Formats whose stdout is structured data (json/yaml) or a single name token — -// any stderr banner from us would pollute machine parsing. Default text format -// (the empty string) intentionally falls through and is allowed to warn. -const SUPPRESSED_FORMATS: ReadonlySet<string> = new Set(['json', 'yaml', 'name']) - -export type NudgeDeps = { - readonly store: NudgeStore - // /openapi/v1/_version is intentionally unauthenticated (mirrors _health), - // so the probe does not need a bearer. - readonly probe: (host: string) => Promise<ServerVersionResponse> - readonly emit: (line: string) => void - readonly isTty: boolean - readonly format: string - readonly clientVersion: string - readonly color?: boolean - readonly now?: () => Date -} - -// Public guarantee: never throws. Every internal failure is silenced so the -// calling authed command continues regardless of probe / disk errors. -// -// Order matters: cheap suppression checks (format, TTY, throttle window) run -// before any I/O so the happy path costs nothing in steady state. -export async function maybeNudgeCompat(host: string, deps: NudgeDeps): Promise<void> { - try { - if (!deps.isTty) return - if (SUPPRESSED_FORMATS.has(deps.format)) return - if (!deps.store.canWarn(host, deps.now?.())) return - - let server: ServerVersionResponse - try { - server = await deps.probe(host) - } catch { - return - } - - const verdict = evaluateCompat(server.version) - // Only "too new" is a soft nudge here; "too old" is hard-failed up front by - // enforceDifyVersion, so the command never reaches this path for it. - if (verdict.status !== 'too_new') return - - deps.emit(formatBanner(deps.clientVersion, server.version, deps.color === true)) - await deps.store.markWarned(host, deps.now?.()).catch(() => { - // disk failure must not propagate; the user already saw the banner. - }) - } catch { - // belt-and-braces: any unexpected throw must not affect the business command - } -} - -function formatBanner(clientVersion: string, serverVersion: string, color: boolean): string { - const { yellow } = colorScheme(color) - const { minDify, maxDify } = difyCompat - const line = - `warning: difyctl ${clientVersion} may be incompatible with server ` + - `${serverVersion} (tested: ${minDify}..${maxDify}). ` + - 'Run `difyctl version` for details.' - return `${yellow(line)}\n` -} diff --git a/cli/src/version/probe.test.ts b/cli/src/version/probe.test.ts deleted file mode 100644 index 4468c1ca04d..00000000000 --- a/cli/src/version/probe.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { ActiveContext } from '@/auth/hosts' -import { mkdtemp, rm } from 'node:fs/promises' -import { platform, tmpdir } from 'node:os' -import { join } from 'node:path' -import { startMock } from '@test/fixtures/dify-mock/server' -import { describe, expect, it } from 'vite-plus/test' -import { Registry } from '@/auth/hosts' -import { ENV_CONFIG_DIR } from '@/store/dir' -import { arch } from '@/sys/index' -import { runVersionProbe } from './probe' - -function active(overrides: Partial<ActiveContext> = {}): ActiveContext { - return { - host: 'cloud.dify.ai', - email: 'test@dify.ai', - ctx: { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } }, - scheme: 'https', - ...overrides, - } -} - -describe('runVersionProbe', () => { - it('returns skipped server + unknown compat when skipServer=true', async () => { - const report = await runVersionProbe({ - skipServer: true, - loadActive: async () => active(), - probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), - }) - - expect(report.server.reachable).toBe(false) - expect(report.server.endpoint).toBe('') - expect(report.compat.status).toBe('unknown') - expect(report.compat.detail).toContain('skipped') - }) - - it('passes only the endpoint to probe (no bearer; /_version is unauth)', async () => { - let observed: string | undefined - const report = await runVersionProbe({ - skipServer: false, - loadActive: async () => active(), - probe: async (endpoint) => { - observed = endpoint - return { version: '1.6.4', edition: 'CLOUD' } - }, - }) - - expect(observed).toBe('https://cloud.dify.ai') - expect(report.compat.status).toBe('compatible') - }) - - it('returns no-host + unknown compat when active context is missing', async () => { - const report = await runVersionProbe({ - skipServer: false, - loadActive: async () => undefined, - probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), - }) - - expect(report.server.reachable).toBe(false) - expect(report.server.endpoint).toBe('') - expect(report.compat.detail).toContain('no host') - }) - - it('returns no-host when active context has empty host', async () => { - const report = await runVersionProbe({ - skipServer: false, - loadActive: async () => active({ host: '' }), - probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), - }) - - expect(report.server.reachable).toBe(false) - expect(report.compat.status).toBe('unknown') - }) - - it('distinguishes loadActive disk failure from no-host configured in the detail', async () => { - const errReport = await runVersionProbe({ - skipServer: false, - loadActive: async () => { - throw new Error('disk-explode') - }, - probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), - }) - expect(errReport.server.reachable).toBe(false) - expect(errReport.server.endpoint).toBe('') - expect(errReport.compat.detail).toContain('unreadable') - - const noHostReport = await runVersionProbe({ - skipServer: false, - loadActive: async () => undefined, - probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), - }) - expect(noHostReport.compat.detail).toContain('no host') - expect(noHostReport.compat.detail).not.toContain('unreadable') - }) - - it('returns compatible report when server is reachable and in range', async () => { - const report = await runVersionProbe({ - skipServer: false, - loadActive: async () => active(), - probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), - }) - - expect(report.server.reachable).toBe(true) - expect(report.server.endpoint).toBe('https://cloud.dify.ai') - expect(report.server.version).toBe('1.6.4') - expect(report.server.edition).toBe('CLOUD') - expect(report.compat.status).toBe('compatible') - }) - - it('returns too_new when server version is above range', async () => { - const report = await runVersionProbe({ - skipServer: false, - loadActive: async () => active(), - probe: async () => ({ version: '99.0.0', edition: 'COMMUNITY' }), - }) - - expect(report.server.reachable).toBe(true) - expect(report.compat.status).toBe('too_new') - }) - - it('returns unknown when server returns an empty version string', async () => { - const report = await runVersionProbe({ - skipServer: false, - loadActive: async () => active(), - probe: async (): Promise<ServerVersionResponse> => ({ version: '', edition: 'COMMUNITY' }), - }) - - expect(report.server.reachable).toBe(true) - expect(report.compat.status).toBe('unknown') - }) - - it('treats probe rejection as unreachable + unknown compat', async () => { - const report = await runVersionProbe({ - skipServer: false, - loadActive: async () => active(), - probe: async () => { - throw new Error('timeout') - }, - }) - - expect(report.server.reachable).toBe(false) - expect(report.server.endpoint).toBe('https://cloud.dify.ai') - expect(report.server.version).toBeUndefined() - expect(report.compat.status).toBe('unknown') - expect(report.compat.detail).toContain('unreachable') - }) - - it('builds endpoint using active scheme when host has no scheme', async () => { - const report = await runVersionProbe({ - skipServer: false, - loadActive: async () => active({ host: 'localhost:5001', scheme: 'http' }), - probe: async () => ({ version: '1.6.4', edition: 'COMMUNITY' }), - }) - - expect(report.server.endpoint).toBe('http://localhost:5001') - }) - - it('default DI: reads hosts file + probes a real /_version end-to-end', async () => { - // Integration sanity — no DI overrides. Resolves config dir from the - // DIFY_CONFIG_DIR override, reads a real hosts.yml from disk, builds a - // real ky client, and hits the dify-mock /openapi/v1/_version endpoint. - const mock = await startMock() - const configDir = await mkdtemp(join(tmpdir(), 'difyctl-probe-')) - const url = new URL(mock.url) - const prevConfig = process.env[ENV_CONFIG_DIR] - try { - process.env[ENV_CONFIG_DIR] = configDir - const reg = Registry.empty('file') - reg.upsert(url.host, 'test@dify.ai', { - account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' }, - }) - reg.setHost(url.host) - reg.setAccount('test@dify.ai') - reg.setScheme(url.host, url.protocol.replace(':', '')) - await reg.save() - process.env[ENV_CONFIG_DIR] = configDir - - const report = await runVersionProbe({ skipServer: false }) - - expect(report.server.reachable).toBe(true) - expect(report.server.endpoint).toBe(mock.url) - expect(report.server.version).toBe('1.6.4') - expect(report.server.edition).toBe('CLOUD') - expect(report.compat.status).toBe('compatible') - } finally { - if (prevConfig === undefined) delete process.env[ENV_CONFIG_DIR] - else process.env[ENV_CONFIG_DIR] = prevConfig - await mock.stop() - await rm(configDir, { recursive: true, force: true }) - } - }) - - it('always includes client metadata in the report', async () => { - const report = await runVersionProbe({ - skipServer: true, - loadActive: async () => undefined, - probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), - }) - - expect(report.client.version).toBeTypeOf('string') - expect(report.client.commit).toBeTypeOf('string') - expect(report.client.channel).toMatch(/^(dev|rc|stable)$/) - expect(report.client.platform).toBe(platform()) - expect(report.client.arch).toBe(arch()) - }) -}) diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts deleted file mode 100644 index 30a1abf79b5..00000000000 --- a/cli/src/version/probe.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { CompatVerdict } from './compat.js' -import type { Channel } from './info.js' -import type { ActiveContext } from '@/auth/hosts' -import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta' -import { Registry } from '@/auth/hosts' -import { createHttpClient } from '@/http/client' -import { arch, platform } from '@/sys/index' -import { activeHostInfo, openAPIBase } from '@/util/host' -import { difyCompat, evaluateCompat } from './compat.js' -import { versionInfo } from './info.js' - -export type ClientBlock = { - readonly version: string - readonly commit: string - readonly buildDate: string - readonly channel: Channel - readonly platform: string - readonly arch: string -} - -export type ServerBlock = { - readonly endpoint: string - readonly reachable: boolean - readonly version?: string - readonly edition?: ServerVersionResponse['edition'] -} - -export type CompatBlock = CompatVerdict & { - readonly minDify: string - readonly maxDify: string -} - -export type VersionReport = { - readonly client: ClientBlock - readonly server: ServerBlock - readonly compat: CompatBlock -} - -// /openapi/v1/_version is intentionally unauthenticated, so the probe does not -// take a bearer. Same signature shape as the auto-nudge probe — easy to swap. -export type MetaProbe = (endpoint: string) => Promise<ServerVersionResponse> - -export type RunVersionProbeOptions = { - readonly skipServer: boolean - readonly loadActive?: () => Promise<ActiveContext | undefined> - readonly probe?: MetaProbe -} - -const defaultLoadActive = async (): Promise<ActiveContext | undefined> => { - return (await Registry.load()).resolveActive() -} - -function buildDefaultProbe(insecure: boolean): MetaProbe { - return async (endpoint) => { - const http = createHttpClient({ - baseURL: openAPIBase(endpoint), - timeoutMs: META_PROBE_TIMEOUT_MS, - retryAttempts: 0, - insecure, - }) - return new MetaClient(http).serverVersion() - } -} - -function buildClientBlock(): ClientBlock { - return { - version: versionInfo.version, - commit: versionInfo.commit, - buildDate: versionInfo.buildDate, - channel: versionInfo.channel, - platform: platform(), - arch: arch(), - } -} - -function unreachableServer(endpoint: string): ServerBlock { - return { endpoint, reachable: false } -} - -function compatBlock(verdict: CompatVerdict): CompatBlock { - return { - minDify: difyCompat.minDify, - maxDify: difyCompat.maxDify, - status: verdict.status, - detail: verdict.detail, - } -} - -export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<VersionReport> { - const client = buildClientBlock() - - if (opts.skipServer) { - return { - client, - server: { endpoint: '', reachable: false }, - compat: compatBlock({ status: 'unknown', detail: 'server probe skipped' }), - } - } - - const loadActive = opts.loadActive ?? defaultLoadActive - - let active: ActiveContext | undefined - let loadFailed = false - try { - active = await loadActive() - } catch { - loadFailed = true - } - - if (active === undefined || active.host === '') { - const detail = loadFailed ? 'hosts file unreadable' : 'no host configured' - return { - client, - server: { endpoint: '', reachable: false }, - compat: compatBlock({ status: 'unknown', detail }), - } - } - - const { host: endpoint, insecure } = activeHostInfo(active) - const probe = opts.probe ?? buildDefaultProbe(insecure) - - let serverInfo: ServerVersionResponse | undefined - try { - serverInfo = await probe(endpoint) - } catch { - serverInfo = undefined - } - - if (serverInfo === undefined) - return { - client, - server: unreachableServer(endpoint), - compat: compatBlock({ status: 'unknown', detail: 'server unreachable' }), - } - - return { - client, - server: { - endpoint, - reachable: true, - version: serverInfo.version, - edition: serverInfo.edition, - }, - compat: compatBlock(evaluateCompat(serverInfo.version)), - } -} diff --git a/cli/src/version/render.test.ts b/cli/src/version/render.test.ts deleted file mode 100644 index e6761318aaa..00000000000 --- a/cli/src/version/render.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { VersionReport } from './probe' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { renderVersionText } from './render' - -function baseClient(overrides: Partial<VersionReport['client']> = {}): VersionReport['client'] { - return { - version: '0.1.0-rc.1', - commit: '2fd7b82970abcdef', - buildDate: '2026-05-18T00:00:00Z', - channel: 'stable', - platform: 'darwin', - arch: 'arm64', - ...overrides, - } -} - -function compatible(): VersionReport['compat'] { - return { - minDify: '1.6.0', - maxDify: '1.7.0', - status: 'compatible', - detail: 'server 1.6.4 in [1.6.0, 1.7.0]', - } -} - -// Regex matching the ANSI CSI introducer (ESC `[`). -const ANSI_RE = /\[/ - -describe('renderVersionText', () => { - it('renders all three blocks for a reachable, compatible server', () => { - const report: VersionReport = { - client: baseClient(), - server: { - endpoint: 'https://cloud.dify.ai', - reachable: true, - version: '1.6.4', - edition: 'CLOUD', - }, - compat: compatible(), - } - const text = renderVersionText(report) - - expect(text).toContain('Client:') - expect(text).toContain('Version: 0.1.0-rc.1 (channel: stable)') - expect(text).toContain('Commit: 2fd7b82') - expect(text).toContain('Platform: darwin/arm64') - expect(text).toContain('Compat: dify >=1.6.0, <=1.7.0') - - expect(text).toContain('Server:') - expect(text).toContain('Endpoint: https://cloud.dify.ai') - expect(text).toContain('Version: 1.6.4 (cloud)') - - expect(text).toContain('Compatibility: ok') - expect(text).toContain('server 1.6.4 in [1.6.0, 1.7.0]') - - expect(text).not.toContain('WARNING:') - }) - - it('appends warning when channel is rc', () => { - const report: VersionReport = { - client: baseClient({ channel: 'rc' }), - server: { endpoint: '', reachable: false }, - compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' }, - } - const text = renderVersionText(report) - - expect(text).toContain('WARNING: This build is a(n) rc release') - expect(text).toContain('install or wait for the stable channel') - }) - - it('appends warning when channel is edge', () => { - const report: VersionReport = { - client: baseClient({ channel: 'edge' }), - server: { endpoint: '', reachable: false }, - compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' }, - } - const text = renderVersionText(report) - - expect(text).toContain('WARNING: This build is a(n) edge release') - expect(text).toContain('install or wait for the stable channel') - }) - - it('does not append warning when channel is stable', () => { - const report: VersionReport = { - client: baseClient({ channel: 'stable' }), - server: { endpoint: '', reachable: false }, - compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' }, - } - const text = renderVersionText(report) - - expect(text).not.toContain('WARNING:') - }) - - it('shows "(skipped …)" when server.endpoint is empty', () => { - const report: VersionReport = { - client: baseClient(), - server: { endpoint: '', reachable: false }, - compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' }, - } - const text = renderVersionText(report) - - expect(text).toContain('(skipped — no host configured or --client passed)') - expect(text).not.toContain('Endpoint: ') - expect(text).toContain('Compatibility: unknown') - }) - - it('shows "(unreachable)" when endpoint is set but reachable=false', () => { - const report: VersionReport = { - client: baseClient(), - server: { endpoint: 'https://cloud.dify.ai', reachable: false }, - compat: { ...compatible(), status: 'unknown', detail: 'server unreachable' }, - } - const text = renderVersionText(report) - - expect(text).toContain('Endpoint: https://cloud.dify.ai') - expect(text).toContain('Version: (unreachable)') - }) - - it('color=false produces no ANSI escape sequences regardless of TTY state', () => { - const report: VersionReport = { - client: baseClient({ channel: 'rc' }), - server: { - endpoint: 'https://cloud.dify.ai', - reachable: true, - version: '99.0.0', - edition: 'COMMUNITY', - }, - compat: { - minDify: '1.6.0', - maxDify: '1.7.0', - status: 'too_new', - detail: 'server 99.0.0 outside [1.6.0, 1.7.0]', - }, - } - const plain = renderVersionText(report, { color: false }) - // Negative-side proof: every code path that could colorize (verdict + - // RC warning) ran, yet the output is byte-clean. - expect(plain).not.toMatch(ANSI_RE) - expect(plain).toContain('Compatibility: incompatible') - expect(plain).toContain('WARNING: This build is a(n) rc release') - }) - - describe('with picocolors stubbed to always emit ANSI', () => { - // picocolors caches its capability detection at module load, so vitest - // env-var tricks don't change its behavior at runtime. Instead, stub the - // module to return real ANSI-wrapped strings — this proves the color=true - // path actually routes through the colorizer (otherwise the marker is absent). - beforeEach(() => { - vi.resetModules() - vi.doMock('picocolors', () => ({ - default: { - yellow: (s: string) => `${s}`, - dim: (s: string) => `${s}`, - green: (s: string) => `${s}`, - red: (s: string) => `${s}`, - bold: (s: string) => `${s}`, - cyan: (s: string) => `${s}`, - magenta: (s: string) => `${s}`, - }, - })) - }) - afterEach(() => { - vi.doUnmock('picocolors') - vi.resetModules() - }) - - it('color=true emits ANSI sequences for verdict and RC warning lines', async () => { - const { renderVersionText: render } = await import('./render') - const report: VersionReport = { - client: baseClient({ channel: 'rc' }), - server: { - endpoint: 'https://cloud.dify.ai', - reachable: true, - version: '99.0.0', - edition: 'COMMUNITY', - }, - compat: { - minDify: '1.6.0', - maxDify: '1.7.0', - status: 'too_new', - detail: 'server 99.0.0 outside [1.6.0, 1.7.0]', - }, - } - const colored = render(report, { color: true }) - expect(colored).toMatch(ANSI_RE) - expect(colored).toContain('Compatibility: incompatible') - // prerelease warning lines also routed through yellow. - expect(colored).toContain('WARNING: This build is a(n) rc release') - }) - }) - - it('terminates output with a trailing newline', () => { - const report: VersionReport = { - client: baseClient(), - server: { endpoint: '', reachable: false }, - compat: { ...compatible(), status: 'unknown', detail: 'x' }, - } - expect(renderVersionText(report).endsWith('\n')).toBe(true) - }) -}) diff --git a/cli/src/version/render.ts b/cli/src/version/render.ts deleted file mode 100644 index d3dc184bf9d..00000000000 --- a/cli/src/version/render.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Channel } from './info' -import type { VersionReport } from './probe' -import { colorScheme } from '@/sys/io/color' - -function prereleaseWarning(channel: Channel): readonly string[] { - return [ - `WARNING: This build is a(n) ${channel} release. It is not stable`, - ' and may have bugs. For production use, install or wait for the stable channel.', - ] -} - -export type RenderOptions = { - readonly color?: boolean -} - -const COMPAT_LABEL: Record<VersionReport['compat']['status'], string> = { - compatible: 'ok', - too_old: 'incompatible (server too old)', - too_new: 'incompatible (server too new)', - unknown: 'unknown', -} - -function shortCommit(commit: string): string { - return commit.length > 7 ? commit.slice(0, 7) : commit -} - -export function renderVersionText(report: VersionReport, opts: RenderOptions = {}): string { - const c = colorScheme(opts.color === true) - const lines: string[] = [] - - const { client, server, compat } = report - lines.push('Client:') - lines.push(` Version: ${client.version} (channel: ${client.channel})`) - lines.push(` Commit: ${shortCommit(client.commit)} (built ${client.buildDate})`) - lines.push(` Platform: ${client.platform}/${client.arch}`) - lines.push(` Compat: dify >=${compat.minDify}, <=${compat.maxDify}`) - lines.push('') - - lines.push('Server:') - if (server.endpoint === '') { - lines.push(` ${c.dim('(skipped — no host configured or --client passed)')}`) - } else if (!server.reachable) { - lines.push(` Endpoint: ${server.endpoint}`) - lines.push(` Version: ${c.dim('(unreachable)')}`) - } else { - lines.push(` Endpoint: ${server.endpoint}`) - lines.push( - ` Version: ${server.version ?? ''}${server.edition !== undefined ? ` (${server.edition.toLowerCase()})` : ''}`, - ) - } - lines.push('') - - const verdictText = `Compatibility: ${COMPAT_LABEL[compat.status]} — ${compat.detail}` - const incompatible = compat.status === 'too_old' || compat.status === 'too_new' - lines.push(incompatible ? c.yellow(verdictText) : verdictText) - - if (client.channel !== 'stable') { - lines.push('') - for (const line of prereleaseWarning(client.channel)) lines.push(c.yellow(line)) - } - - return `${lines.join('\n')}\n` -} diff --git a/cli/src/workspace/resolver.test.ts b/cli/src/workspace/resolver.test.ts deleted file mode 100644 index cbb1efd9ac5..00000000000 --- a/cli/src/workspace/resolver.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import { describe, expect, it } from 'vite-plus/test' -import { resolveWorkspaceId } from './resolver' - -function active(workspaceId?: string): ActiveContext { - return { - host: 'h', - email: 'e', - ctx: { - account: { id: '', email: 'e', name: '' }, - workspace: workspaceId ? { id: workspaceId, name: 'W', role: 'owner' } : undefined, - }, - } -} - -const UUID_FLAG = 'aaaaaaaa-0000-0000-0000-000000000001' -const UUID_ENV = 'aaaaaaaa-0000-0000-0000-000000000002' -const UUID_CTX = 'aaaaaaaa-0000-0000-0000-000000000003' - -describe('resolveWorkspaceId', () => { - it('prefers the flag', () => { - expect(resolveWorkspaceId({ flag: UUID_FLAG, env: UUID_ENV, active: active(UUID_CTX) })).toBe( - UUID_FLAG, - ) - }) - it('falls back to env over active workspace', () => { - expect(resolveWorkspaceId({ env: UUID_ENV, active: active(UUID_CTX) })).toBe(UUID_ENV) - }) - it('falls back to active workspace when no flag or env', () => { - expect(resolveWorkspaceId({ active: active(UUID_CTX) })).toBe(UUID_CTX) - }) - it('throws when active workspace ID is not a valid UUID', () => { - expect(() => resolveWorkspaceId({ active: active('ws-ctx') })).toThrow(/stored workspace ID/) - }) - it('throws when no workspace is selected (no implicit default)', () => { - expect(() => resolveWorkspaceId({ active: active(undefined) })).toThrow(/no workspace selected/) - }) -}) diff --git a/cli/src/workspace/resolver.ts b/cli/src/workspace/resolver.ts deleted file mode 100644 index 91ecf709cc1..00000000000 --- a/cli/src/workspace/resolver.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ActiveContext } from '@/auth/hosts' -import { BaseError } from '@/errors/base' -import { ErrorCode } from '@/errors/codes' - -export type WorkspaceResolveInputs = { - readonly flag?: string - readonly env?: string - readonly active?: ActiveContext -} - -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - -export function isValidUuid(v: string): boolean { - return UUID_RE.test(v) -} - -export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string { - if (truthy(inputs.flag)) { - if (!isValidUuid(inputs.flag)) - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: `--workspace value ${JSON.stringify(inputs.flag)} is not a valid UUID`, - }) - return inputs.flag - } - if (truthy(inputs.env)) { - if (!isValidUuid(inputs.env)) - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: `DIFY_WORKSPACE_ID value ${JSON.stringify(inputs.env)} is not a valid UUID`, - }) - return inputs.env - } - const wsId = inputs.active?.ctx.workspace?.id - if (truthy(wsId)) { - if (!isValidUuid(wsId)) { - throw new BaseError({ - code: ErrorCode.UsageInvalidFlag, - message: `stored workspace ID ${JSON.stringify(wsId)} is not a valid UUID`, - hint: "run 'difyctl use workspace' to update your active workspace", - }) - } - return wsId - } - throw new BaseError({ - code: ErrorCode.UsageMissingArg, - message: 'no workspace selected', - hint: "pass --workspace, set DIFY_WORKSPACE_ID, or run 'difyctl use workspace <id>'", - }) -} - -function truthy(v: string | undefined): v is string { - return v !== undefined && v !== '' -} diff --git a/cli/test/e2e/helpers/cli.ts b/cli/test/e2e/helpers/cli.ts index 1600ea5f7ca..a744ab1e451 100644 --- a/cli/test/e2e/helpers/cli.ts +++ b/cli/test/e2e/helpers/cli.ts @@ -88,9 +88,6 @@ export function run(argv: string[], opts: RunOptions = {}): Promise<RunResult> { // Suppress interactive prompts in all E2E tests. CI: '1', NO_COLOR: '1', - // Force file-based token storage to avoid macOS keychain UI prompts - // blocking child processes spawned by vitest workers. - DIFY_E2E_NO_KEYRING: '1', // Point the CLI at the isolated config directory. ...(opts.configDir !== undefined ? { DIFY_CONFIG_DIR: opts.configDir } : {}), ...opts.env, @@ -171,7 +168,7 @@ export type AuthInjectionOptions = { workspaceId: string workspaceName: string workspaceRole?: string - /** Full available workspace list. Defaults to the primary workspace only. */ + /** Full available get workspace. Defaults to the primary workspace only. */ availableWorkspaces?: Array<{ id: string; name: string; role: string }> /** * Server-side session UUID (OAuthAccessToken.id). diff --git a/cli/test/e2e/setup/global-setup.ts b/cli/test/e2e/setup/global-setup.ts index 17b7966a3b6..681e2a875bb 100644 --- a/cli/test/e2e/setup/global-setup.ts +++ b/cli/test/e2e/setup/global-setup.ts @@ -541,7 +541,7 @@ async function provisionApps( body: JSON.stringify({ tenant_id: wsId }), signal: AbortSignal.timeout(10_000), }) - if (!r.ok) throw new Error(`workspace switch to ${wsId} failed: HTTP ${r.status}`) + if (!r.ok) throw new Error(`switch workspace to ${wsId} failed: HTTP ${r.status}`) } async function findAppByName(name: string): Promise<string | null> { diff --git a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts b/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts deleted file mode 100644 index 8e6b9a82c8f..00000000000 --- a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts +++ /dev/null @@ -1,717 +0,0 @@ -/** - * E2E: Agent-via-Skill Workflow - * - * Scenario: an AI agent has loaded difyctl SKILL.md and drives difyctl to: - * 1. Bootstrap - read SKILL.md via `skills install --stdout` - * 2. Discover - compact sitemap, then per-command help - * 3. Auth check - no token → exit 4 + JSON error envelope - * 4. Discover apps - `get app -o json` - * 5. Describe app - `describe app <id> -o json` - * 6. Run app - `run app <id> -o json` - * 7. Error handling - JSON envelope on stderr, branch on error.code - * 8. HITL - paused JSON payload - * 9. Effect guard - check effect before write/destructive actions - * 10. Pipeline safety - no ANSI/spinner, stdout/stderr separation - * - * PRD: §3 Agent-Driven, §5 Agent onboarding, Req 1.3/2.1/2.3/3.1-3.3 - * Agent Skills PRD: §4/§5.2 SKILL.md → live command discovery - * - * Groups 1-3, 9: no auth required (local mode compatible) - * Groups 4-8, 10: require DIFY_E2E_TOKEN / staging — wrapped with optionalIt - */ - -import type { AuthFixture, RunResult } from '../../helpers/cli.js' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { - assertErrorEnvelope, - assertExitCode, - assertJson, - assertNoAnsi, - assertPipeFriendlyJson, -} from '../../helpers/assert.js' -import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error injected by vitest global-setup -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -const itWithAuth = optionalIt(Boolean(E.token)) -const itWithSso = optionalIt(Boolean(E.ssoToken)) -const itWithChat = optionalIt(Boolean(E.token) && Boolean(E.chatAppId)) -const itWithWorkflow = optionalIt(Boolean(E.token) && Boolean(E.workflowAppId)) -const itWithHitl = optionalIt(Boolean(E.token) && Boolean(E.hitlAppId)) - -// --------------------------------------------------------------------------- -// 1 + 2. Skill bootstrap → compact sitemap → per-command help -// --------------------------------------------------------------------------- - -describe('E2E / agent skill — bootstrap + discovery (no auth)', () => { - it('[P0] SKILL.md directs agents through compact sitemap and per-command help', async () => { - const r = await run(['skills', 'install', '--stdout']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('difyctl help -o json --compact') - expect(r.stdout).toContain('difyctl help <path> -o json') - expect(r.stdout.indexOf('difyctl help -o json --compact')).toBeLessThan( - r.stdout.indexOf('difyctl help <path> -o json'), - ) - }) - - it('[P0] SKILL.md enumerates no commands from the tree (zero drift surface)', async () => { - const helpR = await run(['help', '-o', 'json']) - expect(helpR.exitCode).toBe(0) - const { commands } = JSON.parse(helpR.stdout) as { commands: Array<{ command: string }> } - const skillR = await run(['skills', 'install', '--stdout']) - expect(skillR.exitCode).toBe(0) - const ALLOWED = new Set(['resume app', 'skills install', 'version']) - for (const { command } of commands) { - if (ALLOWED.has(command)) continue - expect(skillR.stdout, `skill must not enumerate "${command}"`).not.toContain(command) - } - }) - - it('[P0] SKILL.md explains HITL pause is not a crash', async () => { - const r = await run(['skills', 'install', '--stdout']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toMatch(/paused/i) - expect(r.stdout).toMatch(/not a (crash|failure)/i) - }) - - it('[P0] SKILL.md version stamp matches `difyctl version`', async () => { - const skillR = await run(['skills', 'install', '--stdout']) - const verR = await run(['version']) - expect(skillR.exitCode).toBe(0) - expect(verR.exitCode).toBe(0) - const m = skillR.stdout.match(/difyctl skill v([.\w-]+)/) - expect(m).not.toBeNull() - expect(verR.stdout).toContain(m![1]) - }) - - it('[P0] `help -o json` sitemap has bin, contract, commands, topics', async () => { - const r = await run(['help', '-o', 'json']) - expect(r.exitCode).toBe(0) - const map = JSON.parse(r.stdout) - expect(map.bin).toBe('difyctl') - expect(map.contract.exitCodes['0']).toMatch(/success/i) - expect(map.contract.exitCodes['2']).toBeDefined() - expect(map.contract.exitCodes['4']).toBeDefined() - expect(map.contract.errorEnvelope.shape).toContain('hint') - expect(map.contract.hitl.resume).toContain('difyctl resume app') - expect(Array.isArray(map.commands)).toBe(true) - expect(map.commands.every((c: { effect?: unknown }) => typeof c.effect === 'string')).toBe(true) - expect(map.topics.map((t: { name: string }) => t.name)).toEqual( - expect.arrayContaining(['account', 'agent', 'environment', 'external']), - ) - }) - - it('[P0] `help -o json --compact` emits only command, description, and effect', async () => { - const r = await run(['help', '-o', 'json', '--compact']) - expect(r.exitCode).toBe(0) - assertPipeFriendlyJson(r) - const map = JSON.parse(r.stdout) as { - commands: Array<Record<string, unknown>> - bin?: unknown - contract?: unknown - } - expect(map.bin).toBeUndefined() - expect(map.contract).toBeUndefined() - expect(map.commands.length).toBeGreaterThan(0) - for (const command of map.commands) { - expect(Object.keys(command)).toEqual(['command', 'description', 'effect']) - expect(typeof command.command).toBe('string') - expect(typeof command.effect).toBe('string') - } - }) - - it('[P0] every command in help -o json has args, flags, examples arrays', async () => { - const { commands } = JSON.parse((await run(['help', '-o', 'json'])).stdout) as { - commands: Array<{ command: string; args: unknown; flags: unknown; examples: unknown }> - } - for (const cmd of commands) { - expect(Array.isArray(cmd.args), `${cmd.command}.args`).toBe(true) - expect(Array.isArray(cmd.flags), `${cmd.command}.flags`).toBe(true) - expect(Array.isArray(cmd.examples), `${cmd.command}.examples`).toBe(true) - } - }) - - it('[P0] `help -o json` stdout is pipe-safe (no ANSI, starts with {, ends with newline)', async () => { - const r = await run(['help', '-o', 'json']) - expect(r.exitCode).toBe(0) - assertNoAnsi(r.stdout, 'help -o json stdout') - assertPipeFriendlyJson(r) - }) - - it('[P0] per-command: `help run app -o json` has agentGuide and effect=write', async () => { - const r = await run(['help', 'run', 'app', '-o', 'json']) - expect(r.exitCode).toBe(0) - const d = JSON.parse(r.stdout) - expect(d.command).toBe('run app') - expect(d.effect).toBe('write') - expect(typeof d.agentGuide).toBe('string') - expect((d.agentGuide as string).length).toBeGreaterThan(0) - }) - - it('[P0] per-command: `help auth login -o json` agentGuide mentions DIFY_TOKEN', async () => { - const r = await run(['help', 'auth', 'login', '-o', 'json']) - expect(r.exitCode).toBe(0) - expect(JSON.parse(r.stdout).agentGuide).toMatch(/DIFY_TOKEN|non-interactive/i) - }) - - it('[P1] effect=read for get app, describe app', async () => { - for (const cmd of [ - ['help', 'get', 'app', '-o', 'json'], - ['help', 'describe', 'app', '-o', 'json'], - ]) { - const r = await run(cmd) - expect(r.exitCode).toBe(0) - expect(JSON.parse(r.stdout).effect).toBe('read') - } - }) - - it('[P1] effect=destructive for auth devices revoke', async () => { - const r = await run(['help', 'auth', 'devices', 'revoke', '-o', 'json']) - expect(r.exitCode).toBe(0) - expect(JSON.parse(r.stdout).effect).toBe('destructive') - }) - - it('[P1] `help agent` covers DISCOVERY, AUTH, EXIT CODES, ERRORS, HUMAN-IN-THE-LOOP, RETRY', async () => { - const r = await run(['help', 'agent']) - expect(r.exitCode).toBe(0) - for (const section of [ - 'DISCOVERY', - 'AUTH', - 'EXIT CODES', - 'ERRORS', - 'HUMAN-IN-THE-LOOP', - 'RETRY', - ]) - expect(r.stdout, `missing section: ${section}`).toContain(section) - }) -}) - -// --------------------------------------------------------------------------- -// 3. Auth check — no token → exit 4 + JSON error envelope -// --------------------------------------------------------------------------- - -describe('E2E / agent skill — auth error handling (no token)', () => { - it('[P0] no token → exit 4 (auth error, not exit 1 or 2)', async () => { - const tc = await withTempConfig() - try { - const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) - expect(r.exitCode).toBe(4) - } finally { - await tc.cleanup() - } - }) - - it('[P0] no token + -o json → stderr is parseable JSON error envelope', async () => { - const tc = await withTempConfig() - try { - const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) - assertErrorEnvelope(r) - } finally { - await tc.cleanup() - } - }) - - it('[P0] error envelope has hint field pointing to recovery action', async () => { - const tc = await withTempConfig() - try { - const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) - const env = assertErrorEnvelope(r) - expect(typeof env.error.hint).toBe('string') - expect(env.error.hint!.length).toBeGreaterThan(0) - expect(env.error.hint).toMatch(/auth login|DIFY_TOKEN/i) - } finally { - await tc.cleanup() - } - }) - - it('[P0] no token → stdout is empty (error only on stderr)', async () => { - const tc = await withTempConfig() - try { - const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) - expect(r.stdout.trim()).toBe('') - } finally { - await tc.cleanup() - } - }) - - it('[P1] usage error (bad flag) → non-zero exit, not exit 4 (agent can distinguish auth vs usage)', async () => { - // CLI returns exit 1 for unknown flags (not exit 2 as PRD specifies). - // Known deviation: CLI framework does not differentiate usage errors vs generic errors here. - // Agent contract: exit != 0 AND exit != 4 → not an auth error, can diagnose flag issue. - const tc = await withTempConfig() - try { - const r = await run(['get', 'app', '--unknown-flag-xyz-e2e'], { configDir: tc.configDir }) - expect(r.exitCode).not.toBe(0) - expect(r.exitCode).not.toBe(4) - } finally { - await tc.cleanup() - } - }) - - it('[P1] stderr is pure JSON on auth error — entire trim() parses as JSON', async () => { - const tc = await withTempConfig() - try { - const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) - expect(() => JSON.parse(r.stderr.trim())).not.toThrow() - } finally { - await tc.cleanup() - } - }) -}) - -// --------------------------------------------------------------------------- -// 4. get app -o json — app discovery -// --------------------------------------------------------------------------- - -describe('E2E / agent skill — get app -o json (auth required)', () => { - let fx: AuthFixture - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - itWithAuth('[P0] exits 0 and stdout is parseable JSON', async () => { - const r = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(r, 0) - expect(() => JSON.parse(r.stdout)).not.toThrow() - }) - - itWithAuth('[P0] result is array or {data:[]} — agent can iterate app list', async () => { - const r = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(r, 0) - const p = assertJson<unknown>(r) - const isIterable = - Array.isArray(p) || - (typeof p === 'object' && p !== null && Array.isArray((p as Record<string, unknown>).data)) - expect(isIterable).toBe(true) - }) - - itWithAuth('[P0] stdout has no ANSI — safe to pipe through jq', async () => { - const r = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(r, 0) - assertNoAnsi(r.stdout, 'stdout') - assertPipeFriendlyJson(r) - }) - - itWithAuth('[P0] stderr is empty on success', async () => { - const r = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(r, 0) - expect(r.stderr.trim()).toBe('') - }) - - itWithAuth('[P1] each app entry has id and mode (agent needs these for run app)', async () => { - const r = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(r, 0) - const p = assertJson<unknown>(r) - const items = Array.isArray(p) ? p : ((p as Record<string, unknown>).data as unknown[]) - if ((items as unknown[]).length > 0) { - const first = (items as Record<string, unknown>[])[0]! - expect(first).toHaveProperty('id') - expect(first).toHaveProperty('mode') - } - }) - - itWithAuth('[P1] -o name gives one id per line for xargs pipeline', async () => { - const r = await fx.r(['get', 'app', '-o', 'name']) - assertExitCode(r, 0) - assertNoAnsi(r.stdout, 'stdout') - const lines = r.stdout - .trim() - .split('\n') - .filter((l) => l.trim().length > 0) - for (const line of lines) expect(line.trim()).not.toMatch(/\s/) - }) - - itWithSso('[P0] [SSO] dfoe_ get app -o json → permitted-apps list envelope', async () => { - const tc = await withTempConfig() - try { - const { mkdir, writeFile } = await import('node:fs/promises') - const { join } = await import('node:path') - await mkdir(tc.configDir, { recursive: true }) - await writeFile( - join(tc.configDir, 'hosts.yml'), - `${[ - `current_host: ${E.host}`, - 'token_storage: file', - 'tokens:', - ` bearer: ${E.ssoToken}`, - 'external_subject:', - ' email: sso@example.com', - ' issuer: https://issuer.example.com', - ].join('\n')}\n`, - { mode: 0o600 }, - ) - const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) - assertExitCode(r, 0) - const parsed = assertJson<{ data: unknown[] }>(r) - expect(Array.isArray(parsed.data), 'permitted-apps envelope has a data array').toBe(true) - } finally { - await tc.cleanup() - } - }) -}) - -// --------------------------------------------------------------------------- -// 5. describe app -o json — parameter schema -// --------------------------------------------------------------------------- - -describe('E2E / agent skill — describe app -o json (auth required)', () => { - let fx: AuthFixture - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - itWithChat('[P0] exits 0 and stdout is parseable JSON', async () => { - const r = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json']) - assertExitCode(r, 0) - expect(() => JSON.parse(r.stdout)).not.toThrow() - }) - - itWithChat('[P0] response has mode field — agent selects run strategy', async () => { - const r = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json']) - assertExitCode(r, 0) - const desc = assertJson<Record<string, unknown>>(r) - // describe app wraps mode under info: { info: { mode, name, ... }, parameters, input_schema } - expect(desc.info as Record<string, unknown>).toHaveProperty('mode') - }) - - itWithWorkflow( - '[P0] workflow app response has input schema — agent reads before run', - async () => { - const r = await fx.r(['describe', 'app', E.workflowAppId, '-o', 'json']) - assertExitCode(r, 0) - const d = assertJson<Record<string, unknown>>(r) - const hasSchema = 'user_input_form' in d || 'parameters' in d || 'inputs' in d - expect(hasSchema, 'describe response must contain input schema').toBe(true) - }, - ) - - itWithChat('[P0] stdout has no ANSI — pipe-safe', async () => { - const r = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json']) - assertExitCode(r, 0) - assertNoAnsi(r.stdout, 'stdout') - assertPipeFriendlyJson(r) - }) - - itWithAuth('[P0] invalid (non-UUID) app id → exit 2 + usage error envelope', async () => { - // 'app-id-nonexistent-e2e-xyz' is not a valid UUID; describe app rejects it - // client-side via isValidUuid() with usage_invalid_flag (exit 2). - const r = await fx.r(['describe', 'app', 'app-id-nonexistent-e2e-xyz', '-o', 'json']) - expect(r.exitCode).toBe(2) - assertErrorEnvelope(r) - }) -}) - -// --------------------------------------------------------------------------- -// 6. run app -o json — structured output -// --------------------------------------------------------------------------- - -describe('E2E / agent skill — run app -o json (auth required)', () => { - let fx: AuthFixture - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - itWithChat('[P0] run chat app -o json → exit 0, valid JSON with answer field', async () => { - const r = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'hello', '-o', 'json']), { - attempts: 5, - delayMs: 4000, - shouldRetry: (err) => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), - }) - assertExitCode(r, 0) - const p = assertJson<Record<string, unknown>>(r) - expect(p).toHaveProperty('answer') - expect(typeof p.answer).toBe('string') - }) - - itWithWorkflow('[P0] run workflow -o json → exit 0, JSON contains outputs', async () => { - const r = await withRetry( - () => - fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'agent-e2e', num: 1, enum_var: 'A', paragraph: 'ok' }), - '-o', - 'json', - ]), - { - attempts: 5, - delayMs: 4000, - shouldRetry: (err) => - err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), - }, - ) - assertExitCode(r, 0) - const p = assertJson<Record<string, unknown>>(r) - const hasOutputs = - 'outputs' in p || - ('data' in p && - typeof p.data === 'object' && - p.data !== null && - 'outputs' in (p.data as object)) - expect(hasOutputs, 'workflow -o json must contain outputs').toBe(true) - }) - - itWithChat('[P0] stdout has no ANSI — agent can JSON.parse directly', async () => { - const r = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'pipe-test', '-o', 'json']), { - attempts: 5, - delayMs: 4000, - shouldRetry: (err) => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), - }) - assertExitCode(r, 0) - assertNoAnsi(r.stdout, 'stdout') - assertPipeFriendlyJson(r) - }) - - itWithChat('[P0] stderr is empty on success', async () => { - const r = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'clean-test', '-o', 'json']), { - attempts: 5, - delayMs: 4000, - shouldRetry: (err) => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), - }) - assertExitCode(r, 0) - expect(r.stderr.trim()).toBe('') - }) -}) - -// --------------------------------------------------------------------------- -// 7. Error handling — agent branches on error.code -// --------------------------------------------------------------------------- - -describe('E2E / agent skill — error handling for agent branching (auth required)', () => { - let fx: AuthFixture - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - itWithAuth('[P0] nonexistent app → exit 1, stderr JSON envelope, stdout empty', async () => { - const r = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json']) - expect(r.exitCode).toBe(1) - assertErrorEnvelope(r) - expect(r.stdout.trim()).toBe('') - }) - - itWithAuth( - '[P0] error.code is stable across repeated calls (agent can cache branch logic)', - async () => { - const r1 = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json']) - const r2 = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json']) - const e1 = assertErrorEnvelope(r1) - const e2 = assertErrorEnvelope(r2) - expect(e1.error.code).toBe(e2.error.code) - }, - ) - - itWithAuth('[P0] entire stderr (trimmed) is parseable JSON — no mixed text prefix', async () => { - const r = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json']) - expect(r.exitCode).not.toBe(0) - expect(() => JSON.parse(r.stderr.trim())).not.toThrow() - }) - - itWithAuth('[P0] invalid --inputs JSON → exit 2 (usage), stdout empty', async () => { - const r = await fx.r(['run', 'app', E.chatAppId, '--inputs', 'not-json', '-o', 'json']) - expect(r.exitCode).toBe(2) - expect(r.stdout.trim()).toBe('') - }) -}) - -// --------------------------------------------------------------------------- -// 8. HITL — paused JSON + resume pointer -// --------------------------------------------------------------------------- - -const HITL_TRANSIENT_RE = /server_5xx|5\d{2}|ECONNRESET|timeout/i - -async function runHitlPause(fx: AuthFixture, input: string): Promise<RunResult> { - return withRetry( - async () => { - const result = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: input }), - '-o', - 'json', - ]) - if (result.exitCode !== 0 && HITL_TRANSIENT_RE.test(result.stderr)) - throw new Error(`transient HITL run failure: ${result.stderr.slice(0, 200)}`) - return result - }, - { - attempts: 5, - delayMs: 4000, - shouldRetry: (err) => err instanceof Error && HITL_TRANSIENT_RE.test(err.message), - }, - ) -} - -describe('E2E / agent skill — HITL pause handling (auth required)', () => { - let fx: AuthFixture - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - itWithHitl( - '[P0] HITL app exits 0 and returns paused payload — agent resumes rather than retries', - async () => { - const r = await runHitlPause(fx, 'agent-hitl-exit') - assertExitCode(r, 0) - }, - ) - - itWithHitl('[P0] HITL stdout contains status:paused JSON payload', async () => { - const r = await runHitlPause(fx, 'agent-hitl-status') - assertExitCode(r, 0) - expect(assertJson<Record<string, unknown>>(r).status).toBe('paused') - }) - - itWithHitl('[P0] HITL payload has form_token + workflow_run_id for resume call', async () => { - const r = await runHitlPause(fx, 'agent-hitl-token') - assertExitCode(r, 0) - const p = assertJson<Record<string, unknown>>(r) - expect(p).toHaveProperty('form_token') - expect(p).toHaveProperty('workflow_run_id') - }) -}) - -// --------------------------------------------------------------------------- -// 9. Effect guard — agent checks before write/destructive (no auth) -// --------------------------------------------------------------------------- - -describe('E2E / agent skill — effect guard (no auth)', () => { - it('[P0] run app effect=write — agent expects state change', async () => { - const r = await run(['help', 'run', 'app', '-o', 'json']) - expect(r.exitCode).toBe(0) - expect(JSON.parse(r.stdout).effect).toBe('write') - }) - - it('[P0] state-mutating import and context switches have effect=write', async () => { - for (const args of [ - ['help', 'import', 'studio-app', '-o', 'json'], - ['help', 'use', 'account', '-o', 'json'], - ['help', 'use', 'host', '-o', 'json'], - ]) { - const r = await run(args) - expect(r.exitCode).toBe(0) - expect(JSON.parse(r.stdout).effect).toBe('write') - } - }) - - it('[P0] auth devices revoke effect=destructive — agent must confirm before calling', async () => { - const r = await run(['help', 'auth', 'devices', 'revoke', '-o', 'json']) - expect(r.exitCode).toBe(0) - expect(JSON.parse(r.stdout).effect).toBe('destructive') - }) - - it('[P0] get app and describe app effect=read — agent can call freely', async () => { - for (const args of [ - ['help', 'get', 'app', '-o', 'json'], - ['help', 'describe', 'app', '-o', 'json'], - ]) { - const r = await run(args) - expect(r.exitCode).toBe(0) - expect(JSON.parse(r.stdout).effect).toBe('read') - } - }) - - it('[P0] no command in the full tree has undefined/null effect', async () => { - const { commands } = JSON.parse((await run(['help', '-o', 'json'])).stdout) as { - commands: Array<{ command: string; effect: unknown }> - } - const bad = commands.filter((c) => !c.effect || typeof c.effect !== 'string') - expect(bad.map((c) => c.command)).toEqual([]) - }) - - it('[P1] skills install effect=write', async () => { - const r = await run(['help', 'skills', 'install', '-o', 'json']) - expect(r.exitCode).toBe(0) - expect(JSON.parse(r.stdout).effect).toBe('write') - }) -}) - -// --------------------------------------------------------------------------- -// 10. Pipeline safety — -o json is fully pipe-safe (auth required) -// --------------------------------------------------------------------------- - -describe('E2E / agent skill — pipeline safety (auth required)', () => { - let fx: AuthFixture - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - itWithAuth('[P0] stdout has no ANSI on success under -o json', async () => { - const r = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(r, 0) - assertNoAnsi(r.stdout, 'stdout') - }) - - itWithAuth('[P0] stdout is empty on error under -o json (error → stderr only)', async () => { - const r = await fx.r(['run', 'app', 'nonexistent-e2e-xyz', 'hello', '-o', 'json']) - expect(r.exitCode).not.toBe(0) - expect(r.stdout.trim()).toBe('') - expect(r.stderr.trim().length).toBeGreaterThan(0) - }) - - itWithChat('[P0] stdout is non-empty on success under -o json', async () => { - const r = await withRetry( - () => fx.r(['run', 'app', E.chatAppId, 'pipeline-test', '-o', 'json']), - { - attempts: 5, - delayMs: 4000, - shouldRetry: (err) => - err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), - }, - ) - assertExitCode(r, 0) - expect(r.stdout.trim().length).toBeGreaterThan(0) - expect(r.stderr.trim()).toBe('') - }) - - itWithAuth('[P1] no ANSI on stderr under -o json', async () => { - const tc = await withTempConfig() - try { - const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) - assertNoAnsi(r.stderr, 'stderr') - } finally { - await tc.cleanup() - } - }) - - itWithAuth('[P1] stdout ends with newline (POSIX pipe convention)', async () => { - const r = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(r, 0) - expect(r.stdout.endsWith('\n')).toBe(true) - }) - - itWithAuth('[P1] CI=1 + NO_COLOR=1 produce no spinner artifacts', async () => { - const r = await fx.r(['get', 'app', '-o', 'json'], { CI: '1', NO_COLOR: '1' }) - assertExitCode(r, 0) - assertNoAnsi(r.stdout, 'stdout') - assertNoAnsi(r.stderr, 'stderr') - expect(r.stdout).not.toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/) - }) -}) diff --git a/cli/test/e2e/suites/auth/devices.e2e.ts b/cli/test/e2e/suites/auth/devices.e2e.ts deleted file mode 100644 index cb9896defd2..00000000000 --- a/cli/test/e2e/suites/auth/devices.e2e.ts +++ /dev/null @@ -1,420 +0,0 @@ -/** - * E2E: difyctl auth devices — multi-device session management - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Multi-device Session Management (21 wiki cases → 18 automated) - */ - -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { assertExitCode, assertJson } from '../../helpers/assert.js' -import { injectAuth, mintFreshToken, run, withTempConfig } from '../../helpers/cli.js' -import { optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) -const tokenValid = caps.tokenValid -const tokenId = caps.tokenId - -describe('E2E / difyctl auth devices', () => { - let configDir: string - let cleanup: () => Promise<void> - - beforeEach(async () => { - const tmp = await withTempConfig() - configDir = tmp.configDir - cleanup = tmp.cleanup - - await injectAuth(configDir, { - host: E.host, - bearer: E.token, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - tokenId, - }) - }) - afterEach(async () => { - await cleanup() - }) - - function r(argv: string[]) { - return run(argv, { configDir }) - } - - // ── devices list ───────────────────────────────────────────────────────────── - - const itSessions = optionalIt(tokenValid) - - itSessions('[P0] logged-in user can view the devices list', async () => { - // Spec: logged-in user can view the devices list - const result = await r(['auth', 'devices', 'list']) - assertExitCode(result, 0) - expect(result.stdout.length).toBeGreaterThan(0) - }) - - itSessions('[P0] devices list displays device IDs', async () => { - // Spec: devices list displays device IDs - const result = await r(['auth', 'devices', 'list']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/tok-|id|device/i) - }) - - itSessions('[P0] devices list supports JSON output and returns valid JSON', async () => { - // Spec: devices list supports JSON output - const result = await r(['auth', 'devices', 'list', '--json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[]; total: number }>(result) - expect(parsed).toHaveProperty('data') - expect(Array.isArray(parsed.data)).toBe(true) - }) - - itSessions( - '[P1] devices list JSON schema is stable (contains data and total fields)', - async () => { - // Spec: devices list JSON schema is stable - const result = await r(['auth', 'devices', 'list', '--json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[]; total: number; page: number; limit: number }>( - result, - ) - expect(parsed).toHaveProperty('total') - expect(parsed).toHaveProperty('page') - expect(parsed).toHaveProperty('limit') - }, - ) - - it('[P0] unauthenticated devices list returns auth error (exit code 4)', async () => { - // Spec: unauthenticated devices list returns auth error + exit code 4 - const unauthTmp = await withTempConfig() - try { - const result = await run(['auth', 'devices', 'list'], { configDir: unauthTmp.configDir }) - assertExitCode(result, 4) - expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i) - } finally { - await unauthTmp.cleanup() - } - }) - - // ── devices revoke ─────────────────────────────────────────────────────────── - - itSessions('[P0] revoking a specified device succeeds (exit code 0)', async () => { - // Spec: revoking a specified device succeeds - // Mint a fresh token on demand so this test only revokes its own session, - // never the shared E.token or the global-setup disposableToken. - const freshToken = await mintFreshToken(E.host, E.email, E.password) - if (!freshToken) { - // Credentials not configured — skip rather than risk revoking the main session. - return - } - - // Inject the fresh token into a dedicated config dir - const revokeTmp = await withTempConfig() - try { - await injectAuth(revokeTmp.configDir, { - host: E.host, - bearer: freshToken, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const revokeR = (argv: string[]) => run(argv, { configDir: revokeTmp.configDir }) - - // List sessions authenticated as the fresh token - const listResult = await revokeR(['auth', 'devices', 'list', '--json']) - assertExitCode(listResult, 0) - const { data } = assertJson<{ data: Array<{ id: string; prefix: string }> }>(listResult) - - // Find the entry whose prefix matches the fresh token - const entry = data.find((d) => d.prefix && freshToken.startsWith(d.prefix)) - if (!entry) { - // Fresh session not found — may have been filtered; skip gracefully. - return - } - - const revokeResult = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes']) - assertExitCode(revokeResult, 0) - } finally { - await revokeTmp.cleanup() - } - }) - - // ── Current device marking ─────────────────────────────────────────────────── - - itSessions('[P0] devices list marks the current device in the CURRENT column', async () => { - // Spec 1.90: current device is clearly marked in the CURRENT column - const result = await r(['auth', 'devices', 'list']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/CURRENT/i) - }) - - // ── created_at field ───────────────────────────────────────────────────────── - - itSessions('[P1] devices list output contains created_at timestamp', async () => { - // Spec 1.92: output contains the created_at timestamp - const result = await r(['auth', 'devices', 'list']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/CREATED/i) - expect(result.stdout).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) - }) - - // ── last_used_at null ──────────────────────────────────────────────────────── - - itSessions('[P0] devices list last_used_at is null in JSON when not recorded', async () => { - // Spec 1.93: last_used_at is null in JSON when not yet recorded - const result = await r(['auth', 'devices', 'list', '--json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ last_used_at: string | null }> }>(result) - expect(parsed.data.length).toBeGreaterThan(0) - const hasNullLastUsed = parsed.data.some((d) => d.last_used_at === null) - expect(hasNullLastUsed).toBe(true) - }) - - // ── Revoked device disappears from list ────────────────────────────────────── - - itSessions('[P0] revoked device no longer appears in devices list', async () => { - // Spec 1.99: a revoked device no longer appears in devices list - const freshToken = await mintFreshToken(E.host, E.email, E.password) - if (!freshToken) return - - const revokeTmp = await withTempConfig() - try { - await injectAuth(revokeTmp.configDir, { - host: E.host, - bearer: freshToken, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const revokeR = (argv: string[]) => run(argv, { configDir: revokeTmp.configDir }) - - const listBefore = await revokeR(['auth', 'devices', 'list', '--json']) - assertExitCode(listBefore, 0) - const { data: before } = assertJson<{ data: Array<{ id: string; prefix: string }> }>( - listBefore, - ) - const entry = before.find((d) => d.prefix && freshToken.startsWith(d.prefix)) - if (!entry) return - - const revokeResult = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes']) - assertExitCode(revokeResult, 0) - - // Verify the device no longer appears in the main session's list - const listAfter = await r(['auth', 'devices', 'list', '--json']) - assertExitCode(listAfter, 0) - const { data: after } = assertJson<{ data: Array<{ id: string }> }>(listAfter) - const stillExists = after.some((d) => d.id === entry.id) - expect(stillExists).toBe(false) - } finally { - await revokeTmp.cleanup() - } - }) - - // ── Revoke current device → session invalidated ────────────────────────────── - - itSessions( - '[P0] revoking the current device invalidates the session (auth status returns exit 4)', - async () => { - // Spec 1.100: revoking the current device invalidates the session - // Uses caps.devicesToken (disposable, pre-minted for this suite). - const selfToken = caps.devicesToken - if (!selfToken) return - - const selfTmp = await withTempConfig() - try { - await injectAuth(selfTmp.configDir, { - host: E.host, - bearer: selfToken, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const selfR = (argv: string[]) => run(argv, { configDir: selfTmp.configDir }) - - const listResult = await selfR(['auth', 'devices', 'list', '--json']) - assertExitCode(listResult, 0) - const { data } = assertJson<{ data: Array<{ id: string; prefix: string }> }>(listResult) - const entry = data.find((d) => d.prefix && selfToken.startsWith(d.prefix)) - if (!entry) return - - const revokeResult = await selfR(['auth', 'devices', 'revoke', entry.id, '--yes']) - assertExitCode(revokeResult, 0) - // Revoke succeeded — the session is invalidated on the server. - // Note: the server may cache the token briefly, so immediate API calls - // with the revoked token may still succeed; we verify only that revoke exits 0. - } finally { - await selfTmp.cleanup() - } - }, - ) - - // ── Revoke invalid device id ────────────────────────────────────────────────── - - itSessions('[P1] revoking a non-existent device id returns an error', async () => { - // Spec 1.101: revoking a non-existent device id returns an error - const result = await r([ - 'auth', - 'devices', - 'revoke', - 'invalid-device-id-does-not-exist', - '--yes', - ]) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toMatch(/not.?found|invalid|device|error/i) - }) - - // ── revoke --all ───────────────────────────────────────────────────────────── - - it('[P0] revoke --all exits 0 and revokes all sessions except the current one', async () => { - // Spec 1.102: revoke --all exits 0 and revokes all sessions except the current one - const freshToken = await mintFreshToken(E.host, E.email, E.password) - if (!freshToken) return - - const freshTmp = await withTempConfig() - try { - await injectAuth(freshTmp.configDir, { - host: E.host, - bearer: freshToken, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const freshR = (argv: string[]) => run(argv, { configDir: freshTmp.configDir }) - const result = await freshR(['auth', 'devices', 'revoke', '--all', '--yes']) - // Server may return 500 if other sessions are already revoked; skip gracefully. - if (result.exitCode !== 0) return - assertExitCode(result, 0) - } finally { - await freshTmp.cleanup() - } - }) - - it('[P0] after revoke --all only the current device remains in the list', async () => { - // Spec 1.103: after revoke --all only the current device remains - const freshToken = await mintFreshToken(E.host, E.email, E.password) - if (!freshToken) return - - const freshTmp = await withTempConfig() - try { - await injectAuth(freshTmp.configDir, { - host: E.host, - bearer: freshToken, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const freshR = (argv: string[]) => run(argv, { configDir: freshTmp.configDir }) - - const revokeAllResult = await freshR(['auth', 'devices', 'revoke', '--all', '--yes']) - // Server may return 500 if other sessions are already revoked; skip gracefully. - if (revokeAllResult.exitCode !== 0) return - - const listResult = await freshR(['auth', 'devices', 'list', '--json']) - assertExitCode(listResult, 0) - const parsed = assertJson<{ data: unknown[]; total: number }>(listResult) - expect(parsed.total).toBe(1) - expect(parsed.data).toHaveLength(1) - } finally { - await freshTmp.cleanup() - } - }) - - // ── Network error ──────────────────────────────────────────────────────────── - - it('[P1] revoke returns a network error when the host is unreachable', async () => { - // Spec 1.104: revoke returns a network error when the host is unreachable - const netTmp = await withTempConfig() - try { - await injectAuth(netTmp.configDir, { - host: 'http://unreachable-host-xyz.invalid', - bearer: 'dfoa_network_test_token', - email: E.email, - workspaceId: 'ws-1', - workspaceName: 'Test', - }) - const result = await run(['auth', 'devices', 'revoke', 'any-device-id', '--yes'], { - configDir: netTmp.configDir, - timeout: 10_000, - }) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toMatch(/network|unreachable|connect|server|error/i) - } finally { - await netTmp.cleanup() - } - }) - - // ── dfoe_ session ───────────────────────────────────────────────────────────── - - const itSSO = optionalIt(!!E.ssoToken) - - itSSO('[P1] dfoe_ SSO session can list devices successfully', async () => { - // Spec 1.106: a dfoe_ SSO session can list devices successfully - const ssoTmp = await withTempConfig() - try { - await injectAuth(ssoTmp.configDir, { - host: E.host, - bearer: E.ssoToken, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const result = await run(['auth', 'devices', 'list'], { configDir: ssoTmp.configDir }) - // ssoToken may be expired (server 500); skip gracefully rather than fail. - if (result.exitCode !== 0) return - assertExitCode(result, 0) - expect(result.stdout.length).toBeGreaterThan(0) - } finally { - await ssoTmp.cleanup() - } - }) - - // ── Double revoke ───────────────────────────────────────────────────────────── - - itSessions('[P1] revoking an already-revoked device returns a stable result', async () => { - // Spec 1.107: revoking an already-revoked device returns a stable result - const freshToken = await mintFreshToken(E.host, E.email, E.password) - if (!freshToken) return - - const revokeTmp = await withTempConfig() - try { - await injectAuth(revokeTmp.configDir, { - host: E.host, - bearer: freshToken, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const revokeR = (argv: string[]) => run(argv, { configDir: revokeTmp.configDir }) - - const listResult = await revokeR(['auth', 'devices', 'list', '--json']) - assertExitCode(listResult, 0) - const { data } = assertJson<{ data: Array<{ id: string; prefix: string }> }>(listResult) - const entry = data.find((d) => d.prefix && freshToken.startsWith(d.prefix)) - if (!entry) return - - // First revoke - const r1 = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes']) - assertExitCode(r1, 0) - - // Second revoke of the same id — must not crash - const r2 = await r(['auth', 'devices', 'revoke', entry.id, '--yes']) - expect(r2.exitCode).toBeLessThanOrEqual(4) - } finally { - await revokeTmp.cleanup() - } - }) - - // ── JSON error envelope on revoke failure ──────────────────────────────────── - - itSessions('[P1] revoke of a non-existent device returns a non-empty stderr error', async () => { - // Spec 1.109: a failed revoke emits a non-empty error message on stderr - const result = await r(['auth', 'devices', 'revoke', 'nonexistent-device-id-xyz', '--yes']) - expect(result.exitCode).not.toBe(0) - const stderr = result.stderr.trim() - expect(stderr.length).toBeGreaterThan(0) - if (stderr.startsWith('{')) { - const parsed = JSON.parse(stderr) as { error?: { code: string } } - expect(parsed).toHaveProperty('error') - } - }) -}) diff --git a/cli/test/e2e/suites/auth/login.e2e.ts b/cli/test/e2e/suites/auth/login.e2e.ts deleted file mode 100644 index 2c4c54f90b2..00000000000 --- a/cli/test/e2e/suites/auth/login.e2e.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * E2E: difyctl auth login — Interactive Login (Device Flow) - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Interactive Login (1.1–1.17) - * - * Architecture note: - * The full Device Flow requires a user to open a browser and complete OAuth, which is - * fundamentally outside the scope of an automated CLI E2E test. This suite focuses on - * the **CLI-observable** parts of the login command: - * - Client-side URL validation (1.14) - * - Network-unreachable error path (1.13) - * - Credential file permissions after write (1.8) - * - Initial Device Flow output (stderr contains code + URL) before OAuth completes (1.2) - * - Cross-host warning when a session already exists (1.10) - * - --no-browser prompt format (1.16) - * - Invalid URL input rejection via stdin (1.17) - * - * Cases that require completing real OAuth (1.1, 1.3–1.7, 1.9, 1.11, 1.12, 1.15) are - * marked as it.skip with an explanation. - */ - -import type { Buffer } from 'node:buffer' -import { spawn } from 'node:child_process' -import { stat } from 'node:fs/promises' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { BIN, BUN, injectAuth, run, withTempConfig } from '../../helpers/cli.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -describe('E2E / difyctl auth login', () => { - let configDir: string - let cleanup: () => Promise<void> - - beforeEach(async () => { - const tmp = await withTempConfig() - configDir = tmp.configDir - cleanup = tmp.cleanup - }) - afterEach(async () => { - await cleanup() - }) - - function r(argv: string[], extraOpts: { stdin?: string; timeout?: number } = {}) { - return run(argv, { configDir, ...extraOpts }) - } - - // ── 1.13: Network error ──────────────────────────────────────────────────── - - it('[P0] auth login with unreachable host returns network/connection error (1.13)', async () => { - // Spec 1.13: when the host is unreachable, CLI returns a server/network error. - // 127.0.0.1:19999 has nothing listening — ECONNREFUSED is immediate. - // https:// passes the scheme validation; then ECONNREFUSED fires immediately. - const result = await r(['auth', 'login', '--host', 'https://127.0.0.1:19999'], { - timeout: 15_000, - }) - expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) - expect(result.stderr + result.stdout).toMatch( - /network|connect|ECONNREFUSED|server|unreachable|refused|fetch/i, - ) - }) - - // ── 1.14: Invalid URL format ─────────────────────────────────────────────── - - it('[P1] auth login --host with invalid URL returns usage/connection error (1.14)', async () => { - // Spec 1.14: `auth login --host invalid_url` → CLI returns usage error or connection failure. - const result = await r(['auth', 'login', '--host', 'not_a_valid_url'], { timeout: 10_000 }) - expect(result.exitCode, 'invalid URL should cause non-zero exit').not.toBe(0) - }) - - it('[P1] auth login --host with bare hostname (no scheme) returns error (1.14 variant)', async () => { - // A bare hostname without https:// or http:// is also invalid. - const result = await r(['auth', 'login', '--host', 'just-a-hostname'], { timeout: 10_000 }) - expect(result.exitCode, 'bare hostname should cause non-zero exit').not.toBe(0) - }) - - // ── 1.8: File permissions ────────────────────────────────────────────────── - - it('[P1] hosts.yml credential file permissions are 0600 after auth write (1.8)', async () => { - // Spec 1.8: token written to file-based storage must have permission 0600. - // injectAuth() replicates the same write path the CLI uses for file-fallback storage. - await injectAuth(configDir, { - host: E.host, - bearer: E.token, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const hostsPath = join(configDir, 'hosts.yml') - const fileStat = await stat(hostsPath) - // Extract POSIX mode bits (lower 12 bits) - const mode = fileStat.mode & 0o777 - expect(mode, `hosts.yml must be 0600, got ${mode.toString(8)}`).toBe(0o600) - }) - - // ── 1.2: Device Flow initial output (partial) ───────────────────────────── - - it('[P0] auth login --host outputs device code and verification URL to stderr (1.2)', async () => { - // Spec 1.2: after `auth login --host <host>`, CLI emits one-time code + URL to stderr - // before the user opens the browser. We spawn the process, collect stderr until we see - // the expected output (or 10 s), then SIGINT before any OAuth completes. - // Omit CI=1 so the Device Flow is not suppressed in non-TTY mode. - const proc = spawn(BUN, [BIN, 'auth', 'login', '--host', E.host], { - env: { ...process.env, DIFY_CONFIG_DIR: configDir, NO_COLOR: '1' }, - }) - - let stderrBuf = '' - let stdoutBuf = '' - const seen = await new Promise<boolean>((resolve) => { - const timer = setTimeout(() => resolve(false), 15_000) - const pattern = - /[A-Z0-9]{4}-[A-Z0-9]{4}|https?:\/\/|user.?code|verification|one.?time|device|login/i - proc.stderr.on('data', (chunk: Buffer) => { - stderrBuf += chunk.toString('utf8') - if (pattern.test(stderrBuf)) { - clearTimeout(timer) - resolve(true) - } - }) - proc.stdout.on('data', (chunk: Buffer) => { - stdoutBuf += chunk.toString('utf8') - if (pattern.test(stdoutBuf)) { - clearTimeout(timer) - resolve(true) - } - }) - // If process exits before we see the output, collect what we have - proc.on('close', () => { - clearTimeout(timer) - resolve(false) - }) - }) - - proc.kill('SIGINT') - await new Promise<void>((res) => proc.on('close', () => res())) - - expect( - seen, - `Expected device code or verification URL in CLI output within 10s.\nstderr: ${stderrBuf}\nstdout: ${stdoutBuf}`, - ).toBe(true) - }) - - // ── 1.10: Cross-host warning (partial) ──────────────────────────────────── - - it('[P1] auth login --host <B> when already logged into host <A> exits non-zero or warns (1.10)', async () => { - // Spec 1.10: if a session already exists for host A and the user runs - // `auth login --host B`, CLI must output a warning about the host change. - // We use run() with https:// so the scheme check passes, then check output. - await injectAuth(configDir, { - host: E.host, - bearer: E.token, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - - // Use https:// to bypass scheme validation; ECONNREFUSED fires immediately. - // The warning may appear before or after the connection error depending on CLI version. - const result = await r(['auth', 'login', '--host', 'https://127.0.0.1:19999'], { - timeout: 10_000, - }) - const combined = result.stderr + result.stdout - // Accept any of: cross-host warning, connection error, or non-zero exit (WTA-254 may not be shipped yet) - expect( - result.exitCode !== 0 || - /warn|different.?host|already|switch|ECONNREFUSED|refused|connect|network/i.test(combined), - `Expected non-zero exit or relevant message.\nexitCode: ${result.exitCode}\noutput: ${combined.slice(0, 400)}`, - ).toBe(true) - }) - - // ── 1.16: --no-browser prompt format (partial) ──────────────────────────── - - it('[P1] auth login --no-browser host prompt contains URL format example (1.16)', async () => { - // Spec 1.16: the host-input prompt must include a URL format hint such as - // "https://cloud.dify.ai" or "http://localhost". - // We spawn the process and collect stdout/stderr for up to 5 s, then SIGINT. - const proc = spawn(BUN, [BIN, 'auth', 'login', '--no-browser'], { - env: { ...process.env, DIFY_CONFIG_DIR: configDir, NO_COLOR: '1' }, - // Deliberately omit CI=1 so the interactive prompt is rendered - }) - - let output = '' - const promptSeen = await new Promise<boolean>((resolve) => { - const timer = setTimeout(() => resolve(false), 5_000) - const collect = (chunk: Buffer) => { - output += chunk.toString('utf8') - if (/https?:\/\/|cloud\.dify\.ai|localhost|host/i.test(output)) { - clearTimeout(timer) - resolve(true) - } - } - proc.stderr.on('data', collect) - proc.stdout.on('data', collect) - proc.on('close', () => { - clearTimeout(timer) - resolve(false) - }) - }) - - proc.kill('SIGINT') - await new Promise<void>((res) => proc.on('close', () => res())) - - expect( - promptSeen, - `Expected URL format hint in host prompt within 5s.\noutput: ${output.slice(0, 400)}`, - ).toBe(true) - }) - - // ── 1.17: Invalid URL typed at prompt (partial) ─────────────────────────── - - it('[P1] typing a bare hostname at the host prompt returns a URL format error (1.17)', async () => { - // Spec 1.17: when the user enters a value that is not a valid URL (e.g. "localhost" - // without a scheme), CLI reports an error and re-prompts or exits. - // We pipe "localhost\n" to stdin so the CLI's prompt handler receives invalid input. - const result = await r(['auth', 'login'], { stdin: 'localhost\n', timeout: 10_000 }) - // Either exit non-0 (usage error) or output contains an error message about the URL format. - const combinedOutput = result.stdout + result.stderr - const isValidationError = - result.exitCode !== 0 || /invalid|url|format|scheme|http|expected/i.test(combinedOutput) - expect( - isValidationError, - `Expected non-zero exit or URL validation error.\nexitCode: ${result.exitCode}\noutput: ${combinedOutput.slice(0, 400)}`, - ).toBe(true) - }) - - // ── it.skip: Requires completed Device Flow ──────────────────────────────── - - it.skip('[P0] completing browser OAuth shows "Logged in as" (1.5) — requires real OAuth', () => { - // Cannot automate: depends on user opening a browser and approving the OAuth grant. - }) - - it.skip('[P0] token stored in OS Keychain after login (1.6) — requires completed Device Flow', () => { - // Cannot automate: requires full Device Flow + OS Keychain write verification. - }) - - it.skip('[P0] Keychain unavailable → token written to hosts.yml (1.7) — requires Device Flow + disabled Keychain', () => { - // Cannot automate: requires Keychain to be disabled and Device Flow to complete. - }) - - it.skip('[P0] re-login replaces existing session (1.9) — requires two complete Device Flows', () => { - // Cannot automate: requires completing Device Flow twice. - }) - - it.skip('[P0] browser rejection causes login failure (1.12) — requires OAuth deny', () => { - // Cannot automate: requires the OAuth server to return access_denied. - }) - - it.skip('[P1] login timeout when browser is never opened (1.11) — poll TTL > 5 min', () => { - // Cannot automate: requires waiting for the full Device Flow poll timeout (~5 min). - }) -}) diff --git a/cli/test/e2e/suites/auth/logout.e2e.ts b/cli/test/e2e/suites/auth/logout.e2e.ts deleted file mode 100644 index e78f7569a24..00000000000 --- a/cli/test/e2e/suites/auth/logout.e2e.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * E2E: difyctl auth logout — Logout - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Logout (18 wiki cases → 14 automated) - */ - -import { access } from 'node:fs/promises' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { assertExitCode } from '../../helpers/assert.js' -import { injectAuth, injectSsoAuth, run, withTempConfig } from '../../helpers/cli.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -describe('E2E / difyctl auth logout', () => { - let configDir: string - let cleanup: () => Promise<void> - - beforeEach(async () => { - const { configDir: dir, cleanup: cl } = await withTempConfig() - configDir = dir - cleanup = cl - }) - afterEach(async () => { - await cleanup() - }) - - function r(argv: string[]) { - return run(argv, { configDir }) - } - - /** - * Inject the dedicated per-suite logoutToken so that auth logout - * calls DELETE /account/sessions/self on a disposable session and - * never revokes the shared DIFY_E2E_TOKEN used by other suites. - */ - async function withAuth() { - const token = caps.logoutToken || 'dfoa_logout_suite_unavailable' - await injectAuth(configDir, { - host: E.host, - bearer: token, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - } - - async function hostsFileExists(): Promise<boolean> { - try { - await access(join(configDir, 'hosts.yml')) - return true - } catch { - return false - } - } - - async function expectNoActiveSession(): Promise<void> { - const result = await r(['auth', 'whoami']) - assertExitCode(result, 4) - expect(result.stderr).toMatch(/not.?logged.?in/i) - } - - // ── Basic logout ──────────────────────────────────────────────────────────── - - it('[P0] logged-in user can logout successfully — stdout contains success message', async () => { - // Spec: logged-in user can logout successfully - await withAuth() - const result = await r(['auth', 'logout']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/logged out/i) - }) - - it('[P0] local active session is cleared after logout', async () => { - // Spec: local token deleted after logout - await withAuth() - expect(await hostsFileExists()).toBe(true) - await r(['auth', 'logout']) - await expectNoActiveSession() - }) - - it('[P0] auth status returns "Not logged in" after logout', async () => { - // Spec: auth status returns not-logged-in after logout - await withAuth() - await r(['auth', 'logout']) - const statusResult = await r(['auth', 'whoami']) - expect(statusResult.exitCode).toBe(4) - expect(statusResult.stderr).toMatch(/not.?logged.?in/i) - }) - - it('[P1] auth status exit code is 4 after logout', async () => { - // Spec: auth status exit code is 4 after logout - await withAuth() - await r(['auth', 'logout']) - const statusResult = await r(['auth', 'whoami']) - expect(statusResult.exitCode).toBe(4) - }) - - it('[P0] logout calls the revoke session endpoint (or best-effort local credential clear)', async () => { - // Spec: logout calls the revoke session endpoint + logout returns success when revoke succeeds - // Uses disposableToken so the shared DIFY_E2E_TOKEN is not revoked. - await withAuth() - const result = await r(['auth', 'logout']) - // Local token must be cleared regardless of whether server revoke succeeds - assertExitCode(result, 0) - await expectNoActiveSession() - }) - - it('[P0] local credentials are cleared even when server revoke fails (best-effort)', async () => { - // Spec: local credentials cleared even when server revoke fails - // Inject an invalid token → server rejects revoke, but local state must still be cleared - await injectAuth(configDir, { - host: E.host, - bearer: 'dfoa_invalid_will_fail_revoke', - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const result = await r(['auth', 'logout']) - // exit 0 (best-effort); local file is cleared - assertExitCode(result, 0) - await expectNoActiveSession() - }) - - // ── Unauthenticated (idempotent) ───────────────────────────────────────────── - - it('[P0] logout without a session returns not_logged_in error (exit code 4)', async () => { - // Spec: logout without a session is idempotent - // Actual behaviour: CLI returns not_logged_in (exit 4) when no token is present - const result = await r(['auth', 'logout']) - assertExitCode(result, 4) - expect(result.stderr).toMatch(/not.?logged.?in/i) - }) - - // ── External SSO logout ───────────────────────────────────────────────────── - - it('[P0] external SSO user logout works correctly — local token cleared', async () => { - // Spec: external SSO user logout works correctly - await injectSsoAuth(configDir, { - host: E.host, - bearer: E.ssoToken || 'dfoe_sso_test_token', - email: 'sso@example.com', - issuer: 'https://issuer.example.com', - }) - - const result = await r(['auth', 'logout']) - assertExitCode(result, 0) - await expectNoActiveSession() - }) - - // ── Network error scenario ─────────────────────────────────────────────────── - - it('[P0] local token is cleared even when logout encounters a network error', async () => { - // Spec: local credentials cleared even when network is unavailable - // Use an unreachable host to simulate network failure - await injectAuth(configDir, { - host: 'http://unreachable-host-xyz.invalid', - bearer: 'dfoa_test_network_error', - email: E.email, - workspaceId: 'ws-1', - workspaceName: 'Test', - }) - - const result = await run(['auth', 'logout'], { configDir, timeout: 10_000 }) - // Local token is cleared even if network request fails - assertExitCode(result, 0) - await expectNoActiveSession() - }) - - // ── Post-logout operations ─────────────────────────────────────────────────── - - it('[P1] run app returns auth error (exit code 4) after logout', async () => { - // Spec: run app returns auth error after logout - // Use disposableToken so the shared DIFY_E2E_TOKEN is not revoked. - await withAuth() - await r(['auth', 'logout']) - const result = await r(['run', 'app', E.chatAppId, 'test']) - expect(result.exitCode).toBe(4) - }) - - // ── Warning output when revoke fails ──────────────────────────────────────── - - it('[P1] warning is printed to stdout/stderr when server revoke fails (best-effort)', async () => { - // Spec 1.56: when revoke API fails the CLI emits a warning but logout still completes - await injectAuth(configDir, { - host: E.host, - bearer: 'dfoa_invalid_will_fail_revoke', - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const result = await r(['auth', 'logout']) - // Logout completes successfully despite revoke failure - assertExitCode(result, 0) - // CLI must emit a warning (either stdout or stderr) about the revoke failure - const combined = result.stdout + result.stderr - expect(combined).toMatch(/warning|revoke|failed|could not/i) - // Local credentials must still be cleared - await expectNoActiveSession() - }) - - // ── Keychain token storage ─────────────────────────────────────────────────── - - it('[P1] keychain token is deleted after logout', async () => { - // Spec 1.59: keychain token is deleted after logout - // We inject a session with token_storage=keychain; the CLI must clear the - // keychain entry on logout. In CI environments without a real keychain the - // CLI falls back to file storage, so we accept either: - // (a) exit 0 + hosts.yml removed (file-fallback path), OR - // (b) exit 0 + hosts.yml absent (keychain-only path) - await injectAuth(configDir, { - host: E.host, - bearer: 'dfoa_keychain_test_token', - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - - const result = await r(['auth', 'logout']) - assertExitCode(result, 0) - await expectNoActiveSession() - }) - - // ── Multiple workspace sessions ────────────────────────────────────────────── - - it('[P1] logout clears only the current session when multiple workspace sessions exist', async () => { - // Spec 1.62: current session is cleared on logout when multiple workspace sessions exist - await injectAuth(configDir, { - host: E.host, - bearer: 'dfoa_multi_ws_test_token', - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - availableWorkspaces: [ - { id: E.workspaceId, name: E.workspaceName, role: 'owner' }, - { id: 'ws-secondary-001', name: 'Secondary Workspace', role: 'member' }, - ], - }) - - const result = await r(['auth', 'logout']) - assertExitCode(result, 0) - // The current session (hosts.yml) must be cleared after logout - await expectNoActiveSession() - }) - - // ── Re-login after logout ──────────────────────────────────────────────────── - - it('[P1] a new session can be injected and used successfully after logout', async () => { - // Spec 1.63: a new session can be created successfully after logout - // Use disposableToken so the shared DIFY_E2E_TOKEN is not revoked. - await withAuth() - await r(['auth', 'logout']) - await expectNoActiveSession() - - // Simulate a new login by injecting fresh credentials - const token = caps.logoutToken || E.token - await injectAuth(configDir, { - host: E.host, - bearer: token, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - - // New session must be recognised as valid - const statusResult = await r(['auth', 'whoami']) - assertExitCode(statusResult, 0) - expect(statusResult.stdout).toContain(E.email) - }) -}) diff --git a/cli/test/e2e/suites/auth/status.e2e.ts b/cli/test/e2e/suites/auth/status.e2e.ts deleted file mode 100644 index e27fdd23b22..00000000000 --- a/cli/test/e2e/suites/auth/status.e2e.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * E2E: difyctl auth session state - * - * The current CLI exposes local session state through: - * - `auth whoami` for the active identity - * - `auth list` for configured host/account contexts - */ - -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js' -import { injectAuth, injectSsoAuth, run, withTempConfig } from '../../helpers/cli.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -describe('E2E / difyctl auth session state', () => { - let configDir: string - let cleanup: () => Promise<void> - - beforeEach(async () => { - const { configDir: dir, cleanup: cl } = await withTempConfig() - configDir = dir - cleanup = cl - }) - afterEach(async () => { - await cleanup() - }) - - function r(argv: string[], extraEnv?: Record<string, string>) { - return run(argv, { configDir, env: extraEnv }) - } - - async function withAuth(overrideWorkspaceId?: string) { - const wsId = overrideWorkspaceId ?? E.workspaceId - const wsName = overrideWorkspaceId ? 'Workspace 2' : E.workspaceName - await injectAuth(configDir, { - host: E.host, - bearer: E.token, - email: 'e2e@example.com', - accountName: 'E2E User', - accountId: 'acct-e2e', - workspaceId: wsId, - workspaceName: wsName, - availableWorkspaces: [ - { id: E.workspaceId, name: E.workspaceName, role: 'owner' }, - { id: '747729d0-c476-4ba3-b44a-52bdf962c4f6', name: 'Workspace 2', role: 'member' }, - ], - }) - } - - async function withSSOAuth() { - await injectSsoAuth(configDir, { - host: E.host, - bearer: E.ssoToken || 'dfoe_test', - email: 'sso@example.com', - issuer: 'https://issuer.example.com', - }) - } - - it('[P0] auth list displays host, account, name and active marker (1.37)', async () => { - await withAuth() - const result = await r(['auth', 'list']) - assertExitCode(result, 0) - expect(result.stdout).toContain(E.host.replace(/^https?:\/\//, '')) - expect(result.stdout).toContain('e2e@example.com') - expect(result.stdout).toContain('E2E User') - expect(result.stdout).toContain('*') - }) - - it('[P1] auth list -o json displays active context metadata (1.38)', async () => { - await withAuth() - const result = await r(['auth', 'list', '-o', 'json']) - assertExitCode(result, 0) - const parsed = JSON.parse(result.stdout) as { - contexts: Array<{ host: string; account: string; name: string; active: boolean }> - } - expect(parsed.contexts).toHaveLength(1) - expect(parsed.contexts[0]).toMatchObject({ - account: 'e2e@example.com', - name: 'E2E User', - active: true, - }) - }) - - it('[P0] auth whoami --json outputs stable identity schema (1.39)', async () => { - await withAuth() - const result = await r(['auth', 'whoami', '--json']) - assertExitCode(result, 0) - const parsed = JSON.parse(result.stdout) as { id: string; email: string; name: string } - expect(parsed).toMatchObject({ - id: 'acct-e2e', - email: 'e2e@example.com', - name: 'E2E User', - }) - }) - - it('[P0] unauthenticated auth whoami returns "Not logged in" — exit code 4 (1.40)', async () => { - const result = await r(['auth', 'whoami']) - assertExitCode(result, 4) - expect(result.stderr).toMatch(/not.?logged.?in/i) - }) - - it('[P0] external SSO user auth whoami does not display workspace row (1.41)', async () => { - await withSSOAuth() - const result = await r(['auth', 'whoami']) - assertExitCode(result, 0) - expect(result.stdout).not.toMatch(/workspace/i) - }) - - it('[P0] external SSO user auth whoami displays issuer URL (1.42)', async () => { - await withSSOAuth() - const result = await r(['auth', 'whoami']) - assertExitCode(result, 0) - expect(result.stdout).toContain('issuer.example.com') - }) - - it('[P0] external SSO user auth whoami displays External SSO session info (1.43)', async () => { - await withSSOAuth() - const result = await r(['auth', 'whoami']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/SSO/i) - }) - - it('[P0] auth whoami with an expired/invalid token still exits 0 (1.44)', async () => { - await injectAuth(configDir, { - host: E.host, - bearer: 'dfoa_invalid_expired_token_xyz', - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const result = await r(['auth', 'whoami']) - assertExitCode(result, 0) - expect(result.stdout).toContain(E.email) - }) - - it('[P1] unauthenticated auth whoami --json returns not_logged_in (1.45)', async () => { - const result = await r(['auth', 'whoami', '--json']) - assertExitCode(result, 4) - expect(result.stderr).toMatch(/not.?logged.?in/i) - }) - - it('[P1] auth list with unreachable host still exits 0 — purely local (1.46)', async () => { - await injectAuth(configDir, { - host: 'https://127.0.0.1:19999', - bearer: E.token, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const result = await r(['auth', 'list']) - assertExitCode(result, 0) - expect(result.stdout).toContain('127.0.0.1:19999') - }) - - it('[P1] file-based token storage context works correctly (1.47)', async () => { - await withAuth() - const list = await r(['auth', 'list']) - assertExitCode(list, 0) - expect(list.stdout).toContain('e2e@example.com') - - const whoami = await r(['auth', 'whoami']) - assertExitCode(whoami, 0) - expect(whoami.stdout).toContain('e2e@example.com') - }) - - it('[P1] local registry shows the active workspace after workspace switch (1.48)', async () => { - await withAuth('747729d0-c476-4ba3-b44a-52bdf962c4f6') - const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8') - expect(hostsContent).toContain('Workspace 2') - expect(hostsContent).toContain('747729d0-c476-4ba3-b44a-52bdf962c4f6') - }) - - it('[P0] auth list output contains no ANSI colour codes (non-TTY)', async () => { - await withAuth() - const result = await r(['auth', 'list']) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, 'stdout') - }) -}) diff --git a/cli/test/e2e/suites/auth/use.e2e.ts b/cli/test/e2e/suites/auth/use.e2e.ts deleted file mode 100644 index 5694d7d784f..00000000000 --- a/cli/test/e2e/suites/auth/use.e2e.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * E2E: difyctl use workspace — Workspace switching - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Workspace Switching (22 wiki cases → 19 automated) - */ - -import type { RunResult } from '../../helpers/cli.js' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { assertExitCode } from '../../helpers/assert.js' -import { injectAuth, injectSsoAuth, run, withTempConfig } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -// Secondary workspace used in tests — injected into available_workspaces -const WS2_ID = '00000000-e2e2-0000-0001-000000000002' -const WS2_NAME = 'Secondary Workspace' - -describe('E2E / difyctl use workspace', () => { - let configDir: string - let cleanup: () => Promise<void> - - beforeEach(async () => { - const tmp = await withTempConfig() - configDir = tmp.configDir - cleanup = tmp.cleanup - }) - afterEach(async () => { - await cleanup() - }) - - function r(argv: string[]) { - return run(argv, { configDir }) - } - - function isServer5xx(result: RunResult): boolean { - return result.exitCode !== 0 && /server_5xx|HTTP 5\d\d/i.test(result.stderr) - } - - async function switchWorkspace(workspaceId: string): Promise<RunResult | undefined> { - try { - return await withRetry( - async () => { - const result = await r(['use', 'workspace', workspaceId]) - if (isServer5xx(result)) throw new Error(result.stderr) - return result - }, - { - attempts: 3, - delayMs: 1_000, - shouldRetry: (err) => /server_5xx|HTTP 5\d\d/i.test(String(err)), - }, - ) - } catch (err) { - if (/server_5xx|HTTP 5\d\d/i.test(String(err))) { - console.warn( - `[E2E] workspace switch ${workspaceId} returned persistent server_5xx; skipping server-dependent assertion.`, - ) - return undefined - } - throw err - } - } - - /** Inject a bundle with two workspaces. */ - async function withTwoWorkspaces() { - await injectAuth(configDir, { - host: E.host, - bearer: E.token, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - availableWorkspaces: [ - { id: E.workspaceId, name: E.workspaceName, role: 'owner' }, - { id: E.ws2Id || WS2_ID, name: WS2_NAME, role: 'normal' }, - ], - }) - } - - async function withSSOAuth() { - await injectSsoAuth(configDir, { - host: E.host, - bearer: E.ssoToken || 'dfoe_sso_test', - email: 'sso@example.com', - issuer: 'https://issuer.example.com', - }) - } - - // ── Normal workspace switch ────────────────────────────────────────────────── - - it('[P0] internal user can switch to a specified workspace', async () => { - // Spec: internal user can switch to a specified workspace - // use E.workspaceId (real server id); WS2_ID is synthetic and not on server - await withTwoWorkspaces() - const result = await switchWorkspace(E.workspaceId) - if (result === undefined) return - assertExitCode(result, 0) - expect(result.stdout).toMatch(/switched|workspace/i) - expect(result.stdout).toContain(E.workspaceId) - }) - - it('[P0] auth status shows the new workspace after auth use', async () => { - // Spec: auth status shows new workspace after auth use - await withTwoWorkspaces() - const switchResult = await switchWorkspace(E.workspaceId) - if (switchResult === undefined) return - assertExitCode(switchResult, 0) - const hostsContent = await ( - await import('node:fs/promises') - ).readFile(join(configDir, 'hosts.yml'), 'utf8') - expect(hostsContent).toContain(E.workspaceId) - }) - - it('[P0] auth use updates current_workspace_id (hosts.yml is updated)', async () => { - // Spec: auth use updates current_workspace_id - // Switch to primary workspace (real server id); verify hosts.yml is updated - await withTwoWorkspaces() - const switchResult = await switchWorkspace(E.workspaceId) - if (switchResult === undefined) return - assertExitCode(switchResult, 0) - const { readFile } = await import('node:fs/promises') - const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8') - expect(hostsContent).toContain(E.workspaceId) - }) - - it('[P1] switching to the same workspace repeatedly is idempotent', async () => { - // Spec: switching to the same workspace is idempotent - await withTwoWorkspaces() - const r1 = await switchWorkspace(E.workspaceId) - if (r1 === undefined) return - assertExitCode(r1, 0) - const r2 = await switchWorkspace(E.workspaceId) - if (r2 === undefined) return - assertExitCode(r2, 0) - }) - - // ── Error scenarios ────────────────────────────────────────────────────────── - - it('[P0] switching to a non-existent workspace returns an error', async () => { - // Spec: switching to a non-existent workspace returns an error - await withTwoWorkspaces() - const result = await r(['auth', 'use', 'ffffffff-dead-0000-0000-000000000000']) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toMatch(/server_5xx|not found|workspace|error/i) - }) - - it('[P0] current_workspace_id is unchanged when workspace switch fails', async () => { - // Spec: current_workspace_id is unchanged when workspace switch fails - await withTwoWorkspaces() - await r(['auth', 'use', 'ffffffff-dead-0000-0000-000000000000']) - // Read hosts.yml directly; the original workspace id should still be present - const { readFile } = await import('node:fs/promises') - const { join } = await import('node:path') - const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8') - expect(hostsContent).toContain(E.workspaceId) - }) - - it('[P0] unauthenticated auth use returns auth error (exit code 4)', async () => { - // Spec: unauthenticated auth use returns auth error + exit code 4 - const result = await r(['use', 'workspace', E.workspaceId]) - assertExitCode(result, 4) - expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i) - }) - - it('[P0] missing workspace argument returns a usage error', async () => { - // Spec: missing workspace argument returns a usage error - await withTwoWorkspaces() - const result = await r(['use', 'workspace']) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toMatch(/missing|required|arg|usage|workspace/i) - }) - - // ── External SSO user ──────────────────────────────────────────────────────── - - it('[P0] external SSO user is rejected when executing auth use', async () => { - // Spec: external SSO user is rejected when executing auth use - await withSSOAuth() - const result = await r(['use', 'workspace', 'any-ws-id']) - expect(result.exitCode).not.toBe(0) - // SSO token rejected by server — error may be server_5xx or auth-related - expect(result.stderr.trim().length).toBeGreaterThan(0) - }) - - it('[P1] external SSO user auth use exit code is 1 or 2', async () => { - // Spec: external SSO user auth use exit code is 1 - await withSSOAuth() - const result = await r(['use', 'workspace', 'any-ws-id']) - expect([1, 2]).toContain(result.exitCode) - }) - - // ── Post-switch get app ────────────────────────────────────────────────────── -}) diff --git a/cli/test/e2e/suites/auth/whoami.e2e.ts b/cli/test/e2e/suites/auth/whoami.e2e.ts deleted file mode 100644 index a10c572a034..00000000000 --- a/cli/test/e2e/suites/auth/whoami.e2e.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * E2E: difyctl auth whoami + external SSO session behaviour - * - * Test cases sourced from: Dify CLI Enhanced spec - * - Dify CLI/Auth/External SSO Login (19 cases, testable subset) - * - * Note: interactive login (Device Flow browser) and Headless auth require a real browser; - * E2E layer bypasses Device Flow via injectAuth, focusing on session state and CLI behaviour. - */ - -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { assertExitCode } from '../../helpers/assert.js' -import { injectAuth, injectSsoAuth, run, withTempConfig } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -describe('E2E / difyctl auth whoami + SSO session', () => { - let configDir: string - let cleanup: () => Promise<void> - - beforeEach(async () => { - const tmp = await withTempConfig() - configDir = tmp.configDir - cleanup = tmp.cleanup - }) - afterEach(async () => { - await cleanup() - }) - - function r(argv: string[]) { - return run(argv, { configDir }) - } - - async function withInternalAuth() { - await injectAuth(configDir, { - host: E.host, - bearer: E.token, - email: 'e2e-user@example.com', - accountName: 'E2E User', - accountId: 'acct-e2e', - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - } - - async function withSSOAuth(issuer = 'https://idp.example.com') { - await injectSsoAuth(configDir, { - host: E.host, - bearer: E.ssoToken || 'dfoe_sso_test_token', - email: 'sso-user@example.com', - issuer, - }) - } - - const itWithSso = optionalIt(Boolean(E.ssoToken)) - - // ── auth whoami — internal user ────────────────────────────────────────────── - - it('[P0] internal user auth whoami outputs email', async () => { - await withInternalAuth() - const result = await r(['auth', 'whoami']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/@/) - }) - - it('[P0] auth whoami --json outputs valid JSON containing email', async () => { - await withInternalAuth() - const result = await r(['auth', 'whoami', '--json']) - assertExitCode(result, 0) - const parsed = JSON.parse(result.stdout) as { email: string } - expect(parsed).toHaveProperty('email') - expect(parsed.email).toMatch(/@/) - }) - - it('[P0] unauthenticated auth whoami returns auth error (exit code 4)', async () => { - const result = await r(['auth', 'whoami']) - assertExitCode(result, 4) - }) - - // ── External SSO user behaviour ────────────────────────────────────────────── - - it('[P0] external SSO user auth status displays apps:run-only restriction', async () => { - // Spec: auth status displays apps:run-only restriction - await withSSOAuth() - const result = await r(['auth', 'whoami']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/SSO/i) - }) - - it('[P0] external SSO user auth status does not display workspace info', async () => { - // Spec: auth status does not display workspace information - await withSSOAuth() - const result = await r(['auth', 'whoami']) - assertExitCode(result, 0) - // SSO users have no workspace - expect(result.stdout).not.toMatch(/workspace/i) - }) - - it('[P0] external SSO user auth status displays issuer URL', async () => { - // Spec: auth status displays External SSO Session + issuer URL - await withSSOAuth('https://idp.enterprise.com') - const result = await r(['auth', 'whoami']) - assertExitCode(result, 0) - expect(result.stdout).toContain('idp.enterprise.com') - }) - - it('[P0] external user gets an error executing auth use (external SSO subjects have no workspaces)', async () => { - // Spec: external user gets an error when executing auth use - await withSSOAuth() - const result = await r(['use', 'workspace', 'any-ws-id']) - expect(result.exitCode).not.toBe(0) - expect(result.stderr.trim().length).toBeGreaterThan(0) - }) - - it('[P0] external user get workspace returns empty list or insufficient_scope', async () => { - // Spec: external user get workspace returns an empty list - await withSSOAuth() - const result = await r(['get', 'workspace']) - // SSO token has no workspace scope - expect(result.exitCode).not.toBe(0) - }) - - itWithSso('[P0] external user can list permitted apps via SSO token', async () => { - // External users read apps via the permitted-external surface (no workspace scope). - await withSSOAuth() - const result = await r(['get', 'app']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i) - }) - - it('[P0] external user whoami outputs SSO email', async () => { - await withSSOAuth() - const result = await r(['auth', 'whoami']) - assertExitCode(result, 0) - expect(result.stdout).toContain('sso-user@example.com') - }) - - itWithSso('[P0] external user can execute run app using SSO token', async () => { - await injectSsoAuth(configDir, { - host: E.host, - bearer: E.ssoToken, - email: 'sso@example.com', - issuer: 'https://issuer.example.com', - }) - - let result: Awaited<ReturnType<typeof r>> - try { - result = await withRetry( - async () => { - const runResult = await r([ - 'run', - 'app', - E.chatAppId, - 'hello', - '--workspace', - E.workspaceId, - ]) - if (runResult.exitCode !== 0 && /server_5xx|HTTP 5\d\d/i.test(runResult.stderr)) - throw new Error(runResult.stderr) - return runResult - }, - { - attempts: 3, - delayMs: 1_000, - shouldRetry: (err) => /server_5xx|HTTP 5\d\d/i.test(String(err)), - }, - ) - } catch (err) { - if (/server_5xx|HTTP 5\d\d/i.test(String(err))) { - console.warn( - '[E2E] SSO run app returned persistent server_5xx; SSO identity and scope checks were verified before run.', - ) - return - } - throw err - } - assertExitCode(result, 0) - expect(result.stdout.length).toBeGreaterThan(0) - }) -}) diff --git a/cli/test/e2e/suites/discovery/describe-app.e2e.ts b/cli/test/e2e/suites/discovery/describe-app.e2e.ts deleted file mode 100644 index 52fae14a746..00000000000 --- a/cli/test/e2e/suites/discovery/describe-app.e2e.ts +++ /dev/null @@ -1,342 +0,0 @@ -/** - * E2E: difyctl describe app — Describe App - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Describe App (29 cases) - */ - -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { - assertErrorEnvelope, - assertExitCode, - assertJson, - assertNoAnsi, -} from '../../helpers/assert.js' -import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) -const itWithSso = optionalIt(Boolean(E.ssoToken)) -const NONEXISTENT_ID = 'app-does-not-exist-e2e-xyz' - -describe('E2E / difyctl describe app', () => { - let fx: Awaited<ReturnType<typeof withAuthFixture>> - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ── Basic describe ──────────────────────────────────────────────────────── - - it('[P0] logged-in user can describe an app', async () => { - const result = await fx.r(['describe', 'app', E.chatAppId]) - assertExitCode(result, 0) - expect(result.stdout.length).toBeGreaterThan(0) - }) - - it('[P0] default text output is labelled-section style', async () => { - // Spec: default output is kubectl-describe-style labelled sections - const result = await fx.r(['describe', 'app', E.chatAppId]) - assertExitCode(result, 0) - // Labelled output contains key: value pairs - expect(result.stdout).toMatch(/\w+:\s+\S/) - }) - - it('[P1] describe output contains ID field', async () => { - const result = await fx.r(['describe', 'app', E.chatAppId]) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/ID:/i) - expect(result.stdout).toContain(E.chatAppId) - }) - - it('[P1] describe output contains Mode field', async () => { - const result = await fx.r(['describe', 'app', E.chatAppId]) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/Mode:/i) - }) - - it('[P1] describe output contains Name field', async () => { - const result = await fx.r(['describe', 'app', E.chatAppId]) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/Name:/i) - }) - - // ── Input schema ────────────────────────────────────────────────────────── - - it('[P0] describe output contains Parameters section', async () => { - // Spec: Inputs/Parameters section present when app has an input schema - const result = await fx.r(['describe', 'app', E.workflowAppId]) - assertExitCode(result, 0) - // Workflow app has at least a 'x' required input - expect(result.stdout).toMatch(/Parameters|Inputs/i) - }) - - // ── JSON output ─────────────────────────────────────────────────────────── - - it('[P0] -o json outputs raw describe response with info and parameters (3.78)', async () => { - // Spec 3.78: -o json → raw describe response containing info + parameters. - const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ info: { id: string }; parameters: unknown }>(result) - expect(parsed.info?.id, 'info.id should match the queried app').toBe(E.chatAppId) - expect(parsed.parameters, 'parameters field must be present').toBeDefined() - }) - - it('[P1] JSON output is valid indented JSON', async () => { - const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json']) - assertExitCode(result, 0) - // Indented JSON: multiple lines, starts with '{' - expect(result.stdout.trim()).toMatch(/^\{/) - expect(result.stdout.split('\n').length).toBeGreaterThan(2) - }) - - it('[P1] JSON output can be piped and has no ANSI (3.82)', async () => { - // Spec 3.82: -o json | jq . works; no ANSI codes. - const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json']) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, 'stdout') - expect(result.stdout.trimStart().startsWith('{')).toBe(true) - expect(result.stdout.endsWith('\n')).toBe(true) - }) - - // ── Unsupported formats ─────────────────────────────────────────────────── - - it('[P0] -o wide returns NoCompatiblePrinterError (exit non-0) (3.80)', async () => { - // Spec 3.80: describe -o wide → NoCompatiblePrinterError, exit non-0. - // CLI returns exit 1 (not 2) for printer incompatibility on this version. - const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'wide']) - expect(result.exitCode, '-o wide should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch(/NoCompatiblePrinter|invalid|unsupported|wide/i) - }) - - it('[P0] -o name returns NoCompatiblePrinterError (exit non-0) (3.81)', async () => { - // Spec 3.81: describe -o name → NoCompatiblePrinterError, exit non-0. - // CLI returns exit 1 (not 2) for printer incompatibility on this version. - const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'name']) - expect(result.exitCode, '-o name should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch(/NoCompatiblePrinter|invalid|unsupported|name/i) - }) - - // ── Not found ───────────────────────────────────────────────────────────── - - it('[P0] invalid (non-UUID) app id returns usage error (exit code 2)', async () => { - // NONEXISTENT_ID is not a valid UUID, so the CLI rejects it client-side via - // isValidUuid() before making any network request → usage_invalid_flag (exit 2). - const result = await fx.r(['describe', 'app', NONEXISTENT_ID]) - expect(result.exitCode, 'invalid UUID should exit with code 2').toBe(2) - expect(result.stderr).toMatch(/uuid|valid|usage_invalid_flag/i) - }) - - it('[P1] non-existent app in JSON mode outputs JSON error envelope', async () => { - const result = await fx.r(['describe', 'app', NONEXISTENT_ID, '-o', 'json']) - expect(result.exitCode).not.toBe(0) - assertErrorEnvelope(result) - }) - - // ── Missing argument ────────────────────────────────────────────────────── - - it('[P1] missing app id returns usage error (3.84)', async () => { - // Spec 3.84: describe app without id → usage error, exit non-0. - // CLI returns exit 1 for missing required argument (not 2). - const result = await fx.r(['describe', 'app']) - expect(result.exitCode, 'missing id should cause non-zero exit').not.toBe(0) - expect(result.stderr).toMatch(/missing required argument|required/i) - }) - - // ── Unauthenticated ─────────────────────────────────────────────────────── - - it('[P0] unauthenticated describe app returns auth error', async () => { - const tmp = await withTempConfig() - try { - const { run } = await import('../../helpers/cli.js') - const result = await run(['describe', 'app', E.chatAppId], { configDir: tmp.configDir }) - assertExitCode(result, 4) - expect(result.stderr).toMatch(/not.?logged.?in|auth/i) - } finally { - await tmp.cleanup() - } - }) - - // ── External SSO ────────────────────────────────────────────────────────── - - itWithSso('[P0] external SSO user can describe a permitted app', async () => { - // A dfoe_ token resolves `describe app` via the permitted-external surface - // (not the account /apps surface), so a permitted app describes successfully. - // Uses DIFY_E2E_SSO_TOKEN; skipped when not configured. - const { mkdir, writeFile } = await import('node:fs/promises') - const { join } = await import('node:path') - const ssoTmp = await withTempConfig() - try { - await mkdir(ssoTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: ${E.host}`, - `token_storage: file`, - `tokens:`, - ` bearer: ${E.ssoToken}`, - `external_subject:`, - ` email: sso@example.com`, - ` issuer: https://issuer.example.com`, - ].join('\n')}\n` - await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['describe', 'app', E.chatAppId], { configDir: ssoTmp.configDir }) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/ID:/i) - expect(result.stdout).toContain(E.chatAppId) - expect(result.stdout).toMatch(/Mode:/i) - } finally { - await ssoTmp.cleanup() - } - }) - - // ── Output quality ──────────────────────────────────────────────────────── - - it('[P0] describe output has no ANSI colour codes (non-TTY)', async () => { - // withRetry: staging may return transient 500 on cold start - const result = await withRetry(() => fx.r(['describe', 'app', E.chatAppId]), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, 'stdout') - }) - - // ── New cases ───────────────────────────────────────────────────────────── - - it('[P1] describe output contains Description field (3.66)', async () => { - // Spec 3.66: output includes Description when app has a non-empty description. - // Prerequisite: echo-bot description set to 'e2e-test' in the Dify web console. - const result = await withRetry(() => fx.r(['describe', 'app', E.chatAppId]), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/Description:/i) - expect(result.stdout).toContain('e2e-test') - }) - - it('[P0] Inputs section shows parameter names (3.70)', async () => { - // Spec 3.70: Parameters/Inputs section displays variable names. - // workflow app has x, num, enum_var, paragraph. - const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId]), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/Parameters|Inputs/i) - expect(result.stdout).toContain('"x"') - expect(result.stdout).toContain('"num"') - }) - - it('[P0] Inputs section shows parameter types (3.71)', async () => { - // Spec 3.71: Parameters section displays parameter type info. - // input_schema is a JSON Schema object with properties.inputs.properties.<var>.type. - const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId, '-o', 'json']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - const parsed = assertJson<{ - input_schema: { properties?: { inputs?: { properties?: Record<string, { type: string }> } } } - }>(result) - const varProps = parsed.input_schema?.properties?.inputs?.properties - expect(varProps, 'input_schema should expose variable type properties').toBeDefined() - const types = Object.values(varProps ?? {}).map((v) => v.type) - expect(types.length, 'should have at least one typed parameter').toBeGreaterThan(0) - types.forEach((t) => expect(typeof t, 'each type must be a string').toBe('string')) - }) - - it('[P0] Inputs section shows required/optional markers (3.72)', async () => { - // Spec 3.72: Parameters section shows required/optional per field. - // user_input_form entries each have a required:boolean flag. - const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId, '-o', 'json']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - type FormItem = Record<string, { variable: string; required: boolean }> - const parsed = assertJson<{ parameters: { user_input_form: FormItem[] } }>(result) - const fields = parsed.parameters.user_input_form - expect(fields.length, 'user_input_form should have entries').toBeGreaterThan(0) - fields.forEach((item) => { - const entry = Object.values(item)[0]! - expect(typeof entry.required, `field ${entry.variable} must have required flag`).toBe( - 'boolean', - ) - }) - }) - - it('[P0] workflow app with 4 typed fields shows all in Parameters (3.73)', async () => { - // Spec 3.73: 4-field workflow app — x / num / enum_var / paragraph all appear. - const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId]), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - expect(result.stdout).toContain('"x"') - expect(result.stdout).toContain('"num"') - expect(result.stdout).toContain('"enum_var"') - expect(result.stdout).toContain('"paragraph"') - }) - - it('[P1] enum parameter shows options list (3.74)', async () => { - // Spec 3.74: enum-type input shows the selectable options. - // enum_var has options A, B, C. - const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId]), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - // Options A / B / C appear in the raw JSON dump of parameters - expect(result.stdout).toMatch(/"A"|"B"|"C"/) - }) - - it('[P1] paragraph parameter shows max_length value (3.75)', async () => { - // Spec 3.75: paragraph input with max_length shows the limit value. - // paragraph has max_length = 100. - const result = await withRetry(() => fx.r(['describe', 'app', E.workflowAppId]), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - expect(result.stdout).toContain('100') - }) - - it('[P1] network error on describe app returns non-zero exit (3.88)', async () => { - // Spec 3.88: unreachable host → network error, exit non-0. - const { writeFile, mkdir } = await import('node:fs/promises') - const { join } = await import('node:path') - const networkTmp = await withTempConfig() - try { - await mkdir(networkTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: http://127.0.0.1:19999`, - `token_storage: file`, - `tokens:`, - ` bearer: dfoa_fake_token_network_test`, - `workspace:`, - ` id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - `available_workspaces:`, - ` - id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - ].join('\n')}\n` - await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['describe', 'app', E.chatAppId], { - configDir: networkTmp.configDir, - timeout: 15_000, - }) - expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) - expect(result.stderr.length).toBeGreaterThan(0) - } finally { - await networkTmp.cleanup() - } - }) -}) diff --git a/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts b/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts deleted file mode 100644 index 136b96e4ab7..00000000000 --- a/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * E2E: difyctl get app -A — Cross-Workspace App Query - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Cross-Workspace Query (22 cases) - * - * Note: Most cases require the test account to have multiple workspaces. - * Tests that depend on multiple workspaces are guarded by checking the - * available_workspaces count from auth status. - */ - -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { - assertErrorEnvelope, - assertExitCode, - assertJson, - assertNoAnsi, - assertPipeFriendlyJson, -} from '../../helpers/assert.js' -import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { enterpriseOnlyIt, optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) -const itWithSso = optionalIt(Boolean(E.ssoToken) && E.ssoToken !== E.token) -const eeIt = enterpriseOnlyIt(caps) - -describe('E2E / difyctl get app -A (all-workspaces)', () => { - let fx: Awaited<ReturnType<typeof withAuthFixture>> - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ── Basic fan-out ───────────────────────────────────────────────────────── - - it('[P0] internal user can execute all-workspaces query', async () => { - const result = await fx.r(['get', 'app', '-A', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[] }>(result) - expect(Array.isArray(parsed.data)).toBe(true) - }) - - it('[P1] --all-workspaces and -A flags behave identically', async () => { - const r1 = await fx.r(['get', 'app', '-A', '-o', 'json']) - const r2 = await fx.r(['get', 'app', '--all-workspaces', '-o', 'json']) - assertExitCode(r1, 0) - assertExitCode(r2, 0) - // Both return same structure - const p1 = assertJson<{ data: unknown[] }>(r1) - const p2 = assertJson<{ data: unknown[] }>(r2) - expect(p1.data.length).toBe(p2.data.length) - }) - - // ── Output format ───────────────────────────────────────────────────────── - - eeIt( - '[EE][P0] -o wide output contains WORKSPACE column and JSON has workspace_id (3.92)', - async () => { - // Spec 3.92: WORKSPACE column (priority:1) appears only in -o wide mode. - // Default table shows priority:0 columns only (NAME/ID/MODE/UPDATED). - const wideResult = await withRetry(() => fx.r(['get', 'app', '-A', '-o', 'wide']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(wideResult, 0) - expect(wideResult.stdout).toMatch(/WORKSPACE/i) - // JSON confirms workspace_id is populated - const jsonResult = await withRetry(() => fx.r(['get', 'app', '-A', '-o', 'json']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(jsonResult, 0) - const parsed = assertJson<{ data: Array<{ workspace_id: string }> }>(jsonResult) - expect(parsed.data.length, 'data must be non-empty').toBeGreaterThan(0) - parsed.data.forEach((app) => - expect(typeof app.workspace_id, 'workspace_id must be a string').toBe('string'), - ) - }, - ) - - it('[P0] JSON output contains workspace_id in every app entry (3.95)', async () => { - // Spec 3.95: every app object must carry a workspace_id string field. - const result = await withRetry(() => fx.r(['get', 'app', '-A', '-o', 'json']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ workspace_id: string }> }>(result) - expect(parsed.data.length, 'all-workspaces data must be non-empty').toBeGreaterThan(0) - parsed.data.forEach((app) => - expect(typeof app.workspace_id, `workspace_id must be a string`).toBe('string'), - ) - }) - - it('[P1] YAML output contains workspace_id', async () => { - const result = await fx.r(['get', 'app', '-A', '-o', 'yaml']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/workspace_id/) - }) - - it('[P1] all-workspaces output is pipe-friendly in JSON mode', async () => { - const result = await fx.r(['get', 'app', '-A', '-o', 'json']) - assertExitCode(result, 0) - assertPipeFriendlyJson(result) - }) - - it('[P0] all-workspaces output has no ANSI colour codes (non-TTY)', async () => { - const result = await fx.r(['get', 'app', '-A']) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, 'stdout') - }) - - // ── Filters in all-workspaces mode ──────────────────────────────────────── - - eeIt('[EE][P1] --limit applies per workspace in all-workspaces mode (3.101)', async () => { - // Spec 3.101: --limit is applied per-workspace; total across all workspaces - // may exceed the limit value. Verify the command succeeds with a valid data array. - const result = await fx.r(['get', 'app', '-A', '--limit', '2', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[] }>(result) - expect(Array.isArray(parsed.data)).toBe(true) - // With 2 workspaces each capped at 2, total should be ≤ 2 * num_workspaces - expect( - parsed.data.length, - 'total should be bounded by limit × workspace count', - ).toBeLessThanOrEqual(10) - }) - - it('[P1] --mode filter applies in all-workspaces mode', async () => { - const result = await fx.r(['get', 'app', '-A', '--mode', 'workflow', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ mode: string }> }>(result) - parsed.data.forEach((app) => expect(app.mode).toBe('workflow')) - }) - - // ── Unauthenticated ─────────────────────────────────────────────────────── - - it('[P0] unauthenticated get app -A returns auth error and exit code 4 (3.104)', async () => { - // Spec 3.104: no session → auth error; exit code 4. Merged from two duplicate cases. - const tmp = await withTempConfig() - try { - const result = await run(['get', 'app', '-A'], { configDir: tmp.configDir }) - assertExitCode(result, 4) - expect(result.stderr).toMatch(/not.?logged.?in|auth/i) - } finally { - await tmp.cleanup() - } - }) - - // ── External SSO ────────────────────────────────────────────────────────── - - itWithSso('[P0] external SSO user get app -A is rejected as an invalid flag', async () => { - // --all-workspaces is meaningless for external SSO users (no workspace - // scope), so the CLI rejects it client-side with usage_invalid_flag (exit 2). - // Uses real DIFY_E2E_SSO_TOKEN; skipped when not configured. - const { mkdir, writeFile } = await import('node:fs/promises') - const { join } = await import('node:path') - const ssoTmp = await withTempConfig() - try { - await mkdir(ssoTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: ${E.host}`, - `token_storage: file`, - `tokens:`, - ` bearer: ${E.ssoToken}`, - `external_subject:`, - ` email: sso@example.com`, - ` issuer: https://issuer.example.com`, - ].join('\n')}\n` - await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir }) - assertExitCode(result, 2) - expect(result.stderr).toMatch(/--all-workspaces is not available for external logins/) - } finally { - await ssoTmp.cleanup() - } - }) - - // ── JSON error envelope ─────────────────────────────────────────────────── - - it('[P1] JSON mode error outputs JSON error envelope to stderr', async () => { - const tmp = await withTempConfig() - try { - const { run } = await import('../../helpers/cli.js') - const result = await run(['get', 'app', '-A', '-o', 'json'], { configDir: tmp.configDir }) - expect(result.exitCode).not.toBe(0) - assertErrorEnvelope(result) - } finally { - await tmp.cleanup() - } - }) - - // ── Stability ───────────────────────────────────────────────────────────── - - it('[P1] using -A with -w together returns a stable result or clear error', async () => { - // Spec: behaviour when both flags are provided should be stable - const result = await fx.r(['get', 'app', '-A', '-w', E.workspaceId, '-o', 'json']) - // Either success (ignores -w) or a clear usage/logical error — must not panic - const isValid = result.exitCode === 0 || result.exitCode === 1 || result.exitCode === 2 - expect(isValid).toBe(true) - }) - - // ── New cases ───────────────────────────────────────────────────────────── - - eeIt('[EE][P1] -o wide WORKSPACE column shows workspace name for each app (3.93)', async () => { - // Spec 3.93: WORKSPACE column correctly displays the workspace name. - // WORKSPACE has priority:1 so it only appears in -o wide mode. - const result = await withRetry(() => fx.r(['get', 'app', '-A', '-o', 'wide']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/WORKSPACE/i) - // At least one workspace name from available_workspaces should appear - expect(result.stdout.length).toBeGreaterThan(0) - }) - - eeIt('[EE][P1] all-workspaces result is sorted by updated_at DESC (3.94)', async () => { - // Spec 3.94: results ordered by updated_at DESC (first item newest). - const result = await withRetry(() => fx.r(['get', 'app', '-A', '-o', 'json']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ updated_at: string }> }>(result) - if (parsed.data.length >= 2) { - const dates = parsed.data.map((a) => new Date(a.updated_at).getTime()) - // Loose check: most-recently updated item should be somewhere in the first half. - // The server may not guarantee strict per-item DESC order within the same second, - // so we only assert the global max appears in the data (not necessarily first). - const maxDate = Math.max(...dates) - const minDate = Math.min(...dates) - expect(maxDate, 'results should span some time range').toBeGreaterThanOrEqual(minDate) - // Weakly: the first item's date should be at least as recent as the median - const medianIdx = Math.floor(dates.length / 2) - expect(dates[0]!, 'first item should not be older than the median').toBeGreaterThanOrEqual( - dates[medianIdx]!, - ) - } - }) - - it('[P1] network error on get app -A returns non-zero exit (3.107)', async () => { - // Spec 3.107: unreachable host → network error, exit non-0. - const { writeFile, mkdir } = await import('node:fs/promises') - const { join } = await import('node:path') - const networkTmp = await withTempConfig() - try { - await mkdir(networkTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: http://127.0.0.1:19999`, - `token_storage: file`, - `tokens:`, - ` bearer: dfoa_fake_token_network_test`, - `workspace:`, - ` id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - `available_workspaces:`, - ` - id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - ].join('\n')}\n` - await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['get', 'app', '-A'], { - configDir: networkTmp.configDir, - timeout: 15_000, - }) - expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) - expect(result.stderr.length).toBeGreaterThan(0) - } finally { - await networkTmp.cleanup() - } - }) -}) diff --git a/cli/test/e2e/suites/discovery/get-app-list.e2e.ts b/cli/test/e2e/suites/discovery/get-app-list.e2e.ts deleted file mode 100644 index e7d9540a10e..00000000000 --- a/cli/test/e2e/suites/discovery/get-app-list.e2e.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * E2E: difyctl get app (list mode) — App List - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/App List (31 cases) - * - * Prerequisites (DIFY_E2E_* env vars): - * DIFY_E2E_CHAT_APP_ID — echo-chat app - * DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app - */ - -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { - assertErrorEnvelope, - assertExitCode, - assertJson, - assertNoAnsi, - assertPipeFriendlyJson, -} from '../../helpers/assert.js' -import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) -const itWithSso = optionalIt(Boolean(E.ssoToken)) - -describe('E2E / difyctl get app (list)', () => { - let fx: Awaited<ReturnType<typeof withAuthFixture>> - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ── Basic listing ───────────────────────────────────────────────────────── - - it('[P0] logged-in user can retrieve app list', async () => { - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - expect(result.stdout.length).toBeGreaterThan(0) - }) - - it('[P0] default output format is table', async () => { - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - // table output: has column headers, no leading '{' (not JSON) - expect(result.stdout.trimStart()).not.toMatch(/^\{/) - }) - - it('[P1] table output contains app ID', async () => { - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/ID/i) - }) - - it('[P1] table output contains app name', async () => { - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/NAME/i) - }) - - it('[P1] table output contains mode column', async () => { - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/MODE/i) - }) - - // ── Output formats ──────────────────────────────────────────────────────── - - it('[P0] -o json outputs valid JSON', async () => { - const result = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[] }>(result) - expect(Array.isArray(parsed.data)).toBe(true) - }) - - it('[P1] -o yaml outputs valid YAML (non-empty, no JSON braces)', async () => { - const result = await fx.r(['get', 'app', '-o', 'yaml']) - assertExitCode(result, 0) - expect(result.stdout.length).toBeGreaterThan(0) - // YAML lists start with '- ' not '{' - expect(result.stdout.trimStart()).not.toMatch(/^\{/) - }) - - it('[P1] -o name outputs only app IDs (one per line)', async () => { - const result = await fx.r(['get', 'app', '-o', 'name']) - assertExitCode(result, 0) - const lines = result.stdout.trim().split('\n').filter(Boolean) - expect(lines.length).toBeGreaterThan(0) - // Each line should look like a UUID - expect(lines[0]).toMatch(/^[0-9a-f-]{36}$/) - }) - - it('[P1] -o wide outputs extended fields', async () => { - const result = await fx.r(['get', 'app', '-o', 'wide']) - assertExitCode(result, 0) - // wide adds the WORKSPACE column - expect(result.stdout).toMatch(/WORKSPACE/i) - }) - - it('[P1] output is pipe-friendly in JSON mode', async () => { - const result = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(result, 0) - assertPipeFriendlyJson(result) - }) - - it('[P0] output has no ANSI colour codes (non-TTY)', async () => { - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, 'stdout') - }) - - // ── --limit ─────────────────────────────────────────────────────────────── - - it('[P0] --limit restricts number of returned apps', async () => { - const result = await fx.r(['get', 'app', '--limit', '1', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[] }>(result) - expect(parsed.data.length).toBeLessThanOrEqual(1) - }) - - it('[P1] --limit 1 returns exactly one result', async () => { - const result = await fx.r(['get', 'app', '--limit', '1', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[] }>(result) - expect(parsed.data.length).toBe(1) - }) - - it('[P0] --limit 0 returns usage error (exit code 2)', async () => { - const result = await fx.r(['get', 'app', '--limit', '0']) - expect(result.exitCode).toBe(2) - }) - - it('[P0] --limit 201 returns usage error (exit code 2)', async () => { - const result = await fx.r(['get', 'app', '--limit', '201']) - expect(result.exitCode).toBe(2) - }) - - // ── --mode filter ───────────────────────────────────────────────────────── - - it('[P0] --mode chat filters to chat apps only', async () => { - const result = await fx.r(['get', 'app', '--mode', 'chat', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ mode: string }> }>(result) - parsed.data.forEach((app) => expect(app.mode).toBe('chat')) - }) - - it('[P0] --mode workflow filters to workflow apps only', async () => { - const result = await fx.r(['get', 'app', '--mode', 'workflow', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ mode: string }> }>(result) - parsed.data.forEach((app) => expect(app.mode).toBe('workflow')) - }) - - it('[P0] --mode with a valid enum value succeeds', async () => { - // Spec: valid enum filter returns successfully - const result = await fx.r(['get', 'app', '--mode', 'workflow', '-o', 'json']) - assertExitCode(result, 0) - }) - - it('[P1] --mode with truly unknown value returns non-zero (3.18)', async () => { - // Spec 3.18: --mode invalid (not a known Dify mode) → CLI intercepts, exit non-0. - const result = await fx.r(['get', 'app', '--mode', 'unknown_mode_xyz']) - expect(result.exitCode, '--mode with unknown value should be rejected').not.toBe(0) - }) - - it('[P1] --mode chatbot is intercepted client-side with usage error (3.31)', async () => { - // Spec 3.31: 'chatbot' is not a valid enum value; CLI intercepts (exit 2). - // Before fix WTA-F-01 the server returned 422; after fix CLI rejects early. - const result = await fx.r(['get', 'app', '--mode', 'chatbot']) - // exit 2 is the expected CLI-intercept behaviour; current server returns exit 1 - // (WTA-F-01 not yet applied on this env). Accept any non-zero exit. - expect(result.exitCode, '--mode chatbot should cause non-zero exit').not.toBe(0) - }) - - // Regression: rag-pipeline (a knowledge Pipeline), channel (unused) and agent - // (roster-owned) are AppMode members but not listable app types. The old CLI - // whitelist advertised rag-pipeline/channel, so the CLI forwarded them and the - // server replied 400. The whitelist now derives from SupportedAppType, so the - // CLI rejects them before any HTTP call. - it.each(['rag-pipeline', 'channel', 'agent'])( - '[P0] non-listable mode %s is intercepted client-side', - async (mode) => { - const result = await fx.r(['get', 'app', '--mode', mode]) - expect(result.exitCode, `--mode ${mode} should be rejected client-side`).not.toBe(0) - }, - ) - - // ── workspace override ──────────────────────────────────────────────────── - - it('[P0] -w overrides the default workspace', async () => { - // Pass the known workspace id — should return apps for that workspace - const result = await fx.r(['get', 'app', '--workspace', E.workspaceId, '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[] }>(result) - expect(Array.isArray(parsed.data)).toBe(true) - }) - - // ── Unauthenticated ─────────────────────────────────────────────────────── - - it('[P0] unauthenticated get app returns auth error and exit code 4 (3.22 / 3.23)', async () => { - // Spec 3.22: returns auth error; Spec 3.23: exit code is 4. - // Merged into one case — both assertions on the same run. - const tmp = await withTempConfig() - try { - const result = await run(['get', 'app'], { configDir: tmp.configDir }) - assertExitCode(result, 4) - expect(result.stderr).toMatch(/not.?logged.?in|auth/i) - } finally { - await tmp.cleanup() - } - }) - - // ── External SSO ────────────────────────────────────────────────────────── - - itWithSso('[P0] external SSO user can list permitted apps', async () => { - // A dfoe_ token lists apps via the permitted-external surface - // (apps:read:permitted-external scope), with no workspace scoping. - // Uses DIFY_E2E_SSO_TOKEN (itWithSso skips when not configured). - const { mkdir, writeFile } = await import('node:fs/promises') - const { join } = await import('node:path') - const ssoTmp = await withTempConfig() - try { - await mkdir(ssoTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: ${E.host}`, - `token_storage: file`, - `tokens:`, - ` bearer: ${E.ssoToken}`, - `external_subject:`, - ` email: sso@example.com`, - ` issuer: https://issuer.example.com`, - ].join('\n')}\n` - await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['get', 'app'], { configDir: ssoTmp.configDir }) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i) - } finally { - await ssoTmp.cleanup() - } - }) - - // ── JSON error envelope ─────────────────────────────────────────────────── - - it('[P1] JSON mode error outputs JSON error envelope to stderr', async () => { - const tmp = await withTempConfig() - try { - const { run } = await import('../../helpers/cli.js') - const result = await run(['get', 'app', '-o', 'json'], { configDir: tmp.configDir }) - expect(result.exitCode).not.toBe(0) - assertErrorEnvelope(result) - } finally { - await tmp.cleanup() - } - }) - - // ── New cases ───────────────────────────────────────────────────────────── - - it('[P0] -o json elements contain id, name, and mode fields (3.7 extended)', async () => { - // Spec 3.7: JSON output must include core fields per item. - const result = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ id: string; name: string; mode: string }> }>(result) - expect(parsed.data.length, 'data array must be non-empty').toBeGreaterThan(0) - const first = parsed.data[0]! - expect(typeof first.id, 'id must be a string').toBe('string') - expect(first.id.length, 'id must be non-empty').toBeGreaterThan(0) - expect(typeof first.name, 'name must be a string').toBe('string') - expect(typeof first.mode, 'mode must be a string').toBe('string') - }) - - it('[P1] app list is sorted by updated_at DESC (3.2)', async () => { - // Spec 3.2: apps are returned in descending updated_at order. - const result = await withRetry(() => fx.r(['get', 'app', '-o', 'json']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ updated_at: string }> }>(result) - // Loose check: first item's updated_at should be >= last item's. - // Strict pairwise check is fragile because apps updated at the same second - // may appear in any order within that second. - const dates = parsed.data.map((a) => new Date(a.updated_at).getTime()) - expect(dates[0]!, 'first item should have the newest updated_at').toBeGreaterThanOrEqual( - dates[dates.length - 1]!, - ) - }) - - it('[P1] --limit 100 (server max) returns apps and exits 0 (3.13)', async () => { - // Spec 3.13: upper limit is the server-enforced maximum. - // The server validates limit ≤ 100 (not 200 as stated in the original spec); - // --limit 200 returns a 400 validation error on this environment. - const result = await fx.r(['get', 'app', '--limit', '100', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[] }>(result) - expect(parsed.data.length, 'should return ≤ 100 apps').toBeLessThanOrEqual(100) - }) - - it('[P1] --name filter returns only apps whose name contains the keyword (3.19)', async () => { - // Spec 3.19: --name performs substring match on app name. - // Uses "auto" which matches the fixture apps (basic_auto_test, file_auto_test, etc.). - const result = await fx.r(['get', 'app', '--name', 'auto', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ name: string }> }>(result) - expect(parsed.data.length, '--name auto should return at least 1 app').toBeGreaterThan(0) - parsed.data.forEach((app) => - expect(app.name.toLowerCase(), `app "${app.name}" should contain "auto"`).toContain('auto'), - ) - }) - - it('[P1] -o name output is pipe-friendly — each line is a UUID-format ID (3.29)', async () => { - // Spec 3.29: -o name | wc -l works; each line is an app ID (UUID format). - const result = await fx.r(['get', 'app', '-o', 'name']) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, 'stdout') - const lines = result.stdout.trim().split('\n').filter(Boolean) - expect(lines.length, '-o name should output at least one line').toBeGreaterThan(0) - lines.forEach((line) => - expect(line.trim(), `"${line}" should be a UUID`).toMatch(/^[0-9a-f-]{36}$/), - ) - }) - - it('[P1] network error on get app returns non-zero exit and error message (3.27)', async () => { - // Spec 3.27: unreachable host → network error, exit non-0. - const { writeFile, mkdir } = await import('node:fs/promises') - const { join } = await import('node:path') - const networkTmp = await withTempConfig() - try { - await mkdir(networkTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: http://127.0.0.1:19999`, - `token_storage: file`, - `tokens:`, - ` bearer: dfoa_fake_token_network_test`, - `workspace:`, - ` id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - `available_workspaces:`, - ` - id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - ].join('\n')}\n` - await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['get', 'app'], { configDir: networkTmp.configDir, timeout: 15_000 }) - expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) - expect(result.stderr.length, 'stderr should contain error message').toBeGreaterThan(0) - } finally { - await networkTmp.cleanup() - } - }) -}) diff --git a/cli/test/e2e/suites/discovery/get-app-single.e2e.ts b/cli/test/e2e/suites/discovery/get-app-single.e2e.ts deleted file mode 100644 index e264337d1c7..00000000000 --- a/cli/test/e2e/suites/discovery/get-app-single.e2e.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * E2E: difyctl get app <id> — Single App Query - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Single App Query (22 cases) - * - * Note: difyctl get app <id> queries a single app via GET /apps/<id>?fields=info. - * The response is returned in list-envelope format {page,limit,total,data:[...]}. - */ - -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { - assertErrorEnvelope, - assertExitCode, - assertJson, - assertNoAnsi, - assertPipeFriendlyJson, -} from '../../helpers/assert.js' -import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) -const itWithSso = optionalIt(Boolean(E.ssoToken)) -const NONEXISTENT_ID = 'app-does-not-exist-e2e-xyz' - -describe('E2E / difyctl get app <id> (single)', () => { - let fx: Awaited<ReturnType<typeof withAuthFixture>> - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ── Not found ───────────────────────────────────────────────────────────── - - it('[P0] non-existent app returns exit code 1 with not-found error (3.50)', async () => { - // Spec 3.50: get app <invalid-id> → stderr contains not-found error, exit code is 1. - const result = await fx.r(['get', 'app', NONEXISTENT_ID]) - expect(result.exitCode, 'non-existent app should exit with code 1').toBe(1) - expect(result.stderr).toMatch(/not.?found|404|does not exist|server_5xx/i) - }) - - it('[P1] JSON mode error for non-existent app outputs JSON error envelope', async () => { - const result = await fx.r(['get', 'app', NONEXISTENT_ID, '-o', 'json']) - expect(result.exitCode).not.toBe(0) - assertErrorEnvelope(result) - }) - - // ── Unauthenticated ─────────────────────────────────────────────────────── - - it('[P0] unauthenticated get app <id> returns auth error and exit code 4 (3.54)', async () => { - // Spec 3.54: no session → auth error; exit code 4. Merged from two duplicate cases. - const tmp = await withTempConfig() - try { - const result = await run(['get', 'app', E.workflowAppId], { configDir: tmp.configDir }) - assertExitCode(result, 4) - expect(result.stderr).toMatch(/not.?logged.?in|auth/i) - } finally { - await tmp.cleanup() - } - }) - - // ── External SSO ────────────────────────────────────────────────────────── - - itWithSso('[P0] external SSO user can get a permitted app by id', async () => { - // A dfoe_ token resolves get app <id> via the permitted-external describe - // surface (apps:read:permitted-external scope), so a permitted app is returned. - // Uses DIFY_E2E_SSO_TOKEN; skipped when not configured. - const { mkdir, writeFile } = await import('node:fs/promises') - const { join } = await import('node:path') - const ssoTmp = await withTempConfig() - try { - await mkdir(ssoTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: ${E.host}`, - `token_storage: file`, - `tokens:`, - ` bearer: ${E.ssoToken}`, - `external_subject:`, - ` email: sso@example.com`, - ` issuer: https://issuer.example.com`, - ].join('\n')}\n` - await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['get', 'app', E.chatAppId], { configDir: ssoTmp.configDir }) - assertExitCode(result, 0) - expect(result.stdout).toContain(E.chatAppId) - } finally { - await ssoTmp.cleanup() - } - }) - - // ── New cases: successful single-app query ─────────────────────────────── - - it('[P0] get app <valid-id> returns metadata and exits 0 (3.39 / 3.40 / 3.41 / 3.42-44)', async () => { - // Spec 3.39: returns metadata; 3.40: table format; 3.41: no ANSI; - // 3.42-44: output contains id, name, mode. - const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId]), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, 'stdout') - // table format: has column headers - expect(result.stdout).toMatch(/ID/i) - expect(result.stdout).toMatch(/NAME/i) - expect(result.stdout).toMatch(/MODE/i) - // actual data row: contains the app id and its name - expect(result.stdout).toContain(E.chatAppId) - }) - - it('[P0] get app <id> -o json returns valid JSON with id, name, mode fields (3.45)', async () => { - // Spec 3.45: -o json → valid JSON, contains id/name/mode per item. - const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId, '-o', 'json']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ id: string; name: string; mode: string }> }>(result) - expect(parsed.data.length, 'data array should contain the queried app').toBeGreaterThan(0) - const app = parsed.data[0]! - expect(typeof app.id).toBe('string') - expect(typeof app.name).toBe('string') - expect(typeof app.mode).toBe('string') - }) - - it('[P1] get app <id> -o yaml returns valid YAML and exits 0 (3.46)', async () => { - // Spec 3.46: -o yaml → valid YAML, exit 0. - const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId, '-o', 'yaml']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - expect(result.stdout.length).toBeGreaterThan(0) - expect(result.stdout.trimStart()).not.toMatch(/^\{/) - }) - - it('[P1] get app <id> -o name outputs only the app ID (3.47)', async () => { - // Spec 3.47: -o name → only the app ID per line. - const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId, '-o', 'name']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - const lines = result.stdout.trim().split('\n').filter(Boolean) - expect(lines.length).toBeGreaterThan(0) - expect(lines[0]).toMatch(/^[0-9a-f-]{36}$/) - }) - - it('[P1] get app <id> -o wide outputs extended columns (3.48)', async () => { - // Spec 3.48: -o wide → UPDATED/WORKSPACE columns, exit 0. - const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId, '-o', 'wide']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/UPDATED|WORKSPACE/i) - }) - - it('[P1] get app <id> -o json is pipe-friendly with no ANSI (3.49)', async () => { - // Spec 3.49: -o json | jq . works; no ANSI codes. - const result = await withRetry(() => fx.r(['get', 'app', E.chatAppId, '-o', 'json']), { - attempts: 3, - delayMs: 2000, - }) - assertExitCode(result, 0) - assertPipeFriendlyJson(result) - }) - - it('[P1] get app with special-character id returns non-zero exit (3.53)', async () => { - // Spec 3.53: get app "!@#" → query fails, exit 1 (server-side error). - const result = await fx.r(['get', 'app', '!@#']) - expect(result.exitCode, 'special-character id should cause non-zero exit').not.toBe(0) - expect(result.stderr.length).toBeGreaterThan(0) - }) - - it('[P1] get app <id> -w <workspace-id> returns app from that workspace (3.56)', async () => { - // Spec 3.56: -w override with the known workspace → returns the app, exit 0. - const result = await withRetry( - () => fx.r(['get', 'app', E.chatAppId, '--workspace', E.workspaceId, '-o', 'json']), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown[] }>(result) - expect(Array.isArray(parsed.data)).toBe(true) - }) - - it('[P1] network error on get app <id> returns non-zero exit (3.58)', async () => { - // Spec 3.58: unreachable host → network error, exit non-0. - const { writeFile, mkdir } = await import('node:fs/promises') - const { join } = await import('node:path') - const networkTmp = await withTempConfig() - try { - await mkdir(networkTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: http://127.0.0.1:19999`, - `token_storage: file`, - `tokens:`, - ` bearer: dfoa_fake_token_network_test`, - `workspace:`, - ` id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - `available_workspaces:`, - ` - id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - ].join('\n')}\n` - await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['get', 'app', E.chatAppId], { - configDir: networkTmp.configDir, - timeout: 15_000, - }) - expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) - expect(result.stderr.length).toBeGreaterThan(0) - } finally { - await networkTmp.cleanup() - } - }) - - // Spec 3.57: current workspace does not contain the queried app → not found, exit 1 - it('[P1] get app <id> --workspace <other-workspace-id> returns not found (3.57)', async () => { - // Spec 3.57: when the queried app does not belong to the specified workspace, - // the server returns not-found. We construct the scenario by passing a - // well-formed but non-existent workspace UUID so the server cannot locate the - // app within it, which is equivalent to "current workspace does not contain - // the app". - const FOREIGN_WORKSPACE_ID = '00000000-0000-0000-0000-000000000001' - const result = await withRetry( - () => fx.r(['get', 'app', E.chatAppId, '--workspace', FOREIGN_WORKSPACE_ID]), - { attempts: 3, delayMs: 2000 }, - ) - expect(result.exitCode, 'app not in workspace should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch( - /not.?found|404|does not exist|server_5xx|not.?authorized|forbidden|workspace/i, - ) - }) -}) diff --git a/cli/test/e2e/suites/dsl/export-studio-app.e2e.ts b/cli/test/e2e/suites/dsl/export-studio-app.e2e.ts deleted file mode 100644 index 650b0f47e0c..00000000000 --- a/cli/test/e2e/suites/dsl/export-studio-app.e2e.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * E2E: difyctl export studio-app — DSL export - * - * Prerequisites (DIFY_E2E_* env vars): - * DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app (no model provider dependency) - * DIFY_E2E_CHAT_APP_ID — echo-chat app - */ - -import type { AuthFixture } from '../../helpers/cli.js' -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { assertExitCode } from '../../helpers/assert.js' -import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -describe('E2E / difyctl export studio-app', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ── Basic export ────────────────────────────────────────────────────────── - - it('[P0] exported DSL is non-empty YAML printed to stdout', async () => { - const result = await fx.r(['export', 'studio-app', E.workflowAppId]) - assertExitCode(result, 0) - expect(result.stdout.trim().length).toBeGreaterThan(0) - }) - - it('[P0] exported YAML contains kind: app', async () => { - const result = await fx.r(['export', 'studio-app', E.workflowAppId]) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/^kind:\s*app/m) - }) - - it('[P0] exported YAML contains version field', async () => { - const result = await fx.r(['export', 'studio-app', E.workflowAppId]) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/^version:/m) - }) - - it('[P0] exported YAML contains app section with mode', async () => { - const result = await fx.r(['export', 'studio-app', E.workflowAppId]) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/^\s+mode:/m) - }) - - it('[P1] exported YAML ends with a newline (POSIX pipe convention)', async () => { - const result = await fx.r(['export', 'studio-app', E.workflowAppId]) - assertExitCode(result, 0) - expect(result.stdout.endsWith('\n')).toBe(true) - }) - - it('[P1] chat app export also succeeds and includes mode', async () => { - const result = await fx.r(['export', 'studio-app', E.chatAppId]) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/^kind:\s*app/m) - expect(result.stdout).toMatch(/^\s+mode:/m) - }) - - // ── --output flag ───────────────────────────────────────────────────────── - - it('[P1] --output writes DSL to file and exits 0', async () => { - const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-')) - const outPath = join(dir, 'exported.yaml') - try { - const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath]) - assertExitCode(result, 0) - const content = await readFile(outPath, 'utf8') - expect(content).toMatch(/^kind:\s*app/m) - expect(content).toMatch(/^version:/m) - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - it('[P1] --output writes same content as stdout', async () => { - const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-cmp-')) - const outPath = join(dir, 'exported.yaml') - try { - const [stdoutResult, fileResult] = await Promise.all([ - fx.r(['export', 'studio-app', E.workflowAppId]), - fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath]).then(async (r) => { - const content = await readFile(outPath, 'utf8') - return { exitCode: r.exitCode, content } - }), - ]) - assertExitCode(stdoutResult, 0) - expect(fileResult.exitCode).toBe(0) - expect(fileResult.content.trim()).toBe(stdoutResult.stdout.trim()) - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - // ── Roundtrip: export → import ──────────────────────────────────────────── - - it('[P1] roundtrip: exported DSL can be re-imported as a new app', async () => { - const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-roundtrip-')) - const dslPath = join(dir, 'roundtrip.yaml') - try { - const exportResult = await fx.r([ - 'export', - 'studio-app', - E.workflowAppId, - '--output', - dslPath, - ]) - assertExitCode(exportResult, 0) - - const importResult = await fx.r([ - 'import', - 'studio-app', - '--from-file', - dslPath, - '--name', - 'e2e-export-roundtrip', - ]) - assertExitCode(importResult, 0) - - const match = importResult.stderr.match(/app ([0-9a-f-]{36})/) - expect(match?.[1], 'import stderr must contain the new app UUID').toBeTruthy() - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - // ── Error scenarios ─────────────────────────────────────────────────────── - - it('[P0] non-existent app returns exit code 1 with error in stderr', async () => { - const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-export-e2e']) - expect(result.exitCode).toBe(1) - expect(result.stderr.length).toBeGreaterThan(0) - }) - - it('[P0] unauthenticated export returns auth error (exit code 4)', async () => { - const unauthTmp = await withTempConfig() - try { - const result = await run(['export', 'studio-app', E.workflowAppId], { - configDir: unauthTmp.configDir, - }) - assertExitCode(result, 4) - } finally { - await unauthTmp.cleanup() - } - }) - - it('[P1] export with missing app id argument exits non-zero', async () => { - const result = await fx.r(['export', 'studio-app']) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toMatch(/missing required argument|required|app id/i) - }) - - it('[P1] malformed --workflow-id returns a 4xx, not a 5xx', async () => { - const result = await fx.r([ - 'export', - 'studio-app', - E.workflowAppId, - '--workflow-id', - 'not-a-uuid', - ]) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toMatch(/http_status:\s*4\d\d/) - expect(result.stderr).not.toMatch(/http_status:\s*5\d\d/) - }) - - it('[P1] non-existent --workflow-id returns 404, not a 5xx', async () => { - const result = await fx.r([ - 'export', - 'studio-app', - E.workflowAppId, - '--workflow-id', - '00000000-0000-0000-0000-000000000000', - ]) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toMatch(/http_status:\s*404/) - }) - - it('[P1] non-existent app in --output mode leaves no file behind', async () => { - const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-nofile-')) - const outPath = join(dir, 'should-not-exist.yaml') - try { - const result = await fx.r([ - 'export', - 'studio-app', - 'nonexistent-app-id-nofile-e2e', - '--output', - outPath, - ]) - expect(result.exitCode).not.toBe(0) - const exists = await readFile(outPath, 'utf8') - .then(() => true) - .catch(() => false) - expect(exists, 'output file must not be created on export failure').toBe(false) - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) -}) diff --git a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts deleted file mode 100644 index b7860bf786d..00000000000 --- a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts +++ /dev/null @@ -1,520 +0,0 @@ -/** - * E2E: Error message standards — spec 5.3 - * - * Covers cross-cutting error output behaviour: error codes, message - * format, stdout/stderr isolation, no sensitive data leak, no stack - * traces in non-debug mode, Unicode/Chinese paths in error messages. - * - * Already covered in other suites (not duplicated here): - * 5.58 usage_invalid_flag (--limit abc) → get-app-list.e2e.ts - * 5.60 app not found → server_5xx → get-app-single.e2e.ts - * 5.62 not_logged_in, exit 4 → multiple auth suites - * 5.64 network_timeout → get-app-list / devices - * 5.67 file not found ENOENT with path → run-app-file.e2e.ts - * 5.71 missing required arg usage error → run-app-basic.e2e.ts - * 5.72 failed + -o json → JSON envelope → get-app-list / run-app-basic - * 5.73 JSON error.code present → assertErrorEnvelope (global) - * 5.74 JSON error.message present → assertErrorEnvelope (global) - * 5.75 JSON schema consistent → output/json-yaml-output.e2e.ts - * 5.77 failed → stdout empty → multiple suites - * 5.79 pipe stderr → no ANSI → output/table-output / get-app-list - * - * Non-automatable cases (excluded): - * 5.63b dfoe_ without workspace → usage_missing_arg — complex fixture setup - * 5.65 request timeout — cannot reliably control timing - * 5.68 upload failure (non-ENOENT) — hard to trigger reliably - * 5.69 workflow node failure — no stable fixture - * 5.78 TTY error colour — E2E runs with NO_COLOR=1 / non-TTY - * 5.82 --debug request log — --debug flag not implemented in CLI v1.0 - * 5.84 complex multi-line error readable — requires visual inspection - */ - -import type { AuthFixture } from '../../helpers/cli.js' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { ZERO } from '@/util/uuid.js' -import { - assertErrorEnvelope, - assertExitCode, - assertNoAnsi, - assertNonZeroExit, -} from '../../helpers/assert.js' -import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js' -import { optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) -const itWithSso = optionalIt(Boolean(E.ssoToken)) - -describe('E2E / error message standards (spec 5.3)', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ── 5.59 Unknown command ────────────────────────────────────────────────── - - it('[P0] 5.59 unknown command returns "unknown command" message and exit 1', async () => { - // Spec 5.59: executing an unrecognised command must exit 1 with a clear - // "unknown command" message so the user knows the command doesn't exist. - const result = await fx.r(['foobar', 'baz']) - expect(result.exitCode).toBe(1) - expect(result.stderr).toMatch(/unknown command/i) - }) - - // ── 5.61 Workspace not found ────────────────────────────────────────────── - - it('[P0] 5.61 use workspace with non-existent id returns workspace not found error', async () => { - // Spec 5.61: switching to a workspace that doesn't exist must return a - // recognisable "workspace not found" error with a non-zero exit code. - const result = await fx.r(['use', 'workspace', 'nonexistent-workspace-id-xyz']) - assertNonZeroExit(result) - expect(result.stderr).toMatch(/workspace.*(not found|404)|server_4xx/i) - }) - - // ── 5.63 dfoe_ token insufficient_scope ────────────────────────────────── - - itWithSso('[P0] dfoe_ SSO token is denied account-only management commands', async () => { - // A dfoe_ SSO token is rejected with a non-zero exit when it targets an - // account-only management command (`export studio-app`). - const { mkdir } = await import('node:fs/promises') - const ssoTmp = await withTempConfig() - try { - await mkdir(ssoTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: ${E.host}`, - `token_storage: file`, - `tokens:`, - ` bearer: ${E.ssoToken}`, - `external_subject:`, - ` email: sso@example.com`, - ` issuer: https://issuer.example.com`, - ].join('\n')}\n` - await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['export', 'studio-app', E.chatAppId], { - configDir: ssoTmp.configDir, - }) - assertNonZeroExit(result) - expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0) - } finally { - await ssoTmp.cleanup() - } - }) - - // ── 5.66 Corrupt config — error contains config file path ──────────────── - - it('[P0] 5.66 corrupt config.yml produces an error message that includes the file path', async () => { - // Spec 5.66: when config.yml is invalid YAML, the error message must - // include the config file path so the user knows which file to fix. - const corruptTmp = await withTempConfig() - try { - await writeFile(join(corruptTmp.configDir, 'config.yml'), ': broken: yaml: [[[', { - mode: 0o600, - }) - const result = await run(['config', 'get', 'defaults.format'], { - configDir: corruptTmp.configDir, - }) - assertNonZeroExit(result) - // The error must mention the config file path (either full path or filename) - expect(result.stderr).toMatch(/config\.yml/) - } finally { - await corruptTmp.cleanup() - } - }) - - // ── 5.70 Invalid field type → server error ─────────────────────────────── - - it('[P0] 5.70 passing a wrong-type input to a workflow app returns a non-zero exit', async () => { - // Spec 5.70: submitting a value of the wrong type must fail. - // The workflow app (workflowAppId) expects x as a string; passing a JSON - // number causes the server to reject the request. - // After the @accepts/@returns contract unification, the server returns HTTP 422 for request schema failures. - const result = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 123, num: 'not-a-number', enum_var: 'A', paragraph: 'ok' }), - '-o', - 'json', - ]) - assertNonZeroExit(result) - // stderr must contain an error (either validation or server error) - expect(result.stderr.trim().length).toBeGreaterThan(0) - }) - - // ── 5.70a/b/c P4 sanitization — 422 error body is clean (no leaks) ──────── - - it('[P0] 5.70a validation failure message is a plain string, not double-encoded JSON', async () => { - // After the @accepts contract fix, the server aborts with - // abort(422, message="Request validation failed", errors=[...]) - // The CLI wraps this into its envelope. The message field must be a plain - // human-readable string — NOT a JSON-serialised string that itself contains - // pydantic error details (which was the double-encoding bug in P4). - const result = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A', paragraph: 'ok' }), - '-o', - 'json', - ]) - assertNonZeroExit(result) - const envelope = assertErrorEnvelope(result) - // message must be a plain string, not a JSON string (no double encoding) - expect(typeof envelope.error.message).toBe('string') - expect(() => JSON.parse(envelope.error.message)).toThrow() - }) - - it('[P1] 5.70b validation error response does not leak pydantic version URL', async () => { - // Before the P4 fix, exc.json() included a "url" field pointing to - // https://errors.pydantic.dev/<version>/... — exposing the server's pydantic - // version. The sanitised response must not contain this URL. - const result = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A', paragraph: 'ok' }), - '-o', - 'json', - ]) - assertNonZeroExit(result) - expect(result.stderr).not.toMatch(/errors\.pydantic\.dev|pydantic\.dev\//) - }) - - it('[P1] 5.70c validation error response does not echo back user input', async () => { - // Before the P4 fix, exc.json() included the user's original "input" value - // inside the error details. The sanitised response must not repeat the - // submitted value so that sensitive payloads are not reflected to callers. - const sentValue = 'not-a-number-sentinel-12345' - const result = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'hello', num: sentValue, enum_var: 'A', paragraph: 'ok' }), - '-o', - 'json', - ]) - assertNonZeroExit(result) - expect(result.stderr).not.toContain(sentValue) - }) - - // ── 5.70d-h ErrorBody contract — error.server structure and rendering priorities ── - // PR #37285 introduces canonical ErrorBody on every /openapi/v1 non-2xx response. - // CLI strict-parses via zErrorBody.safeParse; success → full struct at error.server. - // - // V2 rendering priorities (format.ts, verified against codebase): - // header code : server?.code ?? cliCode — server wins, CLI fallback - // hint : cliHint ?? server?.hint — CLI wins, server fallback (V2 correction) - // details : server?.details[] — " - loc: msg (type)" per entry, no -v - - it('[P0] 5.70d JSON envelope contains error.server with canonical code/status/message', async () => { - // Trigger: describe app ZERO — server returns canonical 404 ErrorBody - // { code:"not_found", status:404, message:"app not found" }. - // zErrorBody.safeParse succeeds → error.server is populated on the current server. - const result = await fx.r(['describe', 'app', ZERO, '-o', 'json']) - assertNonZeroExit(result) - const envelope = JSON.parse(result.stderr.trim()) as { - error: { code: string; server?: { code: string; status: number; message: string } } - } - expect( - envelope.error.server, - 'error.server must be present when server returns canonical ErrorBody', - ).toBeDefined() - expect(typeof envelope.error.server?.code, 'error.server.code must be a string').toBe('string') - expect(envelope.error.server?.code.length).toBeGreaterThan(0) - expect(typeof envelope.error.server?.status, 'error.server.status must be a number').toBe( - 'number', - ) - expect(typeof envelope.error.server?.message, 'error.server.message must be a string').toBe( - 'string', - ) - expect(envelope.error.server?.message.length).toBeGreaterThan(0) - }) - - it('[P1] 5.70e @accepts query validation returns canonical 422 with details array', async () => { - // Trigger: direct fetch to GET /apps?page=not-integer — @accepts(query=AppListQuery) - // validates page as int and emits canonical 422 ErrorBody with details[]. - // Direct fetch is used because the CLI validates --page as integer client-side - // (would exit 2 before hitting the server); this pins the server-side contract. - const res = await fetch( - `${E.host.replace(/\/$/, '')}/openapi/v1/apps?workspace_id=${E.workspaceId}&page=not-an-integer`, - { headers: { Authorization: `Bearer ${E.token}` }, signal: AbortSignal.timeout(8_000) }, - ) - expect(res.status).toBe(422) - const body = (await res.json()) as { - code?: string - status?: number - details?: Array<{ type: string; loc: Array<string | number>; msg: string }> - } - expect(body.code).toBe('invalid_param') - expect(body.status).toBe(422) - expect(Array.isArray(body.details), 'details must be an array').toBe(true) - expect(body.details!.length).toBeGreaterThan(0) - const entry = body.details![0]! - expect(typeof entry.type).toBe('string') - expect(typeof entry.msg).toBe('string') - expect(Array.isArray(entry.loc)).toBe(true) - }) - - it('[P1] 5.70g rendering priority — header code: server code wins over CLI classification code', async () => { - // renderHuman: headerCode = server?.code ?? e.code (server wins, V2 unchanged) - // When canonical ErrorBody is parsed, the server semantic code replaces the CLI - // classification code ("server_4xx_other") in the human-readable output header. - // Trigger: describe app ZERO → canonical 404; header starts with "not_found:". - const result = await fx.r(['describe', 'app', ZERO]) - assertNonZeroExit(result) - expect(result.stderr.trimStart()).not.toMatch(/^server_4xx_other:/) - expect(result.stderr.trimStart()).toMatch(/^not_found:/) - }) - - it('[P1] 5.70g2 rendering priority — hint: CLI hint wins over server hint (V2 correction)', async () => { - // renderHuman: hint = cliHint ?? server?.hint (CLI wins — V2 spec correction) - // V1 incorrectly documented "server wins"; V2 aligns with codebase: CLI wins. - // Test: 401 AuthExpired — classifyResponse sets c.hint = AUTH_LOGIN_HINT before - // serverError is parsed; CLI hint takes precedence over any server-provided hint. - // Verified on current server (no @accepts deployment required). - const unauthTmp = await withTempConfig() - try { - const result = await run(['get', 'app', '-o', 'json'], { configDir: unauthTmp.configDir }) - assertExitCode(result, 4) - const envelope = JSON.parse(result.stderr.trim()) as { error: { hint?: string } } - expect(envelope.error.hint, 'CLI login hint must appear for auth error').toMatch( - /auth login/i, - ) - } finally { - await unauthTmp.cleanup() - } - }) - - it('[P1] 5.70h JSON envelope: error.code = CLI classification; error.server.code = server semantic code', async () => { - // toEnvelope() sets error.code from HTTP status bucket (e.g. "server_4xx_other") - // while the server's semantic code is separate in error.server.code. - // Agents can branch on error.server.code without parsing human-readable text. - // Trigger: describe app ZERO → canonical 404; error.code="server_4xx_other", - // error.server.code="not_found" — always distinct when ErrorBody is present. - const result = await fx.r(['describe', 'app', ZERO, '-o', 'json']) - assertNonZeroExit(result) - const envelope = JSON.parse(result.stderr.trim()) as { - error: { code: string; server?: { code: string } } - } - expect(envelope.error.code).toBe('server_4xx_other') - expect(envelope.error.server?.code).toBeDefined() - expect(envelope.error.server?.code).not.toBe('server_4xx_other') - }) - // ── 5.70i / 5.70j PR #37285 boundary contract ─────────────────────────── - - it('[P1] 5.70i unknown /openapi/v1 route returns canonical 404 ErrorBody without route suggestions', async () => { - // PR #37285: ExternalApi._help_on_404 suppresses flask-restx route enumeration. - // Previously, an unknown path under /openapi/v1/ returned flask-restx's default - // 404 with a "Did you mean /openapi/v1/apps?" suggestion, leaking the route table. - // After the fix it must return a canonical ErrorBody and contain no suggestions. - const res = await fetch( - `${E.host.replace(/\/$/, '')}/openapi/v1/this-path-does-not-exist-e2e`, - { - headers: { Authorization: `Bearer ${E.token}` }, - signal: AbortSignal.timeout(8_000), - }, - ) - expect(res.status).toBe(404) - const body = (await res.json()) as Record<string, unknown> - // canonical ErrorBody fields must be present - expect(typeof body.code, '404 body must have a string code field').toBe('string') - expect(body.status, '404 body must have status: 404').toBe(404) - // no flask-restx route enumeration in the response - const raw = JSON.stringify(body) - expect(raw).not.toMatch(/did you mean/i) - expect(raw).not.toMatch(/you might want/i) - }) - - it('[P1] 5.70j device-flow 4xx uses RFC 8628 format, not ErrorBody — zErrorBody parse fails gracefully', async () => { - // PR #37285 explicitly excludes RFC 8628 device-flow endpoints from the - // ErrorBody contract. This test pins that contract: - // - The device/token endpoint returns RFC 8628 {error: string} on failure, - // not the canonical {code, status, message} shape. - // - When the CLI's classifyResponse encounters this, zErrorBody.safeParse - // returns failure → serverError = undefined → generic status-based message, - // no error.server field, no crash. - const res = await fetch(`${E.host.replace(/\/$/, '')}/openapi/v1/oauth/device/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - device_code: 'fake-invalid-device-code-e2e-test', - client_id: 'difyctl', - }), - signal: AbortSignal.timeout(8_000), - }) - // device flow errors are 4xx (400 bad_request or 401 expired_token etc.) - expect(res.status).toBeGreaterThanOrEqual(400) - expect(res.status).toBeLessThan(500) - const body = (await res.json()) as Record<string, unknown> - // RFC 8628 shape: has 'error' string, must NOT have ErrorBody 'code'/'status' pair - expect(typeof body.error, 'RFC 8628 body must have a string error field').toBe('string') - expect(body).not.toHaveProperty('status') - // zErrorBody.safeParse would fail → CLI sets serverError = undefined → generic message - }) - - // ── 5.76 Failed command + -o yaml → stderr is still JSON envelope ──────── - - it('[P1] 5.76 failed command with -o yaml still outputs a JSON error envelope on stderr', async () => { - // Spec 5.76: the CLI outputs JSON error envelopes to stderr regardless of - // the -o format flag. A failure with -o yaml must produce a JSON envelope - // on stderr (not a YAML structure). - const unauthTmp = await withTempConfig() - try { - const result = await run(['get', 'app', '-o', 'yaml'], { - configDir: unauthTmp.configDir, - }) - assertNonZeroExit(result) - // Current CLI behaviour: plain-text error format is used for not_logged_in - // regardless of -o flag. This differs from the spec which expects a JSON - // envelope. We verify the minimum contract: stderr is non-empty. - expect(result.stderr.trim().length, 'stderr must be non-empty on failure').toBeGreaterThan(0) - } finally { - await unauthTmp.cleanup() - } - }) - - // ── 5.80 Error output contains no token / secret ───────────────────────── - - it('[P0] 5.80 error output does not leak bearer tokens or secrets', async () => { - // Spec 5.80: under no error condition must the CLI print bearer tokens, - // passwords or other secrets to stdout or stderr. - const unauthTmp = await withTempConfig() - try { - const result = await run(['get', 'app'], { configDir: unauthTmp.configDir }) - const combined = result.stdout + result.stderr - // Tokens start with dfoa_ (internal) or dfoe_ (SSO) - expect(combined).not.toMatch(/dfoa_[\w-]{10,}/) - expect(combined).not.toMatch(/dfoe_[\w-]{10,}/) - expect(combined).not.toMatch(/password|secret/i) - } finally { - await unauthTmp.cleanup() - } - }) - - // ── 5.81 / 5.83 No stack trace in error output ─────────────────────────── - - it('[P0] 5.81/5.83 server error output does not contain a stack trace', async () => { - // Spec 5.81: a server 500 must not expose internal stack details. - // Spec 5.83: without --debug the CLI must never print a stack trace. - // We trigger a server_5xx by querying a non-existent app id and verify - // that no "at <FunctionName>" stack-trace lines appear in stderr. - const result = await fx.r(['get', 'app', ZERO]) - assertNonZeroExit(result) - // Stack trace lines look like " at Object.xxx (/path/to/file.js:123:45)" - expect(result.stderr).not.toMatch(/^\s+at\s+\S/m) - // Internal file paths must not be exposed - expect(result.stderr).not.toMatch(/node_modules|\.js:\d+:\d+/) - }) - - // ── 5.85 Chinese / CJK file path in error message ──────────────────────── - - it('[P1] 5.85 error message for a non-existent file with a CJK path displays the path correctly', async () => { - // Spec 5.85: when a file path contains CJK characters and the file does - // not exist, the error message must display the path without garbling. - const fileDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-cjk-')) - try { - const cjkPath = join(fileDir, 'cjk-test-\u6587\u6863.txt') // "document" in Chinese — tests CJK path handling - // Do not create the file — we want the "not found" error - const result = await fx.r([ - 'run', - 'app', - E.fileAppId || E.chatAppId, - '--file', - `doc=@${cjkPath}`, - ]) - assertNonZeroExit(result) - const combined = result.stdout + result.stderr - // The path (or a portion) must appear in the error without Unicode escaping - expect(combined).toMatch(/cjk-test-|\u6587\u6863|ENOENT|not.*found|failed/i) - // Must not contain \uXXXX escapes for the CJK characters - expect(combined).not.toMatch(/\\u[0-9a-fA-F]{4}/) - } finally { - await rm(fileDir, { recursive: true, force: true }) - } - }) - - // ── 5.86 Unicode characters in error messages ──────────────────────────── - - it('[P1] 5.86 error messages containing Unicode data display it correctly without escaping', async () => { - // Spec 5.86: any Unicode characters that appear in an error message (e.g. - // from a workspace name or app name) must appear as literal characters, - // not as \uXXXX escape sequences. - const result = await fx.r(['get', 'app', '-o', 'json']) - // get app may succeed or fail depending on staging; in either case the - // output (stdout or stderr) must contain no \uXXXX escape sequences. - const combined = result.stdout + result.stderr - expect(combined).not.toMatch(/\\u[0-9a-fA-F]{4}/) - }) - - // ── 5.87 stderr still outputs in pipe mode ─────────────────────────────── - - it('[P1] 5.87 stderr is non-empty when a command fails in pipe mode', async () => { - // Spec 5.87: even when stdout is piped (non-TTY), stderr must still - // contain the error message — it must not be suppressed. - // In E2E all runs use non-TTY stdout; we verify stderr is populated. - const unauthTmp = await withTempConfig() - try { - const result = await run(['get', 'app'], { configDir: unauthTmp.configDir }) - assertNonZeroExit(result) - expect( - result.stderr.trim().length, - 'stderr must be non-empty in pipe/non-TTY mode', - ).toBeGreaterThan(0) - // stderr must also have no ANSI codes (non-TTY = no colour) - assertNoAnsi(result.stderr, 'stderr') - } finally { - await unauthTmp.cleanup() - } - }) - - // ── 5.88 / 5.89 Corrupt local state handling ──────────────────────────── - - it('[P1] 5.88 corrupt app-info cache does not produce a bare TypeError', async () => { - const cacheDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-cache-')) - try { - await writeFile(join(cacheDir, 'app-info.yml'), ': : not valid yaml', 'utf8') - const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'], { - DIFY_CACHE_DIR: cacheDir, - }) - expect(result.stderr).not.toMatch(/TypeError|SyntaxError|^\s+at\s+\S/m) - if (result.exitCode !== 0) { - assertErrorEnvelope(result) - } else { - expect(result.stdout.trim()).toMatch(/^\{/) - } - } finally { - await rm(cacheDir, { recursive: true, force: true }) - } - }) - - it('[P1] 5.89 corrupt hosts.yml produces JSON error envelope', async () => { - const corruptTmp = await withTempConfig() - try { - await writeFile(join(corruptTmp.configDir, 'hosts.yml'), ': : not valid yaml', { - mode: 0o600, - }) - const result = await run(['get', 'app', '-o', 'json'], { - configDir: corruptTmp.configDir, - }) - assertNonZeroExit(result) - const envelope = assertErrorEnvelope(result) - expect(envelope.error.message).toContain('hosts.yml') - expect(result.stderr).not.toMatch(/YAMLException|^\s+at\s+\S/m) - } finally { - await corruptTmp.cleanup() - } - }) -}) diff --git a/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts b/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts deleted file mode 100644 index 9fb97215064..00000000000 --- a/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * E2E: Exit Code standards — spec 5.4 - * - * Exit code contract: - * 0 — success (also: --help, version, empty command) - * 1 — server / resource error (not_found, server_5xx, network) - * 2 — usage / argument error (unknown flag, invalid value, missing arg) - * 4 — authentication error (not_logged_in, token expired) - * 6 — config schema error (config parse failure, unsupported version) - * - * Already covered in other suites (not duplicated here): - * 5.90 success exit 0 → all passing tests in every suite - * 5.91 usage error exit 2 → get-app-list (--limit 0/201), run-app-basic - * 5.92 app not found exit 1 → get-app-single - * 5.93 auth error exit 4 → get-app-list, auth suites, run-app-basic - * 5.94 insufficient_scope → get-app-list (SSO guard) - * 5.96 network error → get-app-list, get-app-single, devices - * 5.98 Ctrl+C streaming → run-app-streaming.e2e.ts - * 5.99 Ctrl+C streaming → run-app-streaming.e2e.ts - * 5.100 server 500 exit 1 → get-app-single, error-messages - * 5.101 invalid input exit 2/1 → run-app-basic (many cases) - * 5.109 unknown command exit 1 → error-handling/error-messages.e2e.ts (5.59) - * 5.111 failed stdout empty → run-app-basic, get-app-list (many) - * - * Non-automatable cases (excluded): - * 5.97 timeout exit — cannot reliably control request timeout - * 5.102 file upload failure — hard to trigger non-ENOENT upload failure - * 5.103 workflow node failure — no stable staging fixture - * 5.115 shell stays healthy after failure — needs real shell context - * 5.116 crash exit — cannot reliably trigger CLI crash - * 5.117 panic output — cannot reliably trigger panic - */ - -import type { AuthFixture } from '@test/e2e/helpers/cli.js' -import { writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { assertExitCode, assertNonZeroExit } from '@test/e2e/helpers/assert.js' -import { run, withAuthFixture, withTempConfig } from '@test/e2e/helpers/cli.js' -import { resolveEnv } from '@test/e2e/setup/env.js' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { ZERO } from '@/util/uuid.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('@test/e2e/setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -describe('E2E / exit code standards (spec 5.4)', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ── 5.95 Corrupt config → exit 6 ───────────────────────────────────────── - - it('[P0] 5.95 corrupt config.yml causes a non-zero exit (exit 6 — config_schema_unsupported)', async () => { - // Spec 5.95: when config.yml contains invalid YAML the CLI must exit with - // a non-zero code. In practice the CLI exits 6 (config_schema_unsupported). - const corruptTmp = await withTempConfig() - try { - await writeFile(join(corruptTmp.configDir, 'config.yml'), ': broken yaml [[[', { - mode: 0o600, - }) - const result = await run(['config', 'get', 'defaults.format'], { - configDir: corruptTmp.configDir, - }) - expect(result.exitCode).toBe(6) - } finally { - await corruptTmp.cleanup() - } - }) - - // ── 5.104 Failed + -o json exit code (WTA-249) ─────────────────────────── - - it('[P0] 5.104 failed command with -o json returns non-zero exit — documents WTA-249 known defect', async () => { - // Spec 5.104: a failed command with -o json must return a non-zero exit code. - // WTA-249 has been fixed: app not found now correctly returns exit 1. - // - // Scenario: get app with a non-existent UUID + -o json → server_4xx_other - const result = await fx.r(['get', 'app', ZERO, '-o', 'json']) - // WTA-249 has been fixed in the current build: 4xx with -o json now - // correctly returns exit 1. - expect(result.exitCode, 'app not found with -o json must exit 1 (WTA-249 fixed)').toBe(1) - // stderr must still contain the JSON error envelope - expect(result.stderr).toMatch(/app not found|server_4xx|error/i) - }) - - // ── 5.105 Failed + -o yaml exit code ──────────────────────────────────── - - it('[P1] 5.105 failed command with -o yaml returns a non-zero exit code', async () => { - // Spec 5.105: -o yaml on a failing command must not swallow the exit code. - const unauthTmp = await withTempConfig() - try { - const result = await run(['get', 'app', '-o', 'yaml'], { - configDir: unauthTmp.configDir, - }) - assertNonZeroExit(result) - } finally { - await unauthTmp.cleanup() - } - }) - - // ── 5.106 --help exit 0 ────────────────────────────────────────────────── - - it('[P1] 5.106 difyctl --help exits with code 0', async () => { - // Spec 5.106: help output must not be treated as an error. - const result = await fx.r(['--help']) - assertExitCode(result, 0) - }) - - // ── 5.107 version exit 0 ───────────────────────────────────────────────── - - it('[P1] 5.107 difyctl version exits with code 0', async () => { - // Spec 5.107: --version does not exist; the correct command is "version". - const result = await fx.r(['version']) - assertExitCode(result, 0) - }) - - // ── 5.108 Empty command exit 0 ─────────────────────────────────────────── - - it('[P1] 5.108 difyctl with no arguments exits with code 0 (displays help)', async () => { - // Spec 5.108: running difyctl without arguments prints help and exits 0. - const result = await fx.r([]) - assertExitCode(result, 0) - // Must print some usage/command output - expect(result.stdout.length).toBeGreaterThan(0) - }) - - // ── 5.112 Successful command stderr is empty ───────────────────────────── - - it('[P1] 5.112 a successful query command produces no stderr output', async () => { - // Spec 5.112: on success stderr must be empty (no spurious warnings). - // Using get app -o json --limit 1 which has no hint or side-channel output. - const result = await fx.r(['get', 'app', '-o', 'json', '--limit', '1']) - assertExitCode(result, 0) - expect(result.stderr.trim(), 'stderr must be empty on successful query').toBe('') - }) - - // ── 5.113 Repeated identical failure → consistent exit code ────────────── - - it('[P1] 5.113 repeated identical failure commands return the same exit code each time', async () => { - // Spec 5.113: exit codes must be deterministic — the same error condition - // must always produce the same exit code. - const unauthTmp = await withTempConfig() - try { - const r1 = await run(['get', 'app'], { configDir: unauthTmp.configDir }) - const r2 = await run(['get', 'app'], { configDir: unauthTmp.configDir }) - expect(r1.exitCode).toBe(r2.exitCode) - expect(r1.exitCode).not.toBe(0) - } finally { - await unauthTmp.cleanup() - } - }) - - // ── 5.114 Exit code classification ────────────────────────────────────── - - it('[P1] 5.114 exit codes follow the classification: usage=2, auth=4, server=1', async () => { - // Spec 5.114: the three main exit code classes must be distinct and correct. - - // Class 2 — usage/argument error - const usageResult = await fx.r(['get', 'app', '-o', 'table']) - expect(usageResult.exitCode, 'illegal -o value must exit 2').toBe(2) - - // Class 4 — authentication error - const unauthTmp = await withTempConfig() - let authExitCode: number - try { - const authResult = await run(['get', 'app'], { configDir: unauthTmp.configDir }) - authExitCode = authResult.exitCode - } finally { - await unauthTmp.cleanup() - } - expect(authExitCode!, 'not_logged_in must exit 4').toBe(4) - - // Class 1 — server/resource error (not_found, server_5xx, network) - const serverResult = await fx.r(['use', 'workspace', 'nonexistent-workspace-id-xyz']) - expect(serverResult.exitCode, 'workspace not found must exit 1').toBe(1) - }) -}) diff --git a/cli/test/e2e/suites/framework/global-flags.e2e.ts b/cli/test/e2e/suites/framework/global-flags.e2e.ts deleted file mode 100644 index 2212cdb1cc5..00000000000 --- a/cli/test/e2e/suites/framework/global-flags.e2e.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * E2E: Global Flags — spec 5.5 - * - * Covers -o/--output, --workspace, --http-retry, --help, version, and - * flag-position behaviour. - * - * Key CLI behaviour confirmed by local testing: - * - `-w` shorthand does NOT exist (only --workspace); exit 1 - * - `--version` flag does NOT exist (only `version` sub-command); exit 1 - * - Flags placed BEFORE the command are not supported (unknown command error) - * - `run --help` shows global help, not the run sub-command help - * - `--invalidflag -o json` outputs plain-text error (not JSON envelope) - * - Repeating -o flag: last value wins - * - * Already covered in other suites (not duplicated here): - * 5.119 --help exit 0 → exit-codes.e2e.ts (5.106) - * 5.122 empty command exit 0 → exit-codes.e2e.ts (5.108) - * 5.124 version exit 0 → exit-codes.e2e.ts (5.107) - * 5.126 get app -o json → get-app-list / json-yaml-output - * 5.127 get app -o yaml → get-app-list - * 5.128 --workspace override → get-app-list.e2e.ts (line 180) - * 5.131 flag after command OK → implicit in all -o json tests - * 5.135 -o invalid exit 2 → table-output / json-yaml-output - * 5.138 version exit 0 → exit-codes.e2e.ts (5.107) - * 5.142 --stream -o json → run-app-streaming.e2e.ts - * 5.143 -o json | jq → json-yaml-output.e2e.ts (5.39) - * 5.147 -O json unknown flag → json-yaml-output.e2e.ts (5.51) - * - * Non-automatable cases (excluded): - * 5.144 Unicode terminal encoding — cannot control terminal charset in E2E - * 5.146 Small terminal width — cannot control terminal width in E2E - */ - -import type { AuthFixture } from '../../helpers/cli.js' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { assertExitCode, assertNoAnsi, assertNonZeroExit } from '../../helpers/assert.js' -import { withAuthFixture } from '../../helpers/cli.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -describe('E2E / global flags (spec 5.5)', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ── 5.120 run app --help → sub-command help ───────────────────────────── - - it('[P0] 5.120 difyctl run app --help outputs sub-command help with USAGE and FLAGS sections', async () => { - // Spec 5.120: run app --help must show the run app sub-command detail. - const result = await fx.r(['run', 'app', '--help']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/USAGE/i) - expect(result.stdout).toMatch(/FLAGS/i) - // Must mention the run app command itself - expect(result.stdout).toMatch(/run app/i) - }) - - // ── 5.120b run --help → global help (NOT sub-command help) ────────────── - - it('[P1] 5.120b difyctl run --help shows global command list (not run sub-command detail)', async () => { - // Spec 5.120b: run --help falls back to global help, not sub-command help. - const result = await fx.r(['run', '--help']) - assertExitCode(result, 0) - // Global help shows the top-level COMMANDS section - expect(result.stdout).toMatch(/COMMANDS/i) - // Must NOT look like a specific sub-command help (no ARGUMENTS section) - expect(result.stdout).not.toMatch(/^ARGUMENTS/m) - }) - - // ── 5.121 sub-command --help contains usage ────────────────────────────── - - it('[P1] 5.121 any sub-command --help outputs a usage section', async () => { - // Spec 5.121: every sub-command must have --help that includes usage. - const result = await fx.r(['get', 'app', '--help']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/USAGE/i) - expect(result.stdout).toMatch(/\$ difyctl/i) - }) - - // ── 5.123 --help contains GLOBAL FLAGS section ────────────────────────── - - it('[P1] 5.123 difyctl --help contains a GLOBAL FLAGS section', async () => { - // Spec 5.123: --help must include a dedicated GLOBAL FLAGS chapter listing - // -o/--output, --workspace, --http-retry. - const result = await fx.r(['--help']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/GLOBAL FLAGS/i) - expect(result.stdout).toContain('-o, --output') - expect(result.stdout).toContain('--http-retry') - }) - - // ── 5.124b --version flag does not exist → exit 1 ─────────────────────── - - it('[P0] 5.124b difyctl --version returns "unknown command" with exit 1 (flag does not exist)', async () => { - // Spec 5.124b: --version is not a valid flag; the correct command is - // `difyctl version`. Running --version must produce an error. - const result = await fx.r(['--version']) - expect(result.exitCode).toBe(1) - expect(result.stderr).toMatch(/unknown command/i) - }) - - // ── 5.125 version output contains semver ───────────────────────────────── - - it('[P1] 5.125 difyctl version output contains a semantic version string', async () => { - // Spec 5.125: version output must include a recognisable semver string. - const result = await fx.r(['version']) - assertExitCode(result, 0) - // Version line: "Version: 1.2.3-..." - expect(result.stdout).toMatch(/Version:\s+\d+\.\d+\.\d+/i) - }) - - // ── 5.128b --workspace is per-command only, not persistent ────────────── - - it('[P0] 5.128b --workspace override is per-command only — subsequent calls use the default workspace', async () => { - // Spec 5.128b: --workspace must not persist to the next command call. - // Use get app which supports --workspace flag - const withFlag = await fx.r([ - 'get', - 'app', - '-o', - 'json', - '--limit', - '1', - '--workspace', - E.workspaceId, - ]) - assertExitCode(withFlag, 0) - - // Subsequent call without the flag must still work using the default workspace - const withoutFlag = await fx.r(['get', 'app', '-o', 'json', '--limit', '1']) - assertExitCode(withoutFlag, 0) - // Both must succeed — confirming the flag did not alter persistent state - expect(withFlag.stdout.length).toBeGreaterThan(0) - expect(withoutFlag.stdout.length).toBeGreaterThan(0) - }) - - // ── 5.130 Flag placed before command → unknown command error ───────────── - - it('[P0] 5.130 placing a flag before the command (POSIX style) is not supported', async () => { - // Spec 5.130: difyctl -o json get app is not supported. - // Flags must follow the sub-command, not precede it. - const result = await fx.r(['-o', 'json', 'get', 'app']) - // The CLI treats "-o json get app" as an unknown command - assertNonZeroExit(result) - expect(result.stderr).toMatch(/unknown command/i) - }) - - // ── 5.132 -o json --workspace <id> both flags work simultaneously ───────── - - it('[P0] 5.132 -o json and --workspace can be used together', async () => { - // Spec 5.132: two global flags applied simultaneously must both take effect. - const result = await fx.r([ - 'get', - 'app', - '-o', - 'json', - '--workspace', - E.workspaceId, - '--limit', - '1', - ]) - assertExitCode(result, 0) - // JSON output must be valid and non-empty - expect(result.stdout.trimStart()).toMatch(/^\{/) - }) - - // ── 5.132b -w shorthand does not exist ─────────────────────────────────── - - it('[P0] 5.132b -w shorthand does not exist — returns unknown flag with exit 1', async () => { - // Spec 5.132b: only --workspace (long form) is supported; -w is not a valid - // shorthand and must be rejected. - const result = await fx.r(['get', 'app', '-w', E.workspaceId]) - expect(result.exitCode).toBe(1) - expect(result.stderr).toMatch(/unknown flag: -w/i) - }) - - // ── 5.133 Unknown flag → exit 1 ────────────────────────────────────────── - - it('[P0] 5.133 unknown flag returns "unknown flag" error with exit 1', async () => { - // Spec 5.133: unrecognised flags must produce a clear error and exit 1. - const result = await fx.r(['get', 'app', '--this-flag-does-not-exist']) - expect(result.exitCode).toBe(1) - expect(result.stderr).toMatch(/unknown flag/i) - }) - - // ── 5.134 -o missing value → exit 1 ───────────────────────────────────── - - it('[P0] 5.134 -o without a value returns "flag -o expects a value" with exit 1', async () => { - // Spec 5.134: -o must be followed by a format value; omitting it is an error. - // Note: exit code is 1 (not 2), distinct from illegal-value errors (exit 2). - const result = await fx.r(['get', 'app', '-o']) - expect(result.exitCode).toBe(1) - expect(result.stderr).toMatch(/flag -o expects a value/i) - }) - - // ── 5.140 help + -o json doesn't crash ─────────────────────────────────── - - it('[P1] 5.140 difyctl --help -o json runs without crashing and exits 0', async () => { - // Spec 5.140: combining --help with -o json must not cause a crash; - // the CLI should either apply -o json to help output or silently ignore it. - const result = await fx.r(['--help', '-o', 'json']) - assertExitCode(result, 0) - // Output must be non-empty - expect(result.stdout.length).toBeGreaterThan(0) - }) - - // ── 5.141 Invalid flag + -o json → plain-text error (not JSON envelope) ── - - it('[P1] 5.141 unknown flag with -o json outputs plain-text error (not a JSON error envelope)', async () => { - // Spec 5.141 (revised): unknown-flag errors are plain-text regardless of - // the -o flag because the flag is rejected before output formatting applies. - const result = await fx.r(['get', 'app', '--unknownflag', '-o', 'json']) - assertNonZeroExit(result) - // stderr must be plain text (start with the error code word, not '{') - expect(result.stderr.trimStart()).not.toMatch(/^\{/) - expect(result.stderr).toMatch(/unknown flag/i) - }) - - // ── 5.145 Help output is pipe-friendly (no ANSI) ───────────────────────── - - it('[P1] 5.145 difyctl --help output contains no ANSI control characters (pipe-friendly)', async () => { - // Spec 5.145: help text must be clean when piped to a file or another command. - const result = await fx.r(['--help']) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, '--help stdout') - }) - - // ── 5.148 Duplicate -o flags → last value wins ─────────────────────────── - - it('[P1] 5.148 repeating -o flag is stable — last value takes effect', async () => { - // Spec 5.148: passing -o json -o yaml should use yaml (last wins) or report - // a clear error, not crash or produce garbled output. - const result = await fx.r(['get', 'app', '-o', 'json', '-o', 'yaml', '--limit', '1']) - assertExitCode(result, 0) - // Output must be parseable (either JSON or YAML) and non-empty - expect(result.stdout.trim().length).toBeGreaterThan(0) - }) - - // ── 5.http-retry --http-retry flag works ──────────────────────────────── - - it('[P1] 5.http-retry --http-retry 0 disables retries and command executes normally', async () => { - // Spec 5.http-retry: --http-retry is a valid global flag that controls the - // number of HTTP retry attempts. Setting it to 0 disables retries. - const result = await fx.r(['get', 'app', '--http-retry', '0', '-o', 'json', '--limit', '1']) - assertExitCode(result, 0) - expect(result.stdout.trimStart()).toMatch(/^\{/) - }) - - // ── WTA-252 Help improvements ──────────────────────────────────────────── - - it('[P1] 5.149 difyctl --help shows auth devices description', async () => { - const result = await fx.r(['--help']) - assertExitCode(result, 0) - expect(result.stdout).toContain('auth devices list') - expect(result.stdout).toContain('List active sessions for the current bearer') - expect(result.stdout).toContain('auth devices revoke') - expect(result.stdout).toContain('Revoke one or all session devices') - }) - - it('[P1] 5.150 help surfaces contain global flags and command-level --workspace', async () => { - const result = await fx.r(['--help']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/GLOBAL FLAGS/i) - expect(result.stdout).toContain('-o, --output') - expect(result.stdout).toContain('--http-retry') - - const commandHelp = await fx.r(['get', 'app', '--help']) - assertExitCode(commandHelp, 0) - expect(commandHelp.stdout).toContain('--workspace') - }) - - it('[P1] 5.151 difyctl --help contains quick-start example flow', async () => { - const result = await fx.r(['--help']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/EXAMPLES/i) - expect(result.stdout).toContain('$ difyctl auth login') - expect(result.stdout).toContain('$ difyctl get app') - expect(result.stdout).toContain('$ difyctl run app') - }) -}) diff --git a/cli/test/e2e/suites/framework/help.e2e.ts b/cli/test/e2e/suites/framework/help.e2e.ts deleted file mode 100644 index 4152a1187a2..00000000000 --- a/cli/test/e2e/suites/framework/help.e2e.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * E2E: difyctl help — Help system - * - * Covers: - * 1. Top-level help overview (difyctl help / difyctl --help / difyctl -h / difyctl <no args>) - * 2. Per-command help via --help flag (e.g. auth login --help) - * 3. help <topic> subcommands (help account / help external / help environment) - * 4. Unknown command help routing - * - * Key behaviours confirmed by local testing: - * - `difyctl help`, `difyctl --help`, `difyctl -h`, `difyctl` all output the same top-level help - * - `difyctl help account/external/environment` routes via the help flag path: - * helpArgv = ['account'] / ['external'] / ['environment'] → resolveCommand fails - * → falls back to printTopLevelHelp() (same as top-level help) - * - `difyctl help account --help` also prints top-level help (--help strips before resolve) - * - Per-command help: `difyctl auth login --help` → formatHelp() output with USAGE/FLAGS/EXAMPLES - * - No auth is required for any help invocation - * - Exit code is always 0 for help commands - */ - -import { describe, expect, it } from 'vite-plus/test' -import { run } from '../../helpers/cli.js' - -// ── 1. Top-level help overview ──────────────────────────────────────────────── - -describe('E2E / difyctl help — top-level overview', () => { - it('[P0] `difyctl help` exits 0 and prints COMMANDS section', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('COMMANDS') - }) - - it('[P0] `difyctl help` lists all top-level command groups', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('auth') - expect(r.stdout).toContain('config') - expect(r.stdout).toContain('get') - expect(r.stdout).toContain('run') - expect(r.stdout).toContain('help') - expect(r.stdout).toContain('version') - }) - - it('[P0] `difyctl help` lists auth subcommands (login, logout, list, whoami)', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('login') - expect(r.stdout).toContain('logout') - expect(r.stdout).toContain('list') - expect(r.stdout).toContain('devices') - expect(r.stdout).toContain('whoami') - }) - - it('[P0] `difyctl help` lists help subcommands (account, external, environment)', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('account') - expect(r.stdout).toContain('external') - expect(r.stdout).toContain('environment') - }) - - it('[P0] `difyctl --help` produces the same output as `difyctl help`', async () => { - const fromHelp = await run(['help']) - const fromFlag = await run(['--help']) - expect(fromFlag.exitCode).toBe(0) - expect(fromFlag.stdout).toBe(fromHelp.stdout) - }) - - it('[P0] `difyctl -h` produces the same output as `difyctl help`', async () => { - const fromHelp = await run(['help']) - const fromShort = await run(['-h']) - expect(fromShort.exitCode).toBe(0) - expect(fromShort.stdout).toBe(fromHelp.stdout) - }) - - it('[P0] `difyctl` (no args) produces the same output as `difyctl help`', async () => { - const fromHelp = await run(['help']) - const fromNoArgs = await run([]) - expect(fromNoArgs.exitCode).toBe(0) - expect(fromNoArgs.stdout).toBe(fromHelp.stdout) - }) - - it('[P1] top-level help contains the binary name `difyctl`', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('difyctl') - }) - - it('[P1] top-level help has no output on stderr', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - expect(r.stderr).toBe('') - }) - - it('[P1] top-level help lists `get app` subcommand with description', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('app') - }) - - it('[P1] top-level help lists `run app` subcommand', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - // run → app should both appear - expect(r.stdout).toContain('run') - expect(r.stdout).toContain('app') - }) - - it('[P1] top-level help lists `env list` subcommand', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('env') - expect(r.stdout).toContain('list') - }) - - it('[P1] top-level help lists `describe app` subcommand', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('describe') - }) - - it('[P1] top-level help lists `resume app` subcommand', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('resume') - }) - - it('[P1] top-level help lists `version` command', async () => { - const r = await run(['help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('version') - }) -}) - -// ── 2. Per-command help via --help ──────────────────────────────────────────── - -describe('E2E / difyctl help — per-command --help flag', () => { - it('[P0] `auth login --help` exits 0 and prints USAGE section', async () => { - const r = await run(['auth', 'login', '--help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('USAGE') - }) - - it('[P0] `auth login --help` prints FLAGS section with --host', async () => { - const r = await run(['auth', 'login', '--help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('FLAGS') - expect(r.stdout).toContain('--host') - }) - - it('[P0] `auth login --help` prints EXAMPLES section', async () => { - const r = await run(['auth', 'login', '--help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('EXAMPLES') - expect(r.stdout).toContain('difyctl auth login') - }) - - it('[P0] `auth login --help` prints the command description', async () => { - const r = await run(['auth', 'login', '--help']) - expect(r.exitCode).toBe(0) - // Description from command class - expect(r.stdout).toMatch(/sign in|oauth|device flow/i) - }) - - it('[P0] `auth logout --help` exits 0 and prints USAGE for auth logout', async () => { - const r = await run(['auth', 'logout', '--help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('USAGE') - expect(r.stdout).toContain('auth logout') - }) - - it('[P0] `auth whoami --help` exits 0 and prints USAGE for auth whoami', async () => { - const r = await run(['auth', 'whoami', '--help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('USAGE') - expect(r.stdout).toContain('auth whoami') - }) - - it('[P0] `get app --help` exits 0 and prints per-command help', async () => { - const r = await run(['get', 'app', '--help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('USAGE') - expect(r.stdout).toContain('get app') - }) - - it('[P0] `run app --help` exits 0 and prints per-command help', async () => { - const r = await run(['run', 'app', '--help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('USAGE') - expect(r.stdout).toContain('run app') - }) - - it('[P1] `help auth login --help` exits 0 (--help triggers top-level help)', async () => { - // run.ts: --help is filtered first → helpArgv still contains 'auth login' - // → resolved → formatHelp for auth login - const r = await run(['help', 'auth', 'login', '--help']) - expect(r.exitCode).toBe(0) - }) - - it('[P1] `version --help` exits 0 and prints USAGE for version', async () => { - const r = await run(['version', '--help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('USAGE') - }) - - it('[P1] `config get --help` exits 0 and prints USAGE for config get', async () => { - const r = await run(['config', 'get', '--help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('USAGE') - expect(r.stdout).toContain('config get') - }) - - it('[P1] `env list --help` exits 0 and prints USAGE for env list', async () => { - const r = await run(['env', 'list', '--help']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('USAGE') - expect(r.stdout).toContain('env list') - }) -}) - -// ── 3. help <topic> subcommands ─────────────────────────────────────────────── - -describe('E2E / difyctl help — topic subcommands', () => { - it('[P0] `difyctl help account` exits 0 and prints account onboarding topic', async () => { - const r = await run(['help', 'account']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('account-bearer onboarding') - expect(r.stdout).toContain('difyctl auth login') - }) - - it('[P0] `difyctl help external` exits 0 and prints external SSO topic', async () => { - const r = await run(['help', 'external']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('external-SSO bearer onboarding') - expect(r.stdout).toContain('dfoe_') - }) - - it('[P0] `difyctl help environment` exits 0 and prints environment topic', async () => { - const r = await run(['help', 'environment']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('ENVIRONMENT VARIABLES') - expect(r.stdout).toContain('DIFY_CONFIG_DIR') - }) - - it('[P0] `difyctl help account` output differs from `difyctl help`', async () => { - const base = await run(['help']) - const topic = await run(['help', 'account']) - expect(topic.exitCode).toBe(0) - expect(topic.stdout).not.toBe(base.stdout) - }) - - it('[P0] `difyctl help external` output differs from `difyctl help`', async () => { - const base = await run(['help']) - const topic = await run(['help', 'external']) - expect(topic.exitCode).toBe(0) - expect(topic.stdout).not.toBe(base.stdout) - }) - - it('[P0] `difyctl help environment` output differs from `difyctl help`', async () => { - const base = await run(['help']) - const topic = await run(['help', 'environment']) - expect(topic.exitCode).toBe(0) - expect(topic.stdout).not.toBe(base.stdout) - }) - - it('[P1] `difyctl help account` has no output on stderr', async () => { - const r = await run(['help', 'account']) - expect(r.exitCode).toBe(0) - expect(r.stderr).toBe('') - }) - - it('[P1] `difyctl help unknowntopic` exits 1 and reports unknown help topic', async () => { - const r = await run(['help', 'unknowntopic']) - expect(r.exitCode).toBe(1) - expect(r.stderr).toContain('unknown help topic') - }) -}) - -// ── 4. help topic subcommands invoked directly ──────────────────────────────── - -describe('E2E / difyctl help — direct subcommand invocation', () => { - it('[P0] `difyctl help account` (direct) prints onboarding text via top-level routing', async () => { - // Even when invoked as a normal command (not via help routing), - // the current top-level routing still outputs help fallback - const r = await run(['help', 'account']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('difyctl') - }) - - it('[P0] `difyctl help external` prints content about external bearers or top-level help', async () => { - const r = await run(['help', 'external']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toBeTruthy() - }) - - it('[P0] `difyctl help environment` prints content about env vars or top-level help', async () => { - const r = await run(['help', 'environment']) - expect(r.exitCode).toBe(0) - expect(r.stdout).toBeTruthy() - }) -}) diff --git a/cli/test/e2e/suites/output/json-yaml-output.e2e.ts b/cli/test/e2e/suites/output/json-yaml-output.e2e.ts deleted file mode 100644 index 2ae470c223c..00000000000 --- a/cli/test/e2e/suites/output/json-yaml-output.e2e.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * E2E: JSON / YAML output format — spec 5.2 - * - * Covers -o json and -o yaml output correctness, illegal format values, - * and format-specific behaviours (indentation, null fields, Unicode, - * nested objects, pipe-friendliness, schema stability). - * - * Already covered elsewhere (not duplicated here): - * 5.29 get app -o json valid JSON → get-app-list.e2e.ts - * 5.30 get app -o json | jq . → get-app-list.e2e.ts - * 5.38 -o json no ANSI → get-app-list.e2e.ts - * 5.40 failed command -o json envelope → get-app-list.e2e.ts / run-app-basic - * 5.42 get app -o yaml valid YAML → get-app-list.e2e.ts - * 5.50 -o invalid → illegal_argument → output/table-output.e2e.ts - * 5.52 get app -o table → error → output/table-output.e2e.ts - * 5.55 get app -o name → get-app-list.e2e.ts - * 5.56 get app -o wide → get-app-list.e2e.ts - * 5.57 describe app -o json → describe-app.e2e.ts - * - * Non-automatable cases (excluded): - * 5.32 Field names match PRD — PRD is a living document; hard-coding - * every field name creates fragile, hard-to-maintain tests. - * 5.43 -o yaml | yq . — yq is not guaranteed to be present in CI. - * 5.45 YAML nested structure — no YAML parser available in the test - * runtime without adding a runtime dependency. - * 5.48 -o yaml | tee — equivalent to pipe test covered by 5.39/5.47. - * 5.49 failed command + -o yaml stable — CLI outputs a JSON error - * envelope on stderr regardless of -o flag; covered by 5.40/5.41. - */ - -import type { AuthFixture } from '../../helpers/cli.js' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { - assertErrorEnvelope, - assertExitCode, - assertJson, - assertNoAnsi, - assertNonZeroExit, - assertPipeFriendlyJson, -} from '../../helpers/assert.js' -import { withAuthFixture, withTempConfig } from '../../helpers/cli.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -describe('E2E / JSON & YAML output format (spec 5.2)', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ── 5.31 JSON schema stability ──────────────────────────────────────────── - - it('[P0] 5.31 two consecutive -o json calls return the same top-level schema', async () => { - // Spec 5.31: the JSON schema must be deterministic across invocations. - const r1 = await fx.r(['get', 'app', '-o', 'json', '--limit', '1']) - const r2 = await fx.r(['get', 'app', '-o', 'json', '--limit', '1']) - assertExitCode(r1, 0) - assertExitCode(r2, 0) - const d1 = assertJson<Record<string, unknown>>(r1) - const d2 = assertJson<Record<string, unknown>>(r2) - // Top-level key sets must be identical - expect(Object.keys(d1).sort()).toEqual(Object.keys(d2).sort()) - }) - - // ── 5.33 null field in JSON output ──────────────────────────────────────── - - it('[P1] 5.33 null fields are serialised as JSON null (not omitted or stringified)', async () => { - // Spec 5.33: when a field value is null the JSON output must contain - // an explicit null literal, not an empty string or missing key. - // auth devices list --json exposes last_used_at which is null when - // the session has never been used for an API call. - const result = await fx.r(['auth', 'devices', 'list', '--json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<Record<string, unknown>> }>(result) - expect(Array.isArray(parsed.data)).toBe(true) - expect(parsed.data.length).toBeGreaterThan(0) - // At least one device entry must have the last_used_at key present (even if null) - const hasKey = parsed.data.some((d) => Object.prototype.hasOwnProperty.call(d, 'last_used_at')) - expect(hasKey, 'last_used_at key must be present in device entries').toBe(true) - // Verify null serialisation — if the field is null it must be JSON null - const nullEntry = parsed.data.find((d) => d.last_used_at === null) - if (nullEntry) { - // Confirm the raw JSON contains the literal "null" value - // The JSON may be compact (no space) or indented — match both - expect(result.stdout).toMatch(/"last_used_at":\s*null/) - } - }) - - // ── 5.34 Unicode / Chinese in JSON ──────────────────────────────────────── - - it('[P0] 5.34 -o json does not escape Unicode characters in field values', async () => { - // Spec 5.34: Unicode characters (CJK, accented, emoji) must appear as-is, - // not as \uXXXX escape sequences. - // get workspace -o json returns workspace names; the staging account has - // workspaces whose names may contain non-ASCII characters. - const result = await fx.r(['get', 'workspace', '-o', 'json']) - assertExitCode(result, 0) - assertJson(result) // valid JSON - // Verify the raw JSON does not contain \uXXXX Unicode escape sequences - const hasEscapedUnicode = /\\u[0-9a-fA-F]{4}/.test(result.stdout) - expect(hasEscapedUnicode, 'JSON must not contain \\uXXXX Unicode escapes').toBe(false) - }) - - // ── 5.35 List command returns array structure ────────────────────────────── - - it('[P1] 5.35 list command -o json wraps results in a data array', async () => { - // Spec 5.35: list commands return a JSON envelope where the result set is - // an array, not a bare object. - const result = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: unknown }>(result) - expect(Array.isArray(parsed.data), 'data field must be an array').toBe(true) - }) - - // ── 5.36 Nested objects preserved ───────────────────────────────────────── - - it('[P1] 5.36 -o json preserves nested object structure', async () => { - // Spec 5.36: nested objects must not be flattened or stringified. - // describe app -o json returns {info: {...}, parameters: {...}} which is - // a two-level nested structure. - const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ - info: Record<string, unknown> - parameters: Record<string, unknown> - }>(result) - // Both top-level fields must be proper objects, not strings - expect(typeof parsed.info).toBe('object') - expect(parsed.info).not.toBeNull() - expect(typeof parsed.parameters).toBe('object') - expect(parsed.parameters).not.toBeNull() - // info must contain an id field (proving nesting is intact) - expect(parsed.info).toHaveProperty('id') - }) - - // ── 5.37 Indented / pretty-printed JSON ─────────────────────────────────── - - it('[P1] 5.37 -o json output is indented (human-readable, not minified)', async () => { - // Spec 5.37: the JSON output must be pretty-printed with indentation, not - // a single-line compact string. - const result = await fx.r(['get', 'app', '-o', 'json', '--limit', '1']) - assertExitCode(result, 0) - // Indented JSON has at least one newline and leading spaces on inner lines - expect(result.stdout).toContain('\n') - expect(result.stdout).toMatch(/\n\s+"/) - }) - - // ── 5.39 Pipe-friendly JSON ──────────────────────────────────────────────── - - it('[P0] 5.39 -o json output is pipe-friendly (no ANSI, starts with { or [, ends with \\n)', async () => { - // Spec 5.39: output must be usable in a pipe chain (e.g. | tee out.json). - const result = await fx.r(['get', 'app', '-o', 'json']) - assertExitCode(result, 0) - assertPipeFriendlyJson(result) - }) - - // ── 5.41 JSON error schema consistent across failure types ──────────────── - - it('[P1] 5.41 JSON error envelope has the same schema across different failure scenarios', async () => { - // Spec 5.41: regardless of the error type (not_found, auth, usage), - // the JSON error envelope always has the same top-level structure. - const unauthTmp = await withTempConfig() - let envelope1: ReturnType<typeof assertErrorEnvelope> - try { - // Scenario A: unauthenticated → not_logged_in (error in stderr) - const { run: runFn } = await import('../../helpers/cli.js') - const r1 = await runFn(['get', 'app', '-o', 'json'], { configDir: unauthTmp.configDir }) - assertNonZeroExit(r1) - envelope1 = assertErrorEnvelope(r1) - } finally { - await unauthTmp.cleanup() - } - // Scenario B: non-existent app → server error (error in stderr when -o json) - const r2 = await fx.r(['get', 'app', 'nonexistent-app-id-00000000', '-o', 'json']) - assertNonZeroExit(r2) - const envelope2 = assertErrorEnvelope(r2) - - // Both envelopes must share the same schema structure - expect(envelope1.error).toHaveProperty('code') - expect(envelope1.error).toHaveProperty('message') - expect(envelope2.error).toHaveProperty('code') - expect(envelope2.error).toHaveProperty('message') - expect(typeof envelope1.error.code).toBe('string') - expect(typeof envelope2.error.code).toBe('string') - }) - - // ── 5.44 JSON and YAML contain the same data ─────────────────────────────── - - it('[P1] 5.44 -o json and -o yaml for the same command return the same data', async () => { - // Spec 5.44: the two serialisation formats must represent identical data. - // We verify that the top-level key names visible in both outputs match. - const jsonResult = await fx.r(['get', 'app', '-o', 'json', '--limit', '1']) - const yamlResult = await fx.r(['get', 'app', '-o', 'yaml', '--limit', '1']) - assertExitCode(jsonResult, 0) - assertExitCode(yamlResult, 0) - - const jsonParsed = assertJson<Record<string, unknown>>(jsonResult) - const topKeys = Object.keys(jsonParsed) - - // Each JSON top-level key should appear as a YAML key (unquoted name followed by :) - for (const key of topKeys) { - expect(yamlResult.stdout, `YAML must contain key "${key}"`).toMatch( - new RegExp(`\\b${key}\\s*:`), - ) - } - }) - - // ── 5.47 YAML has no ANSI codes ─────────────────────────────────────────── - - it('[P0] 5.47 -o yaml output contains no ANSI control characters (non-TTY)', async () => { - // Spec 5.47: YAML output must be clean in non-TTY environments (CI). - const result = await fx.r(['get', 'app', '-o', 'yaml']) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, '-o yaml stdout') - }) - - // ── 5.51 -o JSON (uppercase) → illegal_argument ─────────────────────────── - - it('[P1] 5.51 -o JSON (uppercase O value) returns illegal_argument (format names are case-sensitive)', async () => { - // Spec 5.51: only lowercase format names are valid; -o JSON must fail. - const result = await fx.r(['get', 'app', '-o', 'JSON']) - expect(result.exitCode).toBe(2) - expect(result.stderr).toMatch(/illegal_argument|illegal value/i) - }) - - // ── 5.53 run app -o table → illegal_argument (different hint from get app) ─ - - it('[P0] 5.53 run app -o table returns illegal_argument with hint listing json, yaml, text', async () => { - // Spec 5.53: execution commands (run app) support json/yaml/text only. - // The hint must list the correct supported values for this command class. - const result = await fx.r(['run', 'app', E.chatAppId, 'hello', '-o', 'table']) - expect(result.exitCode).toBe(2) - expect(result.stderr).toMatch(/illegal_argument|illegal value table/i) - // Hint must mention the execution-command format set (not the query-command set) - expect(result.stderr).toMatch(/json/i) - expect(result.stderr).toMatch(/yaml/i) - expect(result.stderr).toMatch(/text/i) - }) - - // ── 5.54 run app -o text (explicit) → valid ─────────────────────────────── - - it('[P1] 5.54 run app -o text explicitly produces the same plain-text output as the default', async () => { - // Spec 5.54: "text" is a valid format for run app and must match the - // default (no -o) output. - const defaultResult = await fx.r(['run', 'app', E.chatAppId, 'hello']) - const textResult = await fx.r(['run', 'app', E.chatAppId, 'hello', '-o', 'text']) - // Skip gracefully if staging SSL error causes transient failure - if (defaultResult.exitCode !== 0 || textResult.exitCode !== 0) return - assertExitCode(defaultResult, 0) - assertExitCode(textResult, 0) - // Both must be plain text (not JSON) - expect(textResult.stdout.trimStart()).not.toMatch(/^\{/) - // Both must be non-empty - expect(textResult.stdout.trim().length).toBeGreaterThan(0) - }) - - // ── 5.46 YAML with Unicode / Chinese ────────────────────────────────────── - - it('[P1] 5.46 -o yaml does not escape Unicode characters in string values', async () => { - // Spec 5.46: Unicode characters must appear literally in YAML output. - const result = await fx.r(['get', 'workspace', '-o', 'yaml']) - assertExitCode(result, 0) - // YAML output must not contain \uXXXX escape sequences - expect(result.stdout).not.toMatch(/\\u[0-9a-fA-F]{4}/) - // Must be non-empty - expect(result.stdout.trim().length).toBeGreaterThan(0) - }) -}) diff --git a/cli/test/e2e/suites/output/table-output.e2e.ts b/cli/test/e2e/suites/output/table-output.e2e.ts deleted file mode 100644 index 72c6fd1ae77..00000000000 --- a/cli/test/e2e/suites/output/table-output.e2e.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * E2E: Table output format — spec 5.1 - * - * Covers the default text-table output behaviour of query commands. - * The default format (no -o flag) is an aligned text table; -o table does not - * exist and returns an illegal_argument error. - * - * Primary command under test: difyctl get app - * Additional commands: difyctl get workspace, difyctl auth devices list - * - * Non-automatable cases (excluded): - * 5.4 Row/column alignment — requires visual inspection, no reliable - * programmatic assertion. - * 5.7 Long-text truncation based on terminal width — terminal width is - * not controllable in E2E. - * 5.8 Very long text still readable — same reason as 5.7, and test data - * cannot be controlled. - * 5.9 CJK/emoji alignment — CJK column-width alignment requires visual - * inspection; current fixtures have no CJK app names. - * 5.10 CJK column width — same as 5.9. - * 5.11 Small terminal width — terminal width not controllable. - * 5.12 Large terminal width — same as 5.11. - * 5.13 ANSI colour in TTY — E2E runs with NO_COLOR=1 and CI=1 (non-TTY). - * 5.18 NULL fields stable — current fixtures have no NULL field values. - * 5.21 run app --stream non-table — covered by run-app-streaming.e2e.ts. - * 5.22 describe app uses describe printer — covered by describe-app.e2e.ts. - * 5.23 Printer error / fallback — cannot reliably trigger a printer error. - * 5.24 Printer error exit code — same as 5.23. - * 5.20 get app -A -o wide has WORKSPACE column — covered by - * get-app-all-workspaces.e2e.ts (spec 3.92/3.93). - * - * Already covered in get-app-list.e2e.ts (not duplicated here): - * 5.1 (partial) default format is not JSON - * 5.2 (partial) header contains ID / NAME / MODE - * 5.14 no ANSI colour codes in non-TTY - * - * All cases require a valid session (DIFY_E2E_TOKEN). - */ - -import type { AuthFixture } from '../../helpers/cli.js' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js' -import { withAuthFixture } from '../../helpers/cli.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -// ── 5.1 / 5.2 / 5.3 / 5.5 / 5.19 — Header & columns ─────────────────────── -describe('E2E / table output — header and column format (spec 5.1–5.19)', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - it('[P0] 5.1 default output (no -o) is an aligned text table, not JSON or YAML', async () => { - // Spec 5.1: the default format is a text table; -o table does not exist. - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - // Must not be JSON (starts with {) or YAML (starts with -) - expect(result.stdout.trimStart()).not.toMatch(/^\{/) - expect(result.stdout.trimStart()).not.toMatch(/^- /) - // Must have content (non-empty) - expect(result.stdout.trim().length).toBeGreaterThan(0) - }) - - it('[P0] 5.2 header row contains all four expected column names', async () => { - // Spec 5.2: header columns are NAME / ID / MODE / UPDATED. - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - const header = result.stdout.split('\n')[0] ?? '' - expect(header).toMatch(/NAME/i) - expect(header).toMatch(/ID/i) - expect(header).toMatch(/MODE/i) - expect(header).toMatch(/UPDATED/i) - }) - - it('[P0] 5.3 column order is NAME → ID → MODE → UPDATED', async () => { - // Spec 5.3: columns appear in the defined order (as verified from actual CLI output). - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - const header = result.stdout.split('\n')[0] ?? '' - const nameIdx = header.indexOf('NAME') - const idIdx = header.indexOf('ID') - const modeIdx = header.indexOf('MODE') - const updatedIdx = header.indexOf('UPDATED') - // All columns must be present - expect(nameIdx).toBeGreaterThanOrEqual(0) - expect(idIdx).toBeGreaterThanOrEqual(0) - expect(modeIdx).toBeGreaterThanOrEqual(0) - expect(updatedIdx).toBeGreaterThanOrEqual(0) - // Verify left-to-right order - expect(nameIdx).toBeLessThan(idIdx) - expect(idIdx).toBeLessThan(modeIdx) - expect(modeIdx).toBeLessThan(updatedIdx) - }) - - it('[P0] 5.5 table displays multiple data rows when more than one app exists', async () => { - // Spec 5.5: when there are multiple apps, all rows are rendered. - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - const lines = result.stdout - .trim() - .split('\n') - .filter((l) => l.trim()) - // At least header + 1 data row - expect(lines.length).toBeGreaterThan(1) - }) - - it('[P0] 5.6 empty result set shows only the header row (no data rows)', async () => { - // Spec 5.6: when the filter matches nothing, the output is a single header - // row with no data rows underneath (not an error, exit 0). - const result = await fx.r(['get', 'app', '--name', 'zzz-nonexistent-app-xyz-000']) - assertExitCode(result, 0) - const lines = result.stdout - .trim() - .split('\n') - .filter((l) => l.trim()) - // Only the header row should remain - expect(lines).toHaveLength(1) - expect(lines[0] ?? '').toMatch(/NAME/i) - }) - - it('[P0] 5.19 all header column names are uppercase', async () => { - // Spec 5.19: header column names follow all-caps convention per implementation. - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - const header = result.stdout.split('\n')[0] ?? '' - // Extract word-like tokens from the header - const tokens = header.match(/[A-Z]{2,}/g) ?? [] - expect(tokens.length).toBeGreaterThan(0) - tokens.forEach((token) => - expect(token, `header token "${token}" should be uppercase`).toBe(token.toUpperCase()), - ) - }) - - // ── 5.15 / 5.16 — Pipe-friendliness ────────────────────────────────────── - - it('[P0] 5.15 default table output is pipe-friendly — no unexpected control characters', async () => { - // Spec 5.15: output can pass through cat / pipes without corruption. - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - // No NUL, BEL, BS, VT, FF, SO–US, DEL bytes that would corrupt a pipe - expect(result.stdout).not.toMatch(/[\x00-\x08\v\f\x0E-\x1F\x7F]/) - }) - - it('[P0] 5.16 default table output written to a file contains no control characters', async () => { - // Spec 5.16: redirecting to a file must not embed control characters. - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, 'stdout') - expect(result.stdout).not.toMatch(/[\x00-\x08\v\f\x0E-\x1F\x7F]/) - }) - - // ── 5.25 — Performance ──────────────────────────────────────────────────── - - it('[P1] 5.25 querying up to 100 apps completes without timeout', async () => { - // Spec 5.25: large result sets must not freeze the CLI. - // The testTimeout covers the timeout assertion implicitly. - const result = await fx.r(['get', 'app', '--limit', '100']) - assertExitCode(result, 0) - expect(result.stdout.trim().length).toBeGreaterThan(0) - }) - - // ── 5.26 — Sort stability ───────────────────────────────────────────────── - - it('[P1] 5.26 two consecutive get app calls return rows in the same order', async () => { - // Spec 5.26: output order must be deterministic (updated_at DESC). - const r1 = await fx.r(['get', 'app', '-o', 'name']) - const r2 = await fx.r(['get', 'app', '-o', 'name']) - assertExitCode(r1, 0) - assertExitCode(r2, 0) - expect(r1.stdout).toBe(r2.stdout) - }) - - // ── Additional commands — header format ─────────────────────────────────── - - it('[P0] get workspace default table has correct column headers', async () => { - // Verifies the header columns for the workspace list table. - const result = await fx.r(['get', 'workspace']) - assertExitCode(result, 0) - const header = result.stdout.split('\n')[0] ?? '' - expect(header).toMatch(/ID/i) - expect(header).toMatch(/NAME/i) - expect(header).toMatch(/ROLE/i) - expect(header).toMatch(/STATUS/i) - expect(header).toMatch(/CURRENT/i) - }) - - it('[P0] auth devices list default table has correct column headers', async () => { - // Verifies the header columns for the devices list table. - const result = await fx.r(['auth', 'devices', 'list']) - assertExitCode(result, 0) - const header = result.stdout.split('\n')[0] ?? '' - expect(header).toMatch(/DEVICE/i) - expect(header).toMatch(/CREATED/i) - expect(header).toMatch(/CURRENT/i) - }) - - // ── -o table is not a valid format ──────────────────────────────────────── - - it('[P0] -o table returns illegal_argument error for query commands', async () => { - // Spec: -o table does not exist; the default (no -o) is the table format. - const result = await fx.r(['get', 'app', '-o', 'table']) - expect(result.exitCode).toBe(2) - expect(result.stderr).toMatch(/illegal_argument|illegal value table/i) - expect(result.stderr).toMatch(/json|yaml|name|wide/i) - }) -}) diff --git a/cli/test/e2e/suites/run/run-app-basic.e2e.ts b/cli/test/e2e/suites/run/run-app-basic.e2e.ts deleted file mode 100644 index aa549d12977..00000000000 --- a/cli/test/e2e/suites/run/run-app-basic.e2e.ts +++ /dev/null @@ -1,534 +0,0 @@ -/** - * E2E: difyctl run app — basic app execution - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Basic App Execution (4.1) - * - * Streaming output cases → run-app-streaming.e2e.ts - * Conversation mode cases → run-app-conversation.e2e.ts - * - * Staging app prerequisites (specified via DIFY_E2E_* env vars): - * echo-chat — mode=chat, query variable, outputs "echo: {query}" - * echo-workflow — mode=workflow, x variable (required), outputs x directly (no echo prefix) - */ - -import type { AuthFixture } from '../../helpers/cli.js' -import { mkdir, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { - assertErrorEnvelope, - assertExitCode, - assertJson, - assertNoAnsi, - assertPipeFriendlyJson, - assertStdoutContains, -} from '../../helpers/assert.js' -import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) -const itWithSso = optionalIt(Boolean(E.ssoToken)) - -// ── Suite ────────────────────────────────────────────────────────────────── - -describe('E2E / difyctl run app', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ========================================================================= - // Basic execution - // ========================================================================= - - describe('Basic execution', () => { - it('[P0] logged-in internal user can run app — stdout contains the app result', async () => { - // Spec: logged-in internal user can run app / default output shows execution result - // withRetry: staging LLM inference may have transient 5xx on cold start - const result = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'hello']), { - attempts: 3, - delayMs: 2000, - shouldRetry: (err) => - err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), - }) - assertExitCode(result, 0) - assertStdoutContains(result, 'echo:hello') - // Spec 4.1.4: default output has no ANSI colour codes (non-TTY; run() sets NO_COLOR=1) - assertNoAnsi(result.stdout, 'stdout') - }) - - it('[P0] run app invokes the execute endpoint (stdout has actual content)', async () => { - // Spec: run app invokes the execute endpoint - const result = await fx.r(['run', 'app', E.chatAppId, 'e2e-smoke']) - assertExitCode(result, 0) - expect(result.stdout.length).toBeGreaterThan(0) - }) - - it('[P1] text output preserves newlines (stdout ends with \\n)', async () => { - // Spec: text output preserves newlines - const result = await fx.r(['run', 'app', E.chatAppId, 'newline']) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/\n$/) - }) - - it('[P1] repeated run app calls each complete independently (3 iterations)', async () => { - // Spec: repeated run app calls do not affect historical state - for (let i = 0; i < 3; i++) { - const result = await fx.r(['run', 'app', E.chatAppId, `repeat-${i}`]) - assertExitCode(result, 0) - assertStdoutContains(result, `echo:repeat-${i}`) - } - }) - }) - - // ========================================================================= - // Output format - // ========================================================================= - - describe('Output format (-o)', () => { - it('[P0] -o json outputs valid JSON', async () => { - // Spec: -o json produces valid JSON - const result = await fx.r(['run', 'app', E.chatAppId, 'json-test', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ answer: string; mode: string }>(result) - expect(parsed).toHaveProperty('answer') - expect(parsed.mode).toMatch(/chat/) - }) - - it('[P1] JSON output includes execution metadata (message_id / conversation_id)', async () => { - // Spec: JSON output includes execution metadata - const result = await fx.r(['run', 'app', E.chatAppId, 'meta', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<Record<string, unknown>>(result) - expect(parsed).toHaveProperty('message_id') - expect(parsed).toHaveProperty('conversation_id') - }) - - it('[P1] JSON output supports piping (no ANSI, starts with {, ends with \\n)', async () => { - // Spec: JSON output supports piping - const result = await fx.r(['run', 'app', E.chatAppId, 'pipe', '-o', 'json']) - assertExitCode(result, 0) - assertPipeFriendlyJson(result) - }) - - it('[P1] JSON mode outputs a JSON error envelope to stderr', async () => { - // Spec: JSON mode outputs a JSON error envelope - const result = await fx.r(['run', 'app', 'app-nonexistent-xyz-e2e', 'hello', '-o', 'json']) - assertNonZeroExit(result) - assertErrorEnvelope(result, 'server_4xx_other') - }) - }) - - // ========================================================================= - // --inputs flag - // ========================================================================= - - describe('--inputs flag', () => { - it('[P0] run app supports --inputs (workflow app)', async () => { - // Spec: run app supports --inputs - // withRetry: staging workflow execution may have transient 5xx - const result = await withRetry( - () => - fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'workflow-val', num: 42, enum_var: 'A', paragraph: 'short text' }), - ]), - { - attempts: 3, - delayMs: 2000, - shouldRetry: (err) => - err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message), - }, - ) - assertExitCode(result, 0) - assertStdoutContains(result, 'workflow-val') - }) - - it('[P0] multiple inputs take effect simultaneously', async () => { - // Spec: multiple --inputs entries take effect simultaneously - const result = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'multi-test', num: 42, enum_var: 'A', paragraph: 'short text' }), - ]) - assertExitCode(result, 0) - }) - - it('[P0] invalid JSON for --inputs returns usage error (exit code 2)', async () => { - // Spec: missing required parameter / invalid input - const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', 'not-json']) - assertExitCode(result, 2) - expect(result.stderr).toMatch(/valid JSON/i) - }) - - it('[P0] JSON array for --inputs returns usage error', async () => { - const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', '[1,2,3]']) - assertExitCode(result, 2) - expect(result.stderr).toMatch(/JSON object/i) - }) - - it('[P0] --inputs and --inputs-file are mutually exclusive — returns usage error', async () => { - // Spec: mutually exclusive flags return a usage error - const inputsFile = join(fx.configDir, 'inputs.json') - await writeFile(inputsFile, JSON.stringify({ x: 'file-val' })) - const result = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - '{"x":"flag-val"}', - '--inputs-file', - inputsFile, - ]) - assertExitCode(result, 2) - expect(result.stderr).toMatch(/mutually exclusive/i) - }) - - it('[P0] positional message passed to workflow app returns usage error', async () => { - // Spec: execution fails when required positional parameter is missing (workflow) - const result = await fx.r(['run', 'app', E.workflowAppId, 'positional-msg']) - assertExitCode(result, 2) - expect(result.stderr).toMatch(/workflow apps do not accept a positional message/i) - }) - - it('[P0] --inputs-file reads JSON inputs from a file', async () => { - const inputsFile = join(fx.configDir, 'wf-inputs.json') - await writeFile( - inputsFile, - JSON.stringify({ x: 'from-file', num: 42, enum_var: 'A', paragraph: 'short text' }), - ) - const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs-file', inputsFile]) - assertExitCode(result, 0) - assertStdoutContains(result, 'from-file') - }) - - it('[P0] required inputs missing causes execution failure (exit code non-zero)', async () => { - // Spec 4.1.11: workflow app fails when required inputs are not provided. - // Passing an empty object omits the required "x" field; the server - // returns a validation error and the CLI exits with a non-zero code. - const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', '{}']) - expect(result.exitCode).not.toBe(0) - expect(result.stderr.length).toBeGreaterThan(0) - }) - - it('[P0] paragraph input within limit succeeds; exceeding max_length returns error', async () => { - // Spec 4.1.19: paragraph input exceeding max_length (100) returns validation error - // App: basic_auto_test — variable "paragraph" (text-input, max_length=100, optional) - - // ── Within limit: 50 chars ────────────────────────────────────────── - const shortResult = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ - x: 'hello', - num: 42, - enum_var: 'A', - paragraph: 'A'.repeat(50), - }), - ]) - assertExitCode(shortResult, 0) - - // ── Exceeding limit: 101 chars ────────────────────────────────────── - const longResult = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ - x: 'hello', - num: 42, - enum_var: 'A', - paragraph: 'A'.repeat(101), - }), - ]) - expect(longResult.exitCode).not.toBe(0) - expect(longResult.stderr).toMatch(/paragraph.*less than 100|paragraph.*100 characters/i) - }) - - it('[P0] valid inputs of all types execute successfully; invalid typed/enum inputs return errors', async () => { - // Spec 4.1.17: non-typed input value returns a validation error - // Spec 4.1.18: invalid enum value returns a validation error - // - // App: basic_auto_test (DIFY_E2E_WORKFLOW_APP_ID) - // Input schema: - // x — text-input (required) - // num — number (required, Spec 4.1.17) - // enum_var — select (required, options: A/B/C, Spec 4.1.18) - - // ── Happy path: all correct values ────────────────────────────────── - const happyResult = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'hello', num: 42, enum_var: 'A', paragraph: 'short text' }), - ]) - assertExitCode(happyResult, 0) - assertStdoutContains(happyResult, 'hello') // workflow outputs x directly; echo: prefix removed (no sandbox on server) - - // ── 4.1.17: number field receives a string value ───────────────────── - const typedResult = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A' }), - ]) - expect(typedResult.exitCode).not.toBe(0) - expect(typedResult.stderr).toMatch(/num.*number|must be a valid number/i) - - // ── 4.1.18: enum field receives a value outside the allowed options ── - const enumResult = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'hello', num: 42, enum_var: 'invalid' }), - ]) - expect(enumResult.exitCode).not.toBe(0) - expect(enumResult.stderr).toMatch(/enum_var.*must be one of|one of the following/i) - }) - }) - - it('[P1] validation failure returns http_status 422 in JSON error envelope', async () => { - // After the @accepts/@returns server contract unification, input schema - // validation failures consistently return HTTP 422 (not 400 or 500). - // This verifies the CLI propagates the unified status code. - const result = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A', paragraph: 'ok' }), - '-o', - 'json', - ]) - expect(result.exitCode).not.toBe(0) - const envelope = JSON.parse(result.stderr.trim()) as { - error: { code: string; message: string; http_status?: number } - } - expect(envelope.error.http_status, 'validation failure must return http_status 422').toBe(422) - }) - - // ========================================================================= - // Error scenarios - // ========================================================================= - - describe('Error scenarios', () => { - it('[P0] non-existent app returns error — exit code 1', async () => { - // Spec 4.1.20: non-existent app returns an error with not-found message - // Spec 4.1.21: exit code is exactly 1 - const result = await fx.r(['run', 'app', 'app-id-does-not-exist-e2e-xyz', 'hello']) - assertExitCode(result, 1) - expect(result.stderr).toMatch(/not.?found|server_5xx|Internal Server Error|500/i) - }) - - it('[P0] missing app id returns error (exit code 1 — CLI returns 1 for missing required arg)', async () => { - // Spec: missing app id returns a usage error - // Actual behaviour: CLI framework returns exit 1 (not 2) for missing required argument - const result = await fx.r(['run', 'app']) - assertExitCode(result, 1) - expect(result.stderr).toMatch(/missing required argument/i) - }) - - it('[P0] unauthenticated run app returns auth error (exit code 4)', async () => { - // Spec 4.1.22: unauthenticated run app returns auth error message - // Spec 4.1.23: exit code is exactly 4 - const unauthTmp = await withTempConfig() - try { - const result = await run(['run', 'app', E.chatAppId, 'hello'], { - configDir: unauthTmp.configDir, - }) - assertExitCode(result, 4) - expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i) - } finally { - await unauthTmp.cleanup() - } - }) - - it('[P1] network error returns non-zero exit code and error message', async () => { - // Spec 4.1.26: when the host is unreachable the CLI returns a network error. - // Uses a local port that has nothing listening (127.0.0.1:19999) so the - // connection is refused immediately without waiting for DNS. - const networkTmp = await withTempConfig() - try { - await mkdir(networkTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: http://127.0.0.1:19999`, - `token_storage: file`, - `tokens:`, - ` bearer: dfoa_fake_token_network_test`, - `workspace:`, - ` id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - `available_workspaces:`, - ` - id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - ].join('\n')}\n` - await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['run', 'app', E.chatAppId, 'hello'], { - configDir: networkTmp.configDir, - timeout: 15_000, - }) - expect(result.exitCode).not.toBe(0) - expect(result.stderr.length).toBeGreaterThan(0) - } finally { - await networkTmp.cleanup() - } - }) - }) - - // ========================================================================= - // Non-interactive mode / CI environment - // ========================================================================= - - describe('Non-interactive mode (CI)', () => { - it('[P0] CI=1 environment has no spinner — stdout has no ANSI colour', async () => { - // Spec: ANSI colour is disabled in non-TTY environment; spinner is suppressed in non-interactive mode - const result = await fx.r(['run', 'app', E.chatAppId, 'ci-test'], { CI: '1', NO_COLOR: '1' }) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, 'stdout') - assertNoAnsi(result.stderr, 'stderr') - }) - - it('[P0] non-interactive mode exit code is correctly propagated', async () => { - // Spec: non-interactive mode exit code is correct - const result = await fx.r(['run', 'app', E.chatAppId, 'code']) - expect(typeof result.exitCode).toBe('number') - expect(result.exitCode).toBe(0) - }) - }) - - // ========================================================================= - // Workspace override - // ========================================================================= - - describe('workspace override', () => { - it('[P1] --workspace flag overrides the default workspace', async () => { - // Spec: workspace override takes effect - // run app uses --workspace (no -w short form) - const result = await fx.r([ - 'run', - 'app', - E.chatAppId, - 'ws-override', - '--workspace', - E.workspaceId, - ]) - assertExitCode(result, 0) - }) - - itWithSso('[P1] external SSO user: --workspace parameter is silently ignored', async () => { - // Spec 4.1.25: SSO subjects operate without workspace scoping. - // Passing --workspace must not change the outcome — the parameter - // should be ignored, so both calls produce the same exit code. - const ssoTmp = await withTempConfig() - try { - await mkdir(ssoTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: ${E.host}`, - `token_storage: file`, - `tokens:`, - ` bearer: ${E.ssoToken}`, - `external_subject:`, - ` email: sso@example.com`, - ` issuer: https://issuer.example.com`, - ].join('\n')}\n` - await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - - // Run WITHOUT --workspace - const resultWithout = await run(['run', 'app', E.chatAppId, 'hello'], { - configDir: ssoTmp.configDir, - }) - - // Run WITH --workspace (should be ignored → same exit code) - const resultWith = await run( - ['run', 'app', E.chatAppId, 'hello', '--workspace', E.workspaceId], - { configDir: ssoTmp.configDir }, - ) - - // If --workspace were honoured for SSO users it would change behaviour; - // identical exit codes confirm the parameter is silently ignored. - expect(resultWith.exitCode).toBe(resultWithout.exitCode) - } finally { - await ssoTmp.cleanup() - } - }) - }) - - // ========================================================================= - // Cache behaviour (4.6.1) - // ========================================================================= - - describe('Cache behaviour', () => { - it('[P0] deleting app-info cache forces CLI to re-fetch from backend (4.6.1)', async () => { - // Spec 4.6.1: when the local app-info.json cache is absent the CLI must - // transparently re-fetch app metadata from the backend and complete normally. - // - // Strategy: - // 1. Run once to populate the cache under fx.configDir. - // 2. Assert the cache file now exists. - // 3. Delete the cache file. - // 4. Run again — must still succeed (cache miss → fresh fetch). - // - // DIFY_CACHE_DIR redirects the CLI's cache directory into the isolated - // temp dir so the test can observe and manipulate it without touching - // ~/Library/Caches/difyctl (macOS platform default). - // New cache layout: {DIFY_CACHE_DIR}/app-info.yml (was: cache/app-info.json) - const cacheEnv = { DIFY_CACHE_DIR: fx.configDir, DIFY_E2E_NO_KEYRING: '1' } - - // Step 1: prime the cache - const prime = await withRetry( - () => fx.r(['run', 'app', E.chatAppId, 'cache-prime'], cacheEnv), - { - attempts: 3, - delayMs: 2000, - }, - ) - assertExitCode(prime, 0) - - // Step 2: cache file must have been written at {configDir}/app-info.yml - const cacheFile = join(fx.configDir, 'app-info.yml') - const { access } = await import('node:fs/promises') - await expect(access(cacheFile)).resolves.toBeUndefined() - - // Step 3: delete the cache - await rm(cacheFile, { force: true }) - - // Step 4: run again — cache miss must not cause failure - const result = await withRetry( - () => fx.r(['run', 'app', E.chatAppId, 'cache-miss'], cacheEnv), - { - attempts: 3, - delayMs: 2000, - }, - ) - assertExitCode(result, 0) - expect(result.stdout.length, 'stdout must be non-empty after cache re-fetch').toBeGreaterThan( - 0, - ) - }) - }) -}) - -// ── local helper (avoids import confusion) ───────────────────────────────── -function assertNonZeroExit(result: import('../../helpers/cli.js').RunResult): void { - expect(result.exitCode, 'exit code should be non-zero').not.toBe(0) -} diff --git a/cli/test/e2e/suites/run/run-app-conversation.e2e.ts b/cli/test/e2e/suites/run/run-app-conversation.e2e.ts deleted file mode 100644 index e639f33aea8..00000000000 --- a/cli/test/e2e/suites/run/run-app-conversation.e2e.ts +++ /dev/null @@ -1,546 +0,0 @@ -/** - * E2E: difyctl run app --conversation — Conversation mode - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Conversation Mode (24 cases) - * Cases migrated from: run-app-basic.e2e.ts (Conversation mode describe block) - * - * Prerequisites (DIFY_E2E_* env vars): - * DIFY_E2E_CHAT_APP_ID — echo-chat app, mode=chat, outputs "echo: {query}" - */ - -import type { AuthFixture } from '../../helpers/cli.js' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { - assertErrorEnvelope, - assertExitCode, - assertJson, - assertPipeFriendlyJson, - assertStderrContains, -} from '../../helpers/assert.js' -import { registerConversation } from '../../helpers/cleanup-registry.js' -import { - injectAuth, - run, - spawn_background, - withAuthFixture, - withTempConfig, -} from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) -const itWithSso = optionalIt(Boolean(E.ssoToken)) - -describe('E2E / difyctl run app --conversation', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ── Create & reuse ────────────────────────────────────────────────────── - - it('[P0] chat app can create a new conversation — stderr contains hint', async () => { - // Spec: chat app can create a new conversation - const result = await fx.r(['run', 'app', E.chatAppId, 'start-conv']) - assertExitCode(result, 0) - assertStderrContains(result, '--conversation') - }) - - it('[P0] JSON output includes conversation_id', async () => { - // Spec: JSON output includes conversation_id - const result = await fx.r(['run', 'app', E.chatAppId, 'conv-json', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ conversation_id: string }>(result) - expect(typeof parsed.conversation_id).toBe('string') - expect(parsed.conversation_id.length).toBeGreaterThan(0) - registerConversation(E.host, E.token, E.chatAppId, parsed.conversation_id) - }) - - it('[P0] --conversation flag works — conversation_id is reused in subsequent requests', async () => { - // Spec: --conversation flag works; conversation_id is reused in subsequent requests - const first = await fx.r(['run', 'app', E.chatAppId, 'first-msg', '-o', 'json']) - assertExitCode(first, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(first) - registerConversation(E.host, E.token, E.chatAppId, conversation_id) - - const second = await fx.r([ - 'run', - 'app', - E.chatAppId, - 'second-msg', - '--conversation', - conversation_id, - '-o', - 'json', - ]) - assertExitCode(second, 0) - const secondParsed = assertJson<{ conversation_id: string }>(second) - expect(secondParsed.conversation_id).toBe(conversation_id) - }) - - it('[P0] a new session is auto-created when conversation_id is omitted', async () => { - // Spec 4.3.5: omitting --conversation creates a brand-new session each time; - // the new conversation_id must be non-empty and distinct from the previous one. - // withRetry: echo-chat app may return empty answer on back-to-back calls under load. - const firstId = await withRetry( - async () => { - const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-1', '-o', 'json']) - assertExitCode(r, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(r) - expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy() - return conversation_id - }, - { attempts: 3, delayMs: 2000 }, - ) - - const secondId = await withRetry( - async () => { - const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-2', '-o', 'json']) - assertExitCode(r, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(r) - expect(conversation_id, 'second call must return a non-empty conversation_id').toBeTruthy() - return conversation_id - }, - { attempts: 3, delayMs: 2000 }, - ) - - expect( - secondId, - 'omitting --conversation must create a new session, not reuse the previous one', - ).not.toBe(firstId) - }) - - // ── Error scenarios ───────────────────────────────────────────────────── - - it('[P0] invalid conversation_id returns error (exit code 1)', async () => { - // Spec 4.3.9: passing a non-existent conversation_id should return a - // "conversation not found" error with exit code exactly 1. - const result = await fx.r([ - 'run', - 'app', - E.chatAppId, - 'bad-conv', - '--conversation', - 'invalid-conv-id-xyz-not-exist', - ]) - assertExitCode(result, 1) - expect(result.stderr).toMatch(/not.?found|conversation|404|422|validation/i) - }) - - // ── Combined flags ────────────────────────────────────────────────────── - - it('[P1] conversation mode supports streaming', async () => { - // Spec 4.3.6: --conversation <cid> --stream should work and the streaming - // reply must carry the same conversation_id as the one used in the request. - // withRetry: echo-chat may return empty answer (no conversation_id) under load. - await withRetry( - async () => { - const first = await fx.r(['run', 'app', E.chatAppId, 'init', '-o', 'json']) - assertExitCode(first, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(first) - expect(conversation_id, 'first call should return a conversation_id').toBeTruthy() - - const result = await fx.r([ - 'run', - 'app', - E.chatAppId, - 'continue', - '--conversation', - conversation_id, - '--stream', - '-o', - 'json', - ]) - assertExitCode(result, 0) - const streamed = assertJson<{ conversation_id?: string; answer?: string }>(result) - expect( - streamed.conversation_id, - 'streaming reply must carry the same conversation_id', - ).toBe(conversation_id) - }, - { attempts: 3, delayMs: 2000 }, - ) - }) - - it('[P1] conversation output supports piping (-o json pipe-friendly format)', async () => { - // Spec: conversation output supports piping - const result = await fx.r(['run', 'app', E.chatAppId, 'pipe-conv', '-o', 'json']) - assertExitCode(result, 0) - assertPipeFriendlyJson(result) - }) - - // ── Auth error scenarios ──────────────────────────────────────────────── - - it('[P0] unauthenticated conversation run returns auth error (exit code 4)', async () => { - // Spec 4.3.16: running --conversation without a valid session must return - // an authentication error with exit code exactly 4. - const unauthTmp = await withTempConfig() - try { - const result = await run( - ['run', 'app', E.chatAppId, 'hello', '--conversation', 'any-conv-id'], - { configDir: unauthTmp.configDir }, - ) - assertExitCode(result, 4) - } finally { - await unauthTmp.cleanup() - } - }) - - itWithSso('[P0] SSO (dfoe_) token can run conversation mode (exit code 0)', async () => { - // Spec 4.3.17: an external SSO token (dfoe_) must be able to start a new - // conversation and receive a valid response; exit code must be 0. - const ssoTmp = await withTempConfig() - try { - await injectAuth(ssoTmp.configDir, { - host: E.host, - bearer: E.ssoToken, - email: 'sso-e2e@example.com', - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const result = await withRetry( - () => - run(['run', 'app', E.chatAppId, 'sso-conv-test', '-o', 'json'], { - configDir: ssoTmp.configDir, - }), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(result, 0) - const parsed = assertJson<{ conversation_id?: string }>(result) - expect( - parsed.conversation_id, - 'SSO conversation run should return a conversation_id', - ).toBeTruthy() - } finally { - await ssoTmp.cleanup() - } - }) - - // ── P1 additions ──────────────────────────────────────────────────────── - - it('[P1] JSON output includes message_id field', async () => { - // Spec 4.3.15: -o json response must include a non-empty message_id field. - const result = await withRetry( - async () => { - const r = await fx.r(['run', 'app', E.chatAppId, 'msg-id-check', '-o', 'json']) - assertExitCode(r, 0) - const parsed = assertJson<{ message_id?: string }>(r) - expect(parsed.message_id, 'message_id must be non-empty').toBeTruthy() - return r - }, - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(result, 0) - }) - - it('[P1] after streaming interruption the same conversation_id remains usable', async () => { - // Spec 4.3.18: interrupting a streaming run must not corrupt the conversation; - // a subsequent non-streaming call with the same conversation_id must succeed. - const conversation_id = await withRetry( - async () => { - const r = await fx.r(['run', 'app', E.chatAppId, 'pre-interrupt', '-o', 'json']) - assertExitCode(r, 0) - const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r) - expect(cid, 'setup call must return a conversation_id').toBeTruthy() - return cid - }, - { attempts: 3, delayMs: 2000 }, - ) - - // Start a streaming run and interrupt it after 800 ms. - const proc = spawn_background( - ['run', 'app', E.chatAppId, 'streaming-msg', '--conversation', conversation_id, '--stream'], - { configDir: fx.configDir }, - ) - await new Promise((res) => setTimeout(res, 800)) - proc.interrupt() - await proc.wait() - - // The conversation must still be usable after the interruption. - const resume = await withRetry( - () => - fx.r([ - 'run', - 'app', - E.chatAppId, - 'after-interrupt', - '--conversation', - conversation_id, - '-o', - 'json', - ]), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(resume, 0) - const parsed = assertJson<{ conversation_id: string }>(resume) - expect(parsed.conversation_id, 'resumed call must carry the same conversation_id').toBe( - conversation_id, - ) - }) - - it('[P1] conversation run with unreachable host returns network error (exit non-zero)', async () => { - // Spec 4.3.19: when the configured host is unreachable, the CLI must return - // a network error with a non-zero exit code. - const { writeFile, mkdir } = await import('node:fs/promises') - const { join } = await import('node:path') - const networkTmp = await withTempConfig() - try { - await mkdir(networkTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: http://127.0.0.1:19999`, - `token_storage: file`, - `tokens:`, - ` bearer: dfoa_fake_token_network_test`, - `workspace:`, - ` id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - `available_workspaces:`, - ` - id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - ].join('\n')}\n` - await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run( - ['run', 'app', E.chatAppId, 'hello', '--conversation', 'any-conv-id'], - { configDir: networkTmp.configDir, timeout: 15_000 }, - ) - expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) - expect(result.stderr.length, 'stderr should contain an error message').toBeGreaterThan(0) - } finally { - await networkTmp.cleanup() - } - }) - - it('[P1] invalid conversation_id with -o json outputs JSON error envelope on stderr', async () => { - // Spec 4.3.22: when conversation_id is invalid and -o json is active, - // stderr must contain a valid JSON error envelope. - const result = await fx.r([ - 'run', - 'app', - E.chatAppId, - 'bad-conv-json', - '--conversation', - 'nonexistent-conv-id-json-e2e', - '-o', - 'json', - ]) - expect(result.exitCode, 'invalid conversation in json mode should exit non-zero').not.toBe(0) - assertErrorEnvelope(result) - }) - - it('[P1] passing --conversation to a workflow app does not crash (stable fallback)', async () => { - // Spec 4.3.23: workflow apps do not support conversations; the CLI must not - // crash. The server silently ignores the parameter and runs the workflow normally. - const result = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'conv-wf-test', num: 1, enum_var: 'A', paragraph: 'ok' }), - '--conversation', - 'any-conv-id-for-wf', - ]) - expect( - result.exitCode, - '--conversation on workflow must not cause an unhandled crash (exit 2)', - ).not.toBe(2) - expect(result.stderr).not.toMatch(/unhandled|uncaught|TypeError|ReferenceError/i) - }) - - it('[P1] same conversation_id remains stable across 3 consecutive calls', async () => { - // Spec 4.3.24: reusing the same conversation_id multiple times must always - // succeed; each call must exit 0 and return the same conversation_id. - const conversation_id = await withRetry( - async () => { - const r = await fx.r(['run', 'app', E.chatAppId, 'stable-1', '-o', 'json']) - assertExitCode(r, 0) - const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r) - expect(cid, 'initial call must return a conversation_id').toBeTruthy() - return cid - }, - { attempts: 3, delayMs: 2000 }, - ) - - for (let i = 2; i <= 3; i++) { - const result = await withRetry( - () => - fx.r([ - 'run', - 'app', - E.chatAppId, - `stable-${i}`, - '--conversation', - conversation_id, - '-o', - 'json', - ]), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(result, 0) - const parsed = assertJson<{ conversation_id: string }>(result) - expect(parsed.conversation_id, `call ${i}: conversation_id must be stable`).toBe( - conversation_id, - ) - } - }) - - // ── 4.3.7 --conversation + --file ────────────────────────────────────────── - // - // Prerequisite: DIFY_E2E_FILE_CHAT_APP_ID must be set in .env.e2e. - // The fixture app is an advanced-chat app with a required file input variable - // named "file_input" (document type, remote-URL upload supported). - // We use a remote PDF URL to avoid SSL certificate issues with local upload - // on the staging server. - - const itWithFileChat = optionalIt(Boolean(E.fileChatAppId)) - - itWithFileChat( - '[P1] --conversation + --file doc uploads a file and continues the conversation', - async () => { - // Spec 4.3.7: --conversation <cid> --file doc=@test.txt - // File upload succeeds, app executes correctly, conversation_id is preserved. - const FILE_URL = 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' - - // Step 1: Start a new conversation with a file — get the conversation_id. - const first = await fx.r([ - 'run', - 'app', - E.fileChatAppId, - 'summarize this document', - '--file', - `file_input=${FILE_URL}`, - '-o', - 'json', - ]) - assertExitCode(first, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(first) - expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy() - registerConversation(E.host, E.token, E.fileChatAppId, conversation_id) - - // Step 2: Continue the same conversation with another file upload. - const second = await fx.r([ - 'run', - 'app', - E.fileChatAppId, - 'what is this document about?', - '--conversation', - conversation_id, - '--file', - `file_input=${FILE_URL}`, - '-o', - 'json', - ]) - assertExitCode(second, 0) - const secondParsed = assertJson<{ conversation_id: string }>(second) - - // The conversation_id must be the same across both calls. - expect(secondParsed.conversation_id, '--conversation must preserve the conversation_id').toBe( - conversation_id, - ) - }, - ) - - // ── 4.3.8 --conversation + --inputs ──────────────────────────────────────── - // - // The echo-chat app (E.chatAppId) now has an optional text-input variable - // named "input" (maxLength 256, required: false). This allows 4.3.8 to be - // tested against the existing fixture without a separate app. - // - // Spec: --conversation <cid> --inputs '{"key":"val"}' — input takes effect, - // app executes correctly, and conversation_id is preserved across calls. - - it('[P1] --conversation + --inputs passes input variables and preserves conversation_id', async () => { - // Spec 4.3.8: combining --conversation with --inputs should work correctly. - // Step 1: start a new conversation with an explicit input variable. - const first = await fx.r([ - 'run', - 'app', - E.chatAppId, - 'hello with inputs', - '--inputs', - JSON.stringify({ input: 'context-value' }), - '-o', - 'json', - ]) - assertExitCode(first, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(first) - expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy() - registerConversation(E.host, E.token, E.chatAppId, conversation_id) - - // Step 2: continue the conversation with another --inputs payload. - const second = await fx.r([ - 'run', - 'app', - E.chatAppId, - 'follow-up with inputs', - '--conversation', - conversation_id, - '--inputs', - JSON.stringify({ input: 'updated-context-value' }), - '-o', - 'json', - ]) - assertExitCode(second, 0) - const secondParsed = assertJson<{ conversation_id: string }>(second) - - // The conversation_id must be identical across both calls. - expect(secondParsed.conversation_id, '--inputs must not break conversation continuity').toBe( - conversation_id, - ) - }) - - // ── 4.3.11 wrong app id + valid conversation_id ───────────────────────────── - // - // Prerequisite: DIFY_E2E_FILE_CHAT_APP_ID must be set. - // Scenario (Plan A): - // 1. Create a conversation using E.chatAppId → get conv_id (owned by chatApp). - // 2. Run E.fileChatAppId with that conv_id → server rejects because the - // conversation does not belong to fileChatApp (HTTP 500, exit 1). - // - // Note: the server returns a 500 / "stream terminated by error event" rather than - // a 404 "not found", because the conversation lookup is done inside the streaming - // pipeline. The important contract is: exit code is 1 (non-zero) and stderr is - // non-empty with a recognisable error code. - - itWithFileChat( - '[P0] running fileChatApp with a conversation_id owned by chatApp returns an error (exit 1)', - async () => { - // Spec 4.3.11: using the wrong app id with a valid conversation_id from another - // app must fail with a non-zero exit code. - - // Step 1: create a conversation with chatApp. - const setup = await fx.r(['run', 'app', E.chatAppId, 'init-for-cross-app', '-o', 'json']) - assertExitCode(setup, 0) - const { conversation_id } = assertJson<{ conversation_id: string }>(setup) - expect(conversation_id, 'setup call must return a conversation_id').toBeTruthy() - registerConversation(E.host, E.token, E.chatAppId, conversation_id) - - // Step 2: attempt to continue that conversation using fileChatApp. - // The server rejects it because the conversation belongs to a different app. - const result = await fx.r([ - 'run', - 'app', - E.fileChatAppId, - 'this should fail', - '--conversation', - conversation_id, - '-o', - 'json', - ]) - - // The server returns HTTP 500 (stream terminated by error event) with exit 1. - expect(result.exitCode, 'cross-app conversation_id must cause a non-zero exit').toBe(1) - expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0) - // stderr must be a JSON error envelope when -o json is active - assertErrorEnvelope(result) - }, - ) -}) diff --git a/cli/test/e2e/suites/run/run-app-file.e2e.ts b/cli/test/e2e/suites/run/run-app-file.e2e.ts deleted file mode 100644 index 1c96d779cdc..00000000000 --- a/cli/test/e2e/suites/run/run-app-file.e2e.ts +++ /dev/null @@ -1,429 +0,0 @@ -/** - * E2E: difyctl run app --file — file input specialisation - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/File Input (31 cases) - * - * Prerequisites: - * DIFY_E2E_FILE_APP_ID — workflow app with a required 'doc' file variable - * All file-related cases are skipped when this variable is not configured. - */ - -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, inject, it } from 'vite-plus/test' -import { assertExitCode, assertJson, assertNoAnsi } from '../../helpers/assert.js' -import { injectAuth, run, withTempConfig } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { optionalDescribe, optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) -const itWithSso = optionalIt(Boolean(E.ssoToken)) -// supportsLocalUpload capability removed — local file upload probe is no longer -// performed in global-setup. Default to false (skip upload-specific cases). -const supportsLocalUpload = true - -const describeSuite = optionalDescribe(Boolean(E.fileAppId)) - -describeSuite('E2E / difyctl run app --file', () => { - let configDir: string - let fileDir: string - let cleanupConfig: () => Promise<void> - - beforeEach(async () => { - const tmp = await withTempConfig() - configDir = tmp.configDir - cleanupConfig = tmp.cleanup - fileDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-files-')) - await injectAuth(configDir, { - host: E.host, - bearer: E.token, - email: E.email, - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - }) - - afterEach(async () => { - await cleanupConfig() - await rm(fileDir, { recursive: true, force: true }) - }) - - function r(argv: string[]) { - return run(argv, { configDir }) - } - - // Minimal 1×1 white PNG — used as the required 'picture' (image) fixture. - async function writePng(path: string): Promise<void> { - const { Buffer } = await import('node:buffer') - const pngBytes = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg==', - 'base64', - ) - await writeFile(path, pngBytes) - } - - const itLocalUpload = optionalIt(supportsLocalUpload) - - itLocalUpload( - '[P0] run app supports single file upload (key=@path) — app executes correctly', - async () => { - // Spec: run app supports single file upload + app executes correctly after upload - const filePath = join(fileDir, 'test.txt') - const picPath = join(fileDir, 'test.png') - await writeFile(filePath, 'E2E test file content — single upload') - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${filePath}`, - '--file', - `picture=@${picPath}`, - ]) - assertExitCode(result, 0) - }, - ) - - itLocalUpload( - '[P0] file input argument name maps correctly (key binds to correct input field)', - async () => { - // Spec: file input argument name maps correctly - const filePath = join(fileDir, 'mapping.txt') - const picPath = join(fileDir, 'mapping.png') - await writeFile(filePath, 'mapping test content') - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${filePath}`, - '--file', - `picture=@${picPath}`, - '-o', - 'json', - ]) - assertExitCode(result, 0) - const parsed = assertJson<Record<string, unknown>>(result) - expect(parsed).toBeDefined() - }, - ) - - itLocalUpload('[P0] run app --file syntax is key=@path (local file upload)', async () => { - // Spec: run app --file syntax is key=@path - const filePath = join(fileDir, 'syntax.txt') - const picPath = join(fileDir, 'syntax.png') - await writeFile(filePath, 'syntax verification') - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${filePath}`, - '--file', - `picture=@${picPath}`, - ]) - assertExitCode(result, 0) - }) - - it('[P0] --file remote URL syntax (key=https://...) requires no local upload', async () => { - // Spec: run app --file with remote URL executes the workflow correctly - // file_auto_test requires both 'doc' (document) and 'picture' (image) fields. - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - 'doc=https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', - '--file', - 'picture=https://www.w3.org/Icons/w3c_home.png', - ]) - assertExitCode(result, 0) - }) - - it('[P0] non-existent file path returns an error', async () => { - // Spec: non-existent file path returns an error - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - 'doc=@/nonexistent/path/missing-file.txt', - ]) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toMatch(/failed|not.?found|upload|no such file|ENOENT/i) - }) - - it('[P1] malformed --file argument returns usage error (exit code 2)', async () => { - // Spec: malformed --file argument returns a usage error - const result = await r(['run', 'app', E.chatAppId, 'hello', '--file', 'invalidformat']) - assertExitCode(result, 2) - expect(result.stderr).toMatch(/--file|key[^\n\r@\u2028\u2029]*@.*path|invalid.*file/i) - }) - - itLocalUpload('[P1] file path containing spaces can be uploaded correctly', async () => { - // Spec: file path containing spaces can be uploaded correctly - const filePath = join(fileDir, 'file with spaces.txt') - const picPath = join(fileDir, 'pic spaces.png') - await writeFile(filePath, 'space in name test') - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${filePath}`, - '--file', - `picture=@${picPath}`, - ]) - assertExitCode(result, 0) - }) - - itLocalUpload('[P1] txt file upload is supported', async () => { - // Spec: txt file upload is supported - const f = join(fileDir, 'note.txt') - const picPath = join(fileDir, 'note.png') - await writeFile(f, 'plain text content') - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${f}`, - '--file', - `picture=@${picPath}`, - ]) - assertExitCode(result, 0) - }) - - itLocalUpload('[P1] --file combined with --stream works correctly', async () => { - // Spec: run app --file combined with --stream - const f = join(fileDir, 'stream.txt') - const picPath = join(fileDir, 'stream.png') - await writeFile(f, 'stream + file test') - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${f}`, - '--file', - `picture=@${picPath}`, - '--stream', - ]) - assertExitCode(result, 0) - }) - - it('[P0] unauthenticated file upload returns auth error (exit code 4)', async () => { - // Spec: unauthenticated file upload returns an auth error - const unauthTmp = await withTempConfig() - try { - const f = join(fileDir, 'unauth.txt') - await writeFile(f, 'test') - const result = await run(['run', 'app', E.fileAppId || E.chatAppId, '--file', `doc=@${f}`], { - configDir: unauthTmp.configDir, - }) - assertExitCode(result, 4) - } finally { - await unauthTmp.cleanup() - } - }) - - // ── P0 additions ──────────────────────────────────────────────────────── - - itLocalUpload('[P0] pdf file upload is supported (4.4.8)', async () => { - // Spec 4.4.8: .pdf is a valid document type for the doc field. - const pdfPath = join(fileDir, 'test.pdf') - const picPath = join(fileDir, 'pdf-pic.png') - await writeFile( - pdfPath, - '%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj ' + - '2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj ' + - '3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 3 3]>>endobj\n' + - 'xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n' + - '0000000058 00000 n \n0000000115 00000 n \n' + - 'trailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF\n', - ) - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${pdfPath}`, - '--file', - `picture=@${picPath}`, - ]) - assertExitCode(result, 0) - }) - itWithSso('[P0] SSO (dfoe_) token can execute file run (exit code 0) (4.4.23)', async () => { - // Spec 4.4.23: an SSO-provisioned token must be able to run a file app. - // Note: DIFY_E2E_SSO_TOKEN may be a dfoa_ token in dev environments; - // the test verifies the token can execute the app regardless of prefix. - const { join: pjoin } = await import('node:path') - const ssoTmp = await withTempConfig() - try { - await injectAuth(ssoTmp.configDir, { - host: E.host, - bearer: E.ssoToken, - email: 'sso-e2e@example.com', - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const docPath = pjoin(fileDir, 'sso-doc.txt') - const picPath = pjoin(fileDir, 'sso-pic.png') - await writeFile(docPath, 'sso file run test') - await writePng(picPath) - const result = await withRetry( - () => - run( - [ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${docPath}`, - '--file', - `picture=@${picPath}`, - ], - { configDir: ssoTmp.configDir }, - ), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(result, 0) - } finally { - await ssoTmp.cleanup() - } - }) - - // ── P1 additions ──────────────────────────────────────────────────────── - - itLocalUpload('[P1] empty file upload returns stable result without crash (4.4.11)', async () => { - // Spec 4.4.11: uploading a zero-byte file must not crash the CLI (exit code != 2). - const emptyPath = join(fileDir, 'empty.txt') - const picPath = join(fileDir, 'empty-pic.png') - await writeFile(emptyPath, '') - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${emptyPath}`, - '--file', - `picture=@${picPath}`, - ]) - expect(result.exitCode, 'empty file must not cause CLI crash (exit 2)').not.toBe(2) - expect(result.stderr).not.toMatch(/unhandled|uncaught|TypeError|ReferenceError/i) - }) - - itLocalUpload('[P1] --file and --inputs flags can coexist (4.4.15 / 4.4.29)', async () => { - // Spec 4.4.15: passing both --file and --inputs must not cause a CLI error. - // Spec 4.4.29: workflow app accepts --inputs + --file together. - // file_auto_test has no non-file inputs; empty --inputs '{}' is passed to verify - // the CLI accepts both flags without a usage error. - const docPath = join(fileDir, 'inputs-doc.txt') - const picPath = join(fileDir, 'inputs-pic.png') - await writeFile(docPath, 'inputs + file coexist test') - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--inputs', - '{}', - '--file', - `doc=@${docPath}`, - '--file', - `picture=@${picPath}`, - ]) - expect( - result.exitCode, - '--inputs and --file together must not cause CLI usage error (exit 2)', - ).not.toBe(2) - }) - - itLocalUpload( - '[P1] files with same name in different paths upload without conflict (4.4.16)', - async () => { - // Spec 4.4.16: multiple --file entries with the same filename (different paths) - // must all upload successfully without collision. - const { mkdtemp: mkd } = await import('node:fs/promises') - const { tmpdir: td } = await import('node:os') - const dir2 = await mkd(join(td(), 'difyctl-e2e-samename-')) - try { - const docPath = join(fileDir, 'same.txt') // doc field - const picPath = join(dir2, 'same.png') // picture field — same base name, different dir - await writeFile(docPath, 'same name doc test') - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${docPath}`, - '--file', - `picture=@${picPath}`, - ]) - assertExitCode(result, 0) - } finally { - const { rm: rmDir } = await import('node:fs/promises') - await rmDir(dir2, { recursive: true, force: true }) - } - }, - ) - - itLocalUpload( - '[P1] -o json after file upload contains workflow response fields (4.4.21)', - async () => { - // Spec 4.4.21: -o json output after a file run must contain structured response metadata. - const docPath = join(fileDir, 'json-doc.txt') - const picPath = join(fileDir, 'json-pic.png') - await writeFile(docPath, 'json output test') - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${docPath}`, - '--file', - `picture=@${picPath}`, - '-o', - 'json', - ]) - assertExitCode(result, 0) - const parsed = assertJson<Record<string, unknown>>(result) - // workflow response must contain at minimum a mode field - expect(parsed.mode, 'JSON output must contain mode field').toBeTruthy() - assertNoAnsi(result.stdout, 'stdout') - }, - ) - - itLocalUpload('[P1] file path with CJK characters uploads correctly (4.4.26)', async () => { - // Spec 4.4.26: a file whose path contains CJK (Chinese) characters must upload - // and execute successfully. - const cjkPath = join(fileDir, 'cjk-test-doc.txt') - const picPath = join(fileDir, 'cjk-pic.png') - await writeFile(cjkPath, 'CJK path upload test — Chinese content') - await writePng(picPath) - const result = await r([ - 'run', - 'app', - E.fileAppId, - '--file', - `doc=@${cjkPath}`, - '--file', - `picture=@${picPath}`, - ]) - assertExitCode(result, 0) - }) -}) diff --git a/cli/test/e2e/suites/run/run-app-hitl.e2e.ts b/cli/test/e2e/suites/run/run-app-hitl.e2e.ts deleted file mode 100644 index cf5df886039..00000000000 --- a/cli/test/e2e/suites/run/run-app-hitl.e2e.ts +++ /dev/null @@ -1,598 +0,0 @@ -/** - * E2E: difyctl run app + difyctl resume app — HITL human-in-the-loop specialisation - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/HITL Human Intervention (19 cases) - * - * Prerequisites: - * DIFY_E2E_HITL_APP_ID — workflow app containing a Human Input node with display_in_ui=true - * All HITL cases are skipped when this variable is not configured. - */ - -import type { AuthFixture } from '../../helpers/cli.js' -import { writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, inject, it } from 'vite-plus/test' -import { - assertExitCode, - assertJson, - assertNonZeroExit, - assertStderrContains, -} from '../../helpers/assert.js' -import { run, withAuthFixture } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { optionalDescribe } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -const describeSuite = optionalDescribe(Boolean(E.hitlAppId)) - -describeSuite('E2E / difyctl run app — HITL human intervention', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - it('[P0] workflow HITL pause outputs a pause block on stdout — exit code 0', async () => { - // Spec 4.5.1/4.5.2: stdout contains pause block with Node name + Actions list; exit 0. - const result = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'hitl-e2e' }), - ]) - assertExitCode(result, 0) - // pause block must be present - expect(result.stdout).toMatch(/paused|pause/i) - // actions list rendered in stdout - expect(result.stdout).toMatch(/action|button/i) - }) - - it('[P0] HITL pause JSON contains all required fields', async () => { - // Spec 4.5.3/4.5.4/4.5.5: JSON envelope must include the full set of fields. - const result = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'hitl-json' }), - '-o', - 'json', - ]) - assertExitCode(result, 0) - const p = assertJson<Record<string, unknown>>(result) - // core status - expect(p).toHaveProperty('status', 'paused') - // identity fields - expect(p).toHaveProperty('app_id') - expect(p).toHaveProperty('task_id') - expect(p).toHaveProperty('workflow_run_id') - expect(p).toHaveProperty('form_id') - expect(p).toHaveProperty('node_id') - // display fields - expect(p).toHaveProperty('node_title') - expect(p).toHaveProperty('form_content') - expect(p).toHaveProperty('inputs') - expect(p).toHaveProperty('actions') - expect(p).toHaveProperty('display_in_ui') - expect(p).toHaveProperty('resolved_default_values') - // token + expiry - expect(p).toHaveProperty('form_token') - expect(typeof p.form_token).toBe('string') - expect((p.form_token as string).length).toBeGreaterThan(0) - expect(p).toHaveProperty('expiration_time') - expect(typeof p.expiration_time).toBe('number') - expect(p.expiration_time as number).toBeGreaterThan(0) - }) - - it('[P0] HITL pause hint contains the full resume command', async () => { - // Spec 4.5.6: stderr hint must be a directly executable resume command including - // the app id, form_token, and --workflow-run-id flag. - const pauseResult = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'hint-test' }), - '-o', - 'json', - ]) - assertExitCode(pauseResult, 0) - const { form_token, workflow_run_id } = assertJson<{ - form_token: string - workflow_run_id: string - }>(pauseResult) - // hint must contain all three identifiers - assertStderrContains(pauseResult, 'difyctl resume app') - assertStderrContains(pauseResult, '--workflow-run-id') - assertStderrContains(pauseResult, form_token) - assertStderrContains(pauseResult, workflow_run_id) - }) - - it('[P0] AI Agent automation — extract form_token from JSON and auto-resume', async () => { - // Spec 4.5.11/4.5.13/4.5.19: run → extract form_token → resume with --action and - // --inputs; final output must reflect workflow_finished (exit 0). - const pauseResult = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'auto-resume' }), - '-o', - 'json', - ]) - assertExitCode(pauseResult, 0) - const envelope = assertJson<{ - form_token: string - workflow_run_id: string - app_id?: string - actions?: Array<{ id: string }> - }>(pauseResult) - expect(envelope.form_token).toBeTruthy() - expect(envelope.workflow_run_id).toBeTruthy() - - // Step 2: resume with explicit --action and --inputs (Spec 4.5.11) - const actionId = envelope.actions?.[0]?.id ?? 'action_1' - const resumeResult = await fx.r([ - 'resume', - 'app', - E.hitlAppId, - envelope.form_token, - '--workflow-run-id', - envelope.workflow_run_id, - '--action', - actionId, - '--inputs', - JSON.stringify({ name: 'E2E-auto-resume' }), - ]) - assertExitCode(resumeResult, 0) - // Spec 4.5.13: final output must signal workflow completion - expect(resumeResult.stdout + resumeResult.stderr).toMatch( - /succeeded|finished|workflow_finished|completed/i, - ) - }) - - it('[P0] resume app auto-selects the single action — workflow continues execution', async () => { - // Spec 4.5.9: when the form has exactly one action, --action may be omitted - // and the CLI auto-selects it. - // Uses hitlSingleActionAppId (display_in_ui=true, 1 action, no required inputs). - // hitlAppId now has 3 actions so it cannot be used here. - if (!E.hitlSingleActionAppId) return - - const pause = await fx.r(['run', 'app', E.hitlSingleActionAppId, '-o', 'json']) - assertExitCode(pause, 0) - const { form_token, workflow_run_id, actions } = assertJson<{ - form_token: string - workflow_run_id: string - actions: Array<{ id: string }> - }>(pause) - expect(actions.length, 'fixture must have exactly 1 action').toBe(1) - - // Resume without --action — CLI auto-selects the only available action. - const resume = await fx.r([ - 'resume', - 'app', - E.hitlSingleActionAppId, - form_token, - '--workflow-run-id', - workflow_run_id, - ]) - assertExitCode(resume, 0) - }) - - // ── New cases ──────────────────────────────────────────────────────────── - - it('[P0] HITL pause in streaming mode outputs pause block (4.5.7)', async () => { - // Spec 4.5.7: --stream mode must still emit pause block and exit 0 on HITL. - // Streaming HITL: SSE connection can be closed unexpectedly; - // withRetry triggers on thrown errors so we throw when exit != 0. - const result = await withRetry( - async () => { - const r = await run( - ['run', 'app', E.hitlAppId, '--inputs', JSON.stringify({ x: 'hitl-stream' }), '--stream'], - { configDir: fx.configDir, timeout: 60_000 }, - ) - if (r.exitCode !== 0) - throw new Error(`streaming HITL exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`) - return r - }, - { attempts: 3, delayMs: 3000 }, - ) - assertExitCode(result, 0) - expect(result.stdout + result.stderr).toMatch(/paused|pause|resume/i) - }) - - it('[P0] resume with already-consumed form_token returns error (4.5.16)', async () => { - // Spec 4.5.16: once a form_token has been consumed by a successful resume, - // submitting the same token again must return an error with exit code non-zero. - const pause = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'double-resume' }), - '-o', - 'json', - ]) - assertExitCode(pause, 0) - const { form_token, workflow_run_id, actions } = assertJson<{ - form_token: string - workflow_run_id: string - actions?: Array<{ id: string }> - }>(pause) - const actionId = actions?.[0]?.id ?? 'action_1' - - // First resume — must succeed - const first = await fx.r([ - 'resume', - 'app', - E.hitlAppId, - form_token, - '--workflow-run-id', - workflow_run_id, - '--action', - actionId, - ]) - assertExitCode(first, 0) - - // Second resume with the same token — must fail - const second = await fx.r([ - 'resume', - 'app', - E.hitlAppId, - form_token, - '--workflow-run-id', - workflow_run_id, - '--action', - actionId, - ]) - assertNonZeroExit(second) - }) - - it('[P1] resume with --inputs-file reads form values from JSON file (4.5.12)', async () => { - // Spec 4.5.12: --inputs-file must read form field values from a local JSON file. - const pause = await withRetry( - async () => { - const r = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'inputs-file-test' }), - '-o', - 'json', - ]) - assertExitCode(r, 0) - return r - }, - { attempts: 3, delayMs: 2000 }, - ) - const { form_token, workflow_run_id, actions } = assertJson<{ - form_token: string - workflow_run_id: string - actions?: Array<{ id: string }> - }>(pause) - const actionId = actions?.[0]?.id ?? 'action_1' - - // Write form values to a temp file - const inputsFile = join(tmpdir(), `hitl-e2e-${Date.now()}.json`) - await writeFile(inputsFile, JSON.stringify({ name: 'E2E-inputs-file' })) - - const result = await fx.r([ - 'resume', - 'app', - E.hitlAppId, - form_token, - '--workflow-run-id', - workflow_run_id, - '--action', - actionId, - '--inputs-file', - inputsFile, - ]) - assertExitCode(result, 0) - }) - - it('[P1] resume with --with-history returns node history in output (4.5.14)', async () => { - // Spec 4.5.14: --with-history must request include_state_snapshot=true and - // return historical node events; the CLI must exit 0 with non-empty output. - const pause = await withRetry( - async () => { - const r = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'with-history-test' }), - '-o', - 'json', - ]) - assertExitCode(r, 0) - return r - }, - { attempts: 3, delayMs: 2000 }, - ) - const { form_token, workflow_run_id, actions } = assertJson<{ - form_token: string - workflow_run_id: string - actions?: Array<{ id: string }> - }>(pause) - const actionId = actions?.[0]?.id ?? 'action_1' - - const result = await fx.r([ - 'resume', - 'app', - E.hitlAppId, - form_token, - '--workflow-run-id', - workflow_run_id, - '--action', - actionId, - '--with-history', - ]) - assertExitCode(result, 0) - expect(result.stdout.length + result.stderr.length).toBeGreaterThan(0) - }) - - it('[P1] resume with --stream outputs workflow completion in real-time (4.5.17)', async () => { - // Spec 4.5.17: resume --stream must stream continuation node outputs to stdout - // and exit 0 after workflow_finished. - const pause = await withRetry( - async () => { - const r = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'resume-stream-test' }), - '-o', - 'json', - ]) - assertExitCode(r, 0) - return r - }, - { attempts: 3, delayMs: 2000 }, - ) - const { form_token, workflow_run_id, actions } = assertJson<{ - form_token: string - workflow_run_id: string - actions?: Array<{ id: string }> - }>(pause) - const actionId = actions?.[0]?.id ?? 'action_1' - - const result = await fx.r([ - 'resume', - 'app', - E.hitlAppId, - form_token, - '--workflow-run-id', - workflow_run_id, - '--action', - actionId, - '--stream', - ]) - assertExitCode(result, 0) - }) -}) - -// ── 4.5.8 display_in_ui=false — HITL pause with external channel delivery ────── -// -// A separate describe block so the suite can be skipped independently when -// DIFY_E2E_HITL_EXTERNAL_APP_ID is not configured. - -const describeExternal = optionalDescribe(Boolean(E.hitlExternalAppId)) - -describeExternal('E2E / difyctl run app — HITL display_in_ui=false (4.5.8)', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - it('[P1] 4.5.8 HITL pause with display_in_ui=false: external-channel form is not CLI-resumable', async () => { - const result = await fx.r(['run', 'app', E.hitlExternalAppId, '-o', 'json']) - assertExitCode(result, 0) - - const parsed = assertJson<{ - status: string - display_in_ui: boolean - form_token: string | null - approval_channels: string[] - workflow_run_id: string - }>(result) - - // display_in_ui must be false for this fixture - expect(parsed.display_in_ui, 'display_in_ui must be false for external-channel fixture').toBe( - false, - ) - - // status must be paused - expect(parsed.status).toBe('paused') - - // external delivery is not CLI-resumable: no token, channels name the real route - expect(parsed.form_token, 'form_token must be null for external delivery').toBeNull() - expect(parsed.approval_channels, 'approval_channels must name the delivery channel').toContain( - 'email', - ) - - // stderr hint must describe the channel, not offer a resume command - expect(result.stderr).toMatch(/delivered via|resume only from/i) - expect(result.stderr).not.toMatch(/difyctl resume/i) - }) -}) - -// ── 4.5.10 multiple actions — resume without --action returns error ────────── -// -// The existing DIFY_E2E_HITL_APP_ID fixture now has 3 actions (action_1/2/3). -// When --action is omitted and the form has multiple actions, the CLI must -// return "--action required: form has multiple user actions" with exit 1. - -const describeMultiAction = optionalDescribe(Boolean(E.hitlAppId)) - -describeMultiAction('E2E / difyctl resume app — HITL multiple actions (4.5.10)', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - it('[P0] 4.5.10 resume without --action when form has multiple actions returns exit 1', async () => { - // Spec 4.5.10: when the HITL form has multiple user actions and --action is - // not provided, the CLI must reject the command with a clear error. - // - // Step 1: trigger the HITL pause and extract form_token + workflow_run_id. - const runResult = await fx.r([ - 'run', - 'app', - E.hitlAppId, - '--inputs', - JSON.stringify({ x: 'multi-action-test' }), - '-o', - 'json', - ]) - assertExitCode(runResult, 0) - const { form_token, workflow_run_id, actions } = assertJson<{ - form_token: string - workflow_run_id: string - actions: Array<{ id: string }> - }>(runResult) - - // Confirm the fixture has more than one action. - expect(actions.length, 'fixture must have multiple actions for this test').toBeGreaterThan(1) - - // Step 2: attempt to resume without --action. - const resumeResult = await fx.r([ - 'resume', - 'app', - E.hitlAppId, - form_token, - '--workflow-run-id', - workflow_run_id, - // intentionally omit --action - ]) - - expect( - resumeResult.exitCode, - 'omitting --action with multiple actions must exit non-zero', - ).toBe(1) - expect(resumeResult.stderr).toMatch(/--action required|multiple.*action|action.*required/i) - }) -}) - -// ── 4.5.18 2 serial HITL nodes — run → resume → resume → finished ──────────── -// -// Prerequisite: DIFY_E2E_HITL_MULTI_NODE_APP_ID must be set. -// The fixture app has 2 serial Human Input nodes, each with 1 action. -// Flow: run → pause at node 1 → resume 1 → pause at node 2 → resume 2 → finished. - -const describeMultiNode = optionalDescribe(Boolean(E.hitlMultiNodeAppId)) - -describeMultiNode('E2E / difyctl run + resume — HITL 2 serial nodes (4.5.18)', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - it('[P1] 4.5.18 workflow with 2 serial HITL nodes completes after two resumes', async () => { - // Spec 4.5.18: run → resume(node1) → resume(node2) → workflow_finished. - // Both resumes must succeed; final output must indicate success. - - // ── Step 1: run — pauses at first HITL node ────────────────────────── - const pause1 = await withRetry( - async () => { - const r = await fx.r(['run', 'app', E.hitlMultiNodeAppId, '-o', 'json']) - if (r.exitCode !== 0) throw new Error(`run failed: ${r.stderr.slice(0, 200)}`) - return r - }, - { attempts: 3, delayMs: 3000 }, - ) - - assertExitCode(pause1, 0) - const node1 = assertJson<{ - status: string - form_token: string - workflow_run_id: string - actions: Array<{ id: string }> - }>(pause1) - expect(node1.status).toBe('paused') - expect(node1.form_token, 'node 1 must return a form_token').toBeTruthy() - - const actionId1 = node1.actions[0]?.id ?? 'action_1' - - // ── Step 2: resume node 1 — workflow continues to second HITL node ─── - const pause2 = await withRetry( - async () => { - const r = await fx.r([ - 'resume', - 'app', - E.hitlMultiNodeAppId, - node1.form_token, - '--workflow-run-id', - node1.workflow_run_id, - '--action', - actionId1, - '-o', - 'json', - ]) - if (r.exitCode !== 0) throw new Error(`resume 1 failed: ${r.stderr.slice(0, 200)}`) - return r - }, - { attempts: 3, delayMs: 3000 }, - ) - - assertExitCode(pause2, 0) - const node2 = assertJson<{ - status: string - form_token: string - workflow_run_id: string - actions: Array<{ id: string }> - }>(pause2) - expect(node2.status, 'after first resume the workflow must pause again at node 2').toBe( - 'paused', - ) - expect(node2.form_token, 'node 2 must return a new form_token').toBeTruthy() - expect(node2.form_token, 'node 2 form_token must differ from node 1').not.toBe(node1.form_token) - - const actionId2 = node2.actions[0]?.id ?? 'action_1' - - // ── Step 3: resume node 2 — workflow finishes ───────────────────────── - const finish = await withRetry( - async () => { - const r = await fx.r([ - 'resume', - 'app', - E.hitlMultiNodeAppId, - node2.form_token, - '--workflow-run-id', - node2.workflow_run_id, - '--action', - actionId2, - ]) - if (r.exitCode !== 0) throw new Error(`resume 2 failed: ${r.stderr.slice(0, 200)}`) - return r - }, - { attempts: 3, delayMs: 3000 }, - ) - - assertExitCode(finish, 0) - expect(finish.stdout + finish.stderr).toMatch(/succeeded|finished/i) - }) -}) diff --git a/cli/test/e2e/suites/run/run-app-reasoning.e2e.ts b/cli/test/e2e/suites/run/run-app-reasoning.e2e.ts deleted file mode 100644 index 1778289511f..00000000000 --- a/cli/test/e2e/suites/run/run-app-reasoning.e2e.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * E2E: difyctl run app — separated-mode reasoning (PR #37460) - * - * Exercises the out-of-band `reasoning_chunk` SSE channel against a real server. - * Requires a chatflow whose LLM node uses reasoning_format=separated AND a - * workspace with a configured chat model. The whole suite is skipped unless - * DIFY_E2E_REASONING_APP_ID resolves (set it directly, or provision the - * reasoning-chat.yml fixture with DIFY_E2E_REASONING_PROVISION=1). - * - * Verifies the client adaptation: - * - --think surfaces the separated reasoning to stderr, framed as <think>…</think> - * - the answer (stdout) stays free of <think> - * - -o json persists the reasoning under metadata.reasoning - * - without --think, reasoning stays hidden - */ - -import type { AuthFixture } from '../../helpers/cli.js' -import { afterEach, beforeEach, describe, expect, inject } from 'vite-plus/test' -import { assertExitCode, assertJson, assertStderrContains } from '../../helpers/assert.js' -import { registerConversation } from '../../helpers/cleanup-registry.js' -import { withAuthFixture } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) - -// Skipped unless a separated-reasoning chatflow is wired up (needs a real model). -const reasoningIt = optionalIt(Boolean(E.reasoningAppId)) - -const QUERY = 'In one short sentence, why is the sky blue?' - -describe('E2E / difyctl run app — separated reasoning', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - reasoningIt( - '[P1] --think --stream surfaces reasoning on stderr, clean answer on stdout', - async () => { - const result = await withRetry( - () => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--think', '--stream']), - { attempts: 3, delayMs: 1000 }, - ) - - assertExitCode(result, 0) - expect(result.stdout.trim().length).toBeGreaterThan(0) - // Separated mode keeps the answer free of <think>; reasoning is framed on stderr. - expect(result.stdout).not.toContain('<think>') - assertStderrContains(result, '<think>') - }, - ) - - reasoningIt('[P1] --think -o json persists reasoning under metadata.reasoning', async () => { - const result = await withRetry( - () => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--think', '-o', 'json']), - { attempts: 3, delayMs: 1000 }, - ) - - assertExitCode(result, 0) - const parsed = assertJson<{ - conversation_id?: string - answer: string - metadata?: { reasoning?: Record<string, string> } - }>(result) - - if (parsed.conversation_id) - registerConversation(E.host, E.token, E.reasoningAppId, parsed.conversation_id) - - const reasoning = parsed.metadata?.reasoning ?? {} - expect(Object.keys(reasoning).length).toBeGreaterThan(0) - expect(Object.values(reasoning).join('').length).toBeGreaterThan(0) - // --think also echoes the separated reasoning to stderr. - assertStderrContains(result, '<think>') - }) - - reasoningIt('[P1] without --think, reasoning stays hidden', async () => { - const result = await withRetry( - () => fx.r(['run', 'app', E.reasoningAppId, QUERY, '--stream']), - { attempts: 3, delayMs: 1000 }, - ) - - assertExitCode(result, 0) - expect(result.stdout.trim().length).toBeGreaterThan(0) - expect(result.stderr).not.toContain('<think>') - }) -}) diff --git a/cli/test/e2e/suites/run/run-app-streaming.e2e.ts b/cli/test/e2e/suites/run/run-app-streaming.e2e.ts deleted file mode 100644 index 6ed8d2ebe3b..00000000000 --- a/cli/test/e2e/suites/run/run-app-streaming.e2e.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * E2E: difyctl run app --stream — streaming output specialisation - * - * Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Streaming Output (24 cases) - * - * Covers scenarios that run-app-basic.e2e.ts cannot handle: - * - Ctrl+C interruption (SIGINT) - * - Chunk arrival order verification (timing) - * - Cases migrated from run-app-basic.e2e.ts: exit code, stderr separation, - * -o json envelope, unauthenticated, pipe, workflow succeeded status - */ - -import type { Buffer } from 'node:buffer' -import type { AuthFixture } from '../../helpers/cli.js' -import { spawn } from 'node:child_process' -import { afterEach, beforeEach, describe, expect, inject, it } from 'vite-plus/test' -import { - assertErrorEnvelope, - assertExitCode, - assertJson, - assertNoAnsi, - assertStderrContains, -} from '../../helpers/assert.js' -import { BIN, BUN, injectAuth, run, withAuthFixture, withTempConfig } from '../../helpers/cli.js' -import { withRetry } from '../../helpers/retry.js' -import { optionalIt } from '../../helpers/skip.js' -import { resolveEnv } from '../../setup/env.js' - -// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation -const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities -const E = resolveEnv(caps) -const itWithSso = optionalIt(Boolean(E.ssoToken)) - -describe('E2E / difyctl run app --stream (specialisation)', () => { - let fx: AuthFixture - - beforeEach(async () => { - fx = await withAuthFixture(E) - }) - afterEach(async () => { - await fx.cleanup() - }) - - // ── Chunk timing & token order ────────────────────────────────────────── - - it('[P0] streaming output arrives in real-time chunks (stdout non-empty, echo complete)', async () => { - // Spec: streaming output is printed in real-time by chunk + token order is preserved - // withRetry: staging SSE connections may fail transiently on cold start - await withRetry( - async () => { - const query = 'chunk-order-test' - const proc = spawn(BUN, [BIN, 'run', 'app', E.chatAppId, query, '--stream'], { - env: { - ...process.env, - DIFY_CONFIG_DIR: fx.configDir, - CI: '1', - NO_COLOR: '1', - DIFY_E2E_NO_KEYRING: '1', - }, - }) - - const chunks: string[] = [] - proc.stdout.on('data', (d: Buffer) => { - chunks.push(d.toString('utf8')) - }) - - let stderr = '' - proc.stderr.on('data', (d: Buffer) => { - stderr += d.toString('utf8') - }) - - const exitCode = await new Promise<number>((res) => { - proc.on('close', (code) => res(code ?? 1)) - }) - - assertExitCode({ stdout: chunks.join(''), stderr, exitCode }, 0) - // May arrive in multiple chunks; the concatenated result must contain the full query - expect(chunks.join('')).toContain(query) - }, - { attempts: 3, delayMs: 2000 }, - ) - }) - - // ── Basic streaming behaviour ─────────────────────────────────────────── - - it('[P0] exit code is 0 after streaming completes', async () => { - // Spec: streaming exits normally after completion - const result = await fx.r(['run', 'app', E.chatAppId, 'end-ok', '--stream']) - assertExitCode(result, 0) - }) - - it('[P1] stderr is not mixed into stdout in streaming mode', async () => { - // Spec: stderr is not mixed into stdout in streaming mode - const result = await fx.r(['run', 'app', E.chatAppId, 'sep', '--stream']) - assertExitCode(result, 0) - expect(result.stdout).not.toContain('hint:') - assertStderrContains(result, '--conversation') - }) - - it('[P1] --stream -o json outputs a valid JSON envelope', async () => { - // Spec: streaming mode produces valid JSON output - const result = await fx.r(['run', 'app', E.chatAppId, 'sjson', '--stream', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ mode: string; answer: string }>(result) - expect(parsed.mode).toMatch(/chat/) - }) - - it('[P1] streaming mode output supports piping (no ANSI, ends with \\n)', async () => { - // Spec: streaming mode output supports piping - const result = await fx.r(['run', 'app', E.chatAppId, 'pipe-s', '--stream']) - assertExitCode(result, 0) - assertNoAnsi(result.stdout, 'stdout') - expect(result.stdout.endsWith('\n')).toBe(true) - }) - - it('[P0] workflow streaming output contains succeeded status', async () => { - // Spec: workflow streaming output includes succeeded status - const result = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'wf-stream-val', num: 42, enum_var: 'A', paragraph: 'short text' }), - '--stream', - '-o', - 'json', - ]) - assertExitCode(result, 0) - const parsed = assertJson<{ data?: { status?: string } }>(result) - expect(parsed.data?.status).toBe('succeeded') - }) - - // ── Error scenarios ───────────────────────────────────────────────────── - - it('[P0] server-side error event causes CLI to exit with non-zero code', async () => { - // Spec: server-side error event causes CLI to exit with non-zero code - // Use a non-existent app ID to force a server-side error. - const proc = spawn(BUN, [BIN, 'run', 'app', 'nonexistent-app-xyz-e2e', 'hi', '--stream'], { - env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' }, - }) - let stderr = '' - proc.stderr.on('data', (d: Buffer) => { - stderr += d.toString('utf8') - }) - const exitCode = await new Promise<number>((res) => { - proc.on('close', (code) => res(code ?? 1)) - }) - expect(exitCode, 'error event should cause non-zero exit').not.toBe(0) - expect(stderr.length).toBeGreaterThan(0) - }) - - it('[P0] unauthenticated streaming returns auth error (exit code 4)', async () => { - // Spec: unauthenticated streaming returns an auth error - const unauthTmp = await withTempConfig() - try { - const result = await run(['run', 'app', E.chatAppId, 'hi', '--stream'], { - configDir: unauthTmp.configDir, - }) - assertExitCode(result, 4) - } finally { - await unauthTmp.cleanup() - } - }) - - it('[P0] streaming fails when a required input is missing (exit code non-zero)', async () => { - // Spec: streaming fails when a required input is missing - // workflow app requires variable x (required); the server should return a validation error - // immediately, and the CLI exits with a non-zero code. - // - // ⚠️ Depends on feat/cli API version (server-side pre-validation of missing required inputs). - // Current local server 1.14.1 does not support this check; test passes once upgraded. - const proc = spawn(BUN, [BIN, 'run', 'app', E.workflowAppId, '--stream'], { - env: { - ...process.env, - DIFY_CONFIG_DIR: fx.configDir, - CI: '1', - NO_COLOR: '1', - DIFY_E2E_NO_KEYRING: '1', - }, - }) - let stderr = '' - proc.stderr.on('data', (d: Buffer) => { - stderr += d.toString('utf8') - }) - const exitCode = await new Promise<number>((res) => { - proc.on('close', (code) => res(code ?? 1)) - }) - expect(exitCode).not.toBe(0) - // The server should return a clear validation error rather than timing out - expect(stderr).toMatch(/validation|required|invalid|missing/i) - }) - - // ── SIGINT ────────────────────────────────────────────────────────────── - - it('[P1] Ctrl+C interrupts streaming (SIGINT → non-zero exit code)', async () => { - // Spec: Ctrl+C interrupts streaming + exit code is non-zero after Ctrl+C - const proc = spawn(BUN, [BIN, 'run', 'app', E.chatAppId, 'ctrl-c-test', '--stream'], { - env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' }, - }) - - let _stdout = '' - let _stderr = '' - proc.stdout.on('data', (d: Buffer) => { - _stdout += d.toString('utf8') - }) - proc.stderr.on('data', (d: Buffer) => { - _stderr += d.toString('utf8') - }) - - // Wait for the process to start streaming, then interrupt. - await new Promise((res) => setTimeout(res, 800)) - proc.kill('SIGINT') - - const exitCode = await new Promise<number>((res) => { - proc.on('close', (code) => res(code ?? 1)) - }) - - expect(exitCode, 'SIGINT should cause non-zero exit').not.toBe(0) - }) - - // ── Multiple inputs in streaming mode (4.2.8) ────────────────────────── - - it('[P1] workflow streaming with multiple inputs passes all params correctly', async () => { - // Spec 4.2.8: multiple --inputs entries take effect simultaneously in streaming mode - const result = await withRetry( - () => - fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'multi-stream-k1', num: 42, enum_var: 'A', paragraph: 'short text' }), - '--stream', - '-o', - 'json', - ]), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(result, 0) - const parsed = assertJson<{ data?: { status?: string } }>(result) - expect(parsed.data?.status).toBe('succeeded') - }) - - // ── Unreachable host in streaming mode (4.2.13) ──────────────────────── - - it('[P0] streaming with unreachable host returns network error (exit code non-zero)', async () => { - // Spec 4.2.13: unreachable host → network error, exit code non-zero - // 127.0.0.1:19999 is a local port with nothing listening — ECONNREFUSED immediately. - const { writeFile, mkdir } = await import('node:fs/promises') - const { join } = await import('node:path') - const networkTmp = await withTempConfig() - try { - await mkdir(networkTmp.configDir, { recursive: true }) - const hostsYml = `${[ - `current_host: http://127.0.0.1:19999`, - `token_storage: file`, - `tokens:`, - ` bearer: dfoa_fake_token_network_test`, - `workspace:`, - ` id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - `available_workspaces:`, - ` - id: ${E.workspaceId}`, - ` name: "E2E Test Workspace"`, - ` role: owner`, - ].join('\n')}\n` - await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['run', 'app', E.chatAppId, 'hello', '--stream'], { - configDir: networkTmp.configDir, - timeout: 15_000, - }) - expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0) - expect(result.stderr.length, 'stderr should contain error message').toBeGreaterThan(0) - } finally { - await networkTmp.cleanup() - } - }) - - // ── Wrong-type input in streaming mode (4.2.15) ──────────────────────── - - it('[P0] streaming with wrong-type input returns validation error (exit code non-zero)', async () => { - // Spec 4.2.15: passing a value of the wrong type triggers server-side validation failure - // The workflow app expects `num` to be a number; passing a string should cause a validation error. - const result = await fx.r([ - 'run', - 'app', - E.workflowAppId, - '--inputs', - JSON.stringify({ x: 'ok', num: 'not-a-number', enum_var: 'A', paragraph: 'short text' }), - '--stream', - ]) - expect(result.exitCode, 'wrong-type input should cause non-zero exit').not.toBe(0) - expect(result.stderr).toMatch(/validation|invalid|type|422|must be/i) - }) - - // ── Non-existent app with positional query (4.2.16) ──────────────────── - - it('[P0] streaming with non-existent app id and query exits 1 with app-not-found error', async () => { - // Spec 4.2.16: non-existent app id + positional query → app not found, exit code 1 - // Distinct from the earlier server-error test: this checks exit=1 precisely and the not-found message. - const result = await fx.r([ - 'run', - 'app', - 'nonexistent-app-id-404-streaming-e2e', - 'hello', - '--stream', - ]) - expect(result.exitCode, 'app not found should exit with code 1').toBe(1) - expect(result.stderr).toMatch(/not.?found|404|does not exist/i) - }) - - // ── SSO (dfoe_) token in streaming mode (4.2.18) ────────────────────── - - itWithSso( - '[P0] streaming with SSO (dfoe_) token succeeds (exit code 0, stdout non-empty)', - async () => { - // Spec 4.2.18: dfoe_ token can invoke streaming run on an authorised app - const ssoTmp = await withTempConfig() - try { - await injectAuth(ssoTmp.configDir, { - host: E.host, - bearer: E.ssoToken, - email: 'sso-e2e@example.com', - workspaceId: E.workspaceId, - workspaceName: E.workspaceName, - }) - const result = await withRetry( - () => - run(['run', 'app', E.chatAppId, 'sso-stream-test', '--stream'], { - configDir: ssoTmp.configDir, - }), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(result, 0) - expect(result.stdout.length, 'SSO streaming should produce output').toBeGreaterThan(0) - } finally { - await ssoTmp.cleanup() - } - }, - ) - - // ── JSON error envelope for non-existent app in -o json mode (4.2.23) ─ - - it('[P1] non-existent app with --stream -o json outputs JSON error envelope on stderr', async () => { - // Spec 4.2.23: when app does not exist and -o json is set, stderr must be a valid JSON error envelope - const result = await fx.r([ - 'run', - 'app', - 'nonexistent-app-id-json-streaming-e2e', - 'hello', - '--stream', - '-o', - 'json', - ]) - expect(result.exitCode, 'should exit non-zero').not.toBe(0) - assertErrorEnvelope(result) - }) -}) diff --git a/cli/test/fixtures/catalog-v2.json b/cli/test/fixtures/catalog-v2.json new file mode 100644 index 00000000000..80815ea680b --- /dev/null +++ b/cli/test/fixtures/catalog-v2.json @@ -0,0 +1,1389 @@ +{ + "ops": { + "describe.account": { + "bind": {}, + "deprecated": false, + "input": { + "properties": {}, + "required": [], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/account", + "summary": "Current account" + }, + "get.account.session": { + "bind": { + "limit": "query", + "page": "query" + }, + "deprecated": false, + "input": { + "properties": { + "limit": { + "default": 100, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "page": { + "default": 1, + "minimum": 1, + "title": "Page", + "type": "integer" + } + }, + "required": [], + "type": "object" + }, + "internal": false, + "kind": "list", + "method": "GET", + "path": "/openapi/v1/account/sessions", + "summary": "List login sessions of the current account" + }, + "delete.account.session": { + "bind": { + "session_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "session_id": { + "type": "string" + } + }, + "required": ["session_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "DELETE", + "path": "/openapi/v1/account/sessions/{session_id}", + "summary": "Revoke one login session by id" + }, + "delete.account.current_session": { + "bind": {}, + "deprecated": false, + "input": { + "properties": {}, + "required": [], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "DELETE", + "path": "/openapi/v1/account/sessions/self", + "summary": "Revoke the session behind this token" + }, + "run.console_app.advanced_chat": { + "bind": { + "app_id": "path", + "attachments": "file", + "auto_generate_name": "body", + "conversation_id": "body", + "files": "file", + "inputs": "body", + "query": "body", + "workflow_id": "body", + "workspace_id": "body" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "type": "string" + }, + "attachments": { + "anyOf": [ + { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Local files attached to the run itself (the app's `sys.files`), not to a variable", + "title": "Attachments" + }, + "auto_generate_name": { + "default": true, + "description": "Let the server name a new conversation", + "title": "Auto Generate Name", + "type": "boolean" + }, + "conversation_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Continue an existing conversation", + "title": "Conversation Id" + }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "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", + "title": "Files" + }, + "inputs": { + "additionalProperties": true, + "description": "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.", + "title": "Inputs", + "type": "object" + }, + "query": { + "description": "User message", + "title": "Query", + "type": "string" + }, + "workflow_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pin a published workflow version", + "title": "Workflow Id" + }, + "workspace_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Workspace that owns the app", + "title": "Workspace Id" + } + }, + "required": ["app_id", "inputs", "query"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/advanced-chat:run", + "summary": "Run an advanced-chat (chatflow) app; streams message and workflow events" + }, + "run.console_app.chat": { + "bind": { + "app_id": "path", + "attachments": "file", + "auto_generate_name": "body", + "conversation_id": "body", + "files": "file", + "inputs": "body", + "query": "body", + "workspace_id": "body" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "type": "string" + }, + "attachments": { + "anyOf": [ + { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Local files attached to the run itself (the app's `sys.files`), not to a variable", + "title": "Attachments" + }, + "auto_generate_name": { + "default": true, + "description": "Let the server name a new conversation", + "title": "Auto Generate Name", + "type": "boolean" + }, + "conversation_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Continue an existing conversation", + "title": "Conversation Id" + }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "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", + "title": "Files" + }, + "inputs": { + "additionalProperties": true, + "description": "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.", + "title": "Inputs", + "type": "object" + }, + "query": { + "description": "User message", + "title": "Query", + "type": "string" + }, + "workspace_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Workspace that owns the app", + "title": "Workspace Id" + } + }, + "required": ["app_id", "inputs", "query"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/chat:run", + "summary": "Run a chat or agent app; streams message events" + }, + "run.console_app.completion": { + "bind": { + "app_id": "path", + "attachments": "file", + "files": "file", + "inputs": "body", + "query": "body", + "workspace_id": "body" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "type": "string" + }, + "attachments": { + "anyOf": [ + { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Local files attached to the run itself (the app's `sys.files`), not to a variable", + "title": "Attachments" + }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "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", + "title": "Files" + }, + "inputs": { + "additionalProperties": true, + "description": "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.", + "title": "Inputs", + "type": "object" + }, + "query": { + "default": "", + "description": "Prompt text; most completion apps take their input through `inputs`", + "title": "Query", + "type": "string" + }, + "workspace_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Workspace that owns the app", + "title": "Workspace Id" + } + }, + "required": ["app_id", "inputs"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/completion:run", + "summary": "Run a completion app; streams message events" + }, + "check.console_app.dependency": { + "bind": { + "app_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "type": "string" + } + }, + "required": ["app_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/apps/{app_id}/dependencies:check", + "summary": "Check plugin dependencies of an app" + }, + "describe.console_app": { + "bind": { + "app_id": "path", + "fields": "query" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "type": "string" + }, + "fields": { + "default": null, + "title": "Fields", + "type": "string" + } + }, + "required": ["app_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/apps/{app_id}", + "summary": "App detail, parameters and runtime input_schema" + }, + "export.console_app.dsl": { + "bind": { + "app_id": "path", + "include_secret": "query", + "workflow_id": "query" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "type": "string" + }, + "include_secret": { + "default": false, + "description": "Include encrypted secret values in the exported DSL", + "title": "Include Secret", + "type": "boolean" + }, + "workflow_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Export a specific workflow version instead of the current draft", + "title": "Workflow Id" + } + }, + "required": ["app_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/apps/{app_id}/dsl", + "summary": "Export app DSL as YAML text inside a JSON object" + }, + "import.console_app.dsl": { + "bind": { + "app_id": "body", + "description": "body", + "icon": "body", + "icon_background": "body", + "icon_type": "body", + "mode": "body", + "name": "body", + "workspace_id": "path", + "yaml_content": "body", + "yaml_url": "body" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Existing app ID to overwrite (workflow/advanced-chat apps only)", + "title": "App Id" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Override the app description from the DSL", + "title": "Description" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Icon" + }, + "icon_background": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Icon Background" + }, + "icon_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Icon Type" + }, + "mode": { + "description": "Import mode: yaml-content or yaml-url", + "enum": ["yaml-content", "yaml-url"], + "title": "Mode", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Override the app name from the DSL", + "title": "Name" + }, + "workspace_id": { + "type": "string" + }, + "yaml_content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Inline YAML DSL string (required when mode is yaml-content)", + "title": "Yaml Content" + }, + "yaml_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Remote URL to fetch YAML from (required when mode is yaml-url)", + "title": "Yaml Url" + } + }, + "required": ["workspace_id", "mode"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/workspaces/{workspace_id}/apps/imports", + "summary": "Import an app from DSL text or URL" + }, + "confirm.console_app.dsl_import": { + "bind": { + "import_id": "path", + "workspace_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "import_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": ["import_id", "workspace_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/workspaces/{workspace_id}/apps/imports/{import_id}:confirm", + "summary": "Confirm a pending DSL import" + }, + "upload.console_app.file": { + "bind": { + "app_id": "path", + "file": "file" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "type": "string" + }, + "file": { + "description": "The file to upload; its id can then be used in an app run's file variables", + "format": "binary", + "title": "File", + "type": "string" + } + }, + "required": ["app_id", "file"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/files", + "summary": "Upload a file and get a file id for later runs" + }, + "get.console_app": { + "bind": { + "limit": "query", + "mode": "query", + "name": "query", + "page": "query", + "workspace_id": "query" + }, + "deprecated": false, + "input": { + "properties": { + "limit": { + "default": 20, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "mode": { + "anyOf": [ + { + "description": "App types the ``app`` usage face (``get app``) lists and filters.\n\nA curated subset of :class:`AppMode`: the real, user-facing app categories.\nExcludes runtime-only mode tags that are not standalone apps\n(``rag-pipeline`` is a knowledge ``Pipeline``; ``channel`` is unused) and the\nroster-owned ``agent`` type (surfaced through the roster, not this list).\n\nMembers reference ``AppMode.*.value`` so the subset relationship is\ntype-checked: dropping a member from ``AppMode`` breaks this at import.\nThis is the single source for the listable set — params, filters, and the\ngenerated CLI whitelist all derive from it.", + "enum": ["completion", "chat", "advanced-chat", "workflow", "agent-chat"], + "title": "SupportedAppType", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "name": { + "anyOf": [ + { + "maxLength": 200, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "page": { + "default": 1, + "minimum": 1, + "title": "Page", + "type": "integer" + }, + "workspace_id": { + "format": "uuid", + "title": "Workspace Id", + "type": "string" + } + }, + "required": ["workspace_id"], + "type": "object" + }, + "internal": false, + "kind": "list", + "method": "GET", + "path": "/openapi/v1/apps", + "summary": "List apps in a workspace" + }, + "legacy_run.console_app": { + "bind": { + "app_id": "path", + "attachments": "file", + "auto_generate_name": "body", + "conversation_id": "body", + "files": "file", + "inputs": "body", + "query": "body", + "workflow_id": "body", + "workspace_id": "body" + }, + "deprecated": true, + "input": { + "properties": { + "app_id": { + "type": "string" + }, + "attachments": { + "anyOf": [ + { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Local files attached to the run itself (the app's `sys.files`), not to a variable", + "title": "Attachments" + }, + "auto_generate_name": { + "default": true, + "description": "Let the server name a new conversation", + "title": "Auto Generate Name", + "type": "boolean" + }, + "conversation_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Continue an existing conversation", + "title": "Conversation Id" + }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "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", + "title": "Files" + }, + "inputs": { + "additionalProperties": true, + "description": "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.", + "title": "Inputs", + "type": "object" + }, + "query": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "User message. Required for chat-family apps, rejected for workflow apps", + "title": "Query" + }, + "workflow_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pin a published workflow version", + "title": "Workflow Id" + }, + "workspace_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Workspace that owns the app", + "title": "Workspace Id" + } + }, + "required": ["app_id", "inputs"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}:legacy_run", + "summary": "Deprecated: use console_app.<mode>.run for the app's mode" + }, + "run.console_app.workflow": { + "bind": { + "app_id": "path", + "attachments": "file", + "files": "file", + "inputs": "body", + "workflow_id": "body", + "workspace_id": "body" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "type": "string" + }, + "attachments": { + "anyOf": [ + { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Local files attached to the run itself (the app's `sys.files`), not to a variable", + "title": "Attachments" + }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "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", + "title": "Files" + }, + "inputs": { + "additionalProperties": true, + "description": "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.", + "title": "Inputs", + "type": "object" + }, + "workflow_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pin a published workflow version", + "title": "Workflow Id" + }, + "workspace_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Workspace that owns the app", + "title": "Workspace Id" + } + }, + "required": ["app_id", "inputs"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/workflow:run", + "summary": "Run a workflow app; streams workflow events" + }, + "get.run.event": { + "bind": { + "app_id": "path", + "continue_on_pause": "query", + "include_state_snapshot": "query", + "task_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "type": "string" + }, + "continue_on_pause": { + "default": false, + "description": "Whether to keep the event stream open on pause", + "title": "Continue On Pause", + "type": "boolean" + }, + "include_state_snapshot": { + "default": false, + "description": "Whether to include workflow state snapshots", + "title": "Include State Snapshot", + "type": "boolean" + }, + "task_id": { + "type": "string" + } + }, + "required": ["app_id", "task_id"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "GET", + "path": "/openapi/v1/apps/{app_id}/tasks/{task_id}/events", + "summary": "Stream the events of a workflow run" + }, + "describe.run.form": { + "bind": { + "app_id": "path", + "form_token": "path" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "type": "string" + }, + "form_token": { + "type": "string" + } + }, + "required": ["app_id", "form_token"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/apps/{app_id}/human-input-forms/{form_token}", + "summary": "Read a human-input form" + }, + "submit.run.form": { + "bind": { + "action": "body", + "app_id": "path", + "files": "file", + "form_token": "path", + "inputs": "body" + }, + "deprecated": false, + "input": { + "properties": { + "action": { + "description": "ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list.", + "title": "Action", + "type": "string" + }, + "app_id": { + "type": "string" + }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Local files keyed by the form's file input name, same convention as the run ops' `files`", + "title": "Files" + }, + "form_token": { + "type": "string" + }, + "inputs": { + "additionalProperties": {}, + "description": "Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`.", + "examples": [ + { + "attachment": { + "transfer_method": "local_file", + "type": "document", + "upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e" + }, + "attachments": [ + { + "transfer_method": "local_file", + "type": "document", + "upload_file_id": "1a77f0df-c0e6-461c-987c-e72526f341ee" + }, + { + "transfer_method": "remote_url", + "type": "document", + "url": "https://example.com/report.pdf" + } + ], + "decision": "approve" + } + ], + "title": "Inputs", + "type": "object" + } + }, + "required": ["app_id", "form_token", "inputs", "action"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/human-input-forms/{form_token}:submit", + "summary": "Submit a human-input form" + }, + "stop.run": { + "bind": { + "app_id": "path", + "task_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": ["app_id", "task_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/tasks/{task_id}:stop", + "summary": "Stop a running task" + }, + "describe.workspace": { + "bind": { + "workspace_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "workspace_id": { + "type": "string" + } + }, + "required": ["workspace_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/workspaces/{workspace_id}", + "summary": "Workspace detail" + }, + "get.workspace": { + "bind": { + "limit": "query", + "page": "query" + }, + "deprecated": false, + "input": { + "properties": { + "limit": { + "default": 20, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "page": { + "default": 1, + "minimum": 1, + "title": "Page", + "type": "integer" + } + }, + "required": [], + "type": "object" + }, + "internal": false, + "kind": "list", + "method": "GET", + "path": "/openapi/v1/workspaces", + "summary": "List workspaces of the current account" + }, + "invite.workspace.member": { + "bind": { + "email": "body", + "role": "body", + "workspace_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "email": { + "title": "Email", + "type": "string" + }, + "role": { + "enum": ["normal", "admin"], + "title": "Role", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": ["workspace_id", "email", "role"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/workspaces/{workspace_id}/members", + "summary": "Invite a member by email" + }, + "get.workspace.member": { + "bind": { + "limit": "query", + "page": "query", + "workspace_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "limit": { + "default": 20, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "page": { + "default": 1, + "minimum": 1, + "title": "Page", + "type": "integer" + }, + "workspace_id": { + "type": "string" + } + }, + "required": ["workspace_id"], + "type": "object" + }, + "internal": false, + "kind": "list", + "method": "GET", + "path": "/openapi/v1/workspaces/{workspace_id}/members", + "summary": "List workspace members" + }, + "delete.workspace.member": { + "bind": { + "member_id": "path", + "workspace_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "member_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": ["member_id", "workspace_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "DELETE", + "path": "/openapi/v1/workspaces/{workspace_id}/members/{member_id}", + "summary": "Remove a member" + }, + "set.workspace.member_role": { + "bind": { + "member_id": "path", + "role": "body", + "workspace_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "member_id": { + "type": "string" + }, + "role": { + "enum": ["normal", "admin"], + "title": "Role", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": ["member_id", "workspace_id", "role"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "PATCH", + "path": "/openapi/v1/workspaces/{workspace_id}/members/{member_id}", + "summary": "Change a member's role" + }, + "switch.workspace": { + "bind": { + "workspace_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "workspace_id": { + "type": "string" + } + }, + "required": ["workspace_id"], + "type": "object" + }, + "internal": true, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/workspaces/{workspace_id}:switch", + "summary": "Server-side current switch workspace (shared with the web console)" + }, + "ping.workspace": { + "bind": { + "workspace_id": "path" + }, + "deprecated": false, + "id": "not.the.op.id", + "input": { + "properties": { + "workspace_id": { + "type": "string" + } + }, + "required": ["workspace_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/workspaces/{workspace_id}/ping", + "summary": "Ping a workspace", + "usage": "not the usage line" + } + } +} diff --git a/cli/test/fixtures/catalog.json b/cli/test/fixtures/catalog.json new file mode 100644 index 00000000000..e9c7b838d90 --- /dev/null +++ b/cli/test/fixtures/catalog.json @@ -0,0 +1,922 @@ +{ + "ops": { + "describe.account": { + "bind": {}, + "deprecated": false, + "input": { "properties": {}, "required": [], "type": "object" }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/account", + "summary": "Current account" + }, + "get.account.session": { + "bind": { "limit": "query", "page": "query" }, + "deprecated": false, + "input": { + "properties": { + "limit": { + "default": 100, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "page": { "default": 1, "minimum": 1, "title": "Page", "type": "integer" } + }, + "required": [], + "type": "object" + }, + "internal": false, + "kind": "list", + "method": "GET", + "path": "/openapi/v1/account/sessions", + "summary": "List login sessions of the current account" + }, + "delete.account.session": { + "bind": { "session_id": "path" }, + "deprecated": false, + "input": { + "properties": { "session_id": { "type": "string" } }, + "required": ["session_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "DELETE", + "path": "/openapi/v1/account/sessions/{session_id}", + "summary": "Revoke one login session by id" + }, + "delete.account.current_session": { + "bind": {}, + "deprecated": false, + "input": { "properties": {}, "required": [], "type": "object" }, + "internal": false, + "kind": "object", + "method": "DELETE", + "path": "/openapi/v1/account/sessions/self", + "summary": "Revoke the session behind this token" + }, + "run.console_app.advanced_chat": { + "bind": { + "app_id": "path", + "attachments": "file", + "auto_generate_name": "body", + "conversation_id": "body", + "files": "file", + "inputs": "body", + "query": "body", + "workflow_id": "body", + "workspace_id": "body" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { "type": "string" }, + "attachments": { + "anyOf": [ + { "items": { "format": "binary", "type": "string" }, "type": "array" }, + { "type": "null" } + ], + "default": null, + "description": "Local files attached to the run itself (the app's `sys.files`), not to a variable", + "title": "Attachments" + }, + "auto_generate_name": { + "default": true, + "description": "Let the server name a new conversation", + "title": "Auto Generate Name", + "type": "boolean" + }, + "conversation_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Continue an existing conversation", + "title": "Conversation Id" + }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { "format": "binary", "type": "string" }, + { "items": { "format": "binary", "type": "string" }, "type": "array" } + ] + }, + "type": "object" + }, + { "type": "null" } + ], + "default": null, + "description": "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", + "title": "Files" + }, + "inputs": { + "additionalProperties": true, + "description": "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.", + "title": "Inputs", + "type": "object" + }, + "query": { "description": "User message", "title": "Query", "type": "string" }, + "workflow_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Pin a published workflow version", + "title": "Workflow Id" + }, + "workspace_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Workspace that owns the app", + "title": "Workspace Id" + } + }, + "required": ["app_id", "inputs", "query"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/advanced-chat:run", + "summary": "Run an advanced-chat (chatflow) app; streams message and workflow events" + }, + "run.console_app.chat": { + "bind": { + "app_id": "path", + "attachments": "file", + "auto_generate_name": "body", + "conversation_id": "body", + "files": "file", + "inputs": "body", + "query": "body", + "workspace_id": "body" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { "type": "string" }, + "attachments": { + "anyOf": [ + { "items": { "format": "binary", "type": "string" }, "type": "array" }, + { "type": "null" } + ], + "default": null, + "description": "Local files attached to the run itself (the app's `sys.files`), not to a variable", + "title": "Attachments" + }, + "auto_generate_name": { + "default": true, + "description": "Let the server name a new conversation", + "title": "Auto Generate Name", + "type": "boolean" + }, + "conversation_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Continue an existing conversation", + "title": "Conversation Id" + }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { "format": "binary", "type": "string" }, + { "items": { "format": "binary", "type": "string" }, "type": "array" } + ] + }, + "type": "object" + }, + { "type": "null" } + ], + "default": null, + "description": "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", + "title": "Files" + }, + "inputs": { + "additionalProperties": true, + "description": "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.", + "title": "Inputs", + "type": "object" + }, + "query": { "description": "User message", "title": "Query", "type": "string" }, + "workspace_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Workspace that owns the app", + "title": "Workspace Id" + } + }, + "required": ["app_id", "inputs", "query"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/chat:run", + "summary": "Run a chat or agent app; streams message events" + }, + "run.console_app.completion": { + "bind": { + "app_id": "path", + "attachments": "file", + "files": "file", + "inputs": "body", + "query": "body", + "workspace_id": "body" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { "type": "string" }, + "attachments": { + "anyOf": [ + { "items": { "format": "binary", "type": "string" }, "type": "array" }, + { "type": "null" } + ], + "default": null, + "description": "Local files attached to the run itself (the app's `sys.files`), not to a variable", + "title": "Attachments" + }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { "format": "binary", "type": "string" }, + { "items": { "format": "binary", "type": "string" }, "type": "array" } + ] + }, + "type": "object" + }, + { "type": "null" } + ], + "default": null, + "description": "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", + "title": "Files" + }, + "inputs": { + "additionalProperties": true, + "description": "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.", + "title": "Inputs", + "type": "object" + }, + "query": { + "default": "", + "description": "Prompt text; most completion apps take their input through `inputs`", + "title": "Query", + "type": "string" + }, + "workspace_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Workspace that owns the app", + "title": "Workspace Id" + } + }, + "required": ["app_id", "inputs"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/completion:run", + "summary": "Run a completion app; streams message events" + }, + "check.console_app.dependency": { + "bind": { "app_id": "path" }, + "deprecated": false, + "input": { + "properties": { "app_id": { "type": "string" } }, + "required": ["app_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/apps/{app_id}/dependencies:check", + "summary": "Check plugin dependencies of an app" + }, + "describe.console_app": { + "bind": { "app_id": "path", "fields": "query" }, + "deprecated": false, + "input": { + "properties": { + "app_id": { "type": "string" }, + "fields": { "default": null, "title": "Fields", "type": "string" } + }, + "required": ["app_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/apps/{app_id}", + "summary": "App detail, parameters and runtime input_schema" + }, + "export.console_app.dsl": { + "bind": { "app_id": "path", "include_secret": "query", "workflow_id": "query" }, + "deprecated": false, + "input": { + "properties": { + "app_id": { "type": "string" }, + "include_secret": { + "default": false, + "description": "Include encrypted secret values in the exported DSL", + "title": "Include Secret", + "type": "boolean" + }, + "workflow_id": { + "anyOf": [{ "format": "uuid", "type": "string" }, { "type": "null" }], + "default": null, + "description": "Export a specific workflow version instead of the current draft", + "title": "Workflow Id" + } + }, + "required": ["app_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/apps/{app_id}/dsl", + "summary": "Export app DSL as YAML text inside a JSON object" + }, + "import.console_app.dsl": { + "bind": { + "app_id": "body", + "description": "body", + "icon": "body", + "icon_background": "body", + "icon_type": "body", + "mode": "body", + "name": "body", + "workspace_id": "path", + "yaml_content": "body", + "yaml_url": "body" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Existing app ID to overwrite (workflow/advanced-chat apps only)", + "title": "App Id" + }, + "description": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Override the app description from the DSL", + "title": "Description" + }, + "icon": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "title": "Icon" + }, + "icon_background": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "title": "Icon Background" + }, + "icon_type": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "title": "Icon Type" + }, + "mode": { + "description": "Import mode: yaml-content or yaml-url", + "enum": ["yaml-content", "yaml-url"], + "title": "Mode", + "type": "string" + }, + "name": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Override the app name from the DSL", + "title": "Name" + }, + "workspace_id": { "type": "string" }, + "yaml_content": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Inline YAML DSL string (required when mode is yaml-content)", + "title": "Yaml Content" + }, + "yaml_url": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Remote URL to fetch YAML from (required when mode is yaml-url)", + "title": "Yaml Url" + } + }, + "required": ["workspace_id", "mode"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/workspaces/{workspace_id}/apps/imports", + "summary": "Import an app from DSL text or URL" + }, + "confirm.console_app.dsl_import": { + "bind": { "import_id": "path", "workspace_id": "path" }, + "deprecated": false, + "input": { + "properties": { "import_id": { "type": "string" }, "workspace_id": { "type": "string" } }, + "required": ["import_id", "workspace_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/workspaces/{workspace_id}/apps/imports/{import_id}:confirm", + "summary": "Confirm a pending DSL import" + }, + "upload.console_app.file": { + "bind": { "app_id": "path", "file": "file" }, + "deprecated": false, + "input": { + "properties": { + "app_id": { "type": "string" }, + "file": { + "description": "The file to upload; its id can then be used in an app run's file variables", + "format": "binary", + "title": "File", + "type": "string" + } + }, + "required": ["app_id", "file"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/files", + "summary": "Upload a file and get a file id for later runs" + }, + "get.console_app": { + "bind": { + "limit": "query", + "mode": "query", + "name": "query", + "page": "query", + "workspace_id": "query" + }, + "deprecated": false, + "input": { + "properties": { + "limit": { + "default": 20, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "mode": { + "anyOf": [ + { + "description": "App types the ``app`` usage face (``get app``) lists and filters.\n\nA curated subset of :class:`AppMode`: the real, user-facing app categories.\nExcludes runtime-only mode tags that are not standalone apps\n(``rag-pipeline`` is a knowledge ``Pipeline``; ``channel`` is unused) and the\nroster-owned ``agent`` type (surfaced through the roster, not this list).\n\nMembers reference ``AppMode.*.value`` so the subset relationship is\ntype-checked: dropping a member from ``AppMode`` breaks this at import.\nThis is the single source for the listable set — params, filters, and the\ngenerated CLI whitelist all derive from it.", + "enum": ["completion", "chat", "advanced-chat", "workflow", "agent-chat"], + "title": "SupportedAppType", + "type": "string" + }, + { "type": "null" } + ], + "default": null + }, + "name": { + "anyOf": [{ "maxLength": 200, "type": "string" }, { "type": "null" }], + "default": null, + "title": "Name" + }, + "page": { "default": 1, "minimum": 1, "title": "Page", "type": "integer" }, + "workspace_id": { "format": "uuid", "title": "Workspace Id", "type": "string" } + }, + "required": ["workspace_id"], + "type": "object" + }, + "internal": false, + "kind": "list", + "method": "GET", + "path": "/openapi/v1/apps", + "summary": "List apps in a workspace" + }, + "legacy_run.console_app": { + "bind": { + "app_id": "path", + "attachments": "file", + "auto_generate_name": "body", + "conversation_id": "body", + "files": "file", + "inputs": "body", + "query": "body", + "workflow_id": "body", + "workspace_id": "body" + }, + "deprecated": true, + "input": { + "properties": { + "app_id": { "type": "string" }, + "attachments": { + "anyOf": [ + { "items": { "format": "binary", "type": "string" }, "type": "array" }, + { "type": "null" } + ], + "default": null, + "description": "Local files attached to the run itself (the app's `sys.files`), not to a variable", + "title": "Attachments" + }, + "auto_generate_name": { + "default": true, + "description": "Let the server name a new conversation", + "title": "Auto Generate Name", + "type": "boolean" + }, + "conversation_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Continue an existing conversation", + "title": "Conversation Id" + }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { "format": "binary", "type": "string" }, + { "items": { "format": "binary", "type": "string" }, "type": "array" } + ] + }, + "type": "object" + }, + { "type": "null" } + ], + "default": null, + "description": "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", + "title": "Files" + }, + "inputs": { + "additionalProperties": true, + "description": "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.", + "title": "Inputs", + "type": "object" + }, + "query": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "User message. Required for chat-family apps, rejected for workflow apps", + "title": "Query" + }, + "workflow_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Pin a published workflow version", + "title": "Workflow Id" + }, + "workspace_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Workspace that owns the app", + "title": "Workspace Id" + } + }, + "required": ["app_id", "inputs"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}:legacy_run", + "summary": "Deprecated: use console_app.<mode>.run for the app's mode" + }, + "run.console_app.workflow": { + "bind": { + "app_id": "path", + "attachments": "file", + "files": "file", + "inputs": "body", + "workflow_id": "body", + "workspace_id": "body" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { "type": "string" }, + "attachments": { + "anyOf": [ + { "items": { "format": "binary", "type": "string" }, "type": "array" }, + { "type": "null" } + ], + "default": null, + "description": "Local files attached to the run itself (the app's `sys.files`), not to a variable", + "title": "Attachments" + }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { "format": "binary", "type": "string" }, + { "items": { "format": "binary", "type": "string" }, "type": "array" } + ] + }, + "type": "object" + }, + { "type": "null" } + ], + "default": null, + "description": "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", + "title": "Files" + }, + "inputs": { + "additionalProperties": true, + "description": "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.", + "title": "Inputs", + "type": "object" + }, + "workflow_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Pin a published workflow version", + "title": "Workflow Id" + }, + "workspace_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "default": null, + "description": "Workspace that owns the app", + "title": "Workspace Id" + } + }, + "required": ["app_id", "inputs"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/workflow:run", + "summary": "Run a workflow app; streams workflow events" + }, + "get.run.event": { + "bind": { + "app_id": "path", + "continue_on_pause": "query", + "include_state_snapshot": "query", + "task_id": "path" + }, + "deprecated": false, + "input": { + "properties": { + "app_id": { "type": "string" }, + "continue_on_pause": { + "default": false, + "description": "Whether to keep the event stream open on pause", + "title": "Continue On Pause", + "type": "boolean" + }, + "include_state_snapshot": { + "default": false, + "description": "Whether to include workflow state snapshots", + "title": "Include State Snapshot", + "type": "boolean" + }, + "task_id": { "type": "string" } + }, + "required": ["app_id", "task_id"], + "type": "object" + }, + "internal": false, + "kind": "sse", + "method": "GET", + "path": "/openapi/v1/apps/{app_id}/tasks/{task_id}/events", + "summary": "Stream the events of a workflow run" + }, + "describe.run.form": { + "bind": { "app_id": "path", "form_token": "path" }, + "deprecated": false, + "input": { + "properties": { "app_id": { "type": "string" }, "form_token": { "type": "string" } }, + "required": ["app_id", "form_token"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/apps/{app_id}/human-input-forms/{form_token}", + "summary": "Read a human-input form" + }, + "submit.run.form": { + "bind": { + "action": "body", + "app_id": "path", + "files": "file", + "form_token": "path", + "inputs": "body" + }, + "deprecated": false, + "input": { + "properties": { + "action": { + "description": "ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list.", + "title": "Action", + "type": "string" + }, + "app_id": { "type": "string" }, + "files": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { "format": "binary", "type": "string" }, + { "items": { "format": "binary", "type": "string" }, "type": "array" } + ] + }, + "type": "object" + }, + { "type": "null" } + ], + "default": null, + "description": "Local files keyed by the form's file input name, same convention as the run ops' `files`", + "title": "Files" + }, + "form_token": { "type": "string" }, + "inputs": { + "additionalProperties": {}, + "description": "Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`.", + "examples": [ + { + "attachment": { + "transfer_method": "local_file", + "type": "document", + "upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e" + }, + "attachments": [ + { + "transfer_method": "local_file", + "type": "document", + "upload_file_id": "1a77f0df-c0e6-461c-987c-e72526f341ee" + }, + { + "transfer_method": "remote_url", + "type": "document", + "url": "https://example.com/report.pdf" + } + ], + "decision": "approve" + } + ], + "title": "Inputs", + "type": "object" + } + }, + "required": ["app_id", "form_token", "inputs", "action"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/human-input-forms/{form_token}:submit", + "summary": "Submit a human-input form" + }, + "stop.run": { + "bind": { "app_id": "path", "task_id": "path" }, + "deprecated": false, + "input": { + "properties": { "app_id": { "type": "string" }, "task_id": { "type": "string" } }, + "required": ["app_id", "task_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/apps/{app_id}/tasks/{task_id}:stop", + "summary": "Stop a running task" + }, + "describe.workspace": { + "bind": { "workspace_id": "path" }, + "deprecated": false, + "input": { + "properties": { "workspace_id": { "type": "string" } }, + "required": ["workspace_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "GET", + "path": "/openapi/v1/workspaces/{workspace_id}", + "summary": "Workspace detail" + }, + "get.workspace": { + "bind": { "limit": "query", "page": "query" }, + "deprecated": false, + "input": { + "properties": { + "limit": { + "default": 20, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "page": { "default": 1, "minimum": 1, "title": "Page", "type": "integer" } + }, + "required": [], + "type": "object" + }, + "internal": false, + "kind": "list", + "method": "GET", + "path": "/openapi/v1/workspaces", + "summary": "List workspaces of the current account" + }, + "invite.workspace.member": { + "bind": { "email": "body", "role": "body", "workspace_id": "path" }, + "deprecated": false, + "input": { + "properties": { + "email": { "title": "Email", "type": "string" }, + "role": { "enum": ["normal", "admin"], "title": "Role", "type": "string" }, + "workspace_id": { "type": "string" } + }, + "required": ["workspace_id", "email", "role"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/workspaces/{workspace_id}/members", + "summary": "Invite a member by email" + }, + "get.workspace.member": { + "bind": { "limit": "query", "page": "query", "workspace_id": "path" }, + "deprecated": false, + "input": { + "properties": { + "limit": { + "default": 20, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "page": { "default": 1, "minimum": 1, "title": "Page", "type": "integer" }, + "workspace_id": { "type": "string" } + }, + "required": ["workspace_id"], + "type": "object" + }, + "internal": false, + "kind": "list", + "method": "GET", + "path": "/openapi/v1/workspaces/{workspace_id}/members", + "summary": "List workspace members" + }, + "delete.workspace.member": { + "bind": { "member_id": "path", "workspace_id": "path" }, + "deprecated": false, + "input": { + "properties": { "member_id": { "type": "string" }, "workspace_id": { "type": "string" } }, + "required": ["member_id", "workspace_id"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "DELETE", + "path": "/openapi/v1/workspaces/{workspace_id}/members/{member_id}", + "summary": "Remove a member" + }, + "set.workspace.member_role": { + "bind": { "member_id": "path", "role": "body", "workspace_id": "path" }, + "deprecated": false, + "input": { + "properties": { + "member_id": { "type": "string" }, + "role": { "enum": ["normal", "admin"], "title": "Role", "type": "string" }, + "workspace_id": { "type": "string" } + }, + "required": ["member_id", "workspace_id", "role"], + "type": "object" + }, + "internal": false, + "kind": "object", + "method": "PATCH", + "path": "/openapi/v1/workspaces/{workspace_id}/members/{member_id}", + "summary": "Change a member's role" + }, + "switch.workspace": { + "bind": { "workspace_id": "path" }, + "deprecated": false, + "input": { + "properties": { "workspace_id": { "type": "string" } }, + "required": ["workspace_id"], + "type": "object" + }, + "internal": true, + "kind": "object", + "method": "POST", + "path": "/openapi/v1/workspaces/{workspace_id}:switch", + "summary": "Server-side current switch workspace (shared with the web console)" + } + } +} diff --git a/cli/test/fixtures/dify-mock/scenarios.ts b/cli/test/fixtures/dify-mock/scenarios.ts index 667e2d0d0d5..41778eae37e 100644 --- a/cli/test/fixtures/dify-mock/scenarios.ts +++ b/cli/test/fixtures/dify-mock/scenarios.ts @@ -9,16 +9,18 @@ export type Scenario = | 'server-5xx' | 'slow-down' | 'stream-error' + | 'slow-stream' + | 'broken-stream' | 'hitl-pause' | 'hitl-resume' - | 'server-version-empty' - | 'server-version-unsupported' - | 'run-422-stale' - | 'workflow-think' - | 'chat-reasoning' - | 'workflow-reasoning' + | 'catalog-changed' + | 'no-catalog' + | 'workspaces-malformed' + | 'always-stale' | 'import-pending' | 'import-failed' + | 'no-device-flow' + | 'bad-verification-uri' export type AccountFixture = { id: string @@ -69,19 +71,19 @@ export const ACCOUNT: AccountFixture = { email: 'tester@dify.ai', name: 'Test Tester', is_external: false, - current_workspace_id: '550e8400-e29b-41d4-a716-446655440000', + current_workspace_id: 'ws-1', } export const WORKSPACES: WorkspaceFixture[] = [ { - id: '550e8400-e29b-41d4-a716-446655440000', + id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', is_current: true, }, { - id: '550e8400-e29b-41d4-a716-446655440001', + id: 'ws-2', name: 'Other', role: 'normal', status: 'normal', @@ -92,7 +94,7 @@ export const WORKSPACES: WorkspaceFixture[] = [ export const APPS: AppFixture[] = [ { id: 'app-1', - workspace_id: '550e8400-e29b-41d4-a716-446655440000', + workspace_id: 'ws-1', workspace_name: 'Default', name: 'Greeter', mode: 'chat', @@ -115,7 +117,7 @@ export const APPS: AppFixture[] = [ }, { id: 'app-4', - workspace_id: '550e8400-e29b-41d4-a716-446655440001', + workspace_id: 'ws-2', workspace_name: 'Other', name: 'Researcher', mode: 'agent-chat', @@ -130,7 +132,7 @@ export const APPS: AppFixture[] = [ }, { id: 'app-2', - workspace_id: '550e8400-e29b-41d4-a716-446655440000', + workspace_id: 'ws-1', workspace_name: 'Default', name: 'Workflow', mode: 'workflow', @@ -144,7 +146,7 @@ export const APPS: AppFixture[] = [ }, { id: 'app-3', - workspace_id: '550e8400-e29b-41d4-a716-446655440001', + workspace_id: 'ws-2', workspace_name: 'Other', name: 'OtherWS Bot', mode: 'chat', diff --git a/cli/test/fixtures/dify-mock/server.test.ts b/cli/test/fixtures/dify-mock/server.test.ts index 1c0cabe3130..5eabcc5539b 100644 --- a/cli/test/fixtures/dify-mock/server.test.ts +++ b/cli/test/fixtures/dify-mock/server.test.ts @@ -1,7 +1,18 @@ import type { DifyMock } from './server.js' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' +import { CATALOG_HEADER, CATALOG_PATH } from '@/plugins/catalog' import { startMock } from './server.js' +const CATALOG_FIXTURE = readFileSync(join(__dirname, '../catalog.json')) +const CATALOG_V2_FIXTURE = readFileSync(join(__dirname, '../catalog-v2.json')) +const CATALOG_FINGERPRINT = createHash('sha256').update(CATALOG_FIXTURE).digest('hex') +const CATALOG_V2_FINGERPRINT = createHash('sha256').update(CATALOG_V2_FIXTURE).digest('hex') + +const AUTH_HEADERS = { Authorization: 'Bearer dfoa_test', [CATALOG_HEADER]: CATALOG_FINGERPRINT } + describe('dify-mock fixture server', () => { let mock: DifyMock @@ -37,23 +48,19 @@ describe('dify-mock fixture server', () => { }) it('accepts dfoa_ tokens (community/account)', async () => { - const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) + const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { headers: AUTH_HEADERS }) expect(r.status).toBe(200) }) it('accepts dfoe_ tokens (enterprise/external-subject)', async () => { const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { - headers: { Authorization: 'Bearer dfoe_test' }, + headers: { ...AUTH_HEADERS, Authorization: 'Bearer dfoe_test' }, }) expect(r.status).toBe(200) }) it('GET /openapi/v1/workspaces returns the seeded list with status + current', async () => { - const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) + const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { headers: AUTH_HEADERS }) expect(r.status).toBe(200) const body = (await r.json()) as { data: Array<{ @@ -63,28 +70,26 @@ describe('dify-mock fixture server', () => { status: string current: boolean }> + hints: unknown[] } expect(body.data).toHaveLength(2) - expect(body.data[0]?.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(body.data[0]?.id).toBe('ws-1') expect(body.data[0]?.status).toBe('normal') expect(body.data[0]?.current).toBe(true) expect(body.data[1]?.current).toBe(false) + expect(body.hints).toEqual([]) }) it('GET /openapi/v1/workspaces returns empty list under sso scenario', async () => { mock.setScenario('sso') - const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) + const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { headers: AUTH_HEADERS }) expect(r.status).toBe(200) const body = (await r.json()) as { data: unknown[] } expect(body.data).toHaveLength(0) }) it('GET /openapi/v1/account returns the seeded account envelope', async () => { - const r = await fetch(`${mock.url}/openapi/v1/account`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) + const r = await fetch(`${mock.url}/openapi/v1/account`, { headers: AUTH_HEADERS }) expect(r.status).toBe(200) const body = (await r.json()) as { subject_type: string @@ -95,16 +100,13 @@ describe('dify-mock fixture server', () => { expect(body.subject_type).toBe('account') expect(body.account?.email).toBe('tester@dify.ai') expect(body.workspaces).toHaveLength(2) - expect(body.default_workspace_id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(body.default_workspace_id).toBe('ws-1') }) it('GET /openapi/v1/apps respects ?mode filter', async () => { - const r = await fetch( - `${mock.url}/openapi/v1/apps?workspace_id=550e8400-e29b-41d4-a716-446655440000&mode=workflow`, - { - headers: { Authorization: 'Bearer dfoa_test' }, - }, - ) + const r = await fetch(`${mock.url}/openapi/v1/apps?workspace_id=ws-1&mode=workflow`, { + headers: AUTH_HEADERS, + }) const body = (await r.json()) as { data: Array<{ mode: string }>; total: number } expect(body.data).toHaveLength(1) expect(body.data[0]?.mode).toBe('workflow') @@ -112,61 +114,64 @@ describe('dify-mock fixture server', () => { }) it('GET /openapi/v1/apps scopes by workspace_id', async () => { - const r = await fetch( - `${mock.url}/openapi/v1/apps?workspace_id=550e8400-e29b-41d4-a716-446655440001`, - { - headers: { Authorization: 'Bearer dfoa_test' }, - }, - ) + const r = await fetch(`${mock.url}/openapi/v1/apps?workspace_id=ws-2`, { + headers: AUTH_HEADERS, + }) const body = (await r.json()) as { data: Array<{ id: string }> } expect(body.data).toHaveLength(2) expect(body.data.map((r) => r.id).sort()).toEqual(['app-3', 'app-4']) }) - it('GET /openapi/v1/apps/:id returns 404 for unknown id', async () => { - const r = await fetch( - `${mock.url}/openapi/v1/apps/nope?workspace_id=550e8400-e29b-41d4-a716-446655440000`, + it('GET /openapi/v1/apps paginates with a "Next page" hint when has_more', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps?workspace_id=ws-1&limit=1`, { + headers: AUTH_HEADERS, + }) + const body = (await r.json()) as { has_more: boolean; hints: Array<Record<string, unknown>> } + expect(body.has_more).toBe(true) + expect(body.hints).toEqual([ { - headers: { Authorization: 'Bearer dfoa_test' }, + summary: 'Next page', + op: 'get.console_app', + input: { workspace_id: 'ws-1', page: 2, limit: 1 }, }, - ) + ]) + }) + + it('GET /openapi/v1/apps/:id returns 404 for unknown id', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps/nope?workspace_id=ws-1`, { + headers: AUTH_HEADERS, + }) expect(r.status).toBe(404) + const body = (await r.json()) as { code: string; message: string; status: number } + expect(body).toEqual({ code: 'not_found', message: 'app not found', status: 404 }) }) it('GET /openapi/v1/apps/:id returns the app for known id', async () => { - const r = await fetch( - `${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000`, - { - headers: { Authorization: 'Bearer dfoa_test' }, - }, - ) + const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=ws-1`, { + headers: AUTH_HEADERS, + }) expect(r.status).toBe(200) const body = (await r.json()) as { info: { id: string } } expect(body.info.id).toBe('app-1') }) - it('POST /openapi/v1/apps/:id:run returns SSE stream for chat app', async () => { - const r = await fetch(`${mock.url}/openapi/v1/apps/app-1:run`, { + it('POST /openapi/v1/apps/:id:legacy_run returns SSE stream for chat app', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps/app-1:legacy_run`, { method: 'POST', - headers: { - Authorization: 'Bearer dfoa_test', - 'Content-Type': 'application/json', - }, + headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: 'hi', inputs: {} }), }) expect(r.status).toBe(200) expect(r.headers.get('content-type')).toContain('text/event-stream') const text = await r.text() expect(text).toContain('"answer":"echo: "') + expect(text).toContain('"op":"run.console_app.chat"') }) - it('POST /openapi/v1/apps/:id:run returns SSE stream for workflow app', async () => { - const r = await fetch(`${mock.url}/openapi/v1/apps/app-2:run`, { + it('POST /openapi/v1/apps/:id:legacy_run returns SSE stream for workflow app', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps/app-2:legacy_run`, { method: 'POST', - headers: { - Authorization: 'Bearer dfoa_test', - 'Content-Type': 'application/json', - }, + headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ inputs: { x: 1 } }), }) expect(r.status).toBe(200) @@ -175,13 +180,55 @@ describe('dify-mock fixture server', () => { expect(text).toContain('"workflow_finished"') }) + it('POST /openapi/v1/apps/:id/workflow:run runs the workflow app by mode-specific route', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps/app-2/workflow:run`, { + method: 'POST', + headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, + body: JSON.stringify({ inputs: { x: 1 } }), + }) + expect(r.status).toBe(200) + const text = await r.text() + expect(text).toContain('"workflow_finished"') + expect(mock.lastRunBody).toEqual({ inputs: { x: 1 } }) + }) + + it('POST /openapi/v1/apps/:id/chat:run runs the chat app by mode-specific route', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/chat:run`, { + method: 'POST', + headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: 'hi', inputs: {} }), + }) + expect(r.status).toBe(200) + const text = await r.text() + expect(text).toContain('"answer":"echo: "') + }) + + it('POST /openapi/v1/apps/:id/chat:run accepts multipart bodies and records the parts', async () => { + const form = new FormData() + form.set('query', JSON.stringify('hi')) + form.set('inputs', JSON.stringify({})) + form.set('files[avatar]', new File(['x'], 'avatar.png', { type: 'image/png' })) + form.append('attachments[]', new File(['a'], 'a.txt')) + form.append('attachments[]', new File(['b'], 'b.txt')) + const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/chat:run`, { + method: 'POST', + headers: AUTH_HEADERS, + body: form, + }) + expect(r.status).toBe(200) + expect(mock.lastRunBody).toEqual({ query: 'hi', inputs: {} }) + const parts = mock.lastRunParts + expect(parts?.query).toBe('"hi"') + const avatar = parts?.['files[avatar]'] + expect(Array.isArray(avatar) && avatar[0]?.name).toBe('avatar.png') + const attachments = parts?.['attachments[]'] + expect(Array.isArray(attachments) && attachments.length).toBe(2) + }) + it('GET /openapi/v1/apps/:id?fields=info returns slim payload', async () => { - const r = await fetch( - `${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000&fields=info`, - { - headers: { Authorization: 'Bearer dfoa_test' }, - }, - ) + const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=ws-1&fields=info`, { + headers: AUTH_HEADERS, + }) expect(r.status).toBe(200) const body = (await r.json()) as { info: { id: string } @@ -194,12 +241,9 @@ describe('dify-mock fixture server', () => { }) it('GET /openapi/v1/apps/:id full returns parameters when present', async () => { - const r = await fetch( - `${mock.url}/openapi/v1/apps/app-1?workspace_id=550e8400-e29b-41d4-a716-446655440000`, - { - headers: { Authorization: 'Bearer dfoa_test' }, - }, - ) + const r = await fetch(`${mock.url}/openapi/v1/apps/app-1?workspace_id=ws-1`, { + headers: AUTH_HEADERS, + }) expect(r.status).toBe(200) const body = (await r.json()) as { parameters: { opening_statement: string } | null } expect(body.parameters?.opening_statement).toBe('Hi, I am Greeter.') @@ -287,26 +331,95 @@ describe('dify-mock fixture server', () => { it('scenario:auth-expired returns 401 on bearer-protected endpoint', async () => { mock.setScenario('auth-expired') - const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) + const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { headers: AUTH_HEADERS }) expect(r.status).toBe(401) + const body = (await r.json()) as { code: string } + expect(body.code).toBe('unauthorized') }) it('scenario:rate-limited returns 429 with retry-after', async () => { mock.setScenario('rate-limited') - const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) + const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { headers: AUTH_HEADERS }) expect(r.status).toBe(429) expect(r.headers.get('retry-after')).toBe('1') + const body = (await r.json()) as { code: string; status: number } + expect(body).toMatchObject({ code: 'rate_limited', status: 429 }) }) it('scenario:server-5xx returns 503', async () => { mock.setScenario('server-5xx') - const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { - headers: { Authorization: 'Bearer dfoa_test' }, - }) + const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { headers: AUTH_HEADERS }) expect(r.status).toBe(503) + const body = (await r.json()) as { code: string; status: number } + expect(body).toMatchObject({ code: 'server_5xx', status: 503 }) + }) + + it('GET _catalog serves the catalog bytes and its own fingerprint header, no auth required', async () => { + const r = await fetch(`${mock.url}${CATALOG_PATH}`) + expect(r.status).toBe(200) + expect(new Uint8Array(await r.arrayBuffer())).toEqual(new Uint8Array(CATALOG_FIXTURE)) + expect(r.headers.get(CATALOG_HEADER.toLowerCase())).toBe(CATALOG_FINGERPRINT) + }) + + it('scenario:catalog-changed serves catalog-v2 bytes and fingerprint', async () => { + mock.setScenario('catalog-changed') + const r = await fetch(`${mock.url}${CATALOG_PATH}`) + expect(r.status).toBe(200) + expect(new Uint8Array(await r.arrayBuffer())).toEqual(new Uint8Array(CATALOG_V2_FIXTURE)) + expect(r.headers.get(CATALOG_HEADER.toLowerCase())).toBe(CATALOG_V2_FINGERPRINT) + }) + + it('scenario:no-catalog returns 404 for _catalog', async () => { + mock.setScenario('no-catalog') + const r = await fetch(`${mock.url}${CATALOG_PATH}`) + expect(r.status).toBe(404) + }) + + it('every response carries the current catalog fingerprint header', async () => { + const r = await fetch(`${mock.url}/openapi/v1/account`, { headers: AUTH_HEADERS }) + expect(r.headers.get(CATALOG_HEADER.toLowerCase())).toBe(CATALOG_FINGERPRINT) + }) + + it('rejects a stale X-Dify-Catalog header with 412', async () => { + const r = await fetch(`${mock.url}/openapi/v1/account`, { + headers: { Authorization: 'Bearer dfoa_test', [CATALOG_HEADER]: 'deadbeef' }, + }) + expect(r.status).toBe(412) + const body = (await r.json()) as { code: string; message: string; status: number; hint: string } + expect(body).toEqual({ + code: 'catalog_stale', + message: "The request was built from a catalog that is not this server's current catalog.", + status: 412, + hint: 'GET /openapi/v1/_catalog, rebuild the request from it, and send its sha256 in X-Dify-Catalog.', + }) + }) + + it('scenario:always-stale rejects even a fresh header', async () => { + mock.setScenario('always-stale') + const r = await fetch(`${mock.url}/openapi/v1/account`, { headers: AUTH_HEADERS }) + expect(r.status).toBe(412) + }) + + it('_version and oauth/* are exempt from the catalog gate', async () => { + const version = await fetch(`${mock.url}/openapi/v1/_version`) + expect(version.status).toBe(200) + const device = await fetch(`${mock.url}/openapi/v1/oauth/device/code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(device.status).toBe(200) + }) + + it('tracks lastRequest (with query string) and requestCount across requests', async () => { + expect(mock.requestCount).toBe(0) + await fetch(`${mock.url}${CATALOG_PATH}`) + await fetch(`${mock.url}/openapi/v1/apps?limit=1&workspace_id=ws-1`, { headers: AUTH_HEADERS }) + expect(mock.requestCount).toBe(2) + expect(mock.lastRequest).toMatchObject({ + method: 'GET', + path: '/openapi/v1/apps?limit=1&workspace_id=ws-1', + }) + expect(mock.lastRequest?.headers.authorization).toBe('Bearer dfoa_test') }) }) diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index 47c81186b3f..a9f172b64c8 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -1,7 +1,17 @@ +import type { Context as HonoContext } from 'hono' +import type { ServerResponse } from 'node:http' import type { AddressInfo } from 'node:net' -import type { Scenario } from './scenarios.js' +import type { AppFixture, Scenario } from './scenarios.js' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { serve } from '@hono/node-server' +import { RESPONSE_ALREADY_SENT } from '@hono/node-server/utils/response' import { Hono } from 'hono' +import { matchedRoutes } from 'hono/route' +import { METHOD_NAME_ALL } from 'hono/router' +import { CATALOG_HEADER, CATALOG_PATH } from '@/plugins/catalog' import { ACCOUNT, APPS, DSL_YAML, SESSIONS, WORKSPACES } from './scenarios.js' export type DifyMockOptions = { @@ -9,34 +19,129 @@ export type DifyMockOptions = { port?: number } +export type LastRequest = Readonly<{ + method: string + path: string + headers: Record<string, string> + contentType: string | null +}> + export type DifyMock = { url: string port: number scenario: Scenario setScenario: (s: Scenario) => void stop: () => Promise<void> - /** Body of the most recent POST to /apps/:id:run */ + /** Body of the most recent POST to a run route */ lastRunBody: Record<string, unknown> | null + /** Multipart parts of the most recent POST to a run route, keyed by part name as received */ + lastRunParts: Record<string, string | File[]> | null /** Number of times POST /apps/:id/files was called */ uploadCallCount: number /** Body of the most recent POST to /workspaces/:id/apps/imports */ lastImportBody: Record<string, unknown> | null + /** Method, path (with query string), headers and content-type of the most recent request */ + lastRequest: LastRequest | null + /** Number of requests received, including GET _catalog */ + requestCount: number } const TOKEN_RE = /^Bearer\s+dfo[ae]_[\w-]+$/ +const VERSION_PATH = '/openapi/v1/_version' +const HANG_PATH = '/hang' +const EMPTY_ERROR_PATH = '/empty-error' +const BAD_VERIFICATION_URI = 'ftp://elsewhere.example/device' +const HANG_MS = 30_000 +const OAUTH_PREFIX = '/openapi/v1/oauth/' + +const ServerErrorCode = { + Unauthorized: 'unauthorized', + Forbidden: 'forbidden', + RateLimited: 'rate_limited', + Server5xx: 'server_5xx', + NotFound: 'not_found', + CatalogStale: 'catalog_stale', + ValidationError: 'validation_error', +} as const + +const OpId = { + AppList: 'get.console_app', + WorkspaceList: 'get.workspace', + AccountSessionsList: 'get.account.session', + ChatRun: 'run.console_app.chat', + AdvancedChatRun: 'run.console_app.advanced_chat', + FormSubmit: 'submit.run.form', +} as const + +const CATALOG_STALE_MESSAGE = + "The request was built from a catalog that is not this server's current catalog." +const CATALOG_STALE_HINT = + 'GET /openapi/v1/_catalog, rebuild the request from it, and send its sha256 in X-Dify-Catalog.' + +type MockErrorBody = { code: string; message: string; status: number; hint?: string } + +function errorBody(code: string, message: string, status: number, hint?: string): MockErrorBody { + return hint === undefined ? { code, message, status } : { code, message, status, hint } +} + +function errorResponse(code: string, message: string, status: number, hint?: string) { + return Response.json(errorBody(code, message, status, hint), { status }) +} function unauthorized() { - return Response.json( - { error: { code: 'auth_expired', message: 'invalid or expired token' } }, - { status: 401 }, - ) + return errorResponse(ServerErrorCode.Unauthorized, 'invalid or expired token', 401) +} + +const FIXTURE_DIR = dirname(fileURLToPath(import.meta.url)) +const CATALOG_BYTES = readFileSync(join(FIXTURE_DIR, '..', 'catalog.json')) +const CATALOG_V2_BYTES = readFileSync(join(FIXTURE_DIR, '..', 'catalog-v2.json')) +const CATALOG_FINGERPRINT = createHash('sha256').update(CATALOG_BYTES).digest('hex') +const CATALOG_V2_FINGERPRINT = createHash('sha256').update(CATALOG_V2_BYTES).digest('hex') + +function catalogBytesFor(scenario: Scenario): Uint8Array | undefined { + if (scenario === 'no-catalog') return undefined + if (scenario === 'catalog-changed') return CATALOG_V2_BYTES + return CATALOG_BYTES +} + +function catalogFingerprintFor(scenario: Scenario): string { + return scenario === 'catalog-changed' ? CATALOG_V2_FINGERPRINT : CATALOG_FINGERPRINT } function sseChunks(events: { event: string; data: Record<string, unknown> }[]): string { return events.map((e) => `data: ${JSON.stringify({ ...e.data, event: e.event })}\n\n`).join('') } -function streamingRunResponse(mode: string, query: string, isAgent: boolean): string { +function chatReplyHints(mode: string, appId: string): Record<string, unknown>[] { + const op = + mode === 'chat' ? OpId.ChatRun : mode === 'advanced-chat' ? OpId.AdvancedChatRun : undefined + if (op === undefined) return [] + return [ + { + summary: 'Reply in this conversation', + op, + input: { app_id: appId, conversation_id: 'conv-1', query: null, inputs: {} }, + }, + ] +} + +function nextPageHints( + op: string, + query: Record<string, string>, + page: number, + limit: number, + hasMore: boolean, +): Record<string, unknown>[] { + if (!hasMore) return [] + return [{ summary: 'Next page', op, input: { ...query, page: page + 1, limit } }] +} + +function streamingRunResponse( + mode: string, + query: string, + isAgent: boolean, + appId: string, +): string { if (mode === 'workflow') { return sseChunks([ { event: 'workflow_started', data: { id: 'wf-run-1', workflow_id: 'wf-1' } }, @@ -56,7 +161,15 @@ function streamingRunResponse(mode: string, query: string, isAgent: boolean): st return sseChunks([ { event: 'message', data: { message_id: 'msg-1', mode, answer: 'echo: ' } }, { event: 'message', data: { answer: query } }, - { event: 'message_end', data: { message_id: 'msg-1', task_id: 'task-1', metadata: {} } }, + { + event: 'message_end', + data: { + message_id: 'msg-1', + task_id: 'task-1', + metadata: {}, + hints: chatReplyHints(mode, appId), + }, + }, ]) } const evt = isAgent ? 'agent_message' : 'message' @@ -70,12 +183,17 @@ function streamingRunResponse(mode: string, query: string, isAgent: boolean): st if (isAgent) events.push({ event: 'agent_thought', data: { thought: 'thinking…' } }) events.push({ event: 'message_end', - data: { message_id: 'msg-1', conversation_id: 'conv-1', metadata: {} }, + data: { + message_id: 'msg-1', + conversation_id: 'conv-1', + metadata: {}, + hints: chatReplyHints(mode, appId), + }, }) return sseChunks(events) } -function hitlPauseResponse(): string { +function hitlPauseResponse(appId: string): string { return sseChunks([ { event: 'workflow_started', data: { id: 'wf-run-hitl-1', workflow_id: 'wf-1' } }, { event: 'node_started', data: { id: 'n1', title: 'First Node' } }, @@ -96,6 +214,19 @@ function hitlPauseResponse(): string { resolved_default_values: { name: 'Alice' }, expiration_time: 9999999999, }, + hints: [ + { + summary: 'Submit', + op: OpId.FormSubmit, + input: { + app_id: appId, + form_token: 'ft-hitl-1', + action: 'submit', + inputs: { name: null }, + }, + form: [{ output_variable_name: 'name' }], + }, + ], }, }, { event: 'workflow_paused', data: { reasons: [] } }, @@ -119,25 +250,176 @@ function hitlResumedResponse(): string { export type MockState = { lastRunBody: Record<string, unknown> | null + lastRunParts: Record<string, string | File[]> | null uploadCallCount: number lastImportBody: Record<string, unknown> | null + lastRequest: LastRequest | null + requestCount: number +} + +async function readMultipartRunBody( + c: HonoContext, +): Promise<{ json: Record<string, unknown>; parts: Record<string, string | File[]> }> { + const parsed = await c.req.parseBody({ all: true }) + const json: Record<string, unknown> = {} + const parts: Record<string, string | File[]> = {} + for (const [key, value] of Object.entries(parsed)) { + const values = Array.isArray(value) ? value : [value] + const files = values.filter((v): v is File => v instanceof File) + if (files.length > 0) { + parts[key] = files + continue + } + const text = values[0] as string + parts[key] = text + json[key] = JSON.parse(text) + } + return { json, parts } +} + +const SLOW_STREAM_GAP_MS = 200 + +function sseResponse(body: string | ReadableStream<Uint8Array>): Response { + return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } }) +} + +// Headers land at once, the first event right after, and the rest only after a gap — +// the shape that a whole-request timeout would cut short but a headers deadline must not. +function slowStreamBody(query: string): ReadableStream<Uint8Array> { + const enc = new TextEncoder() + return new ReadableStream({ + async start(controller) { + controller.enqueue( + enc.encode( + sseChunks([{ event: 'message', data: { message_id: 'msg-1', answer: 'echo: ' } }]), + ), + ) + await new Promise((r) => setTimeout(r, SLOW_STREAM_GAP_MS)) + controller.enqueue( + enc.encode( + sseChunks([ + { event: 'message', data: { answer: query } }, + { event: 'message_end', data: { message_id: 'msg-1', metadata: {} } }, + ]), + ), + ) + controller.close() + }, + }) +} + +// One good event, then the socket dies under the client: the body read rejects +// mid-stream, which an errored ReadableStream alone does not reproduce. +function destroyMidStream(c: HonoContext): Response { + const { outgoing } = c.env as { outgoing: ServerResponse } + outgoing.writeHead(200, { 'content-type': 'text/event-stream' }) + outgoing.write( + sseChunks([{ event: 'message', data: { message_id: 'msg-1', answer: 'partial' } }]), + () => outgoing.socket?.destroy(), + ) + return RESPONSE_ALREADY_SENT +} + +function runResponse( + c: HonoContext, + app: AppFixture, + body: Record<string, unknown>, + scenario: Scenario, +): Response { + if (scenario === 'broken-stream') return destroyMidStream(c) + const isAgent = app.is_agent === true || app.mode === 'agent-chat' + const query = (body.query as string | undefined) ?? '' + if (scenario === 'stream-error') + return sseResponse(sseChunks([{ event: 'error', data: { message: 'boom', status: 503 } }])) + if (scenario === 'slow-stream') return sseResponse(slowStreamBody(query)) + if (scenario === 'hitl-pause') return sseResponse(hitlPauseResponse(app.id)) + return sseResponse(streamingRunResponse(app.mode, query, isAgent, app.id)) +} + +async function handleRun( + c: HonoContext, + id: string, + state: MockState | undefined, + getScenario: () => Scenario, +): Promise<Response> { + const contentType = c.req.header('content-type') ?? '' + let body: Record<string, unknown> + if (contentType.includes('multipart/form-data')) { + const { json, parts } = await readMultipartRunBody(c) + body = json + if (state !== undefined) state.lastRunParts = parts + } else { + body = (await c.req.json()) as Record<string, unknown> + if (state !== undefined) state.lastRunParts = null + } + if (state !== undefined) state.lastRunBody = body + const app = APPS.find((a) => a.id === id) + if (app === undefined) return errorResponse(ServerErrorCode.NotFound, 'app not found', 404) + return runResponse(c, app, body, getScenario()) +} + +function recoverLegacyRunId(c: HonoContext): string { + // Hono drops the param adjacent to the `:legacy_run` literal; recover the app id from the path. + return c.req.path.replace(/^.*\/apps\//, '').replace(/:legacy_run$/, '') } export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { const app = new Hono() + app.use('*', async (c, next) => { + if (state !== undefined) { + state.requestCount++ + const url = new URL(c.req.url) + state.lastRequest = { + method: c.req.method, + path: url.pathname + url.search, + headers: Object.fromEntries(c.req.raw.headers.entries()), + contentType: c.req.header('content-type') ?? null, + } + } + await next() + }) + + app.use('*', async (c, next) => { + await next() + c.res.headers.set(CATALOG_HEADER, catalogFingerprintFor(getScenario())) + }) + + app.notFound(() => + errorResponse(ServerErrorCode.NotFound, 'The requested URL was not found on the server.', 404), + ) + app.get('/healthz', (c) => c.json({ ok: true })) + // A rejection with no body at all: nothing for the client to name it by but the status. + app.get(EMPTY_ERROR_PATH, () => new Response(null, { status: 503 })) + + // Accepts the connection and never answers: the headers deadline has to fire. + // The timer is unref'd so it never holds the test process open. + app.get(HANG_PATH, async () => { + await new Promise<void>((resolve) => { + setTimeout(resolve, HANG_MS).unref() + }) + return new Response('too late', { status: 200 }) + }) + + app.get(CATALOG_PATH, (_c) => { + const bytes = catalogBytesFor(getScenario()) + if (bytes === undefined) + return errorResponse(ServerErrorCode.NotFound, 'catalog not found', 404) + return new Response(bytes, { status: 200, headers: { 'content-type': 'application/json' } }) + }) + app.use('*', async (c, next) => { if (c.req.path === '/healthz') { await next() return } - if (c.req.path.startsWith('/openapi/v1/oauth/')) { + if (c.req.path.startsWith(OAUTH_PREFIX)) { await next() return } - if (c.req.path === '/openapi/v1/_version') { + if (c.req.path === VERSION_PATH) { await next() return } @@ -148,29 +430,48 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { await next() }) - app.get('/openapi/v1/_version', (c) => { + app.get(VERSION_PATH, (c) => c.json({ version: '1.6.4', edition: 'CLOUD' })) + + app.use('*', async (c, next) => { + const path = c.req.path + if (!path.startsWith('/openapi/v1/')) { + await next() + return + } + if (path === CATALOG_PATH || path === VERSION_PATH || path.startsWith(OAUTH_PREFIX)) { + await next() + return + } + // Flask answers an unknown path 404 before any per-route guard runs. + if (!matchedRoutes(c).some((route) => route.method !== METHOD_NAME_ALL)) { + await next() + return + } const scenario = getScenario() - if (scenario === 'server-version-empty') return c.json({ version: '', edition: 'COMMUNITY' }) - if (scenario === 'server-version-unsupported') - return c.json({ version: '99.0.0', edition: 'COMMUNITY' }) - return c.json({ version: '1.6.4', edition: 'CLOUD' }) + const clientFingerprint = c.req.header(CATALOG_HEADER) ?? '' + const serverFingerprint = catalogFingerprintFor(scenario) + if (scenario === 'always-stale' || clientFingerprint !== serverFingerprint) { + return errorResponse( + ServerErrorCode.CatalogStale, + CATALOG_STALE_MESSAGE, + 412, + CATALOG_STALE_HINT, + ) + } + await next() }) app.use('*', async (c, next) => { const scenario = getScenario() if (scenario === 'rate-limited') { - // Unified ErrorBody — per-token throttle (retryable); Retry-After advises the wait. - return c.json( - { - code: 'too_many_requests', - message: 'Too many requests for this API token.', - status: 429, - }, + // Per-token throttle (retryable); Retry-After advises the wait. + return Response.json( + errorBody(ServerErrorCode.RateLimited, 'Too many requests for this API token.', 429), { status: 429, headers: { 'retry-after': '1' } }, ) } if (scenario === 'server-5xx') { - return c.json({ error: { code: 'server_5xx', message: 'upstream broken' } }, { status: 503 }) + return errorResponse(ServerErrorCode.Server5xx, 'upstream broken', 503) } await next() }) @@ -202,12 +503,14 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { const total = SESSIONS.length const start = (page - 1) * limit const slice = SESSIONS.slice(start, start + limit) + const hasMore = page * limit < total return c.json({ page, limit, total, - has_more: page * limit < total, + has_more: hasMore, data: slice, + hints: nextPageHints(OpId.AccountSessionsList, c.req.query(), page, limit, hasMore), }) }) @@ -218,11 +521,13 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { app.delete('/openapi/v1/account/sessions/:id', (c) => { const id = c.req.param('id') if (!SESSIONS.some((s) => s.id === id)) - return c.json({ error: { code: 'not_found', message: 'session not found' } }, { status: 404 }) + return errorResponse(ServerErrorCode.NotFound, 'session not found', 404) return Response.json({ status: 'revoked' }, { status: 200 }) }) app.get('/openapi/v1/workspaces', (c) => { + // A server that answers the list op with something other than a list envelope. + if (getScenario() === 'workspaces-malformed') return c.json({ data: { id: 'ws-1' } }) const page = Number(c.req.query('page') ?? '1') const limit = Number(c.req.query('limit') ?? '20') const rows = @@ -236,15 +541,20 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { current: w.is_current, })) const total = rows.length + const hasMore = page * limit < total return c.json({ page, limit, total, - has_more: page * limit < total, + has_more: hasMore, data: rows.slice((page - 1) * limit, page * limit), + hints: nextPageHints(OpId.WorkspaceList, c.req.query(), page, limit, hasMore), }) }) + // Reachable only under the `catalog-changed` scenario: the op that catalog v2 adds. + app.get('/openapi/v1/workspaces/:wsId/ping', (c) => c.json({ ok: true })) + app.get('/openapi/v1/apps', (c) => { const page = Number(c.req.query('page') ?? '1') const limit = Number(c.req.query('limit') ?? '20') @@ -260,12 +570,14 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { const total = filtered.length const start = (page - 1) * limit const slice = filtered.slice(start, start + limit) + const hasMore = page * limit < total return c.json({ page, limit, total, - has_more: page * limit < total, + has_more: hasMore, data: slice, + hints: nextPageHints(OpId.AppList, c.req.query(), page, limit, hasMore), }) }) @@ -283,8 +595,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { const app = APPS.find( (a) => a.id === id && (wsId === undefined || wsId === '' || a.workspace_id === wsId), ) - if (app === undefined) - return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) + if (app === undefined) return errorResponse(ServerErrorCode.NotFound, 'app not found', 404) const wantInfo = fields.length === 0 || fields.includes('info') const wantParams = fields.length === 0 || fields.includes('parameters') const wantInputSchema = fields.length === 0 || fields.includes('input_schema') @@ -317,8 +628,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { .filter((s) => s !== '') // External subjects have no workspace scope; the app is reachable across workspaces. const app = APPS.find((a) => a.id === id) - if (app === undefined) - return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) + if (app === undefined) return errorResponse(ServerErrorCode.NotFound, 'app not found', 404) const wantInfo = fields.length === 0 || fields.includes('info') const wantParams = fields.length === 0 || fields.includes('parameters') const wantInputSchema = fields.length === 0 || fields.includes('input_schema') @@ -342,16 +652,14 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { app.get('/openapi/v1/apps/:id/dsl', (c) => { const id = c.req.param('id') const found = APPS.find((a) => a.id === id) - if (found === undefined) - return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) + if (found === undefined) return errorResponse(ServerErrorCode.NotFound, 'app not found', 404) return c.json({ data: DSL_YAML }) }) app.get('/openapi/v1/apps/:id/dependencies:check', (c) => { const id = c.req.param('id') const found = APPS.find((a) => a.id === id) - if (found === undefined) - return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) + if (found === undefined) return errorResponse(ServerErrorCode.NotFound, 'app not found', 404) return c.json({ leaked_dependencies: [] }) }) @@ -362,7 +670,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { if (scenario === 'import-failed') return c.json( { id: 'imp-1', status: 'failed', error: 'unsupported DSL version' }, - { status: 200 }, + { status: 400 }, ) if (scenario === 'import-pending') return c.json( @@ -387,143 +695,29 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { ) }) - app.post('/openapi/v1/apps/:id:run', async (c) => { - // Hono drops the param adjacent to the `:run` literal; recover the app id from the path. - const id = c.req.path.replace(/^.*\/apps\//, '').replace(/:run$/, '') - const body = (await c.req.json()) as { query?: string; inputs?: unknown } - if (state !== undefined) state.lastRunBody = body as Record<string, unknown> - const app = APPS.find((a) => a.id === id) - if (app === undefined) - return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) - const isAgent = app.is_agent === true || app.mode === 'agent-chat' - const query = body.query ?? '' - const scenario = getScenario() - if (scenario === 'run-422-stale') { - return c.json( - { - error: { - code: 'query_not_supported_for_workflow', - message: 'query not supported for workflow mode', - }, - }, - { status: 422 }, - ) - } - if (scenario === 'stream-error') { - const errSse = sseChunks([{ event: 'error', data: { message: 'boom', status: 503 } }]) - return new Response(errSse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) - } - if (scenario === 'hitl-pause') { - return new Response(hitlPauseResponse(), { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }) - } - if (scenario === 'workflow-think') { - const thinkSse = sseChunks([ - { event: 'workflow_started', data: { id: 'wf-run-1', workflow_id: 'wf-1' } }, - { - event: 'workflow_finished', - data: { - id: 'wf-run-1', - workflow_id: 'wf-1', - data: { - id: 'wf-run-1', - status: 'succeeded', - outputs: { result: '<think>secret reasoning</think>\nfinal answer' }, - }, - }, - }, - ]) - return new Response(thinkSse, { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }) - } - if (scenario === 'chat-reasoning') { - // Separated mode: reasoning streams out-of-band on `reasoning_chunk` (nested - // under `data`), the answer stays free of <think>, and the terminal reasoning - // is persisted into message_end metadata. - const reasoningSse = sseChunks([ - { - event: 'reasoning_chunk', - data: { - data: { - message_id: 'msg-1', - reasoning: 'secret reasoning', - node_id: 'llm-1', - is_final: false, - }, - }, - }, - { - event: 'reasoning_chunk', - data: { data: { message_id: 'msg-1', reasoning: '', node_id: 'llm-1', is_final: true } }, - }, - { - event: 'message', - data: { - message_id: 'msg-1', - conversation_id: 'conv-1', - mode: app.mode, - answer: 'final answer', - }, - }, - { - event: 'message_end', - data: { - message_id: 'msg-1', - conversation_id: 'conv-1', - task_id: 'task-1', - metadata: { reasoning: { 'llm-1': 'secret reasoning' } }, - }, - }, - ]) - return new Response(reasoningSse, { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }) - } - if (scenario === 'workflow-reasoning') { - // Separated mode in a workflow: reasoning streams out-of-band on - // `reasoning_chunk` (no message_id), outputs stay clean, and there is NO - // persisted metadata — the live deltas are the only source. - const wfReasoningSse = sseChunks([ - { event: 'workflow_started', data: { id: 'wf-run-1', workflow_id: 'wf-1' } }, - { event: 'node_started', data: { id: 'llm-1', title: 'LLM' } }, - { - event: 'reasoning_chunk', - data: { data: { reasoning: 'secret reasoning', node_id: 'llm-1', is_final: false } }, - }, - { - event: 'reasoning_chunk', - data: { data: { reasoning: '', node_id: 'llm-1', is_final: true } }, - }, - { event: 'node_finished', data: { id: 'llm-1', status: 'succeeded' } }, - { - event: 'workflow_finished', - data: { - id: 'wf-run-1', - workflow_id: 'wf-1', - data: { id: 'wf-run-1', status: 'succeeded', outputs: { result: 'final answer' } }, - }, - }, - ]) - return new Response(wfReasoningSse, { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }) - } - const sse = streamingRunResponse(app.mode, query, isAgent) - return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) - }) + // A deprecated op the fixture catalogs keep so the deprecation notice stays tested. + app.post('/openapi/v1/apps/:id:legacy_run', (c) => + handleRun(c, recoverLegacyRunId(c), state, getScenario), + ) + app.post('/openapi/v1/apps/:id/workflow:run', (c) => + handleRun(c, c.req.param('id'), state, getScenario), + ) + app.post('/openapi/v1/apps/:id/chat:run', (c) => + handleRun(c, c.req.param('id'), state, getScenario), + ) + app.post('/openapi/v1/apps/:id/completion:run', (c) => + handleRun(c, c.req.param('id'), state, getScenario), + ) + app.post('/openapi/v1/apps/:id/advanced-chat:run', (c) => + handleRun(c, c.req.param('id'), state, getScenario), + ) app.post('/openapi/v1/apps/:id/files', async (c) => { if (state !== undefined) state.uploadCallCount++ const form = await c.req.formData() const file = form.get('file') if (!(file instanceof File)) - return Response.json({ message: 'No file uploaded' }, { status: 400 }) + return errorResponse(ServerErrorCode.ValidationError, 'No file uploaded', 400) const ext = file.name.split('.').pop() ?? null return Response.json( { @@ -546,23 +740,30 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { return c.json({}) }) - app.get('/openapi/v1/apps/:id/tasks/:task_id/events', (_c) => { - return new Response(hitlResumedResponse(), { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }) - }) + app.get('/openapi/v1/apps/:id/tasks/:task_id/events', (_c) => sseResponse(hitlResumedResponse())) - app.post('/openapi/v1/oauth/device/code', (c) => - c.json({ + app.post('/openapi/v1/oauth/device/code', (c) => { + const scenario = getScenario() + if (scenario === 'no-device-flow') + return errorResponse(ServerErrorCode.NotFound, 'not found', 404) + if (scenario === 'bad-verification-uri') + return c.json({ + device_code: 'devcode-1', + user_code: 'ABCD-1234', + verification_uri: BAD_VERIFICATION_URI, + verification_uri_complete: BAD_VERIFICATION_URI, + expires_in: 600, + interval: 1, + }) + return c.json({ device_code: 'devcode-1', user_code: 'ABCD-1234', verification_uri: `${new URL(c.req.url).origin}/device`, verification_uri_complete: `${new URL(c.req.url).origin}/device?user_code=ABCD-1234`, expires_in: 600, interval: 1, - }), - ) + }) + }) app.post('/openapi/v1/oauth/device/token', async (c) => { const scenario = getScenario() @@ -612,7 +813,14 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { export function startMock(opts: DifyMockOptions = {}): Promise<DifyMock> { let scenario: Scenario = opts.scenario ?? 'happy' - const state: MockState = { lastRunBody: null, uploadCallCount: 0, lastImportBody: null } + const state: MockState = { + lastRunBody: null, + lastRunParts: null, + uploadCallCount: 0, + lastImportBody: null, + lastRequest: null, + requestCount: 0, + } const app = buildApp(() => scenario, state) return new Promise((resolve, reject) => { const server = serve({ @@ -638,12 +846,21 @@ export function startMock(opts: DifyMockOptions = {}): Promise<DifyMock> { get lastRunBody() { return state.lastRunBody }, + get lastRunParts() { + return state.lastRunParts + }, get uploadCallCount() { return state.uploadCallCount }, get lastImportBody() { return state.lastImportBody }, + get lastRequest() { + return state.lastRequest + }, + get requestCount() { + return state.requestCount + }, }) }) server.on('error', reject) diff --git a/cli/test/fixtures/http-client.ts b/cli/test/fixtures/http-client.ts deleted file mode 100644 index ca198729c61..00000000000 --- a/cli/test/fixtures/http-client.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ClientOptions, HttpClient } from '../../src/http/types.js' -import { createHttpClient } from '../../src/http/client.js' -import { openAPIBase } from '../../src/util/host.js' - -type ClientOverrides = Omit<ClientOptions, 'baseURL'> - -// Wraps createHttpClient + openAPIBase for tests so call sites read at a glance. -// Accepts a bare bearer string for the common case, or an options object for everything else. -export function testHttpClient(host: string, bearerOrOpts?: string | ClientOverrides): HttpClient { - const opts: ClientOverrides = - typeof bearerOrOpts === 'string' ? { bearer: bearerOrOpts } : (bearerOrOpts ?? {}) - return createHttpClient({ baseURL: openAPIBase(host), ...opts }) -} diff --git a/cli/test/fixtures/kernel.ts b/cli/test/fixtures/kernel.ts new file mode 100644 index 00000000000..e0aaba23d49 --- /dev/null +++ b/cli/test/fixtures/kernel.ts @@ -0,0 +1,111 @@ +import type { DifyMock } from '@test/fixtures/dify-mock/server' +import type { Override } from '@/kernel/context' +import type { Login } from '@/plugins/session' +import type { BufferStreams } from '@/sys/io/streams' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { startMock } from '@test/fixtures/dify-mock/server' +import { Context } from '@/kernel/context' +import { argv } from '@/plugins/argv' +import { ENV } from '@/plugins/env' +import { io, ioService } from '@/plugins/io' +import { resolveOutput } from '@/plugins/output' +import { session } from '@/plugins/session' +import { token } from '@/plugins/token' +import { bufferStreams } from '@/sys/io/streams' + +export type TestWorld = { + ctx: Context + io: BufferStreams + mock: DifyMock + dir: string + overrides: Override[] + stop: () => Promise<void> +} + +export type TestWorldOptions = { + readonly login: boolean + readonly argv?: readonly string[] + readonly env?: boolean + readonly stdin?: string + readonly tty?: boolean + /** Share another world's temp dir and mock instead of making new ones. */ + readonly reuseDirOf?: TestWorld +} + +const TEMP_PREFIX = 'difyctl-world-' +const FILE_EMAIL = 'me@x' +const FILE_WORKSPACE_ID = 'ws-1' +const FILE_LOGIN_TOKEN = 'dfoa_file' +const ENV_LOGIN_TOKEN = 'dfoa_env' + +type EnvPatch = Readonly<Record<string, string | undefined>> + +function applyEnv(patch: EnvPatch): () => void { + const saved = new Map<string, string | undefined>() + for (const [key, value] of Object.entries(patch)) { + saved.set(key, process.env[key]) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + return () => { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } +} + +async function writeFileLogin(server: string): Promise<void> { + const boot = new Context() + const sessionService = await boot.get(session) + const tokenService = await boot.get(token) + const login: Login = { + server, + email: FILE_EMAIL, + account: null, + workspaceId: FILE_WORKSPACE_ID, + tokenStorage: 'file', + insecure: false, + } + await sessionService.save(login) + await tokenService.write(login, FILE_LOGIN_TOKEN) +} + +export async function testContext(opts: TestWorldOptions): Promise<TestWorld> { + const shared = opts.reuseDirOf + const mock = shared === undefined ? await startMock() : shared.mock + const dir = shared === undefined ? await mkdtemp(join(tmpdir(), TEMP_PREFIX)) : shared.dir + + const restoreEnv = applyEnv({ + [ENV.ConfigDir]: dir, + [ENV.CacheDir]: dir, + [ENV.Server]: opts.env === true ? mock.url : undefined, + [ENV.Token]: opts.env === true ? ENV_LOGIN_TOKEN : undefined, + [ENV.WorkspaceId]: undefined, + }) + + if (opts.login) await writeFileLogin(mock.url) + + const streams = bufferStreams(opts.stdin ?? '') + streams.isOutTTY = streams.isErrTTY = opts.tty === true + const overrides: Override[] = [ + [io, ioService(streams, resolveOutput({ argv: opts.argv ?? [], env: {}, streams }))], + [argv, opts.argv ?? []], + ] + + return { + ctx: new Context(overrides), + io: streams, + mock, + dir, + overrides, + stop: async () => { + restoreEnv() + if (shared !== undefined) return + await mock.stop() + await rm(dir, { recursive: true, force: true }) + }, + } +} diff --git a/cli/test/fixtures/mem-store.ts b/cli/test/fixtures/mem-store.ts deleted file mode 100644 index 879477b7497..00000000000 --- a/cli/test/fixtures/mem-store.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { TokenStore } from '../../src/store/token-store.js' - -// In-memory TokenStore for tests; mirrors keyring semantics (empty string = missing). -export class MemStore implements TokenStore { - readonly entries: Map<string, string> - - // Seed keys use the composite "<host> <email>" form, e.g. { 'h1 a@x': 'dfoa_a' }. - constructor(seed: Record<string, string> = {}) { - this.entries = new Map(Object.entries(seed)) - } - - private k(host: string, email: string): string { - return `${host} ${email}` - } - - async read(host: string, email: string): Promise<string> { - return this.entries.get(this.k(host, email)) ?? '' - } - - async write(host: string, email: string, bearer: string): Promise<void> { - this.entries.set(this.k(host, email), bearer) - } - - async remove(host: string, email: string): Promise<void> { - this.entries.delete(this.k(host, email)) - } -} diff --git a/cli/test/fixtures/pkg-manifest.ts b/cli/test/fixtures/pkg-manifest.ts index dd99cb6f02b..4250c9f3239 100644 --- a/cli/test/fixtures/pkg-manifest.ts +++ b/cli/test/fixtures/pkg-manifest.ts @@ -10,11 +10,7 @@ const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH' // release-naming.mjs and release-r2-edge.mjs read their data from // cli/package.json. Tests spawn them against this fixture instead, so -// assertions can name exact windows without tracking the live release. - -// Deliberately far from any real Dify version, and min != max so "inside the -// window" is a case distinct from either bound. -export const FIXTURE_COMPAT = { minDify: '2.0.0', maxDify: '2.5.0' } +// assertions can name an exact version/channel without tracking the live release. export const FIXTURE_VERSION = '7.7.7' export const FIXTURE_CHANNEL = 'stable' @@ -42,7 +38,6 @@ const FIXTURE_RELEASE = { export type PkgManifestOverrides = { version?: string channel?: string - compat?: { minDify: string; maxDify: string } } // Returns the env additions that point a spawned script at the fixture. @@ -51,7 +46,6 @@ export function pkgManifestEnv(overrides: PkgManifestOverrides = {}): Record<str version: overrides.version ?? FIXTURE_VERSION, difyctl: { channel: overrides.channel ?? FIXTURE_CHANNEL, - compat: overrides.compat ?? FIXTURE_COMPAT, release: FIXTURE_RELEASE, }, } diff --git a/cli/test/fixtures/stub-server.ts b/cli/test/fixtures/stub-server.ts deleted file mode 100644 index 7b175e59c83..00000000000 --- a/cli/test/fixtures/stub-server.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { AddressInfo } from 'node:net' -import { Buffer } from 'node:buffer' -import * as http from 'node:http' - -// Records what the client actually put on the wire so API-client tests can -// assert method / path / query / body / headers without mocking fetch. -export type CapturedRequest = { - method?: string - url?: string - body?: string - headers?: http.IncomingHttpHeaders -} - -export type StubServer = { - readonly url: string - readonly captured: CapturedRequest - readonly stop: () => Promise<void> -} - -// Buffers the request body, captures the request line + headers, then replies -// with a JSON payload and a matching Content-Length. -export function jsonResponder( - status: number, - body: unknown, - captured: CapturedRequest, -): http.RequestListener { - return (req, res) => { - captured.method = req.method - captured.url = req.url - captured.headers = req.headers - const chunks: Buffer[] = [] - req.on('data', (c) => chunks.push(c)) - req.on('end', () => { - captured.body = Buffer.concat(chunks).toString('utf8') - const payload = JSON.stringify(body) - res.writeHead(status, { - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(payload), - }) - res.end(payload) - }) - } -} - -// Starts a throwaway loopback server. The handler is built from the same -// `captured` object the caller reads back via `stub.captured`, so there is no -// reassignment dance between the listener and the assertions. -export function startStubServer( - makeHandler: (captured: CapturedRequest) => http.RequestListener, -): Promise<StubServer> { - const captured: CapturedRequest = {} - const handler = makeHandler(captured) - return new Promise((resolve, reject) => { - const server = http.createServer((req, res) => handler(req, res)) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as AddressInfo - resolve({ - url: `http://127.0.0.1:${addr.port}`, - captured, - stop: () => - new Promise<void>((res, rej) => server.close((err) => (err ? rej(err) : res()))), - }) - }) - server.on('error', reject) - }) -} diff --git a/cli/test/scripts/resolve-buildinfo.test.ts b/cli/test/scripts/resolve-buildinfo.test.ts index e80f16ffdb3..1a3613b08f3 100644 --- a/cli/test/scripts/resolve-buildinfo.test.ts +++ b/cli/test/scripts/resolve-buildinfo.test.ts @@ -1,21 +1,16 @@ import { execFileSync } from 'node:child_process' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vite-plus/test' +import { describe, expect, it } from 'vite-plus/test' import { BUILD_CHANNELS, resolveBuildInfo } from '../../scripts/lib/resolve-buildinfo.js' -import { ENV_CACHE_DIR, ENV_CONFIG_DIR } from '../../src/store/dir.js' const CLI_ROOT = new URL('../../', import.meta.url) const RELEASE_NAMING = fileURLToPath(new URL('scripts/release-naming.mjs', CLI_ROOT)) -const DEV_ENTRY = fileURLToPath(new URL('bin/dev.js', CLI_ROOT)) const FIXED_DATE = new Date('2026-05-09T12:00:00.000Z') const fixedNow = () => FIXED_DATE const noGit = () => null // Stub the package.json reader so tests exercise the "no sources" path -// without coupling to the live cli/package.json#difyctl.compat values. +// without coupling to the live cli/package.json#difyctl.channel value. const noPkg = () => ({}) describe('resolveBuildInfo', () => { @@ -36,8 +31,6 @@ describe('resolveBuildInfo', () => { commit: 'abcdef0123456789', buildDate: '2026-01-01T00:00:00.000Z', channel: 'stable', - minDify: '0.0.0', - maxDify: '0.0.0', }) }) @@ -55,8 +48,6 @@ describe('resolveBuildInfo', () => { commit: '1234567890abcdef', buildDate: '2026-05-09T12:00:00.000Z', channel: 'dev', - minDify: '0.0.0', - maxDify: '0.0.0', }) expect(calls).toStrictEqual(['git describe --tags --dirty --always', 'git rev-parse HEAD']) }) @@ -68,8 +59,6 @@ describe('resolveBuildInfo', () => { commit: 'none', buildDate: '2026-05-09T12:00:00.000Z', channel: 'dev', - minDify: '0.0.0', - maxDify: '0.0.0', }) }) @@ -128,56 +117,20 @@ describe('resolveBuildInfo', () => { expect(info.channel).toBe('dev') }) - it('reads minDify and maxDify from env', () => { - const info = resolveBuildInfo({ - env: { - DIFYCTL_VERSION: '0.1.0-rc.1', - DIFYCTL_CHANNEL: 'rc', - DIFYCTL_COMMIT: 'abc', - DIFYCTL_BUILD_DATE: '2026-01-01T00:00:00.000Z', - DIFYCTL_MIN_DIFY: '1.6.0', - DIFYCTL_MAX_DIFY: '1.7.0', - }, - git: noGit, - now: fixedNow, - pkg: noPkg, - }) - expect(info.minDify).toBe('1.6.0') - expect(info.maxDify).toBe('1.7.0') - }) - - it('defaults minDify and maxDify to 0.0.0 when env and package.json are unset', () => { - const info = resolveBuildInfo({ env: {}, git: noGit, now: fixedNow, pkg: noPkg }) - expect(info.minDify).toBe('0.0.0') - expect(info.maxDify).toBe('0.0.0') - }) - - it('falls back to package.json#difyctl.compat when env unset', () => { - const pkg = () => ({ - difyctl: { compat: { minDify: '1.6.0', maxDify: '1.7.0' }, channel: 'rc' }, - }) + it('falls back to package.json#difyctl.channel when env unset', () => { + const pkg = () => ({ difyctl: { channel: 'rc' } }) const info = resolveBuildInfo({ env: {}, git: noGit, now: fixedNow, pkg }) - expect(info.minDify).toBe('1.6.0') - expect(info.maxDify).toBe('1.7.0') expect(info.channel).toBe('rc') }) - it('env wins over package.json for compat range and channel', () => { - const pkg = () => ({ - difyctl: { compat: { minDify: '1.6.0', maxDify: '1.7.0' }, channel: 'rc' }, - }) + it('env wins over package.json for channel', () => { + const pkg = () => ({ difyctl: { channel: 'rc' } }) const info = resolveBuildInfo({ - env: { - DIFYCTL_MIN_DIFY: '2.0.0', - DIFYCTL_MAX_DIFY: '2.1.0', - DIFYCTL_CHANNEL: 'stable', - }, + env: { DIFYCTL_CHANNEL: 'stable' }, git: noGit, now: fixedNow, pkg, }) - expect(info.minDify).toBe('2.0.0') - expect(info.maxDify).toBe('2.1.0') expect(info.channel).toBe('stable') }) }) @@ -200,31 +153,3 @@ describe('channel list parity', () => { ) }) }) - -type ClientVersionReport = { client: { channel: string } } - -describe('bin/dev.js pins the local build channel', () => { - const ENV_CHANNEL = 'DIFYCTL_CHANNEL' - const stateDir = mkdtempSync(join(tmpdir(), 'difyctl-dev-channel-')) - afterAll(() => rmSync(stateDir, { recursive: true, force: true })) - - function reportedChannel(channelOverride?: string): string { - const env: NodeJS.ProcessEnv = { - ...process.env, - [ENV_CONFIG_DIR]: stateDir, - [ENV_CACHE_DIR]: stateDir, - } - if (channelOverride === undefined) delete env[ENV_CHANNEL] - else env[ENV_CHANNEL] = channelOverride - const stdout = execFileSync('bun', [DEV_ENTRY, 'version', '--client', '--output', 'json'], { - cwd: fileURLToPath(CLI_ROOT), - encoding: 'utf8', - env, - }) - return (JSON.parse(stdout) as ClientVersionReport).client.channel - } - - it('reports dev when the env does not set a channel', { timeout: 30_000 }, () => { - expect(reportedChannel()).toBe('dev') - }) -}) diff --git a/cli/test/setup.ts b/cli/test/setup.ts index 16ddd21b784..70ec0406c20 100644 --- a/cli/test/setup.ts +++ b/cli/test/setup.ts @@ -3,5 +3,3 @@ ;(globalThis as unknown as Record<string, string>).__DIFYCTL_BUILD_DATE__ = '1970-01-01T00:00:00.000Z' ;(globalThis as unknown as Record<string, string>).__DIFYCTL_CHANNEL__ = 'dev' -;(globalThis as unknown as Record<string, string>).__DIFYCTL_MIN_DIFY__ = '1.6.0' -;(globalThis as unknown as Record<string, string>).__DIFYCTL_MAX_DIFY__ = '1.7.0' diff --git a/cli/vite.config.ts b/cli/vite.config.ts index c62269687b1..a7381e317ca 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ }, pack: { deps: { resolveDepSubpath: true }, - entry: ['src/index.ts', 'src/commands/**/*.ts', 'src/framework/**/*.ts'], + entry: ['src/main.ts', 'src/kernel/**/*.ts', 'src/plugins/**/*.ts', 'src/commands/**/*.ts'], format: ['esm'], fixedExtension: false, dts: true, @@ -29,8 +29,6 @@ export default defineConfig({ __DIFYCTL_COMMIT__: JSON.stringify(buildInfo.commit), __DIFYCTL_BUILD_DATE__: JSON.stringify(buildInfo.buildDate), __DIFYCTL_CHANNEL__: JSON.stringify(buildInfo.channel), - __DIFYCTL_MIN_DIFY__: JSON.stringify(buildInfo.minDify), - __DIFYCTL_MAX_DIFY__: JSON.stringify(buildInfo.maxDify), }, }, test: { @@ -42,7 +40,7 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'text-summary', 'json'], include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts', 'src/types/**'], + exclude: ['src/**/*.test.ts'], }, }, }) diff --git a/cli/vitest.e2e.config.ts b/cli/vitest.e2e.config.ts index e3f143f3dcc..3c3a1347ef1 100644 --- a/cli/vitest.e2e.config.ts +++ b/cli/vitest.e2e.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ }, }, pack: { - entry: ['src/index.ts'], + entry: ['src/main.ts'], format: ['esm'], outDir: 'dist', target: 'node24', @@ -49,8 +49,6 @@ export default defineConfig({ __DIFYCTL_COMMIT__: JSON.stringify(buildInfo.commit), __DIFYCTL_BUILD_DATE__: JSON.stringify(buildInfo.buildDate), __DIFYCTL_CHANNEL__: JSON.stringify(buildInfo.channel), - __DIFYCTL_MIN_DIFY__: JSON.stringify(buildInfo.minDify), - __DIFYCTL_MAX_DIFY__: JSON.stringify(buildInfo.maxDify), }, }, test: { diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 4df4e48e59c..09ee039cc58 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1,9 +1,4 @@ { - "cli/src/framework/run.test.ts": { - "dify/require-disable-directive-description": { - "count": 1 - } - }, "packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/migrate.ts": { "no-console": { "count": 11 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 167f697220c..d60807462c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -419,6 +419,9 @@ catalogs: ahooks: specifier: 3.10.0 version: 3.10.0 + ajv: + specifier: 8.20.0 + version: 8.20.0 bowser: specifier: 2.14.1 version: 2.14.1 @@ -431,9 +434,6 @@ catalogs: class-variance-authority: specifier: 0.7.1 version: 0.7.1 - cli-table3: - specifier: 0.6.5 - version: 0.6.5 cn: specifier: 0.4.0 version: 0.4.0 @@ -641,9 +641,6 @@ catalogs: open: specifier: 11.0.4 version: 11.0.4 - ora: - specifier: 9.4.1 - version: 9.4.1 picocolors: specifier: 1.1.1 version: 1.1.1 @@ -922,24 +919,12 @@ importers: cli: dependencies: - '@dify/contracts': - specifier: workspace:* - version: link:../packages/contracts '@napi-rs/keyring': specifier: 'catalog:' version: 1.3.0 - '@orpc/client': + ajv: specifier: 'catalog:' - version: 1.15.4 - '@orpc/contract': - specifier: 'catalog:' - version: 1.15.4 - '@orpc/openapi-client': - specifier: 'catalog:' - version: 1.15.4 - cli-table3: - specifier: 'catalog:' - version: 0.6.5 + version: 8.20.0 eventsource-parser: specifier: 'catalog:' version: 4.1.1 @@ -952,15 +937,9 @@ importers: open: specifier: 'catalog:' version: 11.0.4 - ora: - specifier: 'catalog:' - version: 9.4.1 picocolors: specifier: 'catalog:' version: 1.1.1 - std-semver: - specifier: 'catalog:' - version: 1.0.8 undici: specifier: 'catalog:' version: 7.30.0 @@ -5830,6 +5809,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -6096,14 +6078,6 @@ packages: classnames@2.3.1: resolution: {integrity: sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==} - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - - cli-spinners@3.4.0: - resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} - engines: {node: '>=18.20'} - cli-table3@0.6.5: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} @@ -7002,6 +6976,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fastdom@1.0.12: resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} @@ -7409,10 +7386,6 @@ packages: resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} engines: {node: '>=18'} - is-interactive@2.0.0: - resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} - engines: {node: '>=12'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -7508,6 +7481,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -7762,10 +7738,6 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - log-symbols@7.0.1: - resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} - engines: {node: '>=18'} - longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -8000,10 +7972,6 @@ packages: engines: {node: '>=16'} hasBin: true - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -8321,10 +8289,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -8350,10 +8314,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - ora@9.4.1: - resolution: {integrity: sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==} - engines: {node: '>=20'} - oxc-parser@0.127.0: resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8864,6 +8824,10 @@ packages: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} @@ -8882,10 +8846,6 @@ packages: engines: {node: '>= 0.4'} hasBin: true - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -8988,10 +8948,6 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -9080,10 +9036,6 @@ packages: resolution: {integrity: sha512-9SN0XIjBBXCT6ZXXVnScJN4KP2RyFg6B8sEoFlugVHMANysfaEni4LTWlvUQQ/R0wgZl1Ovt9KBQbzn21kHoZA==} engines: {node: '>=20.19.0'} - stdin-discarder@0.3.2: - resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} - engines: {node: '>=18'} - storybook@10.6.0: resolution: {integrity: sha512-H48X6AURAFTpfN2zEux6vNesee5Oq8Gxj1IanYmblPZ218sfKSeyyerF1NC7GD7BrruoC8L4/cU0zII4y+d6zw==} hasBin: true @@ -9810,10 +9762,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors@2.1.2: - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} - engines: {node: '>=18'} - yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -13440,6 +13388,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -13684,12 +13639,6 @@ snapshots: classnames@2.3.1: {} - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - - cli-spinners@3.4.0: {} - cli-table3@0.6.5: dependencies: string-width: 8.2.2 @@ -14737,6 +14686,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.8: {} + fastdom@1.0.12: dependencies: strictdom: 1.0.1 @@ -15174,8 +15125,6 @@ snapshots: global-directory: 4.0.1 is-path-inside: 4.0.0 - is-interactive@2.0.0: {} - is-number@7.0.0: {} is-path-inside@4.0.0: {} @@ -15239,6 +15188,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -15449,11 +15400,6 @@ snapshots: lodash@4.18.1: {} - log-symbols@7.0.1: - dependencies: - is-unicode-supported: 2.1.0 - yoctocolors: 2.1.2 - longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -15961,8 +15907,6 @@ snapshots: mime@4.1.0: {} - mimic-function@5.0.1: {} - mimic-response@3.1.0: optional: true @@ -16119,10 +16063,6 @@ snapshots: dependencies: wrappy: 1.0.2 - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 - oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -16167,17 +16107,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - ora@9.4.1: - dependencies: - chalk: 5.6.2 - cli-cursor: 5.0.0 - cli-spinners: 3.4.0 - is-interactive: 2.0.0 - is-unicode-supported: 2.1.0 - log-symbols: 7.0.1 - stdin-discarder: 0.3.2 - string-width: 8.2.2 - oxc-parser@0.127.0: dependencies: '@oxc-project/types': 0.127.0 @@ -16972,6 +16901,8 @@ snapshots: repeat-string@1.6.1: {} + require-from-string@2.0.2: {} + reselect@5.2.0: {} reserved-identifiers@1.2.0: {} @@ -16987,11 +16918,6 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - reusify@1.1.0: {} robust-predicates@3.0.3: {} @@ -17116,8 +17042,6 @@ snapshots: signal-exit@3.0.7: {} - signal-exit@4.1.0: {} - simple-concat@1.0.1: optional: true @@ -17205,8 +17129,6 @@ snapshots: std-semver@1.0.8: {} - stdin-discarder@0.3.2: {} - storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)(vite-plus@1.0.0(@types/node@25.9.8)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-v8@5.0.1)(@vitest/ui@5.0.1)(esbuild@0.28.2)(happy-dom@20.14.5)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)): dependencies: '@storybook/global': 5.0.0 @@ -18036,8 +17958,6 @@ snapshots: yocto-queue@0.1.0: {} - yoctocolors@2.1.2: {} - yoga-layout@3.2.1: {} yuku-ast@0.9.5: @@ -18204,11 +18124,11 @@ time: abcjs@6.7.1: '2026-09-21T14:46:50.846Z' agentation@3.1.2: '2026-09-22T02:41:05.110Z' ahooks@3.10.0: '2026-09-06T01:59:54.024Z' + ajv@8.20.0: '2026-04-24T15:22:16.529Z' bowser@2.14.1: '2026-02-08T22:04:35.837Z' c12@4.0.0-rc.1: '2026-09-03T11:31:59.413Z' chokidar@5.0.0: '2025-11-25T23:28:06.854Z' class-variance-authority@0.7.1: '2024-11-26T08:20:34.604Z' - cli-table3@0.6.5: '2024-05-12T16:36:50.079Z' cn@0.4.0: '2026-09-22T10:43:46.016Z' code-inspector-plugin@1.6.6: '2026-07-07T04:00:26.523Z' concurrently@10.0.5: '2026-08-15T03:33:20.323Z' @@ -18280,7 +18200,6 @@ time: next@16.3.6: '2026-09-22T16:19:00.458Z' nuqs@2.10.1: '2026-08-25T10:59:58.402Z' open@11.0.4: '2026-09-14T20:36:00.110Z' - ora@9.4.1: '2026-06-22T12:24:49.225Z' picocolors@1.1.1: '2024-10-16T18:20:03.921Z' picomatch@4.0.7: '2026-08-24T14:36:23.690Z' pinyin-pro@3.29.4: '2026-09-11T07:49:12.561Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 225f3f4e086..b86a0e2071b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -152,11 +152,11 @@ catalog: abcjs: 6.7.1 agentation: 3.1.2 ahooks: 3.10.0 + ajv: 8.20.0 bowser: 2.14.1 c12: 4.0.0-rc.1 chokidar: 5.0.0 class-variance-authority: 0.7.1 - cli-table3: 0.6.5 cn: 0.4.0 code-inspector-plugin: 1.6.6 concurrently: 10.0.5 @@ -227,7 +227,6 @@ catalog: next-themes: 0.4.6 nuqs: 2.10.1 open: 11.0.4 - ora: 9.4.1 picocolors: 1.1.1 picomatch: 4.0.7 pinyin-pro: 3.29.4 diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 00000000000..65015a5ed6e --- /dev/null +++ b/skills/README.md @@ -0,0 +1,26 @@ +# difyctl skills + +Agent skills for the difyctl CLI. They use the standard Agent Skills layout: one +folder per skill, each with a `SKILL.md`. There is no index to maintain. + +`difyctl install skills <dir>` downloads them from GitHub at install time, from +the commit that difyctl was built from. `--from` points at any GitHub folder +with the same layout, or at a local folder. The same files install through the +Vercel installer: `npx skills add langgenius/dify --skill difyctl -g`. + +## Adding a skill + +- One folder per skill: `difyctl` is the base skill, scenario skills are + `difyctl-<scenario>`. +- `SKILL.md` frontmatter has `name` (equal to the folder name: lowercase + letters, digits, dashes) and `description`. The description says when to use + the skill; the base skill quotes it. +- Any files may sit beside `SKILL.md`: `references/`, `scripts/`, `assets/`. + Commit scripts as executable (`chmod +x`); the installer keeps the bit. +- A scenario skill opens with: Read `../difyctl/SKILL.md` first. +- Every `difyctl <command>` named in backticks must exist in the command tree. + Every dotted op id must exist in `cli/test/fixtures/catalog.json`. +- List every scenario skill in the base skill's "Other skills" section. + +A change here reaches users with the next difyctl build. Anyone can get it +sooner with `--from https://github.com/langgenius/dify/tree/main/skills`. diff --git a/skills/difyctl/SKILL.md b/skills/difyctl/SKILL.md new file mode 100644 index 00000000000..e62b3ccece6 --- /dev/null +++ b/skills/difyctl/SKILL.md @@ -0,0 +1,59 @@ +--- +name: difyctl +description: 'Base skill for the difyctl CLI: find any command or Dify operation through help and run it as a command. Use when the task involves difyctl or a Dify server from the command line.' +--- + +# difyctl + +difyctl talks to a Dify server. Business commands are not built in: the server publishes a catalog of operations, and each one is a command you discover at run time. + +Always pass `--json`. 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. + +## Finding what the CLI can do + +An operation id is dotted, `run.console_app.chat`, and is typed as words: `difyctl run console_app chat`. Ids come from the server and can change, so look them up instead of assuming them. + +Four views under `help`, covering local commands and server operations as one tree: + +1. `difyctl help --json`: the map. Start here. +2. `difyctl help <namespace> --json`: everything under one namespace (`difyctl help get`). The bare namespace, `difyctl get --json`, prints the same. +3. `difyctl help <words> --json`: search by plain words (`difyctl help upload file`), top 20 entries plus `total`. Widen the words when nothing fits. +4. `difyctl help <id> --json`: one descriptor, dotted or spaced id. For an operation: `input` (JSON Schema), `bind`, `kind`, `examples` and `pins` (values difyctl fills in, currently `workspace_id`). `<command> --help` prints the same. + +`--all` adds operations the server marks internal. `--full` on the bare `help` prints every descriptor; rarely needed. + +Not logged in, or the server unreachable, gives the local commands plus one line on stderr. + +## Running an operation + +`difyctl run console_app workflow --app-id <id> --inputs '{"a":1}'`. Each top-level field of `input` is a flag, dashes for underscores. Scalars take a value; objects, maps and lists of objects take a JSON literal or `@file.json`; lists of scalars repeat the flag. Fields named `input`, `stream`, `only`, `output`, `verbose`, `json` or `help` have no flag; pass them through `--input`. + +`--input '{"a":1}'`, `--input @file.json` or `--input @-` (stdin) sends the whole body; field flags merge over it. A dotted id is a command with the dots as spaces: `run.console_app.workflow` is `difyctl run console_app workflow`. + +Run an app with the op for its mode: `run console_app workflow`, `run console_app chat`, `run console_app advanced_chat`, `run console_app completion`. First `difyctl describe console_app --app-id <id> --json` and read `input_schema`; that is the shape of `inputs`. Local files go under `files`, keyed by the app's file variable name (`{"files":{"doc":"./r.pdf"}}` or a list of paths); files for the run itself under `attachments`; URLs straight into `inputs`. + +## difyctl-only flags + +`--stream` prints each event of a streaming op as one JSON line instead of folding them. `--only <event>` filters streamed events (repeatable). `--output <path>` saves a file-kind response; the default is the current directory under the server's filename, or `<op-id>.<ext>`. `--verbose` adds the raw server response to errors. A flag the operation's kind does not support exits 2. + +## Reading results + +Streaming ops fold to `{status, text, outputs?, total_tokens?, message_id?, conversation_id?, error?, hints}`. `text.answer` is the reply; `text.by_source` appears only while `status` is `incomplete`. `status` is `ended`, `failed` (exit 1), `suspended` (a form is waiting; follow `hints`) or `incomplete` (the stream broke early). Any response may carry `hints: [{summary, op, input, form?}]`, the server's next steps: next page, confirm step, reply op, form submit. Fill any `null` in `input` and pass it back with `difyctl <op, dots as spaces> --input '<input>'`. + +## Errors and exit codes + +Errors are one envelope on stderr: `{"error":{"code","message","hint"?,"details"?,"schema"?}}`. Exit 2 is bad input (schema included), 4 not logged in or forbidden, 6 catalog unavailable or unknown op (run `difyctl refresh cache` or upgrade difyctl), 7 rate limited. + +## Login and environment + +`difyctl login --server https://...` (device flow; `--no-browser` prints the URL and code; `--no-keyring` keeps the token in a file; `--insecure` skips TLS verification). One login at a time. `difyctl use workspace <id>` pins a workspace; `difyctl get workspace` shows them. Scripts skip login with `DIFY_SERVER` and `DIFY_TOKEN`; `DIFY_WORKSPACE_ID` overrides the pin; `DIFY_CONFIG_DIR` and `DIFY_CACHE_DIR` move the files. + +`login` blocks until the browser approval arrives. Run `difyctl login --server <url> --no-browser` as a background job, relay the `open <url>` and `code <code>` lines from its stderr to the user, and do not cancel the job. Its exit code reports the result. In a sandbox, add `--no-keyring` and set `DIFY_CONFIG_DIR` to persistent storage on every call, so the login survives the next session. If background jobs do not survive between calls, run `login --no-wait` instead, relay the `verification_uri` and `user_code` it prints, and after the user approves run `login --resume` until it stops printing `status: pending`. + +## Destructive operations + +Operations that remove or revoke have no confirmation prompt. Confirm with the person first. + +## Other skills + +Scenario skills add guidance for one kind of task. `difyctl get skills` shows them; `difyctl install skills <skills root> --skill <name>` installs one, where `<skills root>` is the folder above this file's folder. Re-running `difyctl install skills <skills root>` after upgrading difyctl refreshes every skill.